Files
EVCS/modified/GARAGE_backup_20260709-234608.json

1 line
59 KiB
JSON

{"updatedAt":"2026-07-09T21:45:53.542Z","createdAt":"2026-03-15T01:03:27.823Z","id":"FtPVF7byhtu4o6Oh","name":"Victron - Garage Thermal Surplus Manager V2.2","description":null,"active":true,"isArchived":false,"nodes":[{"parameters":{"rule":{"interval":[{"field":"minutes","minutesInterval":10}]}},"type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.3,"position":[0,0],"id":"e443b81a-4d13-4de5-b3c2-a2af4951b134","name":"Schedule Trigger"},{"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":"fd527f0f-757b-421e-bb87-2bf06c16467d","name":"Execute HA Service Garage Thermal","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[896,0],"alwaysOutputData":true,"credentials":{"httpBearerAuth":{"id":"uv3sknYYigxBkaVN","name":"Bearer Auth account 2"}}},{"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 \"today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n\n \"garage_temperature_c\": states(\"sensor.garage_t_temperature\"),\n \"garage_switch\": states(\"switch.clim_hygro_socket_1\"),\n\n \"auto_enabled\": states(\"input_boolean.garage_clim_surplus_auto_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.garage_clim_surplus_notifications\"),\n \"manual_adopt_enabled\": states(\"input_boolean.garage_clim_manual_adopt_enabled\"),\n \"latch\": states(\"input_boolean.garage_clim_latch\"),\n\n \"temp_min_c\": states(\"input_number.garage_temp_min_c\"),\n \"temp_max_c\": states(\"input_number.garage_temp_max_c\"),\n \"soc_on\": states(\"input_number.garage_clim_soc_on\"),\n \"soc_off\": states(\"input_number.garage_clim_soc_off\"),\n \"pv_on_w\": states(\"input_number.garage_clim_pv_on_w\"),\n \"pv_off_w\": states(\"input_number.garage_clim_pv_off_w\"),\n \"surplus_min_kwh\": states(\"input_number.garage_clim_surplus_min_kwh\"),\n \"min_on_minutes\": states(\"input_number.garage_clim_min_on_minutes\"),\n \"min_off_minutes\": states(\"input_number.garage_clim_min_off_minutes\"),\n \"battery_discharge_stop_a\": states(\"input_number.garage_clim_battery_discharge_stop_a\"),\n \"house_ac_max_w\": states(\"input_number.garage_clim_house_ac_max_w\"),\n\n \"start_time\": states(\"input_datetime.garage_clim_start_time\"),\n \"stop_time\": states(\"input_datetime.garage_clim_stop_time\"),\n\n \"last_on_at\": states(\"input_datetime.garage_clim_last_on_at\"),\n \"last_off_at\": states(\"input_datetime.garage_clim_last_off_at\"),\n\n \"last_signature\": states(\"input_text.garage_clim_last_signature\"),\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} | tojson }}\n"}]},"options":{}},"id":"6756e5ad-7edc-42d4-a81f-7238fd44f8bb","name":"HA Snapshot Garage Thermal V1.2","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[224,0],"credentials":{"httpBearerAuth":{"id":"uv3sknYYigxBkaVN","name":"Bearer Auth account 2"}}},{"parameters":{"jsCode":"function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction parseMaybeJson(v) {\n if (typeof v !== 'string') return v;\n try {\n return JSON.parse(v);\n } catch {\n return v;\n }\n}\n\nfunction extractSnapshot(input) {\n if (typeof input === 'string') return parseMaybeJson(input);\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\n if (input?.data && typeof input.data === 'object') return input.data;\n if (input?.body && typeof input.body === 'object') return input.body;\n return input;\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction 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 date,\n stamp,\n hour,\n minute: Number(parts.minute),\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 minutesSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 60000);\n}\n\nconst cfg = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt'\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;\nconst todayRemainingKwh = Math.max(0, num(input.today_remaining_kwh, 0));\n\nconst garageTempC = num(input.garage_temperature_c, NaN);\nconst switchIsOn = low(input.garage_switch || 'off') === 'on';\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';\nconst latchIsOn = low(input.latch || 'off') === 'on';\n\nconst tempMinC = num(input.temp_min_c, 22);\nconst tempMaxC = num(input.temp_max_c, 26);\nconst socOn = clamp(num(input.soc_on, 85), 0, 100);\nconst socOff = clamp(num(input.soc_off, 80), 0, 100);\nconst pvOnW = Math.max(0, num(input.pv_on_w, 2500));\nconst pvOffW = Math.max(0, num(input.pv_off_w, 1200));\nconst surplusMinKwh = Math.max(0, num(input.surplus_min_kwh, 2));\nconst minOnMinutes = Math.max(0, num(input.min_on_minutes, 10));\nconst minOffMinutes = Math.max(0, num(input.min_off_minutes, 10));\nconst batteryDischargeStopA = num(input.battery_discharge_stop_a, -8);\nconst houseAcMaxW = Math.max(0, num(input.house_ac_max_w, 6500));\n\nconst startMin = parseTimeToMinutes(input.start_time, 10 * 60);\nconst stopMin = parseTimeToMinutes(input.stop_time, 17 * 60 + 30);\nconst inWindow =\n startMin < stopMin\n ? (now.minNow >= startMin && now.minNow < stopMin)\n : (now.minNow >= startMin || now.minNow < stopMin);\n\nconst lastOnAt = (input.last_on_at ?? '').toString().trim();\nconst lastOffAt = (input.last_off_at ?? '').toString().trim();\nconst lastSignature = (input.last_signature ?? '').toString().trim();\n\nconst lastOnMs = parseParisStampToMs(lastOnAt);\nconst lastOffMs = parseParisStampToMs(lastOffAt);\n\nconst minutesSinceOn = minutesSince(lastOnMs, nowMs);\nconst minutesSinceOff = minutesSince(lastOffMs, nowMs);\n\nconst minOnElapsed = minutesSinceOn === null ? true : minutesSinceOn >= minOnMinutes;\nconst minOffElapsed = minutesSinceOff === null ? true : minutesSinceOff >= minOffMinutes;\n\n// ======================================================\n// PRIORITE EVCS\n// ======================================================\nconst evcsPriorityActiveRaw = low(input.evcs_priority_active || 'off') === 'on';\nconst energyPriorityMode = (input.energy_priority_mode ?? 'normal').toString().trim().toLowerCase();\nconst energyPriorityReason = (input.energy_priority_reason ?? '').toString().trim();\nconst evcsPriorityUntilAt = (input.evcs_priority_until ?? '').toString().trim();\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\n// ======================================================\n// BESOIN THERMIQUE\n// ======================================================\nlet thermalNeed = false;\nlet thermalState = 'comfort_ok';\n\nif (Number.isFinite(garageTempC)) {\n if (garageTempC < tempMinC) {\n thermalNeed = true;\n thermalState = 'heat_needed';\n } else if (garageTempC > tempMaxC) {\n thermalNeed = true;\n thermalState = 'cool_needed';\n }\n}\n\n// ======================================================\n// CONDITIONS AUTO\n// ======================================================\nconst startConditionsOk =\n inWindow &&\n !evcsPriorityActive &&\n thermalNeed &&\n soc >= socOn &&\n (pvPowerW >= pvOnW || pvPowerAvg10mW >= pvOnW) &&\n todayRemainingKwh >= surplusMinKwh &&\n batteryCurrentA >= batteryDischargeStopA &&\n houseAcPowerW <= houseAcMaxW;\n\nconst holdConditionsOk =\n inWindow &&\n !evcsPriorityActive &&\n thermalNeed &&\n soc >= socOff &&\n (pvPowerW >= pvOffW || pvPowerAvg10mW >= pvOffW) &&\n batteryCurrentA >= batteryDischargeStopA &&\n houseAcPowerW <= houseAcMaxW;\n\n// ======================================================\n// DECISION\n// ======================================================\nlet desiredOn = false;\nlet desiredLatch = false;\nlet state = 'idle';\nlet reason = [];\n\nlet adoptManual = false;\nlet notifyWanted = false;\nlet notifyTitle = 'Garage Thermal Manager';\nlet notifyMessage = '';\n\n// 1) Automatisation désactivée\nif (!autoEnabled) {\n desiredOn = switchIsOn;\n desiredLatch = false;\n\n if (switchIsOn) {\n state = 'manual_on_automation_disabled';\n reason.push(\"Clim garage ON alors que l'automatisation garage est désactivée\");\n } else {\n state = 'automation_disabled';\n reason.push(\"Automatisation garage désactivée\");\n }\n}\n\n// 1.b) Priorité EVCS active : le garage/clim doit s'effacer\nelse if (evcsPriorityActive) {\n if (switchIsOn && latchIsOn && !minOnElapsed) {\n desiredOn = true;\n desiredLatch = true;\n state = 'evcs_priority_hold_min_on';\n reason.push(`Garage maintenu temporairement jusqu'au mini ON malgré priorité EVCS (${energyPriorityReason || 'raison non précisée'})`);\n }\n else if (switchIsOn && latchIsOn && minOnElapsed) {\n desiredOn = false;\n desiredLatch = false;\n state = 'evcs_priority_stop';\n reason.push(`Garage coupé : priorité EVCS active (${energyPriorityReason || 'raison non précisée'})`);\n }\n else if (switchIsOn && !latchIsOn) {\n desiredOn = true;\n desiredLatch = false;\n state = 'manual_on_not_adopted_evcs_priority';\n reason.push(`Garage ON manuellement non adopté : priorité EVCS active (${energyPriorityReason || 'raison non précisée'})`);\n }\n else {\n desiredOn = false;\n desiredLatch = false;\n state = 'blocked_by_evcs_priority';\n reason.push(`Démarrage garage bloqué : priorité EVCS active (${energyPriorityReason || 'raison non précisée'})`);\n }\n}\n\n// 2) Switch ON mais pas latché : cas manuel / non pris en charge\nelse if (switchIsOn && !latchIsOn) {\n // 2.a hors fenêtre : manuel probable\n if (!inWindow) {\n desiredOn = true;\n desiredLatch = false;\n state = 'manual_on_outside_window';\n reason.push(\"Clim garage ON mais non maintenue par l'automate (manuel probable : latch OFF, hors fenêtre)\");\n }\n\n // 2.b dans fenêtre + conditions OK + adoption autorisée\n else if (manualAdoptEnabled && startConditionsOk) {\n desiredOn = true;\n desiredLatch = true;\n adoptManual = true;\n state = 'manual_on_adopted';\n reason.push(\"Clim garage ON manuellement puis adoptée par l'automate\");\n }\n\n // 2.c dans fenêtre mais pas adoptée\n else {\n desiredOn = true;\n desiredLatch = false;\n state = 'manual_on_not_adopted';\n reason.push(\"Clim garage ON mais non adoptée par l'automate (conditions insuffisantes ou adoption refusée)\");\n }\n}\n\n// 3) Latch actif : automate en charge\nelse if (latchIsOn) {\n if (!inWindow) {\n if (!minOnElapsed) {\n desiredOn = true;\n desiredLatch = true;\n state = 'hold_min_on_outside_window';\n reason.push(\"Hors fenêtre, mais maintien jusqu'au temps mini ON\");\n } else {\n desiredOn = false;\n desiredLatch = false;\n state = 'outside_window_release';\n reason.push(\"Hors fenêtre : l'automate relâche la clim garage\");\n }\n } else if (holdConditionsOk) {\n desiredOn = true;\n desiredLatch = true;\n state = 'auto_running';\n reason.push(\"Clim garage maintenue par l'automate\");\n } else if (!minOnElapsed) {\n desiredOn = true;\n desiredLatch = true;\n state = 'hold_min_on';\n reason.push(\"Conditions perdues, mais maintien jusqu'au temps mini ON\");\n } else {\n desiredOn = false;\n desiredLatch = false;\n state = 'auto_stop_conditions_lost';\n reason.push(\"Conditions garage perdues : arrêt automatique\");\n }\n}\n\n// 4) Pas ON, pas latché : démarrage auto éventuel\nelse {\n if (!inWindow) {\n desiredOn = false;\n desiredLatch = false;\n state = 'outside_window';\n reason.push(\"Hors fenêtre horaire garage\");\n } else if (!thermalNeed) {\n desiredOn = false;\n desiredLatch = false;\n state = 'idle';\n reason.push(\"Aucun besoin thermique garage\");\n } else if (!minOffElapsed) {\n desiredOn = false;\n desiredLatch = false;\n state = 'wait_min_off';\n reason.push(\"Temps mini OFF garage non écoulé\");\n } else if (startConditionsOk) {\n desiredOn = true;\n desiredLatch = true;\n state = 'auto_start';\n reason.push(\"Démarrage automatique garage\");\n } else {\n desiredOn = false;\n desiredLatch = false;\n state = 'conditions_not_met';\n reason.push(\"Besoin thermique détecté mais conditions énergétiques insuffisantes\");\n }\n}\n\n// ======================================================\n// ACTIONS\n// ======================================================\nconst switchAction =\n desiredOn === switchIsOn\n ? 'none'\n : desiredOn ? 'turn_on' : 'turn_off';\n\nconst latchAction =\n desiredLatch === latchIsOn\n ? 'none'\n : desiredLatch ? 'turn_on' : 'turn_off';\n\nconst setLastOnAt = switchAction === 'turn_on' || adoptManual;\nconst setLastOffAt = switchAction === 'turn_off';\n\n// ======================================================\n// REASONS DETAIL\n// ======================================================\nreason.push(`Temp garage ${Number.isFinite(garageTempC) ? round1(garageTempC) + '°C' : 'n/a'} | besoin=${thermalState}`);\nreason.push(`Fenêtre ${inWindow ? 'OK' : 'hors fenêtre'} (${input.start_time || '10:00:00'} → ${input.stop_time || '17:30:00'})`);\nreason.push(`Switch=${switchIsOn ? 'ON' : 'OFF'} | Latch=${latchIsOn ? 'ON' : 'OFF'} | Adoption manuelle=${manualAdoptEnabled ? 'ON' : 'OFF'}`);\nreason.push(`SOC ${round1(soc)}% (start>=${round1(socOn)} / hold>=${round1(socOff)})`);\nreason.push(`PV ${Math.round(pvPowerW)}W | PV10 ${Math.round(pvPowerAvg10mW)}W (start>=${Math.round(pvOnW)} / hold>=${Math.round(pvOffW)})`);\nreason.push(`Prod restante ${round1(todayRemainingKwh)}kWh (mini ${round1(surplusMinKwh)}kWh)`);\nreason.push(`Batterie ${round1(batteryCurrentA)}A (stop si < ${round1(batteryDischargeStopA)}A)`);\nreason.push(`AC maison ${Math.round(houseAcPowerW)}W / max ${Math.round(houseAcMaxW)}W`);\nreason.push(`Grid ${gridIsOn ? 'ON' : 'OFF'} | AC-in ${acInAvailable ? 'OK' : 'KO'} | dispo=${gridAvailable ? 'oui' : 'non'} | ${Math.round(gridPowerW)}W`);\nreason.push(`Prio EVCS ${evcsPriorityActive ? 'ON' : 'OFF'} | mode=${energyPriorityMode}${energyPriorityReason ? ` | ${energyPriorityReason}` : ''}`);\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst signature = [\n now.date,\n state,\n thermalState,\n `sw=${switchIsOn ? '1' : '0'}`,\n `lt=${latchIsOn ? '1' : '0'}`,\n `pv=${Math.round(pvPowerW)}`,\n `soc=${round1(soc)}`,\n `evcs_prio=${evcsPriorityActive ? '1' : '0'}`\n].join('|');\n\nconst signatureChanged = signature !== lastSignature;\n\n// ======================================================\n// NOTIFS\n// ======================================================\nif (notificationsEnabled && signatureChanged) {\n if (state === 'manual_on_outside_window') {\n notifyWanted = true;\n notifyTitle = 'Garage : ON manuel hors fenêtre';\n notifyMessage = \"La clim/hygro garage est ON mais non maintenue par l'automate (latch OFF).\";\n } else if (state === 'manual_on_adopted') {\n notifyWanted = true;\n notifyTitle = 'Garage : ON manuel adopté';\n notifyMessage = \"La clim/hygro garage a été adoptée par l'automate.\";\n } else if (state === 'auto_start') {\n notifyWanted = true;\n notifyTitle = 'Garage : démarrage auto';\n notifyMessage = `Temp ${Number.isFinite(garageTempC) ? round1(garageTempC) : 'n/a'}°C | PV ${Math.round(pvPowerW)}W | SOC ${round1(soc)}%.`;\n } else if (state === 'auto_stop_conditions_lost' || state === 'outside_window_release') {\n notifyWanted = true;\n notifyTitle = 'Garage : arrêt auto';\n notifyMessage = shortReason;\n } else if (state === 'evcs_priority_stop') {\n notifyWanted = true;\n notifyTitle = 'Garage : arrêt priorité EVCS';\n notifyMessage = shortReason;\n }\n}\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 today_remaining_kwh: todayRemainingKwh,\n\n garage_temperature_c: Number.isFinite(garageTempC) ? round1(garageTempC) : null,\n garage_switch: switchIsOn,\n\n auto_enabled: autoEnabled,\n notifications_enabled: notificationsEnabled,\n manual_adopt_enabled: manualAdoptEnabled,\n latch: latchIsOn,\n\n temp_min_c: tempMinC,\n temp_max_c: tempMaxC,\n soc_on: socOn,\n soc_off: socOff,\n pv_on_w: pvOnW,\n pv_off_w: pvOffW,\n surplus_min_kwh: surplusMinKwh,\n min_on_minutes: minOnMinutes,\n min_off_minutes: minOffMinutes,\n battery_discharge_stop_a: batteryDischargeStopA,\n house_ac_max_w: houseAcMaxW,\n\n start_time: input.start_time || null,\n stop_time: input.stop_time || null,\n last_on_at: lastOnAt || null,\n last_off_at: lastOffAt || null,\n last_signature: lastSignature || 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\n computed: {\n thermal_need: thermalNeed,\n thermal_state: thermalState,\n in_window: inWindow,\n start_conditions_ok: startConditionsOk,\n hold_conditions_ok: holdConditionsOk,\n minutes_since_on: minutesSinceOn !== null ? round1(minutesSinceOn) : null,\n minutes_since_off: minutesSinceOff !== null ? round1(minutesSinceOff) : null,\n min_on_elapsed: minOnElapsed,\n min_off_elapsed: minOffElapsed,\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason || null,\n grid_available: gridAvailable\n },\n\n decision: {\n state,\n desired_on: desiredOn,\n desired_latch: desiredLatch,\n switch_action: switchAction,\n latch_action: latchAction,\n adopt_manual: adoptManual,\n set_last_on_at: setLastOnAt,\n set_last_off_at: setLastOffAt,\n signature,\n signature_changed: signatureChanged,\n should_notify: notifyWanted,\n reason,\n short_reason: shortReason\n },\n\n ha_service_calls: {\n helper_state: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.garage_clim_state',\n value: state\n }\n },\n\n helper_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.garage_clim_reason',\n value: shortReason || 'Aucune raison'\n }\n },\n\n helper_signature: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.garage_clim_last_signature',\n value: signature\n }\n },\n\n helper_latch: {\n domain: 'input_boolean',\n service: desiredLatch ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.garage_clim_latch'\n }\n },\n\n garage_switch: {\n domain: 'switch',\n service: desiredOn ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.clim_hygro_socket_1'\n }\n },\n\n helper_last_on_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.garage_clim_last_on_at',\n datetime: now.stamp\n }\n },\n\n helper_last_off_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.garage_clim_last_off_at',\n datetime: now.stamp\n }\n },\n\n logbook: {\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Garage Thermal Manager',\n message: `${state} | switch=${desiredOn ? 'on' : 'off'} | latch=${desiredLatch ? 'on' : 'off'} | ${shortReason}`,\n entity_id: 'switch.clim_hygro_socket_1'\n }\n },\n\n mobile_notification: {\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":"95b9baf2-b49b-4442-bd25-99a8ef4f2e37","name":"Compute Garage Thermal Advice V1.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 À JOUR\n// =====================================================\npushAction('helper_state', calls.helper_state, true);\npushAction('helper_reason', calls.helper_reason, true);\n\n// =====================================================\n// ARRET : SWITCH OFF D'ABORD, PUIS LATCH OFF\n// =====================================================\nif (decision.switch_action === 'turn_off') {\n pushAction('garage_switch', calls.garage_switch, true);\n}\n\nif (decision.latch_action === 'turn_off') {\n pushAction('helper_latch', calls.helper_latch, true);\n}\n\n// =====================================================\n// DEMARRAGE / ADOPTION : LATCH ON D'ABORD, PUIS SWITCH ON\n// =====================================================\nif (decision.latch_action === 'turn_on') {\n pushAction('helper_latch', calls.helper_latch, true);\n}\n\nif (decision.switch_action === 'turn_on') {\n pushAction('garage_switch', calls.garage_switch, true);\n}\n\n// =====================================================\n// TIMESTAMPS\n// =====================================================\npushAction('helper_last_on_at', calls.helper_last_on_at, !!decision.set_last_on_at);\npushAction('helper_last_off_at', calls.helper_last_off_at, !!decision.set_last_off_at);\n\n// =====================================================\n// SIGNATURE + LOGBOOK SI CHANGEMENT\n// =====================================================\npushAction('helper_signature', calls.helper_signature, !!decision.signature_changed);\npushAction('logbook', calls.logbook, !!decision.signature_changed);\n\n// =====================================================\n// NOTIFICATION SI DEMANDEE\n// =====================================================\npushAction('mobile_notification', calls.mobile_notification, !!decision.should_notify);\n\nreturn actions;\n"},"type":"n8n-nodes-base.code","typeVersion":2,"position":[672,0],"id":"fdcf2b8a-15d9-46fb-95ec-c90448fbf3ac","name":"Build Garage Thermal V1.2 Actions"}],"connections":{"Schedule Trigger":{"main":[[{"node":"HA Snapshot Garage Thermal V1.2","type":"main","index":0}]]},"HA Snapshot Garage Thermal V1.2":{"main":[[{"node":"Compute Garage Thermal Advice V1.2","type":"main","index":0}]]},"Compute Garage Thermal Advice V1.2":{"main":[[{"node":"Build Garage Thermal V1.2 Actions","type":"main","index":0}]]},"Build Garage Thermal V1.2 Actions":{"main":[[{"node":"Execute HA Service Garage Thermal","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":"51eac7c3-c307-43d5-944f-fc7cc9aaeb8f","activeVersionId":"51eac7c3-c307-43d5-944f-fc7cc9aaeb8f","versionCounter":74,"triggerCount":1,"sourceWorkflowId":null,"shared":[{"updatedAt":"2026-03-15T01:03:27.827Z","createdAt":"2026-03-15T01:03:27.827Z","role":"workflow:owner","workflowId":"FtPVF7byhtu4o6Oh","projectId":"1q2yd8IyTc8fb25V","project":{"updatedAt":"2026-02-06T16:27:24.469Z","createdAt":"2026-02-06T16:23:40.444Z","id":"1q2yd8IyTc8fb25V","name":"thierry tallagrand <tallagrand.thierry@gmail.com>","type":"personal","icon":null,"description":null,"customTelemetryTags":[],"creatorId":"8b9e9552-cd74-4863-82a3-174b3b3befa2"}}],"tags":[],"activeVersion":{"updatedAt":"2026-06-12T21:14:01.000Z","createdAt":"2026-04-14T15:19:09.186Z","versionId":"51eac7c3-c307-43d5-944f-fc7cc9aaeb8f","workflowId":"FtPVF7byhtu4o6Oh","nodes":[{"parameters":{"rule":{"interval":[{"field":"minutes","minutesInterval":10}]}},"type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.3,"position":[0,0],"id":"e443b81a-4d13-4de5-b3c2-a2af4951b134","name":"Schedule Trigger"},{"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":"fd527f0f-757b-421e-bb87-2bf06c16467d","name":"Execute HA Service Garage Thermal","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[896,0],"alwaysOutputData":true,"credentials":{"httpBearerAuth":{"id":"uv3sknYYigxBkaVN","name":"Bearer Auth account 2"}}},{"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 \"today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n\n \"garage_temperature_c\": states(\"sensor.garage_t_temperature\"),\n \"garage_switch\": states(\"switch.clim_hygro_socket_1\"),\n\n \"auto_enabled\": states(\"input_boolean.garage_clim_surplus_auto_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.garage_clim_surplus_notifications\"),\n \"manual_adopt_enabled\": states(\"input_boolean.garage_clim_manual_adopt_enabled\"),\n \"latch\": states(\"input_boolean.garage_clim_latch\"),\n\n \"temp_min_c\": states(\"input_number.garage_temp_min_c\"),\n \"temp_max_c\": states(\"input_number.garage_temp_max_c\"),\n \"soc_on\": states(\"input_number.garage_clim_soc_on\"),\n \"soc_off\": states(\"input_number.garage_clim_soc_off\"),\n \"pv_on_w\": states(\"input_number.garage_clim_pv_on_w\"),\n \"pv_off_w\": states(\"input_number.garage_clim_pv_off_w\"),\n \"surplus_min_kwh\": states(\"input_number.garage_clim_surplus_min_kwh\"),\n \"min_on_minutes\": states(\"input_number.garage_clim_min_on_minutes\"),\n \"min_off_minutes\": states(\"input_number.garage_clim_min_off_minutes\"),\n \"battery_discharge_stop_a\": states(\"input_number.garage_clim_battery_discharge_stop_a\"),\n \"house_ac_max_w\": states(\"input_number.garage_clim_house_ac_max_w\"),\n\n \"start_time\": states(\"input_datetime.garage_clim_start_time\"),\n \"stop_time\": states(\"input_datetime.garage_clim_stop_time\"),\n\n \"last_on_at\": states(\"input_datetime.garage_clim_last_on_at\"),\n \"last_off_at\": states(\"input_datetime.garage_clim_last_off_at\"),\n\n \"last_signature\": states(\"input_text.garage_clim_last_signature\"),\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} | tojson }}\n"}]},"options":{}},"id":"6756e5ad-7edc-42d4-a81f-7238fd44f8bb","name":"HA Snapshot Garage Thermal V1.2","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[224,0],"credentials":{"httpBearerAuth":{"id":"uv3sknYYigxBkaVN","name":"Bearer Auth account 2"}}},{"parameters":{"jsCode":"function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction parseMaybeJson(v) {\n if (typeof v !== 'string') return v;\n try {\n return JSON.parse(v);\n } catch {\n return v;\n }\n}\n\nfunction extractSnapshot(input) {\n if (typeof input === 'string') return parseMaybeJson(input);\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\n if (input?.data && typeof input.data === 'object') return input.data;\n if (input?.body && typeof input.body === 'object') return input.body;\n return input;\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction 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 date,\n stamp,\n hour,\n minute: Number(parts.minute),\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 minutesSince(ts, nowMs) {\n if (ts === null || nowMs === null) return null;\n return Math.max(0, (nowMs - ts) / 60000);\n}\n\nconst cfg = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt'\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;\nconst todayRemainingKwh = Math.max(0, num(input.today_remaining_kwh, 0));\n\nconst garageTempC = num(input.garage_temperature_c, NaN);\nconst switchIsOn = low(input.garage_switch || 'off') === 'on';\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';\nconst latchIsOn = low(input.latch || 'off') === 'on';\n\nconst tempMinC = num(input.temp_min_c, 22);\nconst tempMaxC = num(input.temp_max_c, 26);\nconst socOn = clamp(num(input.soc_on, 85), 0, 100);\nconst socOff = clamp(num(input.soc_off, 80), 0, 100);\nconst pvOnW = Math.max(0, num(input.pv_on_w, 2500));\nconst pvOffW = Math.max(0, num(input.pv_off_w, 1200));\nconst surplusMinKwh = Math.max(0, num(input.surplus_min_kwh, 2));\nconst minOnMinutes = Math.max(0, num(input.min_on_minutes, 10));\nconst minOffMinutes = Math.max(0, num(input.min_off_minutes, 10));\nconst batteryDischargeStopA = num(input.battery_discharge_stop_a, -8);\nconst houseAcMaxW = Math.max(0, num(input.house_ac_max_w, 6500));\n\nconst startMin = parseTimeToMinutes(input.start_time, 10 * 60);\nconst stopMin = parseTimeToMinutes(input.stop_time, 17 * 60 + 30);\nconst inWindow =\n startMin < stopMin\n ? (now.minNow >= startMin && now.minNow < stopMin)\n : (now.minNow >= startMin || now.minNow < stopMin);\n\nconst lastOnAt = (input.last_on_at ?? '').toString().trim();\nconst lastOffAt = (input.last_off_at ?? '').toString().trim();\nconst lastSignature = (input.last_signature ?? '').toString().trim();\n\nconst lastOnMs = parseParisStampToMs(lastOnAt);\nconst lastOffMs = parseParisStampToMs(lastOffAt);\n\nconst minutesSinceOn = minutesSince(lastOnMs, nowMs);\nconst minutesSinceOff = minutesSince(lastOffMs, nowMs);\n\nconst minOnElapsed = minutesSinceOn === null ? true : minutesSinceOn >= minOnMinutes;\nconst minOffElapsed = minutesSinceOff === null ? true : minutesSinceOff >= minOffMinutes;\n\n// ======================================================\n// PRIORITE EVCS\n// ======================================================\nconst evcsPriorityActiveRaw = low(input.evcs_priority_active || 'off') === 'on';\nconst energyPriorityMode = (input.energy_priority_mode ?? 'normal').toString().trim().toLowerCase();\nconst energyPriorityReason = (input.energy_priority_reason ?? '').toString().trim();\nconst evcsPriorityUntilAt = (input.evcs_priority_until ?? '').toString().trim();\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\n// ======================================================\n// BESOIN THERMIQUE\n// ======================================================\nlet thermalNeed = false;\nlet thermalState = 'comfort_ok';\n\nif (Number.isFinite(garageTempC)) {\n if (garageTempC < tempMinC) {\n thermalNeed = true;\n thermalState = 'heat_needed';\n } else if (garageTempC > tempMaxC) {\n thermalNeed = true;\n thermalState = 'cool_needed';\n }\n}\n\n// ======================================================\n// CONDITIONS AUTO\n// ======================================================\nconst startConditionsOk =\n inWindow &&\n !evcsPriorityActive &&\n thermalNeed &&\n soc >= socOn &&\n (pvPowerW >= pvOnW || pvPowerAvg10mW >= pvOnW) &&\n todayRemainingKwh >= surplusMinKwh &&\n batteryCurrentA >= batteryDischargeStopA &&\n houseAcPowerW <= houseAcMaxW;\n\nconst holdConditionsOk =\n inWindow &&\n !evcsPriorityActive &&\n thermalNeed &&\n soc >= socOff &&\n (pvPowerW >= pvOffW || pvPowerAvg10mW >= pvOffW) &&\n batteryCurrentA >= batteryDischargeStopA &&\n houseAcPowerW <= houseAcMaxW;\n\n// ======================================================\n// DECISION\n// ======================================================\nlet desiredOn = false;\nlet desiredLatch = false;\nlet state = 'idle';\nlet reason = [];\n\nlet adoptManual = false;\nlet notifyWanted = false;\nlet notifyTitle = 'Garage Thermal Manager';\nlet notifyMessage = '';\n\n// 1) Automatisation désactivée\nif (!autoEnabled) {\n desiredOn = switchIsOn;\n desiredLatch = false;\n\n if (switchIsOn) {\n state = 'manual_on_automation_disabled';\n reason.push(\"Clim garage ON alors que l'automatisation garage est désactivée\");\n } else {\n state = 'automation_disabled';\n reason.push(\"Automatisation garage désactivée\");\n }\n}\n\n// 1.b) Priorité EVCS active : le garage/clim doit s'effacer\nelse if (evcsPriorityActive) {\n if (switchIsOn && latchIsOn && !minOnElapsed) {\n desiredOn = true;\n desiredLatch = true;\n state = 'evcs_priority_hold_min_on';\n reason.push(`Garage maintenu temporairement jusqu'au mini ON malgré priorité EVCS (${energyPriorityReason || 'raison non précisée'})`);\n }\n else if (switchIsOn && latchIsOn && minOnElapsed) {\n desiredOn = false;\n desiredLatch = false;\n state = 'evcs_priority_stop';\n reason.push(`Garage coupé : priorité EVCS active (${energyPriorityReason || 'raison non précisée'})`);\n }\n else if (switchIsOn && !latchIsOn) {\n desiredOn = true;\n desiredLatch = false;\n state = 'manual_on_not_adopted_evcs_priority';\n reason.push(`Garage ON manuellement non adopté : priorité EVCS active (${energyPriorityReason || 'raison non précisée'})`);\n }\n else {\n desiredOn = false;\n desiredLatch = false;\n state = 'blocked_by_evcs_priority';\n reason.push(`Démarrage garage bloqué : priorité EVCS active (${energyPriorityReason || 'raison non précisée'})`);\n }\n}\n\n// 2) Switch ON mais pas latché : cas manuel / non pris en charge\nelse if (switchIsOn && !latchIsOn) {\n // 2.a hors fenêtre : manuel probable\n if (!inWindow) {\n desiredOn = true;\n desiredLatch = false;\n state = 'manual_on_outside_window';\n reason.push(\"Clim garage ON mais non maintenue par l'automate (manuel probable : latch OFF, hors fenêtre)\");\n }\n\n // 2.b dans fenêtre + conditions OK + adoption autorisée\n else if (manualAdoptEnabled && startConditionsOk) {\n desiredOn = true;\n desiredLatch = true;\n adoptManual = true;\n state = 'manual_on_adopted';\n reason.push(\"Clim garage ON manuellement puis adoptée par l'automate\");\n }\n\n // 2.c dans fenêtre mais pas adoptée\n else {\n desiredOn = true;\n desiredLatch = false;\n state = 'manual_on_not_adopted';\n reason.push(\"Clim garage ON mais non adoptée par l'automate (conditions insuffisantes ou adoption refusée)\");\n }\n}\n\n// 3) Latch actif : automate en charge\nelse if (latchIsOn) {\n if (!inWindow) {\n if (!minOnElapsed) {\n desiredOn = true;\n desiredLatch = true;\n state = 'hold_min_on_outside_window';\n reason.push(\"Hors fenêtre, mais maintien jusqu'au temps mini ON\");\n } else {\n desiredOn = false;\n desiredLatch = false;\n state = 'outside_window_release';\n reason.push(\"Hors fenêtre : l'automate relâche la clim garage\");\n }\n } else if (holdConditionsOk) {\n desiredOn = true;\n desiredLatch = true;\n state = 'auto_running';\n reason.push(\"Clim garage maintenue par l'automate\");\n } else if (!minOnElapsed) {\n desiredOn = true;\n desiredLatch = true;\n state = 'hold_min_on';\n reason.push(\"Conditions perdues, mais maintien jusqu'au temps mini ON\");\n } else {\n desiredOn = false;\n desiredLatch = false;\n state = 'auto_stop_conditions_lost';\n reason.push(\"Conditions garage perdues : arrêt automatique\");\n }\n}\n\n// 4) Pas ON, pas latché : démarrage auto éventuel\nelse {\n if (!inWindow) {\n desiredOn = false;\n desiredLatch = false;\n state = 'outside_window';\n reason.push(\"Hors fenêtre horaire garage\");\n } else if (!thermalNeed) {\n desiredOn = false;\n desiredLatch = false;\n state = 'idle';\n reason.push(\"Aucun besoin thermique garage\");\n } else if (!minOffElapsed) {\n desiredOn = false;\n desiredLatch = false;\n state = 'wait_min_off';\n reason.push(\"Temps mini OFF garage non écoulé\");\n } else if (startConditionsOk) {\n desiredOn = true;\n desiredLatch = true;\n state = 'auto_start';\n reason.push(\"Démarrage automatique garage\");\n } else {\n desiredOn = false;\n desiredLatch = false;\n state = 'conditions_not_met';\n reason.push(\"Besoin thermique détecté mais conditions énergétiques insuffisantes\");\n }\n}\n\n// ======================================================\n// ACTIONS\n// ======================================================\nconst switchAction =\n desiredOn === switchIsOn\n ? 'none'\n : desiredOn ? 'turn_on' : 'turn_off';\n\nconst latchAction =\n desiredLatch === latchIsOn\n ? 'none'\n : desiredLatch ? 'turn_on' : 'turn_off';\n\nconst setLastOnAt = switchAction === 'turn_on' || adoptManual;\nconst setLastOffAt = switchAction === 'turn_off';\n\n// ======================================================\n// REASONS DETAIL\n// ======================================================\nreason.push(`Temp garage ${Number.isFinite(garageTempC) ? round1(garageTempC) + '°C' : 'n/a'} | besoin=${thermalState}`);\nreason.push(`Fenêtre ${inWindow ? 'OK' : 'hors fenêtre'} (${input.start_time || '10:00:00'} → ${input.stop_time || '17:30:00'})`);\nreason.push(`Switch=${switchIsOn ? 'ON' : 'OFF'} | Latch=${latchIsOn ? 'ON' : 'OFF'} | Adoption manuelle=${manualAdoptEnabled ? 'ON' : 'OFF'}`);\nreason.push(`SOC ${round1(soc)}% (start>=${round1(socOn)} / hold>=${round1(socOff)})`);\nreason.push(`PV ${Math.round(pvPowerW)}W | PV10 ${Math.round(pvPowerAvg10mW)}W (start>=${Math.round(pvOnW)} / hold>=${Math.round(pvOffW)})`);\nreason.push(`Prod restante ${round1(todayRemainingKwh)}kWh (mini ${round1(surplusMinKwh)}kWh)`);\nreason.push(`Batterie ${round1(batteryCurrentA)}A (stop si < ${round1(batteryDischargeStopA)}A)`);\nreason.push(`AC maison ${Math.round(houseAcPowerW)}W / max ${Math.round(houseAcMaxW)}W`);\nreason.push(`Grid ${gridIsOn ? 'ON' : 'OFF'} | AC-in ${acInAvailable ? 'OK' : 'KO'} | dispo=${gridAvailable ? 'oui' : 'non'} | ${Math.round(gridPowerW)}W`);\nreason.push(`Prio EVCS ${evcsPriorityActive ? 'ON' : 'OFF'} | mode=${energyPriorityMode}${energyPriorityReason ? ` | ${energyPriorityReason}` : ''}`);\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst signature = [\n now.date,\n state,\n thermalState,\n `sw=${switchIsOn ? '1' : '0'}`,\n `lt=${latchIsOn ? '1' : '0'}`,\n `pv=${Math.round(pvPowerW)}`,\n `soc=${round1(soc)}`,\n `evcs_prio=${evcsPriorityActive ? '1' : '0'}`\n].join('|');\n\nconst signatureChanged = signature !== lastSignature;\n\n// ======================================================\n// NOTIFS\n// ======================================================\nif (notificationsEnabled && signatureChanged) {\n if (state === 'manual_on_outside_window') {\n notifyWanted = true;\n notifyTitle = 'Garage : ON manuel hors fenêtre';\n notifyMessage = \"La clim/hygro garage est ON mais non maintenue par l'automate (latch OFF).\";\n } else if (state === 'manual_on_adopted') {\n notifyWanted = true;\n notifyTitle = 'Garage : ON manuel adopté';\n notifyMessage = \"La clim/hygro garage a été adoptée par l'automate.\";\n } else if (state === 'auto_start') {\n notifyWanted = true;\n notifyTitle = 'Garage : démarrage auto';\n notifyMessage = `Temp ${Number.isFinite(garageTempC) ? round1(garageTempC) : 'n/a'}°C | PV ${Math.round(pvPowerW)}W | SOC ${round1(soc)}%.`;\n } else if (state === 'auto_stop_conditions_lost' || state === 'outside_window_release') {\n notifyWanted = true;\n notifyTitle = 'Garage : arrêt auto';\n notifyMessage = shortReason;\n } else if (state === 'evcs_priority_stop') {\n notifyWanted = true;\n notifyTitle = 'Garage : arrêt priorité EVCS';\n notifyMessage = shortReason;\n }\n}\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 today_remaining_kwh: todayRemainingKwh,\n\n garage_temperature_c: Number.isFinite(garageTempC) ? round1(garageTempC) : null,\n garage_switch: switchIsOn,\n\n auto_enabled: autoEnabled,\n notifications_enabled: notificationsEnabled,\n manual_adopt_enabled: manualAdoptEnabled,\n latch: latchIsOn,\n\n temp_min_c: tempMinC,\n temp_max_c: tempMaxC,\n soc_on: socOn,\n soc_off: socOff,\n pv_on_w: pvOnW,\n pv_off_w: pvOffW,\n surplus_min_kwh: surplusMinKwh,\n min_on_minutes: minOnMinutes,\n min_off_minutes: minOffMinutes,\n battery_discharge_stop_a: batteryDischargeStopA,\n house_ac_max_w: houseAcMaxW,\n\n start_time: input.start_time || null,\n stop_time: input.stop_time || null,\n last_on_at: lastOnAt || null,\n last_off_at: lastOffAt || null,\n last_signature: lastSignature || 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\n computed: {\n thermal_need: thermalNeed,\n thermal_state: thermalState,\n in_window: inWindow,\n start_conditions_ok: startConditionsOk,\n hold_conditions_ok: holdConditionsOk,\n minutes_since_on: minutesSinceOn !== null ? round1(minutesSinceOn) : null,\n minutes_since_off: minutesSinceOff !== null ? round1(minutesSinceOff) : null,\n min_on_elapsed: minOnElapsed,\n min_off_elapsed: minOffElapsed,\n evcs_priority_active: evcsPriorityActive,\n energy_priority_mode: energyPriorityMode,\n energy_priority_reason: energyPriorityReason || null,\n grid_available: gridAvailable\n },\n\n decision: {\n state,\n desired_on: desiredOn,\n desired_latch: desiredLatch,\n switch_action: switchAction,\n latch_action: latchAction,\n adopt_manual: adoptManual,\n set_last_on_at: setLastOnAt,\n set_last_off_at: setLastOffAt,\n signature,\n signature_changed: signatureChanged,\n should_notify: notifyWanted,\n reason,\n short_reason: shortReason\n },\n\n ha_service_calls: {\n helper_state: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.garage_clim_state',\n value: state\n }\n },\n\n helper_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.garage_clim_reason',\n value: shortReason || 'Aucune raison'\n }\n },\n\n helper_signature: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.garage_clim_last_signature',\n value: signature\n }\n },\n\n helper_latch: {\n domain: 'input_boolean',\n service: desiredLatch ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.garage_clim_latch'\n }\n },\n\n garage_switch: {\n domain: 'switch',\n service: desiredOn ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.clim_hygro_socket_1'\n }\n },\n\n helper_last_on_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.garage_clim_last_on_at',\n datetime: now.stamp\n }\n },\n\n helper_last_off_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.garage_clim_last_off_at',\n datetime: now.stamp\n }\n },\n\n logbook: {\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Garage Thermal Manager',\n message: `${state} | switch=${desiredOn ? 'on' : 'off'} | latch=${desiredLatch ? 'on' : 'off'} | ${shortReason}`,\n entity_id: 'switch.clim_hygro_socket_1'\n }\n },\n\n mobile_notification: {\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":"95b9baf2-b49b-4442-bd25-99a8ef4f2e37","name":"Compute Garage Thermal Advice V1.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 À JOUR\n// =====================================================\npushAction('helper_state', calls.helper_state, true);\npushAction('helper_reason', calls.helper_reason, true);\n\n// =====================================================\n// ARRET : SWITCH OFF D'ABORD, PUIS LATCH OFF\n// =====================================================\nif (decision.switch_action === 'turn_off') {\n pushAction('garage_switch', calls.garage_switch, true);\n}\n\nif (decision.latch_action === 'turn_off') {\n pushAction('helper_latch', calls.helper_latch, true);\n}\n\n// =====================================================\n// DEMARRAGE / ADOPTION : LATCH ON D'ABORD, PUIS SWITCH ON\n// =====================================================\nif (decision.latch_action === 'turn_on') {\n pushAction('helper_latch', calls.helper_latch, true);\n}\n\nif (decision.switch_action === 'turn_on') {\n pushAction('garage_switch', calls.garage_switch, true);\n}\n\n// =====================================================\n// TIMESTAMPS\n// =====================================================\npushAction('helper_last_on_at', calls.helper_last_on_at, !!decision.set_last_on_at);\npushAction('helper_last_off_at', calls.helper_last_off_at, !!decision.set_last_off_at);\n\n// =====================================================\n// SIGNATURE + LOGBOOK SI CHANGEMENT\n// =====================================================\npushAction('helper_signature', calls.helper_signature, !!decision.signature_changed);\npushAction('logbook', calls.logbook, !!decision.signature_changed);\n\n// =====================================================\n// NOTIFICATION SI DEMANDEE\n// =====================================================\npushAction('mobile_notification', calls.mobile_notification, !!decision.should_notify);\n\nreturn actions;\n"},"type":"n8n-nodes-base.code","typeVersion":2,"position":[672,0],"id":"fdcf2b8a-15d9-46fb-95ec-c90448fbf3ac","name":"Build Garage Thermal V1.2 Actions"}],"connections":{"Schedule Trigger":{"main":[[{"node":"HA Snapshot Garage Thermal V1.2","type":"main","index":0}]]},"HA Snapshot Garage Thermal V1.2":{"main":[[{"node":"Compute Garage Thermal Advice V1.2","type":"main","index":0}]]},"Compute Garage Thermal Advice V1.2":{"main":[[{"node":"Build Garage Thermal V1.2 Actions","type":"main","index":0}]]},"Build Garage Thermal V1.2 Actions":{"main":[[{"node":"Execute HA Service Garage Thermal","type":"main","index":0}]]}},"nodeGroups":[],"authors":"thierry tallagrand","name":"Version 51eac7c3","description":"","autosaved":true,"workflowPublishHistory":[{"createdAt":"2026-04-14T15:22:15.803Z","id":51,"workflowId":"FtPVF7byhtu4o6Oh","versionId":"51eac7c3-c307-43d5-944f-fc7cc9aaeb8f","event":"activated","userId":"8b9e9552-cd74-4863-82a3-174b3b3befa2"},{"createdAt":"2026-06-12T21:14:01.583Z","id":120,"workflowId":"FtPVF7byhtu4o6Oh","versionId":"51eac7c3-c307-43d5-944f-fc7cc9aaeb8f","event":"activated","userId":"8b9e9552-cd74-4863-82a3-174b3b3befa2"},{"createdAt":"2026-06-12T21:14:04.688Z","id":122,"workflowId":"FtPVF7byhtu4o6Oh","versionId":"51eac7c3-c307-43d5-944f-fc7cc9aaeb8f","event":"activated","userId":"8b9e9552-cd74-4863-82a3-174b3b3befa2"},{"createdAt":"2026-07-09T21:45:53.597Z","id":192,"workflowId":"FtPVF7byhtu4o6Oh","versionId":"51eac7c3-c307-43d5-944f-fc7cc9aaeb8f","event":"activated","userId":"8b9e9552-cd74-4863-82a3-174b3b3befa2"},{"createdAt":"2026-06-12T21:13:57.392Z","id":119,"workflowId":"FtPVF7byhtu4o6Oh","versionId":"51eac7c3-c307-43d5-944f-fc7cc9aaeb8f","event":"deactivated","userId":"8b9e9552-cd74-4863-82a3-174b3b3befa2"},{"createdAt":"2026-06-12T21:14:04.646Z","id":121,"workflowId":"FtPVF7byhtu4o6Oh","versionId":"51eac7c3-c307-43d5-944f-fc7cc9aaeb8f","event":"deactivated","userId":"8b9e9552-cd74-4863-82a3-174b3b3befa2"},{"createdAt":"2026-07-09T21:45:53.579Z","id":191,"workflowId":"FtPVF7byhtu4o6Oh","versionId":"51eac7c3-c307-43d5-944f-fc7cc9aaeb8f","event":"deactivated","userId":"8b9e9552-cd74-4863-82a3-174b3b3befa2"}]}}