Своими рукамипрактическая инструкция

«Что в теплице?» — собираем MQTT-сервер с ботом ВКонтакте

Редакция Фермозавра·12 апреля 2026 г.·17 мин чтения

Попросим бота показать датчики, запросим свежее измерение и попробуем перезапустить одну ESP32. Общий сервер соберёт сообщения и правила оповещения в одном месте.

«Что в теплице?» — собираем MQTT-сервер с ботом ВКонтакте

В одной теплице свой датчик, в другой — ещё один. Хочется написать «статус» и увидеть их вместе: температуру, влажность и время последнего измерения. Для этого соберём общий сервер MQTT и подключим бота ВКонтакте. Токен бота останется на сервере, а платы будут передавать свои показания.

Начнём с лабораторной сборки для температуры и влажности. Освоим три действия: общий статус, новое измерение и перезапуск выбранной платы. Исполнительные нагрузки пока оставим отдельно. MQTT позволит разнести обмен по каналам и менять правила оповещения в одном месте.

Каждому датчику — свой адрес сообщений

На Linux-компьютере запустим Mosquitto и программу Node.js. Для первого датчика выберем имя teplica1: измерения идут в ferma/teplica1/data, команды — в ferma/teplica1/cmd, ответы — в ferma/teplica1/ack. Следующему узлу присвоим другое имя, создадим для него отдельный пароль и права доступа, затем добавим запись на сервере.

Пример использует Mosquitto 2.0, Node.js 22, MQTT.js 5.16.0 и vk-io 4.10.1. Для ESP32: Arduino-ESP32 3.3.12, PubSubClient 2.8.0, ArduinoJson 7.4.3, библиотека DHT sensor library 1.4.7 и её зависимость Adafruit Unified Sensor. Подключите DHT22 к GPIO4: питание и подтяжка линии данных — к 3,3 В, земля общая. Номинал подтяжки и распиновку сверяйте с документацией конкретного датчика или модуля.

Проверьте два пути связи: сервер должен обращаться к VK API, плата — к локальному брокеру и службе времени. Long Poll получает события исходящим соединением, поэтому отдельный публичный входящий адрес для бота здесь не нужен. Для постоянной работы учтите сервер, питание и связь.

Настраиваем брокер и доступ к каналам

Дальнейшая настройка предназначена для нового выделенного брокера. На действующей системе сначала сохраните конфигурацию и пользователей: команда с -c создаёт файл паролей заново. Адрес 192.168.1.100 — пример; закрепите реальный адрес сервера и замените его в конфигурации, сертификате и скетче.

sudo apt update
sudo apt install mosquitto mosquitto-clients openssl
sudo mosquitto_passwd -c /etc/mosquitto/passwd hub
sudo mosquitto_passwd /etc/mosquitto/passwd teplica1
sudo chown root:mosquitto /etc/mosquitto/passwd
sudo chmod 640 /etc/mosquitto/passwd

Задайте разные длинные пароли для сервера hub и датчика teplica1. Для локальной сети также нужна проверка подлинности и шифрование. Ниже пример собственного центра сертификации: выполняйте команды в закрытом рабочем каталоге на доверенном компьютере. Пароль ключа CA вводится интерактивно.

mkdir farm-pki
cd farm-pki
umask 077
openssl genpkey -algorithm RSA -aes-256-cbc -pkeyopt rsa_keygen_bits:2048 -out ca.key
openssl req -new -x509 -sha256 -days 3650 -key ca.key -out ca.crt   -subj '/CN=Farm MQTT CA'   -addext 'basicConstraints=critical,CA:TRUE'   -addext 'keyUsage=critical,keyCertSign,cRLSign'
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out server.key
openssl req -new -key server.key -out server.csr -subj '/CN=Farm MQTT'
cat > server.ext <<'EOF'
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=IP:192.168.1.100
EOF
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial   -out server.crt -days 365 -sha256 -extfile server.ext
openssl verify -CAfile ca.crt -verify_ip 192.168.1.100 server.crt

Это выбранные сроки для примера: запланируйте обновление сертификата до истечения срока. Закрытый ca.key храните отдельно от брокера. На сервер безопасно перенесите только server.crt и server.key; открытый ca.crt потребуется для прошивки. Не используйте чужие демонстрационные ключи.

sudo install -d -o root -g mosquitto -m 750 /etc/mosquitto/certs
sudo install -o root -g mosquitto -m 640 server.crt /etc/mosquitto/certs/server.crt
sudo install -o root -g mosquitto -m 640 server.key /etc/mosquitto/certs/server.key

Создайте /etc/mosquitto/conf.d/farm.conf:

allow_anonymous false
password_file /etc/mosquitto/passwd
acl_file /etc/mosquitto/farm.acl

listener 1883 127.0.0.1

listener 8883 192.168.1.100
certfile /etc/mosquitto/certs/server.crt
keyfile /etc/mosquitto/certs/server.key

Проверьте, что основной конфигурационный файл включает этот каталог и другие файлы не объявляют конфликтующие слушатели. Обычный MQTT на порту 1883 здесь доступен только на самом сервере; для ESP32 используется TLS на порту 8883. Не пробрасывайте эти порты из интернета.

Файл /etc/mosquitto/farm.acl разрешает датчику писать только свои измерения и ответы, а читать — только свои команды:

user hub
topic read ferma/+/data
topic read ferma/+/ack
topic write ferma/+/cmd

user teplica1
topic write ferma/teplica1/data
topic write ferma/teplica1/ack
topic read ferma/teplica1/cmd
sudo chown root:mosquitto /etc/mosquitto/farm.acl
sudo chmod 640 /etc/mosquitto/farm.acl
sudo systemctl restart mosquitto
sudo systemctl status mosquitto --no-pager

При ошибке запуска прочитайте журнал сервиса и проверьте адрес, доступ к ключу и отсутствие повторных объявлений порта. Формат правил и настройки аутентификации описаны в документации Mosquitto. Пример проверен с файловыми паролями и ACL версии 2.0.18; при другой версии сверяйте её документацию.

ESP32 отвечает на первый запрос

Рядом со скетчем создайте файл broker_ca.h. Между строками ограничителя вставьте весь открытый сертификат ca.crt, включая строки BEGIN и END. Закрытые ключи в плату не загружаются:

#pragma once
static const char BROKER_CA[] = R"PEM(
-----BEGIN CERTIFICATE-----
ВСТАВЬТЕ СОДЕРЖИМОЕ ca.crt
-----END CERTIFICATE-----
)PEM";

Скетч ниже рассчитан на обычный ESP32. Заполните Wi-Fi, адрес брокера и пароль teplica1. Он проверяет сертификат сервера и сначала ждёт установленного времени. При недоступном NTP соединение не начинается; это следует проверить отдельно, прежде чем искать ошибку в пароле MQTT.

#include <WiFi.h>
#include <NetworkClientSecure.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <DHT.h>
#include <esp_random.h>
#include <time.h>
#include "broker_ca.h"

const char *WIFI_SSID = "CHANGE_ME";
const char *WIFI_PASSWORD = "CHANGE_ME";
const char *MQTT_SERVER = "192.168.1.100"; // Должен совпадать с SAN сертификата.
const char *DEVICE_ID = "teplica1";      // Также имя пользователя Mosquitto.
const char *MQTT_PASSWORD = "CHANGE_ME";
NetworkClientSecure tls;
PubSubClient mqtt(tls);
DHT dht(4, DHT22);
String bootId, dataTopic, commandTopic, ackTopic;
String pendingId, pendingAction, cachedId, cachedAck;
String seen[8]; unsigned seenNext = 0;
uint32_t lastConnect = 0, lastWifi = 0, lastSample = 0, rebootAt = 0;
uint32_t lastCommand = 0, pendingExpiry = 0;
bool commandSeen = false, needSample = true;

bool hexId(const char *s) {
  if (!s || strlen(s) != 32) return false;
  for (unsigned i = 0; i < 32; ++i)
    if (!((s[i] >= '0' && s[i] <= '9') || (s[i] >= 'a' && s[i] <= 'f'))) return false;
  return true;
}
void onMessage(char *topic, byte *payload, unsigned length) {
  if (String(topic) != commandTopic || length > 768 || rebootAt) return;
  JsonDocument doc;
  if (deserializeJson(doc, payload, length) || !doc["id"].is<const char *>() ||
      !doc["boot"].is<const char *>() || !doc["action"].is<const char *>() ||
      !doc["expires_at"].is<uint32_t>()) return;
  const char *id = doc["id"], *boot = doc["boot"], *action = doc["action"];
  time_t now = time(nullptr);
  uint32_t expires = doc["expires_at"].as<uint32_t>();
  if (!hexId(id) || bootId != boot || now < 1700000000 || expires <= uint64_t(now) ||
      uint64_t(expires) > uint64_t(now) + 15 || (strcmp(action, "status") && strcmp(action, "reboot"))) return;
  for (const String &old : seen) if (old == id) {
    if (cachedId == id) mqtt.publish(ackTopic.c_str(), cachedAck.c_str(), false);
    return;
  }
  if (!pendingId.isEmpty() || (commandSeen && uint32_t(millis() - lastCommand) < 2000)) return;
  seen[seenNext++ % 8] = id; commandSeen = true; lastCommand = millis();
  pendingId = id; pendingAction = action; pendingExpiry = expires;
}
void publishJson(const String &topic, JsonDocument &doc, bool cache = false) {
  String text;
  serializeJson(doc, text);
  if (cache) { cachedId = pendingId; cachedAck = text; }
  if (!mqtt.publish(topic.c_str(), text.c_str(), false)) Serial.println("MQTT write not accepted");
}
void sample(bool reply) {
  // Этот пример не содержит исполнительных нагрузок.
  float temp = dht.readTemperature(false, true);
  float hum = dht.readHumidity();
  lastSample = millis(); needSample = false;
  JsonDocument doc;
  doc["boot"] = bootId; doc["sampled_at"] = int64_t(time(nullptr));
  bool ok = isfinite(temp) && isfinite(hum) && temp >= -40 && temp <= 80 && hum >= 0 && hum <= 100;
  doc["ok"] = ok;
  if (ok) { doc["temp"] = temp; doc["hum"] = hum; }
  publishJson(dataTopic, doc);
  if (reply) {
    doc["id"] = pendingId; doc["action"] = "status";
    publishJson(ackTopic, doc, true);
    pendingId = ""; pendingAction = "";
  }
}
void setup() {
  Serial.begin(115200);
  dht.begin();
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  configTime(0, 0, "pool.ntp.org", "time.cloudflare.com");
  uint8_t randomBytes[16]; esp_fill_random(randomBytes, sizeof(randomBytes));
  char pair[3];
  for (uint8_t value : randomBytes) { snprintf(pair, sizeof(pair), "%02x", value); bootId += pair; }
  dataTopic = "ferma/" + String(DEVICE_ID) + "/data";
  commandTopic = "ferma/" + String(DEVICE_ID) + "/cmd";
  ackTopic = "ferma/" + String(DEVICE_ID) + "/ack";
  tls.setCACert(BROKER_CA); tls.setHandshakeTimeout(3); tls.setTimeout(3000);
  mqtt.setServer(MQTT_SERVER, 8883); mqtt.setSocketTimeout(3);
  mqtt.setBufferSize(1024); mqtt.setCallback(onMessage);
  lastConnect = uint32_t(millis() - 5000);
}
void loop() {
  if (rebootAt) { if (int32_t(millis() - rebootAt) >= 0) ESP.restart(); delay(2); return; }
  if (WiFi.status() != WL_CONNECTED) {
    if (uint32_t(millis() - lastWifi) >= 15000) { lastWifi = millis(); WiFi.reconnect(); }
    delay(2); return;
  }
  if (time(nullptr) < 1700000000) { delay(2); return; }
  if (!mqtt.connected()) {
    if (uint32_t(millis() - lastConnect) >= 5000) {
      lastConnect = millis();
      if (mqtt.connect(DEVICE_ID, DEVICE_ID, MQTT_PASSWORD)) {
        if (!mqtt.subscribe(commandTopic.c_str(), 1)) Serial.println("Subscription write failed");
        needSample = true;
      }
    }
    delay(2); return;
  }
  mqtt.loop();
  if (!pendingId.isEmpty() && uint64_t(time(nullptr)) >= pendingExpiry) { pendingId = ""; pendingAction = ""; }
  if (pendingAction == "reboot") {
    JsonDocument doc;
    doc["id"] = pendingId; doc["boot"] = bootId;
    doc["action"] = "reboot"; doc["result"] = "accepted";
    publishJson(ackTopic, doc, true);
    pendingId = ""; pendingAction = "";
    rebootAt = millis() + 500; if (!rebootAt) rebootAt = 1;
  } else if (uint32_t(millis() - lastSample) >= 2000 &&
             (pendingAction == "status" || needSample || uint32_t(millis() - lastSample) >= 300000)) {
    sample(pendingAction == "status");
  }
  delay(2);
}

После соединения плата отправляет показание и повторяет чтение через пять минут. Теперь попробуйте status: оно запросит новое измерение с паузой между чтениями хотя бы две секунды. Ошибка даст ok:false. После разрыва связи плата присылает новое чтение; накопление истории можно добавить отдельным этапом.

У запроса есть случайный номер, идентификатор запуска платы и срок 15 секунд. Плата принимает только status и reboot, проверяет свежесть и запоминает недавние запросы. Попробуйте повтор: действие должно выполниться один раз. После перезапуска старый идентификатор запуска уже не подойдёт; часы платы и сервера должны быть синхронизированы.

В PubSubClient публикация идёт с QoS 0: успешный возврат publish() не подтверждает получение сообщения сервером. Поэтому ответ на перезагрузку означает только принятие запроса. Плата перезапускается через 500 мс и может не успеть доставить ответ; завершение проверяют по новым данным и сменившемуся boot. Все сообщения отправляются без retain. Попытки подключения выполняются с паузой, но сами сетевые вызовы могут блокировать выполнение на время тайм-аута.

Готовим собеседника во ВКонтакте

В своём сообществе включите сообщения и Bots Long Poll, разрешите событие message_new и получите токен сообщества с правом работы с сообщениями. Названия разделов интерфейса могут меняться; ориентируйтесь на настройки API сообщества. Сначала напишите сообществу от своего личного аккаунта. В ADMIN_ID нужен числовой ID этого пользователя, а не ID сообщества.

Токен даёт доступ к API: храните его в закрытом файле на сервере, не в прошивке и не в репозитории. Обработчик проверяет отправителя, личную беседу, направление и возраст сообщения. Одного указания получателя тревог для защиты команд недостаточно. Интерфейс событий сверяется с MessageContext библиотеки vk-io.

Установите поддерживаемую ветку Node.js 22 и создайте отдельный каталог программы. Команды ниже выполняются обычным пользователем:

mkdir farm-hub
cd farm-hub
npm init -y
npm install --save-exact mqtt@5.16.0 vk-io@4.10.1
umask 077
touch .env
chmod 600 .env

Откройте .env в редакторе и замените заглушки своими значениями. Пароль MQTT должен совпадать с паролем пользователя hub. Если значение содержит специальные символы, заключите его в кавычки по правилам env-файла Node.js.

VK_TOKEN=CHANGE_ME
ADMIN_ID=123456789
MQTT_PASSWORD=CHANGE_ME
DEVICES=["teplica1"]

Сохраните следующий файл как farm-hub.mjs. Он допускает до 32 явно перечисленных устройств; это ограничение учебной программы, а не предел MQTT. Для большего списка потребуется пересмотреть обработку и хранение состояния.

import { randomBytes, randomInt } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import { pathToFileURL } from 'node:url';
import mqtt from 'mqtt';
import { VK } from 'vk-io';

const hex = /^[0-9a-f]{32}$/;
const STALE = 900; // Учебный предел свежести, секунды.
export class FarmHub {
  constructor({ admin, devices, client, send, wall = Date.now, mono = () => performance.now() }) {
    if (!Number.isSafeInteger(admin) || admin <= 0 || !Array.isArray(devices) || !devices.length ||
        devices.length > 32 || new Set(devices).size !== devices.length ||
        devices.some(id => !/^[a-z][a-z0-9_-]{0,23}$/.test(id))) throw Error('Invalid configuration');
    Object.assign(this, { admin, client, send, wall, mono });
    this.devices = new Map(devices.map(id => [id, { data: null, at: 0, age: 0, bad: false }]));
    this.pending = new Map(); this.seen = new Map(); this.rules = new Map();
    this.queue = new Map(); this.busy = false; this.lastCommand = -Infinity;
    this.started = mono();
  }
  age(row) { return row.data ? row.age + (this.mono() - row.at) / 1000 : Infinity; }
  validData(d) {
    const age = this.wall() / 1000 - d?.sampled_at;
    return d && !Array.isArray(d) && typeof d.boot === 'string' && hex.test(d.boot) && Number.isSafeInteger(d.sampled_at) &&
      age >= -5 && age <= STALE && typeof d.ok === 'boolean' &&
      (!d.ok || (typeof d.temp === 'number' && Number.isFinite(d.temp) && d.temp >= -40 && d.temp <= 80 &&
                 typeof d.hum === 'number' && Number.isFinite(d.hum) && d.hum >= 0 && d.hum <= 100));
  }
  enqueue(key, text, valid = () => true, delivered = () => {}) {
    if (this.queue.has(key)) return;
    if (this.queue.size >= 256) { console.error('Notification queue full'); return; }
    this.queue.set(key, { text, valid, delivered, id: randomInt(1, 2147483647),
                          retry: 0, until: this.mono() + 600000 });
  }
  async flush() {
    if (this.busy) return;
    for (const [key, item] of this.queue) {
      if (!item.valid() || this.mono() >= item.until) { this.queue.delete(key); continue; }
      if (this.mono() < item.retry) continue;
      this.busy = true;
      try {
        await this.send({ user_id: this.admin, random_id: item.id, message: item.text });
        item.delivered(); this.queue.delete(key);
      } catch { item.retry = this.mono() + 10000; console.error('VK delivery failed; retry queued'); }
      finally { this.busy = false; }
      break;
    }
  }
  rule(id, name, active) {
    const key = `${id}:${name}`;
    let r = this.rules.get(key);
    if (!r) { r = { active: false, delivered: false, sentAt: -Infinity }; this.rules.set(key, r); }
    r.active = active;
    if (r.delivered !== active || (active && this.mono() - r.sentAt >= 900000)) {
      const value = active;
      const labels = { cold: 'низкая температура', hot: 'высокая температура', wet: 'высокая влажность',
                       sensor: 'ошибка измерения', stale: 'нет свежих данных' };
      this.enqueue(key, `${id}: ${labels[name]} — ${value ? 'тревога' : 'восстановление'}.`,
        () => r.active === value && (['sensor', 'stale'].includes(name) ||
          (!this.devices.get(id).bad && this.devices.get(id).data?.ok && this.age(this.devices.get(id)) <= STALE)), () => { r.delivered = value; r.sentAt = this.mono(); });
    }
  }
  active(id, name) { return this.rules.get(`${id}:${name}`)?.active ?? false; }
  message(topic, payload, packet = {}) {
    if (packet.retain || payload.length > 1024) return;
    const parts = topic.split('/');
    if (parts.length !== 3 || parts[0] !== 'ferma' || !this.devices.has(parts[1])) return;
    const [, id, kind] = parts; const row = this.devices.get(id);
    let d; try { d = JSON.parse(payload.toString()); } catch { if (kind === 'data') row.bad = true; return; }
    if (kind === 'data') {
      if (!this.validData(d)) { row.bad = true; return; }
      if (row.data && d.boot === row.data.boot && d.sampled_at <= row.data.sampled_at) return;
      row.data = d; row.at = this.mono(); row.age = Math.max(0, this.wall() / 1000 - d.sampled_at); row.bad = false;
    } else if (kind === 'ack') {
      const request = this.pending.get(id);
      if (!request || !d || d.id !== request.id || d.boot !== request.boot || d.action !== request.action ||
          this.mono() >= request.until) return;
      let result;
      if (request.action === 'status' && this.validData(d))
        result = d.ok ? `${d.temp} °C, ${d.hum} %; время измерения ${new Date(d.sampled_at * 1000).toISOString()}` : 'датчик сообщил об ошибке';
      else if (request.action === 'reboot' && d.result === 'accepted')
        result = 'устройство приняло запрос перезагрузки; завершение проверьте по новым данным';
      else return;
      this.pending.delete(id); this.enqueue(`reply:${request.id}`, `${id}: ${result}.`);
    }
  }
  command(context) {
    if (context.isOutbox || context.senderId !== this.admin || context.peerId !== this.admin) return;
    const age = this.wall() / 1000 - context.createdAt;
    if (!Number.isSafeInteger(context.conversationMessageId) || context.conversationMessageId <= 0 ||
        !Number.isFinite(age) || age < -5 || age > 30 || this.seen.has(context.conversationMessageId)) return;
    if (this.mono() - this.lastCommand < 2000) return;
    this.lastCommand = this.mono();
    this.seen.set(context.conversationMessageId, this.mono() + 300000);
    const text = typeof context.text === 'string' ? context.text.trim().toLowerCase() : '';
    const reply = value => {
      let part = 0, chunk = '';
      for (const line of value.split('\n')) {
        if (chunk.length + line.length > 3500) {
          this.enqueue(`vk:${context.conversationMessageId}:${part++}`, chunk); chunk = '';
        }
        chunk += (chunk ? '\n' : '') + line;
      }
      if (chunk) this.enqueue(`vk:${context.conversationMessageId}:${part}`, chunk);
    };
    if (text === 'статус' || text === 'status') {
      reply([...this.devices].map(([id, row]) => {
        if (!row.data) return `${id}: данные ещё не получены`;
        return `${id}: ${(row.bad || !row.data.ok) ? 'ошибка данных' : `${row.data.temp} °C, ${row.data.hum} %`}; ` +
          `${Math.floor(this.age(row))} с назад${this.age(row) > STALE ? ', УСТАРЕЛИ' : ''}; boot ${row.data.boot}`;
      }).join('\n')); return;
    }
    const match = /^cmd ([a-z][a-z0-9_-]{0,23}) (status|reboot)$/.exec(text);
    if (!match || !this.devices.has(match[1])) { reply('Команды: статус; cmd teplica1 status; cmd teplica1 reboot.'); return; }
    const [, id, action] = match; const row = this.devices.get(id);
    if (!this.client.connected || this.pending.has(id) || !row.data || this.age(row) > STALE || row.bad) {
      reply(`${id}: команда не передана — нет связи/свежих данных или ожидается предыдущий ответ.`); return;
    }
    const request = { id: randomBytes(16).toString('hex'), boot: row.data.boot, action,
                      expires_at: Math.floor(this.wall() / 1000) + 15, until: this.mono() + 15000 };
    this.pending.set(id, request);
    const body = JSON.stringify({ id: request.id, boot: request.boot, action, expires_at: request.expires_at });
    try {
      this.client.publish(`ferma/${id}/cmd`, body, { qos: 1, retain: false }, error => {
        if (error && this.pending.get(id) === request) {
          this.pending.delete(id); reply(`${id}: ошибка передачи; выполнение не подтверждено.`);
        }
      });
    } catch { this.pending.delete(id); reply(`${id}: ошибка передачи; выполнение не подтверждено.`); }
  }
  tick() {
    for (const [id, until] of this.seen) if (this.mono() >= until) this.seen.delete(id);
    for (const [id, request] of this.pending) if (this.mono() >= request.until) {
      this.pending.delete(id); this.enqueue(`reply:${request.id}`, `${id}: ответ не получен. Результат команды неизвестен.`);
    }
    for (const [id, row] of this.devices) {
      const stale = this.age(row) > STALE;
      this.rule(id, 'stale', stale && (!!row.data || this.mono() - this.started >= STALE * 1000));
      this.rule(id, 'sensor', row.bad || row.data?.ok === false);
      if (!stale && !row.bad && row.data?.ok) {
        const { temp, hum } = row.data;
        this.rule(id, 'cold', this.active(id, 'cold') ? temp < 6 : temp < 5);
        this.rule(id, 'hot', this.active(id, 'hot') ? temp > 34 : temp > 35);
        this.rule(id, 'wet', this.active(id, 'wet') ? hum > 85 : hum > 90);
      }
    }
    void this.flush();
  }
}

async function main() {
  const admin = Number(process.env.ADMIN_ID);
  const devices = JSON.parse(process.env.DEVICES ?? '["teplica1"]');
  if (!process.env.VK_TOKEN || !process.env.MQTT_PASSWORD) throw Error('Missing secrets');
  const vk = new VK({ token: process.env.VK_TOKEN, apiTimeout: 10000, apiRetryLimit: 0 });
  const client = mqtt.connect('mqtt://127.0.0.1:1883', {
    username: 'hub', password: process.env.MQTT_PASSWORD, clientId: 'farm-hub',
    clean: true, reconnectPeriod: 5000, connectTimeout: 5000, queueQoSZero: false
  });
  const hub = new FarmHub({ admin, devices, client, send: params => vk.api.messages.send(params) });
  client.on('connect', () => client.subscribe(devices.flatMap(id => [`ferma/${id}/data`, `ferma/${id}/ack`]),
    { qos: 1 }, (error, grants) => {
      if (error || grants?.some(g => g.qos === 128)) { console.error('Subscription failed'); client.end(true); process.exit(1); }
    }));
  client.on('message', (topic, bytes, packet) => hub.message(topic, bytes, packet));
  client.on('error', () => console.error('MQTT error'));
  vk.updates.on('message_new', context => hub.command(context));
  setInterval(() => hub.tick(), 1000);
  await vk.updates.start();
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
  main().catch(() => { console.error('Hub startup failed'); process.exit(1); });
node --env-file=.env farm-hub.mjs

Первый запуск оставьте в терминале, чтобы видеть сообщения обмена. Когда разберётесь с ним, настройте службу запуска и контроль её состояния: мониторинг должен продолжаться после закрытия терминала.

Пишем «статус» и читаем ответ

Отправьте статус. Бот покажет настроенные устройства, возраст показаний и идентификатор запуска; отметит и те, от которых ещё ничего не пришло. Затем попробуйте cmd teplica1 status и сравните время. Для cmd teplica1 reboot проверьте новые данные и изменившийся boot. Если за 15 секунд ответа нет, результат остаётся неизвестным — сначала выясните состояние платы.

Для первого опыта заданы холод ниже 5 °C, жара выше 35 °C и влажность выше 90%. Возврат происходит при 6 °C, 34 °C и 85% соответственно. Понаблюдайте за показанием возле границы: этот зазор удерживает тревогу от частых переключений. После знакомства выберите рабочие пороги для своей культуры.

Теперь выключите датчик. Предел свежести в примере — 15 минут; столько же сервер ждёт ещё не появившийся узел после запуска. Ошибка измерения и отсутствие данных создают разные тревоги. Старое retain-сообщение брокера отбрасывается, время и поля проверяются, а после приёма возраст отсчитывается монотонными часами сервера.

Интервал повторной тревоги отсчитывается от успешного ответа VK API, а не от попытки отправки. При ошибке программа повторяет обращение с тем же random_id. Уведомления отправляются последовательно; очередь ограничена 256 записями, срок одной записи — 10 минут. Неактуальная тревога перед отправкой отбрасывается. Успешный ответ API не означает, что человек прочитал сообщение.

Проверяем поведение в знакомых сбоях

Возьмите одну плату без нагрузок и по очереди проверьте чтение, отключение датчика, Wi-Fi, брокера и отправки в VK. Обратитесь с другого аккаунта и из общей беседы: команды должны остаться без исполнения. Верните связь, посмотрите на свежесть и новый идентификатор после перезапуска.

Сохраните короткую памятку: какая команда что возвращает и как выглядит каждый проверенный сбой. Она пригодится, когда второй датчик окажется в другой теплице и вы будете искать причину по сообщениям.

Следующий шаг — сохранять состояние на диске. Сейчас перезапуск очищает ожидающие ответы, историю тревог, очередь и защиту от повторных событий VK. Для постоянной работы добавьте устойчивое хранение, наблюдение за сервером и резервное оповещение. Команды насосу потребуют отдельного проекта с местными блокировками и обратной связью.

Для расширения проекта пригодятся документация MQTT.js, vk-io и официальная схема методов VK. Когда первый узел отвечает предсказуемо, добавьте второй и сравните их в общем статусе. Так постепенно получится единое место для наблюдений за хозяйством.

💬 Комментарии

Чтобы оставить комментарий, войдите или зарегистрируйтесь

Загрузка комментариев...

Похожие статьи