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
This commit is contained in:
@@ -13,7 +13,7 @@ views:
|
|||||||
- type: conditional
|
- type: conditional
|
||||||
conditions:
|
conditions:
|
||||||
- entity: input_text.evcs_balance_status
|
- entity: input_text.evcs_balance_status
|
||||||
state_not: "ok"
|
state: "indisponible"
|
||||||
card:
|
card:
|
||||||
type: markdown
|
type: markdown
|
||||||
content: |
|
content: |
|
||||||
@@ -23,6 +23,22 @@ views:
|
|||||||
|
|
||||||
Les contrôleurs fonctionnent sur leurs règles historiques.
|
Les contrôleurs fonctionnent sur leurs règles historiques.
|
||||||
|
|
||||||
|
- type: conditional
|
||||||
|
conditions:
|
||||||
|
- entity: input_text.evcs_balance_status
|
||||||
|
state: "degrade"
|
||||||
|
card:
|
||||||
|
type: markdown
|
||||||
|
content: |
|
||||||
|
## 🟠 Bilan dégradé
|
||||||
|
|
||||||
|
Les chiffres sont publiés, mais ils reposent sur une base
|
||||||
|
incomplète : soit aucun croisement production/consommation
|
||||||
|
n'apparaît sur l'horizon, soit la forme de production des heures
|
||||||
|
de déficit vient d'un repli sur l'enveloppe mesurée.
|
||||||
|
|
||||||
|
{{ states('input_text.evcs_balance_reason') }}
|
||||||
|
|
||||||
# --- LA comparaison qui décidera du câblage ---------------------
|
# --- LA comparaison qui décidera du câblage ---------------------
|
||||||
- type: markdown
|
- type: markdown
|
||||||
title: Seuil calculé contre seuil fixe
|
title: Seuil calculé contre seuil fixe
|
||||||
|
|||||||
@@ -65,7 +65,21 @@ if (!balance.usable) {
|
|||||||
|
|
||||||
const c = balance.coverage;
|
const c = balance.coverage;
|
||||||
|
|
||||||
setText('input_text.evcs_balance_status', 'ok');
|
// `ok` ne veut pas dire « le calcul a abouti » mais « on peut s'appuyer
|
||||||
|
// dessus ». Deux cas dégradent la réponse sans l'empêcher :
|
||||||
|
// · aucun croisement production/consommation sur l'horizon — le SOC requis
|
||||||
|
// n'est alors plus une exigence datée mais la somme d'un horizon entier ;
|
||||||
|
// · des heures de déficit reposant sur une forme de production minorée
|
||||||
|
// (repli sur l'enveloppe faute de courbe horaire fraîche).
|
||||||
|
// Dans les deux cas le bilan publie quand même ses chiffres — ils restent
|
||||||
|
// informatifs — mais il le DIT, pour qu'aucun câblage futur ne s'y fie en
|
||||||
|
// silence. Mesuré le 25/08 : sans cette distinction, la nuit précédant la mise
|
||||||
|
// en service de la courbe horaire, le bilan a publié un SOC requis de 100 %
|
||||||
|
// avec un statut `ok`.
|
||||||
|
const degraded =
|
||||||
|
!balance.crossing.found ||
|
||||||
|
!(balance.confidence && balance.confidence.deficitTrustworthy);
|
||||||
|
setText('input_text.evcs_balance_status', degraded ? 'degrade' : 'ok');
|
||||||
setNumber('input_number.evcs_balance_required_soc', c.requiredSocPct);
|
setNumber('input_number.evcs_balance_required_soc', c.requiredSocPct);
|
||||||
setNumber('input_number.evcs_balance_required_soc_prudent', c.prudent.requiredSocPct);
|
setNumber('input_number.evcs_balance_required_soc_prudent', c.prudent.requiredSocPct);
|
||||||
setNumber('input_number.evcs_balance_energy_to_cover_kwh', c.energyToCoverKwh);
|
setNumber('input_number.evcs_balance_energy_to_cover_kwh', c.energyToCoverKwh);
|
||||||
@@ -93,10 +107,13 @@ const ndText = nd.found
|
|||||||
? ` | prochaine traversée ${nd.energyKwh} kWh dès ${nd.startHour} h → SOC requis ${nd.requiredSocAtStartPct}%${nd.feasibleWithCurrentStorage ? '' : ` (manque ${Math.round(nd.storageShortfallKwh * 100) / 100} kWh de stockage)`}`
|
? ` | prochaine traversée ${nd.energyKwh} kWh dès ${nd.startHour} h → SOC requis ${nd.requiredSocAtStartPct}%${nd.feasibleWithCurrentStorage ? '' : ` (manque ${Math.round(nd.storageShortfallKwh * 100) / 100} kWh de stockage)`}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
const degradedNote = !balance.crossing.found
|
||||||
|
? ' ⚠ aucun croisement sur l\'horizon : chiffre indicatif, pas une exigence datée.'
|
||||||
|
: (degraded ? ' ⚠ forme de production minorée sur les heures de déficit.' : '');
|
||||||
const reason = balance.crossing.found
|
const reason = balance.crossing.found
|
||||||
? `Croisement à ${balance.crossing.hour} h, ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}% (actuel ${c.currentSocPct}%). ${c.gridNeeded ? `Réseau : ${c.gridEnergyNeededKwh} kWh.` : 'Réseau non nécessaire.'}`
|
? `Croisement à ${balance.crossing.hour} h, ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}% (actuel ${c.currentSocPct}%). ${c.gridNeeded ? `Réseau : ${c.gridEnergyNeededKwh} kWh.` : 'Réseau non nécessaire.'}`
|
||||||
: `Aucun croisement PV/conso sur l'horizon : ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}%.`;
|
: `Aucun croisement PV/conso sur l'horizon : ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}%.`;
|
||||||
setText('input_text.evcs_balance_reason', reason + ndText);
|
setText('input_text.evcs_balance_reason', reason + degradedNote + ndText);
|
||||||
|
|
||||||
// Trace longue durée, pour comparer jour après jour le seuil calculé au seuil
|
// Trace longue durée, pour comparer jour après jour le seuil calculé au seuil
|
||||||
// fixe encore en vigueur — c'est cette comparaison qui décidera du câblage.
|
// fixe encore en vigueur — c'est cette comparaison qui décidera du câblage.
|
||||||
|
|||||||
@@ -89,17 +89,37 @@ function bucketHourly(series) {
|
|||||||
// Deltas entre heures consécutives. Un delta négatif signale une remise à zéro
|
// Deltas entre heures consécutives. Un delta négatif signale une remise à zéro
|
||||||
// du compteur (redémarrage HA, réinitialisation d'intégration) : on jette
|
// du compteur (redémarrage HA, réinitialisation d'intégration) : on jette
|
||||||
// l'échantillon plutôt que de fabriquer une valeur.
|
// l'échantillon plutôt que de fabriquer une valeur.
|
||||||
|
//
|
||||||
|
// ⚠️ À QUELLE HEURE APPARTIENT UN DELTA ? (corrigé le 25/08/2026)
|
||||||
|
// `bucketHourly` garde la DERNIÈRE valeur de chaque heure. Le delta entre
|
||||||
|
// l'heure H−1 et l'heure H est donc l'énergie consommée PENDANT H : il se
|
||||||
|
// range sous `hours[i]`, jamais sous `hours[i-1]`.
|
||||||
|
//
|
||||||
|
// L'erreur d'un cran avait deux effets, l'un discret et l'autre grave :
|
||||||
|
// · tout le profil horaire était décalé d'une heure vers l'avant ;
|
||||||
|
// · surtout, les compteurs de cette installation sont ÉPARS — ils n'émettent
|
||||||
|
// que lorsqu'ils avancent. `ac_in_1_to_ac_out` ne peuple que 57 heures sur
|
||||||
|
// 267, `evcs_total` 57 sur 264. Une heure vide signifie « valeur inchangée,
|
||||||
|
// donc zéro énergie » : toute l'énergie d'un intervalle à trou a été
|
||||||
|
// consommée pendant l'heure FINALE, celle qui a réveillé le compteur.
|
||||||
|
// En la rangeant sous l'heure initiale, l'import réseau de la nuit suivante
|
||||||
|
// atterrissait sur la dernière heure de la nuit précédente. Mesuré le
|
||||||
|
// 25/08 : `consumption_p50[8] = 2,90 kWh/h` pour une consommation réelle de
|
||||||
|
// 0,9 — six échantillons sur douze portaient un delta étalé sur 17 heures.
|
||||||
|
// Le bilan réclamait alors deux fois trop d'énergie le matin.
|
||||||
function deltaSeries(buckets) {
|
function deltaSeries(buckets) {
|
||||||
const hours = [...buckets.keys()].sort((a, b) => a - b);
|
const hours = [...buckets.keys()].sort((a, b) => a - b);
|
||||||
const deltas = new Map();
|
const deltas = new Map();
|
||||||
let resets = 0;
|
let resets = 0;
|
||||||
|
let spannedGaps = 0;
|
||||||
for (let i = 1; i < hours.length; i++) {
|
for (let i = 1; i < hours.length; i++) {
|
||||||
const d = buckets.get(hours[i]).value - buckets.get(hours[i - 1]).value;
|
const d = buckets.get(hours[i]).value - buckets.get(hours[i - 1]).value;
|
||||||
if (d < 0) { resets++; continue; }
|
if (d < 0) { resets++; continue; }
|
||||||
if (d > CFG.MAX_PLAUSIBLE_HOURLY_KWH) { resets++; continue; }
|
if (d > CFG.MAX_PLAUSIBLE_HOURLY_KWH) { resets++; continue; }
|
||||||
deltas.set(hours[i - 1], d);
|
if (hours[i] - hours[i - 1] > 3600000 && d > 0) spannedGaps++;
|
||||||
|
deltas.set(hours[i], d);
|
||||||
}
|
}
|
||||||
return { deltas, resets };
|
return { deltas, resets, spannedGaps };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -261,6 +281,14 @@ return [{
|
|||||||
socHoursUsed: socHourly.size,
|
socHoursUsed: socHourly.size,
|
||||||
hoursWithEvcs: evcsHours,
|
hoursWithEvcs: evcsHours,
|
||||||
countersReset: fromInverter.resets + fromGrid.resets + fromEvcs.resets,
|
countersReset: fromInverter.resets + fromGrid.resets + fromEvcs.resets,
|
||||||
|
// Nombre de deltas couvrant plus d'une heure : c'est le signe que le
|
||||||
|
// compteur est resté silencieux (donc à l'arrêt). L'énergie est rangée
|
||||||
|
// sous l'heure qui l'a réveillé. Un chiffre élevé n'est pas une anomalie
|
||||||
|
// sur cette installation — le réseau et la borne sont éteints la plupart
|
||||||
|
// du temps — mais il faut le voir pour ne pas relire un profil sans le
|
||||||
|
// savoir.
|
||||||
|
spannedGaps:
|
||||||
|
fromInverter.spannedGaps + fromGrid.spannedGaps + fromEvcs.spannedGaps,
|
||||||
unreliableHours: profile.filter(h => !h.reliable).map(h => h.hour),
|
unreliableHours: profile.filter(h => !h.reliable).map(h => h.hour),
|
||||||
},
|
},
|
||||||
cfg: CFG,
|
cfg: CFG,
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ const SUMMARIZE = {
|
|||||||
csvFitsHelper: o.csv.p80.length <= 255 && o.csv.p50.length <= 255,
|
csvFitsHelper: o.csv.p80.length <= 255 && o.csv.p50.length <= 255,
|
||||||
p50AtHour14: o.profile[14].p50,
|
p50AtHour14: o.profile[14].p50,
|
||||||
p80AtHour14: o.profile[14].p80,
|
p80AtHour14: o.profile[14].p80,
|
||||||
|
// Heures 1 à 3 et 8 : sert au scénario d'attribution horaire, où un
|
||||||
|
// compteur épars ne se réveille que la nuit.
|
||||||
|
p50Hours1a3: o.profile.slice(1, 4).map(h => h.p50).join(','),
|
||||||
|
p50AtHour8: o.profile[8].p50,
|
||||||
|
spannedGaps: o.quality.spannedGaps,
|
||||||
}),
|
}),
|
||||||
RESOURCE_ESTIMATE: o => ({
|
RESOURCE_ESTIMATE: o => ({
|
||||||
usable: o.usable,
|
usable: o.usable,
|
||||||
@@ -93,6 +98,7 @@ SUMMARIZE.BUILD_HA_CALLS = (calls) => {
|
|||||||
wroteGridNeeded: entities.includes('input_boolean.evcs_balance_grid_needed'),
|
wroteGridNeeded: entities.includes('input_boolean.evcs_balance_grid_needed'),
|
||||||
hasLogbook: calls.some(c => c.json.domain === 'logbook'),
|
hasLogbook: calls.some(c => c.json.domain === 'logbook'),
|
||||||
wroteProfiles: entities.includes('input_text.evcs_balance_consumption_p50'),
|
wroteProfiles: entities.includes('input_text.evcs_balance_consumption_p50'),
|
||||||
|
status: (calls.find(c => c.json.data.entity_id === 'input_text.evcs_balance_status') || { json: { data: {} } }).json.data.value,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
SUMMARIZE.BUILD_HA_CALLS_PROFILES = SUMMARIZE.BUILD_HA_CALLS;
|
SUMMARIZE.BUILD_HA_CALLS_PROFILES = SUMMARIZE.BUILD_HA_CALLS;
|
||||||
|
|||||||
@@ -83,6 +83,55 @@ export const profileScenarios = [
|
|||||||
unreliableHours: '0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23',
|
unreliableHours: '0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
node: 'DAILY_PROFILES',
|
||||||
|
id: 'compteur-epars-attribution-horaire',
|
||||||
|
label:
|
||||||
|
'Compteur épars (réseau allumé seulement la nuit) : chaque kWh se range sous l\'heure qui l\'a consommé',
|
||||||
|
// Reproduit le défaut mesuré le 25/08/2026 : `ac_in_1_to_ac_out` n'émet que
|
||||||
|
// pendant les heures creuses. Entre deux rafales, le compteur reste muet
|
||||||
|
// pendant 21 heures. L'énergie d'un intervalle à trou appartient à l'heure
|
||||||
|
// qui a réveillé le compteur, pas à celle où il s'était endormi.
|
||||||
|
snapshot: (() => {
|
||||||
|
const inverter = [];
|
||||||
|
const grid = [];
|
||||||
|
let cumInverter = 0;
|
||||||
|
let cumGrid = 0;
|
||||||
|
const start = Date.parse('2026-08-01T00:00:00+02:00');
|
||||||
|
// Rafale réseau : 1 kWh à 01 h, 2 kWh à 02 h, 3 kWh à 03 h. Montants
|
||||||
|
// distincts pour qu'un décalage d'un cran soit impossible à confondre.
|
||||||
|
const burst = { 1: 1, 2: 2, 3: 3 };
|
||||||
|
for (let h = 0; h < 12 * 24; h++) {
|
||||||
|
const ts = new Date(start + h * 3600000);
|
||||||
|
const hourParis = Number.parseInt(
|
||||||
|
new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
timeZone: 'Europe/Paris', hour: '2-digit', hour12: false,
|
||||||
|
}).format(ts),
|
||||||
|
10
|
||||||
|
);
|
||||||
|
cumInverter += 0.8;
|
||||||
|
inverter.push({ ts: ts.toISOString(), state: String(cumInverter.toFixed(2)) });
|
||||||
|
const kwh = burst[hourParis];
|
||||||
|
if (kwh) {
|
||||||
|
cumGrid += kwh;
|
||||||
|
// Le compteur n'émet QUE pendant les heures où il avance.
|
||||||
|
grid.push({ ts: ts.toISOString(), state: String(cumGrid.toFixed(2)) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
series: { inverter_to_ac_out: inverter, ac_in_to_ac_out: grid, evcs_total: [] },
|
||||||
|
nowMs: NOW,
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
expect: {
|
||||||
|
// 0,8 de fond + la rafale de l'heure : 1,8 / 2,8 / 3,8.
|
||||||
|
p50Hours1a3: '1.8,2.8,3.8',
|
||||||
|
// 08 h ne consomme que le fond : c'est l'heure qui portait 2,90 kWh/h
|
||||||
|
// en production avant correction.
|
||||||
|
p50AtHour8: 0.8,
|
||||||
|
complete: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// --- RESOURCE_ESTIMATE -------------------------------------------------
|
// --- RESOURCE_ESTIMATE -------------------------------------------------
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ const BALANCE_OK = {
|
|||||||
prudent: { requiredSocPct: 38.1 },
|
prudent: { requiredSocPct: 38.1 },
|
||||||
},
|
},
|
||||||
vehicle: { surplusAvailableKwh: 20.17 },
|
vehicle: { surplusAvailableKwh: 20.17 },
|
||||||
|
confidence: { deficitTrustworthy: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Le même bilan, mais sans croisement trouvé sur l'horizon : le SOC requis
|
||||||
|
// n'est plus une exigence datée, seulement la somme d'un horizon entier. Le
|
||||||
|
// statut doit le dire.
|
||||||
|
const BALANCE_SANS_CROISEMENT = {
|
||||||
|
...BALANCE_OK,
|
||||||
|
crossing: { found: false, hour: null },
|
||||||
|
coverage: { ...BALANCE_OK.coverage, requiredSocPct: 100, energyToCoverKwh: 25.97 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const shadowScenarios = [
|
export const shadowScenarios = [
|
||||||
@@ -45,6 +55,22 @@ export const shadowScenarios = [
|
|||||||
wroteRequiredSoc: true,
|
wroteRequiredSoc: true,
|
||||||
wroteGridNeeded: true,
|
wroteGridNeeded: true,
|
||||||
hasLogbook: true,
|
hasLogbook: true,
|
||||||
|
status: 'ok',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: 'BUILD_HA_CALLS',
|
||||||
|
id: 'ombre-sans-croisement-statut-degrade',
|
||||||
|
label:
|
||||||
|
'Aucun croisement sur l\'horizon : les chiffres sont publiés mais le statut passe à `degrade`',
|
||||||
|
snapshot: {
|
||||||
|
...BALANCE_SANS_CROISEMENT,
|
||||||
|
__nodes: { 'HA Snapshot Balance': SNAPSHOT, CENSORSHIP: CENSORSHIP_LIBRE },
|
||||||
|
},
|
||||||
|
expect: {
|
||||||
|
outOfScope: '',
|
||||||
|
wroteRequiredSoc: true,
|
||||||
|
status: 'degrade',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
84
tools/ha-history.mjs
Normal file
84
tools/ha-history.mjs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
#!/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('');
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user