- Регистрация
- 23 Авг 2023
- Сообщения
- 3,969
- Реакции
- 0
- Баллы
- 36
Ofline
Расскажу, как решал задачу принудительного притока воздуха на кухне и немного автоматизировал управление приточным клапаном с помощью MQTT и WirenBoard.
Когда встал вопрос с вентиляцией, я начал смотреть в сторону приточных клапанов с автоматическим управлением. В какой-то момент попался один экземпляр от компании Vakio — привлёк прежде всего небольшими размерами. После изучения сайта выяснилось, что у них есть три модели одного устройства:
Выбор остановился на Vakio OpenAir: хотелось не просто открывать клапан, но и принудительно нагнетать воздух.
Из интересного: модели KIV SMART и OpenAir оснащены датчиками температуры и влажности, а управляются через MQTT. Внутри устройства — контроллер на базе ESP32, который при первом запуске поднимает собственную Wi-Fi точку доступа для настройки. Из периферии — сервопривод заслонки и мотор нагнетания.
У клапана два режима работы: облако Vakio либо локальный MQTT-брокер. Для домашней автоматизации, конечно, интереснее второй вариант.
После настройки по инструкции устройство подключается к домашней Wi-Fi сети и открывает веб-интерфейс на 80-м порту — можно зайти прямо в браузере и поуправлять клапаном вручную.
Поскольку у меня уже был настроен контроллер WirenBoard с запущенным MQTT-брокером, достаточно было указать параметры подключения в настройках Vakio — и устройство сразу начало публиковать данные.
Устройство публикует данные в двух форматах одновременно: старый, где каждый параметр в отдельном топике, и новый JSON-формат. Ребята из Vakio открыто описали протокол в репозитории vakio-ru/vakio-public-api — там детально расписаны все топики, параметры и команды управления.
Структура топиков выглядит так:
Готовые правила для WirenBoard мне не подошли: не хватало нужной логики управления, обработки всех состояний и нормального мониторинга онлайн/офлайн. Поэтому написал драйвер с нуля на wb-rules 2.0.
Что умеет драйвер:
В итоге WirenBoard видит клапан как полноценное виртуальное устройство со всеми параметрами: температура, влажность, состояние заслонки, скорость, режим работы, ошибки.
Дальше можно строить любую автоматику: расписания, реакцию на CO₂ или влажность, интеграцию с другими устройствами в доме.
Vakio OpenAir оказался приятным устройством: открытый API, нормальная документация, поддержка локального MQTT без обязательного облака. Единственное — пришлось разобраться с нюансами протокола самому, потому что готовых интеграций под WirenBoard на мой вкус не нашлось. Надеюсь, этот драйвер пригодится тем, кто захочет повторить что-то похожее.
Выбор устройства
Когда встал вопрос с вентиляцией, я начал смотреть в сторону приточных клапанов с автоматическим управлением. В какой-то момент попался один экземпляр от компании Vakio — привлёк прежде всего небольшими размерами. После изучения сайта выяснилось, что у них есть три модели одного устройства:
Vakio KIV Pro | Vakio KIV SMART | Vakio OpenAir |
|---|---|---|
Базовая модель | Управление заслонкой | Двигатель нагнетания воздуха + управление заслонкой |
Выбор остановился на Vakio OpenAir: хотелось не просто открывать клапан, но и принудительно нагнетать воздух.
Из интересного: модели KIV SMART и OpenAir оснащены датчиками температуры и влажности, а управляются через MQTT. Внутри устройства — контроллер на базе ESP32, который при первом запуске поднимает собственную Wi-Fi точку доступа для настройки. Из периферии — сервопривод заслонки и мотор нагнетания.
У клапана два режима работы: облако Vakio либо локальный MQTT-брокер. Для домашней автоматизации, конечно, интереснее второй вариант.
Первоначальная настройка
После настройки по инструкции устройство подключается к домашней Wi-Fi сети и открывает веб-интерфейс на 80-м порту — можно зайти прямо в браузере и поуправлять клапаном вручную.
Поскольку у меня уже был настроен контроллер WirenBoard с запущенным MQTT-брокером, достаточно было указать параметры подключения в настройках Vakio — и устройство сразу начало публиковать данные.
Протокол и API
Устройство публикует данные в двух форматах одновременно: старый, где каждый параметр в отдельном топике, и новый JSON-формат. Ребята из Vakio открыто описали протокол в репозитории vakio-ru/vakio-public-api — там детально расписаны все топики, параметры и команды управления.
Структура топиков выглядит так:
От устройства (device/+/openair/...):system(авторизация, ошибки),mode(возможности, настройки), плюс легаси-топики с отдельными значениями
На устройство (server/+/openair/...): команды в формате JSON
Почему пришлось писать свой драйвер
Готовые правила для WirenBoard мне не подошли: не хватало нужной логики управления, обработки всех состояний и нормального мониторинга онлайн/офлайн. Поэтому написал драйвер с нуля на wb-rules 2.0.
Что умеет драйвер:
Интерлок заслонки и скорости — при изменении скорости или заслонки команды отправляются в правильном порядке с задержкой 1,5 с, чтобы механика не конфликтовала
Watchdog — периодически проверяет связь с устройством и выставляет статус онлайн/офлайн, подсвечивая красным нужные контролы
Глубокий опрос — раз в N минут запрашивает полное состояние устройства (прошивку, MAC, настройки)
Поддержка смарт-режима — управление параметрами автоматики: пороговая температура, скорость, аварийное закрытие клапана
Отладочный лог — включается кнопкой прямо из веб-интерфейса WirenBoard, без правки кода
Код:
// =============================================================
// Vakio OpenAir Rev2 — драйвер для Wiren Board / wb-rules 2.0
// Основан на официальном API: github.com/vakio-ru/vakio-public-api
// Протокол: JSON MQTT (прошивка >= 1.1.0, exchange_type: json)
// =============================================================
//
// ТОПИКИ ОТ УСТРОЙСТВА (device/+/openair/...):
// system → auth{device_mac, version}, device_subtype{exchange_type,series,subtype,xtal_freq}
// errors{shutdown}
// mode → capabilities{mode,on_off,speed,gate}
// settings{temperature_speed[temp,speed], emerg_shunt, gate, smart_speed}
// +/temp, +/hud, +/state, +/speed, +/gate, +/workmode (легаси-топики)
//
// ТОПИКИ К УСТРОЙСТВУ (server/+/openair/...):
// system → {"type":"auth"} | {"shutdown":{"limit":N}} | {"firmware":{...}} | {"reset":[...]}
// mode → {"capabilities":{mode,on_off,speed,gate}}
// {"settings":{gate,smart_speed,emerg_shunt,temperature_speed:[temp,speed]}}
//
// =============================================================
// УПРАВЛЕНИЕ РЕЖИМОМ
// =============================================================
// Поле "Workmode" — редактируемое текстовое поле.
// manual — ручной режим
// super_auto — смарт-режим
//
// =============================================================
// ИНТЕРЛОК заслонки и скорости
// =============================================================
// Speed = 0 → стоп → заслонка в позицию 2 (через 1.5с)
// Speed > 0 → заслонка в 4 (через 1.5с) → скорость + вкл
// Ручное изменение Gate → стоп → переход заслонки (через 1.5с)
// CloseGate → speed=0 → off (через 1.5с)
//
// =============================================================
// ОБНОВЛЕНИЕ ДАННЫХ
// =============================================================
// Контрол "ForceRefresh" (pushbutton) — принудительно запросить
// состояние устройства (auth + эхо текущих capabilities).
// Автоматически выполняется каждые deep_poll_interval секунд.
//
// =============================================================
// ОТЛАДОЧНЫЙ ЛОГ
// =============================================================
// Контрол "Debug_Log" (switch) — включить/выключить детальный лог
// Смотреть: journalctl -u wb-rules -f
// =============================================================
createVakioOpenAir({
id: 1,
topic: "VAKIO",
endpoint: "openair",
polling_interval: 10, // интервал heartbeat (сек)
deep_poll_interval: 300 // интервал глубокого опроса (сек), по умолчанию 5 минут
});
function createVakioOpenAir(params) {
var id = params.id;
var topic = params.topic;
var endpoint = params.endpoint !== undefined ? params.endpoint : "openair";
var polling_interval = params.polling_interval !== undefined ? params.polling_interval : 10;
var deep_poll_interval= params.deep_poll_interval!== undefined ? params.deep_poll_interval: 300;
var vd = "Vakio_" + id + "_" + endpoint;
// ============================================================
// Имена контролов
// ============================================================
var ctrlState = "State";
var ctrlWorkmode = "Workmode";
var ctrlSpeed = "Speed";
var ctrlGate = "Gate";
var ctrlClose = "CloseGate";
var ctrlForceRefresh = "ForceRefresh";
var ctrlConnect = "Connect";
var ctrlTemp = "Temperature";
var ctrlHumidity = "Humidity";
var ctrlErrShutdown = "Err_Shutdown";
// Настройки смарт — то что ОТПРАВЛЯЕМ на устройство
var ctrlSmartGate = "Smart_Gate"; // gate в smart (позиция заслонки)
var ctrlSmartSpeed = "Smart_Speed"; // smart_speed
var ctrlEmergShunt = "Emerg_Shunt"; // emerg_shunt (темп. отключения клапана)
var ctrlSmartTempThreshold = "Smart_Temp"; // temperature_speed[0] — порог температуры
var ctrlShutdownLimit = "Shutdown_Limit";
// Readonly — что ПОЛУЧАЕМ из settings устройства (feedback)
var ctrlAutoTemp = "Auto_Temp"; // temperature_speed[0]
var ctrlAutoSpeed = "Auto_Speed"; // temperature_speed[1]
var ctrlRelGate = "Rel_Gate"; // settings.gate (feedback)
var ctrlRelSmartSpd = "Rel_SmartSpeed"; // settings.smart_speed (feedback)
var ctrlRelEmerg = "Rel_EmergShunt"; // settings.emerg_shunt (feedback)
// Информация об устройстве (readonly)
var ctrlFwVer = "FW_Version";
var ctrlMac = "Device_MAC";
var ctrlSeries = "HW_Series";
var ctrlSubtype = "HW_Subtype";
var ctrlExchange = "Exchange";
var ctrlDebugLog = "Debug_Log";
// ============================================================
// MQTT пути
// ============================================================
var deviceBase = "device/" + topic + "/" + endpoint;
var serverBase = "server/" + topic + "/" + endpoint;
var legacyBase = topic;
// ============================================================
// Внутреннее состояние
// ============================================================
var _selfChange = false;
var _initialized = false;
var _lastAlive = 0;
var _isOnline = false;
var _deepPollTick = 0;
// ============================================================
// Виртуальное устройство
// ============================================================
defineVirtualDevice(vd, {
title: "Vakio OpenAir [" + topic + "/" + endpoint + "]",
cells: {
// --- Основное управление ---
State: {
title: "Вкл / Выкл",
type: "switch",
value: false,
order: 1
},
Workmode: {
title: "Режим (manual / super_auto)",
type: "text",
value: "manual",
readonly: false,
order: 2
},
Speed: {
title: "Скорость (0=стоп, 1-5)",
type: "range",
value: 0,
min: 0,
max: 5,
order: 3
},
Gate: {
title: "Заслонка (1=мин, 4=макс)",
type: "range",
value: 2,
min: 1,
max: 4,
order: 4
},
CloseGate: {
title: "Стоп + закрыть",
type: "pushbutton",
value: false,
order: 5
},
ForceRefresh: {
title: "Обновить данные с устройства",
type: "pushbutton",
value: false,
order: 6
},
// --- Статус ---
Connect: {
title: "Связь",
type: "switch",
value: false,
readonly: true,
order: 10
},
Temperature: {
title: "Температура (°C)",
type: "temperature",
value: 0,
readonly: true,
order: 11
},
Humidity: {
title: "Влажность (%)",
type: "rel_humidity",
value: 0,
readonly: true,
order: 12
},
Err_Shutdown: {
title: "Ошибка: переохлаждение",
type: "switch",
value: false,
readonly: true,
order: 13
},
// --- Настройки смарт-режима (ЗАПИСЫВАЕМЫЕ) ---
Smart_Gate: {
title: "Смарт: заслонка (1-4)",
type: "range",
value: 4,
min: 1,
max: 4,
order: 20
},
Smart_Speed: {
title: "Смарт: скорость (1-5)",
type: "range",
value: 3,
min: 1,
max: 5,
order: 21
},
Smart_Temp: {
title: "Смарт: порог температуры (°C)",
type: "range",
value: 20,
min: -20,
max: 40,
order: 22
},
Emerg_Shunt: {
title: "Смарт: темп. откл. клапана (°C)",
type: "range",
value: 10,
min: -20,
max: 25,
order: 23
},
Shutdown_Limit: {
title: "Порог переохлаждения (°C)",
type: "range",
value: 0,
min: -30,
max: 25,
order: 24
},
// --- Авто-режим (readonly, feedback из settings) ---
Auto_Temp: {
title: "Авто: порог темп. (°C) [feedback]",
type: "value",
value: 0,
readonly: true,
order: 30
},
Auto_Speed: {
title: "Авто: скорость [feedback]",
type: "value",
value: 0,
readonly: true,
order: 31
},
// --- Информация об устройстве (readonly) ---
FW_Version: {
title: "Прошивка",
type: "text",
value: "—",
readonly: true,
order: 40
},
Device_MAC: {
title: "MAC адрес",
type: "text",
value: "—",
readonly: true,
order: 41
},
HW_Series: {
title: "Серия железа",
type: "text",
value: "—",
readonly: true,
order: 42
},
HW_Subtype: {
title: "Подтип железа",
type: "text",
value: "—",
readonly: true,
order: 43
},
Exchange: {
title: "Протокол обмена",
type: "text",
value: "—",
readonly: true,
order: 44
},
// --- Rel (readonly, feedback) ---
Rel_Gate: {
title: "Rel: заслонка [feedback]",
type: "value",
value: 0,
readonly: true,
order: 50
},
Rel_SmartSpeed: {
title: "Rel: скорость смарт [feedback]",
type: "value",
value: 0,
readonly: true,
order: 51
},
Rel_EmergShunt: {
title: "Rel: emerg_shunt [feedback]",
type: "value",
value: 0,
readonly: true,
order: 52
},
// --- Отладка ---
Debug_Log: {
title: "Детальный лог (Debug)",
type: "switch",
value: false,
order: 60
}
}
});
// ============================================================
// Вспомогательные функции
// ============================================================
function dbg(tag, message) {
if (dev[vd][ctrlDebugLog]) {
log.info("[DBG][" + vd + "][" + tag + "] " + message);
}
}
function sendCapabilities(caps) {
var payload = JSON.stringify({ capabilities: caps });
publish(serverBase + "/mode", payload, 2, false);
dbg("TX/mode", "capabilities: " + payload);
}
function sendSettings(s) {
var payload = JSON.stringify({ settings: s });
publish(serverBase + "/mode", payload, 2, false);
dbg("TX/mode", "settings: " + payload);
}
function sendSystem(obj) {
var payload = JSON.stringify(obj);
publish(serverBase + "/system", payload, 2, false);
dbg("TX/system", payload);
}
function setCtrlError(ctrl, isError) {
dev[vd + "/" + ctrl + "#error"] = isError ? "r" : "";
}
var watchedCtrls = [ctrlState, ctrlSpeed, ctrlGate];
function setOnline() {
_lastAlive = Date.now();
if (!_isOnline) {
_isOnline = true;
dev[vd][ctrlConnect] = true;
for (var i = 0; i < watchedCtrls.length; i++) {
setCtrlError(watchedCtrls[i], false);
}
log.info("[" + vd + "] онлайн");
}
}
function setOffline() {
if (_isOnline) {
_isOnline = false;
dev[vd][ctrlConnect] = false;
for (var i = 0; i < watchedCtrls.length; i++) {
setCtrlError(watchedCtrls[i], true);
}
log.warning("[" + vd + "] офлайн");
}
}
function safeJson(str) {
try {
return JSON.parse(str);
} catch (e) {
log.warning("[" + vd + "] ошибка JSON: " + str);
return null;
}
}
// ============================================================
// Глубокий опрос — только запрос auth + 0687
//
// ВАЖНО: НЕ эхоим capabilities обратно на устройство!
// Если эхоить — устройство применяет значения и присылает feedback,
// перезатирая реальное состояние после интерлока (speed=0 при смене gate).
//
// Устройство само присылает актуальное состояние в ответ на auth:
// device/.../system → auth + device_subtype (mac, fw, серия)
// device/.../mode → capabilities + settings (скорость, заслонка, режим)
// ============================================================
function deepPoll() {
sendSystem({ type: "auth" });
publish(legacyBase + "/system", "0687", 2, false);
dbg("DEEP_POLL", "auth + 0687 отправлены, ждём ответа от устройства");
log.info("[" + vd + "] глубокий опрос выполнен");
}
// ============================================================
// Команды пользователя → Устройство
// ============================================================
trackMqtt(
"/devices/" + vd + "/controls/" + ctrlState + "/on",
function(msg) {
var isOn = (msg.value === true || msg.value === "true" || msg.value === "1");
sendCapabilities({ on_off: isOn ? "on" : "off" });
}
);
trackMqtt(
"/devices/" + vd + "/controls/" + ctrlWorkmode + "/on",
function(msg) {
var mode = String(msg.value).trim();
log.info("[" + vd + "] режим → " + mode);
sendCapabilities({ mode: mode });
}
);
trackMqtt(
"/devices/" + vd + "/controls/" + ctrlSpeed + "/on",
function(msg) {
var newSpeed = parseInt(msg.value, 10);
_selfChange = true;
if (newSpeed === 0) {
sendCapabilities({ speed: 0 });
setTimeout(function() {
sendCapabilities({ gate: 2 });
_selfChange = false;
}, 1500);
} else {
sendCapabilities({ gate: 4 });
setTimeout(function() {
sendCapabilities({ speed: newSpeed, on_off: "on" });
_selfChange = false;
}, 1500);
}
}
);
trackMqtt(
"/devices/" + vd + "/controls/" + ctrlGate + "/on",
function(msg) {
if (_selfChange) { return; }
var newGate = parseInt(msg.value, 10);
_selfChange = true;
sendCapabilities({ speed: 0 });
setTimeout(function() {
sendCapabilities({ gate: newGate });
_selfChange = false;
}, 1500);
}
);
defineRule("CloseGate_" + vd, {
whenChanged: vd + "/" + ctrlClose,
then: function(newValue) {
if (!newValue) { return; }
sendCapabilities({ speed: 0 });
setTimeout(function() {
sendCapabilities({ on_off: "off" });
}, 1500);
}
});
defineRule("ForceRefresh_" + vd, {
whenChanged: vd + "/" + ctrlForceRefresh,
then: function(newValue) {
if (!newValue) { return; }
log.info("[" + vd + "] принудительное обновление данных...");
deepPoll();
}
});
defineRule("SmartSettings_" + vd, {
whenChanged: [
vd + "/" + ctrlSmartGate,
vd + "/" + ctrlSmartSpeed,
vd + "/" + ctrlEmergShunt
],
then: function() {
var s = {
gate: dev[vd][ctrlSmartGate],
smart_speed: dev[vd][ctrlSmartSpeed],
emerg_shunt: dev[vd][ctrlEmergShunt]
};
sendSettings(s);
log.info("[" + vd + "] настройки смарт → gate=" + s.gate +
" speed=" + s.smart_speed + " emerg=" + s.emerg_shunt);
}
});
defineRule("SmartTempSpeed_" + vd, {
whenChanged: vd + "/" + ctrlSmartTempThreshold,
then: function() {
var threshold = parseFloat(dev[vd][ctrlSmartTempThreshold]);
var speed = parseInt(dev[vd][ctrlSmartSpeed], 10);
var s = {
temperature_speed: [threshold, speed]
};
sendSettings(s);
log.info("[" + vd + "] temperature_speed → " + threshold + "°C / speed=" + speed);
}
});
defineRule("ShutdownLimit_" + vd, {
whenChanged: vd + "/" + ctrlShutdownLimit,
then: function() {
sendSystem({ shutdown: { limit: dev[vd][ctrlShutdownLimit] } });
}
});
// ============================================================
// Обратная связь Устройство → Wiren Board
// ============================================================
trackMqtt(deviceBase + "/system", function(msg) {
dbg("RX/system", "RAW: " + msg.value);
var obj = safeJson(msg.value);
if (!obj) { return; }
if (obj.auth) {
if (obj.auth.device_mac !== undefined && obj.auth.device_mac !== null) {
dev[vd][ctrlMac] = String(obj.auth.device_mac);
}
if (obj.auth.version !== undefined && obj.auth.version !== null) {
dev[vd][ctrlFwVer] = String(obj.auth.version);
}
dbg("RX/system", "auth: mac=" + dev[vd][ctrlMac] + " ver=" + dev[vd][ctrlFwVer]);
}
if (obj.device_subtype) {
var ds = obj.device_subtype;
if (ds.exchange_type !== undefined && ds.exchange_type !== null) {
dev[vd][ctrlExchange] = String(ds.exchange_type);
}
if (ds.series !== undefined && ds.series !== null) {
dev[vd][ctrlSeries] = String(ds.series);
}
if (ds.subtype !== undefined && ds.subtype !== null) {
var sub = String(ds.subtype);
if (ds.xtal_freq !== undefined && ds.xtal_freq !== null) {
sub = sub + " (xtal:" + String(ds.xtal_freq) + ")";
}
dev[vd][ctrlSubtype] = sub;
}
dbg("RX/system", "device_subtype: exchange=" + ds.exchange_type +
" series=" + ds.series + " subtype=" + ds.subtype);
}
if (obj.errors !== undefined && obj.errors.shutdown !== undefined) {
var isShutdown = (parseInt(obj.errors.shutdown, 10) === 1);
dev[vd][ctrlErrShutdown] = isShutdown;
setCtrlError(ctrlErrShutdown, isShutdown);
if (isShutdown) {
log.warning("[" + vd + "] ПЕРЕОХЛАЖДЕНИЕ! shutdown=1");
}
}
setOnline();
if (!_initialized) {
_initialized = true;
log.info("[" + vd + "] первый пакет — FW=" + dev[vd][ctrlFwVer] +
" MAC=" + dev[vd][ctrlMac] + " series=" + dev[vd][ctrlSeries]);
}
});
trackMqtt(deviceBase + "/mode", function(msg) {
dbg("RX/mode", "RAW: " + msg.value);
var obj = safeJson(msg.value);
if (!obj) { return; }
if (obj.capabilities !== undefined) {
var cap = obj.capabilities;
if (cap.speed !== undefined && cap.speed !== null) {
dev[vd][ctrlSpeed] = parseInt(cap.speed, 10);
}
if (cap.gate !== undefined && cap.gate !== null) {
var g = parseInt(cap.gate, 10);
if (g >= 1 && g <= 4) { dev[vd][ctrlGate] = g; }
}
if (cap.on_off !== undefined && cap.on_off !== null) {
dev[vd][ctrlState] = (cap.on_off === "on");
}
if (cap.mode !== undefined && cap.mode !== null) {
dev[vd][ctrlWorkmode] = String(cap.mode);
}
dbg("RX/mode", "capabilities: mode=" + cap.mode + " on_off=" + cap.on_off +
" speed=" + cap.speed + " gate=" + cap.gate);
}
if (obj.settings !== undefined) {
var s = obj.settings;
dbg("RX/mode", "settings: " + JSON.stringify(s));
if (s.temperature_speed !== undefined && Array.isArray(s.temperature_speed)) {
if (s.temperature_speed.length >= 1) {
var thr = parseFloat(s.temperature_speed[0]);
dev[vd][ctrlAutoTemp] = thr;
dev[vd][ctrlSmartTempThreshold] = thr;
}
if (s.temperature_speed.length >= 2) {
dev[vd][ctrlAutoSpeed] = parseInt(s.temperature_speed[1], 10);
}
}
if (s.emerg_shunt !== undefined && s.emerg_shunt !== null) {
dev[vd][ctrlRelEmerg] = parseFloat(s.emerg_shunt);
dev[vd][ctrlEmergShunt] = parseFloat(s.emerg_shunt);
}
if (s.gate !== undefined && s.gate !== null) {
dev[vd][ctrlRelGate] = parseInt(s.gate, 10);
dev[vd][ctrlSmartGate] = parseInt(s.gate, 10);
}
if (s.smart_speed !== undefined && s.smart_speed !== null) {
dev[vd][ctrlRelSmartSpd] = parseInt(s.smart_speed, 10);
dev[vd][ctrlSmartSpeed] = parseInt(s.smart_speed, 10);
}
}
setOnline();
});
// Легаси-топики
trackMqtt(legacyBase + "/temp", function(msg) {
var v = parseFloat(msg.value);
if (!isNaN(v)) { dev[vd][ctrlTemp] = v; }
setOnline();
dbg("RX/temp", msg.value);
});
trackMqtt(legacyBase + "/hud", function(msg) {
var v = parseFloat(msg.value);
if (!isNaN(v)) { dev[vd][ctrlHumidity] = v; }
setOnline();
dbg("RX/hud", msg.value);
});
trackMqtt(legacyBase + "/state", function(msg) {
dev[vd][ctrlState] = (msg.value === "on");
setOnline();
dbg("RX/state", msg.value);
});
trackMqtt(legacyBase + "/speed", function(msg) {
var v = parseInt(msg.value, 10);
if (!isNaN(v) && v >= 0 && v <= 5) { dev[vd][ctrlSpeed] = v; }
setOnline();
dbg("RX/speed", msg.value);
});
trackMqtt(legacyBase + "/gate", function(msg) {
var v = parseInt(msg.value, 10);
if (!isNaN(v) && v >= 1 && v <= 4) { dev[vd][ctrlGate] = v; }
setOnline();
dbg("RX/gate", msg.value);
});
trackMqtt(legacyBase + "/workmode", function(msg) {
dev[vd][ctrlWorkmode] = String(msg.value);
setOnline();
dbg("RX/workmode", msg.value);
});
trackMqtt(legacyBase + "/system", function(msg) {
setOnline();
dbg("RX/legacy-system", msg.value);
});
// ============================================================
// Polling / watchdog
// ============================================================
function pollDevice() {
sendSystem({ type: "auth" });
publish(legacyBase + "/system", "0687", 2, false);
dbg("POLL", "heartbeat отправлен");
}
var _monitorReady = false;
var _pollCount = 0;
var _deepInterval = Math.max(1, Math.round(deep_poll_interval / polling_interval));
pollDevice();
setTimeout(deepPoll, 2000);
setInterval(function() {
_pollCount++;
pollDevice();
if (_pollCount % _deepInterval === 0) {
deepPoll();
log.info("[" + vd + "] плановый глубокий опрос #" + Math.floor(_pollCount / _deepInterval));
}
if (!_monitorReady) {
_monitorReady = true;
return;
}
var silenceMs = polling_interval * 2 * 1000;
if (_lastAlive === 0 || (Date.now() - _lastAlive) > silenceMs) {
setOffline();
}
}, polling_interval * 1000);
log.info("[" + vd + "] инициализирован.");
log.info("[" + vd + "] JSON RX : " + deviceBase + "/{system,mode}");
log.info("[" + vd + "] JSON TX : " + serverBase + "/{system,mode}");
log.info("[" + vd + "] Легаси : " + legacyBase + "/{system,temp,hud,state,speed,gate,workmode}");
log.info("[" + vd + "] Heartbeat : каждые " + polling_interval + "с");
log.info("[" + vd + "] Глубокий опрос: каждые " + deep_poll_interval + "с");
}
Результат
В итоге WirenBoard видит клапан как полноценное виртуальное устройство со всеми параметрами: температура, влажность, состояние заслонки, скорость, режим работы, ошибки.
Дальше можно строить любую автоматику: расписания, реакцию на CO₂ или влажность, интеграцию с другими устройствами в доме.
Выводы
Vakio OpenAir оказался приятным устройством: открытый API, нормальная документация, поддержка локального MQTT без обязательного облака. Единственное — пришлось разобраться с нюансами протокола самому, потому что готовых интеграций под WirenBoard на мой вкус не нашлось. Надеюсь, этот драйвер пригодится тем, кто захочет повторить что-то похожее.