Plan du planificateur HC ; dialogue avec le conseiller dans HA ; arbitre encadre (question a choix fermes, liste blanche)

This commit is contained in:
Tallagrand Thierry
2026-10-08 07:26:14 +02:00
parent b25770736f
commit dea9c88530
16 changed files with 1114 additions and 3 deletions

View File

@@ -32,6 +32,7 @@ import pg from 'pg';
import { creerSchemaKm, ingererReleves, ingererNiveauSaisi, ingererJeep } from './kilometrage.mjs';
import { texteRapport, envoyer, donneesNuit, texteDepuis } from './rapport-nuit.mjs';
import { donneesRevue, constatsRevue } from './revue-jour.mjs';
import { detecterSituation, lireChoix, DUREE_MIN as ARBITRAGE_DUREE_MIN } from './arbitrage.mjs';
import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, minuitLocal, COMPTEURS_IMPORT, COMPTEUR_EDF, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit, vrmVersCourbes, nuitMaisonPrevue } from './diagnostics-agent.mjs';
import { transitionsDansSerie } from './allers-retours.mjs';
import { energieSur24h, energieDepuis, COMPTEUR_CHAUFFE_EAU } from './chauffe-eau.mjs';
@@ -1950,6 +1951,8 @@ const ORDRES = {
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 },
// 08/10 (proprietaire) : l'arbitre peut proposer le mode de nuit.
mode_nuit: { entite: 'input_select.victron_evcs_night_charge_mode', genre: 'option' },
};
async function appliquerOrdreAirtria(sql, client, st) {
@@ -2060,6 +2063,119 @@ async function appliquerOrdreAirtria(sql, client, st) {
return 1;
}
// ---------------------------------------------------------------------
// ARBITRE ENCADRE (08/10/2026, tools/arbitrage.mjs)
// ---------------------------------------------------------------------
// Situation incoherente tenue ARBITRAGE_DUREE_MIN -> question a choix fermes
// publiee dans sensor.evcs_arbitrage (OpenClaw l'envoie par WhatsApp). La
// reponse arrive dans input_text.evcs_arbitrage_reponse (JSON {id, choix,
// source, confirmed_at}) ; on n'applique QUE l'ordre attache au choix, pris dans
// ORDRES (liste blanche), d'une source humaine, si
// input_boolean.evcs_arbitrage_enabled est ON. Tout est historise (table
// `arbitrage`). Le guichet general (airtria_ordres_enabled) n'est pas concerne.
let arbitrageCandidat = null; // { cas, depuis }
let arbitrageSchema = false;
async function arbitrer(sql, client, st) {
if (!client) return 0;
if (!arbitrageSchema) {
await client.query(`CREATE TABLE IF NOT EXISTS arbitrage (
id text PRIMARY KEY, cas text, question text, choix jsonb, depuis timestamptz, ouvert timestamptz DEFAULT now(),
ferme timestamptz, reponse_cle text, reponse_source text, reponse_at timestamptz, ordre jsonb,
application text, issue text)`);
arbitrageSchema = true;
}
const maintenant = Date.now();
const hm = (id) => txt(st.get(id)?.state).slice(0, 5);
const minParis = (() => {
const p = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit', hour12: false }).formatToParts(new Date(maintenant));
return Number(p.find((x) => x.type === 'hour').value) % 24 * 60 + Number(p.find((x) => x.type === 'minute').value);
})();
const dans = (a, b) => { const m = (x) => { const [h, mi] = x.split(':').map(Number); return h * 60 + mi; };
if (!a || !b) return false; const A = m(a), B = m(b); return A <= B ? minParis >= A && minParis < B : minParis >= A || minParis < B; };
const nuit = [hm('input_datetime.victron_evcs_hc_night_start'), hm('input_datetime.victron_evcs_hc_night_stop')];
const midi = [hm('input_datetime.victron_evcs_hc_midday_start'), hm('input_datetime.victron_evcs_hc_midday_stop')];
const fenetre = dans(...nuit) ? 'nuit' : dans(...midi) ? 'midi' : null;
const sit = detecterSituation({
fenetre, finFenetre: fenetre === 'nuit' ? nuit[1] : fenetre === 'midi' ? midi[1] : null,
contacteurOn: txt(st.get('switch.edf_general_ok_switch')?.state) === 'on',
voitureBranchee: txt(st.get('binary_sensor.victron_evcs_vehicle_connected')?.state) === 'on',
borneW: Number(st.get('sensor.victron_evcs_ac_power')?.state),
sourceMode: txt(st.get('input_text.victron_evcs_source_mode')?.state),
etat: txt(st.get('input_text.victron_evcs_surplus_state')?.state),
raison: txt(st.get('input_text.victron_evcs_surplus_reason')?.state),
vehicule: txt(st.get('input_select.victron_evcs_vehicle')?.state),
modeNuit: txt(st.get('input_select.victron_evcs_night_charge_mode')?.state),
});
const { rows: [ouvert] } = await client.query(`SELECT * FROM arbitrage WHERE ferme IS NULL ORDER BY ouvert DESC LIMIT 1`);
const publier = (etat, a) => publierCapteur('sensor.evcs_arbitrage', etat, { friendly_name: 'Arbitrage demande au proprietaire', ...a });
let n = 0;
// Fermeture : la situation a disparu (resolue par le systeme ou par la reponse).
if (ouvert && (!sit || sit.cas !== ouvert.cas)) {
const issue = ouvert.reponse_cle ? 'resolu_apres_reponse' : 'resolu_sans_reponse';
await client.query(`UPDATE arbitrage SET ferme = now(), issue = $2 WHERE id = $1`, [ouvert.id, issue]);
await journaliser(sql, 'arbitrage', `Arbitrage clos (${issue}) : ${ouvert.cas}`, { id: ouvert.id, issue });
await publier('aucun', { dernier_id: ouvert.id, dernier_cas: ouvert.cas, derniere_issue: issue });
arbitrageCandidat = null;
return 1;
}
if (!sit) {
arbitrageCandidat = null;
if (txt(st.get('sensor.evcs_arbitrage')?.state) === '') await publier('aucun', {});
return 0;
}
if (!ouvert) {
if (!arbitrageCandidat || arbitrageCandidat.cas !== sit.cas) { arbitrageCandidat = { cas: sit.cas, depuis: maintenant }; return 0; }
if (maintenant - arbitrageCandidat.depuis < ARBITRAGE_DUREE_MIN * 60000) return 0;
const id = `${sit.cas}-${new Date(arbitrageCandidat.depuis).toISOString().slice(0, 16)}`;
await client.query(`INSERT INTO arbitrage (id, cas, question, choix, depuis) VALUES ($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING`,
[id, sit.cas, sit.question, JSON.stringify(sit.choix), new Date(arbitrageCandidat.depuis)]);
await journaliser(sql, 'arbitrage', `Arbitrage demande : ${sit.cas}`, { id, question: sit.question });
await publier('question', { id, cas: sit.cas, question: sit.question,
choix: sit.choix.map((c) => ({ cle: c.cle, libelle: c.libelle })),
depuis: new Date(arbitrageCandidat.depuis).toISOString(),
consigne: 'Repondre par le numero du choix. Seul l ordre attache au choix est applique.' });
return 1;
}
// Reponse du proprietaire, par le concierge.
if (!ouvert.reponse_cle) {
let rep = null;
try { rep = JSON.parse(txt(st.get('input_text.evcs_arbitrage_reponse')?.state) || 'null'); } catch { rep = null; }
if (rep && rep.id === ouvert.id) {
const choix = lireChoix(rep.choix, ouvert.choix || []);
const source = txt(rep.source);
let application;
if (!choix) application = `refuse : choix « ${txt(rep.choix)} » inconnu`;
else if (!CANAUX_HUMAINS.has(source)) application = `refuse : source « ${source || 'absente'} » non humaine`;
else if (!choix.ordre) application = 'aucun ordre (laisser en attente)';
else if (txt(st.get('input_boolean.evcs_arbitrage_enabled')?.state) !== 'on') application = 'non applique : input_boolean.evcs_arbitrage_enabled est OFF';
else {
const spec = ORDRES[choix.ordre.ordre];
const cible = spec && st.get(spec.entite);
const valeur = txt(choix.ordre.valeur);
if (!spec || !cible) application = `refuse : ordre ${choix.ordre.ordre} hors liste blanche`;
else if (spec.genre !== 'option' || !(cible.attributes?.options || []).includes(valeur)) application = `refuse : valeur ${valeur} invalide`;
else if (DRY) application = `essai a blanc : ${spec.entite} = ${valeur}`;
else {
const r = await fetch(`${HA_URL}/api/services/input_select/select_option`, { method: 'POST',
headers: { Authorization: `Bearer ${HA_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_id: spec.entite, option: valeur }), signal: AbortSignal.timeout(8000) });
application = r.ok ? `applique : ${spec.entite} = ${valeur} (etait ${txt(cible.state)})` : `echec HTTP ${r.status}`;
}
}
await client.query(`UPDATE arbitrage SET reponse_cle = $2, reponse_source = $3, reponse_at = now(), ordre = $4, application = $5 WHERE id = $1`,
[ouvert.id, choix ? choix.cle : txt(rep.choix), source, JSON.stringify(choix?.ordre || null), application]);
await journaliser(sql, 'arbitrage', `Arbitrage repondu (${choix ? choix.cle : '?'}) : ${application}`, { id: ouvert.id, reponse: rep, application });
await publier('repondu', { id: ouvert.id, cas: ouvert.cas, question: ouvert.question,
choix: (ouvert.choix || []).map((c) => ({ cle: c.cle, libelle: c.libelle })),
reponse: choix ? choix.libelle : txt(rep.choix), application });
n++;
}
}
return n;
}
// ---------------------------------------------------------------------
// ANOMALIE "branchee mais ne charge pas" (19/09/2026)
// ---------------------------------------------------------------------
@@ -2516,6 +2632,9 @@ async function cycle(client) {
rapport: DRY ? 0 : await envoyerRapportDuMatin(sql, client).catch((e) => {
console.error('rapport de nuit :', e.message); return 0;
}),
arbitrage: DRY ? 0 : await arbitrer(sql, client, st).catch((e) => {
console.error('arbitrage :', e.message); return 0;
}),
revue: DRY ? 0 : await revueDeLaJournee(sql, client, st).catch((e) => {
console.error('revue de la journee :', e.message); return 0;
}),