Guichet d'ordres : Airtria demande, ce service decide et applique

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
This commit is contained in:
Tallagrand Thierry
2026-09-16 09:20:37 +02:00
parent 7aadfde090
commit 54b0dd14b8
3 changed files with 218 additions and 1 deletions

View File

@@ -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:

54
packages/airtria_ordres.yaml Executable file
View File

@@ -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

View File

@@ -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);