Обновление моего DIY умного дома на зигби — архитектура и логика работы (прожарка гермесом)
26.09.2026 | из категории: IOT умный дом
Хост: 192.168.***.*** (raspberrypi, Debian 12 bookworm, Pi 5, 8 ГБ RAM, NVMe 117 ГБ) ·
Документ составлен 26.09.2026 по факту чтения кода и живого стенда.
Коротко о том, что здесь есть. Пять независимых кусков, работающих на одной машине:
zigbee2mqtt — общается с Zigbee-устройствами (датчики движения, протечки, реле, кран, свет, качество воздуха, влажность почвы) и публикует их состояние в MQTT.
Mosquitto — брокер MQTT. Через него всё общается, шина данных.
mqtt_to_mysql.py — слушает MQTT и складывает текущие значения в MariaDB (таблица sensor_data), историю — в sensor_history.
mqtt_listener.php — «мозг»: раз в секунду читает последние значения из БД, проверяет правила из таблицы automations и отправляет команды обратно в MQTT.
PHP-панель на nginx — веб-интерфейс: графики, список датчиков, редактор правил.
Важная деталь: «мозг» не слушает MQTT. Он читает состояние из базы раз в секунду. Скрипт на Python — единственный, кто слушает брокер, и он пишет в ту же базу. Отсюда все странности с задержками и дублями, о которых ниже.
1. Из чего состоит и как связано
Zigbee-устройствадатчики, реле, кран, свет
→
zigbee2mqtt:8080 (node) Wi-Fi/Zigbee-мост
→
Mosquitto:1883 +1884 local брокер, топики zigbee2mqtt/#
mqtt_to_mysql.pyслушает MQTT (paho)
→
MariaDB iot_db:3306 sensor_data, sensor_history
←
mqtt_listener.phpчитает БД раз в 1 сек
mqtt_listener.php решение о действии
→
mosquitto_pub → брокер .../set
→
устройство включается→ состояние возвращается в MQTT → в БД
Пользователь браузер /admin/
→
nginx + php8.2-fpm:80 панель, API, графики
→
MariaDB чтение/запись правил и настроек
2. Как идёт обработка одного события (сквозной путь)
Датчик движения сработал → zigbee2mqtt публикует zigbee2mqtt/motion_sensor/presence = {"presence":1,"linkquality":120}.
mqtt_to_mysql.py получает сообщение и раскладывает его по «плоским» топикам: .../presence = 1, плюс весь JSON целиком в топик zigbee2mqtt/motion_sensor. Служебные поля (linkquality, countdown_l*, last_seen) отбрасываются. Каждое значение пишется в sensor_data через INSERT ... ON DUPLICATE KEY UPDATE — в таблице хранится только последнее значение по каждому топику.
Если для топика в sensor_info стоит save_history=1 и значение приводится к числу — дополнительно пишется строка в sensor_history и тут же удаляются старые (оставляем 10 000 последних по топику). Для bool true/false история, кстати, не пишется — они сохраняются как «1»/«0» (число), этот случай работает.
mqtt_listener.php раз в секунду одним запросом вытягивает последнее значение каждого топика из sensor_data в массив $sensorData.
По каждому правилу из automations (перечитываются раз в 60 сек) проверяется условие: trigger_topic + оператор + condition_value. Значение {"state":"ON"} разворачивается до ON.
Если условие выполнилось и состояние изменилось (или истёк cooldown) — правило срабатывает: пишется строка в automation_log, затем выполняется действие.
Действие = вызов mosquitto_pub с топиком и payload из правила (например zigbee2mqtt/fish_usb_reley/set = {"state_l3":"ON"}). Перед отправкой проверяется shouldSkipAction() — если устройство уже в нужном состоянии, команда не отправляется.
Через MQTT состояние возвращается в базу, и на следующем круге движок видит результат. Если у правила задан confirmation_topic, движок ставит правило в «ожидание подтверждения» и ищет в sensor_data совпадение ожидаемого состояния.
cron_fish_heater.php каждые 20 мин, cron_fish_feeder.php в 10:00, cron_push.php и cron_automation_emails.php каждую минуту, export_cron.sh в 10:00
—
root
cron (mazzick)
rpi_monitor.sh каждые 5 мин, log_analyzer.sh каждый час, check_battery.py в 09:00, OPTIMIZE TABLE по воскресеньям
—
mazzick
Двойной путь управления. Часть автоматики живёт в БД и обрабатывается PHP-движком, часть — отдельными cron-скриптами (cron_fish_heater.php, cron_fish_feeder.php, cron_push.php), которые пишут в email_triggers/push_notifications. Это две разные логики про одни и те же устройства — при разборе полётов надо помнить, что событие могло прийти не из движка, а из cron.
4. Файлы и структура (состояние после чистки 26.09.2026)
/var/www/html/iot/
├── public_html/ ← корень сайта (nginx root)
│ ├── index.php ← главная панель (плитка датчиков, PWA)
│ ├── index.html ← статичная старая версия
│ ├── manifest.json, sw.js ← PWA-манифест и service worker
│ ├── graf.php ← графики истории
│ ├── datchiki.php, datchiki2.php, datchiki_min.php ← страницы/фрагменты датчиков
│ ├── all_charts.php, all_charts2.php, all_charts_2.php, chart_min.php,
│ │ chart_only.php, charts_by_button.php, charts_only.php ← варианты графиков
│ ├── crontab_all.txt / crontab_mazzick.txt / crontab_root.txt ← выгрузка кронов (для показа в панели)
│ ├── admin/
│ │ ├── sensors.php ← ВХОД В ПАНЕЛЬ (логин/пароль) + редактор списка датчиков
│ │ ├── logic.php ← редактор правил автоматизации (то, что лежит в automations)
│ │ ├── automation_log.php ← просмотр журнала срабатываний
│ │ ├── mqtt_listener.php ← ДВИЖОК правил (запускается systemd, не через браузер)
│ │ ├── mqtt_to_mysql.py ← ЛОГГЕР MQTT→БД (запускается systemd; перенесён сюда 26.09.2026)
│ │ ├── mqtt_listener_logs.php, log_viewer.php, cron_logs.php, read_log.php, logs_proxy.php
│ │ │ ← разные просмотрщики логов (дублируют друг друга)
│ │ ├── service_status.php ← статус служб для панели
│ │ ├── send_mqtt.php, set_channel.php, clear_log.php ← мелкие утилиты
│ │ ├── cottage_panel.php ← демо-панель «коттедж» на выдуманных данных
│ │ └── logout.php ← выход
│ ├── api/
│ │ ├── live_data.php ← JSON последних значений датчиков (для главной)
│ │ ├── live_system_data.php ← JSON состояния самой малины (темп/память/диск)
│ │ ├── history.php ← JSON истории для графиков
│ │ ├── control.php ← команда устройству: ?topic=...&state=ON (без авторизации!)
│ │ └── clear_history.php ← очистка истории
│ ├── include/links_panel.php ← общее меню ссылок панели
│ ├── css/ ← style.css, index*.css, logic_admin.css
│ ├── icons/ ← иконки PWA (ВСЕ файлы по 0 байт — битые)
│ ├── db/sensors.db ← SQLite-остаток от старой версии, не используется
│ └── mqtt/ ← php-mqtt (composer) — НЕ используется ни одной страницей
└── cron_*.php ← скрипты, которые запускает cron (см. таблицу служб)
cron_automation_emails.php, cron_fish_feeder.php, cron_fish_filter.php,
cron_fish_heater.php, cron_fish_light.php, cron_motion_hourly.php,
cron_polivayka.php, cron_push.php, cron_soil_moisture.php, generate_sensor_data.py
Что перенесено в карантин (не удалено): номерные версии index2..index40, тестовые test1..test2222.php, все *.phparc / *.phpgood / *.bak / .swp, дубли-копии движка и логгера в домашней папке, старые SQL-дампы, каталоги-копии сайта. Всё лежит в /home/mazzick/cleanup_20260926/, полный список — manifest.txt (25 179 файлов, 1,5 ГБ).
5. Логика движка mqtt_listener.php
Один бесконечный цикл while(true) с паузой sleep(1). Что происходит на каждой итерации:
раз в 60 сек → reloadRules(): SELECT * FROM automations WHERE enabled=1
раз в 1 сек → SELECT последних значений по каждому topic из sensor_data → $sensorData
раз в 1 сек → проверка РАСПИСАНИЯ (schedule_type = daily/weekly):
если текущее время >= schedule_time И правило сегодня ещё не срабатывало
→ evaluateRuleCondition() → срабатывание
каждую итерацию → проверка ТРИГГЕРНЫХ правил (schedule_type = none):
trigger_topic + оператор + condition_value
→ cooldown проверка → срабатывание при СМЕНЕ значения
всегда → checkPendingConfirmations(): ищем подтверждение действий
всегда → отложенные действия (delay_seconds): выполняем, когда время пришло
Механики, которые реально работают
Механика
Как работает
Где настраивается в панели
Условие
trigger_topic + оператор (> < == != >= <=) + condition_value; если значение — JSON с полем state, берётся оно
logic.php → правило → «Условие»
condition_duration
условие должно держаться N секунд, иначе таймер сбрасывается
logic.php → «Длительность условия»
cooldown_seconds
после действия правило молчит N секунд
logic.php → «Пауза»
Смена состояния
срабатывает только при изменении значения (защита от повторов), lastTriggerState
автоматически
shouldSkipAction
если устройство уже в нужном состоянии — команда не отправляется
автоматически
delay_seconds
действие через N секунд (в памяти процесса, не в БД)
logic.php → «Задержка»
confirmation_topic
ждём, что устройство подтвердит состояние; при подтверждении правило удаляется (если не стоит «Повторять»)
logic.php → «Подтверждение»
persistent
«Повторять»: правило не удаляется после срабатывания
logic.php → галочка
dependency_topic/value
доп. условие-разрешение (например «окно закрыто»)
logic.php → «Зависимость»
schedule_type
none / daily / weekly + время + дни недели
logic.php → «Расписание»
Мёртвый код в движке. В таблице есть колонки, которые движок не использует вовсе: hysteresis (гистерезис), max_retries/retry_delay/failure_count (повторы), time_restriction_start/end (окно времени), in_progress, push_notify/push_topic/push_priority. Также есть режим condition_type='script' — исполнение PHP-выражения из БД (файл /tmp/eval_*.php + include): ни одно правило его не использует, а фильтрация там слабая. Рекомендую выключить эту возможность (см. раздел 8).
Примеры правил в базе данных
ID
Правило
Условие / расписание
Действие
Cooldown
Вкл
139
выключение_света_через_60с
motion_sensor/presence == 0
fish_usb_reley/set {"state_l3":"OFF"}
65 с
да
141
тест_движ
motion_sensor/presence == 1
— (нет действия)
30 с
выкл
142
протечка_светВКЛ
leak_bath/water_leak == 1
fish_light/set {"state":"ON"}
30 с
да
151
светАква2_ВКЛ
daily 17:00
fish_power/set {"state_l2":"ON"}
30 с
да
152
светАква2_ВЫКЛ
daily 20:52
fish_power/set {"state_l2":"OFF"}
30 с
да
157
Движение_кор
motion_sensor/presence == 1
fish_usb_reley/set {"state_l3":"ON"}
60 с
да
158
AirBox PM2.5 > 20
air_box/pm25 > 100
— (нет действия!)
65 с
да
159
летуч_органика_ЛОС
smart_air_sensor/voc > 1000
— (нет действия!)
65 с
да
163
Защита от заливания (кран)
условия нет вообще
PushOK_kran/set {"state":"OFF"}
360 с
да
164
Анти-залипание крана
условия нет
PushOK_kran/set {"state":"OFF"}
360 с
выкл
165
Защита от перелива горшок
PushOK_kran/state == ON
PushOK_kran/set {"state":"OFF"}
300 с
да
168
земляника подсветка выкл 20-00
daily 20:00
fish_light/set {"state":"OFF"}
360 с
да
169
Земляника подсветка вкл 8:00
daily 09:00 (имя и время разошлись)
fish_light/set {"state":"ON"}
360 с
да
170
свет АКВА глав ВКЛ
daily 09:00
fish_power/set {"state_l1":"ON"}
360 с
да
171
свет АКВА глав ВыКЛ
daily 20:35
fish_power/set {"state_l1":"OFF"}
360 с
да
173
полив земляника по влажности почвы
daily 19:58
PushOK_kran/set {"state":"ON"}
3600 с
выкл
174
полив по влажности
gorshok_smallone/soil_moisture < 40
PushOK_kran/set {"state":"ON"}
3600 с
да
На что смотреть: 158 и 159 включены, но ничего не делают (действия нет) — это либо заготовки под уведомления, либо забытые правила; 163 без условия — срабатывает при любом изменении топика «кран» (работает как «страховка от заливания», но логика случайна); 174 открывает кран по сухости почвы, а закрывает его только 165 при ON — цепочка держится на порядке срабатывания правил.
6. База данных iot_db
Таблица
Что хранит
Размер / строки
sensor_data
последнее значение по каждому топику (137 топиков). Ключ уникальности — topic, поэтому строка одна на топик
85 строк
sensor_history
история чисел для графиков; пишется логгером, чистится до 10 000 последних на топик
≈101 тыс. строк, 11 МБ
sensor_info
справочник топиков: название, комната, группа, иконка, save_history (что писать в историю)
25 строк
automations
правила автоматизации — то, что редактируется в logic.php
17 строк
automation_log
журнал срабатываний: правило, топик, payload, skipped, confirmed, expected/actual state
16 271 строка (после чистки)
automation_email_sent, email_triggers
учёт отправленных писем (чтобы не спамить), пишут cron-скрипты
56 / 855
rpi_monitor
телеметрия самой малины (темп, нагрузка, память, диск) — cron каждые 5 мин
≈73 тыс. строк, 9,5 МБ
log_stats
счётчики ключевых слов из journald — cron каждый час
пустые таблицы — остатки нереализованных функций (push, RS-485, старая схема датчиков)
0 строк
7. Что исправлено 26.09.2026 (с проверкой)
#
Проблема
Что сделано
Как проверено
1
Логи дублировались (раньше троились)
В MariaDB жил триггер after_automation_update на таблице automations: при каждом обновлении last_triggered он вставлял ещё одну строку в automation_log. Движок пишет в лог сам и затем обновляет last_triggered → каждое событие превращалось в 2-3 строки. Триггер удалён (определение сохранено в /home/mazzick/przorka_backup/trigger_after_automation_update.sql)
Ручное UPDATE last_triggered больше не создаёт строку (до/после = 27 321). Сквозной тест: одно событие → ровно 1 строка в логе
2
«Фантомные» строки в журнале
Удалены 11 060 строк без rule_id (следы триггера) — журнал 27 331 → 16 271, таблица пересобрана (OPTIMIZE)
Повторный подсчёт по базе
3
Расписание молотило одно правило каждую секунду (169 и 170 дали по 50 срабатываний за 50 секунд, лог рос без остановки, пока время суток больше заданного)
В mqtt_listener.php: правило расписания проверяется не чаще одного раза в 30 секунд; если действие уже не нужно (устройство в нужном состоянии) — правило помечается выполненным на сегодня, а не перепроверяется до полуночи
Временное правило расписания: за минуту ровно 1 проверка (было ~60). php -l — без ошибок. Резервная копия до правки: przorka_backup/mqtt_listener.php.orig
4
mqtt_to_mysql.py лежал в домашней папке, вне сайта
Скрипт перенесён в /var/www/html/iot/public_html/admin/mqtt_to_mysql.py, юнит mqtt-to-mysql.service обновлён; запускается от пользователя mazzick тем же venv (/home/mazzick/mqtt-env). Старая копия — в przorka_backup/mqtt_to_mysql.py.orig
Служба active, значения приходят в БД, лог чистый
5
Лог логгера вырос до 6,25 ГБ: на каждое сообщение писалось INFO, ≈16 млн строк за 17 дней
Уровень сообщения «📥 Получено» понижен до debug, лог обрезан, добавлен /etc/logrotate.d/mqtt-logger (50 МБ, 5 файлов, сжатие, copytruncate)
Лог остаётся 228 байт, данные идут, 0 ошибок в журнале службы
6
По HTTP отдавались исходники и резервные копии: /admin/mqtt_to_mysql.py, sensors.bak, .cron_logs.php.swp, crontab_root.txt, test_fish_power.sh, копии движка, logic.phparcsec
В nginx добавлены правила: файлы *.bak/*.py/*.sh/*.sql/*.log/*.txt/*.swp и *.phpgood* не отдаются (403)
Все перечисленные адреса → 403; панель, /manifest.json, /sw.js, иконки и .php-страницы работают как раньше
7
Мусор: 44 файла-версии в корне сайта, 26 архивных копий в admin, дубли движка и логгера в домашней папке, 3 каталога-копии сайта (1,3 ГБ), старые дампы БД
Всё перенесено в карантин /home/mazzick/cleanup_20260926/ — ничего не удалено, есть manifest.txt со списком. Освобождено ≈5 ГБ на диске (35 ГБ → 30 ГБ) и 1,5 ГБ в карантине
Главные страницы отдают 200, новых 404 в логах nginx нет, движок и логгер без ошибок
8. Что осталось сделать (по приоритету)
Сначала — безопасность (сейчас дом открыт)
Пароль панели ходит в открытом виде. В admin/sensors.php логин и пароль приходят методом GET — в журнале nginx видно ?username=...&password=0*****6. Сама панель паролем 0****6 и закрыта, но пароль совпадает с паролем SSH и с паролем MQTT.
Главная панель и API открыты.index.php, graf.php, api/live_data.php отдают данные без авторизации, а api/control.php?topic=...&state=ON вообще позволяет любому в сети включать и выключать устройства.
nginx слушает всю сеть (0.0.0.0:80), как и MQTT (1883). Кто в сети — тот в доме.
Пароли в коде открытым текстом — в ~30 PHP-файлах, в mqtt_to_mysql.py, в двух python-скриптах, в cron-заданиях и в скриптах бэкапа. Учётка iot_user/A***0****56 вообще лежала в файле, доступном по HTTP → считай её скомпрометированной.
Пароль SMTP от it@logoakademia.ru лежит в ~/.msmtprc и попадал в ~/.msmtp.log (файл 3,8 МБ — стоит почистить).
Что предлагаю сделать: свести все учётки в /etc/iot/credentials.ini (права 640, владелец www-data) и читать их из кода; сменить пароли (отдельный для панели, отдельный для MQTT, отдельный для БД iot_ro/iot_rw); закрыть панель basic-auth или разрешить доступ только с домашней подсети в nginx; MQTT с 1883 слушать только на 127.0.0.1 и локальной сети; на api/control.php повесить токен.
Потом — логика и лишний код
Отключить исполнение PHP из базы (condition_type='script'): сейчас движок собирает файл в /tmp и делает include — это фактически запуск произвольного кода из таблицы. Ни одно правило так не работает.
Две логики управления: часть скриптов в cron (cron_fish_heater.php, cron_fish_feeder.php, cron_polivayka.php, cron_push.php) дублирует то, что умеет движок. Их стоит перевести в правила automations и оставить один путь — иначе при разборе сбоя непонятно, кто включил свет.
Проверить правила 158/159 (включены, но без действия) и 163 (без условия). Настроить имя 169 под фактическое время (09:00, а не 8:00).
Убрать неиспользуемое: каталог public_html/mqtt/ (php-mqtt), db/sensors.db, пустые таблицы (push_notifications, rs485_*, sensors), колонки hysteresis/max_retries/retry_delay/time_restriction_*/push_*, страницы-дубли (datchiki.php vs index.php, четыре варианта all_charts*, четыре просмотрщика логов).
Подтверждения и задержки живут только в памяти процесса: delay_seconds, ожидание подтверждения, cooldown после перезапуска теряются. Для «выключить свет через 60 секунд» это значит, что systemctl restart отменяет отложенное действие.
Задержка реакции до 1 секунды (цикл с sleep(1)) и полное чтение 137 топиков каждую секунду — на этом железе терпимо, но по-хорошему движок должен слушать MQTT сам, а не опрашивать базу.
Журналы systemd не сохраняются (Storage=volatile): при перезагрузке пропадают, а log_analyzer.sh каждый час считает по ним статистику. Включить постоянное хранение и ограничить размер.
Ротация:/var/log/cron_automation_emails.log (7 МБ) и ~/.msmtp.log (3,8 МБ) не ротируются вообще.
Иконки PWA по 0 байт — приложение на телефон ставится без иконки.
Проверка после правок
# движок и логгер живы
systemctl is-active iot-automation mqtt-to-mysql mosquitto zigbee2mqtt mariadb nginx
# данные идут (timestamp должен быть «сейчас»)
mysql -u mazzick -p... iot_db -e "SELECT topic,LEFT(value,20),timestamp FROM sensor_data ORDER BY timestamp DESC LIMIT 3;"
# нет ошибок
journalctl -u iot-automation -u mqtt-to-mysql --since "10 min ago" | grep -iE "error|fatal|traceback"
# лог логгера не растёт
ls -la /home/mazzick/mqtt_logger.log # должен оставаться 228 байт
# дублей в журнале больше нет: за одно срабатывание — одна строка
mysql -u mazzick -p... iot_db -e "SELECT triggered_at,rule_name,COUNT(*) FROM automation_log WHERE triggered_at > NOW()-INTERVAL 1 HOUR GROUP BY 1,2 HAVING COUNT(*)>1;"