diff --git a/HANDOFF.md b/HANDOFF.md index aeae0d9..7054bde 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2188,5 +2188,33 @@ Le conseiller (observation toutes les 15 min, 1 500 « rien » / 871 signalement `/api/template` avec deux capteurs fictifs dates du 07/10, supprimes ensuite. Sauvegarde `evcs_master.yaml.bak-20261008-conseiller`. +### 08/10 ~07:30 — Plan du planificateur, dialogue avec le conseiller dans HA, arbitre encadre +Constat du proprietaire : chaque incident produit une regle par fenetre ou par vehicule (« 50 000 cas tordus par +approximations successives ») ; cette nuit la VW a charge A FOND de 05:17 a 07:24 au lieu de 6 A des 01:24. +- **Plan** : `docs/PLAN_PLANIFICATEUR_HC.md` (A VALIDER, aucun code) — une seule allocation par fenetre HC : + besoins (batterie, voiture, chauffe-eau, voiture sur batterie) x energie x echeance x priorite -> puissance + minimale constante qui finit a l'echeance, bornee par 90 % de 12 kVA - maison ; sorties coherentes (borne, ACM, + consigne, contacteur engage pour la fenetre). Ombre puis rejeu puis bascule par besoin. Quatre decisions au + proprietaire (§6 : priorites, niveau inconnu, etaler ou finir tot, sens de « sans EDF »). +- **Dialogue dans HA** : workflow n8n **`EVCS Conseiller Dialogue V1` (`zs7j7jvi6Q7dGDrJ`, toutes les minutes)**, + `src/conseiller-dialogue/`, tests `tests/conseiller-dialogue/run.mjs` 14/14 (mutation qui mord). Question dans + `input_text.evcs_conseiller_question` (paquet `evcs_conseiller_dialogue.yaml`), reponse dans + `sensor.evcs_conseiller_reponse` (question, question_at, reponse, avertissement, chiffres_non_trouves, modele, + source local/openrouter). Donnees transmises : mesures, controleur, bilan, chauffe-eau, prevision de nuit, revue et + pistes. Les chiffres de la reponse absents des donnees sont SIGNALES en tete (arrondis admis). Vocabulaire impose + (contacteur on = EDF FERME ; ACM = Auto Charge Manager) apres le premier essai reel (07:21) ou le modele avait + dit « on (ouvert) ». Carte « Poser une question au conseiller » (EVCS Master, Session). +- **Arbitre encadre** (`tools/arbitrage.mjs`, tests `tests/arbitrage/run.mjs` 12/12, mutation qui mord ; service + `arbitrer()`) : situation tenue 10 min -> `sensor.evcs_arbitrage` (`question` | `repondu` | `aucun`, attributs id, + cas, question, choix [{cle, libelle}], depuis) ; reponse du concierge dans `input_text.evcs_arbitrage_reponse` + (JSON {id, choix, source, confirmed_at}) ; seul l'ordre attache au choix, pris dans `ORDRES` (ajout + `mode_nuit`), source humaine, si `input_boolean.evcs_arbitrage_enabled` (ON, paquet `evcs_arbitrage.yaml`) ; + le guichet general `airtria_ordres_enabled` (OFF) n'est pas concerne. Table `arbitrage` (question, choix, + reponse, application, issue resolu_apres_reponse / resolu_sans_reponse). **Premier cas** : + `voiture_bloquee_edf_ferme` (HC, EDF ferme, voiture branchee non pleine bloquee ; nuit -> « complete », midi -> + « force_grid_now »). Pas encore de cas de jour (force_charge_now n'a pas d'echeance : a traiter avant). + Carte « Arbitrage » (EVCS Master). Cote SPARK : envoi WhatsApp et ecriture de la reponse, a cabler. +- Service redemarre 07:25. + --- -*Document mis a jour le : 08/10/2026 (~05:55) — conseiller relecteur de la journee (revue calculee + pistes) ; nuit : voiture qui profite d'EDF ferme pour la batterie, nuit qui prepare le soir les jours annonces faibles ; 08/10/2026 (~00:15) — jour faible : charge maximale en HC de midi (bilan 100 %, ACM plafond + solaire) ; 07/10/2026 (~22:45) — reprise de 16 h verifiee, EDF en HP a 18:03, bilan : reprise du soir et prevision recalee sur la matinee ; bilan HC de midi, reprise du soir apres coupure en pleine chauffe, mesure ECS sur 10 min ; recharge reseau HC : courant calcule (12 kVA) et consigne non tenue ; HC de midi : AUTO ne chauffe pas (BOOST), alerte v2, Jeep (contact, relecture, zone), recharge batterie bridee a 30 A par l'ACM ; jour faible = EDF les deux fenetres HC pour le chauffe-eau, alerte BOOST, Victron Mobile refait ; 1re decision jour faible en reel, mesure ECS reparee, douches 06:00/20:00, Grafana chauffe-eau, tableaux ECS ; chauffe-eau en HC de nuit les jours faibles (decision du bilan, workflow ECS sous controle du depot, VRM, V2.3 desactive) ; garde batterie reseau : voiture bridee a 6 A au lieu d'un arret ; garde-fou concierge 2 h ; allers-retours brefs de la borne retrouves dans l'historique HA (echange VW -> JEEP de 20 s), production 14,5 kWh contre 22 prevus, defaut garde batterie en mode reseau a trancher ; voiture a midi : la batterie n'est plus comptee comme du solaire (EDF automatique les jours de depart si le surplus ne couvre pas) ; nuit 05->06 : JEEP chargee sur EDF en complete (6,32 kWh), budget 0 du a la prevision, Windy/ECMWF contre VRM, a comparer ce soir ; concierge aligne sur la preuve fin de trajet ; a verifier : nuit 05->06 et mardi faible, comparaison VRM/Forecast.Solar ; JEEP identifiee aussi par sa fin de trajet, prevision de la nuit 05->06 ; identification JEEP reussie en reel, correction humaine qui prime (refus supprime), session du 04/10 coupee ; EVCS Master en quatre onglets ; Jeep lue par Uconnect et identifiee seule, capacite JEEP 10 kWh, concierge variante (a) ; releve par photo (SPARK), hass-uconnect en attente ; ecart semaine/week-end mesure (pas de profil week-end la nuit) ; nuit du budget : budget borne au plancher prudent, sans EDF = voiture bloquee meme EDF ferme (batterie seule) ; Energie : sauts corriges ; tableau Energie complete (Sud/Ouest, batterie par integration, borne ; aucun redemarrage HA) ; kilometrage des vehicules (km/kWh, capacite, releves de reference, agent cable 15/15) ; a verifier le 04/10 au matin : nuit du budget JEEP ; concierge : vehicule non confirme demande, heure de saisie ; Grafana vehicules en tableau ; Grafana vehicules : ZERO et Invite ajoutes, session 16:46 reetiquetee ZERO ; prevision SOC 10 h ~35 % ; concierge fiable : niveau note par le script, une ligne, dedoublonnage, simulation par drapeau ; agent : message incompris = question, bourse sur demande explicite (banc final 6/6) ; a verifier : nuit du samedi 03 au dimanche 04/10 (budget < besoin JEEP), cron orange-cloture lundi 05/10 apres 17:40, comparaison VRM / Forecast.Solar lundi 05/10* +*Document mis a jour le : 08/10/2026 (~07:30) — conseiller relecteur de la journee (revue calculee + pistes) ; nuit : voiture qui profite d'EDF ferme pour la batterie, nuit qui prepare le soir les jours annonces faibles ; 08/10/2026 (~00:15) — jour faible : charge maximale en HC de midi (bilan 100 %, ACM plafond + solaire) ; 07/10/2026 (~22:45) — reprise de 16 h verifiee, EDF en HP a 18:03, bilan : reprise du soir et prevision recalee sur la matinee ; bilan HC de midi, reprise du soir apres coupure en pleine chauffe, mesure ECS sur 10 min ; recharge reseau HC : courant calcule (12 kVA) et consigne non tenue ; HC de midi : AUTO ne chauffe pas (BOOST), alerte v2, Jeep (contact, relecture, zone), recharge batterie bridee a 30 A par l'ACM ; jour faible = EDF les deux fenetres HC pour le chauffe-eau, alerte BOOST, Victron Mobile refait ; 1re decision jour faible en reel, mesure ECS reparee, douches 06:00/20:00, Grafana chauffe-eau, tableaux ECS ; chauffe-eau en HC de nuit les jours faibles (decision du bilan, workflow ECS sous controle du depot, VRM, V2.3 desactive) ; garde batterie reseau : voiture bridee a 6 A au lieu d'un arret ; garde-fou concierge 2 h ; allers-retours brefs de la borne retrouves dans l'historique HA (echange VW -> JEEP de 20 s), production 14,5 kWh contre 22 prevus, defaut garde batterie en mode reseau a trancher ; voiture a midi : la batterie n'est plus comptee comme du solaire (EDF automatique les jours de depart si le surplus ne couvre pas) ; nuit 05->06 : JEEP chargee sur EDF en complete (6,32 kWh), budget 0 du a la prevision, Windy/ECMWF contre VRM, a comparer ce soir ; concierge aligne sur la preuve fin de trajet ; a verifier : nuit 05->06 et mardi faible, comparaison VRM/Forecast.Solar ; JEEP identifiee aussi par sa fin de trajet, prevision de la nuit 05->06 ; identification JEEP reussie en reel, correction humaine qui prime (refus supprime), session du 04/10 coupee ; EVCS Master en quatre onglets ; Jeep lue par Uconnect et identifiee seule, capacite JEEP 10 kWh, concierge variante (a) ; releve par photo (SPARK), hass-uconnect en attente ; ecart semaine/week-end mesure (pas de profil week-end la nuit) ; nuit du budget : budget borne au plancher prudent, sans EDF = voiture bloquee meme EDF ferme (batterie seule) ; Energie : sauts corriges ; tableau Energie complete (Sud/Ouest, batterie par integration, borne ; aucun redemarrage HA) ; kilometrage des vehicules (km/kWh, capacite, releves de reference, agent cable 15/15) ; a verifier le 04/10 au matin : nuit du budget JEEP ; concierge : vehicule non confirme demande, heure de saisie ; Grafana vehicules en tableau ; Grafana vehicules : ZERO et Invite ajoutes, session 16:46 reetiquetee ZERO ; prevision SOC 10 h ~35 % ; concierge fiable : niveau note par le script, une ligne, dedoublonnage, simulation par drapeau ; agent : message incompris = question, bourse sur demande explicite (banc final 6/6) ; a verifier : nuit du samedi 03 au dimanche 04/10 (budget < besoin JEEP), cron orange-cloture lundi 05/10 apres 17:40, comparaison VRM / Forecast.Solar lundi 05/10* diff --git a/dashboards/evcs_master.yaml b/dashboards/evcs_master.yaml index 0f00a91..aee2017 100755 --- a/dashboards/evcs_master.yaml +++ b/dashboards/evcs_master.yaml @@ -270,9 +270,57 @@ views: Tout est gardé en base (`revue_constat`, `revue_piste`, avec les pistes écartées et leur motif) pour rejouer le conseiller plus tard et mesurer s'il progresse. - type: markdown - title: Dialoguer avec le conseiller + title: ⚖️ Arbitrage (cas non prévus par les règles) content: | - Pour l'instant, le dialogue passe par **OpenClaw** (WhatsApp ou vocal) : « qu'est-ce qui a cloché hier ? » — il lit la revue et les pistes ci-dessus, et présente les pistes comme des avis. + {% set a = states.sensor.evcs_arbitrage %} + {% if a is none or a.state == 'aucun' %} + Aucune question en attente.{% if a is not none and a.attributes.dernier_cas %} Dernier arbitrage : {{ a.attributes.dernier_cas }} ({{ a.attributes.derniere_issue }}).{% endif %} + {% else %} + {% if a.state == 'question' %}❓ **Question envoyée par WhatsApp**{% else %}✅ **Réponse reçue**{% endif %} (depuis {{ as_timestamp(a.attributes.depuis) | timestamp_custom('%H:%M') if a.attributes.depuis else '?' }}) + + {{ a.attributes.question }} + + {% for c in a.attributes.choix | default([]) %} + **{{ c.cle }}.** {{ c.libelle }}
+ {% endfor %} + {% if a.state == 'repondu' %} + Réponse : **{{ a.attributes.reponse }}** → {{ a.attributes.application }} + {% endif %} + {% endif %} + + Le code détecte la situation et propose des choix fermés ; seul l'ordre du choix retenu est appliqué (liste blanche). Appliquer : {{ states('input_boolean.evcs_arbitrage_enabled') }}. + - type: entities + entities: + - entity: input_boolean.evcs_arbitrage_enabled + name: Arbitrage — appliquer ma réponse + + - type: entities + title: 💬 Poser une question au conseiller + entities: + - entity: input_text.evcs_conseiller_question + name: Ta question (Entrée pour envoyer) + - type: markdown + content: | + {% set r = states.sensor.evcs_conseiller_reponse %} + {% set q = states('input_text.evcs_conseiller_question') %} + {% if r is none %} + Pas encore de réponse. Écris une question ci-dessus : le conseiller répond en une à deux minutes (il lit l'état du système, la revue du jour et ses pistes). + {% else %} + {% set en_attente = (as_timestamp(states.input_text.evcs_conseiller_question.last_changed) | int(0)) > (r.attributes.question_at | int(0)) %} + {% if en_attente %}⏳ **Question en cours de traitement…** (« {{ q }} ») + + --- + {% endif %} + **Question** ({{ r.state }}) : _{{ r.attributes.question }}_ + + {% if r.attributes.avertissement %}⚠️ **{{ r.attributes.avertissement }}** + + {% endif %} + {{ r.attributes.reponse }} + + {% if r.attributes.source == 'openrouter' %}☁️ Réponse d'**OpenRouter** (cloud, secours : la Spark n'a pas répondu){% else %}🏠 Réponse du modèle local de la **Spark** ({{ r.attributes.modele }}){% endif %} · le conseiller explique, il ne commande rien. + {% endif %} + Aussi par **OpenClaw** (WhatsApp ou vocal) : « qu'est-ce qui a cloché hier ? » - type: entities entities: diff --git a/docs/PLAN_PLANIFICATEUR_HC.md b/docs/PLAN_PLANIFICATEUR_HC.md new file mode 100644 index 0000000..ffb5c4f --- /dev/null +++ b/docs/PLAN_PLANIFICATEUR_HC.md @@ -0,0 +1,156 @@ +# Plan — le planificateur des heures creuses + +> Rédigé le 08/10/2026 à la demande du propriétaire, après la nuit du 07 au 08/10. +> **Statut : à valider. Aucune ligne de code n'est écrite pour ce chantier.** + +## 1. Le problème + +Depuis septembre, chaque incident a produit une règle de plus, écrite pour **une +fenêtre** ou **un véhicule** : + +| règle | pour quel cas | +|---|---| +| P7 | voiture sur le réseau en HC de nuit | +| P12 + budget | voiture sur la batterie la nuit, arrêt au compteur | +| « sans EDF » / « complète » | choix manuel du soir, remis chaque jour à 12:00 | +| option A (04/10), puis son inverse (08/10) | voiture bloquée, puis non, quand EDF est fermé pour la batterie | +| voiture à midi (28/09, 06/10) | jour de départ JEEP, la batterie n'est pas du solaire | +| chauffe-eau jour faible (07/10) | eau chaude en HC sur EDF | +| charge maximale de midi (08/10) | batterie jour faible | +| la nuit prépare le soir (08/10) | batterie jour annoncé faible | +| courant ACM calculé (07/10) | 30 A fixes la nuit | +| consigne non tenue (07/10) | batterie à 3 A à midi | + +Chaque règle est juste dans son cas. Les trous apparaissent **aux +croisements** : la HC de midi gérait la voiture, la nuit non ; le budget +voyait la batterie, pas la voiture ; le courant de la voiture n'a jamais été +pensé en fonction du temps restant. Cette nuit, la VW a attendu 01:24 → 05:14, +puis a chargé **à fond** de 05:17 à 07:24 parce qu'une correction est arrivée +tard. Le bon comportement était connu : **6 A dès 01:24, fini à 07:24**. + +## 2. Le principe : une seule question, posée à chaque cycle + +> **« Dans cette fenêtre d'heures creuses, qu'y a-t-il à donner, à qui, avant +> quand — et quelle puissance faut-il à chacun pour finir à temps, dans la +> limite de ce que l'abonnement laisse ? »** + +Une fenêtre HC (nuit ou midi, sans distinction de code) a : + +- une **ressource** : `P_libre(t) = 90 % × 12 kVA − maison (hors charges + pilotées)`, plus le solaire de jour (courant continu, en plus) ; bornée par les + 140 A des deux MultiPlus et par 32 A à la borne ; +- une **fin** (07:24 ou 14:24) ; +- une liste de **besoins**, chacun avec une énergie, une échéance et une + priorité. + +| besoin | énergie | échéance | d'où vient le chiffre | +|---|---|---|---| +| **batterie** | jusqu'à la cible du bilan (« tenir jusqu'à la prochaine HC », « préparer le soir », « charge maximale » les jours faibles) | fin de fenêtre | bilan, déjà calculé | +| **voiture branchée** | (100 − niveau) × capacité − déjà donné | min(fin de fenêtre, départ − marge) | niveau Uconnect (JEEP) ou saisi ; sinon « jusqu'au plein » avec la capacité mesurée | +| **chauffe-eau** (jour faible) | reprise mesurée (~2,1-2,3 kWh) | fin de fenêtre, BOOST | historique ECS | +| **voiture sur batterie** (ex-P12) | budget batterie au-dessus du plancher prudent | fin de fenêtre | bilan, déjà calculé | + +**Allocation** (le cœur, une fonction pure) : + +1. Pour chaque besoin, la puissance **minimale constante** qui finit à + l'échéance, plus une marge de 30 min (`énergie / (temps restant − 0,5 h)`). + C'est le « faible ampérage toute la nuit » : la VW à 6 A, la JEEP à 6-8 A. +2. Bornes physiques : voiture entre 6 A et 32 A (et sa propre limite ~16 A), + batterie ≤ 140 A, chauffe-eau tout ou rien. +3. Si la somme dépasse `P_libre`, on coupe selon un **ordre de priorité** + (à choisir par le propriétaire, voir §6) ; ce qui est coupé est reporté sur + le temps restant, donc la puissance remonte au cycle suivant. +4. Recalcul **à chaque cycle** : la maison qui monte (cuisine) réduit les + allocations, elle qui redescend les rend. Rattrapage automatique. + +**Sorties**, toutes déduites du même plan, donc cohérentes par construction : + +- courant de la borne (contrôleur EVCS) ; +- courant de charge de la batterie (ACM, DVCC) ; +- consigne réseau = maison + somme des allocations réseau ; +- contacteur : fermé pour **toute la fenêtre** dès qu'un besoin réseau existe + (engagement monotone, relâché à la fin de la fenêtre — la règle du 03/09, qui + ne crée pas d'oscillateur). + +Ce qui disparaît : P7, la reprise réseau de nuit pour la voiture, l'option A et +son inverse, la règle voiture-à-midi dans sa forme actuelle, le courant +calculé de l'ACM (il devient une sortie du plan). Le choix « sans EDF / +complète » devient une **politique** du plan (« la voiture a-t-elle droit au +réseau en HC si personne d'autre n'en a besoin ? ») au lieu d'un interrupteur +qui pilote trois chemins différents. + +Ce qui reste **inchangé** : toute la logique solaire de jour hors HC, les gardes +de sécurité (hystérésis 20/30, garde batterie en charge réseau bridée à 6 A, +plafonds BMS), le bilan (il fournit les énergies), le chauffe-eau hors HC. + +## 3. Où il vit + +- **Énergies et échéances : le bilan** (toutes les 5 min, il a la prévision et le + profil) publie un plan `input_text.evcs_plan_hc` : besoins, énergies, + échéances, priorités. +- **Allocation : le contrôleur** (toutes les 30 s, il a la maison en direct), + module pur `src/nodes/PLAN_HC.js`, appelé depuis `WINDOWS_SOURCEV2`. +- **ACM** : lit la part batterie du plan au lieu de recalculer. + +## 4. Migration sans casser + +1. **Ombre** (1 semaine) : le plan est calculé et publié à côté des décisions + réelles (`sensor.evcs_plan_hc_ombre` : « ce que j'aurais fait »), sans rien + commander. La revue du matin gagne un constat « écart entre le plan et le + réel » (ex. : voiture bloquée alors que le plan la chargeait à 6 A). +2. **Rejeu** sur les nuits historisées (`decision`, `mesure`, `session_charge`, + depuis le 04/09) : le plan aurait-il évité les incidents connus ? +3. **Bascule par besoin**, chacune derrière un interrupteur de retour arrière : + voiture de nuit d'abord, puis batterie (ACM), puis midi, puis chauffe-eau. +4. Retrait des anciennes règles une fois la bascule tenue deux semaines. + +## 5. Critères d'acceptation (golden, avant toute bascule) + +Les incidents réels deviennent les scénarios d'acceptation : + +| incident | ce que le plan doit faire | +|---|---| +| 07→08/10, VW branchée, session de batterie | VW à 6 A de 01:24 à ~05:30, pleine avant 07:24 | +| 03→04/10, JEEP, budget 1,41 kWh | batterie protégée, JEEP sur réseau en HC à faible courant | +| 06/10 midi, JEEP départ 15:30 | réseau dès 12:24, JEEP finie avant 15:00 | +| 07/10 midi, jour faible, BOOST + cuisine | batterie au maximum que laisse l'abonnement | +| 05→06/10, JEEP départ 06:30, complète | JEEP finie à 06:00 à faible courant | +| nuit ensoleillée, rien à donner | contacteur ouvert, rien ne change | + +## 6. Décisions à prendre par le propriétaire + +1. **Ordre de priorité** quand l'abonnement ne suffit pas. Proposition : + sécurité batterie (plancher) > voiture avec départ dans la fenêtre > + batterie (cible) > chauffe-eau jour faible > voiture sans départ. +2. **Voiture dont le niveau est inconnu** (VW, ZERO sans saisie) : supposer + « vide » (capacité entière, le plan finit tôt) ou demander par WhatsApp + (l'arbitre, §7) ? +3. **Étaler ou finir tôt ?** Étaler (faible courant jusqu'à la fin − 30 min) + ménage la batterie de la voiture et l'abonnement ; finir tôt protège d'un + départ imprévu. Proposition : étaler, avec fin visée à **fin de fenêtre − + 1 h** pour la voiture. +4. **« Sans EDF » garde-t-il un sens ?** Proposition : oui, comme politique — + « la voiture ne fait jamais fermer EDF à elle seule » — mais elle profite + toujours d'une session ouverte pour la batterie ou le chauffe-eau. + +## 7. Et ce que le plan ne saura pas faire : l'arbitre + +Le plan couvre les cas **prévus**. Pour le reste, l'arbitre encadré +(construit en parallèle, voir HANDOFF) : le code détecte une situation +incohérente en direct, publie une question à choix fermés, OpenClaw te +l'envoie, ta réponse est appliquée par le guichet d'ordres (liste blanche). +Chaque arbitrage est historisé ; un cas qui revient avec la même réponse +devient une règle du plan, après rejeu. + +## 8. Effort estimé + +| étape | contenu | +|---|---| +| A | `PLAN_HC.js` pur + 20 scénarios (dont §5) | +| B | publication du plan par le bilan + ombre dans le contrôleur + constat de revue | +| C | simulateur de rejeu sur les nuits historisées | +| D | bascule voiture de nuit, puis batterie, puis midi, puis chauffe-eau | +| E | retrait des anciennes règles, nettoyage des golden | + +A + B : une session. C : une session. D et E s'étalent sur deux à trois semaines +d'observation. diff --git a/packages/evcs_arbitrage.yaml b/packages/evcs_arbitrage.yaml new file mode 100755 index 0000000..96f12e7 --- /dev/null +++ b/packages/evcs_arbitrage.yaml @@ -0,0 +1,14 @@ +# Arbitre encadre (08/10/2026, proprietaire). Le service d'historisation publie +# sensor.evcs_arbitrage (question a choix fermes) ; le concierge OpenClaw l'envoie +# par WhatsApp et ecrit la reponse ici (JSON {id, choix, source, confirmed_at}). +# Seul l'ordre attache au choix retenu est applique, pris dans la liste blanche +# du guichet d'ordres, et seulement si l'interrupteur est ON. +input_text: + evcs_arbitrage_reponse: + name: Arbitrage - reponse du proprietaire + max: 255 + icon: mdi:scale-balance +input_boolean: + evcs_arbitrage_enabled: + name: Arbitrage - appliquer la reponse + icon: mdi:scale-balance diff --git a/packages/evcs_conseiller_dialogue.yaml b/packages/evcs_conseiller_dialogue.yaml new file mode 100755 index 0000000..a788d55 --- /dev/null +++ b/packages/evcs_conseiller_dialogue.yaml @@ -0,0 +1,10 @@ +# Dialogue avec le conseiller IA (08/10/2026, proprietaire). +# Ecrire une question ici : le workflow n8n « EVCS Conseiller Dialogue V1 » +# (toutes les minutes) la voit et publie la reponse dans +# sensor.evcs_conseiller_reponse (carte du conseiller, EVCS Master, Session). +# Le conseiller explique, il ne commande rien. +input_text: + evcs_conseiller_question: + name: Question au conseiller + max: 255 + icon: mdi:chat-question-outline diff --git a/src/conseiller-dialogue/BUILD_HA_CALLS.js b/src/conseiller-dialogue/BUILD_HA_CALLS.js new file mode 100644 index 0000000..ac84249 --- /dev/null +++ b/src/conseiller-dialogue/BUILD_HA_CALLS.js @@ -0,0 +1,27 @@ +// ===================================================== +// CONSEILLER — DIALOGUE : BUILD_HA_CALLS (08/10/2026) +// GARANTIE : n'ecrit QUE sensor.evcs_conseiller_reponse (via /api/states) et +// input_text.evcs_advisor_status. AUCUN switch, AUCUNE strategie, AUCUNE +// commande. Ne pas ajouter d'autres entites ici. +// ===================================================== + +const { status, reponse, avertissement, inventes, source, modelUsed, context } = $input.first().json; +const stamp = new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); + +return [{ json: { state_entity: 'sensor.evcs_conseiller_reponse', data: { + state: stamp, + attributes: { + friendly_name: 'Reponse du conseiller', + question: context.question, + question_at: context.questionAt, + statut: status, + reponse: reponse || (status === 'llm_offline' ? 'Le modele n a pas repondu (Spark et OpenRouter hors ligne). Reessaie en reecrivant la question.' : ''), + avertissement: avertissement || '', + chiffres_non_trouves: inventes || [], + modele: modelUsed, + source, + source_texte: source === 'openrouter' + ? 'Repli OpenRouter (cloud) : le modele local de la Spark n a pas repondu, la question et les donnees ont ete envoyees a OpenRouter.' + : 'Modele local de la Spark : rien n est sorti de la maison.', + }, +} } }]; diff --git a/src/conseiller-dialogue/BUILD_PROMPT.js b/src/conseiller-dialogue/BUILD_PROMPT.js new file mode 100644 index 0000000..6c20643 --- /dev/null +++ b/src/conseiller-dialogue/BUILD_PROMPT.js @@ -0,0 +1,72 @@ +// ===================================================== +// CONSEILLER — DIALOGUE DANS HOME ASSISTANT (08/10/2026, proprietaire) +// Le proprietaire ecrit une question dans input_text.evcs_conseiller_question ; +// ce workflow (toutes les minutes) la voit, interroge le modele avec l'etat du +// systeme, la revue du jour et ses pistes, et publie la reponse dans +// sensor.evcs_conseiller_reponse, affichee sur la carte du conseiller. +// Le conseiller EXPLIQUE, il ne commande rien (doctrine §79 : les chiffres +// viennent des mesures transmises, jamais de lui). +// ===================================================== + +const raw = $input.first().json; +const input = typeof raw.data === 'string' ? JSON.parse(raw.data) : (raw.data || raw); +const onOff = (v) => (v ?? 'off').toString().trim().toLowerCase() === 'on'; +const txt = (v) => (v ?? '').toString().trim(); + +const question = txt(input.question); +const questionAt = Number(input.question_at) || 0; +const nouvelle = !!question && !['unknown', 'unavailable'].includes(question) + && questionAt > (Number(input.derniere_question_at) || 0); + +let pistes = input.revue_pistes; +if (typeof pistes === 'string') { try { pistes = JSON.parse(pistes); } catch { pistes = []; } } +if (!Array.isArray(pistes)) pistes = []; + +const donnees = [ + `Maintenant : ${txt(input.maintenant)} (heure de Paris).`, + `Batterie : SOC ${txt(input.soc_percent)} %, courant ${txt(input.battery_current_a)} A (positif = charge) ; courant de charge maximal fixe par l'ACM ${txt(input.acm_courant_max)} A.`, + `Solaire ${txt(input.pv_power_w)} W ; maison ${txt(input.house_ac_power_w)} W (borne comprise) ; reseau ${txt(input.grid_power_w)} W ; contacteur EDF ${txt(input.edf_contacteur)} ; session reseau d'heures creuses engagee : ${txt(input.session_hc)}.`, + `Aujourd'hui : production ${txt(input.production_jour_kwh)} kWh, consommation ${txt(input.consommation_jour_kwh)} kWh, achete a EDF ${txt(input.import_jour_kwh)} kWh.`, + `Borne : vehicule branche ${txt(input.vehicule_branche)} (selecteur ${txt(input.vehicule)}), puissance ${txt(input.borne_puissance_w)} W.`, + `Controleur de la borne : etat ${txt(input.controleur_etat)}, source ${txt(input.controleur_source)}, strategie ${txt(input.strategie)}, mode de nuit ${txt(input.mode_nuit)}. Raison : ${txt(input.controleur_raison)}`, + `Bilan energetique : SOC requis ${txt(input.bilan_requis)} %, cible du matin ${txt(input.cible_matin)} %. ${txt(input.bilan_raison)}`, + `Chauffe-eau : ${txt(input.chauffe_eau)}, decision ${txt(input.chauffe_eau_decision)}.`, + input.prevision_nuit ? `Prevision de la nuit : ${txt(input.prevision_nuit)}` : '', + `Revue de la journee (${txt(input.revue_jour_date)}) : ${txt(input.revue_jour_texte) || 'pas encore publiee'}`, + pistes.length ? `Pistes du conseiller pour cette revue : ${pistes.map((p) => `[${p.code}] ${p.piste}`).join(' ; ')}` : '', +].filter(Boolean).join('\n'); + +const systemPrompt = [ + "Tu es le CONSEILLER du systeme energie d'une maison en Bretagne (16 kWc solaire,", + 'batterie ~23 kWh utiles soit 0,30 kWh par point de SOC, Victron ESS SANS injection,', + 'abonnement EDF 12 kVA, contacteur EDF ouvert la plupart du temps, heures creuses', + '01:24-07:24 et 12:24-14:24, borne pour trois vehicules : JEEP, VW, ZERO).', + 'Le proprietaire te pose une question. Reponds en francais, clairement, en 1 500', + 'caracteres au plus.', + '', + 'VOCABULAIRE (ne te trompe pas) :', + '- contacteur EDF « on » = EDF FERME = la maison est raccordee au reseau ; « off » = EDF', + ' ouvert = la maison vit sur batterie et solaire. Le proprietaire dit « EDF ferme » pour on.', + '- ACM = « Auto Charge Manager », le workflow qui fixe le courant de charge maximal de la', + ' batterie ; le controleur = celui de la borne ; le bilan = le calcul du SOC requis.', + '- mode de nuit sans_edf : la voiture ne fait jamais fermer EDF a elle seule ; complete :', + ' la voiture charge sur EDF en heures creuses.', + '', + 'Reponds d abord directement a la question, puis explique.', + '', + 'REGLES ABSOLUES :', + '- Tu n utilises QUE les donnees ci-dessous. N invente aucun chiffre : si une valeur', + ' manque, dis « je n ai pas cette donnee ».', + '- Tu ne commandes rien et tu ne pretends pas avoir agi. Si une action semble utile,', + ' dis laquelle et laisse le proprietaire decider.', + '- Distingue ce qui est MESURE (les donnees) de ce que tu SUPPOSES (dis « je suppose »).', +].join('\n'); + +return [{ json: { + enabled: onOff(input.advisor_enabled) && nouvelle, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: `DONNEES MESUREES :\n${donnees}\n\nQUESTION : ${question}` }, + ], + context: { question, questionAt, donnees }, +} }]; diff --git a/src/conseiller-dialogue/HA_SNAPSHOT_TEMPLATE.json.tpl b/src/conseiller-dialogue/HA_SNAPSHOT_TEMPLATE.json.tpl new file mode 100644 index 0000000..8de1545 --- /dev/null +++ b/src/conseiller-dialogue/HA_SNAPSHOT_TEMPLATE.json.tpl @@ -0,0 +1,36 @@ +{{ { + "advisor_enabled": states("input_boolean.evcs_advisor_enabled"), + "question": states("input_text.evcs_conseiller_question"), + "question_at": as_timestamp(states.input_text.evcs_conseiller_question.last_changed) | int(0) if states.input_text.evcs_conseiller_question is not none else 0, + "derniere_question_at": state_attr("sensor.evcs_conseiller_reponse", "question_at") | int(0), + + "maintenant": now().strftime("%d/%m %H:%M"), + "soc_percent": states("sensor.victron_battery_soc"), + "battery_current_a": states("sensor.victron_battery_current"), + "pv_power_w": states("sensor.victron_pv_power_system"), + "house_ac_power_w": states("sensor.victron_consumption_power_l1"), + "grid_power_w": states("sensor.victron_grid_power_l1"), + "edf_contacteur": states("switch.edf_general_ok_switch"), + "session_hc": states("input_boolean.victron_evcs_night_grid_session_committed"), + "production_jour_kwh": states("sensor.evcs_production_jour_kwh"), + "import_jour_kwh": states("sensor.evcs_import_jour_kwh"), + "consommation_jour_kwh": states("sensor.evcs_consommation_jour_kwh"), + "vehicule_branche": states("binary_sensor.victron_evcs_vehicle_connected"), + "vehicule": states("input_select.victron_evcs_vehicle"), + "borne_puissance_w": states("sensor.victron_evcs_ac_power"), + "controleur_etat": states("input_text.victron_evcs_surplus_state"), + "controleur_source": states("input_text.victron_evcs_source_mode"), + "controleur_raison": states("input_text.victron_evcs_surplus_reason")[:250], + "strategie": states("input_select.victron_evcs_strategy"), + "mode_nuit": states("input_select.victron_evcs_night_charge_mode"), + "cible_matin": states("input_number.victron_evcs_night_target_soc"), + "acm_courant_max": states("sensor.victron_max_charge_current_setting"), + "bilan_raison": states("input_text.evcs_balance_reason"), + "bilan_requis": states("input_number.evcs_balance_required_soc"), + "chauffe_eau": states("switch.tt_chauffe_eau_pleumeur_switch"), + "chauffe_eau_decision": states("input_text.victron_water_heater_advice_state"), + "prevision_nuit": state_attr("sensor.evcs_prevision_nuit", "texte_rapport") or "", + "revue_jour_date": states("sensor.evcs_revue_jour"), + "revue_jour_texte": state_attr("sensor.evcs_revue_jour", "texte") or "", + "revue_pistes": state_attr("sensor.evcs_advisor_revue", "pistes") or [] +} | tojson }} diff --git a/src/conseiller-dialogue/VALIDATE_PARSE.js b/src/conseiller-dialogue/VALIDATE_PARSE.js new file mode 100644 index 0000000..d441f03 --- /dev/null +++ b/src/conseiller-dialogue/VALIDATE_PARSE.js @@ -0,0 +1,31 @@ +// ===================================================== +// CONSEILLER — DIALOGUE : VALIDATE_PARSE (08/10/2026) +// Reponse en texte libre. ANTI-INVENTION : les nombres de la reponse absents des +// donnees transmises (et de la question) sont SIGNALES en tete de reponse, pas +// caches : le proprietaire juge. +// ===================================================== + +const resp = $input.first().json; +const context = $('BUILD_PROMPT').first().json.context; +const viaCloud = (() => { try { return $('LLM_CLOUD').isExecuted === true; } catch { return false; } })() + || typeof resp?.provider === 'string' || /^gen-/.test(String(resp?.id || '')); +const source = viaCloud ? 'openrouter' : 'local'; +const modelUsed = (resp?.model || 'aucun').toString().slice(0, 60); + +const content = resp?.choices?.[0]?.message?.content; +if (typeof content !== 'string' || !content.trim()) { + return [{ json: { status: 'llm_offline', reponse: null, avertissement: '', source, modelUsed, context } }]; +} +const reponse = content.replace(/[\s\S]*?<\/think>/gi, '').replace(/```[a-z]*/gi, '').trim().slice(0, 3000); + +const nombres = (s) => (String(s).match(/\d+(?:[.,]\d+)?/g) || []).map((x) => String(Number(x.replace(',', '.')))); +// Une mesure publiee 1.2000000298 est citee « 1,2 » : on admet ses arrondis. +const permis = new Set(nombres(`${context.donnees} ${context.question}`) + .flatMap((n) => [n, String(Math.round(Number(n))), String(Math.round(Number(n) * 10) / 10), String(Math.round(Number(n) * 100) / 100)])); +// Les petits entiers (1 a 3) servent aux enumerations ; ils ne sont pas des mesures. +const inventes = [...new Set(nombres(reponse).filter((n) => !permis.has(n) && !['0', '1', '2', '3'].includes(n)))]; +const avertissement = inventes.length + ? `Attention : chiffre(s) absent(s) des donnees transmises : ${inventes.join(', ')}. A verifier.` + : ''; + +return [{ json: { status: 'ok', reponse, avertissement, inventes, source, modelUsed, context } }]; diff --git a/tests/arbitrage/run.mjs b/tests/arbitrage/run.mjs new file mode 100644 index 0000000..37d6b2d --- /dev/null +++ b/tests/arbitrage/run.mjs @@ -0,0 +1,24 @@ +#!/usr/bin/env node +// Tests de l'arbitre encadre (tools/arbitrage.mjs, 08/10/2026). +import { detecterSituation, lireChoix } from '../../tools/arbitrage.mjs'; +let f = 0; +const ok = (l, c, d = '') => { if (c) console.log(`✅ ${l}`); else { f++; console.log(`❌ ${l} ${d}`); } }; +// Releve reel du 08/10 01:30 : VW bloquee, EDF ferme, session HC. +const nuit0810 = { fenetre: 'nuit', finFenetre: '07:24', contacteurOn: true, voitureBranchee: true, borneW: 0, + sourceMode: 'Bloqué', etat: 'night_no_grid_hc_lock', raison: 'Nuit sans autorisation HC réseau | Nuit EVCS: target=38.8%', vehicule: 'VW', modeNuit: 'sans_edf' }; +const s = detecterSituation(nuit0810); +ok('nuit-0810-question', s && s.cas === 'voiture_bloquee_edf_ferme' && /VW est branchee/.test(s.question) && /Nuit sans autorisation HC réseau/.test(s.question), JSON.stringify(s)); +ok('nuit-0810-choix-ferme', s && s.choix.length === 2 && s.choix[0].ordre.ordre === 'mode_nuit' && s.choix[0].ordre.valeur === 'complete' && s.choix[1].ordre === null); +const m = detecterSituation({ ...nuit0810, fenetre: 'midi', finFenetre: '14:24' }); +ok('midi-force-grid', m && m.choix[0].ordre.ordre === 'strategie' && m.choix[0].ordre.valeur === 'force_grid_now' && /14:24/.test(m.choix[0].libelle)); +ok('deja-complete-pas-de-question', detecterSituation({ ...nuit0810, modeNuit: 'complete' }) === null); +ok('hors-hc-rien', detecterSituation({ ...nuit0810, fenetre: null }) === null); +ok('edf-ouvert-rien', detecterSituation({ ...nuit0810, contacteurOn: false }) === null); +ok('voiture-pleine-rien', detecterSituation({ ...nuit0810, etat: 'night_recovery_done_idle' }) === null); +ok('voiture-en-charge-rien', detecterSituation({ ...nuit0810, borneW: 1400, sourceMode: 'Réseau forcé' }) === null); +ok('pas-de-voiture-rien', detecterSituation({ ...nuit0810, voitureBranchee: false }) === null); +ok('choix-1', lireChoix('1', s.choix)?.cle === '1'); +ok('choix-texte', lireChoix('je prends le 2', s.choix)?.cle === '2'); +ok('choix-inconnu', lireChoix('5', s.choix) === null && lireChoix('oui', s.choix) === null); +console.log(f ? `\n${f} ECHEC(S)` : '\nARBITRAGE OK'); +process.exit(f ? 1 : 0); diff --git a/tests/conseiller-dialogue/run.mjs b/tests/conseiller-dialogue/run.mjs new file mode 100644 index 0000000..b1453e3 --- /dev/null +++ b/tests/conseiller-dialogue/run.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +// Tests du dialogue avec le conseiller (08/10/2026) : vrai code des noeuds en vm. +import fs from 'fs'; +import vm from 'vm'; +import path from 'path'; +import { fileURLToPath } from 'url'; +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const src = (f) => fs.readFileSync(path.join(ROOT, 'src/conseiller-dialogue', f), 'utf8'); +function run(code, inputJson, nodes = {}) { + const ctx = { $input: { first: () => ({ json: inputJson }) }, $: (n) => ({ first: () => ({ json: nodes[n] }) }), + console, Date, Intl, Math, Number, Object, String, JSON, Array, Boolean, Set, Map }; + vm.createContext(ctx); + return vm.runInContext(`(function(){\n${code}\n})()`, ctx); +} +let f = 0; +const check = (l, c, d = '') => { if (c) console.log(`✅ ${l}`); else { f++; console.log(`❌ ${l} ${d}`); } }; +const snap = (o = {}) => ({ data: JSON.stringify({ advisor_enabled: 'on', question: 'Pourquoi la VW ne charge pas ?', + question_at: 2000, derniere_question_at: 1000, maintenant: '08/10 05:09', soc_percent: '30', battery_current_a: '0.2', + pv_power_w: '0', house_ac_power_w: '1751', grid_power_w: '1908', edf_contacteur: 'on', session_hc: 'on', + vehicule_branche: 'on', vehicule: 'VW', borne_puissance_w: '0', controleur_etat: 'night_no_grid_hc_lock', + controleur_raison: 'Nuit sans autorisation HC réseau', mode_nuit: 'sans_edf', cible_matin: '30.7', acm_courant_max: '2', + bilan_requis: '30.7', revue_jour_date: '2026-10-08', revue_jour_texte: '- EDF ferme en heures pleines de 18:06 a 01:24', + revue_pistes: [{ code: 'edf_heures_pleines', piste: 'recharge de midi' }], ...o }) }); + +const P = src('BUILD_PROMPT.js'), V = src('VALIDATE_PARSE.js'), B = src('BUILD_HA_CALLS.js'); +const p = run(P, snap())[0].json; +check('PROMPT: nouvelle question -> actif', p.enabled === true); +check('PROMPT: les mesures et la revue sont transmises', /SOC 30 %/.test(p.messages[1].content) && /night_no_grid_hc_lock/.test(p.messages[1].content) && /18:06 a 01:24/.test(p.messages[1].content) && /recharge de midi/.test(p.messages[1].content)); +check('PROMPT: question deja traitee -> inactif', run(P, snap({ derniere_question_at: 2000 }))[0].json.enabled === false); +check('PROMPT: question vide -> inactif', run(P, snap({ question: '' }))[0].json.enabled === false); +check('PROMPT: kill-switch -> inactif', run(P, snap({ advisor_enabled: 'off' }))[0].json.enabled === false); + +const rep = (t, extra = {}) => ({ model: 'qwen3-coder', choices: [{ message: { content: t } }], ...extra }); +const v1 = run(V, rep('La VW est bloquee (night_no_grid_hc_lock) : mode sans_edf, SOC 30 %.'), { BUILD_PROMPT: p })[0].json; +check('VALIDATE: chiffres des donnees -> aucun avertissement', v1.status === 'ok' && v1.avertissement === '', JSON.stringify(v1.inventes)); +const v2 = run(V, rep('Il faudrait 6,5 kWh pour la remplir.'), { BUILD_PROMPT: p })[0].json; +check('VALIDATE: ANTI-INVENTION — chiffre absent signale', /6.5/.test(v2.avertissement), v2.avertissement); +check('VALIDATE: OpenRouter detecte', run(V, rep('ok', { id: 'gen-1' }), { BUILD_PROMPT: p })[0].json.source === 'openrouter'); +const v3 = run(V, { error: 'x' }, { BUILD_PROMPT: p })[0].json; +check('VALIDATE: hors ligne -> llm_offline', v3.status === 'llm_offline'); + +const v4 = run(V, rep('Courant de la batterie : 0,2 A.'), { BUILD_PROMPT: run(P, snap({ battery_current_a: '0.20000000298023224' }))[0].json })[0].json; +check('VALIDATE: arrondi d une mesure accepte (0.2000000029 -> 0,2)', v4.avertissement === '', v4.avertissement); +check('PROMPT: vocabulaire du contacteur transmis', /« on » = EDF FERME/.test(p.messages[0].content)); +const calls = run(B, v2, { BUILD_PROMPT: p }).map((i) => i.json); +check('PERIMETRE: une seule ecriture, sensor.evcs_conseiller_reponse', calls.length === 1 && calls[0].state_entity === 'sensor.evcs_conseiller_reponse'); +check('PERIMETRE: question, horodatage, avertissement et source publies', calls[0].data.attributes.question_at === 2000 && /6.5/.test(calls[0].data.attributes.avertissement) && /Spark/.test(calls[0].data.attributes.source_texte)); +const hl = run(B, v3, { BUILD_PROMPT: p }).map((i) => i.json)[0]; +check('PERIMETRE: hors ligne -> reponse explicite, question marquee traitee', /pas repondu/.test(hl.data.attributes.reponse) && hl.data.attributes.question_at === 2000); +console.log(f ? `\n${f} ECHEC(S)` : '\nDIALOGUE OK'); +process.exit(f ? 1 : 0); diff --git a/tools/arbitrage.mjs b/tools/arbitrage.mjs new file mode 100644 index 0000000..96de3df --- /dev/null +++ b/tools/arbitrage.mjs @@ -0,0 +1,60 @@ +// ===================================================================== +// ARBITRE ENCADRE (08/10/2026, decision du proprietaire) +// +// Pour les cas qu'aucune regle n'a prevus : le CODE detecte en direct une +// situation incoherente et formule une question a CHOIX FERMES ; OpenClaw +// (concierge, SPARK) l'envoie par WhatsApp ; la reponse du proprietaire est +// appliquee par le service d'historisation, et SEULEMENT l'ordre attache au +// choix retenu, pris dans la liste blanche du guichet d'ordres (ORDRES). +// Le modele ne decide rien et n'invente aucun choix : il relaie. +// +// Chaque arbitrage est historise (table `arbitrage`) : un cas qui revient avec +// la meme reponse deviendra une regle du planificateur, apres rejeu +// (docs/PLAN_PLANIFICATEUR_HC.md, §7). +// +// `detecterSituation` est pure (tests : tests/arbitrage/run.mjs). +// ===================================================================== + +export const DUREE_MIN = 10; // la situation doit durer 10 min +const VOITURE_ABSENTE_OU_PLEINE = /not_connected|done|full|complete|charged/i; + +/** + * @param {object} e etat lu en direct + * fenetre : 'nuit' | 'midi' | null (fenetre HC en cours) + * finFenetre : 'HH:MM' | null + * contacteurOn, voitureBranchee : booleens + * borneW : puissance de la borne (W) + * sourceMode, etat, raison : controleur de la borne + * vehicule : selecteur (VW, JEEP, ZERO) + * modeNuit : 'sans_edf' | 'complete' + * @returns {null | { cas, question, choix: [{ cle, libelle, ordre: null | { ordre, valeur } }] }} + */ +export function detecterSituation(e) { + // CAS 1 — voiture branchee, non pleine, bloquee, alors qu'EDF est deja ferme + // en heures creuses. Cause de la nuit du 07-08/10 (VW bloquee 01:24-05:14). + if (e.fenetre && e.contacteurOn && e.voitureBranchee && Number(e.borneW) < 100 + && /bloqu/i.test(e.sourceMode || '') && !VOITURE_ABSENTE_OU_PLEINE.test(e.etat || '')) { + const v = e.vehicule || 'la voiture'; + const nuit = e.fenetre === 'nuit'; + if (nuit && e.modeNuit === 'complete') return null; // deja autorisee : ce n'est pas un choix + return { + cas: 'voiture_bloquee_edf_ferme', + question: `${v} est branchee et ne charge pas, alors qu'EDF est ferme en heures creuses (jusqu'a ${e.finFenetre || 'la fin de la fenetre'}). ` + + `Raison du controleur : « ${String(e.raison || 'inconnue').split('|')[0].trim()} ». Que fais-je ?`, + choix: [ + nuit + ? { cle: '1', libelle: `Charger ${v} sur EDF jusqu'a ${e.finFenetre || '07:24'} (mode de nuit « complete »)`, ordre: { ordre: 'mode_nuit', valeur: 'complete' } } + : { cle: '1', libelle: `Charger ${v} sur EDF jusqu'a ${e.finFenetre || '14:24'} (« force_grid_now », expire en fin d'heures creuses)`, ordre: { ordre: 'strategie', valeur: 'force_grid_now' } }, + { cle: '2', libelle: 'Laisser en attente (rien ne change)', ordre: null }, + ], + }; + } + return null; +} + +// Reponse du proprietaire : "1", "2", « 1 », « choix 2 »… -> cle ou null. +export function lireChoix(texte, choix) { + const m = /\b([1-9])\b/.exec(String(texte || '')); + if (!m) return null; + return choix.find((c) => c.cle === m[1]) || null; +} diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index 576220f..1e6aa4b 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -32,6 +32,7 @@ import pg from 'pg'; import { creerSchemaKm, ingererReleves, ingererNiveauSaisi, ingererJeep } from './kilometrage.mjs'; import { texteRapport, envoyer, donneesNuit, texteDepuis } from './rapport-nuit.mjs'; import { donneesRevue, constatsRevue } from './revue-jour.mjs'; +import { detecterSituation, lireChoix, DUREE_MIN as ARBITRAGE_DUREE_MIN } from './arbitrage.mjs'; import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, minuitLocal, COMPTEURS_IMPORT, COMPTEUR_EDF, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit, vrmVersCourbes, nuitMaisonPrevue } from './diagnostics-agent.mjs'; import { transitionsDansSerie } from './allers-retours.mjs'; import { energieSur24h, energieDepuis, COMPTEUR_CHAUFFE_EAU } from './chauffe-eau.mjs'; @@ -1950,6 +1951,8 @@ const ORDRES = { strategie: { entite: 'input_select.victron_evcs_strategy', genre: 'option' }, vehicule: { entite: 'input_select.victron_evcs_vehicle', genre: 'option' }, plafond_edf: { entite: 'input_number.evcs_plafond_courant_edf_on', genre: 'nombre', min: 6, max: 28 }, + // 08/10 (proprietaire) : l'arbitre peut proposer le mode de nuit. + mode_nuit: { entite: 'input_select.victron_evcs_night_charge_mode', genre: 'option' }, }; async function appliquerOrdreAirtria(sql, client, st) { @@ -2060,6 +2063,119 @@ async function appliquerOrdreAirtria(sql, client, st) { return 1; } +// --------------------------------------------------------------------- +// ARBITRE ENCADRE (08/10/2026, tools/arbitrage.mjs) +// --------------------------------------------------------------------- +// Situation incoherente tenue ARBITRAGE_DUREE_MIN -> question a choix fermes +// publiee dans sensor.evcs_arbitrage (OpenClaw l'envoie par WhatsApp). La +// reponse arrive dans input_text.evcs_arbitrage_reponse (JSON {id, choix, +// source, confirmed_at}) ; on n'applique QUE l'ordre attache au choix, pris dans +// ORDRES (liste blanche), d'une source humaine, si +// input_boolean.evcs_arbitrage_enabled est ON. Tout est historise (table +// `arbitrage`). Le guichet general (airtria_ordres_enabled) n'est pas concerne. +let arbitrageCandidat = null; // { cas, depuis } +let arbitrageSchema = false; +async function arbitrer(sql, client, st) { + if (!client) return 0; + if (!arbitrageSchema) { + await client.query(`CREATE TABLE IF NOT EXISTS arbitrage ( + id text PRIMARY KEY, cas text, question text, choix jsonb, depuis timestamptz, ouvert timestamptz DEFAULT now(), + ferme timestamptz, reponse_cle text, reponse_source text, reponse_at timestamptz, ordre jsonb, + application text, issue text)`); + arbitrageSchema = true; + } + const maintenant = Date.now(); + const hm = (id) => txt(st.get(id)?.state).slice(0, 5); + const minParis = (() => { + const p = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit', hour12: false }).formatToParts(new Date(maintenant)); + return Number(p.find((x) => x.type === 'hour').value) % 24 * 60 + Number(p.find((x) => x.type === 'minute').value); + })(); + const dans = (a, b) => { const m = (x) => { const [h, mi] = x.split(':').map(Number); return h * 60 + mi; }; + if (!a || !b) return false; const A = m(a), B = m(b); return A <= B ? minParis >= A && minParis < B : minParis >= A || minParis < B; }; + const nuit = [hm('input_datetime.victron_evcs_hc_night_start'), hm('input_datetime.victron_evcs_hc_night_stop')]; + const midi = [hm('input_datetime.victron_evcs_hc_midday_start'), hm('input_datetime.victron_evcs_hc_midday_stop')]; + const fenetre = dans(...nuit) ? 'nuit' : dans(...midi) ? 'midi' : null; + const sit = detecterSituation({ + fenetre, finFenetre: fenetre === 'nuit' ? nuit[1] : fenetre === 'midi' ? midi[1] : null, + contacteurOn: txt(st.get('switch.edf_general_ok_switch')?.state) === 'on', + voitureBranchee: txt(st.get('binary_sensor.victron_evcs_vehicle_connected')?.state) === 'on', + borneW: Number(st.get('sensor.victron_evcs_ac_power')?.state), + sourceMode: txt(st.get('input_text.victron_evcs_source_mode')?.state), + etat: txt(st.get('input_text.victron_evcs_surplus_state')?.state), + raison: txt(st.get('input_text.victron_evcs_surplus_reason')?.state), + vehicule: txt(st.get('input_select.victron_evcs_vehicle')?.state), + modeNuit: txt(st.get('input_select.victron_evcs_night_charge_mode')?.state), + }); + const { rows: [ouvert] } = await client.query(`SELECT * FROM arbitrage WHERE ferme IS NULL ORDER BY ouvert DESC LIMIT 1`); + const publier = (etat, a) => publierCapteur('sensor.evcs_arbitrage', etat, { friendly_name: 'Arbitrage demande au proprietaire', ...a }); + let n = 0; + + // Fermeture : la situation a disparu (resolue par le systeme ou par la reponse). + if (ouvert && (!sit || sit.cas !== ouvert.cas)) { + const issue = ouvert.reponse_cle ? 'resolu_apres_reponse' : 'resolu_sans_reponse'; + await client.query(`UPDATE arbitrage SET ferme = now(), issue = $2 WHERE id = $1`, [ouvert.id, issue]); + await journaliser(sql, 'arbitrage', `Arbitrage clos (${issue}) : ${ouvert.cas}`, { id: ouvert.id, issue }); + await publier('aucun', { dernier_id: ouvert.id, dernier_cas: ouvert.cas, derniere_issue: issue }); + arbitrageCandidat = null; + return 1; + } + if (!sit) { + arbitrageCandidat = null; + if (txt(st.get('sensor.evcs_arbitrage')?.state) === '') await publier('aucun', {}); + return 0; + } + if (!ouvert) { + if (!arbitrageCandidat || arbitrageCandidat.cas !== sit.cas) { arbitrageCandidat = { cas: sit.cas, depuis: maintenant }; return 0; } + if (maintenant - arbitrageCandidat.depuis < ARBITRAGE_DUREE_MIN * 60000) return 0; + const id = `${sit.cas}-${new Date(arbitrageCandidat.depuis).toISOString().slice(0, 16)}`; + await client.query(`INSERT INTO arbitrage (id, cas, question, choix, depuis) VALUES ($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING`, + [id, sit.cas, sit.question, JSON.stringify(sit.choix), new Date(arbitrageCandidat.depuis)]); + await journaliser(sql, 'arbitrage', `Arbitrage demande : ${sit.cas}`, { id, question: sit.question }); + await publier('question', { id, cas: sit.cas, question: sit.question, + choix: sit.choix.map((c) => ({ cle: c.cle, libelle: c.libelle })), + depuis: new Date(arbitrageCandidat.depuis).toISOString(), + consigne: 'Repondre par le numero du choix. Seul l ordre attache au choix est applique.' }); + return 1; + } + + // Reponse du proprietaire, par le concierge. + if (!ouvert.reponse_cle) { + let rep = null; + try { rep = JSON.parse(txt(st.get('input_text.evcs_arbitrage_reponse')?.state) || 'null'); } catch { rep = null; } + if (rep && rep.id === ouvert.id) { + const choix = lireChoix(rep.choix, ouvert.choix || []); + const source = txt(rep.source); + let application; + if (!choix) application = `refuse : choix « ${txt(rep.choix)} » inconnu`; + else if (!CANAUX_HUMAINS.has(source)) application = `refuse : source « ${source || 'absente'} » non humaine`; + else if (!choix.ordre) application = 'aucun ordre (laisser en attente)'; + else if (txt(st.get('input_boolean.evcs_arbitrage_enabled')?.state) !== 'on') application = 'non applique : input_boolean.evcs_arbitrage_enabled est OFF'; + else { + const spec = ORDRES[choix.ordre.ordre]; + const cible = spec && st.get(spec.entite); + const valeur = txt(choix.ordre.valeur); + if (!spec || !cible) application = `refuse : ordre ${choix.ordre.ordre} hors liste blanche`; + else if (spec.genre !== 'option' || !(cible.attributes?.options || []).includes(valeur)) application = `refuse : valeur ${valeur} invalide`; + else if (DRY) application = `essai a blanc : ${spec.entite} = ${valeur}`; + else { + const r = await fetch(`${HA_URL}/api/services/input_select/select_option`, { method: 'POST', + headers: { Authorization: `Bearer ${HA_TOKEN}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ entity_id: spec.entite, option: valeur }), signal: AbortSignal.timeout(8000) }); + application = r.ok ? `applique : ${spec.entite} = ${valeur} (etait ${txt(cible.state)})` : `echec HTTP ${r.status}`; + } + } + await client.query(`UPDATE arbitrage SET reponse_cle = $2, reponse_source = $3, reponse_at = now(), ordre = $4, application = $5 WHERE id = $1`, + [ouvert.id, choix ? choix.cle : txt(rep.choix), source, JSON.stringify(choix?.ordre || null), application]); + await journaliser(sql, 'arbitrage', `Arbitrage repondu (${choix ? choix.cle : '?'}) : ${application}`, { id: ouvert.id, reponse: rep, application }); + await publier('repondu', { id: ouvert.id, cas: ouvert.cas, question: ouvert.question, + choix: (ouvert.choix || []).map((c) => ({ cle: c.cle, libelle: c.libelle })), + reponse: choix ? choix.libelle : txt(rep.choix), application }); + n++; + } + } + return n; +} + // --------------------------------------------------------------------- // ANOMALIE "branchee mais ne charge pas" (19/09/2026) // --------------------------------------------------------------------- @@ -2516,6 +2632,9 @@ async function cycle(client) { rapport: DRY ? 0 : await envoyerRapportDuMatin(sql, client).catch((e) => { console.error('rapport de nuit :', e.message); return 0; }), + arbitrage: DRY ? 0 : await arbitrer(sql, client, st).catch((e) => { + console.error('arbitrage :', e.message); return 0; + }), revue: DRY ? 0 : await revueDeLaJournee(sql, client, st).catch((e) => { console.error('revue de la journee :', e.message); return 0; }), diff --git a/tools/lib-node-map.mjs b/tools/lib-node-map.mjs index 7738e51..ae1f34f 100644 --- a/tools/lib-node-map.mjs +++ b/tools/lib-node-map.mjs @@ -28,6 +28,18 @@ export const WORKFLOWS = [ { file: 'HA_SNAPSHOT_TEMPLATE.json.tpl', node: 'HA Snapshot Advisor', kind: 'template' }, ], }, + { + // Dialogue avec le conseiller dans Home Assistant (08/10/2026). + id: 'conseiller-dialogue', + workflowFile: 'workflows/evcs-conseiller-dialogue-v1.json', + srcDir: 'src/conseiller-dialogue', + nodes: [ + { file: 'BUILD_PROMPT.js', node: 'BUILD_PROMPT', kind: 'jsCode' }, + { file: 'VALIDATE_PARSE.js', node: 'VALIDATE_PARSE', kind: 'jsCode' }, + { file: 'BUILD_HA_CALLS.js', node: 'BUILD_HA_CALLS', kind: 'jsCode' }, + { file: 'HA_SNAPSHOT_TEMPLATE.json.tpl', node: 'HA Snapshot Dialogue', kind: 'template' }, + ], + }, { id: 'energy-balance', workflowFile: 'workflows/evcs-energy-balance-v1.json', diff --git a/tools/n8n-push.mjs b/tools/n8n-push.mjs index df2dcf5..090fc25 100644 --- a/tools/n8n-push.mjs +++ b/tools/n8n-push.mjs @@ -108,6 +108,7 @@ console.log('Syntaxe OK sur tous les nodes jsCode.'); // 3bis. Suites de tests (R3) : bloquantes par workflow, sauf --skip-golden const isEvcsWorkflow = path.basename(INPUT) === 'evcs-surplus-controller-v2.6.json'; const isAdvisorWorkflow = path.basename(INPUT) === 'evcs-advisor-shadow-v1.json'; +const isDialogueWorkflow = path.basename(INPUT) === 'evcs-conseiller-dialogue-v1.json'; const isHysteresisWorkflow = path.basename(INPUT) === 'victron-grid-hysteresis-v1.json'; const isAcmWorkflow = path.basename(INPUT) === 'victron-auto-charge-manager-v1.2.json'; const isWaterHeaterWorkflow = path.basename(INPUT) === 'water-heater-ecs-v1.json'; @@ -118,6 +119,7 @@ const skipGolden = process.argv.includes('--skip-golden'); const testSuite = isEvcsWorkflow && fs.existsSync('tests/golden/run.mjs') ? 'tests/golden/run.mjs' : isAdvisorWorkflow && fs.existsSync('tests/advisor/run.mjs') ? 'tests/advisor/run.mjs' : + isDialogueWorkflow && fs.existsSync('tests/conseiller-dialogue/run.mjs') ? 'tests/conseiller-dialogue/run.mjs' : isHysteresisWorkflow && fs.existsSync('tests/golden-hysteresis/run.mjs') ? 'tests/golden-hysteresis/run.mjs' : isAcmWorkflow && fs.existsSync('tests/golden-acm/run.mjs') ? 'tests/golden-acm/run.mjs' : isBalanceWorkflow && fs.existsSync('tests/energy-balance/run.mjs') ? 'tests/energy-balance/run.mjs' : diff --git a/workflows/evcs-conseiller-dialogue-v1.json b/workflows/evcs-conseiller-dialogue-v1.json new file mode 100644 index 0000000..b78198b --- /dev/null +++ b/workflows/evcs-conseiller-dialogue-v1.json @@ -0,0 +1,421 @@ +{ + "name": "EVCS Conseiller Dialogue V1", + "nodes": [ + { + "name": "Schedule Trigger", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [ + 0, + 0 + ], + "parameters": { + "rule": { + "interval": [ + { + "field": "minutes", + "minutesInterval": 1 + } + ] + } + } + }, + { + "name": "HA Snapshot Dialogue", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 200, + 0 + ], + "parameters": { + "method": "POST", + "url": "http://10.10.10.17:8123/api/template", + "authentication": "genericCredentialType", + "genericAuthType": "httpBearerAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Accept", + "value": "text/plain" + } + ] + }, + "sendBody": true, + "bodyParameters": { + "parameters": [ + { + "name": "template", + "value": "{{ {\n \"advisor_enabled\": states(\"input_boolean.evcs_advisor_enabled\"),\n \"question\": states(\"input_text.evcs_conseiller_question\"),\n \"question_at\": as_timestamp(states.input_text.evcs_conseiller_question.last_changed) | int(0) if states.input_text.evcs_conseiller_question is not none else 0,\n \"derniere_question_at\": state_attr(\"sensor.evcs_conseiller_reponse\", \"question_at\") | int(0),\n\n \"maintenant\": now().strftime(\"%d/%m %H:%M\"),\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n \"edf_contacteur\": states(\"switch.edf_general_ok_switch\"),\n \"session_hc\": states(\"input_boolean.victron_evcs_night_grid_session_committed\"),\n \"production_jour_kwh\": states(\"sensor.evcs_production_jour_kwh\"),\n \"import_jour_kwh\": states(\"sensor.evcs_import_jour_kwh\"),\n \"consommation_jour_kwh\": states(\"sensor.evcs_consommation_jour_kwh\"),\n \"vehicule_branche\": states(\"binary_sensor.victron_evcs_vehicle_connected\"),\n \"vehicule\": states(\"input_select.victron_evcs_vehicle\"),\n \"borne_puissance_w\": states(\"sensor.victron_evcs_ac_power\"),\n \"controleur_etat\": states(\"input_text.victron_evcs_surplus_state\"),\n \"controleur_source\": states(\"input_text.victron_evcs_source_mode\"),\n \"controleur_raison\": states(\"input_text.victron_evcs_surplus_reason\")[:250],\n \"strategie\": states(\"input_select.victron_evcs_strategy\"),\n \"mode_nuit\": states(\"input_select.victron_evcs_night_charge_mode\"),\n \"cible_matin\": states(\"input_number.victron_evcs_night_target_soc\"),\n \"acm_courant_max\": states(\"sensor.victron_max_charge_current_setting\"),\n \"bilan_raison\": states(\"input_text.evcs_balance_reason\"),\n \"bilan_requis\": states(\"input_number.evcs_balance_required_soc\"),\n \"chauffe_eau\": states(\"switch.tt_chauffe_eau_pleumeur_switch\"),\n \"chauffe_eau_decision\": states(\"input_text.victron_water_heater_advice_state\"),\n \"prevision_nuit\": state_attr(\"sensor.evcs_prevision_nuit\", \"texte_rapport\") or \"\",\n \"revue_jour_date\": states(\"sensor.evcs_revue_jour\"),\n \"revue_jour_texte\": state_attr(\"sensor.evcs_revue_jour\", \"texte\") or \"\",\n \"revue_pistes\": state_attr(\"sensor.evcs_advisor_revue\", \"pistes\") or []\n} | tojson }}\n" + } + ] + }, + "options": { + "timeout": 15000 + } + }, + "credentials": { + "httpBearerAuth": { + "id": "uv3sknYYigxBkaVN", + "name": "Bearer Auth account 2" + } + } + }, + { + "name": "BUILD_PROMPT", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 400, + 0 + ], + "parameters": { + "jsCode": "// =====================================================\n// CONSEILLER — DIALOGUE DANS HOME ASSISTANT (08/10/2026, proprietaire)\n// Le proprietaire ecrit une question dans input_text.evcs_conseiller_question ;\n// ce workflow (toutes les minutes) la voit, interroge le modele avec l'etat du\n// systeme, la revue du jour et ses pistes, et publie la reponse dans\n// sensor.evcs_conseiller_reponse, affichee sur la carte du conseiller.\n// Le conseiller EXPLIQUE, il ne commande rien (doctrine §79 : les chiffres\n// viennent des mesures transmises, jamais de lui).\n// =====================================================\n\nconst raw = $input.first().json;\nconst input = typeof raw.data === 'string' ? JSON.parse(raw.data) : (raw.data || raw);\nconst onOff = (v) => (v ?? 'off').toString().trim().toLowerCase() === 'on';\nconst txt = (v) => (v ?? '').toString().trim();\n\nconst question = txt(input.question);\nconst questionAt = Number(input.question_at) || 0;\nconst nouvelle = !!question && !['unknown', 'unavailable'].includes(question)\n && questionAt > (Number(input.derniere_question_at) || 0);\n\nlet pistes = input.revue_pistes;\nif (typeof pistes === 'string') { try { pistes = JSON.parse(pistes); } catch { pistes = []; } }\nif (!Array.isArray(pistes)) pistes = [];\n\nconst donnees = [\n `Maintenant : ${txt(input.maintenant)} (heure de Paris).`,\n `Batterie : SOC ${txt(input.soc_percent)} %, courant ${txt(input.battery_current_a)} A (positif = charge) ; courant de charge maximal fixe par l'ACM ${txt(input.acm_courant_max)} A.`,\n `Solaire ${txt(input.pv_power_w)} W ; maison ${txt(input.house_ac_power_w)} W (borne comprise) ; reseau ${txt(input.grid_power_w)} W ; contacteur EDF ${txt(input.edf_contacteur)} ; session reseau d'heures creuses engagee : ${txt(input.session_hc)}.`,\n `Aujourd'hui : production ${txt(input.production_jour_kwh)} kWh, consommation ${txt(input.consommation_jour_kwh)} kWh, achete a EDF ${txt(input.import_jour_kwh)} kWh.`,\n `Borne : vehicule branche ${txt(input.vehicule_branche)} (selecteur ${txt(input.vehicule)}), puissance ${txt(input.borne_puissance_w)} W.`,\n `Controleur de la borne : etat ${txt(input.controleur_etat)}, source ${txt(input.controleur_source)}, strategie ${txt(input.strategie)}, mode de nuit ${txt(input.mode_nuit)}. Raison : ${txt(input.controleur_raison)}`,\n `Bilan energetique : SOC requis ${txt(input.bilan_requis)} %, cible du matin ${txt(input.cible_matin)} %. ${txt(input.bilan_raison)}`,\n `Chauffe-eau : ${txt(input.chauffe_eau)}, decision ${txt(input.chauffe_eau_decision)}.`,\n input.prevision_nuit ? `Prevision de la nuit : ${txt(input.prevision_nuit)}` : '',\n `Revue de la journee (${txt(input.revue_jour_date)}) : ${txt(input.revue_jour_texte) || 'pas encore publiee'}`,\n pistes.length ? `Pistes du conseiller pour cette revue : ${pistes.map((p) => `[${p.code}] ${p.piste}`).join(' ; ')}` : '',\n].filter(Boolean).join('\\n');\n\nconst systemPrompt = [\n \"Tu es le CONSEILLER du systeme energie d'une maison en Bretagne (16 kWc solaire,\",\n 'batterie ~23 kWh utiles soit 0,30 kWh par point de SOC, Victron ESS SANS injection,',\n 'abonnement EDF 12 kVA, contacteur EDF ouvert la plupart du temps, heures creuses',\n '01:24-07:24 et 12:24-14:24, borne pour trois vehicules : JEEP, VW, ZERO).',\n 'Le proprietaire te pose une question. Reponds en francais, clairement, en 1 500',\n 'caracteres au plus.',\n '',\n 'VOCABULAIRE (ne te trompe pas) :',\n '- contacteur EDF « on » = EDF FERME = la maison est raccordee au reseau ; « off » = EDF',\n ' ouvert = la maison vit sur batterie et solaire. Le proprietaire dit « EDF ferme » pour on.',\n '- ACM = « Auto Charge Manager », le workflow qui fixe le courant de charge maximal de la',\n ' batterie ; le controleur = celui de la borne ; le bilan = le calcul du SOC requis.',\n '- mode de nuit sans_edf : la voiture ne fait jamais fermer EDF a elle seule ; complete :',\n ' la voiture charge sur EDF en heures creuses.',\n '',\n 'Reponds d abord directement a la question, puis explique.',\n '',\n 'REGLES ABSOLUES :',\n '- Tu n utilises QUE les donnees ci-dessous. N invente aucun chiffre : si une valeur',\n ' manque, dis « je n ai pas cette donnee ».',\n '- Tu ne commandes rien et tu ne pretends pas avoir agi. Si une action semble utile,',\n ' dis laquelle et laisse le proprietaire decider.',\n '- Distingue ce qui est MESURE (les donnees) de ce que tu SUPPOSES (dis « je suppose »).',\n].join('\\n');\n\nreturn [{ json: {\n enabled: onOff(input.advisor_enabled) && nouvelle,\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: `DONNEES MESUREES :\\n${donnees}\\n\\nQUESTION : ${question}` },\n ],\n context: { question, questionAt, donnees },\n} }];\n" + } + }, + { + "name": "IF_ENABLED", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + 600, + 0 + ], + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "combinator": "and", + "conditions": [ + { + "leftValue": "={{ $json.enabled }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ] + } + } + }, + { + "name": "LLM_LOCAL_MODELS", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 800, + -100 + ], + "parameters": { + "method": "GET", + "url": "http://10.10.10.8:8000/v1/models", + "options": { + "timeout": 5000 + } + }, + "onError": "continueRegularOutput", + "alwaysOutputData": true + }, + { + "name": "LLM_LOCAL", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1000, + -100 + ], + "parameters": { + "method": "POST", + "url": "http://10.10.10.8:8000/v1/chat/completions", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: $('LLM_LOCAL_MODELS').first().json.data[0].id, messages: $('BUILD_PROMPT').first().json.messages, temperature: 0.1, max_tokens: 6000 }) }}", + "options": { + "timeout": 120000 + } + }, + "onError": "continueRegularOutput", + "alwaysOutputData": true + }, + { + "name": "IF_LOCAL_OK", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + 1200, + -100 + ], + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "combinator": "and", + "conditions": [ + { + "leftValue": "={{ $json.choices !== undefined && $json.choices.length > 0 }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ] + } + } + }, + { + "name": "LLM_CLOUD", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1400, + 40 + ], + "parameters": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: 'anthropic/claude-haiku-4.5', messages: $('BUILD_PROMPT').first().json.messages, temperature: 0.1, max_tokens: 1000 }) }}", + "options": { + "timeout": 60000 + } + }, + "credentials": { + "httpHeaderAuth": { + "id": "eOM9xofDHT0KMoeY", + "name": "OpenRouter Advisor" + } + }, + "onError": "continueRegularOutput", + "alwaysOutputData": true + }, + { + "name": "VALIDATE_PARSE", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1600, + -30 + ], + "parameters": { + "jsCode": "// =====================================================\n// CONSEILLER — DIALOGUE : VALIDATE_PARSE (08/10/2026)\n// Reponse en texte libre. ANTI-INVENTION : les nombres de la reponse absents des\n// donnees transmises (et de la question) sont SIGNALES en tete de reponse, pas\n// caches : le proprietaire juge.\n// =====================================================\n\nconst resp = $input.first().json;\nconst context = $('BUILD_PROMPT').first().json.context;\nconst viaCloud = (() => { try { return $('LLM_CLOUD').isExecuted === true; } catch { return false; } })()\n || typeof resp?.provider === 'string' || /^gen-/.test(String(resp?.id || ''));\nconst source = viaCloud ? 'openrouter' : 'local';\nconst modelUsed = (resp?.model || 'aucun').toString().slice(0, 60);\n\nconst content = resp?.choices?.[0]?.message?.content;\nif (typeof content !== 'string' || !content.trim()) {\n return [{ json: { status: 'llm_offline', reponse: null, avertissement: '', source, modelUsed, context } }];\n}\nconst reponse = content.replace(/[\\s\\S]*?<\\/think>/gi, '').replace(/```[a-z]*/gi, '').trim().slice(0, 3000);\n\nconst nombres = (s) => (String(s).match(/\\d+(?:[.,]\\d+)?/g) || []).map((x) => String(Number(x.replace(',', '.'))));\n// Une mesure publiee 1.2000000298 est citee « 1,2 » : on admet ses arrondis.\nconst permis = new Set(nombres(`${context.donnees} ${context.question}`)\n .flatMap((n) => [n, String(Math.round(Number(n))), String(Math.round(Number(n) * 10) / 10), String(Math.round(Number(n) * 100) / 100)]));\n// Les petits entiers (1 a 3) servent aux enumerations ; ils ne sont pas des mesures.\nconst inventes = [...new Set(nombres(reponse).filter((n) => !permis.has(n) && !['0', '1', '2', '3'].includes(n)))];\nconst avertissement = inventes.length\n ? `Attention : chiffre(s) absent(s) des donnees transmises : ${inventes.join(', ')}. A verifier.`\n : '';\n\nreturn [{ json: { status: 'ok', reponse, avertissement, inventes, source, modelUsed, context } }];\n" + } + }, + { + "name": "BUILD_HA_CALLS", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1800, + -30 + ], + "parameters": { + "jsCode": "// =====================================================\n// CONSEILLER — DIALOGUE : BUILD_HA_CALLS (08/10/2026)\n// GARANTIE : n'ecrit QUE sensor.evcs_conseiller_reponse (via /api/states) et\n// input_text.evcs_advisor_status. AUCUN switch, AUCUNE strategie, AUCUNE\n// commande. Ne pas ajouter d'autres entites ici.\n// =====================================================\n\nconst { status, reponse, avertissement, inventes, source, modelUsed, context } = $input.first().json;\nconst stamp = new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });\n\nreturn [{ json: { state_entity: 'sensor.evcs_conseiller_reponse', data: {\n state: stamp,\n attributes: {\n friendly_name: 'Reponse du conseiller',\n question: context.question,\n question_at: context.questionAt,\n statut: status,\n reponse: reponse || (status === 'llm_offline' ? 'Le modele n a pas repondu (Spark et OpenRouter hors ligne). Reessaie en reecrivant la question.' : ''),\n avertissement: avertissement || '',\n chiffres_non_trouves: inventes || [],\n modele: modelUsed,\n source,\n source_texte: source === 'openrouter'\n ? 'Repli OpenRouter (cloud) : le modele local de la Spark n a pas repondu, la question et les donnees ont ete envoyees a OpenRouter.'\n : 'Modele local de la Spark : rien n est sorti de la maison.',\n },\n} } }];\n" + } + }, + { + "name": "Execute HA Dialogue", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 2000, + -30 + ], + "parameters": { + "method": "POST", + "url": "={{ $json.state_entity ? 'http://10.10.10.17:8123/api/states/' + $json.state_entity : 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}", + "authentication": "genericCredentialType", + "genericAuthType": "httpBearerAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "accept", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.data }}", + "options": { + "timeout": 15000 + } + }, + "credentials": { + "httpBearerAuth": { + "id": "uv3sknYYigxBkaVN", + "name": "Bearer Auth account 2" + } + }, + "onError": "continueRegularOutput" + } + ], + "connections": { + "Schedule Trigger": { + "main": [ + [ + { + "node": "HA Snapshot Dialogue", + "type": "main", + "index": 0 + } + ] + ] + }, + "HA Snapshot Dialogue": { + "main": [ + [ + { + "node": "BUILD_PROMPT", + "type": "main", + "index": 0 + } + ] + ] + }, + "BUILD_PROMPT": { + "main": [ + [ + { + "node": "IF_ENABLED", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF_ENABLED": { + "main": [ + [ + { + "node": "LLM_LOCAL_MODELS", + "type": "main", + "index": 0 + } + ], + [] + ] + }, + "LLM_LOCAL_MODELS": { + "main": [ + [ + { + "node": "LLM_LOCAL", + "type": "main", + "index": 0 + } + ] + ] + }, + "LLM_LOCAL": { + "main": [ + [ + { + "node": "IF_LOCAL_OK", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF_LOCAL_OK": { + "main": [ + [ + { + "node": "VALIDATE_PARSE", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "LLM_CLOUD", + "type": "main", + "index": 0 + } + ] + ] + }, + "LLM_CLOUD": { + "main": [ + [ + { + "node": "VALIDATE_PARSE", + "type": "main", + "index": 0 + } + ] + ] + }, + "VALIDATE_PARSE": { + "main": [ + [ + { + "node": "BUILD_HA_CALLS", + "type": "main", + "index": 0 + } + ] + ] + }, + "BUILD_HA_CALLS": { + "main": [ + [ + { + "node": "Execute HA Dialogue", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "callerPolicy": "workflowsFromSameOwner" + }, + "staticData": null, + "id": "zs7j7jvi6Q7dGDrJ" +}