Files
signalk-plugin/plugin/cores/rulesets.js
Giuseppe Raffa c2c1598226 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.
2026-05-12 10:17:54 +02:00

248 lines
8.4 KiB
JavaScript

/**
* Rulesets manager (plugin side) — formato v2.
*
* Gestisce 5 tipi: logs | forecast_current | forecast_hourly | marine_current | marine_hourly
*
* Formato item (v2):
* { path, meta: { name, unit, decimals? }, olds: [], enabled: true }
*
* Fonti:
* 1. cache locale (data/rulesets.json) — sopravvive ai restart
* 2. GET HTTP {API_URL}/rulesets/<type>/active — bootstrap o riconciliazione
* 3. push WS realtime ({_t:'ruleset_update'}) — runtime updates
*
* Dopo applyRemote(type, ruleset):
* - emette 'update' (type, new, prev)
* - emette 'update:<type>' (new, prev)
* - invia ack al server (via realtime control message ruleset_ack)
*
* I consumer (logs.local, openmeteo) si sottoscrivono per applicare il cambio.
* Quando arriva un update di tipo 'logs', il consumer triggera un session_reset
* (cosi' Influx separa storico vecchio da nuovo schema).
*/
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const EventEmitter = require('events');
const {
LOG_PATHS,
FORECAST_CURRENT,
FORECAST_HOURLY,
MARINE_CURRENT,
MARINE_HOURLY
} = require('../rules');
const TYPES = ['logs', 'forecast_current', 'forecast_hourly', 'marine_current', 'marine_hourly'];
const cacheFile = path.join(__dirname, '../../data/rulesets.json');
const emitter = new EventEmitter();
const API_URL = process.env.API_URL;
// state: { type -> { id, version: {major,minor,patch,str}, content: [items], _default } }
let state = {};
// =============== DEFAULTS (fallback iniziale, no DB / no cache) ===============
/** Mapping legacy openmeteo code → SK path */
const LEGACY_SK_MAP = {
'temperature_2m': 'meb.forecast.temperature',
'wind_speed_10m': 'meb.forecast.wind.speed',
'wind_direction_10m': 'meb.forecast.wind.direction',
'wind_gusts_10m': 'meb.forecast.wind.gusts',
'precipitation': 'meb.forecast.precipitation',
'rain': 'meb.forecast.rain',
'relative_humidity_2m': 'meb.forecast.humidity',
'pressure_msl': 'meb.forecast.pressure',
'precipitation_probability': 'meb.forecast.precipitationProbability',
'cloud_cover': 'meb.forecast.cloudCover',
'wave_height': 'meb.waves.height',
'wave_direction': 'meb.waves.direction',
'wave_period': 'meb.waves.period',
'wave_peak_period': 'meb.waves.peakPeriod',
'ocean_current_velocity': 'meb.waves.currentVelocity',
'ocean_current_direction': 'meb.waves.currentDirection',
};
function defaultItem(p, meta = {}) {
return { path: p, meta, olds: [], enabled: true };
}
function buildDefaults() {
const mk = (items) => ({
id: null,
version: { major: 1, minor: 0, patch: 0, str: '1.0.0' },
description: 'default',
content: items,
_default: true,
});
return {
logs: mk(LOG_PATHS.map(p => defaultItem(p, {}))),
forecast_current: mk(FORECAST_CURRENT.map(c => defaultItem(c, { sk_path: LEGACY_SK_MAP[c] }))),
forecast_hourly: mk(FORECAST_HOURLY.map(c => defaultItem(c, { sk_path: LEGACY_SK_MAP[c] }))),
marine_current: mk(MARINE_CURRENT.map(c => defaultItem(c, { sk_path: LEGACY_SK_MAP[c] }))),
marine_hourly: mk(MARINE_HOURLY.map(c => defaultItem(c, { sk_path: LEGACY_SK_MAP[c] }))),
};
}
// =============== CACHE I/O ===============
async function ensureDir() {
try { await fsp.mkdir(path.dirname(cacheFile), { recursive: true }); } catch {}
}
async function loadCache() {
try {
const raw = await fsp.readFile(cacheFile, 'utf-8');
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') return parsed;
} catch {}
return {};
}
async function saveCache() {
try { await ensureDir(); await fsp.writeFile(cacheFile, JSON.stringify(state, null, 2)); }
catch (err) { console.warn('[RULESETS] save cache failed:', err.message); }
}
// =============== INIT + BOOTSTRAP ===============
/**
* Init: carica cache → applica defaults sui type mancanti. Non emette eventi.
* Successivamente `bootstrapFromServer()` chiama gli endpoint per riconciliare.
*/
async function init() {
const cached = await loadCache();
const defaults = buildDefaults();
state = {};
for (const t of TYPES) state[t] = cached[t] || defaults[t];
console.log('[RULESETS] init:',
TYPES.map(t => `${t}@v${state[t]?.version?.str || '?'}${state[t]?._default ? '(d)' : ''}`).join(' '));
}
/**
* Tenta di scaricare la versione attiva di ogni tipo dal server.
* Se ottenuta e nuova, la applica (emette gli eventi).
*/
async function bootstrapFromServer() {
if (!API_URL) {
console.warn('[RULESETS] API_URL non configurato, salto bootstrap');
return;
}
for (const type of TYPES) {
try {
const r = await fetch(`${API_URL}/rulesets/${type}/active`, { signal: AbortSignal.timeout(8000) });
if (!r.ok) { if (r.status !== 404) console.warn(`[RULESETS] ${type} bootstrap ${r.status}`); continue; }
const remote = await r.json();
// formato server: { id, type, version:{...,str}, content:[...] }
await applyRemote(type, remote, { source: 'bootstrap' });
} catch (err) {
console.warn(`[RULESETS] bootstrap ${type} err:`, err.message);
}
}
}
// =============== ACCESSORS ===============
function get(type) { return state[type] || null; }
function getEnabledItems(type) {
const rs = state[type];
if (!rs) return [];
return (rs.content || []).filter(it => it.enabled !== false);
}
function getEnabledPaths(type) {
return getEnabledItems(type).map(it => it.path).filter(Boolean);
}
function getPathMap(type) {
const map = {};
for (const it of getEnabledItems(type)) {
if (it.path && it.meta?.sk_path) map[it.path] = it.meta.sk_path;
}
return map;
}
function getMetaForPath(type, p) {
return (state[type]?.content || []).find(it => it.path === p)?.meta || null;
}
function versionStr(type) { return state[type]?.version?.str || null; }
function rulesetId(type) { return state[type]?.id || null; }
// =============== APPLY ===============
/**
* Applica un ruleset ricevuto. Salva cache, emette update events,
* e (se non e' bootstrap) manda ack al server.
*
* Idempotente per versione: ignora payload con version <= corrente.
*/
async function applyRemote(type, ruleset, { source = 'push' } = {}) {
if (!TYPES.includes(type)) {
console.warn(`[RULESETS] tipo sconosciuto: ${type}`);
return false;
}
if (!ruleset || !Array.isArray(ruleset.content)) {
console.warn(`[RULESETS] ruleset invalido per ${type}`);
return false;
}
const prev = state[type];
// dedup per id+version: scarta replays
if (prev && !prev._default && ruleset.id && prev.id === ruleset.id
&& prev.version?.str === ruleset.version?.str) {
return false;
}
state[type] = {
id: ruleset.id,
version: ruleset.version,
description: ruleset.description,
content: ruleset.content,
_default: false,
};
await saveCache();
const prevV = prev?.version?.str || '∅';
const newV = ruleset.version?.str || '?';
console.log(`[RULESETS] ${type} ${prevV}${newV} (${ruleset.content.length} items, src=${source})`);
emitter.emit('update', type, state[type], prev);
emitter.emit(`update:${type}`, state[type], prev);
// ack al server (skipped on bootstrap to avoid loop)
if (source !== 'bootstrap' && ruleset.id) {
try {
const realtime = require('./realtime/core');
realtime.sendRaw({
_t: 'ruleset_ack',
type,
ruleset_id: ruleset.id,
version: ruleset.version?.str || '?',
});
} catch (err) {
console.warn('[RULESETS] ack send failed:', err.message);
}
}
return true;
}
function onUpdate(listener) { emitter.on('update', listener); return () => emitter.off('update', listener); }
function onUpdateOf(type, listener) { emitter.on(`update:${type}`, listener); return () => emitter.off(`update:${type}`, listener); }
module.exports = {
TYPES,
init,
bootstrapFromServer,
get,
getEnabledItems,
getEnabledPaths,
getPathMap,
getMetaForPath,
versionStr,
rulesetId,
applyRemote,
onUpdate,
onUpdateOf,
};