From fc10cb77babaa141c2c6cb252eae730ed5e6077b Mon Sep 17 00:00:00 2001 From: german Date: Thu, 9 Jul 2026 23:49:34 +0300 Subject: [PATCH] =?UTF-8?q?egorin:=20LoRa/LAN=20=D1=8D=D0=BD=D0=B5=D1=80?= =?UTF-8?q?=D0=B3=D0=BE=D0=BC=D0=BE=D0=BD=D0=B8=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3=20=E2=80=94=20=D0=BA=D0=B8=D1=82=20=D0=B2=20/lora?= =?UTF-8?q?=20(=D0=BF=D1=80=D0=BE=D1=88=D0=B8=D0=B2=D0=BA=D0=B8+schema+pro?= =?UTF-8?q?visioner+UI),=20=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4=20ingest?= =?UTF-8?q?=20=D0=BE=D1=82=20=D1=88=D0=BB=D1=8E=D0=B7=D0=B0-=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=86=D0=B5=D0=BD=D1=82=D1=80=D0=B0=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B0=20+=20=D1=80=D0=B5=D0=B5=D1=81=D1=82=D1=80=20=D1=83?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B9=D1=81=D1=82=D0=B2,=20=D1=84?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D1=82-=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=20(=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8/=D1=80?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5/pending/=D1=88=D0=BB=D1=8E=D0=B7=D1=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lora/README_v2.md | 122 +++++++++++ lora/gateway_lora_eth.ino | 164 +++++++++++++++ lora/node_lan_energy.ino | 191 +++++++++++++++++ lora/node_lora_energy_v2.ino | 152 ++++++++++++++ lora/provisioner.js | 232 +++++++++++++++++++++ lora/schema.sql | 77 +++++++ lora/ui-prototype.html | 336 ++++++++++++++++++++++++++++++ server/src/lora.js | 101 +++++++++ server/src/server.js | 17 ++ src/config/modules.ts | 4 +- src/modules/energy/LoraModule.tsx | 148 +++++++++++++ src/modules/gps/MapClusterGL.tsx | 2 +- 12 files changed, 1544 insertions(+), 2 deletions(-) create mode 100644 lora/README_v2.md create mode 100644 lora/gateway_lora_eth.ino create mode 100644 lora/node_lan_energy.ino create mode 100644 lora/node_lora_energy_v2.ino create mode 100644 lora/provisioner.js create mode 100644 lora/schema.sql create mode 100644 lora/ui-prototype.html create mode 100644 server/src/lora.js create mode 100644 src/modules/energy/LoraModule.tsx diff --git a/lora/README_v2.md b/lora/README_v2.md new file mode 100644 index 0000000..8ccdca7 --- /dev/null +++ b/lora/README_v2.md @@ -0,0 +1,122 @@ +# Энергомониторинг v2: LoRa + LAN, сервер учёта, интерфейс (Егорин / ORBIT) + +## 1. Архитектура + +``` + ┌─ LoRa-узлы (869 МГц) ──→ Шлюз gw1 (ESP32 + RA-01H + W5500, RJ45) ─┐ + │ Ethernet + ┌─ LAN-узлы (WT32-ETH01, RJ45) ─────────────────────────────────────┤ + ▼ + Mosquitto (Proxmox srv001, :1883) + │ + ┌───────────────────────────────┼──────────────────┐ + ▼ ▼ ▼ + provisioner.js Home Assistant n8n + (учёт устройств, API :8090, (MQTT Discovery — (алерты в + Postgres, HA Discovery) сущности сами) @Svetdo_bot) + ▲ + │ REST + Веб-интерфейс (ui-prototype.html → далее React) +``` + +Правило выбора транспорта узла: **есть розетка RJ45 в щите → LAN-узел** (WT32-ETH01, +надёжнее, чаще телеметрия, LWT-статус). **Кабеля нет → LoRa-узел** через шлюз. +Протокол данных у обоих одинаковый. + +## 2. Железо (дополнение к v1) + +| Позиция | Модель | Цена | Прим. | +|---|---|---|---| +| LAN-узел | **WT32-ETH01** (ESP32 + LAN8720, RJ45) | 700–900 ₽ | вместо DevKit+RA-01H | +| Шлюз v2 | ESP32 DevKit + RA-01H + **W5500 mini** | +250 ₽ к v1 | Ethernet вместо WiFi | +| PoE-вариант | Olimex **ESP32-POE** | ~2500 ₽ | питание по одному кабелю, для мачт/удалённых щитов | + +Распиновка WT32-ETH01: PZEM на IO5/IO17 (UART2); входы реле IO32/IO33 +(внутренний pullup) и IO35/IO36 (только вход — внешний pullup 10к на 3.3V); +выходы реле IO2/IO4/IO14/IO15. Шлюз: LoRa и W5500 на общей шине VSPI +(SCK18/MISO19/MOSI23), CS: LoRa=5, W5500=4. + +## 3. Единое пространство топиков (v2) + +| Топик | Направление | Содержимое | +|---|---|---| +| `nodes//info` | узел → сервер, **retained** | паспорт: `{"id","fw","hw":"lan\|lora","caps":{"pzem":true,"relays":4,"ctl":true},"ip","mac","gw"}` | +| `nodes//data` | узел → сервер | телеметрия: v, a, w, kwh, pf, hz, r[], seq (+rssi/snr/gw для LoRa) | +| `nodes//status` | LAN-узел → сервер | `online`/`offline` (LWT). LoRa-узлам статус ставит сервер по таймауту last_seen | +| `nodes//cmd` | сервер → узел | `{"cmd":"relay","n":0,"s":1}` · `{"cmd":"poll"}` · `{"cmd":"info"}` | +| `gateways//status` | шлюз | online/offline (LWT) | + +LAN-узлы публикуют напрямую; LoRa-узлы шлют JSON с полем `"t":"info"|"d"`, +шлюз раскладывает по тем же топикам и добавляет rssi/snr/gw. + +## 4. Механизм добавления устройств на сервер + +Принцип: **узел регистрируется сам, человек только подтверждает.** +Прошивка одинаковая для всех узлов, отличается только `NODE_ID` — на объекте +монтажнику ничего настраивать не нужно. + +1. **Выход в эфир.** При включении узел публикует `nodes//info` (retained). + LoRa-узел делает то же через шлюз. Повтор раз в 10 минут. +2. **Регистрация.** `provisioner.js` видит неизвестный `node_id` → создаёт запись + в `devices` со `status='pending'`. Паспорт (транспорт, IP/MAC или шлюз+RSSI, + прошивка, число реле) сохраняется автоматически. +3. **Интерфейс.** На экране «Устройства» появляется янтарная полоса + «Обнаружены новые устройства» со списком pending-узлов. +4. **Мастер добавления** (3 шага): + - *Паспорт* — проверка, что это ожидаемый узел (ID, транспорт, RSSI/IP, реле); + - *Параметры* — название, проект (Егорин/ORBIT), объект, имена реле; + - *Подтверждение* — `POST /api/devices//approve`. +5. **Автопубликация в HA.** При approve сервис публикует retained-конфиги + MQTT Discovery (`homeassistant/sensor|switch/.../config`) — датчики V/A/W/кВт·ч + и выключатели реле появляются в Home Assistant без правки yaml. +6. **Жизненный цикл.** `pending → active → disabled`. Disabled-узел данные пишет, + но скрыт с борда и не публикуется в HA. Повторное включение — снова approve. + +Замена узла в поле: новый узел с тем же `NODE_ID` подхватывает старую карточку +(info обновит fw/mac), история сохраняется. + +## 5. REST API (provisioner.js, :8090) + +| Метод | Путь | Назначение | +|---|---|---| +| GET | `/api/devices?status=pending\|active&project=egorin\|orbit` | список + последняя телеметрия (view `v_devices_board`) | +| POST | `/api/devices/:id/approve` | `{name, project, object, relay_names[]}` → active + HA Discovery | +| POST | `/api/devices/:id/disable` | скрыть/отклонить | +| POST | `/api/devices/:id/cmd` | команда узлу, пишется в журнал `commands` | +| GET | `/api/devices/:id/history?hours=24` | ряды для графиков | +| GET | `/api/devices/:id/relay-events` | журнал переключений реле | + +Запуск: `npm i mqtt pg express && node provisioner.js` +(ENV: `MQTT_URL`, `PG_URL`, `PORT`). Схема БД — `schema.sql` (Postgres). + +## 6. Структура интерфейса (ORBIT design system) + +Стиль: фон #070a10, акцент #4ADE80, Unbounded + Onest + JetBrains Mono, +glassmorphism-карточки. Desktop ≥1024: левый рейл 80px; mobile: нижний таб-бар +(паттерн ORBIT GPS). Рабочий прототип — `ui-prototype.html`. + +``` +├─ Обзор сводка по проектам: Σ кВт·ч сегодня/месяц, узлы в сети, +│ последние события реле, состояние шлюзов +├─ Устройства ★ основной экран (реализован в прототипе): +│ ├─ фильтр-чипы Все / Егорин / ORBIT +│ ├─ полоса «Обнаружены новые устройства» (pending) → мастер добавления +│ ├─ сетка карточек: статус-точка с пульсацией, бейдж LAN/LoRa+RSSI, +│ │ метрики В·А·Вт·кВт·ч (mono), тумблеры реле с именами, last seen +│ └─ карточка узла (drill-down, след. итерация): графики W/kWh за 24ч/30д, +│ журнал реле, паспорт, кнопки poll / перезапрос info / disable +├─ Шлюзы gw1..N: online, IP, число узлов, средний RSSI по узлам +├─ Журнал relay_events + commands: кто переключил, что, когда, источник +└─ Настройки MQTT-брокер, таймаут offline, частота/SF сети, ретенция БД +``` + +## 7. Порядок внедрения + +1. Postgres: применить `schema.sql` (LXC на srv001). +2. Запустить `provisioner.js` (Docker/LXC, systemd). +3. Прошить шлюз `gateway_lora_eth.ino`, воткнуть в свитч щитовой. +4. Прошить узлы: LAN — `node_lan_energy.ino`, LoRa — `node_lora_energy_v2.ino` + (уникальные NODE_ID: eg01…, orb01…, eg-lan1…). +5. Узлы появятся в pending → добавить через мастер → проверить сущности в HA. +6. n8n: подписка на `nodes/+/data`, алерты в @Svetdo_bot + (узел offline >5 мин, ток выше порога, внеплановое отключение реле). diff --git a/lora/gateway_lora_eth.ino b/lora/gateway_lora_eth.ino new file mode 100644 index 0000000..12e0bdf --- /dev/null +++ b/lora/gateway_lora_eth.ino @@ -0,0 +1,164 @@ +/* + * ================================================================ + * ШЛЮЗ v2: LoRa ↔ Ethernet (W5500) ↔ MQTT + * Проекты: Егорин / ORBIT · протокол v2 (nodes/...) + * ================================================================ + * Железо: + * - ESP32 DevKit V1 + * - LoRa RA-01H (SX1276, 869 МГц) + * - Ethernet W5500 mini (SPI) + * Оба модуля на общей шине VSPI, разные CS: + * SCK=18 MISO=19 MOSI=23 + * LoRa: NSS=5 RST=14 DIO0=26 + * W5500: CS=4 + * + * Ретранслирует LoRa-узлы в единое пространство топиков: + * пакет {"t":"info",...} → nodes//info (retained) + * пакет {"t":"d",...} → nodes//data (+rssi/snr/gw) + * nodes//cmd (MQTT) → LoRa вниз, адресно по id + * + * Онлайн/оффлайн LoRa-узлов решает сервер по last_seen + * (у радиоузлов нет LWT). + * + * Библиотеки: LoRa (Sandeep Mistry), Ethernet (Arduino), + * PubSubClient, ArduinoJson + * ================================================================ + */ + +#include +#include +#include +#include +#include + +// ================== НАСТРОЙКИ ================== +#define GW_ID "gw1" +#define MQTT_HOST "192.168.1.200" +#define MQTT_PORT 1883 +#define MQTT_USER "" +#define MQTT_PASSWD "" + +byte mac[] = {0xDE, 0xAD, 0xBE, 0xEF, 0x10, 0x01}; // уникальный на шлюз + +// LoRa — параметры должны совпадать с узлами +#define LORA_FREQ 869000000E0 +#define LORA_SF 9 +#define LORA_BW 125E3 +#define LORA_SYNC 0x2B +#define LORA_TX_DBM 20 +#define LORA_NSS 5 +#define LORA_RST 14 +#define LORA_DIO0 26 +#define W5500_CS 4 + +EthernetClient ethClient; +PubSubClient mqtt(ethClient); + +// ================== СЕТЬ ================== +void netBegin() { + Ethernet.init(W5500_CS); + Serial.print("DHCP... "); + while (Ethernet.begin(mac) == 0) { + Serial.println("fail, retry 5s"); + delay(5000); + } + Serial.println(Ethernet.localIP()); +} + +void mqttReconnect() { + while (!mqtt.connected()) { + if (mqtt.connect("lora-" GW_ID, MQTT_USER, MQTT_PASSWD, + "gateways/" GW_ID "/status", 1, true, "offline")) { + mqtt.publish("gateways/" GW_ID "/status", "online", true); + mqtt.subscribe("nodes/+/cmd"); + Serial.println("MQTT OK"); + } else { + Serial.printf("MQTT rc=%d, retry 5s\n", mqtt.state()); + delay(5000); + } + } +} + +// ================== MQTT → LoRa (вниз) ================== +void mqttCallback(char *topic, byte *payload, unsigned int len) { + String t(topic); + int a = t.indexOf('/') + 1; + int b = t.indexOf('/', a); + String nodeId = t.substring(a, b); + + // LAN-узлы обрабатывают свои cmd сами; шлюз ретранслирует всё, + // радиоузлы фильтруют по своему id, чужое игнорируют. + StaticJsonDocument<192> doc; + if (deserializeJson(doc, payload, len)) return; + doc["id"] = nodeId; + + char buf[192]; + size_t n = serializeJson(doc, buf); + LoRa.beginPacket(); + LoRa.write((const uint8_t*)buf, n); + LoRa.endPacket(); + LoRa.receive(); + Serial.printf("DOWN [%s]: %s\n", nodeId.c_str(), buf); +} + +// ================== LoRa → MQTT (вверх) ================== +void handleLoRaPacket() { + String s; + while (LoRa.available()) s += (char)LoRa.read(); + + StaticJsonDocument<384> doc; + if (deserializeJson(doc, s)) { Serial.println("Bad pkt: " + s); return; } + const char *id = doc["id"]; + if (!id) return; + + const char *type = doc["t"] | "d"; + bool isInfo = !strcmp(type, "info"); + + doc["gw"] = GW_ID; + if (!isInfo) { + doc["rssi"] = LoRa.packetRssi(); + doc["snr"] = serialized(String(LoRa.packetSnr(), 1)); + } + doc.remove("t"); + + char buf[384]; + size_t n = serializeJson(doc, buf); + + char topic[64]; + snprintf(topic, sizeof(topic), "nodes/%s/%s", id, isInfo ? "info" : "data"); + mqtt.publish(topic, (const uint8_t*)buf, n, isInfo); // info — retained + Serial.printf("UP %s: %s\n", topic, buf); +} + +// ================== SETUP / LOOP ================== +void setup() { + Serial.begin(115200); + SPI.begin(18, 19, 23); + + netBegin(); + mqtt.setServer(MQTT_HOST, MQTT_PORT); + mqtt.setCallback(mqttCallback); + mqtt.setBufferSize(512); + + LoRa.setPins(LORA_NSS, LORA_RST, LORA_DIO0); + if (!LoRa.begin(LORA_FREQ)) { + Serial.println("LoRa init FAIL"); + while (true) delay(1000); + } + LoRa.setSpreadingFactor(LORA_SF); + LoRa.setSignalBandwidth(LORA_BW); + LoRa.setSyncWord(LORA_SYNC); + LoRa.setTxPower(LORA_TX_DBM); + LoRa.enableCrc(); + LoRa.receive(); + + Serial.println("Gateway " GW_ID " ready (ETH)"); +} + +void loop() { + Ethernet.maintain(); // продление DHCP-аренды + if (!mqtt.connected()) mqttReconnect(); + mqtt.loop(); + + if (LoRa.parsePacket() > 0) handleLoRaPacket(); +} diff --git a/lora/node_lan_energy.ino b/lora/node_lan_energy.ino new file mode 100644 index 0000000..bb4794c --- /dev/null +++ b/lora/node_lan_energy.ino @@ -0,0 +1,191 @@ +/* + * ================================================================ + * LAN-УЗЕЛ: электроэнергия + реле → Ethernet → MQTT + * Проекты: Егорин / ORBIT · протокол v2 (nodes/...) + * ================================================================ + * Железо: + * - WT32-ETH01 (ESP32 + LAN8720, порт RJ45) + * - PZEM-004T v3.0 (UART2: RX=IO5, TX=IO17) + * - Входы состояния реле: IO32, IO33 (внутр. pullup), + * IO35, IO36 (только вход! внешний pullup 10к на 3.3V) + * - Выходы управления реле: IO2, IO4, IO14, IO15 + * - Питание платы: 5V (не 3.3 на пин 5V!) + * + * Узел регистрируется на сервере сам: при старте публикует + * retained nodes//info — провижн-сервис ставит его в + * «Ожидают добавления», админ подтверждает в интерфейсе. + * + * Библиотеки: ArduinoJson, PubSubClient, PZEM004Tv30 + * Плата в IDE: "WT32-ETH01 Ethernet Module" (или ESP32 Dev Module) + * ================================================================ + */ + +#include +#include +#include +#include + +// ================== НАСТРОЙКИ УЗЛА ================== +#define NODE_ID "eg-lan1" // уникальный ID: eg-lan1, orb-lan1... +#define FW_VER "2.0" +#define SEND_PERIOD_MS 15000UL // LAN не жалко — можно чаще, чем LoRa +#define INFO_PERIOD_MS 600000UL // info раз в 10 минут + +#define MQTT_HOST "192.168.1.200" // Mosquitto (Proxmox srv001) +#define MQTT_PORT 1883 +#define MQTT_USER "" +#define MQTT_PASSWD "" + +// WT32-ETH01: конфигурация LAN8720 +#define ETH_PHY_TYPE ETH_PHY_LAN8720 +#define ETH_PHY_ADDR 1 +#define ETH_PHY_MDC 23 +#define ETH_PHY_MDIO 18 +#define ETH_PHY_POWER 16 +#define ETH_CLK ETH_CLOCK_GPIO0_IN + +// PZEM на UART2 (WT32-ETH01: свободные IO5/IO17) +PZEM004Tv30 pzem(Serial2, 5, 17); + +// Реле +const uint8_t RELAY_IN[] = {32, 33, 35, 36}; // 35/36 — внешний pullup! +const uint8_t RELAY_OUT[] = {2, 4, 14, 15}; +const uint8_t N_RELAYS = 4; + +// Топики +#define T_DATA "nodes/" NODE_ID "/data" +#define T_INFO "nodes/" NODE_ID "/info" +#define T_STATUS "nodes/" NODE_ID "/status" +#define T_CMD "nodes/" NODE_ID "/cmd" + +WiFiClient ethClient; // ETH.h использует сетевой стек WiFiClient +PubSubClient mqtt(ethClient); + +static bool ethUp = false; +unsigned long lastSend = 0, lastInfo = 0; +uint16_t seq = 0; + +// ================== ETHERNET ================== +void onEthEvent(WiFiEvent_t event) { + switch (event) { + case ARDUINO_EVENT_ETH_GOT_IP: + ethUp = true; + Serial.printf("ETH IP: %s\n", ETH.localIP().toString().c_str()); + break; + case ARDUINO_EVENT_ETH_DISCONNECTED: + case ARDUINO_EVENT_ETH_STOP: + ethUp = false; + break; + default: break; + } +} + +// ================== ПУБЛИКАЦИИ ================== +void publishInfo() { + StaticJsonDocument<256> doc; + doc["id"] = NODE_ID; + doc["fw"] = FW_VER; + doc["hw"] = "lan"; // транспорт узла + doc["ip"] = ETH.localIP().toString(); + doc["mac"] = ETH.macAddress(); + JsonObject caps = doc.createNestedObject("caps"); + caps["pzem"] = true; + caps["relays"] = N_RELAYS; + caps["ctl"] = true; // умеет управлять реле + + char buf[256]; + size_t n = serializeJson(doc, buf); + mqtt.publish(T_INFO, (const uint8_t*)buf, n, true); // retained +} + +void publishTelemetry() { + StaticJsonDocument<320> doc; + doc["id"] = NODE_ID; + doc["seq"] = seq++; + + float v = pzem.voltage(); + if (!isnan(v)) { + doc["v"] = serialized(String(v, 1)); + doc["a"] = serialized(String(pzem.current(), 3)); + doc["w"] = serialized(String(pzem.power(), 1)); + doc["kwh"] = serialized(String(pzem.energy(), 3)); + doc["pf"] = serialized(String(pzem.pf(), 2)); + doc["hz"] = serialized(String(pzem.frequency(), 1)); + } else { + doc["err"] = "pzem"; + } + + JsonArray r = doc.createNestedArray("r"); + for (uint8_t i = 0; i < N_RELAYS; i++) + r.add(digitalRead(RELAY_IN[i]) == LOW ? 1 : 0); + + char buf[320]; + size_t n = serializeJson(doc, buf); + mqtt.publish(T_DATA, (const uint8_t*)buf, n); + Serial.printf("PUB %s: %s\n", T_DATA, buf); +} + +// ================== КОМАНДЫ ================== +// nodes//cmd: {"cmd":"relay","n":0,"s":1} | {"cmd":"poll"} +void mqttCallback(char *topic, byte *payload, unsigned int len) { + StaticJsonDocument<192> doc; + if (deserializeJson(doc, payload, len)) return; + const char *cmd = doc["cmd"]; + if (!cmd) return; + + if (!strcmp(cmd, "relay")) { + int n = doc["n"] | -1; + int s = doc["s"] | 0; + if (n >= 0 && n < N_RELAYS) { + digitalWrite(RELAY_OUT[n], s ? HIGH : LOW); + delay(200); + publishTelemetry(); // подтверждаем фактом + } + } else if (!strcmp(cmd, "poll")) { + publishTelemetry(); + } +} + +void mqttReconnect() { + while (ethUp && !mqtt.connected()) { + if (mqtt.connect(NODE_ID, MQTT_USER, MQTT_PASSWD, + T_STATUS, 1, true, "offline")) { + mqtt.publish(T_STATUS, "online", true); // LWT-пара + mqtt.subscribe(T_CMD); + publishInfo(); // саморегистрация + Serial.println("MQTT OK"); + } else { + Serial.printf("MQTT rc=%d, retry 5s\n", mqtt.state()); + delay(5000); + } + } +} + +// ================== SETUP / LOOP ================== +void setup() { + Serial.begin(115200); + + for (uint8_t i = 0; i < N_RELAYS; i++) { + pinMode(RELAY_IN[i], (RELAY_IN[i] >= 34) ? INPUT : INPUT_PULLUP); + pinMode(RELAY_OUT[i], OUTPUT); + digitalWrite(RELAY_OUT[i], LOW); + } + + WiFi.onEvent(onEthEvent); + ETH.begin(ETH_PHY_ADDR, ETH_PHY_POWER, ETH_PHY_MDC, + ETH_PHY_MDIO, ETH_PHY_TYPE, ETH_CLK); + + mqtt.setServer(MQTT_HOST, MQTT_PORT); + mqtt.setCallback(mqttCallback); + mqtt.setBufferSize(512); +} + +void loop() { + if (!ethUp) { delay(200); return; } + if (!mqtt.connected()) mqttReconnect(); + mqtt.loop(); + + unsigned long now = millis(); + if (now - lastSend >= SEND_PERIOD_MS) { lastSend = now; publishTelemetry(); } + if (now - lastInfo >= INFO_PERIOD_MS) { lastInfo = now; publishInfo(); } +} diff --git a/lora/node_lora_energy_v2.ino b/lora/node_lora_energy_v2.ino new file mode 100644 index 0000000..8274772 --- /dev/null +++ b/lora/node_lora_energy_v2.ino @@ -0,0 +1,152 @@ +/* + * ================================================================ + * LoRa-УЗЕЛ v2: электроэнергия + реле → LoRa → шлюз + * Отличия от v1: + * - пакет info при старте и раз в 10 мин (саморегистрация) + * - поле "t": "info" | "d" — шлюз раскладывает по топикам nodes/ + * Железо и распиновка — как в v1 (ESP32 DevKit + PZEM + RA-01H): + * PZEM: RX←GPIO17, TX→GPIO16 (Serial2) + * LoRa: SCK=18 MISO=19 MOSI=23 NSS=5 RST=14 DIO0=26 + * Входы реле: 32, 33, 25, 27 · Выходы: 4, 13, 21, 22 + * ================================================================ + */ + +#include +#include +#include +#include + +#define NODE_ID "eg01" +#define FW_VER "2.0" +#define SEND_PERIOD_MS 30000UL +#define INFO_PERIOD_MS 600000UL +#define LORA_FREQ 869000000E0 +#define LORA_SF 9 +#define LORA_BW 125E3 +#define LORA_SYNC 0x2B +#define LORA_TX_DBM 20 +#define LORA_NSS 5 +#define LORA_RST 14 +#define LORA_DIO0 26 + +const uint8_t RELAY_IN[] = {32, 33, 25, 27}; +const uint8_t RELAY_OUT[] = {4, 13, 21, 22}; +const uint8_t N_RELAYS = 4; + +PZEM004Tv30 pzem(Serial2, 16, 17); +unsigned long lastSend = 0, lastInfo = 0; +uint16_t seq = 0; + +void loraSend(const char *buf, size_t n) { + LoRa.beginPacket(); + LoRa.write((const uint8_t*)buf, n); + LoRa.endPacket(); + LoRa.receive(); +} + +void sendInfo() { + StaticJsonDocument<224> doc; + doc["id"] = NODE_ID; + doc["t"] = "info"; + doc["fw"] = FW_VER; + doc["hw"] = "lora"; + JsonObject caps = doc.createNestedObject("caps"); + caps["pzem"] = true; + caps["relays"] = N_RELAYS; + caps["ctl"] = true; + char buf[224]; + loraSend(buf, serializeJson(doc, buf)); + Serial.println("TX info"); +} + +void sendTelemetry() { + StaticJsonDocument<288> doc; + doc["id"] = NODE_ID; + doc["t"] = "d"; + doc["seq"] = seq++; + + float v = pzem.voltage(); + if (!isnan(v)) { + doc["v"] = serialized(String(v, 1)); + doc["a"] = serialized(String(pzem.current(), 3)); + doc["w"] = serialized(String(pzem.power(), 1)); + doc["kwh"] = serialized(String(pzem.energy(), 3)); + doc["pf"] = serialized(String(pzem.pf(), 2)); + doc["hz"] = serialized(String(pzem.frequency(), 1)); + } else { + doc["err"] = "pzem"; + } + + JsonArray r = doc.createNestedArray("r"); + for (uint8_t i = 0; i < N_RELAYS; i++) + r.add(digitalRead(RELAY_IN[i]) == LOW ? 1 : 0); + + char buf[288]; + size_t n = serializeJson(doc, buf); + loraSend(buf, n); + Serial.printf("TX: %s\n", buf); +} + +void handleCommand(const String &payload) { + StaticJsonDocument<192> doc; + if (deserializeJson(doc, payload)) return; + if (String((const char*)doc["id"]) != NODE_ID) return; + + const char *cmd = doc["cmd"]; + if (!cmd) return; + + if (!strcmp(cmd, "relay")) { + int n = doc["n"] | -1; + int s = doc["s"] | 0; + if (n >= 0 && n < N_RELAYS) { + digitalWrite(RELAY_OUT[n], s ? HIGH : LOW); + delay(200); + sendTelemetry(); + } + } else if (!strcmp(cmd, "poll")) { + sendTelemetry(); + } else if (!strcmp(cmd, "info")) { + sendInfo(); + } +} + +void setup() { + Serial.begin(115200); + for (uint8_t i = 0; i < N_RELAYS; i++) { + pinMode(RELAY_IN[i], INPUT_PULLUP); + pinMode(RELAY_OUT[i], OUTPUT); + digitalWrite(RELAY_OUT[i], LOW); + } + + LoRa.setPins(LORA_NSS, LORA_RST, LORA_DIO0); + if (!LoRa.begin(LORA_FREQ)) { Serial.println("LoRa FAIL"); while (1) delay(1000); } + LoRa.setSpreadingFactor(LORA_SF); + LoRa.setSignalBandwidth(LORA_BW); + LoRa.setSyncWord(LORA_SYNC); + LoRa.setTxPower(LORA_TX_DBM); + LoRa.enableCrc(); + LoRa.receive(); + + randomSeed(esp_random()); + lastSend = millis() - random(0, SEND_PERIOD_MS); + delay(random(0, 3000)); // рассинхронизация info при массовом включении + sendInfo(); + lastInfo = millis(); +} + +void loop() { + int sz = LoRa.parsePacket(); + if (sz > 0) { + String s; + while (LoRa.available()) s += (char)LoRa.read(); + handleCommand(s); + } + + unsigned long now = millis(); + if (now - lastSend >= SEND_PERIOD_MS + random(0, 2000)) { + lastSend = now; sendTelemetry(); + } + if (now - lastInfo >= INFO_PERIOD_MS) { + lastInfo = now; sendInfo(); + } +} diff --git a/lora/provisioner.js b/lora/provisioner.js new file mode 100644 index 0000000..2db31d6 --- /dev/null +++ b/lora/provisioner.js @@ -0,0 +1,232 @@ +/** + * ================================================================ + * provisioner.js — сервис учёта и добавления устройств + * Егорин / ORBIT · Node.js 20+ · npm i mqtt pg express + * ================================================================ + * Делает: + * 1. Слушает nodes/+/info → неизвестный узел = запись в devices + * со status='pending' («Ожидают добавления» в UI) + * 2. Слушает nodes/+/data → телеметрия в Postgres, online-флаг, + * события смены реле + * 3. Слушает nodes/+/status (LWT LAN-узлов) → online/offline + * 4. LoRa-узлы: offline, если молчат > OFFLINE_AFTER_S + * 5. REST API для интерфейса (см. README) + * 6. При подтверждении устройства публикует MQTT Discovery + * для Home Assistant — сущности появляются в HA сами + * + * Деплой: LXC/Docker на Proxmox, systemd unit или compose. + * ENV: MQTT_URL, PG_URL, PORT + * ================================================================ + */ + +const mqtt = require("mqtt"); +const { Pool } = require("pg"); +const express = require("express"); + +const MQTT_URL = process.env.MQTT_URL || "mqtt://192.168.1.200:1883"; +const PG_URL = process.env.PG_URL || "postgres://energy:energy@127.0.0.1:5432/energy"; +const PORT = process.env.PORT || 8090; +const OFFLINE_AFTER_S = 120; // 4 пропущенных периода LoRa-узла + +const pg = new Pool({ connectionString: PG_URL }); +const mq = mqtt.connect(MQTT_URL, { will: { + topic: "services/provisioner/status", payload: "offline", retain: true +}}); + +const lastRelays = new Map(); // node_id -> [..] для детекта фронтов + +// ---------------- MQTT ---------------- +mq.on("connect", () => { + mq.publish("services/provisioner/status", "online", { retain: true }); + mq.subscribe(["nodes/+/info", "nodes/+/data", "nodes/+/status"]); + console.log("MQTT connected:", MQTT_URL); +}); + +mq.on("message", async (topic, payload) => { + const [, nodeId, kind] = topic.split("/"); + try { + if (kind === "info") await onInfo(nodeId, JSON.parse(payload)); + if (kind === "data") await onData(nodeId, JSON.parse(payload)); + if (kind === "status") await onStatus(nodeId, payload.toString()); + } catch (e) { + console.error(`${topic}: ${e.message}`); + } +}); + +// узел представился — регистрируем или обновляем паспорт +async function onInfo(id, m) { + await pg.query(` + INSERT INTO devices (node_id, transport, caps, fw, ip, mac, gw, last_seen, online) + VALUES ($1,$2,$3,$4,$5,$6,$7,now(),TRUE) + ON CONFLICT (node_id) DO UPDATE SET + transport=$2, caps=$3, fw=$4, + ip=COALESCE($5, devices.ip), mac=COALESCE($6, devices.mac), + gw=COALESCE($7, devices.gw), last_seen=now(), online=TRUE`, + [id, m.hw || "lora", m.caps || {}, m.fw || null, + m.ip || null, m.mac || null, m.gw || null]); + console.log(`info: ${id} (${m.hw})`); +} + +// телеметрия +async function onData(id, m) { + // авторегистрация, если data пришла раньше info + await pg.query(` + INSERT INTO devices (node_id, transport) VALUES ($1,$2) + ON CONFLICT (node_id) DO NOTHING`, [id, m.rssi != null ? "lora" : "lan"]); + + await pg.query(` + UPDATE devices SET last_seen=now(), online=TRUE, + last_rssi=$2, last_snr=$3, gw=COALESCE($4, gw) + WHERE node_id=$1`, + [id, m.rssi ?? null, m.snr ?? null, m.gw ?? null]); + + await pg.query(` + INSERT INTO telemetry (node_id, v, a, w, kwh, pf, hz, relays, rssi, snr) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, + [id, m.v ?? null, m.a ?? null, m.w ?? null, m.kwh ?? null, + m.pf ?? null, m.hz ?? null, JSON.stringify(m.r ?? []), + m.rssi ?? null, m.snr ?? null]); + + // фронты реле → журнал + const prev = lastRelays.get(id); + if (Array.isArray(m.r)) { + if (prev) { + for (let i = 0; i < m.r.length; i++) { + if (prev[i] !== m.r[i]) { + await pg.query( + `INSERT INTO relay_events (node_id, relay_n, state) VALUES ($1,$2,$3)`, + [id, i, !!m.r[i]]); + } + } + } + lastRelays.set(id, m.r.slice()); + } +} + +// LWT LAN-узлов +async function onStatus(id, s) { + await pg.query(`UPDATE devices SET online=$2 WHERE node_id=$1`, + [id, s === "online"]); +} + +// LoRa-узлы без LWT: оффлайн по таймауту +setInterval(async () => { + await pg.query(` + UPDATE devices SET online=FALSE + WHERE online AND transport='lora' + AND last_seen < now() - make_interval(secs => $1)`, [OFFLINE_AFTER_S]); +}, 30000); + +// ---------------- HA MQTT Discovery ---------------- +// вызывается при approve: сущности появляются в HA автоматически +async function publishHaDiscovery(d) { + const dev = { + identifiers: [`energy_${d.node_id}`], + name: d.name || d.node_id, + manufacturer: "МПО-Информ", + model: d.transport === "lan" ? "Energy Node LAN" : "Energy Node LoRa", + sw_version: d.fw || "", + }; + const base = `nodes/${d.node_id}`; + const pub = (t, cfg) => + mq.publish(t, JSON.stringify(cfg), { retain: true }); + + const sensors = [ + ["v", "Напряжение", "voltage", "V", null], + ["a", "Ток", "current", "A", null], + ["w", "Мощность", "power", "W", null], + ["kwh", "Энергия", "energy", "kWh", "total_increasing"], + ]; + for (const [key, name, cls, unit, stateClass] of sensors) { + const cfg = { + name, unique_id: `${d.node_id}_${key}`, + state_topic: `${base}/data`, + value_template: `{{ value_json.${key} }}`, + device_class: cls, unit_of_measurement: unit, device: dev, + }; + if (stateClass) cfg.state_class = stateClass; + pub(`homeassistant/sensor/${d.node_id}_${key}/config`, cfg); + } + + const nRelays = d.caps?.relays || 0; + const names = d.relay_names || []; + for (let i = 0; i < nRelays; i++) { + pub(`homeassistant/switch/${d.node_id}_r${i}/config`, { + name: names[i] || `Реле ${i + 1}`, + unique_id: `${d.node_id}_r${i}`, + state_topic: `${base}/data`, + value_template: `{{ 'ON' if value_json.r[${i}] == 1 else 'OFF' }}`, + command_topic: `${base}/cmd`, + payload_on: JSON.stringify({ cmd: "relay", n: i, s: 1 }), + payload_off: JSON.stringify({ cmd: "relay", n: i, s: 0 }), + device: dev, + }); + } +} + +// ---------------- REST API для интерфейса ---------------- +const app = express(); +app.use(express.json()); + +// список устройств (+ последняя телеметрия); ?status=pending|active +app.get("/api/devices", async (req, res) => { + const { status, project } = req.query; + const cond = [], args = []; + if (status) { args.push(status); cond.push(`status=$${args.length}`); } + if (project) { args.push(project); cond.push(`project=$${args.length}`); } + const where = cond.length ? "WHERE " + cond.join(" AND ") : ""; + const { rows } = await pg.query( + `SELECT * FROM v_devices_board ${where} ORDER BY project, name NULLS LAST, node_id`, args); + res.json(rows); +}); + +// подтвердить устройство (добавить на сервер) +app.post("/api/devices/:id/approve", async (req, res) => { + const { name, project, object, relay_names, approved_by } = req.body || {}; + const { rows } = await pg.query(` + UPDATE devices SET status='active', name=$2, project=$3, object=$4, + relay_names=$5, approved_at=now(), approved_by=$6 + WHERE node_id=$1 RETURNING *`, + [req.params.id, name || req.params.id, project || null, object || null, + JSON.stringify(relay_names || []), approved_by || "ui"]); + if (!rows.length) return res.status(404).json({ error: "not found" }); + await publishHaDiscovery(rows[0]); + res.json(rows[0]); +}); + +// отклонить / отключить +app.post("/api/devices/:id/disable", async (req, res) => { + await pg.query(`UPDATE devices SET status='disabled' WHERE node_id=$1`, + [req.params.id]); + res.json({ ok: true }); +}); + +// команда узлу (реле, poll) + журнал +app.post("/api/devices/:id/cmd", async (req, res) => { + const payload = req.body || {}; + mq.publish(`nodes/${req.params.id}/cmd`, JSON.stringify(payload)); + await pg.query( + `INSERT INTO commands (node_id, payload, source) VALUES ($1,$2,'ui')`, + [req.params.id, JSON.stringify(payload)]); + res.json({ ok: true }); +}); + +// история для графиков: ?hours=24 +app.get("/api/devices/:id/history", async (req, res) => { + const hours = Math.min(parseInt(req.query.hours || "24", 10), 720); + const { rows } = await pg.query(` + SELECT ts, v, a, w, kwh FROM telemetry + WHERE node_id=$1 AND ts > now() - make_interval(hours => $2) + ORDER BY ts`, [req.params.id, hours]); + res.json(rows); +}); + +// журнал реле +app.get("/api/devices/:id/relay-events", async (req, res) => { + const { rows } = await pg.query(` + SELECT ts, relay_n, state FROM relay_events + WHERE node_id=$1 ORDER BY ts DESC LIMIT 100`, [req.params.id]); + res.json(rows); +}); + +app.listen(PORT, () => console.log(`API on :${PORT}`)); diff --git a/lora/schema.sql b/lora/schema.sql new file mode 100644 index 0000000..d6411b0 --- /dev/null +++ b/lora/schema.sql @@ -0,0 +1,77 @@ +-- ================================================================ +-- Схема БД: учёт устройств энергомониторинга (Егорин / ORBIT) +-- PostgreSQL 14+ (LXC на Proxmox, та же база что у Лилии — ок) +-- ================================================================ + +CREATE TABLE IF NOT EXISTS devices ( + node_id TEXT PRIMARY KEY, -- eg01, eg-lan1, orb1... + name TEXT, -- «Щит насосной», задаёт админ + project TEXT, -- 'egorin' | 'orbit' + object TEXT, -- площадка/объект + transport TEXT NOT NULL DEFAULT 'lora', -- 'lora' | 'lan' + status TEXT NOT NULL DEFAULT 'pending', -- pending|active|disabled + caps JSONB DEFAULT '{}', -- {"pzem":true,"relays":4,"ctl":true} + relay_names JSONB DEFAULT '[]', -- ["Насос","Свет",...] + fw TEXT, + ip INET, + mac MACADDR, + gw TEXT, -- через какой шлюз (lora) + last_seen TIMESTAMPTZ, + last_rssi INT, + last_snr NUMERIC(4,1), + online BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + approved_at TIMESTAMPTZ, + approved_by TEXT +); + +CREATE TABLE IF NOT EXISTS telemetry ( + id BIGSERIAL PRIMARY KEY, + node_id TEXT NOT NULL REFERENCES devices(node_id) ON DELETE CASCADE, + ts TIMESTAMPTZ NOT NULL DEFAULT now(), + v NUMERIC(6,1), + a NUMERIC(8,3), + w NUMERIC(9,1), + kwh NUMERIC(12,3), + pf NUMERIC(4,2), + hz NUMERIC(4,1), + relays JSONB, -- [1,0,1,0] + rssi INT, + snr NUMERIC(4,1) +); +CREATE INDEX IF NOT EXISTS telemetry_node_ts ON telemetry (node_id, ts DESC); + +-- журнал команд (кто/что/когда переключил) +CREATE TABLE IF NOT EXISTS commands ( + id BIGSERIAL PRIMARY KEY, + node_id TEXT NOT NULL REFERENCES devices(node_id) ON DELETE CASCADE, + ts TIMESTAMPTZ NOT NULL DEFAULT now(), + payload JSONB NOT NULL, -- {"cmd":"relay","n":0,"s":1} + source TEXT, -- 'ui' | 'n8n' | 'ha' | 'api' + confirmed BOOLEAN DEFAULT FALSE -- узел подтвердил телеметрией +); + +-- события смены состояния реле (для журнала в UI) +CREATE TABLE IF NOT EXISTS relay_events ( + id BIGSERIAL PRIMARY KEY, + node_id TEXT NOT NULL REFERENCES devices(node_id) ON DELETE CASCADE, + ts TIMESTAMPTZ NOT NULL DEFAULT now(), + relay_n INT NOT NULL, + state BOOLEAN NOT NULL +); +CREATE INDEX IF NOT EXISTS relay_events_node_ts ON relay_events (node_id, ts DESC); + +-- удобное представление для экрана «Устройства» +CREATE OR REPLACE VIEW v_devices_board AS +SELECT d.*, + t.v, t.a, t.w, t.kwh, t.relays, + t.ts AS telemetry_ts +FROM devices d +LEFT JOIN LATERAL ( + SELECT * FROM telemetry + WHERE node_id = d.node_id + ORDER BY ts DESC LIMIT 1 +) t ON TRUE; + +-- ретенция сырой телеметрии: 90 дней (крон раз в сутки) +-- DELETE FROM telemetry WHERE ts < now() - INTERVAL '90 days'; diff --git a/lora/ui-prototype.html b/lora/ui-prototype.html new file mode 100644 index 0000000..35ec0ae --- /dev/null +++ b/lora/ui-prototype.html @@ -0,0 +1,336 @@ + + + + + +ORBIT Energy — Устройства + + + + + + + + +
+
+

Устройства

+
+ + + +
+
+ + +
+
+
+ Обнаружены новые устройства + Узлы вышли в эфир и ждут добавления на сервер +
+
+
+ +
+
+ + + + +
+ +
+ + + + diff --git a/server/src/lora.js b/server/src/lora.js new file mode 100644 index 0000000..245771e --- /dev/null +++ b/server/src/lora.js @@ -0,0 +1,101 @@ +// LoRa/LAN энергомониторинг: реестр устройств + приём телеметрии от шлюза-концентратора. +// Датчики (LoRa 869МГц / LAN) → шлюз (Ethernet) → POST /api/lora/ingest → этот реестр. +// In-memory (демо/без Postgres). Модель — по schema.sql (devices/telemetry/relay_events). +import { EventEmitter } from 'node:events'; + +export const loraBus = new EventEmitter(); +const devices = new Map(); // node_id -> device +const gateways = new Map(); // gw_id -> gateway +const relayLog = []; // события реле (последние) + +function now() { return new Date().toISOString(); } + +// Приём паспорта узла (nodes//info). Неизвестный → pending (самрегистрация). +export function ingestInfo(p) { + const id = p.id; + if (!id) return; + const d = devices.get(id) || { node_id: id, status: 'pending', created_at: now(), relays: [] }; + d.transport = p.hw || d.transport || 'lora'; + d.caps = p.caps || d.caps || {}; + d.relay_names = d.relay_names && d.relay_names.length ? d.relay_names : Array.from({ length: (p.caps?.relays || 0) }, (_, i) => `Реле ${i + 1}`); + d.fw = p.fw || d.fw; d.ip = p.ip || d.ip; d.mac = p.mac || d.mac; d.gw = p.gw || d.gw; + d.online = true; d.last_seen = now(); + if (!d.relays.length) d.relays = Array.from({ length: (p.caps?.relays || 0) }, () => 0); + devices.set(id, d); + loraBus.emit('device', d); + return d; +} + +// Приём телеметрии (nodes//data): v,a,w,kwh,pf,hz,r[],rssi,snr,gw. +export function ingestData(p) { + const id = p.id; + const d = devices.get(id); + if (!d) return ingestInfo({ id, hw: p.gw ? 'lora' : 'lan', caps: { relays: (p.r || []).length } }); + Object.assign(d, { + v: p.v, a: p.a, w: p.w, kwh: p.kwh, pf: p.pf, hz: p.hz, + relays: Array.isArray(p.r) ? p.r : d.relays, + last_rssi: p.rssi ?? d.last_rssi, last_snr: p.snr ?? d.last_snr, gw: p.gw || d.gw, + online: true, last_seen: now(), telemetry_ts: now(), + }); + devices.set(id, d); + loraBus.emit('device', d); + return d; +} + +export function listDevices({ status, project } = {}) { + return [...devices.values()].filter((d) => (!status || d.status === status) && (!project || d.project === project)); +} +export function approve(id, { name, project, object, relay_names } = {}) { + const d = devices.get(id); if (!d) return null; + d.status = 'active'; d.name = name || d.name; d.project = project || d.project || 'egorin'; + d.object = object || d.object; if (Array.isArray(relay_names)) d.relay_names = relay_names; + d.approved_at = now(); + loraBus.emit('device', d); return d; +} +export function disable(id) { const d = devices.get(id); if (d) { d.status = 'disabled'; loraBus.emit('device', d); } return d || null; } +export function cmd(id, payload, source = 'ui') { + const d = devices.get(id); if (!d) return null; + if (payload?.cmd === 'relay' && typeof payload.n === 'number') { + d.relays[payload.n] = payload.s ? 1 : 0; + relayLog.unshift({ node_id: id, name: d.name || id, relay_n: payload.n, relay_name: d.relay_names?.[payload.n], state: !!payload.s, ts: now(), source }); + if (relayLog.length > 200) relayLog.pop(); + loraBus.emit('device', d); + } + return d; +} +export const listGateways = () => [...gateways.values()]; +export const relayEvents = () => relayLog.slice(0, 50); + +// ── Демо-начинка: шлюз + датчики (LoRa/LAN) с живой телеметрией ── +export function seedLora() { + gateways.set('gw1', { gw_id: 'gw1', name: 'Шлюз щитовой (ESP32+RA-01H+W5500)', ip: '192.168.1.77', online: true, nodes: 4, avg_rssi: -95, last_seen: now() }); + const seed = [ + { id: 'eg01', name: 'ТП-14 насосная', project: 'egorin', object: 'ТП-14 «Бештау»', hw: 'lora', caps: { pzem: true, relays: 2, ctl: true }, gw: 'gw1', rssi: -92, relay_names: ['Насос-1', 'Насос-2'], status: 'active', base: 4.2 }, + { id: 'eg-lan1', name: 'ТП-7 щит учёта', project: 'egorin', object: 'ТП-7 «Центр»', hw: 'lan', caps: { pzem: true, relays: 4, ctl: true }, ip: '192.168.1.91', relay_names: ['Ввод-1', 'Ввод-2', 'Освещение', 'Вентиляция'], status: 'active', base: 12.5 }, + { id: 'orb1', name: 'ORBIT склад', project: 'orbit', object: 'Склад «Весовая»', hw: 'lora', caps: { pzem: true, relays: 1, ctl: true }, gw: 'gw1', rssi: -104, relay_names: ['Освещение цех'], status: 'active', base: 6.1 }, + { id: 'eg-lan2', name: 'РП-2 распред.', project: 'egorin', object: 'РП-2', hw: 'lan', caps: { pzem: true, relays: 4, ctl: true }, ip: '192.168.1.93', relay_names: ['Фидер-1', 'Фидер-2', 'Фидер-3', 'Резерв'], status: 'active', base: 21.0 }, + { id: 'eg02', name: null, project: null, object: null, hw: 'lora', caps: { pzem: true, relays: 2, ctl: true }, gw: 'gw1', rssi: -111, status: 'pending', base: 3.4 }, // pending — только вышел в эфир + ]; + seed.forEach((s) => { + ingestInfo({ id: s.id, hw: s.hw, caps: s.caps, fw: '2.1.0', ip: s.ip, gw: s.gw }); + const d = devices.get(s.id); + d.name = s.name; d.project = s.project; d.object = s.object; d.status = s.status; + if (s.relay_names) d.relay_names = s.relay_names; + d.relays = Array.from({ length: s.caps.relays }, (_, i) => (i === 0 ? 1 : 0)); + d._base = s.base; d._kwh = Math.round(s.base * 240 + Math.random() * 500); + d.last_rssi = s.rssi; + }); + // Тикер телеметрии активных узлов + setInterval(() => { + for (const d of devices.values()) { + if (d.status === 'disabled') continue; + const load = d._base ? d._base * (0.7 + Math.random() * 0.5) * (d.relays?.some((r) => r) ? 1 : 0.15) : 0; + const v = +(224 + Math.random() * 12).toFixed(1); + const a = +(load).toFixed(3); + const pf = +(0.88 + Math.random() * 0.1).toFixed(2); + const w = +(v * a * pf).toFixed(1); + d._kwh = (d._kwh || 0) + w / 3600 / 1000 * 3; // за 3с + ingestData({ id: d.node_id, v, a, w, kwh: +d._kwh.toFixed(3), pf, hz: +(49.9 + Math.random() * 0.2).toFixed(1), r: d.relays, rssi: d.last_rssi, gw: d.gw }); + } + }, 3000); +} diff --git a/server/src/server.js b/server/src/server.js index 6d6e721..f8ac7a7 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -8,11 +8,13 @@ import * as tenants from './tenants.js'; import * as metrics from './metrics.js'; import * as store from './store.js'; import { seedDemo } from './demo.js'; +import * as lora from './lora.js'; import { startMqtt } from './adapters/mqtt.js'; import { startTraccar } from './adapters/traccar.js'; import { startInflux } from './adapters/influx.js'; seedDemo(); +lora.seedLora(); const live = { wirenboard: startMqtt(), traccar: startTraccar(), influx: startInflux() }; const app = express(); @@ -121,6 +123,21 @@ app.get('/api/cabinet/links', auth, (req, res) => res.json(tenants.linksOf(req.u app.post('/api/cabinet/links', auth, (req, res) => res.json(tenants.addLink(req.user.id, req.body?.email))); app.delete('/api/cabinet/links/:email', auth, (req, res) => res.json(tenants.removeLink(req.user.id, decodeURIComponent(req.params.email)))); +// --- LoRa/LAN энергомониторинг: приём от шлюза-концентратора + реестр устройств --- +// Концентратор (LAN-шлюз) шлёт паспорт/телеметрию узлов сюда (открытый ingress железа). +app.post('/api/lora/ingest', (req, res) => { + const b = req.body || {}; + const items = Array.isArray(b) ? b : [b]; + for (const it of items) (it.t === 'info' ? lora.ingestInfo(it) : lora.ingestData(it)); + res.json({ ok: true, count: items.length }); +}); +app.get('/api/lora/devices', auth, (req, res) => res.json(can(req.user, 'energy', 'view') ? lora.listDevices({ status: req.query.status, project: req.query.project }) : [])); +app.get('/api/lora/gateways', auth, (req, res) => res.json(lora.listGateways())); +app.get('/api/lora/relay-events', auth, (req, res) => res.json(lora.relayEvents())); +app.post('/api/lora/devices/:id/approve', auth, (req, res) => res.json(lora.approve(req.params.id, req.body || {}) || { error: 'not found' })); +app.post('/api/lora/devices/:id/disable', auth, (req, res) => res.json(lora.disable(req.params.id) || { error: 'not found' })); +app.post('/api/lora/devices/:id/cmd', auth, (req, res) => res.json(lora.cmd(req.params.id, req.body || {}, 'ui') || { error: 'not found' })); + // --- Яндекс ID (OAuth) --- app.get('/api/auth/yandex/start', (_req, res) => { if (!config.yandex) return res.redirect('/?autherror=noyandex'); diff --git a/src/config/modules.ts b/src/config/modules.ts index b88c056..8c75e8c 100644 --- a/src/config/modules.ts +++ b/src/config/modules.ts @@ -1,6 +1,7 @@ import type { ComponentType } from 'react'; -import { Zap, Cpu, Navigation, Network, ShieldCheck, Siren, Smartphone, FolderGit2, Gauge, Wallet, BellRing, MapPinned, History, MapPin, Building2, Archive, Boxes } from 'lucide-react'; +import { Zap, Cpu, Navigation, Network, ShieldCheck, Siren, Smartphone, FolderGit2, Gauge, Wallet, BellRing, MapPinned, History, MapPin, Building2, Archive, Boxes, RadioTower } from 'lucide-react'; import { EnergyModule } from '@/modules/energy/EnergyModule'; +import { LoraModule } from '@/modules/energy/LoraModule'; import { SmartModule } from '@/modules/smart/SmartModule'; import { LocationsModule } from '@/modules/smart/LocationsModule'; import { SecurityModule } from '@/modules/security/SecurityModule'; @@ -33,6 +34,7 @@ export interface ModuleDef { export const MODULES: ModuleDef[] = [ { id: 'energy', path: '/energy', title: 'Энергомониторинг', short: 'Энерго', icon: Zap, component: EnergyModule, section: 'energy' }, + { id: 'lora', path: '/lora', title: 'Энерго LoRa/LAN', short: 'LoRa', icon: RadioTower, component: LoraModule, section: 'energy' }, { id: 'smart', path: '/smart', title: 'Смартпанель', short: 'Смарт', icon: Cpu, component: SmartModule, section: 'smart' }, { id: 'smart-locations', path: '/smart-locations', title: 'Локации', short: 'Локации', icon: MapPin, component: LocationsModule, section: 'smart', hidden: true }, { id: 'security', path: '/security', title: 'Охранный пульт (ПЦН)', short: 'Охрана', icon: Siren, component: SecurityModule, section: 'security' }, diff --git a/src/modules/energy/LoraModule.tsx b/src/modules/energy/LoraModule.tsx new file mode 100644 index 0000000..8e6b11e --- /dev/null +++ b/src/modules/energy/LoraModule.tsx @@ -0,0 +1,148 @@ +import { useEffect, useState, useCallback } from 'react'; +import { RadioTower, Cable, Wifi, Zap, Plus, Router } from 'lucide-react'; +import { apiGet, apiPost } from '@/api/client'; +import { cn } from '@/lib/utils'; + +interface LoraDevice { + node_id: string; name?: string | null; project?: string | null; object?: string | null; + transport: 'lora' | 'lan'; status: 'pending' | 'active' | 'disabled'; + caps?: { pzem?: boolean; relays?: number; ctl?: boolean }; relay_names?: string[]; + fw?: string; ip?: string; gw?: string; last_seen?: string; last_rssi?: number; online?: boolean; + v?: number; a?: number; w?: number; kwh?: number; pf?: number; hz?: number; relays?: number[]; +} +interface Gateway { gw_id: string; name: string; ip: string; online: boolean; nodes: number; avg_rssi: number } + +const lastSeen = (iso?: string) => { + if (!iso) return '—'; + const s = Math.round((Date.now() - new Date(iso).getTime()) / 1000); + return s < 60 ? `${s} с назад` : s < 3600 ? `${Math.round(s / 60)} мин назад` : `${Math.round(s / 3600)} ч назад`; +}; + +export function LoraModule() { + const [devices, setDevices] = useState([]); + const [gws, setGws] = useState([]); + const [filter, setFilter] = useState<'all' | 'egorin' | 'orbit'>('all'); + + const load = useCallback(async () => { + try { + const [d, g] = await Promise.all([apiGet('/lora/devices'), apiGet('/lora/gateways')]); + setDevices(d); setGws(g); + } catch { /* ignore */ } + }, []); + useEffect(() => { load(); const t = setInterval(load, 3000); return () => clearInterval(t); }, [load]); + + const pending = devices.filter((d) => d.status === 'pending'); + const active = devices.filter((d) => d.status === 'active' && (filter === 'all' || d.project === filter)); + + const approve = async (id: string) => { await apiPost(`/lora/devices/${id}/approve`, { name: id, project: 'egorin' }); load(); }; + const toggleRelay = async (id: string, n: number, cur: number) => { await apiPost(`/lora/devices/${id}/cmd`, { cmd: 'relay', n, s: cur ? 0 : 1 }); load(); }; + + const sumKwh = active.reduce((s, d) => s + (d.kwh || 0), 0); + const sumW = active.reduce((s, d) => s + (d.w || 0), 0); + + return ( +
+
+
+ +
+
Энерго LoRa/LAN
+
датчики PZEM → шлюз-концентратор → сервер · MQTT-топики nodes/<id>
+
+
+
+ {active.length} узлов + Σ {(sumW / 1000).toFixed(1)} кВт + Σ {sumKwh.toFixed(1)} кВт·ч +
+
+ + {/* Шлюзы */} +
+ {gws.map((g) => ( +
+ + {g.gw_id} + {g.ip} · {g.nodes} узлов · RSSI ~{g.avg_rssi} дБм +
+ ))} +
+ + {/* Обнаружены новые устройства (pending) */} + {pending.length > 0 && ( +
+
Обнаружены новые устройства ({pending.length})
+
+ {pending.map((d) => ( +
+
+ {d.node_id} + + реле: {d.caps?.relays ?? 0} · {lastSeen(d.last_seen)} +
+ +
+ ))} +
+
+ )} + + {/* Фильтр-чипы */} +
+ {(['all', 'egorin', 'orbit'] as const).map((f) => ( + + ))} +
+ + {/* Карточки узлов */} +
+ {active.map((d) => ( +
+
+
+
+ + {d.name || d.node_id} +
+
{d.object || '—'} · {d.node_id}
+
+ +
+
+ {[['V', d.v, 'В'], ['A', d.a, 'А'], ['W', d.w, 'Вт'], ['kWh', d.kwh, 'кВт·ч']].map(([k, val, u]) => ( +
+
{val != null ? Number(val).toFixed(k === 'A' ? 2 : k === 'kWh' ? 1 : 0) : '—'}
+
{u as string}
+
+ ))} +
+ {(d.relays?.length ?? 0) > 0 && ( +
+ {d.relays!.map((r, n) => ( + + ))} +
+ )} +
{lastSeen(d.last_seen)}{d.transport === 'lora' && d.last_rssi != null ? ` · RSSI ${d.last_rssi} дБм` : ''}{d.ip ? ` · ${d.ip}` : ''}
+
+ ))} +
+ {active.length === 0 &&
Нет активных узлов в этом фильтре.
} +
+ ); +} + +function Badge({ d }: { d: LoraDevice }) { + const lora = d.transport === 'lora'; + return ( + + {lora ? : } + {lora ? 'LoRa' : 'LAN'} + + ); +} diff --git a/src/modules/gps/MapClusterGL.tsx b/src/modules/gps/MapClusterGL.tsx index 1c8431f..7d8f230 100644 --- a/src/modules/gps/MapClusterGL.tsx +++ b/src/modules/gps/MapClusterGL.tsx @@ -91,7 +91,7 @@ export function MapClusterGL({ entities, threeD, onSelect, className }: { entiti useEffect(() => { if (!box.current || mapRef.current) return; - const map = new maplibregl.Map({ container: box.current, center: [43.0, 44.04], zoom: 10.4, style: STYLE, attributionControl: { compact: true } as any, preserveDrawingBuffer: true }); + const map = new maplibregl.Map({ container: box.current, center: [43.0, 44.04], zoom: 10.4, style: STYLE, attributionControl: { compact: true }, preserveDrawingBuffer: true } as any); mapRef.current = map; map.touchZoomRotate.enableRotation(); // Fix: MapLibre рисует пустой холст, если контейнер доразметился после init.