Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14ac295e50 | ||
|
|
786b4ef4bb | ||
|
|
2f339803b4 | ||
|
|
d4c05152c5 | ||
|
|
fc10cb77ba | ||
|
|
27067d3d12 | ||
|
|
010a5308f2 | ||
|
|
8d0e26a02b | ||
|
|
b2836af333 |
@@ -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 (реле «Ворота» цело)
|
||||
```
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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` и залить.
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,122 @@
|
||||
# Энергомониторинг v2: LoRa + LAN, сервер учёта, интерфейс (Егорин / ORBIT)
|
||||
|
||||
## 1. Архитектура
|
||||
|
||||
```
|
||||
┌─ LoRa-узлы (869 МГц) ──→ Шлюз gw1 (ESP32 + RA-01H + W5500, RJ45) ─┐
|
||||
│ Ethernet
|
||||
┌─ LAN-узлы (WT32-ETH01, RJ45) ─────────────────────────────────────┤
|
||||
▼
|
||||
Mosquitto (Proxmox srv001, :1883)
|
||||
│
|
||||
┌───────────────────────────────┼──────────────────┐
|
||||
▼ ▼ ▼
|
||||
provisioner.js Home Assistant n8n
|
||||
(учёт устройств, API :8090, (MQTT Discovery — (алерты в
|
||||
Postgres, HA Discovery) сущности сами) @Svetdo_bot)
|
||||
▲
|
||||
│ REST
|
||||
Веб-интерфейс (ui-prototype.html → далее React)
|
||||
```
|
||||
|
||||
Правило выбора транспорта узла: **есть розетка RJ45 в щите → LAN-узел** (WT32-ETH01,
|
||||
надёжнее, чаще телеметрия, LWT-статус). **Кабеля нет → LoRa-узел** через шлюз.
|
||||
Протокол данных у обоих одинаковый.
|
||||
|
||||
## 2. Железо (дополнение к v1)
|
||||
|
||||
| Позиция | Модель | Цена | Прим. |
|
||||
|---|---|---|---|
|
||||
| LAN-узел | **WT32-ETH01** (ESP32 + LAN8720, RJ45) | 700–900 ₽ | вместо DevKit+RA-01H |
|
||||
| Шлюз v2 | ESP32 DevKit + RA-01H + **W5500 mini** | +250 ₽ к v1 | Ethernet вместо WiFi |
|
||||
| PoE-вариант | Olimex **ESP32-POE** | ~2500 ₽ | питание по одному кабелю, для мачт/удалённых щитов |
|
||||
|
||||
Распиновка WT32-ETH01: PZEM на IO5/IO17 (UART2); входы реле IO32/IO33
|
||||
(внутренний pullup) и IO35/IO36 (только вход — внешний pullup 10к на 3.3V);
|
||||
выходы реле IO2/IO4/IO14/IO15. Шлюз: LoRa и W5500 на общей шине VSPI
|
||||
(SCK18/MISO19/MOSI23), CS: LoRa=5, W5500=4.
|
||||
|
||||
## 3. Единое пространство топиков (v2)
|
||||
|
||||
| Топик | Направление | Содержимое |
|
||||
|---|---|---|
|
||||
| `nodes/<id>/info` | узел → сервер, **retained** | паспорт: `{"id","fw","hw":"lan\|lora","caps":{"pzem":true,"relays":4,"ctl":true},"ip","mac","gw"}` |
|
||||
| `nodes/<id>/data` | узел → сервер | телеметрия: v, a, w, kwh, pf, hz, r[], seq (+rssi/snr/gw для LoRa) |
|
||||
| `nodes/<id>/status` | LAN-узел → сервер | `online`/`offline` (LWT). LoRa-узлам статус ставит сервер по таймауту last_seen |
|
||||
| `nodes/<id>/cmd` | сервер → узел | `{"cmd":"relay","n":0,"s":1}` · `{"cmd":"poll"}` · `{"cmd":"info"}` |
|
||||
| `gateways/<id>/status` | шлюз | online/offline (LWT) |
|
||||
|
||||
LAN-узлы публикуют напрямую; LoRa-узлы шлют JSON с полем `"t":"info"|"d"`,
|
||||
шлюз раскладывает по тем же топикам и добавляет rssi/snr/gw.
|
||||
|
||||
## 4. Механизм добавления устройств на сервер
|
||||
|
||||
Принцип: **узел регистрируется сам, человек только подтверждает.**
|
||||
Прошивка одинаковая для всех узлов, отличается только `NODE_ID` — на объекте
|
||||
монтажнику ничего настраивать не нужно.
|
||||
|
||||
1. **Выход в эфир.** При включении узел публикует `nodes/<id>/info` (retained).
|
||||
LoRa-узел делает то же через шлюз. Повтор раз в 10 минут.
|
||||
2. **Регистрация.** `provisioner.js` видит неизвестный `node_id` → создаёт запись
|
||||
в `devices` со `status='pending'`. Паспорт (транспорт, IP/MAC или шлюз+RSSI,
|
||||
прошивка, число реле) сохраняется автоматически.
|
||||
3. **Интерфейс.** На экране «Устройства» появляется янтарная полоса
|
||||
«Обнаружены новые устройства» со списком pending-узлов.
|
||||
4. **Мастер добавления** (3 шага):
|
||||
- *Паспорт* — проверка, что это ожидаемый узел (ID, транспорт, RSSI/IP, реле);
|
||||
- *Параметры* — название, проект (Егорин/ORBIT), объект, имена реле;
|
||||
- *Подтверждение* — `POST /api/devices/<id>/approve`.
|
||||
5. **Автопубликация в HA.** При approve сервис публикует retained-конфиги
|
||||
MQTT Discovery (`homeassistant/sensor|switch/.../config`) — датчики V/A/W/кВт·ч
|
||||
и выключатели реле появляются в Home Assistant без правки yaml.
|
||||
6. **Жизненный цикл.** `pending → active → disabled`. Disabled-узел данные пишет,
|
||||
но скрыт с борда и не публикуется в HA. Повторное включение — снова approve.
|
||||
|
||||
Замена узла в поле: новый узел с тем же `NODE_ID` подхватывает старую карточку
|
||||
(info обновит fw/mac), история сохраняется.
|
||||
|
||||
## 5. REST API (provisioner.js, :8090)
|
||||
|
||||
| Метод | Путь | Назначение |
|
||||
|---|---|---|
|
||||
| GET | `/api/devices?status=pending\|active&project=egorin\|orbit` | список + последняя телеметрия (view `v_devices_board`) |
|
||||
| POST | `/api/devices/:id/approve` | `{name, project, object, relay_names[]}` → active + HA Discovery |
|
||||
| POST | `/api/devices/:id/disable` | скрыть/отклонить |
|
||||
| POST | `/api/devices/:id/cmd` | команда узлу, пишется в журнал `commands` |
|
||||
| GET | `/api/devices/:id/history?hours=24` | ряды для графиков |
|
||||
| GET | `/api/devices/:id/relay-events` | журнал переключений реле |
|
||||
|
||||
Запуск: `npm i mqtt pg express && node provisioner.js`
|
||||
(ENV: `MQTT_URL`, `PG_URL`, `PORT`). Схема БД — `schema.sql` (Postgres).
|
||||
|
||||
## 6. Структура интерфейса (ORBIT design system)
|
||||
|
||||
Стиль: фон #070a10, акцент #4ADE80, Unbounded + Onest + JetBrains Mono,
|
||||
glassmorphism-карточки. Desktop ≥1024: левый рейл 80px; mobile: нижний таб-бар
|
||||
(паттерн ORBIT GPS). Рабочий прототип — `ui-prototype.html`.
|
||||
|
||||
```
|
||||
├─ Обзор сводка по проектам: Σ кВт·ч сегодня/месяц, узлы в сети,
|
||||
│ последние события реле, состояние шлюзов
|
||||
├─ Устройства ★ основной экран (реализован в прототипе):
|
||||
│ ├─ фильтр-чипы Все / Егорин / ORBIT
|
||||
│ ├─ полоса «Обнаружены новые устройства» (pending) → мастер добавления
|
||||
│ ├─ сетка карточек: статус-точка с пульсацией, бейдж LAN/LoRa+RSSI,
|
||||
│ │ метрики В·А·Вт·кВт·ч (mono), тумблеры реле с именами, last seen
|
||||
│ └─ карточка узла (drill-down, след. итерация): графики W/kWh за 24ч/30д,
|
||||
│ журнал реле, паспорт, кнопки poll / перезапрос info / disable
|
||||
├─ Шлюзы gw1..N: online, IP, число узлов, средний RSSI по узлам
|
||||
├─ Журнал relay_events + commands: кто переключил, что, когда, источник
|
||||
└─ Настройки MQTT-брокер, таймаут offline, частота/SF сети, ретенция БД
|
||||
```
|
||||
|
||||
## 7. Порядок внедрения
|
||||
|
||||
1. Postgres: применить `schema.sql` (LXC на srv001).
|
||||
2. Запустить `provisioner.js` (Docker/LXC, systemd).
|
||||
3. Прошить шлюз `gateway_lora_eth.ino`, воткнуть в свитч щитовой.
|
||||
4. Прошить узлы: LAN — `node_lan_energy.ino`, LoRa — `node_lora_energy_v2.ino`
|
||||
(уникальные NODE_ID: eg01…, orb01…, eg-lan1…).
|
||||
5. Узлы появятся в pending → добавить через мастер → проверить сущности в HA.
|
||||
6. n8n: подписка на `nodes/+/data`, алерты в @Svetdo_bot
|
||||
(узел offline >5 мин, ток выше порога, внеплановое отключение реле).
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* ================================================================
|
||||
* ШЛЮЗ v2: LoRa ↔ Ethernet (W5500) ↔ MQTT
|
||||
* Проекты: Егорин / ORBIT · протокол v2 (nodes/...)
|
||||
* ================================================================
|
||||
* Железо:
|
||||
* - ESP32 DevKit V1
|
||||
* - LoRa RA-01H (SX1276, 869 МГц)
|
||||
* - Ethernet W5500 mini (SPI)
|
||||
* Оба модуля на общей шине VSPI, разные CS:
|
||||
* SCK=18 MISO=19 MOSI=23
|
||||
* LoRa: NSS=5 RST=14 DIO0=26
|
||||
* W5500: CS=4
|
||||
*
|
||||
* Ретранслирует LoRa-узлы в единое пространство топиков:
|
||||
* пакет {"t":"info",...} → nodes/<id>/info (retained)
|
||||
* пакет {"t":"d",...} → nodes/<id>/data (+rssi/snr/gw)
|
||||
* nodes/<id>/cmd (MQTT) → LoRa вниз, адресно по id
|
||||
*
|
||||
* Онлайн/оффлайн LoRa-узлов решает сервер по last_seen
|
||||
* (у радиоузлов нет LWT).
|
||||
*
|
||||
* Библиотеки: LoRa (Sandeep Mistry), Ethernet (Arduino),
|
||||
* PubSubClient, ArduinoJson
|
||||
* ================================================================
|
||||
*/
|
||||
|
||||
#include <SPI.h>
|
||||
#include <Ethernet.h>
|
||||
#include <PubSubClient.h>
|
||||
#include <LoRa.h>
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
// ================== НАСТРОЙКИ ==================
|
||||
#define GW_ID "gw1"
|
||||
#define MQTT_HOST "192.168.1.200"
|
||||
#define MQTT_PORT 1883
|
||||
#define MQTT_USER ""
|
||||
#define MQTT_PASSWD ""
|
||||
|
||||
byte mac[] = {0xDE, 0xAD, 0xBE, 0xEF, 0x10, 0x01}; // уникальный на шлюз
|
||||
|
||||
// LoRa — параметры должны совпадать с узлами
|
||||
#define LORA_FREQ 869000000E0
|
||||
#define LORA_SF 9
|
||||
#define LORA_BW 125E3
|
||||
#define LORA_SYNC 0x2B
|
||||
#define LORA_TX_DBM 20
|
||||
#define LORA_NSS 5
|
||||
#define LORA_RST 14
|
||||
#define LORA_DIO0 26
|
||||
#define W5500_CS 4
|
||||
|
||||
EthernetClient ethClient;
|
||||
PubSubClient mqtt(ethClient);
|
||||
|
||||
// ================== СЕТЬ ==================
|
||||
void netBegin() {
|
||||
Ethernet.init(W5500_CS);
|
||||
Serial.print("DHCP... ");
|
||||
while (Ethernet.begin(mac) == 0) {
|
||||
Serial.println("fail, retry 5s");
|
||||
delay(5000);
|
||||
}
|
||||
Serial.println(Ethernet.localIP());
|
||||
}
|
||||
|
||||
void mqttReconnect() {
|
||||
while (!mqtt.connected()) {
|
||||
if (mqtt.connect("lora-" GW_ID, MQTT_USER, MQTT_PASSWD,
|
||||
"gateways/" GW_ID "/status", 1, true, "offline")) {
|
||||
mqtt.publish("gateways/" GW_ID "/status", "online", true);
|
||||
mqtt.subscribe("nodes/+/cmd");
|
||||
Serial.println("MQTT OK");
|
||||
} else {
|
||||
Serial.printf("MQTT rc=%d, retry 5s\n", mqtt.state());
|
||||
delay(5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================== MQTT → LoRa (вниз) ==================
|
||||
void mqttCallback(char *topic, byte *payload, unsigned int len) {
|
||||
String t(topic);
|
||||
int a = t.indexOf('/') + 1;
|
||||
int b = t.indexOf('/', a);
|
||||
String nodeId = t.substring(a, b);
|
||||
|
||||
// LAN-узлы обрабатывают свои cmd сами; шлюз ретранслирует всё,
|
||||
// радиоузлы фильтруют по своему id, чужое игнорируют.
|
||||
StaticJsonDocument<192> doc;
|
||||
if (deserializeJson(doc, payload, len)) return;
|
||||
doc["id"] = nodeId;
|
||||
|
||||
char buf[192];
|
||||
size_t n = serializeJson(doc, buf);
|
||||
LoRa.beginPacket();
|
||||
LoRa.write((const uint8_t*)buf, n);
|
||||
LoRa.endPacket();
|
||||
LoRa.receive();
|
||||
Serial.printf("DOWN [%s]: %s\n", nodeId.c_str(), buf);
|
||||
}
|
||||
|
||||
// ================== LoRa → MQTT (вверх) ==================
|
||||
void handleLoRaPacket() {
|
||||
String s;
|
||||
while (LoRa.available()) s += (char)LoRa.read();
|
||||
|
||||
StaticJsonDocument<384> doc;
|
||||
if (deserializeJson(doc, s)) { Serial.println("Bad pkt: " + s); return; }
|
||||
const char *id = doc["id"];
|
||||
if (!id) return;
|
||||
|
||||
const char *type = doc["t"] | "d";
|
||||
bool isInfo = !strcmp(type, "info");
|
||||
|
||||
doc["gw"] = GW_ID;
|
||||
if (!isInfo) {
|
||||
doc["rssi"] = LoRa.packetRssi();
|
||||
doc["snr"] = serialized(String(LoRa.packetSnr(), 1));
|
||||
}
|
||||
doc.remove("t");
|
||||
|
||||
char buf[384];
|
||||
size_t n = serializeJson(doc, buf);
|
||||
|
||||
char topic[64];
|
||||
snprintf(topic, sizeof(topic), "nodes/%s/%s", id, isInfo ? "info" : "data");
|
||||
mqtt.publish(topic, (const uint8_t*)buf, n, isInfo); // info — retained
|
||||
Serial.printf("UP %s: %s\n", topic, buf);
|
||||
}
|
||||
|
||||
// ================== SETUP / LOOP ==================
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
SPI.begin(18, 19, 23);
|
||||
|
||||
netBegin();
|
||||
mqtt.setServer(MQTT_HOST, MQTT_PORT);
|
||||
mqtt.setCallback(mqttCallback);
|
||||
mqtt.setBufferSize(512);
|
||||
|
||||
LoRa.setPins(LORA_NSS, LORA_RST, LORA_DIO0);
|
||||
if (!LoRa.begin(LORA_FREQ)) {
|
||||
Serial.println("LoRa init FAIL");
|
||||
while (true) delay(1000);
|
||||
}
|
||||
LoRa.setSpreadingFactor(LORA_SF);
|
||||
LoRa.setSignalBandwidth(LORA_BW);
|
||||
LoRa.setSyncWord(LORA_SYNC);
|
||||
LoRa.setTxPower(LORA_TX_DBM);
|
||||
LoRa.enableCrc();
|
||||
LoRa.receive();
|
||||
|
||||
Serial.println("Gateway " GW_ID " ready (ETH)");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
Ethernet.maintain(); // продление DHCP-аренды
|
||||
if (!mqtt.connected()) mqttReconnect();
|
||||
mqtt.loop();
|
||||
|
||||
if (LoRa.parsePacket() > 0) handleLoRaPacket();
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* ================================================================
|
||||
* LAN-УЗЕЛ: электроэнергия + реле → Ethernet → MQTT
|
||||
* Проекты: Егорин / ORBIT · протокол v2 (nodes/...)
|
||||
* ================================================================
|
||||
* Железо:
|
||||
* - WT32-ETH01 (ESP32 + LAN8720, порт RJ45)
|
||||
* - PZEM-004T v3.0 (UART2: RX=IO5, TX=IO17)
|
||||
* - Входы состояния реле: IO32, IO33 (внутр. pullup),
|
||||
* IO35, IO36 (только вход! внешний pullup 10к на 3.3V)
|
||||
* - Выходы управления реле: IO2, IO4, IO14, IO15
|
||||
* - Питание платы: 5V (не 3.3 на пин 5V!)
|
||||
*
|
||||
* Узел регистрируется на сервере сам: при старте публикует
|
||||
* retained nodes/<id>/info — провижн-сервис ставит его в
|
||||
* «Ожидают добавления», админ подтверждает в интерфейсе.
|
||||
*
|
||||
* Библиотеки: ArduinoJson, PubSubClient, PZEM004Tv30
|
||||
* Плата в IDE: "WT32-ETH01 Ethernet Module" (или ESP32 Dev Module)
|
||||
* ================================================================
|
||||
*/
|
||||
|
||||
#include <ETH.h>
|
||||
#include <PubSubClient.h>
|
||||
#include <PZEM004Tv30.h>
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
// ================== НАСТРОЙКИ УЗЛА ==================
|
||||
#define NODE_ID "eg-lan1" // уникальный ID: eg-lan1, orb-lan1...
|
||||
#define FW_VER "2.0"
|
||||
#define SEND_PERIOD_MS 15000UL // LAN не жалко — можно чаще, чем LoRa
|
||||
#define INFO_PERIOD_MS 600000UL // info раз в 10 минут
|
||||
|
||||
#define MQTT_HOST "192.168.1.200" // Mosquitto (Proxmox srv001)
|
||||
#define MQTT_PORT 1883
|
||||
#define MQTT_USER ""
|
||||
#define MQTT_PASSWD ""
|
||||
|
||||
// WT32-ETH01: конфигурация LAN8720
|
||||
#define ETH_PHY_TYPE ETH_PHY_LAN8720
|
||||
#define ETH_PHY_ADDR 1
|
||||
#define ETH_PHY_MDC 23
|
||||
#define ETH_PHY_MDIO 18
|
||||
#define ETH_PHY_POWER 16
|
||||
#define ETH_CLK ETH_CLOCK_GPIO0_IN
|
||||
|
||||
// PZEM на UART2 (WT32-ETH01: свободные IO5/IO17)
|
||||
PZEM004Tv30 pzem(Serial2, 5, 17);
|
||||
|
||||
// Реле
|
||||
const uint8_t RELAY_IN[] = {32, 33, 35, 36}; // 35/36 — внешний pullup!
|
||||
const uint8_t RELAY_OUT[] = {2, 4, 14, 15};
|
||||
const uint8_t N_RELAYS = 4;
|
||||
|
||||
// Топики
|
||||
#define T_DATA "nodes/" NODE_ID "/data"
|
||||
#define T_INFO "nodes/" NODE_ID "/info"
|
||||
#define T_STATUS "nodes/" NODE_ID "/status"
|
||||
#define T_CMD "nodes/" NODE_ID "/cmd"
|
||||
|
||||
WiFiClient ethClient; // ETH.h использует сетевой стек WiFiClient
|
||||
PubSubClient mqtt(ethClient);
|
||||
|
||||
static bool ethUp = false;
|
||||
unsigned long lastSend = 0, lastInfo = 0;
|
||||
uint16_t seq = 0;
|
||||
|
||||
// ================== ETHERNET ==================
|
||||
void onEthEvent(WiFiEvent_t event) {
|
||||
switch (event) {
|
||||
case ARDUINO_EVENT_ETH_GOT_IP:
|
||||
ethUp = true;
|
||||
Serial.printf("ETH IP: %s\n", ETH.localIP().toString().c_str());
|
||||
break;
|
||||
case ARDUINO_EVENT_ETH_DISCONNECTED:
|
||||
case ARDUINO_EVENT_ETH_STOP:
|
||||
ethUp = false;
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
// ================== ПУБЛИКАЦИИ ==================
|
||||
void publishInfo() {
|
||||
StaticJsonDocument<256> doc;
|
||||
doc["id"] = NODE_ID;
|
||||
doc["fw"] = FW_VER;
|
||||
doc["hw"] = "lan"; // транспорт узла
|
||||
doc["ip"] = ETH.localIP().toString();
|
||||
doc["mac"] = ETH.macAddress();
|
||||
JsonObject caps = doc.createNestedObject("caps");
|
||||
caps["pzem"] = true;
|
||||
caps["relays"] = N_RELAYS;
|
||||
caps["ctl"] = true; // умеет управлять реле
|
||||
|
||||
char buf[256];
|
||||
size_t n = serializeJson(doc, buf);
|
||||
mqtt.publish(T_INFO, (const uint8_t*)buf, n, true); // retained
|
||||
}
|
||||
|
||||
void publishTelemetry() {
|
||||
StaticJsonDocument<320> doc;
|
||||
doc["id"] = NODE_ID;
|
||||
doc["seq"] = seq++;
|
||||
|
||||
float v = pzem.voltage();
|
||||
if (!isnan(v)) {
|
||||
doc["v"] = serialized(String(v, 1));
|
||||
doc["a"] = serialized(String(pzem.current(), 3));
|
||||
doc["w"] = serialized(String(pzem.power(), 1));
|
||||
doc["kwh"] = serialized(String(pzem.energy(), 3));
|
||||
doc["pf"] = serialized(String(pzem.pf(), 2));
|
||||
doc["hz"] = serialized(String(pzem.frequency(), 1));
|
||||
} else {
|
||||
doc["err"] = "pzem";
|
||||
}
|
||||
|
||||
JsonArray r = doc.createNestedArray("r");
|
||||
for (uint8_t i = 0; i < N_RELAYS; i++)
|
||||
r.add(digitalRead(RELAY_IN[i]) == LOW ? 1 : 0);
|
||||
|
||||
char buf[320];
|
||||
size_t n = serializeJson(doc, buf);
|
||||
mqtt.publish(T_DATA, (const uint8_t*)buf, n);
|
||||
Serial.printf("PUB %s: %s\n", T_DATA, buf);
|
||||
}
|
||||
|
||||
// ================== КОМАНДЫ ==================
|
||||
// nodes/<id>/cmd: {"cmd":"relay","n":0,"s":1} | {"cmd":"poll"}
|
||||
void mqttCallback(char *topic, byte *payload, unsigned int len) {
|
||||
StaticJsonDocument<192> doc;
|
||||
if (deserializeJson(doc, payload, len)) return;
|
||||
const char *cmd = doc["cmd"];
|
||||
if (!cmd) return;
|
||||
|
||||
if (!strcmp(cmd, "relay")) {
|
||||
int n = doc["n"] | -1;
|
||||
int s = doc["s"] | 0;
|
||||
if (n >= 0 && n < N_RELAYS) {
|
||||
digitalWrite(RELAY_OUT[n], s ? HIGH : LOW);
|
||||
delay(200);
|
||||
publishTelemetry(); // подтверждаем фактом
|
||||
}
|
||||
} else if (!strcmp(cmd, "poll")) {
|
||||
publishTelemetry();
|
||||
}
|
||||
}
|
||||
|
||||
void mqttReconnect() {
|
||||
while (ethUp && !mqtt.connected()) {
|
||||
if (mqtt.connect(NODE_ID, MQTT_USER, MQTT_PASSWD,
|
||||
T_STATUS, 1, true, "offline")) {
|
||||
mqtt.publish(T_STATUS, "online", true); // LWT-пара
|
||||
mqtt.subscribe(T_CMD);
|
||||
publishInfo(); // саморегистрация
|
||||
Serial.println("MQTT OK");
|
||||
} else {
|
||||
Serial.printf("MQTT rc=%d, retry 5s\n", mqtt.state());
|
||||
delay(5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================== SETUP / LOOP ==================
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
|
||||
for (uint8_t i = 0; i < N_RELAYS; i++) {
|
||||
pinMode(RELAY_IN[i], (RELAY_IN[i] >= 34) ? INPUT : INPUT_PULLUP);
|
||||
pinMode(RELAY_OUT[i], OUTPUT);
|
||||
digitalWrite(RELAY_OUT[i], LOW);
|
||||
}
|
||||
|
||||
WiFi.onEvent(onEthEvent);
|
||||
ETH.begin(ETH_PHY_ADDR, ETH_PHY_POWER, ETH_PHY_MDC,
|
||||
ETH_PHY_MDIO, ETH_PHY_TYPE, ETH_CLK);
|
||||
|
||||
mqtt.setServer(MQTT_HOST, MQTT_PORT);
|
||||
mqtt.setCallback(mqttCallback);
|
||||
mqtt.setBufferSize(512);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (!ethUp) { delay(200); return; }
|
||||
if (!mqtt.connected()) mqttReconnect();
|
||||
mqtt.loop();
|
||||
|
||||
unsigned long now = millis();
|
||||
if (now - lastSend >= SEND_PERIOD_MS) { lastSend = now; publishTelemetry(); }
|
||||
if (now - lastInfo >= INFO_PERIOD_MS) { lastInfo = now; publishInfo(); }
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* ================================================================
|
||||
* LoRa-УЗЕЛ v2: электроэнергия + реле → LoRa → шлюз
|
||||
* Отличия от v1:
|
||||
* - пакет info при старте и раз в 10 мин (саморегистрация)
|
||||
* - поле "t": "info" | "d" — шлюз раскладывает по топикам nodes/
|
||||
* Железо и распиновка — как в v1 (ESP32 DevKit + PZEM + RA-01H):
|
||||
* PZEM: RX←GPIO17, TX→GPIO16 (Serial2)
|
||||
* LoRa: SCK=18 MISO=19 MOSI=23 NSS=5 RST=14 DIO0=26
|
||||
* Входы реле: 32, 33, 25, 27 · Выходы: 4, 13, 21, 22
|
||||
* ================================================================
|
||||
*/
|
||||
|
||||
#include <SPI.h>
|
||||
#include <LoRa.h>
|
||||
#include <PZEM004Tv30.h>
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
#define NODE_ID "eg01"
|
||||
#define FW_VER "2.0"
|
||||
#define SEND_PERIOD_MS 30000UL
|
||||
#define INFO_PERIOD_MS 600000UL
|
||||
#define LORA_FREQ 869000000E0
|
||||
#define LORA_SF 9
|
||||
#define LORA_BW 125E3
|
||||
#define LORA_SYNC 0x2B
|
||||
#define LORA_TX_DBM 20
|
||||
#define LORA_NSS 5
|
||||
#define LORA_RST 14
|
||||
#define LORA_DIO0 26
|
||||
|
||||
const uint8_t RELAY_IN[] = {32, 33, 25, 27};
|
||||
const uint8_t RELAY_OUT[] = {4, 13, 21, 22};
|
||||
const uint8_t N_RELAYS = 4;
|
||||
|
||||
PZEM004Tv30 pzem(Serial2, 16, 17);
|
||||
unsigned long lastSend = 0, lastInfo = 0;
|
||||
uint16_t seq = 0;
|
||||
|
||||
void loraSend(const char *buf, size_t n) {
|
||||
LoRa.beginPacket();
|
||||
LoRa.write((const uint8_t*)buf, n);
|
||||
LoRa.endPacket();
|
||||
LoRa.receive();
|
||||
}
|
||||
|
||||
void sendInfo() {
|
||||
StaticJsonDocument<224> doc;
|
||||
doc["id"] = NODE_ID;
|
||||
doc["t"] = "info";
|
||||
doc["fw"] = FW_VER;
|
||||
doc["hw"] = "lora";
|
||||
JsonObject caps = doc.createNestedObject("caps");
|
||||
caps["pzem"] = true;
|
||||
caps["relays"] = N_RELAYS;
|
||||
caps["ctl"] = true;
|
||||
char buf[224];
|
||||
loraSend(buf, serializeJson(doc, buf));
|
||||
Serial.println("TX info");
|
||||
}
|
||||
|
||||
void sendTelemetry() {
|
||||
StaticJsonDocument<288> doc;
|
||||
doc["id"] = NODE_ID;
|
||||
doc["t"] = "d";
|
||||
doc["seq"] = seq++;
|
||||
|
||||
float v = pzem.voltage();
|
||||
if (!isnan(v)) {
|
||||
doc["v"] = serialized(String(v, 1));
|
||||
doc["a"] = serialized(String(pzem.current(), 3));
|
||||
doc["w"] = serialized(String(pzem.power(), 1));
|
||||
doc["kwh"] = serialized(String(pzem.energy(), 3));
|
||||
doc["pf"] = serialized(String(pzem.pf(), 2));
|
||||
doc["hz"] = serialized(String(pzem.frequency(), 1));
|
||||
} else {
|
||||
doc["err"] = "pzem";
|
||||
}
|
||||
|
||||
JsonArray r = doc.createNestedArray("r");
|
||||
for (uint8_t i = 0; i < N_RELAYS; i++)
|
||||
r.add(digitalRead(RELAY_IN[i]) == LOW ? 1 : 0);
|
||||
|
||||
char buf[288];
|
||||
size_t n = serializeJson(doc, buf);
|
||||
loraSend(buf, n);
|
||||
Serial.printf("TX: %s\n", buf);
|
||||
}
|
||||
|
||||
void handleCommand(const String &payload) {
|
||||
StaticJsonDocument<192> doc;
|
||||
if (deserializeJson(doc, payload)) return;
|
||||
if (String((const char*)doc["id"]) != NODE_ID) return;
|
||||
|
||||
const char *cmd = doc["cmd"];
|
||||
if (!cmd) return;
|
||||
|
||||
if (!strcmp(cmd, "relay")) {
|
||||
int n = doc["n"] | -1;
|
||||
int s = doc["s"] | 0;
|
||||
if (n >= 0 && n < N_RELAYS) {
|
||||
digitalWrite(RELAY_OUT[n], s ? HIGH : LOW);
|
||||
delay(200);
|
||||
sendTelemetry();
|
||||
}
|
||||
} else if (!strcmp(cmd, "poll")) {
|
||||
sendTelemetry();
|
||||
} else if (!strcmp(cmd, "info")) {
|
||||
sendInfo();
|
||||
}
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
for (uint8_t i = 0; i < N_RELAYS; i++) {
|
||||
pinMode(RELAY_IN[i], INPUT_PULLUP);
|
||||
pinMode(RELAY_OUT[i], OUTPUT);
|
||||
digitalWrite(RELAY_OUT[i], LOW);
|
||||
}
|
||||
|
||||
LoRa.setPins(LORA_NSS, LORA_RST, LORA_DIO0);
|
||||
if (!LoRa.begin(LORA_FREQ)) { Serial.println("LoRa FAIL"); while (1) delay(1000); }
|
||||
LoRa.setSpreadingFactor(LORA_SF);
|
||||
LoRa.setSignalBandwidth(LORA_BW);
|
||||
LoRa.setSyncWord(LORA_SYNC);
|
||||
LoRa.setTxPower(LORA_TX_DBM);
|
||||
LoRa.enableCrc();
|
||||
LoRa.receive();
|
||||
|
||||
randomSeed(esp_random());
|
||||
lastSend = millis() - random(0, SEND_PERIOD_MS);
|
||||
delay(random(0, 3000)); // рассинхронизация info при массовом включении
|
||||
sendInfo();
|
||||
lastInfo = millis();
|
||||
}
|
||||
|
||||
void loop() {
|
||||
int sz = LoRa.parsePacket();
|
||||
if (sz > 0) {
|
||||
String s;
|
||||
while (LoRa.available()) s += (char)LoRa.read();
|
||||
handleCommand(s);
|
||||
}
|
||||
|
||||
unsigned long now = millis();
|
||||
if (now - lastSend >= SEND_PERIOD_MS + random(0, 2000)) {
|
||||
lastSend = now; sendTelemetry();
|
||||
}
|
||||
if (now - lastInfo >= INFO_PERIOD_MS) {
|
||||
lastInfo = now; sendInfo();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* ================================================================
|
||||
* provisioner.js — сервис учёта и добавления устройств
|
||||
* Егорин / ORBIT · Node.js 20+ · npm i mqtt pg express
|
||||
* ================================================================
|
||||
* Делает:
|
||||
* 1. Слушает nodes/+/info → неизвестный узел = запись в devices
|
||||
* со status='pending' («Ожидают добавления» в UI)
|
||||
* 2. Слушает nodes/+/data → телеметрия в Postgres, online-флаг,
|
||||
* события смены реле
|
||||
* 3. Слушает nodes/+/status (LWT LAN-узлов) → online/offline
|
||||
* 4. LoRa-узлы: offline, если молчат > OFFLINE_AFTER_S
|
||||
* 5. REST API для интерфейса (см. README)
|
||||
* 6. При подтверждении устройства публикует MQTT Discovery
|
||||
* для Home Assistant — сущности появляются в HA сами
|
||||
*
|
||||
* Деплой: LXC/Docker на Proxmox, systemd unit или compose.
|
||||
* ENV: MQTT_URL, PG_URL, PORT
|
||||
* ================================================================
|
||||
*/
|
||||
|
||||
const mqtt = require("mqtt");
|
||||
const { Pool } = require("pg");
|
||||
const express = require("express");
|
||||
|
||||
const MQTT_URL = process.env.MQTT_URL || "mqtt://192.168.1.200:1883";
|
||||
const PG_URL = process.env.PG_URL || "postgres://energy:energy@127.0.0.1:5432/energy";
|
||||
const PORT = process.env.PORT || 8090;
|
||||
const OFFLINE_AFTER_S = 120; // 4 пропущенных периода LoRa-узла
|
||||
|
||||
const pg = new Pool({ connectionString: PG_URL });
|
||||
const mq = mqtt.connect(MQTT_URL, { will: {
|
||||
topic: "services/provisioner/status", payload: "offline", retain: true
|
||||
}});
|
||||
|
||||
const lastRelays = new Map(); // node_id -> [..] для детекта фронтов
|
||||
|
||||
// ---------------- MQTT ----------------
|
||||
mq.on("connect", () => {
|
||||
mq.publish("services/provisioner/status", "online", { retain: true });
|
||||
mq.subscribe(["nodes/+/info", "nodes/+/data", "nodes/+/status"]);
|
||||
console.log("MQTT connected:", MQTT_URL);
|
||||
});
|
||||
|
||||
mq.on("message", async (topic, payload) => {
|
||||
const [, nodeId, kind] = topic.split("/");
|
||||
try {
|
||||
if (kind === "info") await onInfo(nodeId, JSON.parse(payload));
|
||||
if (kind === "data") await onData(nodeId, JSON.parse(payload));
|
||||
if (kind === "status") await onStatus(nodeId, payload.toString());
|
||||
} catch (e) {
|
||||
console.error(`${topic}: ${e.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// узел представился — регистрируем или обновляем паспорт
|
||||
async function onInfo(id, m) {
|
||||
await pg.query(`
|
||||
INSERT INTO devices (node_id, transport, caps, fw, ip, mac, gw, last_seen, online)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,now(),TRUE)
|
||||
ON CONFLICT (node_id) DO UPDATE SET
|
||||
transport=$2, caps=$3, fw=$4,
|
||||
ip=COALESCE($5, devices.ip), mac=COALESCE($6, devices.mac),
|
||||
gw=COALESCE($7, devices.gw), last_seen=now(), online=TRUE`,
|
||||
[id, m.hw || "lora", m.caps || {}, m.fw || null,
|
||||
m.ip || null, m.mac || null, m.gw || null]);
|
||||
console.log(`info: ${id} (${m.hw})`);
|
||||
}
|
||||
|
||||
// телеметрия
|
||||
async function onData(id, m) {
|
||||
// авторегистрация, если data пришла раньше info
|
||||
await pg.query(`
|
||||
INSERT INTO devices (node_id, transport) VALUES ($1,$2)
|
||||
ON CONFLICT (node_id) DO NOTHING`, [id, m.rssi != null ? "lora" : "lan"]);
|
||||
|
||||
await pg.query(`
|
||||
UPDATE devices SET last_seen=now(), online=TRUE,
|
||||
last_rssi=$2, last_snr=$3, gw=COALESCE($4, gw)
|
||||
WHERE node_id=$1`,
|
||||
[id, m.rssi ?? null, m.snr ?? null, m.gw ?? null]);
|
||||
|
||||
await pg.query(`
|
||||
INSERT INTO telemetry (node_id, v, a, w, kwh, pf, hz, relays, rssi, snr)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
|
||||
[id, m.v ?? null, m.a ?? null, m.w ?? null, m.kwh ?? null,
|
||||
m.pf ?? null, m.hz ?? null, JSON.stringify(m.r ?? []),
|
||||
m.rssi ?? null, m.snr ?? null]);
|
||||
|
||||
// фронты реле → журнал
|
||||
const prev = lastRelays.get(id);
|
||||
if (Array.isArray(m.r)) {
|
||||
if (prev) {
|
||||
for (let i = 0; i < m.r.length; i++) {
|
||||
if (prev[i] !== m.r[i]) {
|
||||
await pg.query(
|
||||
`INSERT INTO relay_events (node_id, relay_n, state) VALUES ($1,$2,$3)`,
|
||||
[id, i, !!m.r[i]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
lastRelays.set(id, m.r.slice());
|
||||
}
|
||||
}
|
||||
|
||||
// LWT LAN-узлов
|
||||
async function onStatus(id, s) {
|
||||
await pg.query(`UPDATE devices SET online=$2 WHERE node_id=$1`,
|
||||
[id, s === "online"]);
|
||||
}
|
||||
|
||||
// LoRa-узлы без LWT: оффлайн по таймауту
|
||||
setInterval(async () => {
|
||||
await pg.query(`
|
||||
UPDATE devices SET online=FALSE
|
||||
WHERE online AND transport='lora'
|
||||
AND last_seen < now() - make_interval(secs => $1)`, [OFFLINE_AFTER_S]);
|
||||
}, 30000);
|
||||
|
||||
// ---------------- HA MQTT Discovery ----------------
|
||||
// вызывается при approve: сущности появляются в HA автоматически
|
||||
async function publishHaDiscovery(d) {
|
||||
const dev = {
|
||||
identifiers: [`energy_${d.node_id}`],
|
||||
name: d.name || d.node_id,
|
||||
manufacturer: "МПО-Информ",
|
||||
model: d.transport === "lan" ? "Energy Node LAN" : "Energy Node LoRa",
|
||||
sw_version: d.fw || "",
|
||||
};
|
||||
const base = `nodes/${d.node_id}`;
|
||||
const pub = (t, cfg) =>
|
||||
mq.publish(t, JSON.stringify(cfg), { retain: true });
|
||||
|
||||
const sensors = [
|
||||
["v", "Напряжение", "voltage", "V", null],
|
||||
["a", "Ток", "current", "A", null],
|
||||
["w", "Мощность", "power", "W", null],
|
||||
["kwh", "Энергия", "energy", "kWh", "total_increasing"],
|
||||
];
|
||||
for (const [key, name, cls, unit, stateClass] of sensors) {
|
||||
const cfg = {
|
||||
name, unique_id: `${d.node_id}_${key}`,
|
||||
state_topic: `${base}/data`,
|
||||
value_template: `{{ value_json.${key} }}`,
|
||||
device_class: cls, unit_of_measurement: unit, device: dev,
|
||||
};
|
||||
if (stateClass) cfg.state_class = stateClass;
|
||||
pub(`homeassistant/sensor/${d.node_id}_${key}/config`, cfg);
|
||||
}
|
||||
|
||||
const nRelays = d.caps?.relays || 0;
|
||||
const names = d.relay_names || [];
|
||||
for (let i = 0; i < nRelays; i++) {
|
||||
pub(`homeassistant/switch/${d.node_id}_r${i}/config`, {
|
||||
name: names[i] || `Реле ${i + 1}`,
|
||||
unique_id: `${d.node_id}_r${i}`,
|
||||
state_topic: `${base}/data`,
|
||||
value_template: `{{ 'ON' if value_json.r[${i}] == 1 else 'OFF' }}`,
|
||||
command_topic: `${base}/cmd`,
|
||||
payload_on: JSON.stringify({ cmd: "relay", n: i, s: 1 }),
|
||||
payload_off: JSON.stringify({ cmd: "relay", n: i, s: 0 }),
|
||||
device: dev,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- REST API для интерфейса ----------------
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// список устройств (+ последняя телеметрия); ?status=pending|active
|
||||
app.get("/api/devices", async (req, res) => {
|
||||
const { status, project } = req.query;
|
||||
const cond = [], args = [];
|
||||
if (status) { args.push(status); cond.push(`status=$${args.length}`); }
|
||||
if (project) { args.push(project); cond.push(`project=$${args.length}`); }
|
||||
const where = cond.length ? "WHERE " + cond.join(" AND ") : "";
|
||||
const { rows } = await pg.query(
|
||||
`SELECT * FROM v_devices_board ${where} ORDER BY project, name NULLS LAST, node_id`, args);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// подтвердить устройство (добавить на сервер)
|
||||
app.post("/api/devices/:id/approve", async (req, res) => {
|
||||
const { name, project, object, relay_names, approved_by } = req.body || {};
|
||||
const { rows } = await pg.query(`
|
||||
UPDATE devices SET status='active', name=$2, project=$3, object=$4,
|
||||
relay_names=$5, approved_at=now(), approved_by=$6
|
||||
WHERE node_id=$1 RETURNING *`,
|
||||
[req.params.id, name || req.params.id, project || null, object || null,
|
||||
JSON.stringify(relay_names || []), approved_by || "ui"]);
|
||||
if (!rows.length) return res.status(404).json({ error: "not found" });
|
||||
await publishHaDiscovery(rows[0]);
|
||||
res.json(rows[0]);
|
||||
});
|
||||
|
||||
// отклонить / отключить
|
||||
app.post("/api/devices/:id/disable", async (req, res) => {
|
||||
await pg.query(`UPDATE devices SET status='disabled' WHERE node_id=$1`,
|
||||
[req.params.id]);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// команда узлу (реле, poll) + журнал
|
||||
app.post("/api/devices/:id/cmd", async (req, res) => {
|
||||
const payload = req.body || {};
|
||||
mq.publish(`nodes/${req.params.id}/cmd`, JSON.stringify(payload));
|
||||
await pg.query(
|
||||
`INSERT INTO commands (node_id, payload, source) VALUES ($1,$2,'ui')`,
|
||||
[req.params.id, JSON.stringify(payload)]);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// история для графиков: ?hours=24
|
||||
app.get("/api/devices/:id/history", async (req, res) => {
|
||||
const hours = Math.min(parseInt(req.query.hours || "24", 10), 720);
|
||||
const { rows } = await pg.query(`
|
||||
SELECT ts, v, a, w, kwh FROM telemetry
|
||||
WHERE node_id=$1 AND ts > now() - make_interval(hours => $2)
|
||||
ORDER BY ts`, [req.params.id, hours]);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// журнал реле
|
||||
app.get("/api/devices/:id/relay-events", async (req, res) => {
|
||||
const { rows } = await pg.query(`
|
||||
SELECT ts, relay_n, state FROM relay_events
|
||||
WHERE node_id=$1 ORDER BY ts DESC LIMIT 100`, [req.params.id]);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
app.listen(PORT, () => console.log(`API on :${PORT}`));
|
||||
@@ -0,0 +1,77 @@
|
||||
-- ================================================================
|
||||
-- Схема БД: учёт устройств энергомониторинга (Егорин / ORBIT)
|
||||
-- PostgreSQL 14+ (LXC на Proxmox, та же база что у Лилии — ок)
|
||||
-- ================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
node_id TEXT PRIMARY KEY, -- eg01, eg-lan1, orb1...
|
||||
name TEXT, -- «Щит насосной», задаёт админ
|
||||
project TEXT, -- 'egorin' | 'orbit'
|
||||
object TEXT, -- площадка/объект
|
||||
transport TEXT NOT NULL DEFAULT 'lora', -- 'lora' | 'lan'
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending|active|disabled
|
||||
caps JSONB DEFAULT '{}', -- {"pzem":true,"relays":4,"ctl":true}
|
||||
relay_names JSONB DEFAULT '[]', -- ["Насос","Свет",...]
|
||||
fw TEXT,
|
||||
ip INET,
|
||||
mac MACADDR,
|
||||
gw TEXT, -- через какой шлюз (lora)
|
||||
last_seen TIMESTAMPTZ,
|
||||
last_rssi INT,
|
||||
last_snr NUMERIC(4,1),
|
||||
online BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
approved_at TIMESTAMPTZ,
|
||||
approved_by TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS telemetry (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
node_id TEXT NOT NULL REFERENCES devices(node_id) ON DELETE CASCADE,
|
||||
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
v NUMERIC(6,1),
|
||||
a NUMERIC(8,3),
|
||||
w NUMERIC(9,1),
|
||||
kwh NUMERIC(12,3),
|
||||
pf NUMERIC(4,2),
|
||||
hz NUMERIC(4,1),
|
||||
relays JSONB, -- [1,0,1,0]
|
||||
rssi INT,
|
||||
snr NUMERIC(4,1)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS telemetry_node_ts ON telemetry (node_id, ts DESC);
|
||||
|
||||
-- журнал команд (кто/что/когда переключил)
|
||||
CREATE TABLE IF NOT EXISTS commands (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
node_id TEXT NOT NULL REFERENCES devices(node_id) ON DELETE CASCADE,
|
||||
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
payload JSONB NOT NULL, -- {"cmd":"relay","n":0,"s":1}
|
||||
source TEXT, -- 'ui' | 'n8n' | 'ha' | 'api'
|
||||
confirmed BOOLEAN DEFAULT FALSE -- узел подтвердил телеметрией
|
||||
);
|
||||
|
||||
-- события смены состояния реле (для журнала в UI)
|
||||
CREATE TABLE IF NOT EXISTS relay_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
node_id TEXT NOT NULL REFERENCES devices(node_id) ON DELETE CASCADE,
|
||||
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
relay_n INT NOT NULL,
|
||||
state BOOLEAN NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS relay_events_node_ts ON relay_events (node_id, ts DESC);
|
||||
|
||||
-- удобное представление для экрана «Устройства»
|
||||
CREATE OR REPLACE VIEW v_devices_board AS
|
||||
SELECT d.*,
|
||||
t.v, t.a, t.w, t.kwh, t.relays,
|
||||
t.ts AS telemetry_ts
|
||||
FROM devices d
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT * FROM telemetry
|
||||
WHERE node_id = d.node_id
|
||||
ORDER BY ts DESC LIMIT 1
|
||||
) t ON TRUE;
|
||||
|
||||
-- ретенция сырой телеметрии: 90 дней (крон раз в сутки)
|
||||
-- DELETE FROM telemetry WHERE ts < now() - INTERVAL '90 days';
|
||||
@@ -0,0 +1,336 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ORBIT Energy — Устройства</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;600&family=Onest:wght@400;500;600&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{
|
||||
--bg:#070a10; --panel:rgba(15,22,36,.72); --line:rgba(74,222,128,.14);
|
||||
--line2:rgba(148,163,184,.12);
|
||||
--acc:#4ADE80; --blue:#60A5FA; --off:#64748B; --alarm:#F87171; --amber:#FBBF24;
|
||||
--tx:#E2E8F0; --tx2:#94A3B8; --tx3:#5B6B84;
|
||||
--r:14px;
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{
|
||||
background:var(--bg); color:var(--tx);
|
||||
font-family:'Onest',sans-serif; font-size:14px; min-height:100vh;
|
||||
background-image:radial-gradient(ellipse 900px 500px at 80% -10%, rgba(74,222,128,.06), transparent),
|
||||
radial-gradient(ellipse 700px 400px at 0% 110%, rgba(96,165,250,.05), transparent);
|
||||
}
|
||||
.mono{font-family:'JetBrains Mono',monospace}
|
||||
/* ---------- рейл ---------- */
|
||||
.rail{position:fixed;left:0;top:0;bottom:0;width:80px;border-right:1px solid var(--line2);
|
||||
display:flex;flex-direction:column;align-items:center;padding:18px 0;gap:6px;z-index:20;
|
||||
background:rgba(7,10,16,.85);backdrop-filter:blur(8px)}
|
||||
.logo{font-family:'Unbounded';font-size:11px;color:var(--acc);letter-spacing:.08em;
|
||||
margin-bottom:22px;text-align:center;line-height:1.3}
|
||||
.nav{width:56px;padding:10px 0;border-radius:12px;text-align:center;color:var(--tx3);
|
||||
cursor:pointer;font-size:10px;letter-spacing:.02em;transition:.15s}
|
||||
.nav svg{display:block;margin:0 auto 5px;width:20px;height:20px;stroke:currentColor;fill:none;stroke-width:1.6}
|
||||
.nav:hover{color:var(--tx2);background:rgba(148,163,184,.06)}
|
||||
.nav.on{color:var(--acc);background:rgba(74,222,128,.1)}
|
||||
/* ---------- контент ---------- */
|
||||
.wrap{margin-left:80px;padding:26px 30px 90px;max-width:1360px}
|
||||
header{display:flex;flex-wrap:wrap;align-items:center;gap:14px;margin-bottom:20px}
|
||||
h1{font-family:'Unbounded';font-weight:600;font-size:19px;letter-spacing:.01em}
|
||||
h1 small{display:block;font-family:'Onest';font-weight:400;font-size:12px;color:var(--tx3);margin-top:4px}
|
||||
.chips{display:flex;gap:8px;margin-left:auto}
|
||||
.chip{padding:7px 15px;border-radius:99px;border:1px solid var(--line2);color:var(--tx2);
|
||||
cursor:pointer;font-size:13px;transition:.15s;background:transparent}
|
||||
.chip:hover{border-color:var(--line);color:var(--tx)}
|
||||
.chip.on{border-color:var(--acc);color:var(--acc);background:rgba(74,222,128,.08)}
|
||||
/* ---------- обнаружение (сигнатурный блок) ---------- */
|
||||
.discover{border:1px solid rgba(251,191,36,.28);border-radius:var(--r);
|
||||
background:linear-gradient(90deg, rgba(251,191,36,.07), rgba(251,191,36,.02));
|
||||
padding:14px 18px;margin-bottom:22px;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
|
||||
.radar{position:relative;width:34px;height:34px;flex:none}
|
||||
.radar::before,.radar::after{content:'';position:absolute;inset:0;border-radius:50%;
|
||||
border:1.5px solid var(--amber);opacity:.8;animation:ping 2.2s cubic-bezier(0,0,.2,1) infinite}
|
||||
.radar::after{animation-delay:1.1s}
|
||||
.radar b{position:absolute;inset:12px;border-radius:50%;background:var(--amber)}
|
||||
@keyframes ping{0%{transform:scale(.4);opacity:.9}100%{transform:scale(1.35);opacity:0}}
|
||||
@media (prefers-reduced-motion:reduce){.radar::before,.radar::after{animation:none;opacity:.35}}
|
||||
.discover .txt{flex:1;min-width:200px}
|
||||
.discover .txt b{font-size:14px}
|
||||
.discover .txt span{display:block;color:var(--tx2);font-size:12.5px;margin-top:2px}
|
||||
.pending-list{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.pend{display:flex;align-items:center;gap:10px;border:1px solid rgba(251,191,36,.3);
|
||||
border-radius:10px;padding:8px 12px;background:rgba(7,10,16,.5)}
|
||||
.pend .id{font-family:'JetBrains Mono';font-size:13px}
|
||||
.pend .via{font-size:11px;color:var(--tx3)}
|
||||
.btn{border:1px solid var(--acc);background:rgba(74,222,128,.1);color:var(--acc);
|
||||
border-radius:9px;padding:7px 14px;cursor:pointer;font-family:'Onest';font-size:13px;font-weight:500;transition:.15s}
|
||||
.btn:hover{background:rgba(74,222,128,.2)}
|
||||
.btn.ghost{border-color:var(--line2);background:transparent;color:var(--tx2)}
|
||||
.btn.ghost:hover{color:var(--tx)}
|
||||
/* ---------- сетка устройств ---------- */
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px}
|
||||
.card{border:1px solid var(--line2);border-radius:var(--r);background:var(--panel);
|
||||
backdrop-filter:blur(10px);padding:16px 18px;transition:border-color .15s}
|
||||
.card:hover{border-color:var(--line)}
|
||||
.card .top{display:flex;align-items:flex-start;gap:10px;margin-bottom:12px}
|
||||
.dot{width:9px;height:9px;border-radius:50%;margin-top:5px;flex:none}
|
||||
.dot.on{background:var(--acc);box-shadow:0 0 0 0 rgba(74,222,128,.5);animation:halo 2.4s infinite}
|
||||
.dot.off{background:var(--off)}
|
||||
@keyframes halo{0%{box-shadow:0 0 0 0 rgba(74,222,128,.45)}70%{box-shadow:0 0 0 8px rgba(74,222,128,0)}100%{box-shadow:0 0 0 0 rgba(74,222,128,0)}}
|
||||
@media (prefers-reduced-motion:reduce){.dot.on{animation:none}}
|
||||
.card .name{font-weight:600;font-size:14.5px}
|
||||
.card .sub{font-family:'JetBrains Mono';font-size:11.5px;color:var(--tx3);margin-top:2px}
|
||||
.badge{margin-left:auto;font-size:10.5px;padding:3px 9px;border-radius:99px;flex:none;letter-spacing:.03em}
|
||||
.badge.lan{color:var(--blue);border:1px solid rgba(96,165,250,.35)}
|
||||
.badge.lora{color:var(--acc);border:1px solid rgba(74,222,128,.3)}
|
||||
.meters{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:12px}
|
||||
.m{background:rgba(7,10,16,.5);border-radius:9px;padding:8px 6px;text-align:center}
|
||||
.m b{font-family:'JetBrains Mono';font-size:14px;font-weight:600;display:block}
|
||||
.m span{font-size:10px;color:var(--tx3);letter-spacing:.04em}
|
||||
.relays{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.rl{display:flex;align-items:center;gap:7px;border:1px solid var(--line2);border-radius:9px;
|
||||
padding:6px 10px;cursor:pointer;user-select:none;transition:.15s;font-size:12.5px;color:var(--tx2)}
|
||||
.rl .sw{width:26px;height:15px;border-radius:99px;background:var(--off);position:relative;transition:.2s;flex:none}
|
||||
.rl .sw::after{content:'';position:absolute;top:2px;left:2px;width:11px;height:11px;border-radius:50%;
|
||||
background:var(--bg);transition:.2s}
|
||||
.rl.on{border-color:rgba(74,222,128,.35);color:var(--tx)}
|
||||
.rl.on .sw{background:var(--acc)}
|
||||
.rl.on .sw::after{left:13px}
|
||||
.card .foot{margin-top:12px;padding-top:10px;border-top:1px solid var(--line2);
|
||||
display:flex;justify-content:space-between;font-size:11.5px;color:var(--tx3)}
|
||||
.card .foot .mono{font-size:11.5px}
|
||||
/* ---------- мастер добавления ---------- */
|
||||
.overlay{position:fixed;inset:0;background:rgba(4,6,10,.7);backdrop-filter:blur(4px);
|
||||
display:none;align-items:center;justify-content:center;z-index:50;padding:16px}
|
||||
.overlay.open{display:flex}
|
||||
.modal{width:100%;max-width:520px;background:#0c1220;border:1px solid var(--line);
|
||||
border-radius:18px;padding:24px 26px;max-height:92vh;overflow:auto}
|
||||
.modal h2{font-family:'Unbounded';font-size:15px;font-weight:600;margin-bottom:4px}
|
||||
.modal .steps{display:flex;gap:6px;margin:14px 0 20px}
|
||||
.modal .steps i{flex:1;height:3px;border-radius:3px;background:var(--line2)}
|
||||
.modal .steps i.on{background:var(--acc)}
|
||||
.passport{border:1px solid var(--line2);border-radius:12px;padding:14px;margin-bottom:16px;
|
||||
font-family:'JetBrains Mono';font-size:12.5px;line-height:1.9;color:var(--tx2)}
|
||||
.passport b{color:var(--tx)}
|
||||
label{display:block;font-size:12px;color:var(--tx2);margin:12px 0 5px}
|
||||
input,select{width:100%;background:rgba(7,10,16,.6);border:1px solid var(--line2);
|
||||
border-radius:9px;padding:9px 12px;color:var(--tx);font-family:'Onest';font-size:13.5px;outline:none}
|
||||
input:focus,select:focus{border-color:var(--acc)}
|
||||
.row2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||
.modal .actions{display:flex;justify-content:space-between;gap:10px;margin-top:22px}
|
||||
.done{ text-align:center;padding:12px 0 4px}
|
||||
.done .ok{width:52px;height:52px;border-radius:50%;border:2px solid var(--acc);color:var(--acc);
|
||||
display:flex;align-items:center;justify-content:center;margin:0 auto 14px;font-size:24px}
|
||||
.done p{color:var(--tx2);font-size:13px;line-height:1.6;margin-top:8px}
|
||||
/* ---------- мобильный ---------- */
|
||||
.tabbar{display:none}
|
||||
@media (max-width:1023px){
|
||||
.rail{display:none}
|
||||
.wrap{margin-left:0;padding:18px 14px 90px}
|
||||
.chips{margin-left:0;width:100%;overflow-x:auto}
|
||||
.tabbar{display:flex;position:fixed;left:0;right:0;bottom:0;height:62px;z-index:20;
|
||||
background:rgba(7,10,16,.92);backdrop-filter:blur(10px);border-top:1px solid var(--line2)}
|
||||
.tabbar .nav{flex:1;width:auto;border-radius:0;padding:9px 0}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="rail">
|
||||
<div class="logo">ORBIT<br>ENERGY</div>
|
||||
<div class="nav"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/></svg>Обзор</div>
|
||||
<div class="nav on"><svg viewBox="0 0 24 24"><path d="M13 2 4.5 13.5H11L10 22l8.5-11.5H12L13 2z"/></svg>Устройства</div>
|
||||
<div class="nav"><svg viewBox="0 0 24 24"><circle cx="12" cy="17" r="2.5"/><path d="M6 12.5a8.5 8.5 0 0 1 12 0M3 9a13 13 0 0 1 18 0"/></svg>Шлюзы</div>
|
||||
<div class="nav"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg>Журнал</div>
|
||||
<div class="nav"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.2-1.6l2-1.5-2-3.4-2.3 1a7 7 0 0 0-2.8-1.6L13.3 2h-2.6l-.4 2.9a7 7 0 0 0-2.8 1.6l-2.3-1-2 3.4 2 1.5A7 7 0 0 0 5 12c0 .55.07 1.08.2 1.6l-2 1.5 2 3.4 2.3-1a7 7 0 0 0 2.8 1.6l.4 2.9h2.6l.4-2.9a7 7 0 0 0 2.8-1.6l2.3 1 2-3.4-2-1.5c.13-.52.2-1.05.2-1.6z"/></svg>Настройки</div>
|
||||
</nav>
|
||||
|
||||
<main class="wrap">
|
||||
<header>
|
||||
<h1>Устройства<small id="counter"></small></h1>
|
||||
<div class="chips" id="chips">
|
||||
<button class="chip on" data-p="all">Все</button>
|
||||
<button class="chip" data-p="egorin">Егорин</button>
|
||||
<button class="chip" data-p="orbit">ORBIT</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- обнаруженные, ждут добавления -->
|
||||
<section class="discover" id="discover">
|
||||
<div class="radar"><b></b></div>
|
||||
<div class="txt">
|
||||
<b>Обнаружены новые устройства</b>
|
||||
<span>Узлы вышли в эфир и ждут добавления на сервер</span>
|
||||
</div>
|
||||
<div class="pending-list" id="pending"></div>
|
||||
</section>
|
||||
|
||||
<section class="grid" id="grid"></section>
|
||||
</main>
|
||||
|
||||
<nav class="tabbar">
|
||||
<div class="nav"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/></svg>Обзор</div>
|
||||
<div class="nav on"><svg viewBox="0 0 24 24"><path d="M13 2 4.5 13.5H11L10 22l8.5-11.5H12L13 2z"/></svg>Устройства</div>
|
||||
<div class="nav"><svg viewBox="0 0 24 24"><circle cx="12" cy="17" r="2.5"/><path d="M6 12.5a8.5 8.5 0 0 1 12 0M3 9a13 13 0 0 1 18 0"/></svg>Шлюзы</div>
|
||||
<div class="nav"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg>Журнал</div>
|
||||
</nav>
|
||||
|
||||
<!-- мастер добавления -->
|
||||
<div class="overlay" id="wiz">
|
||||
<div class="modal">
|
||||
<h2 id="wizTitle">Добавить устройство</h2>
|
||||
<div class="steps"><i class="on" id="s1"></i><i id="s2"></i><i id="s3"></i></div>
|
||||
<div id="wizBody"></div>
|
||||
<div class="actions" id="wizActions"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ================= демо-данные (в бою: GET /api/devices) =================
|
||||
const pendingDevices = [
|
||||
{node_id:'eg03', transport:'lora', fw:'2.0', gw:'gw1', rssi:-101, caps:{pzem:true,relays:4,ctl:true}},
|
||||
{node_id:'orb-lan2', transport:'lan', fw:'2.0', ip:'192.168.1.147', mac:'A4:CF:12:8B:03:1D', caps:{pzem:true,relays:2,ctl:true}},
|
||||
];
|
||||
let devices = [
|
||||
{node_id:'eg01', name:'Щит насосной', project:'egorin', transport:'lora', online:true,
|
||||
v:230.4, a:4.82, w:1084, kwh:1284.6, r:[1,0,1,0], rn:['Насос 1','Насос 2','Прогрев','Резерв'], rssi:-94, seen:'32 с назад'},
|
||||
{node_id:'eg02', name:'КТП, ввод 1', project:'egorin', transport:'lora', online:true,
|
||||
v:228.9, a:12.4, w:2790, kwh:8112.3, r:[1,1], rn:['Освещение','Вентиляция'], rssi:-88, seen:'12 с назад'},
|
||||
{node_id:'orb-lan1', name:'Серверная, стойка A', project:'orbit', transport:'lan', online:true,
|
||||
v:231.2, a:6.05, w:1398, kwh:940.8, r:[1,1,1,0], rn:['Стойка','ИБП обход','Кондиционер','Резерв'], ip:'192.168.1.141', seen:'6 с назад'},
|
||||
{node_id:'orb02', name:'Мачта, узел связи', project:'orbit', transport:'lora', online:false,
|
||||
v:null, a:null, w:null, kwh:412.1, r:[0,0], rn:['Обогрев','Подсветка'], rssi:-112, seen:'18 мин назад'},
|
||||
];
|
||||
let filter='all';
|
||||
|
||||
// ================= рендер =================
|
||||
const fmt=(x,d=1)=>x==null?'—':Number(x).toFixed(d);
|
||||
function render(){
|
||||
const list=devices.filter(d=>filter==='all'||d.project===filter);
|
||||
document.getElementById('counter').textContent=
|
||||
`${list.filter(d=>d.online).length} в сети · ${list.length} всего`;
|
||||
document.getElementById('grid').innerHTML=list.map(d=>`
|
||||
<article class="card">
|
||||
<div class="top">
|
||||
<div class="dot ${d.online?'on':'off'}"></div>
|
||||
<div>
|
||||
<div class="name">${d.name}</div>
|
||||
<div class="sub">${d.node_id} · ${d.project==='egorin'?'Егорин':'ORBIT'}</div>
|
||||
</div>
|
||||
<div class="badge ${d.transport}">${d.transport==='lan'?'LAN':'LoRa '+(d.rssi??'')+' dBm'}</div>
|
||||
</div>
|
||||
<div class="meters">
|
||||
<div class="m"><b>${fmt(d.v)}</b><span>В</span></div>
|
||||
<div class="m"><b>${fmt(d.a,2)}</b><span>А</span></div>
|
||||
<div class="m"><b>${fmt(d.w,0)}</b><span>Вт</span></div>
|
||||
<div class="m"><b>${fmt(d.kwh)}</b><span>кВт·ч</span></div>
|
||||
</div>
|
||||
<div class="relays">${d.r.map((s,i)=>`
|
||||
<div class="rl ${s?'on':''}" onclick="toggleRelay('${d.node_id}',${i})">
|
||||
<div class="sw"></div>${d.rn[i]||('Реле '+(i+1))}
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<div class="foot"><span>обновлено ${d.seen}</span><span class="mono">${d.ip||''}</span></div>
|
||||
</article>`).join('');
|
||||
|
||||
const p=document.getElementById('pending');
|
||||
p.innerHTML=pendingDevices.map((d,i)=>`
|
||||
<div class="pend">
|
||||
<span class="id">${d.node_id}</span>
|
||||
<span class="via">${d.transport==='lan'?('LAN · '+d.ip):('LoRa · '+d.gw+' · '+d.rssi+' dBm')}</span>
|
||||
<button class="btn" onclick="openWizard(${i})">Добавить</button>
|
||||
</div>`).join('');
|
||||
document.getElementById('discover').style.display=pendingDevices.length?'flex':'none';
|
||||
}
|
||||
document.getElementById('chips').addEventListener('click',e=>{
|
||||
const c=e.target.closest('.chip'); if(!c)return;
|
||||
document.querySelectorAll('.chip').forEach(x=>x.classList.remove('on'));
|
||||
c.classList.add('on'); filter=c.dataset.p; render();
|
||||
});
|
||||
// в бою: POST /api/devices/:id/cmd {"cmd":"relay","n":i,"s":!s}
|
||||
function toggleRelay(id,i){
|
||||
const d=devices.find(x=>x.node_id===id);
|
||||
if(!d.online)return;
|
||||
d.r[i]=d.r[i]?0:1; render();
|
||||
}
|
||||
|
||||
// ================= мастер добавления =================
|
||||
let wizDev=null, wizStep=1, wizData={};
|
||||
function openWizard(i){ wizDev=pendingDevices[i]; wizStep=1; wizData={};
|
||||
document.getElementById('wiz').classList.add('open'); drawWizard(); }
|
||||
function closeWizard(){ document.getElementById('wiz').classList.remove('open'); }
|
||||
function drawWizard(){
|
||||
['s1','s2','s3'].forEach((id,i)=>
|
||||
document.getElementById(id).classList.toggle('on', i<wizStep));
|
||||
const body=document.getElementById('wizBody'), act=document.getElementById('wizActions');
|
||||
const d=wizDev;
|
||||
if(wizStep===1){
|
||||
document.getElementById('wizTitle').textContent='Добавить устройство · паспорт';
|
||||
body.innerHTML=`<div class="passport">
|
||||
ID узла <b>${d.node_id}</b><br>
|
||||
Транспорт <b>${d.transport==='lan'?'Ethernet (LAN)':'LoRa 869 МГц'}</b><br>
|
||||
${d.transport==='lan'
|
||||
?`IP / MAC <b>${d.ip}</b> · ${d.mac}`
|
||||
:`Шлюз <b>${d.gw}</b> · RSSI ${d.rssi} dBm`}<br>
|
||||
Прошивка <b>v${d.fw}</b><br>
|
||||
Возможности <b>PZEM-004T · реле ×${d.caps.relays} · управление</b>
|
||||
</div>
|
||||
<p style="color:var(--tx2);font-size:13px;line-height:1.55">Узел вышел в эфир и прислал паспорт. Проверьте, что это ожидаемое устройство, и продолжите настройку.</p>`;
|
||||
act.innerHTML=`<button class="btn ghost" onclick="closeWizard()">Отмена</button>
|
||||
<button class="btn" onclick="wizStep=2;drawWizard()">Далее</button>`;
|
||||
}
|
||||
if(wizStep===2){
|
||||
document.getElementById('wizTitle').textContent='Добавить устройство · параметры';
|
||||
const relayInputs=Array.from({length:d.caps.relays},(_,i)=>
|
||||
`<label>Реле ${i+1}</label><input id="rn${i}" placeholder="Например: Насос ${i+1}" value="${wizData.rn?.[i]||''}">`).join('');
|
||||
body.innerHTML=`
|
||||
<label>Название</label>
|
||||
<input id="wName" placeholder="Например: Щит котельной" value="${wizData.name||''}">
|
||||
<div class="row2">
|
||||
<div><label>Проект</label>
|
||||
<select id="wProj">
|
||||
<option value="egorin" ${wizData.project==='egorin'?'selected':''}>Егорин</option>
|
||||
<option value="orbit" ${wizData.project==='orbit'?'selected':''}>ORBIT</option>
|
||||
</select></div>
|
||||
<div><label>Объект / площадка</label>
|
||||
<input id="wObj" placeholder="Иноземцево, Пушкина 1Е" value="${wizData.object||''}"></div>
|
||||
</div>${relayInputs}`;
|
||||
act.innerHTML=`<button class="btn ghost" onclick="wizStep=1;drawWizard()">Назад</button>
|
||||
<button class="btn" onclick="wizCollect()">Добавить на сервер</button>`;
|
||||
}
|
||||
if(wizStep===3){
|
||||
document.getElementById('wizTitle').textContent='Устройство добавлено';
|
||||
body.innerHTML=`<div class="done">
|
||||
<div class="ok">✓</div>
|
||||
<b>${wizData.name}</b>
|
||||
<p>Узел ${d.node_id} активирован в проекте «${wizData.project==='egorin'?'Егорин':'ORBIT'}».<br>
|
||||
Сущности опубликованы в Home Assistant через MQTT Discovery — датчики и выключатели реле уже доступны.</p>
|
||||
</div>`;
|
||||
act.innerHTML=`<span></span><button class="btn" onclick="wizFinish()">Готово</button>`;
|
||||
}
|
||||
}
|
||||
function wizCollect(){
|
||||
// в бою: POST /api/devices/:id/approve {name, project, object, relay_names}
|
||||
wizData={
|
||||
name:document.getElementById('wName').value||wizDev.node_id,
|
||||
project:document.getElementById('wProj').value,
|
||||
object:document.getElementById('wObj').value,
|
||||
rn:Array.from({length:wizDev.caps.relays},(_,i)=>document.getElementById('rn'+i).value||('Реле '+(i+1))),
|
||||
};
|
||||
wizStep=3; drawWizard();
|
||||
}
|
||||
function wizFinish(){
|
||||
devices.push({node_id:wizDev.node_id, name:wizData.name, project:wizData.project,
|
||||
transport:wizDev.transport, online:true, v:229.7, a:0.42, w:96, kwh:0,
|
||||
r:Array(wizDev.caps.relays).fill(0), rn:wizData.rn,
|
||||
rssi:wizDev.rssi, ip:wizDev.ip, seen:'только что'});
|
||||
pendingDevices.splice(pendingDevices.indexOf(wizDev),1);
|
||||
closeWizard(); render();
|
||||
}
|
||||
document.getElementById('wiz').addEventListener('click',e=>{
|
||||
if(e.target.id==='wiz') closeWizard();
|
||||
});
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+45
-31
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -8,11 +8,13 @@ import * as tenants from './tenants.js';
|
||||
import * as metrics from './metrics.js';
|
||||
import * as store from './store.js';
|
||||
import { seedDemo } from './demo.js';
|
||||
import * as lora from './lora.js';
|
||||
import { startMqtt } from './adapters/mqtt.js';
|
||||
import { startTraccar } from './adapters/traccar.js';
|
||||
import { startInflux } from './adapters/influx.js';
|
||||
|
||||
seedDemo();
|
||||
lora.seedLora();
|
||||
const live = { wirenboard: startMqtt(), traccar: startTraccar(), influx: startInflux() };
|
||||
|
||||
const app = express();
|
||||
@@ -121,6 +123,21 @@ app.get('/api/cabinet/links', auth, (req, res) => res.json(tenants.linksOf(req.u
|
||||
app.post('/api/cabinet/links', auth, (req, res) => res.json(tenants.addLink(req.user.id, req.body?.email)));
|
||||
app.delete('/api/cabinet/links/:email', auth, (req, res) => res.json(tenants.removeLink(req.user.id, decodeURIComponent(req.params.email))));
|
||||
|
||||
// --- LoRa/LAN энергомониторинг: приём от шлюза-концентратора + реестр устройств ---
|
||||
// Концентратор (LAN-шлюз) шлёт паспорт/телеметрию узлов сюда (открытый ingress железа).
|
||||
app.post('/api/lora/ingest', (req, res) => {
|
||||
const b = req.body || {};
|
||||
const items = Array.isArray(b) ? b : [b];
|
||||
for (const it of items) (it.t === 'info' ? lora.ingestInfo(it) : lora.ingestData(it));
|
||||
res.json({ ok: true, count: items.length });
|
||||
});
|
||||
app.get('/api/lora/devices', auth, (req, res) => res.json(can(req.user, 'energy', 'view') ? lora.listDevices({ status: req.query.status, project: req.query.project }) : []));
|
||||
app.get('/api/lora/gateways', auth, (req, res) => res.json(lora.listGateways()));
|
||||
app.get('/api/lora/relay-events', auth, (req, res) => res.json(lora.relayEvents()));
|
||||
app.post('/api/lora/devices/:id/approve', auth, (req, res) => res.json(lora.approve(req.params.id, req.body || {}) || { error: 'not found' }));
|
||||
app.post('/api/lora/devices/:id/disable', auth, (req, res) => res.json(lora.disable(req.params.id) || { error: 'not found' }));
|
||||
app.post('/api/lora/devices/:id/cmd', auth, (req, res) => res.json(lora.cmd(req.params.id, req.body || {}, 'ui') || { error: 'not found' }));
|
||||
|
||||
// --- Яндекс ID (OAuth) ---
|
||||
app.get('/api/auth/yandex/start', (_req, res) => {
|
||||
if (!config.yandex) return res.redirect('/?autherror=noyandex');
|
||||
|
||||
@@ -1,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,
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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[] {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' }} />;
|
||||
}
|
||||
@@ -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/<id></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="rounded-lg bg-muted px-2.5 py-1"><b className="tnum">{active.length}</b> узлов</span>
|
||||
<span className="rounded-lg bg-muted px-2.5 py-1">Σ <b className="tnum">{(sumW / 1000).toFixed(1)}</b> кВт</span>
|
||||
<span className="rounded-lg bg-muted px-2.5 py-1">Σ <b className="tnum">{sumKwh.toFixed(1)}</b> кВт·ч</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Шлюзы */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{gws.map((g) => (
|
||||
<div key={g.gw_id} className="flex items-center gap-2 rounded-xl border border-border bg-surface px-3 py-2 text-xs">
|
||||
<Router className={cn('h-4 w-4', g.online ? 'text-ok' : 'text-fg-muted')} />
|
||||
<span className="font-semibold">{g.gw_id}</span>
|
||||
<span className="text-fg-muted">{g.ip} · {g.nodes} узлов · RSSI ~{g.avg_rssi} дБм</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Визуализатор ретрансляции/маршрутизации 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>
|
||||
);
|
||||
}
|
||||
@@ -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 }} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user