diff --git a/HANDOFF.md b/HANDOFF.md index 0c54739..3523bfa 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2277,5 +2277,29 @@ batterie a 32 A et JEEP (branchee 13:01, 52 %) qui ne demarre pas. SOC 47 % a 12 - ⚠️ JEEP part samedi 06:30 : mode de nuit « complete » conseille ce soir (aucune regle ne charge une voiture a depart connu en « sans EDF » si EDF ne se ferme pas pour la batterie — cas du planificateur). +### 10/10 00:15 — SDB : deshumidificateur bloque allume par un capteur muet ; alertes de piles +- Constat du proprietaire (captures EVCS SDB Premium) : capteur `sensor.sdb_temp_hygro_*` `unavailable` du 04/10 14:52 + (redemarrage HA) au 10/10 00:20 (piles changees par lui). Le workflow « SDB Climate & Surplus Manager V2.2 » + (`2NhrfxuGm4pTUNs9`) maintenait le deshumidificateur « jusqu'a H <= 41 % » : sans mesure, jamais -> deshumidificateur + (~150 W) depuis 20:00 et prise SDB (~500 W) depuis 22:39, la nuit, SOC 26 %, EDF ouvert. Aucun garde batterie, aucune + alerte. +- **00:23 (accord du proprietaire)** : deshumidificateur et prise SDB coupes, latches remis a off (sinon rallumes au + cycle suivant). Etat `idle`. +- **Workflow sous controle du depot** (`workflows/sdb-climate-surplus-v2.2.json`, `src/sdb/`, entree `sdb` du node-map, + tests `node tests/sdb/run.mjs` 12/12, 5 echecs sur l'ancien code) : **capteur muet** (illisible ou derniere publication + > 3 h, `sdb_humidity_age_min` par `last_reported`) -> rien n'est tenu au-dela du temps mini, pas de demarrage, + notification « Capteur SDB muet » ; **energie** : le deshumidificateur (charge reportable) ne tourne sur la batterie + que si SOC >= `evcs_balance_required_soc_prudent` (pas de seuil fixe), sinon EDF ferme ou vrai surplus ; bilan + illisible -> comportement anterieur. Signature `muet=`, `nrj=`. Deploye 00:26, executions en succes ; 00:27 : capteur + vivant (24,3 °C, 58 %), `nrj=0` (batterie reservee). Sauvegarde du code d'origine `sdb_compute.bak-20261010.js` + (scratchpad) et la version d'origine du JSON est le premier commit du fichier. +- **Piles** (`tools/piles.mjs`, tests `tests/piles/run.mjs` 7/7) : `sensor.evcs_piles` (toutes les 15 min) = alertes + pile <= 20 % ou etat `low`, ou capteur de pile indisponible > 24 h ; exclus Victron, GX, parc, Jeep, telephones ; + `input_text.evcs_piles_ignorees` (paquet `evcs_piles.yaml`) pour faire taire un appareil. Premier releve : **9 + alertes** (SDB a 0 % juste apres le changement — a revoir ; SYLVIE CHAMBRE 12 % ; sept capteurs muets depuis le 04/10, + dont des chambres et « TRETS » : a confirmer par le proprietaire, peut-etre une autre maison -> liste d'ignores). + SPARK previent par WhatsApp. +- ⚠️ `switch.deshumide_sdb_socket_1` `unavailable` depuis 00:25 (prise debranchee ?). + --- -*Document mis a jour le : 09/10/2026 (~14:15) — conseiller relecteur de la journee (revue calculee + pistes) ; nuit : voiture qui profite d'EDF ferme pour la batterie, nuit qui prepare le soir les jours annonces faibles ; 08/10/2026 (~00:15) — jour faible : charge maximale en HC de midi (bilan 100 %, ACM plafond + solaire) ; 07/10/2026 (~22:45) — reprise de 16 h verifiee, EDF en HP a 18:03, bilan : reprise du soir et prevision recalee sur la matinee ; bilan HC de midi, reprise du soir apres coupure en pleine chauffe, mesure ECS sur 10 min ; recharge reseau HC : courant calcule (12 kVA) et consigne non tenue ; HC de midi : AUTO ne chauffe pas (BOOST), alerte v2, Jeep (contact, relecture, zone), recharge batterie bridee a 30 A par l'ACM ; jour faible = EDF les deux fenetres HC pour le chauffe-eau, alerte BOOST, Victron Mobile refait ; 1re decision jour faible en reel, mesure ECS reparee, douches 06:00/20:00, Grafana chauffe-eau, tableaux ECS ; chauffe-eau en HC de nuit les jours faibles (decision du bilan, workflow ECS sous controle du depot, VRM, V2.3 desactive) ; garde batterie reseau : voiture bridee a 6 A au lieu d'un arret ; garde-fou concierge 2 h ; allers-retours brefs de la borne retrouves dans l'historique HA (echange VW -> JEEP de 20 s), production 14,5 kWh contre 22 prevus, defaut garde batterie en mode reseau a trancher ; voiture a midi : la batterie n'est plus comptee comme du solaire (EDF automatique les jours de depart si le surplus ne couvre pas) ; nuit 05->06 : JEEP chargee sur EDF en complete (6,32 kWh), budget 0 du a la prevision, Windy/ECMWF contre VRM, a comparer ce soir ; concierge aligne sur la preuve fin de trajet ; a verifier : nuit 05->06 et mardi faible, comparaison VRM/Forecast.Solar ; JEEP identifiee aussi par sa fin de trajet, prevision de la nuit 05->06 ; identification JEEP reussie en reel, correction humaine qui prime (refus supprime), session du 04/10 coupee ; EVCS Master en quatre onglets ; Jeep lue par Uconnect et identifiee seule, capacite JEEP 10 kWh, concierge variante (a) ; releve par photo (SPARK), hass-uconnect en attente ; ecart semaine/week-end mesure (pas de profil week-end la nuit) ; nuit du budget : budget borne au plancher prudent, sans EDF = voiture bloquee meme EDF ferme (batterie seule) ; Energie : sauts corriges ; tableau Energie complete (Sud/Ouest, batterie par integration, borne ; aucun redemarrage HA) ; kilometrage des vehicules (km/kWh, capacite, releves de reference, agent cable 15/15) ; a verifier le 04/10 au matin : nuit du budget JEEP ; concierge : vehicule non confirme demande, heure de saisie ; Grafana vehicules en tableau ; Grafana vehicules : ZERO et Invite ajoutes, session 16:46 reetiquetee ZERO ; prevision SOC 10 h ~35 % ; concierge fiable : niveau note par le script, une ligne, dedoublonnage, simulation par drapeau ; agent : message incompris = question, bourse sur demande explicite (banc final 6/6) ; a verifier : nuit du samedi 03 au dimanche 04/10 (budget < besoin JEEP), cron orange-cloture lundi 05/10 apres 17:40, comparaison VRM / Forecast.Solar lundi 05/10* +*Document mis a jour le : 10/10/2026 (~00:40) — conseiller relecteur de la journee (revue calculee + pistes) ; nuit : voiture qui profite d'EDF ferme pour la batterie, nuit qui prepare le soir les jours annonces faibles ; 08/10/2026 (~00:15) — jour faible : charge maximale en HC de midi (bilan 100 %, ACM plafond + solaire) ; 07/10/2026 (~22:45) — reprise de 16 h verifiee, EDF en HP a 18:03, bilan : reprise du soir et prevision recalee sur la matinee ; bilan HC de midi, reprise du soir apres coupure en pleine chauffe, mesure ECS sur 10 min ; recharge reseau HC : courant calcule (12 kVA) et consigne non tenue ; HC de midi : AUTO ne chauffe pas (BOOST), alerte v2, Jeep (contact, relecture, zone), recharge batterie bridee a 30 A par l'ACM ; jour faible = EDF les deux fenetres HC pour le chauffe-eau, alerte BOOST, Victron Mobile refait ; 1re decision jour faible en reel, mesure ECS reparee, douches 06:00/20:00, Grafana chauffe-eau, tableaux ECS ; chauffe-eau en HC de nuit les jours faibles (decision du bilan, workflow ECS sous controle du depot, VRM, V2.3 desactive) ; garde batterie reseau : voiture bridee a 6 A au lieu d'un arret ; garde-fou concierge 2 h ; allers-retours brefs de la borne retrouves dans l'historique HA (echange VW -> JEEP de 20 s), production 14,5 kWh contre 22 prevus, defaut garde batterie en mode reseau a trancher ; voiture a midi : la batterie n'est plus comptee comme du solaire (EDF automatique les jours de depart si le surplus ne couvre pas) ; nuit 05->06 : JEEP chargee sur EDF en complete (6,32 kWh), budget 0 du a la prevision, Windy/ECMWF contre VRM, a comparer ce soir ; concierge aligne sur la preuve fin de trajet ; a verifier : nuit 05->06 et mardi faible, comparaison VRM/Forecast.Solar ; JEEP identifiee aussi par sa fin de trajet, prevision de la nuit 05->06 ; identification JEEP reussie en reel, correction humaine qui prime (refus supprime), session du 04/10 coupee ; EVCS Master en quatre onglets ; Jeep lue par Uconnect et identifiee seule, capacite JEEP 10 kWh, concierge variante (a) ; releve par photo (SPARK), hass-uconnect en attente ; ecart semaine/week-end mesure (pas de profil week-end la nuit) ; nuit du budget : budget borne au plancher prudent, sans EDF = voiture bloquee meme EDF ferme (batterie seule) ; Energie : sauts corriges ; tableau Energie complete (Sud/Ouest, batterie par integration, borne ; aucun redemarrage HA) ; kilometrage des vehicules (km/kWh, capacite, releves de reference, agent cable 15/15) ; a verifier le 04/10 au matin : nuit du budget JEEP ; concierge : vehicule non confirme demande, heure de saisie ; Grafana vehicules en tableau ; Grafana vehicules : ZERO et Invite ajoutes, session 16:46 reetiquetee ZERO ; prevision SOC 10 h ~35 % ; concierge fiable : niveau note par le script, une ligne, dedoublonnage, simulation par drapeau ; agent : message incompris = question, bourse sur demande explicite (banc final 6/6) ; a verifier : nuit du samedi 03 au dimanche 04/10 (budget < besoin JEEP), cron orange-cloture lundi 05/10 apres 17:40, comparaison VRM / Forecast.Solar lundi 05/10* diff --git a/packages/evcs_piles.yaml b/packages/evcs_piles.yaml new file mode 100755 index 0000000..7e80530 --- /dev/null +++ b/packages/evcs_piles.yaml @@ -0,0 +1,8 @@ +# Piles des capteurs (10/10/2026). Le service d'historisation publie +# sensor.evcs_piles ; SPARK previent par WhatsApp. Liste d'entites a ignorer +# (separees par des virgules), par exemple un appareil d'une autre maison. +input_text: + evcs_piles_ignorees: + name: Piles - capteurs a ignorer + max: 255 + icon: mdi:battery-off-outline diff --git a/src/sdb/BUILD_ACTIONS.js b/src/sdb/BUILD_ACTIONS.js new file mode 100644 index 0000000..2321fc0 --- /dev/null +++ b/src/sdb/BUILD_ACTIONS.js @@ -0,0 +1,159 @@ +const root = $input.first().json || {}; +const calls = root.ha_service_calls || {}; +const decision = root.decision || {}; +const actions = []; + +function pushAction(action, call, enabled = true) { + if (!enabled) return; + if (!call) return; + if (call.execute === false) return; + + const { execute, ...rest } = call; + + actions.push({ + json: { + action, + ...rest + } + }); +} + +// ===================================================== +// HELPERS TOUJOURS MIS A JOUR +// ===================================================== +pushAction('helper_state', calls.helper_state, true); +pushAction('helper_reason', calls.helper_reason, true); + +// ===================================================== +// OFF ENFANTS D'ABORD +// ===================================================== +pushAction( + 'deshumide', + calls.deshumide, + decision.deshumide_action === 'turn_off' +); + +pushAction( + 'radiator', + calls.radiator, + decision.radiator_action === 'turn_off' +); + +// ===================================================== +// PUIS OFF MASTER SI NECESSAIRE +// ===================================================== +pushAction( + 'master_power', + calls.master_power, + decision.master_action === 'turn_off' +); + +// ===================================================== +// ON MASTER D'ABORD +// ===================================================== +pushAction( + 'master_power', + calls.master_power, + decision.master_action === 'turn_on' +); + +// ===================================================== +// PUIS ON ENFANTS +// ===================================================== +pushAction( + 'deshumide', + calls.deshumide, + decision.deshumide_action === 'turn_on' +); + +pushAction( + 'radiator', + calls.radiator, + decision.radiator_action === 'turn_on' +); + +// ===================================================== +// LATCHES +// ===================================================== +pushAction( + 'helper_master_latch', + calls.helper_master_latch, + decision.master_latch_action !== 'none' +); + +pushAction( + 'helper_deshumide_latch', + calls.helper_deshumide_latch, + decision.deshumide_latch_action !== 'none' +); + +pushAction( + 'helper_radiator_latch', + calls.helper_radiator_latch, + decision.radiator_latch_action !== 'none' +); + +// ===================================================== +// TIMESTAMPS +// ===================================================== +pushAction( + 'helper_master_last_on_at', + calls.helper_master_last_on_at, + !!decision.set_master_last_on_at +); + +pushAction( + 'helper_master_last_off_at', + calls.helper_master_last_off_at, + !!decision.set_master_last_off_at +); + +pushAction( + 'helper_deshumide_last_on_at', + calls.helper_deshumide_last_on_at, + !!decision.set_deshumide_last_on_at +); + +pushAction( + 'helper_deshumide_last_off_at', + calls.helper_deshumide_last_off_at, + !!decision.set_deshumide_last_off_at +); + +pushAction( + 'helper_radiator_last_on_at', + calls.helper_radiator_last_on_at, + !!decision.set_radiator_last_on_at +); + +pushAction( + 'helper_radiator_last_off_at', + calls.helper_radiator_last_off_at, + !!decision.set_radiator_last_off_at +); + +// ===================================================== +// SIGNATURE + LOGBOOK SI CHANGEMENT +// ===================================================== +pushAction( + 'helper_signature', + calls.helper_signature, + !!decision.signature_changed +); + +pushAction( + 'logbook', + calls.logbook, + !!decision.signature_changed +); + +// ===================================================== +// NOTIFICATION +// ===================================================== +pushAction( + 'mobile_notification', + calls.mobile_notification, + !!decision.should_notify +); + +return actions; diff --git a/src/sdb/COMPUTE.js b/src/sdb/COMPUTE.js new file mode 100644 index 0000000..92f2692 --- /dev/null +++ b/src/sdb/COMPUTE.js @@ -0,0 +1,1058 @@ +function num(v, fallback = NaN) { + const n = Number.parseFloat(v); + return Number.isFinite(n) ? n : fallback; +} + +function low(v) { + return (v ?? '').toString().trim().toLowerCase(); +} + +function parseMaybeJson(v) { + if (typeof v !== 'string') return v; + try { + return JSON.parse(v); + } catch { + return v; + } +} + +function extractSnapshot(input) { + if (typeof input === 'string') return parseMaybeJson(input); + if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data); + if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body); + if (input?.data && typeof input.data === 'object') return input.data; + if (input?.body && typeof input.body === 'object') return input.body; + return input; +} + +function round1(v) { + return Math.round(v * 10) / 10; +} + +function round2(v) { + return Math.round(v * 100) / 100; +} + +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} + +function parseTimeToMinutes(v, fallback = null) { + const s = (v ?? '').toString().trim(); + const m = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/); + if (!m) return fallback; + const hh = Number(m[1]); + const mm = Number(m[2]); + if (!Number.isFinite(hh) || !Number.isFinite(mm)) return fallback; + return hh * 60 + mm; +} + +function to2(n) { + return String(n).padStart(2, '0'); +} + +function getParisNowParts() { + const fmt = new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + fmt.formatToParts(new Date()) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour); + const date = `${parts.year}-${parts.month}-${parts.day}`; + const stamp = `${date} ${to2(hour)}:${parts.minute}:${parts.second}`; + + return { + year: Number(parts.year), + month: Number(parts.month), + day: Number(parts.day), + hour, + minute: Number(parts.minute), + second: Number(parts.second), + date, + stamp, + minNow: hour * 60 + Number(parts.minute), + }; +} + +function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') { + const dtf = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + const parts = Object.fromEntries( + dtf.formatToParts(new Date(utcTs)) + .filter(p => p.type !== 'literal') + .map(p => [p.type, p.value]) + ); + + const asIfUtc = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour) === 24 ? 0 : Number(parts.hour), + Number(parts.minute), + Number(parts.second) + ); + + const d = new Date(utcTs); + const actualUtc = Date.UTC( + d.getUTCFullYear(), + d.getUTCMonth(), + d.getUTCDate(), + d.getUTCHours(), + d.getUTCMinutes(), + d.getUTCSeconds() + ); + + return asIfUtc - actualUtc; +} + +function parseParisStampToMs(s) { + const text = (s ?? '').toString().trim(); + const m = text.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/); + if (!m) return null; + + const y = Number(m[1]); + const mo = Number(m[2]); + const d = Number(m[3]); + const h = Number(m[4]); + const mi = Number(m[5]); + const se = Number(m[6]); + + if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null; + + const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se); + const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris'); + let ts = guessUtc - off1; + const off2 = getTzOffsetMs(ts, 'Europe/Paris'); + + if (off2 !== off1) ts = guessUtc - off2; + return ts; +} + +function cleanStateText(v) { + const s = (v ?? '').toString().trim(); + const x = s.toLowerCase(); + return ['unknown', 'unavailable', 'none', 'null'].includes(x) ? '' : s; +} + +function cleanParisStamp(v) { + const s = cleanStateText(v); + return parseParisStampToMs(s) !== null ? s : ''; +} + +function minutesSince(ts, nowMs) { + if (ts === null || nowMs === null) return null; + return Math.max(0, (nowMs - ts) / 60000); +} + +function safeText(v, max = 255) { + return (v ?? '').toString().slice(0, max); +} + +const cfg = { + mobileNotifyDomain: 'notify', + mobileNotifyService: 'mobile_app_iphone_16_de_tt', + + radiatorRealPowerW: 960, + batteryNominalVoltageV: 52, + + radiatorStartSurplusRatio: 0.60, + radiatorHoldSurplusRatio: 0.25, + radiatorMinBatteryStartA: 2, + radiatorMinBatteryHoldA: 0, + radiatorMaxBatteryHoldDischargeA: -1.5 +}; + +const raw = $input.first().json; +const input = extractSnapshot(raw); +const now = getParisNowParts(); +const nowMs = parseParisStampToMs(now.stamp); + +// ====================================================== +// INPUTS +// ====================================================== +const soc = clamp(num(input.soc_percent, 0), 0, 100); +const batteryCurrentA = num(input.battery_current_a, 0); +const pvPowerW = Math.max(0, num(input.pv_power_w, 0)); +const pvPowerAvg10mW = Math.max(0, num(input.pv_power_10m_avg_w, pvPowerW)); +const houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0)); +const gridPowerW = num(input.grid_power_w, 0); +const gridIsOn = low(input.grid_state || 'off') === 'on'; +const acInAvailable = num(input.victron_acin1_available, 0) === 1; +const gridAvailable = gridIsOn || acInAvailable; + +const masterIsOn = low(input.sdb_master_power || 'off') === 'on'; +const deshumideIsOn = low(input.deshumide_switch || 'off') === 'on'; +const radiatorIsOn = low(input.radiator_switch || 'off') === 'on'; + +const masterEnergyTotalKwh = Math.max(0, num(input.sdb_master_energy_total, 0)); +const deshumideEnergyTotalKwh = Math.max(0, num(input.deshumide_energy_total, 0)); +const radiatorEnergyTotalKwh = Math.max(0, num(input.radiator_energy_total, 0)); + +const temperatureC = num(input.sdb_temperature_c, NaN); +const humidityPct = num(input.sdb_humidity_pct, NaN); +// ⚠️ CAPTEUR MUET (10/10/2026). Le capteur sdb_temp_hygro est reste `unavailable` +// du 04/10 14:52 au 10/10 00:20 (piles) : sans humidite, la regle « maintenir +// jusqu'a H <= seuil OFF » ne pouvait jamais se terminer, et le deshumidificateur +// (avec la prise SDB) tournait sans fin, y compris la nuit sur batterie a 26 %. +// Muet = valeur illisible OU derniere publication il y a plus de 3 h. +const humidityAgeMin = num(input.sdb_humidity_age_min, NaN); +const CAPTEUR_MUET_MIN = 180; +const capteurMuet = !Number.isFinite(humidityPct) || (Number.isFinite(humidityAgeMin) && humidityAgeMin > CAPTEUR_MUET_MIN); +// ⚠️ ENERGIE (10/10/2026) : le deshumidificateur est une charge REPORTABLE. Sur la +// batterie, seulement si elle reste au-dessus de ce que le bilan juge necessaire +// (profil prudent) ; sinon il attend EDF ferme ou un vrai surplus solaire. Pas de +// seuil fixe : l'exigence vient du bilan. Bilan illisible -> pas de restriction +// (comportement anterieur), pour ne jamais bloquer sur une panne de bilan. +const balanceRequiredPrudent = num(input.balance_required_soc_prudent, NaN); +const balanceOk = low(input.balance_status || '') === 'ok' && Number.isFinite(balanceRequiredPrudent); + +const autoEnabled = low(input.auto_enabled || 'off') === 'on'; +const notificationsEnabled = low(input.notifications_enabled || 'off') === 'on'; +const manualAdoptEnabled = low(input.manual_adopt_enabled || 'off') === 'on'; + +const masterLatchIsOn = low(input.master_power_latch || 'off') === 'on'; +const deshumideLatchIsOn = low(input.deshumide_latch || 'off') === 'on'; +const radiatorLatchIsOn = low(input.radiator_latch || 'off') === 'on'; + +const tempOnMinC = num(input.temp_on_min_c, 21); +const tempOffMaxC = num(input.temp_off_max_c, 23); +const humidityOnPct = num(input.humidity_on_pct, 55); +const humidityOffPct = num(input.humidity_off_pct, 45); + +const deshumideMinOnMinutes = Math.max(0, num(input.deshumide_min_on_minutes, 15)); +const deshumideMinOffMinutes = Math.max(0, num(input.deshumide_min_off_minutes, 10)); + +const radiatorAutoEnabled = low(input.radiator_auto_enabled || 'off') === 'on'; +const radiatorSocOn = clamp(num(input.radiator_soc_on, 90), 0, 100); +const radiatorSocOff = clamp(num(input.radiator_soc_off, 88), 0, 100); +const radiatorPvOnW = Math.max(0, num(input.radiator_pv_on_w, 2500)); +const radiatorPvOffW = Math.max(0, num(input.radiator_pv_off_w, 1200)); +const radiatorBatteryChargeCeilingA = Math.max(0, num(input.radiator_battery_charge_ceiling_a, 5)); +const radiatorHouseAcMaxW = Math.max(0, num(input.radiator_house_ac_max_w, 6500)); +const radiatorMinOnMinutes = Math.max(0, num(input.radiator_min_on_minutes, 15)); +const radiatorMinOffMinutes = Math.max(0, num(input.radiator_min_off_minutes, 10)); + +const dayStartMin = parseTimeToMinutes(input.day_start_time, 9 * 60); +const dayStopMin = parseTimeToMinutes(input.day_stop_time, 19 * 60 + 15); +const inDayWindow = + dayStartMin < dayStopMin + ? (now.minNow >= dayStartMin && now.minNow < dayStopMin) + : (now.minNow >= dayStartMin || now.minNow < dayStopMin); + +const masterLastOnAt = cleanParisStamp(input.master_last_on_at); +const masterLastOffAt = cleanParisStamp(input.master_last_off_at); +const deshumideLastOnAt = cleanParisStamp(input.deshumide_last_on_at); +const deshumideLastOffAt = cleanParisStamp(input.deshumide_last_off_at); +const radiatorLastOnAt = cleanParisStamp(input.radiator_last_on_at); +const radiatorLastOffAt = cleanParisStamp(input.radiator_last_off_at); +const lastSignature = cleanStateText(input.last_signature); + +// ====================================================== +// EVCS PRIORITY +// ====================================================== +const evcsPriorityActiveRaw = low(input.evcs_priority_active || 'off') === 'on'; +const energyPriorityMode = cleanStateText(input.energy_priority_mode || 'normal').toLowerCase() || 'normal'; +const energyPriorityReason = cleanStateText(input.energy_priority_reason); +const evcsPriorityUntilAt = cleanParisStamp(input.evcs_priority_until); +const evcsPriorityUntilMs = parseParisStampToMs(evcsPriorityUntilAt); + +const evcsPriorityActive = + evcsPriorityActiveRaw && + ( + evcsPriorityUntilMs === null || + (nowMs !== null && evcsPriorityUntilMs > nowMs + 1000) + ); + +// ====================================================== +// TIMESTAMPS +// ====================================================== +const masterLastOnMs = parseParisStampToMs(masterLastOnAt); +const masterLastOffMs = parseParisStampToMs(masterLastOffAt); +const deshumideLastOnMs = parseParisStampToMs(deshumideLastOnAt); +const deshumideLastOffMs = parseParisStampToMs(deshumideLastOffAt); +const radiatorLastOnMs = parseParisStampToMs(radiatorLastOnAt); +const radiatorLastOffMs = parseParisStampToMs(radiatorLastOffAt); + +const minutesSinceMasterOn = minutesSince(masterLastOnMs, nowMs); +const minutesSinceMasterOff = minutesSince(masterLastOffMs, nowMs); +const minutesSinceDeshumideOn = minutesSince(deshumideLastOnMs, nowMs); +const minutesSinceDeshumideOff = minutesSince(deshumideLastOffMs, nowMs); +const minutesSinceRadiatorOn = minutesSince(radiatorLastOnMs, nowMs); +const minutesSinceRadiatorOff = minutesSince(radiatorLastOffMs, nowMs); + +const deshumideMinOnElapsed = minutesSinceDeshumideOn === null ? true : minutesSinceDeshumideOn >= deshumideMinOnMinutes; +const deshumideMinOffElapsed = minutesSinceDeshumideOff === null ? true : minutesSinceDeshumideOff >= deshumideMinOffMinutes; +const radiatorMinOnElapsed = minutesSinceRadiatorOn === null ? true : minutesSinceRadiatorOn >= radiatorMinOnMinutes; +const radiatorMinOffElapsed = minutesSinceRadiatorOff === null ? true : minutesSinceRadiatorOff >= radiatorMinOffMinutes; + +// ====================================================== +// BESOINS METIER SEPARES +// ====================================================== +const heatNeed = Number.isFinite(temperatureC) ? temperatureC < tempOnMinC : false; +const heatRecovered = Number.isFinite(temperatureC) ? temperatureC >= tempOffMaxC : false; + +const humidityNeed = Number.isFinite(humidityPct) ? humidityPct > humidityOnPct : false; +const humidityRecovered = Number.isFinite(humidityPct) ? humidityPct <= humidityOffPct : false; + +const comfortDemand = heatNeed || humidityNeed; +const comfortRecovered = heatRecovered && humidityRecovered; + +// ====================================================== +// RADIATEUR SDB : SURPLUS REEL V2 +// ====================================================== +const estimatedBatteryChargeW = + Number.isFinite(batteryCurrentA) && batteryCurrentA > 0 + ? batteryCurrentA * cfg.batteryNominalVoltageV + : 0; + +const directPvHeadroomW = Math.max(0, pvPowerAvg10mW - houseAcPowerW); +const estimatedSurplusW = Math.max(0, directPvHeadroomW + estimatedBatteryChargeW); + +const radiatorStartSurplusNeededW = Math.round(cfg.radiatorRealPowerW * cfg.radiatorStartSurplusRatio); +const radiatorHoldSurplusNeededW = Math.round(cfg.radiatorRealPowerW * cfg.radiatorHoldSurplusRatio); + +const radiatorBatteryStartOk = + Number.isFinite(batteryCurrentA) && + batteryCurrentA >= cfg.radiatorMinBatteryStartA && + batteryCurrentA <= radiatorBatteryChargeCeilingA; + +const radiatorBatteryHoldOk = + Number.isFinite(batteryCurrentA) && + batteryCurrentA >= cfg.radiatorMaxBatteryHoldDischargeA && + batteryCurrentA <= radiatorBatteryChargeCeilingA + 4; + +const radiatorStartByAbsolutePv = + pvPowerAvg10mW >= radiatorPvOnW && radiatorBatteryStartOk; + +const radiatorStartByEstimatedSurplus = + estimatedSurplusW >= radiatorStartSurplusNeededW && radiatorBatteryStartOk; + +const radiatorHoldByAbsolutePv = + pvPowerAvg10mW >= radiatorPvOffW && radiatorBatteryHoldOk; + +const radiatorHoldByEstimatedSurplus = + estimatedSurplusW >= radiatorHoldSurplusNeededW && radiatorBatteryHoldOk; + +const radiatorStartConditionsOk = + radiatorAutoEnabled && + heatNeed && + inDayWindow && + !gridAvailable && + !evcsPriorityActive && + soc >= radiatorSocOn && + houseAcPowerW < radiatorHouseAcMaxW && + (radiatorStartByAbsolutePv || radiatorStartByEstimatedSurplus); + +const radiatorHoldConditionsOk = + radiatorAutoEnabled && + !heatRecovered && + inDayWindow && + !gridAvailable && + !evcsPriorityActive && + soc >= radiatorSocOff && + houseAcPowerW < radiatorHouseAcMaxW && + (radiatorHoldByAbsolutePv || radiatorHoldByEstimatedSurplus); + +// ====================================================== +// SORTIES DESIREES +// ====================================================== +let desiredDeshumide = false; +let desiredRadiator = false; +let desiredMaster = false; + +let state = 'idle'; +let reason = []; + +let dehumActionReason = 'idle'; +let radiatorActionReason = 'idle'; + +let adoptMaster = false; +let adoptDeshumide = false; +let adoptRadiator = false; + +let notifyTitle = 'SDB Climate & Surplus'; +let notifyMessage = ''; +let notifyWanted = false; + +// Energie disponible pour une charge reportable (calcule apres les entrees). +const deshumideEnergyOk = + gridIsOn || + (pvPowerAvg10mW - houseAcPowerW) > 0 || + !balanceOk || + soc >= balanceRequiredPrudent; + +// ====================================================== +// DESHUMIDIFICATEUR - PRIORITAIRE HUMIDITE +// ====================================================== +if (!autoEnabled) { + desiredDeshumide = deshumideIsOn; + dehumActionReason = 'automation_disabled'; +} +else if (capteurMuet && (deshumideIsOn || deshumideLatchIsOn)) { + // Sans mesure, on ne tient plus rien au-dela du temps minimum. + desiredDeshumide = !deshumideMinOnElapsed; + dehumActionReason = deshumideMinOnElapsed ? 'deshumide_stop_capteur_muet' : 'deshumide_hold_min_on'; +} +else if (capteurMuet) { + desiredDeshumide = false; + dehumActionReason = 'deshumide_capteur_muet'; +} +else if (!deshumideEnergyOk && (deshumideIsOn || deshumideLatchIsOn)) { + desiredDeshumide = !deshumideMinOnElapsed; + dehumActionReason = deshumideMinOnElapsed ? 'deshumide_stop_batterie_reservee' : 'deshumide_hold_min_on'; +} +else if (!deshumideEnergyOk && humidityNeed) { + desiredDeshumide = false; + dehumActionReason = 'deshumide_attente_energie'; +} +else if (humidityNeed) { + if (deshumideIsOn) { + desiredDeshumide = true; + dehumActionReason = 'deshumide_already_running'; + } else if (deshumideMinOffElapsed) { + desiredDeshumide = true; + dehumActionReason = 'deshumide_start_on_humidity_need'; + } else { + desiredDeshumide = false; + dehumActionReason = 'deshumide_wait_min_off'; + } +} +else if (deshumideIsOn || deshumideLatchIsOn) { + if (!humidityRecovered) { + desiredDeshumide = true; + dehumActionReason = 'deshumide_hold_until_humidity_recovered'; + } else if (!deshumideMinOnElapsed) { + desiredDeshumide = true; + dehumActionReason = 'deshumide_hold_min_on'; + } else { + desiredDeshumide = false; + dehumActionReason = 'deshumide_stop_humidity_recovered'; + } +} +else { + desiredDeshumide = false; + dehumActionReason = 'deshumide_idle'; +} + +// Adoption manuelle déshumide uniquement si cohérent avec la demande +if (manualAdoptEnabled && desiredDeshumide && deshumideIsOn && !deshumideLatchIsOn) { + adoptDeshumide = true; +} + +// ====================================================== +// RADIATEUR - CHAUFFAGE + SURPLUS + PRIORITE EVCS +// ====================================================== +if (!autoEnabled) { + desiredRadiator = radiatorIsOn; + radiatorActionReason = 'automation_disabled'; +} +else if (!radiatorAutoEnabled) { + desiredRadiator = radiatorIsOn; + radiatorActionReason = 'radiator_auto_disabled_observe_only'; +} +else if (evcsPriorityActive) { + if (radiatorIsOn || radiatorLatchIsOn) { + if (!radiatorMinOnElapsed) { + desiredRadiator = true; + radiatorActionReason = 'radiator_hold_min_on_evcs_priority'; + } else { + desiredRadiator = false; + radiatorActionReason = 'radiator_blocked_by_evcs_priority'; + } + } else { + desiredRadiator = false; + radiatorActionReason = 'radiator_blocked_by_evcs_priority'; + } +} +else if (heatNeed && radiatorStartConditionsOk) { + if (radiatorIsOn) { + desiredRadiator = true; + radiatorActionReason = 'radiator_already_running'; + } else if (radiatorMinOffElapsed) { + desiredRadiator = true; + radiatorActionReason = 'radiator_start_heat_need_surplus_v2'; + } else { + desiredRadiator = false; + radiatorActionReason = 'radiator_wait_min_off'; + } +} +else if (radiatorIsOn || radiatorLatchIsOn) { + if (radiatorHoldConditionsOk) { + desiredRadiator = true; + radiatorActionReason = 'radiator_hold_heat_need_surplus_v2'; + } else if (!radiatorMinOnElapsed) { + desiredRadiator = true; + radiatorActionReason = 'radiator_hold_min_on'; + } else if (evcsPriorityActive) { + desiredRadiator = false; + radiatorActionReason = 'radiator_stop_evcs_priority'; + } else if (heatRecovered) { + desiredRadiator = false; + radiatorActionReason = 'radiator_stop_heat_recovered'; + } else { + desiredRadiator = false; + radiatorActionReason = 'radiator_stop_surplus_lost'; + } +} +else { + desiredRadiator = false; + radiatorActionReason = heatNeed ? 'radiator_heat_need_but_energy_not_ok' : 'radiator_idle'; +} + +// Adoption manuelle radiateur uniquement si cohérent et hors EVCS +if ( + manualAdoptEnabled && + desiredRadiator && + radiatorIsOn && + !radiatorLatchIsOn && + !evcsPriorityActive +) { + adoptRadiator = true; +} + +// ====================================================== +// MASTER SDB PRISE = COORDINATEUR +// ====================================================== +if (!autoEnabled) { + desiredMaster = masterIsOn; +} else { + desiredMaster = desiredDeshumide || desiredRadiator; +} + +if (manualAdoptEnabled && desiredMaster && masterIsOn && !masterLatchIsOn) { + adoptMaster = true; +} + +// ====================================================== +// ETAT GLOBAL +// ====================================================== +if (!autoEnabled) { + state = 'automation_disabled'; + reason.push("Automatisation SDB climate/surplus désactivée"); +} +else if (desiredDeshumide && desiredRadiator) { + state = 'deshumide_and_radiator_on'; + reason.push("Déshumidification active + radiateur actif sur surplus"); +} +else if (desiredDeshumide) { + state = 'deshumide_on'; + reason.push("Déshumidification SDB active"); +} +else if (desiredRadiator) { + state = 'radiator_on'; + reason.push("Radiateur SDB actif sur besoin thermique et surplus"); +} +else if (radiatorActionReason === 'radiator_blocked_by_evcs_priority' || radiatorActionReason === 'radiator_stop_evcs_priority') { + state = 'radiator_blocked_by_evcs_priority'; + reason.push("Radiateur SDB bloqué par priorité EVCS"); +} +else if (dehumActionReason === 'deshumide_wait_min_off') { + state = 'wait_deshumide_min_off'; + reason.push("Déshumidificateur en attente de temps mini OFF"); +} +else if (radiatorActionReason === 'radiator_wait_min_off') { + state = 'wait_radiator_min_off'; + reason.push("Radiateur en attente de temps mini OFF"); +} +else { + state = 'idle'; + reason.push("Aucune demande SDB active"); +} + +reason.push(`Température SDB ${Number.isFinite(temperatureC) ? round1(temperatureC) + '°C' : 'n/a'}`); +reason.push(`Humidité SDB ${Number.isFinite(humidityPct) ? round1(humidityPct) + '%' : 'n/a'}`); + +reason.push(`Chauffage ON si T<${round1(tempOnMinC)}°C`); +reason.push(`Chauffage OFF si T>=${round1(tempOffMaxC)}°C`); +reason.push(`Déshumidification ON si H>${round1(humidityOnPct)}%`); +reason.push(`Déshumidification OFF si H<=${round1(humidityOffPct)}%`); + +reason.push(`Besoin chauffage=${heatNeed ? 'oui' : 'non'} | Rétabli chauffage=${heatRecovered ? 'oui' : 'non'}`); +reason.push(`Besoin humidité=${humidityNeed ? 'oui' : 'non'} | Humidité rétablie=${humidityRecovered ? 'oui' : 'non'}`); + +reason.push(`Déshumide: ${dehumActionReason}`); +if (capteurMuet) reason.push(`⚠️ Capteur SDB muet${Number.isFinite(humidityAgeMin) && humidityAgeMin >= 0 ? ` depuis ${Math.round(humidityAgeMin)} min` : ''} (piles ?)`); +if (!deshumideEnergyOk) reason.push(`Batterie reservee : SOC ${round1(soc)}% < ${round1(balanceRequiredPrudent)}% (bilan, prudent), EDF ouvert, pas de surplus`); +reason.push(`Radiateur: ${radiatorActionReason}`); + +reason.push(`Master=${masterIsOn ? 'ON' : 'OFF'} | Deshumide=${deshumideIsOn ? 'ON' : 'OFF'} | Radiateur=${radiatorIsOn ? 'ON' : 'OFF'}`); +reason.push(`SOC ${round1(soc)}% | PV ${Math.round(pvPowerW)}W | PV10 ${Math.round(pvPowerAvg10mW)}W | AC maison ${Math.round(houseAcPowerW)}W | Batterie ${round1(batteryCurrentA)}A`); +reason.push(`Réseau cmd=${gridIsOn ? 'ON' : 'OFF'} | acin=${acInAvailable ? 'OK' : 'KO'} | dispo=${gridAvailable ? 'oui' : 'non'}`); +reason.push(`Fenêtre jour ${inDayWindow ? 'OK' : 'Hors fenêtre'}`); +reason.push(`Prio EVCS ${evcsPriorityActive ? 'ON' : 'OFF'}${energyPriorityReason ? ` | ${energyPriorityReason}` : ''}`); + +reason.push(`Radiateur ${Math.round(cfg.radiatorRealPowerW)}W | Headroom PV ${Math.round(directPvHeadroomW)}W | Charge batt estimée ${Math.round(estimatedBatteryChargeW)}W | Surplus estimé ${Math.round(estimatedSurplusW)}W`); +reason.push(`Start radiateur si surplus >= ${Math.round(radiatorStartSurplusNeededW)}W ou PV10 >= ${Math.round(radiatorPvOnW)}W`); +reason.push(`Hold radiateur si surplus >= ${Math.round(radiatorHoldSurplusNeededW)}W ou PV10 >= ${Math.round(radiatorPvOffW)}W`); + +if (adoptMaster) reason.push("Prise maître SDB adoptée"); +if (adoptDeshumide) reason.push("Déshumidificateur adopté"); +if (adoptRadiator) reason.push("Radiateur adopté"); + +// ====================================================== +// ACTIONS / LATCHES +// ====================================================== +const desiredMasterLatch = desiredMaster ? 'on' : 'off'; +const desiredDeshumideLatch = desiredDeshumide ? 'on' : 'off'; +const desiredRadiatorLatch = desiredRadiator ? 'on' : 'off'; + +const masterAction = + desiredMaster === masterIsOn + ? 'none' + : desiredMaster ? 'turn_on' : 'turn_off'; + +const deshumideAction = + desiredDeshumide === deshumideIsOn + ? 'none' + : desiredDeshumide ? 'turn_on' : 'turn_off'; + +const radiatorAction = + desiredRadiator === radiatorIsOn + ? 'none' + : desiredRadiator ? 'turn_on' : 'turn_off'; + +const masterLatchAction = + desiredMasterLatch === (masterLatchIsOn ? 'on' : 'off') + ? 'none' + : desiredMasterLatch === 'on' ? 'turn_on' : 'turn_off'; + +const deshumideLatchAction = + desiredDeshumideLatch === (deshumideLatchIsOn ? 'on' : 'off') + ? 'none' + : desiredDeshumideLatch === 'on' ? 'turn_on' : 'turn_off'; + +const radiatorLatchAction = + desiredRadiatorLatch === (radiatorLatchIsOn ? 'on' : 'off') + ? 'none' + : desiredRadiatorLatch === 'on' ? 'turn_on' : 'turn_off'; + +// ====================================================== +// TIMESTAMPS +// ====================================================== +const setMasterLastOnAt = masterAction === 'turn_on' || adoptMaster; +const setMasterLastOffAt = masterAction === 'turn_off'; + +const setDeshumideLastOnAt = deshumideAction === 'turn_on' || adoptDeshumide; +const setDeshumideLastOffAt = deshumideAction === 'turn_off'; + +const setRadiatorLastOnAt = radiatorAction === 'turn_on' || adoptRadiator; +const setRadiatorLastOffAt = radiatorAction === 'turn_off'; + +// ====================================================== +// NOTIFICATIONS +// ====================================================== +if (state === 'deshumide_on' && (deshumideAction === 'turn_on' || adoptDeshumide)) { + notifyTitle = '💧 Déshumidification SDB active'; + notifyMessage = `SDB: T=${Number.isFinite(temperatureC) ? round1(temperatureC) : 'n/a'}°C | H=${Number.isFinite(humidityPct) ? round1(humidityPct) : 'n/a'}%.`; + notifyWanted = true; +} +else if ((dehumActionReason === 'deshumide_stop_capteur_muet' || dehumActionReason === 'deshumide_stop_batterie_reservee') && deshumideAction === 'turn_off') { + notifyTitle = dehumActionReason === 'deshumide_stop_capteur_muet' ? '⚠️ Capteur SDB muet : déshumidificateur arrêté' : '🔋 Déshumidificateur SDB arrêté : batterie réservée'; + notifyMessage = dehumActionReason === 'deshumide_stop_capteur_muet' + ? 'Le capteur température/humidité de la SDB ne publie plus (piles ?). Sans mesure, le déshumidificateur ne peut plus être piloté.' + : `SOC ${round1(soc)}% sous les ${round1(balanceRequiredPrudent)}% que le bilan réserve ; il repartira avec EDF ou le soleil.`; + notifyWanted = true; +} +else if (dehumActionReason === 'deshumide_stop_humidity_recovered' && deshumideAction === 'turn_off') { + notifyTitle = '✅ Déshumidification SDB arrêtée'; + notifyMessage = `Humidité revenue dans la zone cible : H=${Number.isFinite(humidityPct) ? round1(humidityPct) : 'n/a'}%.`; + notifyWanted = true; +} +else if (state === 'radiator_on' && radiatorAction === 'turn_on') { + notifyTitle = '☀️ Radiateur SDB actif sur surplus'; + notifyMessage = `T=${Number.isFinite(temperatureC) ? round1(temperatureC) : 'n/a'}°C | SOC ${round1(soc)}% | PV10 ${Math.round(pvPowerAvg10mW)}W | Surplus ${Math.round(estimatedSurplusW)}W.`; + notifyWanted = true; +} +else if ( + (radiatorActionReason === 'radiator_stop_surplus_lost' || radiatorActionReason === 'radiator_stop_heat_recovered') && + radiatorAction === 'turn_off' +) { + notifyTitle = '⛔ Radiateur SDB arrêté'; + notifyMessage = `T=${Number.isFinite(temperatureC) ? round1(temperatureC) : 'n/a'}°C | PV10 ${Math.round(pvPowerAvg10mW)}W | Surplus ${Math.round(estimatedSurplusW)}W.`; + notifyWanted = true; +} +else if ( + (radiatorActionReason === 'radiator_blocked_by_evcs_priority' || radiatorActionReason === 'radiator_stop_evcs_priority') && + radiatorAction === 'turn_off' +) { + notifyTitle = '🚗 Priorité EVCS : radiateur SDB coupé'; + notifyMessage = `${energyPriorityReason || 'La recharge EVCS est prioritaire'}${evcsPriorityUntilAt ? ` jusqu'à ${evcsPriorityUntilAt}` : ''}.`; + notifyWanted = true; +} +else if (state === 'deshumide_and_radiator_on' && (deshumideAction === 'turn_on' || radiatorAction === 'turn_on')) { + notifyTitle = '🌤️ SDB active : humidité + chauffage'; + notifyMessage = `Déshumidificateur et radiateur actifs. T=${Number.isFinite(temperatureC) ? round1(temperatureC) : 'n/a'}°C | H=${Number.isFinite(humidityPct) ? round1(humidityPct) : 'n/a'}% | SOC ${round1(soc)}%.`; + notifyWanted = true; +} + +const shortReason = reason.join(' | ').slice(0, 255); + +const signature = [ + now.date, + state, + `m=${desiredMaster ? '1' : '0'}`, + `d=${desiredDeshumide ? '1' : '0'}`, + `r=${desiredRadiator ? '1' : '0'}`, + `heat=${heatNeed ? '1' : '0'}`, + `hum=${humidityNeed ? '1' : '0'}`, + `ev=${evcsPriorityActive ? '1' : '0'}`, + `t=${Number.isFinite(temperatureC) ? round1(temperatureC) : 'na'}`, + `h=${Number.isFinite(humidityPct) ? round1(humidityPct) : 'na'}`, + `sx=${Math.round(estimatedSurplusW)}`, + `muet=${capteurMuet ? '1' : '0'}`, + `nrj=${deshumideEnergyOk ? '1' : '0'}` +].join('|'); + +const signatureChanged = signature !== lastSignature; + +// ====================================================== +// OUTPUT +// ====================================================== +const result = { + timestamp_paris: now.stamp, + + inputs: { + soc_percent: soc, + battery_current_a: batteryCurrentA, + pv_power_w: pvPowerW, + pv_power_10m_avg_w: pvPowerAvg10mW, + house_ac_power_w: houseAcPowerW, + grid_power_w: gridPowerW, + grid_state: gridIsOn ? 'on' : 'off', + victron_acin1_available: acInAvailable ? 1 : 0, + + sdb_master_power: masterIsOn, + sdb_master_energy_total: round2(masterEnergyTotalKwh), + deshumide_switch: deshumideIsOn, + deshumide_energy_total: round2(deshumideEnergyTotalKwh), + radiator_switch: radiatorIsOn, + radiator_energy_total: round2(radiatorEnergyTotalKwh), + + sdb_temperature_c: Number.isFinite(temperatureC) ? round1(temperatureC) : null, + sdb_humidity_pct: Number.isFinite(humidityPct) ? round1(humidityPct) : null, + + auto_enabled: autoEnabled, + notifications_enabled: notificationsEnabled, + manual_adopt_enabled: manualAdoptEnabled, + + master_power_latch: masterLatchIsOn, + deshumide_latch: deshumideLatchIsOn, + radiator_latch: radiatorLatchIsOn, + + temp_on_min_c: tempOnMinC, + temp_off_max_c: tempOffMaxC, + humidity_on_pct: humidityOnPct, + humidity_off_pct: humidityOffPct, + + deshumide_min_on_minutes: deshumideMinOnMinutes, + deshumide_min_off_minutes: deshumideMinOffMinutes, + + radiator_auto_enabled: radiatorAutoEnabled, + radiator_soc_on: radiatorSocOn, + radiator_soc_off: radiatorSocOff, + radiator_pv_on_w: radiatorPvOnW, + radiator_pv_off_w: radiatorPvOffW, + radiator_battery_charge_ceiling_a: radiatorBatteryChargeCeilingA, + radiator_house_ac_max_w: radiatorHouseAcMaxW, + radiator_min_on_minutes: radiatorMinOnMinutes, + radiator_min_off_minutes: radiatorMinOffMinutes, + + radiator_real_power_w_internal: cfg.radiatorRealPowerW, + + day_start_time: input.day_start_time || null, + day_stop_time: input.day_stop_time || null, + + master_last_on_at: masterLastOnAt || null, + master_last_off_at: masterLastOffAt || null, + deshumide_last_on_at: deshumideLastOnAt || null, + deshumide_last_off_at: deshumideLastOffAt || null, + radiator_last_on_at: radiatorLastOnAt || null, + radiator_last_off_at: radiatorLastOffAt || null, + + evcs_priority_active: evcsPriorityActive, + energy_priority_mode: energyPriorityMode, + energy_priority_reason: energyPriorityReason || null, + evcs_priority_until: evcsPriorityUntilAt || null, + + last_signature: lastSignature || null + }, + + computed: { + in_day_window: inDayWindow, + + heat_need: heatNeed, + heat_recovered: heatRecovered, + humidity_need: humidityNeed, + humidity_recovered: humidityRecovered, + comfort_demand: comfortDemand, + comfort_recovered: comfortRecovered, + + temp_too_low: heatNeed, + humidity_too_high: humidityNeed, + + direct_pv_headroom_w: Math.round(directPvHeadroomW), + estimated_battery_charge_w: Math.round(estimatedBatteryChargeW), + estimated_surplus_w: Math.round(estimatedSurplusW), + + radiator_start_surplus_needed_w: Math.round(radiatorStartSurplusNeededW), + radiator_hold_surplus_needed_w: Math.round(radiatorHoldSurplusNeededW), + + radiator_start_by_absolute_pv: radiatorStartByAbsolutePv, + radiator_start_by_estimated_surplus: radiatorStartByEstimatedSurplus, + radiator_hold_by_absolute_pv: radiatorHoldByAbsolutePv, + radiator_hold_by_estimated_surplus: radiatorHoldByEstimatedSurplus, + + radiator_start_conditions_ok: radiatorStartConditionsOk, + radiator_hold_conditions_ok: radiatorHoldConditionsOk, + radiator_battery_start_ok: radiatorBatteryStartOk, + radiator_battery_hold_ok: radiatorBatteryHoldOk, + + evcs_priority_active: evcsPriorityActive, + grid_available: gridAvailable, + + dehum_action_reason: dehumActionReason, + radiator_action_reason: radiatorActionReason, + + minutes_since_master_on: minutesSinceMasterOn !== null ? round1(minutesSinceMasterOn) : null, + minutes_since_master_off: minutesSinceMasterOff !== null ? round1(minutesSinceMasterOff) : null, + minutes_since_deshumide_on: minutesSinceDeshumideOn !== null ? round1(minutesSinceDeshumideOn) : null, + minutes_since_deshumide_off: minutesSinceDeshumideOff !== null ? round1(minutesSinceDeshumideOff) : null, + minutes_since_radiator_on: minutesSinceRadiatorOn !== null ? round1(minutesSinceRadiatorOn) : null, + minutes_since_radiator_off: minutesSinceRadiatorOff !== null ? round1(minutesSinceRadiatorOff) : null, + + deshumide_min_on_elapsed: deshumideMinOnElapsed, + deshumide_min_off_elapsed: deshumideMinOffElapsed, + radiator_min_on_elapsed: radiatorMinOnElapsed, + radiator_min_off_elapsed: radiatorMinOffElapsed + }, + + decision: { + state, + desired_master: desiredMaster, + desired_deshumide: desiredDeshumide, + desired_radiator: desiredRadiator, + + master_action: masterAction, + deshumide_action: deshumideAction, + radiator_action: radiatorAction, + + master_latch_action: masterLatchAction, + deshumide_latch_action: deshumideLatchAction, + radiator_latch_action: radiatorLatchAction, + + adopt_master: adoptMaster, + adopt_deshumide: adoptDeshumide, + adopt_radiator: adoptRadiator, + + set_master_last_on_at: setMasterLastOnAt, + set_master_last_off_at: setMasterLastOffAt, + set_deshumide_last_on_at: setDeshumideLastOnAt, + set_deshumide_last_off_at: setDeshumideLastOffAt, + set_radiator_last_on_at: setRadiatorLastOnAt, + set_radiator_last_off_at: setRadiatorLastOffAt, + + should_notify: notificationsEnabled && signatureChanged && notifyWanted, + signature, + signature_changed: signatureChanged, + reason, + short_reason: shortReason + }, + + ha_service_calls: { + helper_state: { + execute: true, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.sdb_climate_surplus_state', + value: state + } + }, + + helper_reason: { + execute: true, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.sdb_climate_surplus_reason', + value: shortReason || 'Aucune raison' + } + }, + + helper_signature: { + execute: signatureChanged, + domain: 'input_text', + service: 'set_value', + data: { + entity_id: 'input_text.sdb_climate_surplus_last_signature', + value: signature + } + }, + + helper_master_latch: { + execute: masterLatchAction !== 'none', + domain: 'input_boolean', + service: desiredMaster ? 'turn_on' : 'turn_off', + data: { + entity_id: 'input_boolean.sdb_master_power_latch' + } + }, + + helper_deshumide_latch: { + execute: deshumideLatchAction !== 'none', + domain: 'input_boolean', + service: desiredDeshumide ? 'turn_on' : 'turn_off', + data: { + entity_id: 'input_boolean.sdb_deshumide_latch' + } + }, + + helper_radiator_latch: { + execute: radiatorLatchAction !== 'none', + domain: 'input_boolean', + service: desiredRadiator ? 'turn_on' : 'turn_off', + data: { + entity_id: 'input_boolean.sdb_radiator_latch' + } + }, + + master_power: { + execute: masterAction !== 'none', + domain: 'switch', + service: desiredMaster ? 'turn_on' : 'turn_off', + data: { + entity_id: 'switch.sdb_prise_socket_1' + } + }, + + deshumide: { + execute: deshumideAction !== 'none', + domain: 'switch', + service: desiredDeshumide ? 'turn_on' : 'turn_off', + data: { + entity_id: 'switch.deshumide_sdb_socket_1' + } + }, + + radiator: { + execute: radiatorAction !== 'none', + domain: 'switch', + service: desiredRadiator ? 'turn_on' : 'turn_off', + data: { + entity_id: 'switch.sdb_radiateur_socket_1' + } + }, + + helper_master_last_on_at: { + execute: setMasterLastOnAt, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.sdb_master_last_on_at', + datetime: now.stamp + } + }, + + helper_master_last_off_at: { + execute: setMasterLastOffAt, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.sdb_master_last_off_at', + datetime: now.stamp + } + }, + + helper_deshumide_last_on_at: { + execute: setDeshumideLastOnAt, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.sdb_deshumide_last_on_at', + datetime: now.stamp + } + }, + + helper_deshumide_last_off_at: { + execute: setDeshumideLastOffAt, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.sdb_deshumide_last_off_at', + datetime: now.stamp + } + }, + + helper_radiator_last_on_at: { + execute: setRadiatorLastOnAt, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.sdb_radiator_last_on_at', + datetime: now.stamp + } + }, + + helper_radiator_last_off_at: { + execute: setRadiatorLastOffAt, + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.sdb_radiator_last_off_at', + datetime: now.stamp + } + }, + + logbook: { + execute: signatureChanged, + domain: 'logbook', + service: 'log', + data: { + name: 'SDB Climate & Surplus', + message: `${state} | master=${desiredMaster ? 'on' : 'off'} | deshumide=${desiredDeshumide ? 'on' : 'off'} | radiator=${desiredRadiator ? 'on' : 'off'} | ${shortReason}`, + entity_id: 'switch.sdb_prise_socket_1' + } + }, + + mobile_notification: { + execute: notificationsEnabled && signatureChanged && notifyWanted, + domain: cfg.mobileNotifyDomain, + service: cfg.mobileNotifyService, + data: { + title: notifyTitle, + message: notifyMessage, + data: { + push: { + "interruption-level": "time-sensitive", + sound: { name: "default" } + }, + ttl: 0, + priority: "high" + } + } + } + } +}; + +return [{ json: result }]; diff --git a/src/sdb/HA_SNAPSHOT_TEMPLATE.json.tpl b/src/sdb/HA_SNAPSHOT_TEMPLATE.json.tpl new file mode 100644 index 0000000..5a7bf32 --- /dev/null +++ b/src/sdb/HA_SNAPSHOT_TEMPLATE.json.tpl @@ -0,0 +1,68 @@ +{{ { + "soc_percent": states("sensor.victron_battery_soc"), + "battery_current_a": states("sensor.victron_battery_current"), + "pv_power_w": states("sensor.victron_pv_power_system"), + "pv_power_10m_avg_w": states("sensor.victron_pv_power_10m_avg"), + "house_ac_power_w": states("sensor.victron_consumption_power_l1"), + "grid_power_w": states("sensor.victron_grid_power_l1"), + "grid_state": states("switch.edf_general_ok_switch"), + "victron_acin1_available": states("sensor.victron_ac_in1_available"), + + "sdb_master_power": states("switch.sdb_prise_socket_1"), + "sdb_master_energy_total": states("sensor.sdb_prise_energie_totale"), + + "deshumide_switch": states("switch.deshumide_sdb_socket_1"), + "deshumide_energy_total": states("sensor.deshumide_sdb_energie_totale"), + + "radiator_switch": states("switch.sdb_radiateur_socket_1"), + "radiator_energy_total": states("sensor.sdb_radiateur_energie_totale"), + + "sdb_temperature_c": states("sensor.sdb_temp_hygro_temperature"), + "sdb_humidity_pct": states("sensor.sdb_temp_hygro_humidite"), + "sdb_humidity_age_min": ((as_timestamp(now()) - as_timestamp(states.sensor.sdb_temp_hygro_humidite.last_reported or states.sensor.sdb_temp_hygro_humidite.last_changed)) / 60) | round(0) if states.sensor.sdb_temp_hygro_humidite is not none else -1, + "balance_required_soc_prudent": states("input_number.evcs_balance_required_soc_prudent"), + "balance_status": states("input_text.evcs_balance_status"), + + "auto_enabled": states("input_boolean.sdb_climate_surplus_auto_enabled"), + "notifications_enabled": states("input_boolean.sdb_climate_surplus_notifications"), + "manual_adopt_enabled": states("input_boolean.sdb_master_manual_adopt_enabled"), + + "master_power_latch": states("input_boolean.sdb_master_power_latch"), + "deshumide_latch": states("input_boolean.sdb_deshumide_latch"), + "radiator_latch": states("input_boolean.sdb_radiator_latch"), + + "temp_on_min_c": states("input_number.sdb_temp_on_min_c"), + "temp_off_max_c": states("input_number.sdb_temp_off_max_c"), + "humidity_on_pct": states("input_number.sdb_humidity_on_pct"), + "humidity_off_pct": states("input_number.sdb_humidity_off_pct"), + + "deshumide_min_on_minutes": states("input_number.sdb_deshumide_min_on_minutes"), + "deshumide_min_off_minutes": states("input_number.sdb_deshumide_min_off_minutes"), + + "radiator_auto_enabled": states("input_boolean.sdb_radiator_surplus_auto_enabled"), + "radiator_soc_on": states("input_number.sdb_radiator_soc_on"), + "radiator_soc_off": states("input_number.sdb_radiator_soc_off"), + "radiator_pv_on_w": states("input_number.sdb_radiator_pv_on_w"), + "radiator_pv_off_w": states("input_number.sdb_radiator_pv_off_w"), + "radiator_battery_charge_ceiling_a": states("input_number.sdb_radiator_battery_charge_ceiling_a"), + "radiator_house_ac_max_w": states("input_number.sdb_radiator_house_ac_max_w"), + "radiator_min_on_minutes": states("input_number.sdb_radiator_min_on_minutes"), + "radiator_min_off_minutes": states("input_number.sdb_radiator_min_off_minutes"), + + "day_start_time": states("input_datetime.victron_flexible_day_start_time"), + "day_stop_time": states("input_datetime.victron_flexible_day_stop_time"), + + "master_last_on_at": states("input_datetime.sdb_master_last_on_at"), + "master_last_off_at": states("input_datetime.sdb_master_last_off_at"), + "deshumide_last_on_at": states("input_datetime.sdb_deshumide_last_on_at"), + "deshumide_last_off_at": states("input_datetime.sdb_deshumide_last_off_at"), + "radiator_last_on_at": states("input_datetime.sdb_radiator_last_on_at"), + "radiator_last_off_at": states("input_datetime.sdb_radiator_last_off_at"), + + "evcs_priority_active": states("input_boolean.victron_evcs_priority_active"), + "energy_priority_mode": states("input_select.victron_energy_priority_mode"), + "energy_priority_reason": states("input_text.victron_energy_priority_reason"), + "evcs_priority_until": states("input_datetime.victron_evcs_priority_until"), + + "last_signature": states("input_text.sdb_climate_surplus_last_signature") +} | tojson }} diff --git a/tests/piles/run.mjs b/tests/piles/run.mjs new file mode 100644 index 0000000..0fe3e19 --- /dev/null +++ b/tests/piles/run.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node +import { pilesAAlerter } from '../../tools/piles.mjs'; +const T = Date.parse('2026-10-10T00:30:00+02:00'); +const e = (entity_id, state, attrs = {}, last = '2026-10-09T22:00:00+02:00') => ({ entity_id, state, attributes: attrs, last_changed: last }); +// Extrait reel des etats HA du 10/10 00:30. +const etats = [ + e('sensor.victron_battery_soc', '25', { device_class: 'battery', unit_of_measurement: '%' }), + e('sensor.iphone_16_de_tt_battery_level', '15', { device_class: 'battery', unit_of_measurement: '%' }), + e('sensor.jeep_renegade_phev_hvbattery_charge', '10', { device_class: 'battery', unit_of_measurement: '%' }), + e('sensor.sdb_temp_hygro_batterie', '0.0', { device_class: 'battery', unit_of_measurement: '%', friendly_name: 'SDB TEMP HYGRO Batterie' }), + e('sensor.kt_chambre_batterie', '12.0', { device_class: 'battery', unit_of_measurement: '%', friendly_name: 'SYLVIE CHAMBRE Batterie' }), + e('sensor.salle_de_bain_bruz_batterie', '27.0', { device_class: 'battery', unit_of_measurement: '%', friendly_name: 'GRENIER Batterie' }), + e('sensor.tt_chambre_batterie', 'unavailable', { device_class: 'battery', unit_of_measurement: '%', friendly_name: 'TT CHAMBRE Batterie' }, '2026-10-04T14:52:01+02:00'), + e('sensor.garage_t_etat_batterie', 'middle', { device_class: 'enum', friendly_name: 'Garage T État batterie' }), + e('sensor.cuisine_etat_batterie', 'low', { device_class: 'enum', friendly_name: 'TRETS CUISINE État batterie' }), + e('sensor.chambre_danielle_etat_batterie', 'unavailable', { device_class: 'enum', friendly_name: 'Chambre danielle État batterie' }, '2026-10-09T23:00:00+02:00'), +]; +let f = 0; const ok = (l, c, d = '') => { if (c) console.log(`✅ ${l}`); else { f++; console.log(`❌ ${l} ${d}`); } }; +const r = pilesAAlerter(etats, T); +const ids = r.alertes.map((a) => a.entite); +ok('exclus-victron-telephone-voiture', !ids.some((i) => /victron|iphone|jeep/.test(i)), ids.join(',')); +ok('pourcentage-faible', ids.includes('sensor.sdb_temp_hygro_batterie') && ids.includes('sensor.kt_chambre_batterie') && !ids.includes('sensor.salle_de_bain_bruz_batterie')); +ok('enum-low', ids.includes('sensor.cuisine_etat_batterie') && !ids.includes('sensor.garage_t_etat_batterie')); +ok('muet-plus-de-24h', r.alertes.find((a) => a.entite === 'sensor.tt_chambre_batterie')?.statut === 'muet'); +ok('muet-recent-pas-encore', !ids.includes('sensor.chambre_danielle_etat_batterie')); +ok('ignorees', !pilesAAlerter(etats, T, ['sensor.kt_chambre_batterie']).alertes.some((a) => a.entite === 'sensor.kt_chambre_batterie')); +ok('texte', /SYLVIE CHAMBRE Batterie : pile a 12 %/.test(r.texte) && /TT CHAMBRE Batterie ne publie plus depuis 5 j/.test(r.texte), r.texte); +console.log(f ? `\n${f} ECHEC(S)` : '\nPILES OK'); process.exit(f ? 1 : 0); diff --git a/tests/sdb/run.mjs b/tests/sdb/run.mjs new file mode 100644 index 0000000..a85c1ea --- /dev/null +++ b/tests/sdb/run.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +// Tests du workflow SDB Climate & Surplus (10/10/2026) : vrai code de COMPUTE et +// BUILD_ACTIONS en vm, horloge figee au 10/10/2026 00:15 (Paris). +import fs from 'fs'; +import vm from 'vm'; +import path from 'path'; +import { fileURLToPath } from 'url'; +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const COMPUTE = fs.readFileSync(path.join(ROOT, 'src/sdb/COMPUTE.js'), 'utf8'); +const BUILD = fs.readFileSync(path.join(ROOT, 'src/sdb/BUILD_ACTIONS.js'), 'utf8'); +const FIXE = Date.parse('2026-10-10T00:15:00+02:00'); +class D extends Date { constructor(...a) { super(...(a.length ? a : [FIXE])); } static now() { return FIXE; } } +function run(code, json) { + const ctx = { $input: { first: () => ({ json }) }, console, Date: D, Intl, Math, Number, Object, String, JSON, Array, Boolean }; + vm.createContext(ctx); + return vm.runInContext(`(function(){\n${code}\n})()`, ctx); +} +// Releve reel du 10/10 00:15 : capteur muet depuis le 04/10, deshumidificateur et +// prise SDB allumes et memorises, SOC 26 %, EDF ouvert, nuit. +const base = { + soc_percent: '26', battery_current_a: '-28', pv_power_w: '0', pv_power_10m_avg_w: '0', + house_ac_power_w: '1400', grid_power_w: '0', grid_state: 'off', victron_acin1_available: '0', + sdb_master_power: 'on', sdb_master_energy_total: '4.1', deshumide_switch: 'on', deshumide_energy_total: '3.0', + radiator_switch: 'off', radiator_energy_total: '0', + sdb_temperature_c: 'unavailable', sdb_humidity_pct: 'unavailable', sdb_humidity_age_min: '7900', + auto_enabled: 'on', notifications_enabled: 'on', manual_adopt_enabled: 'on', + master_power_latch: 'on', deshumide_latch: 'on', radiator_latch: 'off', + temp_on_min_c: '20', temp_off_max_c: '24', humidity_on_pct: '66', humidity_off_pct: '41', + deshumide_min_on_minutes: '15', deshumide_min_off_minutes: '10', + radiator_auto_enabled: 'on', radiator_soc_on: '85', radiator_soc_off: '80', + master_last_on_at: '2026-10-09 22:39:35', deshumide_last_on_at: '2026-10-09 20:00:35', + deshumide_last_off_at: '2026-05-17 02:50:35', master_last_off_at: '2026-05-17 05:45:35', + balance_required_soc_prudent: '60', balance_status: 'ok', +}; +const dec = (o) => run(COMPUTE, { data: JSON.stringify({ ...base, ...o }) })[0].json.decision; +let f = 0; +const ok = (l, c, d = '') => { if (c) console.log(`✅ ${l}`); else { f++; console.log(`❌ ${l} ${d}`); } }; + +let d = dec({}); +ok('muet-releve-1010-coupe', d.deshumide_action === 'turn_off' && d.master_action === 'turn_off' && d.deshumide_latch_action === 'turn_off', JSON.stringify(d)); +d = dec({ deshumide_last_on_at: '2026-10-10 00:10:00' }); +ok('muet-temps-mini-respecte', d.deshumide_action === 'none', JSON.stringify(d)); +d = dec({ deshumide_switch: 'off', deshumide_latch: 'off', master_power_latch: 'off', sdb_master_power: 'off' }); +ok('muet-ne-demarre-pas', d.deshumide_action === 'none' && d.master_action === 'none', JSON.stringify(d)); +const vivant = { sdb_temperature_c: '26.3', sdb_humidity_pct: '70', sdb_humidity_age_min: '2', deshumide_switch: 'off', deshumide_latch: 'off', master_power_latch: 'off', sdb_master_power: 'off' }; +d = dec(vivant); +ok('batterie-reservee-pas-de-demarrage', d.deshumide_action === 'none', JSON.stringify(d)); +d = dec({ ...vivant, grid_state: 'on' }); +ok('edf-ferme-demarre', d.deshumide_action === 'turn_on' && d.master_action === 'turn_on', JSON.stringify(d)); +d = dec({ ...vivant, soc_percent: '80' }); +ok('batterie-au-dessus-du-bilan-demarre', d.deshumide_action === 'turn_on', JSON.stringify(d)); +d = dec({ ...vivant, pv_power_10m_avg_w: '2500', house_ac_power_w: '1200' }); +ok('surplus-solaire-demarre', d.deshumide_action === 'turn_on', JSON.stringify(d)); +d = dec({ ...vivant, balance_status: 'degrade' }); +ok('bilan-illisible-comportement-anterieur', d.deshumide_action === 'turn_on', JSON.stringify(d)); +d = dec({ sdb_temperature_c: '26.3', sdb_humidity_pct: '56', sdb_humidity_age_min: '2' }); +ok('batterie-reservee-coupe-apres-temps-mini', d.deshumide_action === 'turn_off', JSON.stringify(d)); +d = dec({ sdb_temperature_c: '26.3', sdb_humidity_pct: '56', sdb_humidity_age_min: '2', soc_percent: '80' }); +ok('hysteresis-humidite-inchangee', d.deshumide_action === 'none', JSON.stringify(d)); +d = dec({ sdb_temperature_c: '26.3', sdb_humidity_pct: '56', sdb_humidity_age_min: '400', soc_percent: '80' }); +ok('valeur-figee-3h-muet', d.deshumide_action === 'turn_off', JSON.stringify(d)); +const out = run(COMPUTE, { data: JSON.stringify(base) })[0].json; +const acts = run(BUILD, out).map((x) => x.json.action); +ok('build-coupe-enfant-puis-master', acts.indexOf('deshumide') >= 0 && acts.indexOf('deshumide') < acts.indexOf('master_power'), acts.join(',')); +console.log(f ? `\n${f} ECHEC(S)` : '\nSDB OK'); +process.exit(f ? 1 : 0); diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index 5104672..f0f4388 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -33,6 +33,7 @@ import { creerSchemaKm, ingererReleves, ingererNiveauSaisi, ingererJeep } from ' import { texteRapport, envoyer, donneesNuit, texteDepuis } from './rapport-nuit.mjs'; import { donneesRevue, constatsRevue } from './revue-jour.mjs'; import { detecterSituation, lireChoix, DUREE_MIN as ARBITRAGE_DUREE_MIN } from './arbitrage.mjs'; +import { pilesAAlerter } from './piles.mjs'; import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, minuitLocal, COMPTEURS_IMPORT, COMPTEUR_EDF, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit, vrmVersCourbes, nuitMaisonPrevue } from './diagnostics-agent.mjs'; import { transitionsDansSerie } from './allers-retours.mjs'; import { energieSur24h, energieDepuis, COMPTEUR_CHAUFFE_EAU } from './chauffe-eau.mjs'; @@ -2064,6 +2065,28 @@ async function appliquerOrdreAirtria(sql, client, st) { return 1; } +// --------------------------------------------------------------------- +// PILES DES CAPTEURS (10/10/2026, tools/piles.mjs) : toutes les 15 min, +// sensor.evcs_piles = nombre d'alertes ; attributs alertes [{entite, nom, +// statut faible|muet, niveau, depuis, texte}] et texte. SPARK previent par +// WhatsApp. input_text.evcs_piles_ignorees (liste separee par des virgules) +// pour faire taire un appareil. +let pilesPubliees = false; +async function publierPiles() { + const r = await fetch(`${HA_URL}/api/states`, { headers: { Authorization: `Bearer ${HA_TOKEN}` }, signal: AbortSignal.timeout(15000) }); + if (!r.ok) throw new Error(`HA ${r.status}`); + const etats = await r.json(); + const ign = String(etats.find((x) => x.entity_id === 'input_text.evcs_piles_ignorees')?.state || '') + .split(',').filter((x) => x && x !== 'unknown'); + const p = pilesAAlerter(etats, Date.now(), ign); + await publierCapteur('sensor.evcs_piles', p.alertes.length, { + friendly_name: 'Piles des capteurs a changer', icon: 'mdi:battery-alert-variant-outline', + alertes: p.alertes, texte: p.texte, ignorees: ign, + }); + pilesPubliees = true; + return 1; +} + // --------------------------------------------------------------------- // ARBITRE ENCADRE (08/10/2026, tools/arbitrage.mjs) // --------------------------------------------------------------------- @@ -2633,6 +2656,9 @@ async function cycle(client) { rapport: DRY ? 0 : await envoyerRapportDuMatin(sql, client).catch((e) => { console.error('rapport de nuit :', e.message); return 0; }), + piles: (DRY || (pilesPubliees && new Date().getMinutes() % 15 !== 6)) ? 0 : await publierPiles().catch((e) => { + console.error('piles :', e.message); return 0; + }), arbitrage: DRY ? 0 : await arbitrer(sql, client, st).catch((e) => { console.error('arbitrage :', e.message); return 0; }), diff --git a/tools/lib-node-map.mjs b/tools/lib-node-map.mjs index ae1f34f..495cc7e 100644 --- a/tools/lib-node-map.mjs +++ b/tools/lib-node-map.mjs @@ -28,6 +28,17 @@ export const WORKFLOWS = [ { file: 'HA_SNAPSHOT_TEMPLATE.json.tpl', node: 'HA Snapshot Advisor', kind: 'template' }, ], }, + { + // SDB Climate & Surplus Manager V2.2 (2NhrfxuGm4pTUNs9), sous controle du depot le 10/10/2026. + id: 'sdb', + workflowFile: 'workflows/sdb-climate-surplus-v2.2.json', + srcDir: 'src/sdb', + nodes: [ + { file: 'COMPUTE.js', node: 'Compute SDB Climate & Surplus V2.2', kind: 'jsCode' }, + { file: 'BUILD_ACTIONS.js', node: 'Build SDB Climate & Surplus Actions V2.2', kind: 'jsCode' }, + { file: 'HA_SNAPSHOT_TEMPLATE.json.tpl', node: 'HA Snapshot SDB Climate & Surplus', kind: 'template' }, + ], + }, { // Dialogue avec le conseiller dans Home Assistant (08/10/2026). id: 'conseiller-dialogue', diff --git a/tools/n8n-push.mjs b/tools/n8n-push.mjs index 090fc25..2c79c1a 100644 --- a/tools/n8n-push.mjs +++ b/tools/n8n-push.mjs @@ -109,6 +109,7 @@ console.log('Syntaxe OK sur tous les nodes jsCode.'); const isEvcsWorkflow = path.basename(INPUT) === 'evcs-surplus-controller-v2.6.json'; const isAdvisorWorkflow = path.basename(INPUT) === 'evcs-advisor-shadow-v1.json'; const isDialogueWorkflow = path.basename(INPUT) === 'evcs-conseiller-dialogue-v1.json'; +const isSdbWorkflow = path.basename(INPUT) === 'sdb-climate-surplus-v2.2.json'; const isHysteresisWorkflow = path.basename(INPUT) === 'victron-grid-hysteresis-v1.json'; const isAcmWorkflow = path.basename(INPUT) === 'victron-auto-charge-manager-v1.2.json'; const isWaterHeaterWorkflow = path.basename(INPUT) === 'water-heater-ecs-v1.json'; @@ -120,6 +121,7 @@ const testSuite = isEvcsWorkflow && fs.existsSync('tests/golden/run.mjs') ? 'tests/golden/run.mjs' : isAdvisorWorkflow && fs.existsSync('tests/advisor/run.mjs') ? 'tests/advisor/run.mjs' : isDialogueWorkflow && fs.existsSync('tests/conseiller-dialogue/run.mjs') ? 'tests/conseiller-dialogue/run.mjs' : + isSdbWorkflow && fs.existsSync('tests/sdb/run.mjs') ? 'tests/sdb/run.mjs' : isHysteresisWorkflow && fs.existsSync('tests/golden-hysteresis/run.mjs') ? 'tests/golden-hysteresis/run.mjs' : isAcmWorkflow && fs.existsSync('tests/golden-acm/run.mjs') ? 'tests/golden-acm/run.mjs' : isBalanceWorkflow && fs.existsSync('tests/energy-balance/run.mjs') ? 'tests/energy-balance/run.mjs' : diff --git a/tools/piles.mjs b/tools/piles.mjs new file mode 100644 index 0000000..5e2bb9f --- /dev/null +++ b/tools/piles.mjs @@ -0,0 +1,52 @@ +// ===================================================================== +// PILES DES CAPTEURS (10/10/2026, demande du proprietaire) +// Le capteur temperature/humidite de la SDB est reste muet six jours (piles) +// sans que personne ne le sache. Ce module repere, parmi les etats HA, les +// capteurs d'APPAREILS a pile dont la pile est faible ou qui ne publient plus ; +// le service d'historisation publie sensor.evcs_piles et SPARK previent par +// WhatsApp. Fonction pure (tests : tests/piles/run.mjs). +// ===================================================================== + +export const SEUIL_PCT = 20; // pile faible a 20 % et moins +export const MUET_H = 24; // indisponible depuis plus de 24 h +// Pas des piles de capteurs : parc Victron, voiture, telephones, reglages GX. +const EXCLUS = /^(sensor|number|binary_sensor)\.(victron_|gx_device_|airtria_8_|jeep_|iphone_|ipad_|samsung_)/; + +/** + * @param {Array} etats liste des etats HA ({entity_id, state, attributes, last_changed}) + * @param {number} maintenantMs + * @param {string[]} ignorees entites que le proprietaire a demande d'ignorer + */ +export function pilesAAlerter(etats, maintenantMs, ignorees = []) { + const ign = new Set(ignorees.map((x) => x.trim()).filter(Boolean)); + const alertes = []; + for (const s of etats) { + const id = s.entity_id; + if (!id.startsWith('sensor.') || EXCLUS.test(id) || ign.has(id)) continue; + const a = s.attributes || {}; + const enPct = a.device_class === 'battery' && (a.unit_of_measurement === '%' || a.unit_of_measurement === undefined); + const enEnum = /_etat_batterie$/.test(id); + if (!enPct && !enEnum) continue; + const nom = String(a.friendly_name || id).replace(/\s+/g, ' ').trim(); + const v = String(s.state ?? '').trim().toLowerCase(); + const depuisMs = Date.parse(s.last_changed); + if (v === 'unavailable' || v === 'unknown' || v === '') { + const h = Number.isFinite(depuisMs) ? (maintenantMs - depuisMs) / 3600000 : Infinity; + if (h > MUET_H) alertes.push({ entite: id, nom, statut: 'muet', niveau: null, depuis: s.last_changed, + texte: `${nom} ne publie plus depuis ${Number.isFinite(h) ? Math.round(h / 24) + ' j' : 'longtemps'} (pile vide ou appareil hors ligne)` }); + continue; + } + if (enPct) { + const n = Number(v); + if (Number.isFinite(n) && n <= SEUIL_PCT) alertes.push({ entite: id, nom, statut: 'faible', niveau: n, depuis: s.last_changed, + texte: `${nom} : pile a ${n} %` }); + } else if (v === 'low') { + alertes.push({ entite: id, nom, statut: 'faible', niveau: 'low', depuis: s.last_changed, texte: `${nom} : pile faible` }); + } + } + alertes.sort((x, y) => (x.statut === y.statut ? x.nom.localeCompare(y.nom) : x.statut === 'faible' ? -1 : 1)); + return { + alertes, + texte: alertes.length ? alertes.map((x) => `- ${x.texte}`).join('\n') : 'Toutes les piles des capteurs sont bonnes.', + }; +} diff --git a/workflows/sdb-climate-surplus-v2.2.json b/workflows/sdb-climate-surplus-v2.2.json new file mode 100644 index 0000000..2df0512 --- /dev/null +++ b/workflows/sdb-climate-surplus-v2.2.json @@ -0,0 +1,467 @@ +{ + "updatedAt": "2026-07-09T21:45:53.855Z", + "createdAt": "2026-03-17T16:32:38.178Z", + "id": "2NhrfxuGm4pTUNs9", + "name": "SDB Climate & Surplus Manager V2.2", + "description": null, + "active": true, + "isArchived": false, + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "minutes", + "minutesInterval": 1 + } + ] + } + }, + "id": "1e5571d8-a01b-4249-8f06-0c694605975e", + "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_current_a\": states(\"sensor.victron_battery_current\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"pv_power_10m_avg_w\": states(\"sensor.victron_pv_power_10m_avg\"),\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"victron_acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n\n \"sdb_master_power\": states(\"switch.sdb_prise_socket_1\"),\n \"sdb_master_energy_total\": states(\"sensor.sdb_prise_energie_totale\"),\n\n \"deshumide_switch\": states(\"switch.deshumide_sdb_socket_1\"),\n \"deshumide_energy_total\": states(\"sensor.deshumide_sdb_energie_totale\"),\n\n \"radiator_switch\": states(\"switch.sdb_radiateur_socket_1\"),\n \"radiator_energy_total\": states(\"sensor.sdb_radiateur_energie_totale\"),\n\n \"sdb_temperature_c\": states(\"sensor.sdb_temp_hygro_temperature\"),\n \"sdb_humidity_pct\": states(\"sensor.sdb_temp_hygro_humidite\"),\n \"sdb_humidity_age_min\": ((as_timestamp(now()) - as_timestamp(states.sensor.sdb_temp_hygro_humidite.last_reported or states.sensor.sdb_temp_hygro_humidite.last_changed)) / 60) | round(0) if states.sensor.sdb_temp_hygro_humidite is not none else -1,\n \"balance_required_soc_prudent\": states(\"input_number.evcs_balance_required_soc_prudent\"),\n \"balance_status\": states(\"input_text.evcs_balance_status\"),\n\n \"auto_enabled\": states(\"input_boolean.sdb_climate_surplus_auto_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.sdb_climate_surplus_notifications\"),\n \"manual_adopt_enabled\": states(\"input_boolean.sdb_master_manual_adopt_enabled\"),\n\n \"master_power_latch\": states(\"input_boolean.sdb_master_power_latch\"),\n \"deshumide_latch\": states(\"input_boolean.sdb_deshumide_latch\"),\n \"radiator_latch\": states(\"input_boolean.sdb_radiator_latch\"),\n\n \"temp_on_min_c\": states(\"input_number.sdb_temp_on_min_c\"),\n \"temp_off_max_c\": states(\"input_number.sdb_temp_off_max_c\"),\n \"humidity_on_pct\": states(\"input_number.sdb_humidity_on_pct\"),\n \"humidity_off_pct\": states(\"input_number.sdb_humidity_off_pct\"),\n\n \"deshumide_min_on_minutes\": states(\"input_number.sdb_deshumide_min_on_minutes\"),\n \"deshumide_min_off_minutes\": states(\"input_number.sdb_deshumide_min_off_minutes\"),\n\n \"radiator_auto_enabled\": states(\"input_boolean.sdb_radiator_surplus_auto_enabled\"),\n \"radiator_soc_on\": states(\"input_number.sdb_radiator_soc_on\"),\n \"radiator_soc_off\": states(\"input_number.sdb_radiator_soc_off\"),\n \"radiator_pv_on_w\": states(\"input_number.sdb_radiator_pv_on_w\"),\n \"radiator_pv_off_w\": states(\"input_number.sdb_radiator_pv_off_w\"),\n \"radiator_battery_charge_ceiling_a\": states(\"input_number.sdb_radiator_battery_charge_ceiling_a\"),\n \"radiator_house_ac_max_w\": states(\"input_number.sdb_radiator_house_ac_max_w\"),\n \"radiator_min_on_minutes\": states(\"input_number.sdb_radiator_min_on_minutes\"),\n \"radiator_min_off_minutes\": states(\"input_number.sdb_radiator_min_off_minutes\"),\n\n \"day_start_time\": states(\"input_datetime.victron_flexible_day_start_time\"),\n \"day_stop_time\": states(\"input_datetime.victron_flexible_day_stop_time\"),\n\n \"master_last_on_at\": states(\"input_datetime.sdb_master_last_on_at\"),\n \"master_last_off_at\": states(\"input_datetime.sdb_master_last_off_at\"),\n \"deshumide_last_on_at\": states(\"input_datetime.sdb_deshumide_last_on_at\"),\n \"deshumide_last_off_at\": states(\"input_datetime.sdb_deshumide_last_off_at\"),\n \"radiator_last_on_at\": states(\"input_datetime.sdb_radiator_last_on_at\"),\n \"radiator_last_off_at\": states(\"input_datetime.sdb_radiator_last_off_at\"),\n\n \"evcs_priority_active\": states(\"input_boolean.victron_evcs_priority_active\"),\n \"energy_priority_mode\": states(\"input_select.victron_energy_priority_mode\"),\n \"energy_priority_reason\": states(\"input_text.victron_energy_priority_reason\"),\n \"evcs_priority_until\": states(\"input_datetime.victron_evcs_priority_until\"),\n\n \"last_signature\": states(\"input_text.sdb_climate_surplus_last_signature\")\n} | tojson }}\n" + } + ] + }, + "options": {} + }, + "id": "74f4b11c-bdb6-41db-9223-f1031cb8a12b", + "name": "HA Snapshot SDB Climate & Surplus", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 224, + 0 + ], + "credentials": { + "httpBearerAuth": { + "id": "uv3sknYYigxBkaVN", + "name": "Bearer Auth account 2" + } + } + }, + { + "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": "761fbc47-54ca-4961-863e-634b52e764d3", + "name": "Execute HA Service SDB Climate & Surplus", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 896, + 0 + ], + "alwaysOutputData": true, + "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 round2(v) {\n return Math.round(v * 100) / 100;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction parseTimeToMinutes(v, fallback = null) {\n const s = (v ?? '').toString().trim();\n const m = s.match(/^(\\d{1,2}):(\\d{2})(?::(\\d{2}))?$/);\n if (!m) return fallback;\n const hh = Number(m[1]);\n const mm = Number(m[2]);\n if (!Number.isFinite(hh) || !Number.isFinite(mm)) return fallback;\n return hh * 60 + mm;\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\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 hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n const stamp = `${date} ${to2(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,\n minute: Number(parts.minute),\n second: Number(parts.second),\n date,\n stamp,\n minNow: hour * 60 + Number(parts.minute),\n };\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\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 dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nfunction cleanStateText(v) {\n const s = (v ?? '').toString().trim();\n const x = s.toLowerCase();\n return ['unknown', 'unavailable', 'none', 'null'].includes(x) ? '' : s;\n}\n\nfunction cleanParisStamp(v) {\n const s = cleanStateText(v);\n return parseParisStampToMs(s) !== null ? s : '';\n}\n\nfunction minutesSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 60000);\n}\n\nfunction safeText(v, max = 255) {\n return (v ?? '').toString().slice(0, max);\n}\n\nconst cfg = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\n\n radiatorRealPowerW: 960,\n batteryNominalVoltageV: 52,\n\n radiatorStartSurplusRatio: 0.60,\n radiatorHoldSurplusRatio: 0.25,\n radiatorMinBatteryStartA: 2,\n radiatorMinBatteryHoldA: 0,\n radiatorMaxBatteryHoldDischargeA: -1.5\n};\n\nconst raw = $input.first().json;\nconst input = extractSnapshot(raw);\nconst now = getParisNowParts();\nconst nowMs = parseParisStampToMs(now.stamp);\n\n// ======================================================\n// INPUTS\n// ======================================================\nconst soc = clamp(num(input.soc_percent, 0), 0, 100);\nconst batteryCurrentA = num(input.battery_current_a, 0);\nconst pvPowerW = Math.max(0, num(input.pv_power_w, 0));\nconst pvPowerAvg10mW = Math.max(0, num(input.pv_power_10m_avg_w, pvPowerW));\nconst houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));\nconst gridPowerW = num(input.grid_power_w, 0);\nconst gridIsOn = low(input.grid_state || 'off') === 'on';\nconst acInAvailable = num(input.victron_acin1_available, 0) === 1;\nconst gridAvailable = gridIsOn || acInAvailable;\n\nconst masterIsOn = low(input.sdb_master_power || 'off') === 'on';\nconst deshumideIsOn = low(input.deshumide_switch || 'off') === 'on';\nconst radiatorIsOn = low(input.radiator_switch || 'off') === 'on';\n\nconst masterEnergyTotalKwh = Math.max(0, num(input.sdb_master_energy_total, 0));\nconst deshumideEnergyTotalKwh = Math.max(0, num(input.deshumide_energy_total, 0));\nconst radiatorEnergyTotalKwh = Math.max(0, num(input.radiator_energy_total, 0));\n\nconst temperatureC = num(input.sdb_temperature_c, NaN);\nconst humidityPct = num(input.sdb_humidity_pct, NaN);\n// ⚠️ CAPTEUR MUET (10/10/2026). Le capteur sdb_temp_hygro est reste `unavailable`\n// du 04/10 14:52 au 10/10 00:20 (piles) : sans humidite, la regle « maintenir\n// jusqu'a H <= seuil OFF » ne pouvait jamais se terminer, et le deshumidificateur\n// (avec la prise SDB) tournait sans fin, y compris la nuit sur batterie a 26 %.\n// Muet = valeur illisible OU derniere publication il y a plus de 3 h.\nconst humidityAgeMin = num(input.sdb_humidity_age_min, NaN);\nconst CAPTEUR_MUET_MIN = 180;\nconst capteurMuet = !Number.isFinite(humidityPct) || (Number.isFinite(humidityAgeMin) && humidityAgeMin > CAPTEUR_MUET_MIN);\n// ⚠️ ENERGIE (10/10/2026) : le deshumidificateur est une charge REPORTABLE. Sur la\n// batterie, seulement si elle reste au-dessus de ce que le bilan juge necessaire\n// (profil prudent) ; sinon il attend EDF ferme ou un vrai surplus solaire. Pas de\n// seuil fixe : l'exigence vient du bilan. Bilan illisible -> pas de restriction\n// (comportement anterieur), pour ne jamais bloquer sur une panne de bilan.\nconst balanceRequiredPrudent = num(input.balance_required_soc_prudent, NaN);\nconst balanceOk = low(input.balance_status || '') === 'ok' && Number.isFinite(balanceRequiredPrudent);\n\nconst autoEnabled = low(input.auto_enabled || 'off') === 'on';\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\nconst manualAdoptEnabled = low(input.manual_adopt_enabled || 'off') === 'on';\n\nconst masterLatchIsOn = low(input.master_power_latch || 'off') === 'on';\nconst deshumideLatchIsOn = low(input.deshumide_latch || 'off') === 'on';\nconst radiatorLatchIsOn = low(input.radiator_latch || 'off') === 'on';\n\nconst tempOnMinC = num(input.temp_on_min_c, 21);\nconst tempOffMaxC = num(input.temp_off_max_c, 23);\nconst humidityOnPct = num(input.humidity_on_pct, 55);\nconst humidityOffPct = num(input.humidity_off_pct, 45);\n\nconst deshumideMinOnMinutes = Math.max(0, num(input.deshumide_min_on_minutes, 15));\nconst deshumideMinOffMinutes = Math.max(0, num(input.deshumide_min_off_minutes, 10));\n\nconst radiatorAutoEnabled = low(input.radiator_auto_enabled || 'off') === 'on';\nconst radiatorSocOn = clamp(num(input.radiator_soc_on, 90), 0, 100);\nconst radiatorSocOff = clamp(num(input.radiator_soc_off, 88), 0, 100);\nconst radiatorPvOnW = Math.max(0, num(input.radiator_pv_on_w, 2500));\nconst radiatorPvOffW = Math.max(0, num(input.radiator_pv_off_w, 1200));\nconst radiatorBatteryChargeCeilingA = Math.max(0, num(input.radiator_battery_charge_ceiling_a, 5));\nconst radiatorHouseAcMaxW = Math.max(0, num(input.radiator_house_ac_max_w, 6500));\nconst radiatorMinOnMinutes = Math.max(0, num(input.radiator_min_on_minutes, 15));\nconst radiatorMinOffMinutes = Math.max(0, num(input.radiator_min_off_minutes, 10));\n\nconst dayStartMin = parseTimeToMinutes(input.day_start_time, 9 * 60);\nconst dayStopMin = parseTimeToMinutes(input.day_stop_time, 19 * 60 + 15);\nconst inDayWindow =\n dayStartMin < dayStopMin\n ? (now.minNow >= dayStartMin && now.minNow < dayStopMin)\n : (now.minNow >= dayStartMin || now.minNow < dayStopMin);\n\nconst masterLastOnAt = cleanParisStamp(input.master_last_on_at);\nconst masterLastOffAt = cleanParisStamp(input.master_last_off_at);\nconst deshumideLastOnAt = cleanParisStamp(input.deshumide_last_on_at);\nconst deshumideLastOffAt = cleanParisStamp(input.deshumide_last_off_at);\nconst radiatorLastOnAt = cleanParisStamp(input.radiator_last_on_at);\nconst radiatorLastOffAt = cleanParisStamp(input.radiator_last_off_at);\nconst lastSignature = cleanStateText(input.last_signature);\n\n// ======================================================\n// EVCS PRIORITY\n// ======================================================\nconst evcsPriorityActiveRaw = low(input.evcs_priority_active || 'off') === 'on';\nconst energyPriorityMode = cleanStateText(input.energy_priority_mode || 'normal').toLowerCase() || 'normal';\nconst energyPriorityReason = cleanStateText(input.energy_priority_reason);\nconst evcsPriorityUntilAt = cleanParisStamp(input.evcs_priority_until);\nconst evcsPriorityUntilMs = parseParisStampToMs(evcsPriorityUntilAt);\n\nconst evcsPriorityActive =\n evcsPriorityActiveRaw &&\n (\n evcsPriorityUntilMs === null ||\n (nowMs !== null && evcsPriorityUntilMs > nowMs + 1000)\n );\n\n// ======================================================\n// TIMESTAMPS\n// ======================================================\nconst masterLastOnMs = parseParisStampToMs(masterLastOnAt);\nconst masterLastOffMs = parseParisStampToMs(masterLastOffAt);\nconst deshumideLastOnMs = parseParisStampToMs(deshumideLastOnAt);\nconst deshumideLastOffMs = parseParisStampToMs(deshumideLastOffAt);\nconst radiatorLastOnMs = parseParisStampToMs(radiatorLastOnAt);\nconst radiatorLastOffMs = parseParisStampToMs(radiatorLastOffAt);\n\nconst minutesSinceMasterOn = minutesSince(masterLastOnMs, nowMs);\nconst minutesSinceMasterOff = minutesSince(masterLastOffMs, nowMs);\nconst minutesSinceDeshumideOn = minutesSince(deshumideLastOnMs, nowMs);\nconst minutesSinceDeshumideOff = minutesSince(deshumideLastOffMs, nowMs);\nconst minutesSinceRadiatorOn = minutesSince(radiatorLastOnMs, nowMs);\nconst minutesSinceRadiatorOff = minutesSince(radiatorLastOffMs, nowMs);\n\nconst deshumideMinOnElapsed = minutesSinceDeshumideOn === null ? true : minutesSinceDeshumideOn >= deshumideMinOnMinutes;\nconst deshumideMinOffElapsed = minutesSinceDeshumideOff === null ? true : minutesSinceDeshumideOff >= deshumideMinOffMinutes;\nconst radiatorMinOnElapsed = minutesSinceRadiatorOn === null ? true : minutesSinceRadiatorOn >= radiatorMinOnMinutes;\nconst radiatorMinOffElapsed = minutesSinceRadiatorOff === null ? true : minutesSinceRadiatorOff >= radiatorMinOffMinutes;\n\n// ======================================================\n// BESOINS METIER SEPARES\n// ======================================================\nconst heatNeed = Number.isFinite(temperatureC) ? temperatureC < tempOnMinC : false;\nconst heatRecovered = Number.isFinite(temperatureC) ? temperatureC >= tempOffMaxC : false;\n\nconst humidityNeed = Number.isFinite(humidityPct) ? humidityPct > humidityOnPct : false;\nconst humidityRecovered = Number.isFinite(humidityPct) ? humidityPct <= humidityOffPct : false;\n\nconst comfortDemand = heatNeed || humidityNeed;\nconst comfortRecovered = heatRecovered && humidityRecovered;\n\n// ======================================================\n// RADIATEUR SDB : SURPLUS REEL V2\n// ======================================================\nconst estimatedBatteryChargeW =\n Number.isFinite(batteryCurrentA) && batteryCurrentA > 0\n ? batteryCurrentA * cfg.batteryNominalVoltageV\n : 0;\n\nconst directPvHeadroomW = Math.max(0, pvPowerAvg10mW - houseAcPowerW);\nconst estimatedSurplusW = Math.max(0, directPvHeadroomW + estimatedBatteryChargeW);\n\nconst radiatorStartSurplusNeededW = Math.round(cfg.radiatorRealPowerW * cfg.radiatorStartSurplusRatio);\nconst radiatorHoldSurplusNeededW = Math.round(cfg.radiatorRealPowerW * cfg.radiatorHoldSurplusRatio);\n\nconst radiatorBatteryStartOk =\n Number.isFinite(batteryCurrentA) &&\n batteryCurrentA >= cfg.radiatorMinBatteryStartA &&\n batteryCurrentA <= radiatorBatteryChargeCeilingA;\n\nconst radiatorBatteryHoldOk =\n Number.isFinite(batteryCurrentA) &&\n batteryCurrentA >= cfg.radiatorMaxBatteryHoldDischargeA &&\n batteryCurrentA <= radiatorBatteryChargeCeilingA + 4;\n\nconst radiatorStartByAbsolutePv =\n pvPowerAvg10mW >= radiatorPvOnW && radiatorBatteryStartOk;\n\nconst radiatorStartByEstimatedSurplus =\n estimatedSurplusW >= radiatorStartSurplusNeededW && radiatorBatteryStartOk;\n\nconst radiatorHoldByAbsolutePv =\n pvPowerAvg10mW >= radiatorPvOffW && radiatorBatteryHoldOk;\n\nconst radiatorHoldByEstimatedSurplus =\n estimatedSurplusW >= radiatorHoldSurplusNeededW && radiatorBatteryHoldOk;\n\nconst radiatorStartConditionsOk =\n radiatorAutoEnabled &&\n heatNeed &&\n inDayWindow &&\n !gridAvailable &&\n !evcsPriorityActive &&\n soc >= radiatorSocOn &&\n houseAcPowerW < radiatorHouseAcMaxW &&\n (radiatorStartByAbsolutePv || radiatorStartByEstimatedSurplus);\n\nconst radiatorHoldConditionsOk =\n radiatorAutoEnabled &&\n !heatRecovered &&\n inDayWindow &&\n !gridAvailable &&\n !evcsPriorityActive &&\n soc >= radiatorSocOff &&\n houseAcPowerW < radiatorHouseAcMaxW &&\n (radiatorHoldByAbsolutePv || radiatorHoldByEstimatedSurplus);\n\n// ======================================================\n// SORTIES DESIREES\n// ======================================================\nlet desiredDeshumide = false;\nlet desiredRadiator = false;\nlet desiredMaster = false;\n\nlet state = 'idle';\nlet reason = [];\n\nlet dehumActionReason = 'idle';\nlet radiatorActionReason = 'idle';\n\nlet adoptMaster = false;\nlet adoptDeshumide = false;\nlet adoptRadiator = false;\n\nlet notifyTitle = 'SDB Climate & Surplus';\nlet notifyMessage = '';\nlet notifyWanted = false;\n\n// Energie disponible pour une charge reportable (calcule apres les entrees).\nconst deshumideEnergyOk =\n gridIsOn ||\n (pvPowerAvg10mW - houseAcPowerW) > 0 ||\n !balanceOk ||\n soc >= balanceRequiredPrudent;\n\n// ======================================================\n// DESHUMIDIFICATEUR - PRIORITAIRE HUMIDITE\n// ======================================================\nif (!autoEnabled) {\n desiredDeshumide = deshumideIsOn;\n dehumActionReason = 'automation_disabled';\n}\nelse if (capteurMuet && (deshumideIsOn || deshumideLatchIsOn)) {\n // Sans mesure, on ne tient plus rien au-dela du temps minimum.\n desiredDeshumide = !deshumideMinOnElapsed;\n dehumActionReason = deshumideMinOnElapsed ? 'deshumide_stop_capteur_muet' : 'deshumide_hold_min_on';\n}\nelse if (capteurMuet) {\n desiredDeshumide = false;\n dehumActionReason = 'deshumide_capteur_muet';\n}\nelse if (!deshumideEnergyOk && (deshumideIsOn || deshumideLatchIsOn)) {\n desiredDeshumide = !deshumideMinOnElapsed;\n dehumActionReason = deshumideMinOnElapsed ? 'deshumide_stop_batterie_reservee' : 'deshumide_hold_min_on';\n}\nelse if (!deshumideEnergyOk && humidityNeed) {\n desiredDeshumide = false;\n dehumActionReason = 'deshumide_attente_energie';\n}\nelse if (humidityNeed) {\n if (deshumideIsOn) {\n desiredDeshumide = true;\n dehumActionReason = 'deshumide_already_running';\n } else if (deshumideMinOffElapsed) {\n desiredDeshumide = true;\n dehumActionReason = 'deshumide_start_on_humidity_need';\n } else {\n desiredDeshumide = false;\n dehumActionReason = 'deshumide_wait_min_off';\n }\n}\nelse if (deshumideIsOn || deshumideLatchIsOn) {\n if (!humidityRecovered) {\n desiredDeshumide = true;\n dehumActionReason = 'deshumide_hold_until_humidity_recovered';\n } else if (!deshumideMinOnElapsed) {\n desiredDeshumide = true;\n dehumActionReason = 'deshumide_hold_min_on';\n } else {\n desiredDeshumide = false;\n dehumActionReason = 'deshumide_stop_humidity_recovered';\n }\n}\nelse {\n desiredDeshumide = false;\n dehumActionReason = 'deshumide_idle';\n}\n\n// Adoption manuelle déshumide uniquement si cohérent avec la demande\nif (manualAdoptEnabled && desiredDeshumide && deshumideIsOn && !deshumideLatchIsOn) {\n adoptDeshumide = true;\n}\n\n// ======================================================\n// RADIATEUR - CHAUFFAGE + SURPLUS + PRIORITE EVCS\n// ======================================================\nif (!autoEnabled) {\n desiredRadiator = radiatorIsOn;\n radiatorActionReason = 'automation_disabled';\n}\nelse if (!radiatorAutoEnabled) {\n desiredRadiator = radiatorIsOn;\n radiatorActionReason = 'radiator_auto_disabled_observe_only';\n}\nelse if (evcsPriorityActive) {\n if (radiatorIsOn || radiatorLatchIsOn) {\n if (!radiatorMinOnElapsed) {\n desiredRadiator = true;\n radiatorActionReason = 'radiator_hold_min_on_evcs_priority';\n } else {\n desiredRadiator = false;\n radiatorActionReason = 'radiator_blocked_by_evcs_priority';\n }\n } else {\n desiredRadiator = false;\n radiatorActionReason = 'radiator_blocked_by_evcs_priority';\n }\n}\nelse if (heatNeed && radiatorStartConditionsOk) {\n if (radiatorIsOn) {\n desiredRadiator = true;\n radiatorActionReason = 'radiator_already_running';\n } else if (radiatorMinOffElapsed) {\n desiredRadiator = true;\n radiatorActionReason = 'radiator_start_heat_need_surplus_v2';\n } else {\n desiredRadiator = false;\n radiatorActionReason = 'radiator_wait_min_off';\n }\n}\nelse if (radiatorIsOn || radiatorLatchIsOn) {\n if (radiatorHoldConditionsOk) {\n desiredRadiator = true;\n radiatorActionReason = 'radiator_hold_heat_need_surplus_v2';\n } else if (!radiatorMinOnElapsed) {\n desiredRadiator = true;\n radiatorActionReason = 'radiator_hold_min_on';\n } else if (evcsPriorityActive) {\n desiredRadiator = false;\n radiatorActionReason = 'radiator_stop_evcs_priority';\n } else if (heatRecovered) {\n desiredRadiator = false;\n radiatorActionReason = 'radiator_stop_heat_recovered';\n } else {\n desiredRadiator = false;\n radiatorActionReason = 'radiator_stop_surplus_lost';\n }\n}\nelse {\n desiredRadiator = false;\n radiatorActionReason = heatNeed ? 'radiator_heat_need_but_energy_not_ok' : 'radiator_idle';\n}\n\n// Adoption manuelle radiateur uniquement si cohérent et hors EVCS\nif (\n manualAdoptEnabled &&\n desiredRadiator &&\n radiatorIsOn &&\n !radiatorLatchIsOn &&\n !evcsPriorityActive\n) {\n adoptRadiator = true;\n}\n\n// ======================================================\n// MASTER SDB PRISE = COORDINATEUR\n// ======================================================\nif (!autoEnabled) {\n desiredMaster = masterIsOn;\n} else {\n desiredMaster = desiredDeshumide || desiredRadiator;\n}\n\nif (manualAdoptEnabled && desiredMaster && masterIsOn && !masterLatchIsOn) {\n adoptMaster = true;\n}\n\n// ======================================================\n// ETAT GLOBAL\n// ======================================================\nif (!autoEnabled) {\n state = 'automation_disabled';\n reason.push(\"Automatisation SDB climate/surplus désactivée\");\n}\nelse if (desiredDeshumide && desiredRadiator) {\n state = 'deshumide_and_radiator_on';\n reason.push(\"Déshumidification active + radiateur actif sur surplus\");\n}\nelse if (desiredDeshumide) {\n state = 'deshumide_on';\n reason.push(\"Déshumidification SDB active\");\n}\nelse if (desiredRadiator) {\n state = 'radiator_on';\n reason.push(\"Radiateur SDB actif sur besoin thermique et surplus\");\n}\nelse if (radiatorActionReason === 'radiator_blocked_by_evcs_priority' || radiatorActionReason === 'radiator_stop_evcs_priority') {\n state = 'radiator_blocked_by_evcs_priority';\n reason.push(\"Radiateur SDB bloqué par priorité EVCS\");\n}\nelse if (dehumActionReason === 'deshumide_wait_min_off') {\n state = 'wait_deshumide_min_off';\n reason.push(\"Déshumidificateur en attente de temps mini OFF\");\n}\nelse if (radiatorActionReason === 'radiator_wait_min_off') {\n state = 'wait_radiator_min_off';\n reason.push(\"Radiateur en attente de temps mini OFF\");\n}\nelse {\n state = 'idle';\n reason.push(\"Aucune demande SDB active\");\n}\n\nreason.push(`Température SDB ${Number.isFinite(temperatureC) ? round1(temperatureC) + '°C' : 'n/a'}`);\nreason.push(`Humidité SDB ${Number.isFinite(humidityPct) ? round1(humidityPct) + '%' : 'n/a'}`);\n\nreason.push(`Chauffage ON si T<${round1(tempOnMinC)}°C`);\nreason.push(`Chauffage OFF si T>=${round1(tempOffMaxC)}°C`);\nreason.push(`Déshumidification ON si H>${round1(humidityOnPct)}%`);\nreason.push(`Déshumidification OFF si H<=${round1(humidityOffPct)}%`);\n\nreason.push(`Besoin chauffage=${heatNeed ? 'oui' : 'non'} | Rétabli chauffage=${heatRecovered ? 'oui' : 'non'}`);\nreason.push(`Besoin humidité=${humidityNeed ? 'oui' : 'non'} | Humidité rétablie=${humidityRecovered ? 'oui' : 'non'}`);\n\nreason.push(`Déshumide: ${dehumActionReason}`);\nif (capteurMuet) reason.push(`⚠️ Capteur SDB muet${Number.isFinite(humidityAgeMin) && humidityAgeMin >= 0 ? ` depuis ${Math.round(humidityAgeMin)} min` : ''} (piles ?)`);\nif (!deshumideEnergyOk) reason.push(`Batterie reservee : SOC ${round1(soc)}% < ${round1(balanceRequiredPrudent)}% (bilan, prudent), EDF ouvert, pas de surplus`);\nreason.push(`Radiateur: ${radiatorActionReason}`);\n\nreason.push(`Master=${masterIsOn ? 'ON' : 'OFF'} | Deshumide=${deshumideIsOn ? 'ON' : 'OFF'} | Radiateur=${radiatorIsOn ? 'ON' : 'OFF'}`);\nreason.push(`SOC ${round1(soc)}% | PV ${Math.round(pvPowerW)}W | PV10 ${Math.round(pvPowerAvg10mW)}W | AC maison ${Math.round(houseAcPowerW)}W | Batterie ${round1(batteryCurrentA)}A`);\nreason.push(`Réseau cmd=${gridIsOn ? 'ON' : 'OFF'} | acin=${acInAvailable ? 'OK' : 'KO'} | dispo=${gridAvailable ? 'oui' : 'non'}`);\nreason.push(`Fenêtre jour ${inDayWindow ? 'OK' : 'Hors fenêtre'}`);\nreason.push(`Prio EVCS ${evcsPriorityActive ? 'ON' : 'OFF'}${energyPriorityReason ? ` | ${energyPriorityReason}` : ''}`);\n\nreason.push(`Radiateur ${Math.round(cfg.radiatorRealPowerW)}W | Headroom PV ${Math.round(directPvHeadroomW)}W | Charge batt estimée ${Math.round(estimatedBatteryChargeW)}W | Surplus estimé ${Math.round(estimatedSurplusW)}W`);\nreason.push(`Start radiateur si surplus >= ${Math.round(radiatorStartSurplusNeededW)}W ou PV10 >= ${Math.round(radiatorPvOnW)}W`);\nreason.push(`Hold radiateur si surplus >= ${Math.round(radiatorHoldSurplusNeededW)}W ou PV10 >= ${Math.round(radiatorPvOffW)}W`);\n\nif (adoptMaster) reason.push(\"Prise maître SDB adoptée\");\nif (adoptDeshumide) reason.push(\"Déshumidificateur adopté\");\nif (adoptRadiator) reason.push(\"Radiateur adopté\");\n\n// ======================================================\n// ACTIONS / LATCHES\n// ======================================================\nconst desiredMasterLatch = desiredMaster ? 'on' : 'off';\nconst desiredDeshumideLatch = desiredDeshumide ? 'on' : 'off';\nconst desiredRadiatorLatch = desiredRadiator ? 'on' : 'off';\n\nconst masterAction =\n desiredMaster === masterIsOn\n ? 'none'\n : desiredMaster ? 'turn_on' : 'turn_off';\n\nconst deshumideAction =\n desiredDeshumide === deshumideIsOn\n ? 'none'\n : desiredDeshumide ? 'turn_on' : 'turn_off';\n\nconst radiatorAction =\n desiredRadiator === radiatorIsOn\n ? 'none'\n : desiredRadiator ? 'turn_on' : 'turn_off';\n\nconst masterLatchAction =\n desiredMasterLatch === (masterLatchIsOn ? 'on' : 'off')\n ? 'none'\n : desiredMasterLatch === 'on' ? 'turn_on' : 'turn_off';\n\nconst deshumideLatchAction =\n desiredDeshumideLatch === (deshumideLatchIsOn ? 'on' : 'off')\n ? 'none'\n : desiredDeshumideLatch === 'on' ? 'turn_on' : 'turn_off';\n\nconst radiatorLatchAction =\n desiredRadiatorLatch === (radiatorLatchIsOn ? 'on' : 'off')\n ? 'none'\n : desiredRadiatorLatch === 'on' ? 'turn_on' : 'turn_off';\n\n// ======================================================\n// TIMESTAMPS\n// ======================================================\nconst setMasterLastOnAt = masterAction === 'turn_on' || adoptMaster;\nconst setMasterLastOffAt = masterAction === 'turn_off';\n\nconst setDeshumideLastOnAt = deshumideAction === 'turn_on' || adoptDeshumide;\nconst setDeshumideLastOffAt = deshumideAction === 'turn_off';\n\nconst setRadiatorLastOnAt = radiatorAction === 'turn_on' || adoptRadiator;\nconst setRadiatorLastOffAt = radiatorAction === 'turn_off';\n\n// ======================================================\n// NOTIFICATIONS\n// ======================================================\nif (state === 'deshumide_on' && (deshumideAction === 'turn_on' || adoptDeshumide)) {\n notifyTitle = '💧 Déshumidification SDB active';\n notifyMessage = `SDB: T=${Number.isFinite(temperatureC) ? round1(temperatureC) : 'n/a'}°C | H=${Number.isFinite(humidityPct) ? round1(humidityPct) : 'n/a'}%.`;\n notifyWanted = true;\n}\nelse if ((dehumActionReason === 'deshumide_stop_capteur_muet' || dehumActionReason === 'deshumide_stop_batterie_reservee') && deshumideAction === 'turn_off') {\n notifyTitle = dehumActionReason === 'deshumide_stop_capteur_muet' ? '⚠️ Capteur SDB muet : déshumidificateur arrêté' : '🔋 Déshumidificateur SDB arrêté : batterie réservée';\n notifyMessage = dehumActionReason === 'deshumide_stop_capteur_muet'\n ? 'Le capteur température/humidité de la SDB ne publie plus (piles ?). Sans mesure, le déshumidificateur ne peut plus être piloté.'\n : `SOC ${round1(soc)}% sous les ${round1(balanceRequiredPrudent)}% que le bilan réserve ; il repartira avec EDF ou le soleil.`;\n notifyWanted = true;\n}\nelse if (dehumActionReason === 'deshumide_stop_humidity_recovered' && deshumideAction === 'turn_off') {\n notifyTitle = '✅ Déshumidification SDB arrêtée';\n notifyMessage = `Humidité revenue dans la zone cible : H=${Number.isFinite(humidityPct) ? round1(humidityPct) : 'n/a'}%.`;\n notifyWanted = true;\n}\nelse if (state === 'radiator_on' && radiatorAction === 'turn_on') {\n notifyTitle = '☀️ Radiateur SDB actif sur surplus';\n notifyMessage = `T=${Number.isFinite(temperatureC) ? round1(temperatureC) : 'n/a'}°C | SOC ${round1(soc)}% | PV10 ${Math.round(pvPowerAvg10mW)}W | Surplus ${Math.round(estimatedSurplusW)}W.`;\n notifyWanted = true;\n}\nelse if (\n (radiatorActionReason === 'radiator_stop_surplus_lost' || radiatorActionReason === 'radiator_stop_heat_recovered') &&\n radiatorAction === 'turn_off'\n) {\n notifyTitle = '⛔ Radiateur SDB arrêté';\n notifyMessage = `T=${Number.isFinite(temperatureC) ? round1(temperatureC) : 'n/a'}°C | PV10 ${Math.round(pvPowerAvg10mW)}W | Surplus ${Math.round(estimatedSurplusW)}W.`;\n notifyWanted = true;\n}\nelse if (\n (radiatorActionReason === 'radiator_blocked_by_evcs_priority' || radiatorActionReason === 'radiator_stop_evcs_priority') &&\n radiatorAction === 'turn_off'\n) {\n notifyTitle = '🚗 Priorité EVCS : radiateur SDB coupé';\n notifyMessage = `${energyPriorityReason || 'La recharge EVCS est prioritaire'}${evcsPriorityUntilAt ? ` jusqu'à ${evcsPriorityUntilAt}` : ''}.`;\n notifyWanted = true;\n}\nelse if (state === 'deshumide_and_radiator_on' && (deshumideAction === 'turn_on' || radiatorAction === 'turn_on')) {\n notifyTitle = '🌤️ SDB active : humidité + chauffage';\n notifyMessage = `Déshumidificateur et radiateur actifs. T=${Number.isFinite(temperatureC) ? round1(temperatureC) : 'n/a'}°C | H=${Number.isFinite(humidityPct) ? round1(humidityPct) : 'n/a'}% | SOC ${round1(soc)}%.`;\n notifyWanted = true;\n}\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst signature = [\n now.date,\n state,\n `m=${desiredMaster ? '1' : '0'}`,\n `d=${desiredDeshumide ? '1' : '0'}`,\n `r=${desiredRadiator ? '1' : '0'}`,\n `heat=${heatNeed ? '1' : '0'}`,\n `hum=${humidityNeed ? '1' : '0'}`,\n `ev=${evcsPriorityActive ? '1' : '0'}`,\n `t=${Number.isFinite(temperatureC) ? round1(temperatureC) : 'na'}`,\n `h=${Number.isFinite(humidityPct) ? round1(humidityPct) : 'na'}`,\n `sx=${Math.round(estimatedSurplusW)}`,\n `muet=${capteurMuet ? '1' : '0'}`,\n `nrj=${deshumideEnergyOk ? '1' : '0'}`\n].join('|');\n\nconst signatureChanged = signature !== lastSignature;\n\n// ======================================================\n// OUTPUT\n// ======================================================\nconst result = {\n timestamp_paris: now.stamp,\n\n inputs: {\n soc_percent: soc,\n battery_current_a: batteryCurrentA,\n pv_power_w: pvPowerW,\n pv_power_10m_avg_w: pvPowerAvg10mW,\n house_ac_power_w: houseAcPowerW,\n grid_power_w: gridPowerW,\n grid_state: gridIsOn ? 'on' : 'off',\n victron_acin1_available: acInAvailable ? 1 : 0,\n\n sdb_master_power: masterIsOn,\n sdb_master_energy_total: round2(masterEnergyTotalKwh),\n deshumide_switch: deshumideIsOn,\n deshumide_energy_total: round2(deshumideEnergyTotalKwh),\n radiator_switch: radiatorIsOn,\n radiator_energy_total: round2(radiatorEnergyTotalKwh),\n\n sdb_temperature_c: Number.isFinite(temperatureC) ? round1(temperatureC) : null,\n sdb_humidity_pct: Number.isFinite(humidityPct) ? round1(humidityPct) : null,\n\n auto_enabled: autoEnabled,\n notifications_enabled: notificationsEnabled,\n manual_adopt_enabled: manualAdoptEnabled,\n\n master_power_latch: masterLatchIsOn,\n deshumide_latch: deshumideLatchIsOn,\n radiator_latch: radiatorLatchIsOn,\n\n temp_on_min_c: tempOnMinC,\n temp_off_max_c: tempOffMaxC,\n humidity_on_pct: humidityOnPct,\n humidity_off_pct: humidityOffPct,\n\n deshumide_min_on_minutes: deshumideMinOnMinutes,\n deshumide_min_off_minutes: deshumideMinOffMinutes,\n\n radiator_auto_enabled: radiatorAutoEnabled,\n radiator_soc_on: radiatorSocOn,\n radiator_soc_off: radiatorSocOff,\n radiator_pv_on_w: radiatorPvOnW,\n radiator_pv_off_w: radiatorPvOffW,\n radiator_battery_charge_ceiling_a: radiatorBatteryChargeCeilingA,\n radiator_house_ac_max_w: radiatorHouseAcMaxW,\n radiator_min_on_minutes: radiatorMinOnMinutes,\n radiator_min_off_minutes: radiatorMinOffMinutes,\n\n radiator_real_power_w_internal: cfg.radiatorRealPowerW,\n\n day_start_time: input.day_start_time || null,\n day_stop_time: input.day_stop_time || null,\n\n master_last_on_at: masterLastOnAt || null,\n master_last_off_at: masterLastOffAt || null,\n deshumide_last_on_at: deshumideLastOnAt || null,\n deshumide_last_off_at: deshumideLastOffAt || null,\n radiator_last_on_at: radiatorLastOnAt || null,\n radiator_last_off_at: radiatorLastOffAt || null,\n\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason || null,\n evcs_priority_until: evcsPriorityUntilAt || null,\n\n last_signature: lastSignature || null\n },\n\n computed: {\n in_day_window: inDayWindow,\n\n heat_need: heatNeed,\n heat_recovered: heatRecovered,\n humidity_need: humidityNeed,\n humidity_recovered: humidityRecovered,\n comfort_demand: comfortDemand,\n comfort_recovered: comfortRecovered,\n\n temp_too_low: heatNeed,\n humidity_too_high: humidityNeed,\n\n direct_pv_headroom_w: Math.round(directPvHeadroomW),\n estimated_battery_charge_w: Math.round(estimatedBatteryChargeW),\n estimated_surplus_w: Math.round(estimatedSurplusW),\n\n radiator_start_surplus_needed_w: Math.round(radiatorStartSurplusNeededW),\n radiator_hold_surplus_needed_w: Math.round(radiatorHoldSurplusNeededW),\n\n radiator_start_by_absolute_pv: radiatorStartByAbsolutePv,\n radiator_start_by_estimated_surplus: radiatorStartByEstimatedSurplus,\n radiator_hold_by_absolute_pv: radiatorHoldByAbsolutePv,\n radiator_hold_by_estimated_surplus: radiatorHoldByEstimatedSurplus,\n\n radiator_start_conditions_ok: radiatorStartConditionsOk,\n radiator_hold_conditions_ok: radiatorHoldConditionsOk,\n radiator_battery_start_ok: radiatorBatteryStartOk,\n radiator_battery_hold_ok: radiatorBatteryHoldOk,\n\n evcs_priority_active: evcsPriorityActive,\n grid_available: gridAvailable,\n\n dehum_action_reason: dehumActionReason,\n radiator_action_reason: radiatorActionReason,\n\n minutes_since_master_on: minutesSinceMasterOn !== null ? round1(minutesSinceMasterOn) : null,\n minutes_since_master_off: minutesSinceMasterOff !== null ? round1(minutesSinceMasterOff) : null,\n minutes_since_deshumide_on: minutesSinceDeshumideOn !== null ? round1(minutesSinceDeshumideOn) : null,\n minutes_since_deshumide_off: minutesSinceDeshumideOff !== null ? round1(minutesSinceDeshumideOff) : null,\n minutes_since_radiator_on: minutesSinceRadiatorOn !== null ? round1(minutesSinceRadiatorOn) : null,\n minutes_since_radiator_off: minutesSinceRadiatorOff !== null ? round1(minutesSinceRadiatorOff) : null,\n\n deshumide_min_on_elapsed: deshumideMinOnElapsed,\n deshumide_min_off_elapsed: deshumideMinOffElapsed,\n radiator_min_on_elapsed: radiatorMinOnElapsed,\n radiator_min_off_elapsed: radiatorMinOffElapsed\n },\n\n decision: {\n state,\n desired_master: desiredMaster,\n desired_deshumide: desiredDeshumide,\n desired_radiator: desiredRadiator,\n\n master_action: masterAction,\n deshumide_action: deshumideAction,\n radiator_action: radiatorAction,\n\n master_latch_action: masterLatchAction,\n deshumide_latch_action: deshumideLatchAction,\n radiator_latch_action: radiatorLatchAction,\n\n adopt_master: adoptMaster,\n adopt_deshumide: adoptDeshumide,\n adopt_radiator: adoptRadiator,\n\n set_master_last_on_at: setMasterLastOnAt,\n set_master_last_off_at: setMasterLastOffAt,\n set_deshumide_last_on_at: setDeshumideLastOnAt,\n set_deshumide_last_off_at: setDeshumideLastOffAt,\n set_radiator_last_on_at: setRadiatorLastOnAt,\n set_radiator_last_off_at: setRadiatorLastOffAt,\n\n should_notify: notificationsEnabled && signatureChanged && notifyWanted,\n signature,\n signature_changed: signatureChanged,\n reason,\n short_reason: shortReason\n },\n\n ha_service_calls: {\n helper_state: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.sdb_climate_surplus_state',\n value: state\n }\n },\n\n helper_reason: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.sdb_climate_surplus_reason',\n value: shortReason || 'Aucune raison'\n }\n },\n\n helper_signature: {\n execute: signatureChanged,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.sdb_climate_surplus_last_signature',\n value: signature\n }\n },\n\n helper_master_latch: {\n execute: masterLatchAction !== 'none',\n domain: 'input_boolean',\n service: desiredMaster ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.sdb_master_power_latch'\n }\n },\n\n helper_deshumide_latch: {\n execute: deshumideLatchAction !== 'none',\n domain: 'input_boolean',\n service: desiredDeshumide ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.sdb_deshumide_latch'\n }\n },\n\n helper_radiator_latch: {\n execute: radiatorLatchAction !== 'none',\n domain: 'input_boolean',\n service: desiredRadiator ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.sdb_radiator_latch'\n }\n },\n\n master_power: {\n execute: masterAction !== 'none',\n domain: 'switch',\n service: desiredMaster ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.sdb_prise_socket_1'\n }\n },\n\n deshumide: {\n execute: deshumideAction !== 'none',\n domain: 'switch',\n service: desiredDeshumide ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.deshumide_sdb_socket_1'\n }\n },\n\n radiator: {\n execute: radiatorAction !== 'none',\n domain: 'switch',\n service: desiredRadiator ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.sdb_radiateur_socket_1'\n }\n },\n\n helper_master_last_on_at: {\n execute: setMasterLastOnAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.sdb_master_last_on_at',\n datetime: now.stamp\n }\n },\n\n helper_master_last_off_at: {\n execute: setMasterLastOffAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.sdb_master_last_off_at',\n datetime: now.stamp\n }\n },\n\n helper_deshumide_last_on_at: {\n execute: setDeshumideLastOnAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.sdb_deshumide_last_on_at',\n datetime: now.stamp\n }\n },\n\n helper_deshumide_last_off_at: {\n execute: setDeshumideLastOffAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.sdb_deshumide_last_off_at',\n datetime: now.stamp\n }\n },\n\n helper_radiator_last_on_at: {\n execute: setRadiatorLastOnAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.sdb_radiator_last_on_at',\n datetime: now.stamp\n }\n },\n\n helper_radiator_last_off_at: {\n execute: setRadiatorLastOffAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.sdb_radiator_last_off_at',\n datetime: now.stamp\n }\n },\n\n logbook: {\n execute: signatureChanged,\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'SDB Climate & Surplus',\n message: `${state} | master=${desiredMaster ? 'on' : 'off'} | deshumide=${desiredDeshumide ? 'on' : 'off'} | radiator=${desiredRadiator ? 'on' : 'off'} | ${shortReason}`,\n entity_id: 'switch.sdb_prise_socket_1'\n }\n },\n\n mobile_notification: {\n execute: notificationsEnabled && signatureChanged && notifyWanted,\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: notifyTitle,\n message: notifyMessage,\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" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 448, + 0 + ], + "id": "607b1441-a1af-4d0b-8e1a-40d001f36ed8", + "name": "Compute SDB Climate & Surplus V2.2" + }, + { + "parameters": { + "jsCode": "const root = $input.first().json || {};\nconst calls = root.ha_service_calls || {};\nconst decision = root.decision || {};\nconst actions = [];\n\nfunction pushAction(action, call, enabled = true) {\n if (!enabled) return;\n if (!call) return;\n if (call.execute === false) return;\n\n const { execute, ...rest } = call;\n\n actions.push({\n json: {\n action,\n ...rest\n }\n });\n}\n\n// =====================================================\n// HELPERS TOUJOURS MIS A JOUR\n// =====================================================\npushAction('helper_state', calls.helper_state, true);\npushAction('helper_reason', calls.helper_reason, true);\n\n// =====================================================\n// OFF ENFANTS D'ABORD\n// =====================================================\npushAction(\n 'deshumide',\n calls.deshumide,\n decision.deshumide_action === 'turn_off'\n);\n\npushAction(\n 'radiator',\n calls.radiator,\n decision.radiator_action === 'turn_off'\n);\n\n// =====================================================\n// PUIS OFF MASTER SI NECESSAIRE\n// =====================================================\npushAction(\n 'master_power',\n calls.master_power,\n decision.master_action === 'turn_off'\n);\n\n// =====================================================\n// ON MASTER D'ABORD\n// =====================================================\npushAction(\n 'master_power',\n calls.master_power,\n decision.master_action === 'turn_on'\n);\n\n// =====================================================\n// PUIS ON ENFANTS\n// =====================================================\npushAction(\n 'deshumide',\n calls.deshumide,\n decision.deshumide_action === 'turn_on'\n);\n\npushAction(\n 'radiator',\n calls.radiator,\n decision.radiator_action === 'turn_on'\n);\n\n// =====================================================\n// LATCHES\n// =====================================================\npushAction(\n 'helper_master_latch',\n calls.helper_master_latch,\n decision.master_latch_action !== 'none'\n);\n\npushAction(\n 'helper_deshumide_latch',\n calls.helper_deshumide_latch,\n decision.deshumide_latch_action !== 'none'\n);\n\npushAction(\n 'helper_radiator_latch',\n calls.helper_radiator_latch,\n decision.radiator_latch_action !== 'none'\n);\n\n// =====================================================\n// TIMESTAMPS\n// =====================================================\npushAction(\n 'helper_master_last_on_at',\n calls.helper_master_last_on_at,\n !!decision.set_master_last_on_at\n);\n\npushAction(\n 'helper_master_last_off_at',\n calls.helper_master_last_off_at,\n !!decision.set_master_last_off_at\n);\n\npushAction(\n 'helper_deshumide_last_on_at',\n calls.helper_deshumide_last_on_at,\n !!decision.set_deshumide_last_on_at\n);\n\npushAction(\n 'helper_deshumide_last_off_at',\n calls.helper_deshumide_last_off_at,\n !!decision.set_deshumide_last_off_at\n);\n\npushAction(\n 'helper_radiator_last_on_at',\n calls.helper_radiator_last_on_at,\n !!decision.set_radiator_last_on_at\n);\n\npushAction(\n 'helper_radiator_last_off_at',\n calls.helper_radiator_last_off_at,\n !!decision.set_radiator_last_off_at\n);\n\n// =====================================================\n// SIGNATURE + LOGBOOK SI CHANGEMENT\n// =====================================================\npushAction(\n 'helper_signature',\n calls.helper_signature,\n !!decision.signature_changed\n);\n\npushAction(\n 'logbook',\n calls.logbook,\n !!decision.signature_changed\n);\n\n// =====================================================\n// NOTIFICATION\n// =====================================================\npushAction(\n 'mobile_notification',\n calls.mobile_notification,\n !!decision.should_notify\n);\n\nreturn actions;\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 672, + 0 + ], + "id": "02e0d133-7be4-41f3-a419-17e945e43b62", + "name": "Build SDB Climate & Surplus Actions V2.2" + } + ], + "connections": { + "Schedule Trigger": { + "main": [ + [ + { + "node": "HA Snapshot SDB Climate & Surplus", + "type": "main", + "index": 0 + } + ] + ] + }, + "HA Snapshot SDB Climate & Surplus": { + "main": [ + [ + { + "node": "Compute SDB Climate & Surplus V2.2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compute SDB Climate & Surplus V2.2": { + "main": [ + [ + { + "node": "Build SDB Climate & Surplus Actions V2.2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build SDB Climate & Surplus Actions V2.2": { + "main": [ + [ + { + "node": "Execute HA Service SDB Climate & Surplus", + "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": "a50b531f-5399-463a-9943-e1a30a0cd2de", + "activeVersionId": "a50b531f-5399-463a-9943-e1a30a0cd2de", + "versionCounter": 76, + "triggerCount": 1, + "sourceWorkflowId": null, + "shared": [ + { + "updatedAt": "2026-03-17T16:32:38.182Z", + "createdAt": "2026-03-17T16:32:38.182Z", + "role": "workflow:owner", + "workflowId": "2NhrfxuGm4pTUNs9", + "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-06-12T21:13:37.000Z", + "createdAt": "2026-04-14T19:35:18.391Z", + "versionId": "a50b531f-5399-463a-9943-e1a30a0cd2de", + "workflowId": "2NhrfxuGm4pTUNs9", + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "minutes", + "minutesInterval": 1 + } + ] + } + }, + "id": "1e5571d8-a01b-4249-8f06-0c694605975e", + "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_current_a\": states(\"sensor.victron_battery_current\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"pv_power_10m_avg_w\": states(\"sensor.victron_pv_power_10m_avg\"),\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"victron_acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n\n \"sdb_master_power\": states(\"switch.sdb_prise_socket_1\"),\n \"sdb_master_energy_total\": states(\"sensor.sdb_prise_energie_totale\"),\n\n \"deshumide_switch\": states(\"switch.deshumide_sdb_socket_1\"),\n \"deshumide_energy_total\": states(\"sensor.deshumide_sdb_energie_totale\"),\n\n \"radiator_switch\": states(\"switch.sdb_radiateur_socket_1\"),\n \"radiator_energy_total\": states(\"sensor.sdb_radiateur_energie_totale\"),\n\n \"sdb_temperature_c\": states(\"sensor.sdb_temp_hygro_temperature\"),\n \"sdb_humidity_pct\": states(\"sensor.sdb_temp_hygro_humidite\"),\n\n \"auto_enabled\": states(\"input_boolean.sdb_climate_surplus_auto_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.sdb_climate_surplus_notifications\"),\n \"manual_adopt_enabled\": states(\"input_boolean.sdb_master_manual_adopt_enabled\"),\n\n \"master_power_latch\": states(\"input_boolean.sdb_master_power_latch\"),\n \"deshumide_latch\": states(\"input_boolean.sdb_deshumide_latch\"),\n \"radiator_latch\": states(\"input_boolean.sdb_radiator_latch\"),\n\n \"temp_on_min_c\": states(\"input_number.sdb_temp_on_min_c\"),\n \"temp_off_max_c\": states(\"input_number.sdb_temp_off_max_c\"),\n \"humidity_on_pct\": states(\"input_number.sdb_humidity_on_pct\"),\n \"humidity_off_pct\": states(\"input_number.sdb_humidity_off_pct\"),\n\n \"deshumide_min_on_minutes\": states(\"input_number.sdb_deshumide_min_on_minutes\"),\n \"deshumide_min_off_minutes\": states(\"input_number.sdb_deshumide_min_off_minutes\"),\n\n \"radiator_auto_enabled\": states(\"input_boolean.sdb_radiator_surplus_auto_enabled\"),\n \"radiator_soc_on\": states(\"input_number.sdb_radiator_soc_on\"),\n \"radiator_soc_off\": states(\"input_number.sdb_radiator_soc_off\"),\n \"radiator_pv_on_w\": states(\"input_number.sdb_radiator_pv_on_w\"),\n \"radiator_pv_off_w\": states(\"input_number.sdb_radiator_pv_off_w\"),\n \"radiator_battery_charge_ceiling_a\": states(\"input_number.sdb_radiator_battery_charge_ceiling_a\"),\n \"radiator_house_ac_max_w\": states(\"input_number.sdb_radiator_house_ac_max_w\"),\n \"radiator_min_on_minutes\": states(\"input_number.sdb_radiator_min_on_minutes\"),\n \"radiator_min_off_minutes\": states(\"input_number.sdb_radiator_min_off_minutes\"),\n\n \"day_start_time\": states(\"input_datetime.victron_flexible_day_start_time\"),\n \"day_stop_time\": states(\"input_datetime.victron_flexible_day_stop_time\"),\n\n \"master_last_on_at\": states(\"input_datetime.sdb_master_last_on_at\"),\n \"master_last_off_at\": states(\"input_datetime.sdb_master_last_off_at\"),\n \"deshumide_last_on_at\": states(\"input_datetime.sdb_deshumide_last_on_at\"),\n \"deshumide_last_off_at\": states(\"input_datetime.sdb_deshumide_last_off_at\"),\n \"radiator_last_on_at\": states(\"input_datetime.sdb_radiator_last_on_at\"),\n \"radiator_last_off_at\": states(\"input_datetime.sdb_radiator_last_off_at\"),\n\n \"evcs_priority_active\": states(\"input_boolean.victron_evcs_priority_active\"),\n \"energy_priority_mode\": states(\"input_select.victron_energy_priority_mode\"),\n \"energy_priority_reason\": states(\"input_text.victron_energy_priority_reason\"),\n \"evcs_priority_until\": states(\"input_datetime.victron_evcs_priority_until\"),\n\n \"last_signature\": states(\"input_text.sdb_climate_surplus_last_signature\")\n} | tojson }}\n" + } + ] + }, + "options": {} + }, + "id": "74f4b11c-bdb6-41db-9223-f1031cb8a12b", + "name": "HA Snapshot SDB Climate & Surplus", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 224, + 0 + ], + "credentials": { + "httpBearerAuth": { + "id": "uv3sknYYigxBkaVN", + "name": "Bearer Auth account 2" + } + } + }, + { + "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": "761fbc47-54ca-4961-863e-634b52e764d3", + "name": "Execute HA Service SDB Climate & Surplus", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 896, + 0 + ], + "alwaysOutputData": true, + "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 round2(v) {\n return Math.round(v * 100) / 100;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction parseTimeToMinutes(v, fallback = null) {\n const s = (v ?? '').toString().trim();\n const m = s.match(/^(\\d{1,2}):(\\d{2})(?::(\\d{2}))?$/);\n if (!m) return fallback;\n const hh = Number(m[1]);\n const mm = Number(m[2]);\n if (!Number.isFinite(hh) || !Number.isFinite(mm)) return fallback;\n return hh * 60 + mm;\n}\n\nfunction to2(n) {\n return String(n).padStart(2, '0');\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 hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n const stamp = `${date} ${to2(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,\n minute: Number(parts.minute),\n second: Number(parts.second),\n date,\n stamp,\n minNow: hour * 60 + Number(parts.minute),\n };\n}\n\nfunction getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {\n const dtf = new Intl.DateTimeFormat('en-US', {\n timeZone,\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 dtf.formatToParts(new Date(utcTs))\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const asIfUtc = Date.UTC(\n Number(parts.year),\n Number(parts.month) - 1,\n Number(parts.day),\n Number(parts.hour) === 24 ? 0 : Number(parts.hour),\n Number(parts.minute),\n Number(parts.second)\n );\n\n const d = new Date(utcTs);\n const actualUtc = Date.UTC(\n d.getUTCFullYear(),\n d.getUTCMonth(),\n d.getUTCDate(),\n d.getUTCHours(),\n d.getUTCMinutes(),\n d.getUTCSeconds()\n );\n\n return asIfUtc - actualUtc;\n}\n\nfunction parseParisStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\n\n const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);\n const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');\n let ts = guessUtc - off1;\n const off2 = getTzOffsetMs(ts, 'Europe/Paris');\n\n if (off2 !== off1) ts = guessUtc - off2;\n return ts;\n}\n\nfunction cleanStateText(v) {\n const s = (v ?? '').toString().trim();\n const x = s.toLowerCase();\n return ['unknown', 'unavailable', 'none', 'null'].includes(x) ? '' : s;\n}\n\nfunction cleanParisStamp(v) {\n const s = cleanStateText(v);\n return parseParisStampToMs(s) !== null ? s : '';\n}\n\nfunction minutesSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 60000);\n}\n\nfunction safeText(v, max = 255) {\n return (v ?? '').toString().slice(0, max);\n}\n\nconst cfg = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\n\n radiatorRealPowerW: 960,\n batteryNominalVoltageV: 52,\n\n radiatorStartSurplusRatio: 0.60,\n radiatorHoldSurplusRatio: 0.25,\n radiatorMinBatteryStartA: 2,\n radiatorMinBatteryHoldA: 0,\n radiatorMaxBatteryHoldDischargeA: -1.5\n};\n\nconst raw = $input.first().json;\nconst input = extractSnapshot(raw);\nconst now = getParisNowParts();\nconst nowMs = parseParisStampToMs(now.stamp);\n\n// ======================================================\n// INPUTS\n// ======================================================\nconst soc = clamp(num(input.soc_percent, 0), 0, 100);\nconst batteryCurrentA = num(input.battery_current_a, 0);\nconst pvPowerW = Math.max(0, num(input.pv_power_w, 0));\nconst pvPowerAvg10mW = Math.max(0, num(input.pv_power_10m_avg_w, pvPowerW));\nconst houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));\nconst gridPowerW = num(input.grid_power_w, 0);\nconst gridIsOn = low(input.grid_state || 'off') === 'on';\nconst acInAvailable = num(input.victron_acin1_available, 0) === 1;\nconst gridAvailable = gridIsOn || acInAvailable;\n\nconst masterIsOn = low(input.sdb_master_power || 'off') === 'on';\nconst deshumideIsOn = low(input.deshumide_switch || 'off') === 'on';\nconst radiatorIsOn = low(input.radiator_switch || 'off') === 'on';\n\nconst masterEnergyTotalKwh = Math.max(0, num(input.sdb_master_energy_total, 0));\nconst deshumideEnergyTotalKwh = Math.max(0, num(input.deshumide_energy_total, 0));\nconst radiatorEnergyTotalKwh = Math.max(0, num(input.radiator_energy_total, 0));\n\nconst temperatureC = num(input.sdb_temperature_c, NaN);\nconst humidityPct = num(input.sdb_humidity_pct, NaN);\n\nconst autoEnabled = low(input.auto_enabled || 'off') === 'on';\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\nconst manualAdoptEnabled = low(input.manual_adopt_enabled || 'off') === 'on';\n\nconst masterLatchIsOn = low(input.master_power_latch || 'off') === 'on';\nconst deshumideLatchIsOn = low(input.deshumide_latch || 'off') === 'on';\nconst radiatorLatchIsOn = low(input.radiator_latch || 'off') === 'on';\n\nconst tempOnMinC = num(input.temp_on_min_c, 21);\nconst tempOffMaxC = num(input.temp_off_max_c, 23);\nconst humidityOnPct = num(input.humidity_on_pct, 55);\nconst humidityOffPct = num(input.humidity_off_pct, 45);\n\nconst deshumideMinOnMinutes = Math.max(0, num(input.deshumide_min_on_minutes, 15));\nconst deshumideMinOffMinutes = Math.max(0, num(input.deshumide_min_off_minutes, 10));\n\nconst radiatorAutoEnabled = low(input.radiator_auto_enabled || 'off') === 'on';\nconst radiatorSocOn = clamp(num(input.radiator_soc_on, 90), 0, 100);\nconst radiatorSocOff = clamp(num(input.radiator_soc_off, 88), 0, 100);\nconst radiatorPvOnW = Math.max(0, num(input.radiator_pv_on_w, 2500));\nconst radiatorPvOffW = Math.max(0, num(input.radiator_pv_off_w, 1200));\nconst radiatorBatteryChargeCeilingA = Math.max(0, num(input.radiator_battery_charge_ceiling_a, 5));\nconst radiatorHouseAcMaxW = Math.max(0, num(input.radiator_house_ac_max_w, 6500));\nconst radiatorMinOnMinutes = Math.max(0, num(input.radiator_min_on_minutes, 15));\nconst radiatorMinOffMinutes = Math.max(0, num(input.radiator_min_off_minutes, 10));\n\nconst dayStartMin = parseTimeToMinutes(input.day_start_time, 9 * 60);\nconst dayStopMin = parseTimeToMinutes(input.day_stop_time, 19 * 60 + 15);\nconst inDayWindow =\n dayStartMin < dayStopMin\n ? (now.minNow >= dayStartMin && now.minNow < dayStopMin)\n : (now.minNow >= dayStartMin || now.minNow < dayStopMin);\n\nconst masterLastOnAt = cleanParisStamp(input.master_last_on_at);\nconst masterLastOffAt = cleanParisStamp(input.master_last_off_at);\nconst deshumideLastOnAt = cleanParisStamp(input.deshumide_last_on_at);\nconst deshumideLastOffAt = cleanParisStamp(input.deshumide_last_off_at);\nconst radiatorLastOnAt = cleanParisStamp(input.radiator_last_on_at);\nconst radiatorLastOffAt = cleanParisStamp(input.radiator_last_off_at);\nconst lastSignature = cleanStateText(input.last_signature);\n\n// ======================================================\n// EVCS PRIORITY\n// ======================================================\nconst evcsPriorityActiveRaw = low(input.evcs_priority_active || 'off') === 'on';\nconst energyPriorityMode = cleanStateText(input.energy_priority_mode || 'normal').toLowerCase() || 'normal';\nconst energyPriorityReason = cleanStateText(input.energy_priority_reason);\nconst evcsPriorityUntilAt = cleanParisStamp(input.evcs_priority_until);\nconst evcsPriorityUntilMs = parseParisStampToMs(evcsPriorityUntilAt);\n\nconst evcsPriorityActive =\n evcsPriorityActiveRaw &&\n (\n evcsPriorityUntilMs === null ||\n (nowMs !== null && evcsPriorityUntilMs > nowMs + 1000)\n );\n\n// ======================================================\n// TIMESTAMPS\n// ======================================================\nconst masterLastOnMs = parseParisStampToMs(masterLastOnAt);\nconst masterLastOffMs = parseParisStampToMs(masterLastOffAt);\nconst deshumideLastOnMs = parseParisStampToMs(deshumideLastOnAt);\nconst deshumideLastOffMs = parseParisStampToMs(deshumideLastOffAt);\nconst radiatorLastOnMs = parseParisStampToMs(radiatorLastOnAt);\nconst radiatorLastOffMs = parseParisStampToMs(radiatorLastOffAt);\n\nconst minutesSinceMasterOn = minutesSince(masterLastOnMs, nowMs);\nconst minutesSinceMasterOff = minutesSince(masterLastOffMs, nowMs);\nconst minutesSinceDeshumideOn = minutesSince(deshumideLastOnMs, nowMs);\nconst minutesSinceDeshumideOff = minutesSince(deshumideLastOffMs, nowMs);\nconst minutesSinceRadiatorOn = minutesSince(radiatorLastOnMs, nowMs);\nconst minutesSinceRadiatorOff = minutesSince(radiatorLastOffMs, nowMs);\n\nconst deshumideMinOnElapsed = minutesSinceDeshumideOn === null ? true : minutesSinceDeshumideOn >= deshumideMinOnMinutes;\nconst deshumideMinOffElapsed = minutesSinceDeshumideOff === null ? true : minutesSinceDeshumideOff >= deshumideMinOffMinutes;\nconst radiatorMinOnElapsed = minutesSinceRadiatorOn === null ? true : minutesSinceRadiatorOn >= radiatorMinOnMinutes;\nconst radiatorMinOffElapsed = minutesSinceRadiatorOff === null ? true : minutesSinceRadiatorOff >= radiatorMinOffMinutes;\n\n// ======================================================\n// BESOINS METIER SEPARES\n// ======================================================\nconst heatNeed = Number.isFinite(temperatureC) ? temperatureC < tempOnMinC : false;\nconst heatRecovered = Number.isFinite(temperatureC) ? temperatureC >= tempOffMaxC : false;\n\nconst humidityNeed = Number.isFinite(humidityPct) ? humidityPct > humidityOnPct : false;\nconst humidityRecovered = Number.isFinite(humidityPct) ? humidityPct <= humidityOffPct : false;\n\nconst comfortDemand = heatNeed || humidityNeed;\nconst comfortRecovered = heatRecovered && humidityRecovered;\n\n// ======================================================\n// RADIATEUR SDB : SURPLUS REEL V2\n// ======================================================\nconst estimatedBatteryChargeW =\n Number.isFinite(batteryCurrentA) && batteryCurrentA > 0\n ? batteryCurrentA * cfg.batteryNominalVoltageV\n : 0;\n\nconst directPvHeadroomW = Math.max(0, pvPowerAvg10mW - houseAcPowerW);\nconst estimatedSurplusW = Math.max(0, directPvHeadroomW + estimatedBatteryChargeW);\n\nconst radiatorStartSurplusNeededW = Math.round(cfg.radiatorRealPowerW * cfg.radiatorStartSurplusRatio);\nconst radiatorHoldSurplusNeededW = Math.round(cfg.radiatorRealPowerW * cfg.radiatorHoldSurplusRatio);\n\nconst radiatorBatteryStartOk =\n Number.isFinite(batteryCurrentA) &&\n batteryCurrentA >= cfg.radiatorMinBatteryStartA &&\n batteryCurrentA <= radiatorBatteryChargeCeilingA;\n\nconst radiatorBatteryHoldOk =\n Number.isFinite(batteryCurrentA) &&\n batteryCurrentA >= cfg.radiatorMaxBatteryHoldDischargeA &&\n batteryCurrentA <= radiatorBatteryChargeCeilingA + 4;\n\nconst radiatorStartByAbsolutePv =\n pvPowerAvg10mW >= radiatorPvOnW && radiatorBatteryStartOk;\n\nconst radiatorStartByEstimatedSurplus =\n estimatedSurplusW >= radiatorStartSurplusNeededW && radiatorBatteryStartOk;\n\nconst radiatorHoldByAbsolutePv =\n pvPowerAvg10mW >= radiatorPvOffW && radiatorBatteryHoldOk;\n\nconst radiatorHoldByEstimatedSurplus =\n estimatedSurplusW >= radiatorHoldSurplusNeededW && radiatorBatteryHoldOk;\n\nconst radiatorStartConditionsOk =\n radiatorAutoEnabled &&\n heatNeed &&\n inDayWindow &&\n !gridAvailable &&\n !evcsPriorityActive &&\n soc >= radiatorSocOn &&\n houseAcPowerW < radiatorHouseAcMaxW &&\n (radiatorStartByAbsolutePv || radiatorStartByEstimatedSurplus);\n\nconst radiatorHoldConditionsOk =\n radiatorAutoEnabled &&\n !heatRecovered &&\n inDayWindow &&\n !gridAvailable &&\n !evcsPriorityActive &&\n soc >= radiatorSocOff &&\n houseAcPowerW < radiatorHouseAcMaxW &&\n (radiatorHoldByAbsolutePv || radiatorHoldByEstimatedSurplus);\n\n// ======================================================\n// SORTIES DESIREES\n// ======================================================\nlet desiredDeshumide = false;\nlet desiredRadiator = false;\nlet desiredMaster = false;\n\nlet state = 'idle';\nlet reason = [];\n\nlet dehumActionReason = 'idle';\nlet radiatorActionReason = 'idle';\n\nlet adoptMaster = false;\nlet adoptDeshumide = false;\nlet adoptRadiator = false;\n\nlet notifyTitle = 'SDB Climate & Surplus';\nlet notifyMessage = '';\nlet notifyWanted = false;\n\n// ======================================================\n// DESHUMIDIFICATEUR - PRIORITAIRE HUMIDITE\n// ======================================================\nif (!autoEnabled) {\n desiredDeshumide = deshumideIsOn;\n dehumActionReason = 'automation_disabled';\n}\nelse if (humidityNeed) {\n if (deshumideIsOn) {\n desiredDeshumide = true;\n dehumActionReason = 'deshumide_already_running';\n } else if (deshumideMinOffElapsed) {\n desiredDeshumide = true;\n dehumActionReason = 'deshumide_start_on_humidity_need';\n } else {\n desiredDeshumide = false;\n dehumActionReason = 'deshumide_wait_min_off';\n }\n}\nelse if (deshumideIsOn || deshumideLatchIsOn) {\n if (!humidityRecovered) {\n desiredDeshumide = true;\n dehumActionReason = 'deshumide_hold_until_humidity_recovered';\n } else if (!deshumideMinOnElapsed) {\n desiredDeshumide = true;\n dehumActionReason = 'deshumide_hold_min_on';\n } else {\n desiredDeshumide = false;\n dehumActionReason = 'deshumide_stop_humidity_recovered';\n }\n}\nelse {\n desiredDeshumide = false;\n dehumActionReason = 'deshumide_idle';\n}\n\n// Adoption manuelle déshumide uniquement si cohérent avec la demande\nif (manualAdoptEnabled && desiredDeshumide && deshumideIsOn && !deshumideLatchIsOn) {\n adoptDeshumide = true;\n}\n\n// ======================================================\n// RADIATEUR - CHAUFFAGE + SURPLUS + PRIORITE EVCS\n// ======================================================\nif (!autoEnabled) {\n desiredRadiator = radiatorIsOn;\n radiatorActionReason = 'automation_disabled';\n}\nelse if (!radiatorAutoEnabled) {\n desiredRadiator = radiatorIsOn;\n radiatorActionReason = 'radiator_auto_disabled_observe_only';\n}\nelse if (evcsPriorityActive) {\n if (radiatorIsOn || radiatorLatchIsOn) {\n if (!radiatorMinOnElapsed) {\n desiredRadiator = true;\n radiatorActionReason = 'radiator_hold_min_on_evcs_priority';\n } else {\n desiredRadiator = false;\n radiatorActionReason = 'radiator_blocked_by_evcs_priority';\n }\n } else {\n desiredRadiator = false;\n radiatorActionReason = 'radiator_blocked_by_evcs_priority';\n }\n}\nelse if (heatNeed && radiatorStartConditionsOk) {\n if (radiatorIsOn) {\n desiredRadiator = true;\n radiatorActionReason = 'radiator_already_running';\n } else if (radiatorMinOffElapsed) {\n desiredRadiator = true;\n radiatorActionReason = 'radiator_start_heat_need_surplus_v2';\n } else {\n desiredRadiator = false;\n radiatorActionReason = 'radiator_wait_min_off';\n }\n}\nelse if (radiatorIsOn || radiatorLatchIsOn) {\n if (radiatorHoldConditionsOk) {\n desiredRadiator = true;\n radiatorActionReason = 'radiator_hold_heat_need_surplus_v2';\n } else if (!radiatorMinOnElapsed) {\n desiredRadiator = true;\n radiatorActionReason = 'radiator_hold_min_on';\n } else if (evcsPriorityActive) {\n desiredRadiator = false;\n radiatorActionReason = 'radiator_stop_evcs_priority';\n } else if (heatRecovered) {\n desiredRadiator = false;\n radiatorActionReason = 'radiator_stop_heat_recovered';\n } else {\n desiredRadiator = false;\n radiatorActionReason = 'radiator_stop_surplus_lost';\n }\n}\nelse {\n desiredRadiator = false;\n radiatorActionReason = heatNeed ? 'radiator_heat_need_but_energy_not_ok' : 'radiator_idle';\n}\n\n// Adoption manuelle radiateur uniquement si cohérent et hors EVCS\nif (\n manualAdoptEnabled &&\n desiredRadiator &&\n radiatorIsOn &&\n !radiatorLatchIsOn &&\n !evcsPriorityActive\n) {\n adoptRadiator = true;\n}\n\n// ======================================================\n// MASTER SDB PRISE = COORDINATEUR\n// ======================================================\nif (!autoEnabled) {\n desiredMaster = masterIsOn;\n} else {\n desiredMaster = desiredDeshumide || desiredRadiator;\n}\n\nif (manualAdoptEnabled && desiredMaster && masterIsOn && !masterLatchIsOn) {\n adoptMaster = true;\n}\n\n// ======================================================\n// ETAT GLOBAL\n// ======================================================\nif (!autoEnabled) {\n state = 'automation_disabled';\n reason.push(\"Automatisation SDB climate/surplus désactivée\");\n}\nelse if (desiredDeshumide && desiredRadiator) {\n state = 'deshumide_and_radiator_on';\n reason.push(\"Déshumidification active + radiateur actif sur surplus\");\n}\nelse if (desiredDeshumide) {\n state = 'deshumide_on';\n reason.push(\"Déshumidification SDB active\");\n}\nelse if (desiredRadiator) {\n state = 'radiator_on';\n reason.push(\"Radiateur SDB actif sur besoin thermique et surplus\");\n}\nelse if (radiatorActionReason === 'radiator_blocked_by_evcs_priority' || radiatorActionReason === 'radiator_stop_evcs_priority') {\n state = 'radiator_blocked_by_evcs_priority';\n reason.push(\"Radiateur SDB bloqué par priorité EVCS\");\n}\nelse if (dehumActionReason === 'deshumide_wait_min_off') {\n state = 'wait_deshumide_min_off';\n reason.push(\"Déshumidificateur en attente de temps mini OFF\");\n}\nelse if (radiatorActionReason === 'radiator_wait_min_off') {\n state = 'wait_radiator_min_off';\n reason.push(\"Radiateur en attente de temps mini OFF\");\n}\nelse {\n state = 'idle';\n reason.push(\"Aucune demande SDB active\");\n}\n\nreason.push(`Température SDB ${Number.isFinite(temperatureC) ? round1(temperatureC) + '°C' : 'n/a'}`);\nreason.push(`Humidité SDB ${Number.isFinite(humidityPct) ? round1(humidityPct) + '%' : 'n/a'}`);\n\nreason.push(`Chauffage ON si T<${round1(tempOnMinC)}°C`);\nreason.push(`Chauffage OFF si T>=${round1(tempOffMaxC)}°C`);\nreason.push(`Déshumidification ON si H>${round1(humidityOnPct)}%`);\nreason.push(`Déshumidification OFF si H<=${round1(humidityOffPct)}%`);\n\nreason.push(`Besoin chauffage=${heatNeed ? 'oui' : 'non'} | Rétabli chauffage=${heatRecovered ? 'oui' : 'non'}`);\nreason.push(`Besoin humidité=${humidityNeed ? 'oui' : 'non'} | Humidité rétablie=${humidityRecovered ? 'oui' : 'non'}`);\n\nreason.push(`Déshumide: ${dehumActionReason}`);\nreason.push(`Radiateur: ${radiatorActionReason}`);\n\nreason.push(`Master=${masterIsOn ? 'ON' : 'OFF'} | Deshumide=${deshumideIsOn ? 'ON' : 'OFF'} | Radiateur=${radiatorIsOn ? 'ON' : 'OFF'}`);\nreason.push(`SOC ${round1(soc)}% | PV ${Math.round(pvPowerW)}W | PV10 ${Math.round(pvPowerAvg10mW)}W | AC maison ${Math.round(houseAcPowerW)}W | Batterie ${round1(batteryCurrentA)}A`);\nreason.push(`Réseau cmd=${gridIsOn ? 'ON' : 'OFF'} | acin=${acInAvailable ? 'OK' : 'KO'} | dispo=${gridAvailable ? 'oui' : 'non'}`);\nreason.push(`Fenêtre jour ${inDayWindow ? 'OK' : 'Hors fenêtre'}`);\nreason.push(`Prio EVCS ${evcsPriorityActive ? 'ON' : 'OFF'}${energyPriorityReason ? ` | ${energyPriorityReason}` : ''}`);\n\nreason.push(`Radiateur ${Math.round(cfg.radiatorRealPowerW)}W | Headroom PV ${Math.round(directPvHeadroomW)}W | Charge batt estimée ${Math.round(estimatedBatteryChargeW)}W | Surplus estimé ${Math.round(estimatedSurplusW)}W`);\nreason.push(`Start radiateur si surplus >= ${Math.round(radiatorStartSurplusNeededW)}W ou PV10 >= ${Math.round(radiatorPvOnW)}W`);\nreason.push(`Hold radiateur si surplus >= ${Math.round(radiatorHoldSurplusNeededW)}W ou PV10 >= ${Math.round(radiatorPvOffW)}W`);\n\nif (adoptMaster) reason.push(\"Prise maître SDB adoptée\");\nif (adoptDeshumide) reason.push(\"Déshumidificateur adopté\");\nif (adoptRadiator) reason.push(\"Radiateur adopté\");\n\n// ======================================================\n// ACTIONS / LATCHES\n// ======================================================\nconst desiredMasterLatch = desiredMaster ? 'on' : 'off';\nconst desiredDeshumideLatch = desiredDeshumide ? 'on' : 'off';\nconst desiredRadiatorLatch = desiredRadiator ? 'on' : 'off';\n\nconst masterAction =\n desiredMaster === masterIsOn\n ? 'none'\n : desiredMaster ? 'turn_on' : 'turn_off';\n\nconst deshumideAction =\n desiredDeshumide === deshumideIsOn\n ? 'none'\n : desiredDeshumide ? 'turn_on' : 'turn_off';\n\nconst radiatorAction =\n desiredRadiator === radiatorIsOn\n ? 'none'\n : desiredRadiator ? 'turn_on' : 'turn_off';\n\nconst masterLatchAction =\n desiredMasterLatch === (masterLatchIsOn ? 'on' : 'off')\n ? 'none'\n : desiredMasterLatch === 'on' ? 'turn_on' : 'turn_off';\n\nconst deshumideLatchAction =\n desiredDeshumideLatch === (deshumideLatchIsOn ? 'on' : 'off')\n ? 'none'\n : desiredDeshumideLatch === 'on' ? 'turn_on' : 'turn_off';\n\nconst radiatorLatchAction =\n desiredRadiatorLatch === (radiatorLatchIsOn ? 'on' : 'off')\n ? 'none'\n : desiredRadiatorLatch === 'on' ? 'turn_on' : 'turn_off';\n\n// ======================================================\n// TIMESTAMPS\n// ======================================================\nconst setMasterLastOnAt = masterAction === 'turn_on' || adoptMaster;\nconst setMasterLastOffAt = masterAction === 'turn_off';\n\nconst setDeshumideLastOnAt = deshumideAction === 'turn_on' || adoptDeshumide;\nconst setDeshumideLastOffAt = deshumideAction === 'turn_off';\n\nconst setRadiatorLastOnAt = radiatorAction === 'turn_on' || adoptRadiator;\nconst setRadiatorLastOffAt = radiatorAction === 'turn_off';\n\n// ======================================================\n// NOTIFICATIONS\n// ======================================================\nif (state === 'deshumide_on' && (deshumideAction === 'turn_on' || adoptDeshumide)) {\n notifyTitle = '💧 Déshumidification SDB active';\n notifyMessage = `SDB: T=${Number.isFinite(temperatureC) ? round1(temperatureC) : 'n/a'}°C | H=${Number.isFinite(humidityPct) ? round1(humidityPct) : 'n/a'}%.`;\n notifyWanted = true;\n}\nelse if (dehumActionReason === 'deshumide_stop_humidity_recovered' && deshumideAction === 'turn_off') {\n notifyTitle = '✅ Déshumidification SDB arrêtée';\n notifyMessage = `Humidité revenue dans la zone cible : H=${Number.isFinite(humidityPct) ? round1(humidityPct) : 'n/a'}%.`;\n notifyWanted = true;\n}\nelse if (state === 'radiator_on' && radiatorAction === 'turn_on') {\n notifyTitle = '☀️ Radiateur SDB actif sur surplus';\n notifyMessage = `T=${Number.isFinite(temperatureC) ? round1(temperatureC) : 'n/a'}°C | SOC ${round1(soc)}% | PV10 ${Math.round(pvPowerAvg10mW)}W | Surplus ${Math.round(estimatedSurplusW)}W.`;\n notifyWanted = true;\n}\nelse if (\n (radiatorActionReason === 'radiator_stop_surplus_lost' || radiatorActionReason === 'radiator_stop_heat_recovered') &&\n radiatorAction === 'turn_off'\n) {\n notifyTitle = '⛔ Radiateur SDB arrêté';\n notifyMessage = `T=${Number.isFinite(temperatureC) ? round1(temperatureC) : 'n/a'}°C | PV10 ${Math.round(pvPowerAvg10mW)}W | Surplus ${Math.round(estimatedSurplusW)}W.`;\n notifyWanted = true;\n}\nelse if (\n (radiatorActionReason === 'radiator_blocked_by_evcs_priority' || radiatorActionReason === 'radiator_stop_evcs_priority') &&\n radiatorAction === 'turn_off'\n) {\n notifyTitle = '🚗 Priorité EVCS : radiateur SDB coupé';\n notifyMessage = `${energyPriorityReason || 'La recharge EVCS est prioritaire'}${evcsPriorityUntilAt ? ` jusqu'à ${evcsPriorityUntilAt}` : ''}.`;\n notifyWanted = true;\n}\nelse if (state === 'deshumide_and_radiator_on' && (deshumideAction === 'turn_on' || radiatorAction === 'turn_on')) {\n notifyTitle = '🌤️ SDB active : humidité + chauffage';\n notifyMessage = `Déshumidificateur et radiateur actifs. T=${Number.isFinite(temperatureC) ? round1(temperatureC) : 'n/a'}°C | H=${Number.isFinite(humidityPct) ? round1(humidityPct) : 'n/a'}% | SOC ${round1(soc)}%.`;\n notifyWanted = true;\n}\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst signature = [\n now.date,\n state,\n `m=${desiredMaster ? '1' : '0'}`,\n `d=${desiredDeshumide ? '1' : '0'}`,\n `r=${desiredRadiator ? '1' : '0'}`,\n `heat=${heatNeed ? '1' : '0'}`,\n `hum=${humidityNeed ? '1' : '0'}`,\n `ev=${evcsPriorityActive ? '1' : '0'}`,\n `t=${Number.isFinite(temperatureC) ? round1(temperatureC) : 'na'}`,\n `h=${Number.isFinite(humidityPct) ? round1(humidityPct) : 'na'}`,\n `sx=${Math.round(estimatedSurplusW)}`\n].join('|');\n\nconst signatureChanged = signature !== lastSignature;\n\n// ======================================================\n// OUTPUT\n// ======================================================\nconst result = {\n timestamp_paris: now.stamp,\n\n inputs: {\n soc_percent: soc,\n battery_current_a: batteryCurrentA,\n pv_power_w: pvPowerW,\n pv_power_10m_avg_w: pvPowerAvg10mW,\n house_ac_power_w: houseAcPowerW,\n grid_power_w: gridPowerW,\n grid_state: gridIsOn ? 'on' : 'off',\n victron_acin1_available: acInAvailable ? 1 : 0,\n\n sdb_master_power: masterIsOn,\n sdb_master_energy_total: round2(masterEnergyTotalKwh),\n deshumide_switch: deshumideIsOn,\n deshumide_energy_total: round2(deshumideEnergyTotalKwh),\n radiator_switch: radiatorIsOn,\n radiator_energy_total: round2(radiatorEnergyTotalKwh),\n\n sdb_temperature_c: Number.isFinite(temperatureC) ? round1(temperatureC) : null,\n sdb_humidity_pct: Number.isFinite(humidityPct) ? round1(humidityPct) : null,\n\n auto_enabled: autoEnabled,\n notifications_enabled: notificationsEnabled,\n manual_adopt_enabled: manualAdoptEnabled,\n\n master_power_latch: masterLatchIsOn,\n deshumide_latch: deshumideLatchIsOn,\n radiator_latch: radiatorLatchIsOn,\n\n temp_on_min_c: tempOnMinC,\n temp_off_max_c: tempOffMaxC,\n humidity_on_pct: humidityOnPct,\n humidity_off_pct: humidityOffPct,\n\n deshumide_min_on_minutes: deshumideMinOnMinutes,\n deshumide_min_off_minutes: deshumideMinOffMinutes,\n\n radiator_auto_enabled: radiatorAutoEnabled,\n radiator_soc_on: radiatorSocOn,\n radiator_soc_off: radiatorSocOff,\n radiator_pv_on_w: radiatorPvOnW,\n radiator_pv_off_w: radiatorPvOffW,\n radiator_battery_charge_ceiling_a: radiatorBatteryChargeCeilingA,\n radiator_house_ac_max_w: radiatorHouseAcMaxW,\n radiator_min_on_minutes: radiatorMinOnMinutes,\n radiator_min_off_minutes: radiatorMinOffMinutes,\n\n radiator_real_power_w_internal: cfg.radiatorRealPowerW,\n\n day_start_time: input.day_start_time || null,\n day_stop_time: input.day_stop_time || null,\n\n master_last_on_at: masterLastOnAt || null,\n master_last_off_at: masterLastOffAt || null,\n deshumide_last_on_at: deshumideLastOnAt || null,\n deshumide_last_off_at: deshumideLastOffAt || null,\n radiator_last_on_at: radiatorLastOnAt || null,\n radiator_last_off_at: radiatorLastOffAt || null,\n\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason || null,\n evcs_priority_until: evcsPriorityUntilAt || null,\n\n last_signature: lastSignature || null\n },\n\n computed: {\n in_day_window: inDayWindow,\n\n heat_need: heatNeed,\n heat_recovered: heatRecovered,\n humidity_need: humidityNeed,\n humidity_recovered: humidityRecovered,\n comfort_demand: comfortDemand,\n comfort_recovered: comfortRecovered,\n\n temp_too_low: heatNeed,\n humidity_too_high: humidityNeed,\n\n direct_pv_headroom_w: Math.round(directPvHeadroomW),\n estimated_battery_charge_w: Math.round(estimatedBatteryChargeW),\n estimated_surplus_w: Math.round(estimatedSurplusW),\n\n radiator_start_surplus_needed_w: Math.round(radiatorStartSurplusNeededW),\n radiator_hold_surplus_needed_w: Math.round(radiatorHoldSurplusNeededW),\n\n radiator_start_by_absolute_pv: radiatorStartByAbsolutePv,\n radiator_start_by_estimated_surplus: radiatorStartByEstimatedSurplus,\n radiator_hold_by_absolute_pv: radiatorHoldByAbsolutePv,\n radiator_hold_by_estimated_surplus: radiatorHoldByEstimatedSurplus,\n\n radiator_start_conditions_ok: radiatorStartConditionsOk,\n radiator_hold_conditions_ok: radiatorHoldConditionsOk,\n radiator_battery_start_ok: radiatorBatteryStartOk,\n radiator_battery_hold_ok: radiatorBatteryHoldOk,\n\n evcs_priority_active: evcsPriorityActive,\n grid_available: gridAvailable,\n\n dehum_action_reason: dehumActionReason,\n radiator_action_reason: radiatorActionReason,\n\n minutes_since_master_on: minutesSinceMasterOn !== null ? round1(minutesSinceMasterOn) : null,\n minutes_since_master_off: minutesSinceMasterOff !== null ? round1(minutesSinceMasterOff) : null,\n minutes_since_deshumide_on: minutesSinceDeshumideOn !== null ? round1(minutesSinceDeshumideOn) : null,\n minutes_since_deshumide_off: minutesSinceDeshumideOff !== null ? round1(minutesSinceDeshumideOff) : null,\n minutes_since_radiator_on: minutesSinceRadiatorOn !== null ? round1(minutesSinceRadiatorOn) : null,\n minutes_since_radiator_off: minutesSinceRadiatorOff !== null ? round1(minutesSinceRadiatorOff) : null,\n\n deshumide_min_on_elapsed: deshumideMinOnElapsed,\n deshumide_min_off_elapsed: deshumideMinOffElapsed,\n radiator_min_on_elapsed: radiatorMinOnElapsed,\n radiator_min_off_elapsed: radiatorMinOffElapsed\n },\n\n decision: {\n state,\n desired_master: desiredMaster,\n desired_deshumide: desiredDeshumide,\n desired_radiator: desiredRadiator,\n\n master_action: masterAction,\n deshumide_action: deshumideAction,\n radiator_action: radiatorAction,\n\n master_latch_action: masterLatchAction,\n deshumide_latch_action: deshumideLatchAction,\n radiator_latch_action: radiatorLatchAction,\n\n adopt_master: adoptMaster,\n adopt_deshumide: adoptDeshumide,\n adopt_radiator: adoptRadiator,\n\n set_master_last_on_at: setMasterLastOnAt,\n set_master_last_off_at: setMasterLastOffAt,\n set_deshumide_last_on_at: setDeshumideLastOnAt,\n set_deshumide_last_off_at: setDeshumideLastOffAt,\n set_radiator_last_on_at: setRadiatorLastOnAt,\n set_radiator_last_off_at: setRadiatorLastOffAt,\n\n should_notify: notificationsEnabled && signatureChanged && notifyWanted,\n signature,\n signature_changed: signatureChanged,\n reason,\n short_reason: shortReason\n },\n\n ha_service_calls: {\n helper_state: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.sdb_climate_surplus_state',\n value: state\n }\n },\n\n helper_reason: {\n execute: true,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.sdb_climate_surplus_reason',\n value: shortReason || 'Aucune raison'\n }\n },\n\n helper_signature: {\n execute: signatureChanged,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.sdb_climate_surplus_last_signature',\n value: signature\n }\n },\n\n helper_master_latch: {\n execute: masterLatchAction !== 'none',\n domain: 'input_boolean',\n service: desiredMaster ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.sdb_master_power_latch'\n }\n },\n\n helper_deshumide_latch: {\n execute: deshumideLatchAction !== 'none',\n domain: 'input_boolean',\n service: desiredDeshumide ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.sdb_deshumide_latch'\n }\n },\n\n helper_radiator_latch: {\n execute: radiatorLatchAction !== 'none',\n domain: 'input_boolean',\n service: desiredRadiator ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.sdb_radiator_latch'\n }\n },\n\n master_power: {\n execute: masterAction !== 'none',\n domain: 'switch',\n service: desiredMaster ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.sdb_prise_socket_1'\n }\n },\n\n deshumide: {\n execute: deshumideAction !== 'none',\n domain: 'switch',\n service: desiredDeshumide ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.deshumide_sdb_socket_1'\n }\n },\n\n radiator: {\n execute: radiatorAction !== 'none',\n domain: 'switch',\n service: desiredRadiator ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.sdb_radiateur_socket_1'\n }\n },\n\n helper_master_last_on_at: {\n execute: setMasterLastOnAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.sdb_master_last_on_at',\n datetime: now.stamp\n }\n },\n\n helper_master_last_off_at: {\n execute: setMasterLastOffAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.sdb_master_last_off_at',\n datetime: now.stamp\n }\n },\n\n helper_deshumide_last_on_at: {\n execute: setDeshumideLastOnAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.sdb_deshumide_last_on_at',\n datetime: now.stamp\n }\n },\n\n helper_deshumide_last_off_at: {\n execute: setDeshumideLastOffAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.sdb_deshumide_last_off_at',\n datetime: now.stamp\n }\n },\n\n helper_radiator_last_on_at: {\n execute: setRadiatorLastOnAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.sdb_radiator_last_on_at',\n datetime: now.stamp\n }\n },\n\n helper_radiator_last_off_at: {\n execute: setRadiatorLastOffAt,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.sdb_radiator_last_off_at',\n datetime: now.stamp\n }\n },\n\n logbook: {\n execute: signatureChanged,\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'SDB Climate & Surplus',\n message: `${state} | master=${desiredMaster ? 'on' : 'off'} | deshumide=${desiredDeshumide ? 'on' : 'off'} | radiator=${desiredRadiator ? 'on' : 'off'} | ${shortReason}`,\n entity_id: 'switch.sdb_prise_socket_1'\n }\n },\n\n mobile_notification: {\n execute: notificationsEnabled && signatureChanged && notifyWanted,\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: notifyTitle,\n message: notifyMessage,\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" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 448, + 0 + ], + "id": "607b1441-a1af-4d0b-8e1a-40d001f36ed8", + "name": "Compute SDB Climate & Surplus V2.2" + }, + { + "parameters": { + "jsCode": "const root = $input.first().json || {};\nconst calls = root.ha_service_calls || {};\nconst decision = root.decision || {};\nconst actions = [];\n\nfunction pushAction(action, call, enabled = true) {\n if (!enabled) return;\n if (!call) return;\n if (call.execute === false) return;\n\n const { execute, ...rest } = call;\n\n actions.push({\n json: {\n action,\n ...rest\n }\n });\n}\n\n// =====================================================\n// HELPERS TOUJOURS MIS A JOUR\n// =====================================================\npushAction('helper_state', calls.helper_state, true);\npushAction('helper_reason', calls.helper_reason, true);\n\n// =====================================================\n// OFF ENFANTS D'ABORD\n// =====================================================\npushAction(\n 'deshumide',\n calls.deshumide,\n decision.deshumide_action === 'turn_off'\n);\n\npushAction(\n 'radiator',\n calls.radiator,\n decision.radiator_action === 'turn_off'\n);\n\n// =====================================================\n// PUIS OFF MASTER SI NECESSAIRE\n// =====================================================\npushAction(\n 'master_power',\n calls.master_power,\n decision.master_action === 'turn_off'\n);\n\n// =====================================================\n// ON MASTER D'ABORD\n// =====================================================\npushAction(\n 'master_power',\n calls.master_power,\n decision.master_action === 'turn_on'\n);\n\n// =====================================================\n// PUIS ON ENFANTS\n// =====================================================\npushAction(\n 'deshumide',\n calls.deshumide,\n decision.deshumide_action === 'turn_on'\n);\n\npushAction(\n 'radiator',\n calls.radiator,\n decision.radiator_action === 'turn_on'\n);\n\n// =====================================================\n// LATCHES\n// =====================================================\npushAction(\n 'helper_master_latch',\n calls.helper_master_latch,\n decision.master_latch_action !== 'none'\n);\n\npushAction(\n 'helper_deshumide_latch',\n calls.helper_deshumide_latch,\n decision.deshumide_latch_action !== 'none'\n);\n\npushAction(\n 'helper_radiator_latch',\n calls.helper_radiator_latch,\n decision.radiator_latch_action !== 'none'\n);\n\n// =====================================================\n// TIMESTAMPS\n// =====================================================\npushAction(\n 'helper_master_last_on_at',\n calls.helper_master_last_on_at,\n !!decision.set_master_last_on_at\n);\n\npushAction(\n 'helper_master_last_off_at',\n calls.helper_master_last_off_at,\n !!decision.set_master_last_off_at\n);\n\npushAction(\n 'helper_deshumide_last_on_at',\n calls.helper_deshumide_last_on_at,\n !!decision.set_deshumide_last_on_at\n);\n\npushAction(\n 'helper_deshumide_last_off_at',\n calls.helper_deshumide_last_off_at,\n !!decision.set_deshumide_last_off_at\n);\n\npushAction(\n 'helper_radiator_last_on_at',\n calls.helper_radiator_last_on_at,\n !!decision.set_radiator_last_on_at\n);\n\npushAction(\n 'helper_radiator_last_off_at',\n calls.helper_radiator_last_off_at,\n !!decision.set_radiator_last_off_at\n);\n\n// =====================================================\n// SIGNATURE + LOGBOOK SI CHANGEMENT\n// =====================================================\npushAction(\n 'helper_signature',\n calls.helper_signature,\n !!decision.signature_changed\n);\n\npushAction(\n 'logbook',\n calls.logbook,\n !!decision.signature_changed\n);\n\n// =====================================================\n// NOTIFICATION\n// =====================================================\npushAction(\n 'mobile_notification',\n calls.mobile_notification,\n !!decision.should_notify\n);\n\nreturn actions;\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 672, + 0 + ], + "id": "02e0d133-7be4-41f3-a419-17e945e43b62", + "name": "Build SDB Climate & Surplus Actions V2.2" + } + ], + "connections": { + "Schedule Trigger": { + "main": [ + [ + { + "node": "HA Snapshot SDB Climate & Surplus", + "type": "main", + "index": 0 + } + ] + ] + }, + "HA Snapshot SDB Climate & Surplus": { + "main": [ + [ + { + "node": "Compute SDB Climate & Surplus V2.2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compute SDB Climate & Surplus V2.2": { + "main": [ + [ + { + "node": "Build SDB Climate & Surplus Actions V2.2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build SDB Climate & Surplus Actions V2.2": { + "main": [ + [ + { + "node": "Execute HA Service SDB Climate & Surplus", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "nodeGroups": [], + "authors": "thierry tallagrand", + "name": "Version a50b531f", + "description": "", + "autosaved": true, + "workflowPublishHistory": [ + { + "createdAt": "2026-04-14T19:35:38.771Z", + "id": 53, + "workflowId": "2NhrfxuGm4pTUNs9", + "versionId": "a50b531f-5399-463a-9943-e1a30a0cd2de", + "event": "activated", + "userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2" + }, + { + "createdAt": "2026-06-12T21:13:37.639Z", + "id": 118, + "workflowId": "2NhrfxuGm4pTUNs9", + "versionId": "a50b531f-5399-463a-9943-e1a30a0cd2de", + "event": "activated", + "userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2" + }, + { + "createdAt": "2026-07-09T21:45:53.923Z", + "id": 194, + "workflowId": "2NhrfxuGm4pTUNs9", + "versionId": "a50b531f-5399-463a-9943-e1a30a0cd2de", + "event": "activated", + "userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2" + }, + { + "createdAt": "2026-06-12T21:13:33.962Z", + "id": 117, + "workflowId": "2NhrfxuGm4pTUNs9", + "versionId": "a50b531f-5399-463a-9943-e1a30a0cd2de", + "event": "deactivated", + "userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2" + }, + { + "createdAt": "2026-07-09T21:45:53.898Z", + "id": 193, + "workflowId": "2NhrfxuGm4pTUNs9", + "versionId": "a50b531f-5399-463a-9943-e1a30a0cd2de", + "event": "deactivated", + "userId": "8b9e9552-cd74-4863-82a3-174b3b3befa2" + } + ] + } +}