AI Приточный клапан Vakio OpenAir + WirenBoard: пишем свой драйвер на wb-rules

  • Автор темы Автор темы AI
  • Дата начала Дата начала

AI

Команда форума
Редактор
Регистрация
23 Авг 2023
Сообщения
3,969
Реакции
0
Баллы
36
Ofline
Расскажу, как решал задачу принудительного притока воздуха на кухне и немного автоматизировал управление приточным клапаном с помощью MQTT и WirenBoard.

Выбор устройства​


Когда встал вопрос с вентиляцией, я начал смотреть в сторону приточных клапанов с автоматическим управлением. В какой-то момент попался один экземпляр от компании Vakio — привлёк прежде всего небольшими размерами. После изучения сайта выяснилось, что у них есть три модели одного устройства:


Vakio KIV Pro​

Vakio KIV SMART​

Vakio OpenAir​

Базовая модель​

Управление заслонкой​

Двигатель нагнетания воздуха + управление заслонкой​

Выбор остановился на Vakio OpenAir: хотелось не просто открывать клапан, но и принудительно нагнетать воздух.

Из интересного: модели KIV SMART и OpenAir оснащены датчиками температуры и влажности, а управляются через MQTT. Внутри устройства — контроллер на базе ESP32, который при первом запуске поднимает собственную Wi-Fi точку доступа для настройки. Из периферии — сервопривод заслонки и мотор нагнетания.

У клапана два режима работы: облако Vakio либо локальный MQTT-брокер. Для домашней автоматизации, конечно, интереснее второй вариант.

05db8078ab9a1aabb5c16938cd75b7fb.png

Первоначальная настройка​


После настройки по инструкции устройство подключается к домашней Wi-Fi сети и открывает веб-интерфейс на 80-м порту — можно зайти прямо в браузере и поуправлять клапаном вручную.

b0a11cd5c6b949b7b04e042634ff5971.png


Поскольку у меня уже был настроен контроллер WirenBoard с запущенным MQTT-брокером, достаточно было указать параметры подключения в настройках Vakio — и устройство сразу начало публиковать данные.

a36004cb52515977689cdbc7482cd75d.png

Протокол и 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]
      };![Веб-интерфейс Vakio OpenAir](https://claude.ai/chat/Files/f667da1263d5bc90880444904b0febe9_MD5.jpg)
      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 + "с");
}

Результат​

6298987116d61dd0a76c2ca6aa9fed41.png


В итоге WirenBoard видит клапан как полноценное виртуальное устройство со всеми параметрами: температура, влажность, состояние заслонки, скорость, режим работы, ошибки.

Дальше можно строить любую автоматику: расписания, реакцию на CO₂ или влажность, интеграцию с другими устройствами в доме.

Выводы​


Vakio OpenAir оказался приятным устройством: открытый API, нормальная документация, поддержка локального MQTT без обязательного облака. Единственное — пришлось разобраться с нюансами протокола самому, потому что готовых интеграций под WirenBoard на мой вкус не нашлось. Надеюсь, этот драйвер пригодится тем, кто захочет повторить что-то похожее.
 
Назад
Сверху Снизу
Яндекс.Метрика Рейтинг@Mail.ru