Plan du planificateur HC ; dialogue avec le conseiller dans HA ; arbitre encadre (question a choix fermes, liste blanche)
This commit is contained in:
30
HANDOFF.md
30
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*
|
||||
|
||||
@@ -270,9 +270,57 @@ views:
|
||||
<small>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.</small>
|
||||
|
||||
- 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 }}<br>
|
||||
{% endfor %}
|
||||
{% if a.state == 'repondu' %}
|
||||
Réponse : **{{ a.attributes.reponse }}** → {{ a.attributes.application }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<small>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') }}.</small>
|
||||
- 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 }}
|
||||
|
||||
<small>{% 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.</small>
|
||||
{% endif %}
|
||||
<small>Aussi par **OpenClaw** (WhatsApp ou vocal) : « qu'est-ce qui a cloché hier ? »</small>
|
||||
|
||||
- type: entities
|
||||
entities:
|
||||
|
||||
156
docs/PLAN_PLANIFICATEUR_HC.md
Normal file
156
docs/PLAN_PLANIFICATEUR_HC.md
Normal file
@@ -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.
|
||||
14
packages/evcs_arbitrage.yaml
Executable file
14
packages/evcs_arbitrage.yaml
Executable file
@@ -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
|
||||
10
packages/evcs_conseiller_dialogue.yaml
Executable file
10
packages/evcs_conseiller_dialogue.yaml
Executable file
@@ -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
|
||||
27
src/conseiller-dialogue/BUILD_HA_CALLS.js
Normal file
27
src/conseiller-dialogue/BUILD_HA_CALLS.js
Normal file
@@ -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.',
|
||||
},
|
||||
} } }];
|
||||
72
src/conseiller-dialogue/BUILD_PROMPT.js
Normal file
72
src/conseiller-dialogue/BUILD_PROMPT.js
Normal file
@@ -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 },
|
||||
} }];
|
||||
36
src/conseiller-dialogue/HA_SNAPSHOT_TEMPLATE.json.tpl
Normal file
36
src/conseiller-dialogue/HA_SNAPSHOT_TEMPLATE.json.tpl
Normal file
@@ -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 }}
|
||||
31
src/conseiller-dialogue/VALIDATE_PARSE.js
Normal file
31
src/conseiller-dialogue/VALIDATE_PARSE.js
Normal file
@@ -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(/<think>[\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 } }];
|
||||
24
tests/arbitrage/run.mjs
Normal file
24
tests/arbitrage/run.mjs
Normal file
@@ -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);
|
||||
51
tests/conseiller-dialogue/run.mjs
Normal file
51
tests/conseiller-dialogue/run.mjs
Normal file
@@ -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);
|
||||
60
tools/arbitrage.mjs
Normal file
60
tools/arbitrage.mjs
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' :
|
||||
|
||||
421
workflows/evcs-conseiller-dialogue-v1.json
Normal file
421
workflows/evcs-conseiller-dialogue-v1.json
Normal file
@@ -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(/<think>[\\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"
|
||||
}
|
||||
Reference in New Issue
Block a user