egorin: LoRa/LAN энергомониторинг — кит в /lora (прошивки+schema+provisioner+UI), бэкенд ingest от шлюза-концентратора + реестр устройств, фронт-раздел (карточки/реле/pending/шлюзы)

This commit is contained in:
german
2026-07-09 23:49:34 +03:00
parent 27067d3d12
commit fc10cb77ba
12 changed files with 1544 additions and 2 deletions
+101
View File
@@ -0,0 +1,101 @@
// LoRa/LAN энергомониторинг: реестр устройств + приём телеметрии от шлюза-концентратора.
// Датчики (LoRa 869МГц / LAN) → шлюз (Ethernet) → POST /api/lora/ingest → этот реестр.
// In-memory (демо/без Postgres). Модель — по schema.sql (devices/telemetry/relay_events).
import { EventEmitter } from 'node:events';
export const loraBus = new EventEmitter();
const devices = new Map(); // node_id -> device
const gateways = new Map(); // gw_id -> gateway
const relayLog = []; // события реле (последние)
function now() { return new Date().toISOString(); }
// Приём паспорта узла (nodes/<id>/info). Неизвестный → pending (самрегистрация).
export function ingestInfo(p) {
const id = p.id;
if (!id) return;
const d = devices.get(id) || { node_id: id, status: 'pending', created_at: now(), relays: [] };
d.transport = p.hw || d.transport || 'lora';
d.caps = p.caps || d.caps || {};
d.relay_names = d.relay_names && d.relay_names.length ? d.relay_names : Array.from({ length: (p.caps?.relays || 0) }, (_, i) => `Реле ${i + 1}`);
d.fw = p.fw || d.fw; d.ip = p.ip || d.ip; d.mac = p.mac || d.mac; d.gw = p.gw || d.gw;
d.online = true; d.last_seen = now();
if (!d.relays.length) d.relays = Array.from({ length: (p.caps?.relays || 0) }, () => 0);
devices.set(id, d);
loraBus.emit('device', d);
return d;
}
// Приём телеметрии (nodes/<id>/data): v,a,w,kwh,pf,hz,r[],rssi,snr,gw.
export function ingestData(p) {
const id = p.id;
const d = devices.get(id);
if (!d) return ingestInfo({ id, hw: p.gw ? 'lora' : 'lan', caps: { relays: (p.r || []).length } });
Object.assign(d, {
v: p.v, a: p.a, w: p.w, kwh: p.kwh, pf: p.pf, hz: p.hz,
relays: Array.isArray(p.r) ? p.r : d.relays,
last_rssi: p.rssi ?? d.last_rssi, last_snr: p.snr ?? d.last_snr, gw: p.gw || d.gw,
online: true, last_seen: now(), telemetry_ts: now(),
});
devices.set(id, d);
loraBus.emit('device', d);
return d;
}
export function listDevices({ status, project } = {}) {
return [...devices.values()].filter((d) => (!status || d.status === status) && (!project || d.project === project));
}
export function approve(id, { name, project, object, relay_names } = {}) {
const d = devices.get(id); if (!d) return null;
d.status = 'active'; d.name = name || d.name; d.project = project || d.project || 'egorin';
d.object = object || d.object; if (Array.isArray(relay_names)) d.relay_names = relay_names;
d.approved_at = now();
loraBus.emit('device', d); return d;
}
export function disable(id) { const d = devices.get(id); if (d) { d.status = 'disabled'; loraBus.emit('device', d); } return d || null; }
export function cmd(id, payload, source = 'ui') {
const d = devices.get(id); if (!d) return null;
if (payload?.cmd === 'relay' && typeof payload.n === 'number') {
d.relays[payload.n] = payload.s ? 1 : 0;
relayLog.unshift({ node_id: id, name: d.name || id, relay_n: payload.n, relay_name: d.relay_names?.[payload.n], state: !!payload.s, ts: now(), source });
if (relayLog.length > 200) relayLog.pop();
loraBus.emit('device', d);
}
return d;
}
export const listGateways = () => [...gateways.values()];
export const relayEvents = () => relayLog.slice(0, 50);
// ── Демо-начинка: шлюз + датчики (LoRa/LAN) с живой телеметрией ──
export function seedLora() {
gateways.set('gw1', { gw_id: 'gw1', name: 'Шлюз щитовой (ESP32+RA-01H+W5500)', ip: '192.168.1.77', online: true, nodes: 4, avg_rssi: -95, last_seen: now() });
const seed = [
{ id: 'eg01', name: 'ТП-14 насосная', project: 'egorin', object: 'ТП-14 «Бештау»', hw: 'lora', caps: { pzem: true, relays: 2, ctl: true }, gw: 'gw1', rssi: -92, relay_names: ['Насос-1', 'Насос-2'], status: 'active', base: 4.2 },
{ id: 'eg-lan1', name: 'ТП-7 щит учёта', project: 'egorin', object: 'ТП-7 «Центр»', hw: 'lan', caps: { pzem: true, relays: 4, ctl: true }, ip: '192.168.1.91', relay_names: ['Ввод-1', 'Ввод-2', 'Освещение', 'Вентиляция'], status: 'active', base: 12.5 },
{ id: 'orb1', name: 'ORBIT склад', project: 'orbit', object: 'Склад «Весовая»', hw: 'lora', caps: { pzem: true, relays: 1, ctl: true }, gw: 'gw1', rssi: -104, relay_names: ['Освещение цех'], status: 'active', base: 6.1 },
{ id: 'eg-lan2', name: 'РП-2 распред.', project: 'egorin', object: 'РП-2', hw: 'lan', caps: { pzem: true, relays: 4, ctl: true }, ip: '192.168.1.93', relay_names: ['Фидер-1', 'Фидер-2', 'Фидер-3', 'Резерв'], status: 'active', base: 21.0 },
{ id: 'eg02', name: null, project: null, object: null, hw: 'lora', caps: { pzem: true, relays: 2, ctl: true }, gw: 'gw1', rssi: -111, status: 'pending', base: 3.4 }, // pending — только вышел в эфир
];
seed.forEach((s) => {
ingestInfo({ id: s.id, hw: s.hw, caps: s.caps, fw: '2.1.0', ip: s.ip, gw: s.gw });
const d = devices.get(s.id);
d.name = s.name; d.project = s.project; d.object = s.object; d.status = s.status;
if (s.relay_names) d.relay_names = s.relay_names;
d.relays = Array.from({ length: s.caps.relays }, (_, i) => (i === 0 ? 1 : 0));
d._base = s.base; d._kwh = Math.round(s.base * 240 + Math.random() * 500);
d.last_rssi = s.rssi;
});
// Тикер телеметрии активных узлов
setInterval(() => {
for (const d of devices.values()) {
if (d.status === 'disabled') continue;
const load = d._base ? d._base * (0.7 + Math.random() * 0.5) * (d.relays?.some((r) => r) ? 1 : 0.15) : 0;
const v = +(224 + Math.random() * 12).toFixed(1);
const a = +(load).toFixed(3);
const pf = +(0.88 + Math.random() * 0.1).toFixed(2);
const w = +(v * a * pf).toFixed(1);
d._kwh = (d._kwh || 0) + w / 3600 / 1000 * 3; // за 3с
ingestData({ id: d.node_id, v, a, w, kwh: +d._kwh.toFixed(3), pf, hz: +(49.9 + Math.random() * 0.2).toFixed(1), r: d.relays, rssi: d.last_rssi, gw: d.gw });
}
}, 3000);
}
+17
View File
@@ -8,11 +8,13 @@ import * as tenants from './tenants.js';
import * as metrics from './metrics.js';
import * as store from './store.js';
import { seedDemo } from './demo.js';
import * as lora from './lora.js';
import { startMqtt } from './adapters/mqtt.js';
import { startTraccar } from './adapters/traccar.js';
import { startInflux } from './adapters/influx.js';
seedDemo();
lora.seedLora();
const live = { wirenboard: startMqtt(), traccar: startTraccar(), influx: startInflux() };
const app = express();
@@ -121,6 +123,21 @@ app.get('/api/cabinet/links', auth, (req, res) => res.json(tenants.linksOf(req.u
app.post('/api/cabinet/links', auth, (req, res) => res.json(tenants.addLink(req.user.id, req.body?.email)));
app.delete('/api/cabinet/links/:email', auth, (req, res) => res.json(tenants.removeLink(req.user.id, decodeURIComponent(req.params.email))));
// --- LoRa/LAN энергомониторинг: приём от шлюза-концентратора + реестр устройств ---
// Концентратор (LAN-шлюз) шлёт паспорт/телеметрию узлов сюда (открытый ingress железа).
app.post('/api/lora/ingest', (req, res) => {
const b = req.body || {};
const items = Array.isArray(b) ? b : [b];
for (const it of items) (it.t === 'info' ? lora.ingestInfo(it) : lora.ingestData(it));
res.json({ ok: true, count: items.length });
});
app.get('/api/lora/devices', auth, (req, res) => res.json(can(req.user, 'energy', 'view') ? lora.listDevices({ status: req.query.status, project: req.query.project }) : []));
app.get('/api/lora/gateways', auth, (req, res) => res.json(lora.listGateways()));
app.get('/api/lora/relay-events', auth, (req, res) => res.json(lora.relayEvents()));
app.post('/api/lora/devices/:id/approve', auth, (req, res) => res.json(lora.approve(req.params.id, req.body || {}) || { error: 'not found' }));
app.post('/api/lora/devices/:id/disable', auth, (req, res) => res.json(lora.disable(req.params.id) || { error: 'not found' }));
app.post('/api/lora/devices/:id/cmd', auth, (req, res) => res.json(lora.cmd(req.params.id, req.body || {}, 'ui') || { error: 'not found' }));
// --- Яндекс ID (OAuth) ---
app.get('/api/auth/yandex/start', (_req, res) => {
if (!config.yandex) return res.redirect('/?autherror=noyandex');