From 54b0dd14b86eff30a799b752a2d02d63517a0e60 Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Wed, 16 Sep 2026 09:20:37 +0200 Subject: [PATCH] Guichet d'ordres : Airtria demande, ce service decide et applique MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Valide separement par le proprietaire le 16/09, comme la session OpenClaw et moi l'avions demande : c'est la premiere fois qu'une reponse WhatsApp touche au PILOTAGE et non a une etiquette consultative. ⚠️ LA LISTE BLANCHE EST DANS LE SERVICE, JAMAIS CHEZ L'AGENT — moindre privilege : il demande, il ne decide pas de ce qui est demandable. Six ordres admis : p12, p9, hc_nuit, strategie, vehicule, plafond_edf (borne 6-28). Les options d'un input_select sont lues EN DIRECT sur l'entite, jamais codees en dur : ZERO sera consomme sans modification quand la moto reviendra. ⚠️ HORS LISTE DEFINITIVEMENT : le contacteur EDF, les limites DVCC, la consigne de puissance reseau, les planchers SOC de securite — exactement ceux qui peuvent vider la batterie sous son plancher, la gaver sur le reseau, ou evincer le solaire une journee entiere. Garde supplementaire : un ordre de pilotage exige un canal HUMAIN. Une regle qui deduit « il faudrait activer P12 » n'a rien a faire ici — c'est une decision, pas une observation. Interrupteur DISTINCT de celui du concierge (input_boolean.airtria_ordres_enabled, a OFF), pour que couper le pilotage ne coupe pas l'etiquetage des vehicules. Il gate l'ECRITURE, pas la lecture : a OFF le service journalise ce qu'il aurait fait, ce qui permet d'eprouver la chaine avant qu'elle ne compte. NEUF ESSAIS, tous verifies. Refuses : ordre edf, dvcc, consigne_reseau, soc_stop_min (hors liste) · source regle-puissance (canal non humain) · plafond 45 A (hors bornes) · strategie « turbo » et vehicule « MEGANE » (absents des options, listees en direct). Applique : plafond_edf 28 -> 20, journalise avec l'etat anterieur ; rejeu du MEME confirmed_at -> ordre=0, rien ; nouveau confirmed_at -> restauration a 28. P12 intact tout du long. ⚠️ DEFAUT TROUVE EN EPROUVANT CECI : le mode --once n'appelait pas assurerConnexion(). Une base absente au demarrage lui donnait client = null, donc un TypeError au lieu d'un message clair. Trouve sur un simple mauvais numero de port. Corrige. Carte « Agents & surveillance » completee : le guichet et son dernier ordre, a cote du concierge et de l'etat du service. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj --- dashboards/evcs_master.yaml | 16 ++++ packages/airtria_ordres.yaml | 54 +++++++++++++ tools/historise-evcs.mjs | 149 ++++++++++++++++++++++++++++++++++- 3 files changed, 218 insertions(+), 1 deletion(-) create mode 100755 packages/airtria_ordres.yaml diff --git a/dashboards/evcs_master.yaml b/dashboards/evcs_master.yaml index 509ee32..996841d 100755 --- a/dashboards/evcs_master.yaml +++ b/dashboards/evcs_master.yaml @@ -547,6 +547,22 @@ views: name: Véhicule branché (piloté par la réponse) icon: mdi:car-multiple + - type: entities + title: Guichet d'ordres (pilotage) + entities: + # ⚠️ Interrupteur DISTINCT de celui du concierge, et c'est + # delibere : couper le pilotage ne doit pas couper l'etiquetage + # des vehicules. Deux rayons d'action tres differents. + # La liste blanche de ce qui est demandable vit dans le service + # d'historisation, jamais chez l'agent — il demande, il ne decide + # pas de ce qui est demandable. + - entity: input_boolean.airtria_ordres_enabled + name: Appliquer les ordres de pilotage + icon: mdi:remote + - entity: input_text.airtria_ordre + name: Dernier ordre demande + icon: mdi:gesture-tap-button + - type: entities title: Service d'historisation (10.10.10.22) entities: diff --git a/packages/airtria_ordres.yaml b/packages/airtria_ordres.yaml new file mode 100755 index 0000000..b69acd0 --- /dev/null +++ b/packages/airtria_ordres.yaml @@ -0,0 +1,54 @@ +# Guichet d'ordres — l'agent Airtria (OpenClaw) demande, le service applique +# +# Cree le 16/09/2026, sur validation EXPLICITE et SEPAREE du proprietaire. +# +# ⚠️ CE PAQUET EST D'UNE AUTRE NATURE QUE `airtria_concierge.yaml`. +# Le concierge pose une ETIQUETTE consultative sur des sessions passees. Ici, +# une reponse WhatsApp touche au PILOTAGE : P12, P9, la strategie, le plafond de +# courant. C'est la premiere fois qu'une ecriture d'Airtria influence une +# decision d'energie, et c'est pourquoi le proprietaire l'a validee a part, +# plutot que fondue dans l'accord general sur le perimetre. +# +# ⚠️ LA LISTE BLANCHE N'EST PAS ICI. Elle est tenue dans le service +# d'historisation (tools/historise-evcs.mjs, constante ORDRES), et c'est +# delibere : Airtria ne doit jamais la detenir — moindre privilege. Il demande, +# il ne decide pas de ce qui est demandable. Tout ordre hors liste est refuse et +# journalise. +# +# ⚠️ CE QUI N'EST PAS DEMANDABLE, ET NE LE SERA PAS : le contacteur +# `switch.edf_general_ok_switch`, les limites DVCC de l'Auto Charge Manager, la +# consigne de puissance reseau, et les planchers SOC de securite. Ce sont +# exactement ceux qui peuvent vider la batterie sous son plancher, la gaver sur +# le reseau, ou evincer le solaire une journee entiere. + +input_boolean: + # Kill-switch PROPRE au guichet, distinct de celui du concierge. + # Le proprietaire doit pouvoir couper le pilotage sans couper l'etiquetage des + # vehicules : deux rayons d'action tres differents, deux interrupteurs. + # Pas d'`initial:` — il desactiverait la restauration d'etat et remettrait la + # valeur a chaque demarrage (piege corrige le 06/09 sur cinq helpers). + airtria_ordres_enabled: + name: Airtria - Appliquer les ordres de pilotage + icon: mdi:remote + +input_text: + # Un ordre, en JSON, atomique : + # + # {"ordre":"p12","valeur":"on", + # "confirmed_at":"2026-09-16T05:20:00.000Z","source":"whatsapp"} + # + # `confirmed_at` est l'horodatage de LA VALEUR, jamais du run de l'agent, et + # il sert de clef d'idempotence : le service ne rejoue pas un ordre deja + # applique. Airtria ne le reutilise jamais d'un ordre a l'autre. + # + # `source` doit etre un canal HUMAIN. Un ordre de pilotage deduit par une + # regle n'a pas de sens : seule une personne decide de changer une strategie. + # + # `max: 255` EXPLICITE. Le defaut de Home Assistant est 100, et un texte trop + # long est rejete EN SILENCE — le 08/09, un helper declare a 32 avalait les + # messages d'erreur de 43 caracteres, quatre heures quarante-cinq de panne + # sans une trace. + airtria_ordre: + name: Airtria - Ordre de pilotage demande + max: 255 + icon: mdi:gesture-tap-button diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index 5a9dbe0..892857b 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -1489,6 +1489,144 @@ async function publierBattement({ statut, baseJoignable, cyclesEnEchec, dernierE if (!r.ok) throw new Error(`HTTP ${r.status}`); } +// --------------------------------------------------------------------- +// GUICHET D ORDRES — Airtria demande, ce service decide et applique +// --------------------------------------------------------------------- +// Valide separement par le proprietaire le 16/09. C est la premiere fois qu une +// reponse WhatsApp touche au PILOTAGE et non a une etiquette consultative : P12, +// P9, la strategie, le plafond de courant. +// +// ⚠️ LA LISTE BLANCHE EST ICI ET NULLE PART AILLEURS. Airtria ne doit jamais la +// detenir — moindre privilege : il demande, il ne decide pas de ce qui est +// demandable. Tout ordre hors de cette table est refuse et journalise, y compris +// si Airtria se trompe ou si quelqu un ecrit le helper a la main. +// +// ⚠️ CE QUI N Y FIGURE PAS, ET N Y FIGURERA PAS : le contacteur +// switch.edf_general_ok_switch, les limites DVCC de l Auto Charge Manager, la +// consigne de puissance reseau, et les planchers SOC de securite. Ce sont +// exactement ceux qui peuvent vider la batterie sous son plancher, la gaver sur +// le reseau, ou evincer le solaire une journee entiere. +// +// Les options d un input_select sont lues EN DIRECT sur l entite, jamais codees +// en dur : le jour ou ZERO sera ajoute au selecteur de vehicule (moto remise en +// service fin septembre), ce code le consommera sans modification. +const ORDRES = { + p12: { entite: 'input_boolean.victron_evcs_night_battery_charge', genre: 'bool' }, + p9: { entite: 'input_boolean.victron_evcs_allow_grid_departure_urgent', genre: 'bool' }, + hc_nuit: { entite: 'input_boolean.victron_evcs_allow_grid_hc_night', genre: 'bool' }, + strategie: { entite: 'input_select.victron_evcs_strategy', genre: 'option' }, + vehicule: { entite: 'input_select.victron_evcs_vehicle', genre: 'option' }, + plafond_edf: { entite: 'input_number.evcs_plafond_courant_edf_on', genre: 'nombre', min: 6, max: 28 }, +}; + +async function appliquerOrdreAirtria(sql, client, st) { + const brut = txt(st.get('input_text.airtria_ordre')?.state); + if (!brut || brut === 'unknown' || brut === 'unavailable') return 0; + + let o; + try { o = JSON.parse(brut); } catch { + return journaliser(sql, 'intervention', 'Ordre Airtria illisible (JSON invalide)', + { origine: 'airtria', brut: brut.slice(0, 200) }).then(() => 1); + } + + const nom = txt(o.ordre).toLowerCase(); + const source = txt(o.source); + const confirmedAt = txt(o.confirmed_at); + const valeur = txt(o.valeur); + if (!nom || !valeur) return 0; + + const appliquer = txt(st.get('input_boolean.airtria_ordres_enabled')?.state) === 'on'; + const lire = DRY ? async () => ({ rows: [] }) : (q, v) => client.query(q, v); + + const refuser = async (motif) => { + if (!appliquer && dernierBlancVu === confirmedAt + motif) return 0; + dernierBlancVu = confirmedAt + motif; + await journaliser(sql, 'intervention', + `Ordre Airtria REFUSE (${nom}) : ${motif}`, + { origine: 'airtria', source_airtria: source, ordre: nom, valeur, + confirmed_at: confirmedAt, refus: motif }); + return 1; + }; + + const spec = ORDRES[nom]; + if (!spec) return refuser(`ordre inconnu, hors liste blanche`); + + // Un ordre de PILOTAGE ne peut venir que d un humain. Une regle qui deduit + // « il faudrait activer P12 » n a rien a faire ici : c est une decision, pas + // une observation. + if (!CANAUX_HUMAINS.has(source)) { + return refuser(`source « ${source || 'absente'} » : un ordre de pilotage exige un canal humain`); + } + + // Deja applique : on ne rejoue pas. Meme clef que le concierge. + if (confirmedAt) { + const { rows } = await lire( + `SELECT 1 FROM evenement WHERE genre = 'intervention' + AND detail->>'origine' = 'airtria' AND detail->>'confirmed_at' = $1 LIMIT 1`, + [confirmedAt]); + if (rows.length) return 0; + } + + const cible = st.get(spec.entite); + if (!cible) return refuser(`entite cible ${spec.entite} absente de Home Assistant`); + const avant = txt(cible.state); + + let service, data; + if (spec.genre === 'bool') { + const v = valeur.toLowerCase(); + if (v !== 'on' && v !== 'off') return refuser(`valeur « ${valeur} » : attendu on ou off`); + service = v === 'on' ? 'input_boolean/turn_on' : 'input_boolean/turn_off'; + data = { entity_id: spec.entite }; + if (avant === v) return 0; // deja dans l etat demande + } else if (spec.genre === 'option') { + const options = cible.attributes?.options || []; + if (!options.includes(valeur)) { + return refuser(`valeur « ${valeur} » absente des options (${options.join(', ') || 'aucune'})`); + } + service = 'input_select/select_option'; + data = { entity_id: spec.entite, option: valeur }; + if (avant === valeur) return 0; + } else { + const v = Number(valeur); + if (!Number.isFinite(v)) return refuser(`valeur « ${valeur} » non numerique`); + if (v < spec.min || v > spec.max) { + return refuser(`valeur ${v} hors des bornes [${spec.min}, ${spec.max}]`); + } + service = 'input_number/set_value'; + data = { entity_id: spec.entite, value: v }; + if (Number(avant) === v) return 0; + } + + if (!appliquer) { + if (dernierBlancVu === confirmedAt) return 0; + dernierBlancVu = confirmedAt; + console.log(` [ordres, kill-switch OFF] appliquerait ${nom}=${valeur} ` + + `sur ${spec.entite} (actuellement ${avant})`); + return 0; + } + + try { + const r = await fetch(`${HA_URL}/api/services/${service}`, { + method: 'POST', + headers: { Authorization: `Bearer ${HA_TOKEN}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + signal: AbortSignal.timeout(8000), + }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + } catch (e) { + return journaliser(sql, 'intervention', + `Ordre Airtria NON APPLIQUE (${nom}=${valeur}) : ${e.message}`, + { origine: 'airtria', source_airtria: source, ordre: nom, valeur, + confirmed_at: confirmedAt, entite: spec.entite, echec: e.message }).then(() => 1); + } + + await journaliser(sql, 'intervention', + `Ordre Airtria applique : ${nom} = ${valeur} (etait ${avant})`, + { origine: 'airtria', source_airtria: source, ordre: nom, valeur, + confirmed_at: confirmedAt, entite: spec.entite, avant }); + return 1; +} + async function cycle(client) { // En blanc, on compte ce qu'on ECRIRAIT. Un essai a blanc qui renvoie // toujours zero ne verifie rien — c'est le piege classique du test qui ne @@ -1515,6 +1653,10 @@ async function cycle(client) { }) + await appliquerEtiquettesEnAttente(sql, client).catch((e) => { console.error(`etiquettes differees en echec : ${e.message}`); return 0; }), + // Guichet d'ordres : Airtria demande, la liste blanche d'ici decide. + ordre: await appliquerOrdreAirtria(sql, client, st).catch((e) => { + console.error(`ordre airtria en echec : ${e.message}`); return 0; + }), // Ingestion des relevés de cellule deposes par le poste au cable serie. // Hors essai a blanc : l'insertion est massive et idempotente, la simuler // n'apprendrait rien. @@ -1537,7 +1679,7 @@ async function cycle(client) { `${new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })} ` + `${DRY ? '[BLANC] ' : ''}mesures=${r.mesures} bilan=${r.bilan} ` + `decision=${r.decision} prevision=${r.prevision} session=${r.session} evenement=${r.evenement} ` + - `observation=${r.observation} etiquette=${r.etiquette} cellules=${r.cellules}` + `observation=${r.observation} etiquette=${r.etiquette} ordre=${r.ordre} cellules=${r.cellules}` ); } } @@ -1604,6 +1746,11 @@ console.log( ); if (ONCE) { + // ⚠️ La boucle appelle assurerConnexion() a chaque tour ; ce chemin-la ne le + // faisait pas, et une base absente au demarrage lui donnait client = null — + // donc un TypeError au lieu d'un message clair. Trouve en eprouvant le + // guichet d'ordres le 16/09, sur un simple mauvais numero de port. + if (!DRY) await assurerConnexion(); await cycle(client); if (!DRY) await client.end(); process.exit(0);