feat: Implement rulesets and layout management for kiosk plugin

- Added rulesets manager to handle various data types and updates via HTTP and WebSocket.
- Introduced layout store for managing kiosk layouts with caching and server synchronization.
- Enhanced dashboard and data routes to support new layout and ruleset features.
- Updated kiosk HTML and JavaScript to utilize new layout rendering and data binding.
- Removed obsolete map route and integrated map functionality into the new tile renderer.
- Improved Telegram commands to reflect changes in data structure and logging.
- Refactored weather fetching intervals to prevent multiple instances.
- Added SSE stream for real-time layout updates in the kiosk.
This commit is contained in:
Giuseppe Raffa
2026-05-12 10:17:54 +02:00
parent bb8d267cd4
commit c2c1598226
27 changed files with 1061 additions and 326 deletions

View File

@@ -4,7 +4,7 @@ const paths = [
]
window.skPaths = paths;
mapboxgl.accessToken = 'pk.eyJ1Ijoic2VzZWUzIiwiYSI6ImNtZ2dydndkMDBsNjUya3NjeW91dW41MzcifQ.M2qxj0wL1W7plRzIataojQ';
mapboxgl.accessToken = document.querySelector('meta[name="mapbox-key"]')?.content || '';
let map = null;
let boatMark = null;

View File

@@ -0,0 +1,72 @@
/**
* data-binder.js — apre una singola sottoscrizione SignalK delta locale
* e smista i valori ai tile per path.
*
* Espone window.dataBinder con:
* subscribe(path, fn) → unsubscribe()
* getLatest(path) → ultimo valore visto (o null)
* onConnState(fn) → notifica connesso/disconnesso
*/
(function () {
const subs = new Map(); // path -> Set<fn>
const latest = new Map(); // path -> { value, ts }
const stateListeners = new Set();
let ws = null;
let reconnectTimer = null;
function notify(path, value) {
latest.set(path, { value, ts: Date.now() });
const ss = subs.get(path);
if (!ss) return;
for (const fn of ss) { try { fn(value); } catch (e) { console.warn(e); } }
}
function notifyState(s) { for (const fn of stateListeners) { try { fn(s); } catch (_) {} } }
function connect() {
const url = `ws://${location.host}/signalk/v1/stream?subscribe=all`;
try { ws = new WebSocket(url); }
catch (err) { console.error('[BINDER] WS create:', err); scheduleReconnect(); return; }
ws.onopen = () => { notifyState({ connected: true }); console.log('[BINDER] WS connected'); };
ws.onerror = (e) => console.warn('[BINDER] WS error', e);
ws.onclose = () => { notifyState({ connected: false }); scheduleReconnect(); };
ws.onmessage = (ev) => {
let msg;
try { msg = JSON.parse(ev.data); } catch { return; }
if (!msg.updates) return;
for (const u of msg.updates) {
if (!u.values) continue;
for (const v of u.values) {
if (!v.path) continue;
notify(v.path, v.value);
// espandi position in lat/lon per chi si iscrive a quelli
if (v.path === 'navigation.position' && v.value && typeof v.value === 'object') {
if (v.value.latitude != null) notify('navigation.position.latitude', v.value.latitude);
if (v.value.longitude != null) notify('navigation.position.longitude', v.value.longitude);
}
}
}
};
}
function scheduleReconnect() {
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, 3000);
}
function subscribe(path, fn) {
if (!subs.has(path)) subs.set(path, new Set());
subs.get(path).add(fn);
const last = latest.get(path);
if (last) { try { fn(last.value); } catch (_) {} }
return () => subs.get(path)?.delete(fn);
}
function getLatest(path) { return latest.get(path)?.value ?? null; }
function onConnState(fn) { stateListeners.add(fn); return () => stateListeners.delete(fn); }
connect();
window.dataBinder = { subscribe, getLatest, onConnState };
})();

View File

@@ -3,22 +3,41 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kiosk</title>
<title>MEB Kiosk</title>
<style>
html,body { margin:0; padding:0; height:100%; background:#0b1220; color:#fff; font-family:-apple-system,sans-serif; overflow:hidden; }
#canvas { position:relative; width:100vw; height:100vh; }
.box { position:absolute; border-radius:10px; padding:14px; box-sizing:border-box; display:flex; flex-direction:column; overflow:hidden; transition: background .25s, color .25s, left .25s, top .25s, width .25s, height .25s; }
.box .title { font-size:.9rem; opacity:.65; letter-spacing:.06em; text-transform:uppercase; }
.box .val { flex:1; display:flex; align-items:center; justify-content:center; font-weight:800; font-variant-numeric: tabular-nums; line-height:1; }
.box .unit { opacity:.7; font-size:.6em; margin-left:.25em; }
#statusChip { position:fixed; right:10px; bottom:10px; background:#111827aa; padding:4px 8px; border-radius:999px; font-size:11px; opacity:.6; }
html,body { margin:0; padding:0; height:100%; background:#0b1220; color:#fff;
font-family:-apple-system,Segoe UI,sans-serif; overflow:hidden; }
#grid { position:relative; width:100vw; height:100vh; display:grid;
grid-template-columns: repeat(var(--cols, 12), 1fr);
grid-template-rows: repeat(var(--rows, 8), 1fr);
gap: var(--gap, 4px); padding: var(--gap, 4px); box-sizing:border-box; }
.tile { border-radius:10px; padding:14px; box-sizing:border-box; display:flex;
flex-direction:column; overflow:hidden; background:#111827;
transition: background .25s, color .25s; }
.tile .title { font-size:.85rem; opacity:.65; letter-spacing:.06em;
text-transform:uppercase; margin-bottom:6px; }
.tile .val { flex:1; display:flex; align-items:center; justify-content:center;
font-weight:800; font-variant-numeric: tabular-nums; line-height:1; }
.tile .val .unit { opacity:.55; font-size:.5em; margin-left:.3em; }
.tile .stale { opacity:.45; }
.tile.map .map-host { flex:1; border-radius:6px; overflow:hidden; }
.tile.map .overlays { display:flex; flex-wrap:wrap; gap:8px 14px; padding-top:6px;
font-size:.8rem; opacity:.85; }
.tile.gauge svg { width:100%; height:100%; max-height:100%; }
#statusChip { position:fixed; right:10px; bottom:10px; background:#111827cc;
padding:4px 8px; border-radius:999px; font-size:11px; opacity:.7; }
#statusChip.err { background:#dc2626cc; opacity:1; }
</style>
<link rel="stylesheet" href="https://api.mapbox.com/mapbox-gl-js/v3.6.0/mapbox-gl.css">
</head>
<body>
<div id="canvas"></div>
<div id="grid"></div>
<div id="statusChip">boot…</div>
<script src="template-loader.js"></script>
<script src="control-socket.js"></script>
<!-- Mapbox GL (caricato lazy solo se serve un tile di tipo "map") -->
<script src="data-binder.js" defer></script>
<script src="tile-renderer.js" defer></script>
<script src="layout-client.js" defer></script>
</body>
</html>

View File

@@ -0,0 +1,98 @@
/**
* layout-client.js — apre l'EventSource /meb/kiosk/stream del plugin, riceve
* il layout corrente (e i successivi update), e ridisegna la griglia.
*
* Il plugin NON edita: si limita a mostrare. La logica di binding ai dati
* SignalK (local stream) e' in data-binder.js; il render dei tile e' in
* tile-renderer.js.
*/
(function () {
const grid = document.getElementById('grid');
const chip = document.getElementById('statusChip');
let activeUnbinds = []; // funzioni di unbind per i tile correnti
let currentLayout = null;
function setStatus(text, isErr = false) {
chip.textContent = text;
chip.classList.toggle('err', isErr);
}
function clearGrid() {
for (const off of activeUnbinds) { try { off(); } catch (_) {} }
activeUnbinds = [];
grid.innerHTML = '';
}
function applyLayout(layoutRow) {
const content = layoutRow?.content;
if (!content || !Array.isArray(content.tiles)) {
console.warn('[KIOSK] layout vuoto/non valido');
return;
}
clearGrid();
currentLayout = layoutRow;
// grid CSS variables
grid.style.setProperty('--cols', String(content.grid?.cols ?? 12));
grid.style.setProperty('--rows', String(content.grid?.rows ?? 8));
grid.style.setProperty('--gap', String(content.grid?.gap ?? 4) + 'px');
// theme (opzionale)
if (content.theme === 'light') {
document.body.style.background = '#f4f5f7';
document.body.style.color = '#111';
} else {
document.body.style.background = '#0b1220';
document.body.style.color = '#fff';
}
for (const tile of content.tiles) {
const el = document.createElement('div');
el.className = `tile tile-${tile.type}`;
el.style.gridColumn = `${tile.x + 1} / span ${tile.w}`;
el.style.gridRow = `${tile.y + 1} / span ${tile.h}`;
grid.appendChild(el);
const r = window.tileRenderer.get(tile.type);
if (!r) { el.innerHTML = `<div class="title">tipo sconosciuto: ${tile.type}</div>`; continue; }
try {
r.mount(el, tile);
const off = r.bind(el, tile, window.dataBinder);
if (off) activeUnbinds.push(off);
} catch (err) {
console.error('[KIOSK] render', tile.id, err);
el.innerHTML = `<div class="title">errore render ${tile.id}</div>`;
}
}
setStatus(`v${layoutRow.version || '?'} · ${content.tiles.length} tiles`);
}
// ====== Stream del plugin (SSE) ======
function openStream() {
const es = new EventSource('/meb/kiosk/stream');
es.addEventListener('layout', (ev) => {
try { applyLayout(JSON.parse(ev.data)); }
catch (err) { console.warn('[KIOSK] layout parse:', err); }
});
es.onerror = () => setStatus('stream disconnesso', true);
es.onopen = () => setStatus('stream ok');
}
// status connessione SignalK
if (window.dataBinder) {
window.dataBinder.onConnState(({ connected }) => {
if (!connected) setStatus('signalk disconnesso', true);
else if (currentLayout) setStatus(`v${currentLayout.version || '?'} · live`);
});
}
// bootstrap: prima fetch one-shot del layout (per non aspettare il primo evento SSE),
// poi apri lo stream per gli update successivi
fetch('/meb/kiosk/layout')
.then(r => r.ok ? r.json() : null)
.then(l => { if (l) applyLayout(l); })
.catch(() => {})
.finally(openStream);
})();

View File

@@ -0,0 +1,80 @@
/**
* Store del kiosk layout lato plugin.
*
* - cache su disco: data/kiosk-layout.json
* - bootstrap HTTP: GET {API_URL}/kiosklayouts/sensor/<SENSOR_ID>/active
* - apply push WS: invocato da socket.js quando arriva _t:'kiosk_layout_update'
* - SSE stream: espone un emitter consumato dalla route /meb/kiosk/stream
*
* Il plugin NON sceglie il layout: lo riceve dal server e lo mostra.
*/
const fs = require('fs').promises;
const path = require('path');
const EventEmitter = require('events');
const configManager = require('../../config/configManager');
const cacheFile = path.join(__dirname, '../../../data/kiosk-layout.json');
const emitter = new EventEmitter();
let current = null;
async function ensureDir() {
try { await fs.mkdir(path.dirname(cacheFile), { recursive: true }); } catch {}
}
async function loadCache() {
try {
const raw = await fs.readFile(cacheFile, 'utf-8');
return JSON.parse(raw);
} catch { return null; }
}
async function saveCache() {
if (!current) return;
try { await ensureDir(); await fs.writeFile(cacheFile, JSON.stringify(current, null, 2)); }
catch (err) { console.warn('[KIOSK|STORE] save cache failed:', err.message); }
}
async function init() {
current = await loadCache();
if (current) {
console.log(`[KIOSK|STORE] cache caricata v=${current.version || '?'} (${current.content?.tiles?.length || 0} tiles)`);
} else {
console.log('[KIOSK|STORE] nessun layout in cache');
}
}
async function bootstrapFromServer() {
const API_URL = process.env.API_URL;
if (!API_URL) { console.warn('[KIOSK|STORE] API_URL non configurato'); return; }
const sensorId = configManager.getSensorName() || process.env.SENSOR_ID;
if (!sensorId) { console.warn('[KIOSK|STORE] SENSOR_ID non configurato'); return; }
try {
const r = await fetch(`${API_URL}/kiosklayouts/sensor/${encodeURIComponent(sensorId)}/active`, {
signal: AbortSignal.timeout(8000)
});
if (!r.ok) { if (r.status !== 404) console.warn('[KIOSK|STORE] bootstrap', r.status); return; }
const layout = await r.json();
await applyRemote(layout);
} catch (err) {
console.warn('[KIOSK|STORE] bootstrap err:', err.message);
}
}
/**
* Applica un layout (dedup per id+version), salva cache, emette 'update'.
*/
async function applyRemote(layout) {
if (!layout || !layout.content) return false;
if (current && current.id === layout.id && current.version === layout.version) return false;
current = layout;
await saveCache();
console.log(`[KIOSK|STORE] applicato v=${layout.version || '?'} (${layout.content.tiles?.length || 0} tiles)`);
emitter.emit('update', current);
return true;
}
function get() { return current; }
function onUpdate(fn) { emitter.on('update', fn); return () => emitter.off('update', fn); }
module.exports = { init, bootstrapFromServer, applyRemote, get, onUpdate };

View File

@@ -0,0 +1,188 @@
/**
* tile-renderer.js — registra renderer per ogni tipo di tile.
*
* Ogni renderer espone:
* mount(el, tile) → setup DOM iniziale
* bind(el, tile, binder) → sottoscrizione ai path → ritorna unbind()
*
* Tipi supportati: value | gauge | map
*/
(function () {
const renderers = {};
function register(type, r) { renderers[type] = r; }
function get(type) { return renderers[type]; }
function format(value, { decimals = 1 } = {}) {
if (value == null || Number.isNaN(value)) return '—';
if (typeof value === 'number') {
const factor = Math.pow(10, decimals);
return (Math.round(value * factor) / factor).toString();
}
return String(value);
}
// ============================== VALUE ==================================
register('value', {
mount(el, tile) {
const d = tile.display || {};
el.innerHTML = `
<div class="title">${escapeHtml(d.title || tile.source?.path || '')}</div>
<div class="val" data-val>
<span data-num>—</span>
${d.unit ? `<span class="unit">${escapeHtml(d.unit)}</span>` : ''}
</div>`;
const val = el.querySelector('[data-val]');
if (d.font) val.style.fontFamily = d.font;
if (d.fontSize) val.style.fontSize = d.fontSize;
if (d.color) val.style.color = d.color;
if (d.bg) el.style.background = d.bg;
},
bind(el, tile, binder) {
const num = el.querySelector('[data-num]');
const off = binder.subscribe(tile.source.path, (v) => {
num.textContent = format(v, { decimals: tile.display?.decimals ?? 1 });
el.classList.remove('stale');
});
const staleTimer = setInterval(() => {
const l = binder.getLatest(tile.source.path);
if (!l && !el.classList.contains('stale')) el.classList.add('stale');
}, 5000);
return () => { off(); clearInterval(staleTimer); };
}
});
// ============================== GAUGE ==================================
// SVG gauge semplice (arco 270°, lancetta). Range = tile.range = [min, max].
register('gauge', {
mount(el, tile) {
const d = tile.display || {};
el.innerHTML = `
<div class="title">${escapeHtml(d.title || tile.source?.path || '')}</div>
<svg viewBox="0 0 200 140" preserveAspectRatio="xMidYMid meet">
<path d="M 25 120 A 80 80 0 1 1 175 120" fill="none" stroke="#1f2937" stroke-width="14" stroke-linecap="round"/>
<path d="M 25 120 A 80 80 0 1 1 175 120" fill="none" stroke="${escapeHtml(d.color || '#22d3ee')}" stroke-width="14" stroke-linecap="round" data-arc/>
<line x1="100" y1="120" x2="100" y2="55" stroke="${escapeHtml(d.color || '#fff')}" stroke-width="3" stroke-linecap="round" data-needle transform="rotate(0 100 120)"/>
<circle cx="100" cy="120" r="6" fill="${escapeHtml(d.color || '#fff')}"/>
<text x="100" y="100" text-anchor="middle" font-size="22" font-weight="700" fill="#fff" data-num>—</text>
<text x="100" y="118" text-anchor="middle" font-size="9" opacity=".5" fill="#fff">${escapeHtml(d.unit || '')}</text>
</svg>`;
if (d.bg) el.style.background = d.bg;
},
bind(el, tile, binder) {
const [min, max] = tile.range || [0, 100];
const arc = el.querySelector('[data-arc]');
const needle = el.querySelector('[data-needle]');
const num = el.querySelector('[data-num]');
// pre-compute arc length (perimetro semicerchio R=80)
const totalLen = 376.99; // ~ 80 * Math.PI * 1.5 (270deg)
arc.setAttribute('stroke-dasharray', `${totalLen} ${totalLen}`);
const off = binder.subscribe(tile.source.path, (v) => {
const num_v = (v == null || Number.isNaN(+v)) ? null : Number(v);
num.textContent = format(num_v, { decimals: tile.display?.decimals ?? 1 });
if (num_v == null) return;
const ratio = Math.max(0, Math.min(1, (num_v - min) / (max - min)));
arc.setAttribute('stroke-dashoffset', String(totalLen * (1 - ratio)));
// needle: da -135° a +135°
const deg = -135 + 270 * ratio;
needle.setAttribute('transform', `rotate(${deg} 100 120)`);
el.classList.remove('stale');
});
return off;
}
});
// ============================== MAP ====================================
let mapboxLoadingPromise = null;
function loadMapbox() {
if (window.mapboxgl) return Promise.resolve();
if (mapboxLoadingPromise) return mapboxLoadingPromise;
mapboxLoadingPromise = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = 'https://api.mapbox.com/mapbox-gl-js/v3.6.0/mapbox-gl.js';
s.onload = resolve; s.onerror = reject;
document.head.appendChild(s);
});
return mapboxLoadingPromise;
}
register('map', {
mount(el, tile) {
const d = tile.display || {};
el.innerHTML = `
<div class="title">${escapeHtml(d.title || 'Mappa')}</div>
<div class="map-host" data-map></div>
<div class="overlays" data-overlays></div>`;
if (d.bg) el.style.background = d.bg;
},
bind(el, tile, binder) {
const mapHost = el.querySelector('[data-map]');
const overlays = el.querySelector('[data-overlays]');
const extras = Array.isArray(tile.extras) ? tile.extras : [];
// pre-popola overlay
const extraEls = extras.map((ex, i) => {
const span = document.createElement('span');
span.dataset.idx = i;
span.innerHTML = `<b>${escapeHtml(ex.label || ex.path)}</b>: <span data-ex>—</span> ${escapeHtml(ex.unit || '')}`;
overlays.appendChild(span);
return span.querySelector('[data-ex]');
});
let map = null, marker = null;
const mapboxKey = document.querySelector('meta[name="mapbox-key"]')?.content || '';
const offs = [];
loadMapbox().then(() => {
if (!window.mapboxgl) return;
window.mapboxgl.accessToken = mapboxKey;
map = new window.mapboxgl.Map({
container: mapHost,
style: {
version: 8,
sources: {
osm: { type:'raster', tiles:['https://a.tile.openstreetmap.org/{z}/{x}/{y}.png'], tileSize:256 },
openseamap: { type:'raster', tiles:['https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png'], tileSize:256 },
},
layers: [
{ id:'osm-layer', type:'raster', source:'osm', minzoom:0, maxzoom:22 },
{ id:'sea-layer', type:'raster', source:'openseamap', minzoom:0, maxzoom:18 },
],
},
center: [0, 0], zoom: 2,
});
marker = new window.mapboxgl.Marker({ color: tile.display?.color || '#ef4444' })
.setLngLat([0, 0]).addTo(map);
}).catch(err => console.warn('[KIOSK|MAP] mapbox load failed:', err));
let curLat = null, curLon = null;
const update = () => {
if (curLat == null || curLon == null || !map || !marker) return;
marker.setLngLat([curLon, curLat]);
map.flyTo({ center: [curLon, curLat], zoom: Math.max(13, map.getZoom()) });
};
offs.push(binder.subscribe(tile.source.latPath, v => { curLat = Number(v); update(); }));
offs.push(binder.subscribe(tile.source.lonPath, v => { curLon = Number(v); update(); }));
// extras
extras.forEach((ex, i) => {
offs.push(binder.subscribe(ex.path, v => {
extraEls[i].textContent = format(v, { decimals: ex.decimals ?? 1 });
}));
});
return () => {
offs.forEach(o => o && o());
if (map) { try { map.remove(); } catch (_) {} map = null; }
};
}
});
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
window.tileRenderer = { register, get };
})();