Таким образом я описал строение системы управляемых программных аксессуаров.
Упрощенная модель включает в себя главный процесс(bobaoskit.worker
) и скрипты аксессуаров(использующие объекты bobaoskit.sdk
и bobaoskit.accessory
). От главного процесса идет запрос к аксессуару для контроля некоторых полей. От аксессуара, в свою очередь, идет запрос к главному на обновление статуса.
В качестве примера возьмем обычное реле.
При входящей команде реле может иногда не изменить свое положение в силу различных причин(зависло оборудование, и прочее). Соответственно, сколько мы не будет отправять команд, статус меняться не будет. И, в другой ситауции, реле может поменять свое состояние при команде от сторонней системы. Его статус в таком случае изменится, скрипт аксессуара может среагировать на входящее событие о смене статуса и отправить запрос главному процессу.
Мотивация
Внедрив на несколько объектов Apple HomeKit, я начал искать похожее на Android, т.к. сам из iOS устройств имею только рабочий iPad. Основным критерием была возможность работать в локальной сети, без облачных сервисов. Так же, что недоставало в HomeKit — ограниченность информации. Для примера можно взять термостат. Все его управление сводится к выбору режима работы(выкл, нагрев, охлаждение и авто) и заданной температуре. Проще — лучше, но, по моему мнению, не всегда. Не хватает диагностической информации. Например, работает ли кондиционер, конвектор, какие параметры вентиляции. Возможно, кондиционер работать не может из-за внутренней ошибки. Учитывая то, что эту информацию можно считать, решено было написать свою реализацию.
Можно было посмотреть варианты, такие как ioBroker, OpenHAB, home-assistant.
Но на node.js из перечисленных только ioBroker(пока пишу статью, обратил внимание, что redis тоже участвует в процессе). И к тому моменту обнаружил каким образом можно организовать межпроцессное взаимодействие и интересно было разобраться с redis, который на слуху в последнее время.
Так же можно обратить внимание на следующую спецификацию:
Устройство
Redis
помогает межпроцессному взаимодействию, а так же выступает в качестве базы данных для аксессуаров.
Модуль bobaoskit.worker
случает очередь запросов(поверх redis
с использованием bee-queue
), исполняет запрос, записывает/читает из базы данных.
В пользовательских скриптах объект bobaoskit.accessory
слушает отдельную очередь bee-queue
для данного конкретного аксессуара, выполняет прописанные действия, отправляет запросы в очередь главного процесса посредством объекта bobaoskit.sdk
.
Протокол
Все запросы и опубликованные сообщения — строки в JSON
формате, содержат поля method
и payload
. Поля обязательны, даже если payload = null
.
Запросы к bobaoskit.worker
:
- method:
ping
, payload:null
. - method:
get general info
, payload:null
- method:
clear accessories
, payload:null
, - method:
add accessory
,
payload:
{
id: "accessoryId",
type: "switch/sensor/etc",
name: "Accessory Display Name",
control: [<array of control fields>],
status: [<array of status fields>]
}
- method:
remove accessory
, payload:accessoryId/[acc1id, acc2id, ...]
- method:
get accessory info
, payload:null/accId/[acc1id, acc2id...]
В полеpayload
можно отправитьnull
/id
аксессуара/массовid
. Если отправленnull
, тогда в ответ придет информация о всех существующих аксессуарах. - method:
get status value
, payload:{id: accessoryId, status: fieldId}
В полеpayload
можно отправить объект вида{id: accessoryId, status: fieldId}
, (где полеstatus
может быть массивом полей), либоpayload
может быть массивом объектов такого вида. - method:
update status value
, payload:{id: accessoryId, status: {field: fieldId, value: value}
В полеpayload
можно отправить объект вида{id: accessoryId, status: {field: fieldId, value: value}}
, (где полеstatus
может быть массивом{field: fieldId, value: value}
), либоpayload
может быть массивом объектов такого вида. - method:
control accessory value
, payload:{id: accessoryId, control: {field: fieldId, value: value}}
.
В полеpayload
можно отправить объект вида{id: accessoryId, control: {field: fieldId, value: value}}
, (где полеcontrol
может быть массивом{field: fieldId, value: value}
), либоpayload
может быть массивом объектов такого вида.
В качестве ответа на любой запрос в случае успеха приходит сообщение вида:
{ method: "success", payload: <...> }
В случае неудачи:
{ method: "error", payload: "Error description" }
Также публикуются сообщения в redis PUB/SUB
канал(определенный в config.json
) в следующих случаях: очищены все аксессуары(clear accessories
); аксессуар добавлен(add accessory
); аксессуар удален(remove accessory
); аксесуар обновил статус(update status value
).
Широковещательные сообщения также содержат два поля: method
и payload
.
Клиентский SDK
Описание
Клиентский SDK(bobaoskit.accessory
) позволяет вызывать вышеперечисленные методы из js
скриптов.
Внутри модуля два объекта конструктора. Первый создает объект Sdk
для доступа к вышеперечисленным методам, а второй создает аксессуар — обертку поверх этих функций.
const BobaosKit = require("bobaoskit.accessory");
// Создаем объект sdk.
// Не обязательно,
// но если планируется много аксессуаров,
// то лучше использовать общий sdk,
const sdk = BobaosKit.Sdk({
redis: redisClient // optional
job_channel: "bobaoskit_job", // optional. default: bobaoskit_job
broadcast_channel: "bobaoskit_bcast" // optional. default: bobaoskit_bcast
});
// Создаем аксессуар
const dummySwitchAcc = BobaosKit.Accessory({
id: "dummySwitch", // required
name: "Dummy Switch", // required
type: "switch", // required
control: ["state"], // requried. Поля, которыми можем управлять.
status: ["state"], // required. Поля со значениями.
sdk: sdk, // optional.
// Если не определен, новый объект sdk будет создан
// со следующими опциональными параметрами
redis: undefined,
job_channel: "bobaoskit_job",
broadcast_channel: "bobaoskit_bcast"
});
Объект sdk поддерживает Promise
-методы:
sdk.ping();
sdk.getGeneralInfo();
sdk.clearAccessories();
sdk.addAccessory(payload);
sdk.removeAccessory(payload);
sdk.getAccessoryInfo(payload);
sdk.getStatusValue(payload);
sdk.updateStatusValue(payload);
sdk.controlAccessoryValue(payload);
Объект BobaosKit.Accessory({..})
является оберткой поверх объекта BobaosKit.Sdk(...)
.
Далее покажу каким образом это оборачивается:
// из исходного кода модуля
self.getAccessoryInfo = _ => {
return _sdk.getAccessoryInfo(id);
};
self.getStatusValue = payload => {
return _sdk.getStatusValue({ id: id, status: payload });
};
self.updateStatusValue = payload => {
return _sdk.updateStatusValue({ id: id, status: payload });
};
Оба объекта являются так же EventEmitter
.
Sdk
вызывает функции по событиям ready
и broadcasted event
.
Accessory
вызывает функции по событиям ready
, error
, control accessory value
.
Пример
const BobaosKit = require("bobaoskit.accessory");
const Bobaos = require("bobaos.sub");
// init bobaos with default params
const bobaos = Bobaos();
// init sdk with default params
const accessorySdk = BobaosKit.Sdk();
const SwitchAccessory = params => {
let { id, name, controlDatapoint, stateDatapoint } = params;
// init accessory
const swAcc = BobaosKit.Accessory({
id: id,
name: name,
type: "switch",
control: ["state"],
status: ["state"],
sdk: accessorySdk
});
// по входящему запросу на переключение поля state
// отправляем запрос в шину KNX посредством bobaos
swAcc.on("control accessory value", async (payload, cb) => {
const processOneAccessoryValue = async payload => {
let { field, value } = payload;
if (field === "state") {
await bobaos.setValue({ id: controlDatapoint, value: value });
}
};
if (Array.isArray(payload)) {
await Promise.all(payload.map(processOneAccessoryValue));
return;
}
await processOneAccessoryValue(payload);
});
const processOneBaosValue = async payload => {
let { id, value } = payload;
if (id === stateDatapoint) {
await swAcc.updateStatusValue({ field: "state", value: value });
}
};
// при входящем значении с шины KNX
// обновляем поле state аксессуара
bobaos.on("datapoint value", payload => {
if (Array.isArray(payload)) {
return payload.forEach(processOneBaosValue);
}
return processOneBaosValue(payload);
});
return swAcc;
};
const switches = [
{ id: "sw651", name: "Санузел", controlDatapoint: 651, stateDatapoint: 652 },
{ id: "sw653", name: "Щитовая 1", controlDatapoint: 653, stateDatapoint: 653 },
{ id: "sw655", name: "Щитовая 2", controlDatapoint: 655, stateDatapoint: 656 },
{ id: "sw657", name: "Комната 1", controlDatapoint: 657, stateDatapoint: 658 },
{ id: "sw659", name: "Кинотеатр", controlDatapoint: 659, stateDatapoint: 660 }
];
switches.forEach(SwitchAccessory);
WebSocket API
bobaoskit.worker
слушает WebSocket порт, определенный в ./config.json
.
Входящие запросы — JSON
строки, которые должны иметь следующие поля: request_id
, method
и payload
.
API ограничен следующими запросами:
- method:
ping
, payload:null
- method:
get general info
, payload:null
, - method:
get accessory info
, payload:null/accId/[acc1Id, ...]
- method:
get status value
, payload:{id: accId, status: field1/[field1, ...]}/[{id: ...}...]
- method:
control accessory value
, payload:{id: accId, control: {field: field1, value: value}/[{field: .. value: ..}]}/[{id: ...}, ...]
Методы get status value
, control accessory value
принимают поле payload
как один объект, либо как массив. Поля control/status
внутри payload
так же могут быть как одним объектом, так и массивом.
Так же рассылаются следующие сообщения о событиях от сервера всем клиентам:
- method:
clear accessories
, payload: null - method:
remove accessory
, payload: accessory id - method:
add accessory, payload
: {id: ...} - method:
update status value, payload
: {id: ...}
dnssd
Приложение рекламирует WebSocket порт в локальной сети как сервис _bobaoskit._tcp
, благодаря npm модулю dnssd
.
Демо
О том как написано приложение с видео и о впечатлениях от flutter
будет отдельная статья.
Послесловие
Таким образом, получилась простая система для управления программными аксессуарами.
Аксессуары можно противопоставить объектам из реального мира: кнопки, датчики, переключатели, термостаты, радио. Поскольку нет стандартизации, можно реализовывать любые аксессуары, укладываясь в модель control < == > update
.
Что можно было сделать лучше:
- Бинарный протокол позволил бы отправлять меньше данных. С другой стороны,
JSON
быстрее в разработке и понимании. Так же бинарный протокол требует стандартизации.
На этом все, буду рад любой обратной связи.