Author SHA1 Message Date
german 14ac295e50 egorin /network: реальная вставка фото прибора (meta.photo) + надёжный SVG-снимок с onError-фолбэком (LED/LIVE, 3×/сут) 2026-07-12 20:52:56 +03:00
german 786b4ef4bb egorin Рация: качественный движок чтения — умный выбор голоса (приоритет онлайн/нейронных ru), последовательное чтение очереди через onend-цепочку 2026-07-12 13:30:06 +03:00
german 2f339803b4 egorin LoRa-карта: всплывающие бейджи-символы над узлами (счётчик опроса+мощность), красный при опросе вне норматива, счётчик последнего опроса, разворот карты на пол-экрана; фикс fp<0 в LoraMeshViz (убивал RAF); frozen-датчик в демо 2026-07-12 13:11:48 +03:00
german d4c05152c5 egorin: визуализатор маршрутизации LoRa-хопов (canvas mesh-паутина, многоскачковая ретрансляция, бегущие пакеты) 2026-07-10 16:36:49 +03:00
german fc10cb77ba egorin: LoRa/LAN энергомониторинг — кит в /lora (прошивки+schema+provisioner+UI), бэкенд ingest от шлюза-концентратора + реестр устройств, фронт-раздел (карточки/реле/pending/шлюзы) 2026-07-09 23:49:34 +03:00
german 27067d3d12 egorin: демо-раздел «Карта-лаборатория (GL)» — MapLibre GL, donut-кластеризация, 3D-здания, CARTO (порт benzrf/orbit-gps-mobile); главная карта = Leaflet+2ГИС везде; docs 2026-07-09 14:01:17 +03:00
german 010a5308f2 docs: вся документация egorin в своей папке — обзор, DEPLOY на gate, макет горэлектросети, конфиги (секрет не в git) 2026-07-08 23:06:33 +03:00
german 8d0e26a02b egorin backend: демо-данные горэлектросети (5 ТП/РП + автопарк бригад с автовышкой, события ТП) 2026-07-08 23:01:31 +03:00
german b2836af333 egorin: макет горэлектросетей — ТП + автопарк бригад (автовышка) на карте, рация с доской объявлений (мигание+чекбоксы) и списком чтения, 2ГИС по умолчанию 2026-07-08 22:52:21 +03:00
34 changed files with 2459 additions and 115 deletions
+56
View File
@@ -0,0 +1,56 @@
# Развёртывание Егорин на gate (egorin.r.servaki.online)
Узел **gate** = `gate.servaki.online` = **5.101.118.232** (Ubuntu 24.04, nginx 1.24, certbot).
Выбран как чистый веб-узел: уже nginx+LE (панель «Ворота»), **нет amnezia/xray/MTProxy** —
ничего не ломается. egorin — отдельный `server_name`, реле «Ворота» (`gate.conf`) не трогать.
Имя **egorin.r.servaki.online** (relay-зона `.r.`) — совпадает с redirect_uri в Яндекс ID.
## Компоненты
- Фронт (статика SPA) → `/var/www/egorin.r.servaki.online`.
- Бэкенд-шлюз (Node) → `/opt/egorin-api/server`, порт **127.0.0.1:4100**, systemd
`servaki-monitor-api` (см. [deploy/servaki-monitor-api.service](deploy/servaki-monitor-api.service)).
- nginx-vhost: SPA + proxy `/api/`,`/socket` → 4100
(см. [deploy/egorin.r.servaki.online.conf](deploy/egorin.r.servaki.online.conf)).
- `.env` бэкенда → `/opt/egorin-api/.env` (см. [deploy/egorin.env.example](deploy/egorin.env.example);
**секрет Яндекс — только на сервере, не в git**).
## Шаги (проверено, SSH из песочницы к gate работает по id_rsa0404)
```bash
# 1. DNS (на ru1, это только API-вызов, ru1 не меняем)
/root/beget-api.sh setA egorin.r.servaki.online 5.101.118.232
# 2. Node 20 на gate
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs
# 3. Фронт
npm run build # локально → dist/
scp -r dist/* root@5.101.118.232:/var/www/egorin.r.servaki.online/
# 4. Бэкенд
scp -r server root@5.101.118.232:/opt/egorin-api/
# на gate: cd /opt/egorin-api/server && npm install --omit=dev
# cp .env → /opt/egorin-api/.env ; cp unit → /etc/systemd/system/
# systemctl enable --now servaki-monitor-api
# 5. nginx + TLS
cp egorin.r.servaki.online.conf /etc/nginx/sites-available/ && ln -s … sites-enabled/
nginx -t && systemctl reload nginx
certbot --nginx -d egorin.r.servaki.online --redirect -m mpo-inform@yandex.ru --agree-tos -n
```
## Обновление
- Только фронт: `npm run build` → залить `dist/` в `/var/www/egorin.r.servaki.online/`.
- Бэкенд/демо-данные: залить `server/src/*` → `/opt/egorin-api/server/src/` →
`systemctl restart servaki-monitor-api`.
## Яндекс ID (приложение «Железный монитор»)
- `YANDEX_CLIENT_ID=12f8169bc7f74571b53e90a2bafea786`
- `YANDEX_REDIRECT=https://egorin.r.servaki.online/api/auth/yandex/callback` (совпадает с
зарегистрированным в oauth.yandex.ru — иначе mismatch).
- `YANDEX_ADMINS=mpo-inform@yandex.ru` (роль admin).
- **Client secret** — в `/opt/egorin-api/.env` на gate, НЕ в git.
- Поток: `/api/auth/yandex/start` → oauth.yandex.ru → `/api/auth/yandex/callback` → сессия.
## Проверка
```bash
curl -sI https://egorin.r.servaki.online/ # 200 (фронт)
curl -sI https://egorin.r.servaki.online/api/auth/yandex/start # 302 → oauth.yandex.ru
curl -sI https://gate.servaki.online/ # 200 (реле «Ворота» цело)
```
+22
View File
@@ -0,0 +1,22 @@
# Егорин («Железный монитор» / Servaki Monitor) — документация
Мультиарендная веб-панель мониторинга: энергомониторинг, GPS/ГЛОНАСС, охрана (ПЦН),
смарт (Wiren Board), сетевая инфраструктура. Форк «Орбиты», тёплый стиль (amber),
вход через Яндекс ID.
## Содержание
- [DEPLOY.md](DEPLOY.md) — развёртывание на gate (`egorin.r.servaki.online`): DNS, nginx,
бэкенд-шлюз (systemd), TLS (certbot), Яндекс ID.
- [gorelectroseti-demo.md](gorelectroseti-demo.md) — демо-макет «городские электросети»
(ТП + автопарк бригад) и **где живут данные панели**.
- [deploy/](deploy/) — nginx-vhost, systemd-юнит, `.env.example`.
## Стек
React 18 + Vite + TypeScript + Tailwind, zustand, react-router, recharts, leaflet +
maplibre-gl (карта). Бэкенд-шлюз: Node (`server/`, порт 4100) — Яндекс OAuth + адаптеры
MQTT/InfluxDB/Traccar (иначе демо-данные).
## Прод
- **Живьё:** `https://egorin.r.servaki.online/` — на узле **gate** (5.101.118.232).
- Демо-превью (Beget path): `https://servaki.online/egorin/`.
- Репозиторий: `git1.servaki.online/german/egorin`.
+15
View File
@@ -0,0 +1,15 @@
# /opt/egorin-api/.env — бэкенд-шлюз egorin (gate). НЕ коммитить в git.
PORT=4100
JWT_SECRET=egorin-gate-2026-CHANGE-IF-NEEDED
DATA_DIR=/opt/egorin-api/data
# Яндекс ID (приложение «Железный монитор»)
YANDEX_CLIENT_ID=12f8169bc7f74571b53e90a2bafea786
YANDEX_CLIENT_SECRET=<секрет в .env на gate, НЕ в git>
YANDEX_REDIRECT=https://egorin.r.servaki.online/api/auth/yandex/callback
YANDEX_ADMINS=mpo-inform@yandex.ru
# Источники данных — включатся по мере готовности стеков (иначе демо-fallback):
# MQTT_URL=mqtt://mosquitto:1883
# INFLUX_URL=http://influxdb:8086
# TRACCAR_URL=http://traccar:8082
+40
View File
@@ -0,0 +1,40 @@
# /etc/nginx/sites-available/egorin.r.servaki.online.conf (gate 5.101.118.232)
# HTTP-first. certbot --nginx -d egorin.r.servaki.online довыпустит серт и добавит 443.
# Рядом с панелью «Ворота» (другой server_name) — её не трогает.
server {
listen 80;
listen [::]:80;
server_name egorin.r.servaki.online;
root /var/www/egorin.r.servaki.online;
index index.html;
# SPA
location / {
try_files $uri $uri/ /index.html;
}
location /assets/ {
expires 30d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Бэкенд-шлюз (Yandex OAuth + данные) на 127.0.0.1:4100
location /api/ {
proxy_pass http://127.0.0.1:4100;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /socket {
proxy_pass http://127.0.0.1:4100;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
access_log /var/log/nginx/egorin.access.log;
error_log /var/log/nginx/egorin.error.log;
}
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=Egorin / Servaki Monitor API gateway (Node)
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/egorin-api/server
EnvironmentFile=/opt/egorin-api/.env
ExecStart=/usr/bin/node src/server.js
Restart=on-failure
RestartSec=3
User=www-data
Group=www-data
[Install]
WantedBy=multi-user.target
+31
View File
@@ -0,0 +1,31 @@
# Демо-макет «Городские электросети»
Сценарий-начинка панели: работа городской электросетевой службы — трансформаторные
подстанции под мониторингом + бригадный автопарк на карте.
## Объекты
- **Трансформаторные подстанции / РП** (тип `energy`): ТП-14 «Бештау» (авария/offline),
ТП-7 «Центр» (перегрев 41 °C), ТП-3 «Западный», ТП-9 «Южный», РП-2 (распределительный
пункт). Телеметрия: `load` (кВт), `u` (В), `i` (А), `temp` (°C); `meta.feeder` — фидер ВЛ-10.
- **Бригадный автопарк — 5 машин** (тип `transport`): **Автовышка АГП-22**, Бригада ВЛ-1
(ГАЗель), Аварийная бригада (УАЗ), Кабельщики (ГАЗ-3309), Мастер участка (Нива).
Часть машин «движется» → живые треки.
- Сетевые узлы mesh2 (раздел «Сеть»): finik-хаб, РЭС-1, Диспетчерская.
## Рация (`RadioMiniWindow`)
- **Доска объявлений**: аварийные объявления с **миганием** (CSS `.sm-blink`), задачи бригад
с **чекбоксами** — закрытие даёт зачёркивание (`line-through`).
- **Список чтения**: очередь голосовых уведомлений, кнопка «Читать» → Web Speech (`speak`).
## Карта — 2ГИС по умолчанию
- Leaflet (`MapView.tsx`): дефолт-слой '2ГИС' (переключение слоёв — в контроле карты).
- MapLibre GL (`MapViewGL.tsx`): `STYLE` — растровый источник 2ГИС (tile0-3.maps.2gis.com).
## ⚠️ ГДЕ живут данные панели (важно при правках)
На проде **бэкенд активен** → объекты/энерго/GPS/события панель тянет из
**`server/src/demo.js`** (демо-fallback бэкенда), а НЕ из фронтового `src/data/mock.ts`
(фронт-mock работает только без бэкенда, чистая статика).
**Правишь объекты монитора (ТП, машины, события) → правь `server/src/demo.js`**, залей на
gate в `/opt/egorin-api/server/src/` и `systemctl restart servaki-monitor-api`.
Рация-стор (`src/core/radio.ts`) и карты (2ГИС) — это фронт: пересобрать `dist` и залить.
+21
View File
@@ -0,0 +1,21 @@
# Карта: GL-рендер по референсу benzrf.ru (кластеризация + donut + 3D)
Портирован рендер-подход benzrf.ru из прототипа `prototypes/orbit-gps-mobile.html` в React.
## Модули
- **`src/modules/gps/MapClusterGL.tsx`** — карта MapLibre GL: GeoJSON-источник с
`cluster:true` + `clusterProperties` (статусы), **donut-кластеры** (SVG), пины со
статус-цветом, экспандинг кластера по клику, подложка **2ГИС** + OpenFreeMap для 3D,
**3D-здания** (fill-extrusion).
- **`src/modules/gps/MapLabModule.tsx`** — демо-раздел **«Карта-лаборатория (GL)»**
(маршрут `/map-lab`): карта + тумблер 3D + легенда статусов, данные из `useGpsData`.
## Статусы → цвет
moving `#3fd06b` · parked/норма `#f4b740` · offline `#7d94a3` · alarm (ТП) `#ef4444`.
## Смысл
Основная карта = Leaflet (DOM-маркеры). GL-рендер (GPU + кластеризация) — плавно при
сотнях объектов и длинных треках. Демо-раздел показывает подход, не трогая рабочий
GPS-раздел. Дальнейшее: треки на line-слои с градиентом, полная миграция.
Прод: `https://egorin.r.servaki.online/map-lab`.
+122
View File
@@ -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 мин, ток выше порога, внеплановое отключение реле).
+164
View File
@@ -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();
}
+191
View File
@@ -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(); }
}
+152
View File
@@ -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();
}
}
+232
View File
@@ -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}`));
+77
View File
@@ -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';
+336
View File
@@ -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 узла&nbsp;&nbsp;&nbsp;&nbsp;<b>${d.node_id}</b><br>
Транспорт&nbsp;&nbsp;<b>${d.transport==='lan'?'Ethernet (LAN)':'LoRa 869 МГц'}</b><br>
${d.transport==='lan'
?`IP / MAC&nbsp;&nbsp;&nbsp;<b>${d.ip}</b> · ${d.mac}`
:`Шлюз&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<b>${d.gw}</b> · RSSI ${d.rssi} dBm`}<br>
Прошивка&nbsp;&nbsp;&nbsp;<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>
+45 -31
View File
@@ -1,28 +1,35 @@
// Демо-данные (fallback, когда реальные источники не сконфигурированы).
// Транспорт «двигается» — формируются живые треки, как от реальных устройств.
// Сценарий: городские электросети — трансформаторные подстанции + бригадный автопарк.
import { upsertEntity, addPosition, setChannels, updateChannel, addEvent, setEnergySeries, setTrips } from './store.js';
export function seedDemo() {
// Энерго-щит (стационар)
upsertEntity({ id: 'cab-07', clientId: 'tempelhoff', name: 'Щит КТП-7', type: 'energy', online: false, lat: 44.08, lon: 43.0, telemetry: { pwr_ext: 0, pwr_int: 3.9, temp: 24 } });
// --- Трансформаторные подстанции / РП (узлы энергомониторинга, стационар) ---
const subs = [
['tp-14', 'ТП-14 «Бештау»', 44.082, 43.002, false, { load: 0, u: 0, i: 0, temp: 23 }, { feeder: 'ВЛ-10 «Север»' }],
['tp-07', 'ТП-7 «Центр»', 44.048, 43.058, true, { load: 184, u: 402, i: 265, temp: 41 }, { feeder: 'ВЛ-10 «Центр»' }],
['tp-03', 'ТП-3 «Западный»', 44.041, 43.02, true, { load: 96, u: 398, i: 139, temp: 33 }, { feeder: 'ВЛ-10 «Запад»' }],
['tp-09', 'ТП-9 «Южный»', 44.018, 43.07, true, { load: 142, u: 401, i: 205, temp: 37 }, { feeder: 'ВЛ-10 «Юг»' }],
['rp-02', 'РП-2 (распределительный пункт)', 44.062, 43.045, true, { load: 310, u: 6300, i: 29, temp: 29 }, { feeder: 'ПС 110/10 «Город»' }],
];
subs.forEach(([id, name, lat, lon, online, t, meta]) => upsertEntity({ id, clientId: 'tempelhoff', name, type: 'energy', online, lat, lon, telemetry: t, meta }));
// Сетевые узлы mesh2
// --- Сетевые узлы mesh2 (раздел «Сеть») ---
const nodes = [
['mesh2-hub', 'finik-хаб', 44.072, 43.05, { latency: 1.2, uplink: 940, clients: 38, cpu: 17 }, { ip: '10.200.0.1', role: 'Центральный хаб mesh2' }],
['mesh2-kn1', 'Keenetic · Склад', 44.064, 43.058, { latency: 3.4, uplink: 310, clients: 12, cpu: 22 }, { ip: '10.200.1.2', role: 'Спица · склад' }],
['mesh2-kn2', 'Keenetic · Ферма', 44.081, 43.041, { latency: 5.1, uplink: 95, clients: 7, cpu: 19 }, { ip: '10.200.2.2', role: 'Спица · ферма' }],
['mesh2-kn1', 'Keenetic · РЭС-1', 44.064, 43.058, { latency: 3.4, uplink: 310, clients: 12, cpu: 22 }, { ip: '10.200.1.2', role: 'Спица · РЭС-1' }],
['mesh2-kn2', 'Keenetic · Диспетчерская', 44.081, 43.041, { latency: 5.1, uplink: 95, clients: 7, cpu: 19 }, { ip: '10.200.2.2', role: 'Спица · диспетчерская' }],
];
nodes.forEach(([id, name, lat, lon, t, meta]) => upsertEntity({ id, clientId: 'tempelhoff', name, type: 'network', online: true, lat, lon, telemetry: t, meta }));
// Каналы Wiren Board
// Каналы Wiren Board (телеметрия щита ТП)
setChannels([
{ id: 'mr6c_214/K1', device: 'WB-MR6C #214', name: 'Освещение цех', kind: 'relay', on: true },
{ id: 'mr6c_214/K2', device: 'WB-MR6C #214', name: 'Ворота', kind: 'relay', on: false },
{ id: 'mdm3_30/CH1', device: 'WB-MDM3 #30', name: 'Свет офис', kind: 'dimmer', on: true, level: 65 },
{ id: 'mr6c_214/IN1', device: 'WB-MR6C #214', name: 'Датчик двери', kind: 'input', on: false },
{ id: 'mr6c_214/K1', device: 'WB-MR6C #214', name: 'Ввод №1 (секц. выкл.)', kind: 'relay', on: true },
{ id: 'mr6c_214/K2', device: 'WB-MR6C #214', name: 'Ввод №2 (резерв АВР)', kind: 'relay', on: false },
{ id: 'mdm3_30/CH1', device: 'WB-MDM3 #30', name: 'Освещение ТП', kind: 'dimmer', on: true, level: 65 },
{ id: 'mr6c_214/IN1', device: 'WB-MR6C #214', name: 'Дверь ТП (охрана)', kind: 'input', on: false },
]);
// Энерго-ряд за сутки
// Энерго-ряд за сутки (суммарная нагрузка сети)
setEnergySeries(
Array.from({ length: 24 }, (_, h) => {
const s = Math.max(0, Math.sin(((h - 6) / 12) * Math.PI));
@@ -30,47 +37,54 @@ export function seedDemo() {
}),
);
// Поездки (журнал)
// Поездки бригад (журнал)
setTrips([
{ id: 't1', entityId: 'veh-123', start: new Date(Date.now() - 6 * 3.6e6).toISOString(), end: new Date(Date.now() - 5.2 * 3.6e6).toISOString(), from: 'База', to: 'Пятигорск', distanceKm: 18.4, durationMin: 48, maxSpeed: 78, avgSpeed: 41 },
{ id: 't2', entityId: 'veh-123', start: new Date(Date.now() - 2 * 3.6e6).toISOString(), end: new Date(Date.now() - 1.4 * 3.6e6).toISOString(), from: 'Ессентуки', to: 'База', distanceKm: 31.6, durationMin: 37, maxSpeed: 91, avgSpeed: 51 },
{ id: 't1', entityId: 'veh-avar', start: new Date(Date.now() - 6 * 3.6e6).toISOString(), end: new Date(Date.now() - 5.2 * 3.6e6).toISOString(), from: 'РЭС-1', to: 'ТП-9 «Южный»', distanceKm: 18.4, durationMin: 48, maxSpeed: 78, avgSpeed: 41 },
{ id: 't2', entityId: 'veh-agp22', start: new Date(Date.now() - 2 * 3.6e6).toISOString(), end: new Date(Date.now() - 1.4 * 3.6e6).toISOString(), from: 'База', to: 'ул. Лесная (ЛЭП)', distanceKm: 12.6, durationMin: 34, maxSpeed: 61, avgSpeed: 33 },
]);
addEvent({ id: 'e1', entityId: 'cab-07', callsign: 'Щит КТП-7', severity: 'critical', type: 'power_loss', text: 'Пропадание сетевого питания, переход на ИБП.', ts: new Date().toISOString(), ackRequired: true });
// Стартовые события
addEvent({ id: 'e1', entityId: 'tp-14', callsign: 'ТП-14 «Бештау»', severity: 'critical', type: 'power_loss', text: 'Пропадание питания на ВЛ-10 «Север». Потребители обесточены, выехала ВЛ-1.', ts: new Date().toISOString(), ackRequired: true });
addEvent({ id: 'e2', entityId: 'tp-07', callsign: 'ТП-7 «Центр»', severity: 'warning', type: 'overload', text: 'Перегрев трансформатора 41 °C при нагрузке 184 кВт.', ts: new Date().toISOString() });
// Подвижные объекты + стартовые позиции
// --- Бригадный автопарк (5 машин, включая автовышку) — подвижные объекты ---
const moving = {
'veh-123': { name: 'ГАЗель-123', lat: 44.05, lon: 43.06, hd: 0.4 },
'veh-204': { name: 'МТЗ-82 трактор', lat: 44.02, lon: 43.11, hd: 1.2 },
'veh-agp22': { name: 'Автовышка АГП-22', lat: 44.046, lon: 43.062, hd: 0.4, drive: true, meta: { boom: 'поднята' } },
'veh-vl1': { name: 'Бригада ВЛ-1 (ГАЗель)', lat: 44.081, lon: 43.001, hd: 1.0, drive: false },
'veh-avar': { name: 'Аварийная бригада (УАЗ)', lat: 44.058, lon: 43.041, hd: 2.1, drive: true },
'veh-kabel': { name: 'Кабельщики (ГАЗ-3309)', lat: 44.033, lon: 43.089, hd: 3.4, drive: true },
'veh-master': { name: 'Мастер участка (Нива)', lat: 44.069, lon: 43.073, hd: 5.0, drive: true },
};
Object.entries(moving).forEach(([id, m]) =>
upsertEntity({ id, clientId: 'tempelhoff', name: m.name, type: 'transport', online: true, lat: m.lat, lon: m.lon, telemetry: { speed: 0, sat: 10, fuel: 55, voltage: 13.6 } }),
upsertEntity({ id, clientId: 'tempelhoff', name: m.name, type: 'transport', online: true, lat: m.lat, lon: m.lon, telemetry: { speed: 0, sat: 11, fuel: 55, voltage: 13.7 }, meta: m.meta }),
);
// Тикер «движения» → живые треки (имитация приёма с устройств)
// Тикер «движения» → живые треки
setInterval(() => {
for (const [id, m] of Object.entries(moving)) {
m.hd += (Math.random() - 0.5) * 0.6;
const speed = id === 'veh-204' ? Math.round(Math.random() * 12) : Math.round(35 + Math.random() * 45);
const step = (speed / 3600) * 0.02;
m.lat += Math.cos(m.hd) * step;
m.lon += Math.sin(m.hd) * step;
addPosition(id, +m.lat.toFixed(5), +m.lon.toFixed(5), { speed, sat: 9 + Math.round(Math.random() * 4), fuel: 50, voltage: 13.6 });
const speed = m.drive ? Math.round(20 + Math.random() * 45) : 0;
if (m.drive) {
m.hd += (Math.random() - 0.5) * 0.6;
const step = (speed / 3600) * 0.02;
m.lat += Math.cos(m.hd) * step;
m.lon += Math.sin(m.hd) * step;
}
addPosition(id, +m.lat.toFixed(5), +m.lon.toFixed(5), { speed, sat: 9 + Math.round(Math.random() * 4), fuel: 50, voltage: 13.7 });
}
}, 3000);
// Датчик двери: периодическая смена статуса + событие — демонстрация live-обновления по WS
// Дверь ТП: периодическая смена статуса + событие (демо live-обновления по WS)
let door = false;
setInterval(() => {
door = !door;
updateChannel('mr6c_214/IN1', { on: door });
addEvent({
id: 'door-' + new Date().toISOString(),
entityId: 'cab-07',
callsign: 'Датчик двери · WB-MR6C #214',
entityId: 'tp-07',
callsign: 'Дверь ТП-7 · WB-MR6C #214',
severity: door ? 'warning' : 'info',
type: door ? 'door_open' : 'door_closed',
text: door ? 'Дверь шкафа открыта' : 'Дверь шкафа закрыта',
text: door ? 'Несанкц. доступ: дверь ТП-7 открыта' : 'Дверь ТП-7 закрыта',
ts: new Date().toISOString(),
});
}, 9000);
+103
View File
@@ -0,0 +1,103 @@
// 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: 'eg03', name: 'ТП-3 фидер (обрыв связи)', project: 'egorin', object: 'ТП-3 «Западный»', hw: 'lora', caps: { pzem: true, relays: 1, ctl: true }, gw: 'gw1', rssi: -98, relay_names: ['Фидер'], status: 'active', base: 5.2, frozen: true }, // активен, но перестал опрашиваться → вне норматива (красный)
{ 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; d._frozen = !!s.frozen;
if (s.frozen) { const v = 229, a = s.base, pf = 0.9; Object.assign(d, { v, a, pf, w: +(v * a * pf).toFixed(1), kwh: d._kwh, hz: 50 }); } // одна телеметрия, дальше не обновляется
});
// Тикер телеметрии активных узлов (frozen — пропускаем: имитация обрыва опроса)
setInterval(() => {
for (const d of devices.values()) {
if (d.status === 'disabled' || d._frozen) 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);
}
+17
View File
@@ -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');
+55 -2
View File
@@ -1,5 +1,5 @@
import { createPortal } from 'react-dom';
import { Radio, X, Volume2, VolumeX, Play } from 'lucide-react';
import { Radio, X, Volume2, VolumeX, Play, Megaphone, ListChecks, Trash2, CheckSquare, Square, Volume1 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useRadio, speak, beep } from '@/core/radio';
@@ -8,9 +8,11 @@ const DOT: Record<string, string> = { critical: 'bg-crit', warning: 'bg-warn', i
// Мини-окно «Рация»: включение/отключение оповещения + эфир. Открывается кнопкой в шапке.
export function RadioMiniWindow({ open, onClose }: { open: boolean; onClose: () => void }) {
const { on, ether, toggle } = useRadio();
const { on, ether, toggle, board, readList, toggleTask, removeBoard, enqueueRead, removeRead, clearRead, readAll } = useRadio();
if (!open) return null;
const openTasks = board.filter((b) => b.kind === 'task' && !b.done).length;
const test = () => {
beep(on ? 880 : 440, 0.14, 0.25);
if (on) speak('Проверка связи. Рация в эфире.');
@@ -64,6 +66,57 @@ export function RadioMiniWindow({ open, onClose }: { open: boolean; onClose: ()
))}
</div>
</div>
{/* Доска объявлений */}
<div className="border-t border-border">
<div className="flex items-center justify-between px-3 py-1.5">
<span className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-fg-muted"><Megaphone className="h-3 w-3" /> Доска объявлений</span>
{openTasks > 0 && <span className="rounded-full bg-warn/20 px-1.5 text-[10px] font-semibold text-warn">{openTasks} задач</span>}
</div>
<div className="max-h-52 space-y-1 overflow-y-auto px-2 pb-2">
{board.map((b) => (
<div key={b.id} className={cn('group flex items-start gap-2 rounded-lg px-2 py-1.5', b.urgent && !b.done ? 'sm-blink bg-crit/10' : 'hover:bg-muted/50')}>
{b.kind === 'task' ? (
<button onClick={() => toggleTask(b.id)} title={b.done ? 'Открыть задачу' : 'Закрыть задачу'} className={cn('mt-0.5 shrink-0', b.done ? 'text-ok' : 'text-fg-muted hover:text-fg')}>
{b.done ? <CheckSquare className="h-4 w-4" /> : <Square className="h-4 w-4" />}
</button>
) : (
<span className={cn('mt-1 h-2 w-2 shrink-0 rounded-full', b.urgent ? 'bg-crit' : 'bg-info')} />
)}
<div className="min-w-0 flex-1">
<div className={cn('text-[11px] leading-snug', b.done ? 'text-fg-muted line-through' : 'text-fg')}>{b.text}</div>
</div>
<div className="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button onClick={() => enqueueRead(b.text)} title="В список чтения (озвучить)" className="text-fg-muted hover:text-accent"><Volume1 className="h-3.5 w-3.5" /></button>
<button onClick={() => removeBoard(b.id)} title="Удалить" className="text-fg-muted hover:text-crit"><Trash2 className="h-3.5 w-3.5" /></button>
</div>
</div>
))}
</div>
</div>
{/* Список чтения — голосовые уведомления */}
<div className="border-t border-border">
<div className="flex items-center justify-between px-3 py-1.5">
<span className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-fg-muted"><ListChecks className="h-3 w-3" /> Список чтения · {readList.length}</span>
<div className="flex items-center gap-1">
<button onClick={readAll} disabled={readList.length === 0} className="flex items-center gap-1 rounded-md bg-accent/15 px-2 py-0.5 text-[10px] font-semibold text-accent hover:bg-accent/25 disabled:opacity-40">
<Volume2 className="h-3 w-3" /> Читать
</button>
<button onClick={clearRead} disabled={readList.length === 0} title="Очистить" className="text-fg-muted hover:text-crit disabled:opacity-40"><Trash2 className="h-3.5 w-3.5" /></button>
</div>
</div>
<div className="max-h-36 space-y-0.5 overflow-y-auto px-2 pb-2">
{readList.length === 0 && <div className="px-2 py-3 text-center text-[11px] text-fg-muted">Список пуст. Добавьте объявление кнопкой 🔈.</div>}
{readList.map((t, i) => (
<div key={i} className="flex items-start gap-2 rounded-lg px-2 py-1 hover:bg-muted/50">
<button onClick={() => speak(t)} title="Прочитать" className="mt-0.5 shrink-0 text-fg-muted hover:text-accent"><Play className="h-3.5 w-3.5" /></button>
<div className="min-w-0 flex-1 text-[11px] leading-snug text-fg-muted">{t}</div>
<button onClick={() => removeRead(i)} title="Убрать" className="shrink-0 text-fg-muted hover:text-crit"><X className="h-3.5 w-3.5" /></button>
</div>
))}
</div>
</div>
</div>,
document.body,
);
+5 -1
View File
@@ -1,12 +1,14 @@
import type { ComponentType } from 'react';
import { Zap, Cpu, Navigation, Network, ShieldCheck, Siren, Smartphone, FolderGit2, Gauge, Wallet, BellRing, MapPinned, History, MapPin, Building2, Archive } 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';
import { ObjectsModule } from '@/modules/security/ObjectsModule';
import { ArchiveModule } from '@/modules/security/ArchiveModule';
import { GpsModule } from '@/modules/gps/GpsModule';
import { MapLabModule } from '@/modules/gps/MapLabModule';
import { GeoModule } from '@/modules/geo/GeoModule';
import { EventsModule } from '@/modules/gps/EventsModule';
import { NetworkModule } from '@/modules/network/NetworkModule';
@@ -32,12 +34,14 @@ 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' },
{ id: 'security-objects', path: '/security-objects', title: 'Охраняемые объекты', short: 'Объекты', icon: Building2, component: ObjectsModule, section: 'security', hidden: true },
{ id: 'security-archive', path: '/security-archive', title: 'Архив сработок', short: 'Архив', icon: Archive, component: ArchiveModule, section: 'security', hidden: true },
{ id: 'gps', path: '/gps', title: 'GPS-мониторинг', short: 'GPS', icon: Navigation, component: GpsModule, section: 'gps' },
{ id: 'map-lab', path: '/map-lab', title: 'Карта-лаборатория (GL)', short: 'GL-карта', icon: Boxes, component: MapLabModule, section: 'gps' },
{ id: 'geo', path: '/geofences', title: 'Геозоны', short: 'Геозоны', icon: MapPinned, component: GeoModule, section: 'gps', hidden: true },
{ id: 'events', path: '/events', title: 'История событий', short: 'События', icon: History, component: EventsModule, section: 'gps', hidden: true },
{ id: 'network', path: '/network', title: 'Сетевая инфраструктура', short: 'Сеть', icon: Network, component: NetworkModule, section: 'netinfra' },
+1 -1
View File
@@ -59,7 +59,7 @@ export const USERS: User[] = [
id: 'energetik-ro',
name: 'Сидоров А.',
role: 'Энергетик · часть, только просмотр',
access: { energy: { caps: ['view'], objects: ['cab-07'] } },
access: { energy: { caps: ['view'], objects: ['tp-07'] } },
},
{
id: 'energetik-rw',
+108 -7
View File
@@ -46,18 +46,47 @@ function chime(sev: MonitorEvent['severity']) {
}
}
export function speak(text: string) {
// Умный выбор голоса: приоритет качества — онлайн/нейронные (Google/Yandex/Microsoft),
// затем именованные женские, затем любой ru. Кэш + переизбор на voiceschanged.
let cachedVoice: SpeechSynthesisVoice | null = null;
let voiceHooked = false;
function pickVoice(): SpeechSynthesisVoice | null {
if (!('speechSynthesis' in window)) return null;
const ru = window.speechSynthesis.getVoices().filter((v) => v.lang.toLowerCase().startsWith('ru'));
if (!ru.length) return null;
const score = (v: SpeechSynthesisVoice) => {
const n = v.name.toLowerCase();
let s = 0;
if (!v.localService) s += 4; // remote-голоса обычно качественнее
if (/google|yandex|neural|natural|online|premium/.test(n)) s += 3;
if (/milena|dariya|alena|katya|irina|svetlana|tatyana|elena/.test(n)) s += 2;
if (/microsoft/.test(n)) s += 1;
return s;
};
return ru.slice().sort((a, b) => score(b) - score(a))[0];
}
function bestVoice(): SpeechSynthesisVoice | null {
if (!cachedVoice) cachedVoice = pickVoice();
if (!voiceHooked && 'speechSynthesis' in window) {
voiceHooked = true;
window.speechSynthesis.addEventListener('voiceschanged', () => { cachedVoice = pickVoice(); });
}
return cachedVoice;
}
export function speak(text: string, opts?: { onend?: () => void }) {
try {
if (!('speechSynthesis' in window)) return;
if (!('speechSynthesis' in window) || !text) { opts?.onend?.(); return; }
const u = new SpeechSynthesisUtterance(text);
u.lang = 'ru-RU';
u.rate = 1;
u.pitch = 1;
const ru = window.speechSynthesis.getVoices().find((v) => v.lang.toLowerCase().startsWith('ru'));
if (ru) u.voice = ru;
u.rate = 1.0;
u.pitch = 1.03; // чуть живее, менее «робот»
const v = bestVoice();
if (v) u.voice = v;
if (opts?.onend) { u.onend = () => opts.onend!(); u.onerror = () => opts.onend!(); }
window.speechSynthesis.speak(u);
} catch {
/* ignore */
opts?.onend?.();
}
}
@@ -68,11 +97,51 @@ export function radioReport(ev: MonitorEvent) {
setTimeout(() => speak(`${prefix}${ev.callsign}. ${ev.text}`), ev.severity === 'critical' ? 500 : 200);
}
// Доска объявлений: объявления и задачи бригад. Задачи закрываются чекбоксом (зачёркивание).
export interface BoardItem {
id: string;
text: string;
kind: 'announce' | 'task';
urgent?: boolean; // мигающее уведомление
done?: boolean; // задача закрыта → текст перечёркнут
ts: string;
}
const nowISO = () => new Date().toISOString();
const uid = () => 'b' + Math.random().toString(36).slice(2, 9);
const BOARD_SEED: BoardItem[] = [
{ id: 'brd-1', text: 'АВАРИЯ: обесточен ТП-14 (мкр. Бештау). Бригада ВЛ-1 в пути.', kind: 'announce', urgent: true, ts: nowISO() },
{ id: 'brd-2', text: 'Плановое отключение ТП-7 сегодня 14:00–16:00 (замена трансформатора).', kind: 'announce', ts: nowISO() },
{ id: 'brd-3', text: 'Заменить изолятор на опоре №42, ВЛ-10кВ «Центр».', kind: 'task', ts: nowISO() },
{ id: 'brd-4', text: 'Автовышке АГП-22: обрезка веток у ЛЭП, ул. Лесная.', kind: 'task', urgent: true, ts: nowISO() },
{ id: 'brd-5', text: 'Снять показания счётчиков ТП-3, ТП-9.', kind: 'task', done: true, ts: nowISO() },
];
const READ_SEED: string[] = [
'Внимание бригадам. На ТП-14 пропало сетевое питание, потребители переведены на резерв.',
'Диспетчер: автовышке АГП-22 подтвердить прибытие на улицу Лесная.',
];
function loadBoard(): BoardItem[] {
try { const j = localStorage.getItem('sm-radio-board'); if (j) return JSON.parse(j); } catch { /* ignore */ }
return BOARD_SEED;
}
function saveBoard(v: BoardItem[]) { try { localStorage.setItem('sm-radio-board', JSON.stringify(v)); } catch { /* ignore */ } }
interface RadioState {
on: boolean;
ether: MonitorEvent[]; // лента «эфир»
board: BoardItem[]; // доска объявлений
readList: string[]; // список чтения (очередь голосовых уведомлений)
toggle: () => void;
push: (ev: MonitorEvent) => void;
toggleTask: (id: string) => void; // закрыть/открыть задачу (зачёркивание)
addBoard: (text: string, kind?: BoardItem['kind'], urgent?: boolean) => void;
removeBoard: (id: string) => void;
enqueueRead: (text: string) => void; // добавить голосовое уведомление в список чтения
removeRead: (i: number) => void;
clearRead: () => void;
readAll: () => void; // озвучить весь список чтения по очереди
}
const initOn = (() => {
@@ -86,6 +155,38 @@ const initOn = (() => {
export const useRadio = create<RadioState>((set, get) => ({
on: initOn,
ether: [],
board: loadBoard(),
readList: READ_SEED,
toggleTask: (id) =>
set((s) => {
const board = s.board.map((b) => (b.id === id ? { ...b, done: !b.done } : b));
saveBoard(board);
return { board };
}),
addBoard: (text, kind = 'announce', urgent = false) =>
set((s) => {
const board = [{ id: uid(), text, kind, urgent, ts: nowISO() }, ...s.board].slice(0, 60);
saveBoard(board);
return { board };
}),
removeBoard: (id) =>
set((s) => {
const board = s.board.filter((b) => b.id !== id);
saveBoard(board);
return { board };
}),
enqueueRead: (text) => set((s) => ({ readList: [...s.readList, text].slice(0, 40) })),
removeRead: (i) => set((s) => ({ readList: s.readList.filter((_, idx) => idx !== i) })),
clearRead: () => set({ readList: [] }),
readAll: () => {
ctx(); // прайм аудио на жесте
if (!('speechSynthesis' in window)) return;
window.speechSynthesis.cancel(); // сбросить прошлую очередь
const list = get().readList.slice();
let i = 0;
const next = () => { if (i >= list.length) return; speak(list[i++], { onend: next }); }; // последовательно, без наложения
next();
},
toggle: () => {
const on = !get().on;
try {
+6 -6
View File
@@ -13,7 +13,7 @@ export const meshNodes: MonitoredEntity[] = [
type: 'network',
online: true,
telemetry: { latency: 1.2, uplink: 940, clients: 38, cpu: 17 },
meta: { ip: '10.200.0.1', model: 'x86 / AmneziaWG', role: 'Центральный хаб mesh2', uplink: 'GPON 1 Гбит' },
meta: { ip: '10.200.0.1', model: 'x86 / AmneziaWG', role: 'Центральный хаб mesh2', uplink: 'GPON 1 Гбит', photo: 'https://cloud-static.wirenboard.com/preview/wb7.jpeg' },
lat: 44.072,
lon: 43.05,
updatedAt: now,
@@ -25,7 +25,7 @@ export const meshNodes: MonitoredEntity[] = [
type: 'network',
online: true,
telemetry: { latency: 3.4, uplink: 310, clients: 12, cpu: 22 },
meta: { ip: '10.200.1.2', model: 'Keenetic Hopper SE', role: 'Спица · склад', uplink: 'AWG → хаб' },
meta: { ip: '10.200.1.2', model: 'Keenetic Hopper SE', role: 'Спица · склад', uplink: 'AWG → хаб', photo: 'https://cloud-static.wirenboard.com/preview/wb8.jpeg' },
lat: 44.064,
lon: 43.058,
updatedAt: now,
@@ -37,7 +37,7 @@ export const meshNodes: MonitoredEntity[] = [
type: 'network',
online: true,
telemetry: { latency: 5.1, uplink: 95, clients: 7, cpu: 19 },
meta: { ip: '10.200.2.2', model: 'Keenetic Hero 4G', role: 'Спица · ферма (LTE-бэкап)', uplink: 'AWG → хаб' },
meta: { ip: '10.200.2.2', model: 'Keenetic Hero 4G', role: 'Спица · ферма (LTE-бэкап)', uplink: 'AWG → хаб', photo: 'https://cloud-static.wirenboard.com/preview/wb6.jpg' },
lat: 44.081,
lon: 43.041,
updatedAt: now,
@@ -49,7 +49,7 @@ export const meshNodes: MonitoredEntity[] = [
type: 'network',
online: true,
telemetry: { latency: 8.7, uplink: 60, clients: 4, cpu: 31 },
meta: { ip: '10.200.3.2', model: 'Mikrotik hAP ac3', role: 'Спица · полевой узел', uplink: 'Радиомост 5 ГГц' },
meta: { ip: '10.200.3.2', model: 'Mikrotik hAP ac3', role: 'Спица · полевой узел', uplink: 'Радиомост 5 ГГц', photo: 'https://cloud-static.wirenboard.com/preview/wb7.jpeg' },
lat: 44.09,
lon: 43.07,
updatedAt: now,
@@ -61,7 +61,7 @@ export const meshNodes: MonitoredEntity[] = [
type: 'network',
online: true,
telemetry: { latency: 6.0, uplink: 8, clients: 1, cpu: 0 },
meta: { ip: '10.200.1.51', model: 'Hikvision DS-2CD', role: 'IP-камера (RTSP)', uplink: 'PoE → Keenetic Склад' },
meta: { ip: '10.200.1.51', model: 'Hikvision DS-2CD', role: 'IP-камера (RTSP)', uplink: 'PoE → Keenetic Склад', photo: 'https://cloud-static.wirenboard.com/preview/wb8.jpeg' },
lat: 44.066,
lon: 43.06,
updatedAt: now,
@@ -73,7 +73,7 @@ export const meshNodes: MonitoredEntity[] = [
type: 'network',
online: false,
telemetry: { latency: 0, uplink: 0, clients: 0, cpu: 0 },
meta: { ip: '10.200.2.40', model: 'Wiren Board / Modbus-TCP', role: 'Солнечный контроллер', uplink: 'нет связи 14 мин' },
meta: { ip: '10.200.2.40', model: 'Wiren Board / Modbus-TCP', role: 'Солнечный контроллер', uplink: 'нет связи 14 мин', photo: 'https://cloud-static.wirenboard.com/preview/wb6.jpg' },
lat: 44.083,
lon: 43.045,
updatedAt: ago(14),
+48 -38
View File
@@ -63,70 +63,80 @@ export const wbChannels: WbChannel[] = [
{ id: 'mr6c_214/IN1', device: 'WB-MR6C #214', name: 'Датчик двери', kind: 'input', on: false },
];
// --- Transport / GPS: объекты Navtelecom (FLEX/NTCB) ---
// --- Городские электросети: бригадный автопарк (GPS) + трансформаторные подстанции (энерго) ---
const iso = (minAgo = 0) => new Date(Date.now() - minAgo * 60000).toISOString();
export const entities: MonitoredEntity[] = [
// Бригадный автопарк — 5 машин (включая автовышку)
{
id: 'veh-123',
clientId: 'tempelhoff',
name: 'ГАЗель-123',
type: 'transport',
online: true,
telemetry: { speed: 64, sat: 11, fuel: 58, voltage: 13.8 },
lat: 44.05,
lon: 43.06,
updatedAt: new Date().toISOString(),
id: 'veh-agp22', clientId: 'tempelhoff', name: 'Автовышка АГП-22', type: 'transport', online: true,
telemetry: { speed: 28, sat: 12, fuel: 64, voltage: 13.9 }, meta: { boom: 'поднята' }, lat: 44.046, lon: 43.062, updatedAt: iso(0),
},
{
id: 'veh-204',
clientId: 'tempelhoff',
name: 'МТЗ-82 трактор',
type: 'transport',
online: true,
telemetry: { speed: 0, sat: 9, fuel: 31, voltage: 12.9 },
lat: 44.02,
lon: 43.11,
updatedAt: new Date().toISOString(),
id: 'veh-vl1', clientId: 'tempelhoff', name: 'Бригада ВЛ-1 (ГАЗель)', type: 'transport', online: true,
telemetry: { speed: 0, sat: 10, fuel: 47, voltage: 12.8 }, lat: 44.081, lon: 43.001, updatedAt: iso(1),
},
{
id: 'cab-07',
clientId: 'tempelhoff',
name: 'Щит КТП-7',
type: 'energy',
online: false,
telemetry: { pwr_ext: 0, pwr_int: 3.9, temp: 24 },
lat: 44.08,
lon: 43.0,
updatedAt: new Date(Date.now() - 9 * 60000).toISOString(),
id: 'veh-avar', clientId: 'tempelhoff', name: 'Аварийная бригада (УАЗ)', type: 'transport', online: true,
telemetry: { speed: 54, sat: 11, fuel: 38, voltage: 13.7 }, lat: 44.058, lon: 43.041, updatedAt: iso(0),
},
{
id: 'veh-kabel', clientId: 'tempelhoff', name: 'Кабельщики (ГАЗ-3309)', type: 'transport', online: true,
telemetry: { speed: 12, sat: 9, fuel: 57, voltage: 13.6 }, lat: 44.033, lon: 43.089, updatedAt: iso(0),
},
{
id: 'veh-master', clientId: 'tempelhoff', name: 'Мастер участка (Нива)', type: 'transport', online: true,
telemetry: { speed: 41, sat: 12, fuel: 72, voltage: 13.8 }, lat: 44.069, lon: 43.073, updatedAt: iso(0),
},
// Трансформаторные подстанции — узлы энергомониторинга
{
id: 'tp-14', clientId: 'tempelhoff', name: 'ТП-14 «Бештау»', type: 'energy', online: false,
telemetry: { load: 0, u: 0, i: 0, temp: 23 }, meta: { feeder: 'ВЛ-10 «Север»' }, lat: 44.082, lon: 43.002, updatedAt: iso(9),
},
{
id: 'tp-07', clientId: 'tempelhoff', name: 'ТП-7 «Центр»', type: 'energy', online: true,
telemetry: { load: 184, u: 402, i: 265, temp: 41 }, meta: { feeder: 'ВЛ-10 «Центр»' }, lat: 44.048, lon: 43.058, updatedAt: iso(0),
},
{
id: 'tp-03', clientId: 'tempelhoff', name: 'ТП-3 «Западный»', type: 'energy', online: true,
telemetry: { load: 96, u: 398, i: 139, temp: 33 }, meta: { feeder: 'ВЛ-10 «Запад»' }, lat: 44.041, lon: 43.02, updatedAt: iso(0),
},
{
id: 'tp-09', clientId: 'tempelhoff', name: 'ТП-9 «Южный»', type: 'energy', online: true,
telemetry: { load: 142, u: 401, i: 205, temp: 37 }, meta: { feeder: 'ВЛ-10 «Юг»' }, lat: 44.018, lon: 43.07, updatedAt: iso(0),
},
{
id: 'rp-02', clientId: 'tempelhoff', name: 'РП-2 (распределительный пункт)', type: 'energy', online: true,
telemetry: { load: 310, u: 6300, i: 29, temp: 29 }, meta: { feeder: 'ПС 110/10 «Город»' }, lat: 44.062, lon: 43.045, updatedAt: iso(0),
},
];
export const events: MonitorEvent[] = [
{
id: 'e1',
entityId: 'cab-07',
callsign: 'Щит КТП-7',
entityId: 'tp-14',
callsign: 'ТП-14 «Бештау»',
severity: 'critical',
type: 'power_loss',
text: 'Пропадание сетевого питания, переход на ИБП.',
text: 'Пропадание питания на ВЛ-10 «Север». Потребители обесточены, выехала ВЛ-1.',
ts: new Date(Date.now() - 9 * 60000).toISOString(),
ackRequired: true,
},
{
id: 'e2',
entityId: 'veh-204',
callsign: 'МТЗ-82 трактор',
entityId: 'tp-07',
callsign: 'ТП-7 «Центр»',
severity: 'warning',
type: 'fuel_low',
text: 'Низкий уровень топлива (31%).',
type: 'overload',
text: 'Перегрев трансформатора 41 °C при нагрузке 184 кВт.',
ts: new Date(Date.now() - 42 * 60000).toISOString(),
},
{
id: 'e3',
entityId: 'veh-123',
callsign: 'ГАЗель-123',
entityId: 'veh-agp22',
callsign: 'Автовышка АГП-22',
severity: 'info',
type: 'geofence_in',
text: 'Вход в геозону «База».',
text: 'Прибытие на объект: ул. Лесная (обрезка веток у ЛЭП).',
ts: new Date(Date.now() - 78 * 60000).toISOString(),
},
];
+5 -5
View File
@@ -16,11 +16,11 @@ export interface Trip {
const h = (n: number) => new Date(Date.now() - n * 3600000).toISOString();
export const trips: Trip[] = [
{ id: 't1', entityId: 'veh-123', start: h(6), end: h(5.2), from: 'База «Тэмпельхоф»', to: 'Пятигорск, рынок', distanceKm: 18.4, durationMin: 48, maxSpeed: 78, avgSpeed: 41, route: [[44.07, 43.05], [44.061, 43.058], [44.052, 43.064], [44.043, 43.071]] },
{ id: 't2', entityId: 'veh-123', start: h(4.5), end: h(3.7), from: 'Пятигорск, рынок', to: 'Ессентуки, склад', distanceKm: 22.1, durationMin: 49, maxSpeed: 84, avgSpeed: 38, route: [[44.043, 43.071], [44.05, 43.0], [44.05, 42.93], [44.045, 42.86]] },
{ id: 't3', entityId: 'veh-123', start: h(2), end: h(1.4), from: 'Ессентуки, склад', to: 'База «Тэмпельхоф»', distanceKm: 31.6, durationMin: 37, maxSpeed: 91, avgSpeed: 51, route: [[44.045, 42.86], [44.052, 42.95], [44.062, 43.0], [44.07, 43.05]] },
{ id: 't4', entityId: 'veh-204', start: h(8), end: h(6.5), from: 'Поле №3', to: 'МТМ (ремонт)', distanceKm: 6.2, durationMin: 90, maxSpeed: 24, avgSpeed: 9, route: [[44.02, 43.11], [44.025, 43.1], [44.03, 43.095]] },
{ id: 't5', entityId: 'veh-204', start: h(3), end: h(2.2), from: 'МТМ', to: 'Поле №5', distanceKm: 8.8, durationMin: 48, maxSpeed: 28, avgSpeed: 12, route: [[44.03, 43.095], [44.038, 43.085], [44.045, 43.08]] },
{ id: 't1', entityId: 'veh-avar', start: h(6), end: h(5.2), from: 'База «Тэмпельхоф»', to: 'Пятигорск, рынок', distanceKm: 18.4, durationMin: 48, maxSpeed: 78, avgSpeed: 41, route: [[44.07, 43.05], [44.061, 43.058], [44.052, 43.064], [44.043, 43.071]] },
{ id: 't2', entityId: 'veh-avar', start: h(4.5), end: h(3.7), from: 'Пятигорск, рынок', to: 'Ессентуки, склад', distanceKm: 22.1, durationMin: 49, maxSpeed: 84, avgSpeed: 38, route: [[44.043, 43.071], [44.05, 43.0], [44.05, 42.93], [44.045, 42.86]] },
{ id: 't3', entityId: 'veh-avar', start: h(2), end: h(1.4), from: 'Ессентуки, склад', to: 'База «Тэмпельхоф»', distanceKm: 31.6, durationMin: 37, maxSpeed: 91, avgSpeed: 51, route: [[44.045, 42.86], [44.052, 42.95], [44.062, 43.0], [44.07, 43.05]] },
{ id: 't4', entityId: 'veh-agp22', start: h(8), end: h(6.5), from: 'Поле №3', to: 'МТМ (ремонт)', distanceKm: 6.2, durationMin: 90, maxSpeed: 24, avgSpeed: 9, route: [[44.02, 43.11], [44.025, 43.1], [44.03, 43.095]] },
{ id: 't5', entityId: 'veh-agp22', start: h(3), end: h(2.2), from: 'МТМ', to: 'Поле №5', distanceKm: 8.8, durationMin: 48, maxSpeed: 28, avgSpeed: 12, route: [[44.03, 43.095], [44.038, 43.085], [44.045, 43.08]] },
];
export function tripsOf(entityId: string): Trip[] {
+15
View File
@@ -61,6 +61,21 @@
}
}
/* Мигающее уведомление на доске объявлений */
.sm-blink {
animation: smblink 1.05s steps(1, end) infinite;
}
@keyframes smblink {
0%, 55% {
box-shadow: inset 3px 0 0 0 rgb(var(--crit, 239 68 68));
background-color: rgba(239, 68, 68, 0.14);
}
56%, 100% {
box-shadow: inset 3px 0 0 0 transparent;
background-color: rgba(239, 68, 68, 0.04);
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.001ms !important;
+122
View File
@@ -0,0 +1,122 @@
import { useEffect, useRef } from 'react';
// Визуализатор ретрансляции/маршрутизации LoRa-хопов: узлы-паутинка вокруг шлюза,
// нити-связи (прямые и многоскачковые), бегущие по ним пакеты (передвижение по массиву).
interface Node { id: string; label: string; status: 'active' | 'pending' | 'disabled'; rssi?: number; transport?: 'lora' | 'lan'; lastSeen?: string; w?: number }
const COL = { active: [63, 208, 107], pending: [244, 183, 64], disabled: [125, 148, 163], gw: [34, 224, 255], pkt: [124, 220, 255], stale: [239, 68, 68] } as const;
const rgba = (c: readonly number[], a: number) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;
// возраст последнего опроса, сек
const ageSec = (iso?: string) => (iso ? Math.max(0, (Date.now() - Date.parse(iso)) / 1000) : Infinity);
export function LoraMeshViz({ nodes, gatewayLabel = 'gw1', normSec = 15, className }: { nodes: Node[]; gatewayLabel?: string; normSec?: number; className?: string }) {
const ref = useRef<HTMLCanvasElement>(null);
const nodesRef = useRef(nodes);
nodesRef.current = nodes;
useEffect(() => {
const cv = ref.current;
if (!cv) return;
const ctx = cv.getContext('2d')!;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
let raf = 0;
const resize = () => { cv.width = cv.clientWidth * dpr; cv.height = cv.clientHeight * dpr; };
resize();
const ro = new ResizeObserver(resize);
ro.observe(cv);
const draw = (now: number) => {
const W = cv.width, H = cv.height, cx = W / 2, cy = H / 2;
ctx.clearRect(0, 0, W, H);
const ns = nodesRef.current;
const R = Math.min(W, H) * 0.36;
// раскладка узлов по кольцу; слабый RSSI (< -100) — «дальний», ретранслируется через ближнего
const pts = ns.map((n, i) => {
const ang = (i / Math.max(1, ns.length)) * Math.PI * 2 - Math.PI / 2;
const far = (n.rssi ?? -90) < -100;
const r = far ? R * 1.0 : R * 0.66;
const age = ageSec(n.lastSeen);
const stale = n.status === 'active' && age > normSec; // опрос вне норматива
return { n, x: cx + Math.cos(ang) * r, y: cy + Math.sin(ang) * r, far, ang, age, stale };
});
// связи: дальние — через ближайшего не-дальнего соседа (2 хопа), иначе прямо к шлюзу
const relays = pts.filter((p) => !p.far);
const linkFor = (p: typeof pts[number]) => {
if (!p.far || !relays.length) return { x: cx, y: cy, hop: false };
let best = relays[0], bd = Infinity;
for (const r of relays) { const d = (r.x - p.x) ** 2 + (r.y - p.y) ** 2; if (d < bd) { bd = d; best = r; } }
return { x: best.x, y: best.y, hop: true, relay: best };
};
// паутинка-фон (тонкие нити между близкими узлами)
ctx.lineWidth = dpr;
for (let a = 0; a < pts.length; a++) for (let b = a + 1; b < pts.length; b++) {
const dx = pts[a].x - pts[b].x, dy = pts[a].y - pts[b].y;
if (dx * dx + dy * dy < (R * 0.7) ** 2) {
ctx.strokeStyle = rgba(COL.gw, 0.05);
ctx.beginPath(); ctx.moveTo(pts[a].x, pts[a].y); ctx.lineTo(pts[b].x, pts[b].y); ctx.stroke();
}
}
const tsec = now * 0.001;
// связи узел → (ретранслятор) → шлюз + бегущие пакеты
for (const p of pts) {
const target = linkFor(p);
const c = p.stale ? COL.stale : COL[p.n.status];
// сегмент 1: узел → цель
ctx.strokeStyle = rgba(c, 0.28); ctx.lineWidth = 1.4 * dpr;
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(target.x, target.y); ctx.stroke();
// сегмент 2 (если хоп): ретранслятор → шлюз
if (target.hop) {
ctx.strokeStyle = rgba(COL.gw, 0.22);
ctx.beginPath(); ctx.moveTo(target.x, target.y); ctx.lineTo(cx, cy); ctx.stroke();
}
if (p.n.status !== 'active') continue;
// бегущий пакет по маршруту (узел→…→шлюз)
const route: Array<[number, number]> = target.hop ? [[p.x, p.y], [target.x, target.y], [cx, cy]] : [[p.x, p.y], [cx, cy]];
const speed = 0.35 + ((p.n.rssi ?? -90) + 120) / 200; // ближе — быстрее
const fp = (((tsec * speed + p.ang) % 1) + 1) % 1; // всегда [0,1) — p.ang бывает <0
const seg = fp * (route.length - 1), si = Math.min(route.length - 2, Math.max(0, Math.floor(seg))), fr = seg - si;
const [ax, ay] = route[si], [bx, by] = route[si + 1] || route[si];
const px = ax + (bx - ax) * fr, py = ay + (by - ay) * fr;
ctx.fillStyle = rgba(COL.pkt, 0.95); ctx.beginPath(); ctx.arc(px, py, 2.6 * dpr, 0, 6.283); ctx.fill();
ctx.fillStyle = rgba(COL.pkt, 0.2); ctx.beginPath(); ctx.arc(px, py, 7 * dpr, 0, 6.283); ctx.fill();
}
// узлы + всплывающие символы (счётчик последнего опроса; красный если вне норматива)
for (const p of pts) {
const c = p.stale ? COL.stale : COL[p.n.status];
const pulse = p.n.status === 'active' ? 0.6 + 0.4 * Math.sin(tsec * (p.stale ? 5 : 2.4) + p.ang) : 1;
ctx.fillStyle = rgba(c, 0.9); ctx.beginPath(); ctx.arc(p.x, p.y, (p.n.transport === 'lan' ? 5 : 4) * dpr * pulse, 0, 6.283); ctx.fill();
ctx.fillStyle = rgba(c, p.stale ? 0.22 : 0.14); ctx.beginPath(); ctx.arc(p.x, p.y, 12 * dpr * pulse, 0, 6.283); ctx.fill();
ctx.fillStyle = 'rgba(207,227,214,0.75)'; ctx.font = `${9 * dpr}px Inter, system-ui`; ctx.textAlign = 'center';
ctx.fillText(p.n.label, p.x, p.y + 18 * dpr);
// всплывающий бейдж над узлом: возраст опроса + мощность
if (p.n.status === 'active') {
const bob = Math.sin(tsec * 2 + p.ang) * 2 * dpr;
const ageTxt = p.age >= 3600 ? `${Math.round(p.age / 3600)}ч` : p.age >= 60 ? `${Math.round(p.age / 60)}м` : `${Math.round(p.age)}с`;
const txt = (p.stale ? '⚠ ' : '') + ageTxt + (p.n.w != null ? ` · ${Math.round(p.n.w)}Вт` : '');
ctx.font = `bold ${8.5 * dpr}px Inter, system-ui`;
const bw = ctx.measureText(txt).width + 10 * dpr, bh = 13 * dpr, x0 = p.x - bw / 2, y0 = p.y - 15 * dpr + bob, rr = 4 * dpr;
ctx.fillStyle = rgba(p.stale ? COL.stale : COL.gw, p.stale ? 0.92 : 0.18);
ctx.beginPath();
ctx.moveTo(x0 + rr, y0); ctx.arcTo(x0 + bw, y0, x0 + bw, y0 + bh, rr); ctx.arcTo(x0 + bw, y0 + bh, x0, y0 + bh, rr); ctx.arcTo(x0, y0 + bh, x0, y0, rr); ctx.arcTo(x0, y0, x0 + bw, y0, rr); ctx.closePath(); ctx.fill();
ctx.fillStyle = p.stale ? '#fff' : rgba(COL.gw, 0.95);
ctx.fillText(txt, p.x, y0 + bh - 3.5 * dpr);
}
}
// шлюз в центре
const gp = 0.6 + 0.4 * Math.sin(tsec * 1.6);
ctx.fillStyle = rgba(COL.gw, 0.16); ctx.beginPath(); ctx.arc(cx, cy, 22 * dpr * gp, 0, 6.283); ctx.fill();
ctx.fillStyle = rgba(COL.gw, 0.95); ctx.beginPath(); ctx.arc(cx, cy, 7 * dpr, 0, 6.283); ctx.fill();
ctx.fillStyle = '#0b0f14'; ctx.font = `bold ${8 * dpr}px Inter`; ctx.fillText('GW', cx, cy + 3 * dpr);
ctx.fillStyle = rgba(COL.gw, 0.9); ctx.font = `${9 * dpr}px Inter`; ctx.fillText(gatewayLabel, cx, cy + 30 * dpr);
raf = requestAnimationFrame(draw);
};
raf = requestAnimationFrame(draw);
return () => { cancelAnimationFrame(raf); ro.disconnect(); };
}, [gatewayLabel]);
return <canvas ref={ref} className={className} style={{ width: '100%', height: '100%', display: 'block' }} />;
}
+189
View File
@@ -0,0 +1,189 @@
import { useEffect, useState, useCallback } from 'react';
import { RadioTower, Cable, Wifi, Zap, Plus, Router, Waypoints, Maximize2, Minimize2, Timer } from 'lucide-react';
import { apiGet, apiPost } from '@/api/client';
import { cn } from '@/lib/utils';
import { LoraMeshViz } from './LoraMeshViz';
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 [expanded, setExpanded] = useState(false);
const [, setTick] = useState(0); // раз в сек — живой счётчик «последний опрос»
useEffect(() => { const t = setInterval(() => setTick((x) => x + 1), 1000); return () => clearInterval(t); }, []);
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);
const NORM_SEC = 15; // норматив свежести опроса
const pollAges = active.map((d) => (d.last_seen ? (Date.now() - Date.parse(d.last_seen)) / 1000 : Infinity));
const lastPollAge = pollAges.length ? Math.min(...pollAges) : Infinity; // с последнего опроса (мин по узлам)
const staleCount = pollAges.filter((a) => a > NORM_SEC).length;
const vizNodes = devices
.filter((d) => d.transport === 'lora' && d.status !== 'disabled')
.map((d) => ({ id: d.node_id, label: d.name || d.node_id, status: d.status, rssi: d.last_rssi, transport: d.transport, lastSeen: d.last_seen, w: d.w }));
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/&lt;id&gt;</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>
{/* Визуализатор ретрансляции/маршрутизации LoRa-хопов */}
{(() => {
const pollTxt = lastPollAge === Infinity ? '—' : lastPollAge < 60 ? `${Math.round(lastPollAge)} с назад` : `${Math.round(lastPollAge / 60)} мин назад`;
const header = (
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<span className="flex items-center gap-2 text-[11px] uppercase tracking-wide text-fg-muted"><Waypoints className="h-3.5 w-3.5" /> Маршрутизация LoRa · хопы и ретрансляция</span>
<div className="flex items-center gap-2">
<span className={cn('flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] font-semibold', staleCount ? 'bg-crit/20 text-crit' : 'bg-muted text-fg-muted')}>
<Timer className="h-3 w-3" /> последний опрос: {pollTxt}{staleCount ? ` · ${staleCount} вне норматива` : ''}
</span>
<button onClick={() => setExpanded((v) => !v)} title={expanded ? 'Свернуть' : 'Развернуть карту'} className="grid h-7 w-7 place-items-center rounded-lg text-fg-muted hover:bg-muted hover:text-fg">
{expanded ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
</div>
</div>
);
const viz = <LoraMeshViz gatewayLabel={gws[0]?.gw_id || 'gw1'} normSec={NORM_SEC} nodes={vizNodes} />;
return expanded ? (
<div className="fixed inset-3 z-[4000] flex flex-col overflow-hidden rounded-2xl border border-border bg-surface shadow-card">
{header}
<div className="min-h-0 flex-1 bg-[#070a10]">{viz}</div>
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-border bg-surface">
{header}
<div className="h-[280px] w-full bg-[#070a10]">{viz}</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>
);
}
+185
View File
@@ -0,0 +1,185 @@
import { useEffect, useRef } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import type { MonitoredEntity } from '@/core/types';
// «Карта-лаборатория» — рендер по референсу benzrf.ru / прототипа orbit-gps-mobile:
// MapLibre GL, GeoJSON-источник с нативной кластеризацией + donut-кластеры (SVG-сегменты
// по статусам), 2ГИС-подложка, опц. 3D-здания (OpenFreeMap fill-extrusion).
type Status = 'moving' | 'parked' | 'offline' | 'alarm';
const COLORS: Record<Status, string> = { moving: '#3fd06b', parked: '#f4b740', offline: '#7d94a3', alarm: '#ef4444' };
function statusOf(e: MonitoredEntity): Status {
if (!e.online) return e.type === 'energy' ? 'alarm' : 'offline';
if (e.type === 'transport') return (e.telemetry?.speed ?? 0) > 0 ? 'moving' : 'parked';
return 'parked'; // стационарные онлайн (энерго/сеть)
}
function glyphOf(e: MonitoredEntity): string {
if (e.type === 'energy') return '⚡';
if (e.type === 'network') return '📶';
return '▲';
}
// Подложка: CARTO dark (растр, CORS-совместим с WebGL — 2ГИС-тайлы в GL не отдают CORS/204,
// поэтому 2ГИС остаётся на Leaflet-разделах, а GL-демо — на CARTO, как в эталоне benzrf).
// + OpenFreeMap-вектор для 3D-зданий.
const STYLE: any = {
version: 8,
sources: {
carto: { type: 'raster', tileSize: 256, tiles: ['a', 'b', 'c', 'd'].map((s) => `https://${s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png`), attribution: '© OpenStreetMap © CARTO', maxzoom: 19 },
openfreemap: { type: 'vector', url: 'https://tiles.openfreemap.org/planet' },
},
layers: [
{ id: 'bg', type: 'background', paint: { 'background-color': '#0b0f14' } },
{ id: 'base', type: 'raster', source: 'carto' },
],
};
function fc(entities: MonitoredEntity[]) {
return {
type: 'FeatureCollection',
features: entities
.filter((e) => e.lat != null && e.lon != null)
.map((e) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [e.lon!, e.lat!] }, properties: { id: e.id, status: statusOf(e) } })),
} as any;
}
function donutSegment(start: number, end: number, r: number, r0: number, color: string): string {
if (end - start === 1) end -= 0.00001;
const a0 = 2 * Math.PI * (start - 0.25), a1 = 2 * Math.PI * (end - 0.25);
const x0 = Math.cos(a0), y0 = Math.sin(a0), x1 = Math.cos(a1), y1 = Math.sin(a1);
const large = end - start > 0.5 ? 1 : 0;
return `<path d="M ${r + r0 * x0} ${r + r0 * y0} L ${r + r * x0} ${r + r * y0} A ${r} ${r} 0 ${large} 1 ${r + r * x1} ${r + r * y1} L ${r + r0 * x1} ${r + r0 * y1} A ${r0} ${r0} 0 ${large} 0 ${r + r0 * x0} ${r + r0 * y0}" fill="${color}"/>`;
}
function createDonut(props: any): HTMLElement {
const keys: Status[] = ['moving', 'parked', 'offline', 'alarm'];
const total = props.point_count as number;
const r = total >= 15 ? 27 : total >= 8 ? 23 : 19;
const r0 = Math.round(r * 0.62), w = r * 2;
let svg = `<svg width="${w}" height="${w}" viewBox="0 0 ${w} ${w}">`;
let offset = 0;
for (const k of keys) {
const n = Number(props[k] || 0);
if (!n) continue;
svg += donutSegment(offset / total, (offset + n) / total, r, r0, COLORS[k]);
offset += n;
}
svg += `<circle cx="${r}" cy="${r}" r="${r0}" fill="#141b22"/><text x="${r}" y="${r}" dominant-baseline="central" text-anchor="middle" fill="#cfe3d6" font-size="${Math.round(r * 0.58)}" font-weight="700" font-family="Inter,system-ui">${total}</text></svg>`;
const el = document.createElement('div');
el.style.cursor = 'pointer';
el.style.filter = 'drop-shadow(0 2px 6px rgba(0,0,0,.5))';
el.innerHTML = svg;
return el;
}
function createPin(e: MonitoredEntity, onClick: () => void): HTMLElement {
const c = COLORS[statusOf(e)];
const el = document.createElement('div');
el.style.cssText = 'display:flex;flex-direction:column;align-items:center;cursor:pointer';
el.innerHTML = `<div style="width:26px;height:26px;border-radius:50%;background:${c};border:2px solid #0F172A;display:grid;place-items:center;box-shadow:0 0 0 3px ${c}40;font-size:13px">${glyphOf(e)}</div><div style="margin-top:2px;font:600 10px Inter,system-ui;color:#cfe3d6;background:rgba(12,16,20,.7);padding:1px 5px;border-radius:6px;white-space:nowrap;max-width:120px;overflow:hidden;text-overflow:ellipsis">${e.name}</div>`;
el.addEventListener('click', (ev) => { ev.stopPropagation(); onClick(); });
return el;
}
export function MapClusterGL({ entities, threeD, onSelect, className }: { entities: MonitoredEntity[]; threeD?: boolean; onSelect?: (id: string) => void; className?: string }) {
const box = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const markers = useRef<Record<string, maplibregl.Marker>>({});
const onScreen = useRef<Record<string, maplibregl.Marker>>({});
const entRef = useRef(entities);
entRef.current = entities;
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 }, preserveDrawingBuffer: true } as any);
mapRef.current = map;
map.touchZoomRotate.enableRotation();
// Fix: MapLibre рисует пустой холст, если контейнер доразметился после init.
// ResizeObserver + отложенные resize (rAF/таймауты) форсируют перерисовку после раскладки.
const ro = new ResizeObserver(() => map.resize());
ro.observe(box.current);
// map.resize() в одиночку иногда не форсирует перерисовку из-за раскладки контейнера;
// синтетический window-resize надёжно триггерит внутренний обработчик MapLibre (проверено).
const bump = () => { try { map.resize(); window.dispatchEvent(new Event('resize')); } catch { /* map removed */ } };
requestAnimationFrame(bump);
const t1 = setTimeout(bump, 250), t2 = setTimeout(bump, 700), t3 = setTimeout(bump, 1400);
const updateMarkers = () => {
if (!map.getSource('objects') || !map.isSourceLoaded('objects')) return;
const next: Record<string, maplibregl.Marker> = {};
const feats = map.querySourceFeatures('objects');
for (const f of feats) {
const coords = (f.geometry as any).coordinates as [number, number];
const p: any = f.properties;
let id: string, m: maplibregl.Marker | undefined;
if (p.cluster) {
id = 'c' + p.cluster_id;
m = markers.current[id];
const sig = [p.point_count, p.moving, p.parked, p.offline, p.alarm].join('|');
if (m && (m as any)._sig !== sig) { m.remove(); delete markers.current[id]; delete onScreen.current[id]; m = undefined; }
if (!m) {
const el = createDonut(p);
el.addEventListener('click', (e) => {
e.stopPropagation();
(map.getSource('objects') as any).getClusterExpansionZoom(p.cluster_id).then((z: number) => map.easeTo({ center: coords, zoom: z + 0.4, duration: 500 }));
});
m = markers.current[id] = new maplibregl.Marker({ element: el }).setLngLat(coords);
(m as any)._sig = sig;
}
} else {
id = 'v' + p.id;
const e = entRef.current.find((x) => x.id === p.id);
if (!e) continue;
m = markers.current[id];
if (!m) m = markers.current[id] = new maplibregl.Marker({ element: createPin(e, () => onSelect?.(e.id)) }).setLngLat(coords);
else m.setLngLat(coords);
}
next[id] = m;
if (!onScreen.current[id]) m.addTo(map);
}
for (const id in onScreen.current) if (!next[id]) onScreen.current[id].remove();
onScreen.current = next;
};
map.on('load', () => {
map.addSource('objects', {
type: 'geojson', data: fc(entRef.current), cluster: true, clusterMaxZoom: 13, clusterRadius: 56,
clusterProperties: {
moving: ['+', ['case', ['==', ['get', 'status'], 'moving'], 1, 0]],
parked: ['+', ['case', ['==', ['get', 'status'], 'parked'], 1, 0]],
offline: ['+', ['case', ['==', ['get', 'status'], 'offline'], 1, 0]],
alarm: ['+', ['case', ['==', ['get', 'status'], 'alarm'], 1, 0]],
},
} as any);
map.addLayer({ id: 'stub', type: 'circle', source: 'objects', paint: { 'circle-radius': 0, 'circle-opacity': 0 } });
map.on('render', updateMarkers);
map.resize(); // на случай, если контейнер получил размер только сейчас
});
return () => { clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); ro.disconnect(); map.remove(); mapRef.current = null; markers.current = {}; onScreen.current = {}; };
}, [onSelect]);
// Обновление данных источника
useEffect(() => {
const map = mapRef.current;
const src = map?.getSource('objects') as any;
if (src) src.setData(fc(entities));
}, [entities]);
// 3D-здания
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const apply = () => {
if (threeD && !map.getLayer('b3d')) {
map.addLayer({ id: 'b3d', type: 'fill-extrusion', source: 'openfreemap', 'source-layer': 'building', minzoom: 14, paint: { 'fill-extrusion-color': '#2c3a47', 'fill-extrusion-height': ['coalesce', ['get', 'render_height'], 12], 'fill-extrusion-base': ['coalesce', ['get', 'render_min_height'], 0], 'fill-extrusion-opacity': 0.78 } } as any, 'stub');
map.easeTo({ pitch: 52, duration: 600 });
} else if (!threeD && map.getLayer('b3d')) {
map.removeLayer('b3d');
map.easeTo({ pitch: 0, duration: 600 });
}
};
if (map.isStyleLoaded()) apply(); else map.once('load', apply);
}, [threeD]);
return <div ref={box} className={className} style={{ width: '100%', height: '100%', minHeight: 320 }} />;
}
+57
View File
@@ -0,0 +1,57 @@
import { useState } from 'react';
import { Boxes, Layers3 } from 'lucide-react';
import { useAuth } from '@/core/store';
import { useGpsData } from './useGpsData';
import { MapClusterGL } from './MapClusterGL';
const LEGEND: Array<{ k: string; c: string; label: string }> = [
{ k: 'moving', c: '#3fd06b', label: 'В движении' },
{ k: 'parked', c: '#f4b740', label: 'Стоянка / норма' },
{ k: 'offline', c: '#7d94a3', label: 'Нет связи' },
{ k: 'alarm', c: '#ef4444', label: 'Авария (ТП)' },
];
// Демо-раздел «Карта-лаборатория»: рендер карты по референсу benzrf.ru —
// MapLibre GL + нативная кластеризация (donut-кластеры) + 2ГИС + 3D-здания.
export function MapLabModule() {
const user = useAuth((s) => s.user);
const { objs, live } = useGpsData(user);
const [threeD, setThreeD] = useState(false);
return (
<div className="flex h-full flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="grid h-9 w-9 place-items-center rounded-xl bg-accent/15 text-accent"><Boxes className="h-5 w-5" /></span>
<div>
<div className="text-base font-semibold leading-tight">Карта-лаборатория (GL)</div>
<div className="text-[11px] text-fg-muted">MapLibre GL · кластеризация donut · 2ГИС · референс benzrf.ru</div>
</div>
</div>
<div className="flex items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${live ? 'bg-ok/20 text-ok' : 'bg-muted text-fg-muted'}`}>{live ? 'live · шлюз' : 'демо'} · {objs.length} объектов</span>
<button onClick={() => setThreeD((v) => !v)} className={`flex items-center gap-1.5 rounded-xl px-3 py-1.5 text-sm font-semibold transition-colors ${threeD ? 'bg-accent text-[#06210f]' : 'border border-border text-fg hover:bg-muted'}`}>
<Layers3 className="h-4 w-4" /> 3D-здания
</button>
</div>
</div>
<div className="relative min-h-[420px] flex-1 overflow-hidden rounded-2xl border border-border">
<MapClusterGL entities={objs} threeD={threeD} className="absolute inset-0" />
<div className="pointer-events-none absolute bottom-2 left-2 flex flex-col gap-1 rounded-xl bg-surface/80 px-3 py-2 backdrop-blur">
{LEGEND.map((l) => (
<span key={l.k} className="flex items-center gap-2 text-[11px] text-fg-muted">
<span className="h-2.5 w-2.5 rounded-full" style={{ background: l.c }} /> {l.label}
</span>
))}
</div>
</div>
<p className="text-[11px] text-fg-muted">
Скрипты отрисовки портированы из прототипа <span className="font-mono">prototypes/orbit-gps-mobile.html</span>:
GeoJSON-источник с <span className="font-mono">cluster:true</span>, donut-кластеры (SVG-сегменты по статусам),
подложка 2ГИС, 3D-здания OpenFreeMap (fill-extrusion). Кластер разворачивается по клику.
</p>
</div>
);
}
+5 -13
View File
@@ -1,17 +1,9 @@
import { useEffect, useState, type ComponentProps } from 'react';
import { type ComponentProps } from 'react';
import { MapView } from './MapView';
import { MapViewGL } from './MapViewGL';
// «Вторая схема» карты: на мобильном (<1024px) — MapLibre GL (плавно, GPU),
// на десктопе — привычный Leaflet. Пропсы идентичны, переключение по ширине окна.
// Основная карта — Leaflet с подложкой 2ГИС на всех разделах и устройствах
// (2ГИС-растр отдаётся как <img>, работает везде; в WebGL/MapLibre 2ГИС не годится —
// нет CORS. GL-рендер с кластеризацией вынесен в демо-раздел «Карта-лаборатория» на CARTO).
export function MapSwitch(props: ComponentProps<typeof MapView>) {
const [gl, setGl] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 1023px)').matches);
useEffect(() => {
const mq = window.matchMedia('(max-width: 1023px)');
const on = () => setGl(mq.matches);
on();
mq.addEventListener('change', on);
return () => mq.removeEventListener('change', on);
}, []);
return gl ? <MapViewGL {...(props as any)} /> : <MapView {...props} />;
return <MapView {...props} />;
}
+2 -1
View File
@@ -92,7 +92,8 @@ export function MapView({
if (!elRef.current || mapRef.current) return;
const map = L.map(elRef.current, { center: [44.05, 43.06], zoom: 11, zoomControl: true });
const bases = baseLayers();
bases[theme === 'dark' ? 'Тёмная' : 'Светлая'].addTo(map);
// По умолчанию — 2ГИС на всех разделах (переключение слоёв остаётся в контроле)
(bases['2ГИС'] ?? bases[theme === 'dark' ? 'Тёмная' : 'Светлая']).addTo(map);
L.control.layers(bases, undefined, { position: 'topright', collapsed: true }).addTo(map);
mapRef.current = map;
+1
View File
@@ -27,6 +27,7 @@ interface Props {
events?: Array<{ id: string; lat: number; lon: number; color: string; char: string; label: string; sub?: string }>;
}
// CARTO GL-стиль (2ГИС-растр в WebGL не работает — нет CORS; основная карта = Leaflet+2ГИС).
const STYLE = (dark: boolean) =>
`https://basemaps.cartocdn.com/gl/${dark ? 'dark-matter' : 'positron'}-gl-style/style.json`;
+14 -9
View File
@@ -24,17 +24,21 @@ function linkStyle(n: MonitoredEntity): { style: 'solid' | 'dash' | 'flow'; colo
function photoOf(n: MonitoredEntity) {
const camera = /камера/i.test(n.name);
const kind = camera ? 'Снимок камеры' : 'Фото прибора';
// Снимок-заглушка, похожая на реальное фото прибора (self-contained SVG, рисуется всегда).
const leds = [0, 1, 2, 3].map((i) => `<circle cx='${70 + i * 22}' cy='150' r='5' fill='${i < 3 ? '#22C55E' : '#F59E0B'}'/>`).join('');
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='420' height='230'>
<defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='#16223C'/><stop offset='1' stop-color='#0F172A'/></linearGradient></defs>
<rect width='420' height='230' fill='url(#g)'/>
<rect x='10' y='10' width='400' height='210' fill='none' stroke='#27344B'/>
<circle cx='210' cy='112' r='30' fill='none' stroke='#22C55E' stroke-width='2'/>
<circle cx='210' cy='112' r='11' fill='#22C55E' opacity='0.55'/>
<text x='20' y='34' fill='#94A3B8' font-family='monospace' font-size='13'>${kind}</text>
<text x='20' y='205' fill='#22C55E' font-family='monospace' font-size='12'>${n.name}</text>
<text x='300' y='205' fill='#94A3B8' font-family='monospace' font-size='12'>06:00 · сегодня</text>
<defs><linearGradient id='bg' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='#1B2942'/><stop offset='1' stop-color='#0B1220'/></linearGradient></defs>
<rect width='420' height='230' fill='url(#bg)'/>
${camera ? "<rect x='150' y='70' width='120' height='70' rx='8' fill='#0F1B30' stroke='#3B4B6B'/><circle cx='210' cy='105' r='22' fill='#0B1220' stroke='#22C55E' stroke-width='2'/><circle cx='210' cy='105' r='8' fill='#22C55E' opacity='0.6'/>" : "<rect x='55' y='95' width='310' height='80' rx='6' fill='#132038' stroke='#3B4B6B' stroke-width='2'/><rect x='300' y='108' width='52' height='54' rx='3' fill='#0F1B30' stroke='#3B4B6B'/>" + leds}
<text x='20' y='32' fill='#9FB3C8' font-family='monospace' font-size='13'>${kind}</text>
<circle cx='300' cy='26' r='4' fill='#22C55E'><animate attributeName='opacity' values='1;0.3;1' dur='2s' repeatCount='indefinite'/></circle>
<text x='312' y='31' fill='#22C55E' font-family='monospace' font-size='11'>LIVE</text>
<text x='20' y='208' fill='#22C55E' font-family='monospace' font-size='12'>${n.name}</text>
<text x='290' y='208' fill='#8AA0B8' font-family='monospace' font-size='11'>снимок 06:00 · сегодня</text>
</svg>`;
return { url: `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`, kind };
const fallback = `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
// Реальное изображение (same-origin/MinIO) — из meta.photo; иначе снимок-заглушка.
return { url: n.meta?.photo || fallback, fallback, kind, real: !!n.meta?.photo };
}
export function NetworkModule() {
@@ -145,6 +149,7 @@ export function NetworkModule() {
src={photoOf(n).url}
alt={photoOf(n).kind}
loading="lazy"
onError={(e) => { const el = e.currentTarget; const fb = photoOf(n).fallback; if (el.src !== fb) el.src = fb; }}
className="h-32 w-full rounded-lg border border-border object-cover"
/>
</div>
+1 -1
View File
@@ -27,7 +27,7 @@ export function useSmartData(user: User): { channels: WbChannel[]; events: Monit
[
{
id: 'door-' + new Date().toISOString(),
entityId: 'cab-07',
entityId: 'tp-07',
callsign: 'Датчик двери · WB-MR6C #214',
severity: (open ? 'warning' : 'info') as MonitorEvent['severity'],
type: open ? 'door_open' : 'door_closed',