Files
EVCS/tools/ha-history.mjs
Tallagrand Thierry 17d65f1e40 Bilan : un delta horaire appartient a l'heure qui l'a consomme
`bucketHourly` garde la derniere valeur de chaque heure : le delta entre H-1
et H est l'energie depensee PENDANT H. `deltaSeries` le rangeait sous H-1.

Discret sur un compteur dense, catastrophique sur un compteur epars — et les
compteurs de cette installation le sont : `ac_in_1_to_ac_out` ne peuple que 57
heures sur 267, `evcs_total` 57 sur 264, parce qu'ils n'emettent que lorsqu'ils
avancent. Une heure vide vaut « zero energie », donc toute l'energie d'un
intervalle a trou a ete consommee pendant l'heure FINALE. Rangee sous l'heure
initiale, l'import reseau de la nuit suivante atterrissait sur la derniere
heure de la nuit precedente.

Mesure du 25/08 : consumption_p50[8] = 2,90 kWh/h pour 0,80 reellement — six
echantillons sur douze portaient un delta etale sur 17 heures. Le bilan
reclamait environ deux fois trop d'energie le matin (3,37 kWh annonces a 06 h
contre 1,35 mesures), et donc un SOC requis de 44,5 % la ou 31 % suffisait.
L'enveloppe de production etait decalee de la meme heure, ce qui a contribue a
surestimer la part matinale de la production dans le journal de chantier.

Ajoute aussi un statut `degrade` : le bilan publie ses chiffres quand aucun
croisement n'apparait sur l'horizon ou quand les heures de deficit reposent sur
une forme minoree, mais il ne les presente plus comme fiables. La nuit du 25/08,
avant la mise en service de la courbe horaire, il a publie un SOC requis de
100 % avec un statut `ok`.

Tests 36 -> 37, dont `compteur-epars-attribution-horaire` (rafale reseau de
1/2/3 kWh a 01/02/03 h : montants distincts pour qu'un decalage d'un cran soit
impossible a confondre). Mutation verifiee : l'ancienne attribution le fait
echouer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-08-25 11:11:24 +02:00

85 lines
3.0 KiB
JavaScript

#!/usr/bin/env node
// Usage: node tools/ha-history.mjs <entity_id>[,<entity_id>...] [debut] [fin]
// node tools/ha-history.mjs sensor.victron_battery_soc 2026-08-25T00:00 2026-08-25T10:00
// node tools/ha-history.mjs input_boolean.evcs_advisor_apply_night_target -48h
//
// Lit l'historique d'états via l'API REST de Home Assistant (jeton dans .env,
// même mécanique que tools/ha-service.mjs).
//
// ⚠️ PIÈGE VÉRIFIÉ : /api/history/period ne renvoie QU'UNE SEULE JOURNÉE si
// end_time est omis, sans le signaler. Ce script impose donc toujours les deux
// bornes.
//
// Les MCP du projet ne donnent que l'état courant : pour toute analyse
// a posteriori (qui a changé quoi, et quand), il faut l'historique.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
function loadToken() {
if (process.env.HA_TOKEN) return process.env.HA_TOKEN;
const envPath = path.join(ROOT, '.env');
if (!fs.existsSync(envPath)) {
console.error('Jeton introuvable : ni $HA_TOKEN, ni fichier .env.');
process.exit(1);
}
for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
const m = line.match(/^\s*HA_TOKEN\s*=\s*(.+?)\s*$/);
if (m) return m[1].replace(/^["']|["']$/g, '');
}
console.error('HA_TOKEN absent du fichier .env');
process.exit(1);
}
const HA_URL = process.env.HA_URL || 'http://10.10.10.17:8123';
const entities = (process.argv[2] || '').split(',').filter(Boolean);
if (!entities.length) {
console.error('Usage: node tools/ha-history.mjs <entity_id>[,...] [debut|-Nh] [fin]');
process.exit(1);
}
function parseBound(arg, fallback) {
if (!arg) return fallback;
const rel = arg.match(/^-(\d+)h$/);
if (rel) return new Date(Date.now() - Number(rel[1]) * 3600e3);
return new Date(arg);
}
const end = parseBound(process.argv[4], new Date());
const start = parseBound(process.argv[3], new Date(end.getTime() - 24 * 3600e3));
const url =
`${HA_URL}/api/history/period/${start.toISOString()}` +
`?end_time=${encodeURIComponent(end.toISOString())}` +
`&filter_entity_id=${encodeURIComponent(entities.join(','))}` +
`&minimal_response&significant_changes_only`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${loadToken()}` } });
if (!res.ok) {
console.error(`HTTP ${res.status} — ${await res.text()}`);
process.exit(1);
}
const series = await res.json();
const fmt = (iso) =>
new Date(iso).toLocaleString('fr-FR', {
timeZone: 'Europe/Paris',
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
});
console.log(`Fenêtre : ${fmt(start.toISOString())} → ${fmt(end.toISOString())} (heure de Paris)\n`);
for (const rows of series) {
if (!rows.length) continue;
console.log(`=== ${rows[0].entity_id} (${rows.length} points) ===`);
let prev = null;
for (const r of rows) {
if (r.state === prev) continue;
console.log(` ${fmt(r.last_changed || r.last_updated)} ${r.state}`);
prev = r.state;
}
console.log('');
}