From 2f339803b48be2835d3ca8c9e54a881e70e038f0 Mon Sep 17 00:00:00 2001 From: german Date: Sun, 12 Jul 2026 13:11:48 +0300 Subject: [PATCH] =?UTF-8?q?egorin=20LoRa-=D0=BA=D0=B0=D1=80=D1=82=D0=B0:?= =?UTF-8?q?=20=D0=B2=D1=81=D0=BF=D0=BB=D1=8B=D0=B2=D0=B0=D1=8E=D1=89=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B1=D0=B5=D0=B9=D0=B4=D0=B6=D0=B8-=D1=81=D0=B8?= =?UTF-8?q?=D0=BC=D0=B2=D0=BE=D0=BB=D1=8B=20=D0=BD=D0=B0=D0=B4=20=D1=83?= =?UTF-8?q?=D0=B7=D0=BB=D0=B0=D0=BC=D0=B8=20(=D1=81=D1=87=D1=91=D1=82?= =?UTF-8?q?=D1=87=D0=B8=D0=BA=20=D0=BE=D0=BF=D1=80=D0=BE=D1=81=D0=B0+?= =?UTF-8?q?=D0=BC=D0=BE=D1=89=D0=BD=D0=BE=D1=81=D1=82=D1=8C),=20=D0=BA?= =?UTF-8?q?=D1=80=D0=B0=D1=81=D0=BD=D1=8B=D0=B9=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D0=BE=D0=BF=D1=80=D0=BE=D1=81=D0=B5=20=D0=B2=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=BD=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D0=B8=D0=B2=D0=B0,=20?= =?UTF-8?q?=D1=81=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D0=BB=D0=B5=D0=B4=D0=BD=D0=B5=D0=B3=D0=BE=20=D0=BE=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=81=D0=B0,=20=D1=80=D0=B0=D0=B7=D0=B2=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D1=82=20=D0=BA=D0=B0=D1=80=D1=82=D1=8B=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB-=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B0;=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=20fp<0=20=D0=B2=20LoraMeshViz=20(?= =?UTF-8?q?=D1=83=D0=B1=D0=B8=D0=B2=D0=B0=D0=BB=20RAF);=20frozen-=D0=B4?= =?UTF-8?q?=D0=B0=D1=82=D1=87=D0=B8=D0=BA=20=D0=B2=20=D0=B4=D0=B5=D0=BC?= =?UTF-8?q?=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/lora.js | 8 +++-- src/modules/energy/LoraMeshViz.tsx | 39 ++++++++++++++++------- src/modules/energy/LoraModule.tsx | 51 +++++++++++++++++++++++------- 3 files changed, 72 insertions(+), 26 deletions(-) diff --git a/server/src/lora.js b/server/src/lora.js index 245771e..6be20fd 100644 --- a/server/src/lora.js +++ b/server/src/lora.js @@ -74,6 +74,7 @@ export function seedLora() { { 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) => { @@ -83,12 +84,13 @@ export function seedLora() { 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.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') continue; + 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); diff --git a/src/modules/energy/LoraMeshViz.tsx b/src/modules/energy/LoraMeshViz.tsx index db96f22..e241be6 100644 --- a/src/modules/energy/LoraMeshViz.tsx +++ b/src/modules/energy/LoraMeshViz.tsx @@ -2,12 +2,14 @@ import { useEffect, useRef } from 'react'; // Визуализатор ретрансляции/маршрутизации LoRa-хопов: узлы-паутинка вокруг шлюза, // нити-связи (прямые и многоскачковые), бегущие по ним пакеты (передвижение по массиву). -interface Node { id: string; label: string; status: 'active' | 'pending' | 'disabled'; rssi?: number; transport?: 'lora' | 'lan' } +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] } as const; +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', className }: { nodes: Node[]; gatewayLabel?: string; className?: string }) { +export function LoraMeshViz({ nodes, gatewayLabel = 'gw1', normSec = 15, className }: { nodes: Node[]; gatewayLabel?: string; normSec?: number; className?: string }) { const ref = useRef(null); const nodesRef = useRef(nodes); nodesRef.current = nodes; @@ -33,7 +35,9 @@ export function LoraMeshViz({ nodes, gatewayLabel = 'gw1', className }: { nodes: 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; - return { n, x: cx + Math.cos(ang) * r, y: cy + Math.sin(ang) * r, far, ang }; + 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); @@ -58,7 +62,7 @@ export function LoraMeshViz({ nodes, gatewayLabel = 'gw1', className }: { nodes: // связи узел → (ретранслятор) → шлюз + бегущие пакеты for (const p of pts) { const target = linkFor(p); - const c = COL[p.n.status]; + 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(); @@ -71,22 +75,35 @@ export function LoraMeshViz({ nodes, gatewayLabel = 'gw1', className }: { nodes: // бегущий пакет по маршруту (узел→…→шлюз) 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; - const seg = fp * (route.length - 1), si = Math.floor(seg), fr = seg - si; + 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 = COL[p.n.status]; - const pulse = p.n.status === 'active' ? 0.6 + 0.4 * Math.sin(tsec * 2.4 + p.ang) : 1; + 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, 0.14); ctx.beginPath(); ctx.arc(p.x, p.y, 12 * 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); diff --git a/src/modules/energy/LoraModule.tsx b/src/modules/energy/LoraModule.tsx index 8d21e99..9be9ba7 100644 --- a/src/modules/energy/LoraModule.tsx +++ b/src/modules/energy/LoraModule.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useCallback } from 'react'; -import { RadioTower, Cable, Wifi, Zap, Plus, Router, Waypoints } from 'lucide-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'; @@ -23,6 +23,9 @@ export function LoraModule() { const [devices, setDevices] = useState([]); const [gws, setGws] = useState([]); 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 { @@ -40,6 +43,13 @@ export function LoraModule() { 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 (
@@ -70,17 +80,34 @@ export function LoraModule() {
{/* Визуализатор ретрансляции/маршрутизации LoRa-хопов */} -
-
- Маршрутизация LoRa · хопы и ретрансляция -
-
- 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 }))} - /> -
-
+ {(() => { + const pollTxt = lastPollAge === Infinity ? '—' : lastPollAge < 60 ? `${Math.round(lastPollAge)} с назад` : `${Math.round(lastPollAge / 60)} мин назад`; + const header = ( +
+ Маршрутизация LoRa · хопы и ретрансляция +
+ + последний опрос: {pollTxt}{staleCount ? ` · ${staleCount} вне норматива` : ''} + + +
+
+ ); + const viz = ; + return expanded ? ( +
+ {header} +
{viz}
+
+ ) : ( +
+ {header} +
{viz}
+
+ ); + })()} {/* Обнаружены новые устройства (pending) */} {pending.length > 0 && (