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

Теплица пишет вам: собираем уведомления через VK, почту и push

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

Отправим первое сообщение с ESP32, затем попробуем серверную доставку через несколько каналов. Разберёмся, что происходит, когда сеть пропадает или адресат меняет подписку.

Теплица пишет вам: собираем уведомления через VK, почту и push

Датчик заметил жару в теплице. Где вам удобнее увидеть сообщение: во VK, в почте или прямо на экране телефона? Попробуем четыре канала — VK, почту, Web Push и ntfy. Начнём с тестовой новости, а затем подключим событие от своего датчика.

Это лабораторные примеры для развития метеостанции и шлюза. Можно выбрать прямую отправку с ESP32 или отдельный сервер на Node.js: настройка каждой ветки приведена ниже. Защиту оборудования оставляйте на местном контроллере, чтобы остановка неисправной помпы работала и без интернета.

Первое сообщение: смотрим на оба конца связи

Отправьте тест и посмотрите одновременно на журнал программы и телефон. API или SMTP сообщает, что сервис принял сообщение; появление у вас на экране проверяется отдельно. При обрыве ответ сервиса иногда теряется уже после приёма — повтор может дать дубликат. Даже Message-ID письма оставляет необходимость учитывать такой случай.

Выбираем и готовим канал

Для VK включите сообщения своего сообщества, создайте ключ с правом работы с сообщениями и разрешите сообществу писать вашему пользователю. Укажите числовой peer_id адресата. Для беседы он равен 2000000000 + chat_id из API; это не произвольный номер из адресной строки. Правила параметров приведены в официальной схеме VK. Токен передаётся в теле POST, random_id сохраняется при повторе.

Для почты используйте разрешённый SMTP-сервер и пароль приложения. У Яндекс Почты здесь используется smtp.yandex.ru:465 с TLS. Для другого провайдера проверьте его документацию. MAIL_FROM должен быть разрешённым отправителем; адреса example.com в примерах замените своими.

ESP32 отправляет первую новость

Полный скетч ниже собран с Arduino-ESP32 3.3.12, ArduinoJson 7.4.3 и ReadyMail 0.4.2. Устаревшая ESP-Mail-Client больше не поддерживается. У ReadyMail отдельные условия коммерческого использования — проверьте лицензию перед применением в хозяйстве. Серверная почтовая ветка ниже её не использует.

Заполните настройки, включите нужные элементы ENABLED и отправьте букву t в монитор порта на 115200 бод. Так можно проверить доставку ещё до подключения датчика. Затем вызывайте enqueueAlert(text) из своей программы при нужном событии. Проверяйте ответ: false означает, что очередь событие не приняла.

notify.ino

#include <Arduino.h>
#include <WiFi.h>
#include <NetworkClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <time.h>
#define ENABLE_SMTP
#define READYMAIL_TIME_SOURCE time(nullptr)
#include <ReadyMail.h>

const char *SSID="CHANGE_ME", *WIFI_PASSWORD="CHANGE_ME";
const char *VK_TOKEN="CHANGE_ME", *VK_PEER_ID="CHANGE_ME";
const char *NTFY_URL="https://notify.example.com/", *NTFY_TOPIC="farm-alerts", *NTFY_TOKEN="CHANGE_ME";
const char *SMTP_HOST="smtp.yandex.ru", *SMTP_USER="CHANGE_ME", *SMTP_PASSWORD="CHANGE_ME";
const char *MAIL_FROM="sender@example.com", *MAIL_TO="recipient@example.com";
const bool ENABLED[3]={false,false,false}; // VK, SMTP, ntfy. Включить после настройки.
struct Event { String text,id; int32_t randomId=0; uint32_t born=0; bool accepted[3]={}; bool active=false; } event;
uint32_t triedAt=0; bool tried=false; uint8_t channel=0;

bool enqueueAlert(const String& text) {
  if(event.active || text.length()==0 || text.length()>512 || !(ENABLED[0]||ENABLED[1]||ENABLED[2])) return false;
  event=Event{}; event.text=text; event.born=millis(); event.active=true;
  char id[33]; snprintf(id,sizeof(id),"%08lx%08lx%08lx%08lx",(unsigned long)esp_random(),(unsigned long)esp_random(),(unsigned long)esp_random(),(unsigned long)esp_random());
  event.id=id; event.randomId=1+(esp_random()%2147483646U); tried=false; channel=0;
  return true;
}
String form(const String& value) {
  String out; const char *hex="0123456789ABCDEF";
  for(size_t i=0;i<value.length();++i) { uint8_t c=value[i];
    if((c>='a'&&c<='z')||(c>='A'&&c<='Z')||(c>='0'&&c<='9')||c=='-'||c=='_'||c=='.'||c=='~') out+=(char)c;
    else {out+='%';out+=hex[c>>4];out+=hex[c&15];}
  } return out;
}
class Limited : public Stream {
public:
  String data;
  size_t write(uint8_t b) override { if(data.length()>=4096) return 0; data+=(char)b; return 1; }
  size_t write(const uint8_t *b,size_t n) override { size_t i=0;for(;i<n&&write(b[i]);++i){}return i; }
  int available() override{return 0;} int read() override{return -1;} int peek() override{return -1;}
};
bool sendHttp(uint8_t which) {
  if(which==2 && !String(NTFY_URL).startsWith("https://"))return false;
  NetworkClientSecure client; client.useBuiltinCACertBundle(); client.setHandshakeTimeout(5);
  HTTPClient http; http.setConnectTimeout(3000); http.setTimeout(5000);
  String body,url=which==0?"https://api.vk.com/method/messages.send":NTFY_URL;
  if(!http.begin(client,url)) return false;
  if(which==0) {
    body="access_token="+form(VK_TOKEN)+"&v=5.199&peer_id="+form(VK_PEER_ID)+"&random_id="+String(event.randomId)+"&message="+form(event.text);
    http.addHeader("Content-Type","application/x-www-form-urlencoded");
  } else {
    JsonDocument d; d["topic"]=NTFY_TOPIC;d["title"]="Событие на ферме";d["message"]=event.text;
    serializeJson(d,body);http.addHeader("Content-Type","application/json");
    http.addHeader("Authorization",String("Bearer ")+NTFY_TOKEN);
  }
  int status=http.POST(body);Limited response;int written=status==200?http.writeToStream(&response):-1;http.end();
  JsonDocument d;if(written<0 || deserializeJson(d,response.data))return false;
  if(which==0)return d["error"].isNull()&&d["response"].is<int>()&&d["response"].as<int>()>0;
  return d["event"]=="message" && d["topic"]==NTFY_TOPIC && d["id"].is<const char*>() && d["id"].as<String>().length()>0;
}
bool waitSmtp(SMTPClient& smtp) {
  uint32_t start=millis();
  while(smtp.isProcessing() && uint32_t(millis()-start)<20000) {smtp.loop();delay(1);}
  return !smtp.isProcessing() && smtp.status().errorCode==0;
}
bool sendMail() {
  String from=MAIL_FROM;int at=from.indexOf('@');if(at<1)return false;
  NetworkClientSecure client; client.useBuiltinCACertBundle();client.setHandshakeTimeout(5);client.setTimeout(5000);
  SMTPClient smtp(client);
  bool ok=smtp.connect(SMTP_HOST,465,nullptr,true,false)&&waitSmtp(smtp)&&smtp.isConnected();
  if(ok)ok=smtp.authenticate(SMTP_USER,SMTP_PASSWORD,readymail_auth_password,false)&&waitSmtp(smtp)&&smtp.isAuthenticated();
  if(ok) {
    SMTPMessage msg;msg.headers.add(rfc822_from,MAIL_FROM);msg.headers.add(rfc822_to,MAIL_TO);
    msg.headers.add(rfc822_subject,"Событие на ферме");
    msg.headers.addCustom("Message-ID","<farm-"+event.id+"@"+from.substring(at+1)+">");
    msg.text.body(event.text);msg.timestamp=time(nullptr);
    ok=smtp.send(msg,"",false)&&waitSmtp(smtp)&&smtp.status().isComplete;
  }
  smtp.stop();client.stop();return ok;
}
void setup() {
  Serial.begin(115200);WiFi.mode(WIFI_STA);WiFi.setAutoReconnect(true);WiFi.begin(SSID,WIFI_PASSWORD);
  configTime(0,0,"pool.ntp.org","time.cloudflare.com");
  Serial.println("Send t for a test event. No sensors or relays are controlled.");
}
void loop() {
  if(Serial.available() && Serial.read()=='t')Serial.println(enqueueAlert("Тест канала уведомлений")?"Queued":"Queue busy");
  if(!event.active)return;
  if(uint32_t(millis()-event.born)>=1800000){event.active=false;Serial.println("Expired; not all channels confirmed");return;}
  bool all=true;for(int i=0;i<3;++i)if(ENABLED[i]&&!event.accepted[i])all=false;
  if(all){event.active=false;Serial.println("Selected channels accepted; reading is not confirmed");return;}
  if(WiFi.status()!=WL_CONNECTED || time(nullptr)<1735689600 || (tried&&uint32_t(millis()-triedAt)<30000))return;
  for(int n=0;n<3;++n){uint8_t i=channel;channel=(channel+1)%3;if(!ENABLED[i]||event.accepted[i])continue;
    tried=true;triedAt=millis();event.accepted[i]=i==1?sendMail():sendHttp(i);
    Serial.printf("channel=%u accepted=%d\n",i,event.accepted[i]);break;
  }
  delay(1);
}

Для начала в очереди хранится одно событие. Успешные каналы отмечаются, остальные пробуются с паузой 30 секунд; через 30 минут событие устаревает. Эти интервалы подберите под задачу. При отсутствии Wi-Fi или времени отправка ждёт, при перезапуске очередь очищается. Проверка сертификата включена. Сетевые вызовы могут задержать цикл — учитывайте это отдельно при соединении с управлением помпой.

Переносим отправку на сервер

Используйте поддерживаемую ветку Node.js 22 или 24 и отдельный каталог с доступом только владельцу. Создайте перечисленные ниже файлы; они работают вместе. Для повторяемой установки сохраните package-lock.json.

npm init -y
npm install --save-exact nodemailer@10.0.10 web-push@3.6.7 vk-io@4.10.1

notify.mjs

import { createHash } from 'node:crypto';

// Один проход отправки; очередь, расписание и хранение состояния находятся у вызывающей программы.
export function validEvent(e, now = Date.now()) {
  if (!e || !/^[a-f0-9]{32}$/.test(e.id) || !Number.isSafeInteger(e.randomId) ||
      e.randomId < 1 || e.randomId > 2147483647 || typeof e.text !== 'string' ||
      !e.text.trim() || Buffer.byteLength(e.text, 'utf8') > 2000 ||
      !Number.isSafeInteger(e.createdAt) || !Number.isSafeInteger(e.expiresAt) ||
      e.createdAt > now + 5000 || e.expiresAt <= now || e.expiresAt <= e.createdAt ||
      e.expiresAt - e.createdAt > 3600000) throw Error('Invalid or expired event');
  return e;
}

export async function requestJSON(url, options, fetcher = fetch, timeout = 10000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeout);
  try {
    const r = await fetcher(url, { ...options, redirect: 'error', signal: controller.signal });
    if (!r.ok || !r.body) throw Error('HTTP response rejected');
    const chunks = []; let bytes = 0;
    for await (const chunk of r.body) {
      bytes += chunk.length;
      if (bytes > 16384) { controller.abort(); throw Error('Response too large'); }
      chunks.push(chunk);
    }
    return JSON.parse(Buffer.concat(chunks).toString('utf8'));
  } finally { clearTimeout(timer); }
}

export function vkSender({ token, peerId }, fetcher = fetch) {
  if (typeof token !== 'string' || !token || !Number.isSafeInteger(peerId) || peerId <= 0)
    throw Error('Invalid VK configuration');
  return async e => {
    validEvent(e);
    const body = new URLSearchParams({ access_token: token, v: '5.199',
      peer_id: String(peerId), random_id: String(e.randomId), message: e.text });
    const data = await requestJSON('https://api.vk.com/method/messages.send', {
      method: 'POST', body, headers: { 'content-type': 'application/x-www-form-urlencoded' }
    }, fetcher);
    if (data.error || !Number.isSafeInteger(data.response) || data.response <= 0)
      throw Error('VK did not accept message');
    return { accepted: true };
  };
}

const address = /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
export function mailSender({ transporter, from, to }) {
  if (typeof from !== 'string' || typeof to !== 'string' || !address.test(from) || !address.test(to))
    throw Error('Invalid mail configuration');
  return async e => {
    validEvent(e);
    const result = await transporter.sendMail({ from, to, subject: 'Событие на ферме',
      text: e.text, messageId: `<farm-${e.id}@${from.split('@')[1]}>`,
      disableFileAccess: true, disableUrlAccess: true });
    const accepted = new Set(result.accepted ?? []);
    if ((result.rejected?.length ?? 0) || !accepted.has(to))
      throw Error('SMTP did not accept every recipient');
    return { accepted: true };
  };
}

export function ntfySender({ baseUrl, topic, token }, fetcher = fetch) {
  const base = new URL(baseUrl);
  if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash ||
      base.pathname !== '/' || !/^[-_A-Za-z0-9]{1,64}$/.test(topic) ||
      typeof token !== 'string' || !token || /[\r\n]/.test(token))
    throw Error('Invalid ntfy configuration');
  return async e => {
    validEvent(e);
    const result = await requestJSON(base.href, { method: 'POST',
      headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
      body: JSON.stringify({ topic, title: 'Событие на ферме', message: e.text, priority: 3 })
    }, fetcher);
    if (result.event !== 'message' || result.topic !== topic ||
        typeof result.id !== 'string' || !result.id) throw Error('ntfy did not accept message');
    return { accepted: true };
  };
}

// Повтор использует то же событие и результаты предыдущего прохода.
// Принятие провайдером не означает доставки на телефон или прочтения.
export async function sendChannels(event, senders, previous = {}, save = async () => {}) {
  validEvent(event);
  const names = Object.keys(senders);
  if (!names.length || names.some(n => !/^[a-z][a-z0-9_-]{0,31}$/.test(n)))
    throw Error('Invalid channels');
  const fingerprint = createHash('sha256').update(JSON.stringify(event)).digest('hex');
  if ((previous.fingerprint && previous.fingerprint !== fingerprint) ||
      (!previous.fingerprint && Object.keys(previous.channels ?? {}).length)) throw Error('Event changed');
  const state = { fingerprint, channels: { ...previous.channels } };
  // Последовательно, чтобы результат каждого канала можно было сохранить сразу.
  for (const name of names) {
    if (state.channels[name] === 'accepted') continue;
    validEvent(event);
    try {
      const result = await senders[name](event);
      state.channels[name] = result?.accepted === true ? 'accepted' : result?.expired === true ? 'expired' : 'failed';
    } catch { state.channels[name] = 'failed'; }
    // Ошибка хранения прерывает проход; она не маскируется сетевой ошибкой.
    await save(structuredClone(state));
  }
  return state;
}

mail-transport.mjs

import nodemailer from 'nodemailer';

// Параметры берутся из локальной конфигурации владельца, не из входящего HTTP-запроса.
export function makeMailTransport({ host, port = 465, user, password, ca }) {
  if (typeof host !== 'string' || !/^[a-z0-9.-]+$/i.test(host) ||
      !Number.isSafeInteger(port) || port < 1 || port > 65535 ||
      typeof user !== 'string' || !user || typeof password !== 'string' || !password)
    throw Error('Invalid SMTP configuration');
  return nodemailer.createTransport({ host, port, secure: true,
    auth: { user, pass: password },
    tls: { rejectUnauthorized: true, ...(ca ? { ca } : {}) },
    connectionTimeout: 10000, greetingTimeout: 10000, socketTimeout: 10000,
    logger: false, debug: false, disableFileAccess: true, disableUrlAccess: true });
}

Почтовый пример рассчитан на одного получателя. Для нескольких создайте отдельный mailSender и отдельное имя канала на каждый адрес: иначе повтор после частичного отказа может снова отправить письмо уже принявшему его адресату. Тайм-ауты SMTP относятся к отдельным этапам и простоям, а не обещают доставку за десять секунд.

Добавляем уведомление браузера

Web Push использует push-службы браузеров и ОС. Нужны HTTPS, service worker и разрешение пользователя. Для iOS/iPadOS начиная с 16.4 это веб-приложение на домашнем экране; запрос разрешения связан с нажатием пользователя. Закрытая вкладка, остановленный браузер и ограничения батареи — разные условия, которые нужно проверить на своём устройстве.

push.mjs

import webpush from 'web-push';

export function checkSubscription(s) {
  if (!s || typeof s.endpoint !== 'string' || s.endpoint.length > 2048) throw Error('Invalid subscription');
  const u = new URL(s.endpoint);
  const allowed = ['fcm.googleapis.com', 'updates.push.services.mozilla.com', 'web.push.apple.com'];
  if (u.protocol !== 'https:' || !allowed.includes(u.hostname) || u.port || u.username || u.password || u.hash)
    throw Error('Push host is not allowed');
  for (const [key, bytes] of [['p256dh', 65], ['auth', 16]]) {
    const value = s.keys?.[key];
    if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+={0,2}$/.test(value) ||
        Buffer.from(value, 'base64url').length !== bytes) throw Error('Invalid push key');
  }
  return { endpoint: u.href, keys: { p256dh: s.keys.p256dh, auth: s.keys.auth } };
}
export function pushSender(subscription, vapidDetails, remove, fetcher = fetch) {
  const sub = checkSubscription(subscription);
  return async event => {
    const ttl = Math.max(0, Math.floor((event.expiresAt - Date.now()) / 1000));
    if (!ttl) throw Error('Expired event');
    const request = webpush.generateRequestDetails(sub, JSON.stringify({ text: event.text, id: event.id }),
      { vapidDetails, TTL: ttl, urgency: 'normal', topic: event.id });
    const controller = new AbortController(), timer = setTimeout(() => controller.abort(), 10000);
    try {
      const r = await fetcher(request.endpoint, { method: request.method, headers: request.headers,
        body: request.body, signal: controller.signal, redirect: 'error' });
      await r.body?.cancel();
      if ([404, 410].includes(r.status)) { await remove(); return { accepted: false, expired: true }; }
      if (r.status !== 201) throw Error('Push service did not accept message');
      return { accepted: true };
    } finally { clearTimeout(timer); }
  };
}

Список разрешённых серверов ограничен тремя именами. Если браузер выдаёт другой endpoint, проверьте его по документации провайдера и явно добавьте нужное имя. Не разрешайте произвольные адреса. VAPID_PRIVATE_KEY хранится только на сервере; публичный ключ понадобится странице подписки.

Сохраняем подписки и результаты отправки

Сервис слушает только 127.0.0.1:8081; наружу его публикует ваш HTTPS-прокси. OWNER_TOKEN — ключ владельца из 64 случайных шестнадцатеричных символов. Не вставляйте его в публичный HTML. Запускайте только один экземпляр. Файл notify-state.json содержит подписки и результаты; резервируйте его и сохраняйте закрытый доступ.

server.mjs

import http from 'node:http';
import { readFile, open, rename, mkdir, rmdir } from 'node:fs/promises';
import { createHash, timingSafeEqual } from 'node:crypto';
import { pathToFileURL } from 'node:url';
import { vkSender, mailSender, ntfySender, sendChannels, validEvent } from './notify.mjs';
import { makeMailTransport } from './mail-transport.mjs';
import { checkSubscription, pushSender } from './push.mjs';

export async function fileStore(path) {
  let data;
  try { data = JSON.parse(await readFile(path, 'utf8')); }
  catch (e) { if (e.code !== 'ENOENT') throw e; data = { subscriptions: {}, events: {} }; }
  if (!data.subscriptions || !data.events || Array.isArray(data.subscriptions) || Array.isArray(data.events))
    throw Error('Invalid state');
  return { data, async save() {
    const f = await open(path + '.tmp', 'w', 0o600);
    try { await f.writeFile(JSON.stringify(data)); await f.sync(); } finally { await f.close(); }
    await rename(path + '.tmp', path);
  } };
}
export function createApp({ token, origin, publicKey, store, senders, vapid, fetcher = fetch }) {
  if (!/^[a-f0-9]{64}$/.test(token) || new URL(origin).protocol !== 'https:') throw Error('Invalid server config');
  let pending = 0, chain = Promise.resolve(), storageFailed = false;
  const save = async () => { try { await store.save(); } catch (e) { storageFailed = true; throw e; } };
  const respond = (res, status, value) => { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8',
    'cache-control': 'no-store' }); res.end(JSON.stringify(value)); };
  const server = http.createServer(async (req, res) => {
    if (req.method !== 'POST' || !['/api/subscribe', '/api/notify'].includes(req.url)) return respond(res, 404, { error: 'Not found' });
    const supplied = Buffer.from(req.headers.authorization ?? ''); const expected = Buffer.from('Bearer ' + token);
    if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected) ||
        (req.headers.origin && req.headers.origin !== origin)) return respond(res, 403, { error: 'Forbidden' });
    if (pending >= 8) return respond(res, 429, { error: 'Busy' });
    ++pending;
    try {
      const chunks = []; let bytes = 0;
      for await (const chunk of req) { bytes += chunk.length; if (bytes > 16384) throw Error('Request too large'); chunks.push(chunk); }
      const input = JSON.parse(Buffer.concat(chunks).toString('utf8'));
      const job = chain.then(async () => {
        if (storageFailed) throw Error('Storage failed; restart only after repair');
        if (req.url === '/api/subscribe') {
          const sub = checkSubscription(input);
          const key = createHash('sha256').update(sub.endpoint).digest('hex').slice(0, 24);
          if (!store.data.subscriptions[key] && Object.keys(store.data.subscriptions).length >= 32) throw Error('Too many subscriptions');
          store.data.subscriptions[key] = sub; await save();
          return { subscribed: true, publicKey };
        }
        validEvent(input);
        for (const [id, row] of Object.entries(store.data.events))
          if (row.expiresAt <= Date.now()) delete store.data.events[id];
        if (!store.data.events[input.id] && Object.keys(store.data.events).length >= 128) throw Error('Too many events');
        const active = { ...senders };
        for (const [id, sub] of Object.entries(store.data.subscriptions))
          active['push_' + id] = pushSender(sub, vapid, async () => { delete store.data.subscriptions[id]; await save(); }, fetcher);
        const result = await sendChannels(input, active, store.data.events[input.id], async state => {
          store.data.events[input.id] = { ...state, expiresAt: input.expiresAt }; await save();
        });
        return { channels: result.channels };
      });
      chain = job.catch(() => {});
      respond(res, 200, await job);
    } catch { respond(res, 400, { error: 'Request failed; check configuration and retry the same event' }); }
    finally { --pending; }
  });
  server.requestTimeout = 15000; server.headersTimeout = 10000;
  return server;
}
async function main() {
  const env = process.env, lock = './notify-state.lock';
  await mkdir(lock); // Только один экземпляр, обслуживающий этот файл состояния.
  try {
    const store = await fileStore('./notify-state.json');
    const senders = {};
    if (env.VK_TOKEN) senders.vk = vkSender({ token: env.VK_TOKEN, peerId: Number(env.VK_PEER_ID) });
    if (env.SMTP_PASSWORD) senders.mail = mailSender({ from: env.SMTP_FROM, to: env.MAIL_TO,
      transporter: makeMailTransport({ host: env.SMTP_HOST, user: env.SMTP_USER, password: env.SMTP_PASSWORD }) });
    if (env.NTFY_TOKEN) senders.ntfy = ntfySender({ baseUrl: env.NTFY_URL, topic: env.NTFY_TOPIC, token: env.NTFY_TOKEN });
    const vapid = { subject: env.VAPID_SUBJECT, publicKey: env.VAPID_PUBLIC_KEY, privateKey: env.VAPID_PRIVATE_KEY };
    const server = createApp({ token: env.OWNER_TOKEN, origin: env.PUBLIC_ORIGIN, publicKey: vapid.publicKey, store, senders, vapid });
    server.listen(8081, '127.0.0.1');
    for (const sig of ['SIGINT', 'SIGTERM']) process.once(sig, () => server.close(async () => { await rmdir(lock); process.exit(); }));
    server.on('error', async () => { await rmdir(lock); process.exit(1); });
  } catch (e) { await rmdir(lock); throw e; }
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
  main().catch(() => { console.error('Notification server failed'); process.exitCode = 1; });

В файле .env задайте настройки своей установки. Не настроенный VK, SMTP или ntfy исключается отсутствием соответствующего секрета. Для push создайте пару ключей; сохраните приватную часть в .env, публичную также вставьте в client.js. Поля CHANGE_ME — заполнители, не рабочие ключи.

node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
node -e "console.log(require('web-push').generateVAPIDKeys())"
OWNER_TOKEN=CHANGE_ME
PUBLIC_ORIGIN=https://farm.example.com
VK_TOKEN=CHANGE_ME
VK_PEER_ID=CHANGE_ME
SMTP_HOST=smtp.yandex.ru
SMTP_USER=CHANGE_ME
SMTP_PASSWORD=CHANGE_ME
SMTP_FROM=sender@example.com
MAIL_TO=recipient@example.com
NTFY_URL=https://notify.example.com/
NTFY_TOPIC=farm-alerts
NTFY_TOKEN=CHANGE_ME
VAPID_SUBJECT=mailto:owner@example.com
VAPID_PUBLIC_KEY=CHANGE_ME
VAPID_PRIVATE_KEY=CHANGE_ME
VK_ADMIN_ID=CHANGE_ME
chmod 600 .env
node --env-file=.env server.mjs

У HTTPS-прокси направьте /api/ на этот локальный порт, а статические файлы следующего раздела разместите в корне сайта. Это обязательная часть установки: сам Node-сервис не выдаёт HTML и не получает сертификат. При аварийном завершении остаётся каталог notify-state.lock; удаляйте его только убедившись, что прежний экземпляр остановлен.

Подписываем свой телефон

Разместите четыре файла на том же HTTPS-домене. Введите ключ владельца на своей странице и нажмите кнопку. При смене VAPID-ключей удалите старую подписку в настройках сайта и подпишитесь заново.

index.html

<!doctype html><html lang="ru"><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="manifest" href="/manifest.json"><title>Уведомления фермы</title>
<h1>Уведомления фермы</h1>
<p>Введите ключ владельца только на своём HTTPS-сайте.</p>
<label>Ключ <input id="token" type="password" autocomplete="off"></label>
<button id="subscribe">Подписать это устройство</button><p id="status" role="status"></p>
<script type="module" src="/client.js"></script></html>

client.js

const publicKey = 'CHANGE_TO_PUBLIC_VAPID_KEY'; // Только публичная часть ключа.
const status = document.querySelector('#status'), button = document.querySelector('#subscribe');
const supported = 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
button.disabled = !supported;
if (!supported) status.textContent = 'Этот браузер не поддерживает нужные функции.';
const ready = supported ? navigator.serviceWorker.register('/sw.js').then(() => navigator.serviceWorker.ready) : null;
ready?.catch(() => { status.textContent = 'Не удалось зарегистрировать service worker.'; button.disabled = true; });
function decode(s) { return Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - s.length % 4) % 4)), c => c.charCodeAt(0)); }
button.onclick = async () => {
  button.disabled = true;
  try {
    const token = document.querySelector('#token').value;
    if (!/^[a-f0-9]{64}$/.test(token)) throw Error('key');
    if (await Notification.requestPermission() !== 'granted') throw Error('permission');
    const reg = await ready;
    const sub = await reg.pushManager.getSubscription() ?? await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: decode(publicKey) });
    const r = await fetch('/api/subscribe', { method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer ' + token }, body: JSON.stringify(sub), signal: AbortSignal.timeout(15000) });
    if (!r.ok || (await r.json()).subscribed !== true) throw Error('server');
    document.querySelector('#token').value = '';
    status.textContent = 'Подписка сохранена. Проверьте тестовое уведомление на этом устройстве.';
  } catch { status.textContent = 'Подписка не подтверждена. Проверьте ключ, разрешение и соединение.'; }
  finally { button.disabled = false; }
};

sw.js

self.addEventListener('push', event => {
  let data;
  try { data = event.data?.json(); } catch { data = null; }
  const text = typeof data?.text === 'string' ? data.text.slice(0, 2000) : 'Откройте панель и проверьте состояние.';
  event.waitUntil(self.registration.showNotification('Событие на ферме', { body: text,
    tag: /^[a-f0-9]{32}$/.test(data?.id) ? data.id : 'farm-event' }));
});
self.addEventListener('notificationclick', event => {
  event.notification.close(); event.waitUntil(clients.openWindow('/'));
});

manifest.json

{"name":"Уведомления фермы","short_name":"Ферма","start_url":"/","display":"standalone"}

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

Сначала инструмент создаёт файл события, затем отправляет его. Для опыта отключите один канал и повторите send с тем же файлом: принятые каналы будут пропущены, а результат появится отдельно для каждого. Здесь повтор запускаете вы; автоматическую фоновую отправку можно добавить следующим шагом.

event.mjs

import { readFile, writeFile } from 'node:fs/promises';
import { randomBytes, randomInt } from 'node:crypto';
import { requestJSON, validEvent } from './notify.mjs';
const [action, path, ...words] = process.argv.slice(2);
try {
  if (!path) throw Error('Path required');
  if (action === 'new') {
    const e = { id: randomBytes(16).toString('hex'), randomId: randomInt(1,2147483647),
      text: words.join(' '), createdAt: Date.now(), expiresAt: Date.now()+600000 };
    validEvent(e); await writeFile(path, JSON.stringify(e), { flag: 'wx', mode: 0o600 });
    console.log('Event saved; nothing sent');
  } else if (action === 'send') {
    const e = validEvent(JSON.parse(await readFile(path, 'utf8'))), u = new URL(process.env.PUBLIC_ORIGIN);
    if (u.protocol !== 'https:' || u.username || u.password || u.pathname !== '/' || u.search || u.hash) throw Error('Invalid origin');
    const result = await requestJSON(new URL('/api/notify', u), { method: 'POST',
      headers: { authorization: 'Bearer '+process.env.OWNER_TOKEN, 'content-type': 'application/json' }, body: JSON.stringify(e) }, fetch, 600000);
    if (!result.channels || !Object.keys(result.channels).length) throw Error('No result');
    console.log(JSON.stringify(result.channels));
    if (Object.values(result.channels).some(x => x !== 'accepted')) process.exitCode=2;
  } else throw Error('Use new or send');
} catch { console.error('Event operation failed; no delivery claim'); process.exitCode=1; }
node event.mjs new test-event.json "Тест уведомлений"
node --env-file=.env event.mjs send test-event.json

Тестовое событие из команды живёт десять минут. Если получили expired для push-подписки, подпишитесь заново: прежняя удаляется. Сервис хранит до 128 действующих событий и 32 подписок. При ошибке записи отправка останавливается до исправления хранилища и перезапуска. Учтите возможный дубликат при сбое между приёмом сообщения сервисом и сохранением результата.

Спрашиваем у бота: что сейчас в теплице?

Это Bots Long Poll, а не Callback API: публичный входящий адрес боту не нужен, исходящее соединение с VK необходимо. В сообществе включите Long Poll и событие message_new. VK_ADMIN_ID — числовой ID владельца; принимаются только его свежие сообщения в личном диалоге.

bot.mjs

import { readFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import { createHash } from 'node:crypto';
import { VK } from 'vk-io';
import { vkSender } from './notify.mjs';

export function botHandler({ admin, send, readStatus, now = Date.now }) {
  if (!Number.isSafeInteger(admin) || admin <= 0) throw Error('Invalid administrator');
  const seen = new Map();
  return async context => {
    const age = now() - context.createdAt * 1000, id = context.conversationMessageId;
    if (context.isOutbox || context.senderId !== admin || context.peerId !== admin ||
        !Number.isSafeInteger(id) || id <= 0 || id > 2147483647 || !Number.isFinite(age) || age < -5000 || age > 30000) return;
    for (const [key, until] of seen) if (until <= now()) seen.delete(key);
    if (seen.has(id) || seen.size >= 100) return;
    seen.set(id, now() + 60000);
    try {
      const command = String(context.text ?? '').trim().toLowerCase();
      let text = 'Команды: /status. Управление поливом в этом примере не подключено.';
      if (command === '/status') {
        try {
          const d = await readStatus(), elapsed = now() - d.sampledAt;
          if (!Number.isSafeInteger(d.sampledAt) || elapsed < -5000 || elapsed > 900000 ||
              !Number.isFinite(d.tempC) || d.tempC < -40 || d.tempC > 80 ||
              !Number.isFinite(d.rh) || d.rh < 0 || d.rh > 100) throw Error('Invalid data');
          text = `Температура ${d.tempC} °C; влажность воздуха ${d.rh} %. Возраст данных ${Math.max(0, Math.floor(elapsed / 1000))} с.`;
        } catch { text = 'Нет проверенных свежих данных. Проверьте сборщик и датчик.'; }
      }
      if (command === '/water_on' || command === '/water_off')
        text = 'Команда не выполнена: исполнитель полива не подключён. Проверьте систему локально.';
      await send({ id: createHash('sha256').update(`vk:${admin}:${id}`).digest('hex').slice(0, 32),
        randomId: id, text, createdAt: now(), expiresAt: now() + 60000 });
    } catch { seen.delete(id); throw Error('Bot reply failed'); }
  };
}
async function main() {
  const admin = Number(process.env.VK_ADMIN_ID), token = process.env.VK_TOKEN;
  const handler = botHandler({ admin, send: vkSender({ token, peerId: admin }),
    readStatus: async () => {
      const raw = await readFile('./status.json', 'utf8');
      if (Buffer.byteLength(raw) > 4096) throw Error('Status too large');
      return JSON.parse(raw);
    } });
  const vk = new VK({ token, apiTimeout: 10000, apiRetryLimit: 0 });
  vk.updates.on('message_new', context => handler(context).catch(() => console.error('Bot reply failed')));
  await vk.updates.startPolling();
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
  main().catch(() => { console.error('Bot startup failed'); process.exitCode = 1; });

Запустите node --env-file=.env bot.mjs и отправьте /status. Бот читает status.json: sampledAt содержит время измерения в миллисекундах UTC, tempC — температуру, rh — относительную влажность. Настройте сборщик на атомарное обновление этого файла. Затем попробуйте оставить старую запись: после заданных в примере 15 минут бот должен показать проблему свежести данных.

Попробуйте также /water_on и /water_off: пока они возвращают сообщение об отсутствующем исполнителе. Для физического управления нужны отдельный контроллер, разрешения, срок команды и обратная связь. Пример обмена запросами есть в статье о MQTT и VK; с него можно начать изучение пути команды к контроллеру.

ntfy на своём сервере

У ntfy имя темы содержит только латинские буквы, цифры, дефис и подчёркивание, максимум 64 символа. Публичная тема не становится приватной из-за необычного имени. Для своей установки следуйте документации сервера: auth-file, auth-default-access: deny-all, отдельный пользователь отправителя с write-only на farm-alerts и отдельный читатель с read-only. Создайте их токены через ntfy token; отправитель использует NTFY_TOKEN.

Разместите сервер за HTTPS, подключите приложение к своему адресу и проверьте чтение без авторизации: оно должно быть запрещено. Самостоятельный хостинг не устраняет все внешние зависимости: мгновенные уведомления iOS требуют описанного в документации upstream через APNs/Firebase. Без интернета локальная публикация и доставка на удалённый телефон — разные задачи.

Подключаем первое событие от датчика

КаналЧто требуетсяЧто проверить
VKСообщество, разрешённый адресат, токенAPI, права и приложение получателя
ПочтаSMTP и учётные данныеОтказы, спам-фильтр, почтовый клиент
Web PushHTTPS, подписка, VAPIDБраузер, ОС, срок подписки
ntfyСервер, права на тему, приложениеДоступ и фоновые ограничения телефона

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

Завершите опыт сообщением из своего хозяйства и проверкой на своём телефоне. Запишите, какой канал удобнее в обычной работе и что происходит при сбоях. Для продолжения доступны графики Grafana и OTA-обновления.

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

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

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

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