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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RZsbhyGmRwZogdoBwT6C6y
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,6 +26,9 @@ workspace/*
|
|||||||
!workspace/bourse-config.json
|
!workspace/bourse-config.json
|
||||||
# code du concierge véhicule (aucun secret : lit les tokens depuis des fichiers ignorés)
|
# code du concierge véhicule (aucun secret : lit les tokens depuis des fichiers ignorés)
|
||||||
!workspace/concierge-*.js
|
!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/*
|
workspace/cron/*
|
||||||
!workspace/cron/*.md
|
!workspace/cron/*.md
|
||||||
|
|||||||
57
workspace/MONITORING-LLM.md
Normal file
57
workspace/MONITORING-LLM.md
Normal file
@@ -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`).
|
||||||
@@ -1,5 +1,18 @@
|
|||||||
# Concierge véhicule Airtria — spécification (convenue OpenClaw ↔ EVCS, 2026-09-13)
|
# 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
|
## 🟢 LIVE EN PRODUCTION depuis 2026-09-14 02:22
|
||||||
Kill-switch airtria_concierge_enabled = ON, lecteur EVCS déployé (commit d8ef69b).
|
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
|
Preuve end-to-end réelle : session 12/09 11:55 → JEEP, vehicule_sur=t, evenement
|
||||||
|
|||||||
15
workspace/airtria-helper-ha.yaml.draft
Normal file
15
workspace/airtria-helper-ha.yaml.draft
Normal file
@@ -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":["<debut verbatim>", ...],"label":"VW|JEEP|MEGANE|ZERO|invité",
|
||||||
|
# "confirmed_at":"<ISO>","source":"whatsapp"}
|
||||||
@@ -1,128 +1,122 @@
|
|||||||
// concierge-live.js — Concierge véhicule Airtria, ÉTAGE QUESTION/RÉPONSE (live).
|
// concierge-live.js — Concierge véhicule Airtria, ÉTAGE QUESTION/RÉPONSE (live).
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Deux sous-commandes, appelées par l'agent :
|
// Déclenche AU BRANCHEMENT (transition 'on' de binary_sensor.victron_evcs_vehicle_connected
|
||||||
// detect → cron 10 min : repère un nouveau branchement, décide
|
// dans evenement), PAS à l'ouverture d'une session : au branchement du soir la borne ne
|
||||||
// (invité auto | question | rien), imprime la question WhatsApp
|
// tire rien (heures creuses à 01:24), donc attendre une session laisserait la 1re session
|
||||||
// ou rien. Écrit l'état en attente. Publie le helper si invité.
|
// naître mal étiquetée. Deux sous-commandes appelées par l'agent :
|
||||||
// answer "<txt>" → réception d'une réponse WhatsApp : reconnaît le véhicule,
|
// detect → cron : repère un nouveau branchement, décide (frais → rien /
|
||||||
// publie le helper pour les sessions en attente, confirme.
|
// périmé → question), imprime la question WhatsApp ou rien.
|
||||||
// Tout le déterministe est ici ; l'agent ne fait que router. Voir SPEC.
|
// answer "<txt>" → 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';
|
'use strict';
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const {
|
const { makeClient } = require('./concierge-detection.js');
|
||||||
buildPlugins, assign, isGuest, makeClient, SQL_TRANSITIONS, SQL_SESSIONS,
|
|
||||||
} = require('./concierge-detection.js');
|
|
||||||
const { publishLabel } = require('./concierge-write.js');
|
const { publishLabel } = require('./concierge-write.js');
|
||||||
|
|
||||||
const STATE = path.join(__dirname, 'concierge-state.json'); // progression
|
const STATE = path.join(__dirname, 'concierge-state.json');
|
||||||
const PENDING = path.join(__dirname, 'concierge-pending.json'); // question ouverte
|
const PENDING = path.join(__dirname, 'concierge-pending.json');
|
||||||
const HA_BASE = process.env.HA_BASE || 'http://10.10.10.17:8123';
|
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 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; } };
|
// Transitions de connexion : clef `branchement` = ts::text VERBATIM (EVCS joint dessus,
|
||||||
const saveJSON = (f, o) => fs.writeFileSync(f, JSON.stringify(o, null, 2));
|
// PAS horodatage_ha qui est antérieur de 3-51 s et ferait manquer le branchement en cours).
|
||||||
const haToken = () => fs.readFileSync(
|
const SQL_TRANS = `
|
||||||
process.env.HA_TOKEN_FILE || path.join(__dirname, '.ha-token'), 'utf8').trim();
|
SELECT ts::text AS ts_text, extract(epoch FROM ts) AS ts_epoch, detail->>'apres' AS etat
|
||||||
|
FROM evenement
|
||||||
async function haState(entity) {
|
WHERE detail->>'entite' = 'binary_sensor.victron_evcs_vehicle_connected'
|
||||||
const r = await fetch(`${HA_BASE}/api/states/${entity}`,
|
AND ts >= $1 AND ts < $2
|
||||||
{ headers: { Authorization: `Bearer ${haToken()}` } });
|
ORDER BY ts`;
|
||||||
if (!r.ok) throw new Error(`HA ${entity} HTTP ${r.status}`);
|
// Changements de VALEUR du sélecteur (robuste : hors lectures unavailable).
|
||||||
return r.json(); // { state, last_changed, attributes:{options?} }
|
const SQL_SEL = `
|
||||||
}
|
SELECT extract(epoch FROM ts) AS ts_epoch
|
||||||
|
|
||||||
// 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
|
|
||||||
FROM evenement
|
FROM evenement
|
||||||
WHERE genre = 'intervention'
|
WHERE genre = 'intervention'
|
||||||
AND detail->>'entite' = 'input_select.victron_evcs_vehicle'
|
AND detail->>'entite' = 'input_select.victron_evcs_vehicle'
|
||||||
AND ts >= $1 AND ts < $2
|
AND ts >= $1 AND ts < $2
|
||||||
ORDER BY ts`;
|
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() {
|
async function loadWindow() {
|
||||||
const until = new Date().toISOString();
|
const until = new Date().toISOString();
|
||||||
const since = new Date(Date.now() - WINDOW_DAYS * 864e5).toISOString();
|
const since = new Date(Date.now() - WINDOW_DAYS * 864e5).toISOString();
|
||||||
const c = makeClient();
|
const c = makeClient();
|
||||||
await c.connect();
|
await c.connect();
|
||||||
try {
|
try {
|
||||||
// Requêtes séquentielles : node-postgres n'aime pas des query() concurrentes
|
const tr = await c.query(SQL_TRANS, [since, until]);
|
||||||
// sur un même client (avertissement de dépréciation, retrait prévu en pg@9).
|
const sc = await c.query(SQL_SEL, [since, until]);
|
||||||
const tr = await c.query(SQL_TRANSITIONS, [since, until]);
|
return { plugins: buildPlugins(tr.rows), selChanges: sc.rows.map(r => Number(r.ts_epoch)) };
|
||||||
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,
|
|
||||||
};
|
|
||||||
} finally { await c.end(); }
|
} 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)
|
const hhmm = (epoch) => new Date(epoch * 1000)
|
||||||
.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
|
.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
|
||||||
|
|
||||||
async function detect() {
|
async function detect() {
|
||||||
const { groups, selChanges } = await loadWindow();
|
const { plugins, selChanges } = await loadWindow();
|
||||||
if (groups.length === 0) return '';
|
if (plugins.length === 0) return '';
|
||||||
const latest = groups[groups.length - 1];
|
const latest = plugins[plugins.length - 1];
|
||||||
const state = loadJSON(STATE, null);
|
const state = loadJSON(STATE, null);
|
||||||
|
|
||||||
// Bootstrap : au 1er lancement, prendre acte de l'historique sans harceler.
|
if (!state) { saveJSON(STATE, { lastOn: latest.on }); return ''; } // bootstrap sans harceler
|
||||||
if (!state) { saveJSON(STATE, { lastOn: latest.plugin.on }); return ''; }
|
if (latest.on <= state.lastOn) return ''; // rien de nouveau
|
||||||
if (latest.plugin.on <= state.lastOn) return ''; // rien de nouveau
|
|
||||||
|
|
||||||
// Invité conclusif (règle puissance) → publier, aucune question.
|
const fresh = selectorFresh(latest.on, selChanges);
|
||||||
// Label INVITE (majuscules, sans accent) = convention de la colonne (VW/JEEP/
|
saveJSON(STATE, { lastOn: latest.on }); // traité : pas de re-question
|
||||||
// MEGANE). La règle de puissance ne sait PAS quelle voiture c'est → jamais MEGANE.
|
if (fresh) return ''; // sélecteur réglé au branchement → confiance
|
||||||
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 ref = 'V-' + Math.random().toString(36).slice(2, 5);
|
const ref = 'V-' + Math.random().toString(36).slice(2, 5);
|
||||||
saveJSON(PENDING, {
|
saveJSON(PENDING, {
|
||||||
ref, sessions: latest.sessions.map(s => s.debut_text),
|
ref, branchement: latest.on_text, askedAt: new Date().toISOString(), on: latest.on,
|
||||||
askedAt: new Date().toISOString(), pluginOn: latest.plugin.on,
|
|
||||||
});
|
});
|
||||||
return `🔌 Charge démarrée à ${hhmm(latest.plugin.on)}. ` +
|
return `🔌 Voiture branchée à ${hhmm(latest.on)}. Quel véhicule est branché ? ` +
|
||||||
`Quel véhicule est branché ? (VW / JEEP / invité) [${ref}]`;
|
`(VW / JEEP / invité) [${ref}]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Labels en MAJUSCULES sans accent (convention de la colonne : VW/JEEP/MEGANE).
|
// Labels en MAJUSCULES sans accent (convention colonne : VW/JEEP/MEGANE). Le propriétaire
|
||||||
// Le propriétaire peut NOMMER l'invité (MEGANE) ; sinon INVITE générique.
|
// peut nommer l'invité (MEGANE) ; sinon INVITE. ZERO accepté (moto, si option ajoutée).
|
||||||
// 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.
|
|
||||||
function parseLabel(text, options) {
|
function parseLabel(text, options) {
|
||||||
const t = (text || '').toLowerCase();
|
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 (/m[ée]gane/.test(t)) return 'MEGANE';
|
||||||
if (/moto|z[ée]ro/.test(t)) return 'ZERO';
|
if (/moto|z[ée]ro/.test(t)) return 'ZERO';
|
||||||
if (/invit|pr[eê]t|ami|emprunt/.test(t)) return 'INVITE';
|
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;
|
if (Array.isArray(sel.attributes?.options)) options = sel.attributes.options;
|
||||||
} catch { /* défaut VW/JEEP */ }
|
} catch { /* défaut VW/JEEP */ }
|
||||||
const label = parseLabel(text, options);
|
const label = parseLabel(text, options);
|
||||||
if (!label) return ''; // non reconnu : l'agent pourra redemander, on ne publie rien
|
if (!label) return ''; // non reconnu : on ne publie rien
|
||||||
await publishLabel(pending.sessions, label, 'whatsapp');
|
await publishLabel(pending.branchement, label, 'whatsapp');
|
||||||
fs.rmSync(PENDING, { force: true });
|
fs.rmSync(PENDING, { force: true });
|
||||||
const when = new Date(pending.pluginOn * 1000)
|
const when = new Date(pending.on * 1000).toLocaleString('fr-FR', { timeZone: 'Europe/Paris' });
|
||||||
.toLocaleString('fr-FR', { timeZone: 'Europe/Paris' });
|
return `✅ Noté : ${label} pour la voiture branchée du ${when}. Merci !`;
|
||||||
return `✅ Noté : ${label} pour la charge du ${when}. Merci !`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const cmd = process.argv[2];
|
const cmd = process.argv[2];
|
||||||
|
|||||||
@@ -23,15 +23,16 @@ function haToken() {
|
|||||||
process.env.HA_TOKEN_FILE || path.join(__dirname, '.ha-token'), 'utf8').trim();
|
process.env.HA_TOKEN_FILE || path.join(__dirname, '.ha-token'), 'utf8').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publie le tuple {sessions[], label, confirmed_at, source} pour un branchement.
|
// Publie le tuple {branchement, label, confirmed_at, source}.
|
||||||
// sessions = clefs `debut` VERBATIM (ms comprises). Le service EVCS joint sur ±1 s.
|
// branchement = ts::text VERBATIM de la transition 'on' (evenement), espace et +02
|
||||||
async function publishLabel(sessions, label, source = 'whatsapp') {
|
// compris. Le service EVCS joint dessus (±1 s). C'est ce que PostgreSQL a rendu.
|
||||||
if (!Array.isArray(sessions) || sessions.length === 0) {
|
async function publishLabel(branchement, label, source = 'whatsapp') {
|
||||||
throw new Error('sessions doit être une liste non vide');
|
if (!branchement || typeof branchement !== 'string') {
|
||||||
|
throw new Error('branchement (ts::text verbatim) requis');
|
||||||
}
|
}
|
||||||
if (!label || typeof label !== 'string') throw new Error('label invalide');
|
if (!label || typeof label !== 'string') throw new Error('label invalide');
|
||||||
const payload = JSON.stringify({
|
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) {
|
if (payload.length > MAX_LEN) {
|
||||||
throw new Error(`payload ${payload.length} > ${MAX_LEN} : scinder le groupe ` +
|
throw new Error(`payload ${payload.length} > ${MAX_LEN} : scinder le groupe ` +
|
||||||
|
|||||||
21
workspace/llm-monitor-ha-card.yaml.draft
Normal file
21
workspace/llm-monitor-ha-card.yaml.draft
Normal file
@@ -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
|
||||||
28
workspace/llm-monitor-ha-package.yaml.draft
Normal file
28
workspace/llm-monitor-ha-package.yaml.draft
Normal file
@@ -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
|
||||||
120
workspace/llm-monitor.js
Normal file
120
workspace/llm-monitor.js
Normal file
@@ -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); });
|
||||||
Reference in New Issue
Block a user