diff --git a/HANDOFF.md b/HANDOFF.md index 8e520c4..961ff7a 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4794,6 +4794,103 @@ Pour memoire : l'acces SSH par cle a 10.10.10.22 fonctionne et `sudo` y est sans mot de passe — la demande de validation avant un `sudo` releve de la regle posee par le proprietaire, pas d'une incapacite. +### Le plafond a 93 % de SOC etait notre reglage, pas une protection +Mesure du 06/09 a 15:01, quatre modules du pack recent : + +| | | +|---|---:| +| ce que le BMS AUTORISE (`maximum_allowed_charging_voltage`) | **53,2 V** | +| ce que nous IMPOSIONS | **51,3 V**, soit 3,42 V par cellule | +| cellules du pack 2 | 3 386 a 3 423 mV, ecart 25-30 mV par module | +| premiere alarme haute du BMS | 3 480 mV — la plus haute cellule 57 mV en dessous | + +Aucune alarme, et le BMS autorisait encore 40 A de charge : c'etait la consigne +de tension qui plafonnait le SOC. Origine : la gestion « tension de cellule +elevee » posee pour proteger les cellules faibles du pack ANCIEN (cellule 12 du +...0322 a 3 649 mV, cellule 15 du ...0324), **hors service depuis le 05/09**. +Le garde-fou protegeait un parc qui n'est plus la, au prix d'environ +**0,68 kWh** — 12 % de l'energie utile a quatre modules, precisement ce qui +manque en fin de nuit. Un Pylontech recale en outre sa jauge de SOC sur une +charge pleine : ne jamais y arriver la fait deriver. + +⚠️ **`input_number.victron_battery_charge_voltage_normal_v` ET SES PALIERS +hcv_step1/step2 NE SONT LUS PAR PERSONNE.** Verifie : le nom n'apparait nulle +part dans l'Auto Charge Manager. Les tensions sont une table CODEE EN DUR dans +le node « Compute Charge Targets » — meme famille que le toggle +`allow_grid_hc_night`, parse mais cable nulle part jusqu'a P7. La vraie +protection est le BMS lui-meme, qui a demontre le 05/09 une reaction en CINQ +SECONDES (cellule au-dessus de 3 550 mV -> plafond de courant effondre) ; elle +reste entiere. + +Table des profils jour de l'ACM, par SOC : + +| SOC | profil | courant | tension | +|---|---|---:|---:| +| < 80 | pv_boost | 130 A | 51,5 V | +| 80-90 | pv_mid | 110 A | 51,2 V | +| **90-95** | **pv_topoff** | 80 A | **51,3 -> 52,4 V** | +| >= 95 | pv_hold | 30 A | 50,6 V | + +Seul `pv_topoff` est modifie : c'est la tranche ou ca plafonnait. `pv_boost` et +`pv_mid` sont limites par le COURANT, pas par la tension. `pv_hold` n'est pas +touche — redescendre a 50,6 V une fois le haut atteint est le bon comportement +pour du LiFePO4, on ne veut pas y sejourner. + +52,4 V = **3,493 V par cellule**, SOUS la tension de charge nominale annoncee +par Pylontech pour l'US2000C (52,5 V) et bien sous les 53,2 V que ce BMS +autorise. Avec 30 mV d'ecart entre cellules, la plus haute atteint ~3,51 V, +encore 40 mV sous le seuil de reduction. + +**Effet immediat, verifie a 15:14** : consigne DVCC 52,4 V, tension du parc +51,12 -> **52,33 V**, courant batterie **-5,3 -> +16,1 A**, SOC 93 -> 94 %, +`tension_de_cellule_elevee` toujours `no_alarm`, plafond BMS inchange a 40 A. +Le parc reprend de la charge. + +Patch rejouable : `tools/patch-20260906c-tension-de-charge-52v4.mjs`. L'Auto +Charge Manager entre au passage dans le depot : +`workflows/victron-auto-charge-manager-v1.2.json` — troisieme workflow +structurant, il n'etait suivi nulle part. Il n'a **pas** de suite de tests. + +### Rapport de nuit par courriel — et pourquoi il ne vit pas dans ma session +Le proprietaire le lit en voiture, en se le faisant lire a voix haute. D'ou la +forme : pas de tableau, pas de liste a tirets, phrases courtes, et les nombres +ecrits comme on les prononce (« 4,2 kilowattheures », virgule et non point). + +⚠️ **Ce qui ne marche PAS pour surveiller la nuit** : `CronCreate` est en +memoire dans la session Claude, ne se declenche que si le REPL est vivant et +inactif, et disparait avec la session. Un sous-agent meurt avec elle aussi. Le +crontab pose le 05/09 est sur le PC Ubuntu du proprietaire et ne lance que le +releve des cellules. + +Et il n'y a **rien a surveiller** : le service d'historisation tourne toutes les +soixante secondes sur une machine allumee en permanence et enregistre deja tout. +Ce qui manquait n'etait pas de la surveillance mais une LECTURE. Le rapport vit +donc DANS le service (`tools/rapport-nuit.mjs`, appele par +`historise-evcs.mjs`), envoye a 6 h 47 avec rattrapage jusqu'a 10 h. +Idempotence par une ligne `evenement` de genre `rapport` — aucun fichier +d'etat, et redemarrer le conteneur ne renvoie pas le courriel. + +Canal : le webhook n8n **deja existant et actif** « Webhook Email solar v2 » +(`https://n8n.airtria.pro/webhook/solar-email`, id `vfPbPFtfZt58HOt2`), en GET +avec `to`, `subject`, `message`. Aucun identifiant nouveau : il porte un compte +SMTP configure. ⚠️ Il declare POST dans ses parametres mais n'est enregistre +qu'en GET — un POST renvoie 404. Teste de bout en bout le 06/09 a 15:10, +execution n8n en succes. + +Chemins ecartes en route, pour ne pas les re-explorer : `notify` de Home +Assistant n'a que les applications mobiles, aucun SMTP ; `synodsmnotify` refuse +le texte libre (« is neither mail string key nor i18n format ») ; +`notification_send` se bloque ; `sendmail` de DSM rend 0 sans journal, donc sans +preuve de remise ; et DSM n'a **pas** node, donc un cron hote ne peut rien +executer. + +Le rapport dit : les fermetures du contacteur et l'energie importee, la +trajectoire du SOC avec l'heure du minimum et l'alerte si le plancher a ete +touche, les sessions de charge par vehicule, l'injection residuelle — **avec un +jugement conditionne a la POINTE et non a l'energie**, parce qu'une pointe a +2 819 W pendant deux minutes n'a rien d'anodin meme si le total reste faible — +et les incidents techniques. + ### Reste ouvert - Detecteur de gel dans le service d'historisation : une grandeur qui ne bouge pas alors qu'une autre du meme bilan bouge = alerte. Non fait. diff --git a/docs/mesures/batteries/202609052040-suivi.csv b/docs/mesures/batteries/202609052040-suivi.csv index bf6a26c..5ffdb84 100644 --- a/docs/mesures/batteries/202609052040-suivi.csv +++ b/docs/mesures/batteries/202609052040-suivi.csv @@ -428,3 +428,15 @@ heure_reelle,etat,courant_mA,c1_mV,c2_mV,c3_mV,c4_mV,c5_mV,c6_mV,c7_mV,c8_mV,c9_ 2026-09-06T12:41:08.607Z,Y230202C30371425,,3413,3415,3410,3419,3413,3409,3414,3410,3415,3415,3415,3415,3397,3414,3405,22 2026-09-06T12:41:08.702Z,Y230202C30371431,,3421,3419,3419,3419,3420,3420,3419,3419,3420,3393,3421,3419,3419,3419,3420,28 2026-09-06T12:41:08.828Z,Y230202C30371427,,3423,3416,3421,3418,3402,3422,3417,3409,3422,3422,3420,3420,3425,3426,3407,24 +2026-09-06T12:51:08.932Z,Y230202C30371426,,3419,3418,3419,3418,3419,3418,3418,3419,3390,3419,3419,3418,3419,3418,3418,29 +2026-09-06T12:51:09.059Z,Y230202C30371425,,3422,3422,3417,3427,3422,3418,3422,3418,3423,3424,3424,3423,3404,3422,3414,23 +2026-09-06T12:51:09.155Z,Y230202C30371431,,3414,3415,3414,3415,3416,3414,3415,3414,3415,3388,3414,3415,3415,3415,3416,28 +2026-09-06T12:51:09.282Z,Y230202C30371427,,3420,3414,3419,3416,3399,3418,3414,3407,3420,3421,3417,3418,3423,3424,3404,25 +2026-09-06T13:01:09.386Z,Y230202C30371426,,3413,3414,3415,3415,3413,3414,3414,3415,3386,3414,3414,3414,3415,3415,3413,29 +2026-09-06T13:01:09.485Z,Y230202C30371425,,3411,3414,3408,3418,3411,3407,3413,3408,3414,3413,3413,3413,3393,3412,3403,25 +2026-09-06T13:01:09.612Z,Y230202C30371431,,3423,3423,3422,3422,3422,3423,3423,3422,3423,3393,3423,3423,3422,3423,3422,30 +2026-09-06T13:01:09.735Z,Y230202C30371427,,3419,3412,3417,3413,3394,3417,3413,3405,3417,3416,3415,3417,3421,3421,3400,27 +2026-09-06T13:11:09.817Z,Y230202C30371426,,3427,3425,3427,3427,3426,3427,3425,3427,3397,3426,3427,3425,3427,3426,3426,30 +2026-09-06T13:11:09.944Z,Y230202C30371425,,3328,3327,3327,3328,3328,3327,3327,3326,3327,3328,3328,3327,3326,3327,3327,2 +2026-09-06T13:11:10.099Z,Y230202C30371431,,3323,3323,3324,3324,3325,3323,3323,3324,3324,3322,3324,3323,3324,3324,3325,3 +2026-09-06T13:11:10.225Z,Y230202C30371427,,3328,3327,3327,3327,3326,3328,3326,3326,3327,3328,3328,3327,3328,3328,3326,2 diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index ccddc47..0339d13 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -29,6 +29,7 @@ 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'); @@ -650,6 +651,51 @@ async function ecrireSession(sql, client, st) { 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 @@ -717,12 +763,15 @@ async function cycle(client) { // 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; + 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' })} ` + diff --git a/tools/patch-20260906c-tension-de-charge-52v4.mjs b/tools/patch-20260906c-tension-de-charge-52v4.mjs new file mode 100644 index 0000000..c3a2d06 --- /dev/null +++ b/tools/patch-20260906c-tension-de-charge-52v4.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node +// Le parc plafonnait a 93 % de SOC, et ce n'etait pas une protection. +// +// Mesure du 06/09 a 15:01, quatre modules du pack recent : +// ce que le BMS AUTORISE : 53,2 V (`maximum_allowed_charging_voltage`) +// ce que nous IMPOSIONS : 51,3 V, soit 3,42 V par cellule +// cellules : 3 386 a 3 423 mV, ecart 25-30 mV par module +// premiere alarme haute : 3 480 mV — la cellule la plus haute est 57 mV en dessous +// Aucune alarme, le BMS autorisait encore 40 A de charge : c'est la consigne de +// tension qui plafonnait le SOC, pas la batterie. +// +// Origine : la gestion « tension de cellule elevee » posee pour proteger les +// cellules faibles du pack ANCIEN (cellule 12 du module ...0322 a 3 649 mV, +// cellule 15 du ...0324). Ces modules sont hors service depuis le 05/09. Le +// garde-fou protegeait un parc qui n'est plus la, au prix du haut de la +// reserve : de 93 a ~100 % sur quatre modules, environ 0,68 kWh, soit 12 % de +// l'energie utile — precisement ce qui manque en fin de nuit. +// +// ⚠️ LE HELPER `input_number.victron_battery_charge_voltage_normal_v` (51,3) ET +// SES PALIERS hcv_step1/step2 NE SONT LUS PAR PERSONNE. Verifie : le nom +// n'apparait nulle part dans l'Auto Charge Manager. Les tensions sont une table +// codee en dur dans le node « Compute Charge Targets ». Meme famille que le +// toggle `allow_grid_hc_night`, parse mais cable nulle part jusqu'a P7. +// La VRAIE protection est le BMS lui-meme, qui a demontre le 05/09 une reaction +// en CINQ SECONDES : cellule au-dessus de 3 550 mV -> plafond de courant +// effondre. Elle reste entiere. +// +// 52,4 V = 3,493 V par cellule, SOUS la tension de charge nominale que +// Pylontech annonce pour l'US2000C (52,5 V) et bien sous les 53,2 V que ce BMS +// autorise. Avec 30 mV d'ecart entre cellules, la plus haute atteindra ~3,51 V, +// encore 40 mV sous le seuil qui declenche la reduction de courant. +// +// Seul `pv_topoff` est touche : c'est le profil de la tranche 90-95 % de SOC, +// exactement la ou ca plafonnait. `pv_boost` (SOC < 80) et `pv_mid` (80-90) +// sont limites par le courant, pas par la tension. `pv_hold` (SOC >= 95, 50,6 V) +// n'est PAS touche : redescendre une fois le haut atteint est le bon +// comportement pour du LiFePO4, on ne veut pas y sejourner. +// +// node tools/patch-20260906c-tension-de-charge-52v4.mjs + +import fs from 'node:fs'; + +const F = 'workflows/victron-auto-charge-manager-v1.2.json'; +const wf = JSON.parse(fs.readFileSync(F, 'utf8')); +const calc = wf.nodes.find((n) => n.name === 'Compute Charge Targets'); +if (!calc) throw new Error('noeud "Compute Charge Targets" introuvable'); + +let c = calc.parameters.jsCode; +const avant = ' pv_topoff: { current_a: 80, voltage_v: 51.3 },'; +const apres = ' pv_topoff: { current_a: 80, voltage_v: 52.4 },'; + +if (c.includes(apres)) { + console.log(' deja fait'); +} else { + if (!c.includes(avant)) throw new Error('ancre pv_topoff introuvable'); + c = c.replace(avant, apres); + calc.parameters.jsCode = c; + fs.writeFileSync(F, JSON.stringify(wf, null, 2) + '\n'); + console.log(' pv_topoff : 51.3 -> 52.4 V (3,493 V/cellule)'); +} diff --git a/tools/rapport-nuit.mjs b/tools/rapport-nuit.mjs new file mode 100644 index 0000000..50e1a47 --- /dev/null +++ b/tools/rapport-nuit.mjs @@ -0,0 +1,220 @@ +#!/usr/bin/env node +// ===================================================================== +// RAPPORT DE NUIT — texte simple, destine a etre LU A VOIX HAUTE +// ===================================================================== +// Le proprietaire le recoit par courriel le matin, souvent en voiture : il se +// le fait lire. D'ou les contraintes de forme, qui ne sont pas cosmetiques — +// pas de tableau, pas de tiret de liste, pas d'abreviation, des phrases +// courtes, et les nombres ecrits comme on les prononce (« 4,2 kilowattheures » +// et non « 4.2 kWh »). +// +// Il n'y a RIEN a surveiller en direct : le service d'historisation tourne +// toutes les soixante secondes sur une machine allumee en permanence et +// enregistre deja tout. Ce fichier ne surveille pas, il LIT — et c'est +// pourquoi il vit dans le service plutot que dans une session interactive, +// qui, elle, s'arrete des que l'ecran se ferme. +// +// Usage : +// node tools/rapport-nuit.mjs # affiche le rapport +// node tools/rapport-nuit.mjs --envoyer # l'envoie par courriel +// node tools/rapport-nuit.mjs --jour 2026-09-05 +// ===================================================================== + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import pg from 'pg'; + +const RACINE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const WEBHOOK = 'https://n8n.airtria.pro/webhook/solar-email'; +const DESTINATAIRE = 'tallagrand.thierry@gmail.com'; + +function envFichier() { + const out = {}; + for (const f of [path.join(RACINE, '.env'), '/app/.env']) { + if (!fs.existsSync(f)) continue; + for (const l of fs.readFileSync(f, 'utf8').split('\n')) { + const i = l.indexOf('='); + if (i > 0 && !l.trimStart().startsWith('#')) out[l.slice(0, i).trim()] = l.slice(i + 1).trim(); + } + } + return out; +} + +// « 4.2 » -> « 4,2 » : une virgule se lit, un point ne se lit pas. +const n1 = (v) => (v === null || v === undefined || !Number.isFinite(Number(v))) + ? null : Number(v).toFixed(1).replace('.', ','); +const n2 = (v) => (v === null || v === undefined || !Number.isFinite(Number(v))) + ? null : Number(v).toFixed(2).replace('.', ','); +const n0 = (v) => (v === null || v === undefined || !Number.isFinite(Number(v))) + ? null : Math.round(Number(v)).toString(); + +const heure = (d) => new Date(d).toLocaleTimeString('fr-FR', + { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' }).replace(':', ' h '); + +function pluriel(n, sing, plur) { return n <= 1 ? `${n} ${sing}` : `${n} ${plur}`; } + +// --------------------------------------------------------------------- +// Le rapport lui-meme. `jour` est la date du MATIN ; la nuit examinee va de +// 18 heures la veille a 10 heures ce matin-la. +// --------------------------------------------------------------------- +export async function texteRapport(client, jour = new Date()) { + const fin = new Date(jour); fin.setHours(10, 0, 0, 0); + const debut = new Date(fin); debut.setDate(debut.getDate() - 1); debut.setHours(18, 0, 0, 0); + const q = (sql, v = []) => client.query(sql, v); + const p = []; // paragraphes + const dire = (...l) => p.push(l.join(' ')); + + const dateLue = fin.toLocaleDateString('fr-FR', + { timeZone: 'Europe/Paris', weekday: 'long', day: 'numeric', month: 'long' }); + dire(`Rapport de la nuit du ${dateLue}.`); + + // ------------------------------------------------ le reseau + const { rows: bascules } = await q( + `SELECT ts, detail->>'apres' AS apres FROM evenement + WHERE genre = 'reseau' AND resume LIKE 'Contacteur EDF%' + AND ts >= $1 AND ts < $2 ORDER BY ts`, [debut, fin]); + const fermetures = bascules.filter((b) => b.apres === 'on'); + + const { rows: [e] } = await q( + `WITH mn AS (SELECT generate_series($1::timestamptz, $2::timestamptz, interval '1 minute') t), + j AS (SELECT mn.t, + (SELECT valeur FROM mesure m WHERE m.cle = 'sensor.victron_grid_power_l1' + AND m.ts <= mn.t ORDER BY m.ts DESC LIMIT 1) g, + (SELECT valeur FROM mesure m WHERE m.cle = 'sensor.victron_evcs_ac_power' + AND m.ts <= mn.t ORDER BY m.ts DESC LIMIT 1) v, + (SELECT valeur FROM mesure m WHERE m.cle = 'sensor.victron_battery_power' + AND m.ts <= mn.t ORDER BY m.ts DESC LIMIT 1) b + FROM mn) + SELECT coalesce(sum(g) FILTER (WHERE g > 0), 0) / 60000.0 AS importe, + coalesce(-sum(g) FILTER (WHERE g < 0), 0) / 60000.0 AS injecte, + coalesce(sum(v) FILTER (WHERE v > 0), 0) / 60000.0 AS voiture, + coalesce(sum(b) FILTER (WHERE b > 0), 0) / 60000.0 AS batterie_chargee, + count(*) FILTER (WHERE g < 0) AS points_negatifs, + min(g) AS pire_injection + FROM j`, [debut, fin]); + + if (!fermetures.length) { + dire('Le contacteur est reste ouvert toute la nuit : aucune energie achetee au reseau.'); + } else { + dire(`Le reseau a ete ferme ${pluriel(fermetures.length, 'fois', 'fois')},`, + `a ${fermetures.map((f) => heure(f.ts)).join(', puis a ')}.`, + `Au total ${n1(e.importe)} kilowattheures importes.`); + } + + // ------------------------------------------------ la batterie + const { rows: [b] } = await q( + `SELECT max(valeur) AS haut, min(valeur) AS bas, + (SELECT ts FROM mesure m2 WHERE m2.cle = 'sensor.victron_battery_soc' + AND m2.ts >= $1 AND m2.ts < $2 + ORDER BY m2.valeur ASC, m2.ts ASC LIMIT 1) AS heure_bas, + -- Valeur AU DEBUT de la fenetre, pas le maximum : dire « partie de » + -- en donnant le maximum de la nuit ferait commencer le recit par un + -- chiffre atteint plus tard. + (SELECT valeur FROM mesure m1 WHERE m1.cle = 'sensor.victron_battery_soc' + AND m1.ts <= $1 ORDER BY m1.ts DESC LIMIT 1) AS depart, + (SELECT valeur FROM mesure m3 WHERE m3.cle = 'sensor.victron_battery_soc' + AND m3.ts < $2 ORDER BY m3.ts DESC LIMIT 1) AS maintenant + FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts >= $1 AND ts < $2`, + [debut, fin]); + + if (b && b.bas !== null) { + dire(`La batterie est partie de ${n0(b.depart ?? b.haut)} pour cent hier soir,`, + `elle est descendue au plus bas a ${n0(b.bas)} pour cent vers ${heure(b.heure_bas)},`, + `et elle est ce matin a ${n0(b.maintenant)} pour cent.`); + if (Number(b.bas) <= 20) { + dire('Attention : elle a touche son plancher de vingt pour cent,', + 'donc le reseau a pris le relais de lui-meme.'); + } + } + + // ------------------------------------------------ la voiture + const { rows: sessions } = await q( + `SELECT debut, vehicule, energie_kwh, energie_compteur_kwh, energie_station_kwh + FROM session_charge WHERE debut >= $1 AND debut < $2 ORDER BY debut`, [debut, fin]); + if (!sessions.length) { + dire('Aucun vehicule n\'a charge cette nuit.'); + } else { + for (const s of sessions) { + const kwh = s.energie_compteur_kwh ?? s.energie_kwh; + dire(`Le vehicule ${s.vehicule ?? 'non identifie'} a charge a partir de ${heure(s.debut)},`, + `et il a pris ${n1(kwh)} kilowattheures.`); + } + } + + // ------------------------------------------------ ce qu'on a deploye le 6 + const { rows: [d] } = await q( + `SELECT count(*) FILTER (WHERE etat = 'hold_on_charge_encore_elevee') AS maintiens, + count(*) FILTER (WHERE etat LIKE 'turn_on_charge%') AS protections + FROM decision WHERE ts >= $1 AND ts < $2`, [debut, fin]); + const { rows: [h] } = await q( + `SELECT count(*) AS n FROM evenement + WHERE ts >= $1 AND ts < $2 AND resume LIKE '%charge maison%'`, [debut, fin]); + + // ⚠️ Ne jamais qualifier l'injection de negligeable sans regarder la pointe : + // le 6 septembre, une pointe a 2 819 watts pendant deux minutes n'avait rien + // d'anodin, alors que l'energie totale restait faible. + if (Number(e.points_negatifs) === 0) { + dire('Cote qualite du raccordement, aucune minute d\'injection vers le reseau.'); + } else { + const pointe = Math.abs(Number(e.pire_injection)); + dire(`Il reste ${pluriel(Number(e.points_negatifs), 'minute', 'minutes')} ou le compteur`, + `est passe en negatif, au plus fort ${n0(pointe)} watts,`, + `soit ${n0(e.injecte * 1000)} wattheures en tout.`, + pointe >= 1000 + ? 'Une pointe de cette taille merite un coup d\'oeil : elle vient d\'une charge' + + ' qui disparait d\'un coup alors que la batterie ne peut plus absorber.' + : 'C\'est du bruit de regulation, sans consequence.'); + } + + // ------------------------------------------------ anomalies + const { rows: soucis } = await q( + `SELECT resume FROM evenement WHERE ts >= $1 AND ts < $2 + AND (resume ILIKE '%injoignable%' OR resume ILIKE '%redemarr%' + OR resume ILIKE '%erreur%' OR resume ILIKE '%echec%') + ORDER BY ts`, [debut, fin]); + if (soucis.length) { + dire(`A signaler : ${soucis.map((s) => s.resume.toLowerCase()).join('. ')}.`); + } else { + dire('Aucun incident technique pendant la nuit.'); + } + + // ------------------------------------------------ la conclusion + const vider = Number(b?.bas ?? 100) <= 20; + const cher = Number(e.importe) > 8; + dire(vider || cher + ? 'En resume, la nuit a demande plus au reseau que prevu. Le detail est dans le depot.' + : 'En resume, la nuit s\'est passee comme prevu.'); + + return p.join('\n\n'); +} + +// --------------------------------------------------------------------- +export async function envoyer(texte, sujet) { + const url = `${WEBHOOK}?${new URLSearchParams( + { to: DESTINATAIRE, subject: sujet, message: texte })}`; + const r = await fetch(url); + if (!r.ok) throw new Error(`webhook courriel : HTTP ${r.status}`); + return true; +} + +// --------------------------------------------------------------------- +const estCli = process.argv[1] && process.argv[1].endsWith('rapport-nuit.mjs'); +if (estCli) { + const env = envFichier(); + const arg = (n) => { const i = process.argv.indexOf(n); return i > 0 ? process.argv[i + 1] : null; }; + const jour = arg('--jour') ? new Date(arg('--jour') + 'T12:00:00') : new Date(); + const client = new pg.Client({ + host: process.env.PGHOST || '10.10.10.22', + port: Number(process.env.PGPORT || 5433), + database: 'evcs', user: 'evcs_lecture', password: env.PG_LECTURE_PASSWORD, + }); + await client.connect(); + const texte = await texteRapport(client, jour); + await client.end(); + console.log(texte); + if (process.argv.includes('--envoyer')) { + await envoyer(texte, 'EVCS - rapport de la nuit'); + console.log('\n[courriel envoye]'); + } +} diff --git a/workflows/victron-auto-charge-manager-v1.2.json b/workflows/victron-auto-charge-manager-v1.2.json new file mode 100644 index 0000000..1a95065 --- /dev/null +++ b/workflows/victron-auto-charge-manager-v1.2.json @@ -0,0 +1,457 @@ +{ + "updatedAt": "2026-07-09T21:45:53.243Z", + "createdAt": "2026-03-13T01:18:19.435Z", + "id": "cTeeHy4ePiVeELLh", + "name": "Victron - Auto Charge Manager V1.2", + "description": null, + "active": true, + "isArchived": false, + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "minutes" + } + ] + } + }, + "id": "3bc36d27-0e92-4207-bbde-7f23efbc1881", + "name": "Schedule Trigger", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [ + 0, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "=http://10.10.10.17:8123/api/template\n", + "authentication": "genericCredentialType", + "genericAuthType": "httpBearerAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Accept", + "value": "text/plain" + } + ] + }, + "sendBody": true, + "bodyParameters": { + "parameters": [ + { + "name": "template", + "value": "{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_voltage_v\": states(\"sensor.victron_battery_voltage\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"edf_switch\": states(\"switch.edf_general_ok_switch\"),\n \"today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n \"tomorrow_kwh\": states(\"sensor.energy_production_tomorrow\"),\n \"current_max_charge_current_a\": states(\"sensor.victron_max_charge_current_setting\"),\n \"current_max_charge_voltage_v\": states(\"sensor.victron_max_charge_voltage_setting\"),\n \"auto_enabled\": states(\"input_boolean.victron_auto_charge_enabled\"),\n \"night_takeover\": states(\"input_boolean.victron_evcs_night_grid_takeover_needed\"),\n \"night_target_soc\": states(\"input_number.victron_evcs_night_target_soc\"),\n \"notifications_enabled\": states(\"input_boolean.victron_auto_charge_notifications\"),\n \"last_signature\": states(\"input_text.victron_charge_last_signature\"),\n\n \"override_enabled\": states(\"input_boolean.victron_charge_override_enabled\"),\n \"override_profile\": states(\"input_select.victron_charge_override_profile\"),\n \"override_current_a\": states(\"input_number.victron_override_charge_current\"),\n \"override_voltage_v\": states(\"input_number.victron_override_charge_voltage\"),\n \"override_until\": states(\"input_datetime.victron_charge_override_until\"),\n \"override_reason\": states(\"input_text.victron_charge_override_reason\")\n} | tojson }}\n" + } + ] + }, + "options": {} + }, + "id": "3aa1ccc7-0831-4c25-b511-e4fc76c0cc83", + "name": "HA Snapshot via Template", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 240, + 0 + ], + "credentials": { + "httpBearerAuth": { + "id": "uv3sknYYigxBkaVN", + "name": "Bearer Auth account 2" + } + } + }, + { + "parameters": { + "jsCode": "function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction parseMaybeJson(v) {\n if (typeof v !== 'string') return v;\n try {\n return JSON.parse(v);\n } catch {\n return v;\n }\n}\n\nfunction extractSnapshot(input) {\n if (typeof input === 'string') return parseMaybeJson(input);\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\n if (input?.data && typeof input.data === 'object') return input.data;\n if (input?.body && typeof input.body === 'object') return input.body;\n return input;\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction timeToMinutes(hh, mm) {\n return hh * 60 + mm;\n}\n\nfunction inRange(minNow, startMin, endMin) {\n return minNow >= startMin && minNow < endMin;\n}\n\nfunction getParisNowParts() {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date())\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n const localStamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`;\n\n return {\n year: Number(parts.year),\n month: Number(parts.month),\n day: Number(parts.day),\n hour: Number(parts.hour),\n minute: Number(parts.minute),\n second: Number(parts.second),\n date,\n localStamp,\n minNow: Number(parts.hour) * 60 + Number(parts.minute),\n };\n}\n\nfunction forecastBand(kwh) {\n if (!Number.isFinite(kwh)) return 'unknown';\n if (kwh >= 25) return 'sunny';\n if (kwh >= 12) return 'mixed';\n return 'dark';\n}\n\nconst cfg = {\n winterMonths: [11, 12, 1, 2],\n\n windows: {\n nightHC_start: timeToMinutes(1, 24),\n nightHC_end: timeToMinutes(7, 24),\n\n middayHC_start: timeToMinutes(12, 24),\n middayHC_end: timeToMinutes(14, 24),\n\n winterDay_start: timeToMinutes(7, 15),\n winterDay_end: timeToMinutes(16, 45),\n\n summerDay_start: timeToMinutes(7, 30),\n summerDay_end: timeToMinutes(19, 30),\n },\n\n profiles: {\n night_slow: { current_a: 30, voltage_v: 50.1 },\n takeover_hold: { current_a: 2, voltage_v: 50.1 },\n pv_boost: { current_a: 130, voltage_v: 51.5 },\n pv_mid: { current_a: 110, voltage_v: 51.2 },\n pv_topoff: { current_a: 80, voltage_v: 52.4 },\n pv_hold: { current_a: 30, voltage_v: 50.6 },\n midday_grid_catchup: { current_a: 30, voltage_v: 50.8 },\n emergency_rescue: { current_a: 60, voltage_v: 50.4 },\n },\n\n voltagePublishDelta: 0.05,\n currentPublishDelta: 0.5,\n\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt'\n};\n\nconst MQTT_TOPIC_CHARGE_CURRENT = 'W/c0619ab34ac5/settings/0/Settings/SystemSetup/MaxChargeCurrent';\nconst MQTT_TOPIC_CHARGE_VOLTAGE = 'W/c0619ab34ac5/settings/0/Settings/SystemSetup/MaxChargeVoltage';\n\nconst raw = $input.first().json;\nconst input = extractSnapshot(raw);\nconst now = getParisNowParts();\n\nconst soc = num(input.soc_percent, 0);\nconst batteryVoltageV = num(input.battery_voltage_v, NaN);\nconst batteryCurrentA = num(input.battery_current_a, NaN);\nconst pvPowerW = num(input.pv_power_w, 0);\nconst edfSwitch = low(input.edf_switch || 'off') === 'on' ? 'on' : 'off';\n\nconst todayRemainingKwh = num(input.today_remaining_kwh, 0);\nconst tomorrowKwh = num(input.tomorrow_kwh, 0);\n\nconst currentMaxChargeCurrentA = num(input.current_max_charge_current_a, 0);\nconst currentMaxChargeVoltageV = num(input.current_max_charge_voltage_v, 0);\n\nconst autoEnabled = low(input.auto_enabled || 'off') === 'on';\nconst nightTakeover = low(input.night_takeover || 'off') === 'on';\nconst nightTargetSoc = num(input.night_target_soc, 35);\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\nconst lastSignature = (input.last_signature ?? '').toString().trim();\n\nconst overrideEnabled = low(input.override_enabled || 'off') === 'on';\nconst overrideProfile = low(input.override_profile || 'auto');\nconst overrideCurrentA = num(input.override_current_a, NaN);\nconst overrideVoltageV = num(input.override_voltage_v, NaN);\nconst overrideUntil = (input.override_until ?? '').toString().trim();\nconst overrideReason = (input.override_reason ?? '').toString().trim();\n\nconst isWinter = cfg.winterMonths.includes(now.month);\nconst inNightHC = inRange(now.minNow, cfg.windows.nightHC_start, cfg.windows.nightHC_end);\nconst inMiddayHC = inRange(now.minNow, cfg.windows.middayHC_start, cfg.windows.middayHC_end);\nconst inDayWindow = isWinter\n ? inRange(now.minNow, cfg.windows.winterDay_start, cfg.windows.winterDay_end)\n : inRange(now.minNow, cfg.windows.summerDay_start, cfg.windows.summerDay_end);\n\nconst forecastRefKwh = now.hour < 8 ? Math.max(todayRemainingKwh, tomorrowKwh, 0) : todayRemainingKwh;\nconst forecastState = forecastBand(forecastRefKwh);\n\nfunction profileValues(profileName) {\n if (profileName === 'manual') {\n return {\n current_a: Number.isFinite(overrideCurrentA) ? overrideCurrentA : cfg.profiles.pv_boost.current_a,\n voltage_v: Number.isFinite(overrideVoltageV) ? overrideVoltageV : cfg.profiles.pv_boost.voltage_v,\n };\n }\n return cfg.profiles[profileName] || null;\n}\n\nconst overrideActive =\n overrideEnabled &&\n overrideProfile !== 'auto' &&\n /^\\d{4}-\\d{2}-\\d{2}/.test(overrideUntil) &&\n now.localStamp < overrideUntil;\n\nlet mode = 'idle';\nlet effectiveProfile = 'auto_idle';\nlet targetCurrentA = currentMaxChargeCurrentA || 30;\nlet targetVoltageV = currentMaxChargeVoltageV || 50.5;\nlet criticalAlert = false;\nlet reason = [];\n\nif (!autoEnabled) {\n mode = 'automation_disabled';\n effectiveProfile = 'manual_keep_current';\n targetCurrentA = currentMaxChargeCurrentA || 30;\n targetVoltageV = currentMaxChargeVoltageV || 50.5;\n reason.push(\"Automatisation désactivée, conservation des réglages actuels\");\n} else {\n if (soc < 20 && !inNightHC && !inMiddayHC) {\n mode = 'emergency_rescue';\n effectiveProfile = 'emergency_rescue';\n targetCurrentA = cfg.profiles.emergency_rescue.current_a;\n targetVoltageV = cfg.profiles.emergency_rescue.voltage_v;\n criticalAlert = true;\n reason.push(`SOC critique ${soc}%`);\n }\n\n // P4 : takeover EVCS nocturne. EDF est ON pour la maison + le VE (HC),\n // la batterie doit seulement être RAMENÉE à la cible matin puis MAINTENUE,\n // pas rechargée à fond (cible adaptée au forecast : été ~35%, hiver ~85%).\n else if (nightTakeover && edfSwitch === 'on') {\n const holdTargetSoc = Math.min(95, Math.max(20, nightTargetSoc));\n if (soc < holdTargetSoc - 1) {\n mode = 'evcs_takeover_recover';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(`Takeover EVCS : recharge vers cible matin ${holdTargetSoc}% (SOC ${soc}%)`);\n } else {\n mode = 'evcs_takeover_hold';\n effectiveProfile = 'takeover_hold';\n targetCurrentA = cfg.profiles.takeover_hold.current_a;\n targetVoltageV = cfg.profiles.takeover_hold.voltage_v;\n reason.push(`Takeover EVCS : maintien SOC ~${holdTargetSoc}% (maison+VE sur HC, batterie en pause)`);\n }\n }\n\n else if (isWinter && inNightHC) {\n let targetSoc;\n if (forecastState === 'sunny') targetSoc = 40;\n else if (forecastState === 'mixed') targetSoc = 60;\n else targetSoc = 80;\n\n if (soc < targetSoc - 1) {\n mode = 'winter_night_charge_window';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(`Hiver HC nuit, cible SOC ${targetSoc}% selon forecast ${forecastState}`);\n } else {\n mode = 'winter_night_idle';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(`Hiver HC nuit, SOC ${soc}% déjà suffisant`);\n }\n }\n\n else if (!isWinter && inNightHC) {\n if (soc < 22) {\n mode = 'summer_night_rescue_window';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(\"Été HC nuit, secours SOC < 22%\");\n } else {\n mode = 'summer_night_idle';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(\"Été HC nuit, pas de besoin réseau\");\n }\n }\n\n else if (\n inMiddayHC &&\n soc < 85 &&\n forecastRefKwh < 8 &&\n pvPowerW < 1500\n ) {\n mode = 'midday_catchup_window';\n effectiveProfile = 'midday_grid_catchup';\n targetCurrentA = cfg.profiles.midday_grid_catchup.current_a;\n targetVoltageV = cfg.profiles.midday_grid_catchup.voltage_v;\n reason.push(`HC midi rattrapage : SOC ${soc}% | forecast ${forecastRefKwh} kWh | PV ${pvPowerW} W`);\n }\n\n else if (inDayWindow) {\n if (soc < 80) {\n mode = 'day_pv_boost';\n effectiveProfile = 'pv_boost';\n targetCurrentA = cfg.profiles.pv_boost.current_a;\n targetVoltageV = cfg.profiles.pv_boost.voltage_v;\n reason.push(`Jour SOC bas ${soc}% : capture PV max`);\n } else if (soc < 90) {\n mode = 'day_pv_mid';\n effectiveProfile = 'pv_mid';\n targetCurrentA = cfg.profiles.pv_mid.current_a;\n targetVoltageV = cfg.profiles.pv_mid.voltage_v;\n reason.push(`Jour SOC intermédiaire ${soc}%`);\n } else if (soc < 95) {\n mode = 'day_pv_topoff';\n effectiveProfile = 'pv_topoff';\n targetCurrentA = cfg.profiles.pv_topoff.current_a;\n targetVoltageV = cfg.profiles.pv_topoff.voltage_v;\n reason.push(`Jour top-off vers 95% | SOC ${soc}%`);\n } else {\n mode = 'day_pv_hold';\n effectiveProfile = 'pv_hold';\n targetCurrentA = cfg.profiles.pv_hold.current_a;\n targetVoltageV = cfg.profiles.pv_hold.voltage_v;\n reason.push(`Jour maintien haut SOC ${soc}%`);\n }\n\n if (\n Number.isFinite(batteryVoltageV) &&\n soc < 95 &&\n pvPowerW > 2000 &&\n batteryVoltageV >= (targetVoltageV - 0.15)\n ) {\n targetVoltageV = Math.max(targetVoltageV, 51.3);\n targetCurrentA = Math.max(targetCurrentA, 80);\n if (soc >= 90) {\n effectiveProfile = 'pv_topoff';\n mode = 'day_pv_topoff';\n } else {\n effectiveProfile = 'pv_boost';\n }\n reason.push(`Batterie proche consigne (${round1(batteryVoltageV)}V), relèvement anti-bridage`);\n }\n\n if (\n Number.isFinite(batteryCurrentA) &&\n soc < 95 &&\n pvPowerW > 6000 &&\n batteryCurrentA >= (targetCurrentA - 3)\n ) {\n targetCurrentA = Math.max(targetCurrentA, soc >= 90 ? 80 : 130);\n reason.push(`Courant batterie proche limite (${round1(batteryCurrentA)}A), relèvement du plafond`);\n }\n }\n\n\n else {\n mode = 'off_window_idle';\n effectiveProfile = 'pv_hold';\n targetCurrentA = cfg.profiles.pv_hold.current_a;\n targetVoltageV = cfg.profiles.pv_hold.voltage_v;\n reason.push(\"Hors fenêtre utile\");\n }\n}\n\nif (overrideActive) {\n const ov = profileValues(overrideProfile);\n if (ov) {\n targetCurrentA = ov.current_a;\n targetVoltageV = ov.voltage_v;\n effectiveProfile = `override_${overrideProfile}`;\n mode = `${mode}_override`;\n reason.unshift(\n `Override actif jusqu'à ${overrideUntil} | profil=${overrideProfile}` +\n (overrideReason ? ` | ${overrideReason}` : '')\n );\n }\n} else if (overrideEnabled && overrideProfile !== 'auto') {\n reason.push(\"Override configuré mais inactif ou expiré\");\n}\n\ntargetCurrentA = round1(targetCurrentA);\ntargetVoltageV = round1(targetVoltageV);\n\nconst shouldPublishCurrent =\n !Number.isFinite(currentMaxChargeCurrentA) ||\n Math.abs(targetCurrentA - currentMaxChargeCurrentA) >= cfg.currentPublishDelta;\n\nconst shouldPublishVoltage =\n !Number.isFinite(currentMaxChargeVoltageV) ||\n Math.abs(targetVoltageV - currentMaxChargeVoltageV) >= cfg.voltagePublishDelta;\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst signature = [\n now.date,\n mode,\n effectiveProfile,\n `i=${targetCurrentA}`,\n `v=${targetVoltageV}`\n].join('|');\n\nconst signatureChanged = signature !== lastSignature;\n\nconst result = {\n timestamp_paris: now.localStamp,\n\n inputs: {\n soc_percent: soc,\n battery_voltage_v: Number.isFinite(batteryVoltageV) ? batteryVoltageV : null,\n battery_current_a: Number.isFinite(batteryCurrentA) ? batteryCurrentA : null,\n pv_power_w: pvPowerW,\n edf_switch_observed: edfSwitch,\n today_remaining_kwh: todayRemainingKwh,\n tomorrow_kwh: tomorrowKwh,\n current_max_charge_current_a: currentMaxChargeCurrentA,\n current_max_charge_voltage_v: currentMaxChargeVoltageV,\n auto_enabled: autoEnabled,\n notifications_enabled: notificationsEnabled,\n last_signature: lastSignature || null,\n\n override_enabled: overrideEnabled,\n override_profile: overrideProfile,\n override_current_a: Number.isFinite(overrideCurrentA) ? overrideCurrentA : null,\n override_voltage_v: Number.isFinite(overrideVoltageV) ? overrideVoltageV : null,\n override_until: overrideUntil || null,\n override_reason: overrideReason || null\n },\n\n computed: {\n is_winter: isWinter,\n in_night_hc: inNightHC,\n in_midday_hc: inMiddayHC,\n in_day_window: inDayWindow,\n forecast_reference_kwh: round1(forecastRefKwh),\n forecast_state: forecastState,\n override_active: overrideActive\n },\n\n decision: {\n mode,\n effective_profile: effectiveProfile,\n target_current_a: targetCurrentA,\n target_voltage_v: targetVoltageV,\n should_publish_current: shouldPublishCurrent,\n should_publish_voltage: shouldPublishVoltage,\n critical_alert: criticalAlert,\n signature,\n signature_changed: signatureChanged,\n reason,\n short_reason: shortReason\n },\n\n ha_service_calls: {\n helper_current: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_target_charge_current',\n value: targetCurrentA\n }\n },\n\n helper_voltage: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_target_charge_voltage',\n value: targetVoltageV\n }\n },\n\n helper_soc_live: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_soc_live',\n value: soc\n }\n },\n\n helper_mode: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_charge_mode',\n value: mode\n }\n },\n\n helper_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_charge_reason',\n value: shortReason || 'Aucune raison'\n }\n },\n\n helper_profile: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_effective_charge_profile',\n value: effectiveProfile\n }\n },\n\n helper_signature: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_charge_last_signature',\n value: signature\n }\n },\n\n mqtt_current: {\n domain: 'mqtt',\n service: 'publish',\n data: {\n topic: MQTT_TOPIC_CHARGE_CURRENT,\n payload: JSON.stringify({ value: targetCurrentA }),\n retain: false\n }\n },\n\n mqtt_voltage: {\n domain: 'mqtt',\n service: 'publish',\n data: {\n topic: MQTT_TOPIC_CHARGE_VOLTAGE,\n payload: JSON.stringify({ value: targetVoltageV }),\n retain: false\n }\n },\n\n logbook: {\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Victron Auto Charge',\n message: `${mode} | profile=${effectiveProfile} | I=${targetCurrentA}A | V=${targetVoltageV}V | SOC=${soc}% | ${shortReason}`,\n entity_id: 'sensor.victron_battery_soc'\n }\n },\n\n mobile_notification: {\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: 'Victron Auto Charge',\n message: `${mode} | profile=${effectiveProfile} | I=${targetCurrentA}A | V=${targetVoltageV}V | SOC=${soc}%`,\n data: {\n push: {\n \"interruption-level\": \"time-sensitive\",\n sound: { name: \"default\" }\n },\n ttl: 0,\n priority: \"high\"\n }\n }\n }\n }\n};\n\nreturn [{ json: result }];\n" + }, + "id": "a98c5bda-cf34-447e-942e-37ae75a45d5c", + "name": "Compute Charge Targets", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 512, + 0 + ] + }, + { + "parameters": { + "jsCode": "const root = $input.first().json;\nconst actions = [];\n\n// Helpers : toujours mis à jour\nactions.push({\n json: {\n action: 'helper_mode',\n ...root.ha_service_calls.helper_mode\n }\n});\n\nactions.push({\n json: {\n action: 'helper_reason',\n ...root.ha_service_calls.helper_reason\n }\n});\n\nactions.push({\n json: {\n action: 'helper_current',\n ...root.ha_service_calls.helper_current\n }\n});\n\nactions.push({\n json: {\n action: 'helper_voltage',\n ...root.ha_service_calls.helper_voltage\n }\n});\n\nactions.push({\n json: {\n action: 'helper_soc_live',\n ...root.ha_service_calls.helper_soc_live\n }\n});\n\nif (root.ha_service_calls.helper_profile) {\n actions.push({\n json: {\n action: 'helper_profile',\n ...root.ha_service_calls.helper_profile\n }\n });\n}\n\n// Signature + log seulement si changement réel\nif (root.decision.signature_changed) {\n actions.push({\n json: {\n action: 'helper_signature',\n ...root.ha_service_calls.helper_signature\n }\n });\n\n actions.push({\n json: {\n action: 'logbook',\n ...root.ha_service_calls.logbook\n }\n });\n}\n\n// D'abord les limites Victron\nif (root.decision.should_publish_current) {\n actions.push({\n json: {\n action: 'mqtt_current',\n ...root.ha_service_calls.mqtt_current\n }\n });\n}\n\nif (root.decision.should_publish_voltage) {\n actions.push({\n json: {\n action: 'mqtt_voltage',\n ...root.ha_service_calls.mqtt_voltage\n }\n });\n}\n\n// Notification iPhone seulement si critique + activée\nif (root.inputs.notifications_enabled && root.decision.critical_alert) {\n actions.push({\n json: {\n action: 'mobile_notification',\n ...root.ha_service_calls.mobile_notification\n }\n });\n}\n\nreturn actions;\n" + }, + "id": "d0a3ecb8-6c4d-46e6-a81c-cb84a789b0d7", + "name": "Build HA Service Actions", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 768, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}\n", + "authentication": "genericCredentialType", + "genericAuthType": "httpBearerAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "accept", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.data }}", + "options": {} + }, + "id": "88eb5cba-7ecb-4fca-a995-fd8612118f3a", + "name": "Execute HA Service", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1040, + 0 + ], + "alwaysOutputData": true, + "credentials": { + "httpBearerAuth": { + "id": "uv3sknYYigxBkaVN", + "name": "Bearer Auth account 2" + } + } + } + ], + "connections": { + "Schedule Trigger": { + "main": [ + [ + { + "node": "HA Snapshot via Template", + "type": "main", + "index": 0 + } + ] + ] + }, + "HA Snapshot via Template": { + "main": [ + [ + { + "node": "Compute Charge Targets", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compute Charge Targets": { + "main": [ + [ + { + "node": "Build HA Service Actions", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build HA Service Actions": { + "main": [ + [ + { + "node": "Execute HA Service", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate", + "availableInMCP": true, + "timeSavedMode": "fixed", + "callerPolicy": "workflowsFromSameOwner", + "saveDataErrorExecution": "all" + }, + "staticData": { + "node:Schedule Trigger": { + "recurrenceRules": [ + null + ], + "recurrenceRuleSignatures": [ + null + ] + } + }, + "meta": null, + "nodeGroups": [], + "pinData": {}, + "versionId": "de4ce3dd-bda2-4d27-ba39-72cfc4cfbcbb", + "activeVersionId": "de4ce3dd-bda2-4d27-ba39-72cfc4cfbcbb", + "versionCounter": 180, + "triggerCount": 1, + "sourceWorkflowId": null, + "shared": [ + { + "updatedAt": "2026-03-13T01:18:19.440Z", + "createdAt": "2026-03-13T01:18:19.440Z", + "role": "workflow:owner", + "workflowId": "cTeeHy4ePiVeELLh", + "projectId": "1q2yd8IyTc8fb25V", + "project": { + "updatedAt": "2026-02-06T16:27:24.469Z", + "createdAt": "2026-02-06T16:23:40.444Z", + "id": "1q2yd8IyTc8fb25V", + "name": "thierry tallagrand ", + "type": "personal", + "icon": null, + "description": null, + "customTelemetryTags": [], + "creatorId": "8b9e9552-cd74-4863-82a3-174b3b3befa2" + } + } + ], + "tags": [], + "activeVersion": { + "updatedAt": "2026-07-09T16:18:50.134Z", + "createdAt": "2026-07-09T16:18:50.134Z", + "versionId": "de4ce3dd-bda2-4d27-ba39-72cfc4cfbcbb", + "workflowId": "cTeeHy4ePiVeELLh", + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "minutes" + } + ] + } + }, + "id": "3bc36d27-0e92-4207-bbde-7f23efbc1881", + "name": "Schedule Trigger", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [ + 0, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "=http://10.10.10.17:8123/api/template\n", + "authentication": "genericCredentialType", + "genericAuthType": "httpBearerAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Accept", + "value": "text/plain" + } + ] + }, + "sendBody": true, + "bodyParameters": { + "parameters": [ + { + "name": "template", + "value": "{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_voltage_v\": states(\"sensor.victron_battery_voltage\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"edf_switch\": states(\"switch.edf_general_ok_switch\"),\n \"today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n \"tomorrow_kwh\": states(\"sensor.energy_production_tomorrow\"),\n \"current_max_charge_current_a\": states(\"sensor.victron_max_charge_current_setting\"),\n \"current_max_charge_voltage_v\": states(\"sensor.victron_max_charge_voltage_setting\"),\n \"auto_enabled\": states(\"input_boolean.victron_auto_charge_enabled\"),\n \"night_takeover\": states(\"input_boolean.victron_evcs_night_grid_takeover_needed\"),\n \"night_target_soc\": states(\"input_number.victron_evcs_night_target_soc\"),\n \"notifications_enabled\": states(\"input_boolean.victron_auto_charge_notifications\"),\n \"last_signature\": states(\"input_text.victron_charge_last_signature\"),\n\n \"override_enabled\": states(\"input_boolean.victron_charge_override_enabled\"),\n \"override_profile\": states(\"input_select.victron_charge_override_profile\"),\n \"override_current_a\": states(\"input_number.victron_override_charge_current\"),\n \"override_voltage_v\": states(\"input_number.victron_override_charge_voltage\"),\n \"override_until\": states(\"input_datetime.victron_charge_override_until\"),\n \"override_reason\": states(\"input_text.victron_charge_override_reason\")\n} | tojson }}\n" + } + ] + }, + "options": {} + }, + "id": "3aa1ccc7-0831-4c25-b511-e4fc76c0cc83", + "name": "HA Snapshot via Template", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 240, + 0 + ], + "credentials": { + "httpBearerAuth": { + "id": "uv3sknYYigxBkaVN", + "name": "Bearer Auth account 2" + } + } + }, + { + "parameters": { + "jsCode": "function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction parseMaybeJson(v) {\n if (typeof v !== 'string') return v;\n try {\n return JSON.parse(v);\n } catch {\n return v;\n }\n}\n\nfunction extractSnapshot(input) {\n if (typeof input === 'string') return parseMaybeJson(input);\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\n if (input?.data && typeof input.data === 'object') return input.data;\n if (input?.body && typeof input.body === 'object') return input.body;\n return input;\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction timeToMinutes(hh, mm) {\n return hh * 60 + mm;\n}\n\nfunction inRange(minNow, startMin, endMin) {\n return minNow >= startMin && minNow < endMin;\n}\n\nfunction getParisNowParts() {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date())\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n const localStamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`;\n\n return {\n year: Number(parts.year),\n month: Number(parts.month),\n day: Number(parts.day),\n hour: Number(parts.hour),\n minute: Number(parts.minute),\n second: Number(parts.second),\n date,\n localStamp,\n minNow: Number(parts.hour) * 60 + Number(parts.minute),\n };\n}\n\nfunction forecastBand(kwh) {\n if (!Number.isFinite(kwh)) return 'unknown';\n if (kwh >= 25) return 'sunny';\n if (kwh >= 12) return 'mixed';\n return 'dark';\n}\n\nconst cfg = {\n winterMonths: [11, 12, 1, 2],\n\n windows: {\n nightHC_start: timeToMinutes(1, 24),\n nightHC_end: timeToMinutes(7, 24),\n\n middayHC_start: timeToMinutes(12, 24),\n middayHC_end: timeToMinutes(14, 24),\n\n winterDay_start: timeToMinutes(7, 15),\n winterDay_end: timeToMinutes(16, 45),\n\n summerDay_start: timeToMinutes(7, 30),\n summerDay_end: timeToMinutes(19, 30),\n },\n\n profiles: {\n night_slow: { current_a: 30, voltage_v: 50.1 },\n takeover_hold: { current_a: 2, voltage_v: 50.1 },\n pv_boost: { current_a: 130, voltage_v: 51.5 },\n pv_mid: { current_a: 110, voltage_v: 51.2 },\n pv_topoff: { current_a: 80, voltage_v: 51.3 },\n pv_hold: { current_a: 30, voltage_v: 50.6 },\n midday_grid_catchup: { current_a: 30, voltage_v: 50.8 },\n emergency_rescue: { current_a: 60, voltage_v: 50.4 },\n },\n\n voltagePublishDelta: 0.05,\n currentPublishDelta: 0.5,\n\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt'\n};\n\nconst MQTT_TOPIC_CHARGE_CURRENT = 'W/c0619ab34ac5/settings/0/Settings/SystemSetup/MaxChargeCurrent';\nconst MQTT_TOPIC_CHARGE_VOLTAGE = 'W/c0619ab34ac5/settings/0/Settings/SystemSetup/MaxChargeVoltage';\n\nconst raw = $input.first().json;\nconst input = extractSnapshot(raw);\nconst now = getParisNowParts();\n\nconst soc = num(input.soc_percent, 0);\nconst batteryVoltageV = num(input.battery_voltage_v, NaN);\nconst batteryCurrentA = num(input.battery_current_a, NaN);\nconst pvPowerW = num(input.pv_power_w, 0);\nconst edfSwitch = low(input.edf_switch || 'off') === 'on' ? 'on' : 'off';\n\nconst todayRemainingKwh = num(input.today_remaining_kwh, 0);\nconst tomorrowKwh = num(input.tomorrow_kwh, 0);\n\nconst currentMaxChargeCurrentA = num(input.current_max_charge_current_a, 0);\nconst currentMaxChargeVoltageV = num(input.current_max_charge_voltage_v, 0);\n\nconst autoEnabled = low(input.auto_enabled || 'off') === 'on';\nconst nightTakeover = low(input.night_takeover || 'off') === 'on';\nconst nightTargetSoc = num(input.night_target_soc, 35);\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\nconst lastSignature = (input.last_signature ?? '').toString().trim();\n\nconst overrideEnabled = low(input.override_enabled || 'off') === 'on';\nconst overrideProfile = low(input.override_profile || 'auto');\nconst overrideCurrentA = num(input.override_current_a, NaN);\nconst overrideVoltageV = num(input.override_voltage_v, NaN);\nconst overrideUntil = (input.override_until ?? '').toString().trim();\nconst overrideReason = (input.override_reason ?? '').toString().trim();\n\nconst isWinter = cfg.winterMonths.includes(now.month);\nconst inNightHC = inRange(now.minNow, cfg.windows.nightHC_start, cfg.windows.nightHC_end);\nconst inMiddayHC = inRange(now.minNow, cfg.windows.middayHC_start, cfg.windows.middayHC_end);\nconst inDayWindow = isWinter\n ? inRange(now.minNow, cfg.windows.winterDay_start, cfg.windows.winterDay_end)\n : inRange(now.minNow, cfg.windows.summerDay_start, cfg.windows.summerDay_end);\n\nconst forecastRefKwh = now.hour < 8 ? Math.max(todayRemainingKwh, tomorrowKwh, 0) : todayRemainingKwh;\nconst forecastState = forecastBand(forecastRefKwh);\n\nfunction profileValues(profileName) {\n if (profileName === 'manual') {\n return {\n current_a: Number.isFinite(overrideCurrentA) ? overrideCurrentA : cfg.profiles.pv_boost.current_a,\n voltage_v: Number.isFinite(overrideVoltageV) ? overrideVoltageV : cfg.profiles.pv_boost.voltage_v,\n };\n }\n return cfg.profiles[profileName] || null;\n}\n\nconst overrideActive =\n overrideEnabled &&\n overrideProfile !== 'auto' &&\n /^\\d{4}-\\d{2}-\\d{2}/.test(overrideUntil) &&\n now.localStamp < overrideUntil;\n\nlet mode = 'idle';\nlet effectiveProfile = 'auto_idle';\nlet targetCurrentA = currentMaxChargeCurrentA || 30;\nlet targetVoltageV = currentMaxChargeVoltageV || 50.5;\nlet criticalAlert = false;\nlet reason = [];\n\nif (!autoEnabled) {\n mode = 'automation_disabled';\n effectiveProfile = 'manual_keep_current';\n targetCurrentA = currentMaxChargeCurrentA || 30;\n targetVoltageV = currentMaxChargeVoltageV || 50.5;\n reason.push(\"Automatisation désactivée, conservation des réglages actuels\");\n} else {\n if (soc < 20 && !inNightHC && !inMiddayHC) {\n mode = 'emergency_rescue';\n effectiveProfile = 'emergency_rescue';\n targetCurrentA = cfg.profiles.emergency_rescue.current_a;\n targetVoltageV = cfg.profiles.emergency_rescue.voltage_v;\n criticalAlert = true;\n reason.push(`SOC critique ${soc}%`);\n }\n\n // P4 : takeover EVCS nocturne. EDF est ON pour la maison + le VE (HC),\n // la batterie doit seulement être RAMENÉE à la cible matin puis MAINTENUE,\n // pas rechargée à fond (cible adaptée au forecast : été ~35%, hiver ~85%).\n else if (nightTakeover && edfSwitch === 'on') {\n const holdTargetSoc = Math.min(95, Math.max(20, nightTargetSoc));\n if (soc < holdTargetSoc - 1) {\n mode = 'evcs_takeover_recover';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(`Takeover EVCS : recharge vers cible matin ${holdTargetSoc}% (SOC ${soc}%)`);\n } else {\n mode = 'evcs_takeover_hold';\n effectiveProfile = 'takeover_hold';\n targetCurrentA = cfg.profiles.takeover_hold.current_a;\n targetVoltageV = cfg.profiles.takeover_hold.voltage_v;\n reason.push(`Takeover EVCS : maintien SOC ~${holdTargetSoc}% (maison+VE sur HC, batterie en pause)`);\n }\n }\n\n else if (isWinter && inNightHC) {\n let targetSoc;\n if (forecastState === 'sunny') targetSoc = 40;\n else if (forecastState === 'mixed') targetSoc = 60;\n else targetSoc = 80;\n\n if (soc < targetSoc - 1) {\n mode = 'winter_night_charge_window';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(`Hiver HC nuit, cible SOC ${targetSoc}% selon forecast ${forecastState}`);\n } else {\n mode = 'winter_night_idle';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(`Hiver HC nuit, SOC ${soc}% déjà suffisant`);\n }\n }\n\n else if (!isWinter && inNightHC) {\n if (soc < 22) {\n mode = 'summer_night_rescue_window';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(\"Été HC nuit, secours SOC < 22%\");\n } else {\n mode = 'summer_night_idle';\n effectiveProfile = 'night_slow';\n targetCurrentA = cfg.profiles.night_slow.current_a;\n targetVoltageV = cfg.profiles.night_slow.voltage_v;\n reason.push(\"Été HC nuit, pas de besoin réseau\");\n }\n }\n\n else if (\n inMiddayHC &&\n soc < 85 &&\n forecastRefKwh < 8 &&\n pvPowerW < 1500\n ) {\n mode = 'midday_catchup_window';\n effectiveProfile = 'midday_grid_catchup';\n targetCurrentA = cfg.profiles.midday_grid_catchup.current_a;\n targetVoltageV = cfg.profiles.midday_grid_catchup.voltage_v;\n reason.push(`HC midi rattrapage : SOC ${soc}% | forecast ${forecastRefKwh} kWh | PV ${pvPowerW} W`);\n }\n\n else if (inDayWindow) {\n if (soc < 80) {\n mode = 'day_pv_boost';\n effectiveProfile = 'pv_boost';\n targetCurrentA = cfg.profiles.pv_boost.current_a;\n targetVoltageV = cfg.profiles.pv_boost.voltage_v;\n reason.push(`Jour SOC bas ${soc}% : capture PV max`);\n } else if (soc < 90) {\n mode = 'day_pv_mid';\n effectiveProfile = 'pv_mid';\n targetCurrentA = cfg.profiles.pv_mid.current_a;\n targetVoltageV = cfg.profiles.pv_mid.voltage_v;\n reason.push(`Jour SOC intermédiaire ${soc}%`);\n } else if (soc < 95) {\n mode = 'day_pv_topoff';\n effectiveProfile = 'pv_topoff';\n targetCurrentA = cfg.profiles.pv_topoff.current_a;\n targetVoltageV = cfg.profiles.pv_topoff.voltage_v;\n reason.push(`Jour top-off vers 95% | SOC ${soc}%`);\n } else {\n mode = 'day_pv_hold';\n effectiveProfile = 'pv_hold';\n targetCurrentA = cfg.profiles.pv_hold.current_a;\n targetVoltageV = cfg.profiles.pv_hold.voltage_v;\n reason.push(`Jour maintien haut SOC ${soc}%`);\n }\n\n if (\n Number.isFinite(batteryVoltageV) &&\n soc < 95 &&\n pvPowerW > 2000 &&\n batteryVoltageV >= (targetVoltageV - 0.15)\n ) {\n targetVoltageV = Math.max(targetVoltageV, 51.3);\n targetCurrentA = Math.max(targetCurrentA, 80);\n if (soc >= 90) {\n effectiveProfile = 'pv_topoff';\n mode = 'day_pv_topoff';\n } else {\n effectiveProfile = 'pv_boost';\n }\n reason.push(`Batterie proche consigne (${round1(batteryVoltageV)}V), relèvement anti-bridage`);\n }\n\n if (\n Number.isFinite(batteryCurrentA) &&\n soc < 95 &&\n pvPowerW > 6000 &&\n batteryCurrentA >= (targetCurrentA - 3)\n ) {\n targetCurrentA = Math.max(targetCurrentA, soc >= 90 ? 80 : 130);\n reason.push(`Courant batterie proche limite (${round1(batteryCurrentA)}A), relèvement du plafond`);\n }\n }\n\n\n else {\n mode = 'off_window_idle';\n effectiveProfile = 'pv_hold';\n targetCurrentA = cfg.profiles.pv_hold.current_a;\n targetVoltageV = cfg.profiles.pv_hold.voltage_v;\n reason.push(\"Hors fenêtre utile\");\n }\n}\n\nif (overrideActive) {\n const ov = profileValues(overrideProfile);\n if (ov) {\n targetCurrentA = ov.current_a;\n targetVoltageV = ov.voltage_v;\n effectiveProfile = `override_${overrideProfile}`;\n mode = `${mode}_override`;\n reason.unshift(\n `Override actif jusqu'à ${overrideUntil} | profil=${overrideProfile}` +\n (overrideReason ? ` | ${overrideReason}` : '')\n );\n }\n} else if (overrideEnabled && overrideProfile !== 'auto') {\n reason.push(\"Override configuré mais inactif ou expiré\");\n}\n\ntargetCurrentA = round1(targetCurrentA);\ntargetVoltageV = round1(targetVoltageV);\n\nconst shouldPublishCurrent =\n !Number.isFinite(currentMaxChargeCurrentA) ||\n Math.abs(targetCurrentA - currentMaxChargeCurrentA) >= cfg.currentPublishDelta;\n\nconst shouldPublishVoltage =\n !Number.isFinite(currentMaxChargeVoltageV) ||\n Math.abs(targetVoltageV - currentMaxChargeVoltageV) >= cfg.voltagePublishDelta;\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst signature = [\n now.date,\n mode,\n effectiveProfile,\n `i=${targetCurrentA}`,\n `v=${targetVoltageV}`\n].join('|');\n\nconst signatureChanged = signature !== lastSignature;\n\nconst result = {\n timestamp_paris: now.localStamp,\n\n inputs: {\n soc_percent: soc,\n battery_voltage_v: Number.isFinite(batteryVoltageV) ? batteryVoltageV : null,\n battery_current_a: Number.isFinite(batteryCurrentA) ? batteryCurrentA : null,\n pv_power_w: pvPowerW,\n edf_switch_observed: edfSwitch,\n today_remaining_kwh: todayRemainingKwh,\n tomorrow_kwh: tomorrowKwh,\n current_max_charge_current_a: currentMaxChargeCurrentA,\n current_max_charge_voltage_v: currentMaxChargeVoltageV,\n auto_enabled: autoEnabled,\n notifications_enabled: notificationsEnabled,\n last_signature: lastSignature || null,\n\n override_enabled: overrideEnabled,\n override_profile: overrideProfile,\n override_current_a: Number.isFinite(overrideCurrentA) ? overrideCurrentA : null,\n override_voltage_v: Number.isFinite(overrideVoltageV) ? overrideVoltageV : null,\n override_until: overrideUntil || null,\n override_reason: overrideReason || null\n },\n\n computed: {\n is_winter: isWinter,\n in_night_hc: inNightHC,\n in_midday_hc: inMiddayHC,\n in_day_window: inDayWindow,\n forecast_reference_kwh: round1(forecastRefKwh),\n forecast_state: forecastState,\n override_active: overrideActive\n },\n\n decision: {\n mode,\n effective_profile: effectiveProfile,\n target_current_a: targetCurrentA,\n target_voltage_v: targetVoltageV,\n should_publish_current: shouldPublishCurrent,\n should_publish_voltage: shouldPublishVoltage,\n critical_alert: criticalAlert,\n signature,\n signature_changed: signatureChanged,\n reason,\n short_reason: shortReason\n },\n\n ha_service_calls: {\n helper_current: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_target_charge_current',\n value: targetCurrentA\n }\n },\n\n helper_voltage: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_target_charge_voltage',\n value: targetVoltageV\n }\n },\n\n helper_soc_live: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_soc_live',\n value: soc\n }\n },\n\n helper_mode: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_charge_mode',\n value: mode\n }\n },\n\n helper_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_charge_reason',\n value: shortReason || 'Aucune raison'\n }\n },\n\n helper_profile: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_effective_charge_profile',\n value: effectiveProfile\n }\n },\n\n helper_signature: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_charge_last_signature',\n value: signature\n }\n },\n\n mqtt_current: {\n domain: 'mqtt',\n service: 'publish',\n data: {\n topic: MQTT_TOPIC_CHARGE_CURRENT,\n payload: JSON.stringify({ value: targetCurrentA }),\n retain: false\n }\n },\n\n mqtt_voltage: {\n domain: 'mqtt',\n service: 'publish',\n data: {\n topic: MQTT_TOPIC_CHARGE_VOLTAGE,\n payload: JSON.stringify({ value: targetVoltageV }),\n retain: false\n }\n },\n\n logbook: {\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Victron Auto Charge',\n message: `${mode} | profile=${effectiveProfile} | I=${targetCurrentA}A | V=${targetVoltageV}V | SOC=${soc}% | ${shortReason}`,\n entity_id: 'sensor.victron_battery_soc'\n }\n },\n\n mobile_notification: {\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: 'Victron Auto Charge',\n message: `${mode} | profile=${effectiveProfile} | I=${targetCurrentA}A | V=${targetVoltageV}V | SOC=${soc}%`,\n data: {\n push: {\n \"interruption-level\": \"time-sensitive\",\n sound: { name: \"default\" }\n },\n ttl: 0,\n priority: \"high\"\n }\n }\n }\n }\n};\n\nreturn [{ json: result }];\n" + }, + "id": "a98c5bda-cf34-447e-942e-37ae75a45d5c", + "name": "Compute Charge Targets", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 512, + 0 + ] + }, + { + "parameters": { + "jsCode": "const root = $input.first().json;\nconst actions = [];\n\n// Helpers : toujours mis à jour\nactions.push({\n json: {\n action: 'helper_mode',\n ...root.ha_service_calls.helper_mode\n }\n});\n\nactions.push({\n json: {\n action: 'helper_reason',\n ...root.ha_service_calls.helper_reason\n }\n});\n\nactions.push({\n json: {\n action: 'helper_current',\n ...root.ha_service_calls.helper_current\n }\n});\n\nactions.push({\n json: {\n action: 'helper_voltage',\n ...root.ha_service_calls.helper_voltage\n }\n});\n\nactions.push({\n json: {\n action: 'helper_soc_live',\n ...root.ha_service_calls.helper_soc_live\n }\n});\n\nif (root.ha_service_calls.helper_profile) {\n actions.push({\n json: {\n action: 'helper_profile',\n ...root.ha_service_calls.helper_profile\n }\n });\n}\n\n// Signature + log seulement si changement réel\nif (root.decision.signature_changed) {\n actions.push({\n json: {\n action: 'helper_signature',\n ...root.ha_service_calls.helper_signature\n }\n });\n\n actions.push({\n json: {\n action: 'logbook',\n ...root.ha_service_calls.logbook\n }\n });\n}\n\n// D'abord les limites Victron\nif (root.decision.should_publish_current) {\n actions.push({\n json: {\n action: 'mqtt_current',\n ...root.ha_service_calls.mqtt_current\n }\n });\n}\n\nif (root.decision.should_publish_voltage) {\n actions.push({\n json: {\n action: 'mqtt_voltage',\n ...root.ha_service_calls.mqtt_voltage\n }\n });\n}\n\n// Notification iPhone seulement si critique + activée\nif (root.inputs.notifications_enabled && root.decision.critical_alert) {\n actions.push({\n json: {\n action: 'mobile_notification',\n ...root.ha_service_calls.mobile_notification\n }\n });\n}\n\nreturn actions;\n" + }, + "id": "d0a3ecb8-6c4d-46e6-a81c-cb84a789b0d7", + "name": "Build HA Service Actions", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 768, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}\n", + "authentication": "genericCredentialType", + "genericAuthType": "httpBearerAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "accept", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.data }}", + "options": {} + }, + "id": "88eb5cba-7ecb-4fca-a995-fd8612118f3a", + "name": "Execute HA Service", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1040, + 0 + ], + "alwaysOutputData": true, + "credentials": { + "httpBearerAuth": { + "id": "uv3sknYYigxBkaVN", + "name": "Bearer Auth account 2" + } + } + } + ], + "connections": { + "Schedule Trigger": { + "main": [ + [ + { + "node": "HA Snapshot via Template", + "type": "main", + "index": 0 + } + ] + ] + }, + "HA Snapshot via Template": { + "main": [ + [ + { + "node": "Compute Charge Targets", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compute Charge Targets": { + "main": [ + [ + { + "node": "Build HA Service Actions", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build HA Service Actions": { + "main": [ + [ + { + "node": "Execute HA Service", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "nodeGroups": [], + "authors": "thierry tallagrand", + "name": null, + "description": null, + "autosaved": false, + "workflowPublishHistory": [ + { + "createdAt": "2026-07-09T16:18:50.209Z", + "id": 180, + "workflowId": "cTeeHy4ePiVeELLh", + "versionId": "de4ce3dd-bda2-4d27-ba39-72cfc4cfbcbb", + "event": "activated", + "userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2" + }, + { + "createdAt": "2026-07-09T21:45:53.307Z", + "id": 190, + "workflowId": "cTeeHy4ePiVeELLh", + "versionId": "de4ce3dd-bda2-4d27-ba39-72cfc4cfbcbb", + "event": "activated", + "userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2" + }, + { + "createdAt": "2026-07-09T16:18:50.185Z", + "id": 179, + "workflowId": "cTeeHy4ePiVeELLh", + "versionId": "de4ce3dd-bda2-4d27-ba39-72cfc4cfbcbb", + "event": "deactivated", + "userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2" + }, + { + "createdAt": "2026-07-09T21:45:53.283Z", + "id": 189, + "workflowId": "cTeeHy4ePiVeELLh", + "versionId": "de4ce3dd-bda2-4d27-ba39-72cfc4cfbcbb", + "event": "deactivated", + "userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2" + } + ] + } +}