Raspberry Pi, zigbee2mqtt, Mosquitto, PHP-движок правил и MariaDB. Что здесь сделано хорошо, где болит и стоит ли переделывать конвейер так, чтобы движок слушал брокер напрямую, а не ходил в базу.
Это не «умный дом на Home Assistant», а собственная реализация: человек написал её сам, от датчика до веб-панели. Масштаб уже не игрушечный — полтора десятка Zigbee-устройств, около 140 «живых» топиков, семь постоянно работающих служб, несколько скриптов по расписанию, своя база истории и панель управления с редактором правил.
| Слой | Реализация |
|---|---|
| Железо | Raspberry Pi 5 (NVMe, ~117 ГБ), Zigbee-координатор в USB |
| Радио → MQTT | zigbee2mqtt (systemd) |
| Брокер | mosquitto: оба порта слушают только 127.0.0.1 — 1883 с паролем (учётки mazzick, iot), 1884 анонимно для внутренних скриптов малины. Наружу не торчит |
| Запись данных | Python-логгер mqtt_to_mysql.py (systemd mqtt-to-mysql): последнее значение на топик + история |
| База | MariaDB, БД iot_db: sensor_data (последнее значение, UNIQUE по топику), sensor_history, sensor_info, automations, automation_log |
| Движок правил | PHP-демон mqtt_listener.php (systemd iot-automation), правит таблицу automations, редактор — admin/logic.php |
| Панель | PHP + nginx, доступ закрыт по IP (localhost, VPN-подсеть, рабочее место) |
| Аномалии | Отдельная служба сбора статистики (медиана/MAD, «пропажа данных», «просадка»), ИИ-модуль выключен сознательно |
| Автоматика по расписанию | Кормушка, push-уведомления, письма, проверка батареек — cron-скрипты |
То есть у каждого сообщения от датчика путь такой: брокер → Python-логгер → таблица «последнее значение» → (через секунду) PHP-движок прочитал → сравнил с порогом → отправил команду в брокер. Латентность — до секунды, потому что движок опрашивает базу циклом, а не получает данные событием.
Дополнительно рядом живёт вторая, независимая автоматика: cron-скрипты PHP.
Ниже — не «список страшного», а конкретные вещи, которые я бы менял, с указанием, почему это вообще проблема.
| № | Что не так | Почему это больно |
|---|---|---|
| 1 | Состояние живёт в трёх местах. Retained-значение в брокере, строка в sensor_data, и «последнее виденное» в памяти движка. | Они расходятся. Отсюда вопросы «панель показывает одно, а реле щёлкнуло по-другому». |
| 2 | Движок опрашивает базу циклом раз в секунду. | Реакция до 1 с вместо миллисекунд; лишние сотни SELECT в секунду; и в теории «гонка» — значение обновилось между двумя чтениями. |
| 3 | Запись в базу на каждое сообщение. Даже те, которые никому не нужны. | Нагрузка на флешку/диск и на MariaDB пропорциональна болтливости датчиков, а не полезности данных. История отдельно, а «последнее значение» — по сути горячий кэш, которому база не нужна. |
| 4 | Пороги в правилах — без гистерезиса. Отдельные правила «включить ниже 23» и «выключить выше 25» борются друг с другом. | Классика: реле щёлкает, потому что состояние не является «машиной состояний», а вычисляется заново из мгновенного значения. (мой косяк, не указал условия состояния реле, хотя возможность есть) |
| 5 | Состояние управления теряется при перезапуске службы. Отложенные действия («выключить через 60 с») и ожидание подтверждения живут в памяти процесса. | Перезапуск движка — и обещанное действие просто не произойдёт. Никто об этом не узнает. |
| 6 | Две параллельные системы автоматизации: движок правил и cron. | Разбор инцидента начинается с расследования «кто это сделал», а не с ответа «почему». (тут все просто, логика движка проверка раз в сек, а кормушка включается на 3 секунды. просто в кроне надежнее) |
| 7 | Хранение секретов исторически было в коде (пароли БД и MQTT прямо в PHP), плюс креды администратора БД использовались там, где нужны MQTT-креды. | Это уже исправлено (секреты вынесены в /etc/iot/, учётка приложения урезана до SELECT/INSERT/UPDATE/DELETE), но остаётся риск возврата: любой новый скрипт легко напишет пароль «по старинке». |
| 8 | Правила — это данные в таблице, с неиспользуемыми колонками. Часть полей движок просто не читает. | Пользователь видит настройки, которые ничего не делают. Это хуже, чем их отсутствие. |
| 9 | Датчики, пишущие «по событию», выглядят для системы как исчезнувшие. | Нужен явный признак «нет данных N часов» — иначе 90 % заряда показывается как свежее значение месячной давности. |
| 10 | История растёт без ретенции. Таблица чистится «до 10 000 строк на топик» при каждой вставке. | Работает, но это тяжёлый способ: удаление по одной вставке, без партиционирования и без явной политики «месяц/год». |
Вопрос поставлен верно, и ответ короткий: движок должен слушать брокер напрямую, а база должна перестать быть «транспортом» между брокером и движком. Но уточню, потому что здесь легко перепутать три разные роли базы:
Проблема текущей схемы в том, что первая роль тоже отдана базе. Уберём это — и всё встанет на место. (вот это и был главный вопрос гермес-агенту, надо настроиться и переделать все)
| Сейчас | Правильно | |
|---|---|---|
| Как движок узнаёт о событии | раз в секунду SELECT из sensor_data | подписка zigbee2mqtt/#, событие приходит само |
| Где хранится «последнее значение» | строка в таблице | в памяти движка (+ retained в брокере как восстановимый снимок) |
| Латентность реакции | до 1 секунды (+ время записи в базу) | единицы миллисекунд |
| Нагрузка на базу | INSERT на каждое сообщение + SELECT-цикл каждую секунду | пакетная запись: только нужные топики и только когда изменилось; «последние значения» сбрасываются в базу раз в N минут |
| Что если база тормозит/лежит | движок слепнет вместе с базой | движок продолжает работать, история догоняет потом |
| Реакция на «молчание» устройства | нужно заметить, что строка в базе старая | естественно: просто нет событий, а движок ведёт таймер последнего касания |
OPTIMIZE TABLE, ручная правка — всё это сегодня на минуту ослепляет систему.Надёжность не в «через базу» или «через брокер», а в том, что происходит при перезапуске и при потере связи. Прямая подписка добавляет три вопроса, и у каждого есть ответ:
| Вопрос | Ответ |
|---|---|
| Упал движок — память пуста, состояние потеряно | Restart=always в systemd + запись состояния на диск после каждого изменения (маленький JSON) + retained-сообщения в брокере как источник «последнего известного». При старте движок публикует .../get и перечитывает актуальные значения — через 2 секунды он снова знает всё. |
| Упал брокер | Движок переподключается (клиентская библиотека это умеет), события копятся в Zigbee-мосте. Здесь важнее другое: правила должны уметь «мягко деградировать» — если данных нет дольше N минут, включается безопасное состояние (кран закрыт, нагреватель в покое), а не «оставить как было». |
| Данные устарели (датчик молчит), а движок этого не видит | Явный guard: у каждого значения в памяти — время последнего обновления; правило срабатывает только на свежих данных, иначе это отдельное событие «нет связи», которое стоит показывать и в панели, и в уведомлениях. |
Ещё один аргумент, который часто упускают: у текущей схемы проблема с перезапуском уже есть (пункт 5 диагноза) — отложенные действия и подтверждения теряются при рестарте. То есть «через базу» не даёт надёжности, которую якобы теряет прямая подписка. Переход на события позволяет эту дыру закрыть системно, а не затыкать страховочными правилами.
Движок правил → одна долгоживущая служба, которая подписана на zigbee2mqtt/# и держит состояние в памяти. База → только история и настройки, запись пакетами. Правило «включить/выключить» вычисляется на событии, а не по таймеру. Это и быстрее, и, если сделать аккуратно, надёжнее, чем сейчас.
Миграцию при этом можно сделать без «большого взрыва»: сначала поднять рядом новый движок, который только слушает и пишет в отдельный лог (что бы он сделал на каждом событии), неделю сравнить его решения с текущим, а потом переключить исполнение. Так делают и в больших системах.
import asyncio, json, time, aiomqtt
STALE = 900 # данных нет 15 минут — считаем датчик пропавшим
class Engine:
def __init__(self, client):
self.c = client
self.vals = {} # топик -> (значение, время)
self.modes = {} # состояние автоматов: 'fish_heater' -> 'idle'/'heating'
async def run(self):
await self.c.subscribe("zigbee2mqtt/#")
async for msg in self.c.messages:
t = str(msg.topic)
if t.endswith("/set"): # эхо собственных команд не считаем данными
continue
try: data = json.loads(msg.payload)
except ValueError: continue
now = time.time()
for k, v in (data.items() if isinstance(data, dict) else [(t, data)]):
self.vals[f"{t}/{k}"] = (v, now)
await self.on_value(f"{t}/{k}", v)
async def on_value(self, topic, value):
# пример: термостат как машина состояний, а не два правила с порогами
if topic == "zigbee2mqtt/fish_reley_temp/temperature":
t = float(value)
mode = self.modes.get("fish_heater", "idle")
if mode == "idle" and t < 23.0:
await self.act("fish_power/set", {"state_l3": "ON"})
self.modes["fish_heater"] = "heating"
elif mode == "heating" and t > 25.0:
await self.act("fish_power/set", {"state_l3": "OFF"})
self.modes["fish_heater"] = "idle"
def fresh(self, topic):
v = self.vals.get(topic)
return v is not None and time.time() - v[1] < STALE
async def act(self, action_topic, payload):
await self.c.publish(f"zigbee2mqtt/{action_topic}", json.dumps(payload), retain=False)
Обратите внимание на три вещи: команды публикуются только на смене состояния (никакого «долбления» каждую секунду), у каждого значения есть время, и «молчание датчика» становится обычным проверяемым условием. Плюс тот же процесс может писать историю пачками — тогда отдельный логгер вообще не нужен.
gorshok_smallone. Имя Zigbee-устройства меняется — правило ломается. Отображение «роль → топик» должно лежать в одном справочнике.Это самая частая логическая ошибка в самодельных домах, и в разбираемом проекте она была: правило «включить нагрев при температуре ниже 23» и правило «выключить при выше 25» — на первый взгляд правильная пара. Проблема в том, что реле физически одно, а решений о нём принимают два независимых потребителя, которые ничего не знают друг о друге. Стоит добавить третий (например, cron с усреднением за 15 минут) — и начинается «реле щёлкает не по правилам, а по моменту запуска скрипта».
Правильно — один автомат с состоянием:
состояние: idle | heating
idle + температура < 23.0 (устойчиво 30 с) → ON → heating
heating + температура > 25.0 → OFF → idle
heating + данных нет > 15 минут → OFF → idle (fail-safe)
любое + ручная команда из панели → принудительное состояние + тайм-аут
Отсюда сразу видны вещи, которых в паре правил не выразить: выдержка (устойчиво 30 секунд), поведение при пропаже данных, приоритет ручного управления. Всё это — состояние, а не условие, и хранить его надо вместе с решением, а не выводить заново из мгновенного значения.
Исторически это была самая слабая часть, и её уже вылечили: секреты вынесены из кода в отдельные файлы с правами 640 root:www-data и 640 root:mazzick, у приложения урезаны права в БД, крон-скрипты перестали ходить в MQTT под учёткой администратора базы. Это стоит зафиксировать как правило и не разъехаться обратно.
Что добавить:
# /etc/mosquitto/acl
user iot_engine # движок: читает всё, командует устройствами
topic read zigbee2mqtt/#
topic write zigbee2mqtt/+/set
user iot_panel # панель: только чтение + ручные команды
topic read zigbee2mqtt/#
topic write zigbee2mqtt/+/set
user iot_logger # логгер: только чтение
topic read zigbee2mqtt/#
pattern write zigbee2mqtt/bridge/request/# # служебные запросы — кому разрешено
/etc/iot/credentials.*, никаких литералов. В healthcheck — отдельная проверка, которая ищет в файлах реальные пароли (и её надо проверять «приманкой», иначе оn врёт).Сейчас есть два скрипта, собирающие архив: дамп всех БД, конфиги zigbee2mqtt, mosquitto, nginx, systemd, cron. Это правильный набор. Чего не хватает:
coordinator_backup.json из zigbee2mqtt. Без него вся сеть спаривается заново.automations — это тоже код. Хочется видеть её в git в виде файла (TSV/JSON), с диффом при каждом изменении из панели. Полчаса работы — и вопросы «кто поменял порог» закрываются навсегда.Здесь в проекте больше, чем в 90 % самоделок: внешняя проверка здоровья, ежечасный сторож с письмом и защитой от повторов, отдельная служба статистики аномалий. Что я бы добавил именно как «правильное»:
| Метрика | Зачем | Порог тревоги (пример) |
|---|---|---|
| Задержка «событие → команда» | Главная метрика качества автоматики | > 2 с — разобраться |
| Доля команд без подтверждения | Молчащие устройства и битый Zigbee | > 5 % за час |
| Длительность непрерывного включения реле | Залипание, заливание грядки, перегрев | > 10 мин для крана, > 60 мин для нагрева |
| Возраст последнего значения по устройству | Различать «молчит по делу» и «сломалось» | > суток для событийных датчиков — показать как «нет связи» |
| Число срабатываний правила за сутки | Дребезг и логические петли | > 100 — почти всегда ошибка правила |
И отдельный момент, который обычно упускают: молчание системы не должно быть единственным доказательством здоровья. Сторож, который пишет письмо только при проблеме, хорош, но раз в неделю нужен «отчёт о жизни»: сколько устройств на связи, сколько срабатываний, среднее время реакции, размер базы. Иначе поломка уведомлений выглядит как «всё хорошо».
Честно: бо́льшую часть этого дома можно не писать самому. Но у самописного есть реальная ценность — понимание каждого узла и полный контроль. Поэтому варианты, а не один «правильный ответ».
zigbee2mqtt + Mosquitto + Home Assistant (или Node-RED) + MariaDB/InfluxDB для истории. Всё, что сегодня делают вручную PHP-движок, панель и часть cron, уже есть: редактор автоматизаций, история, уведомления, мобильное приложение, интеграции. Самописное остаётся ровно там, где оно даёт пользу — специфические сценарии (аквариум, полив) и свои отчёты. Развёртывание в Docker Compose, бэкап — том плюс дамп БД.
Плюсы: поддержка, экосистема, мобильное приложение, меньше кода — меньше своих багов. Минусы: тяжелее по ресурсам, надо освоить чужую модель данных, часть привычных вещей придётся переносить.
Тот же стек, что есть, с заменой «база как шина» на «брокер как шина»: один Python-движок на событиях (asyncio + paho/aiomqtt), правила в YAML с версионированием, FastAPI для панели вместо PHP, SQLite или MariaDB только для истории, Docker Compose, git, pytest для логики правил, симулятор MQTT для тестов.
Плюсы: полный контроль, лёгкость на Raspberry Pi, тестируемость, отсутствие зависимости от больших фреймворков. Минусы: всё это надо поддерживать самому, и мобильное приложение придётся делать самому (или обойтись Telegram-ботом, что для дома обычно достаточно).
Ядро — готовое (Home Assistant / Node-RED), а критичные контуры — свои, с тестами: термостат аквариума, защита от заливания, отчёты. Zigbee остаётся в zigbee2mqtt (он и так лучший в своём деле), брокер один, история одна. Так снимается 80 % рутины и остаётся контроль над тем, что действительно важно.
| Приоритет | Что сделать | Эффект | Трудозатраты |
|---|---|---|---|
| P0 | Аппаратный fail-safe на нагрев и воду: термостат/предохранительный клапан, независимо от софта. Плюс правило «нет данных 15 минут → безопасное состояние». | Гарантия от порчи имущества при любом сбое софта | 1–2 часа + деталь |
| P0 | Гистерезис/машина состояний вместо парных порогов; убрать конкуренцию cron и движка за одно устройство (оставить один владелец). | Дребезг реле, «кто управлял» и споры автоматики исчезают | 2–4 часа |
| P0 | Тест восстановления из бэкапа + ротация архивов + сохранение coordinator_backup.json. |
Дом поднимается после смерти диска | 2 часа |
| P1 | Движок на события: подписка на zigbee2mqtt/#, состояние в памяти, запись истории пачками. Сначала в режиме «только запись в лог решений» — сравнить с текущим движком. |
Реакция миллисекунды, база разгружена, падение БД не останавливает автоматику | 1–2 вечера |
| P1 | Подтверждение команд и учёт «не подтвердилось → тревога»; вынос отложенных действий в файл/таблицу, чтобы переживали перезапуск. | Система знает, когда команда не дошла, вместо тишины | вечер |
| P1 | ACL в брокере и три отдельные учётки (движок/панель/логгер). Закрыть анонимный порт снаружи. | Утечка одной учётки не даёт контроль над домом | 1 час |
| P2 | Правила в файле + в git (снимок automations при каждом изменении); роли устройств вместо имён в правилах; вычистить неиспользуемые колонки формы. |
Понятная история изменений, правила не ломаются от переименования | вечер + час |
| P2 | Ретенция истории (партиционирование/агрегаты), метрики (задержка реакции, число срабатываний, возраст данных) и недельный «отчёт о жизни». | База не растёт бесконтрольно, проблемы видно до аварии | вечер |
| P2 | Убрать балласт: мёртвые скрипты-черновики и вторая (самодельная) копия движка правил — в карантин, а не «пусть лежит». | Меньше шансов запустить не то и разбирать не тот файл | 30 минут |
Через пару часов после публикации статьи появился доступ к малине, и я прогнал проверку по факту. Ничего не менял — только смотрел. Ниже цифры, которыми подтверждаются (или уточняются) тезисы выше.
| Проверка | Результат |
|---|---|
| Службы | Все семь active, NRestarts=0 у движка, логгера и модуля аномалий — ни одна не падала и не поднималась сама |
| Ошибки в журналах за неделю | 0 строк с error/fatal/traceback |
| Данные | 32 сообщения за 5 минут, 97 «живых» топиков, ключевые устройства обновляются прямо сейчас |
| Журнал срабатываний | 276 строк за сутки, следов удалённого триггера (rule_id IS NULL) — 0 |
| Сторож | Ежечасный, состояние last_problem.txt = одна проблема; письмо ушло один раз в 03:05 и дальше не дублируется — защита от спама работает |
| Брокер MQTT | Оба порта слушают только 127.0.0.1: 1883 с паролем, 1884 анонимно (для внутренних скриптов). Наружу не открыт. ACL-файла нет — доступ разграничен только паролем |
| Диск и журналы | Занято 27 %, журнал systemd 77,6 МБ — место не проблема |
| История батареек | 12 402 точки за сутки — тренд строится |
leak_bath — 50 % и 2400 мВ, это ровно порог «вышла из строя» для CR2032. Батарейку надо менять. Сторож при этом ведёт себя правильно: с 03:05 сообщает о ней раз в час в свой лог, но письмо отправил один раз и повторно не шлёт, пока состояние не изменится.ssss. По аквариуму: нагрев получил 2 команды за сутки (одна — 30.09 в 06:04, вторая — 29.09 вечером), повторов от механизма подтверждений — 0 за все 1198 записей журнала. Движок отправляет публикацию только в момент смены состояния: условие проверяется каждую секунду, но вторая публикация при неизменном значении не идёт — её держат три замка: смена значения триггера, cooldown_seconds (65 с у света, 3600 с у нагрева) и проверка «устройство уже в нужном состоянии» (такие записи в журнале помечены skipped=1 — команда не отправлялась вовсе, только строка в журнал).state_l1, state_l2).action_topic (техникой не управляют), но поднят флаг уведомления — то есть это правила-наблюдатели: «PM2.5 > 100» и «ЛОС > 1000» шлют письмо. Формулировка исправлена: это рабочие уведомления, а не мусор.ssss сработало 119 раз подряд, самого правила в таблице уже нет. В базе рядом осталась пустая тестовая таблица zz_test_perm. Мелочь, но такие следы как раз и мешают при разборе «кто управлял техникой» — чистить сразу.Проект сильнее, чем выглядит: разделение на службы, история, журнал срабатываний, внешний сторож, вынесенные секреты — это то, до чего большинство самоделок не доходит даже через год. Реальная слабость не в языке и не в базе, а в архитектурном выборе: база здесь работает шиной событий. Отсюда растёт почти всё остальное — задержка реакции, лишняя нагрузка, споры между cron и правилами, потеря отложенных действий при перезапуске.
Ответ на исходный вопрос: да, движок должен слушать брокер напрямую, а базу оставить для истории и настроек. Это не менее надёжно — при трёх условиях, которые надо написать явно: восстановление состояния при старте, поведение при потере связи (fail-safe) и guard на устаревшие данные. И делать это лучше не «в один день», а поставив новый движок рядом в режиме наблюдения и сравнив его решения с текущими.
И главное, что стоит сказать вслух: у самописного дома есть то, чего не купить — понимание каждой линии своего кода. Поэтому «переписать на Home Assistant» — не единственный правильный путь. Правильный путь — оставить контроль там, где он ценен, и не тащить на себе то, что давно решено другими. (ну это слишком просто. Легче всего HomeAsistent поставить! Но мы легких путей не ищем)