Замена телеграм-бота — открывальщика ворот

от автора

В предыдущей статье я описал как я делал DTMF-MQTT шлюз для открывания ворот. Там я мЕльком упомянул, что делалось это как альтернатива телеграм и в общем свою функцию выполнило. В действительности путь был длинней, тернистей, но зато и завел куда дальше: текущее решение ничем не уступает тому что было в ТГ, а кое в чём и превосходит.

Итак, предыстория.

Однажды решил я прочитал статью на Хабре  «Как я через ioBroker шлагбаумы в поле шатал» и решил повторить. Сказано-сделано, на работе установлен уже знакомый мне иоброкер в виртуалке, сляпано ТГ-инлайн-меню, управление пользователями как объектами, ворота подключены через Ethernet реле, скриптом иоброкера всё стянуто воедино. Красота — пользователи ездят, открывают, админам падают уведомления в чат с видосиками въезда, ведется журнал и так далее.

Когда ТГ начали блокировать не по-детски, и простой установкой проксей пользователем перестало удаваться отделываться, я задумался об альтернативах. И мысль моя пошла двумя путями — первый, с DTMF-MQTT шлюзом описан в предыдущей статье, а второй — попробовать повторить все то же самое, что в телеграм, но без телеграм. И что важно — без хардварных доделок.

Итерация 0 — simpleAPI

Как паллиатив в первые же дни жесткой блокировки я поднял на иоброкере адаптер SimpleAPI, и сделал шаблоны — «команды» для iOS, httpShortcuts для Android. Благо, у компании есть домен с Wildcard-сертификатом, поэтому не пришлось пользоваться средствами IOBroker для https, просто запросил у админов поддомен.

Суть задачи: дёрнуть SimpleAPI одной кнопкой. При этом по возможности исключить случайные нажатия и «застревания» команд — если в момент нажатия у юзера нет интернета, и команда отправилась сильно позже, когда уже не надо.

Решил делать GET запрос с таймстемпом, и в объект иоброкера передавать пользователя и пароль. Конечно такое себе, но надо было быстро и просто.

Структура шаблона была такая:

iOS (Shortcuts / Команды):

  • «Текущая дата» — берёт текущее время

  • «Форматировать дату» — конвертирует в Unix timestamp.

  • «URL» — собирает строку: https://example.ru/set/0\_userdata.0.simple-api.Gates1?value=ТОКЕН:[Форматированная дата]&user=gateapi&pass=ПАРОЛЬ

  • «Получить содержимое URL» — сам GET-запрос по собранному URL

    Добавление на домашний экран через настройки команды → иконка + название («Ворота 1»)

Основной проблемой было то, что формат X (Custom) в какой-то момент дал кривое значение (07 вместо нормального timestamp). ЕМНИП, в финальной версии заменил сразу на дату формата «Unix время» из выпадающего списка.

Android (HTTP Shortcuts):

На андроиде выглядит человечней, я понимаю. Но зато на андроиде не было запуска приложения в фоне, долго тыкался, нашел в приложении возможность отправить команду на рабочий стол в виде ярлыка, который тихо выполнял GET-запрос.

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

Итерация 1 — статический файл

Нулевая итерация решила проблему локально — я смог помочь мимопроходящим пользователям, работающим на одном со мой объекте настроить шорткаты из шаблонов. Но объектов больше одного, не все пользователи каждый день на работе, а вешать на себя задачу «в сжатые сроки отловить всех пользователей, взять у них телефоны, настроить и раздать обратно» я не хотел.

Решение — статическая html-страничка на сервере, дёргающая всё тот же Simple-API. Тогда вся настройка сведется к отправке ссылки и объяснению как её добавить на главный экран.

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

Поэтому пошёл через токены: наклепал каждому токены, записал в датапоинт массивом вида: "simpleApiTokens": ["f47ac10b-58cc-4372-a567-0e02b2c3d479"] и дальше раздавал ссылку вида …/gates.html?token=xxx&user=имя. Страница при первом открытии забирает токен из URL, сохраняет в localStorage и сразу вычищает его из адресной строки — чтобы не светился в истории браузера. Дальше при каждом нажатии кнопки собирается тот же принцип, что и в Shortcuts: значение токен:таймстемп, протухает через 15 секунд.

Первая попытка запустить всё это дело — открыл файл просто локально, file://. Не сработало: оказалось что браузер на всех устройствах намертво блокирует исходящие запросы со страниц, открытых с диска, любым способом, включая fetch и Image(). Разумно с точки зрения безопасности, но получалось, что без сервера тут не обойтись — html-файл придётся положить куда-то, откуда он будет отдаваться по http(s), а не лежать в загрузках.

Захостил на виртуалке с IOBroker, попросил админов настроить nginx, на отдачу статики из папки с файлами visualization-адаптера. Отдельный поддомен не нужен, wildcard-сертификат уже покрывал всё что угодно под родительским доменом. Положили файл — заработало, проверил прямой ссылкой: объект получил ровно то значение, которое ожидал скрипт. Ура, чо.

Подумал про безопасность. Слабых мест три: пароль системного пользователя в открытом виде в исходнике страницы, токен, который можно вытащить из localStorage при физическом доступе к телефону, и то, что скомпрометированный токен не протухает сам, надо руками чистить массив в датапоинте. Первое решил радикально: завёл отдельного пользователя ioBroker gateapi с правами «HTTP-запросы» и «Состояния → писать», без доступа к чтению объектов и админке. Теперь утечка пароля из исходника страницы ничего интереснее ворот не открывает. Остальные два пункта оставил как есть, для управления воротами в конторе это адекватный уровень, не банк всё-таки. В итоге получилась система, которую можно раздать хоть всей компании: одна ссылка на человека, добавил на экран — и кнопка открытия ворот появляется как обычное приложение, без единого объяснения про Shortcuts и таймстемпы, IOBroker однозначно идентифицирует пользователя и пишет логи как обычно, права на открытие ворот проверяются внутри IOBroker по токену пользователя.

Итерация 2 — PWA, Node.js, JWT

Ну хорошо, с паллиативом разобрались, большинство пользователей радуются и наслаждаются не хуже чем с ТГ. Но ведь есть еще админы — которые получали и оповещения, и и риалтайм состояние по запросу, и видосики кто приехал. Тут уже статикой не отделаешься.

Ну ок, допустим я все это могу и на ПК посмотреть, права с ПК раздать-забрать, да и вообще я к ноутбуку почти прикован, как Онегин к постели дяди. Но есть же и другие люди, в том числе не умеющие и не желающие общаться с ПК — и они уже привыкли что у них всё перечисленное есть и им за это ничего нет.

В это время я уже плотно общался с Claude и поэтому смело ввязался туда, где я вообще ничего не понимаю. Ну то есть я могу сформулировать задачу, описать дизайн, провести тестирование. Иногда — вдуматься и таки обсудить (или даже отстоять альтернативное) высокоуровневое решение. Поймать баг на уровне «вот это вот тут вот так не работает». Но реализация конечно не моя. За прошлую статью с таким дисклеймером я отхватил немножко минусов, но честность для меня остается важней.

Тут технические детали проекта, написанные Claude

Архитектура

Backend — Node.js/Express, разворачивается как systemd-сервис на том же CentOS-сервере, где крутится ioBroker. PWA — React + Vite, тоже как отдельный сервис, оба проксируются через nginx. Backend не хранит состояние сам — он тонкий слой между PWA и ioBroker: авторизует запрос, проксирует к simple-api или socket.io, отдаёт файлы клипов.

Авторизация: JWT вместо токенов в URL

Схема стандартная: access token с коротким временем жизни (пара часов) выдаётся при логине, refresh token — подольше, обновляет сессию без повторного ввода пароля. Credentials для доступа к ioBroker (тот самый пользователь gateapi, которого заводили ещё на прошлой итерации) теперь живут только в .env на сервере — фронтенд их никогда не видит, обращается только к своему backend с собственным JWT в заголовке.

Профили пользователей хранятся не в отдельной базе, а прямо в ioBroker — 0_userdata.0.users.{username}, с полями под токены, набором разрешений (gate1, gate2, video, sysMessages, manageUsers, monitor) и флагом активности. Это позволило не заводить отдельную СУБД под пользователей — вся система управления доступом живёт в том же дереве объектов, что и остальная автоматизация.

CORS с credentials

Первая же попытка выставить wildcard origin (*) в CORS-конфиге не сработала — как только запрос идёт с credentials: true (а JWT в куке требует именно этого), браузер требует точный origin, wildcard отклоняется на уровне спецификации Fetch API, а не как баг сервера. Пришлось прописывать конкретный домен PWA явным образом.

Особенности ioBroker JS-адаптера

Три вещи, которые стоили времени:

  • npm, вызываемый изнутри ioBroker-скрипта, обёрнут собственной прослойкой адаптера — обычный npm install из скрипта не отрабатывает, нужен полный путь /usr/bin/npm.

  • В контексте JS-адаптера нет глобального fetch — там, где на любом нормальном Node.js fetch(url) работает из коробки, здесь пришлось откатываться на http.request.

  • Simple-api по-прежнему принимает авторизацию только через query-параметры (?user=&pass=), а не через заголовок Basic Auth — это ограничение тянется ещё с нулевой итерации и осталось неизменным.

Push-уведомления

Web Push через VAPID-ключи. Подписка на push формируется в PWA (через Service Worker), хранится на бэкенде, рассылка триггерится из ioBroker-скрипта gates.js вызовом /api/push/gate, а не напрямую из бэкенда — это выбор архитектуры: единая точка правды по событиям осталась в ioBroker-скрипте, а не размазана между двумя местами. Изначально пуш слался из двух точек (из скрипта ворот и отдельно из обработчика видеоклипов) — это дало дублирующиеся уведомления с разным форматом, один из которых пришлось выпилить.

Видео-подтверждение через FFmpeg

При открытии ворот ioBroker-скрипт триггерит FFmpeg, который вытаскивает короткий клип из RTSP-потока соответствующей камеры и кладёт файл в /opt/iobroker/clips/gate{N}/ с именем вида gate{N}_YYYY_MM_DD_HHMMSS.mp4. Бэкенд отдаёт эти файлы по запросу через отдельный роут — PWA показывает клип рядом с записью в журнале открытий.

Журнал событий: один источник правды

Изначально бэкенд писал в журнал сам при команде из PWA, а скрипт ioBroker — при командах из simple-api/Telegram, что привело к дублирующимся записям, когда открытие шло через PWA (обе стороны видели событие и обе логировали). Решение — убрать логирование из бэкенда полностью и оставить единственным источником журнала сам ioBroker-скрипт: он видит все способы открытия (PWA, simple-api, физическая кнопка) и пишет запись с указанием метода.

Потом был еще ряд доделок типа подключения в PWA мониторинга, отображение доступности ворот и камер, отображение доступности модема и так далее, но все это мелочи.

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

Главный экран

Главный экран
Журнал

Журнал
Админка

Админка

Десерт — скрипт IOBroker

Поскольку первые скрипты я писал еще сам, не вижу греха в выкладывании их, пусть и после причесывания ИИ.

Конфиг и список ворот

Каждые ворота — это конфигурация в массиве, включающая всё, что нужно: ид, объект для открытия, объект-триггер SimpleAPI, триггер камеры и маркер разрешений для пользователя.

javascript

(function () {    const DRY_RUN = false;    const USERS_PATH = '0_userdata.0.users.';    const MAX_AGE_MS = 15_000; // запрос с таймстемпом протухает через 15 сек    const GATES = [        {            id: 'gate1',            label: 'ворота 1',            relay: 'mqtt.0.dingtian.relayXXXXX.in.r1',            simpleApiState: '0_userdata.0.simple-api.Gates1',            cameraTrigger: 'javascript.0.Telegram.Kamera1',            permission: 'gate1',        },        {            id: 'gate2',            label: 'ворота 2',            relay: 'mqtt.0.dingtian.relayYYYYY.in.r1',            simpleApiState: '0_userdata.0.simple-api.Gates2',            cameraTrigger: 'javascript.0.Telegram.Kamera2',            permission: 'gate2',            button: { id: 'mqtt.0.dingtian.relayYYYYY.out.i1', triggerValue: 'ON' },        },    ];    // ...})();

Обработчик SimpleAPI: проверка токена и защита от replay

Сюда стекаются запросы и из PWA, и из старых Shortcuts-шаблонов. Значение объекта приходит в формате токен:таймстемп, скрипт разбирает его и игнорирует всё, что старше 15 секунд:

javascript

for (const gate of GATES) {    on({ id: gate.simpleApiState, change: 'any', ack: false }, (obj) => {        const val = String(obj.state.val || '');        const [token, tsRaw] = val.split(':');        const tsNum = Number(tsRaw);        if (!token || !tsNum) return;        const tsMs = tsNum < 1e12 ? tsNum * 1000 : tsNum;        const ageMs = Date.now() - tsMs;        if (ageMs < 0 || ageMs > MAX_AGE_MS) {            log(`gates: протухший запрос (${gate.id}), возраст ${Math.round(ageMs / 1000)} сек — игнор`);            setTimeout(() => setState(gate.simpleApiState, '', true), 500);            return;        }        const user = findUserByToken(token);        if (!user) {            log(`gates: неизвестный токен на ${gate.id}`);            return;        }        if (!hasPermission(user, gate.permission)) {            log(`gates: нет прав ${gate.permission} — ${user.name}`);            return;        }        openGate(gate, user.name);        requestVideo(gate);        logToBackend(gate, user.name, 'sapi');        setTimeout(() => setState(gate.simpleApiState, '', true), 1000);    });}

Единая точка логирования и пуша

logToBackendединое место, откуда события уходят наружу: и в журнал, и как push-уведомление. Именно этот вызов остался в скрипте после того, как убрали дублирующее логирование из бэкенда:

javascript

function logToBackend(gate, userName, method) {    const http = require('http');    const body = JSON.stringify({        gate: gate.id,        gateName: gate.label,        user: userName,        method: method, // 'sapi' | 'button' | ...    });    const req = http.request({        hostname: 'localhost',        port: 3000,        path: '/api/push/gate',        method: 'POST',        headers: {            'Content-Type': 'application/json',            'Content-Length': Buffer.byteLength(body),            'Authorization': 'Bearer ' + INTERNAL_TOKEN,        },    });    req.write(body);    req.end();}

Важно повторяющим не копипейстом, а сущностно: http.request, а не fetch: в контексте ioBroker JS-адаптера глобального fetch просто нет, это особенность IOBroker, скрасившая мне пару вечеров.

Физическая кнопка / звонок / старые GSM-реле

Обходится тем же массивом GATES

javascript

for (const gate of GATES) {    if (!gate.button) continue;    on({ id: gate.button.id, change: 'ne' }, (obj) => {        if (obj.state.val !== gate.button.triggerValue) return;        log(`gates: входящий звонок — ${gate.label}`);        openGate(gate, 'звонок');        requestVideo(gate);        logToBackend(gate, 'звонок', 'button');    });}

Ну где-то так. Отдельным танцем были камеры — они у меня разные, настройки разные, благо в канале IOBroker подсказали еще когда я на телеге бота делал. В этой итерации только немного поправил:

javascript

const CAMERAS = [  {    id: 'gate1',    name: 'Ворота 1',    triggerState: 'javascript.0.Telegram.Kamera1',    rtsp: 'rtsp://ADMIN:PASSWORD@CAMERA_IP:554/Streaming/Channels/101',  },  {    id: 'gate2',    name: 'Ворота 2',    triggerState: 'javascript.0.Telegram.Kamera2',    rtsp: 'rtsp://ADMIN:PASSWORD@CAMERA_IP:554/Streaming/Channels/201',  },  // добавить камеру — просто скопировать блок и поменять id/triggerState/rtsp];const CLIPS_DIR = '/opt/iobroker/clips';const RECORD_DELAY = 10;   // сек — ждём перед стартом записиconst CLIP_DURATION = 20;  // сек — сколько пишемconst SEND_DELAY = 25;     // сек — должно быть больше RECORD_DELAY + CLIP_DURATION

Задержка перед записью нужна не случайно: если стартовать съёмку сразу по триггеру, в кадр попадает момент открытия ворот, а не момент проезда — машина ещё физически не доехала до камеры. 10 секунд ожидания сдвигают окно записи на реальный въезд.

Сам обработчик и вызов ffmpeg

javascript

function setupCamera(cam) {  on({ id: cam.triggerState, change: 'gt' }, (obj) => {    const ts = formatTimestamp(new Date()); // YYYY_MM_DD_HHMMSS    const clipName = `${cam.id}_${ts}.mp4`;    const clipPath = `${CLIPS_DIR}/${cam.id}/${clipName}`;    console.log(`[${cam.name}] Сработал триггер, запись: ${clipName}`);    setTimeout(() => {      const cmd = `ffmpeg -y -rtsp_transport tcp -i '${cam.rtsp}' ` +        `-t ${CLIP_DURATION} -f mp4 -vcodec libx264 -pix_fmt yuv420p -an ` +        `-vf scale=w=704:h=576:force_original_aspect_ratio=decrease -r 15 ` +        clipPath;      exec(cmd, (error, stdout, stderr) => {        if (error) console.log(`[${cam.name}] ffmpeg ошибка: ${error}`);      });      setTimeout(() => {        sendTo('telegram.0', 'send', { text: clipPath, chatId: CHAT_ID });        notifyBackend(cam.id, clipName);      }, SEND_DELAY * 1000);    }, RECORD_DELAY * 1000);  });}CAMERAS.forEach(setupCamera);

Немножко о граблях:

  • -rtsp_transport tcp не опция, а обязательный параметр. Без него на UDP периодически ловил битые кадры и артефакты в клипах — камеры общей сети это не прощают, TCP-транспорт для RTSP оказался надёжнее.

  • Пустые файлы при живом источнике. Ffmpeg отрабатывал без ошибок, файл создавался, но весил 0 байт. Из терминала руками команда с теми же параметрами писала нормальный клип, а из exec() внутри ioBroker-скрипта — нет. Разгадка была в окружении: скрипт выполняется в контексте адаптера с урезанным PATH/окружением, отличным от интерактивной сессии по SSH.

  • Кодек камеры. Одна из камер отдавала H.265, а команда перекодирует в H.264, это нормально и ожидаемо, но стоит явно понимать, что происходит перекодирование, а не просто копирование потока, отсюда и нагрузка на CPU при большом числе одновременных записей.

Булочка в дорогу

Недавно обновил себе в машине магнитолу и решил что там тоже должна быть кнопка. Столкнулся с тем, что интерфейс магнитолы — урезанный, понятия «рабочий стол» просто нет, есть лаунчер, который не понимает PWA. Решил через PWABuilder.com, который создал мне маленькую компактную apk , идеально вставшую в лаунчер магнитолы.

Заключение

Честно говоря, даже не знаю про что еще рассказать. Может про королеву горизонтального планкинга как решал мониторинг открытия ворот через включение входов ethernet-реле в разрыв датчиков? Или про дальнейшие планы развития системы? Или про подтягивание туда же данных с оборудования на объектах? Пишите, отвечу.

ссылка на оригинал статьи https://habr.com/ru/articles/1062960/