Le plafond a 93 % n etait pas une protection : le BMS autorisait 53,2 V, nous imposions 51,3, et la cellule la plus haute du pack recent etait 57 mV sous la premiere alarme. Le garde-fou datait des cellules faibles du pack ancien, hors service depuis hier — il protegeait un parc qui n est plus la, au prix de 0,68 kWh, soit 12 % de la reserve utile. Le helper de tension et ses paliers ne sont lus par personne : les valeurs sont une table codee en dur dans l Auto Charge Manager, qui entre au passage dans le depot. Seul le profil de la tranche 90-95 % passe a 52,4 V, soit 3,493 V par cellule, sous la tension nominale que Pylontech annonce. Verifie dans la minute : parc de 51,12 a 52,33 V, courant de -5,3 a +16,1 A, aucune alarme. Et le rapport de nuit part par courriel a 6 h 47. Il ne vit pas dans une session Claude, qui s arrete avec l ecran, mais dans le service d historisation, seul composant allume en permanence — il n y avait rien a surveiller, tout est deja enregistre, il manquait une lecture. Le texte est ecrit pour etre entendu : pas de tableau, des phrases courtes, des virgules decimales. Canal : le webhook n8n qui existait deja, teste de bout en bout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
833 lines
39 KiB
JavaScript
833 lines
39 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 } from './rapport-nuit.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',
|
|
];
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 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();
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
await journaliser(sql, genre, `${libelle} : ${prec} -> ${cur}`, {
|
|
entite: id, avant: prec, apres: cur,
|
|
horodatage_ha: e.last_changed,
|
|
});
|
|
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 true,
|
|
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`);
|
|
}
|
|
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)');
|
|
// 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 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 = coalesce($7, vehicule),
|
|
vehicule_sur = vehicule_sur AND ($7 IS NULL OR vehicule IS NULL OR vehicule = $7)
|
|
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);
|
|
const parCompteur = compteur !== null && ouverte.compteur_debut_kwh !== null
|
|
&& compteur >= ouverte.compteur_debut_kwh
|
|
? compteur - Number(ouverte.compteur_debut_kwh) : 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.
|
|
// ---------------------------------------------------------------------
|
|
const RAPPORT_HEURE = 6;
|
|
const RAPPORT_MINUTE = 47;
|
|
|
|
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;
|
|
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
|
|
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, null, null, 'suivi']);
|
|
}
|
|
}
|
|
for (let i = 0; i < val.length; i += 500) {
|
|
const lot = val.slice(i, i + 500);
|
|
const ph = lot.map((_, k) => `($${k * 8 + 1},$${k * 8 + 2},$${k * 8 + 3},$${k * 8 + 4},$${k * 8 + 5},$${k * 8 + 6},$${k * 8 + 7},$${k * 8 + 8})`).join(',');
|
|
const r = await client.query(
|
|
`INSERT INTO cellule (ts, module, cellule, mv, soc, courant_ma, etat, source)
|
|
VALUES ${ph} ON CONFLICT (ts, module, cellule) DO NOTHING`, lot.flat());
|
|
lignes += r.rowCount;
|
|
}
|
|
}
|
|
return lignes;
|
|
}
|
|
|
|
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),
|
|
prevision: await ecrirePrevision(sql, st),
|
|
session: await ecrireSession(sql, client, st),
|
|
evenement: await detecterEvenements(sql, st),
|
|
// 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.
|
|
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;
|
|
}),
|
|
};
|
|
const total = r.mesures + r.bilan + r.decision + r.prevision + r.evenement + r.session + r.cellules + (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} cellules=${r.cellules}`
|
|
);
|
|
}
|
|
}
|
|
|
|
const client = new pg.Client({
|
|
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,
|
|
});
|
|
|
|
if (!DRY) {
|
|
await client.connect();
|
|
await migrer(client); // schema a jour avant le premier cycle
|
|
}
|
|
console.log(
|
|
`Historisation EVCS — HA=${HA_URL} base=${DRY ? '(blanc)' : client.host + ':' + client.port} ` +
|
|
`intervalle=${INTERVAL_S}s`
|
|
);
|
|
|
|
if (ONCE) {
|
|
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;
|
|
|
|
for (;;) {
|
|
try {
|
|
await cycle(client);
|
|
if (echecDepuis !== null) {
|
|
const duree = Math.round((Date.now() - echecDepuis) / 1000);
|
|
try {
|
|
await journaliser(client.query.bind(client), 'systeme',
|
|
`Home Assistant injoignable pendant ${duree} s`,
|
|
{ depuis: new Date(echecDepuis).toISOString(), secondes: duree });
|
|
} catch { /* la base non plus n'etait peut-etre pas la : on n'insiste pas */ }
|
|
echecDepuis = null;
|
|
}
|
|
} catch (e) {
|
|
if (echecDepuis === null) echecDepuis = Date.now();
|
|
console.error(`${new Date().toISOString()} cycle en echec : ${e.message}`);
|
|
}
|
|
await new Promise((r) => setTimeout(r, INTERVAL_S * 1000));
|
|
}
|