Contents
- 1 Intro
- 2 Le flow Node-RED
- 3 Analyse du flow (version 2026)
- 3.1 Bloc “Prévisions VRM : production PV + consommation J+1 (06h → 22h)”
- 3.2 Bloc “Calcul SoC cible (logique DoD J+1)”
- 3.3 Bloc “Entrées Home Assistant (autorisations et forçages)”
- 3.4 Bloc “Horloge CP 22h → 6h + horaires dynamiques”
- 3.5 Bloc “Pilotage CP1 V6 : SoC cible + commande Victron”
- 3.6 Bloc “Écriture Venus OS + MQTT sortant”
- 3.7 Détails techniques
- 4 Dashboard Node red:
- 5 Intégration avec Home Assistant
- 6 Palettes utilisées
- 7 Conclusion
- 8 Annexes
Intro
Depuis plusieurs mois, mon installation photovoltaïque Victron a nettement évolué : nouveaux capteurs, nouveaux besoins (dont la gestion des jours EDF 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 choisi 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 (VRM), calculs, décisions, orchestration,
- MQTT / Home Assistant : supervision, autorisations, forçages et visibilité.
Le focus principal porte 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), en tenant compte des contraintes et opportunités liées à Tempo (heures creuses/heures pleines et jours rouges). Je détaille le pourquoi et le comment, et je fournis les éléments concrets pour reproduire l’approche : structure du flow, variables manipulées, topics MQTT, chemins Venus OS, et points de vigilance.
Objectifs du flow
Ce flow, nommé « DESS Rémy », a 3 missions :
- Estimer les prévisions J+1 (06:00 → 22:00)
- récupération via VRM : prévision production PV (AC-coupled + MPPT) et prévision consommation
- filtrage J+1 et plage 06:00–22:00
- conversion Wh → kWh (arrondi)
- stockage en contexte Node-RED + publication MQTT (valeurs “observables”)
- Calculer un SoC cible pour la charge programmée
- logique “métier” basée sur besoin énergétique J+1 (prod vs conso) + paramètres batterie (capacité, rendement, soc_min, marge…)
- production d’une cible prioritaire
flow.dess_soc_cible_pct - possibilité de forçage manuel depuis Home Assistant (override total)
- Piloter la Charge Programmée (CP1) dans Venus OS sur la fenêtre 22h–6h
- activation/désactivation conditionnée par une autorisation HA
- calcul d’un Start/Duration cohérent avec le besoin réel (horaires dynamiques)
- écriture des paramètres CP1 : Day / Start / Duration / Soc (+ option AllowDischarge si besoin)
- log synthétique + publication MQTT des sorties utiles (cible SoC, valid CP, durée, heure début…)
Ce flow sert donc à la fois de collecte (VRM), de traitement (calculs), et de commande (écriture settings Victron), tout en restant observable via logs + MQTT.
Le flow Node-RED
Visualisation du flow




Analyse du flow (version 2026)
Bloc “Prévisions VRM : production PV + consommation J+1 (06h → 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.
Prévision de production PV J+1 (06–22)
- Un nœud VRM API récupère la série
records.solar_yield_forecast(format typique[[timestamp_ms, Wh], ...]). - Fonction Extraction 6–22 :
- filtre les points correspondant au jour J+1
- somme uniquement les valeurs entre 06:00 et 22:00
- convertit Wh → kWh (arrondi à 0,1 kWh)
- stocke en
flow.previ_prod_6_22(et optionnellementflow.previ_prod_total) - publie sur MQTT :
mp2/dess_remy/prod_total(QoS 2 + retain)
- Un change node “Save previ_prod” mémorise la valeur utilisée ensuite :
flow.previ_prod.
Prévision de consommation J+1 (06–22)
- Un second nœud VRM API récupère la prévision de consommation
records.vrm_consumption_fc(série horaire). - Fonction Extraction 6–22 :
- filtre J+1
- somme uniquement 06:00 → 22:00
- convertit Wh → kWh (arrondi à 0,1 kWh)
- stocke en
flow.conso_j1
À ce stade, Node-RED dispose de deux entrées cohérentes pour dimensionner la batterie sur J+1 :
- Production PV attendue (06–22)
- Consommation attendue (06–22)
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 de :
flow.conso_j1(conso prévue J+1 06–22)flow.previ_prod(prod prévue J+1 06–22)flow.soc(SoC actuel)- paramètres batterie (capacité utile, rendement, soc_min, marge, part réseau acceptée, etc.)
Elle produit et stocke notamment :
flow.dess_soc_cible_pct: SoC cible calculéflow.dess_dod_pct: DoD requis (indicatif / diagnostic)
Ce SoC cible devient la référence prioritaire pour le pilotage CP1.
Bloc “Entrées Home Assistant (autorisations et forçages)”
Trois topics MQTT (HA → Node-RED) permettent de reprendre la main simplement :
ha/mp2/cp/validcp: autorisation globale CP (ON/OFF)
→ stocké englobal.valid_cp_essha/mp2/cp/forcage100: forçage manuel (ON/OFF)
→ stocké enflow.forc100ha/mp2/cp/niveauforcagecp1: cible SoC forcée (ex : 80)
→ stocké enflow.niveauforcp1
Ces entrées sont consommées directement par la logique de pilotage CP1.
Bloc “Horloge CP 22h → 6h + horaires dynamiques”
Le pilotage temporel est maintenant plus propre et plus robuste :
- 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(en 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”)
Recalcul “souple” : un recalcul périodique du SoC est prévu toutes les 15 min et uniquement si BigTimer est OFF, afin d’éviter les oscillations pendant la fenêtre de charge.
Bloc “Pilotage CP1 V6 : SoC cible + commande Victron”
La fonction Pilotage CP1 V6 est le cœur du pilotage.
Calcul du SoC cible (priorités)
Ordre de priorité :
- SoC cible calculé DoD J+1 :
flow.dess_soc_cible_pct(si valide) - Fallback historique :
seuil = 120 - 2.5 × prod - Forçage HA actif (
flow.forc100 == on) :seuil = flow.niveauforcp1
Puis :
- arrondi au pas de 5%
- bornage 0–100
- règle conservée : < 15% ⇒ 0 (si c’est toujours votre choix de fonctionnement)
Activation / désactivation CP
- si
global.valid_cp_essautorise ETflow.horloge == 1→ activation (payload 7 = “Every day”) - sinon → désactivation (payload -1)
Start / Duration
Conversion minutes → secondes pour alimenter :
/Schedule/Charge/0/Start/Schedule/Charge/0/Duration
Log synthétique
Log unique et lisible (UI + debug) indiquant :
- SoC actuel
- seuil retenu + source (DoD / fallback / forçage)
- état forçage
- durée HC (HH:MM)
- horloge (0/1)
- autorisation HA
- valeur effectivement envoyée
Bloc “Écriture Venus OS + MQTT sortant”
Écriture des settings dans 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
MQTT sortant (supervision Home Assistant)
mp2/dess_remy/prod_total(prévision PV J+1 06–22, QoS2 retain)mp2/dess_remy/cible_socmp2/multiplus2/valide_cp(mapping -1/7 → on/off)mp2/dess_remy/h_debut(HH:MM)mp2/dess_remy/duree(HH:MM)
Détails techniques
Extraction PV J+1 06–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_prodvia change node)
Extraction conso J+1 06–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
Calcul SoC cible + activation CP (principe)
- 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_essautorise ETflow.horloge == 1
Dashboard Node red:

Intégration avec Home Assistant
Home Assistant sert ici de poste de supervision (dashboards) et de poste de commande (autorisations / forçages), via MQTT.
MQTT → Home Assistant (télémétrie / supervision)
Le broker MQTT reçoit et peut exposer dans Home Assistant :
- Prévision PV J+1 (06:00 → 22:00)
mp2/dess_remy/prod_total(QoS 2 + retain)
Utile pour affichage, scénarios Tempo, et compréhension “charge vs journée”.
- (Optionnel) Prévision conso J+1 (06:00 → 22:00)
mp2/dess_remy/conso_j1(si tu choisis de la publier)
Très pratique pour visualiser le bilan attendu.
- SoC cible retenu (valeur finale réellement utilisée)
mp2/dess_remy/cible_soc
(incluant priorités : DoD J+1 → fallback → forçage HA)
- État de charge programmée (CP1)
mp2/multiplus2/valide_cp(mapping -1/7 → on/off)
Permet de savoir si CP1 est activée ou non côté logique.
- Horaires calculés
mp2/dess_remy/h_debut(HH:MM)mp2/dess_remy/duree(HH:MM)
Pour lecture immédiate dans les dashboards (et diagnostic).
- (Optionnel) Log synthétique
mp2/dess_remy/log(string)
Hyper utile en debug : SoC actuel, cible, forçage, valid HA, horloge, start/durée envoyés.
Home Assistant → Node-RED (commande / reprise de main)
Home Assistant peut piloter le flow via trois topics simples :
ha/mp2/cp/validcp: autorisation globale CP (on/off)ha/mp2/cp/forcage100: activation du forçage manuel (on/off)ha/mp2/cp/niveauforcagecp1: valeur de SoC cible forcée (ex : 80)
👉 Résultat : Node-RED calcule et décide, Home Assistant autorise et force si nécessaire, et tout reste observable via MQTT.

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

Fichier victron.yaml permettant de récuperer les mqtt:
mqtt:
sensor:
# MQTT
- name: "MP2 SoC Affiché"
unique_id: mp2_soc_affiche
state_topic: "mp2/batteries/soc_affiche"
unit_of_measurement: "%"
device_class: battery
state_class: measurement
- name: "MP2 SoC théorique Lineaire"
unique_id: mp2_soc_lin_json
state_topic: "mp2/batteries/soc_theorique"
unit_of_measurement: "%"
device_class: battery
state_class: measurement
value_template: "{{ value_json.get('SoClin', 0) | float(0) }}"
- 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"
- name: "MP2 DESS Previ Prod 6-22 J1"
unique_id: mp2_dess_previ_prod_6_22_j1
state_topic: "mp2/dess_remy/previ_prod_6-22-J1"
unit_of_measurement: "kWh"
device_class: "energy"
state_class: "measurement"
Palettes utilisées
Pour reproduire ce flow, il faut ces palettes (ou équivalents) :
- node-red-contrib-victron
Lecture/écriture des chemins Venus OS (notamment/Settings/CGwacs/BatteryLife/Schedule/Charge/0/*) et intégration propre avec l’écosystème Victron. - Palette VRM API (nœud vrm-api)
Récupération des séries/prévisions VRM (production PV forecast, consommation forecast), utilisées ici en J+1 et sur la plage 06:00 → 22:00. - node-red-contrib-bigtimer
Définition de la fenêtre de charge nocturne 22:00 → 06:00 (sortie ON/OFF servant de “vérité” pour la logique horaire). - node-red-dashboard
Widgets de debug/observabilité : jauges, textes, graphes, log “lisible”. - node-red-node-ui-table (optionnel mais pratique)
Affichage tabulaire des valeurs clés (prévisions, SoC cible, start/duration, états HA) pour diagnostiquer rapidement. - node-red-node-mqtt (ou les MQTT nodes intégrés selon ta version Node-RED)
Entrées HA → Node-RED (autorisations / forçages) et sorties Node-RED → HA (télémétrie / logs), avec gestion retain / QoS selon tes choix.
Conclusion
Avec ce flow “DESS Rémy”, je ne me contente plus de remonter des données : j’ai une chaîne complète prévoir → décider → agir, pensée pour un usage réel au quotidien — et en particulier pour EDF Tempo.
- Prévoir : récupération via VRM des prévisions J+1 sur la plage utile 06h → 22h (production PV et consommation), afin de raisonner sur “tenir la journée” plutôt que sur des totaux bruts.
- Décider : calcul d’une cible SoC basée sur un besoin énergétique J+1 (logique DoD), avec bornage/arrondi, et possibilité de forçage manuel depuis Home Assistant.
- Agir : pilotage automatique de la Charge Programmée CP1 dans Venus OS (Day / Start / Duration / Soc), avec des horaires dynamiques dans la fenêtre 22h–6h.
- Rester observable : publication MQTT des indicateurs utiles (prévisions, cible SoC, état CP, start/duration, logs), pour dashboards, scénarios Tempo, et debug.
Résultat : une charge nocturne ajustée au besoin, lisible, et facilement contrôlable depuis Home Assistant (autorisation globale + overrides), tout en gardant Node-RED comme “cerveau” central de l’orchestration.
Annexes
Mini table des topics utilisé dans MQTT
HA → Node-RED (commandes / forçages)
ha/mp2/cp/validcp: autorisation globale CP (on/off)ha/mp2/cp/forcage100: forçage manuel ON/OFFha/mp2/cp/niveauforcagecp1: niveau SoC cible forcé (ex: 80)
Node-RED → HA (supervision)
mp2/dess_remy/prod_total: prévision PV J+1 (06–22), QoS 2 + retainmp2/dess_remy/cible_soc: SoC cible retenump2/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)mp2/dess_remy/previ_conso_6-22-J1: prévision conso J+1 (06–22), kWhmp2/dess_remy/previ_prod_6-22-J1: prévision prod J+1 (06–22), kWhmp2/dess_remy/log_audit: log audit (texte)
Flux JSON à copier/coller :
[
{
"id": "5fe7408840b1cdf3",
"type": "tab",
"label": "DESS Rémy",
"disabled": false,
"info": "",
"env": []
},
{
"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",
"f26bc3295655d978",
"100b50b7dd6436f3",
"8ef38bc25af154c7",
"b79542847a8290f1",
"e8f6758392b2f7d7",
"a8b2fec6fdd2265b",
"0980d6b80b2d707a",
"6c9e2d97af61d80e",
"4f987019c9af7859",
"9a02956e8c37d773",
"d0900f1bce2a4111",
"59baa58061ee6884",
"6027c79e01cbefde",
"19d7eb29a5c60cdc",
"d9004c05cabbfc69",
"7914d10c7560953d"
],
"x": 34,
"y": 39,
"w": 1052,
"h": 382
},
{
"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",
"a70c71330cb91fb6"
],
"x": 214,
"y": 1579,
"w": 572,
"h": 122
},
{
"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",
"f7ddcf1e407745e9",
"67bc6865c7406fee",
"701744df73f35cf2",
"b70ceb2c221e3bd2",
"a0b8045a6d72c0eb",
"071b658e8fc627e4",
"6d096b019c095341",
"58133dfe4f341acc",
"0711c68c660d9c2f",
"c8d75201e67fa205"
],
"x": 54,
"y": 999,
"w": 972,
"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",
"5eacf6a66623ab28",
"5ddb73a1d5111255",
"d9d9937809ad2185",
"7a72bd69fea8ec17",
"f5bd2bcbcc8eb176",
"7b1ac5814db8a9ac",
"4b84dbc2655e1bcd",
"ce7397f52af03a02",
"83b23b5304a18717",
"4654dd1c8bf3344e",
"317a0cfb20712290",
"af7be0afa7182573",
"5b9f7733139cceb7",
"51dc07974be1a13d",
"7987ad4db47bf444",
"a5d3d6de6b719c0a",
"c84e616b7483e3a7",
"0b9a873b13652106",
"7f9d7f52a663fbbc"
],
"x": 34,
"y": 439,
"w": 1032,
"h": 522
},
{
"id": "51e27c869bb5e6f1",
"type": "group",
"z": "5fe7408840b1cdf3",
"style": {
"stroke": "#999999",
"stroke-opacity": "1",
"fill": "none",
"fill-opacity": "1",
"label": true,
"label-position": "nw",
"color": "#a4a4a4"
},
"nodes": [
"03c36694b589e110",
"ab48449b78d44baf",
"1f1cd907d3eec9a0",
"800f167bb831e02b",
"b75a9e08a5dbf223",
"f32446775a09a1fc",
"d61899b9b703dfe6",
"66037dc3f0b544c2",
"3cac79c890574a77"
],
"x": 34,
"y": 1299,
"w": 972,
"h": 242
},
{
"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": 1240,
"wires": [
[
"67bc6865c7406fee"
]
]
},
{
"id": "d0900f1bce2a4111",
"type": "change",
"z": "5fe7408840b1cdf3",
"g": "c64c5ecbe0654837",
"name": "Save previ_prod",
"rules": [
{
"t": "set",
"p": "previ_prod",
"pt": "flow",
"to": "payload",
"tot": "msg"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 740,
"y": 140,
"wires": [
[
"9a02956e8c37d773",
"2d3a9307be086227",
"d9004c05cabbfc69"
]
]
},
{
"id": "9cf84313ff660683",
"type": "comment",
"z": "5fe7408840b1cdf3",
"g": "9669903f47819092",
"name": "Calcul du niveau SOC pour la charge programmée",
"info": "",
"x": 590,
"y": 480,
"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": 470,
"y": 860,
"wires": []
},
{
"id": "e23bf7ba06f7abf5",
"type": "function",
"z": "5fe7408840b1cdf3",
"g": "9669903f47819092",
"name": "Pilotage CP1 V8",
"func": "// ===================================================================\n// 🔋 Pilotage CP1 V8\n// - status_cp1 vient de H_DYN via msg.payload.status_cp1 (7 / -1)\n// - hdebut/hfin viennent aussi idéalement du payload, sinon flow\n// - Sorties inchangées : [cmd, hDebutS, duree, debug, seuilMsg, autoconsoMsg]\n// ===================================================================\n\n// ---------- Helpers ----------\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// ---------- Entrées (flow / global) ----------\nlet soc = Number(flow.get('soc'));\nif (!Number.isFinite(soc)) soc = 0;\n\nlet prod = Number(flow.get('previ_prod'));\nif (!Number.isFinite(prod)) prod = 0;\n\nconst havalid = global.get('valid_cp_ess'); // \"on\"/true/1\nconst HA_ON = (havalid === \"on\" || havalid === true || havalid === 1);\n\n// ---------- Lecture STRICTE du payload (sans ?. ) ----------\nconst payloadObj = (msg && msg.payload && typeof msg.payload === \"object\") ? msg.payload : null;\n\nlet status_cp1 = payloadObj ? Number(payloadObj.status_cp1) : NaN;\nif (!Number.isFinite(status_cp1)) {\n node.status({ fill: \"red\", shape: \"ring\", text: \"ERREUR: payload.status_cp1 absent\" });\n return [\n { payload: -1 },\n null,\n null,\n { payload: \"CP1: ERREUR payload.status_cp1 absent\" },\n null,\n { payload: 0 }\n ];\n}\nstatus_cp1 = (status_cp1 === 7) ? 7 : -1;\n\n// ---------- hdebut/hfin : priorité payload, sinon flow ----------\nlet hdeb = payloadObj && Number.isFinite(Number(payloadObj.hdebut_min)) ? Number(payloadObj.hdebut_min) : Number(flow.get('hdebut'));\nlet hfin = payloadObj && Number.isFinite(Number(payloadObj.hfin_min)) ? Number(payloadObj.hfin_min) : Number(flow.get('hfin'));\n\nif (!Number.isFinite(hdeb)) hdeb = 0;\nif (!Number.isFinite(hfin)) hfin = 0;\n\n// Optionnel: mémoriser en flow pour cohérence globale\nflow.set('hdebut', hdeb);\nflow.set('hfin', hfin);\n\n// ---------- Sorties 2/3 : début (s) + durée (s) ----------\nlet h_corr = hdeb / 60;\nlet fin = hfin / 60;\n\nlet tps = (h_corr > fin)\n ? (24 - h_corr + fin) * 3600\n : (fin - h_corr) * 3600;\n\ntps = Math.round(tps);\n\nconst hDebutS = { payload: Math.round(h_corr * 3600) };\nconst duree = { payload: tps };\n\n// Format durée HH:MM\nconst hh = Math.floor(tps / 3600);\nconst mm = Math.floor((tps % 3600) / 60);\nconst duree_hhmm = `${String(hh).padStart(2,'0')}:${String(mm).padStart(2,'0')}`;\n\n// Heures en clair\nconst hdeb_hhmm = hhmmFromMin(hdeb);\nconst hfin_hhmm = hhmmFromMin(hfin);\n\n// ---------- Seuil pour info/debug (tu peux garder ta logique actuelle) ----------\nlet soc_cible_calc = Number(flow.get('dess_soc_cible_pct'));\nlet seuil = Number.isFinite(soc_cible_calc) && soc_cible_calc > 0 ? soc_cible_calc : 0;\nseuil = Math.round(seuil);\nif (seuil < 15) seuil = 0;\nif (seuil > 100) seuil = 100;\nflow.set(\"seuil_soc\", seuil);\nconst seuilMsg = { payload: seuil };\n\n// ---------- Commande CP1 (Sortie 1) ----------\nlet actif_payload = -1;\nif (!HA_ON) actif_payload = -1;\nelse actif_payload = status_cp1;\n\n// mémorise état\nflow.set('ess_actif', (actif_payload === 7));\nflow.set('cp1_status_out', actif_payload); // optionnel\n\n// ---------- Log ----------\nconst tag = (!HA_ON) ? \"🚫 HA OFF\" : (actif_payload === 7 ? \"⚡ CP1 ON\" : \"⏱️ CP1 OFF\");\n\nconst debug = {\n payload:\n `${tag}` +\n ` | SoC=${soc.toFixed(1)}%` +\n ` | Seuil=${seuil}%` +\n ` | Hdeb=${hdeb_hhmm} | Hfin=${hfin_hhmm} | Durée=${duree_hhmm}` +\n ` | status_cp1(payload)=${status_cp1}` +\n ` | havalid=${havalid}` +\n ` | Sortie1=${actif_payload}` +\n (payloadObj && payloadObj.reason ? ` | reason=${payloadObj.reason}` : \"\")\n};\n\n// Status Node-RED\nnode.status({\n fill: (!HA_ON) ? \"red\" : (actif_payload === 7 ? \"green\" : \"blue\"),\n shape: \"dot\",\n text: `${tag} | ${hdeb_hhmm}→${hfin_hhmm} | out=${actif_payload}`\n});\n\n// ---------- Sorties ----------\nreturn [\n { payload: actif_payload }, // 1) status CP1 (7 / -1)\n hDebutS, // 2) début (s)\n duree, // 3) durée (s)\n debug, // 4) log dashboard\n seuilMsg, // 5) seuil\n { payload: 0 } // 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": 220,
"y": 680,
"wires": [
[
"7a72bd69fea8ec17"
],
[
"317a0cfb20712290"
],
[
"4654dd1c8bf3344e"
],
[
"d9d9937809ad2185",
"7b1ac5814db8a9ac",
"a5d3d6de6b719c0a"
],
[
"38b5d65f62960b22",
"4047bb58bd817588",
"4b84dbc2655e1bcd",
"7f9d7f52a663fbbc"
],
[
"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": 670,
"y": 860,
"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": 740,
"y": 620,
"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": 730,
"y": 680,
"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": 710,
"y": 740,
"wires": []
},
{
"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": 920,
"y": 620,
"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": 920,
"y": 680,
"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": 860,
"y": 820,
"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);\n\n// Sortie 1 : image de l'entrée (code brut)\nvar msg1 = { ...msg, payload: code };\n\n// Sortie 2 : texte\nvar msg2 = { ...msg, 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 [msg1, msg2];\n",
"outputs": 2,
"timeout": "",
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 520,
"y": 620,
"wires": [
[
"cbe71d4c64597512"
],
[
"5eacf6a66623ab28"
]
]
},
{
"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": 490,
"y": 920,
"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": 620,
"y": 800,
"wires": []
},
{
"id": "9a02956e8c37d773",
"type": "ui_gauge",
"z": "5fe7408840b1cdf3",
"g": "c64c5ecbe0654837",
"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": 120,
"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": 1240,
"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": 830,
"y": 900,
"wires": []
},
{
"id": "6027c79e01cbefde",
"type": "inject",
"z": "5fe7408840b1cdf3",
"g": "c64c5ecbe0654837",
"name": "6:00->21:00",
"props": [
{
"p": "payload"
},
{
"p": "topic",
"vt": "str"
}
],
"repeat": "",
"crontab": "*/15 6-20 * * *",
"once": true,
"onceDelay": "5",
"topic": "",
"payload": "",
"payloadType": "date",
"x": 160,
"y": 120,
"wires": [
[
"4f987019c9af7859"
]
]
},
{
"id": "a8b2fec6fdd2265b",
"type": "mqtt in",
"z": "5fe7408840b1cdf3",
"g": "c64c5ecbe0654837",
"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": 340,
"wires": [
[
"6c9e2d97af61d80e"
]
]
},
{
"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": 540,
"wires": [
[
"c84e616b7483e3a7"
]
]
},
{
"id": "8ef38bc25af154c7",
"type": "mqtt in",
"z": "5fe7408840b1cdf3",
"g": "c64c5ecbe0654837",
"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": 280,
"wires": [
[
"0980d6b80b2d707a"
]
]
},
{
"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": 890,
"y": 740,
"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// Sorties :\n// 1) msg.payload = valeur brute d'entrée (secondes brutes)\n// 2) msg.payload = \"HH:MM\" (string)\n\nlet tps_in = msg.payload;\nlet tps = Number(tps_in);\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')}`;\n\n// Sortie 1 : image de l'entrée (secondes brutes)\nconst msg1 = { ...msg, payload: tps_in };\n\n// Sortie 2 : HH:MM\nconst msg2 = { ...msg, 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// msg2.topic = \"ton/topic/mqtt/duree_hhmm\";\n\nreturn [msg1, msg2];\n",
"outputs": 2,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 520,
"y": 740,
"wires": [
[
"31630e5509a5a01c"
],
[
"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// Sorties :\n// 1) msg.payload = valeur brute d'entrée (secondes)\n// 2) msg.payload = \"HH:MM\" (string)\n\nlet tps_in = msg.payload;\nlet tps = Number(tps_in);\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')}`;\n\n// Sortie 1 : image de l'entrée (secondes brutes)\nconst msg1 = { ...msg, payload: tps_in };\n\n// Sortie 2 : HH:MM\nconst msg2 = { ...msg, 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 [msg1, msg2];\n",
"outputs": 2,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 520,
"y": 680,
"wires": [
[
"c7bade8283007b6b"
],
[
"5ddb73a1d5111255"
]
]
},
{
"id": "67bc6865c7406fee",
"type": "function",
"z": "5fe7408840b1cdf3",
"g": "f8afda3620427140",
"name": "SOC ts les 10mn",
"func": "// Emission SoC toutes les x minutes (sans condition horloge)\n// - Au maximum 1 fois toutes les 10 minutes\n// - Met à jour flow.soc et laisse passer msg\n\nconst PERIOD_S = 10 * 60; // 10 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": 1240,
"wires": [
[
"a0b8045a6d72c0eb",
"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": 730,
"y": 1120,
"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": 220,
"wires": [
[
"8e2f3efe43139cbb"
]
]
},
{
"id": "3401e9994f1b41b7",
"type": "comment",
"z": "5fe7408840b1cdf3",
"g": "c64c5ecbe0654837",
"name": "Récupération de la Production et la Consommation J+1 estimée par Victron et Calcul SOC Cible",
"info": "",
"x": 550,
"y": 80,
"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": 220,
"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": 220,
"wires": []
},
{
"id": "b70ceb2c221e3bd2",
"type": "inject",
"z": "5fe7408840b1cdf3",
"g": "f8afda3620427140",
"name": "22:00",
"props": [
{
"p": "payload"
},
{
"p": "topic",
"vt": "str"
}
],
"repeat": "",
"crontab": "00 22 * * *",
"once": true,
"onceDelay": "2",
"topic": "",
"payload": "",
"payloadType": "date",
"x": 160,
"y": 1120,
"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": 1240,
"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": 220,
"wires": [
[
"309cfd45dc936c74"
]
]
},
{
"id": "4f987019c9af7859",
"type": "vrm-api",
"z": "5fe7408840b1cdf3",
"g": "c64c5ecbe0654837",
"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": 380,
"y": 140,
"wires": [
[
"59baa58061ee6884"
]
]
},
{
"id": "59baa58061ee6884",
"type": "function",
"z": "5fe7408840b1cdf3",
"g": "c64c5ecbe0654837",
"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": 550,
"y": 140,
"wires": [
[
"d0900f1bce2a4111"
]
]
},
{
"id": "30724406b2c3e9a6",
"type": "function",
"z": "5fe7408840b1cdf3",
"g": "c64c5ecbe0654837",
"name": "Calcul Soc Cible V8",
"func": "// ============================================\n// Calcul SOC Cible V7\n// DESS perso - Calcul SoC cible (sans fenêtre)\n// - SoC utile uniquement entre SOC_USE_MIN et 100%\n// - Ajout SOC_MORNING_BUFFER_PCT : plancher cible = SOC_USE_MIN + buffer\n// - Forçage (forc100=on) archi prioritaire : nfcp1 devient la cible stockée\n// Sorties :\n// 1) payload objet (dashboard / MQTT)\n// 2) log texte lisible (dashboard)\n// 3) log texte HA (fichier)\n// ============================================\n\n// -------- PARAMETRES BATTERIE --------\nconst batt_nom_kWh = 12.0; // capacité utile réelle (sur SOC_USE_MIN->100)\nconst SOC_USE_MIN = 21.0; // en dessous => énergie inutilisable (ex: 21 ou 30)\nconst SOC_USE_MAX = 100.0;\nconst eta = 0.92; // rendement global\n\n// ✅ Réserve matin (plancher cible AU-DESSUS du SoC utile)\nconst SOC_MORNING_BUFFER_PCT = 18.0; // ex: 15 => cible mini = SOC_USE_MIN + 15\n\n// -------- MARGES ----------\nconst marge_kWh = 2.5; //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}\nfunction ymd(d) {\n const y = d.getFullYear();\n const m = String(d.getMonth() + 1).padStart(2,'0');\n const da = String(d.getDate()).padStart(2,'0');\n return `${y}-${m}-${da}`;\n}\n\n// -------- TIME ----------\nconst now = new Date();\nconst today = ymd(now);\n\n// -------- FORCAGE (PRIORITAIRE) ----------\nconst f100 = flow.get('forc100'); // \"on\"/\"off\" (ou bool)\nconst nfcp1 = Number(flow.get('niveauforcp1')); // ex: 100, 85...\nconst isF100 = (f100 === \"on\" || f100 === true || f100 === 1 || f100 === \"1\");\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// Cible courante (veille) pour status en cas d'erreur\nconst cibleVeille = Number(flow.get('dess_soc_cible_pct'));\nconst cibleTxt = Number.isFinite(cibleVeille) ? `${cibleVeille.toFixed(1)}%` : `?%`;\n\n// ---- Normalisation SoC ----\nif (!ok(socNow)) socNow = 0;\nsocNow = clamp(socNow, 0, 100);\n\n// SoC \"utile\" pour les calculs énergie (en dessous du mini => mini)\nconst socNow_util = clamp(socNow, SOC_USE_MIN, SOC_USE_MAX);\n\n// -------- CAPACITES utiles (SOC_USE_MIN..100) ----------\nconst socMin = SOC_USE_MIN;\nconst socMax = SOC_USE_MAX;\n\n// ✅ Plancher de cible (réserve matin)\nconst soc_floor_target = clamp(socMin + SOC_MORNING_BUFFER_PCT, socMin, socMax);\n\n// Capacité utile sur la plage [socMin..socMax]\nconst batt_utile_kWh =\n Math.max(0.01, batt_nom_kWh * (socMax - socMin) / 100) * eta;\n\n// Energie disponible actuelle au-dessus du plancher utile\nconst fracNow = clamp((socNow_util - socMin) / (socMax - socMin), 0, 1);\nconst energie_dispo_kWh = fracNow * batt_utile_kWh;\n\n// -------- CALCUL CIBLE AUTO (si entrées OK) ----------\nlet deficit_kWh = null;\nlet dod = null;\nlet soc_cible_calc = null;\nlet energie_a_charger_kWh = null;\nlet soc_a_charger_pct = null;\n\nconst inputsOk = [conso_kWh, pv_kWh].every(ok);\n\n// Mode FORCE : on ne dépend pas des entrées, mais on peut quand même calculer à titre info si OK\nif (inputsOk) {\n deficit_kWh = (conso_kWh - pv_kWh) + marge_kWh;\n deficit_kWh = Math.max(0, deficit_kWh);\n\n dod = clamp(deficit_kWh / batt_utile_kWh, 0, 1);\n\n soc_cible_calc = socMin + dod * (socMax - socMin);\n soc_cible_calc = clamp(soc_cible_calc + marge_soc_pct, socMin, socMax);\n\n // ✅ applique le plancher \"réserve matin\"\n soc_cible_calc = Math.max(soc_cible_calc, soc_floor_target);\n\n // Energie cible correspondante\n const fracCible = clamp((soc_cible_calc - socMin) / (socMax - socMin), 0, 1);\n const energie_cible_kWh = fracCible * batt_utile_kWh;\n\n energie_a_charger_kWh = Math.max(0, energie_cible_kWh - energie_dispo_kWh);\n\n // SoC à charger en points \"utiles\"\n soc_a_charger_pct = clamp(soc_cible_calc - socNow_util, 0, socMax - socMin);\n}\n\n// -------- CIBLE STOCKÉE (veille) - FORCAGE PRIORITAIRE ----------\nlet soc_cible_stockee = null;\n\nif (isF100) {\n // Forçage archi prioritaire\n const forced = ok(nfcp1) ? nfcp1 : 100;\n soc_cible_stockee = clamp(forced, 0, 100);\n\n // On stocke direct la cible forcée (même si entrées KO)\n flow.set('dess_soc_cible_pct', Number(soc_cible_stockee.toFixed(1)));\n\n // DoD optionnel si inputs OK\n if (ok(dod)) flow.set('dess_dod_pct', Number((dod * 100).toFixed(1)));\n\n} else {\n // AUTO : nécessite entrées OK\n if (!inputsOk) {\n node.status({ fill: \"red\", shape: \"ring\", text: `Entrees manquantes | cible(veille)=${cibleTxt}` });\n\n const txt =\n `DESS_PERSO @ ${hhmm(now)} | ERREUR entrees` +\n ` | conso_j1=${flow.get('conso_j1')}` +\n ` | pv=${flow.get('previ_prod')}` +\n ` | soc=${flow.get('soc')}` +\n ` | cible_veille=${cibleTxt}`;\n\n return [ null, { payload: txt }, { payload: txt } ];\n }\n\n soc_cible_stockee = Number(soc_cible_calc.toFixed(1));\n\n // Mise à jour cible \"veille\"\n flow.set('dess_soc_cible_pct', soc_cible_stockee);\n flow.set('dess_dod_pct', Number((dod * 100).toFixed(1)));\n}\n\n// Trace dernier calcul\nflow.set('dess_soc_calc_ts', now.toISOString());\n\n// -------- SORTIE 1 : OBJET ----------\nconst payload = {\n // Inputs\n conso_kWh: ok(conso_kWh) ? Number(conso_kWh.toFixed(2)) : null,\n pv_kWh: ok(pv_kWh) ? Number(pv_kWh.toFixed(2)) : null,\n deficit_kWh: ok(deficit_kWh) ? Number(deficit_kWh.toFixed(2)) : null,\n\n // SoC\n soc_now_pct: Number(socNow.toFixed(1)),\n soc_now_util_pct: Number(socNow_util.toFixed(1)),\n soc_use_min_pct: socMin,\n soc_use_max_pct: socMax,\n\n // ✅ Buffer / plancher\n soc_morning_buffer_pct: SOC_MORNING_BUFFER_PCT,\n soc_floor_target_pct: Number(soc_floor_target.toFixed(1)),\n\n // Cibles\n soc_cible_calc_pct: ok(soc_cible_calc) ? Number(soc_cible_calc.toFixed(1)) : null,\n soc_cible_stockee_pct: Number(soc_cible_stockee.toFixed(1)),\n\n // Énergies\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: ok(energie_a_charger_kWh) ? Number(energie_a_charger_kWh.toFixed(2)) : null,\n soc_a_charger_pct: ok(soc_a_charger_pct) ? Number(soc_a_charger_pct.toFixed(1)) : null,\n\n // Meta\n dod_pct: ok(dod) ? Number((dod * 100).toFixed(1)) : null,\n forc100: (f100 !== undefined && f100 !== null ? f100 : null),\n niveauforcp1: (ok(nfcp1) ? nfcp1 : null),\n mode: isF100 ? \"FORCE\" : \"AUTO\",\n calc_day: today,\n calc_time: hhmm(now)\n};\n\nmsg.payload = payload;\n\n// -------- LOGS ----------\nconst deltaSoc = payload.soc_cible_stockee_pct - payload.soc_now_pct;\nconst lockTxt = isF100 ? `FORCE(${payload.soc_cible_stockee_pct}%)` : `AUTO(${payload.soc_cible_stockee_pct}%)`;\n\nconst logDash =\n `SOC Cible @ ${hhmm(now)}` +\n ` | SoC=${payload.soc_now_pct}%→${payload.soc_cible_stockee_pct}% (Δ=${deltaSoc.toFixed(1)}%)` +\n ` | SoCutile>=${SOC_USE_MIN}% (SoCutil=${payload.soc_now_util_pct}%)` +\n ` | floor=${payload.soc_floor_target_pct}% (buffer=${SOC_MORNING_BUFFER_PCT}%)` +\n (payload.deficit_kWh !== null ? ` | deficit=${payload.deficit_kWh}kWh` : ``) +\n (payload.energie_a_charger_kWh !== null ? ` | a_charger=${payload.energie_a_charger_kWh}kWh` : ``) +\n ` | mode=${lockTxt}` +\n ` | day=${today}`;\n\nconst logHa =\n `SOC Cible @ ${hhmm(now)}` +\n ` | mode=${lockTxt}` +\n ` | SoC=${payload.soc_now_pct}% (SoCutil=${payload.soc_now_util_pct}%, min=${SOC_USE_MIN}%)` +\n ` | floor=${payload.soc_floor_target_pct}% (buffer=${SOC_MORNING_BUFFER_PCT}%)` +\n ` | cible_stockee=${payload.soc_cible_stockee_pct}%` +\n (payload.soc_cible_calc_pct !== null ? ` | cible_calc=${payload.soc_cible_calc_pct}%` : ``) +\n (payload.conso_kWh !== null ? ` | conso_J1=${payload.conso_kWh}kWh` : ``) +\n (payload.pv_kWh !== null ? ` | pv_J1=${payload.pv_kWh}kWh` : ``) +\n (payload.deficit_kWh !== null ? ` | deficit=${payload.deficit_kWh}kWh` : ``) +\n ` | batt_utile=${payload.batt_utile_kWh}kWh` +\n ` | dispo=${payload.energie_dispo_kWh}kWh` +\n (payload.energie_a_charger_kWh !== null ? ` | a_charger=${payload.energie_a_charger_kWh}kWh` : ``) +\n (payload.soc_a_charger_pct !== null ? ` | soc_a_charger=${payload.soc_a_charger_pct}%` : ``) +\n (payload.dod_pct !== null ? ` | DoD=${payload.dod_pct}%` : ``) +\n ` | forc100=${String(f100)}` +\n ` | nfcp1=${ok(nfcp1) ? nfcp1 : \"NaN\"}` +\n ` | day=${today}`;\n\n// -------- STATUS ----------\nconst need = (payload.energie_a_charger_kWh !== null && payload.energie_a_charger_kWh > 0.05);\nnode.status({\n fill: isF100 ? \"red\" : (need ? \"yellow\" : \"green\"),\n shape: \"dot\",\n text: `SoC cible=${payload.soc_cible_stockee_pct}% (${payload.mode}) | floor=${payload.soc_floor_target_pct}% | SoC=${payload.soc_now_pct}% | ${hhmm(now)}`\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": 220,
"wires": [
[],
[
"fce8b570e7de2979"
],
[
"7914d10c7560953d",
"2f7797d5a59eb37f"
]
]
},
{
"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": 520,
"y": 1660,
"wires": []
},
{
"id": "2f7797d5a59eb37f",
"type": "link out",
"z": "5fe7408840b1cdf3",
"g": "c64c5ecbe0654837",
"name": "Log Audit",
"mode": "link",
"links": [
"573c180d02c5fc31"
],
"x": 945,
"y": 280,
"wires": []
},
{
"id": "573c180d02c5fc31",
"type": "link in",
"z": "5fe7408840b1cdf3",
"g": "27c9a9a63c937a72",
"name": "Log In",
"links": [
"2f7797d5a59eb37f",
"cfd34293e830d3cd",
"071b658e8fc627e4",
"bb69e83f2e660d25",
"a5d3d6de6b719c0a",
"f32446775a09a1fc"
],
"x": 315,
"y": 1660,
"wires": [
[
"30fc5ca1af8c2544",
"a70c71330cb91fb6"
]
]
},
{
"id": "071b658e8fc627e4",
"type": "link out",
"z": "5fe7408840b1cdf3",
"g": "f8afda3620427140",
"name": "link out 4",
"mode": "link",
"links": [
"573c180d02c5fc31"
],
"x": 755,
"y": 1180,
"wires": []
},
{
"id": "6d096b019c095341",
"type": "function",
"z": "5fe7408840b1cdf3",
"g": "f8afda3620427140",
"name": "H Dynamique V7",
"func": "// =====================================================\n// H_DYN v6.2 (SANS BigTimer) - Fenêtre sécurité PRIORITAIRE START_FLOOR→END_FIXED\n// - En dehors de la fenêtre sécurité : out=-1 + purge fenêtre\n// - Dans la fenêtre sécurité :\n// - si CP1 réellement ON (l_status==7 ou -7) => GEL (pas de recalcul d'heures)\n// * MAIS: si SoC>=cible => out=-1 (on coupe), sinon out=7 seulement si nowInWin_mem=true\n// - sinon => calcule hdebut (hfin fixe END_FIXED), mémorise flow.hdebut/hfin,\n// et sort 7 uniquement si nowInWin=true, sinon -1\n// Sorties : [payload_obj, log_txt]\n// =====================================================\n\n// --- Paramètres (identiques à votre algo) ---\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 = 15; // fenêtre minimale (min)\nconst MAX_WIN = 8 * 60; // garde-fou (min)\nconst STEP = 5; // pas minutes\n\n// Fenêtre sécurité/plancher + fin fixe\nconst START_FLOOR_MIN = 22 * 60 + 0; // 22:00\nconst END_FIXED_MIN = 5 * 60 + 45; // 5:45\n\n// Deadband (optionnel). 0 => désactivé\nconst DELTA_DEADBAND_PCT = 0.0;\n\n// --- Utils ---\nfunction clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }\nfunction normMin(mins) {\n mins = Number(mins);\n if (!Number.isFinite(mins)) mins = 0;\n return ((Math.round(mins) % 1440) + 1440) % 1440;\n}\nfunction hhmm(mins) {\n mins = normMin(mins);\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// Fenêtre [start..end) avec gestion minuit\nfunction isInWindow(nowMin, startMin, endMin) {\n nowMin = normMin(nowMin);\n startMin = normMin(startMin);\n endMin = normMin(endMin);\n\n if (startMin === endMin) return false;\n if (startMin < endMin) return (nowMin >= startMin) && (nowMin < endMin);\n return (nowMin >= startMin) || (nowMin < endMin);\n}\n// Fenêtre sécurité START_FLOOR→END_FIXED (peut traverser minuit)\nfunction isInSafety(nowMin) {\n nowMin = normMin(nowMin);\n return isInWindow(nowMin, START_FLOOR_MIN, 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 inSafety = isInSafety(nowMin);\n\n// --- Entrées SoC / cible ---\nlet soc = Number(flow.get('soc'));\nif (!Number.isFinite(soc)) soc = 0;\nsoc = clamp(soc, 0, 100);\n\nlet target = Number(flow.get('dess_soc_cible_pct'));\nif (!Number.isFinite(target)) target = 100;\ntarget = clamp(target, 0, 100);\n\n// Besoin en % (toujours interprété comme \"cible - soc\")\nconst need_pct_signed = target - soc; // signé\nconst need_pct = Math.max(0, need_pct_signed); // >= 0\nconst soc_ge_cible = (need_pct <= 0); // SoC >= cible\n\n// Deadband\nconst inDeadband = (DELTA_DEADBAND_PCT > 0) && (need_pct > 0) && (need_pct < DELTA_DEADBAND_PCT);\n\n// --- Status CP1 réel (retour Victron) ---\n// 7 = CP1 ON (réel). -7 = forcé depuis Victron (considéré ON côté \"réel\")\nlet l_status = Number(flow.get('l_status_cp1'));\nif (!Number.isFinite(l_status)) l_status = -1;\nconst cp1ReallyOn = (l_status === 7 || l_status === -7);\n\n// --- Fenêtre mémorisée ---\nlet hdeb_mem = Number(flow.get('hdebut'));\nlet hfin_mem = Number(flow.get('hfin'));\nif (!Number.isFinite(hdeb_mem)) hdeb_mem = null;\nif (!Number.isFinite(hfin_mem)) hfin_mem = END_FIXED_MIN;\n\n// =====================================================\n// 0) PRIORITÉ ABSOLUE : hors fenêtre sécurité => out=-1 + purge\n// =====================================================\nif (!inSafety) {\n flow.set('hdebut', null);\n flow.set('hfin', END_FIXED_MIN);\n\n const payloadOut = {\n status_cp1: -1,\n soc_pct: Number(soc.toFixed(1)),\n cible_soc_pct: Number(target.toFixed(0)),\n need_pct: Number(need_pct.toFixed(1)),\n now_hhmm,\n inSafety: false,\n nowInWin: false,\n hdebut_min: null,\n hfin_min: normMin(END_FIXED_MIN),\n hdebut_hhmm: null,\n hfin_hhmm: hhmm(END_FIXED_MIN),\n l_status_cp1: l_status,\n reason: \"Hors fenetre de tir\"\n };\n\n msg.payload = payloadOut;\n\n node.status({\n fill: \"blue\",\n shape: \"dot\",\n text: `H_DYN SAFE-OFF | hors ${hhmm(START_FLOOR_MIN)}→${hhmm(END_FIXED_MIN)} | out=-1`\n });\n\n const logOut =\n `H_DYN @ ${now_hhmm}` +\n ` | SoC=${payloadOut.soc_pct}% cible=${payloadOut.cible_soc_pct}% (need=${payloadOut.need_pct}%)` +\n ` | safety=FALSE | out=-1 | l_status=${l_status}` +\n ` | reason=${payloadOut.reason}`;\n\n return [msg, { payload: logOut }];\n}\n\n// =====================================================\n// 1) Dans START_FLOOR→END_FIXED : GEL si CP1 réellement ON (7 ou -7)\n// => on NE RECALCULE PAS\n// => la sortie reste à 7 tant qu'on est dans la fenêtre mémorisée\n// => dès qu'on sort de la fenêtre mémorisée => out=-1 + purge\n// =====================================================\nif (cp1ReallyOn) {\n const nowInWin_mem = (hdeb_mem !== null) ? isInWindow(nowMin, hdeb_mem, hfin_mem) : false;\n\n // Fenêtre prioritaire :\n // - dans la fenêtre mémorisée => 7\n // - hors fenêtre mémorisée => -1 (et purge)\n const out = nowInWin_mem ? 7 : -1;\n const reason = nowInWin_mem ? \"gel_keep_on\" : \"gel_outwin\";\n\n if (!nowInWin_mem) {\n flow.set('hdebut', null);\n flow.set('hfin', END_FIXED_MIN);\n }\n\n const payloadGel = {\n status_cp1: out,\n soc_pct: Number(soc.toFixed(1)),\n cible_soc_pct: Number(target.toFixed(0)),\n delta_soc_pct: Number((target - soc).toFixed(1)),\n now_hhmm,\n inSafetyNight: true,\n nowInWin: nowInWin_mem,\n hdebut_min: (hdeb_mem !== null ? normMin(hdeb_mem) : null),\n hfin_min: normMin(hfin_mem),\n hdebut_hhmm: (hdeb_mem !== null ? hhmm(hdeb_mem) : null),\n hfin_hhmm: hhmm(hfin_mem),\n l_status_cp1: l_status,\n reason\n };\n\n msg.payload = payloadGel;\n\n node.status({\n fill: (out === 7) ? \"green\" : \"blue\",\n shape: \"dot\",\n text: `H_DYN GEL | win=${payloadGel.hdebut_hhmm || \"--:--\"}→${payloadGel.hfin_hhmm} | nowInWin=${payloadGel.nowInWin} | out=${out}`\n });\n\n const logGel =\n `H_DYN @ ${now_hhmm}` +\n ` | SoC=${payloadGel.soc_pct}% cible=${payloadGel.cible_soc_pct}% (Δ=${payloadGel.delta_soc_pct}%)` +\n ` | safety=TRUE` +\n ` | win=${payloadGel.hdebut_hhmm || \"--:--\"}→${payloadGel.hfin_hhmm}` +\n ` | nowInWin=${payloadGel.nowInWin}` +\n ` | l_status=${l_status}` +\n ` | out=${out}` +\n ` | reason=${reason}`;\n\n return [msg, { payload: logGel }];\n}\n\n\n// =====================================================\n// 2) DANS fenêtre sécurité, CP1 pas ON : si pas besoin => OFF + purge\n// =====================================================\nif (soc_ge_cible || inDeadband) {\n flow.set('hdebut', null);\n flow.set('hfin', END_FIXED_MIN);\n\n const payloadOff = {\n status_cp1: -1,\n soc_pct: Number(soc.toFixed(1)),\n cible_soc_pct: Number(target.toFixed(0)),\n need_pct: Number(need_pct.toFixed(1)),\n now_hhmm,\n inSafety: true,\n nowInWin: false,\n hdebut_min: null,\n hfin_min: normMin(END_FIXED_MIN),\n hdebut_hhmm: null,\n hfin_hhmm: hhmm(END_FIXED_MIN),\n l_status_cp1: l_status,\n reason: soc_ge_cible ? \"soc>=cible\" : \"deadband\"\n };\n\n msg.payload = payloadOff;\n\n node.status({\n fill: \"blue\",\n shape: \"dot\",\n text: `H_DYN OFF | need=0 | out=-1`\n });\n\n const logOff =\n `H_DYN @ ${now_hhmm}` +\n ` | SoC=${payloadOff.soc_pct}% cible=${payloadOff.cible_soc_pct}% (need=${payloadOff.need_pct}%)` +\n ` | safety=TRUE | out=-1 | l_status=${l_status}` +\n ` | reason=${payloadOff.reason}`;\n\n return [msg, { payload: logOff }];\n}\n\n// =====================================================\n// 3) DANS fenêtre sécurité, besoin>0 : calcul hdebut (hfin fixe END_FIXED)\n// =====================================================\n\n// --- Calcul besoin (toujours positif) ---\nconst ah_needed = (need_pct / 100) * CAP_AH;\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 = fin - besoin, arrondi, wrap\nlet start_min_raw = END_FIXED_MIN - need_min;\nlet start_min = Math.floor(start_min_raw / STEP) * STEP;\nlet wrapped = false;\nif (start_min < 0) { start_min += 1440; wrapped = true; }\n\n// Clamp au plancher 22:00 si start n’est pas dans la fenêtre sécurité\nlet clampedToFloor = false;\nif (!isInSafety(start_min)) { start_min = START_FLOOR_MIN; clampedToFloor = true; }\n\n// Mémorise la fenêtre\nflow.set('hdebut', start_min);\nflow.set('hfin', END_FIXED_MIN);\n\n// nowInWin\nconst nowInWin = isInWindow(nowMin, start_min, END_FIXED_MIN);\n\n// Fenêtre prioritaire : dehors => -1\nconst out = nowInWin ? 7 : -1;\nconst reason = nowInWin ? \"need_inwin\" : \"need_outwin\";\n\n// Payload\nconst payload = {\n status_cp1: out,\n soc_pct: Number(soc.toFixed(1)),\n cible_soc_pct: Number(target.toFixed(0)),\n need_pct: Number(need_pct.toFixed(1)),\n now_hhmm,\n inSafety: true,\n nowInWin,\n need_min: need_min,\n bulk_min: Math.ceil(bulk_min),\n abs_min: abs_min,\n hdebut_min: normMin(start_min),\n hfin_min: normMin(END_FIXED_MIN),\n hdebut_hhmm: hhmm(start_min),\n hfin_hhmm: hhmm(END_FIXED_MIN),\n l_status_cp1: l_status,\n reason\n};\n\nmsg.payload = payload;\n\n// Status\nnode.status({\n fill: (out === 7) ? \"green\" : \"blue\",\n shape: \"dot\",\n text: `H_DYN ${payload.hdebut_hhmm}→${payload.hfin_hhmm} | nowInWin=${nowInWin} | out=${out}`\n});\n\n// Log\nconst flags = [\n wrapped ? \"wrap\" : null,\n clampedToFloor ? \"clampFloor\" : null\n].filter(Boolean).join(\",\");\n\nconst logTxt =\n `H_DYN @ ${now_hhmm}` +\n ` | SoC=${payload.soc_pct}% cible=${payload.cible_soc_pct}% (need=${payload.need_pct}%)` +\n ` | safety=TRUE` +\n ` | win=${payload.hdebut_hhmm}→${payload.hfin_hhmm}` +\n ` | nowInWin=${nowInWin}` +\n ` | need=${need_min}m (bulk=${payload.bulk_min}m abs=${payload.abs_min}m)` +\n ` | l_status=${l_status}` +\n ` | out=${out}` +\n ` | reason=${reason}` +\n (flags ? ` | flags=${flags}` : \"\");\n\nreturn [msg, { payload: logTxt }];\n",
"outputs": 2,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 410,
"y": 1120,
"wires": [
[
"0711c68c660d9c2f",
"e23bf7ba06f7abf5"
],
[
"c8d75201e67fa205",
"701744df73f35cf2",
"071b658e8fc627e4"
]
]
},
{
"id": "56c0dd1a9c9f59d5",
"type": "comment",
"z": "5fe7408840b1cdf3",
"g": "27c9a9a63c937a72",
"name": "Log Vers MQTT pour Stockage ds fichier via Home Assistant",
"info": "",
"x": 460,
"y": 1620,
"wires": []
},
{
"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": 680,
"y": 320,
"wires": []
},
{
"id": "58133dfe4f341acc",
"type": "comment",
"z": "5fe7408840b1cdf3",
"g": "f8afda3620427140",
"name": "Calcul Heure de début activation de la Charge Programée 1 de ESS Victron",
"info": "",
"x": 580,
"y": 1040,
"wires": []
},
{
"id": "0711c68c660d9c2f",
"type": "debug",
"z": "5fe7408840b1cdf3",
"g": "f8afda3620427140",
"name": "debug 108",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": true,
"complete": "true",
"targetType": "full",
"statusVal": "payload",
"statusType": "auto",
"x": 910,
"y": 1080,
"wires": []
},
{
"id": "c8d75201e67fa205",
"type": "debug",
"z": "5fe7408840b1cdf3",
"g": "f8afda3620427140",
"name": "debug 109",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": true,
"complete": "true",
"targetType": "full",
"statusVal": "payload",
"statusType": "auto",
"x": 910,
"y": 1140,
"wires": []
},
{
"id": "f26bc3295655d978",
"type": "ui_numeric",
"z": "5fe7408840b1cdf3",
"g": "c64c5ecbe0654837",
"name": "",
"label": "numeric",
"tooltip": "",
"group": "c8a7d12bb7f76f27",
"order": 17,
"width": 0,
"height": 0,
"wrap": false,
"passthru": true,
"topic": "topic",
"topicType": "msg",
"format": "{{value}}",
"min": 0,
"max": "100",
"step": 1,
"className": "",
"x": 640,
"y": 380,
"wires": [
[
"100b50b7dd6436f3"
]
]
},
{
"id": "100b50b7dd6436f3",
"type": "change",
"z": "5fe7408840b1cdf3",
"d": true,
"g": "c64c5ecbe0654837",
"name": "",
"rules": [
{
"t": "set",
"p": "dess_soc_cible_pct",
"pt": "flow",
"to": "payload",
"tot": "msg"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 910,
"y": 380,
"wires": [
[]
]
},
{
"id": "af7be0afa7182573",
"type": "function",
"z": "5fe7408840b1cdf3",
"g": "9669903f47819092",
"name": "Change",
"func": "// Equivalent d'un Change : set flow.valid_cp_ess = msg.payload\nflow.set(\"valid_cp_ess\", msg.payload);\n\nnode.status({\n fill: \"green\",\n shape: \"dot\",\n text: `valid_cp_ess=${String(msg.payload)}`\n});\n\nreturn msg;\n",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 460,
"y": 540,
"wires": [
[]
]
},
{
"id": "b79542847a8290f1",
"type": "function",
"z": "5fe7408840b1cdf3",
"g": "c64c5ecbe0654837",
"name": "Change",
"func": "// Equivalent d'un Change : set flow.niveauforcp1 = msg.payload\nflow.set(\"niveauforcp1\", msg.payload);\n\nnode.status({\n fill: \"green\",\n shape: \"dot\",\n text: `niveauforcp1=${String(msg.payload)}`\n});\n\nreturn msg;\n",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 460,
"y": 280,
"wires": [
[
"30724406b2c3e9a6"
]
]
},
{
"id": "e8f6758392b2f7d7",
"type": "function",
"z": "5fe7408840b1cdf3",
"g": "c64c5ecbe0654837",
"name": "Change",
"func": "// Equivalent d'un Change : set flow.forc100 = msg.payload\nflow.set(\"forc100\", msg.payload);\n\nnode.status({\n fill: \"green\",\n shape: \"dot\",\n text: `forc100=${String(msg.payload)}`\n});\n\nreturn msg;\n",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 460,
"y": 340,
"wires": [
[
"30724406b2c3e9a6"
]
]
},
{
"id": "5b9f7733139cceb7",
"type": "victron-input-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": "Status CP1",
"onlyChanges": false,
"x": 650,
"y": 540,
"wires": [
[
"7987ad4db47bf444"
]
]
},
{
"id": "51dc07974be1a13d",
"type": "function",
"z": "5fe7408840b1cdf3",
"g": "9669903f47819092",
"name": "Change",
"func": "// Equivalent d'un Change :\nflow.set(\"l_status_cp1\", msg.payload);\n\nnode.status({\n fill: \"green\",\n shape: \"dot\",\n text: `l_status_cp1=${String(msg.payload)}`\n});\n\nreturn msg;\n",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 980,
"y": 540,
"wires": [
[]
]
},
{
"id": "7987ad4db47bf444",
"type": "delay",
"z": "5fe7408840b1cdf3",
"g": "9669903f47819092",
"name": "",
"pauseType": "delay",
"timeout": "5",
"timeoutUnits": "seconds",
"rate": "1",
"nbRateUnits": "1",
"rateUnits": "second",
"randomFirst": "1",
"randomLast": "5",
"randomUnits": "seconds",
"drop": false,
"allowrate": false,
"outputs": 1,
"x": 820,
"y": 540,
"wires": [
[
"51dc07974be1a13d"
]
]
},
{
"id": "a5d3d6de6b719c0a",
"type": "link out",
"z": "5fe7408840b1cdf3",
"g": "9669903f47819092",
"name": "link out 9",
"mode": "link",
"links": [
"573c180d02c5fc31"
],
"x": 295,
"y": 800,
"wires": []
},
{
"id": "c84e616b7483e3a7",
"type": "rbe",
"z": "5fe7408840b1cdf3",
"g": "9669903f47819092",
"name": "",
"func": "rbe",
"gap": "",
"start": "",
"inout": "out",
"septopics": true,
"property": "payload",
"topi": "topic",
"x": 310,
"y": 540,
"wires": [
[
"af7be0afa7182573"
]
]
},
{
"id": "0980d6b80b2d707a",
"type": "rbe",
"z": "5fe7408840b1cdf3",
"g": "c64c5ecbe0654837",
"name": "",
"func": "rbe",
"gap": "",
"start": "",
"inout": "out",
"septopics": true,
"property": "payload",
"topi": "topic",
"x": 310,
"y": 280,
"wires": [
[
"b79542847a8290f1"
]
]
},
{
"id": "6c9e2d97af61d80e",
"type": "rbe",
"z": "5fe7408840b1cdf3",
"g": "c64c5ecbe0654837",
"name": "",
"func": "rbe",
"gap": "",
"start": "",
"inout": "out",
"septopics": true,
"property": "payload",
"topi": "topic",
"x": 310,
"y": 340,
"wires": [
[
"e8f6758392b2f7d7"
]
]
},
{
"id": "0b9a873b13652106",
"type": "inject",
"z": "5fe7408840b1cdf3",
"g": "9669903f47819092",
"name": "-1",
"props": [
{
"p": "payload"
},
{
"p": "topic",
"vt": "str"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"payload": "-1",
"payloadType": "num",
"x": 330,
"y": 600,
"wires": [
[
"cbe71d4c64597512"
]
]
},
{
"id": "19d7eb29a5c60cdc",
"type": "inject",
"z": "5fe7408840b1cdf3",
"g": "c64c5ecbe0654837",
"name": "21:45",
"props": [
{
"p": "payload"
},
{
"p": "topic",
"vt": "str"
}
],
"repeat": "",
"crontab": "45 21 * * *",
"once": false,
"onceDelay": "5",
"topic": "",
"payload": "",3)
"payloadType": "date",
"x": 140,
"y": 160,
"wires": [
[
"4f987019c9af7859"
]
]
},
{
"id": "d9004c05cabbfc69",
"type": "mqtt out",
"z": "5fe7408840b1cdf3",
"g": "c64c5ecbe0654837",
"name": "Previ Prod J+1",
"topic": "mp2/dess_remy/previ_prod_6-22-J1",
"qos": "",
"retain": "",
"respTopic": "",
"contentType": "",
"userProps": "",
"correl": "",
"expiry": "",
"broker": "502248144035edbf",
"x": 960,
"y": 160,
"wires": []
},
{
"id": "03c36694b589e110",
"type": "victron-output-settings",
"z": "5fe7408840b1cdf3",
"g": "51e27c869bb5e6f1",
"service": "com.victronenergy.settings",
"path": "/Settings/CGwacs/BatteryLife/State",
"serviceObj": {
"service": "com.victronenergy.settings",
"name": "Venus settings"
},
"pathObj": {
"path": "/Settings/CGwacs/BatteryLife/State",
"type": "enum",
"name": "ESS BatteryLife state",
"enum": {
"0": "Unused, BL disabled",
"1": "Restarting",
"2": "Self-consumption",
"3": "Self-consumption",
"4": "Self-consumption",
"5": "Discharge disabled",
"6": "Force charge",
"7": "Sustain",
"8": "Low Soc Recharge",
"9": "Keep batteries charged",
"10": "BL Disabled",
"11": "BL Disabled (Low SoC)",
"12": "BL Disabled (Low SOC recharge)"
}
},
"name": "",
"onlyChanges": false,
"x": 830,
"y": 1380,
"wires": []
},
{
"id": "ab48449b78d44baf",
"type": "victron-input-settings",
"z": "5fe7408840b1cdf3",
"g": "51e27c869bb5e6f1",
"service": "com.victronenergy.settings",
"path": "/Settings/CGwacs/BatteryLife/State",
"serviceObj": {
"service": "com.victronenergy.settings",
"name": "Venus settings"
},
"pathObj": {
"path": "/Settings/CGwacs/BatteryLife/State",
"type": "enum",
"name": "ESS BatteryLife state",
"enum": {
"0": "Unused, BL disabled",
"1": "Restarting",
"2": "Self-consumption",
"3": "Self-consumption",
"4": "Self-consumption",
"5": "Discharge disabled",
"6": "Force charge",
"7": "Sustain",
"8": "Low Soc Recharge",
"9": "Keep batteries charged",
"10": "BL Disabled",
"11": "BL Disabled (Low SoC)",
"12": "BL Disabled (Low SOC recharge)"
}
},
"name": "",
"onlyChanges": false,
"x": 830,
"y": 1440,
"wires": [
[]
]
},
{
"id": "1f1cd907d3eec9a0",
"type": "function",
"z": "5fe7408840b1cdf3",
"g": "51e27c869bb5e6f1",
"name": "Surveillance U Batt V1",
"func": "// =====================================================\n// GUARD Ubatt -> ESS BatteryLife State\n// Objectif : ne forcer QUE l'état 5 (Discharge disabled) si Ubatt trop basse,\n// sinon relâcher et laisser BatteryLife décider (NE RIEN écrire).\n// Dbus path: /Settings/CGwacs/BatteryLife/State\n// Sorties :\n// 1) msgWrite (seulement quand il faut écrire)\n// 2) log texte (fichier HA)\n// =====================================================\n\nconst V_CUTOFF = 46.0; // entree protection\nconst V_RELEASE = 46.8; // sortie protection (hystérésis)\n\n// lecture ubatt (adapte si besoin)\nlet ubatt = Number(flow.get(\"ubatt\"));\nif (!Number.isFinite(ubatt)) ubatt = Number(msg.payload);\nif (!Number.isFinite(ubatt)) {\n node.status({ fill:\"red\", shape:\"ring\", text:\"Ubatt absente\" });\n return [null, { payload: \"BL_GUARD | ERREUR ubatt absente\" }];\n}\n\n// état interne\nlet forced = (flow.get(\"bl_forced5\") === true);\n\n// décision hysteresis\nif (!forced && ubatt < V_CUTOFF) forced = true;\nelse if (forced && ubatt > V_RELEASE) forced = false;\n\n// détecter changement d'état du mode \"forçage\"\nconst prevForced = (flow.get(\"bl_forced5_prev\") === true);\nflow.set(\"bl_forced5\", forced);\nflow.set(\"bl_forced5_prev\", forced);\n\n// timestamp\nconst now = new Date();\nconst hhmm = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;\n\n// si on n'est pas en protection => NE RIEN écrire (laisser BL décider)\nif (!forced) {\n node.status({\n fill: \"green\",\n shape: \"dot\",\n text: `BL libre | Ubatt=${ubatt.toFixed(2)}V`\n });\n\n // log seulement quand on sort du mode forcé (événement significatif)\n if (prevForced) {\n const log = `BL_GUARD @ ${hhmm} | Ubatt=${ubatt.toFixed(2)}V | RELEASE -> BatteryLife libre`;\n return [null, { payload: log }];\n }\n return [null, null];\n}\n\n// forced == true => on veut imposer State=5\n// anti-spam: n'écrire que si on vient d'entrer en mode forcé ou si dernière écriture != 5\nconst lastWritten = Number(flow.get(\"bl_last_written\"));\nconst needWrite = (!Number.isFinite(lastWritten) || lastWritten !== 5 || !prevForced);\n\nflow.set(\"bl_last_written\", 5);\n\nnode.status({\n fill: \"red\",\n shape: \"dot\",\n text: `Discharge DISABLED (5) | Ubatt=${ubatt.toFixed(2)}V`\n});\n\n// message write\nconst msgWrite = {\n payload: 5,\n // si ton node write utilise msg.topic:\n // topic: \"/Settings/CGwacs/BatteryLife/State\"\n};\n\n// log\nconst log = `BL_GUARD @ ${hhmm} | Ubatt=${ubatt.toFixed(2)}V | FORCE State=5 (discharge disabled)`;\n\nreturn [needWrite ? msgWrite : null, { payload: log }];\n",
"outputs": 2,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 420,
"y": 1420,
"wires": [
[
"03c36694b589e110"
],
[
"f32446775a09a1fc",
"3cac79c890574a77"
]
]
},
{
"id": "800f167bb831e02b",
"type": "victron-input-battery",
"z": "5fe7408840b1cdf3",
"g": "51e27c869bb5e6f1",
"service": "com.victronenergy.battery/277",
"path": "/Dc/0/Voltage",
"serviceObj": {
"service": "com.victronenergy.battery/277",
"name": "SmartShunt 500A/50mV"
},
"pathObj": {
"path": "/Dc/0/Voltage",
"type": "float",
"name": "Battery voltage (V)"
},
"name": "U Batteries",
"onlyChanges": false,
"roundValues": "2",
"x": 120,
"y": 1420,
"wires": [
[
"b75a9e08a5dbf223"
]
]
},
{
"id": "b75a9e08a5dbf223",
"type": "change",
"z": "5fe7408840b1cdf3",
"g": "51e27c869bb5e6f1",
"name": "UB",
"rules": [
{
"t": "set",
"p": "ubatt",
"pt": "flow",
"to": "payload",
"tot": "msg"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 250,
"y": 1420,
"wires": [
[
"1f1cd907d3eec9a0"
]
],
"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": "f32446775a09a1fc",
"type": "link out",
"z": "5fe7408840b1cdf3",
"g": "51e27c869bb5e6f1",
"name": "link out 10",
"mode": "link",
"links": [
"573c180d02c5fc31"
],
"x": 605,
"y": 1460,
"wires": []
},
{
"id": "d61899b9b703dfe6",
"type": "comment",
"z": "5fe7408840b1cdf3",
"g": "51e27c869bb5e6f1",
"name": "Surveillance Tension Batteries: 46.0<->46.8V",
"info": "",
"x": 510,
"y": 1340,
"wires": []
},
{
"id": "a70c71330cb91fb6",
"type": "ui_text",
"z": "5fe7408840b1cdf3",
"g": "27c9a9a63c937a72",
"group": "c8a7d12bb7f76f27",
"order": 7,
"width": "12",
"height": "2",
"name": "",
"label": "Log HA :",
"format": "{{msg.payload}}",
"layout": "row-spread",
"className": "",
"style": false,
"font": "",
"fontSize": 16,
"color": "#000000",
"x": 700,
"y": 1660,
"wires": []
},
{
"id": "66037dc3f0b544c2",
"type": "inject",
"z": "5fe7408840b1cdf3",
"g": "51e27c869bb5e6f1",
"name": "",
"props": [
{
"p": "payload"
},
{
"p": "topic",
"vt": "str"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"payload": "",
"payloadType": "date",
"x": 370,
"y": 1380,
"wires": [
[
"03c36694b589e110"
]
]
},
{
"id": "3cac79c890574a77",
"type": "ui_text",
"z": "5fe7408840b1cdf3",
"g": "51e27c869bb5e6f1",
"group": "c8a7d12bb7f76f27",
"order": 2,
"width": "12",
"height": "2",
"name": "",
"label": "Log Surv Bat:",
"format": "{{msg.payload}}",
"layout": "row-spread",
"className": "",
"style": false,
"font": "",
"fontSize": 16,
"color": "#000000",
"x": 720,
"y": 1500,
"wires": []
},
{
"id": "7914d10c7560953d",
"type": "debug",
"z": "5fe7408840b1cdf3",
"g": "c64c5ecbe0654837",
"name": "debug 119",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": true,
"complete": "true",
"targetType": "full",
"statusVal": "payload",
"statusType": "auto",
"x": 970,
"y": 320,
"wires": []
},
{
"id": "7f9d7f52a663fbbc",
"type": "link out",
"z": "5fe7408840b1cdf3",
"g": "9669903f47819092",
"name": "link out Graph",
"mode": "link",
"links": [
"bbbf1b3d884bf814"
],
"x": 945,
"y": 900,
"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
}
]
