// Tests des diagnostics publies pour l'agent vocal (tools/diagnostics-agent.mjs). // Les cas PV reprennent les gammes du banc OpenClaw (HANDOFF §77) : G3, G4, G5, G7. // node tests/diagnostics-agent/run.mjs import fs from 'node:fs'; import { diagnosticPv, etatCellules, productionJour, chargeSolaire, MPPT_MODES, ORIGINE_PAR_SOURCE } from '../../tools/diagnostics-agent.mjs'; const LIM = 'voltage_current_limited', ACT = 'mppt_active'; function st({ pv, conso, reseau, soc = 80, courant = 0, edf = 'off', modes }) { const m = new Map(); const put = (id, v) => m.set(id, { entity_id: id, state: v === null ? 'unavailable' : String(v) }); put('sensor.victron_pv_power_system', pv); put('sensor.victron_consumption_power_l1', conso); put('sensor.victron_grid_power_l1', reseau); put('sensor.victron_battery_soc', soc); put('sensor.victron_battery_current', courant); put('switch.edf_general_ok_switch', edf); const liste = Array.isArray(modes) ? modes : MPPT_MODES.map(() => modes); MPPT_MODES.forEach((id, i) => put(id, liste[i])); return m; } const cas = [ ['G3 batterie pleine, 7 brides, import nul -> bridage normal', st({ pv: 1570, conso: 1500, reseau: 0, soc: 97, courant: 1, modes: LIM }), 'bridage_normal'], ['G4 vraie baisse : 7 a fond, SOC 69, +25 A -> ressource limitee', st({ pv: 1600, conso: 400, reseau: 0, soc: 69, courant: 25, modes: ACT }), 'ressource_limitee'], ['G5 brides MAIS import ~ conso -> substitution', st({ pv: 300, conso: 1200, reseau: 1150, soc: 97, courant: 0, edf: 'on', modes: LIM }), 'substitution_edf'], ['G7 un seul bride, import nul -> bridage normal (ressource dispo)', st({ pv: 5200, conso: 2000, reseau: 0, soc: 92, courant: 60, modes: [LIM, ACT, ACT, ACT, ACT, ACT, ACT] }), 'bridage_normal'], ['bruit du compteur (60 W) contacteur ferme : pas une substitution', st({ pv: 1500, conso: 1400, reseau: 60, soc: 96, edf: 'on', modes: LIM }), 'bridage_normal'], ['un seul bride suffit a signer la substitution si le reseau importe', st({ pv: 3000, conso: 3500, reseau: 900, edf: 'on', modes: [LIM, ACT, ACT, ACT, ACT, ACT, ACT] }), 'substitution_edf'], ['tous a fond + import -> reseau en appoint, pas une anomalie', st({ pv: 2000, conso: 3500, reseau: 1500, edf: 'on', modes: ACT }), 'reseau_en_appoint'], ['nuit : PV 0, tous off -> pas de production', st({ pv: 0, conso: 600, reseau: 0, modes: 'off' }), 'pas_de_production'], ['nuit + reseau ferme : toujours pas de substitution', st({ pv: 0, conso: 600, reseau: 600, edf: 'on', modes: 'off' }), 'pas_de_production'], ['reseau indisponible -> indetermine (ne pas conclure)', st({ pv: 1500, conso: 1400, reseau: null, modes: LIM }), 'indetermine'], ['modes illisibles en journee -> indetermine', st({ pv: 1500, conso: 1400, reseau: 0, modes: 'unavailable' }), 'indetermine'], ]; let ko = 0; for (const [nom, etats, attendu] of cas) { const r = diagnosticPv(etats); const ok = r.etat === attendu; if (!ok) ko++; console.log(`${ok ? 'OK ' : 'KO '} ${nom}${ok ? '' : ` (obtenu ${r.etat})`}`); } const cel = [ ['releve frais -> ecart et module publies', { spread_max: 21, weakest: 'K220803C30160323', age_min: 1 }, (r) => r.etat === 21 && r.attributs.module_le_plus_sensible === 'K220803C30160323'], ['releve a 30 min (limite) -> encore frais', { spread_max: 5, weakest: 'Y1', age_min: 30 }, (r) => r.etat === 5], ['G13b releve a 42 min -> perime, AUCUNE valeur visible', { spread_max: 21, weakest: 'K220803C30160323', age_min: 42 }, (r) => r.etat === 'perime' && !('module_le_plus_sensible' in r.attributs) && !JSON.stringify(r).includes('21,') && !JSON.stringify(r).includes('K2208')], ['table vide -> muet', { spread_max: null, weakest: null, age_min: null }, (r) => r.etat === 'muet'], ]; for (const [nom, row, test] of cel) { const ok = test(etatCellules(row)); if (!ok) ko++; console.log(`${ok ? 'OK ' : 'KO '} ${nom}`); } // --- Production du jour (G1, G9) --------------------------------------- const MIDI = new Date('2026-09-24T12:00:00Z'); // 14:00 a Paris const NUIT = new Date('2026-09-24T22:40:00Z'); // 00:40 a Paris le 25 function rend(v1, v2, v3, change = '2026-09-24T11:59:00Z') { const m = new Map(); [['sensor.airtria_mppt1_yield_today', v1], ['sensor.mppt_450_200_hq2449yr9pt_yield_today', v2], ['sensor.v1_yield_today', v3]].forEach(([id, v]) => { const [val, lc] = Array.isArray(v) ? v : [v, change]; m.set(id, { state: val === null ? 'unavailable' : String(val), last_changed: lc }); }); // un sous-tracker present ne doit JAMAIS etre additionne m.set('sensor.mppt_450_200_hq2449yr9pt_tracker_solar_1_yield_today', { state: '20.0', last_changed: change }); return m; } const prod = [ ['G1 somme des trois (45,1 du 24/09), sous-tracker ignore', rend(9.45, 32.71, 2.97), MIDI, (r) => r.etat === 45.1 && r.attributs.statut === 'complet'], ['G9 un chargeur unavailable -> partiel, somme du reste, muet nomme', rend(9.45, 32.71, null), MIDI, (r) => r.etat === 42.2 && r.attributs.statut === 'partiel' && r.attributs.chargeurs_muets.join() === 'v1'], ['tous muets -> indisponible', rend(null, null, null), MIDI, (r) => r.etat === 'indisponible'], ['00:40 : un chargeur pas encore remis a zero (valeur d hier) -> compte 0', rend(['0.0', '2026-09-24T22:37:00Z'], ['32.71', '2026-09-24T17:30:00Z'], ['0.0', '2026-09-24T22:37:00Z']), NUIT, (r) => r.etat === 0 && r.attributs.pas_encore_remis_a_zero.join() === 'mppt_450_200'], ['heure d ete : 23:30 Paris le 24 = encore aujourd hui', rend(9.45, 32.71, 2.97, '2026-09-24T17:30:00Z'), new Date('2026-09-24T21:30:00Z'), (r) => r.etat === 45.1], ]; for (const [nom, etats, t, test] of prod) { const ok = test(productionJour(etats, t)); if (!ok) { ko++; console.log(JSON.stringify(productionJour(etats, t))); } console.log(`${ok ? 'OK ' : 'KO '} ${nom}`); } // --- Charge voiture : RELAIS du controleur (G6, G7) -------------------- function borne({ branche = 'on', p = 0, source = 'Solaire', raison = 'Excedent negatif | Profil=x', pv = 2000, conso = 3854, modes = ACT }) { const m = st({ pv, conso, reseau: 0, modes }); const put = (id, v) => m.set(id, { state: String(v) }); put('binary_sensor.victron_evcs_vehicle_connected', branche); put('sensor.victron_evcs_ac_power', p); put('input_text.victron_evcs_source_mode', source); put('input_text.victron_evcs_surplus_reason', raison); put('input_select.victron_evcs_strategy', 'auto_solar_plus'); return m; } const charge = [ ['G6 branchee, pas en charge -> en_attente + raison du controleur, excedent explicatif', borne({ source: 'Bloqué', raison: 'Excedent insuffisant | Profil=outside_window' }), (r) => r.etat === 'en_attente' && r.attributs.raison_controleur === 'Excedent insuffisant' && r.attributs.excedent_w === -1854], ['en charge sur source solaire -> en_charge_solaire', borne({ p: 3600, source: 'Solaire' }), (r) => r.etat === 'en_charge_solaire'], ['en charge source reseau -> en_charge_reseau', borne({ p: 3600, source: 'Réseau HC autorisé' }), (r) => r.etat === 'en_charge_reseau'], // Incident du 25/09 01:25-06:00 : JEEP sur batterie la nuit, publie « solaire ». ['INCIDENT 25/09 : assistance batterie nuit -> en_charge_batterie, JAMAIS solaire', borne({ p: 1198, source: 'Assistance batterie nuit', pv: 0, conso: 900, modes: 'off' }), (r) => r.etat === 'en_charge_batterie' && !/solaire/i.test(r.attributs.explication.split('(')[0])], ['sonde hybride -> en_charge_sonde', borne({ p: 2300, source: 'Probe hybride' }), (r) => r.etat === 'en_charge_sonde'], ['potentiel solaire -> en_charge_solaire', borne({ p: 2300, source: 'Potentiel solaire' }), (r) => r.etat === 'en_charge_solaire'], ['reseau force -> en_charge_reseau', borne({ p: 3600, source: 'Réseau forcé' }), (r) => r.etat === 'en_charge_reseau'], ['libelle inconnu -> en_charge sans origine devinee', borne({ p: 3600, source: 'nouveau_mode' }), (r) => r.etat === 'en_charge'], ['non branchee -> non_branchee', borne({ branche: 'off' }), (r) => r.etat === 'non_branchee'], ['G7 excedent positif et bride, mais controleur bloque -> en_attente (on RELAIE, on ne rejuge pas)', borne({ pv: 5000, conso: 1500, modes: LIM, source: 'Bloqué', raison: 'Vehicule plein' }), (r) => r.etat === 'en_attente' && r.attributs.diagnostic_pv === 'bridage_normal'], ['borne illisible -> indetermine', (() => { const m = borne({}); m.set('sensor.victron_evcs_ac_power', { state: 'unavailable' }); return m; })(), (r) => r.etat === 'indetermine'], ]; for (const [nom, etats, test] of charge) { const ok = test(chargeSolaire(etats)); if (!ok) { ko++; console.log(JSON.stringify(chargeSolaire(etats))); } console.log(`${ok ? 'OK ' : 'KO '} ${nom}`); } // Chaque libelle publie par le controleur doit avoir une origine explicite : // lu dans SON code, pour qu'un libelle ajoute la-bas fasse tomber ce test. const srcCtrl = fs.readFileSync(new URL('../../src/nodes/HA_SERVICE_CALLS_OUTPUT.js', import.meta.url), 'utf8'); const bloc = srcCtrl.slice(srcCtrl.indexOf('const sourceModeLabel ='), srcCtrl.indexOf('context.sourceMode;', srcCtrl.indexOf('const sourceModeLabel ='))); const libelles = [...bloc.matchAll(/\? '([^']+)'/g)].map((m) => m[1]).filter((l) => l !== 'Bloqué'); const nonCouverts = libelles.filter((l) => !ORIGINE_PAR_SOURCE[l]); const okLib = libelles.length >= 6 && nonCouverts.length === 0; if (!okLib) ko++; console.log(`${okLib ? 'OK ' : 'KO '} les ${libelles.length} libelles de source du controleur ont une origine${okLib ? '' : ` (manquent : ${nonCouverts.join(', ') || 'table introuvable'})`}`); const n = cas.length + cel.length + prod.length + charge.length + 1; console.log(`\n${n - ko}/${n}`); process.exit(ko ? 1 : 0);