diff --git a/dashboards/victron_mobile.yaml b/dashboards/victron_mobile.yaml index 4769486..71fec96 100755 --- a/dashboards/victron_mobile.yaml +++ b/dashboards/victron_mobile.yaml @@ -1,3 +1,12 @@ +# ===================================================================== +# VICTRON MOBILE — refait le 07/10/2026 (demande du proprietaire). +# Une colonne, de haut en bas : alertes, batterie, reseau, energie du jour, +# nuit, borne, chauffe-eau, cellules, flux des 4 dernieres heures. +# ⚠️ LECTURE SEULE : aucun appui ne commande le contacteur EDF, la borne ou +# le chauffe-eau (une fiche « more-info » de switch permet de les basculer en +# deux appuis ; les automates en sont les seuls proprietaires). +# Ancienne version : victron_mobile.yaml.bak-20261007. +# ===================================================================== views: - title: Mobile path: default_view @@ -5,40 +14,60 @@ views: cards: - type: vertical-stack cards: - - type: custom:mushroom-title-card - title: Victron Mobile - subtitle: Vue rapide iPhone / écran étroit - + # ---------- 1. ALERTES (seulement quand quelque chose ne va pas) ---------- + # « EDF ouvert » n'est PAS une alerte : c'est l'etat normal ici. - type: conditional conditions: - - entity: input_boolean.victron_auto_charge_enabled + - condition: or + conditions: + - condition: state + entity: sensor.evcs_reseau + state: mesure_muette + - condition: state + entity: sensor.evcs_reseau + state: incoherent + card: + type: markdown + content: | + ## ⚠️ Réseau : {{ states('sensor.evcs_reseau') | replace('_', ' ') }} + {{ state_attr('sensor.evcs_reseau', 'explication') or '' }} + - type: conditional + conditions: + - condition: or + conditions: + - condition: state + entity: sensor.evcs_cellules + state: perime + - condition: state + entity: sensor.evcs_cellules + state: muet + card: + type: markdown + content: | + ## ⚠️ Cellules : {{ states('sensor.evcs_cellules') }} + Modules lus : {{ state_attr('sensor.evcs_cellules', 'modules_lus') }} sur 9. + - type: conditional + conditions: + - condition: state + entity: input_boolean.victron_auto_charge_enabled state: "off" card: type: markdown content: | ## ⚠️ Auto batterie OFF - - type: conditional conditions: - - entity: input_boolean.victron_charge_override_enabled + - condition: state + entity: input_boolean.victron_charge_override_enabled state: "on" card: type: markdown content: | ## 🖐️ Override actif - - type: conditional - conditions: - - entity: switch.edf_general_ok_switch - state: "off" - card: - type: markdown - content: | - ## ⚠️ Réseau EDF OFF - + # ---------- 2. BATTERIE ---------- - type: custom:vertical-stack-in-card - mode: vertical - card_mod: + card_mod: &carte style: | ha-card { border-radius: 20px; @@ -48,7 +77,7 @@ views: cards: - type: custom:gauge-card-pro entity: sensor.victron_battery_soc - header: SOC Batterie + header: Batterie min: 0 max: 100 needle: true @@ -61,323 +90,146 @@ views: color: "#fdd835" - from: 75 color: "#43a047" - - type: custom:mushroom-template-card - entity: sensor.victron_battery_soc - primary: Batterie - secondary: > - {{ states('sensor.victron_battery_soc') | float(0) | round(1) }} % - • {{ states('sensor.victron_battery_power') | float(0) | round(0) }} W - • {{ states('input_text.victron_charge_mode') }} + primary: >- + {% set p = states('sensor.victron_battery_power') | float(0) %} + {{ 'Charge' if p > 30 else ('Décharge' if p < -30 else 'Au repos') }} + {{ p | abs | round(0) }} W ({{ states('sensor.victron_battery_current') | float(0) | round(1) }} A) + secondary: >- + Cible du matin {{ states('input_number.victron_evcs_night_target_soc') | float(0) | round(1) }} % + • requis maintenant {{ states('input_number.evcs_balance_required_soc') | float(0) | round(1) }} % multiline_secondary: true - icon: mdi:battery - icon_color: > - {% set s = states('sensor.victron_battery_soc') | float(0) %} - {% if s < 25 %} red - {% elif s < 50 %} orange - {% elif s < 75 %} yellow - {% else %} green - {% endif %} + icon: mdi:battery-charging + tap_action: + action: none - - type: custom:vertical-stack-in-card - mode: vertical - card_mod: - style: | - ha-card { - border-radius: 20px; - border: 1px solid rgba(255,255,255,0.10); - box-shadow: 0 6px 16px rgba(0,0,0,0.18); - } - cards: - - type: custom:mushroom-title-card - title: Énergie - subtitle: Instantané + # ---------- 3. RESEAU (lecture seule) ---------- + - type: custom:mushroom-template-card + card_mod: *carte + primary: >- + {% set r = states('sensor.evcs_reseau') %} + {{ {'non_raccorde': 'EDF ouvert (normal)', 'raccorde': 'EDF fermé', 'etablissement': 'EDF en cours de fermeture', + 'mesure_muette': 'EDF : mesure muette', 'incoherent': 'EDF : incohérent'}.get(r, 'EDF : ' ~ r) }} + {% if is_state('switch.edf_general_ok_switch', 'on') %} — {{ states('sensor.victron_grid_power_l1') | float(0) | round(0) }} W{% endif %} + secondary: >- + Acheté aujourd'hui : {{ states('sensor.evcs_import_jour_kwh') }} kWh + icon: mdi:transmission-tower + icon_color: "{{ 'blue' if is_state('switch.edf_general_ok_switch', 'on') else 'grey' }}" + tap_action: + action: none - - type: custom:mushroom-template-card - primary: Production PV - secondary: > - {{ states('sensor.victron_pv_power_system') | float(0) | round(0) }} W - icon: mdi:solar-power - icon_color: > - {% set pv = states('sensor.victron_pv_power_system') | float(0) %} - {% if pv < 300 %} grey - {% elif pv < 1500 %} yellow - {% else %} green - {% endif %} + # ---------- 4. ENERGIE DU JOUR ---------- + - type: markdown + card_mod: *carte + content: | + {% set vt = states('input_text.evcs_balance_vrm_today').split(',') %} + {% set ns = namespace(tot=0, reste=0) %} + {% if vt | length == 24 %}{% for i in range(24) %}{% set v = vt[i] | float(0) %}{% set ns.tot = ns.tot + v %}{% if i > now().hour %}{% set ns.reste = ns.reste + v %}{% elif i == now().hour %}{% set ns.reste = ns.reste + v * (1 - now().minute / 60) %}{% endif %}{% endfor %}{% endif %} + ### Aujourd'hui + | | | + |---|---:| + | ☀️ Produit | **{{ states('sensor.evcs_production_jour_kwh') }} kWh** | + | ☀️ Prévu (VRM) / reste | {{ ns.tot | round(1) }} / {{ ns.reste | round(1) }} kWh | + | 🏠 Consommé | {{ states('sensor.evcs_consommation_jour_kwh') }} kWh | + | ⚡ Acheté EDF | {{ states('sensor.evcs_import_jour_kwh') }} kWh | + | 🔎 Solaire maintenant | {{ states('sensor.victron_pv_power_system') | float(0) | round(0) }} W — {{ states('sensor.evcs_pv_diagnostic') | replace('_', ' ') }} | + {% if state_attr('sensor.evcs_historisation_heartbeat', 'statut') != 'ok' %} - - type: custom:mushroom-template-card - primary: Maison - secondary: > - {{ states('sensor.victron_consumption_power_l1') | float(0) | round(0) }} W - icon: mdi:home-lightning-bolt - icon_color: blue + **⚠️ Service d'historisation : {{ state_attr('sensor.evcs_historisation_heartbeat', 'statut') }}** — les capteurs calculés sont figés. + {% endif %} - - type: custom:mushroom-template-card - primary: Réseau - secondary: > - {{ states('sensor.victron_grid_power_l1') | float(0) | round(0) }} W - icon: mdi:transmission-tower - icon_color: > - {% set g = states('sensor.victron_grid_power_l1') | float(0) %} - {% if g > 0 %} red - {% elif g < 0 %} green - {% else %} blue - {% endif %} + # ---------- 5. LA NUIT ---------- + # Le soir : la prevision ; le matin : le bilan de la nuit ecoulee. + - type: markdown + card_mod: *carte + content: | + {% if now().hour >= 14 or now().hour < 2 %} + ### 🌙 Cette nuit + {{ state_attr('sensor.evcs_prevision_nuit', 'texte_rapport') or states('sensor.evcs_prevision_nuit') }} + {% else %} + ### 🌙 La nuit dernière + {{ state_attr('sensor.evcs_bilan_nuit', 'texte_rapport') or states('sensor.evcs_bilan_nuit') }} + {% endif %} - - type: custom:vertical-stack-in-card - mode: vertical - card_mod: - style: | - ha-card { - border-radius: 20px; - border: 1px solid rgba(255,255,255,0.10); - box-shadow: 0 6px 16px rgba(0,0,0,0.18); - } - cards: - - type: custom:mushroom-title-card - title: Charges pilotées - subtitle: États clés + Charge de la voiture la nuit : **{{ {'sans_edf': 'sans EDF', 'complete': 'complète'}.get(states('input_select.victron_evcs_night_charge_mode'), states('input_select.victron_evcs_night_charge_mode')) }}** - - type: custom:mushroom-template-card - entity: sensor.victron_evcs_status - primary: EVCS - secondary: > - {{ states('sensor.victron_evcs_status') }} - • {{ states('sensor.victron_evcs_current') | float(0) | round(0) }} A - multiline_secondary: true - icon: mdi:ev-station - icon_color: > - {% if is_state('switch.victron_evcs_charging', 'on') %} - green - {% elif is_state('binary_sensor.victron_evcs_vehicle_connected', 'on') %} - blue - {% else %} - grey - {% endif %} + # ---------- 6. BORNE / VEHICULE ---------- + - type: custom:mushroom-template-card + card_mod: *carte + primary: >- + {{ states('sensor.victron_evcs_status_fr') }} + {% if is_state('binary_sensor.victron_evcs_vehicle_connected', 'on') %} — {{ states('input_select.victron_evcs_vehicle') }}{% endif %} + secondary: >- + {% set w = states('sensor.victron_evcs_ac_power') | float(0) %} + {% if w > 50 %}{{ w | round(0) }} W • {{ states('input_text.victron_evcs_source_mode') }} • {{ states('sensor.evcs_hq2239dnt7p_last_session_energy') }} kWh + {% else %}{{ (states('input_text.victron_evcs_surplus_reason').split('|') | first) | trim }}{% endif %} + {% if is_state('input_select.victron_evcs_vehicle', 'JEEP') %} • Jeep {{ states('sensor.jeep_renegade_phev_hvbattery_charge') }} %{% endif %} + multiline_secondary: true + icon: mdi:ev-station + icon_color: >- + {% if states('sensor.victron_evcs_ac_power') | float(0) > 50 %}green + {% elif is_state('binary_sensor.victron_evcs_vehicle_connected', 'on') %}blue{% else %}grey{% endif %} + tap_action: + action: none - - type: custom:mushroom-template-card - entity: switch.tt_chauffe_eau_pleumeur_switch - primary: Chauffe-eau - secondary: > - {{ states('input_select.victron_water_heater_recommended_mode') }} - multiline_secondary: true - icon: mdi:water-boiler - icon_color: > - {% if is_state('switch.tt_chauffe_eau_pleumeur_switch', 'on') %} - orange - {% else %} - grey - {% endif %} + # ---------- 7. CHAUFFE-EAU ---------- + - type: custom:mushroom-template-card + card_mod: *carte + primary: >- + Chauffe-eau {{ 'sous tension' if is_state('switch.tt_chauffe_eau_pleumeur_switch', 'on') else 'coupé' }} + {% set w = states('input_number.victron_water_heater_observed_power_w') | float(0) %}{% if w > 50 %} — {{ w | round(0) }} W{% endif %} + secondary: >- + {% set p = states('input_text.evcs_balance_ecs_nuit_jour').split('|') %} + {% if p | length >= 3 and p[0] == now().strftime('%Y-%m-%d') %}{{ 'Jour faible : eau chaude en heures creuses' if p[1] == 'on' else 'Jour normal : eau chaude au solaire' }} + {% else %}Décision du jour après 07:24{% endif %} + • {{ states('sensor.evcs_chauffe_eau_jour_kwh') }} kWh depuis 07:24 + multiline_secondary: true + icon: mdi:water-boiler + icon_color: "{{ 'orange' if is_state('switch.tt_chauffe_eau_pleumeur_switch', 'on') else 'grey' }}" + tap_action: + action: none - - type: custom:mushroom-template-card - entity: switch.clim_hygro_socket_1 - primary: Garage - secondary: > - {{ states('input_text.garage_clim_state') }} - multiline_secondary: true - icon: mdi:air-conditioner - icon_color: > - {% if is_state('switch.clim_hygro_socket_1', 'on') %} - cyan - {% else %} - grey - {% endif %} + # ---------- 8. CELLULES ---------- + - type: custom:mushroom-template-card + card_mod: *carte + primary: >- + Cellules : écart {{ states('sensor.evcs_cellules') }}{{ ' mV' if states('sensor.evcs_cellules') | is_number else '' }} + secondary: >- + {{ state_attr('sensor.evcs_cellules', 'modules_lus') }} modules sur 9 + • le plus sensible : n°{{ state_attr('sensor.evcs_cellules', 'repere_le_plus_sensible') }} + icon: mdi:battery-heart-variant + icon_color: "{{ 'green' if states('sensor.evcs_cellules') | is_number else 'orange' }}" + tap_action: + action: none - - type: custom:vertical-stack-in-card - mode: vertical - card_mod: - style: | - ha-card { - border-radius: 20px; - border: 1px solid rgba(255,255,255,0.10); - box-shadow: 0 6px 16px rgba(0,0,0,0.18); - } - cards: - - type: custom:mushroom-title-card - title: Actions rapides - subtitle: Contrôle direct - - - type: grid - columns: 2 - square: false - cards: - - type: custom:button-card - entity: switch.victron_evcs_charging - name: EVCS - icon: mdi:ev-station - show_state: true - state_display: > - [[[ - if (!entity) return 'Inconnu'; - if (entity.state === 'on') return 'ON'; - if (entity.state === 'off') return 'OFF'; - return entity.state; - ]]] - tap_action: - action: more-info - styles: - card: - - border-radius: 18px - - padding: 10px - name: - - font-size: 14px - - font-weight: 600 - state: - - font-size: 12px - state: - - value: "on" - color: "#43a047" - - value: "off" - color: "#757575" - - - type: custom:button-card - entity: switch.tt_chauffe_eau_pleumeur_switch - name: ECS - icon: mdi:water-boiler - show_state: true - state_display: > - [[[ - if (!entity) return 'Inconnu'; - if (entity.state === 'on') return 'ON'; - if (entity.state === 'off') return 'OFF'; - return entity.state; - ]]] - tap_action: - action: more-info - styles: - card: - - border-radius: 18px - - padding: 10px - name: - - font-size: 14px - - font-weight: 600 - state: - - font-size: 12px - state: - - value: "on" - color: "#fb8c00" - - value: "off" - color: "#757575" - - - type: custom:button-card - entity: switch.edf_general_ok_switch - name: Réseau - icon: mdi:transmission-tower - show_state: true - state_display: > - [[[ - if (!entity) return 'Inconnu'; - if (entity.state === 'on') return 'ON'; - if (entity.state === 'off') return 'OFF'; - return entity.state; - ]]] - tap_action: - action: more-info - styles: - card: - - border-radius: 18px - - padding: 10px - name: - - font-size: 14px - - font-weight: 600 - state: - - font-size: 12px - state: - - value: "on" - color: "#42a5f5" - - value: "off" - color: "#e53935" - - - type: custom:button-card - entity: switch.clim_hygro_socket_1 - name: Garage - icon: mdi:air-conditioner - show_state: true - state_display: > - [[[ - if (!entity) return 'Inconnu'; - if (entity.state === 'on') return 'ON'; - if (entity.state === 'off') return 'OFF'; - return entity.state; - ]]] - tap_action: - action: more-info - styles: - card: - - border-radius: 18px - - padding: 10px - name: - - font-size: 14px - - font-weight: 600 - state: - - font-size: 12px - state: - - value: "on" - color: "#26c6da" - - value: "off" - color: "#757575" - - - type: custom:vertical-stack-in-card - mode: vertical - card_mod: - style: | - ha-card { - border-radius: 20px; - border: 1px solid rgba(255,255,255,0.10); - box-shadow: 0 6px 16px rgba(0,0,0,0.18); - } - cards: - - type: custom:mushroom-title-card - title: Verdict rapide - subtitle: Automatisation - - - type: entities - show_header_toggle: false - entities: - - entity: input_boolean.victron_advice_evcs_ok - name: EVCS OK - - entity: input_boolean.victron_advice_water_heater_ok - name: ECS OK - - entity: input_boolean.victron_advice_garage_clim_ok - name: Garage OK - - entity: input_boolean.victron_advice_sdb_heater_ok - name: SDB OK - - entity: input_select.victron_water_heater_recommended_mode - name: Mode ECS - - entity: input_text.victron_grid_control_state - name: État réseau - - - type: custom:vertical-stack-in-card - mode: vertical - card_mod: - style: | - ha-card { - border-radius: 20px; - border: 1px solid rgba(255,255,255,0.10); - box-shadow: 0 6px 16px rgba(0,0,0,0.18); - } - cards: - - type: custom:mushroom-title-card - title: Historique court - subtitle: 4 dernières heures - - - type: custom:apexcharts-card - graph_span: 4h - header: - show: true - title: Flux énergie - show_states: false - series: - - entity: sensor.victron_pv_power_system - name: PV - color: "#fdd835" - - entity: sensor.victron_consumption_power_l1 - name: Maison - color: "#42a5f5" - - entity: sensor.victron_grid_power_l1 - name: Réseau - color: "#ef5350" - - entity: sensor.victron_battery_power - name: Batterie - color: "#ab47bc" + # ---------- 9. FLUX DES 4 DERNIERES HEURES ---------- + - type: custom:apexcharts-card + card_mod: *carte + graph_span: 4h + header: + show: true + title: Flux d'énergie (4 h) + show_states: false + series: + - entity: sensor.victron_pv_power_system + name: Solaire + color: "#fdd835" + - entity: sensor.victron_consumption_power_l1 + name: Maison + color: "#42a5f5" + - entity: sensor.victron_grid_power_l1 + name: EDF + color: "#ef5350" + - entity: sensor.victron_battery_power + name: Batterie + color: "#ab47bc" + # ---------- 10. AUTRES CHARGES ---------- + - type: custom:mushroom-template-card + card_mod: *carte + primary: "Garage : {{ states('input_text.garage_clim_state') | replace('_', ' ') }}" + secondary: "Clim {{ 'en marche' if is_state('switch.clim_hygro_socket_1', 'on') else 'arrêtée' }}" + icon: mdi:air-conditioner + icon_color: "{{ 'cyan' if is_state('switch.clim_hygro_socket_1', 'on') else 'grey' }}" + tap_action: + action: none diff --git a/src/water-heater/OBSERVATION.js b/src/water-heater/OBSERVATION.js index c8dc986..c5cd219 100644 --- a/src/water-heater/OBSERVATION.js +++ b/src/water-heater/OBSERVATION.js @@ -139,7 +139,11 @@ const sampleIsUsable = Number.isFinite(energyDeltaKwh); let observedPowerW = 0; -if (sampleIsUsable) { +// Coupe = 0 W (07/10/2026) : le compteur Tuya remonte avec retard, et la +// derniere chauffe apparaissait apres la coupure (« arret — 1595 W »). +if (!waterHeaterSwitchOn) { + observedPowerW = 0; +} else if (sampleIsUsable) { observedPowerW = Math.max(0, (energyDeltaKwh * 60000) / sampleDurationMin); } else if (waterHeaterSwitchOn && observedPowerPrevW > 0) { observedPowerW = observedPowerPrevW; diff --git a/tests/water-heater/run.mjs b/tests/water-heater/run.mjs index 6e75902..41f44f1 100644 --- a/tests/water-heater/run.mjs +++ b/tests/water-heater/run.mjs @@ -73,6 +73,14 @@ for (const c of cas) { if (!own) erreurs.push('helper de propriete absent des appels'); if (erreurs.length) { ko++; console.log(`❌ ${c.id} | ${erreurs.join(' ; ')}`); } else { ok++; console.log(`✅ ${c.id}`); } } +// Coupe = 0 W meme si le compteur remonte en retard (07/10 : « arret — 1595 W »). +{ + const stamp = new Date(Date.now() - 5 * 60000).toLocaleString('sv-SE', { timeZone: 'Europe/Paris' }); + const s = executer({ ...BASE, water_heater_switch: 'off', water_heater_energy_total_kwh: '18.88', energy_total_prev_kwh: '18.75', energy_prev_at: stamp }); + const o = s.OBSERVATION[0].json.observation; + if (o.observedPowerW === 0 && o.observedRegime === 'off') { ok++; console.log('✅ coupe-zero-watt'); } + else { ko++; console.log(`❌ coupe-zero-watt | ${o.observedPowerW} W ${o.observedRegime}`); } +} // Prevision forte = le reste du jour seulement (06/10 : demain fort ne rend plus forte une journee faible). { const s = executer({ ...BASE, ecs_nuit: 'off', today_remaining_kwh: '8', tomorrow_kwh: '30' }); diff --git a/workflows/water-heater-ecs-v1.json b/workflows/water-heater-ecs-v1.json index 360dfc8..04fc5e5 100644 --- a/workflows/water-heater-ecs-v1.json +++ b/workflows/water-heater-ecs-v1.json @@ -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;\n// Coupe = 0 W (07/10/2026) : le compteur Tuya remonte avec retard, et la\n// derniere chauffe apparaissait apres la coupure (« arret — 1595 W »).\nif (!waterHeaterSwitchOn) {\n observedPowerW = 0;\n} else if (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",