В предыдущей статье я описал как я делал 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):
-
Basic Request, GET
На андроиде выглядит человечней, я понимаю. Но зато на андроиде не было запуска приложения в фоне, долго тыкался, нашел в приложении возможность отправить команду на рабочий стол в виде ярлыка, который тихо выполнял 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.jsfetch(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/