From b834e3bf75789ae812403dce0dd964f2fc293f47 Mon Sep 17 00:00:00 2001 From: Thierry Tallagrand Date: Tue, 15 Sep 2026 04:59:15 +0200 Subject: [PATCH] Concierge au branchement + moniteur LLM/RAM + docs Concierge: declenche au BRANCHEMENT (transition on de evenement) au lieu de la session ; helper porte 'branchement' (ts::text verbatim) ; fraicheur [on-10, min(now, on+30)]. Moniteur LLM/RAM (llm-monitor.js) : endpoint spark + RAM, alerte WhatsApp sur changement d'etat, publie l'etat dans HA (last_seen a chaque passage) + interrupteur de pilotage. Docs: SPEC a jour, MONITORING-LLM.md, brouillons de config HA. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RZsbhyGmRwZogdoBwT6C6y --- .gitignore | 3 + workspace/MONITORING-LLM.md | 57 +++++++ workspace/airtria-concierge-vehicule-SPEC.md | 13 ++ workspace/airtria-helper-ha.yaml.draft | 15 ++ workspace/concierge-live.js | 169 +++++++++---------- workspace/concierge-write.js | 13 +- workspace/llm-monitor-ha-card.yaml.draft | 21 +++ workspace/llm-monitor-ha-package.yaml.draft | 28 +++ workspace/llm-monitor.js | 120 +++++++++++++ 9 files changed, 345 insertions(+), 94 deletions(-) create mode 100644 workspace/MONITORING-LLM.md create mode 100644 workspace/airtria-helper-ha.yaml.draft create mode 100644 workspace/llm-monitor-ha-card.yaml.draft create mode 100644 workspace/llm-monitor-ha-package.yaml.draft create mode 100644 workspace/llm-monitor.js diff --git a/.gitignore b/.gitignore index eea0033..44d0d23 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ workspace/* !workspace/bourse-config.json # code du concierge véhicule (aucun secret : lit les tokens depuis des fichiers ignorés) !workspace/concierge-*.js +# moniteur LLM/RAM + brouillons de config HA (aucun secret) +!workspace/llm-monitor.js +!workspace/*.yaml.draft !workspace/cron/ workspace/cron/* !workspace/cron/*.md diff --git a/workspace/MONITORING-LLM.md b/workspace/MONITORING-LLM.md new file mode 100644 index 0000000..6748380 --- /dev/null +++ b/workspace/MONITORING-LLM.md @@ -0,0 +1,57 @@ +# Surveillance LLM + RAM du spark — mode d'emploi + +But : alerter par WhatsApp **uniquement sur un changement d'état** (LLM injoignable / +RAM basse → alerte, puis retour à la normale). Silencieux quand tout va bien. +Principe : « le spark mesure, OpenClaw notifie » — déterministe, sans LLM, donc l'alerte +part même si le modèle est mort. + +## Deux composants +1. **Côté spark (10.10.10.8)** — cron `1 min` : `~/spark-llm-monitor/health-writer.sh` + lit la RAM (`/proc/meminfo`) + teste l'endpoint local `:8000/health`, et publie + `.spark-health.json` dans le workspace. +2. **Côté OpenClaw (.17)** — cron `*/2 min`, id **`dbd2e8dd-68d1-4ebe-9a55-7f33810de6bc`**, + nom `llm-monitor` : `node /home/node/.openclaw/workspace/llm-monitor.js` lit le relevé + + teste l'endpoint à distance, et envoie le WhatsApp (announce) sur transition. + +## Seuils +- RAM : alerte si dispo < **4 %** (~5 Gi), rétabli au-dessus de **6 %**. (earlyoom tue le + serveur d'inférence à ~2 %.) +- Endpoint : injoignable, ou latence > 10 s. +- Relevé spark figé > 5 min → alerte « writer arrêté ou spark KO ». + +## Voir l'état courant +``` +cat /mnt/ARCH3/docker/openclaw-data/workspace/.spark-health.json # RAM + endpoint (spark) +cat /mnt/ARCH3/docker/openclaw-data/workspace/.llm-monitor-state.json # dernier verdict notifier +docker exec openclaw-gateway openclaw cron get dbd2e8dd-68d1-4ebe-9a55-7f33810de6bc | tail -20 +``` + +## ARRÊTER +- Notifier (.17) : `docker exec openclaw-gateway openclaw cron disable dbd2e8dd-68d1-4ebe-9a55-7f33810de6bc` +- Mesure (spark) : `crontab -e` puis retirer la ligne contenant `health-writer.sh` + (ou `crontab -l | grep -v health-writer.sh | crontab -`). + +## RELANCER +- Notifier (.17) : `docker exec openclaw-gateway openclaw cron enable dbd2e8dd-68d1-4ebe-9a55-7f33810de6bc` +- Mesure (spark) : `( crontab -l 2>/dev/null; echo "* * * * * /home/tmzf5455/spark-llm-monitor/health-writer.sh >/dev/null 2>&1" ) | crontab -` + +## Dashboard Home Assistant (affichage live + pilotage) +Le notifier publie l'état dans HA à chaque cycle (best-effort) et lit un interrupteur : +- `input_number.llm_mem_avail_pct` — RAM disponible (%) du spark. +- `input_text.llm_monitor_status` — libellé d'état (« OK · RAM 6% · 15:56 » ou l'alerte). +- `input_boolean.llm_monitor_enabled` — **interrupteur de pilotage** : OFF coupe les + alertes WhatsApp (l'affichage reste à jour), ON les réactive. + +À appliquer une fois (fichiers brouillons dans le workspace) : +1. Paquet : `llm-monitor-ha-package.yaml.draft` → déposer dans les `packages/` de HA + (ex. `packages/llm_monitor.yaml`), puis Développeur > YAML : recharger + `input_boolean`, `input_number`, `input_text`. +2. Carte : `llm-monitor-ha-card.yaml.draft` → ajouter à un tableau de bord (carte manuelle). +Les entités se remplissent au cycle suivant (≤ 2 min). + +## Tester à la demande +``` +docker exec openclaw-gateway openclaw cron run dbd2e8dd-68d1-4ebe-9a55-7f33810de6bc 2>&1 | tail -15 +``` +Sortie vide = tout va bien. Pour forcer une alerte de test : arrêter brièvement le +serveur d'inférence, ou pointer le notifier sur un port mort (`SPARK_BASE=http://127.0.0.1:9`). diff --git a/workspace/airtria-concierge-vehicule-SPEC.md b/workspace/airtria-concierge-vehicule-SPEC.md index 2fb2511..98797bd 100644 --- a/workspace/airtria-concierge-vehicule-SPEC.md +++ b/workspace/airtria-concierge-vehicule-SPEC.md @@ -1,5 +1,18 @@ # Concierge véhicule Airtria — spécification (convenue OpenClaw ↔ EVCS, 2026-09-13) +## 🔁 MISE À JOUR 2026-09-15 : déclenchement AU BRANCHEMENT (plus à la session) +Le propriétaire veut la question dès le BRANCHEMENT (au branchement du soir, la borne +ne tire rien avant les HC de 01:24 ; attendre une session laissait la 1re mal étiquetée). +concierge-live.js détecte désormais sur les transitions 'on' de `evenement` (plus sur +session_charge). Le helper porte `branchement` = **ts::text VERBATIM** de la transition +'on' (PAS horodatage_ha, antérieur de 3-51 s → EVCS raterait le branchement en cours), +qui remplace `sessions`. Fraîcheur = sélecteur changé dans [on−10 min, min(now, on+30 min)] +(EVCS). Règle de puissance abandonnée au branchement (pas de session/p_max) → on demande +pour TOUT branchement périmé, invité géré par la réponse. Division : Airtria détecte le +branchement + demande + publie ; EVCS résout, mémorise, applique aux sessions du câble au +fur et à mesure (HC 01:24 comprises), refuse les contradictions. Validé contre l'historique +(15 branchements, distribution de fraîcheur inchangée) ; déployé, test de portée vert. + ## 🟢 LIVE EN PRODUCTION depuis 2026-09-14 02:22 Kill-switch airtria_concierge_enabled = ON, lecteur EVCS déployé (commit d8ef69b). Preuve end-to-end réelle : session 12/09 11:55 → JEEP, vehicule_sur=t, evenement diff --git a/workspace/airtria-helper-ha.yaml.draft b/workspace/airtria-helper-ha.yaml.draft new file mode 100644 index 0000000..32f3c95 --- /dev/null +++ b/workspace/airtria-helper-ha.yaml.draft @@ -0,0 +1,15 @@ +# BROUILLON — à fusionner dans le paquet HA (ex. victron_evcs_vehicles.yaml) +# puis Développeur > YAML > Recharger « input_text ». NE PAS appliquer sans +# vérifier que la clef input_text: n'est pas déjà présente ailleurs (fusionner). +# +# Helper d'écriture unique et cantonné du concierge véhicule Airtria. +# max: 255 est OBLIGATOIRE (défaut HA = 100 → rejet silencieux d'un JSON de ~150). + +input_text: + airtria_vehicle_label: + name: "Airtria — label véhicule (concierge)" + max: 255 + icon: mdi:car-electric + # Contenu attendu (écrit par Airtria uniquement, lu par le service EVCS) : + # {"sessions":["", ...],"label":"VW|JEEP|MEGANE|ZERO|invité", + # "confirmed_at":"","source":"whatsapp"} diff --git a/workspace/concierge-live.js b/workspace/concierge-live.js index 8b69df9..ff50132 100644 --- a/workspace/concierge-live.js +++ b/workspace/concierge-live.js @@ -1,128 +1,122 @@ // concierge-live.js — Concierge véhicule Airtria, ÉTAGE QUESTION/RÉPONSE (live). // ----------------------------------------------------------------------------- -// Deux sous-commandes, appelées par l'agent : -// detect → cron 10 min : repère un nouveau branchement, décide -// (invité auto | question | rien), imprime la question WhatsApp -// ou rien. Écrit l'état en attente. Publie le helper si invité. -// answer "" → réception d'une réponse WhatsApp : reconnaît le véhicule, -// publie le helper pour les sessions en attente, confirme. -// Tout le déterministe est ici ; l'agent ne fait que router. Voir SPEC. +// Déclenche AU BRANCHEMENT (transition 'on' de binary_sensor.victron_evcs_vehicle_connected +// dans evenement), PAS à l'ouverture d'une session : au branchement du soir la borne ne +// tire rien (heures creuses à 01:24), donc attendre une session laisserait la 1re session +// naître mal étiquetée. Deux sous-commandes appelées par l'agent : +// detect → cron : repère un nouveau branchement, décide (frais → rien / +// périmé → question), imprime la question WhatsApp ou rien. +// answer "" → réponse WhatsApp : reconnaît le véhicule, publie le helper +// {branchement, label, ...}, confirme. +// La détection est chez Airtria ; la résolution du branchement, la mémorisation et +// l'application aux sessions (y compris celles à venir) sont chez EVCS. Voir SPEC. // ----------------------------------------------------------------------------- 'use strict'; const fs = require('fs'); const path = require('path'); -const { - buildPlugins, assign, isGuest, makeClient, SQL_TRANSITIONS, SQL_SESSIONS, -} = require('./concierge-detection.js'); +const { makeClient } = require('./concierge-detection.js'); const { publishLabel } = require('./concierge-write.js'); -const STATE = path.join(__dirname, 'concierge-state.json'); // progression -const PENDING = path.join(__dirname, 'concierge-pending.json'); // question ouverte +const STATE = path.join(__dirname, 'concierge-state.json'); +const PENDING = path.join(__dirname, 'concierge-pending.json'); const HA_BASE = process.env.HA_BASE || 'http://10.10.10.17:8123'; -const SELECTOR = 'input_select.victron_evcs_vehicle'; // lecture seule (HA) +const SELECTOR = 'input_select.victron_evcs_vehicle'; const WINDOW_DAYS = 5; +const FRESH_BEFORE_S = 600, FRESH_AFTER_S = 1800; -const loadJSON = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } }; -const saveJSON = (f, o) => fs.writeFileSync(f, JSON.stringify(o, null, 2)); -const haToken = () => fs.readFileSync( - process.env.HA_TOKEN_FILE || path.join(__dirname, '.ha-token'), 'utf8').trim(); - -async function haState(entity) { - const r = await fetch(`${HA_BASE}/api/states/${entity}`, - { headers: { Authorization: `Bearer ${haToken()}` } }); - if (!r.ok) throw new Error(`HA ${entity} HTTP ${r.status}`); - return r.json(); // { state, last_changed, attributes:{options?} } -} - -// Changements de VALEUR du sélecteur, lus dans evenement (robuste : le détecteur -// saute les lectures `unavailable`, contrairement au last_changed HA qu'un blip MQTT -// remet à zéro). genre='intervention', entite=input_select.victron_evcs_vehicle. -const SQL_SELECTOR_CHANGES = ` - SELECT extract(epoch FROM ts) AS ts_epoch, - extract(epoch FROM (detail->>'horodatage_ha')::timestamptz) AS ha_epoch +// Transitions de connexion : clef `branchement` = ts::text VERBATIM (EVCS joint dessus, +// PAS horodatage_ha qui est antérieur de 3-51 s et ferait manquer le branchement en cours). +const SQL_TRANS = ` + SELECT ts::text AS ts_text, extract(epoch FROM ts) AS ts_epoch, detail->>'apres' AS etat + FROM evenement + WHERE detail->>'entite' = 'binary_sensor.victron_evcs_vehicle_connected' + AND ts >= $1 AND ts < $2 + ORDER BY ts`; +// Changements de VALEUR du sélecteur (robuste : hors lectures unavailable). +const SQL_SEL = ` + SELECT extract(epoch FROM ts) AS ts_epoch FROM evenement WHERE genre = 'intervention' AND detail->>'entite' = 'input_select.victron_evcs_vehicle' AND ts >= $1 AND ts < $2 ORDER BY ts`; +const loadJSON = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } }; +const saveJSON = (f, o) => fs.writeFileSync(f, JSON.stringify(o, null, 2)); +const haToken = () => fs.readFileSync(process.env.HA_TOKEN_FILE || path.join(__dirname, '.ha-token'), 'utf8').trim(); + +async function haState(entity) { + const r = await fetch(`${HA_BASE}/api/states/${entity}`, { headers: { Authorization: `Bearer ${haToken()}` } }); + if (!r.ok) throw new Error(`HA ${entity} HTTP ${r.status}`); + return r.json(); +} + +// Un branchement = intervalle [on … off suivant]. Défensif contre le flapping : +// un 'on' déjà ouvert est ignoré, un 'off' sans ouverture est ignoré. Chaque +// branchement porte le ts::text VERBATIM de sa transition 'on'. +function buildPlugins(rows) { + const plugins = []; + let open = null; + for (const t of rows) { + const at = Number(t.ts_epoch); + if (t.etat === 'on') { if (open === null) open = { on: at, on_text: t.ts_text, off: null }; } + else if (t.etat === 'off') { if (open !== null) { open.off = at; plugins.push(open); open = null; } } + } + if (open !== null) plugins.push(open); // branchement encore en cours + return plugins; +} + +// Fraîcheur du sélecteur (EVCS 2026-09-14) : réglé dans [on−10 min, min(now, on+30 min)] ? +// La borne haute min(now, on+30) donne le comportement « au branchement » (fenêtre pas +// encore fermée) tout en gardant le garde-fou : un changement bien APRÈS le branchement +// (relu plus tard) n'est pas « frais » — c'est le producteur des groupes mixtes. +function selectorFresh(onEpoch, selChanges) { + const hi = Math.min(Date.now() / 1000, onEpoch + FRESH_AFTER_S); + return selChanges.some(c => c >= onEpoch - FRESH_BEFORE_S && c <= hi); +} + async function loadWindow() { const until = new Date().toISOString(); const since = new Date(Date.now() - WINDOW_DAYS * 864e5).toISOString(); const c = makeClient(); await c.connect(); try { - // Requêtes séquentielles : node-postgres n'aime pas des query() concurrentes - // sur un même client (avertissement de dépréciation, retrait prévu en pg@9). - const tr = await c.query(SQL_TRANSITIONS, [since, until]); - const se = await c.query(SQL_SESSIONS, [since, until]); - const sc = await c.query(SQL_SELECTOR_CHANGES, [since, until]); - const plugins = buildPlugins(tr.rows); - const { groups } = assign(se.rows, plugins); - const selChanges = sc.rows.map(r => Number(r.ha_epoch != null ? r.ha_epoch : r.ts_epoch)); - return { - groups: groups.slice().sort((a, b) => a.plugin.on - b.plugin.on), - selChanges, - }; + const tr = await c.query(SQL_TRANS, [since, until]); + const sc = await c.query(SQL_SEL, [since, until]); + return { plugins: buildPlugins(tr.rows), selChanges: sc.rows.map(r => Number(r.ts_epoch)) }; } finally { await c.end(); } } -// Fraîcheur du sélecteur (règle soutenue par les données, EVCS 2026-09-13) : FRAIS -// seulement si un changement de sélecteur tombe dans [début − 10 min, début + 30 min]. -// Un changement plus tardif (en cours de branchement) ne peut pas étiqueter CE -// branchement (câble non bougé) → producteur des groupes mixtes → DEMANDER. Pas de -// changement → DEMANDER. Absence d'événement ne prouve pas tout (mémoire détecteur -// en-processus, redémarrages), mais dans le doute on demande = le bon côté. -const FRESH_BEFORE_S = 600, FRESH_AFTER_S = 1800; -function selectorFreshAt(onEpoch, selChanges) { - return selChanges.some(c => c >= onEpoch - FRESH_BEFORE_S && c <= onEpoch + FRESH_AFTER_S); -} - const hhmm = (epoch) => new Date(epoch * 1000) .toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' }); async function detect() { - const { groups, selChanges } = await loadWindow(); - if (groups.length === 0) return ''; - const latest = groups[groups.length - 1]; + const { plugins, selChanges } = await loadWindow(); + if (plugins.length === 0) return ''; + const latest = plugins[plugins.length - 1]; const state = loadJSON(STATE, null); - // Bootstrap : au 1er lancement, prendre acte de l'historique sans harceler. - if (!state) { saveJSON(STATE, { lastOn: latest.plugin.on }); return ''; } - if (latest.plugin.on <= state.lastOn) return ''; // rien de nouveau + if (!state) { saveJSON(STATE, { lastOn: latest.on }); return ''; } // bootstrap sans harceler + if (latest.on <= state.lastOn) return ''; // rien de nouveau - // Invité conclusif (règle puissance) → publier, aucune question. - // Label INVITE (majuscules, sans accent) = convention de la colonne (VW/JEEP/ - // MEGANE). La règle de puissance ne sait PAS quelle voiture c'est → jamais MEGANE. - if (latest.sessions.every(isGuest)) { - await publishLabel(latest.sessions.map(s => s.debut_text), 'INVITE', 'regle-puissance'); - saveJSON(STATE, { lastOn: latest.plugin.on }); - return ''; - } - - // Fraîcheur du sélecteur via evenement (fenêtre serrée autour du début), jamais - // via last_changed (qu'un blip unavailable remet à zéro). - const fresh = selectorFreshAt(latest.plugin.on, selChanges); - saveJSON(STATE, { lastOn: latest.plugin.on }); // traité : pas de re-question - if (fresh) return ''; // sélecteur réglé au branchement → confiance, pas de question + const fresh = selectorFresh(latest.on, selChanges); + saveJSON(STATE, { lastOn: latest.on }); // traité : pas de re-question + if (fresh) return ''; // sélecteur réglé au branchement → confiance const ref = 'V-' + Math.random().toString(36).slice(2, 5); saveJSON(PENDING, { - ref, sessions: latest.sessions.map(s => s.debut_text), - askedAt: new Date().toISOString(), pluginOn: latest.plugin.on, + ref, branchement: latest.on_text, askedAt: new Date().toISOString(), on: latest.on, }); - return `🔌 Charge démarrée à ${hhmm(latest.plugin.on)}. ` + - `Quel véhicule est branché ? (VW / JEEP / invité) [${ref}]`; + return `🔌 Voiture branchée à ${hhmm(latest.on)}. Quel véhicule est branché ? ` + + `(VW / JEEP / invité) [${ref}]`; } -// Labels en MAJUSCULES sans accent (convention de la colonne : VW/JEEP/MEGANE). -// Le propriétaire peut NOMMER l'invité (MEGANE) ; sinon INVITE générique. -// ZERO (moto) : accepté par WhatsApp bien que le sélecteur HA ne l'expose pas -// encore — le propriétaire ajoutera l'option quand la moto sera en service. +// Labels en MAJUSCULES sans accent (convention colonne : VW/JEEP/MEGANE). Le propriétaire +// peut nommer l'invité (MEGANE) ; sinon INVITE. ZERO accepté (moto, si option ajoutée). function parseLabel(text, options) { const t = (text || '').toLowerCase(); - for (const o of options) if (t.includes(o.toLowerCase())) return o; // VW/JEEP (options HA) + for (const o of options) if (t.includes(o.toLowerCase())) return o; if (/m[ée]gane/.test(t)) return 'MEGANE'; if (/moto|z[ée]ro/.test(t)) return 'ZERO'; if (/invit|pr[eê]t|ami|emprunt/.test(t)) return 'INVITE'; @@ -138,12 +132,11 @@ async function answer(text) { if (Array.isArray(sel.attributes?.options)) options = sel.attributes.options; } catch { /* défaut VW/JEEP */ } const label = parseLabel(text, options); - if (!label) return ''; // non reconnu : l'agent pourra redemander, on ne publie rien - await publishLabel(pending.sessions, label, 'whatsapp'); + if (!label) return ''; // non reconnu : on ne publie rien + await publishLabel(pending.branchement, label, 'whatsapp'); fs.rmSync(PENDING, { force: true }); - const when = new Date(pending.pluginOn * 1000) - .toLocaleString('fr-FR', { timeZone: 'Europe/Paris' }); - return `✅ Noté : ${label} pour la charge du ${when}. Merci !`; + const when = new Date(pending.on * 1000).toLocaleString('fr-FR', { timeZone: 'Europe/Paris' }); + return `✅ Noté : ${label} pour la voiture branchée du ${when}. Merci !`; } const cmd = process.argv[2]; diff --git a/workspace/concierge-write.js b/workspace/concierge-write.js index ec52a62..5340c51 100644 --- a/workspace/concierge-write.js +++ b/workspace/concierge-write.js @@ -23,15 +23,16 @@ function haToken() { process.env.HA_TOKEN_FILE || path.join(__dirname, '.ha-token'), 'utf8').trim(); } -// Publie le tuple {sessions[], label, confirmed_at, source} pour un branchement. -// sessions = clefs `debut` VERBATIM (ms comprises). Le service EVCS joint sur ±1 s. -async function publishLabel(sessions, label, source = 'whatsapp') { - if (!Array.isArray(sessions) || sessions.length === 0) { - throw new Error('sessions doit être une liste non vide'); +// Publie le tuple {branchement, label, confirmed_at, source}. +// branchement = ts::text VERBATIM de la transition 'on' (evenement), espace et +02 +// compris. Le service EVCS joint dessus (±1 s). C'est ce que PostgreSQL a rendu. +async function publishLabel(branchement, label, source = 'whatsapp') { + if (!branchement || typeof branchement !== 'string') { + throw new Error('branchement (ts::text verbatim) requis'); } if (!label || typeof label !== 'string') throw new Error('label invalide'); const payload = JSON.stringify({ - sessions, label, confirmed_at: new Date().toISOString(), source, + branchement, label, confirmed_at: new Date().toISOString(), source, }); if (payload.length > MAX_LEN) { throw new Error(`payload ${payload.length} > ${MAX_LEN} : scinder le groupe ` + diff --git a/workspace/llm-monitor-ha-card.yaml.draft b/workspace/llm-monitor-ha-card.yaml.draft new file mode 100644 index 0000000..1621eaf --- /dev/null +++ b/workspace/llm-monitor-ha-card.yaml.draft @@ -0,0 +1,21 @@ +# BROUILLON — carte Lovelace pour un dashboard HA. +# À coller dans un tableau de bord (Modifier le tableau de bord > Ajouter une carte > +# Manuel), après avoir appliqué le paquet llm-monitor-ha-package.yaml.draft. + +type: vertical-stack +title: LLM (qwen3-coder) — spark +cards: + - type: gauge + entity: input_number.llm_mem_avail_pct + name: RAM disponible + unit: "%" + min: 0 + max: 20 # la RAM dispo tourne autour de 6 % : échelle 0-20 pour bien la lire + needle: true + - type: entities + entities: + - entity: input_text.llm_monitor_status + name: État + icon: mdi:robot-outline + - entity: input_boolean.llm_monitor_enabled + name: Alertes WhatsApp diff --git a/workspace/llm-monitor-ha-package.yaml.draft b/workspace/llm-monitor-ha-package.yaml.draft new file mode 100644 index 0000000..9247076 --- /dev/null +++ b/workspace/llm-monitor-ha-package.yaml.draft @@ -0,0 +1,28 @@ +# BROUILLON — paquet Home Assistant pour le dashboard LLM monitor. +# À déposer (ex.) dans packages/llm_monitor.yaml sur .17, puis Développeur > YAML : +# recharger « input_boolean », « input_number » et « input_text ». +# Aucun secret, aucune action : juste 3 entités d'affichage + 1 interrupteur. +# Le notifier (llm-monitor.js) les remplit tout seul au cycle suivant (toutes les 2 min). + +input_boolean: + llm_monitor_enabled: + name: "LLM monitor — alertes WhatsApp" + icon: mdi:bell-ring + # pas d'"initial" : laisse HA restaurer l'état après un redémarrage. + # OFF = coupe les alertes WhatsApp (l'affichage reste à jour). ON = alertes actives. + +input_number: + llm_mem_avail_pct: + name: "Spark — RAM disponible" + min: 0 + max: 100 + step: 0.1 + unit_of_measurement: "%" + mode: box + icon: mdi:memory + +input_text: + llm_monitor_status: + name: "LLM monitor — état" + max: 255 + icon: mdi:robot-outline diff --git a/workspace/llm-monitor.js b/workspace/llm-monitor.js new file mode 100644 index 0000000..9cbc632 --- /dev/null +++ b/workspace/llm-monitor.js @@ -0,0 +1,120 @@ +// llm-monitor.js — tourne côté OpenClaw (.17), payload command d'un cron. +// « Le spark mesure, OpenClaw notifie » : lit le relevé publié par le spark +// (.spark-health.json) + teste l'endpoint LLM à distance. Deux sorties : +// 1) WhatsApp (announce) UNIQUEMENT sur changement d'état, et seulement si les +// alertes ne sont pas coupées par l'interrupteur HA (pilotage). +// 2) Publication de l'état LIVE dans Home Assistant. En particulier llm_monitor_last_seen +// est écrit À CHAQUE PASSAGE (même sain) : sans ça, un moniteur mort est +// indiscernable d'un système sain (les autres entités gardent leur dernière valeur). +// HA ne rafraîchit PAS le last_updated d'un helper réécrit à l'identique → il faut +// un horodatage explicite. Best-effort : n'échoue pas si une entité manque. +// Déterministe, sans LLM : l'alerte part même si le modèle est mort. +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const HEALTH = path.join(__dirname, '.spark-health.json'); +const STATE = path.join(__dirname, '.llm-monitor-state.json'); +const SPARK = process.env.SPARK_BASE || 'http://10.10.10.8:8000'; +const HA_BASE = process.env.HA_BASE || 'http://10.10.10.17:8123'; + +const MEM_ALERT_PCT = 4, MEM_RECOVER_PCT = 6; +const ENDPOINT_TIMEOUT_MS = 15000, ENDPOINT_SLOW_MS = 10000, HEALTH_STALE_S = 300; + +const HA_ENABLED = 'input_boolean.llm_monitor_enabled'; // interrupteur alertes on/off +const HA_MEM = 'input_number.llm_mem_avail_pct'; // RAM dispo % +const HA_STATUS = 'input_text.llm_monitor_status'; // libellé d'état +const HA_LASTSEEN = 'input_datetime.llm_monitor_last_seen'; // battement de cœur (chaque cycle) +const HA_MODEL = 'input_text.llm_monitor_model'; // modèle réellement chargé + +const load = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } }; +const save = (f, o) => fs.writeFileSync(f, JSON.stringify(o, null, 2)); +const haToken = () => { try { return fs.readFileSync(path.join(__dirname, '.ha-token'), 'utf8').trim(); } catch { return null; } }; + +async function endpointCheck() { + const t0 = Date.now(); + try { + const ac = new AbortController(); + const to = setTimeout(() => ac.abort(), ENDPOINT_TIMEOUT_MS); + const r = await fetch(`${SPARK}/health`, { signal: ac.signal }); + clearTimeout(to); + return { ok: r.ok, ms: Date.now() - t0 }; + } catch (e) { return { ok: false, ms: Date.now() - t0, err: e.name }; } +} + +async function modelId() { + try { + const ac = new AbortController(); + const to = setTimeout(() => ac.abort(), 5000); + const r = await fetch(`${SPARK}/v1/models`, { signal: ac.signal }); + clearTimeout(to); + if (!r.ok) return null; + const j = await r.json(); + return j?.data?.[0]?.id || null; + } catch { return null; } +} + +// --- Home Assistant, best-effort (ne jamais faire échouer le moniteur) --- +async function haGet(entity) { + const tok = haToken(); if (!tok) return null; + try { + const r = await fetch(`${HA_BASE}/api/states/${entity}`, { headers: { Authorization: `Bearer ${tok}` } }); + return r.ok ? r.json() : null; + } catch { return null; } +} +async function haCall(domain, service, data) { + const tok = haToken(); if (!tok) return; + try { + await fetch(`${HA_BASE}/api/services/${domain}/${service}`, { + method: 'POST', + headers: { Authorization: `Bearer ${tok}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + } catch { /* best-effort */ } +} + +(async () => { + const prev = load(STATE, { status: 'ok', mem_low: false }); + const health = load(HEALTH, null); + const ep = await endpointCheck(); + const problems = []; + + if (!ep.ok) problems.push(`LLM injoignable (${SPARK}/health, ${ep.err || 'HTTP≠200'}, ${ep.ms} ms)`); + else if (ep.ms > ENDPOINT_SLOW_MS) problems.push(`LLM très lent (${ep.ms} ms)`); + + let memLow = false, memPct = null; + if (!health) { + problems.push('pas de relevé RAM du spark (.spark-health.json absent — writer jamais lancé ?)'); + } else { + memPct = Number(health.mem_avail_pct); + const ageS = (Date.now() - Date.parse(health.ts)) / 1000; + if (ageS > HEALTH_STALE_S) problems.push(`relevé spark figé (${Math.round(ageS / 60)} min) — health-writer arrêté ou spark KO ?`); + else { + const thr = (prev.status === 'alert' && prev.mem_low) ? MEM_RECOVER_PCT : MEM_ALERT_PCT; + if (memPct < thr) { memLow = true; problems.push(`RAM spark basse : ${memPct}% (${health.mem_avail_gib} Gi) — earlyoom tue le modèle à ~2%`); } + } + } + + const status = problems.length ? 'alert' : 'ok'; + const hhmm = new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' }); + + // 2) Publication LIVE dans HA (best-effort). last_seen À CHAQUE PASSAGE. + await haCall('input_datetime', 'set_datetime', { entity_id: HA_LASTSEEN, timestamp: Math.floor(Date.now() / 1000) }); + if (memPct != null) await haCall('input_number', 'set_value', { entity_id: HA_MEM, value: memPct }); + const label = (status === 'ok' ? `OK · RAM ${memPct ?? '?'}%` : `⚠️ ${problems.join(' | ')}`).slice(0, 250); + await haCall('input_text', 'set_value', { entity_id: HA_STATUS, value: `${label} · ${hhmm}` }); + const mid = await modelId(); + if (mid) await haCall('input_text', 'set_value', { entity_id: HA_MODEL, value: mid }); + + // Pilotage : interrupteur HA OFF → pas d'alerte WhatsApp (l'affichage reste à jour). + const enabled = await haGet(HA_ENABLED); + const muted = enabled && enabled.state === 'off'; + + let out = ''; + if (status === 'alert' && prev.status !== 'alert') out = `⚠️ Spark/LLM — ${problems.join(' | ')}`; + else if (status === 'ok' && prev.status === 'alert') out = '✅ Spark/LLM — retour à la normale.'; + + save(STATE, { status, mem_low: memLow, at: new Date().toISOString(), problems }); + if (out && !muted) console.log(out); +})().catch(e => { console.error('[llm-monitor]', e.message); process.exit(1); });