diff --git a/src/water-heater/NORMALIZE.js b/src/water-heater/NORMALIZE.js index e43aafd..c1418ae 100644 --- a/src/water-heater/NORMALIZE.js +++ b/src/water-heater/NORMALIZE.js @@ -110,7 +110,7 @@ function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') { function parseParisStampToMs(s) { const text = (s ?? '').toString().trim(); - const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/); + 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]); diff --git a/src/water-heater/OBSERVATION.js b/src/water-heater/OBSERVATION.js index e53045b..c8dc986 100644 --- a/src/water-heater/OBSERVATION.js +++ b/src/water-heater/OBSERVATION.js @@ -55,7 +55,7 @@ function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') { function parseParisStampToMs(s) { const text = (s ?? '').toString().trim(); - const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/); + 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]); diff --git a/src/water-heater/OUTPUT_BUILDER.js b/src/water-heater/OUTPUT_BUILDER.js index f93fc98..65c183d 100644 --- a/src/water-heater/OUTPUT_BUILDER.js +++ b/src/water-heater/OUTPUT_BUILDER.js @@ -56,7 +56,7 @@ function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') { function parseParisStampToMs(s) { const text = (s ?? '').toString().trim(); - const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/); + 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]); diff --git a/workflows/water-heater-ecs-v1.json b/workflows/water-heater-ecs-v1.json index 0849847..6a7140d 100644 --- a/workflows/water-heater-ecs-v1.json +++ b/workflows/water-heater-ecs-v1.json @@ -9,7 +9,7 @@ "nodes": [ { "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 clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\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\nconst cfg = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\n batteryUsableCapacityKwh: 17.5,\n sampleWindowMinMin: 2,\n sampleWindowMinMax: 15,\n energyByDemandKwh: {\n low: { green: 0.9, auto: 1.4, boost: 2.0 },\n normal: { green: 1.4, auto: 2.2, boost: 3.1 },\n high: { green: 2.2, auto: 3.4, boost: 4.6 },\n three_showers: { green: 3.0, auto: 4.4, boost: 5.8 }\n }\n};\n\nconst raw = $input.first()?.json ?? {};\nconst input = extractSnapshot(raw);\nconst now = getParisNowParts();\nconst nowMs = Date.now();\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;\nconst todayRemainingKwh = Math.max(0, num(input.today_remaining_kwh, 0));\nconst tomorrowKwh = Math.max(0, num(input.tomorrow_kwh, 0));\nconst advisorEnabled = low(input.advisor_enabled || 'off') === 'on';\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\n\nconst hotWaterDemandRaw = low(input.hot_water_demand || 'normal');\nconst hotWaterDemand = ['low', 'normal', 'high', 'three_showers'].includes(hotWaterDemandRaw)\n ? hotWaterDemandRaw\n : 'normal';\n\nconst greenPowerW = Math.max(0, num(input.green_power_w, 620));\nconst autoPowerW = Math.max(0, num(input.auto_power_w, 1200));\nconst boostPowerW = Math.max(0, num(input.boost_power_w, 2000));\nconst strongForecastKwh = Math.max(0, num(input.strong_forecast_kwh, 18));\nconst mediumForecastKwh = Math.max(0, num(input.medium_forecast_kwh, 10));\nconst strongPvNowW = Math.max(0, num(input.strong_pv_now_w, 2500));\nconst mediumPvNowW = Math.max(0, num(input.medium_pv_now_w, 1500));\nconst boostMinSocStrongForecastPct = clamp(num(input.morning_boost_min_soc_strong_day, 35), 0, 100);\nconst boostMinSocMediumForecastPct = clamp(num(input.boost_min_soc_medium_day, 50), 0, 100);\nconst endOfDaySocGuard = clamp(num(input.end_of_day_soc_guard, 85), 0, 100);\nconst endOfDaySocTarget = clamp(num(input.end_of_day_soc_target, 85), 0, 100);\nconst baseLoadRemainingKwh = Math.max(0, num(input.base_load_remaining_kwh, 0));\nconst flexibleSafetyMarginKwh = Math.max(0, num(input.flexible_safety_margin_kwh, 0));\nconst acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 8000));\nconst lastSignature = cleanStateText(input.last_signature);\n// Jour faible (07/10/2026) : decision du bilan, session reseau HC engagee,\n// energie du chauffe-eau sur 24 h (service d'historisation), propriete de la coupure.\nconst ecsNuit = low(input.ecs_nuit) === 'on';\nconst hcSessionCommitted = low(input.hc_session_committed) === 'on';\nconst ecs24hKwh = num(input.ecs_24h_kwh, NaN);\nconst offJourFaiblePrev = low(input.off_jour_faible_prev) === 'on';\nconst forecastSource = cleanStateText(input.forecast_source) || 'forecast_solar';\n\nreturn [{\n json: {\n meta: {\n workflow: 'water_heater_advisor',\n architecture: 'ecs_6nodes_simple_early_boost',\n node: 'ecs_normalize_inputs',\n timestamp_paris: now.stamp\n },\n cfg,\n now,\n nowMs,\n input,\n normalized: {\n soc,\n batteryCurrentA,\n pvPowerW,\n pvPowerAvg10mW,\n houseAcPowerW,\n gridPowerW,\n gridIsOn,\n acInAvailable,\n gridAvailable,\n todayRemainingKwh,\n tomorrowKwh,\n advisorEnabled,\n notificationsEnabled,\n hotWaterDemand,\n greenPowerW,\n autoPowerW,\n boostPowerW,\n strongForecastKwh,\n mediumForecastKwh,\n strongPvNowW,\n mediumPvNowW,\n boostMinSocStrongForecastPct,\n boostMinSocMediumForecastPct,\n endOfDaySocGuard,\n endOfDaySocTarget,\n baseLoadRemainingKwh,\n flexibleSafetyMarginKwh,\n acLoadsMaxSafeW,\n lastSignature,\n ecsNuit,\n hcSessionCommitted,\n ecs24hKwh,\n offJourFaiblePrev,\n forecastSource\n }\n }\n}];" + "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 clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\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\nconst cfg = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\n batteryUsableCapacityKwh: 17.5,\n sampleWindowMinMin: 2,\n sampleWindowMinMax: 15,\n energyByDemandKwh: {\n low: { green: 0.9, auto: 1.4, boost: 2.0 },\n normal: { green: 1.4, auto: 2.2, boost: 3.1 },\n high: { green: 2.2, auto: 3.4, boost: 4.6 },\n three_showers: { green: 3.0, auto: 4.4, boost: 5.8 }\n }\n};\n\nconst raw = $input.first()?.json ?? {};\nconst input = extractSnapshot(raw);\nconst now = getParisNowParts();\nconst nowMs = Date.now();\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;\nconst todayRemainingKwh = Math.max(0, num(input.today_remaining_kwh, 0));\nconst tomorrowKwh = Math.max(0, num(input.tomorrow_kwh, 0));\nconst advisorEnabled = low(input.advisor_enabled || 'off') === 'on';\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\n\nconst hotWaterDemandRaw = low(input.hot_water_demand || 'normal');\nconst hotWaterDemand = ['low', 'normal', 'high', 'three_showers'].includes(hotWaterDemandRaw)\n ? hotWaterDemandRaw\n : 'normal';\n\nconst greenPowerW = Math.max(0, num(input.green_power_w, 620));\nconst autoPowerW = Math.max(0, num(input.auto_power_w, 1200));\nconst boostPowerW = Math.max(0, num(input.boost_power_w, 2000));\nconst strongForecastKwh = Math.max(0, num(input.strong_forecast_kwh, 18));\nconst mediumForecastKwh = Math.max(0, num(input.medium_forecast_kwh, 10));\nconst strongPvNowW = Math.max(0, num(input.strong_pv_now_w, 2500));\nconst mediumPvNowW = Math.max(0, num(input.medium_pv_now_w, 1500));\nconst boostMinSocStrongForecastPct = clamp(num(input.morning_boost_min_soc_strong_day, 35), 0, 100);\nconst boostMinSocMediumForecastPct = clamp(num(input.boost_min_soc_medium_day, 50), 0, 100);\nconst endOfDaySocGuard = clamp(num(input.end_of_day_soc_guard, 85), 0, 100);\nconst endOfDaySocTarget = clamp(num(input.end_of_day_soc_target, 85), 0, 100);\nconst baseLoadRemainingKwh = Math.max(0, num(input.base_load_remaining_kwh, 0));\nconst flexibleSafetyMarginKwh = Math.max(0, num(input.flexible_safety_margin_kwh, 0));\nconst acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 8000));\nconst lastSignature = cleanStateText(input.last_signature);\n// Jour faible (07/10/2026) : decision du bilan, session reseau HC engagee,\n// energie du chauffe-eau sur 24 h (service d'historisation), propriete de la coupure.\nconst ecsNuit = low(input.ecs_nuit) === 'on';\nconst hcSessionCommitted = low(input.hc_session_committed) === 'on';\nconst ecs24hKwh = num(input.ecs_24h_kwh, NaN);\nconst offJourFaiblePrev = low(input.off_jour_faible_prev) === 'on';\nconst forecastSource = cleanStateText(input.forecast_source) || 'forecast_solar';\n\nreturn [{\n json: {\n meta: {\n workflow: 'water_heater_advisor',\n architecture: 'ecs_6nodes_simple_early_boost',\n node: 'ecs_normalize_inputs',\n timestamp_paris: now.stamp\n },\n cfg,\n now,\n nowMs,\n input,\n normalized: {\n soc,\n batteryCurrentA,\n pvPowerW,\n pvPowerAvg10mW,\n houseAcPowerW,\n gridPowerW,\n gridIsOn,\n acInAvailable,\n gridAvailable,\n todayRemainingKwh,\n tomorrowKwh,\n advisorEnabled,\n notificationsEnabled,\n hotWaterDemand,\n greenPowerW,\n autoPowerW,\n boostPowerW,\n strongForecastKwh,\n mediumForecastKwh,\n strongPvNowW,\n mediumPvNowW,\n boostMinSocStrongForecastPct,\n boostMinSocMediumForecastPct,\n endOfDaySocGuard,\n endOfDaySocTarget,\n baseLoadRemainingKwh,\n flexibleSafetyMarginKwh,\n acLoadsMaxSafeW,\n lastSignature,\n ecsNuit,\n hcSessionCommitted,\n ecs24hKwh,\n offJourFaiblePrev,\n forecastSource\n }\n }\n}];" }, "id": "1226082d-222c-4837-99d3-35094bd1c4f1", "name": "ECS Normalize Inputs", @@ -22,7 +22,7 @@ }, { "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 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 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 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\nconst root = $input.first()?.json ?? {};\nconst cfg = root.cfg ?? {};\nconst now = root.now ?? {};\nconst nowMs = root.nowMs ?? null;\nconst input = root.input ?? {};\nconst normalized = root.normalized ?? {};\n\nconst { greenPowerW, autoPowerW, boostPowerW } = normalized;\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 energyPriorityMode === 'evcs_priority' &&\n (\n evcsPriorityUntilMs === null ||\n (nowMs !== null && evcsPriorityUntilMs > nowMs + 1000)\n );\n\nconst waterHeaterSwitchOn = low(input.water_heater_switch || 'off') === 'on';\nconst waterHeaterEnergyTotalKwh = Math.max(0, num(input.water_heater_energy_total_kwh, 0));\nconst energyPrevKwh = num(input.energy_total_prev_kwh, NaN);\nconst energyPrevAt = cleanParisStamp(input.energy_prev_at);\nconst energyPrevMs = parseParisStampToMs(energyPrevAt);\nconst observedPowerPrevW = Math.max(0, num(input.observed_power_helper_w, 0));\nconst observedRegimePrev = cleanStateText(input.observed_regime_helper).toLowerCase();\nconst mismatchSinceAt = cleanParisStamp(input.mode_mismatch_since);\nconst mismatchSinceMsPrev = parseParisStampToMs(mismatchSinceAt);\nconst forcedOffByAdvisorPrev = low(input.forced_off_by_advisor || 'off') === 'on';\nconst forcedOffReasonPrev = cleanStateText(input.forced_off_reason);\nconst forcedOffAtPrev = cleanParisStamp(input.forced_off_at);\nconst forcedOffAtPrevMs = parseParisStampToMs(forcedOffAtPrev);\nconst pendingUserActionPrev = cleanStateText(input.pending_user_action_prev);\nconst restoreSurplusW = Math.max(0, num(input.restore_surplus_w, 2500));\nconst modeMismatchDelayMin = Math.max(1, num(input.mode_mismatch_delay_min, 15));\n\nconst sampleDurationMin = minutesSince(energyPrevMs, nowMs);\nconst energyDeltaKwh =\n Number.isFinite(energyPrevKwh)\n ? Math.max(0, waterHeaterEnergyTotalKwh - energyPrevKwh)\n : NaN;\n\nconst sampleIsUsable =\n Number.isFinite(sampleDurationMin) &&\n sampleDurationMin >= (cfg.sampleWindowMinMin ?? 2) &&\n sampleDurationMin <= (cfg.sampleWindowMinMax ?? 15) &&\n Number.isFinite(energyDeltaKwh);\n\nlet observedPowerW = 0;\nif (sampleIsUsable) {\n observedPowerW = Math.max(0, (energyDeltaKwh * 60000) / sampleDurationMin);\n} else if (waterHeaterSwitchOn && observedPowerPrevW > 0) {\n observedPowerW = observedPowerPrevW;\n}\n\nconst offThresholdW = Math.max(80, greenPowerW * 0.25);\nconst hpUpperW = (greenPowerW + autoPowerW) / 2;\nconst resistiveUpperW = (autoPowerW + boostPowerW) / 2;\n\nlet observedRegime = 'unknown';\nif (!waterHeaterSwitchOn) {\n observedRegime = 'off';\n} else if (!sampleIsUsable && observedRegimePrev) {\n observedRegime = observedRegimePrev;\n} else if (observedPowerW < offThresholdW) {\n observedRegime = 'off';\n} else if (observedPowerW < hpUpperW) {\n observedRegime = 'hp_low';\n} else if (observedPowerW < resistiveUpperW) {\n observedRegime = 'resistive_mid';\n} else {\n observedRegime = 'boost_high';\n}\n\nreturn [{\n json: {\n ...root,\n meta: {\n ...(root.meta ?? {}),\n node: 'ecs_evcs_and_water_heater_observation'\n },\n observation: {\n evcsPriorityActiveRaw,\n energyPriorityMode,\n energyPriorityReason,\n evcsPriorityUntilAt,\n evcsPriorityUntilMs,\n evcsPriorityActive,\n waterHeaterSwitchOn,\n waterHeaterEnergyTotalKwh,\n energyPrevKwh,\n energyPrevAt,\n energyPrevMs,\n observedPowerPrevW,\n observedRegimePrev,\n mismatchSinceAt,\n mismatchSinceMsPrev,\n forcedOffByAdvisorPrev,\n forcedOffReasonPrev,\n forcedOffAtPrev,\n forcedOffAtPrevMs,\n pendingUserActionPrev,\n restoreSurplusW,\n modeMismatchDelayMin,\n sampleDurationMin,\n energyDeltaKwh,\n sampleIsUsable,\n observedPowerW,\n observedRegime,\n offThresholdW,\n hpUpperW,\n resistiveUpperW\n }\n }\n}];" + "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 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 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 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\nconst root = $input.first()?.json ?? {};\nconst cfg = root.cfg ?? {};\nconst now = root.now ?? {};\nconst nowMs = root.nowMs ?? null;\nconst input = root.input ?? {};\nconst normalized = root.normalized ?? {};\n\nconst { greenPowerW, autoPowerW, boostPowerW } = normalized;\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 energyPriorityMode === 'evcs_priority' &&\n (\n evcsPriorityUntilMs === null ||\n (nowMs !== null && evcsPriorityUntilMs > nowMs + 1000)\n );\n\nconst waterHeaterSwitchOn = low(input.water_heater_switch || 'off') === 'on';\nconst waterHeaterEnergyTotalKwh = Math.max(0, num(input.water_heater_energy_total_kwh, 0));\nconst energyPrevKwh = num(input.energy_total_prev_kwh, NaN);\nconst energyPrevAt = cleanParisStamp(input.energy_prev_at);\nconst energyPrevMs = parseParisStampToMs(energyPrevAt);\nconst observedPowerPrevW = Math.max(0, num(input.observed_power_helper_w, 0));\nconst observedRegimePrev = cleanStateText(input.observed_regime_helper).toLowerCase();\nconst mismatchSinceAt = cleanParisStamp(input.mode_mismatch_since);\nconst mismatchSinceMsPrev = parseParisStampToMs(mismatchSinceAt);\nconst forcedOffByAdvisorPrev = low(input.forced_off_by_advisor || 'off') === 'on';\nconst forcedOffReasonPrev = cleanStateText(input.forced_off_reason);\nconst forcedOffAtPrev = cleanParisStamp(input.forced_off_at);\nconst forcedOffAtPrevMs = parseParisStampToMs(forcedOffAtPrev);\nconst pendingUserActionPrev = cleanStateText(input.pending_user_action_prev);\nconst restoreSurplusW = Math.max(0, num(input.restore_surplus_w, 2500));\nconst modeMismatchDelayMin = Math.max(1, num(input.mode_mismatch_delay_min, 15));\n\nconst sampleDurationMin = minutesSince(energyPrevMs, nowMs);\nconst energyDeltaKwh =\n Number.isFinite(energyPrevKwh)\n ? Math.max(0, waterHeaterEnergyTotalKwh - energyPrevKwh)\n : NaN;\n\nconst sampleIsUsable =\n Number.isFinite(sampleDurationMin) &&\n sampleDurationMin >= (cfg.sampleWindowMinMin ?? 2) &&\n sampleDurationMin <= (cfg.sampleWindowMinMax ?? 15) &&\n Number.isFinite(energyDeltaKwh);\n\nlet observedPowerW = 0;\nif (sampleIsUsable) {\n observedPowerW = Math.max(0, (energyDeltaKwh * 60000) / sampleDurationMin);\n} else if (waterHeaterSwitchOn && observedPowerPrevW > 0) {\n observedPowerW = observedPowerPrevW;\n}\n\nconst offThresholdW = Math.max(80, greenPowerW * 0.25);\nconst hpUpperW = (greenPowerW + autoPowerW) / 2;\nconst resistiveUpperW = (autoPowerW + boostPowerW) / 2;\n\nlet observedRegime = 'unknown';\nif (!waterHeaterSwitchOn) {\n observedRegime = 'off';\n} else if (!sampleIsUsable && observedRegimePrev) {\n observedRegime = observedRegimePrev;\n} else if (observedPowerW < offThresholdW) {\n observedRegime = 'off';\n} else if (observedPowerW < hpUpperW) {\n observedRegime = 'hp_low';\n} else if (observedPowerW < resistiveUpperW) {\n observedRegime = 'resistive_mid';\n} else {\n observedRegime = 'boost_high';\n}\n\nreturn [{\n json: {\n ...root,\n meta: {\n ...(root.meta ?? {}),\n node: 'ecs_evcs_and_water_heater_observation'\n },\n observation: {\n evcsPriorityActiveRaw,\n energyPriorityMode,\n energyPriorityReason,\n evcsPriorityUntilAt,\n evcsPriorityUntilMs,\n evcsPriorityActive,\n waterHeaterSwitchOn,\n waterHeaterEnergyTotalKwh,\n energyPrevKwh,\n energyPrevAt,\n energyPrevMs,\n observedPowerPrevW,\n observedRegimePrev,\n mismatchSinceAt,\n mismatchSinceMsPrev,\n forcedOffByAdvisorPrev,\n forcedOffReasonPrev,\n forcedOffAtPrev,\n forcedOffAtPrevMs,\n pendingUserActionPrev,\n restoreSurplusW,\n modeMismatchDelayMin,\n sampleDurationMin,\n energyDeltaKwh,\n sampleIsUsable,\n observedPowerW,\n observedRegime,\n offThresholdW,\n hpUpperW,\n resistiveUpperW\n }\n }\n}];" }, "id": "0979b36d-13b0-492a-bafb-8c99219bbdb5", "name": "ECS EVCS & Water Heater Observation", @@ -74,7 +74,7 @@ }, { "parameters": { - "jsCode": "function round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round2(v) {\n return Math.round(v * 100) / 100;\n}\n\nfunction safeText(v, max = 255) {\n return (v ?? '').toString().slice(0, max);\n}\n\nfunction boolService(flag) {\n return flag ? 'turn_on' : 'turn_off';\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\nconst root = $input.first()?.json ?? {};\nconst cfg = root.cfg ?? {};\nconst now = root.now ?? {};\nconst input = root.input ?? {};\nconst normalized = root.normalized ?? {};\nconst observation = root.observation ?? {};\nconst energyModel = root.energyModel ?? {};\nconst finalDecision = root.finalDecision ?? {};\n\nconst {\n soc,\n batteryCurrentA,\n pvPowerW,\n pvPowerAvg10mW,\n houseAcPowerW,\n gridPowerW,\n gridIsOn,\n acInAvailable,\n gridAvailable,\n todayRemainingKwh,\n tomorrowKwh,\n advisorEnabled,\n notificationsEnabled,\n hotWaterDemand,\n greenPowerW,\n autoPowerW,\n boostPowerW,\n strongForecastKwh,\n mediumForecastKwh,\n strongPvNowW,\n mediumPvNowW,\n boostMinSocStrongForecastPct,\n boostMinSocMediumForecastPct,\n endOfDaySocGuard,\n endOfDaySocTarget,\n baseLoadRemainingKwh,\n flexibleSafetyMarginKwh,\n acLoadsMaxSafeW,\n lastSignature\n} = normalized;\n\nconst {\n evcsPriorityActive,\n energyPriorityMode,\n energyPriorityReason,\n evcsPriorityUntilAt,\n waterHeaterSwitchOn,\n waterHeaterEnergyTotalKwh,\n restoreSurplusW,\n modeMismatchDelayMin,\n sampleDurationMin,\n energyDeltaKwh,\n sampleIsUsable,\n observedPowerW,\n observedRegime\n} = observation;\n\nconst {\n targetSoc,\n batteryCapacityKwh,\n greenEnergyKwh,\n autoEnergyKwh,\n boostEnergyKwh,\n energyNeededToReachTargetKwh,\n projectedGreenSoc,\n projectedAutoSoc,\n projectedBoostSoc,\n greenBudgetMarginKwh,\n autoBudgetMarginKwh,\n boostBudgetMarginKwh,\n forecastStrength,\n strongForecast,\n mediumForecast,\n strongPvNow,\n mediumPvNow,\n netPvHeadroomW,\n pvCoversHouseNow,\n pvStartedWell,\n acSafe,\n batteryNotDrainingHard,\n greenAllowed,\n autoAllowed,\n boostAllowedByBudget,\n boostAllowed,\n earlyBoostStrongReady,\n earlyBoostMediumReady,\n autoReady,\n greenReady\n} = energyModel;\n\nlet {\n recommendedMode,\n adviceColor,\n state,\n reason,\n baseRecommendedMode,\n baseAdviceColor,\n baseState,\n selectedLoadKwh,\n selectedProjectedSoc,\n selectedBudgetMarginKwh,\n legacyAdviceOk,\n mismatchActive,\n mismatchSeverity,\n mismatchSinceAtEffective,\n mismatchSinceMsEffective,\n mismatchDurationMin,\n pendingUserAction,\n firstMismatchRun,\n realConflictNow,\n forceOffNow,\n restoreEnergyOk,\n restoreNow,\n forcedOffByAdvisor,\n forcedOffReason,\n forcedOffAt,\n protectionState,\n powerSwitchAction,\n powerSwitchDesiredOn,\n offJourFaible = false\n} = finalDecision;\n\nreason = Array.isArray(reason) ? [...reason] : [];\n\nconst forcedOffAtSafe = forcedOffAt && parseParisStampToMs(forcedOffAt) !== null ? forcedOffAt : '';\nconst forcedOffReasonSafe = forcedOffByAdvisor ? safeText(forcedOffReason, 255) : '';\nconst pendingUserActionSafe = safeText(pendingUserAction, 255);\n\nif (reason.length === 0) {\n if (recommendedMode === 'boost') reason.push('Boost recommandé');\n else if (recommendedMode === 'auto') reason.push('Mode auto recommandé');\n else if (recommendedMode === 'green') reason.push('Mode green recommandé');\n else reason.push('Chauffe-eau déconseillé pour le moment');\n}\n\nreason.push(`Demande ECS: ${hotWaterDemand}`);\nreason.push(`Conseil final: ${recommendedMode}`);\nreason.push(`Conseil initial: ${baseRecommendedMode}`);\nreason.push(`Couleur conseil: ${adviceColor}`);\nreason.push(`Forecast du jour: ${round1(todayRemainingKwh)} kWh`);\nreason.push(`Production demain: ${round1(tomorrowKwh)} kWh`);\nreason.push(`Classe forecast: ${forecastStrength}`);\nreason.push(`PV actuel: ${Math.round(pvPowerW)} W | PV10: ${Math.round(pvPowerAvg10mW)} W`);\nreason.push(`Charges AC maison: ${Math.round(houseAcPowerW)} W`);\nreason.push(`Surplus net estimé: ${Math.round(netPvHeadroomW)} W`);\nreason.push(`PV couvre maison: ${pvCoversHouseNow ? 'oui' : 'non'} | PV bien démarré: ${pvStartedWell ? 'oui' : 'non'}`);\nreason.push(`Batterie: SOC ${round1(soc)}% | courant ${round1(batteryCurrentA)} A`);\nreason.push(`Réseau: cmd=${gridIsOn ? 'ON' : 'OFF'} | acin=${acInAvailable ? 'OK' : 'KO'} | dispo=${gridAvailable ? 'oui' : 'non'}`);\nreason.push(`Target fin de jour: ${round1(targetSoc)}%`);\nreason.push(`Base load restante: ${round1(baseLoadRemainingKwh)} kWh`);\nreason.push(`Marge sécurité: ${round1(flexibleSafetyMarginKwh)} kWh`);\nreason.push(`Green ${round1(greenEnergyKwh)}kWh → SOC fin ${round1(projectedGreenSoc)}% | marge ${round1(greenBudgetMarginKwh)}kWh`);\nreason.push(`Auto ${round1(autoEnergyKwh)}kWh → SOC fin ${round1(projectedAutoSoc)}% | marge ${round1(autoBudgetMarginKwh)}kWh`);\nreason.push(`Boost ${round1(boostEnergyKwh)}kWh → SOC fin ${round1(projectedBoostSoc)}% | marge ${round1(boostBudgetMarginKwh)}kWh`);\nreason.push(`Régime observé: ${observedRegime} | puissance ≈ ${Math.round(observedPowerW)} W | delta ${sampleIsUsable ? round2(energyDeltaKwh) : 'n/a'} kWh/${sampleIsUsable ? round1(sampleDurationMin) : 'n/a'} min`);\nreason.push(`Prio EVCS: ${evcsPriorityActive ? 'ON' : 'OFF'}${energyPriorityReason ? ` | ${energyPriorityReason}` : ''}`);\nreason.push(`Mismatch: ${mismatchActive ? 'oui' : 'non'} | sévérité=${mismatchSeverity} | durée=${round1(mismatchDurationMin)} min`);\nreason.push(`Protection ECS: ${protectionState}`);\nif (pendingUserActionSafe) reason.push(`Action attendue: ${pendingUserActionSafe}`);\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst signature = [\n now.date,\n `d=${hotWaterDemand}`,\n `m=${recommendedMode}`,\n `base=${baseRecommendedMode}`,\n `c=${adviceColor}`,\n `fs=${strongForecast ? '1' : '0'}`,\n `fm=${mediumForecast ? '1' : '0'}`,\n `pb=${pvStartedWell ? '1' : '0'}`,\n `soc=${round1(soc)}`,\n `pv=${Math.round(pvPowerW)}`,\n `pv10=${Math.round(pvPowerAvg10mW)}`,\n `npv=${Math.round(netPvHeadroomW)}`,\n `obs=${observedRegime}`,\n `prio=${evcsPriorityActive ? '1' : '0'}`,\n `cut=${forcedOffByAdvisor ? '1' : '0'}`\n].join('|');\n\nconst signatureChanged = signature !== lastSignature;\n\nlet notifyWanted = false;\nlet notifyTitle = 'Water Heater Advisor';\nlet notifyMessage = '';\n\nif (notificationsEnabled) {\n if (forceOffNow) {\n notifyWanted = true;\n notifyTitle = 'ECS coupé temporairement';\n notifyMessage = forcedOffReasonSafe || 'Le chauffe-eau a été coupé temporairement';\n } else if (restoreNow) {\n notifyWanted = true;\n notifyTitle = 'ECS remis sous tension';\n notifyMessage = 'Le chauffe-eau est réalimenté car la priorité EVCS est terminée ou le surplus est redevenu suffisant.';\n } else if (firstMismatchRun && pendingUserActionSafe) {\n notifyWanted = true;\n notifyTitle = 'Action utilisateur ECS conseillée';\n notifyMessage = pendingUserActionSafe;\n } else if (signatureChanged) {\n notifyWanted = true;\n notifyTitle = `Water Heater Advisor: ${recommendedMode.toUpperCase()} / ${adviceColor.toUpperCase()}`;\n notifyMessage = `Demande=${hotWaterDemand} | régime=${observedRegime} | SOC fin estimé=${round1(selectedProjectedSoc)}% | marge=${round1(selectedBudgetMarginKwh)} kWh`;\n }\n}\nconst BUDGET_MARGIN_MIN = -50;\nconst BUDGET_MARGIN_MAX = 50;\nconst result = {\n timestamp_paris: now.stamp,\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 today_remaining_kwh: round2(todayRemainingKwh),\n tomorrow_kwh: round2(tomorrowKwh),\n advisor_enabled: advisorEnabled,\n notifications_enabled: notificationsEnabled,\n hot_water_demand: hotWaterDemand,\n green_power_w: greenPowerW,\n auto_power_w: autoPowerW,\n boost_power_w: boostPowerW,\n strong_forecast_kwh: strongForecastKwh,\n medium_forecast_kwh: mediumForecastKwh,\n strong_pv_now_w: strongPvNowW,\n medium_pv_now_w: mediumPvNowW,\n boost_min_soc_strong_forecast_pct: boostMinSocStrongForecastPct,\n boost_min_soc_medium_forecast_pct: boostMinSocMediumForecastPct,\n end_of_day_soc_guard: endOfDaySocGuard,\n end_of_day_soc_target: endOfDaySocTarget,\n base_load_remaining_kwh: baseLoadRemainingKwh,\n flexible_safety_margin_kwh: flexibleSafetyMarginKwh,\n ac_loads_max_safe_w: acLoadsMaxSafeW,\n water_heater_switch: waterHeaterSwitchOn,\n water_heater_energy_total_kwh: round2(waterHeaterEnergyTotalKwh),\n last_signature: lastSignature || null,\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason || null,\n evcs_priority_until: evcsPriorityUntilAt || null\n },\n computed: {\n forecast_strength: forecastStrength,\n strong_forecast: strongForecast,\n medium_forecast: mediumForecast,\n strong_pv_now: strongPvNow,\n medium_pv_now: mediumPvNow,\n net_pv_headroom_w: Math.round(netPvHeadroomW),\n pv_covers_house_now: pvCoversHouseNow,\n pv_started_well: pvStartedWell,\n battery_usable_capacity_kwh_internal: batteryCapacityKwh,\n energy_needed_to_reach_target_kwh: round2(energyNeededToReachTargetKwh),\n green_energy_kwh: round2(greenEnergyKwh),\n auto_energy_kwh: round2(autoEnergyKwh),\n boost_energy_kwh: round2(boostEnergyKwh),\n projected_green_soc: round1(projectedGreenSoc),\n projected_auto_soc: round1(projectedAutoSoc),\n projected_boost_soc: round1(projectedBoostSoc),\n green_budget_margin_kwh: round2(greenBudgetMarginKwh),\n auto_budget_margin_kwh: round2(autoBudgetMarginKwh),\n boost_budget_margin_kwh: round2(boostBudgetMarginKwh),\n ac_safe: acSafe,\n battery_not_draining_hard: batteryNotDrainingHard,\n green_allowed: greenAllowed,\n auto_allowed: autoAllowed,\n boost_allowed_by_budget: boostAllowedByBudget,\n boost_allowed: boostAllowed,\n early_boost_strong_ready: earlyBoostStrongReady,\n early_boost_medium_ready: earlyBoostMediumReady,\n auto_ready: autoReady,\n green_ready: greenReady,\n base_recommended_mode: baseRecommendedMode,\n base_advice_color: baseAdviceColor,\n base_state: baseState,\n observed_power_w: Math.round(observedPowerW),\n observed_regime: observedRegime,\n sample_duration_min: sampleIsUsable ? round1(sampleDurationMin) : null,\n energy_delta_kwh: sampleIsUsable ? round2(energyDeltaKwh) : null,\n mismatch_active: mismatchActive,\n mismatch_severity: mismatchSeverity,\n mismatch_duration_min: round1(mismatchDurationMin),\n evcs_priority_active: evcsPriorityActive,\n protection_state: protectionState,\n real_conflict_now: realConflictNow,\n restore_energy_ok: restoreEnergyOk,\n restore_surplus_w: restoreSurplusW,\n mode_mismatch_delay_min: modeMismatchDelayMin\n },\n decision: {\n recommended_mode: recommendedMode,\n advice_color: adviceColor,\n state,\n legacy_advice_ok: legacyAdviceOk,\n selected_load_kwh: round2(selectedLoadKwh),\n projected_end_of_day_soc: round1(selectedProjectedSoc),\n budget_margin_kwh: round2(selectedBudgetMarginKwh),\n observed_power_w: Math.round(observedPowerW),\n observed_regime: observedRegime,\n mismatch_active: mismatchActive,\n mismatch_severity: mismatchSeverity,\n mismatch_duration_min: round1(mismatchDurationMin),\n pending_user_action: pendingUserActionSafe,\n power_switch_action: powerSwitchAction,\n power_switch_desired_on: powerSwitchDesiredOn,\n force_off_now: forceOffNow,\n restore_now: restoreNow,\n forced_off_by_advisor: forcedOffByAdvisor,\n forced_off_reason: forcedOffReasonSafe,\n forced_off_at: forcedOffByAdvisor ? forcedOffAtSafe || null : null,\n signature,\n signature_changed: signatureChanged,\n should_notify: notifyWanted,\n reason,\n short_reason: shortReason\n },\n ha_service_calls: {\n helper_recommended_mode: {\n domain: 'input_select',\n service: 'select_option',\n data: {\n entity_id: 'input_select.victron_water_heater_recommended_mode',\n option: recommendedMode\n }\n },\n helper_advice_color: {\n domain: 'input_select',\n service: 'select_option',\n data: {\n entity_id: 'input_select.victron_water_heater_advice_color',\n option: adviceColor\n }\n },\n helper_state: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_advice_state',\n value: state\n }\n },\n helper_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_advice_reason',\n value: shortReason || 'Aucune raison'\n }\n },\n helper_signature: {\n execute: signatureChanged,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_advice_last_signature',\n value: signature\n }\n },\n helper_projected_soc: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_water_heater_projected_end_of_day_soc',\n value: round1(selectedProjectedSoc)\n }\n },\nhelper_budget_margin: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_water_heater_budget_margin_kwh',\n value: Math.max(BUDGET_MARGIN_MIN, Math.min(BUDGET_MARGIN_MAX, round2(selectedBudgetMarginKwh)))\n }\n},\n helper_load_kwh: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_load_water_heater_kwh',\n value: round2(selectedLoadKwh)\n }\n },\n helper_legacy_advice_ok: {\n domain: 'input_boolean',\n service: boolService(legacyAdviceOk),\n data: {\n entity_id: 'input_boolean.victron_advice_water_heater_ok'\n }\n },\n helper_green_allowed: {\n domain: 'input_boolean',\n service: boolService(greenAllowed),\n data: {\n entity_id: 'input_boolean.victron_water_heater_green_allowed'\n }\n },\n helper_auto_allowed: {\n domain: 'input_boolean',\n service: boolService(autoAllowed),\n data: {\n entity_id: 'input_boolean.victron_water_heater_auto_allowed'\n }\n },\n helper_boost_allowed: {\n domain: 'input_boolean',\n service: boolService(boostAllowed),\n data: {\n entity_id: 'input_boolean.victron_water_heater_boost_allowed'\n }\n },\n helper_observed_power: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_water_heater_observed_power_w',\n value: Math.round(observedPowerW)\n }\n },\n helper_observed_regime: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_observed_regime',\n value: observedRegime\n }\n },\n helper_pending_user_action: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_pending_user_action',\n value: pendingUserActionSafe\n }\n },\n helper_mismatch_since: {\n execute: mismatchActive && !!mismatchSinceAtEffective,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_water_heater_mode_mismatch_since',\n datetime: mismatchSinceAtEffective\n }\n },\n helper_energy_prev_kwh: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_water_heater_energy_total_prev_kwh',\n value: round2(waterHeaterEnergyTotalKwh)\n }\n },\n helper_energy_prev_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_water_heater_energy_prev_at',\n datetime: now.stamp\n }\n },\n helper_forced_off_by_advisor: {\n domain: 'input_boolean',\n service: boolService(forcedOffByAdvisor),\n data: {\n entity_id: 'input_boolean.victron_water_heater_forced_off_by_advisor'\n }\n },\n helper_forced_off_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_forced_off_reason',\n value: forcedOffReasonSafe\n }\n },\n helper_forced_off_at: {\n execute: forceOffNow && !!forcedOffAtSafe,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_water_heater_forced_off_at',\n datetime: forcedOffAtSafe\n }\n },\n // Jour faible (07/10/2026) : la coupure appartient a la regle « eau chaude en HC de nuit ».\n helper_off_jour_faible: {\n domain: 'input_boolean',\n service: boolService(offJourFaible),\n data: {\n entity_id: 'input_boolean.victron_water_heater_off_jour_faible'\n }\n },\n water_heater_power_switch: {\n execute: powerSwitchAction !== 'none',\n domain: 'switch',\n service: powerSwitchDesiredOn ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.tt_chauffe_eau_pleumeur_switch'\n }\n },\n logbook: {\n execute: signatureChanged || forceOffNow || restoreNow,\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Water Heater Advisor',\n message: `${recommendedMode}/${adviceColor} | regime=${observedRegime} | prioEVCS=${evcsPriorityActive ? 'on' : 'off'} | cut=${forcedOffByAdvisor ? 'on' : 'off'} | ${shortReason}`,\n entity_id: 'switch.tt_chauffe_eau_pleumeur_switch'\n }\n },\n mobile_notification: {\n execute: 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 }];" + "jsCode": "function round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction round2(v) {\n return Math.round(v * 100) / 100;\n}\n\nfunction safeText(v, max = 255) {\n return (v ?? '').toString().slice(0, max);\n}\n\nfunction boolService(flag) {\n return flag ? 'turn_on' : 'turn_off';\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\nconst root = $input.first()?.json ?? {};\nconst cfg = root.cfg ?? {};\nconst now = root.now ?? {};\nconst input = root.input ?? {};\nconst normalized = root.normalized ?? {};\nconst observation = root.observation ?? {};\nconst energyModel = root.energyModel ?? {};\nconst finalDecision = root.finalDecision ?? {};\n\nconst {\n soc,\n batteryCurrentA,\n pvPowerW,\n pvPowerAvg10mW,\n houseAcPowerW,\n gridPowerW,\n gridIsOn,\n acInAvailable,\n gridAvailable,\n todayRemainingKwh,\n tomorrowKwh,\n advisorEnabled,\n notificationsEnabled,\n hotWaterDemand,\n greenPowerW,\n autoPowerW,\n boostPowerW,\n strongForecastKwh,\n mediumForecastKwh,\n strongPvNowW,\n mediumPvNowW,\n boostMinSocStrongForecastPct,\n boostMinSocMediumForecastPct,\n endOfDaySocGuard,\n endOfDaySocTarget,\n baseLoadRemainingKwh,\n flexibleSafetyMarginKwh,\n acLoadsMaxSafeW,\n lastSignature\n} = normalized;\n\nconst {\n evcsPriorityActive,\n energyPriorityMode,\n energyPriorityReason,\n evcsPriorityUntilAt,\n waterHeaterSwitchOn,\n waterHeaterEnergyTotalKwh,\n restoreSurplusW,\n modeMismatchDelayMin,\n sampleDurationMin,\n energyDeltaKwh,\n sampleIsUsable,\n observedPowerW,\n observedRegime\n} = observation;\n\nconst {\n targetSoc,\n batteryCapacityKwh,\n greenEnergyKwh,\n autoEnergyKwh,\n boostEnergyKwh,\n energyNeededToReachTargetKwh,\n projectedGreenSoc,\n projectedAutoSoc,\n projectedBoostSoc,\n greenBudgetMarginKwh,\n autoBudgetMarginKwh,\n boostBudgetMarginKwh,\n forecastStrength,\n strongForecast,\n mediumForecast,\n strongPvNow,\n mediumPvNow,\n netPvHeadroomW,\n pvCoversHouseNow,\n pvStartedWell,\n acSafe,\n batteryNotDrainingHard,\n greenAllowed,\n autoAllowed,\n boostAllowedByBudget,\n boostAllowed,\n earlyBoostStrongReady,\n earlyBoostMediumReady,\n autoReady,\n greenReady\n} = energyModel;\n\nlet {\n recommendedMode,\n adviceColor,\n state,\n reason,\n baseRecommendedMode,\n baseAdviceColor,\n baseState,\n selectedLoadKwh,\n selectedProjectedSoc,\n selectedBudgetMarginKwh,\n legacyAdviceOk,\n mismatchActive,\n mismatchSeverity,\n mismatchSinceAtEffective,\n mismatchSinceMsEffective,\n mismatchDurationMin,\n pendingUserAction,\n firstMismatchRun,\n realConflictNow,\n forceOffNow,\n restoreEnergyOk,\n restoreNow,\n forcedOffByAdvisor,\n forcedOffReason,\n forcedOffAt,\n protectionState,\n powerSwitchAction,\n powerSwitchDesiredOn,\n offJourFaible = false\n} = finalDecision;\n\nreason = Array.isArray(reason) ? [...reason] : [];\n\nconst forcedOffAtSafe = forcedOffAt && parseParisStampToMs(forcedOffAt) !== null ? forcedOffAt : '';\nconst forcedOffReasonSafe = forcedOffByAdvisor ? safeText(forcedOffReason, 255) : '';\nconst pendingUserActionSafe = safeText(pendingUserAction, 255);\n\nif (reason.length === 0) {\n if (recommendedMode === 'boost') reason.push('Boost recommandé');\n else if (recommendedMode === 'auto') reason.push('Mode auto recommandé');\n else if (recommendedMode === 'green') reason.push('Mode green recommandé');\n else reason.push('Chauffe-eau déconseillé pour le moment');\n}\n\nreason.push(`Demande ECS: ${hotWaterDemand}`);\nreason.push(`Conseil final: ${recommendedMode}`);\nreason.push(`Conseil initial: ${baseRecommendedMode}`);\nreason.push(`Couleur conseil: ${adviceColor}`);\nreason.push(`Forecast du jour: ${round1(todayRemainingKwh)} kWh`);\nreason.push(`Production demain: ${round1(tomorrowKwh)} kWh`);\nreason.push(`Classe forecast: ${forecastStrength}`);\nreason.push(`PV actuel: ${Math.round(pvPowerW)} W | PV10: ${Math.round(pvPowerAvg10mW)} W`);\nreason.push(`Charges AC maison: ${Math.round(houseAcPowerW)} W`);\nreason.push(`Surplus net estimé: ${Math.round(netPvHeadroomW)} W`);\nreason.push(`PV couvre maison: ${pvCoversHouseNow ? 'oui' : 'non'} | PV bien démarré: ${pvStartedWell ? 'oui' : 'non'}`);\nreason.push(`Batterie: SOC ${round1(soc)}% | courant ${round1(batteryCurrentA)} A`);\nreason.push(`Réseau: cmd=${gridIsOn ? 'ON' : 'OFF'} | acin=${acInAvailable ? 'OK' : 'KO'} | dispo=${gridAvailable ? 'oui' : 'non'}`);\nreason.push(`Target fin de jour: ${round1(targetSoc)}%`);\nreason.push(`Base load restante: ${round1(baseLoadRemainingKwh)} kWh`);\nreason.push(`Marge sécurité: ${round1(flexibleSafetyMarginKwh)} kWh`);\nreason.push(`Green ${round1(greenEnergyKwh)}kWh → SOC fin ${round1(projectedGreenSoc)}% | marge ${round1(greenBudgetMarginKwh)}kWh`);\nreason.push(`Auto ${round1(autoEnergyKwh)}kWh → SOC fin ${round1(projectedAutoSoc)}% | marge ${round1(autoBudgetMarginKwh)}kWh`);\nreason.push(`Boost ${round1(boostEnergyKwh)}kWh → SOC fin ${round1(projectedBoostSoc)}% | marge ${round1(boostBudgetMarginKwh)}kWh`);\nreason.push(`Régime observé: ${observedRegime} | puissance ≈ ${Math.round(observedPowerW)} W | delta ${sampleIsUsable ? round2(energyDeltaKwh) : 'n/a'} kWh/${sampleIsUsable ? round1(sampleDurationMin) : 'n/a'} min`);\nreason.push(`Prio EVCS: ${evcsPriorityActive ? 'ON' : 'OFF'}${energyPriorityReason ? ` | ${energyPriorityReason}` : ''}`);\nreason.push(`Mismatch: ${mismatchActive ? 'oui' : 'non'} | sévérité=${mismatchSeverity} | durée=${round1(mismatchDurationMin)} min`);\nreason.push(`Protection ECS: ${protectionState}`);\nif (pendingUserActionSafe) reason.push(`Action attendue: ${pendingUserActionSafe}`);\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst signature = [\n now.date,\n `d=${hotWaterDemand}`,\n `m=${recommendedMode}`,\n `base=${baseRecommendedMode}`,\n `c=${adviceColor}`,\n `fs=${strongForecast ? '1' : '0'}`,\n `fm=${mediumForecast ? '1' : '0'}`,\n `pb=${pvStartedWell ? '1' : '0'}`,\n `soc=${round1(soc)}`,\n `pv=${Math.round(pvPowerW)}`,\n `pv10=${Math.round(pvPowerAvg10mW)}`,\n `npv=${Math.round(netPvHeadroomW)}`,\n `obs=${observedRegime}`,\n `prio=${evcsPriorityActive ? '1' : '0'}`,\n `cut=${forcedOffByAdvisor ? '1' : '0'}`\n].join('|');\n\nconst signatureChanged = signature !== lastSignature;\n\nlet notifyWanted = false;\nlet notifyTitle = 'Water Heater Advisor';\nlet notifyMessage = '';\n\nif (notificationsEnabled) {\n if (forceOffNow) {\n notifyWanted = true;\n notifyTitle = 'ECS coupé temporairement';\n notifyMessage = forcedOffReasonSafe || 'Le chauffe-eau a été coupé temporairement';\n } else if (restoreNow) {\n notifyWanted = true;\n notifyTitle = 'ECS remis sous tension';\n notifyMessage = 'Le chauffe-eau est réalimenté car la priorité EVCS est terminée ou le surplus est redevenu suffisant.';\n } else if (firstMismatchRun && pendingUserActionSafe) {\n notifyWanted = true;\n notifyTitle = 'Action utilisateur ECS conseillée';\n notifyMessage = pendingUserActionSafe;\n } else if (signatureChanged) {\n notifyWanted = true;\n notifyTitle = `Water Heater Advisor: ${recommendedMode.toUpperCase()} / ${adviceColor.toUpperCase()}`;\n notifyMessage = `Demande=${hotWaterDemand} | régime=${observedRegime} | SOC fin estimé=${round1(selectedProjectedSoc)}% | marge=${round1(selectedBudgetMarginKwh)} kWh`;\n }\n}\nconst BUDGET_MARGIN_MIN = -50;\nconst BUDGET_MARGIN_MAX = 50;\nconst result = {\n timestamp_paris: now.stamp,\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 today_remaining_kwh: round2(todayRemainingKwh),\n tomorrow_kwh: round2(tomorrowKwh),\n advisor_enabled: advisorEnabled,\n notifications_enabled: notificationsEnabled,\n hot_water_demand: hotWaterDemand,\n green_power_w: greenPowerW,\n auto_power_w: autoPowerW,\n boost_power_w: boostPowerW,\n strong_forecast_kwh: strongForecastKwh,\n medium_forecast_kwh: mediumForecastKwh,\n strong_pv_now_w: strongPvNowW,\n medium_pv_now_w: mediumPvNowW,\n boost_min_soc_strong_forecast_pct: boostMinSocStrongForecastPct,\n boost_min_soc_medium_forecast_pct: boostMinSocMediumForecastPct,\n end_of_day_soc_guard: endOfDaySocGuard,\n end_of_day_soc_target: endOfDaySocTarget,\n base_load_remaining_kwh: baseLoadRemainingKwh,\n flexible_safety_margin_kwh: flexibleSafetyMarginKwh,\n ac_loads_max_safe_w: acLoadsMaxSafeW,\n water_heater_switch: waterHeaterSwitchOn,\n water_heater_energy_total_kwh: round2(waterHeaterEnergyTotalKwh),\n last_signature: lastSignature || null,\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason || null,\n evcs_priority_until: evcsPriorityUntilAt || null\n },\n computed: {\n forecast_strength: forecastStrength,\n strong_forecast: strongForecast,\n medium_forecast: mediumForecast,\n strong_pv_now: strongPvNow,\n medium_pv_now: mediumPvNow,\n net_pv_headroom_w: Math.round(netPvHeadroomW),\n pv_covers_house_now: pvCoversHouseNow,\n pv_started_well: pvStartedWell,\n battery_usable_capacity_kwh_internal: batteryCapacityKwh,\n energy_needed_to_reach_target_kwh: round2(energyNeededToReachTargetKwh),\n green_energy_kwh: round2(greenEnergyKwh),\n auto_energy_kwh: round2(autoEnergyKwh),\n boost_energy_kwh: round2(boostEnergyKwh),\n projected_green_soc: round1(projectedGreenSoc),\n projected_auto_soc: round1(projectedAutoSoc),\n projected_boost_soc: round1(projectedBoostSoc),\n green_budget_margin_kwh: round2(greenBudgetMarginKwh),\n auto_budget_margin_kwh: round2(autoBudgetMarginKwh),\n boost_budget_margin_kwh: round2(boostBudgetMarginKwh),\n ac_safe: acSafe,\n battery_not_draining_hard: batteryNotDrainingHard,\n green_allowed: greenAllowed,\n auto_allowed: autoAllowed,\n boost_allowed_by_budget: boostAllowedByBudget,\n boost_allowed: boostAllowed,\n early_boost_strong_ready: earlyBoostStrongReady,\n early_boost_medium_ready: earlyBoostMediumReady,\n auto_ready: autoReady,\n green_ready: greenReady,\n base_recommended_mode: baseRecommendedMode,\n base_advice_color: baseAdviceColor,\n base_state: baseState,\n observed_power_w: Math.round(observedPowerW),\n observed_regime: observedRegime,\n sample_duration_min: sampleIsUsable ? round1(sampleDurationMin) : null,\n energy_delta_kwh: sampleIsUsable ? round2(energyDeltaKwh) : null,\n mismatch_active: mismatchActive,\n mismatch_severity: mismatchSeverity,\n mismatch_duration_min: round1(mismatchDurationMin),\n evcs_priority_active: evcsPriorityActive,\n protection_state: protectionState,\n real_conflict_now: realConflictNow,\n restore_energy_ok: restoreEnergyOk,\n restore_surplus_w: restoreSurplusW,\n mode_mismatch_delay_min: modeMismatchDelayMin\n },\n decision: {\n recommended_mode: recommendedMode,\n advice_color: adviceColor,\n state,\n legacy_advice_ok: legacyAdviceOk,\n selected_load_kwh: round2(selectedLoadKwh),\n projected_end_of_day_soc: round1(selectedProjectedSoc),\n budget_margin_kwh: round2(selectedBudgetMarginKwh),\n observed_power_w: Math.round(observedPowerW),\n observed_regime: observedRegime,\n mismatch_active: mismatchActive,\n mismatch_severity: mismatchSeverity,\n mismatch_duration_min: round1(mismatchDurationMin),\n pending_user_action: pendingUserActionSafe,\n power_switch_action: powerSwitchAction,\n power_switch_desired_on: powerSwitchDesiredOn,\n force_off_now: forceOffNow,\n restore_now: restoreNow,\n forced_off_by_advisor: forcedOffByAdvisor,\n forced_off_reason: forcedOffReasonSafe,\n forced_off_at: forcedOffByAdvisor ? forcedOffAtSafe || null : null,\n signature,\n signature_changed: signatureChanged,\n should_notify: notifyWanted,\n reason,\n short_reason: shortReason\n },\n ha_service_calls: {\n helper_recommended_mode: {\n domain: 'input_select',\n service: 'select_option',\n data: {\n entity_id: 'input_select.victron_water_heater_recommended_mode',\n option: recommendedMode\n }\n },\n helper_advice_color: {\n domain: 'input_select',\n service: 'select_option',\n data: {\n entity_id: 'input_select.victron_water_heater_advice_color',\n option: adviceColor\n }\n },\n helper_state: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_advice_state',\n value: state\n }\n },\n helper_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_advice_reason',\n value: shortReason || 'Aucune raison'\n }\n },\n helper_signature: {\n execute: signatureChanged,\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_advice_last_signature',\n value: signature\n }\n },\n helper_projected_soc: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_water_heater_projected_end_of_day_soc',\n value: round1(selectedProjectedSoc)\n }\n },\nhelper_budget_margin: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_water_heater_budget_margin_kwh',\n value: Math.max(BUDGET_MARGIN_MIN, Math.min(BUDGET_MARGIN_MAX, round2(selectedBudgetMarginKwh)))\n }\n},\n helper_load_kwh: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_load_water_heater_kwh',\n value: round2(selectedLoadKwh)\n }\n },\n helper_legacy_advice_ok: {\n domain: 'input_boolean',\n service: boolService(legacyAdviceOk),\n data: {\n entity_id: 'input_boolean.victron_advice_water_heater_ok'\n }\n },\n helper_green_allowed: {\n domain: 'input_boolean',\n service: boolService(greenAllowed),\n data: {\n entity_id: 'input_boolean.victron_water_heater_green_allowed'\n }\n },\n helper_auto_allowed: {\n domain: 'input_boolean',\n service: boolService(autoAllowed),\n data: {\n entity_id: 'input_boolean.victron_water_heater_auto_allowed'\n }\n },\n helper_boost_allowed: {\n domain: 'input_boolean',\n service: boolService(boostAllowed),\n data: {\n entity_id: 'input_boolean.victron_water_heater_boost_allowed'\n }\n },\n helper_observed_power: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_water_heater_observed_power_w',\n value: Math.round(observedPowerW)\n }\n },\n helper_observed_regime: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_observed_regime',\n value: observedRegime\n }\n },\n helper_pending_user_action: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_pending_user_action',\n value: pendingUserActionSafe\n }\n },\n helper_mismatch_since: {\n execute: mismatchActive && !!mismatchSinceAtEffective,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_water_heater_mode_mismatch_since',\n datetime: mismatchSinceAtEffective\n }\n },\n helper_energy_prev_kwh: {\n domain: 'input_number',\n service: 'set_value',\n data: {\n entity_id: 'input_number.victron_water_heater_energy_total_prev_kwh',\n value: round2(waterHeaterEnergyTotalKwh)\n }\n },\n helper_energy_prev_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_water_heater_energy_prev_at',\n datetime: now.stamp\n }\n },\n helper_forced_off_by_advisor: {\n domain: 'input_boolean',\n service: boolService(forcedOffByAdvisor),\n data: {\n entity_id: 'input_boolean.victron_water_heater_forced_off_by_advisor'\n }\n },\n helper_forced_off_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_water_heater_forced_off_reason',\n value: forcedOffReasonSafe\n }\n },\n helper_forced_off_at: {\n execute: forceOffNow && !!forcedOffAtSafe,\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_water_heater_forced_off_at',\n datetime: forcedOffAtSafe\n }\n },\n // Jour faible (07/10/2026) : la coupure appartient a la regle « eau chaude en HC de nuit ».\n helper_off_jour_faible: {\n domain: 'input_boolean',\n service: boolService(offJourFaible),\n data: {\n entity_id: 'input_boolean.victron_water_heater_off_jour_faible'\n }\n },\n water_heater_power_switch: {\n execute: powerSwitchAction !== 'none',\n domain: 'switch',\n service: powerSwitchDesiredOn ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.tt_chauffe_eau_pleumeur_switch'\n }\n },\n logbook: {\n execute: signatureChanged || forceOffNow || restoreNow,\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Water Heater Advisor',\n message: `${recommendedMode}/${adviceColor} | regime=${observedRegime} | prioEVCS=${evcsPriorityActive ? 'on' : 'off'} | cut=${forcedOffByAdvisor ? 'on' : 'off'} | ${shortReason}`,\n entity_id: 'switch.tt_chauffe_eau_pleumeur_switch'\n }\n },\n mobile_notification: {\n execute: 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 }];" }, "id": "481de0f8-22a8-4aae-b47d-493098383c6c", "name": "ECS Output Builder",