Photovoltaïque Victron : pilotage intelligent du SoC et de la charge programmée avec Node-RED

Intro

Depuis plusieurs mois, mon installation photovoltaïque Victron a évolué : nouveaux capteurs, nouveaux besoins (Tempo, consommation nocturne, sécurisation du SoC), et surtout une logique de pilotage plus structurée. Plutôt que de maintenir une série d’articles éparpillés autour de Node-RED et de quelques flows historiques, j’ai décidé de repartir sur une base saine : un article unique, complet, et régulièrement maintenu.

L’objectif de ce billet est simple : présenter mon architecture de gestion énergétique Victron, centrée sur Node-RED (collecte, calculs, décisions) et MQTT/Home Assistant (supervision et commandes), avec un focus sur un point clé : déterminer automatiquement une cible de SoC et piloter intelligemment la charge programmée sur une fenêtre nocturne (typique 22h → 6h). Je détaille ici le pourquoi, le comment, et je fournis les éléments concrets pour reproduire l’approche : structure du flow, variables manipulées, topics MQTT, et points de vigilance.


Objectif du flow

Ce flow, nommé « DESS Rémy », a 3 missions :

  1. Estimer la production PV (aujourd’hui / J+1)
    • récupération VRM côté onduleur PV (AC-coupled) et côté MPPT
    • normalisation (Wh → kWh arrondi)
    • somme des prévisions et publication MQTT
  2. Calculer un SoC cible pour la charge programmée
    • formule simple et robuste : seuil = 120 - 2.5 × prevision_kWh
    • arrondi au pas de 5%, bornage 0–100%
    • possibilité de forçage manuel (ex : CP à X% même si la prévision est bonne)
  3. Piloter CP1 sur Victron (fenêtre 22h–6h)
    • activation/désactivation via une autorisation (Home Assistant)
    • écriture des paramètres CP1 dans Venus OS : Day, Start, Duration, Soc (et un flag autoconsommation si besoin)
    • log synthétique et publication MQTT des valeurs utiles (cible SoC, valid CP, durée, heure début)

Ce flow sert donc à la fois de collecte (prévisions VRM), de traitement (calcul), et de commande (écriture settings Victron) — tout en restant “observable” grâce aux sorties MQTT et au log.


Le flow Node-RED

Visualisation du flow


Analyse du flow (version 2026)

A) Bloc “Prévisions VRM : production PV + consommation J+1 (6h → 22h)”

Le flow ne se limite plus à “additionner des totaux” : il travaille désormais à la journée suivante (J+1) et sur la plage utile 06:00 → 22:00, ce qui colle au besoin réel (tenir la journée hors HC).

1) Prévision de production PV J+1

  • Un nœud VRM API récupère la série solar_yield_forecast (format typique : records.solar_yield_forecast = [[timestamp_ms, Wh], ...]).
  • Une fonction Extrac 6:22 :
    • filtre les points correspondant au jour J+1
    • somme uniquement les valeurs entre 6h et 22h
    • convertit Wh → kWh (arrondi à 0,1 kWh)
    • stocke en flow.previ_prod_6_22 (et optionnellement flow.previ_prod_total)
    • publie le résultat sur MQTT : mp2/dess_remy/prod_total (QoS 2 + retain)
  • Un change node Save previ_prod mémorise aussi la valeur utilisée par la suite via flow.previ_prod.

2) Prévision de consommation J+1

  • Un second nœud VRM API récupère la prévision de consommation vrm_consumption_fc (série horaire).
  • La fonction Extraction 6-22h :
    • filtre J+1
    • somme uniquement 06:00 → 22:00
    • convertit Wh → kWh (1 décimale)
    • injecte le résultat dans flow.conso_j1

À ce stade, Node-RED dispose de deux entrées cohérentes pour dimensionner la batterie sur la journée suivante :

  • Production PV attendue (6–22)
  • Consommation attendue (6–22)

B) Bloc “Calcul SoC cible (logique DoD J+1)”

C’est la nouveauté structurante du flow.

La fonction Calcul Soc Cible calcule un SoC cible “métier” à partir :

  • flow.conso_j1 (conso prévue 6–22 J+1)
  • flow.previ_prod (prod prévue 6–22 J+1)
  • flow.soc (SoC actuel)
  • paramètres (capacité batterie, rendement, soc_min, marge, part réseau acceptée, etc.)

Elle produit et stocke :

  • flow.dess_soc_cible_pct : SoC cible calculé
  • flow.dess_dod_pct : DoD requis (indicatif)

Ce SoC cible calculé devient ensuite la référence prioritaire pour le pilotage CP1.


C) Bloc “Entrées Home Assistant (autorisations et forçages)”

Trois topics MQTT (HA → Node-RED) restent le moyen simple de reprendre la main :

  • ha/mp2/cp/validcp : autorisation globale de charge programmée (ON/OFF)
    → stocké en global.valid_cp_ess
  • ha/mp2/cp/forcage100 : forçage manuel (ON/OFF)
    → stocké en flow.forc100
  • ha/mp2/cp/niveauforcagecp1 : SoC cible forcé (ex : 80)
    → stocké en flow.niveauforcp1

Ces entrées sont consommées directement par la fonction de pilotage CP1.


D) Bloc “Horloge CP 22h → 6h + horaires dynamiques”

Le pilotage temporel est maintenant plus propre :

  • Un BigTimer définit la fenêtre 22:00 → 06:00 (sortie on/off).
  • La fonction H Dynamique V3 :
    • prend la fenêtre BigTimer “comme vérité”
    • calcule hdebut (minutes) en fonction du besoin (SoC actuel → SoC cible)
    • écrit :
      • flow.horloge (0/1)
      • flow.hdebut (minutes)
      • flow.hfin (minutes, fin de fenêtre)
    • sort un log clair vers un widget dashboard (“Log H Dyn”).

Ensuite, un recalcul “souple” du SoC est prévu :

  • SOC ts les 15mn : ne recalculera que toutes les 15 minutes et uniquement si BigTimer est OFF, pour éviter les oscillations pendant la fenêtre de charge.

E) Bloc “Pilotage CP1 V6 : SoC cible + commande Victron”

La fonction Pilotage CP1 V6 est le cœur du pilotage.

1) Calcul du seuil SoC cible (priorités)

  1. SoC cible calculé via DoD J+1 : flow.dess_soc_cible_pct (si valide)
  2. Sinon fallback historique : seuil = 120 - 2.5 × prod
  3. Enfin, si forçage HA actif (forcage100 == on) : seuil = niveauforcp1

Puis :

  • arrondi au pas de 5%
  • bornage 0–100 (avec règle “<15% ⇒ 0” conservée)

2) Activation de la charge programmée

  • si HA autorise ET horloge==1 → activation (payload 7, i.e. “Every day”)
  • sinon → désactivation (payload -1)

3) Start / Duration

  • conversion minutes → secondes pour alimenter :
    • /Schedule/Charge/0/Start
    • /Schedule/Charge/0/Duration

4) Log synthétique
Un log unique et lisible (UI + debug) affichant :
SoC actuel, Seuil retenu, état forçage, durée HC (HH:MM), horloge, autorisation HA, et valeur effectivement envoyée.


F) Bloc “Écriture des settings dans Venus OS + MQTT sortant”

1) Écriture Venus OS (victron-output-ess)

  • Validation CP : /Settings/CGwacs/BatteryLife/Schedule/Charge/0/Day
  • Heure début : /Settings/CGwacs/BatteryLife/Schedule/Charge/0/Start
  • Durée : /Settings/CGwacs/BatteryLife/Schedule/Charge/0/Duration
  • Cible SoC : /Settings/CGwacs/BatteryLife/Schedule/Charge/0/Soc
  • (option) Autoconsommation : /Settings/CGwacs/BatteryLife/Schedule/Charge/0/AllowDischarge

2) MQTT sortant (supervision Home Assistant)

  • mp2/dess_remy/prod_total (prévision PV J+1 6–22, QoS2 retain)
  • mp2/dess_remy/cible_soc
  • mp2/multiplus2/valide_cp (converti en on/off via mapping -1/7)
  • mp2/dess_remy/h_debut (format HH:MM)
  • mp2/dess_remy/duree (format HH:MM)

Détails techniques (extraits utiles mis à jour)

1) Extraction PV J+1 6–22 (principe)

  • source : records.solar_yield_forecast
  • filtre : jour J+1 + plage 06:00–22:00
  • conversion Wh → kWh (1 décimale)
  • stockage : flow.previ_prod_6_22 (+ flow.previ_prod via change node)

2) Extraction conso J+1 6–22 (principe)

  • source : records.vrm_consumption_fc
  • filtre : jour J+1 + plage 06:00–22:00
  • conversion Wh → kWh (1 décimale)
  • stockage : flow.conso_j1

3) Calcul SoC cible + activation CP (principe mis à jour)

  • SoC cible prioritaire : flow.dess_soc_cible_pct (DoD J+1)
  • fallback : 120 - 2.5 × prod
  • forçage HA : override total
  • activation CP : uniquement si global.valid_cp_ess autorise ET flow.horloge==1

Dashboard Node red:

Intégration avec Home Assistant

Le broker MQTT (Home Assistant) reçoit :

  • les prévisions prod_* (très utiles pour tableaux de bord et automations)
  • la cible SoC retenue
  • l’état CP (on/off)
  • la durée et l’heure début en format HH:MM
  • le log texte (optionnel, très pratique en debug)

On ci-dessous le résultat d’une charge nocturne, la charge programmée démarre vers 1:00 pour se terminer avant 6:00

Côté HA, tu peux :

  • créer des sensor MQTT pour prod_total, cible_soc, duree, etc.
  • utiliser input_boolean / input_number pour validcp, forcage100, niveauforcagecp1, et publier vers les topics ha/mp2/cp/....

Fichier victron.yaml permettant de récuperer les mqtt:

mqtt:
  sensor:
  # MQTT

    - name: "MP2 SOC theorique par MQTT"
      unique_id: mp2_soc_theorique_par_mqtt
      state_topic: "mp2/batteries/soc_theorique"
      unit_of_measurement: '%'
      device_class: battery
      state_class: measurement

    - name: "MP2 VALIDE CP MQTT"
      unique_id: mp2_valide_cp_par_mqtt
      state_topic: "mp2/multiplus2/valide_cp"

    - name: "Capacité Batterie Estimée par NR"
      state_topic: "mp2/batteries/capacity_est_ah"
      unit_of_measurement: "Ah"
      icon: mdi:battery-charging-outline
      state_class: measurement

      # DESS Remy

    - name: "MP2 DESS Cible SOC par MQTT"
      unique_id: mp2_dess_cible_soc
      state_topic: "mp2/dess_remy/cible_soc"
      unit_of_measurement: '%'
      device_class: battery
      state_class: measurement

    - name: "MP2 DESS Difference Cible-SOC"
      unique_id: mp2_difference_cible_soc
      state_topic: "mp2/dess_remy/diff_soc"
      unit_of_measurement: '%'
      device_class: battery
      state_class: measurement

    - name: "MP2 DESS H_Debut DESS Remy"
      unique_id: mp2_dess_h_debut
      state_topic: "mp2/dess_remy/h_debut"   

    - name: "MP2 DESS Durée DESS Remy"
      unique_id: mp2_dess_duree
      state_topic: "mp2/dess_remy/duree"      

    - name: "MP2 DESS Remy Log Audit"
      unique_id: mp2_dess_log_audit
      state_topic: "mp2/dess_remy/log_audit"      

    - name: "MP2 DESS Previ Conso 6-22 J1"
      unique_id: mp2_dess_previ_conso_6_22_j1
      state_topic: "mp2/dess_remy/previ_conso_6-22-J1"
      unit_of_measurement: "kWh"
      device_class: "energy"
      state_class: "measurement"
      
# Victron
#modbus:
#  - name: cerbo
#    host: 192.168.0.86
#    type: tcp
#    port: 502
#    switches:
#      - name: "MP2 cde"
#        slave: 227
#        address: 33
#        command_on: 3
#        command_off: 4
#        verify:
#          input_type: holding
#          address: 33
#          state_on: 3
#          state_off: 4

#    sensors:
#      # Status bus VE
#      # 0=Off;1=Low Power;2=Fault;3=Bulk;4=Absorption;5=Float;6=Storage;
#      # 7=Equalize;8=Passthru;9=Inverting;10=Power assist;11=Power supply;252=External control
#      - name: "MP2 Status bus VE"
#        unique_id: "mp2_status_bus_ve"
#        slave: 227
#        address: 31
#        data_type: uint16
#        scale: 1

Palettes utilisées

Pour reproduire ce flow, installer :

  • node-red-contrib-victron (entrées/sorties ESS vers Venus OS)
  • palette VRM API (nœud vrm-api utilisé pour récupérer les stats/prévisions VRM)
  • node-red-contrib-bigtimer (horloge 22h → 6h)
  • node-red-dashboard (ui_gauge, ui_text, ui_chart)
  • node-red-node-mqtt (mqtt in/out)

Conclusion

Avec ce flow, je ne me contente plus de “collecter des données” : j’intègre la prévision (VRM), la décision (calcul cible SoC) et l’action (écriture CP1 dans Victron), tout en gardant une supervision propre via MQTT/Home Assistant.


Annexe JSON

Flux JSON à copier/coller :

[
    {
        "id": "5fe7408840b1cdf3",
        "type": "tab",
        "label": "DESS Rémy",
        "disabled": false,
        "info": "",
        "env": []
    },
    {
        "id": "58504cbc7c33f631",
        "type": "group",
        "z": "5fe7408840b1cdf3",
        "style": {
            "stroke": "#999999",
            "stroke-opacity": "1",
            "fill": "none",
            "fill-opacity": "1",
            "label": true,
            "label-position": "nw",
            "color": "#a4a4a4"
        },
        "nodes": [
            "d0900f1bce2a4111",
            "9a02956e8c37d773",
            "6027c79e01cbefde",
            "4f987019c9af7859",
            "59baa58061ee6884",
            "80bbf22798b0087c"
        ],
        "x": 34,
        "y": 59,
        "w": 992,
        "h": 142
    },
    {
        "id": "c64c5ecbe0654837",
        "type": "group",
        "z": "5fe7408840b1cdf3",
        "style": {
            "stroke": "#999999",
            "stroke-opacity": "1",
            "fill": "none",
            "fill-opacity": "1",
            "label": true,
            "label-position": "nw",
            "color": "#a4a4a4"
        },
        "nodes": [
            "3401e9994f1b41b7",
            "2d3a9307be086227",
            "8e2f3efe43139cbb",
            "309cfd45dc936c74",
            "3771889792a45c9c",
            "30724406b2c3e9a6",
            "fce8b570e7de2979",
            "2f7797d5a59eb37f"
        ],
        "x": 34,
        "y": 219,
        "w": 1032,
        "h": 222
    },
    {
        "id": "27c9a9a63c937a72",
        "type": "group",
        "z": "5fe7408840b1cdf3",
        "style": {
            "stroke": "#999999",
            "stroke-opacity": "1",
            "fill": "none",
            "fill-opacity": "1",
            "label": true,
            "label-position": "nw",
            "color": "#a4a4a4"
        },
        "nodes": [
            "56c0dd1a9c9f59d5",
            "30fc5ca1af8c2544",
            "573c180d02c5fc31"
        ],
        "x": 194,
        "y": 1639,
        "w": 492,
        "h": 122
    },
    {
        "id": "f7b3a04d7293fa0c",
        "type": "group",
        "z": "5fe7408840b1cdf3",
        "style": {
            "stroke": "#999999",
            "stroke-opacity": "1",
            "fill": "none",
            "fill-opacity": "1",
            "label": true,
            "label-position": "nw",
            "color": "#a4a4a4"
        },
        "nodes": [
            "c3ef0fbdd807d8bc",
            "a45f19bbc80e5a79",
            "fda0c5fc9d3a3778",
            "2b5dea20fe8df2e3",
            "e95a6ca3e8f41607",
            "1cd50a3b5905c265"
        ],
        "x": 54,
        "y": 1779,
        "w": 732,
        "h": 222
    },
    {
        "id": "0ffa85f6b2707ef1",
        "type": "group",
        "z": "5fe7408840b1cdf3",
        "style": {
            "stroke": "#999999",
            "stroke-opacity": "1",
            "fill": "none",
            "fill-opacity": "1",
            "label": true,
            "label-position": "nw",
            "color": "#a4a4a4"
        },
        "nodes": [
            "cc13594f0954243c",
            "1ad72d51e46cbe39",
            "22983449d6bd5095",
            "ddbae0defddeb15a",
            "5f4b44a3e04b450a",
            "35c49a11dd96c53d",
            "b8be24fef26d691b",
            "2b0b581c676e7291",
            "7dbb8d64c879169d",
            "63fe66bb76b0356c",
            "4dbdbae84b4d7390",
            "059db6a88f526987",
            "cfd34293e830d3cd"
        ],
        "x": 34,
        "y": 1319,
        "w": 832,
        "h": 302
    },
    {
        "id": "f8afda3620427140",
        "type": "group",
        "z": "5fe7408840b1cdf3",
        "style": {
            "stroke": "#999999",
            "stroke-opacity": "1",
            "fill": "none",
            "fill-opacity": "1",
            "label": true,
            "label-position": "nw",
            "color": "#a4a4a4"
        },
        "nodes": [
            "0af06d70cb632e52",
            "658208f94208bd9f",
            "f7ddcf1e407745e9",
            "67bc6865c7406fee",
            "701744df73f35cf2",
            "b70ceb2c221e3bd2",
            "a0b8045a6d72c0eb",
            "071b658e8fc627e4",
            "6d096b019c095341",
            "233a80856735ebfb",
            "58133dfe4f341acc"
        ],
        "x": 34,
        "y": 1019,
        "w": 1032,
        "h": 282
    },
    {
        "id": "9669903f47819092",
        "type": "group",
        "z": "5fe7408840b1cdf3",
        "style": {
            "stroke": "#999999",
            "stroke-opacity": "1",
            "fill": "none",
            "fill-opacity": "1",
            "label": true,
            "label-position": "nw",
            "color": "#a4a4a4"
        },
        "nodes": [
            "9cf84313ff660683",
            "4047bb58bd817588",
            "e23bf7ba06f7abf5",
            "38b5d65f62960b22",
            "cbe71d4c64597512",
            "c7bade8283007b6b",
            "31630e5509a5a01c",
            "665a65c2ac7c84ce",
            "84e18d2c39c07038",
            "7c1b7bd28ca87a43",
            "e63917c57e76284a",
            "5eacf6a66623ab28",
            "5ddb73a1d5111255",
            "d9d9937809ad2185",
            "7a72bd69fea8ec17",
            "f5bd2bcbcc8eb176",
            "7b1ac5814db8a9ac",
            "4b84dbc2655e1bcd",
            "a8b2fec6fdd2265b",
            "ce7397f52af03a02",
            "8ef38bc25af154c7",
            "6c53e12e5fa4b06e",
            "733ab2eac5f9cbac",
            "be13222435c38a50",
            "83b23b5304a18717",
            "4654dd1c8bf3344e",
            "317a0cfb20712290",
            "8869e559a62cac58"
        ],
        "x": 34,
        "y": 479,
        "w": 1132,
        "h": 522
    },
    {
        "id": "0af06d70cb632e52",
        "type": "change",
        "z": "5fe7408840b1cdf3",
        "g": "f8afda3620427140",
        "name": "SOC",
        "rules": [
            {
                "t": "set",
                "p": "soc",
                "pt": "flow",
                "to": "payload",
                "tot": "msg"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 310,
        "y": 1260,
        "wires": [
            [
                "67bc6865c7406fee"
            ]
        ]
    },
    {
        "id": "d0900f1bce2a4111",
        "type": "change",
        "z": "5fe7408840b1cdf3",
        "g": "58504cbc7c33f631",
        "name": "Save previ_prod",
        "rules": [
            {
                "t": "set",
                "p": "previ_prod",
                "pt": "flow",
                "to": "payload",
                "tot": "msg"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 700,
        "y": 160,
        "wires": [
            [
                "9a02956e8c37d773",
                "2d3a9307be086227"
            ]
        ]
    },
    {
        "id": "9cf84313ff660683",
        "type": "comment",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "name": "Calcul du niveau SOC pour la charge programmée",
        "info": "",
        "x": 590,
        "y": 520,
        "wires": []
    },
    {
        "id": "4047bb58bd817588",
        "type": "mqtt out",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "name": "Cible SOC",
        "topic": "mp2/dess_remy/cible_soc",
        "qos": "",
        "retain": "",
        "respTopic": "",
        "contentType": "",
        "userProps": "",
        "correl": "",
        "expiry": "",
        "broker": "502248144035edbf",
        "x": 710,
        "y": 920,
        "wires": []
    },
    {
        "id": "e23bf7ba06f7abf5",
        "type": "function",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "name": "Pilotage CP1 V6",
        "func": "// ===================================================================\n// 🔋 Pilotage CP1 V6 : gestion charge programmée 22h–6h\n// Garde le calcul du SoC théorique,\n// ===================================================================\n\n// === Lecture des variables ===\nlet soc      = flow.get('soc');            // SoC actuel (%)\nlet prod     = flow.get('previ_prod');     // Prévision de production (kWh)\nlet f100     = flow.get('forc100');        // Forçage manuel (on/off)\nlet nfcp1    = flow.get('niveauforcp1');   // Niveau CP1 forcé\nlet havalid  = global.get('valid_cp_ess'); // Autorisation HA (\"on\"/true)\nlet hdeb     = flow.get('hdebut');         // Heure début HC (min)\nlet hfin     = flow.get('hfin');           // Heure fin HC (min)\nlet horloge  = flow.get('horloge');        // 1 = fenêtre 22h–6h\nlet soc_cible_calc = Number(flow.get('dess_soc_cible_pct')); // SoC cible calculé via DoD J+1\n\n// Normalisation HA_ON\nconst HA_ON = (havalid === \"on\" || havalid === true || havalid === 1);\n\n// === Constantes ===\nconst a = 120;   // Base de calcul du SoC cible\nconst b = 2.5;   // Pondération selon la prévision\n\n// === Variables locales ===\nlet seuil = 0;\nlet valid   = { payload: 7 };\nlet devalid = { payload: -1 };\nlet debug   = {};\nlet duree   = {};\nlet hDebutS = {};\nlet autoconsoMsg = { payload: 0 }; // 1->PV+Batterie / 0->PV\n\n// ===================================================================\n// Utils HH:MM depuis minutes (0..1439), robuste\n// ===================================================================\nfunction hhmmFromMin(mins) {\n  mins = Number(mins);\n  if (!Number.isFinite(mins)) return \"??:??\";\n  mins = ((Math.round(mins) % 1440) + 1440) % 1440;\n  const h = Math.floor(mins / 60);\n  const m = mins % 60;\n  return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;\n}\n\n// ===================================================================\n// 1️⃣ Calcul du SEUIL cible SoC\n// Priorité : SoC cible calculé (DoD J+1) -> sinon fallback a - b*prod\n// ===================================================================\n\n// Fallback historique (si pas de calcul DoD dispo)\nseuil = a - (b * prod);\n\n// Si le SoC cible calculé est valide, il remplace le fallback\nif (Number.isFinite(soc_cible_calc) && soc_cible_calc > 0) {\n  seuil = soc_cible_calc;\n}\n\n// Forçage manuel prioritaire sur tout le reste\nif (f100 == 'on') {\n  seuil = nfcp1;\n}\n\n// Arrondi / bornage\nseuil = Math.round(seuil);\nif (seuil < 15) seuil = 0;\nif (seuil > 100) seuil = 100;\n\nflow.set(\"seuil_soc\", seuil);\n\n// ===================================================================\n// 2️⃣ Calcul de la durée et heure début HC (pour compatibilité)\n// ===================================================================\nlet h_corr = Number(hdeb) / 60;\nlet fin = Number(hfin) / 60;\n\n// garde-fous simples\nif (!Number.isFinite(h_corr)) h_corr = 0;\nif (!Number.isFinite(fin)) fin = 0;\n\nlet tps = (h_corr > fin)\n  ? (24 - h_corr + fin) * 3600\n  : (fin - h_corr) * 3600;\n\nduree   = { payload: tps };\nhDebutS = { payload: h_corr * 3600 };\n\n// Format durée HH:MM\nlet h = Math.floor(tps / 3600);\nlet m = Math.floor((tps % 3600) / 60);\nlet duree_hhmm = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`;\n\n// Heures début/fin en clair (HH:MM) depuis minutes\nconst hdeb_hhmm = hhmmFromMin(hdeb);\nconst hfin_hhmm = hhmmFromMin(hfin);\n\n// ===================================================================\n// 3️⃣ Sortie 1 : Activation charge programmée Victron (CP1)\n// Règle : on n'active CP1 que si SoC < Seuil (avec hystérésis)\n// Forçage f100 garde la priorité\n// ===================================================================\n\nconst HYST = 1.0; // %\n\nlet actif_payload = -1;\n\n// Normaliser SoC en nombre\nsoc = Number(soc);\nif (!Number.isFinite(soc)) soc = 0;\n\n// Décision CP1 hors forçage\nlet besoin_charge = false;\n\n// Si seuil==0 => on considère \"pas de cible\", donc pas de CP1 automatique\nif (seuil > 0) {\n  const prev = (flow.get('ess_actif') === true);\n\n  if (soc <= (seuil - HYST)) besoin_charge = true;\n  else if (soc >= (seuil + HYST)) besoin_charge = false;\n  else besoin_charge = prev;\n}\n\n// Forçage manuel prioritaire (si f100 ON, on active CP1 pendant la fenêtre HC)\nconst f100_actif = (f100 === 'on');\n\nif (HA_ON && horloge === 1) {\n  if (f100_actif) {\n    actif_payload = 7;\n  } else {\n    actif_payload = besoin_charge ? 7 : -1;\n  }\n} else {\n  actif_payload = -1;\n}\n\nlet ess_actif = (actif_payload === 7);\nflow.set('ess_actif', ess_actif);\n\n// ===================================================================\n// 4️⃣ Log synthétique (avec Hdeb/Hfin en clair)\n// ===================================================================\nlet f100_txt   = f100_actif ? \"ON\" : \"OFF\";\nlet nfcp1_txt  = f100_actif ? `${nfcp1}%` : \"-\";\nlet besoin_txt = f100_actif ? \"FORCE\" : (besoin_charge ? \"ON\" : \"OFF\");\n\nlet tag =\n  (!HA_ON)\n    ? \"🚫 HA OFF\"\n    : (horloge === 1\n        ? \"⚡ CP1 actif (22–6h)\"\n        : \"⏱️ CP1 inactif\");\n\nlet logTxt =\n  `${tag}` +\n  ` | SoC=${soc}%` +\n  ` | Seuil=${seuil}%` +\n  ` | Hdeb=${hdeb_hhmm}` +\n  ` | Hfin=${hfin_hhmm}` +\n  ` | DuréeHC=${duree_hhmm}` +\n  ` | Forçage=${f100_txt}` +\n  ` | NiveauForcé=${nfcp1_txt}` +\n  ` | Horloge=${horloge}` +\n  ` | havalid=${havalid}` +\n  ` | Sortie1=${actif_payload}` +\n  ` | BesoinCP=${besoin_txt}` +\n  ` | Hyst=${HYST}%`;\n\ndebug = { payload: logTxt };\n\n// ===================================================================\n// 5️⃣ Sorties Node-RED\n// ===================================================================\nlet seuilMsg = { payload: seuil };\n\nreturn [\n  (actif_payload === 7) ? valid : devalid, // 1. Commande ESS (7 / -1)\n  hDebutS,                                  // 2. Heure de début (s)\n  duree,                                    // 3. Durée (s)\n  debug,                                    // 4. Log texte\n  seuilMsg,                                 // 5. Seuil SoC\n  autoconsoMsg                              // 6. Autoconso placeholder\n];\n",
        "outputs": 6,
        "timeout": "",
        "noerr": 0,
        "initialize": "// Le code ajouté ici sera exécuté une fois\n// à chaque démarrage du noeud.\nflow.set('cligno', \"off\");",
        "finalize": "",
        "libs": [],
        "x": 560,
        "y": 780,
        "wires": [
            [
                "7a72bd69fea8ec17",
                "cbe71d4c64597512"
            ],
            [
                "c7bade8283007b6b",
                "317a0cfb20712290"
            ],
            [
                "31630e5509a5a01c",
                "4654dd1c8bf3344e"
            ],
            [
                "d9d9937809ad2185",
                "7b1ac5814db8a9ac"
            ],
            [
                "38b5d65f62960b22",
                "4047bb58bd817588",
                "4b84dbc2655e1bcd"
            ],
            [
                "f5bd2bcbcc8eb176"
            ]
        ],
        "outputLabels": [
            "Validation",
            "Heure Début",
            "Durée",
            "Log",
            "Ecart SOC",
            "H Début"
        ]
    },
    {
        "id": "38b5d65f62960b22",
        "type": "victron-output-ess",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "service": "com.victronenergy.settings",
        "path": "/Settings/CGwacs/BatteryLife/Schedule/Charge/0/Soc",
        "serviceObj": {
            "service": "com.victronenergy.settings",
            "name": "Venus settings"
        },
        "pathObj": {
            "path": "/Settings/CGwacs/BatteryLife/Schedule/Charge/0/Soc",
            "type": "integer",
            "name": "Schedule 1: State of charge (%)",
            "writable": true
        },
        "initial": "",
        "name": "Cible SOC ",
        "onlyChanges": false,
        "x": 890,
        "y": 900,
        "wires": []
    },
    {
        "id": "cbe71d4c64597512",
        "type": "victron-output-ess",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "service": "com.victronenergy.settings",
        "path": "/Settings/CGwacs/BatteryLife/Schedule/Charge/0/Day",
        "serviceObj": {
            "service": "com.victronenergy.settings",
            "name": "Venus settings"
        },
        "pathObj": {
            "path": "/Settings/CGwacs/BatteryLife/Schedule/Charge/0/Day",
            "type": "enum",
            "name": "Schedule 1: Day",
            "enum": {
                "0": "Sunday",
                "1": "Monday",
                "2": "Tuesday",
                "3": "Wednesday",
                "4": "Thursday",
                "5": "Friday",
                "6": "Saturday",
                "7": "Every day",
                "8": "Weekdays",
                "9": "Weekends",
                "11": "Monthly",
                "-1": "Disabled"
            },
            "remarks": "<p>A negative value means that the schedule has been de-activated.</p>",
            "mode": "both"
        },
        "name": "Validation CP",
        "onlyChanges": false,
        "x": 800,
        "y": 560,
        "wires": []
    },
    {
        "id": "c7bade8283007b6b",
        "type": "victron-output-ess",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "service": "com.victronenergy.settings",
        "path": "/Settings/CGwacs/BatteryLife/Schedule/Charge/0/Start",
        "serviceObj": {
            "service": "com.victronenergy.settings",
            "name": "Venus settings"
        },
        "pathObj": {
            "path": "/Settings/CGwacs/BatteryLife/Schedule/Charge/0/Start",
            "type": "integer",
            "name": "Schedule 1: Start (seconds after midnight)",
            "writable": true
        },
        "initial": "",
        "name": "Heure Début",
        "onlyChanges": false,
        "x": 770,
        "y": 720,
        "wires": []
    },
    {
        "id": "31630e5509a5a01c",
        "type": "victron-output-ess",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "service": "com.victronenergy.settings",
        "path": "/Settings/CGwacs/BatteryLife/Schedule/Charge/0/Duration",
        "serviceObj": {
            "service": "com.victronenergy.settings",
            "name": "Venus settings"
        },
        "pathObj": {
            "path": "/Settings/CGwacs/BatteryLife/Schedule/Charge/0/Duration",
            "type": "integer",
            "name": "Schedule 1: Duration (seconds)",
            "writable": true
        },
        "initial": "",
        "name": "Durée",
        "onlyChanges": false,
        "x": 790,
        "y": 780,
        "wires": []
    },
    {
        "id": "665a65c2ac7c84ce",
        "type": "bigtimer",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "outtopic": "",
        "outpayload1": "",
        "outpayload2": "",
        "name": "CP 22->6",
        "comment": "",
        "lat": "43.91905434993742",
        "lon": "2.1979451056884747",
        "starttime": "1320",
        "endtime": "360",
        "starttime2": "0",
        "endtime2": "0",
        "startoff": 0,
        "endoff": 0,
        "startoff2": 0,
        "endoff2": 0,
        "offs": 0,
        "outtext1": "\"on\"",
        "outtext2": "\"off\"",
        "timeout": 1440,
        "sun": true,
        "mon": true,
        "tue": true,
        "wed": true,
        "thu": true,
        "fri": true,
        "sat": true,
        "jan": true,
        "feb": true,
        "mar": true,
        "apr": true,
        "may": true,
        "jun": true,
        "jul": true,
        "aug": true,
        "sep": true,
        "oct": true,
        "nov": true,
        "dec": true,
        "day1": 0,
        "month1": 0,
        "day2": 0,
        "month2": 0,
        "day3": 0,
        "month3": 0,
        "day4": 0,
        "month4": 0,
        "day5": 0,
        "month5": 0,
        "day6": 0,
        "month6": 0,
        "day7": 0,
        "month7": 0,
        "day8": 0,
        "month8": 0,
        "day9": 0,
        "month9": 0,
        "day10": 0,
        "month10": 0,
        "day11": 0,
        "month11": 0,
        "day12": 0,
        "month12": 0,
        "d1": 0,
        "w1": 0,
        "d2": 0,
        "w2": 0,
        "d3": 0,
        "w3": 0,
        "d4": 0,
        "w4": 0,
        "d5": 0,
        "w5": 0,
        "d6": 0,
        "w6": 0,
        "xday1": 0,
        "xmonth1": 0,
        "xday2": 0,
        "xmonth2": 0,
        "xday3": 0,
        "xmonth3": 0,
        "xday4": 0,
        "xmonth4": 0,
        "xday5": 0,
        "xmonth5": 0,
        "xday6": 0,
        "xmonth6": 0,
        "xday7": 0,
        "xmonth7": 0,
        "xday8": 0,
        "xmonth8": 0,
        "xday9": 0,
        "xmonth9": 0,
        "xday10": 0,
        "xmonth10": 0,
        "xday11": 0,
        "xmonth11": 0,
        "xday12": 0,
        "xmonth12": 0,
        "xd1": 0,
        "xw1": 0,
        "xd2": 0,
        "xw2": 0,
        "xd3": 0,
        "xw3": 0,
        "xd4": 0,
        "xw4": 0,
        "xd5": 0,
        "xw5": 0,
        "xd6": 0,
        "xw6": 0,
        "suspend": false,
        "random": false,
        "randon1": false,
        "randoff1": false,
        "randon2": false,
        "randoff2": false,
        "repeat": true,
        "atstart": true,
        "odd": false,
        "even": false,
        "x": 160,
        "y": 880,
        "wires": [
            [],
            [
                "8869e559a62cac58"
            ],
            []
        ]
    },
    {
        "id": "84e18d2c39c07038",
        "type": "change",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "name": "F100",
        "rules": [
            {
                "t": "set",
                "p": "forc100",
                "pt": "flow",
                "to": "payload",
                "tot": "msg"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 330,
        "y": 760,
        "wires": [
            [
                "e23bf7ba06f7abf5"
            ]
        ]
    },
    {
        "id": "7c1b7bd28ca87a43",
        "type": "change",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "name": "VCP",
        "rules": [
            {
                "t": "set",
                "p": "valid_cp_ess",
                "pt": "global",
                "to": "payload",
                "tot": "msg"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 330,
        "y": 620,
        "wires": [
            [
                "e23bf7ba06f7abf5"
            ]
        ]
    },
    {
        "id": "e63917c57e76284a",
        "type": "change",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "name": "NF CP1",
        "rules": [
            {
                "t": "set",
                "p": "niveauforcp1",
                "pt": "flow",
                "to": "payload",
                "tot": "msg"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 340,
        "y": 700,
        "wires": [
            [
                "e23bf7ba06f7abf5"
            ]
        ]
    },
    {
        "id": "5eacf6a66623ab28",
        "type": "mqtt out",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "name": "Valid CP",
        "topic": "mp2/multiplus2/valide_cp",
        "qos": "",
        "retain": "",
        "respTopic": "",
        "contentType": "",
        "userProps": "",
        "correl": "",
        "expiry": "",
        "broker": "502248144035edbf",
        "x": 1020,
        "y": 600,
        "wires": []
    },
    {
        "id": "5ddb73a1d5111255",
        "type": "mqtt out",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "name": "H DEBUT",
        "topic": "mp2/dess_remy/h_debut",
        "qos": "",
        "retain": "",
        "respTopic": "",
        "contentType": "",
        "userProps": "",
        "correl": "",
        "expiry": "",
        "broker": "502248144035edbf",
        "x": 1080,
        "y": 720,
        "wires": []
    },
    {
        "id": "d9d9937809ad2185",
        "type": "debug",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "name": "debug 62",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": true,
        "complete": "true",
        "targetType": "full",
        "statusVal": "payload",
        "statusType": "auto",
        "x": 1040,
        "y": 860,
        "wires": []
    },
    {
        "id": "7a72bd69fea8ec17",
        "type": "function",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "name": "Convert",
        "func": "var code = msg.payload;\n\n// Tableau de correspondance code → texte\nvar mapper = {\n    \"-1\": \"off\",\n    \"7\":  \"on\"\n};\n\n// Fonction de mapping\nfunction extraireTexte(valeur) {\n    return mapper[valeur.toString()] || \"Etat inconnu\";\n}\n\n// Application du mapping\nvar texte = extraireTexte(code);\nmsg.payload = texte;\n\n// Affichage dans le status du node\nnode.status({\n    fill: (texte === \"on\") ? \"green\" : (texte === \"off\") ? \"grey\" : \"yellow\",\n    shape: \"dot\",\n    text: \"Etat = \" + texte + \" (\" + code + \")\"\n});\n\nreturn msg;\n",
        "outputs": 1,
        "timeout": "",
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 800,
        "y": 640,
        "wires": [
            [
                "5eacf6a66623ab28"
            ]
        ]
    },
    {
        "id": "658208f94208bd9f",
        "type": "ui_chart",
        "z": "5fe7408840b1cdf3",
        "g": "f8afda3620427140",
        "name": "",
        "group": "c8a7d12bb7f76f27",
        "order": 14,
        "width": 0,
        "height": 0,
        "label": "SOC",
        "chartType": "line",
        "legend": "false",
        "xformat": "HH:mm:ss",
        "interpolate": "linear",
        "nodata": "",
        "dot": false,
        "ymin": "0",
        "ymax": "100",
        "removeOlder": "2",
        "removeOlderPoints": "",
        "removeOlderUnit": "3600",
        "cutout": 0,
        "useOneColor": false,
        "useUTC": true,
        "colors": [
            "#1f77b4",
            "#aec7e8",
            "#ff7f0e",
            "#2ca02c",
            "#98df8a",
            "#d62728",
            "#ff9896",
            "#9467bd",
            "#c5b0d5"
        ],
        "outputs": 1,
        "useDifferentColor": false,
        "className": "",
        "x": 990,
        "y": 1260,
        "wires": [
            []
        ]
    },
    {
        "id": "f5bd2bcbcc8eb176",
        "type": "victron-output-ess",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "service": "com.victronenergy.settings",
        "path": "/Settings/CGwacs/BatteryLife/Schedule/Charge/0/AllowDischarge",
        "serviceObj": {
            "service": "com.victronenergy.settings",
            "name": "Venus settings"
        },
        "pathObj": {
            "path": "/Settings/CGwacs/BatteryLife/Schedule/Charge/0/AllowDischarge",
            "type": "enum",
            "name": "Schedule 1: Self-consumption above limit",
            "enum": {
                "0": "Yes",
                "1": "No"
            },
            "mode": "both"
        },
        "initial": 0,
        "name": "Autoconsommation",
        "onlyChanges": false,
        "x": 930,
        "y": 960,
        "wires": []
    },
    {
        "id": "7b1ac5814db8a9ac",
        "type": "ui_text",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "group": "c8a7d12bb7f76f27",
        "order": 3,
        "width": "12",
        "height": "2",
        "name": "",
        "label": "Log CP1:",
        "format": "{{msg.payload}}",
        "layout": "row-spread",
        "className": "",
        "style": false,
        "font": "",
        "fontSize": 16,
        "color": "#000000",
        "x": 880,
        "y": 860,
        "wires": []
    },
    {
        "id": "9a02956e8c37d773",
        "type": "ui_gauge",
        "z": "5fe7408840b1cdf3",
        "g": "58504cbc7c33f631",
        "name": "",
        "group": "c8a7d12bb7f76f27",
        "order": 12,
        "width": "3",
        "height": "3",
        "gtype": "gage",
        "title": "Previ Prod J",
        "label": "kWh",
        "format": "{{value}}",
        "min": 0,
        "max": "30",
        "colors": [
            "#00b500",
            "#e6e600",
            "#ca3838"
        ],
        "seg1": "",
        "seg2": "",
        "diff": false,
        "className": "",
        "x": 930,
        "y": 160,
        "wires": []
    },
    {
        "id": "f7ddcf1e407745e9",
        "type": "victron-input-battery",
        "z": "5fe7408840b1cdf3",
        "g": "f8afda3620427140",
        "service": "com.victronenergy.battery/277",
        "path": "/Soc",
        "serviceObj": {
            "service": "com.victronenergy.battery/277",
            "name": "SmartShunt 500A/50mV"
        },
        "pathObj": {
            "path": "/Soc",
            "type": "float",
            "name": "State of charge (%)"
        },
        "initial": "",
        "name": "SOC Batteries",
        "onlyChanges": false,
        "roundValues": "2",
        "x": 150,
        "y": 1260,
        "wires": [
            [
                "0af06d70cb632e52"
            ]
        ]
    },
    {
        "id": "4b84dbc2655e1bcd",
        "type": "ui_gauge",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "name": "",
        "group": "c8a7d12bb7f76f27",
        "order": 10,
        "width": "3",
        "height": "3",
        "gtype": "gage",
        "title": "SOC Cible",
        "label": "%",
        "format": "{{value}}",
        "min": 0,
        "max": "100",
        "colors": [
            "#00b500",
            "#e6e600",
            "#ca3838"
        ],
        "seg1": "",
        "seg2": "",
        "diff": false,
        "className": "",
        "x": 1070,
        "y": 920,
        "wires": []
    },
    {
        "id": "6027c79e01cbefde",
        "type": "inject",
        "z": "5fe7408840b1cdf3",
        "g": "58504cbc7c33f631",
        "name": "21:45",
        "props": [
            {
                "p": "payload"
            },
            {
                "p": "topic",
                "vt": "str"
            }
        ],
        "repeat": "",
        "crontab": "45 21 * * *",
        "once": true,
        "onceDelay": "5",
        "topic": "",
        "payload": "",
        "payloadType": "date",
        "x": 140,
        "y": 160,
        "wires": [
            [
                "4f987019c9af7859"
            ]
        ]
    },
    {
        "id": "80bbf22798b0087c",
        "type": "comment",
        "z": "5fe7408840b1cdf3",
        "g": "58504cbc7c33f631",
        "name": "Récupération de la production PV estimée par VRM Victron",
        "info": "",
        "x": 530,
        "y": 100,
        "wires": []
    },
    {
        "id": "a8b2fec6fdd2265b",
        "type": "mqtt in",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "name": "Forcage CP 100%",
        "topic": "ha/mp2/cp/forcage100",
        "qos": "2",
        "datatype": "auto-detect",
        "broker": "502248144035edbf",
        "nl": false,
        "rap": true,
        "rh": 0,
        "inputs": 0,
        "x": 150,
        "y": 760,
        "wires": [
            [
                "84e18d2c39c07038",
                "733ab2eac5f9cbac"
            ]
        ]
    },
    {
        "id": "ce7397f52af03a02",
        "type": "mqtt in",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "name": "Valid ESS MP1",
        "topic": "ha/mp2/cp/validcp",
        "qos": "2",
        "datatype": "auto-detect",
        "broker": "502248144035edbf",
        "nl": false,
        "rap": true,
        "rh": 0,
        "inputs": 0,
        "x": 140,
        "y": 620,
        "wires": [
            [
                "be13222435c38a50",
                "7c1b7bd28ca87a43"
            ]
        ]
    },
    {
        "id": "8ef38bc25af154c7",
        "type": "mqtt in",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "name": "Niveau Forcage CP1",
        "topic": "ha/mp2/cp/niveauforcagecp1",
        "qos": "2",
        "datatype": "auto-detect",
        "broker": "502248144035edbf",
        "nl": false,
        "rap": true,
        "rh": 0,
        "inputs": 0,
        "x": 150,
        "y": 700,
        "wires": [
            [
                "e63917c57e76284a",
                "6c53e12e5fa4b06e"
            ]
        ]
    },
    {
        "id": "6c53e12e5fa4b06e",
        "type": "debug",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "name": "debug 69",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": true,
        "complete": "payload",
        "targetType": "msg",
        "statusVal": "payload",
        "statusType": "auto",
        "x": 540,
        "y": 620,
        "wires": []
    },
    {
        "id": "733ab2eac5f9cbac",
        "type": "debug",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "name": "debug 70",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": true,
        "complete": "payload",
        "targetType": "msg",
        "statusVal": "payload",
        "statusType": "auto",
        "x": 540,
        "y": 680,
        "wires": []
    },
    {
        "id": "be13222435c38a50",
        "type": "debug",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "name": "debug 71",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": true,
        "complete": "payload",
        "targetType": "msg",
        "statusVal": "payload",
        "statusType": "auto",
        "x": 520,
        "y": 560,
        "wires": []
    },
    {
        "id": "83b23b5304a18717",
        "type": "mqtt out",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "name": "Durée",
        "topic": "mp2/dess_remy/duree",
        "qos": "",
        "retain": "",
        "respTopic": "",
        "contentType": "",
        "userProps": "",
        "correl": "",
        "expiry": "",
        "broker": "502248144035edbf",
        "x": 1070,
        "y": 780,
        "wires": []
    },
    {
        "id": "4654dd1c8bf3344e",
        "type": "function",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "name": "Convert",
        "func": "// Entrée attendue : msg.payload = durée en secondes (number ou string)\n// Sortie : msg.payload = \"HH:MM\" (string)\n\nlet tps = Number(msg.payload);\n\n// Garde-fous\nif (!isFinite(tps) || tps < 0) {\n    node.status({\n        fill: \"red\",\n        shape: \"ring\",\n        text: \"Durée invalide\"\n    });\n    return null;\n}\n\n// Arrondi propre\ntps = Math.round(tps);\n\nconst h = Math.floor(tps / 3600);\nconst m = Math.floor((tps % 3600) / 60);\n\nconst hhmm = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;\nmsg.payload = hhmm;\n\n// Status Node-RED\nnode.status({\n    fill: \"blue\",\n    shape: \"dot\",\n    text: `Durée = ${hhmm} (${tps}s)`\n});\n\n// Optionnel : pour tracer\n// msg.topic = \"ton/topic/mqtt/duree_hhmm\";\n\nreturn msg;\n\n",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 940,
        "y": 780,
        "wires": [
            [
                "83b23b5304a18717"
            ]
        ]
    },
    {
        "id": "317a0cfb20712290",
        "type": "function",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "name": "Convert",
        "func": "// Entrée attendue : msg.payload = durée en secondes (number ou string)\n// Sortie : msg.payload = \"HH:MM\" (string)\n\nlet tps = Number(msg.payload);\n\n// Garde-fous\nif (!isFinite(tps) || tps < 0) {\n    node.status({\n        fill: \"red\",\n        shape: \"ring\",\n        text: \"H Début invalide\"\n    });\n    return null;\n}\n\n// Arrondi propre\ntps = Math.round(tps);\n\nconst h = Math.floor(tps / 3600);\nconst m = Math.floor((tps % 3600) / 60);\n\nconst hhmm = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;\nmsg.payload = hhmm;\n\n// Affichage dans le status du node\nnode.status({\n    fill: \"blue\",\n    shape: \"dot\",\n    text: `H Début = ${hhmm} (${tps}s)`\n});\n\nreturn msg;\n",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 920,
        "y": 720,
        "wires": [
            [
                "5ddb73a1d5111255"
            ]
        ]
    },
    {
        "id": "67bc6865c7406fee",
        "type": "function",
        "z": "5fe7408840b1cdf3",
        "g": "f8afda3620427140",
        "name": "SOC ts les 15mn",
        "func": "// Emission SoC toutes les 15 minutes (sans condition horloge)\n// - Au maximum 1 fois toutes les 15 minutes\n// - Met à jour flow.soc et laisse passer msg\n\nconst PERIOD_S = 15 * 60; // 15 minutes\n\nfunction fmtRemain(sec) {\n  sec = Math.max(0, Math.floor(sec));\n  const m = Math.floor(sec / 60);\n  const s = sec % 60;\n  return `${m}m${String(s).padStart(2,'0')}s`;\n}\n\n// SoC courant (source déjà en amont ou flow)\nconst soc = Number(flow.get('soc'));\nif (!isFinite(soc)) {\n  node.status({ fill: \"red\", shape: \"ring\", text: \"SoC invalide / absent\" });\n  return null;\n}\n\n// Rate limit 15 min\nconst nowTs  = Math.floor(Date.now() / 1000);\nconst lastTs = Number(flow.get('bt_last_ts'));\n\nif (isFinite(lastTs)) {\n  const dt = nowTs - lastTs;\n  if (dt < PERIOD_S) {\n    node.status({\n      fill: \"yellow\",\n      shape: \"dot\",\n      text: `Attente ${fmtRemain(PERIOD_S - dt)} | SoC=${soc.toFixed(1)}%`\n    });\n    return null;\n  }\n}\n\n// OK → mémorisation\nflow.set('bt_last_ts', nowTs);\nflow.set('bt_last_soc', soc); // optionnel diagnostic\nflow.set('soc', soc);         // garantit la dispo pour la suite\n\n// Status OK\nnode.status({\n  fill: \"green\",\n  shape: \"dot\",\n  text: `Emission SoC (15min) | SoC=${soc.toFixed(1)}%`\n});\n\n// Passe au node suivant (H_dynamique amont BigTimer, etc.)\nmsg.payload = soc; // optionnel: utile si le node suivant préfère msg.payload\nreturn msg;\n",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 530,
        "y": 1260,
        "wires": [
            [
                "a0b8045a6d72c0eb",
                "658208f94208bd9f",
                "6d096b019c095341"
            ]
        ]
    },
    {
        "id": "701744df73f35cf2",
        "type": "ui_text",
        "z": "5fe7408840b1cdf3",
        "g": "f8afda3620427140",
        "group": "c8a7d12bb7f76f27",
        "order": 2,
        "width": "12",
        "height": "2",
        "name": "",
        "label": "Log H Dyn:",
        "format": "{{msg.payload}}",
        "layout": "row-spread",
        "className": "",
        "style": false,
        "font": "",
        "fontSize": 16,
        "color": "#000000",
        "x": 690,
        "y": 1160,
        "wires": []
    },
    {
        "id": "2d3a9307be086227",
        "type": "vrm-api",
        "z": "5fe7408840b1cdf3",
        "g": "c64c5ecbe0654837",
        "vrm": "5e3a89e5eaeb1cb4",
        "name": "VRM Conso J+1",
        "api_type": "installations",
        "idUser": "",
        "idSite": "223181",
        "installations": "stats",
        "attribute": "vrm_consumption_fc",
        "stats_interval": "hours",
        "show_instance": false,
        "stats_start": "bot",
        "stats_end": "86400",
        "use_utc": false,
        "gps_start": "",
        "gps_end": "",
        "instance": "",
        "vrm_id": "",
        "b_max": "",
        "tb_max": "",
        "fb_max": "",
        "tg_max": "",
        "fg_max": "",
        "b_cycle_cost": "",
        "buy_price_formula": "",
        "sell_price_formula": "",
        "green_mode_on": true,
        "feed_in_possible": true,
        "feed_in_control_on": true,
        "b_goal_hour": "",
        "b_goal_SOC": "",
        "store_in_global_context": false,
        "verbose": false,
        "x": 140,
        "y": 320,
        "wires": [
            [
                "8e2f3efe43139cbb"
            ]
        ]
    },
    {
        "id": "3401e9994f1b41b7",
        "type": "comment",
        "z": "5fe7408840b1cdf3",
        "g": "c64c5ecbe0654837",
        "name": "Récupération de Consommation J+1 estimée par Victron et Calcul SOC Cible",
        "info": "",
        "x": 550,
        "y": 260,
        "wires": []
    },
    {
        "id": "309cfd45dc936c74",
        "type": "change",
        "z": "5fe7408840b1cdf3",
        "g": "c64c5ecbe0654837",
        "name": "Conso_j1",
        "rules": [
            {
                "t": "set",
                "p": "conso_j1",
                "pt": "flow",
                "to": "payload",
                "tot": "msg"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 540,
        "y": 320,
        "wires": [
            [
                "30724406b2c3e9a6",
                "3771889792a45c9c"
            ]
        ],
        "info": "*** mermaid\njourney\n    title My working day\n    section Go to work\n      Make tea: 5: Me\n      Go upstairs: 3: Me\n      Do work: 1: Me, Cat\n    section Go home\n      Go downstairs: 5: Me\n      Sit down: 5: Me"
    },
    {
        "id": "fce8b570e7de2979",
        "type": "ui_text",
        "z": "5fe7408840b1cdf3",
        "g": "c64c5ecbe0654837",
        "group": "c8a7d12bb7f76f27",
        "order": 1,
        "width": "12",
        "height": "2",
        "name": "",
        "label": "Cible Soc :",
        "format": "{{msg.payload}}",
        "layout": "row-spread",
        "className": "",
        "style": false,
        "font": "",
        "fontSize": 16,
        "color": "#000000",
        "x": 970,
        "y": 320,
        "wires": []
    },
    {
        "id": "b70ceb2c221e3bd2",
        "type": "inject",
        "z": "5fe7408840b1cdf3",
        "g": "f8afda3620427140",
        "name": "",
        "props": [
            {
                "p": "payload"
            },
            {
                "p": "topic",
                "vt": "str"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": true,
        "onceDelay": "2",
        "topic": "",
        "payload": "",
        "payloadType": "date",
        "x": 150,
        "y": 1200,
        "wires": [
            [
                "6d096b019c095341"
            ]
        ]
    },
    {
        "id": "a0b8045a6d72c0eb",
        "type": "ui_gauge",
        "z": "5fe7408840b1cdf3",
        "g": "f8afda3620427140",
        "name": "",
        "group": "c8a7d12bb7f76f27",
        "order": 8,
        "width": "3",
        "height": "3",
        "gtype": "gage",
        "title": "SOC Calib",
        "label": "%",
        "format": "{{value}}",
        "min": 0,
        "max": "100",
        "colors": [
            "#00b500",
            "#e6e600",
            "#ca3838"
        ],
        "seg1": "",
        "seg2": "",
        "diff": false,
        "className": "",
        "x": 810,
        "y": 1260,
        "wires": []
    },
    {
        "id": "8e2f3efe43139cbb",
        "type": "function",
        "z": "5fe7408840b1cdf3",
        "g": "c64c5ecbe0654837",
        "name": "Extraction 6-22h",
        "func": "// ===================================================================\n// 🔋 Prévision de consommation 6h–22h (jour J+1) à partir du JSON VRM\n// Exemple de données :\n// \"records\": { \"vrm_consumption_fc\": [[timestamp, Wh], [timestamp, Wh], ...] }\n// ===================================================================\n\n// Lecture du JSON\nlet data = msg.payload;\n\nif (!data || !data.records || !data.records.vrm_consumption_fc) {\n    node.warn(\"⚠️ Données 'vrm_consumption_fc' manquantes !\");\n    msg.payload = null;\n    return msg;\n}\n\nlet series = data.records.vrm_consumption_fc;\nif (!Array.isArray(series) || series.length === 0) {\n    node.warn(\"⚠️ Série 'vrm_consumption_fc' vide ou invalide !\");\n    msg.payload = null;\n    return msg;\n}\n\n// ===================================================================\n// 1️⃣ Détermination du jour du lendemain (en local)\n// ===================================================================\nlet now = new Date();\nlet tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);\n\n// Fonction de comparaison de date locale\nfunction sameYMD(a, b) {\n    return a.getFullYear() === b.getFullYear() &&\n           a.getMonth() === b.getMonth() &&\n           a.getDate() === b.getDate();\n}\n\n// ===================================================================\n// 2️⃣ Somme des valeurs entre 6h00 et 22h00 (jour J+1)\n// ===================================================================\nlet sommeWh = 0;\nfor (let i = 0; i < series.length; i++) {\n    let timestamp = series[i][0];\n    let valeurWh  = series[i][1];\n    if (!timestamp || isNaN(valeurWh)) continue;\n\n    let date = new Date(timestamp);\n    let h = date.getHours();\n\n    // Vérifie que c'est le jour J+1 et dans la fenêtre 6–22h\n    if (sameYMD(date, tomorrow) && h >= 6 && h < 22) {\n        sommeWh += valeurWh;\n    }\n}\n\n// ===================================================================\n// 3️⃣ Conversion Wh → kWh et arrondi\n// ===================================================================\nlet total_kWh = Math.round((sommeWh / 1000) * 10) / 10;  // 1 décimale\n\n// ===================================================================\n// 4️⃣ Sortie et log\n// ===================================================================\nmsg.payload = total_kWh;\nmsg.topic = \"victron/previ_conso_6_22\";\n\nflow.set('previ_conso_6_22', total_kWh); // optionnel : mémorisation en flow\n\nnode.status({ fill: \"green\", shape: \"dot\", text: `Prévi conso 6–22h = ${total_kWh} kWh` });\n\nreturn msg;\n",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 340,
        "y": 320,
        "wires": [
            [
                "309cfd45dc936c74"
            ]
        ]
    },
    {
        "id": "4f987019c9af7859",
        "type": "vrm-api",
        "z": "5fe7408840b1cdf3",
        "g": "58504cbc7c33f631",
        "vrm": "5e3a89e5eaeb1cb4",
        "name": "Prod J+1",
        "api_type": "installations",
        "idUser": "",
        "idSite": "223181",
        "installations": "stats",
        "attribute": "solar_yield_forecast",
        "stats_interval": "hours",
        "show_instance": false,
        "stats_start": "bot",
        "stats_end": "86400",
        "use_utc": false,
        "gps_start": "",
        "gps_end": "",
        "instance": "",
        "vrm_id": "",
        "b_max": "",
        "tb_max": "",
        "fb_max": "",
        "tg_max": "",
        "fg_max": "",
        "b_cycle_cost": "",
        "buy_price_formula": "",
        "sell_price_formula": "",
        "green_mode_on": true,
        "feed_in_possible": true,
        "feed_in_control_on": true,
        "b_goal_hour": "",
        "b_goal_SOC": "",
        "store_in_global_context": false,
        "verbose": false,
        "x": 300,
        "y": 160,
        "wires": [
            [
                "59baa58061ee6884"
            ]
        ]
    },
    {
        "id": "59baa58061ee6884",
        "type": "function",
        "z": "5fe7408840b1cdf3",
        "g": "58504cbc7c33f631",
        "name": "Extrac 6:22",
        "func": "// ===================================================================\n// ☀️ Prévision PV J+1 entre 06:00 et 22:00 (kWh)\n// Source : records.solar_yield_forecast = [[timestamp_ms, Wh], ...]\n// JSON VRM tel que : msg.payload.records.solar_yield_forecast\n// ===================================================================\n\nconst data = msg.payload;\n\nif (!data?.records?.solar_yield_forecast) {\n  node.warn(\"⚠️ Données 'solar_yield_forecast' manquantes !\");\n  node.status({ fill: \"red\", shape: \"ring\", text: \"PV forecast absent\" });\n  msg.payload = null;\n  return msg;\n}\n\nconst series = data.records.solar_yield_forecast;\nif (!Array.isArray(series) || series.length === 0) {\n  node.warn(\"⚠️ Série 'solar_yield_forecast' vide !\");\n  node.status({ fill: \"red\", shape: \"ring\", text: \"PV forecast vide\" });\n  msg.payload = null;\n  return msg;\n}\n\n// -------------------------------------------------------------------\n// 1) Détermination du jour J+1 (local)\n// -------------------------------------------------------------------\nconst now = new Date();\nconst tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);\n\nfunction sameYMD(a, b) {\n  return a.getFullYear() === b.getFullYear() &&\n         a.getMonth() === b.getMonth() &&\n         a.getDate() === b.getDate();\n}\n\n// -------------------------------------------------------------------\n// 2) Somme Wh entre 06:00 et 22:00 sur J+1\n// -------------------------------------------------------------------\nlet sumWh = 0;\nlet kept = 0;\n\nfor (const row of series) {\n  if (!Array.isArray(row) || row.length < 2) continue;\n\n  const ts = row[0];\n  const wh = Number(row[1]);\n\n  if (!Number.isFinite(ts) || !Number.isFinite(wh)) continue;\n\n  const d = new Date(ts);      // ts en millisecondes => OK\n  const h = d.getHours();\n\n  if (sameYMD(d, tomorrow) && h >= 6 && h < 22) {\n    sumWh += wh;\n    kept++;\n  }\n}\n\n// -------------------------------------------------------------------\n// 3) Conversion Wh -> kWh (1 décimale)\n// -------------------------------------------------------------------\nconst kWh_6_22 = Math.round((sumWh / 1000) * 10) / 10;\n\n// Optionnel : total jour depuis totals\nconst totalWh = Number(data?.totals?.solar_yield_forecast);\nconst kWh_total = Number.isFinite(totalWh)\n  ? Math.round((totalWh / 1000) * 10) / 10\n  : null;\n\n// -------------------------------------------------------------------\n// 4) Sortie + stockage flow + status\n// -------------------------------------------------------------------\nflow.set(\"previ_prod_6_22\", kWh_6_22);   // <- clé à utiliser dans ton calcul DoD\nif (kWh_total !== null) flow.set(\"previ_prod_total\", kWh_total);\n\nnode.status({\n  fill: \"green\",\n  shape: \"dot\",\n  text: `PV J+1 6–22 = ${kWh_6_22} kWh` + (kWh_total !== null ? ` (total=${kWh_total})` : \"\") + ` | pts=${kept}`\n});\n\nmsg.payload = kWh_6_22;\nmsg.topic = \"victron/solar_yield_forecast_6_22\";\n\nreturn msg;\n",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 490,
        "y": 160,
        "wires": [
            [
                "d0900f1bce2a4111"
            ]
        ]
    },
    {
        "id": "30724406b2c3e9a6",
        "type": "function",
        "z": "5fe7408840b1cdf3",
        "g": "c64c5ecbe0654837",
        "name": "Calcul Soc Cible V3",
        "func": "// ============================================\n// DESS perso - Calcul SoC cible @06:00 (V3 simple & cohérent)\n// Sorties :\n// 1) payload objet (dashboard / MQTT)\n// 2) log texte lisible (dashboard)\n// 3) log texte HA (fichier)\n// ============================================\n\n// ----------- ENTREES -----------\nconst conso_kWh = Number(flow.get('conso_j1'));     // kWh 06->22\nconst pv_kWh    = Number(flow.get('previ_prod'));   // kWh 06->22\nlet socNow      = Number(flow.get('soc'));          // %\n\n// -------- PARAMETRES BATTERIE --------\nconst batt_nom_kWh = 12.0;   // capacité utile réelle\nconst soc_min_pct  = 8;      // plancher dur\nconst soc_max_pct  = 100;\nconst eta          = 0.92;   // rendement global\n\n// -------- MARGES ----------\nconst marge_kWh     = 0.5;\nconst marge_soc_pct = 2.0;\n\n// -------- UTILS ----------\nfunction clamp(x, a, b) { return Math.max(a, Math.min(b, x)); }\nfunction ok(n) { return Number.isFinite(n); }\nfunction hhmm(d) {\n  return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;\n}\n\n// -------- VALIDATION ----------\nif (![conso_kWh, pv_kWh, socNow].every(ok)) {\n  node.status({ fill: \"red\", shape: \"ring\", text: \"Entrees manquantes\" });\n\n  const now = new Date();\n  const txt = `DESS_PERSO @ ${hhmm(now)} | ERREUR entrees | conso_j1=${flow.get('conso_j1')} | pv=${flow.get('previ_prod')} | soc=${flow.get('soc')}`;\n\n  return [\n    null,\n    { payload: txt },\n    { payload: txt }\n  ];\n}\n\nsocNow = clamp(socNow, 0, 100);\n\n// -------- CAPACITES ----------\nconst socMin = clamp(soc_min_pct, 0, 99);\nconst socMax = clamp(soc_max_pct, socMin + 1, 100);\n\nconst batt_utile_kWh =\n  Math.max(0.01, batt_nom_kWh * (socMax - socMin) / 100) * eta;\n\nconst fracNow = clamp((socNow - socMin) / (socMax - socMin), 0, 1);\nconst energie_dispo_kWh = fracNow * batt_utile_kWh;\n\n// -------- BESOIN ----------\nlet deficit_kWh = (conso_kWh - pv_kWh) + marge_kWh;\ndeficit_kWh = Math.max(0, deficit_kWh);\n\nconst dod = clamp(deficit_kWh / batt_utile_kWh, 0, 1);\n\nlet soc_cible = socMin + dod * (socMax - socMin);\nsoc_cible = clamp(soc_cible + marge_soc_pct, socMin, socMax);\n\nconst fracCible = clamp((soc_cible - socMin) / (socMax - socMin), 0, 1);\nconst energie_cible_kWh = fracCible * batt_utile_kWh;\n\nconst energie_a_charger_kWh = Math.max(0, energie_cible_kWh - energie_dispo_kWh);\nconst soc_a_charger_pct = clamp(soc_cible - socNow, 0, socMax - socMin);\n\n// -------- SORTIE 1 : OBJET ----------\nconst payload = {\n  conso_kWh: Number(conso_kWh.toFixed(2)),\n  pv_kWh: Number(pv_kWh.toFixed(2)),\n  deficit_kWh: Number(deficit_kWh.toFixed(2)),\n\n  soc_now_pct: Number(socNow.toFixed(1)),\n  soc_min_pct: socMin,\n  soc_cible_pct: Number(soc_cible.toFixed(1)),\n  soc_a_charger_pct: Number(soc_a_charger_pct.toFixed(1)),\n\n  batt_utile_kWh: Number(batt_utile_kWh.toFixed(2)),\n  energie_dispo_kWh: Number(energie_dispo_kWh.toFixed(2)),\n  energie_a_charger_kWh: Number(energie_a_charger_kWh.toFixed(2))\n};\n\nflow.set('dess_soc_cible_pct', payload.soc_cible_pct);\nflow.set('dess_dod_pct', Number((dod * 100).toFixed(1)));\n\nmsg.payload = payload;\n\n// -------- SORTIE 2 : LOG DASHBOARD ----------\nconst now = new Date();\nconst deltaSoc = payload.soc_cible_pct - payload.soc_now_pct;\n\nconst logDash =\n  `SOC Cible @ ${hhmm(now)}` +\n  ` | SoC=${payload.soc_now_pct}%→${payload.soc_cible_pct}% (Δ=${deltaSoc.toFixed(1)}%)` +\n  ` | conso=${payload.conso_kWh}kWh pv=${payload.pv_kWh}kWh` +\n  ` | a_charger=${payload.energie_a_charger_kWh}kWh`;\n\n\n// -------- SORTIE 3 : LOG HA (FICHIER) ----------\nconst logHa =\n  `SOC Cible @ ${hhmm(now)}` +\n  ` | SoC=${payload.soc_now_pct}% → cible=${payload.soc_cible_pct}%` +\n  ` | conso_J1=${payload.conso_kWh}kWh` +\n  ` | pv_J1=${payload.pv_kWh}kWh` +\n  ` | deficit=${payload.deficit_kWh}kWh` +\n  ` | batt_utile=${payload.batt_utile_kWh}kWh` +\n  ` | dispo=${payload.energie_dispo_kWh}kWh` +\n  ` | a_charger=${payload.energie_a_charger_kWh}kWh` +\n  ` | soc_a_charger=${payload.soc_a_charger_pct}%` +\n  ` | DoD=${(dod * 100).toFixed(1)}%`;\n\n// -------- STATUS ----------\nnode.status({\n  fill: energie_a_charger_kWh > 0.05 ? \"yellow\" : \"green\",\n  shape: \"dot\",\n  text: `SoC cible=${payload.soc_cible_pct}% | a charger=${payload.energie_a_charger_kWh}kWh`\n});\n\n// -------- RETOUR ----------\nreturn [\n  msg,\n  { payload: logDash },\n  { payload: logHa }\n];\n",
        "outputs": 3,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 760,
        "y": 320,
        "wires": [
            [],
            [
                "fce8b570e7de2979"
            ],
            [
                "2f7797d5a59eb37f"
            ]
        ]
    },
    {
        "id": "cc13594f0954243c",
        "type": "debug",
        "z": "5fe7408840b1cdf3",
        "g": "0ffa85f6b2707ef1",
        "name": "debug 101",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": true,
        "complete": "true",
        "targetType": "full",
        "statusVal": "payload",
        "statusType": "auto",
        "x": 750,
        "y": 1400,
        "wires": []
    },
    {
        "id": "1ad72d51e46cbe39",
        "type": "mqtt in",
        "z": "5fe7408840b1cdf3",
        "g": "0ffa85f6b2707ef1",
        "name": "Energie Reseau Jour",
        "topic": "ha/mp2/cp/energie_reseau_jour",
        "qos": "2",
        "datatype": "auto-detect",
        "broker": "502248144035edbf",
        "nl": false,
        "rap": true,
        "rh": 0,
        "inputs": 0,
        "x": 160,
        "y": 1400,
        "wires": [
            [
                "22983449d6bd5095"
            ]
        ]
    },
    {
        "id": "22983449d6bd5095",
        "type": "change",
        "z": "5fe7408840b1cdf3",
        "g": "0ffa85f6b2707ef1",
        "name": "grid_import_total_kwh",
        "rules": [
            {
                "t": "set",
                "p": "grid_import_total_kwh",
                "pt": "flow",
                "to": "payload",
                "tot": "msg"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 460,
        "y": 1400,
        "wires": [
            [
                "cc13594f0954243c"
            ]
        ]
    },
    {
        "id": "ddbae0defddeb15a",
        "type": "function",
        "z": "5fe7408840b1cdf3",
        "g": "0ffa85f6b2707ef1",
        "name": "Snaptshot 6:00",
        "func": "// ================================\n// AUDIT DESS - SNAPSHOT 06:00\n// ================================\n\nconst soc = Number(flow.get('soc'));  // SoC reel (%)\nconst grid = Number(flow.get('grid_import_total_kwh')); // compteur cumulatif kWh\n\nif (![soc, grid].every(Number.isFinite)) {\n  node.status({ fill: \"red\", shape: \"ring\", text: \"Snapshot 06h invalide (soc/grid)\" });\n  return null;\n}\n\n// Memorisation\nflow.set('audit_06h', {\n  ts: Date.now(),\n  soc_pct: Number(soc.toFixed(2)),\n  grid_import_kwh: Number(grid.toFixed(3))\n});\n\nnode.status({\n  fill: \"blue\",\n  shape: \"dot\",\n  text: `06h | SoC=${soc.toFixed(1)}% | Grid=${grid.toFixed(3)}kWh`\n});\n\nreturn null;\n",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 480,
        "y": 1460,
        "wires": [
            []
        ]
    },
    {
        "id": "5f4b44a3e04b450a",
        "type": "inject",
        "z": "5fe7408840b1cdf3",
        "g": "0ffa85f6b2707ef1",
        "name": "6:00",
        "props": [
            {
                "p": "payload"
            },
            {
                "p": "topic",
                "vt": "str"
            }
        ],
        "repeat": "",
        "crontab": "00 06 * * *",
        "once": false,
        "onceDelay": "2",
        "topic": "",
        "payload": "",
        "payloadType": "date",
        "x": 210,
        "y": 1460,
        "wires": [
            [
                "ddbae0defddeb15a"
            ]
        ]
    },
    {
        "id": "35c49a11dd96c53d",
        "type": "function",
        "z": "5fe7408840b1cdf3",
        "g": "0ffa85f6b2707ef1",
        "name": "Snaptshot 22:00",
        "func": "// ================================\n// AUDIT DESS - SNAPSHOT 22:00\n// ================================\n\nconst soc = Number(flow.get('soc'));  // SoC reel (%)\nconst grid = Number(flow.get('grid_import_total_kwh')); // compteur cumulatif kWh\n\nif (![soc, grid].every(Number.isFinite)) {\n  node.status({ fill: \"red\", shape: \"ring\", text: \"Snapshot 22h invalide (soc/grid)\" });\n  return null;\n}\n\n// Memorisation\nflow.set('audit_22h', {\n  ts: Date.now(),\n  soc_pct: Number(soc.toFixed(2)),\n  grid_import_kwh: Number(grid.toFixed(3))\n});\n\nnode.status({\n  fill: \"blue\",\n  shape: \"dot\",\n  text: `22h | SoC=${soc.toFixed(1)}% | Grid=${grid.toFixed(3)}kWh`\n});\n\nreturn null;\n",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 480,
        "y": 1500,
        "wires": [
            []
        ]
    },
    {
        "id": "b8be24fef26d691b",
        "type": "inject",
        "z": "5fe7408840b1cdf3",
        "g": "0ffa85f6b2707ef1",
        "name": "22:00",
        "props": [
            {
                "p": "payload"
            },
            {
                "p": "topic",
                "vt": "str"
            }
        ],
        "repeat": "",
        "crontab": "00 22 * * *",
        "once": false,
        "onceDelay": "5",
        "topic": "",
        "payload": "",
        "payloadType": "date",
        "x": 220,
        "y": 1500,
        "wires": [
            [
                "35c49a11dd96c53d"
            ]
        ]
    },
    {
        "id": "2b0b581c676e7291",
        "type": "function",
        "z": "5fe7408840b1cdf3",
        "g": "0ffa85f6b2707ef1",
        "name": "Snaptshot 22:05",
        "func": "// ================================\n// AUDIT DESS - CALCUL JOURNALIER\n// Sorties :\n// 1) Objet audit (JSON)\n// 2) Log dashboard (texte)\n// ================================\n\n// Recuperation snapshots\nconst s6  = flow.get('audit_06h');\nconst s22 = flow.get('audit_22h');\n\nconst soc_cible = Number(flow.get('dess_soc_cible_pct')); // SoC cible calcule la veille\nconst soc_min   = 8; // plancher dur (a ajuster)\n\n// Utils\nfunction hhmm(d) {\n  const h = d.getHours();\n  const m = d.getMinutes();\n  return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`;\n}\nfunction f1(x) { return Number(x).toFixed(1); }\nfunction f3(x) { return Number(x).toFixed(3); }\n\nif (!s6 || !s22 || !Number.isFinite(soc_cible)) {\n  node.status({ fill: \"red\", shape: \"ring\", text: \"Audit incomplet (snapshots/cible)\" });\n\n  const now = new Date();\n  const logTxt =\n    `AUDIT_DESS @ ${hhmm(now)}` +\n    ` | ERREUR=Audit incomplet` +\n    ` | have_s6=${!!s6}` +\n    ` | have_s22=${!!s22}` +\n    ` | soc_cible=${Number.isFinite(soc_cible) ? soc_cible : \"NaN\"}`;\n\n  return [null, { payload: logTxt }];\n}\n\n// Calculs\nlet grid_import = s22.grid_import_kwh - s6.grid_import_kwh;\n\nif (!Number.isFinite(grid_import)) {\n  node.status({ fill: \"red\", shape: \"ring\", text: \"Grid import invalide\" });\n\n  const now = new Date();\n  const logTxt =\n    `AUDIT_DESS @ ${hhmm(now)}` +\n    ` | ERREUR=Grid import invalide` +\n    ` | s6.grid=${s6.grid_import_kwh}` +\n    ` | s22.grid=${s22.grid_import_kwh}`;\n\n  return [null, { payload: logTxt }];\n}\n\nif (grid_import < -0.01) {\n  node.status({ fill: \"red\", shape: \"ring\", text: \"Reset compteur grid detecte\" });\n\n  const now = new Date();\n  const logTxt =\n    `AUDIT_DESS @ ${hhmm(now)}` +\n    ` | ERREUR=Reset compteur grid` +\n    ` | grid_delta=${f3(grid_import)}kWh`;\n\n  return [null, { payload: logTxt }];\n}\nconst SOC_TOL_LOW  = 2;  // si SoC06 < cible -> risque\nconst SOC_TOL_HIGH = 6;  // si SoC06 > cible -> pas critique\n\nconst d06 = s6.soc_pct - soc_cible;\nconst ok_soc_06h = (d06 >= -SOC_TOL_LOW) && (d06 <= SOC_TOL_HIGH);\n\nconst ok_grid    = grid_import <= 0.3;\nconst ok_soc_22h = s22.soc_pct >= (soc_min + 2);\n\n// Resultat audit\nconst audit = {\n  soc_cible_pct: Number(soc_cible.toFixed(1)),\n\n  soc_06h_pct: Number(s6.soc_pct.toFixed(1)),\n  soc_22h_pct: Number(s22.soc_pct.toFixed(1)),\n\n  grid_import_06_22_kwh: Number(grid_import.toFixed(3)),\n\n  ok_soc_06h,\n  ok_grid,\n  ok_soc_22h,\n\n  audit_ok: ok_soc_06h && ok_grid && ok_soc_22h\n};\n\n// Stockage\nflow.set('audit_result', audit);\n\n// Status clair\nconst nokFlags = [\n  !ok_soc_06h && \"SoC06\",\n  !ok_grid && \"Grid\",\n  !ok_soc_22h && \"SoC22\"\n].filter(Boolean);\n\nnode.status({\n  fill: audit.audit_ok ? \"green\" : \"yellow\",\n  shape: \"dot\",\n  text: audit.audit_ok ? \"Audit DESS OK\" : `Audit NOK (${nokFlags.join(\",\")})`\n});\n\n// -------- SORTIE 2 : LOG DASHBOARD ----------\nconst now = new Date();\nconst delta06 = (s6.soc_pct - soc_cible);\nconst tags = audit.audit_ok ? \"OK\" : `NOK:${nokFlags.join(\"+\")}`;\n\nconst logTxt =\n  `AUDIT_DESS @ ${hhmm(now)}` +\n  ` | cible=${f1(audit.soc_cible_pct)}%` +\n  ` | SoC06=${f1(audit.soc_06h_pct)}% (Δ=${f1(delta06)}%)` +\n  ` | SoC22=${f1(audit.soc_22h_pct)}%` +\n  ` | Grid06-22=${f3(audit.grid_import_06_22_kwh)}kWh` +\n  ` | checks=[SoC06:${ok_soc_06h ? \"OK\" : \"NOK\"},Grid:${ok_grid ? \"OK\" : \"NOK\"},SoC22:${ok_soc_22h ? \"OK\" : \"NOK\"}]` +\n  ` | ${tags}`;\n\n// Sortie 1: objet audit\nmsg.payload = audit;\n\n// Retour 2 sorties\nreturn [\n  msg,\n  { payload: logTxt }\n];\n",
        "outputs": 2,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 480,
        "y": 1540,
        "wires": [
            [
                "4dbdbae84b4d7390"
            ],
            [
                "059db6a88f526987",
                "cfd34293e830d3cd"
            ]
        ]
    },
    {
        "id": "7dbb8d64c879169d",
        "type": "inject",
        "z": "5fe7408840b1cdf3",
        "g": "0ffa85f6b2707ef1",
        "name": "22:05",
        "props": [
            {
                "p": "payload"
            },
            {
                "p": "topic",
                "vt": "str"
            }
        ],
        "repeat": "",
        "crontab": "05 22 * * *",
        "once": false,
        "onceDelay": "10",
        "topic": "",
        "payload": "",
        "payloadType": "date",
        "x": 220,
        "y": 1560,
        "wires": [
            [
                "2b0b581c676e7291"
            ]
        ]
    },
    {
        "id": "63fe66bb76b0356c",
        "type": "comment",
        "z": "5fe7408840b1cdf3",
        "g": "0ffa85f6b2707ef1",
        "name": "Audit DESS Remy",
        "info": "",
        "x": 470,
        "y": 1360,
        "wires": []
    },
    {
        "id": "30fc5ca1af8c2544",
        "type": "mqtt out",
        "z": "5fe7408840b1cdf3",
        "g": "27c9a9a63c937a72",
        "name": "Log MQTT->HA",
        "topic": "mp2/dess_remy/log_audit",
        "qos": "2",
        "retain": "true",
        "respTopic": "",
        "contentType": "",
        "userProps": "",
        "correl": "",
        "expiry": "",
        "broker": "502248144035edbf",
        "x": 500,
        "y": 1720,
        "wires": []
    },
    {
        "id": "4dbdbae84b4d7390",
        "type": "debug",
        "z": "5fe7408840b1cdf3",
        "g": "0ffa85f6b2707ef1",
        "name": "debug 102",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": true,
        "complete": "true",
        "targetType": "full",
        "statusVal": "payload",
        "statusType": "auto",
        "x": 750,
        "y": 1480,
        "wires": []
    },
    {
        "id": "059db6a88f526987",
        "type": "ui_text",
        "z": "5fe7408840b1cdf3",
        "g": "0ffa85f6b2707ef1",
        "group": "c8a7d12bb7f76f27",
        "order": 7,
        "width": "12",
        "height": "2",
        "name": "",
        "label": "Log Audit Dess :",
        "format": "{{msg.payload}}",
        "layout": "row-spread",
        "className": "",
        "style": false,
        "font": "",
        "fontSize": 16,
        "color": "#000000",
        "x": 740,
        "y": 1540,
        "wires": []
    },
    {
        "id": "2f7797d5a59eb37f",
        "type": "link out",
        "z": "5fe7408840b1cdf3",
        "g": "c64c5ecbe0654837",
        "name": "Log Audit",
        "mode": "link",
        "links": [
            "573c180d02c5fc31"
        ],
        "x": 945,
        "y": 380,
        "wires": []
    },
    {
        "id": "573c180d02c5fc31",
        "type": "link in",
        "z": "5fe7408840b1cdf3",
        "g": "27c9a9a63c937a72",
        "name": "Log In",
        "links": [
            "2f7797d5a59eb37f",
            "cfd34293e830d3cd",
            "071b658e8fc627e4"
        ],
        "x": 295,
        "y": 1720,
        "wires": [
            [
                "30fc5ca1af8c2544"
            ]
        ]
    },
    {
        "id": "cfd34293e830d3cd",
        "type": "link out",
        "z": "5fe7408840b1cdf3",
        "g": "0ffa85f6b2707ef1",
        "name": "link out 3",
        "mode": "link",
        "links": [
            "573c180d02c5fc31"
        ],
        "x": 675,
        "y": 1580,
        "wires": []
    },
    {
        "id": "071b658e8fc627e4",
        "type": "link out",
        "z": "5fe7408840b1cdf3",
        "g": "f8afda3620427140",
        "name": "link out 4",
        "mode": "link",
        "links": [
            "573c180d02c5fc31"
        ],
        "x": 755,
        "y": 1200,
        "wires": []
    },
    {
        "id": "8869e559a62cac58",
        "type": "change",
        "z": "5fe7408840b1cdf3",
        "g": "9669903f47819092",
        "name": "Horloge",
        "rules": [
            {
                "t": "set",
                "p": "horloge",
                "pt": "flow",
                "to": "payload",
                "tot": "msg"
            },
            {
                "t": "set",
                "p": "hdebut",
                "pt": "flow",
                "to": "start",
                "tot": "msg"
            },
            {
                "t": "set",
                "p": "hfin",
                "pt": "flow",
                "to": "end",
                "tot": "msg"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 340,
        "y": 880,
        "wires": [
            [
                "e23bf7ba06f7abf5"
            ]
        ]
    },
    {
        "id": "6d096b019c095341",
        "type": "function",
        "z": "5fe7408840b1cdf3",
        "g": "f8afda3620427140",
        "name": "H Dynamique V5",
        "func": "// =====================================================\n// H_dynamique AMONT BigTimer (gel sur horloge) - Vx finale\n// - Tant que flow.horloge == 0 : recalcul + on_override/off_override\n// - Dès que flow.horloge == 1 : GEL (ne plus recalculer / ne plus envoyer)\n// Sorties :\n// 1) msgOn    : \"on_override HH:MM\"\n// 2) msgOff   : \"off_override HH:MM\"\n// 3) msgSync  : \"sync\"\n// 4) LOG4_DIAG (débridé, rate-limit + changements significatifs)\n// 5) LOG5_EVENT (événementiel, rare)\n// =====================================================\n\n// --- Paramètres ---\nconst CAP_AH   = 300;    // capacité totale (Ah)\nconst I_AVG    = 40;     // courant moyen utile (A)\nconst ABS_MIN  = 60;     // marge absorption/synchro (min)\nconst MIN_WIN  = 90;     // fenêtre minimale (min)\nconst MAX_WIN  = 8 * 60; // garde-fou (min)\nconst STEP     = 5;      // pas d'arrondi minutes\n\n// Fenêtre imposée (traverse minuit)\nconst START_FLOOR_MIN = 22 * 60 + 0;  // 22:00\nconst END_FIXED_MIN   = 5 * 60 + 45;  // 05:45\n\n// Log4 (diag) : débridé mais borné\nconst LOG4_PERIOD_S = 15 * 60;  // 15 min\nconst LOG4_NEED_DELTA_MIN = 10; // changement significatif sur need_min\n\n// --- Utils ---\nfunction clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }\nfunction hhmm(mins) {\n  mins = ((Math.round(mins) % 1440) + 1440) % 1440;\n  const h = Math.floor(mins / 60);\n  const m = mins % 60;\n  return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`;\n}\nfunction inNightWindow(mins) {\n  // Nuit = [22:00..23:59] U [00:00..05:45]\n  return (mins >= START_FLOOR_MIN) || (mins <= END_FIXED_MIN);\n}\n\n// --- Temps courant ---\nconst now = new Date();\nconst nowMin = now.getHours() * 60 + now.getMinutes();\nconst now_hhmm = hhmm(nowMin);\nconst nightNow = inNightWindow(nowMin);\n\n// --- Entrées flow ---\nlet soc = Number(flow.get('soc'));\nif (!Number.isFinite(soc)) soc = 0;\nsoc = clamp(soc, 0, 100);\n\n// ✅ cible SoC = DESS perso\nlet target = Number(flow.get('dess_soc_cible_pct'));\nif (!Number.isFinite(target)) target = 100;\ntarget = clamp(target, 0, 100);\n\n// ✅ horloge BigTimer\n// 0 = pas en cours, 1 = en cours (fenêtre active)\nconst horloge = Number(flow.get('horloge'));\nconst horlogeOn = (horloge === 1);\n\n// --- LOG5_EVENT helper ---\nfunction mkLog5(txt) {\n  // On force le log5 à être uniquement en fenêtre nuit (comme demandé)\n  if (!nightNow) return null;\n  return { payload: txt };\n}\n\n// =====================================================\n// 0) Détection entrée fenêtre nuit (LOG5_EVENT rare)\n// =====================================================\nconst prevNight = (flow.get('bt_prev_night') === true);\nif (nightNow !== prevNight) flow.set('bt_prev_night', nightNow);\n\nlet msgLog5 = null;\nif (nightNow && !prevNight) {\n  msgLog5 = mkLog5(`H_DYN_EVENT @ ${now_hhmm} | entree_fenetre_nuit (22:00-05:45)`);\n}\n\n// =====================================================\n// 1) GEL : si horloge == 1 => on ne calcule plus / on n'envoie plus\n//    (on laisse quand même un LOG5 éventuel \"entrée fenêtre\", déjà traité)\n// =====================================================\nif (horlogeOn) {\n  node.status({\n    fill: \"blue\",\n    shape: \"dot\",\n    text: `H_dynamique GEL (horloge=1) | SoC=${soc.toFixed(1)}% cible=${target.toFixed(0)}%`\n  });\n\n  // LOG4 désactivé en GEL, LOG5 seulement si événement entrée fenêtre (déjà construit)\n  return [null, null, null, null, msgLog5];\n}\n\n// =====================================================\n// 2) Calcul besoin (autorisé uniquement si horloge==0)\n// =====================================================\nconst delta = Math.max(0, target - soc);\nconst ah_needed = (delta / 100) * CAP_AH;\n\nconst bulk_min = (I_AVG > 0) ? (ah_needed / I_AVG) * 60 : 0;\nconst abs_min  = (target >= 95) ? ABS_MIN : 0;\n\nlet need_min = Math.ceil(bulk_min + abs_min);\nneed_min = clamp(need_min, MIN_WIN, MAX_WIN);\n\n// --- Début théorique à partir de la fin ---\nconst start_min_raw = END_FIXED_MIN - need_min;\nlet start_min = start_min_raw;\n\n// Arrondi au pas\nstart_min = Math.floor(start_min / STEP) * STEP;\n\n// Wrap minuit\nlet wrapped = false;\nif (start_min < 0) { start_min += 1440; wrapped = true; }\n\n// --- Appliquer plancher 22:00 (gère minuit) ---\nconst allowed = (start_min >= START_FLOOR_MIN) || (start_min <= END_FIXED_MIN);\nconst clampedTo2200 = (!allowed);\nif (clampedTo2200) start_min = START_FLOOR_MIN;\n\n// --- Fenêtre dispo / ok ---\nconst available_min = (END_FIXED_MIN >= START_FLOOR_MIN)\n  ? (END_FIXED_MIN - START_FLOOR_MIN)\n  : (1440 - START_FLOOR_MIN + END_FIXED_MIN);\n\nconst ok = (need_min <= available_min);\n\n// --- Heures texte ---\nconst start_hhmm = hhmm(start_min);\nconst end_hhmm   = hhmm(END_FIXED_MIN);\nconst start_raw_hhmm = hhmm(start_min_raw);\n\n// --- Expose debug ---\nflow.set('bt_start', start_hhmm);\nflow.set('bt_end', end_hhmm);\nflow.set('bt_need_min', need_min);\nflow.set('bt_available_min', available_min);\nflow.set('bt_ok', ok);\n\n// --- Status Node-RED ---\nnode.status({\n  fill: ok ? \"green\" : \"red\",\n  shape: ok ? \"dot\" : \"ring\",\n  text: ok\n    ? `BT ${start_hhmm}→${end_hhmm} | SoC=${soc.toFixed(1)}% cible=${target.toFixed(0)}% need=${need_min}m`\n    : `ALERTE need=${need_min}m > dispo=${available_min}m | SoC=${soc.toFixed(1)}% cible=${target.toFixed(0)}%`\n});\n\n// =====================================================\n// 3) LOG4_DIAG (débridé mais rate-limit + changements significatifs)\n//    - uniquement dans la fenêtre nuit\n// =====================================================\nlet msgLog4 = null;\nif (nightNow) {\n  const nowTs = Math.floor(Date.now() / 1000);\n  const lastLog4Ts = Number(flow.get('bt_log4_last_ts'));\n  const log4PeriodOk = (!Number.isFinite(lastLog4Ts)) || ((nowTs - lastLog4Ts) >= LOG4_PERIOD_S);\n\n  const lastNeed = Number(flow.get('bt_last_need_min'));\n  const lastOk   = (flow.get('bt_last_ok') === true);\n\n  const needChanged = Number.isFinite(lastNeed) ? (Math.abs(need_min - lastNeed) >= LOG4_NEED_DELTA_MIN) : true;\n  const okChanged   = (ok !== lastOk);\n\n  // comparaison start envoyé (pour savoir si on est stable)\n  const lastStartSent = (flow.get('bt_last_start_sent') || '').toString();\n  const changedStart = (start_hhmm !== lastStartSent);\n\n  const shouldLog4 = (log4PeriodOk || needChanged || okChanged || changedStart);\n\n  if (shouldLog4) {\n    flow.set('bt_log4_last_ts', nowTs);\n    flow.set('bt_last_need_min', need_min);\n    flow.set('bt_last_ok', ok);\n\n    const flags = [wrapped ? \"wrap\" : null, clampedTo2200 ? \"clamp22\" : null].filter(Boolean).join(\",\");\n\n    const logTxt =\n      `H_DYN_DIAG @ ${now_hhmm}` +\n      ` | horloge=${horloge}` +\n      ` | SoC=${soc.toFixed(1)}% -> cible=${target.toFixed(0)}% (Δ=${delta.toFixed(1)}%)` +\n      ` | start=${start_hhmm} end=${end_hhmm}` +\n      ` | need=${need_min}m (bulk=${Math.ceil(bulk_min)}m abs=${abs_min}m)` +\n      ` | dispo=${available_min}m ok=${ok}` +\n      ` | lastStartSent=${lastStartSent || \"-\"}` +\n      (flags ? ` | flags=${flags}` : \"\");\n\n    msgLog4 = { payload: logTxt };\n  }\n}\n\n// =====================================================\n// 4) Envoi overrides BigTimer (seulement si changement d'heure de début)\n//    + LOG5_EVENT sur override envoyé / alerte\n// =====================================================\nconst lastStartSent = (flow.get('bt_last_start_sent') || '').toString();\nconst changed = (start_hhmm !== lastStartSent);\n\n// LOG5_EVENT si alerte (ok=false) : rare mais utile\nif (!ok && nightNow) {\n  msgLog5 = mkLog5(\n    `H_DYN_EVENT @ ${now_hhmm} | ALERTE need=${need_min}m > dispo=${available_min}m | SoC=${soc.toFixed(1)}% cible=${target.toFixed(0)}%`\n  );\n}\n\n//if (!changed) {\n  // Pas d’override, mais on peut sortir LOG4 (si présent) + LOG5 (si présent)\n//  return [null, null, null, msgLog4, msgLog5];\n//}\n\n// Overrides si changement\nconst msgOn   = { payload: `on_override ${start_hhmm}` };\nconst msgOff  = { payload: `off_override ${end_hhmm}` };\nconst msgSync = { payload: \"sync\" };\n\n// Mémorise l’override envoyé\nflow.set('bt_last_start_sent', start_hhmm);\nflow.set('bt_last_sent_ts', now.toISOString());\n\n// LOG5_EVENT : override envoyé (événement clé)\nif (nightNow) {\n  const flags = [wrapped ? \"wrap\" : null, clampedTo2200 ? \"clamp22\" : null].filter(Boolean).join(\",\");\n  msgLog5 = mkLog5(\n    `H_DYN_EVENT @ ${now_hhmm} | OVERRIDE sent start=${start_hhmm} end=${end_hhmm} | SoC=${soc.toFixed(1)}% cible=${target.toFixed(0)}% need=${need_min}m` +\n    (flags ? ` | flags=${flags}` : \"\")\n  );\n}\n\nreturn [msgOn, msgOff, msgSync, msgLog4, msgLog5];\n",
        "outputs": 5,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 390,
        "y": 1140,
        "wires": [
            [
                "665a65c2ac7c84ce"
            ],
            [
                "665a65c2ac7c84ce"
            ],
            [
                "233a80856735ebfb"
            ],
            [
                "701744df73f35cf2"
            ],
            [
                "071b658e8fc627e4"
            ]
        ]
    },
    {
        "id": "233a80856735ebfb",
        "type": "delay",
        "z": "5fe7408840b1cdf3",
        "g": "f8afda3620427140",
        "name": "",
        "pauseType": "delay",
        "timeout": "200",
        "timeoutUnits": "milliseconds",
        "rate": "1",
        "nbRateUnits": "1",
        "rateUnits": "second",
        "randomFirst": "1",
        "randomLast": "5",
        "randomUnits": "seconds",
        "drop": false,
        "allowrate": false,
        "outputs": 1,
        "x": 690,
        "y": 1120,
        "wires": [
            [
                "665a65c2ac7c84ce"
            ]
        ]
    },
    {
        "id": "56c0dd1a9c9f59d5",
        "type": "comment",
        "z": "5fe7408840b1cdf3",
        "g": "27c9a9a63c937a72",
        "name": "Log Vers MQTT pour Stockage ds fichier via Home Assistant",
        "info": "",
        "x": 440,
        "y": 1680,
        "wires": []
    },
    {
        "id": "c3ef0fbdd807d8bc",
        "type": "victron-input-ess",
        "z": "5fe7408840b1cdf3",
        "g": "f7b3a04d7293fa0c",
        "service": "com.victronenergy.settings",
        "path": "/Settings/CGwacs/BatteryLife/Schedule/Charge/1/Day",
        "serviceObj": {
            "service": "com.victronenergy.settings",
            "name": "Venus settings"
        },
        "pathObj": {
            "path": "/Settings/CGwacs/BatteryLife/Schedule/Charge/1/Day",
            "type": "enum",
            "name": "Schedule 2: Day",
            "enum": {
                "0": "Sunday",
                "1": "Monday",
                "2": "Tuesday",
                "3": "Wednesday",
                "4": "Thursday",
                "5": "Friday",
                "6": "Saturday",
                "7": "Every day",
                "8": "Weekdays",
                "9": "Weekends",
                "11": "Monthly",
                "-1": "Disabled"
            },
            "remarks": "<p>A negative value means that the schedule has been de-activated.</p>",
            "mode": "both"
        },
        "name": "",
        "onlyChanges": false,
        "x": 210,
        "y": 1820,
        "wires": [
            [
                "a45f19bbc80e5a79"
            ]
        ]
    },
    {
        "id": "a45f19bbc80e5a79",
        "type": "function",
        "z": "5fe7408840b1cdf3",
        "g": "f7b3a04d7293fa0c",
        "name": "function 22",
        "func": "// Entrée: msg.payload = valeur de /Settings/CGwacs/BatteryLife/Schedule/Charge/0/Day\nconst day = Number(msg.payload);\n\nlet status;\nlet enabled = false;\n\nif (!Number.isFinite(day)) {\n  status = \"Day invalide\";\n} else if (day === -1) {\n  status = \"Desactive\";\n} else if (day === -7) {\n  status = \"Indefini\";\n} else if (day >= 0) {\n  enabled = true;\n  status = `Actif (Day=${day})`;\n} else {\n  status = `Inconnu (Day=${day})`;\n}\n\n// Sorties utiles\nmsg.day = day;\nmsg.enabled = enabled;     // booléen équivalent du switch *_enabled\nmsg.status_text = status;\n\n// Si vous voulez payload = bool\nmsg.payload = enabled;\n\n// Optionnel: statut Node-RED\nnode.status({ fill: enabled ? \"green\" : \"grey\", shape: \"dot\", text: status });\n\nreturn msg;\n",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 510,
        "y": 1820,
        "wires": [
            [
                "fda0c5fc9d3a3778"
            ]
        ]
    },
    {
        "id": "fda0c5fc9d3a3778",
        "type": "debug",
        "z": "5fe7408840b1cdf3",
        "g": "f7b3a04d7293fa0c",
        "name": "debug 103",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": true,
        "complete": "true",
        "targetType": "full",
        "statusVal": "payload",
        "statusType": "auto",
        "x": 670,
        "y": 1820,
        "wires": []
    },
    {
        "id": "2b5dea20fe8df2e3",
        "type": "victron-output-ess",
        "z": "5fe7408840b1cdf3",
        "g": "f7b3a04d7293fa0c",
        "service": "com.victronenergy.settings",
        "path": "/Settings/CGwacs/BatteryLife/Schedule/Charge/1/Day",
        "serviceObj": {
            "service": "com.victronenergy.settings",
            "name": "Venus settings"
        },
        "pathObj": {
            "path": "/Settings/CGwacs/BatteryLife/Schedule/Charge/1/Day",
            "type": "enum",
            "name": "Schedule 2: Day",
            "enum": {
                "0": "Sunday",
                "1": "Monday",
                "2": "Tuesday",
                "3": "Wednesday",
                "4": "Thursday",
                "5": "Friday",
                "6": "Saturday",
                "7": "Every day",
                "8": "Weekdays",
                "9": "Weekends",
                "11": "Monthly",
                "-1": "Disabled"
            },
            "remarks": "<p>A negative value means that the schedule has been de-activated.</p>",
            "mode": "both"
        },
        "name": "Validation CP2",
        "onlyChanges": false,
        "x": 680,
        "y": 1920,
        "wires": []
    },
    {
        "id": "e95a6ca3e8f41607",
        "type": "inject",
        "z": "5fe7408840b1cdf3",
        "g": "f7b3a04d7293fa0c",
        "name": "7",
        "props": [
            {
                "p": "payload"
            },
            {
                "p": "topic",
                "vt": "str"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": 0.1,
        "topic": "",
        "payload": "7",
        "payloadType": "num",
        "x": 350,
        "y": 1900,
        "wires": [
            [
                "2b5dea20fe8df2e3"
            ]
        ]
    },
    {
        "id": "1cd50a3b5905c265",
        "type": "inject",
        "z": "5fe7408840b1cdf3",
        "g": "f7b3a04d7293fa0c",
        "name": "-1",
        "props": [
            {
                "p": "payload"
            },
            {
                "p": "topic",
                "vt": "str"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": 0.1,
        "topic": "",
        "payload": "-1",
        "payloadType": "num",
        "x": 350,
        "y": 1960,
        "wires": [
            [
                "2b5dea20fe8df2e3"
            ]
        ]
    },
    {
        "id": "3771889792a45c9c",
        "type": "mqtt out",
        "z": "5fe7408840b1cdf3",
        "g": "c64c5ecbe0654837",
        "name": "Previ Conso J+1",
        "topic": "mp2/dess_remy/previ_conso_6-22-J1",
        "qos": "",
        "retain": "",
        "respTopic": "",
        "contentType": "",
        "userProps": "",
        "correl": "",
        "expiry": "",
        "broker": "502248144035edbf",
        "x": 560,
        "y": 400,
        "wires": []
    },
    {
        "id": "58133dfe4f341acc",
        "type": "comment",
        "z": "5fe7408840b1cdf3",
        "g": "f8afda3620427140",
        "name": "Calcul Heure de début activtion de la Charge Programée 1 de ESS Victron",
        "info": "",
        "x": 580,
        "y": 1060,
        "wires": []
    },
    {
        "id": "502248144035edbf",
        "type": "mqtt-broker",
        "name": "MQTTHA",
        "broker": "192.168.0.37",
        "port": "1883",
        "clientid": "",
        "autoConnect": true,
        "usetls": false,
        "protocolVersion": "4",
        "keepalive": "60",
        "cleansession": true,
        "autoUnsubscribe": true,
        "birthTopic": "",
        "birthQos": "0",
        "birthPayload": "",
        "birthMsg": {},
        "closeTopic": "",
        "closeQos": "0",
        "closePayload": "",
        "closeMsg": {},
        "willTopic": "",
        "willQos": "0",
        "willPayload": "",
        "willMsg": {},
        "userProps": "",
        "sessionExpiry": ""
    },
    {
        "id": "c8a7d12bb7f76f27",
        "type": "ui_group",
        "name": "DESS",
        "tab": "8c1c69b7bf39eb5b",
        "order": 1,
        "disp": true,
        "width": "12",
        "collapse": false,
        "className": ""
    },
    {
        "id": "5e3a89e5eaeb1cb4",
        "type": "config-vrm-api",
        "name": "vrm_remy"
    },
    {
        "id": "8c1c69b7bf39eb5b",
        "type": "ui_tab",
        "name": "MP2",
        "icon": "dashboard",
        "disabled": false,
        "hidden": false
    }
]

Mini Table des topics utilisé dans mqtt

HA → Node-RED (commandes / forçages)

  • ha/mp2/cp/validcp (autorisation globale CP)
  • ha/mp2/cp/forcage100 (forçage manuel ON/OFF)
  • ha/mp2/cp/niveauforcagecp1 (niveau SoC cible forcé)

Node-RED → HA (supervision)

  • mp2/dess_remy/prod_total (prévision PV J+1 6–22, QoS 2 + retain)
  • mp2/dess_remy/cible_soc (SoC cible retenu)
  • mp2/multiplus2/valide_cp (état CP : on/off)
  • mp2/dess_remy/h_debut (heure début CP, HH:MM)
  • mp2/dess_remy/duree (durée CP, HH:MM)

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *