egorin: LoRa/LAN энергомониторинг — кит в /lora (прошивки+schema+provisioner+UI), бэкенд ingest от шлюза-концентратора + реестр устройств, фронт-раздел (карточки/реле/pending/шлюзы)
This commit is contained in:
@@ -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/<id>/info` | узел → сервер, **retained** | паспорт: `{"id","fw","hw":"lan\|lora","caps":{"pzem":true,"relays":4,"ctl":true},"ip","mac","gw"}` |
|
||||
| `nodes/<id>/data` | узел → сервер | телеметрия: v, a, w, kwh, pf, hz, r[], seq (+rssi/snr/gw для LoRa) |
|
||||
| `nodes/<id>/status` | LAN-узел → сервер | `online`/`offline` (LWT). LoRa-узлам статус ставит сервер по таймауту last_seen |
|
||||
| `nodes/<id>/cmd` | сервер → узел | `{"cmd":"relay","n":0,"s":1}` · `{"cmd":"poll"}` · `{"cmd":"info"}` |
|
||||
| `gateways/<id>/status` | шлюз | online/offline (LWT) |
|
||||
|
||||
LAN-узлы публикуют напрямую; LoRa-узлы шлют JSON с полем `"t":"info"|"d"`,
|
||||
шлюз раскладывает по тем же топикам и добавляет rssi/snr/gw.
|
||||
|
||||
## 4. Механизм добавления устройств на сервер
|
||||
|
||||
Принцип: **узел регистрируется сам, человек только подтверждает.**
|
||||
Прошивка одинаковая для всех узлов, отличается только `NODE_ID` — на объекте
|
||||
монтажнику ничего настраивать не нужно.
|
||||
|
||||
1. **Выход в эфир.** При включении узел публикует `nodes/<id>/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/<id>/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 мин, ток выше порога, внеплановое отключение реле).
|
||||
@@ -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/<id>/info (retained)
|
||||
* пакет {"t":"d",...} → nodes/<id>/data (+rssi/snr/gw)
|
||||
* nodes/<id>/cmd (MQTT) → LoRa вниз, адресно по id
|
||||
*
|
||||
* Онлайн/оффлайн LoRa-узлов решает сервер по last_seen
|
||||
* (у радиоузлов нет LWT).
|
||||
*
|
||||
* Библиотеки: LoRa (Sandeep Mistry), Ethernet (Arduino),
|
||||
* PubSubClient, ArduinoJson
|
||||
* ================================================================
|
||||
*/
|
||||
|
||||
#include <SPI.h>
|
||||
#include <Ethernet.h>
|
||||
#include <PubSubClient.h>
|
||||
#include <LoRa.h>
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
// ================== НАСТРОЙКИ ==================
|
||||
#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();
|
||||
}
|
||||
@@ -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/<id>/info — провижн-сервис ставит его в
|
||||
* «Ожидают добавления», админ подтверждает в интерфейсе.
|
||||
*
|
||||
* Библиотеки: ArduinoJson, PubSubClient, PZEM004Tv30
|
||||
* Плата в IDE: "WT32-ETH01 Ethernet Module" (или ESP32 Dev Module)
|
||||
* ================================================================
|
||||
*/
|
||||
|
||||
#include <ETH.h>
|
||||
#include <PubSubClient.h>
|
||||
#include <PZEM004Tv30.h>
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
// ================== НАСТРОЙКИ УЗЛА ==================
|
||||
#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/<id>/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(); }
|
||||
}
|
||||
@@ -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 <SPI.h>
|
||||
#include <LoRa.h>
|
||||
#include <PZEM004Tv30.h>
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
#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();
|
||||
}
|
||||
}
|
||||
@@ -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}`));
|
||||
@@ -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';
|
||||
@@ -0,0 +1,336 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ORBIT Energy — Устройства</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;600&family=Onest:wght@400;500;600&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{
|
||||
--bg:#070a10; --panel:rgba(15,22,36,.72); --line:rgba(74,222,128,.14);
|
||||
--line2:rgba(148,163,184,.12);
|
||||
--acc:#4ADE80; --blue:#60A5FA; --off:#64748B; --alarm:#F87171; --amber:#FBBF24;
|
||||
--tx:#E2E8F0; --tx2:#94A3B8; --tx3:#5B6B84;
|
||||
--r:14px;
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{
|
||||
background:var(--bg); color:var(--tx);
|
||||
font-family:'Onest',sans-serif; font-size:14px; min-height:100vh;
|
||||
background-image:radial-gradient(ellipse 900px 500px at 80% -10%, rgba(74,222,128,.06), transparent),
|
||||
radial-gradient(ellipse 700px 400px at 0% 110%, rgba(96,165,250,.05), transparent);
|
||||
}
|
||||
.mono{font-family:'JetBrains Mono',monospace}
|
||||
/* ---------- рейл ---------- */
|
||||
.rail{position:fixed;left:0;top:0;bottom:0;width:80px;border-right:1px solid var(--line2);
|
||||
display:flex;flex-direction:column;align-items:center;padding:18px 0;gap:6px;z-index:20;
|
||||
background:rgba(7,10,16,.85);backdrop-filter:blur(8px)}
|
||||
.logo{font-family:'Unbounded';font-size:11px;color:var(--acc);letter-spacing:.08em;
|
||||
margin-bottom:22px;text-align:center;line-height:1.3}
|
||||
.nav{width:56px;padding:10px 0;border-radius:12px;text-align:center;color:var(--tx3);
|
||||
cursor:pointer;font-size:10px;letter-spacing:.02em;transition:.15s}
|
||||
.nav svg{display:block;margin:0 auto 5px;width:20px;height:20px;stroke:currentColor;fill:none;stroke-width:1.6}
|
||||
.nav:hover{color:var(--tx2);background:rgba(148,163,184,.06)}
|
||||
.nav.on{color:var(--acc);background:rgba(74,222,128,.1)}
|
||||
/* ---------- контент ---------- */
|
||||
.wrap{margin-left:80px;padding:26px 30px 90px;max-width:1360px}
|
||||
header{display:flex;flex-wrap:wrap;align-items:center;gap:14px;margin-bottom:20px}
|
||||
h1{font-family:'Unbounded';font-weight:600;font-size:19px;letter-spacing:.01em}
|
||||
h1 small{display:block;font-family:'Onest';font-weight:400;font-size:12px;color:var(--tx3);margin-top:4px}
|
||||
.chips{display:flex;gap:8px;margin-left:auto}
|
||||
.chip{padding:7px 15px;border-radius:99px;border:1px solid var(--line2);color:var(--tx2);
|
||||
cursor:pointer;font-size:13px;transition:.15s;background:transparent}
|
||||
.chip:hover{border-color:var(--line);color:var(--tx)}
|
||||
.chip.on{border-color:var(--acc);color:var(--acc);background:rgba(74,222,128,.08)}
|
||||
/* ---------- обнаружение (сигнатурный блок) ---------- */
|
||||
.discover{border:1px solid rgba(251,191,36,.28);border-radius:var(--r);
|
||||
background:linear-gradient(90deg, rgba(251,191,36,.07), rgba(251,191,36,.02));
|
||||
padding:14px 18px;margin-bottom:22px;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
|
||||
.radar{position:relative;width:34px;height:34px;flex:none}
|
||||
.radar::before,.radar::after{content:'';position:absolute;inset:0;border-radius:50%;
|
||||
border:1.5px solid var(--amber);opacity:.8;animation:ping 2.2s cubic-bezier(0,0,.2,1) infinite}
|
||||
.radar::after{animation-delay:1.1s}
|
||||
.radar b{position:absolute;inset:12px;border-radius:50%;background:var(--amber)}
|
||||
@keyframes ping{0%{transform:scale(.4);opacity:.9}100%{transform:scale(1.35);opacity:0}}
|
||||
@media (prefers-reduced-motion:reduce){.radar::before,.radar::after{animation:none;opacity:.35}}
|
||||
.discover .txt{flex:1;min-width:200px}
|
||||
.discover .txt b{font-size:14px}
|
||||
.discover .txt span{display:block;color:var(--tx2);font-size:12.5px;margin-top:2px}
|
||||
.pending-list{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.pend{display:flex;align-items:center;gap:10px;border:1px solid rgba(251,191,36,.3);
|
||||
border-radius:10px;padding:8px 12px;background:rgba(7,10,16,.5)}
|
||||
.pend .id{font-family:'JetBrains Mono';font-size:13px}
|
||||
.pend .via{font-size:11px;color:var(--tx3)}
|
||||
.btn{border:1px solid var(--acc);background:rgba(74,222,128,.1);color:var(--acc);
|
||||
border-radius:9px;padding:7px 14px;cursor:pointer;font-family:'Onest';font-size:13px;font-weight:500;transition:.15s}
|
||||
.btn:hover{background:rgba(74,222,128,.2)}
|
||||
.btn.ghost{border-color:var(--line2);background:transparent;color:var(--tx2)}
|
||||
.btn.ghost:hover{color:var(--tx)}
|
||||
/* ---------- сетка устройств ---------- */
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px}
|
||||
.card{border:1px solid var(--line2);border-radius:var(--r);background:var(--panel);
|
||||
backdrop-filter:blur(10px);padding:16px 18px;transition:border-color .15s}
|
||||
.card:hover{border-color:var(--line)}
|
||||
.card .top{display:flex;align-items:flex-start;gap:10px;margin-bottom:12px}
|
||||
.dot{width:9px;height:9px;border-radius:50%;margin-top:5px;flex:none}
|
||||
.dot.on{background:var(--acc);box-shadow:0 0 0 0 rgba(74,222,128,.5);animation:halo 2.4s infinite}
|
||||
.dot.off{background:var(--off)}
|
||||
@keyframes halo{0%{box-shadow:0 0 0 0 rgba(74,222,128,.45)}70%{box-shadow:0 0 0 8px rgba(74,222,128,0)}100%{box-shadow:0 0 0 0 rgba(74,222,128,0)}}
|
||||
@media (prefers-reduced-motion:reduce){.dot.on{animation:none}}
|
||||
.card .name{font-weight:600;font-size:14.5px}
|
||||
.card .sub{font-family:'JetBrains Mono';font-size:11.5px;color:var(--tx3);margin-top:2px}
|
||||
.badge{margin-left:auto;font-size:10.5px;padding:3px 9px;border-radius:99px;flex:none;letter-spacing:.03em}
|
||||
.badge.lan{color:var(--blue);border:1px solid rgba(96,165,250,.35)}
|
||||
.badge.lora{color:var(--acc);border:1px solid rgba(74,222,128,.3)}
|
||||
.meters{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:12px}
|
||||
.m{background:rgba(7,10,16,.5);border-radius:9px;padding:8px 6px;text-align:center}
|
||||
.m b{font-family:'JetBrains Mono';font-size:14px;font-weight:600;display:block}
|
||||
.m span{font-size:10px;color:var(--tx3);letter-spacing:.04em}
|
||||
.relays{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.rl{display:flex;align-items:center;gap:7px;border:1px solid var(--line2);border-radius:9px;
|
||||
padding:6px 10px;cursor:pointer;user-select:none;transition:.15s;font-size:12.5px;color:var(--tx2)}
|
||||
.rl .sw{width:26px;height:15px;border-radius:99px;background:var(--off);position:relative;transition:.2s;flex:none}
|
||||
.rl .sw::after{content:'';position:absolute;top:2px;left:2px;width:11px;height:11px;border-radius:50%;
|
||||
background:var(--bg);transition:.2s}
|
||||
.rl.on{border-color:rgba(74,222,128,.35);color:var(--tx)}
|
||||
.rl.on .sw{background:var(--acc)}
|
||||
.rl.on .sw::after{left:13px}
|
||||
.card .foot{margin-top:12px;padding-top:10px;border-top:1px solid var(--line2);
|
||||
display:flex;justify-content:space-between;font-size:11.5px;color:var(--tx3)}
|
||||
.card .foot .mono{font-size:11.5px}
|
||||
/* ---------- мастер добавления ---------- */
|
||||
.overlay{position:fixed;inset:0;background:rgba(4,6,10,.7);backdrop-filter:blur(4px);
|
||||
display:none;align-items:center;justify-content:center;z-index:50;padding:16px}
|
||||
.overlay.open{display:flex}
|
||||
.modal{width:100%;max-width:520px;background:#0c1220;border:1px solid var(--line);
|
||||
border-radius:18px;padding:24px 26px;max-height:92vh;overflow:auto}
|
||||
.modal h2{font-family:'Unbounded';font-size:15px;font-weight:600;margin-bottom:4px}
|
||||
.modal .steps{display:flex;gap:6px;margin:14px 0 20px}
|
||||
.modal .steps i{flex:1;height:3px;border-radius:3px;background:var(--line2)}
|
||||
.modal .steps i.on{background:var(--acc)}
|
||||
.passport{border:1px solid var(--line2);border-radius:12px;padding:14px;margin-bottom:16px;
|
||||
font-family:'JetBrains Mono';font-size:12.5px;line-height:1.9;color:var(--tx2)}
|
||||
.passport b{color:var(--tx)}
|
||||
label{display:block;font-size:12px;color:var(--tx2);margin:12px 0 5px}
|
||||
input,select{width:100%;background:rgba(7,10,16,.6);border:1px solid var(--line2);
|
||||
border-radius:9px;padding:9px 12px;color:var(--tx);font-family:'Onest';font-size:13.5px;outline:none}
|
||||
input:focus,select:focus{border-color:var(--acc)}
|
||||
.row2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||
.modal .actions{display:flex;justify-content:space-between;gap:10px;margin-top:22px}
|
||||
.done{ text-align:center;padding:12px 0 4px}
|
||||
.done .ok{width:52px;height:52px;border-radius:50%;border:2px solid var(--acc);color:var(--acc);
|
||||
display:flex;align-items:center;justify-content:center;margin:0 auto 14px;font-size:24px}
|
||||
.done p{color:var(--tx2);font-size:13px;line-height:1.6;margin-top:8px}
|
||||
/* ---------- мобильный ---------- */
|
||||
.tabbar{display:none}
|
||||
@media (max-width:1023px){
|
||||
.rail{display:none}
|
||||
.wrap{margin-left:0;padding:18px 14px 90px}
|
||||
.chips{margin-left:0;width:100%;overflow-x:auto}
|
||||
.tabbar{display:flex;position:fixed;left:0;right:0;bottom:0;height:62px;z-index:20;
|
||||
background:rgba(7,10,16,.92);backdrop-filter:blur(10px);border-top:1px solid var(--line2)}
|
||||
.tabbar .nav{flex:1;width:auto;border-radius:0;padding:9px 0}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="rail">
|
||||
<div class="logo">ORBIT<br>ENERGY</div>
|
||||
<div class="nav"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/></svg>Обзор</div>
|
||||
<div class="nav on"><svg viewBox="0 0 24 24"><path d="M13 2 4.5 13.5H11L10 22l8.5-11.5H12L13 2z"/></svg>Устройства</div>
|
||||
<div class="nav"><svg viewBox="0 0 24 24"><circle cx="12" cy="17" r="2.5"/><path d="M6 12.5a8.5 8.5 0 0 1 12 0M3 9a13 13 0 0 1 18 0"/></svg>Шлюзы</div>
|
||||
<div class="nav"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg>Журнал</div>
|
||||
<div class="nav"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.2-1.6l2-1.5-2-3.4-2.3 1a7 7 0 0 0-2.8-1.6L13.3 2h-2.6l-.4 2.9a7 7 0 0 0-2.8 1.6l-2.3-1-2 3.4 2 1.5A7 7 0 0 0 5 12c0 .55.07 1.08.2 1.6l-2 1.5 2 3.4 2.3-1a7 7 0 0 0 2.8 1.6l.4 2.9h2.6l.4-2.9a7 7 0 0 0 2.8-1.6l2.3 1 2-3.4-2-1.5c.13-.52.2-1.05.2-1.6z"/></svg>Настройки</div>
|
||||
</nav>
|
||||
|
||||
<main class="wrap">
|
||||
<header>
|
||||
<h1>Устройства<small id="counter"></small></h1>
|
||||
<div class="chips" id="chips">
|
||||
<button class="chip on" data-p="all">Все</button>
|
||||
<button class="chip" data-p="egorin">Егорин</button>
|
||||
<button class="chip" data-p="orbit">ORBIT</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- обнаруженные, ждут добавления -->
|
||||
<section class="discover" id="discover">
|
||||
<div class="radar"><b></b></div>
|
||||
<div class="txt">
|
||||
<b>Обнаружены новые устройства</b>
|
||||
<span>Узлы вышли в эфир и ждут добавления на сервер</span>
|
||||
</div>
|
||||
<div class="pending-list" id="pending"></div>
|
||||
</section>
|
||||
|
||||
<section class="grid" id="grid"></section>
|
||||
</main>
|
||||
|
||||
<nav class="tabbar">
|
||||
<div class="nav"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/></svg>Обзор</div>
|
||||
<div class="nav on"><svg viewBox="0 0 24 24"><path d="M13 2 4.5 13.5H11L10 22l8.5-11.5H12L13 2z"/></svg>Устройства</div>
|
||||
<div class="nav"><svg viewBox="0 0 24 24"><circle cx="12" cy="17" r="2.5"/><path d="M6 12.5a8.5 8.5 0 0 1 12 0M3 9a13 13 0 0 1 18 0"/></svg>Шлюзы</div>
|
||||
<div class="nav"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg>Журнал</div>
|
||||
</nav>
|
||||
|
||||
<!-- мастер добавления -->
|
||||
<div class="overlay" id="wiz">
|
||||
<div class="modal">
|
||||
<h2 id="wizTitle">Добавить устройство</h2>
|
||||
<div class="steps"><i class="on" id="s1"></i><i id="s2"></i><i id="s3"></i></div>
|
||||
<div id="wizBody"></div>
|
||||
<div class="actions" id="wizActions"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ================= демо-данные (в бою: GET /api/devices) =================
|
||||
const pendingDevices = [
|
||||
{node_id:'eg03', transport:'lora', fw:'2.0', gw:'gw1', rssi:-101, caps:{pzem:true,relays:4,ctl:true}},
|
||||
{node_id:'orb-lan2', transport:'lan', fw:'2.0', ip:'192.168.1.147', mac:'A4:CF:12:8B:03:1D', caps:{pzem:true,relays:2,ctl:true}},
|
||||
];
|
||||
let devices = [
|
||||
{node_id:'eg01', name:'Щит насосной', project:'egorin', transport:'lora', online:true,
|
||||
v:230.4, a:4.82, w:1084, kwh:1284.6, r:[1,0,1,0], rn:['Насос 1','Насос 2','Прогрев','Резерв'], rssi:-94, seen:'32 с назад'},
|
||||
{node_id:'eg02', name:'КТП, ввод 1', project:'egorin', transport:'lora', online:true,
|
||||
v:228.9, a:12.4, w:2790, kwh:8112.3, r:[1,1], rn:['Освещение','Вентиляция'], rssi:-88, seen:'12 с назад'},
|
||||
{node_id:'orb-lan1', name:'Серверная, стойка A', project:'orbit', transport:'lan', online:true,
|
||||
v:231.2, a:6.05, w:1398, kwh:940.8, r:[1,1,1,0], rn:['Стойка','ИБП обход','Кондиционер','Резерв'], ip:'192.168.1.141', seen:'6 с назад'},
|
||||
{node_id:'orb02', name:'Мачта, узел связи', project:'orbit', transport:'lora', online:false,
|
||||
v:null, a:null, w:null, kwh:412.1, r:[0,0], rn:['Обогрев','Подсветка'], rssi:-112, seen:'18 мин назад'},
|
||||
];
|
||||
let filter='all';
|
||||
|
||||
// ================= рендер =================
|
||||
const fmt=(x,d=1)=>x==null?'—':Number(x).toFixed(d);
|
||||
function render(){
|
||||
const list=devices.filter(d=>filter==='all'||d.project===filter);
|
||||
document.getElementById('counter').textContent=
|
||||
`${list.filter(d=>d.online).length} в сети · ${list.length} всего`;
|
||||
document.getElementById('grid').innerHTML=list.map(d=>`
|
||||
<article class="card">
|
||||
<div class="top">
|
||||
<div class="dot ${d.online?'on':'off'}"></div>
|
||||
<div>
|
||||
<div class="name">${d.name}</div>
|
||||
<div class="sub">${d.node_id} · ${d.project==='egorin'?'Егорин':'ORBIT'}</div>
|
||||
</div>
|
||||
<div class="badge ${d.transport}">${d.transport==='lan'?'LAN':'LoRa '+(d.rssi??'')+' dBm'}</div>
|
||||
</div>
|
||||
<div class="meters">
|
||||
<div class="m"><b>${fmt(d.v)}</b><span>В</span></div>
|
||||
<div class="m"><b>${fmt(d.a,2)}</b><span>А</span></div>
|
||||
<div class="m"><b>${fmt(d.w,0)}</b><span>Вт</span></div>
|
||||
<div class="m"><b>${fmt(d.kwh)}</b><span>кВт·ч</span></div>
|
||||
</div>
|
||||
<div class="relays">${d.r.map((s,i)=>`
|
||||
<div class="rl ${s?'on':''}" onclick="toggleRelay('${d.node_id}',${i})">
|
||||
<div class="sw"></div>${d.rn[i]||('Реле '+(i+1))}
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<div class="foot"><span>обновлено ${d.seen}</span><span class="mono">${d.ip||''}</span></div>
|
||||
</article>`).join('');
|
||||
|
||||
const p=document.getElementById('pending');
|
||||
p.innerHTML=pendingDevices.map((d,i)=>`
|
||||
<div class="pend">
|
||||
<span class="id">${d.node_id}</span>
|
||||
<span class="via">${d.transport==='lan'?('LAN · '+d.ip):('LoRa · '+d.gw+' · '+d.rssi+' dBm')}</span>
|
||||
<button class="btn" onclick="openWizard(${i})">Добавить</button>
|
||||
</div>`).join('');
|
||||
document.getElementById('discover').style.display=pendingDevices.length?'flex':'none';
|
||||
}
|
||||
document.getElementById('chips').addEventListener('click',e=>{
|
||||
const c=e.target.closest('.chip'); if(!c)return;
|
||||
document.querySelectorAll('.chip').forEach(x=>x.classList.remove('on'));
|
||||
c.classList.add('on'); filter=c.dataset.p; render();
|
||||
});
|
||||
// в бою: POST /api/devices/:id/cmd {"cmd":"relay","n":i,"s":!s}
|
||||
function toggleRelay(id,i){
|
||||
const d=devices.find(x=>x.node_id===id);
|
||||
if(!d.online)return;
|
||||
d.r[i]=d.r[i]?0:1; render();
|
||||
}
|
||||
|
||||
// ================= мастер добавления =================
|
||||
let wizDev=null, wizStep=1, wizData={};
|
||||
function openWizard(i){ wizDev=pendingDevices[i]; wizStep=1; wizData={};
|
||||
document.getElementById('wiz').classList.add('open'); drawWizard(); }
|
||||
function closeWizard(){ document.getElementById('wiz').classList.remove('open'); }
|
||||
function drawWizard(){
|
||||
['s1','s2','s3'].forEach((id,i)=>
|
||||
document.getElementById(id).classList.toggle('on', i<wizStep));
|
||||
const body=document.getElementById('wizBody'), act=document.getElementById('wizActions');
|
||||
const d=wizDev;
|
||||
if(wizStep===1){
|
||||
document.getElementById('wizTitle').textContent='Добавить устройство · паспорт';
|
||||
body.innerHTML=`<div class="passport">
|
||||
ID узла <b>${d.node_id}</b><br>
|
||||
Транспорт <b>${d.transport==='lan'?'Ethernet (LAN)':'LoRa 869 МГц'}</b><br>
|
||||
${d.transport==='lan'
|
||||
?`IP / MAC <b>${d.ip}</b> · ${d.mac}`
|
||||
:`Шлюз <b>${d.gw}</b> · RSSI ${d.rssi} dBm`}<br>
|
||||
Прошивка <b>v${d.fw}</b><br>
|
||||
Возможности <b>PZEM-004T · реле ×${d.caps.relays} · управление</b>
|
||||
</div>
|
||||
<p style="color:var(--tx2);font-size:13px;line-height:1.55">Узел вышел в эфир и прислал паспорт. Проверьте, что это ожидаемое устройство, и продолжите настройку.</p>`;
|
||||
act.innerHTML=`<button class="btn ghost" onclick="closeWizard()">Отмена</button>
|
||||
<button class="btn" onclick="wizStep=2;drawWizard()">Далее</button>`;
|
||||
}
|
||||
if(wizStep===2){
|
||||
document.getElementById('wizTitle').textContent='Добавить устройство · параметры';
|
||||
const relayInputs=Array.from({length:d.caps.relays},(_,i)=>
|
||||
`<label>Реле ${i+1}</label><input id="rn${i}" placeholder="Например: Насос ${i+1}" value="${wizData.rn?.[i]||''}">`).join('');
|
||||
body.innerHTML=`
|
||||
<label>Название</label>
|
||||
<input id="wName" placeholder="Например: Щит котельной" value="${wizData.name||''}">
|
||||
<div class="row2">
|
||||
<div><label>Проект</label>
|
||||
<select id="wProj">
|
||||
<option value="egorin" ${wizData.project==='egorin'?'selected':''}>Егорин</option>
|
||||
<option value="orbit" ${wizData.project==='orbit'?'selected':''}>ORBIT</option>
|
||||
</select></div>
|
||||
<div><label>Объект / площадка</label>
|
||||
<input id="wObj" placeholder="Иноземцево, Пушкина 1Е" value="${wizData.object||''}"></div>
|
||||
</div>${relayInputs}`;
|
||||
act.innerHTML=`<button class="btn ghost" onclick="wizStep=1;drawWizard()">Назад</button>
|
||||
<button class="btn" onclick="wizCollect()">Добавить на сервер</button>`;
|
||||
}
|
||||
if(wizStep===3){
|
||||
document.getElementById('wizTitle').textContent='Устройство добавлено';
|
||||
body.innerHTML=`<div class="done">
|
||||
<div class="ok">✓</div>
|
||||
<b>${wizData.name}</b>
|
||||
<p>Узел ${d.node_id} активирован в проекте «${wizData.project==='egorin'?'Егорин':'ORBIT'}».<br>
|
||||
Сущности опубликованы в Home Assistant через MQTT Discovery — датчики и выключатели реле уже доступны.</p>
|
||||
</div>`;
|
||||
act.innerHTML=`<span></span><button class="btn" onclick="wizFinish()">Готово</button>`;
|
||||
}
|
||||
}
|
||||
function wizCollect(){
|
||||
// в бою: POST /api/devices/:id/approve {name, project, object, relay_names}
|
||||
wizData={
|
||||
name:document.getElementById('wName').value||wizDev.node_id,
|
||||
project:document.getElementById('wProj').value,
|
||||
object:document.getElementById('wObj').value,
|
||||
rn:Array.from({length:wizDev.caps.relays},(_,i)=>document.getElementById('rn'+i).value||('Реле '+(i+1))),
|
||||
};
|
||||
wizStep=3; drawWizard();
|
||||
}
|
||||
function wizFinish(){
|
||||
devices.push({node_id:wizDev.node_id, name:wizData.name, project:wizData.project,
|
||||
transport:wizDev.transport, online:true, v:229.7, a:0.42, w:96, kwh:0,
|
||||
r:Array(wizDev.caps.relays).fill(0), rn:wizData.rn,
|
||||
rssi:wizDev.rssi, ip:wizDev.ip, seen:'только что'});
|
||||
pendingDevices.splice(pendingDevices.indexOf(wizDev),1);
|
||||
closeWizard(); render();
|
||||
}
|
||||
document.getElementById('wiz').addEventListener('click',e=>{
|
||||
if(e.target.id==='wiz') closeWizard();
|
||||
});
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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/<id>/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/<id>/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);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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<LoraDevice[]>([]);
|
||||
const [gws, setGws] = useState<Gateway[]>([]);
|
||||
const [filter, setFilter] = useState<'all' | 'egorin' | 'orbit'>('all');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [d, g] = await Promise.all([apiGet<LoraDevice[]>('/lora/devices'), apiGet<Gateway[]>('/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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="grid h-9 w-9 place-items-center rounded-xl bg-accent/15 text-accent"><RadioTower className="h-5 w-5" /></span>
|
||||
<div>
|
||||
<div className="text-base font-semibold leading-tight">Энерго LoRa/LAN</div>
|
||||
<div className="text-[11px] text-fg-muted">датчики PZEM → шлюз-концентратор → сервер · MQTT-топики nodes/<id></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="rounded-lg bg-muted px-2.5 py-1"><b className="tnum">{active.length}</b> узлов</span>
|
||||
<span className="rounded-lg bg-muted px-2.5 py-1">Σ <b className="tnum">{(sumW / 1000).toFixed(1)}</b> кВт</span>
|
||||
<span className="rounded-lg bg-muted px-2.5 py-1">Σ <b className="tnum">{sumKwh.toFixed(1)}</b> кВт·ч</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Шлюзы */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{gws.map((g) => (
|
||||
<div key={g.gw_id} className="flex items-center gap-2 rounded-xl border border-border bg-surface px-3 py-2 text-xs">
|
||||
<Router className={cn('h-4 w-4', g.online ? 'text-ok' : 'text-fg-muted')} />
|
||||
<span className="font-semibold">{g.gw_id}</span>
|
||||
<span className="text-fg-muted">{g.ip} · {g.nodes} узлов · RSSI ~{g.avg_rssi} дБм</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Обнаружены новые устройства (pending) */}
|
||||
{pending.length > 0 && (
|
||||
<div className="rounded-2xl border border-warn/40 bg-warn/10 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-warn"><Plus className="h-4 w-4" /> Обнаружены новые устройства ({pending.length})</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{pending.map((d) => (
|
||||
<div key={d.node_id} className="flex items-center justify-between gap-2 rounded-xl bg-surface/60 px-3 py-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono font-semibold">{d.node_id}</span>
|
||||
<Badge d={d} />
|
||||
<span className="text-[11px] text-fg-muted">реле: {d.caps?.relays ?? 0} · {lastSeen(d.last_seen)}</span>
|
||||
</div>
|
||||
<button onClick={() => approve(d.node_id)} className="rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-[#06210f] hover:opacity-90">Добавить</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Фильтр-чипы */}
|
||||
<div className="flex gap-2">
|
||||
{(['all', 'egorin', 'orbit'] as const).map((f) => (
|
||||
<button key={f} onClick={() => setFilter(f)} className={cn('rounded-full px-3 py-1 text-xs font-semibold', filter === f ? 'bg-accent text-[#06210f]' : 'border border-border text-fg-muted hover:bg-muted')}>
|
||||
{f === 'all' ? 'Все' : f === 'egorin' ? 'Егорин' : 'ORBIT'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Карточки узлов */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{active.map((d) => (
|
||||
<div key={d.node_id} className="rounded-2xl border border-border bg-surface p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('h-2.5 w-2.5 rounded-full', d.online ? 'animate-pulse bg-ok' : 'bg-fg-muted')} />
|
||||
<span className="truncate font-semibold">{d.name || d.node_id}</span>
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-fg-muted">{d.object || '—'} · {d.node_id}</div>
|
||||
</div>
|
||||
<Badge d={d} />
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-4 gap-2 text-center">
|
||||
{[['V', d.v, 'В'], ['A', d.a, 'А'], ['W', d.w, 'Вт'], ['kWh', d.kwh, 'кВт·ч']].map(([k, val, u]) => (
|
||||
<div key={k as string} className="rounded-lg bg-muted/60 py-1.5">
|
||||
<div className="tnum text-sm font-semibold">{val != null ? Number(val).toFixed(k === 'A' ? 2 : k === 'kWh' ? 1 : 0) : '—'}</div>
|
||||
<div className="text-[9px] text-fg-muted">{u as string}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{(d.relays?.length ?? 0) > 0 && (
|
||||
<div className="mt-3 flex flex-col gap-1.5">
|
||||
{d.relays!.map((r, n) => (
|
||||
<button key={n} onClick={() => toggleRelay(d.node_id, n, r)} className={cn('flex items-center justify-between rounded-lg border px-2.5 py-1.5 text-xs transition-colors', r ? 'border-ok/40 bg-ok/10 text-ok' : 'border-border text-fg-muted hover:bg-muted')}>
|
||||
<span className="flex items-center gap-1.5"><Zap className="h-3 w-3" /> {d.relay_names?.[n] || `Реле ${n + 1}`}</span>
|
||||
<span className={cn('rounded-full px-2 py-0.5 text-[10px] font-semibold', r ? 'bg-ok/20' : 'bg-muted')}>{r ? 'ВКЛ' : 'выкл'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 text-[10px] text-fg-muted">{lastSeen(d.last_seen)}{d.transport === 'lora' && d.last_rssi != null ? ` · RSSI ${d.last_rssi} дБм` : ''}{d.ip ? ` · ${d.ip}` : ''}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{active.length === 0 && <div className="py-8 text-center text-sm text-fg-muted">Нет активных узлов в этом фильтре.</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Badge({ d }: { d: LoraDevice }) {
|
||||
const lora = d.transport === 'lora';
|
||||
return (
|
||||
<span className={cn('flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-semibold', lora ? 'bg-info/15 text-info' : 'bg-accent/15 text-accent')}>
|
||||
{lora ? <Wifi className="h-3 w-3" /> : <Cable className="h-3 w-3" />}
|
||||
{lora ? 'LoRa' : 'LAN'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user