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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user