feat: Add new API endpoints and HTML pages for ML model management

- Implemented HTML pages for datasets, models, training, testing, and results.
- Created API endpoints for managing repositories, results, tests, and training sessions.
- Added functionality for streaming training progress via Server-Sent Events (SSE).
- Introduced a Dockerfile for the ML runner with necessary dependencies.
- Developed an SDK for user code execution within the runner container.
- Enhanced CSS styles for improved UI layout and navigation.
- Established a layout template for consistent HTML structure across pages.
- Added JavaScript for dynamic interactions on the models page.
- Implemented WebSocket handling for real-time communication with kiosk devices and controllers.
- Implemented model registration and management API at /api/models
- Added Gitea proxy API for repository interactions at /api/repos
- Created results API for listing and comparing training results at /api/results
- Developed training management API for enqueueing and retrieving training jobs at /api/trainings
- Introduced SSE endpoint for live training progress updates
- Added HTML pages for models, datasets, and training management
- Created a Dockerfile for the ML runner with necessary dependencies
- Developed SDK for user code execution within the runner container
- Enhanced CSS styles for improved UI/UX
- Implemented WebSocket communication for real-time device and controller interactions in the kiosk system
This commit is contained in:
Giuseppe Raffa
2026-04-28 09:24:38 +02:00
parent ee478e52ef
commit 0ce879aa44
81 changed files with 7491 additions and 746 deletions

View File

@@ -11,8 +11,10 @@
"@influxdata/influxdb-client": "^1.35.0",
"@influxdata/influxdb-client-apis": "^1.35.0",
"@msgpack/msgpack": "^3.1.3",
"cookie-parser": "^1.4.7",
"express": "^5.2.1",
"ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.3",
"pg": "^8.20.0",
"ws": "^8.19.0"
}
@@ -84,6 +86,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -162,6 +170,25 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
@@ -220,6 +247,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -525,18 +561,103 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -876,12 +997,44 @@
"node": ">= 18"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",

View File

@@ -45,9 +45,13 @@ app.get('/health', (req, res) => {
app.use('/connect', require('./routes/connect'));
app.use('/sensors', require('./routes/sensors'));
app.use('/sessions', require('./routes/sessions'));
app.use('/rules', require('./routes/rules'));
const server = app.listen(3000, '0.0.0.0', () => {
console.log(`Realtime started`);
});
wsHandler.setup(server);
// deve essere caricato DOPO setup per avere kioskRelay pronto
app.use('/kiosk', require('./routes/kiosk'));

View File

@@ -1,12 +1,29 @@
const router = require('express').Router();
const db = require('../store/db');
const { kioskRelay } = require('../ws/handler');
// Endpoint per ricevere dati dal kiosk
router.post('/data', async (req, res) => {
const { session_id, sensor_code, value, timestamp } = req.body;
if (!session_id || !sensor_code || value === undefined) {
return res.status(400).json({ error: 'Missing required fields' });
}
const INTERNAL_KEY = process.env.INTERNAL_API_KEY;
function requireInternal(req, res, next) {
if (!INTERNAL_KEY || req.headers['x-api-key'] !== INTERNAL_KEY)
return res.status(403).json({ error: 'forbidden' });
next();
}
// Chiamato dall'API quando cambia il template attivo
router.post('/notify-active', requireInternal, (req, res) => {
const { template } = req.body || {};
if (!template || !template.id) return res.status(400).json({ error: 'template.id required' });
kioskRelay.notifyActiveTemplateChange(template);
res.json({ ok: true });
});
module.exports = router;
// Stato dispositivi connessi (diagnostica)
router.get('/status', requireInternal, (req, res) => {
const list = [];
for (const [name, ws] of kioskRelay.devices) {
list.push({ sensor: name, templateId: ws.templateId || null, lastSeen: ws.lastSeen || null });
}
res.json({ devices: list });
});
module.exports = router;

View File

@@ -0,0 +1,57 @@
/**
* Relay HTTP → WS per il push dei rulesets ai sensori.
* Chiamato SOLO dal servizio api (internal, x-api-key).
*
* POST /rules/push
* Body: { sensors: [name, ...], type, ruleset }
* -> invia msgpack { _t: 'ruleset_update', type, ruleset } ad ogni sensore
* online tramite la connessione WS gia' stabilita.
*/
const router = require('express').Router();
const { encode } = require('@msgpack/msgpack');
const { connectedSensors } = require('../ws/handler');
const INTERNAL_KEY = process.env.INTERNAL_API_KEY;
function requireInternal(req, res, next) {
const k = req.headers['x-api-key'];
if (!INTERNAL_KEY || !k || k !== INTERNAL_KEY) {
return res.status(403).json({ error: 'forbidden' });
}
next();
}
router.post('/push', requireInternal, (req, res) => {
const { sensors, type, ruleset } = req.body || {};
if (!Array.isArray(sensors) || !sensors.length) return res.status(400).json({ error: 'sensors array required' });
if (!type || !ruleset) return res.status(400).json({ error: 'type and ruleset required' });
const payload = { _t: 'ruleset_update', type, ruleset };
let encoded;
try {
encoded = encode(payload);
} catch (err) {
return res.status(500).json({ error: `encode error: ${err.message}` });
}
const pushed = [], offline = [], errors = [];
for (const name of sensors) {
const ws = connectedSensors.get(name);
if (!ws || ws.readyState !== ws.OPEN) {
offline.push(name);
continue;
}
try {
ws.send(encoded);
pushed.push(name);
} catch (err) {
errors.push({ sensor: name, error: err.message });
}
}
console.log(`[RULES] push type=${type} v=${ruleset?.version?.str || '?'} → pushed=${pushed.length} offline=${offline.length} err=${errors.length}`);
res.json({ pushed, offline, errors });
});
module.exports = router;

View File

@@ -5,16 +5,40 @@ const client = new InfluxDB({
token: process.env.INFLX_TOKEN,
});
const bucket = process.env.INFLX_BUCKET || 'logs';
const org = process.env.INFLX_ORG;
const writeApi = client.getWriteApi(org, bucket, 'ms', {
flushInterval: 100,
batchSize: 50,
});
// Bucket dedicati per dominio. Il default per i logs viene mantenuto su
// INFLX_BUCKET per retro-compatibilità con la configurazione esistente.
// Per i dati meteo current e forecast usiamo bucket separati: sono dati
// indipendenti dai logs (frequenze e retention diverse) e tenerli separati
// permette policy di retention più aggressive per i forecast (timestamp
// futuri sovrascritti spesso) senza toccare il volume dei logs.
const BUCKETS = {
logs: process.env.INFLX_BUCKET_LOGS || process.env.INFLX_BUCKET || 'logs',
weather: process.env.INFLX_BUCKET_WEATHER || 'weather_current',
weather_forecast: process.env.INFLX_BUCKET_FORECAST || 'weather_forecast',
};
const writeApis = {};
function getWriteApi(bucket) {
if (!writeApis[bucket]) {
writeApis[bucket] = client.getWriteApi(org, bucket, 'ms', {
flushInterval: 1000,
batchSize: 200,
maxRetries: 3,
});
}
return writeApis[bucket];
}
function bucketFor(measurement) {
if (measurement === 'weather') return BUCKETS.weather;
if (measurement === 'weather_forecast') return BUCKETS.weather_forecast;
return BUCKETS.logs;
}
/**
* Scrive dati generici su InfluxDB senza mapping.
* Scrive dati generici su InfluxDB nel bucket appropriato per il measurement.
* @param {string} measurement - nome della measurement (es. 'logs', 'weather')
* @param {Object} fields - campi { key: value }
* @param {string} sensor - nome del sensore
@@ -36,11 +60,12 @@ function writeGenericData(measurement, fields, sensor, session, timestamp) {
}
}
writeApi.writePoint(point);
getWriteApi(bucketFor(measurement)).writePoint(point);
}
/**
* Scrive un batch di punti forecast (previsioni orarie).
* Usa il bucket weather_forecast (non i logs).
* @param {Array} points - array di [timestamp_ms, { key: value, ... }]
* @param {string} sensor - nome del sensore
* @param {string} session - id sessione
@@ -52,14 +77,14 @@ function writeForecastBatch(points, sensor, session) {
}
/**
* Forza il flush del buffer di scrittura.
* Forza il flush dei buffer di scrittura su tutti i bucket.
*/
async function flush() {
try {
await writeApi.flush();
} catch (err) {
console.error('[INFLUX] Flush error:', err.message);
}
await Promise.all(Object.values(writeApis).map(async (wa) => {
try { await wa.flush(); } catch (err) {
console.error('[INFLUX] Flush error:', err.message);
}
}));
}
/**
@@ -72,7 +97,7 @@ async function flush() {
async function queryHistory(sensor, session, since) {
const queryApi = client.getQueryApi(org);
const fluxQuery = `
from(bucket: "${bucket}")
from(bucket: "${BUCKETS.logs}")
|> range(start: ${since})
|> filter(fn: (r) => r._measurement == "logs")
|> filter(fn: (r) => r.sensor == "${sensor}")
@@ -95,10 +120,6 @@ async function queryHistory(sensor, session, since) {
/**
* Esporta tutti i dati di una sessione come CSV.
* @param {string} sensor - nome sensore
* @param {string} session - session_id
* @param {string} since - ISO timestamp inizio (opzionale, default -30d)
* @returns {string} CSV content
*/
async function exportSessionCSV(sensor, session, since) {
const start = since || '-30d';
@@ -106,7 +127,6 @@ async function exportSessionCSV(sensor, session, since) {
if (rows.length === 0) return '';
// Raccogli tutti i field names (esclusi meta InfluxDB)
const metaKeys = new Set(['result', 'table', '_start', '_stop', '_measurement', 'sensor', 'session', '']);
const fieldNames = new Set();
for (const row of rows) {
@@ -133,4 +153,4 @@ async function exportSessionCSV(sensor, session, since) {
return header + '\n' + csvRows.join('\n') + '\n';
}
module.exports = { writeGenericData, writeForecastBatch, flush, queryHistory, exportSessionCSV };
module.exports = { writeGenericData, writeForecastBatch, flush, queryHistory, exportSessionCSV, BUCKETS };

View File

@@ -3,6 +3,7 @@ const { decode } = require('@msgpack/msgpack');
const { consumeConnectionToken, appendAsConnection, query, hset, del } = require('../store/redis');
const { writeGenericData, writeForecastBatch } = require('../store/influx');
const db = require('../store/db');
const kioskRelay = require('./kiosk');
// In-memory registries
const sensorWatchers = new Map(); // sensorName → Set<WebSocket> (watchers)
@@ -42,6 +43,9 @@ function setup(server) {
handleSensorConnection(ws);
});
} else if (path === '/kiosk') {
await kioskRelay.handleUpgrade(wss, req, socket, head, url);
} else if (path === '/live') {
wss.handleUpgrade(req, socket, head, (ws) => {
handleWatcherConnection(ws);
@@ -95,6 +99,58 @@ async function handleSensorConnection(ws) {
return;
}
// Reset sessione richiesto dal plugin (es. dopo un nuovo ruleset
// di logs/meteo). La connessione WS persiste: cambiamo solo il
// sessionId, marchiamo la vecchia come disconnessa e creiamo la
// nuova in sessiondataref. I dati successivi useranno il nuovo tag.
if (packet._t === 'session_reset') {
const prev = ws.sessionId;
const next = generateSessionId();
ws.sessionId = next;
console.log(`[${sensorName}] session_reset ${prev}${next} (reason: ${packet.reason || 'n/a'})`);
try {
await db.query('sensors',
`UPDATE sessiondataref SET disconnected_at = NOW() WHERE session_id = $1 AND disconnected_at IS NULL`,
[prev]
);
await db.query('sensors',
`INSERT INTO sessiondataref (session_id, sensor_name, name, created_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (session_id) DO NOTHING`,
[next, sensorName, next]
);
} catch (err) {
console.error(`[${sensorName}] session_reset DB error:`, err.message);
}
hset(`sensors:${sensorName}`, 'session', next);
try {
const { encode } = require('@msgpack/msgpack');
ws.send(encode({ _t: 'session_id', sessionId: next, prev }));
} catch (err) {
console.error(`[${sensorName}] session_reset reply error:`, err.message);
}
return;
}
// ACK di un ruleset ricevuto e applicato: il plugin ci dice
// che la versione X del tipo Y e' ora attiva sul device.
if (packet._t === 'ruleset_ack') {
const { type, ruleset_id } = packet;
if (type && ruleset_id) {
const API = process.env.API_URL || 'http://meb-api:3000';
const KEY = process.env.INTERNAL_API_KEY;
if (KEY) {
fetch(`${API}/rules/${type}/${ruleset_id}/ack`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': KEY },
body: JSON.stringify({ sensor: sensorName })
}).catch(err => console.error(`[${sensorName}] ruleset_ack forward error:`, err.message));
}
console.log(`[${sensorName}] ruleset_ack type=${type} id=${ruleset_id}`);
}
return;
}
const { ts, _m, ...fields } = packet;
// InfluxDB: usa SEMPRE sessionId come tag (non cambia mai)
@@ -203,4 +259,4 @@ function handleWatcherConnection(ws) {
});
}
module.exports = { setup, connectedSensors };
module.exports = { setup, connectedSensors, kioskRelay };

167
realtime/src/ws/kiosk.js Normal file
View File

@@ -0,0 +1,167 @@
/**
* Kiosk realtime relay.
* Due ruoli:
* - device: il plugin kiosk sulla barca (uno per sensorName)
* - controller: la pagina kiosklive.html (N per sensorName)
* Messaggi JSON (no msgpack, canale leggero).
*/
const jwt = require('jsonwebtoken');
const { consumeConnectionToken } = require('../store/redis');
const devices = new Map(); // sensorName → ws
const controllers = new Map(); // sensorName → Set<ws>
const JWT_SECRET = process.env.JWT_SECRET;
function verifyJwt(token) {
if (!token || !JWT_SECRET) return null;
try { return jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }); } catch { return null; }
}
function extractCookie(req, name) {
const raw = req.headers.cookie || '';
const m = raw.split(';').map(s => s.trim()).find(s => s.startsWith(name + '='));
return m ? decodeURIComponent(m.slice(name.length + 1)) : null;
}
function send(ws, obj) {
if (ws && ws.readyState === ws.OPEN) ws.send(JSON.stringify(obj));
}
function broadcastControllers(sensorName, obj) {
const set = controllers.get(sensorName);
if (!set) return;
const msg = JSON.stringify(obj);
for (const c of set) if (c.readyState === c.OPEN) c.send(msg);
}
function deviceStatus(sensorName) {
const d = devices.get(sensorName);
return {
t: 'kiosk_status',
online: !!d,
templateId: d?.templateId || null,
lastSeen: d?.lastSeen || null
};
}
/**
* Gestisce l'upgrade per /kiosk?role=device|controller&sensor=<name>
* @returns true se gestito
*/
async function handleUpgrade(wss, req, socket, head, url) {
const role = url.searchParams.get('role');
const sensorName = url.searchParams.get('sensor');
if (!role || !sensorName) {
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); socket.destroy(); return true;
}
if (role === 'device') {
const token = url.searchParams.get('token');
const sensor = token ? await consumeConnectionToken(token) : null;
if (!sensor || sensor !== sensorName) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return true;
}
wss.handleUpgrade(req, socket, head, (ws) => {
ws.sensorName = sensorName;
ws.role = 'device';
attachDevice(ws);
});
return true;
}
if (role === 'controller') {
const token = extractCookie(req, 'auth_token') || url.searchParams.get('token');
const payload = verifyJwt(token);
if (!payload) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return true;
}
wss.handleUpgrade(req, socket, head, (ws) => {
ws.sensorName = sensorName;
ws.role = 'controller';
ws.user = payload.sub;
attachController(ws);
});
return true;
}
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); socket.destroy(); return true;
}
function attachDevice(ws) {
const name = ws.sensorName;
const prev = devices.get(name);
if (prev && prev.readyState === prev.OPEN) prev.close(4000, 'replaced');
devices.set(name, ws);
ws.lastSeen = Date.now();
broadcastControllers(name, deviceStatus(name));
console.log(`[kiosk] device online: ${name}`);
ws.on('message', (raw) => {
let m; try { m = JSON.parse(raw.toString()); } catch { return; }
ws.lastSeen = Date.now();
switch (m.t) {
case 'hello':
ws.templateId = m.templateId || null;
broadcastControllers(name, deviceStatus(name));
break;
case 'ack':
broadcastControllers(name, m);
break;
case 'heartbeat':
break;
default:
// echo diagnostici opzionali
break;
}
});
const hb = setInterval(() => { if (ws.readyState === ws.OPEN) ws.ping(); }, 25000);
ws.on('close', () => {
clearInterval(hb);
if (devices.get(name) === ws) devices.delete(name);
broadcastControllers(name, deviceStatus(name));
console.log(`[kiosk] device offline: ${name}`);
});
ws.on('error', () => {});
}
function attachController(ws) {
const name = ws.sensorName;
if (!controllers.has(name)) controllers.set(name, new Set());
controllers.get(name).add(ws);
send(ws, deviceStatus(name));
console.log(`[kiosk] controller connected on ${name} (total=${controllers.get(name).size})`);
ws.on('message', (raw) => {
let m; try { m = JSON.parse(raw.toString()); } catch { return; }
const allowed = ['patch_box','add_box','remove_box','load_template','apply_inline','persist','reload'];
if (!allowed.includes(m.t)) return;
const device = devices.get(name);
if (!device || device.readyState !== device.OPEN) {
send(ws, { t: 'ack', cmdId: m.cmdId, ok: false, err: 'device offline' });
return;
}
send(device, m);
});
ws.on('close', () => {
const set = controllers.get(name);
if (set) { set.delete(ws); if (!set.size) controllers.delete(name); }
});
ws.on('error', () => {});
}
/** HTTP notify usato dall'API quando cambia template attivo */
function notifyActiveTemplateChange(template) {
for (const [name, ws] of devices) {
send(ws, { t: 'load_template', templateId: template.id });
}
for (const name of controllers.keys()) {
broadcastControllers(name, { t: 'active_template_changed', templateId: template.id });
}
}
module.exports = { handleUpgrade, notifyActiveTemplateChange, devices };