Incident 27-28/09 : l'agent presentait le surplus solaire de demain comme de l'energie batterie et lisait une projection de jour du controleur la nuit. Ce capteur ne relaie que le bilan (meme arbitre que la decision de nuit) ; bilan > 30 min -> indisponible. Tests 65/65. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011uJ47NxgfpGnuuMr3f2n7Z
2211 lines
113 KiB
JavaScript
2211 lines
113 KiB
JavaScript
#!/usr/bin/env node
|
|
// =====================================================================
|
|
// Historisation EVCS — alimente PostgreSQL depuis Home Assistant.
|
|
// =====================================================================
|
|
//
|
|
// POURQUOI PAS DEPUIS n8n. C'était l'idée de départ (« n8n calcule déjà ce qui
|
|
// compte, l'écrire est une ligne de plus »). Mais tout ce que n8n calcule, il
|
|
// le PUBLIE déjà dans des helpers Home Assistant : SOC requis, heure de
|
|
// croisement, décisions, prévisions. Les lire ici donne la même information
|
|
// sans toucher aux trois workflows de production qu'on vient de stabiliser, et
|
|
// sans créer d'identifiant de connexion dans n8n.
|
|
//
|
|
// Ce service tourne sur 10.10.10.22, à côté de la base : si 10.10.10.17 tombe,
|
|
// il n'y a de toute façon plus rien à enregistrer, mais la mémoire déjà
|
|
// constituée, elle, survit.
|
|
//
|
|
// L'HORODATAGE EST CELUI DE HOME ASSISTANT, pas celui du sondage. Une valeur
|
|
// inchangée depuis le cycle précédent porte donc la même clé primaire et n'est
|
|
// pas réécrite (ON CONFLICT DO NOTHING) : on enregistre quand la grandeur a
|
|
// bougé, pas quand on a regardé.
|
|
//
|
|
// Usage :
|
|
// node tools/historise-evcs.mjs # boucle
|
|
// node tools/historise-evcs.mjs --once # un cycle puis sortie
|
|
// node tools/historise-evcs.mjs --once --dry-run # sans écrire
|
|
// =====================================================================
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import pg from 'pg';
|
|
import { texteRapport, envoyer, donneesNuit, texteDepuis } from './rapport-nuit.mjs';
|
|
import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, minuitLocal, COMPTEURS_IMPORT, COMPTEUR_EDF, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit } from './diagnostics-agent.mjs';
|
|
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const ONCE = process.argv.includes('--once');
|
|
const DRY = process.argv.includes('--dry-run');
|
|
|
|
const HA_URL = process.env.HA_URL || 'http://10.10.10.17:8123';
|
|
const INTERVAL_S = Number(process.env.HISTORISE_INTERVAL_S || 60);
|
|
|
|
function fromEnvFile(cle) {
|
|
const p = path.join(ROOT, '.env');
|
|
if (!fs.existsSync(p)) return null;
|
|
for (const l of fs.readFileSync(p, 'utf8').split('\n')) {
|
|
const m = l.match(new RegExp(`^\\s*${cle}\\s*=\\s*(.+?)\\s*$`));
|
|
if (m) return m[1].replace(/^["']|["']$/g, '');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const HA_TOKEN = process.env.HA_TOKEN || fromEnvFile('HA_TOKEN');
|
|
if (!HA_TOKEN) {
|
|
console.error('HA_TOKEN absent (ni variable d\'environnement, ni .env).');
|
|
process.exit(1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Grandeurs suivies. Élargir ici est sans risque : une clé nouvelle
|
|
// commence simplement à s'enregistrer. Retirer une clé n'efface rien.
|
|
// ---------------------------------------------------------------------
|
|
const MESURES = [
|
|
'sensor.victron_battery_soc',
|
|
'sensor.victron_battery_current',
|
|
'sensor.victron_battery_power',
|
|
'sensor.victron_battery_voltage',
|
|
'sensor.victron_pv_power_system',
|
|
'sensor.victron_consumption_power_l1',
|
|
'sensor.victron_grid_power_l1',
|
|
'sensor.victron_evcs_ac_power',
|
|
'sensor.victron_evcs_current',
|
|
'sensor.victron_vebus_dc_power_system',
|
|
'sensor.sun_solar_elevation',
|
|
'sensor.victron_max_charge_current_setting',
|
|
// Le plafond impose par le BMS lui-meme, a distinguer de la limite DVCC
|
|
// ci-dessus que l'Auto Charge Manager ecrit. C'est LUI qui s'effondre quand
|
|
// une cellule atteint 3550 mV, et il commande tout le parc.
|
|
'sensor.airtria_8_us2000c_maximum_allowed_charge_current',
|
|
'sensor.airtria_8_us2000c_maximum_allowed_charging_voltage',
|
|
'sensor.airtria_8_us2000c_maximum_allowed_discharge_current',
|
|
'sensor.victron_max_charge_voltage_setting',
|
|
'number.gx_device_ac_power_setpoint',
|
|
'input_number.evcs_balance_required_soc',
|
|
'input_number.evcs_balance_energy_to_cover_kwh',
|
|
'input_number.victron_evcs_night_target_soc',
|
|
// Compteurs d'energie cumules de l'import (26/09) : HANDOFF §76 notait leur
|
|
// absence, qui empechait tout calcul d'import par delta de compteur.
|
|
'sensor.airtria_mpii_v1_energy_from_ac_in_1_to_ac_out',
|
|
'sensor.airtria_mpii_v1_energy_from_ac_in_1_to_inverter',
|
|
'sensor.edf_general_ok_energie_totale',
|
|
];
|
|
|
|
// ---------------------------------------------------------------------
|
|
// EVENEMENTS DETECTES AUTOMATIQUEMENT
|
|
// ---------------------------------------------------------------------
|
|
// « Une courbe sans ses evenements ne s'interprete pas » : six mois plus tard,
|
|
// une discontinuite ne veut rien dire sans la ligne qui dit ce qui s'est passe
|
|
// ce jour-la. Le service voit passer les etats toutes les minutes — autant
|
|
// qu'il consigne lui-meme ce qu'il peut constater, plutot que d'attendre que
|
|
// quelqu'un y pense.
|
|
//
|
|
// On suit des etats DISCRETS dont chaque changement merite une ligne. Les
|
|
// grandeurs continues n'ont rien a faire ici (elles sont dans `mesure`), et les
|
|
// decisions du controleur non plus (elles sont dans `decision`) : un evenement
|
|
// qui double une autre table est du bruit.
|
|
const TRANSITIONS_SUIVIES = [
|
|
['sensor.airtria_8_us2000c_tension_de_cellule_elevee', 'batterie', 'Alarme tension de cellule'],
|
|
// Avec quatre modules le plafond de decharge tombe a 100 A, soit ~5,1 kW,
|
|
// quand la maison a deja pointe a 8 kW en cuisinant. Cette alarme dira si
|
|
// les pointes du soir touchent la limite.
|
|
['sensor.airtria_8_us2000c_high_discharge_current', 'batterie', 'Alarme courant de decharge'],
|
|
['sensor.airtria_8_us2000c_low_voltage', 'batterie', 'Alarme tension basse'],
|
|
['switch.edf_general_ok_switch', 'reseau', 'Contacteur EDF'],
|
|
['input_boolean.victron_evcs_night_grid_session_committed','reseau', 'Session reseau de nuit'],
|
|
['input_boolean.victron_evcs_night_grid_takeover_needed', 'reseau', 'Reprise reseau nuit'],
|
|
['input_boolean.victron_grid_hysteresis_latch', 'reseau', 'Verrou hysteresis'],
|
|
['binary_sensor.victron_evcs_vehicle_connected', 'vehicule', 'Vehicule branche'],
|
|
['input_select.victron_evcs_vehicle', 'intervention','Vehicule selectionne'],
|
|
['input_select.victron_evcs_strategy', 'intervention','Strategie'],
|
|
['sensor.gx_device_ess_batterylife_state', 'ess', 'Etat BatteryLife de l ESS'],
|
|
['input_text.victron_charge_mode', 'ess', 'Mode Auto Charge Manager'],
|
|
['input_boolean.evcs_balance_grid_needed', 'bilan', 'Reseau necessaire'],
|
|
['input_boolean.evcs_balance_storm_active', 'bilan', 'Vigilance orage'],
|
|
['input_boolean.evcs_balance_pv_censored', 'bilan', 'Production bridee'],
|
|
];
|
|
|
|
// Heure de demarrage de Home Assistant, sans capteur d'uptime — il n'y en a pas
|
|
// sur cette installation. Au redemarrage, HA restaure ses helpers et leur pose
|
|
// un `last_changed` neuf : le 04/09, 352 entites partageaient la seconde du
|
|
// redemarrage, dont 183 input_number. Le MODE des last_changed des
|
|
// input_number est donc l'heure de demarrage. Aucune entite codee en dur, et
|
|
// l'estimation se corrige d'elle-meme : entre deux redemarrages, seuls les
|
|
// quelques helpers reellement ecrits s'ecartent du mode.
|
|
function demarrageHaMs(st) {
|
|
const paquets = new Map();
|
|
for (const [id, e] of st) {
|
|
if (!id.startsWith('input_number.')) continue;
|
|
const t = Date.parse(e.last_changed);
|
|
if (!Number.isFinite(t)) continue;
|
|
const cle = Math.floor(t / 1000);
|
|
paquets.set(cle, (paquets.get(cle) || 0) + 1);
|
|
}
|
|
let meilleur = null, n = 0;
|
|
for (const [cle, c] of paquets) if (c > n) { n = c; meilleur = cle; }
|
|
// Moins de 10 helpers d'accord : signature trop faible pour affirmer quoi que
|
|
// ce soit. On ne devine pas.
|
|
return n >= 10 ? meilleur * 1000 : null;
|
|
}
|
|
|
|
async function journaliser(sql, genre, resume, detail) {
|
|
await sql(
|
|
`INSERT INTO evenement (genre, source, resume, detail) VALUES ($1, $2, $3, $4)`,
|
|
[genre, 'historisation', resume, JSON.stringify(detail || {})]
|
|
);
|
|
console.log(` evenement [${genre}] ${resume}`);
|
|
}
|
|
|
|
const num = (v) => {
|
|
const n = Number.parseFloat(v);
|
|
return Number.isFinite(n) ? n : null;
|
|
};
|
|
const txt = (s) => (s ?? '').toString().trim();
|
|
|
|
// ---------------------------------------------------------------------
|
|
// QUI A PROVOQUE CETTE TRANSITION (12/09/2026)
|
|
// ---------------------------------------------------------------------
|
|
// Le 12/09, le contacteur reseau a bascule six fois en dix minutes. Trouver le
|
|
// coupable a demande sept requetes enchainees — executions n8n des deux
|
|
// workflows, liste des ecrivains, puis automations.yaml — parce que la table
|
|
// des evenements disait QUE ca avait bascule, jamais PAR QUI.
|
|
//
|
|
// Home Assistant le sait pourtant : son logbook resout le contexte d un
|
|
// changement d etat et nomme l automatisation, le script ou l utilisateur qui
|
|
// l a declenche. Pour la coupure de 06:29:44 il rend :
|
|
// context_entity_id : automation.victron_grid_off_si_soc_35
|
|
// context_message : triggered by numeric state of sensor.victron_battery_soc
|
|
//
|
|
// Un appel par transition reelle — quelques-uns par jour, pas par cycle. En cas
|
|
// d echec on n invente rien : pas d auteur, et la ligne est ecrite quand meme.
|
|
// ---------------------------------------------------------------------
|
|
// QUI A PROVOQUE CETTE TRANSITION — reecrit le 14/09/2026
|
|
// ---------------------------------------------------------------------
|
|
// ⚠️ La version precedente filtrait sur `x.context_name`. CE CHAMP N EXISTE
|
|
// PAS dans le journal de bord de Home Assistant : la fonction rendait `null` a
|
|
// tous les coups et les 416 transitions journalisees portaient un auteur vide.
|
|
// Personne ne s en etait apercu parce qu un auteur absent ressemble a un auteur
|
|
// inconnaissable. Or le contexte EST la, sous d autres noms.
|
|
//
|
|
// Trois signatures, relevees sur des transitions dont je connaissais l auteur :
|
|
//
|
|
// automatisation / script ligne voisine dont l entity_id est `script.*` ou
|
|
// `automation.*` — elle se nomme elle-meme.
|
|
// service exterieur (n8n) `context_user_id` sur la ligne cible, et la rafale
|
|
// se CLOT par une ligne de NARRATION (un `message`
|
|
// sans `state`) qui porte le nom du workflow et sa
|
|
// raison : « Victron Auto Charge », « EVCS Bilan
|
|
// energetique (ombre) ».
|
|
// derivation ni l un ni l autre, mais `context_entity_id` : la
|
|
// valeur DECOULE d un autre capteur. Ce n est PAS un
|
|
// auteur et il ne faut pas l annoncer comme tel.
|
|
//
|
|
// ⚠️ ON BALAIE VERS L AVANT pour trouver la narration, jamais vers l arriere :
|
|
// un workflow ecrit ses helpers PUIS se journalise. Vers l arriere on
|
|
// ramasserait la narration du workflow PRECEDENT. Verifie sur le cas ou l Auto
|
|
// Charge Manager et le Water Heater Advisor ecrivent dans la MEME seconde :
|
|
// chaque helper revient bien a son propre workflow, 3 sur 3.
|
|
const NARRATION = (x) => x && x.message !== undefined && x.state === undefined;
|
|
const DELAI_NARRATION_MS = 1500;
|
|
|
|
async function auteurDeLaTransition(entite, horodatageHa) {
|
|
try {
|
|
const t = Date.parse(horodatageHa);
|
|
if (!Number.isFinite(t)) return null;
|
|
const debut = new Date(t - 5000).toISOString();
|
|
const fin = new Date(t + 5000).toISOString();
|
|
const r = await fetch(
|
|
`${HA_URL}/api/logbook/${debut}?end_time=${encodeURIComponent(fin)}&entity_id=${encodeURIComponent(entite)}`,
|
|
{ headers: { Authorization: `Bearer ${HA_TOKEN}` }, signal: AbortSignal.timeout(8000) },
|
|
);
|
|
if (!r.ok) return null;
|
|
const lignes = await r.json();
|
|
if (!Array.isArray(lignes)) return null;
|
|
|
|
const i = lignes.findIndex((x) => x.entity_id === entite && x.state !== undefined
|
|
&& Math.abs(Date.parse(x.when) - t) < 2000);
|
|
if (i < 0) return null;
|
|
const cible = lignes[i];
|
|
|
|
const acteur = lignes.find((x) => /^(automation|script)\./.test(String(x.entity_id || ''))
|
|
&& Math.abs(Date.parse(x.when) - t) < 2000);
|
|
if (acteur) {
|
|
return { auteur: acteur.entity_id, nom: acteur.name || acteur.entity_id,
|
|
type: acteur.entity_id.split('.')[0], declencheur: cible.context_service || null };
|
|
}
|
|
|
|
if (cible.context_user_id) {
|
|
const n = lignes.slice(i + 1).find(NARRATION);
|
|
if (n && Date.parse(n.when) - t < DELAI_NARRATION_MS) {
|
|
// `declencheur` porte la RAISON que le workflow a lui-meme publiee.
|
|
// C est ce que « expliquer les decisions » reclamait, et c etait deja la.
|
|
return { auteur: n.entity_id || n.name, nom: n.name, type: 'service',
|
|
declencheur: (n.message || '').slice(0, 200) };
|
|
}
|
|
return { auteur: cible.context_user_id, nom: 'service externe (non nomme)', type: 'service',
|
|
declencheur: cible.context_domain && cible.context_service
|
|
? `${cible.context_domain}.${cible.context_service}` : null };
|
|
}
|
|
|
|
if (cible.context_entity_id) {
|
|
return { auteur: cible.context_entity_id, nom: cible.context_entity_id,
|
|
type: 'derivation', declencheur: null };
|
|
}
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function etats() {
|
|
const r = await fetch(`${HA_URL}/api/states`, {
|
|
headers: { Authorization: `Bearer ${HA_TOKEN}` },
|
|
});
|
|
if (!r.ok) throw new Error(`HA ${r.status}`);
|
|
const tout = await r.json();
|
|
const m = new Map();
|
|
for (const s of tout) m.set(s.entity_id, s);
|
|
return m;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Écritures. Chacune est idempotente : rejouer un cycle n'invente rien.
|
|
// ---------------------------------------------------------------------
|
|
async function ecrireMesures(sql, st) {
|
|
let n = 0;
|
|
for (const id of MESURES) {
|
|
const e = st.get(id);
|
|
if (!e) continue;
|
|
const v = num(e.state);
|
|
if (v === null) continue; // 'unknown' / 'unavailable' : on ne devine pas
|
|
const { rowCount } = await sql(
|
|
`INSERT INTO mesure (ts, cle, valeur) VALUES ($1, $2, $3)
|
|
ON CONFLICT (cle, ts) DO NOTHING`,
|
|
[e.last_updated, id, v]
|
|
);
|
|
n += rowCount;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
async function ecrireBilan(sql, st) {
|
|
const marqueur = st.get('input_text.evcs_balance_last_run');
|
|
if (!marqueur || !txt(marqueur.state)) return 0;
|
|
const g = (id) => st.get(id);
|
|
const { rowCount } = await sql(
|
|
`INSERT INTO bilan (ts, statut, soc_pct, energie_a_couvrir_kwh, soc_requis_pct,
|
|
soc_requis_prudent_pct, heure_croisement, reseau_necessaire,
|
|
reseau_energie_kwh, manque_stockage_kwh, pv_censure,
|
|
motif_censure, orage_niveau, orage_reserve_kwh, brut)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
|
ON CONFLICT (ts) DO NOTHING`,
|
|
[
|
|
marqueur.last_updated,
|
|
txt(g('input_text.evcs_balance_status')?.state),
|
|
num(g('sensor.victron_battery_soc')?.state),
|
|
num(g('input_number.evcs_balance_energy_to_cover_kwh')?.state),
|
|
num(g('input_number.evcs_balance_required_soc')?.state),
|
|
num(g('input_number.evcs_balance_required_soc_prudent')?.state),
|
|
txt(g('input_text.evcs_balance_crossing_hour')?.state),
|
|
txt(g('input_boolean.evcs_balance_grid_needed')?.state) === 'on',
|
|
num(g('input_number.evcs_balance_grid_energy_needed_kwh')?.state),
|
|
num(g('input_number.evcs_balance_storage_shortfall_kwh')?.state),
|
|
txt(g('input_boolean.evcs_balance_pv_censored')?.state) === 'on',
|
|
txt(g('input_text.evcs_balance_censor_reason')?.state),
|
|
txt(g('input_text.evcs_balance_storm_level')?.state),
|
|
num(g('input_number.evcs_balance_storm_reserve_kwh')?.state),
|
|
JSON.stringify({
|
|
raison: txt(g('input_text.evcs_balance_reason')?.state),
|
|
fenetre_prochain_deficit: txt(g('input_text.evcs_balance_next_deficit_window')?.state),
|
|
soc_requis_prochain_deficit: num(g('input_number.evcs_balance_next_deficit_required_soc')?.state),
|
|
}),
|
|
]
|
|
);
|
|
return rowCount;
|
|
}
|
|
|
|
// Une observation par run du conseiller. La cle est l'horodatage de SON
|
|
// marqueur : rejouer un cycle n'invente rien, et un conseiller a l'arret
|
|
// n'ecrit pas la meme observation toutes les minutes.
|
|
async function ecrireObservation(sql, st) {
|
|
const marqueur = st.get('input_text.evcs_advisor_last_run');
|
|
if (!marqueur || !txt(marqueur.state)) return 0;
|
|
const g = (id) => txt(st.get(id)?.state);
|
|
// `_reason` porte « [NN%] la piste ». On separe pour que la confiance
|
|
// reste interrogeable sans avoir a decouper une chaine en SQL.
|
|
const reason = g('input_text.evcs_advisor_reason') || '';
|
|
const m = reason.match(/^\[(\d+)%\]\s*(.*)$/s);
|
|
const { rowCount } = await sql(
|
|
`INSERT INTO observation (ts, statut, gravite, constat, ce_qui_cloche,
|
|
piste, modele, soc_pct, brut)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
|
ON CONFLICT (ts) DO NOTHING`,
|
|
[
|
|
marqueur.last_updated,
|
|
g('input_text.evcs_advisor_status'),
|
|
g('input_text.evcs_advisor_gravite'),
|
|
g('input_text.evcs_advisor_constat'),
|
|
g('input_text.evcs_advisor_ce_qui_cloche'),
|
|
m ? m[2] : reason,
|
|
g('input_text.evcs_advisor_model'),
|
|
num(st.get('sensor.victron_battery_soc')?.state),
|
|
JSON.stringify({ confiance_pct: m ? Number(m[1]) : null, reason_brut: reason }),
|
|
]
|
|
);
|
|
return rowCount;
|
|
}
|
|
|
|
async function ecrireDecision(sql, st) {
|
|
const etat = st.get('input_text.victron_evcs_surplus_state');
|
|
if (!etat) return 0;
|
|
const g = (id) => st.get(id);
|
|
const { rowCount } = await sql(
|
|
`INSERT INTO decision (ts, profil, source_mode, etat, decision, charge_desiree,
|
|
courant_a, soc_pct, cible_matin_pct, takeover_reseau,
|
|
session_engagee, raison, brut)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
|
ON CONFLICT (ts) DO NOTHING`,
|
|
[
|
|
etat.last_updated,
|
|
txt(g('input_text.victron_evcs_operating_mode')?.state),
|
|
txt(g('input_text.victron_evcs_source_mode')?.state),
|
|
txt(etat.state),
|
|
txt(g('input_text.victron_evcs_surplus_current_decision')?.state),
|
|
num(g('sensor.victron_evcs_ac_power')?.state) > 0,
|
|
num(g('number.victron_evcs_set_current_command')?.state),
|
|
num(g('sensor.victron_battery_soc')?.state),
|
|
num(g('input_number.victron_evcs_night_target_soc')?.state),
|
|
txt(g('input_boolean.victron_evcs_night_grid_takeover_needed')?.state) === 'on',
|
|
txt(g('input_boolean.victron_evcs_night_grid_session_committed')?.state) === 'on',
|
|
txt(g('input_text.victron_evcs_surplus_reason')?.state),
|
|
JSON.stringify({
|
|
session_nuit: txt(g('input_boolean.victron_evcs_night_session_active')?.state),
|
|
edf: txt(g('switch.edf_general_ok_switch')?.state),
|
|
strategie: txt(g('input_select.victron_evcs_strategy')?.state),
|
|
vehicule: txt(g('input_select.victron_evcs_vehicle')?.state),
|
|
}),
|
|
]
|
|
);
|
|
return rowCount;
|
|
}
|
|
|
|
// La prévision n'a de valeur que datée de sa PUBLICATION : sans cela on ne peut
|
|
// pas mesurer son biais, qui est précisément la question ouverte (§42).
|
|
async function ecrirePrevision(sql, st) {
|
|
const marqueur = st.get('input_text.evcs_balance_forecast_updated');
|
|
if (!marqueur || !txt(marqueur.state)) return 0;
|
|
const publieA = marqueur.last_updated;
|
|
const auj = new Date(publieA);
|
|
let n = 0;
|
|
for (const [id, decalageJours] of [
|
|
['input_text.evcs_balance_forecast_today', 0],
|
|
['input_text.evcs_balance_forecast_tomorrow', 1],
|
|
]) {
|
|
const e = st.get(id);
|
|
if (!e) continue;
|
|
const v = txt(e.state).split(',').map(Number);
|
|
if (v.length !== 24 || v.some((x) => !Number.isFinite(x))) continue;
|
|
const jour = new Date(auj.getTime() + decalageJours * 86400000)
|
|
.toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }); // AAAA-MM-JJ
|
|
const { rowCount } = await sql(
|
|
`INSERT INTO prevision (publie_a, jour, total_kwh, horaire)
|
|
VALUES ($1,$2,$3,$4) ON CONFLICT (publie_a, jour) DO NOTHING`,
|
|
[publieA, jour, v.reduce((a, b) => a + b, 0), v]
|
|
);
|
|
n += rowCount;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
// Memoire entre cycles. Au PREMIER passage on enregistre l'etat sans rien
|
|
// journaliser : sinon chaque redemarrage du service produirait douze fausses
|
|
// transitions. Seul le demarrage du service lui-meme est consigne.
|
|
const memoire = { amorce: false, demarrageHa: null, valeurs: new Map() };
|
|
|
|
async function detecterEvenements(sql, st) {
|
|
if (!memoire.amorce) {
|
|
for (const [id] of TRANSITIONS_SUIVIES.map((t) => [t[0]])) {
|
|
const e = st.get(id);
|
|
if (e) memoire.valeurs.set(id, txt(e.state));
|
|
}
|
|
memoire.demarrageHa = demarrageHaMs(st);
|
|
memoire.amorce = true;
|
|
await journaliser(sql, 'systeme', 'Service d historisation demarre', {
|
|
suivis: TRANSITIONS_SUIVIES.length,
|
|
});
|
|
return 1;
|
|
}
|
|
|
|
let n = 0;
|
|
|
|
// Redemarrage de Home Assistant. Tolerance de 5 s : l'estimation par mode
|
|
// peut bouger d'une seconde sans qu'il se soit rien passe.
|
|
const demarrage = demarrageHaMs(st);
|
|
if (
|
|
demarrage !== null &&
|
|
memoire.demarrageHa !== null &&
|
|
demarrage > memoire.demarrageHa + 5000
|
|
) {
|
|
await journaliser(sql, 'systeme', 'Home Assistant a redemarre', {
|
|
demarre_a: new Date(demarrage).toISOString(),
|
|
precedent: new Date(memoire.demarrageHa).toISOString(),
|
|
});
|
|
n++;
|
|
}
|
|
if (demarrage !== null) memoire.demarrageHa = demarrage;
|
|
|
|
// Transitions suivies.
|
|
for (const [id, genre, libelle] of TRANSITIONS_SUIVIES) {
|
|
const e = st.get(id);
|
|
if (!e) continue;
|
|
const cur = txt(e.state);
|
|
// Une lecture indisponible n'est pas une transition : c'est une absence de
|
|
// lecture. La consigner ferait deux lignes de bruit a chaque hoquet MQTT.
|
|
if (cur === '' || cur === 'unavailable' || cur === 'unknown') continue;
|
|
const prec = memoire.valeurs.get(id);
|
|
if (prec === undefined) { memoire.valeurs.set(id, cur); continue; }
|
|
if (prec === cur) continue;
|
|
const qui = await auteurDeLaTransition(id, e.last_changed);
|
|
// L auteur est colle au RESUME, pas seulement au detail : c est le resume
|
|
// que Grafana affiche en annotation sur tous les tableaux et dans les
|
|
// panneaux « journal des evenements », et que le rapport de nuit reprend.
|
|
// Le mettre la le rend visible partout sans toucher a un seul tableau.
|
|
// Une derivation n'est pas un ecrivain : « par sensor.X » serait un
|
|
// contresens pour une valeur qui DECOULE d'un autre capteur.
|
|
const resume = `${libelle} : ${prec} -> ${cur}`
|
|
+ (qui ? (qui.type === 'derivation' ? ` — derive de ${qui.nom}` : ` — par ${qui.nom}`) : '');
|
|
await journaliser(sql, genre, resume, {
|
|
entite: id, avant: prec, apres: cur,
|
|
horodatage_ha: e.last_changed,
|
|
...(qui ? { auteur: qui.auteur, auteur_nom: qui.nom, auteur_type: qui.type, declencheur: qui.declencheur } : {}),
|
|
});
|
|
memoire.valeurs.set(id, cur);
|
|
n++;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Le service fait evoluer son propre schema. `schema.sql` n'est applique qu'a
|
|
// la CREATION de la base : une table ajoutee plus tard n'y arriverait jamais.
|
|
// `IF NOT EXISTS` rend l'operation rejouable a chaque demarrage.
|
|
// ---------------------------------------------------------------------
|
|
async function migrer(client) {
|
|
await client.query(`
|
|
CREATE TABLE IF NOT EXISTS session_charge (
|
|
debut timestamptz PRIMARY KEY,
|
|
fin timestamptz NOT NULL,
|
|
terminee boolean NOT NULL DEFAULT false,
|
|
vehicule text,
|
|
vehicule_sur boolean NOT NULL DEFAULT false,
|
|
energie_kwh double precision NOT NULL DEFAULT 0,
|
|
p_max_w double precision,
|
|
courant_max_a double precision,
|
|
consigne_max_a double precision,
|
|
source_mode text,
|
|
debranche_a timestamptz,
|
|
brut jsonb
|
|
)`);
|
|
// Part venue du reseau. On se fie au `source_mode` du controleur — son
|
|
// INTENTION, sans ambiguite — et non a la puissance reseau instantanee, qui
|
|
// peut alimenter la maison pendant que le soleil alimente la voiture.
|
|
await client.query(
|
|
'ALTER TABLE session_charge ADD COLUMN IF NOT EXISTS energie_reseau_kwh '
|
|
+ 'double precision NOT NULL DEFAULT 0');
|
|
// Le compteur de la BORNE, qui fait foi. Mon integration de la puissance
|
|
// est robuste mais ne couvre que ce que le service a vu : le 05/09, la
|
|
// session avait commence a 01:25 et le detecteur n'a compte qu'a partir de
|
|
// 03:16 — 0,75 kWh au lieu de 8. La borne, elle, compte depuis le debut.
|
|
await client.query(
|
|
'ALTER TABLE session_charge ADD COLUMN IF NOT EXISTS energie_station_kwh double precision');
|
|
// Le COMPTEUR CUMULATIF de la borne (`..._total_energy`, ~3 780 kWh au
|
|
// 06/09). Sa difference entre l'ouverture et la cloture donne l'energie de
|
|
// la session sans aucune ambiguite de semantique et sans erreur
|
|
// d'integration — contrairement a `..._last_session_energy`, dont on ne
|
|
// savait meme pas s'il decrivait la session en cours ou la precedente.
|
|
// Il ne couvre que ce que le service a vu ; `duree_borne_min`
|
|
// (`..._last_session_time`) dit si la borne, elle, en a vu davantage.
|
|
for (const col of ['compteur_debut_kwh', 'energie_compteur_kwh', 'duree_borne_min']) {
|
|
await client.query(
|
|
`ALTER TABLE session_charge ADD COLUMN IF NOT EXISTS ${col} double precision`);
|
|
}
|
|
// ---------------------------------------------------------------------
|
|
// `vehicule_sur` CHANGE DE SENS (14/09/2026)
|
|
// ---------------------------------------------------------------------
|
|
// Avant : « le selecteur n'a pas change PENDANT la session ». Une propriete
|
|
// qui ne prouvait rien — le selecteur est reste sur VW du 07/09 au 13/09 et
|
|
// onze sessions fausses portaient toutes `true`. Mesure faite depuis : sur
|
|
// douze branchements, le selecteur n'a ete regle au bon moment QU'UNE FOIS.
|
|
// Desormais : « un humain a confirme », et seul le concierge le pose.
|
|
await client.query(
|
|
'ALTER TABLE session_charge ALTER COLUMN vehicule_sur SET DEFAULT false');
|
|
// Rattrapage des lignes anterieures, une seule fois. Rien n'ayant ete
|
|
// confirme par le canal tracable, tout retombe a faux — SAUF les six lignes
|
|
// que le proprietaire a lui-meme arbitrees le 13/09 (cf. la reparation
|
|
// ci-dessus) : c'est une vraie confirmation humaine, seulement donnee de
|
|
// vive voix. La perdre serait jeter l'information qu'il a fournie.
|
|
// ⚠️ REJEU. La premiere version de ce bloc n'etait pas garde : a chaque
|
|
// redemarrage elle remettait les lignes anterieures a faux puis les six
|
|
// arbitrees a vrai. Le resultat restait JUSTE — l'operation est idempotente
|
|
// en sortie — mais elle ecrivait une ligne de journal trompeuse a chaque
|
|
// fois, quatre en une journee, qui serait apparue en annotation sur tous les
|
|
// tableaux. On garde donc sur la trace elle-meme : si elle existe, c'est
|
|
// fait.
|
|
const { rows: dejaFait } = await client.query(
|
|
`SELECT 1 FROM evenement WHERE resume LIKE 'Semantique de vehicule_sur%' LIMIT 1`);
|
|
const { rowCount: bascules } = dejaFait.length ? { rowCount: 0 } : await client.query(
|
|
`UPDATE session_charge SET vehicule_sur = false
|
|
WHERE vehicule_sur AND debut < '2026-09-14'::date`);
|
|
if (bascules) {
|
|
await client.query(
|
|
`UPDATE session_charge SET vehicule_sur = true WHERE debut = ANY($1::timestamptz[])`,
|
|
[['2026-09-09 15:55:15.423+02', '2026-09-09 19:35:51.789+02',
|
|
'2026-09-10 01:25:24.359+02', '2026-09-11 16:54:58.101+02',
|
|
'2026-09-12 11:42:56.901+02', '2026-09-12 11:55:54.493+02']]);
|
|
await journaliser(client.query.bind(client), 'vehicule',
|
|
`Semantique de vehicule_sur inversee : ${bascules} lignes repassees a « non confirme »`,
|
|
{ origine: 'proprietaire', motif: 'le drapeau dit desormais « un humain a confirme »',
|
|
confirmees_de_vive_voix: 6, date_arbitrage: '2026-09-13' });
|
|
}
|
|
// -------------------------------------------------------------------
|
|
// ETIQUETTE DE BRANCHEMENT (14/09/2026)
|
|
// -------------------------------------------------------------------
|
|
// Le concierge demandait « quel vehicule ? » a l ouverture d une SESSION de
|
|
// charge. Trop tard : le 14/09 a 19:55 le vehicule etait branche, le
|
|
// selecteur perime depuis six jours, et aucune question ne pouvait partir
|
|
// parce qu aucune session n existait — la borne ne tirait rien, il faisait
|
|
// nuit. La premiere session de la nuit serait donc nee mal etiquetee.
|
|
//
|
|
// On etiquette desormais le BRANCHEMENT, pas les sessions. C est d ailleurs
|
|
// l invariant du projet : un cable, un vehicule. L etiquette se pose des le
|
|
// branchement et s applique aux sessions AU FUR ET A MESURE qu elles
|
|
// s ouvrent — cette table est la memoire qui le permet.
|
|
await client.query(`
|
|
CREATE TABLE IF NOT EXISTS etiquette_branchement (
|
|
branchement timestamptz PRIMARY KEY,
|
|
label text NOT NULL,
|
|
source_airtria text NOT NULL,
|
|
confirmed_at text,
|
|
pose_a timestamptz NOT NULL DEFAULT now(),
|
|
sessions_vues integer NOT NULL DEFAULT 0
|
|
)`);
|
|
await client.query(
|
|
'CREATE INDEX IF NOT EXISTS session_charge_debut_idx ON session_charge (debut DESC)');
|
|
|
|
// -------------------------------------------------------------------
|
|
// REPARATION UNIQUE (06/09/2026) — sessions faussees par le GREATEST
|
|
// -------------------------------------------------------------------
|
|
// Chaque ligne est confrontee a l'integration de `victron_evcs_ac_power`
|
|
// relue dans InfluxDB sur la periode de charge REELLE (detection des
|
|
// periodes > 300 W separees par plus de dix minutes) :
|
|
//
|
|
// periode reelle energie reelle valeur en base ecart
|
|
// 05 01:26-03:55 8,27 kWh 9,400 14 % -> gardee
|
|
// 05 10:00-10:54 2,32 kWh 2,200 5 % -> gardee
|
|
// 05 11:11-12:38 3,37 kWh 5,400 60 % -> effacee
|
|
// 06 01:25-02:44 4,24 kWh 4,200 1 % -> gardee
|
|
// 06 13:32-13:42 0,34 kWh 4,200 1135 % -> corrigee
|
|
//
|
|
// La derniere est celle qui a produit le double comptage dans le journal :
|
|
// le total de la nuit, capture au premier echantillon et verrouille par le
|
|
// GREATEST. Le capteur de la borne lisait 0,3 kWh au moment de la reparation,
|
|
// et `last_session_time` 11 min — la duree exacte de cette session.
|
|
// On ne corrige QUE ce qu'on a verifie ; les trois lignes coherentes ne sont
|
|
// pas touchees, et celle a 60 % d'ecart est effacee plutot que devinee.
|
|
//
|
|
// ⚠️ `debut` porte les millisecondes : on cible une FENETRE D'UNE SECONDE,
|
|
// pas une egalite. Deux sessions ne peuvent pas s'ouvrir dans la meme
|
|
// seconde, et la condition sur la valeur attendue rend l'operation
|
|
// idempotente — rejouee, elle ne trouve plus rien.
|
|
const reparations = [
|
|
['2026-09-06 13:31:18+02', 0.3, 4.2],
|
|
['2026-09-05 11:11:22+02', null, 5.4],
|
|
];
|
|
for (const [debut, valeur, attendu] of reparations) {
|
|
await client.query(
|
|
`UPDATE session_charge SET energie_station_kwh = $2
|
|
WHERE debut >= $1::timestamptz AND debut < $1::timestamptz + interval '1 second'
|
|
AND energie_station_kwh = $3::float8`,
|
|
[debut, valeur, attendu]);
|
|
}
|
|
// Meme jour, meme fonction : l'heure de debranchement etait posee en bloc sur
|
|
// toutes les sessions non marquees des dix-huit dernieres heures. Le 05/09 a
|
|
// 15:02:16, la session de nuit de la JEEP a donc herite du debranchement de
|
|
// la VW. On ne sait pas quand la JEEP a ete debranchee : on efface plutot que
|
|
// d'inventer.
|
|
// Les deux sessions VW de la journee (10:00 et 11:11) appartiennent bien au
|
|
// meme branchement que ce debranchement de 15:02 : elles sont conservees.
|
|
await client.query(
|
|
`UPDATE session_charge SET debranche_a = NULL
|
|
WHERE debut >= '2026-09-05 02:56:13+02'::timestamptz
|
|
AND debut < '2026-09-05 02:56:14+02'::timestamptz
|
|
AND debranche_a >= '2026-09-05 15:02:16+02'::timestamptz
|
|
AND debranche_a < '2026-09-05 15:02:17+02'::timestamptz`);
|
|
|
|
// Tensions cellule par cellule des modules Pylontech. La source est la
|
|
// console serie du BMS, pas Home Assistant : aucune de ces valeurs n'est
|
|
// publiee cote Victron, qui n'expose que l'agregat du parc.
|
|
//
|
|
// L'identite d'un module est son CODE-BARRES, jamais son adresse : chaque
|
|
// chaine renumerote ses modules a partir de 1, donc il existe deux
|
|
// « module 1 » dans ce parc.
|
|
await client.query(`
|
|
CREATE TABLE IF NOT EXISTS cellule (
|
|
ts timestamptz NOT NULL,
|
|
module text NOT NULL,
|
|
cellule smallint NOT NULL,
|
|
mv integer NOT NULL,
|
|
soc smallint,
|
|
courant_ma integer,
|
|
etat text,
|
|
source text NOT NULL,
|
|
PRIMARY KEY (ts, module, cellule)
|
|
)`);
|
|
await client.query(
|
|
'CREATE INDEX IF NOT EXISTS cellule_module_ts_idx ON cellule (module, ts DESC)');
|
|
// Rang du module dans SA chaine AU MOMENT du releve. Enregistre par ligne et
|
|
// non dans une table de correspondance : chaque chaine renumerote a partir
|
|
// de 1, et un recablage du LVHUB peut tout renumeroter — la valeur figee
|
|
// mentirait sur l'historique. Absent des anciens releves => NULL.
|
|
await client.query('ALTER TABLE cellule ADD COLUMN IF NOT EXISTS rang smallint');
|
|
// Le PACK d'origine (26/09/2026), lu dans le nom du fichier (-pack1-, -pack2-).
|
|
// Depuis que deux lecteurs relevent deux chaines, le rang seul est ambigu :
|
|
// chaque chaine numerote ses modules a partir de 1. Repere affiche =
|
|
// pack x 10 + rang (11 = pack 1 module 1, 21 = pack 2 module 1), choix du
|
|
// proprietaire. NULL pour les releves anterieurs dont le pack n'est pas sur.
|
|
await client.query('ALTER TABLE cellule ADD COLUMN IF NOT EXISTS pack smallint');
|
|
|
|
// Ce que le CONSEILLER a observe. Ses helpers sont ecrases toutes les
|
|
// quinze minutes : sans cette table, un signalement grave emis a 3 h du
|
|
// matin a disparu au reveil. C'est precisement la trace que le proprietaire
|
|
// demandait — voir un comportement idiot ne sert a rien si personne ne peut
|
|
// le relire le lendemain.
|
|
await client.query(`
|
|
CREATE TABLE IF NOT EXISTS observation (
|
|
ts timestamptz PRIMARY KEY,
|
|
statut text,
|
|
gravite text,
|
|
constat text,
|
|
ce_qui_cloche text,
|
|
piste text,
|
|
modele text,
|
|
soc_pct double precision,
|
|
brut jsonb
|
|
)`);
|
|
// On interroge cette table par gravite bien plus souvent que par date :
|
|
// « montre-moi tous les signalements graves de la semaine ».
|
|
await client.query(
|
|
'CREATE INDEX IF NOT EXISTS observation_gravite_idx ON observation (gravite, ts DESC)');
|
|
|
|
// -------------------------------------------------------------------
|
|
// REPARATION UNIQUE (13/09/2026) — onze sessions etiquetees « VW » a tort
|
|
// -------------------------------------------------------------------
|
|
// `input_select.victron_evcs_vehicle` est reste sur VW du 07/09 20:49 au
|
|
// 13/09 : onze sessions consecutives ont herite de cette etiquette, toutes
|
|
// avec `vehicule_sur = true` — ce drapeau ne passe a faux que si le
|
|
// selecteur bouge PENDANT une session, donc un selecteur perime reste
|
|
// « sur ». C'est le defaut qui a motive le concierge vehicule.
|
|
//
|
|
// Le proprietaire a tranche, et son arbitrage repose sur un contexte
|
|
// qu'aucun capteur ne produit : sa femme (JEEP) etait en conges cette
|
|
// semaine, son fils (VW) en stage sans retour le midi.
|
|
//
|
|
// mer 09/09 15:55 6,61 kWh -> JEEP | un SEUL branchement, du mercredi
|
|
// mer 09/09 19:35 0,77 kWh -> JEEP | 15:55 au jeudi 09:05 : sa femme
|
|
// jeu 10/09 01:25 4,88 kWh -> JEEP | a branche la JEEP, et le soir son
|
|
// | fils n'a PAS debranche pour mettre
|
|
// | la VW a la place — il a roule a
|
|
// | l'essence le jeudi. Trois sessions,
|
|
// | un seul cable, un seul vehicule.
|
|
// ven 11/09 16:54 7,45 kWh -> MEGANE (voiture de service, voir ci-dessous)
|
|
// sam 12/09 11:42 0,09 kWh -> JEEP (samedi midi, routine du week-end)
|
|
// sam 12/09 11:55 5,94 kWh -> JEEP
|
|
//
|
|
// ⚠️ Le triplet du mercredi a d'abord ete corrige a MOITIE — 15:55 en JEEP,
|
|
// les deux autres laissees en VW — ce qui etait physiquement impossible : il
|
|
// n'y a qu'un point de charge. C'est le journal `evenement` qui l'a montre,
|
|
// en enregistrant les transitions de `binary_sensor.victron_evcs_vehicle_connected` :
|
|
// aucun debranchement entre 15:55 et le lendemain 09:05. La frontiere d'un
|
|
// branchement est un FAIT observe, jamais un ecart de temps — les creux
|
|
// soir/heures creuses font 5 h 20 et ne se distinguent pas d'une nuit entiere.
|
|
//
|
|
// La session du 11/09 n'est NI la VW NI la JEEP : 6 360 W a 28 A, quand les
|
|
// deux vehicules du foyer plafonnent vers 3 680 W. C'est une Megane
|
|
// electrique de service. Un vehicule invite se reconnait donc a sa
|
|
// puissance, contrairement a la VW et a la JEEP entre elles.
|
|
//
|
|
// Les sept autres lignes etaient justes — par chance, la VW etant le
|
|
// vehicule qui charge le plus souvent. Elles ne sont pas touchees.
|
|
//
|
|
// ⚠️ `debut` porte les millisecondes : fenetre d'une seconde, jamais une
|
|
// egalite. La condition sur la valeur attendue rend l'operation idempotente.
|
|
for (const [debut, etait, devient] of [
|
|
['2026-09-09 15:55:00+02', 'VW', 'JEEP'],
|
|
['2026-09-09 19:35:00+02', 'VW', 'JEEP'],
|
|
['2026-09-10 01:25:00+02', 'VW', 'JEEP'],
|
|
['2026-09-11 16:54:00+02', 'VW', 'MEGANE'],
|
|
['2026-09-12 11:42:00+02', 'VW', 'JEEP'],
|
|
['2026-09-12 11:55:00+02', 'VW', 'JEEP'],
|
|
]) {
|
|
await client.query(
|
|
`UPDATE session_charge SET vehicule = $3
|
|
WHERE debut >= $1::timestamptz AND debut < $1::timestamptz + interval '1 minute'
|
|
AND vehicule = $2`,
|
|
[debut, etait, devient]);
|
|
}
|
|
// Le role de lecture doit voir la nouvelle table. S'il n'existe pas encore,
|
|
// on ne fait pas echouer le demarrage pour autant.
|
|
await client.query(`DO $$ BEGIN
|
|
GRANT SELECT ON session_charge, cellule, observation TO evcs_lecture;
|
|
EXCEPTION WHEN undefined_object THEN NULL; END $$`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Sessions de charge.
|
|
//
|
|
// Pourquoi une table plutot qu'un calcul a la demande : l'etiquette du
|
|
// vehicule n'existe QUE pendant la session. `input_select.victron_evcs_vehicle`
|
|
// est un reglage manuel, il change, et rien ne conserve sa valeur au moment ou
|
|
// la charge a eu lieu. Sans cet enregistrement, une session d'il y a trois
|
|
// semaines est anonyme pour toujours.
|
|
//
|
|
// ⚠️ On n'essaie PAS de deviner le vehicule. Mesure du 05/09 : a consigne
|
|
// identique de 20 A, la VW accepte 3 680 W et la JEEP 3 655 W — 0,1 A d'ecart.
|
|
// Ils sont electriquement indiscernables. Et l'heure ne dit rien non plus : en
|
|
// semaine la JEEP charge le midi et la VW la nuit, mais le week-end c'est
|
|
// l'inverse. On enregistre ce que le selecteur disait, et on note s'il a change
|
|
// en cours de route.
|
|
// ---------------------------------------------------------------------
|
|
const SEUIL_CHARGE_W = 300; // au-dela, la voiture prend vraiment du courant
|
|
const FIN_APRES_MIN = 10; // sous le seuil pendant ce temps : session close
|
|
|
|
// "1h 49m 53s" / "8m 27s" / "0s" -> secondes. Meme lecture que INPUT_PARSE.
|
|
// `sensor.victron_evcs_charging_time` repart de zero a CHAQUE nouvelle
|
|
// connexion physique (c'est le signal du reset P11) et se fige a la valeur du
|
|
// debranchement : il donne donc la duree exacte du branchement qui s'acheve.
|
|
function dureeBranchementSec(s) {
|
|
const t = (s ?? '').toString().toLowerCase();
|
|
if (!t) return null;
|
|
let sec = 0, vu = false;
|
|
const h = t.match(/(\d+)\s*h/); if (h) { sec += Number(h[1]) * 3600; vu = true; }
|
|
const m = t.match(/(\d+)\s*m/); if (m) { sec += Number(m[1]) * 60; vu = true; }
|
|
const q = t.match(/(\d+)\s*s/); if (q) { sec += Number(q[1]); vu = true; }
|
|
return vu ? sec : null;
|
|
}
|
|
|
|
async function ecrireSession(sql, client, st) {
|
|
// En blanc la base n'est pas connectee : on lit du vide plutot que d'echouer.
|
|
// La logique se deroule alors comme si aucune session n'etait ouverte — ce
|
|
// qui suffit a verifier le chemin d'ouverture, pas celui de cloture.
|
|
const lire = DRY ? async () => ({ rows: [], rowCount: 0 }) : (q, v) => client.query(q, v);
|
|
const g = (id) => st.get(id);
|
|
const p = num(g('sensor.victron_evcs_ac_power')?.state) ?? 0;
|
|
const connecte = txt(g('binary_sensor.victron_evcs_vehicle_connected')?.state) === 'on';
|
|
const vehicule = txt(g('input_select.victron_evcs_vehicle')?.state) || null;
|
|
const maintenant = new Date();
|
|
let n = 0;
|
|
|
|
const { rows } = await lire(
|
|
'SELECT * FROM session_charge WHERE NOT terminee ORDER BY debut DESC LIMIT 1');
|
|
const ouverte = rows[0] ?? null;
|
|
|
|
if (p > SEUIL_CHARGE_W) {
|
|
if (!ouverte) {
|
|
await sql(
|
|
`INSERT INTO session_charge (debut, fin, vehicule, energie_kwh, p_max_w,
|
|
courant_max_a, consigne_max_a, source_mode, brut,
|
|
compteur_debut_kwh)
|
|
VALUES ($1,$1,$2,0,$3,$4,$5,$6,$7,$8) ON CONFLICT (debut) DO NOTHING`,
|
|
[maintenant, vehicule, p,
|
|
num(g('sensor.victron_evcs_current')?.state),
|
|
num(g('sensor.victron_evcs_set_current')?.state),
|
|
txt(g('input_text.victron_evcs_source_mode')?.state) || null,
|
|
JSON.stringify({ profil: txt(g('input_text.victron_evcs_operating_mode')?.state) }),
|
|
num(g('sensor.evcs_hq2239dnt7p_total_energy')?.state)]);
|
|
n++;
|
|
} else {
|
|
// Le pas de temps est plafonne : un arret du service ne doit pas
|
|
// extrapoler la derniere puissance connue sur plusieurs heures.
|
|
const dt = Math.min((maintenant - new Date(ouverte.fin)) / 1000, 300);
|
|
await sql(
|
|
`UPDATE session_charge SET
|
|
fin = $2,
|
|
-- Transtypages EXPLICITES, sur TOUS les parametres.
|
|
-- node-postgres envoie ses parametres sans type ; multiplier deux
|
|
-- d'entre eux fait echouer PostgreSQL sur « operator is not unique:
|
|
-- unknown * unknown ». Les DEUX lignes en souffraient — la premiere
|
|
-- version de ce code n'a jamais reussi un seul UPDATE, ce qui ne
|
|
-- s'est vu qu'au journal du conteneur : l'essai a blanc court-circuite
|
|
-- l'envoi a la base et ne peut donc rien valider de ce genre.
|
|
-- REGLE : tout parametre entrant dans un calcul porte son ::float8.
|
|
energie_kwh = energie_kwh + $3::float8 * $4::float8 / 3600000.0,
|
|
energie_reseau_kwh = energie_reseau_kwh
|
|
+ $8::float8 * $3::float8 * $4::float8 / 3600000.0,
|
|
p_max_w = GREATEST(coalesce(p_max_w, 0), $3),
|
|
courant_max_a = GREATEST(coalesce(courant_max_a, 0), coalesce($5, 0)),
|
|
consigne_max_a = GREATEST(coalesce(consigne_max_a, 0), coalesce($6, 0)),
|
|
-- vehicule_sur n'est PLUS touche ici, et la virgule de la ligne
|
|
-- precedente a disparu avec lui. Le drapeau ne dit plus « le
|
|
-- selecteur n'a pas bouge » — une propriete qui ne prouvait rien,
|
|
-- un selecteur perime depuis six jours restant « sur » — mais
|
|
-- « UN HUMAIN A CONFIRME ». Seule une reponse du proprietaire,
|
|
-- via le concierge, peut le mettre a vrai.
|
|
-- ⚠️ ET IL NE DOIT PAS ECRASER UNE CONFIRMATION HUMAINE.
|
|
-- Defaut mesure le 16/09 : cette ligne reecrivait le vehicule depuis
|
|
-- le selecteur A CHAQUE CYCLE d'une session ouverte, pendant que
|
|
-- appliquerEtiquettesEnAttente remettait l'etiquette du concierge
|
|
-- juste apres. Deux ecrivains sur la meme colonne, toutes les 60 s,
|
|
-- et une ligne de journal a chaque passe : une soixantaine le 15/09.
|
|
-- Le drapeau vehicule_sur dit « un humain a confirme » : quand il
|
|
-- est vrai, le selecteur n'a plus voix au chapitre.
|
|
vehicule = CASE WHEN vehicule_sur THEN vehicule
|
|
ELSE coalesce($7, vehicule) END
|
|
WHERE debut = $1`,
|
|
[ouverte.debut, maintenant, p, dt,
|
|
num(g('sensor.victron_evcs_current')?.state),
|
|
num(g('sensor.victron_evcs_set_current')?.state), vehicule,
|
|
txt(g('input_text.victron_evcs_source_mode')?.state).startsWith('Réseau') ? 1 : 0]);
|
|
n++;
|
|
}
|
|
} else if (ouverte) {
|
|
const silence = (maintenant - new Date(ouverte.fin)) / 60000;
|
|
if (silence >= FIN_APRES_MIN || !connecte) {
|
|
// Le compteur de la borne se lit UNE FOIS, A LA CLOTURE.
|
|
//
|
|
// Il etait auparavant echantillonne a chaque cycle et conserve par un
|
|
// GREATEST : un operateur monotone, qui ne peut donc que SUREVALUER, et
|
|
// qui verrouillait la premiere valeur vue. Le 06/09 la session de midi
|
|
// (0,34 kWh reels, 0,324 integres) s'est refermee sur 4,20 kWh — le
|
|
// total de la nuit precedente, capture au premier echantillon et jamais
|
|
// relache. D'ou le meme chiffre annonce deux fois dans le journal.
|
|
//
|
|
// ⚠️ La semantique exacte de ce capteur n'est PAS etablie : il n'etait
|
|
// pas historise, donc on n'a aucune serie pour trancher entre « energie
|
|
// de la derniere session terminee » et « compteur de la session en cours
|
|
// qui se reinitialise avec du retard ». Confrontation aux integrations
|
|
// reelles des cinq sessions connues : ecarts de 1, 5, 14, 60 et 1135 %.
|
|
// Il est donc enregistre comme une OBSERVATION, jamais substitue en
|
|
// silence a notre integration. Le capteur vient d'etre ajoute a
|
|
// l'historisation InfluxDB pour pouvoir enfin l'etablir.
|
|
const station = num(g('sensor.evcs_hq2239dnt7p_last_session_energy')?.state);
|
|
const dureeBorne = num(g('sensor.evcs_hq2239dnt7p_last_session_time')?.state);
|
|
const compteur = num(g('sensor.evcs_hq2239dnt7p_total_energy')?.state);
|
|
// Le compteur de la borne a une resolution de 0,1 kWh. La soustraction
|
|
// de deux nombres proches de 3 800 en double precision rend
|
|
// « 0.8000000000001819 » : treize decimales de fausse precision sur une
|
|
// mesure qui n'en a qu'une. On arrondit a l'ECRITURE plutot que de
|
|
// laisser chaque lecteur le faire — signale par la session OpenClaw le
|
|
// 13/09 en lisant la table.
|
|
const parCompteur = compteur !== null && ouverte.compteur_debut_kwh !== null
|
|
&& compteur >= ouverte.compteur_debut_kwh
|
|
? Math.round((compteur - Number(ouverte.compteur_debut_kwh)) * 1000) / 1000 : null;
|
|
await sql(
|
|
`UPDATE session_charge SET terminee = true, energie_station_kwh = $2,
|
|
energie_compteur_kwh = $3, duree_borne_min = $4
|
|
WHERE debut = $1`,
|
|
[ouverte.debut, station, parCompteur, dureeBorne]);
|
|
|
|
// Le chiffre annonce est le NOTRE, celui qu'on sait defendre. Celui de la
|
|
// borne n'apparait que s'il s'en ecarte notablement — soit qu'elle ait vu
|
|
// un debut que le service a manque (le cas qui a motive cette colonne :
|
|
// 8 kWh reels pour 0,75 integres le 05/09), soit qu'elle raconte autre
|
|
// chose, et dans les deux cas ca se voit.
|
|
// Le chiffre annonce est, par ordre de preference : la difference du
|
|
// compteur cumulatif de la borne (exacte, sans integration), sinon notre
|
|
// integration de la puissance. Celui de `last_session_energy` n'apparait
|
|
// que s'il s'en ecarte notablement — soit que la borne ait vu un debut
|
|
// que le service a manque (le cas qui a motive cette colonne : 8 kWh
|
|
// reels pour 0,75 integres le 05/09), soit qu'elle raconte autre chose,
|
|
// et dans les deux cas ca se voit au lieu de se substituer en silence.
|
|
const retenu = parCompteur !== null ? parCompteur : Number(ouverte.energie_kwh);
|
|
const ecart = station !== null && retenu > 0
|
|
? Math.abs(station - retenu) / retenu : 0;
|
|
await journaliser(sql, 'vehicule',
|
|
`Session de charge terminee : ${ouverte.vehicule ?? 'vehicule inconnu'}, `
|
|
+ `${retenu.toFixed(2)} kWh`
|
|
+ (ecart > 0.10 ? ` (borne : ${station.toFixed(2)} kWh sur ${dureeBorne ?? '?'} min)` : ''),
|
|
{ debut: ouverte.debut, vehicule: ouverte.vehicule, kwh: retenu,
|
|
kwh_integre: Number(ouverte.energie_kwh), kwh_compteur: parCompteur,
|
|
kwh_borne: station, duree_borne_min: dureeBorne });
|
|
n += 2;
|
|
}
|
|
}
|
|
|
|
// L'heure de debranchement. Elle confirme le vehicule apres coup : les deux
|
|
// partent a des heures differentes.
|
|
//
|
|
// ⚠️ Ne marquer QUE les sessions du branchement qui vient de s'achever. La
|
|
// version d'origine marquait en bloc toutes les sessions non marquees des
|
|
// dix-huit dernieres heures : le 05/09 a 15:02, la session de nuit de la
|
|
// JEEP a herite de l'heure de debranchement de la VW — soit exactement
|
|
// l'inverse de ce que cette colonne est censee etablir. La duree du
|
|
// branchement borne la fenetre (5 min de marge pour le pas de mesure) ;
|
|
// duree illisible ou nulle => on ne marque que la session la plus recente,
|
|
// jamais celles d'avant.
|
|
if (!connecte) {
|
|
const dureeSec = dureeBranchementSec(txt(g('sensor.victron_evcs_charging_time')?.state));
|
|
const depuis = dureeSec !== null && dureeSec > 0
|
|
? new Date(maintenant.getTime() - dureeSec * 1000 - 5 * 60000)
|
|
: null;
|
|
const { rows: aMarquer } = await lire(
|
|
depuis
|
|
? `SELECT debut FROM session_charge
|
|
WHERE debranche_a IS NULL AND fin > now() - interval '18 hours' AND debut >= $1`
|
|
: `SELECT debut FROM session_charge
|
|
WHERE debranche_a IS NULL AND fin > now() - interval '18 hours'
|
|
ORDER BY debut DESC LIMIT 1`,
|
|
depuis ? [depuis] : []);
|
|
for (const r of aMarquer) {
|
|
await sql('UPDATE session_charge SET debranche_a = $2 WHERE debut = $1',
|
|
[r.debut, maintenant]);
|
|
n++;
|
|
}
|
|
}
|
|
return n;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Le rapport de nuit, envoye par courriel une fois par jour.
|
|
//
|
|
// Pourquoi ici plutot que dans un cron ou dans une session interactive : c'est
|
|
// ce service qui est allume en permanence. Une session Claude s'arrete des que
|
|
// l'ecran se ferme, et le planificateur qu'elle porte s'arrete avec elle ; un
|
|
// cron DSM tournerait sur un hote qui n'a meme pas node. Le service, lui, a
|
|
// deja la base, le depot et une boucle a la minute.
|
|
//
|
|
// L'idempotence ne s'appuie sur aucun fichier d'etat : elle se lit dans la
|
|
// table `evenement`, qui est de toute facon la trace que l'on veut garder.
|
|
// Redemarrer le conteneur ne renvoie donc pas le rapport.
|
|
// ---------------------------------------------------------------------
|
|
// 07:30, apres la fermeture de la fenetre HC (07:24) : l'import de la nuit est
|
|
// alors termine, donc le total du rapport est COMPLET. A 06:47 il manquait
|
|
// l'import 06:47->07:24 (proposition du proprietaire, 24/09). Reserve connue :
|
|
// si la cible du matin est trop basse, l'hysteresis peut refermer EDF apres
|
|
// 07:24 (cas plein tarif §39) et ce rapport le raterait — l'exception, pas la
|
|
// regle. La fenetre de calcul est de toute facon plafonnee a now() (voir
|
|
// rapport-nuit.mjs), donc avancer/reculer cette heure reste sur.
|
|
const RAPPORT_HEURE = 7;
|
|
const RAPPORT_MINUTE = 30;
|
|
|
|
async function envoyerRapportDuMatin(sql, client) {
|
|
if (!client) return 0;
|
|
const parts = Object.fromEntries(
|
|
new Intl.DateTimeFormat('fr-FR', {
|
|
timeZone: 'Europe/Paris', year: 'numeric', month: '2-digit', day: '2-digit',
|
|
hour: '2-digit', minute: '2-digit', hour12: false,
|
|
}).formatToParts(new Date()).filter((x) => x.type !== 'literal')
|
|
.map((x) => [x.type, Number(x.value)]));
|
|
|
|
const minutesDuJour = parts.hour * 60 + parts.minute;
|
|
if (minutesDuJour < RAPPORT_HEURE * 60 + RAPPORT_MINUTE) return 0;
|
|
// Fenetre de rattrapage : si le service etait arrete a 6 h 47, il envoie au
|
|
// premier cycle suivant, mais pas au milieu de l'apres-midi.
|
|
if (minutesDuJour > 10 * 60) return 0;
|
|
|
|
const jour = `${parts.year}-${String(parts.month).padStart(2, '0')}-${String(parts.day).padStart(2, '0')}`;
|
|
const { rowCount } = await client.query(
|
|
`SELECT 1 FROM evenement WHERE genre = 'rapport'
|
|
AND ts >= $1::date AND ts < $1::date + interval '1 day' LIMIT 1`, [jour]);
|
|
if (rowCount) return 0;
|
|
|
|
const texte = await texteRapport(client, new Date());
|
|
await envoyer(texte, 'EVCS - rapport de la nuit');
|
|
await journaliser(sql, 'rapport', 'Rapport de nuit envoye par courriel',
|
|
{ jour, caracteres: texte.length });
|
|
console.log(` rapport de nuit envoye (${texte.length} caracteres)`);
|
|
return 1;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Verse dans la base les relevés de cellule deposes par le poste qui porte le
|
|
// cable serie. Celui-ci n'ecrit qu'un CSV dans le depot partage : il n'a donc
|
|
// aucun mot de passe de base a connaitre, et le service d'ici, qui l'a deja,
|
|
// fait l'ingestion. `ON CONFLICT DO NOTHING` rend l'operation idempotente,
|
|
// donc relire le meme fichier a chaque cycle ne coute qu'une requete.
|
|
const DOSSIER_CELLULES = 'docs/mesures/batteries';
|
|
async function ingererCellules(client) {
|
|
let fichiers;
|
|
try { fichiers = fs.readdirSync(DOSSIER_CELLULES); } catch { return 0; }
|
|
let lignes = 0;
|
|
for (const n of fichiers) {
|
|
if (!n.endsWith('-suivi.csv')) continue;
|
|
const f = path.join(DOSSIER_CELLULES, n);
|
|
// Seuls les fichiers vivants : un releve arrete depuis plus d'une heure a
|
|
// deja ete verse, le relire serait du bruit.
|
|
if (Date.now() - fs.statSync(f).mtimeMs > 3600000) continue;
|
|
const l = fs.readFileSync(f, 'utf8').trim().split('\n');
|
|
const cols = l[0].split(',');
|
|
const iC = cols.indexOf('c1_mV');
|
|
if (iC < 0) continue;
|
|
// Le courant du module, ajoute au releve le 17/09/2026. Repere PAR SON NOM :
|
|
// les US5000 et les US2000C n'emettent pas les memes colonnes, et l'ancrer
|
|
// sur une position ferait lire la mauvaise grandeur des qu'on deplace le
|
|
// cable d'un pack a l'autre. Absent des anciens fichiers => null, donc les
|
|
// lignes deja versees gardent leur courant inconnu.
|
|
const iI = cols.indexOf('courant_mA');
|
|
// Le rang dans la chaine, ajoute au releve le 18/09/2026 — par son NOM,
|
|
// comme le courant : la position en fin de ligne n'est pas un contrat.
|
|
const iA = cols.indexOf('adresse');
|
|
const pk = n.match(/-pack(\d)-suivi\.csv$/);
|
|
const pack = pk ? parseInt(pk[1], 10) : null;
|
|
const val = [];
|
|
for (const ligne of l.slice(1)) {
|
|
const c = ligne.split(',');
|
|
if (c.length < iC + 15) continue;
|
|
if (!/^[A-Z]\d{6}[A-Z]\d+$/.test(c[1])) continue; // sans code-barres, on ne devine pas
|
|
// Signe : negatif en decharge, positif en charge — c'est ce qui permet de
|
|
// filtrer « pendant la charge » sans deviner.
|
|
const ma = iI >= 0 ? Number(c[iI]) : NaN;
|
|
const courant = Number.isFinite(ma) ? Math.round(ma) : null;
|
|
const ra = iA >= 0 ? parseInt(c[iA], 10) : NaN;
|
|
const rang = Number.isFinite(ra) && ra >= 1 && ra <= 16 ? ra : null;
|
|
for (let k = 0; k < 15; k++) {
|
|
const mv = Number(c[iC + k]);
|
|
if (!(mv > 1000)) continue;
|
|
val.push([c[0], c[1], k + 1, mv, null, courant, null, 'suivi', rang, pack]);
|
|
}
|
|
}
|
|
for (let i = 0; i < val.length; i += 500) {
|
|
const lot = val.slice(i, i + 500);
|
|
const ph = lot.map((_, k) => `(${Array.from({ length: 10 }, (_, j) => `$${k * 10 + j + 1}`).join(',')})`).join(',');
|
|
const r = await client.query(
|
|
`INSERT INTO cellule (ts, module, cellule, mv, soc, courant_ma, etat, source, rang, pack)
|
|
VALUES ${ph} ON CONFLICT (ts, module, cellule) DO NOTHING`, lot.flat());
|
|
lignes += r.rowCount;
|
|
}
|
|
}
|
|
return lignes;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// ETIQUETTE DE VEHICULE POSEE PAR LE CONCIERGE (13/09/2026)
|
|
// ---------------------------------------------------------------------
|
|
// Le selecteur `input_select.victron_evcs_vehicle` est un reglage manuel, donc
|
|
// oubliable : du 07/09 au 13/09 il est reste sur VW et onze sessions en ont
|
|
// herite, dont six etaient fausses. L'agent conversationnel d'Airtria demande
|
|
// par WhatsApp quel vehicule est branche et depose la reponse dans un helper ;
|
|
// ce lecteur l'applique. Contrat complet : HANDOFF §66.
|
|
//
|
|
// Airtria n'ecrit JAMAIS dans cette base — seulement le helper. La base reste
|
|
// la propriete de ce service.
|
|
//
|
|
// {"sessions":["2026-09-12 11:55:54.493+02", …], <- verbatim, ms comprises
|
|
// "label":"VW|JEEP|MEGANE|INVITE|ZERO",
|
|
// "confirmed_at":"2026-09-13T18:04:11+02:00",
|
|
// "source":"whatsapp"|"regle-puissance"}
|
|
//
|
|
// ⚠️ LA PROVENANCE DECIDE DE LA CERTITUDE. `whatsapp` est une confirmation
|
|
// humaine et vaut `vehicule_sur = true` ; `regle-puissance` est une DEDUCTION
|
|
// (un invite reconnu a sa puissance) et reste a false. Marquer « sur » une
|
|
// deduction reintroduirait exactement le defaut que ce chantier corrige.
|
|
//
|
|
// ⚠️ LA REGLE DE PUISSANCE EST A SENS UNIQUE : au-dessus de ~4 200 W avec une
|
|
// consigne >= 20 A elle conclut « ni VW ni JEEP », jamais l'inverse — les deux
|
|
// vehicules du foyer plafonnent a 3 680 et 3 655 W, vingt-cinq watts d'ecart.
|
|
// Un label household venu de ce chemin est donc un bug par construction : on
|
|
// REFUSE et on signale, on n'applique pas.
|
|
const HOUSEHOLD = new Set(['VW', 'JEEP']);
|
|
// Les canaux par lesquels un HUMAIN peut confirmer. Enumeres explicitement :
|
|
// un canal inconnu ne doit pas valoir confirmation par defaut. `regle-puissance`
|
|
// n'y est pas et n'y sera jamais — c'est une deduction, pas une parole.
|
|
const CANAUX_HUMAINS = new Set(['whatsapp', 'proprietaire']);
|
|
const LABELS = new Set(['VW', 'JEEP', 'MEGANE', 'INVITE', 'ZERO']);
|
|
let dernierBlancVu = null; // anti-repetition du journal en essai a blanc
|
|
|
|
// Detection d'anomalie "branchee mais ne charge pas" (19/09/2026). Etat
|
|
// d'episode EN PROCESSUS : instant ou la condition a commence a tenir, null
|
|
// sinon. Sur un redemarrage il repart de null — une anomalie reelle et
|
|
// persistante se re-arme en ANOMALIE_CHARGE_MIN. On garde aussi l'horodatage
|
|
// du dernier episode SIGNALE pour ne journaliser qu'une fois par episode.
|
|
let anomalieChargeDepuis = null;
|
|
let anomalieChargeSignalee = null;
|
|
|
|
// ---------------------------------------------------------------------
|
|
// LA REPONSE DU PROPRIETAIRE PILOTE LE SELECTEUR
|
|
// ---------------------------------------------------------------------
|
|
// Demande du proprietaire, 16/09/2026 : « si je dis JEEP il va rester sur VW,
|
|
// donc la solution c'est de le piloter quand je reponds ».
|
|
//
|
|
// Il a raison, et ce n'est pas cosmetique. La base n'est pas le seul lecteur du
|
|
// vehicule : le CONTROLEUR n8n lit input_select.victron_evcs_vehicle pour en
|
|
// deduire l'heure de depart (VW 08:50, JEEP 06:30), donc minutesUntilDeparture,
|
|
// donc batteryRecoveryUrgent — qui coupe P12 et ferme EDF trente minutes avant
|
|
// le depart suppose. Un selecteur perime ne fausse pas une etiquette, il change
|
|
// une decision d'energie. Mesure la nuit du 16/09 : selecteur reste sur JEEP
|
|
// alors que la VW etait branchee et confirmee par WhatsApp => EDF se serait
|
|
// ferme a 06:00 pour rien.
|
|
//
|
|
// ⚠️ UNE SEULE ECRITURE PAR REPONSE, jamais un reassert cyclique. La fonction
|
|
// appelante est idempotente par confirmed_at : elle ne repasse pas ici tant que
|
|
// le proprietaire n'a pas repondu autre chose. C'est ce qui laisse le dernier
|
|
// mot a l'humain, quel que soit le canal — s'il change le selecteur a la main
|
|
// apres avoir repondu, on ne le contredit pas.
|
|
//
|
|
// ⚠️ Et seulement pour les labels qui SONT des options du selecteur. MEGANE,
|
|
// INVITE ou ZERO n'y figurent pas : ecrire une option inexistante echouerait, et
|
|
// surtout la notion d'heure de depart n'a aucun sens pour un vehicule de
|
|
// passage. Dans ce cas on ne touche a rien et le controleur garde son reglage.
|
|
async function piloterSelecteurVehicule(sql, st, label, source) {
|
|
const actuel = txt(st.get('input_select.victron_evcs_vehicle')?.state);
|
|
const options = st.get('input_select.victron_evcs_vehicle')?.attributes?.options || [];
|
|
if (!options.includes(label)) return 0;
|
|
if (actuel === label) return 0;
|
|
// ⚠️ L'essai a blanc ne doit RIEN ecrire, et cette fonction est la premiere du
|
|
// service a toucher un actionneur du controleur. Sans cette garde, un
|
|
// --dry-run aurait pilote le selecteur pour de vrai : trouve en relisant la
|
|
// sortie du premier essai, ou seule l'egalite fortuite actuel === label a
|
|
// evite l'ecriture.
|
|
if (DRY) {
|
|
console.log(` [blanc] piloterait le selecteur ${actuel || 'inconnu'} -> ${label}`);
|
|
return 0;
|
|
}
|
|
try {
|
|
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: 'input_select.victron_evcs_vehicle', option: label }),
|
|
signal: AbortSignal.timeout(8000),
|
|
});
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
} catch (e) {
|
|
await journaliser(sql, 'vehicule',
|
|
`Selecteur vehicule NON pilote (${actuel || 'inconnu'} -> ${label}) : ${e.message}`,
|
|
{ origine: 'airtria', source_airtria: source, label, avant: actuel, echec: e.message });
|
|
return 1;
|
|
}
|
|
await journaliser(sql, 'vehicule',
|
|
`Selecteur vehicule pilote par le concierge : ${actuel || 'inconnu'} -> ${label}`,
|
|
{ origine: 'airtria', source_airtria: source, label, avant: actuel,
|
|
entite: 'input_select.victron_evcs_vehicle',
|
|
motif: 'la reponse du proprietaire fait foi, et le controleur lit le selecteur' });
|
|
return 1;
|
|
}
|
|
|
|
async function appliquerEtiquetteConcierge(sql, client, st) {
|
|
const brut = txt(st.get('input_text.airtria_vehicle_label')?.state);
|
|
if (!brut || brut === 'unknown' || brut === 'unavailable') return 0;
|
|
|
|
let ordre;
|
|
try { ordre = JSON.parse(brut); } catch {
|
|
return journaliser(sql, 'vehicule', 'Etiquette concierge illisible (JSON invalide)',
|
|
{ origine: 'airtria', brut: brut.slice(0, 200) }).then(() => 1);
|
|
}
|
|
const label = txt(ordre.label).toUpperCase();
|
|
const source = txt(ordre.source);
|
|
const confirmedAt = txt(ordre.confirmed_at);
|
|
const demandees = Array.isArray(ordre.sessions) ? ordre.sessions.filter(Boolean) : [];
|
|
// Nouveau contrat : Airtria peut designer le BRANCHEMENT plutot que des
|
|
// sessions, ce qui lui permet de poser sa question des le branchement, avant
|
|
// qu une seule session existe. L ancien contrat reste accepte.
|
|
const brancheDemande = txt(ordre.branchement) || null;
|
|
if (!label || (!demandees.length && !brancheDemande)) return 0;
|
|
|
|
// Le kill-switch gate l'ECRITURE, pas la lecture : tant qu'il est a OFF on
|
|
// deroule tout le raisonnement et on journalise ce qu'on AURAIT ecrit. C'est
|
|
// ce qui permet d'eprouver la jointure sur de vraies clefs, pendant l'essai
|
|
// grandeur nature d'Airtria, avant que quoi que ce soit ne compte.
|
|
const appliquer = txt(st.get('input_boolean.airtria_concierge_enabled')?.state) === 'on';
|
|
const lire = DRY ? async () => ({ rows: [] }) : (q, v) => client.query(q, v);
|
|
|
|
const refuser = async (motif, detail) => {
|
|
if (!appliquer && dernierBlancVu === confirmedAt + motif) return 0;
|
|
dernierBlancVu = confirmedAt + motif;
|
|
await journaliser(sql, 'vehicule',
|
|
`${appliquer ? 'Etiquette concierge REFUSEE' : '[blanc] refus'} : ${motif}`,
|
|
{ origine: 'airtria', source_airtria: source, label, confirmed_at: confirmedAt,
|
|
refus: motif, ...detail });
|
|
return 1;
|
|
};
|
|
|
|
if (!LABELS.has(label)) return refuser(`label inconnu « ${label} »`, {});
|
|
if (source === 'regle-puissance' && HOUSEHOLD.has(label)) {
|
|
return refuser(
|
|
`${label} ne peut pas venir de la regle de puissance, qui ne sait dire que « ni VW ni JEEP »`,
|
|
{ sessions: demandees });
|
|
}
|
|
|
|
// Deja pose : on ne rejoue pas. La clef est l'horodatage de confirmation,
|
|
// qu'Airtria ne reutilise jamais d'une reponse a l'autre.
|
|
if (confirmedAt) {
|
|
const { rows } = await lire(
|
|
`SELECT 1 FROM evenement WHERE genre = 'vehicule'
|
|
AND detail->>'origine' = 'airtria' AND detail->>'confirmed_at' = $1 LIMIT 1`,
|
|
[confirmedAt]);
|
|
if (rows.length) return 0;
|
|
}
|
|
|
|
// UN CABLE, UN VEHICULE. La frontiere d'un branchement est un FAIT observe —
|
|
// les transitions de `binary_sensor.victron_evcs_vehicle_connected` — et
|
|
// JAMAIS un ecart de temps : le creux entre la session du soir et celle des
|
|
// heures creuses fait plus de cinq heures et ne se distingue pas d'une nuit
|
|
// entiere. C'est ce journal qui a montre, le 13/09, qu'une correction du
|
|
// mercredi etait physiquement impossible (trois sessions, un seul cable).
|
|
// ⚠️ Les 2 secondes de tolerance sur la borne basse ne sont pas decoratives.
|
|
// Une session s'OUVRE avant que la transition de branchement soit
|
|
// journalisee, les deux etant ecrites dans le meme cycle et `ecrireSession`
|
|
// passant avant `detecterEvenements` : le 09/09, session a 15:55:15.423 et
|
|
// evenement « on » a 15:55:15.485, soit 62 ms plus tard. Sans cette marge,
|
|
// la session qui OUVRE un branchement est exclue de son propre groupe — ce
|
|
// que le premier essai a blanc a effectivement montre (2 sessions sur 3).
|
|
// ⚠️ NORMALISER AVANT DE DEDUPLIQUER. Airtria envoie sa clef telle que
|
|
// PostgreSQL l'affiche — « 2026-09-12 11:55:54.493+02 » — et la base me la
|
|
// rend en ISO — « 2026-09-12T09:55:54.493Z ». Meme instant, caracteres
|
|
// differents : un Set les compte DEUX FOIS. L'UPDATE resistait (les deux se
|
|
// transtypent en timestamptz) mais le journal annoncait « 2 sessions, dont 1
|
|
// ajoutee par le branchement » pour UNE seule session — et surtout le
|
|
// tableau `sessions` du detail portait le doublon, alors que c'est lui que
|
|
// le controle de contradiction interroge sur les etiquettes suivantes.
|
|
// Une clef illisible est gardee telle quelle : mieux vaut une clef brute
|
|
// qu'une clef inventee.
|
|
const enIso = (x) => { const d = new Date(x); return Number.isNaN(+d) ? String(x) : d.toISOString(); };
|
|
const cibles = new Set(demandees.map(enIso));
|
|
let frontiereConnue = true;
|
|
// Une clef de branchement se resout comme une clef de session : on remonte au
|
|
// dernier « on » qui la precede. Elle peut donc ne designer AUCUNE session —
|
|
// c est precisement le cas qu on veut couvrir.
|
|
for (const d of (brancheDemande ? [...demandees, brancheDemande] : demandees)) {
|
|
const { rows } = await lire(
|
|
`WITH bas AS (
|
|
SELECT max(ts) AS t FROM evenement
|
|
WHERE genre = 'vehicule'
|
|
AND detail->>'entite' = 'binary_sensor.victron_evcs_vehicle_connected'
|
|
AND detail->>'apres' = 'on' AND ts <= $1::timestamptz + interval '1 second'),
|
|
haut AS (
|
|
SELECT min(ts) AS t FROM evenement, bas
|
|
WHERE genre = 'vehicule'
|
|
AND detail->>'entite' = 'binary_sensor.victron_evcs_vehicle_connected'
|
|
AND detail->>'apres' = 'off' AND bas.t IS NOT NULL AND ts > bas.t)
|
|
SELECT s.debut, s.vehicule FROM session_charge s, bas, haut
|
|
WHERE bas.t IS NOT NULL AND s.debut >= bas.t - interval '2 seconds'
|
|
AND (haut.t IS NULL OR s.debut < haut.t) ORDER BY s.debut`,
|
|
[d]);
|
|
// Aucune borne : le journal ne remonte pas assez loin. On n'invente pas
|
|
// une frontiere qu'on n'a pas observee — on s'en tient a la liste recue.
|
|
// Aucune session dans ce branchement n est NORMAL quand l etiquette est
|
|
// posee au branchement : la voiture est branchee et n a pas encore charge.
|
|
// Ce n est une frontiere inconnue que si la clef venait d une session.
|
|
if (!rows.length) { if (d !== brancheDemande) frontiereConnue = false; continue; }
|
|
for (const r of rows) cibles.add(enIso(r.debut));
|
|
}
|
|
|
|
// Deux etiquettes DIFFERENTES sur le meme cable : soit une frontiere fausse,
|
|
// soit une ligne fausse. Dans les deux cas on ne tranche pas tout seul.
|
|
const { rows: deja } = await lire(
|
|
`SELECT DISTINCT e.detail->>'label' AS label FROM evenement e
|
|
WHERE e.genre = 'vehicule' AND e.detail->>'origine' = 'airtria'
|
|
AND e.detail ? 'sessions'
|
|
AND EXISTS (SELECT 1 FROM jsonb_array_elements_text(e.detail->'sessions') x
|
|
WHERE x.value = ANY($1::text[]))`,
|
|
[[...cibles]]);
|
|
const contradiction = deja.map((r) => r.label).filter((l) => l && l !== label);
|
|
if (contradiction.length) {
|
|
return refuser(
|
|
`le branchement porte deja l etiquette ${contradiction.join('/')}, incompatible avec ${label}`,
|
|
{ sessions: [...cibles], deja: contradiction });
|
|
}
|
|
|
|
const sur = CANAUX_HUMAINS.has(source);
|
|
const detail = {
|
|
origine: 'airtria', source_airtria: source, label, confirmed_at: confirmedAt,
|
|
sessions: [...cibles], vehicule_sur: sur,
|
|
recues: demandees.length, elargies: cibles.size - demandees.length,
|
|
...(frontiereConnue ? {} : { frontiere_inconnue: true }),
|
|
};
|
|
|
|
if (!appliquer) {
|
|
// Essai a blanc : on montre ce qu'on aurait fait, une seule fois par ordre.
|
|
if (dernierBlancVu === confirmedAt) return 0;
|
|
dernierBlancVu = confirmedAt;
|
|
console.log(` [concierge, kill-switch OFF] appliquerait ${label} `
|
|
+ `(${source}, sur=${sur}) a ${cibles.size} session(s)`
|
|
+ (detail.elargies ? `, dont ${detail.elargies} ajoutee(s) par le branchement` : '')
|
|
+ (frontiereConnue ? '' : ' — frontiere de branchement INCONNUE'));
|
|
return 0;
|
|
}
|
|
|
|
// On MEMORISE l etiquette au niveau du branchement. C est ce qui permet de
|
|
// repondre avant qu une session existe : les sessions qui s ouvriront ensuite
|
|
// la recevront au cycle suivant, sans nouvelle question.
|
|
const { rows: bornes } = await lire(
|
|
`SELECT max(ts) AS t FROM evenement
|
|
WHERE genre = 'vehicule'
|
|
AND detail->>'entite' = 'binary_sensor.victron_evcs_vehicle_connected'
|
|
AND detail->>'apres' = 'on'
|
|
AND ts <= $1::timestamptz + interval '1 second'`,
|
|
[brancheDemande || demandees[0]]);
|
|
const branchement = bornes[0]?.t || null;
|
|
if (branchement) {
|
|
await sql(
|
|
`INSERT INTO etiquette_branchement (branchement, label, source_airtria, confirmed_at)
|
|
VALUES ($1,$2,$3,$4)
|
|
ON CONFLICT (branchement) DO UPDATE
|
|
SET label = EXCLUDED.label, source_airtria = EXCLUDED.source_airtria,
|
|
confirmed_at = EXCLUDED.confirmed_at, pose_a = now()`,
|
|
[branchement, label, source, confirmedAt || null]);
|
|
}
|
|
|
|
// Le controleur lit le selecteur : la reponse doit y arriver, pas seulement
|
|
// dans la base. Une seule fois, ici, parce que cette fonction ne repasse que
|
|
// sur une nouvelle reponse.
|
|
const sel = appliquer ? await piloterSelecteurVehicule(sql, st, label, source) : 0;
|
|
|
|
const { rowCount } = cibles.size ? await sql(
|
|
`UPDATE session_charge SET vehicule = $2, vehicule_sur = $3
|
|
WHERE debut = ANY($1::timestamptz[]) AND (vehicule IS DISTINCT FROM $2 OR vehicule_sur IS DISTINCT FROM $3)`,
|
|
[[...cibles], label, sur]) : { rowCount: 0 };
|
|
await journaliser(sql, 'vehicule',
|
|
cibles.size
|
|
? `Etiquette ${label} appliquee par le concierge (${cibles.size} session${cibles.size > 1 ? 's' : ''}, ${source})`
|
|
: `Etiquette ${label} retenue pour le branchement en cours (aucune session encore, ${source})`,
|
|
{ ...detail, branchement, selecteur_pilote: !!sel });
|
|
return (rowCount || 0) + sel + 1;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// APPLIQUER LES ETIQUETTES MEMORISEES AUX SESSIONS QUI S OUVRENT
|
|
// ---------------------------------------------------------------------
|
|
// Second temps du concierge : l etiquette est posee AU BRANCHEMENT, les
|
|
// sessions arrivent apres. Cette passe les rattrape a chaque cycle, sans
|
|
// nouvelle question et sans intervention.
|
|
//
|
|
// ⚠️ Elle n applique qu aux sessions du branchement etiquete, bornees par le
|
|
// debranchement suivant — l invariant « un cable, un vehicule ». Les 2 secondes
|
|
// de tolerance sur la borne basse sont celles du 09/09 : une session s ouvre
|
|
// jusqu a 62 ms AVANT que sa transition de branchement soit journalisee.
|
|
async function appliquerEtiquettesEnAttente(sql, client) {
|
|
if (DRY) return 0;
|
|
const { rows } = await client.query(
|
|
`SELECT branchement, label, source_airtria FROM etiquette_branchement
|
|
WHERE branchement > now() - interval '48 hours' ORDER BY branchement`);
|
|
let n = 0;
|
|
for (const e of rows) {
|
|
const sur = CANAUX_HUMAINS.has(e.source_airtria);
|
|
const { rowCount } = await sql(
|
|
`UPDATE session_charge s SET vehicule = $2, vehicule_sur = $3
|
|
WHERE s.debut >= $1::timestamptz - interval '2 seconds'
|
|
AND s.debut < coalesce((
|
|
SELECT min(ts) FROM evenement
|
|
WHERE genre = 'vehicule'
|
|
AND detail->>'entite' = 'binary_sensor.victron_evcs_vehicle_connected'
|
|
AND detail->>'apres' = 'off' AND ts > $1::timestamptz), 'infinity'::timestamptz)
|
|
AND (s.vehicule IS DISTINCT FROM $2 OR s.vehicule_sur IS DISTINCT FROM $3)`,
|
|
[e.branchement, e.label, sur]);
|
|
if (rowCount) {
|
|
await journaliser(sql, 'vehicule',
|
|
`Etiquette ${e.label} posee sur ${rowCount} session(s) ouverte(s) depuis le branchement`,
|
|
{ origine: 'airtria', source_airtria: e.source_airtria, label: e.label,
|
|
branchement: e.branchement, applique_a: rowCount, differe: true });
|
|
await client.query(
|
|
'UPDATE etiquette_branchement SET sessions_vues = sessions_vues + $2 WHERE branchement = $1',
|
|
[e.branchement, rowCount]);
|
|
n += rowCount;
|
|
}
|
|
}
|
|
return n;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// PREVISION CONTRE REEL — la serie que Home Assistant ne peut pas produire
|
|
// ---------------------------------------------------------------------
|
|
// Les courbes de prevision vivent dans des `input_text` ECRASES a chaque
|
|
// publication : Home Assistant n'en garde aucun historique exploitable. La base,
|
|
// elle, a 13 jours de previsions horaires (table `prevision`) ET la production
|
|
// mesuree. C'est donc ici qu'on peut comparer.
|
|
//
|
|
// Le service pousse la serie dans un capteur HA cree par l'API, dont les
|
|
// ATTRIBUTS portent les tableaux — un `input_text` plafonne a 255 caracteres ne
|
|
// tiendrait pas 168 points. La carte ApexCharts les lit directement.
|
|
//
|
|
// ⚠️ C'est la PREMIERE ecriture de ce service vers Home Assistant : il a
|
|
// toujours ete lecteur. Le perimetre est volontairement minuscule — UN capteur,
|
|
// dans son propre espace de noms, aucun actionneur, et un capteur cree par API
|
|
// disparait au redemarrage de HA puis se recree au cycle suivant.
|
|
//
|
|
// ⚠️ ET LA REGLE DE LECTURE EST DANS LA DONNEE : une heure BRIDEE n'est pas
|
|
// comparable. Production sous la prevision peut vouloir dire mauvaise prevision
|
|
// OU batterie pleine, et seules les heures non bridees permettent de juger.
|
|
// D'ou la troisieme serie, sans laquelle la carte ferait accuser la meteo a tort.
|
|
//
|
|
// ⚠️ DEUX COURBES DE PREVISION, ET ELLES NE DISENT PAS LA MEME CHOSE. Pour un
|
|
// jour ECOULE, la seule reference honnete est ce qui avait ete annonce LE MATIN
|
|
// MEME : une prevision revisee a 18 h connait deja la journee, la comparer au
|
|
// reel ne mesure plus rien. Mais cette courbe-la est de l'HISTOIRE, et elle ne
|
|
// dit rien de ce qui vient. La prevision au sens propre — aujourd'hui jusqu'a
|
|
// ce soir, demain en entier — est la DERNIERE publiee, et c'est la serie
|
|
// `attendue`. Ne pas les fondre en une seule : elles n'ont ni la meme
|
|
// provenance ni le meme usage.
|
|
async function publierPrevisionContreReel(client) {
|
|
if (DRY) return 0;
|
|
const { rows } = await client.query(`
|
|
-- Ce qui etait ANNONCE LE MATIN pour un jour deja ecoule. C'est la seule
|
|
-- reference qui mesure la qualite d'une prevision : celle qui n'a pas ete
|
|
-- revisee depuis. Mais c'est de l'HISTOIRE, pas une prevision.
|
|
with matin as (
|
|
select distinct on (jour) jour, horaire from prevision
|
|
where jour between current_date - 6 and current_date
|
|
and (publie_a at time zone 'Europe/Paris')::time < time '10:00'
|
|
order by jour, publie_a desc),
|
|
-- Ce qui est annonce MAINTENANT pour les heures a venir : la prevision au
|
|
-- sens propre, aujourd'hui jusqu'a ce soir et demain en entier.
|
|
courante as (
|
|
select distinct on (jour) jour, horaire from prevision
|
|
where jour >= current_date
|
|
order by jour, publie_a desc),
|
|
maintenant as (select date_trunc('hour', now() at time zone 'Europe/Paris') h),
|
|
passee as (select m.jour + (u.i - 1) * interval '1 hour' as heure, u.v kwh
|
|
from matin m, unnest(m.horaire) with ordinality as u(v, i)),
|
|
futur as (select c.jour + (u.i - 1) * interval '1 hour' as heure, u.v kwh
|
|
from courante c, unnest(c.horaire) with ordinality as u(v, i)),
|
|
mn as (select generate_series((current_date - 6)::timestamptz,
|
|
date_trunc('minute', now()), interval '1 minute') t),
|
|
v as (select date_trunc('minute', ts) t, (array_agg(valeur order by ts desc))[1] v
|
|
from mesure where cle='sensor.victron_pv_power_system'
|
|
and ts >= current_date - 6 group by 1),
|
|
j as (select mn.t, v.v, count(v.v) over (order by mn.t) g
|
|
from mn left join v on v.t=mn.t),
|
|
r as (select t, first_value(v) over (partition by g order by t) w from j),
|
|
reelle as (select date_trunc('hour', t at time zone 'Europe/Paris') heure,
|
|
sum(w)/60/1000 kwh from r where w is not null group by 1),
|
|
censure as (select date_trunc('hour', ts at time zone 'Europe/Paris') heure,
|
|
avg(case when pv_censure then 1.0 else 0.0 end) taux
|
|
from bilan where ts >= current_date - 6 group by 1)
|
|
-- Les series passees s'arretent a l'heure en cours, les futures y
|
|
-- commencent : les deux courbes se TOUCHENT sur cette heure-la au lieu de
|
|
-- laisser un trou. Au-dela, "reel" et "bridage" valent NULL et non zero —
|
|
-- un zero dessinerait une chute de production qui n'a pas eu lieu.
|
|
select extract(epoch from h.heure at time zone 'Europe/Paris')*1000 as ms,
|
|
case when h.heure <= n.h then round(coalesce(p.kwh,0)::numeric,2)::float8 end as predite,
|
|
case when h.heure >= n.h then round(f.kwh::numeric,2)::float8 end as attendue,
|
|
case when h.heure <= n.h then round(coalesce(e.kwh,0)::numeric,2)::float8 end as reelle,
|
|
case when h.heure <= n.h then round(coalesce(c.taux,0)::numeric*100)::float8 end as bridage,
|
|
(h.heure < n.h) as clos,
|
|
(h.heure::date = (now() at time zone 'Europe/Paris')::date) as aujourdhui
|
|
from (select heure from passee union select heure from futur
|
|
union select heure from reelle) h
|
|
cross join maintenant n
|
|
left join passee p on p.heure=h.heure
|
|
left join futur f on f.heure=h.heure
|
|
left join reelle e on e.heure=h.heure
|
|
left join censure c on c.heure=h.heure
|
|
where h.heure >= current_date - 6
|
|
order by h.heure`);
|
|
|
|
// Ecart moyen sur les seules heures COMPARABLES : non bridees, et ou quelque
|
|
// chose etait attendu ou produit. C'est le seul chiffre qui juge la prevision.
|
|
//
|
|
// ⚠️ `clos` EXCLUT L'HEURE EN COURS. Elle n'est mesuree que pour les minutes
|
|
// deja ecoulees : a 14h05 son "reel" vaut cinq minutes de production contre
|
|
// une prevision d'heure pleine, soit un ecart de -92 % qui n'existe pas.
|
|
const comparables = rows.filter((x) => x.clos && x.bridage < 20
|
|
&& (x.predite > 0.2 || x.reelle > 0.2));
|
|
const sp = comparables.reduce((a, x) => a + x.predite, 0);
|
|
const sr = comparables.reduce((a, x) => a + x.reelle, 0);
|
|
const ecart = sp > 0 ? Math.round((sr / sp - 1) * 100) : null;
|
|
|
|
// Le partage aujourd'hui / demain se fait EN SQL, dans le fuseau deja utilise
|
|
// par tout le reste de la requete. Le refaire en JavaScript reviendrait a
|
|
// parier sur le fuseau du conteneur — qui est en UTC pour `date`, et ne doit
|
|
// donc pas servir d'arbitre.
|
|
const aVenir = rows.filter((x) => x.attendue !== null && x.attendue !== undefined);
|
|
const somme = (f) => Math.round(aVenir.filter(f).reduce((a, x) => a + x.attendue, 0) * 10) / 10;
|
|
const corps = {
|
|
state: ecart === null ? 'unknown' : String(ecart),
|
|
attributes: {
|
|
friendly_name: 'Prévision solaire — écart sur les heures comparables',
|
|
unit_of_measurement: '%',
|
|
icon: 'mdi:chart-timeline-variant',
|
|
heures_comparables: comparables.length,
|
|
heures_total: rows.filter((x) => x.clos).length,
|
|
reste_aujourdhui_kwh: somme((x) => x.aujourdhui),
|
|
annonce_demain_kwh: somme((x) => !x.aujourdhui),
|
|
predite: rows.map((x) => [Number(x.ms), x.predite]),
|
|
attendue: rows.map((x) => [Number(x.ms), x.attendue]),
|
|
reelle: rows.map((x) => [Number(x.ms), x.reelle]),
|
|
bridage: rows.map((x) => [Number(x.ms), x.bridage]),
|
|
},
|
|
};
|
|
const r = await fetch(`${HA_URL}/api/states/sensor.evcs_prevision_contre_reel`, {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${HA_TOKEN}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(corps),
|
|
signal: AbortSignal.timeout(10000),
|
|
});
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
return rows.length;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// BATTEMENT DE COEUR — la seule entite dont l immobilite soit interpretable
|
|
// ---------------------------------------------------------------------
|
|
// Defaut que ceci corrige, et il etait beant : un cycle en echec n ecrivait que
|
|
// sur console.error, c est-a-dire dans le journal du conteneur que personne ne
|
|
// lit. Et l incident n etait journalise qu au RETOUR a la normale, dans
|
|
// PostgreSQL — donc precisement la ou l on ne peut pas ecrire quand c est
|
|
// PostgreSQL qui est tombe. Base injoignable : chaque cycle echouait en silence
|
|
// indefiniment, et le rapport de 06:47 n arrivait jamais sans un mot.
|
|
//
|
|
// ⚠️ POURQUOI UN CAPTEUR DEDIE ET PAS LA FRAICHEUR D UNE ENTITE METIER.
|
|
// Mesure du 16/09 : Home Assistant ne materialise PAS une ecriture identique, et
|
|
// `last_reported` — le champ introduit en 2024 pour marquer chaque remontee
|
|
// d etat — est rigoureusement EGAL a `last_changed`. Aucun champ de l API ne
|
|
// distingue donc un ecrivain mort d un ecrivain qui republie la meme valeur.
|
|
// Sur une installation saine, `vehicle_connected` etait inchange depuis 752 min
|
|
// (voiture branchee la veille), `source_mode` depuis 139 min (voiture pleine) :
|
|
// trois faux positifs sur cinq entites si l on lit la fraicheur comme une panne.
|
|
// La vivacite se lit donc ICI et nulle part ailleurs, parce que l etat de ce
|
|
// capteur est un HORODATAGE — il change forcement a chaque ecriture.
|
|
//
|
|
// ⚠️ ET L APPEL SE FAIT HORS DU try/catch DU CYCLE. C est tout le correctif :
|
|
// si la base tombe, le battement part quand meme, en portant `degrade` et la
|
|
// cause. Un battement qui ne survit pas a la panne qu il doit signaler ne sert
|
|
// a rien.
|
|
//
|
|
// Forme figee avec la session OpenClaw le 16/09, pour que les deux battements
|
|
// du systeme soient identiques (le sien : sensor.concierge_heartbeat).
|
|
async function publierBattement({ statut, baseJoignable, cyclesEnEchec, dernierEchec }) {
|
|
const corps = {
|
|
state: new Date().toISOString(),
|
|
attributes: {
|
|
friendly_name: 'Historisation EVCS — battement de coeur',
|
|
icon: statut === 'ok' ? 'mdi:heart-pulse' : 'mdi:heart-broken',
|
|
statut,
|
|
base_joignable: baseJoignable,
|
|
cycles_en_echec: cyclesEnEchec,
|
|
dernier_echec: dernierEchec || '',
|
|
},
|
|
};
|
|
const r = await fetch(`${HA_URL}/api/states/sensor.evcs_historisation_heartbeat`, {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${HA_TOKEN}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(corps),
|
|
signal: AbortSignal.timeout(8000),
|
|
});
|
|
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;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// ANOMALIE "branchee mais ne charge pas" (19/09/2026)
|
|
// ---------------------------------------------------------------------
|
|
// Grief du proprietaire : la JEEP branchee, du soleil dispo, et rien qui
|
|
// charge (borne figee apres un swap trop rapide). Je DETECTE, la session
|
|
// OpenClaw (SPARK) ENVOIE l'alerte WhatsApp en lisant le helper.
|
|
//
|
|
// Critere, et pourquoi il ne se declenche PAS sur une voiture vraiment pleine :
|
|
// sans injection reseau, une batterie pleine fait BRIDER le MPPT (censure) donc
|
|
// PV ~ conso donc excedent ~ 0 -> pas d'alerte. Un excedent POSITIF qui PERSISTE
|
|
// avec voiture branchee et borne a ~0 W signifie qu'il manque un preneur a de
|
|
// l'energie gratuite : ca, ca vaut un signalement.
|
|
// - vehicule branche
|
|
// - borne ac_power < SEUIL_CHARGE_W (ne charge pas)
|
|
// - etat controleur bloquant (source_mode "Bloque" OU surplus_state d'attente)
|
|
// - excedent solaire (pv - conso) > ANOMALIE_CHARGE_SURPLUS_W
|
|
// - tenu >= ANOMALIE_CHARGE_MIN
|
|
// ⚠️ SEUILS A CALIBRER sur l'historique avant d'etre figes (lecon des
|
|
// oscillateurs) — valeurs de depart conservatrices. Un seul signalement par
|
|
// episode. Le helper reste INERTE tant que SPARK n'a pas cable l'envoi.
|
|
const ANOMALIE_CHARGE_MIN = 10; // minutes
|
|
const ANOMALIE_CHARGE_SURPLUS_W = 500; // excedent solaire minimal
|
|
const ETATS_BLOQUANTS = new Set([
|
|
'day_vehicle_full_idle', 'idle_wait_conditions', 'idle_wait_source',
|
|
'stop_source_lost', 'night_session_done_idle', 'night_recovery_done_idle',
|
|
]);
|
|
|
|
async function detecterAnomalieCharge(sql, st) {
|
|
const g = (id) => st.get(id);
|
|
const connecte = txt(g('binary_sensor.victron_evcs_vehicle_connected')?.state) === 'on';
|
|
const acPower = num(g('sensor.victron_evcs_ac_power')?.state) ?? 0;
|
|
const pv = num(g('sensor.victron_pv_power_system')?.state) ?? 0;
|
|
const conso = num(g('sensor.victron_consumption_power_l1')?.state) ?? 0;
|
|
const excedent = pv - conso; // sans injection, ~ puissance batterie au signe pres
|
|
const sourceMode = txt(g('input_text.victron_evcs_source_mode')?.state) || '';
|
|
const surplusState = (txt(g('input_text.victron_evcs_surplus_state')?.state) || '').toLowerCase();
|
|
const bloquant = sourceMode.startsWith('Bloqu') || ETATS_BLOQUANTS.has(surplusState);
|
|
|
|
const condition = connecte && acPower < SEUIL_CHARGE_W && bloquant
|
|
&& excedent > ANOMALIE_CHARGE_SURPLUS_W;
|
|
|
|
const now = Date.now();
|
|
if (!condition) {
|
|
// fin d'episode : on efface le helper si un episode etait actif
|
|
if (anomalieChargeDepuis !== null) {
|
|
anomalieChargeDepuis = null;
|
|
await ecrireHelperTexte('input_text.evcs_alerte_charge', '');
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
if (anomalieChargeDepuis === null) anomalieChargeDepuis = now;
|
|
const tenuMin = (now - anomalieChargeDepuis) / 60000;
|
|
if (tenuMin < ANOMALIE_CHARGE_MIN) return 0;
|
|
|
|
// Episode confirme. Un seul signalement par episode (clef = son debut).
|
|
if (anomalieChargeSignalee === anomalieChargeDepuis) return 0;
|
|
anomalieChargeSignalee = anomalieChargeDepuis;
|
|
|
|
const since = new Date(anomalieChargeDepuis).toISOString();
|
|
const message = `Vehicule branche depuis ${Math.round(tenuMin)} min, `
|
|
+ `${Math.round(excedent)} W de soleil dispo, mais la borne ne charge pas `
|
|
+ `(${sourceMode || surplusState}). Verifier la borne / rebrancher.`;
|
|
await ecrireHelperTexte('input_text.evcs_alerte_charge',
|
|
JSON.stringify({ active: true, since, excedent_w: Math.round(excedent),
|
|
source_mode: sourceMode, surplus_state: surplusState, message }));
|
|
await journaliser(sql, 'anomalie',
|
|
`Branchee mais ne charge pas : ${Math.round(excedent)} W de soleil perdu depuis ${Math.round(tenuMin)} min`,
|
|
{ since, excedent_w: Math.round(excedent), ac_power_w: acPower,
|
|
source_mode: sourceMode, surplus_state: surplusState });
|
|
return 1;
|
|
}
|
|
|
|
// Petit ecrivain de helper texte, reutilise par la detection d'anomalie.
|
|
async function ecrireHelperTexte(entite, valeur) {
|
|
if (DRY) return;
|
|
try {
|
|
await fetch(`${HA_URL}/api/services/input_text/set_value`, {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${HA_TOKEN}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ entity_id: entite, value: valeur }),
|
|
signal: AbortSignal.timeout(8000),
|
|
});
|
|
} catch { /* le helper reste a sa valeur, on reessaiera au prochain cycle */ }
|
|
}
|
|
|
|
async function ecrireHelperNombre(entite, valeur) {
|
|
if (DRY || valeur === null || valeur === undefined || !Number.isFinite(valeur)) return;
|
|
try {
|
|
await fetch(`${HA_URL}/api/services/input_number/set_value`, {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${HA_TOKEN}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ entity_id: entite, value: valeur }),
|
|
signal: AbortSignal.timeout(8000),
|
|
});
|
|
} catch { /* on reessaiera au prochain cycle */ }
|
|
}
|
|
|
|
// Publie le detail cellule (relevé serie, table `cellule`) dans des helpers HA,
|
|
// pour que l'agent vocal OpenClaw le lise sans taper PostgreSQL. Les trames BMS
|
|
// cellule de HA sont mortes depuis le 15/09 (§69) : ce relevé serie est la seule
|
|
// source vivante. On expose l'ECART max du pack, le module extreme, et l'AGE du
|
|
// dernier relevé — c'est l'age qui permet a l'agent d'appliquer la meme
|
|
// discipline que pour le gel GX (frais -> il le donne, vieux -> il dit muet).
|
|
// Publie un capteur calcule (etat + attributs) via l'API d'etats de HA. Il
|
|
// disparait a un redemarrage de HA jusqu'au cycle suivant (<= 1 min) : l'agent
|
|
// lit alors une entite absente, donc « muet », jamais une valeur fausse.
|
|
async function publierCapteur(entite, etat, attributs) {
|
|
if (DRY) return;
|
|
const r = await fetch(`${HA_URL}/api/states/${entite}`, {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${HA_TOKEN}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ state: String(etat), attributes: attributs }),
|
|
signal: AbortSignal.timeout(8000),
|
|
});
|
|
if (!r.ok) throw new Error(`${entite} : HTTP ${r.status}`);
|
|
}
|
|
|
|
// Verdict PV pour l'agent vocal (G5 du banc) : bridage normal / substitution
|
|
// EDF / ressource limitee, calcule par tools/diagnostics-agent.mjs et teste.
|
|
// Hors base : ne depend que des etats HA, donc survit a une panne PostgreSQL.
|
|
async function publierDiagnosticPv(st) {
|
|
const d = diagnosticPv(st);
|
|
await publierCapteur('sensor.evcs_pv_diagnostic', d.etat, {
|
|
friendly_name: 'EVCS — diagnostic production PV', icon: 'mdi:solar-power-variant', ...d,
|
|
calcule_a: new Date().toISOString(),
|
|
});
|
|
// Production du jour (G1/G9) et relais de la decision de charge (G6/G7) :
|
|
// memes proprietes, ne dependent que de HA.
|
|
const prod = productionJour(st);
|
|
await publierCapteur('sensor.evcs_production_jour_kwh', prod.etat, {
|
|
friendly_name: 'EVCS — production PV reelle du jour', icon: 'mdi:solar-power',
|
|
unit_of_measurement: typeof prod.etat === 'number' ? 'kWh' : undefined, ...prod.attributs,
|
|
});
|
|
const ch = chargeSolaire(st);
|
|
await publierCapteur('sensor.evcs_charge_solaire', ch.etat, {
|
|
friendly_name: 'EVCS — charge voiture (decision du controleur)', icon: 'mdi:ev-station', ...ch.attributs,
|
|
});
|
|
// Import EDF depuis minuit (« Depuis reseau » de VRM), sur les compteurs cumules.
|
|
await publierImportJour(st).catch((e) => {
|
|
console.error(`import du jour : ${e.message}`); return 0;
|
|
});
|
|
// Projection du SOC au coucher du soleil : « indetermine » tant que la
|
|
// prevision solaire n'est pas corrigee (decision du proprietaire, 27/09).
|
|
const pj = projectionSoir(st);
|
|
await publierCapteur('sensor.evcs_batterie_projection_soir', pj.etat, {
|
|
friendly_name: 'EVCS — batterie attendue au coucher du soleil', icon: 'mdi:battery-clock',
|
|
...pj.attributs, calcule_a: new Date().toISOString(),
|
|
});
|
|
// Prevision de la nuit (28/09) : uniquement le bilan + etats lus.
|
|
const pnuit = previsionNuit(st);
|
|
await publierCapteur('sensor.evcs_prevision_nuit', pnuit.etat, {
|
|
friendly_name: 'EVCS — prevision de la nuit (EDF a prendre)', icon: 'mdi:weather-night-partly-cloudy',
|
|
unit_of_measurement: typeof pnuit.etat === 'number' ? 'kWh' : undefined, ...pnuit.attributs,
|
|
calcule_a: new Date().toISOString(),
|
|
});
|
|
// Raccordement reseau : contacteur ouvert = import nul, pas une panne (26/09).
|
|
const rr = etatReseau(st);
|
|
await publierCapteur('sensor.evcs_reseau', rr.etat, {
|
|
friendly_name: 'EVCS — raccordement reseau EDF', icon: 'mdi:transmission-tower', ...rr.attributs,
|
|
});
|
|
return 1;
|
|
}
|
|
|
|
// Valeur des compteurs d'energie a minuit (heure de Paris), lue UNE fois par jour
|
|
// dans l'historique de HA : la derniere valeur connue avant minuit. L'historique
|
|
// renvoie en tete l'etat en vigueur au debut de la fenetre, donc un compteur
|
|
// immobile (contacteur ouvert toute la soiree) a bien une valeur.
|
|
const IDS_IMPORT = [...COMPTEURS_IMPORT, COMPTEUR_EDF];
|
|
// La consommation du jour (26/09) lit ses compteurs de sortie AC au meme minuit.
|
|
const IDS_CONSO = COMPTEURS_CONSO.map(([, id]) => id);
|
|
const IDS_MINUIT = [...new Set([...IDS_IMPORT, ...IDS_CONSO])];
|
|
let minuitCache = { jour: null, valeurs: null };
|
|
async function valeursAMinuit() {
|
|
const minuit = minuitLocal(new Date());
|
|
const jour = minuit.toISOString();
|
|
if (minuitCache.jour === jour) return minuitCache.valeurs;
|
|
const debut = new Date(minuit.getTime() - 6 * 3600e3).toISOString();
|
|
const r = await fetch(
|
|
`${HA_URL}/api/history/period/${debut}?end_time=${encodeURIComponent(jour)}` +
|
|
`&filter_entity_id=${IDS_MINUIT.join(',')}&minimal_response&no_attributes`,
|
|
{ headers: { Authorization: `Bearer ${HA_TOKEN}` }, signal: AbortSignal.timeout(15000) },
|
|
);
|
|
if (!r.ok) throw new Error(`historique HA ${r.status}`);
|
|
const series = await r.json();
|
|
const valeurs = {};
|
|
for (const serie of series) {
|
|
const id = serie[0]?.entity_id;
|
|
const nums = serie.map((e) => Number(e.state)).filter(Number.isFinite);
|
|
if (id) valeurs[id] = nums.length ? nums[nums.length - 1] : null;
|
|
}
|
|
// On ne met en cache qu'une lecture complete : sinon on reessaie au cycle suivant.
|
|
if (IDS_MINUIT.every((id) => Number.isFinite(valeurs[id]))) minuitCache = { jour, valeurs };
|
|
return valeurs;
|
|
}
|
|
|
|
async function publierImportJour(st) {
|
|
const minuit = await valeursAMinuit();
|
|
const courant = {};
|
|
for (const id of IDS_IMPORT) {
|
|
const v = Number(st.get(id)?.state);
|
|
courant[id] = Number.isFinite(v) ? v : null;
|
|
}
|
|
const ij = importJour(courant, minuit);
|
|
await publierCapteur('sensor.evcs_import_jour_kwh', ij.etat, {
|
|
friendly_name: 'EVCS — energie importee d\'EDF depuis minuit', icon: 'mdi:transmission-tower-import',
|
|
unit_of_measurement: typeof ij.etat === 'number' ? 'kWh' : undefined, ...ij.attributs,
|
|
calcule_a: new Date().toISOString(),
|
|
});
|
|
// Consommation du jour (« Consommation Total » de VRM) : sortie AC du MultiPlus.
|
|
const cc = {};
|
|
for (const id of IDS_CONSO) { const v = Number(st.get(id)?.state); cc[id] = Number.isFinite(v) ? v : null; }
|
|
const cj = consoJour(cc, minuit);
|
|
await publierCapteur('sensor.evcs_consommation_jour_kwh', cj.etat, {
|
|
friendly_name: 'EVCS — consommation du jour (maison et borne)', icon: 'mdi:home-lightning-bolt',
|
|
unit_of_measurement: typeof cj.etat === 'number' ? 'kWh' : undefined, ...cj.attributs,
|
|
calcule_a: new Date().toISOString(),
|
|
});
|
|
return 1;
|
|
}
|
|
|
|
// Bilan de la nuit pour l'agent vocal : les donnees du rapport du matin (meme
|
|
// code, donc memes chiffres que le courriel). Toutes les 5 min suffit.
|
|
let bilanNuitPublie = false; // publie des le premier cycle, puis toutes les 5 min
|
|
async function publierBilanNuit(client) {
|
|
if (DRY || !client) return 0;
|
|
const d = await donneesNuit(client);
|
|
const bn = bilanNuit(d, texteDepuis(d));
|
|
await publierCapteur('sensor.evcs_bilan_nuit', bn.etat, {
|
|
friendly_name: 'EVCS — bilan de la derniere nuit', icon: 'mdi:weather-night', ...bn.attributs,
|
|
calcule_a: new Date().toISOString(),
|
|
});
|
|
return 1;
|
|
}
|
|
|
|
async function publierCellulesHelpers(client) {
|
|
if (DRY) return 0;
|
|
// Base injoignable : on publie « muet » plutot que de laisser au capteur son
|
|
// dernier etat « frais », qui vieillirait sans que rien ne le dise.
|
|
const muet = (cause) => publierCapteur('sensor.evcs_cellules', 'muet', {
|
|
friendly_name: 'EVCS cellules — ecart max du pack', icon: 'mdi:battery-heart-variant',
|
|
statut: 'muet', explication: `Detail cellule indisponible (${cause}).`,
|
|
});
|
|
if (!client) { await muet('base injoignable'); return 0; }
|
|
const { rows: [r] } = await client.query(
|
|
`WITH mx AS (SELECT max(ts) AS t FROM cellule),
|
|
-- 15 min et non 5 (26/09) : deux lecteurs, un par pack, chacun toutes les
|
|
-- 10 min et sans synchronisation ; une fenetre plus courte que le pas
|
|
-- ferait disparaitre un pack sur deux selon l'instant du calcul.
|
|
recent AS (SELECT DISTINCT ON (module) module, ts FROM cellule
|
|
WHERE ts > (SELECT t FROM mx) - interval '15 minutes' ORDER BY module, ts DESC),
|
|
spreads AS (SELECT c.module, max(c.mv) - min(c.mv) AS ecart,
|
|
max(CASE WHEN c.pack IS NOT NULL AND c.rang IS NOT NULL
|
|
THEN c.pack * 10 + c.rang END) AS repere
|
|
FROM cellule c JOIN recent rc ON rc.module = c.module AND rc.ts = c.ts
|
|
GROUP BY c.module)
|
|
SELECT (SELECT max(ecart) FROM spreads) AS spread_max,
|
|
(SELECT module FROM spreads ORDER BY ecart DESC NULLS LAST LIMIT 1) AS weakest,
|
|
(SELECT repere FROM spreads ORDER BY ecart DESC NULLS LAST LIMIT 1) AS weakest_repere,
|
|
(SELECT round(EXTRACT(EPOCH FROM (now() - t)) / 60)::int FROM mx) AS age_min,
|
|
(SELECT count(*) FROM spreads)::int AS modules_lus`)
|
|
.catch(async (e) => { await muet('base injoignable').catch(() => {}); throw e; });
|
|
// Capteur unique pour l'agent (25/09) : releve perime => ni ecart ni module
|
|
// visibles (G13b du banc OpenClaw). Publie avant le test de table vide, pour
|
|
// que « muet » soit dit aussi quand il n'y a rien.
|
|
const cel = etatCellules(r);
|
|
await publierCapteur('sensor.evcs_cellules', cel.etat, {
|
|
friendly_name: 'EVCS cellules — ecart max du pack', unit_of_measurement: typeof cel.etat === 'number' ? 'mV' : undefined,
|
|
icon: 'mdi:battery-heart-variant', ...cel.attributs,
|
|
});
|
|
// Les trois helpers evcs_cell_* du 24/09 sont retires (25/09) : un
|
|
// input_number garde sa derniere valeur quand le releve perime, c'etait
|
|
// precisement le defaut G13b. sensor.evcs_cellules les remplace.
|
|
return r && r.age_min !== null ? 1 : 0;
|
|
}
|
|
|
|
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
|
|
// regarde que le chemin heureux.
|
|
const sql = DRY
|
|
? async (q, p) => {
|
|
if (process.env.HISTORISE_VERBOSE) {
|
|
console.log(' ' + q.split('\n')[0].trim() + ' <- ' + JSON.stringify(p).slice(0, 120));
|
|
}
|
|
return { rowCount: 1 };
|
|
}
|
|
: (q, p) => client.query(q, p);
|
|
const st = await etats();
|
|
const r = {
|
|
mesures: await ecrireMesures(sql, st),
|
|
bilan: await ecrireBilan(sql, st),
|
|
decision: await ecrireDecision(sql, st),
|
|
observation: await ecrireObservation(sql, st),
|
|
prevision: await ecrirePrevision(sql, st),
|
|
session: await ecrireSession(sql, client, st),
|
|
evenement: await detecterEvenements(sql, st),
|
|
anomalie: await detecterAnomalieCharge(sql, st).catch((e) => {
|
|
console.error(`detection anomalie charge en echec : ${e.message}`); return 0;
|
|
}),
|
|
etiquette: await appliquerEtiquetteConcierge(sql, client, st).catch((e) => {
|
|
console.error(`etiquette concierge en echec : ${e.message}`); return 0;
|
|
}) + 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;
|
|
}),
|
|
pvDiagnostic: DRY ? 0 : await publierDiagnosticPv(st).catch((e) => {
|
|
console.error(`diagnostic PV 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.
|
|
// Serie horaire : inutile de la republier toutes les minutes.
|
|
prevreel: (DRY || new Date().getMinutes() % 10 !== 3) ? 0
|
|
: await publierPrevisionContreReel(client).catch((e) => {
|
|
console.error(`prevision contre reel : ${e.message}`); return 0;
|
|
}),
|
|
bilanNuit: (DRY || (bilanNuitPublie && new Date().getMinutes() % 5 !== 1)) ? 0 : await publierBilanNuit(client).then((n) => { bilanNuitPublie = true; return n; }).catch((e) => {
|
|
console.error(`bilan de nuit : ${e.message}`); return 0;
|
|
}),
|
|
rapport: DRY ? 0 : await envoyerRapportDuMatin(sql, client).catch((e) => {
|
|
console.error('rapport de nuit :', e.message); return 0;
|
|
}),
|
|
cellules: DRY ? 0 : await ingererCellules(client).catch((e) => {
|
|
console.error(`ingestion cellules en echec : ${e.message}`);
|
|
return 0;
|
|
}),
|
|
// Publie le detail cellule dans des helpers HA (pour l'agent vocal), APRES
|
|
// l'ingestion pour utiliser le relevé qui vient d'arriver.
|
|
cellulesHelpers: DRY ? 0 : await publierCellulesHelpers(client).catch((e) => {
|
|
console.error(`publication helpers cellule en echec : ${e.message}`);
|
|
return 0;
|
|
}),
|
|
};
|
|
const total = r.mesures + r.bilan + r.decision + r.prevision + r.evenement + r.session + r.cellules + r.observation + r.etiquette + (r.rapport || 0);
|
|
if (total > 0 || ONCE) {
|
|
console.log(
|
|
`${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} anomalie=${r.anomalie} etiquette=${r.etiquette} ordre=${r.ordre} cellules=${r.cellules}`
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// CONNEXION A LA BASE — resiliente, et ce n'est pas du luxe
|
|
// ---------------------------------------------------------------------
|
|
// ⚠️ DEFAUT MESURE LE 16/09, en eprouvant le battement de coeur : quand la
|
|
// connexion PostgreSQL tombe EN COURS DE ROUTE, le client `pg` emet un
|
|
// evenement 'error' sur le socket. Un 'error' sans ecouteur est une exception
|
|
// NON CAPTUREE — elle ne passe pas par le try/catch du cycle, qui n'attend
|
|
// qu'une promesse, et elle TUE LE PROCESSUS. Le service mourait donc
|
|
// exactement dans la panne que le battement existe pour signaler.
|
|
//
|
|
// Deux consequences, toutes deux traitees ici :
|
|
// 1. un ecouteur 'error' obligatoire, qui marque la connexion morte au lieu
|
|
// de faire tomber le processus ;
|
|
// 2. une reconnexion, car un client `pg` n'est PAS reutilisable apres erreur :
|
|
// sans cela le service resterait vivant mais definitivement muet, et il
|
|
// faudrait un redemarrage a la main pour qu'il reparte quand la base
|
|
// revient.
|
|
//
|
|
// Et la connexion initiale ne doit plus etre fatale : base absente au demarrage,
|
|
// le service tourne quand meme et publie `degrade`. Un service qui meurt ne
|
|
// peut rien signaler.
|
|
const CONFIG_PG = {
|
|
host: process.env.PGHOST || '10.10.10.22',
|
|
port: Number(process.env.PGPORT || 5432),
|
|
user: process.env.PGUSER || 'evcs',
|
|
database: process.env.PGDATABASE || 'evcs',
|
|
password: process.env.PGPASSWORD,
|
|
};
|
|
|
|
let client = null;
|
|
let clientMort = true;
|
|
let schemaFait = false;
|
|
|
|
async function assurerConnexion() {
|
|
if (client && !clientMort) return;
|
|
if (client) { try { await client.end(); } catch { /* deja morte */ } client = null; }
|
|
const c = new pg.Client(CONFIG_PG);
|
|
c.on('error', (e) => {
|
|
clientMort = true;
|
|
console.error(`${new Date().toISOString()} connexion base perdue : ${e.message}`);
|
|
});
|
|
await c.connect();
|
|
if (!schemaFait) { await migrer(c); schemaFait = true; }
|
|
client = c;
|
|
clientMort = false;
|
|
}
|
|
|
|
if (!DRY) {
|
|
// Un echec ici n'est plus fatal : la boucle reessaiera, et le battement
|
|
// dira `degrade` en attendant.
|
|
try {
|
|
await assurerConnexion();
|
|
} catch (e) {
|
|
console.error(`${new Date().toISOString()} base injoignable au demarrage : ${e.message}`);
|
|
}
|
|
}
|
|
console.log(
|
|
`Historisation EVCS — HA=${HA_URL} base=${DRY ? '(blanc)' : CONFIG_PG.host + ':' + CONFIG_PG.port} ` +
|
|
`intervalle=${INTERVAL_S}s`
|
|
);
|
|
|
|
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);
|
|
}
|
|
|
|
// Une panne d'un cycle ne doit pas tuer la boucle : Home Assistant redémarre,
|
|
// le réseau hoquette, la base se recharge. On journalise et on retente.
|
|
// Un episode d'indisponibilite est un evenement a part entiere : c'est
|
|
// exactement ce qu'on voudra retrouver devant un trou dans les courbes. Il est
|
|
// journalise au RETOUR, avec sa duree — au moment de la panne, la base n'est
|
|
// peut-etre pas joignable non plus.
|
|
let echecDepuis = null;
|
|
let cyclesEnEchec = 0;
|
|
let dernierEchec = '';
|
|
|
|
for (;;) {
|
|
try {
|
|
if (!DRY) await assurerConnexion();
|
|
await cycle(client);
|
|
if (echecDepuis !== null) {
|
|
const duree = Math.round((Date.now() - echecDepuis) / 1000);
|
|
try {
|
|
// Le message disait « Home Assistant injoignable » quelle que soit la
|
|
// cause. Depuis que la base peut tomber sans tuer le service, c'est
|
|
// souvent faux : on nomme ce qui a reellement echoue.
|
|
await journaliser(client.query.bind(client), 'systeme',
|
|
`Cycles en echec pendant ${duree} s : ${dernierEchec || 'cause inconnue'}`,
|
|
{ depuis: new Date(echecDepuis).toISOString(), secondes: duree,
|
|
cycles: cyclesEnEchec, cause: dernierEchec });
|
|
} catch { /* la base non plus n'etait peut-etre pas la : on n'insiste pas */ }
|
|
echecDepuis = null;
|
|
}
|
|
cyclesEnEchec = 0;
|
|
dernierEchec = '';
|
|
} catch (e) {
|
|
if (echecDepuis === null) echecDepuis = Date.now();
|
|
cyclesEnEchec += 1;
|
|
dernierEchec = String(e.message).slice(0, 200);
|
|
console.error(`${new Date().toISOString()} cycle en echec : ${e.message}`);
|
|
// Les deux capteurs de l'agent vocal ne doivent pas rester figes sur leur
|
|
// dernier etat quand le cycle tombe (base injoignable : cycle() n'est meme
|
|
// pas appele). Le diagnostic PV ne depend que de HA ; le detail cellule, lui,
|
|
// passe a « muet ».
|
|
if (!DRY) {
|
|
await etats().then(publierDiagnosticPv).catch(() => {});
|
|
await publierCapteur('sensor.evcs_cellules', 'muet', {
|
|
friendly_name: 'EVCS cellules — ecart max du pack', icon: 'mdi:battery-heart-variant',
|
|
statut: 'muet', explication: `Detail cellule indisponible (${dernierEchec}).`,
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// HORS du try/catch ci-dessus, et volontairement : c'est ce qui fait que la
|
|
// panne la plus silencieuse — base morte, cycle avorte a chaque passe —
|
|
// devient visible. Son propre echec ne doit pas tuer la boucle non plus.
|
|
if (!DRY) {
|
|
try {
|
|
await publierBattement({
|
|
statut: cyclesEnEchec === 0 ? 'ok' : 'degrade',
|
|
baseJoignable: cyclesEnEchec === 0,
|
|
cyclesEnEchec,
|
|
dernierEchec,
|
|
});
|
|
} catch (e) {
|
|
// Home Assistant injoignable : il ne reste que le journal du conteneur.
|
|
// C'est la seule panne que ce mecanisme ne peut pas signaler lui-meme —
|
|
// elle est couverte dans l'autre sens par le chien de garde sur .22.
|
|
console.error(`${new Date().toISOString()} battement non publie : ${e.message}`);
|
|
}
|
|
}
|
|
|
|
await new Promise((r) => setTimeout(r, INTERVAL_S * 1000));
|
|
}
|