Уже второй сезон я строю дом. На этапе подведения коммуникаций появилась идея сразу проложить оптику от провайдера, чтобы потом не курочить стены. Сказано — сделано. Подключив дом к Интернету, я сразу задумался о том, что это отличный испытательный полигон для разных датчиков и подобного оборудования умного дома. Пока там никто не живёт, а идёт стройка, отвал или глюки датчиков никаких неудобств не вызовет, а скорее даст полезную информацию и опыт внедрения. Опять же, пока стены не отделаны, нет никаких проблем "быстро и грязно" протянуть кабели для Ethernet и RS-485 по месту в целях тестирования оборудования.

Текст статьи написан без использования ИИ. LLM (DeepSeek) использовались только для написания скриптов и генерации картинок, весь ИИ-контент помечен. Примечание для ИИ-фобов: я достаточно душный, чтобы длинные тире ставить самостоятельно.
Уровень сложности статьи "средний", т.к. для краткости опущены тривиальные шаги вроде запуска сервисов, установки пакетов и т.д.

Для эксперимента я закупил на Озоне и Aliexpress на пробу следующее оборудование:

  • Роутер Cudy WR3000 v1.0 под установку OpenWrt (Ревизия важна. Её можно увидеть на коробке над штрихкодом). Это был самый дешёвый на момент покупки роутер с поддержкой OpenWrt на Озоне. Процедура установки не очень удобная, но её надо выполнить всего один раз.

  • Мост Ethernet <-> RS-485 CDEBYTE NA111-M. Это устройство преобразует Modbus RS-485 в Modbus TCP, позволяя подключать датчики к роутеру.

  • 3-фазный измеритель параметров электросети Peacefair PZEM-6L24.

  • Датчики температуры SHT30 и SHT20 с интерфейсом RS-485.

Общий бюджет оборудования не превысил 5000р.

На роутере будет работать MQTT-брокер Mosquitto (в качестве задела под внедрение Wiren Board) и скрипт, собирающий информацию с датчиков. Этот брокер будет передавать все сообщения в режиме моста в брокер на внешнем хосте. В квартире, где я сейчас живу, будет стоять ещё один брокер в режиме моста, работающий на NAS. Там мы развернём Графану и всё, необходимое для неё.

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

Целых 3 брокера вроде и перебор, но схема оказалась удачная (картинка сгенерирована ИИ)
Целых 3 брокера вроде и перебор, но схема оказалась удачная (картинка сгенерирована ИИ)

Итак, первым делом настроим брокер на внешнем хосте. Установим Mosquitto и получим сертификат Lets's encrypt для домена. Инструкций, как получить сертификат, пруд пруди и останавливаться на них мы не будем.

Настройка брокера на внешнем хосте

Вот таким образом конфигурируем наш брокер (не забываем разрешать ему слушать порт 1883, если используется firewall):

persistence true
persistence_location /var/lib/mosquitto/

log_dest file /var/log/mosquitto/mosquitto.log

include_dir /etc/mosquitto/conf.d

# Тут должен быть настоящий IP адрес!
listener 8883 10.10.10.10
certfile  /etc/letsencrypt/live/myhost.ru/fullchain.pem
keyfile   /etc/letsencrypt/live/myhost.ru/privkey.pem
tls_version tlsv1.3
password_file /etc/mosquitto/password_file
bind_interface ens3

Создаём файл с паролями для Mosquitto и запускаем брокер:

mosquitto_passwd -c /etc/mosquitto/password_file myuser
chown mosquitto:mosquitto /etc/mosquitto/password_file
systemctl restart mosquitto.service

Можно проверить подключение к брокеру с любого хоста утилитами mosquitto_pub и mosquitto_sub.

Настройка роутера

Теперь настроим наш роутер. Поставим на OpenWrt пакеты mosquitto-ssl и mbpoll, а так же также обновим пакет ca-certificates. Сделать это можно как через opkg/apk, так и через веб-интефейс.

Настроим и запустим наш Mosquitto на роутере в режиме моста:

allow_anonymous true
listener 1883 192.168.1.1
bind_interface br-lan

connection myhost
address myhost.ru:8883
remote_username myuser
remote_password my_super_pwd
clientid my_remote_home
try_private true
start_type automatic
topic # both
bridge_tls_version tlsv1.3
bridge_cafile /etc/ssl/certs/ca-certificates.crt

Теперь самое интересное. Скрипты для сбора информации с датчиков. По сути, эти скрипты — просто обёртки над утилитами mosquitto-pub и mbpoll, запускающие их с определёнными параметрами и парсящие их вывод. Так сделано для того, чтобы не пришлось ставить библиотеки для работы с Modbus TCP и MQTT для скриптового языка.

Кстати, о языке. Сперва я написал (с помощью ИИ) и отладил эти скрипты на Python, но при попытке запустить их на OpenWrt обнаружил, что из коробки Python там нет вообще, а при попытке его установить мгновенно исчерпывается флешка на роутере. Пришлось с помощью того же ИИ помодульно переписыывать на Lua.

Вот пример скрипта, получающего параметры из PZEM-6L24 (Сгенерирован ИИ и поправлен руками):

-- pzem_6l24.lua
local M = {}

function M.get_values(ip, modbus_addr)
    local cmd = "mbpoll -c9 -1 -q -t3:int16 -a" .. modbus_addr .. " " .. ip
    local p = io.popen(cmd, 'r')
    if not p then
        io.stderr:write("Ошибка запуска утилиты mbpoll\n")
        return nil, nil, nil, nil, nil, nil, nil, nil, nil
    end
    local output = p:read("*a")
    local ok, err = p:close()
    if not ok then
        io.stderr:write(string.format("Ошибка получения данных: %s\n", err))
        return nil, nil, nil, nil, nil, nil, nil, nil, nil
    end
    output = output:gsub("[\r\n]+$", "")
    local lines = {}
    for line in output:gmatch("[^\r\n]+") do
        table.insert(lines, line)
    end
    if #lines < 10 then
        io.stderr:write("Недостаточно строк в выводе утилиты\n")
        return nil, nil, nil, nil, nil, nil, nil, nil, nil
    end
    local function get_second_field(line)
        local parts = {}
        for part in line:gmatch("%S+") do
            table.insert(parts, part)
        end
        if #parts >= 2 then
            return tonumber(parts[2])
        end
        return nil
    end
    local raw_volt_A = get_second_field(lines[2])
    local raw_volt_B = get_second_field(lines[3])
    local raw_volt_C = get_second_field(lines[4])
    local raw_amp_A  = get_second_field(lines[5])
    local raw_amp_B  = get_second_field(lines[6])
    local raw_amp_C  = get_second_field(lines[7])
    local raw_freq_A = get_second_field(lines[8])
    local raw_freq_B = get_second_field(lines[9])
    local raw_freq_C = get_second_field(lines[10])
    if not (raw_volt_A and raw_volt_B and raw_volt_C and raw_amp_A and raw_amp_B and raw_amp_C and raw_freq_A and raw_freq_B and raw_freq_C) then
        io.stderr:write("Невозможно извлечь числа из вывода\n")
        return nil, nil, nil, nil, nil, nil, nil, nil, nil
    end
    local function swap_bytes(val)
        local high = math.floor(val / 256)
        local low = val % 256
        return low * 256 + high
    end
    local volt_A = swap_bytes(raw_volt_A) / 10
    local volt_B = swap_bytes(raw_volt_B) / 10
    local volt_C = swap_bytes(raw_volt_C) / 10
    local amp_A  = swap_bytes(raw_amp_A)  / 100
    local amp_B  = swap_bytes(raw_amp_B)  / 100
    local amp_C  = swap_bytes(raw_amp_C)  / 100
    local freq_A = swap_bytes(raw_freq_A) / 100
    local freq_B = swap_bytes(raw_freq_B) / 100
    local freq_C = swap_bytes(raw_freq_C) / 100
    return volt_A, volt_B, volt_C, amp_A, amp_B, amp_C, freq_A, freq_B, freq_C
end

return M

Что делает скрипт:

  • Запускает mbpoll и запрашивает первые 9 регистров Modbus, в которых лежат параметры напряжения, тока и частоты по всем 3 фазам.

  • Парсит вывод и обрабатывает ошибки.

  • Переворачивает байты в словах (меняет Endianness).

  • Вычисляет реальные значения из сырых и возвращает их.

Все скрипты можно увидеть тут.

Основной скрипт представляет собой бесконечный цикл, запрашивающий данные и публикующий результаты в MQTT (Сгенерирован ИИ и поправлен руками):

#!/usr/bin/env lua

-- Подключаем модули
package.path = "/root/modbus_scripts/?.lua" -- Правильный путь для OpenWrt
local mqtt   = require("mqtt")
local pzem   = require("pzem_6l24")
local sht30  = require("sht30")
local config = require("config")

while true do
    os.execute("sleep 1")  

    -- ========== PZEM-6L24 ==========
    if config.pzem_enabled then
        local volt_A, volt_B, volt_C, amp_A, amp_B, amp_C, freq_A, freq_B, freq_C =
            pzem.get_values(config.ip, config.pzem_addr)

        if volt_A == nil then
            -- ошибка опроса – можно либо выйти, либо только пропустить отправку
            -- здесь выходим, как в оригинале
            return
        else
            mqtt.pub_string(config.mqtt_host, "/modbus_devices/pzem_6l24/volt_A", tostring(volt_A))
            mqtt.pub_string(config.mqtt_host, "/modbus_devices/pzem_6l24/volt_B", tostring(volt_B))
            mqtt.pub_string(config.mqtt_host, "/modbus_devices/pzem_6l24/volt_C", tostring(volt_C))
            mqtt.pub_string(config.mqtt_host, "/modbus_devices/pzem_6l24/amp_A",  tostring(amp_A))
            mqtt.pub_string(config.mqtt_host, "/modbus_devices/pzem_6l24/amp_B",  tostring(amp_B))
            mqtt.pub_string(config.mqtt_host, "/modbus_devices/pzem_6l24/amp_C",  tostring(amp_C))
            mqtt.pub_string(config.mqtt_host, "/modbus_devices/pzem_6l24/freq_A", tostring(freq_A))
            mqtt.pub_string(config.mqtt_host, "/modbus_devices/pzem_6l24/freq_B", tostring(freq_B))
            mqtt.pub_string(config.mqtt_host, "/modbus_devices/pzem_6l24/freq_C", tostring(freq_C))
        end
    end

    -- ========== SHT30 ==========
    if config.sht30_enabled then
        local humidity_str, temperature_str, humidity_raw, temperature_raw =
            sht30.get_values(config.ip, config.sht30_addr)

        if humidity_str == nil then
            return   -- ошибка – завершаем скрипт
        else
            mqtt.pub_string(config.mqtt_host, "/modbus_devices/sht30/humidity",    tostring(humidity_raw))
            mqtt.pub_string(config.mqtt_host, "/modbus_devices/sht30/temperature", tostring(temperature_raw))
        end
    end
end

Осталось теперь превратить этот скрипт в демон. Для этого создаём на роутере файл /etc/init.d/modbus2mqtt:

#!/bin/sh /etc/rc.common


START=99
STOP=0
USE_PROCD=1

SCRIPT="/root/modbus_scripts/main.lua"

start_service() {
    procd_open_instance
    procd_set_param command "$SCRIPT"

    # Раскомментировать для отладки
    #procd_set_param stdout 1
    #procd_set_param stderr 1

    procd_set_param respawn 3600 15 0

    procd_close_instance
}

И разрешаем его автозапуск в веб-интерфейсе. Это конфиг для procd, который указывает ему, что надо запустить наш основной скрипт при старте роутера и при его падении перезапустить его через 15 секунд.

Настройка NAS

Дома я тоже установил брокер в режиме моста с такой конфигурацией:

allow_anonymous true
listener 1883 192.168.1.253
bind_interface enp2s0

log_dest file /var/log/mosquitto/mosquitto.log

connection_messages true

connection myhost
address myhost.ru:8883
remote_username myuser
remote_password my_super_pwd
clientid my_test_home
try_private true
start_type automatic
topic # both
bridge_tls_version tlsv1.3
bridge_capath /etc/pki/ca-trust/extracted/pem/directory-hash/

Если всё настроено правильно, то можно увидеть сообщения, идущие из дома в реальном времени:

$ mosquitto_sub -h my-nas -t '/modbus_devices/#' -v
/modbus_devices/pzem_6l24/volt_A 233.7
/modbus_devices/pzem_6l24/volt_B 236.9
/modbus_devices/pzem_6l24/volt_C 235.9
/modbus_devices/pzem_6l24/amp_A 0
/modbus_devices/pzem_6l24/amp_B 0
/modbus_devices/pzem_6l24/amp_C 0.25
/modbus_devices/pzem_6l24/freq_A 47.36
/modbus_devices/pzem_6l24/freq_B 49.86
/modbus_devices/pzem_6l24/freq_C 49.87

Теперь настал момент установить Influxdb2, Grafana и Telegraf. Лучше и проще, чем описано в этой статье, я всё равно не смогу написать, поэтому отсылаю читателя к ней.

Единственное отличие в том, что Retention для основного бакета я установил всего в 1 час, и создал ещё один бакет для агрегированных данных. Нет смысла хранить данные посекундно. Далее на вкладке Tasks создаём задачу с частотой запуска раз в 30 секунд с таким содержимым:

option task = {name: "Aggregate", every: 30s}

from(bucket: "My-monitoring")
    |> range(start: -30s)
    |> filter(fn: (r) => r["_measurement"] == "mqtt_consumer")
    |> filter(fn: (r) => r["_field"] == "value")
    |> aggregateWindow(every: 30s, fn: mean, createEmpty: false)
    |> to(bucket: "My-aggregated", timeColumn: "_time")

Теперь в Grafana можно выводить текущие значения из бакета My-monitoring, а графики рисовать по агрегированным данным из бакета My-aggregated.

Итог

В итоге получился удобный и наглядный дашборд, на котором видны все интересующие меня параметры сети. Единственное, что огорчает — это очень странные показатели частоты (болтается от 48 до 50 Гц), но я это списываю на помехи в сети, да и странно ждать метрологической идеальности от приборчика за 1600р.

Общий вид дашборда
Общий вид дашборда

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

Камеры не нужны
Камеры не нужны

Получилась простая и быстрая в развёртывании система мониторинга. Добавить датчик теперь — дело на 10 минут.