Files
EVCS/HANDOFF.md
Tallagrand Thierry 6f3f98e8b6 Le plancher du soir vient de l energie a couvrir, plus d un niveau fixe
grep evcs_balance sur les deux workflows de decision renvoyait 0 depuis la mise
en service du bilan le 26/08 : il calculait l heure de croisement, l energie a
couvrir et le SOC requis, et personne ne l ecoutait. Le 03/09 a 19:44 les deux
nombres coexistaient dans le meme systeme — evening_soc_start_min a 85 %, un
niveau qui ne sait rien de la nuit, contre evcs_balance_required_soc a 99,7 %,
l energie a couvrir jusqu au croisement de 9 h. A SOC 94 le premier autorisait
la charge, le second disait que la batterie ne passerait pas la nuit seule.

Le plancher de demarrage du soir vaut desormais max(regle, bilan). Sens unique
delibere : le bilan peut durcir, jamais relacher. L assouplir — il dira souvent
moins de 85 % par forte prevision d ete — autoriserait des charges qui n ont pas
lieu aujourd hui, decision distincte a valider pour elle-meme. Le max rend au
passage la garde robuste sans borne ni ecretage : une valeur nulle, negative ou
aberrante ne peut pas relacher le plancher.

Portee demarrage : le plancher de maintien reste min(...), donc a 80 %, et
aucune charge en cours n est coupee. L abondance P6 continue de primer, etant
une preuve mesuree que la ressource depasse ce que le systeme absorbe.

Gardes : fraicheur inferieure a 30 min (six cycles du bilan), horodatage futur
rejete, statut ok, valeur finie. Sinon regle historique. Aucun nouveau toggle :
evcs_balance_enabled a OFF arrete les ecritures du bilan, balance_last_run se
fige, la fraicheur expire et le plancher retombe seul sur 85 % en une
demi-heure — un helper de plus aurait fait doublon.

Gabarit complet re-rendu contre HA : 195 champs, JSON valide, aucun null.
parseAdvisorRunToMs reutilise, l advisor et le bilan publiant le meme format.

Golden 66 -> 70. Quatre mutations — bilan ignore, Math.max remplace par une
affectation directe, garde de fraicheur supprimee, garde de statut supprimee —
font tomber chacune exactement un scenario, et un different a chaque fois.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-09-03 22:19:33 +02:00

2824 lines
162 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# HANDOFF.md — EVCS / Grid Hysteresis — État au 09/07/2026
## 1. Contexte Projet
- **Installation** : Victron ESS **sans injection réseau**. Batterie 17,5 kWh utile.
- **Workflows n8n actifs** :
- `HA Snapshot EVCS Surplus Controller V2.6` (id `ATswvGfG4KJUls0K`) — pilote la borne EVCS (30 s)
- `Victron - Battery / Grid Hysteresis Fix V1` (id `7iIlu7LLTW7TZA8o`) — pilote le switch EDF (1 min)
- **Accès** :
- HA : `http://10.10.10.17:8123` — jeton dans `jeton_EVCS.txt` (⚠️ en clair, à révoquer si dépôt partagé)
- n8n : `https://n8n.airtria.pro` — clé API dans `~/.config/opencode/opencode.json` (mcp.n8n-mcp)
- **MCP opencode** (option 1 déployée) : 5 serveurs distants dans `~/.config/opencode/opencode.json`
(`ha-get-state`, `ha-set-helper`, `grid-set-state`, `victron-set-charge-limits`, `pv-get-forecast`)
→ endpoints SSE `https://n8n.airtria.pro/mcp/<path>/sse`.
Les 4 outils paramétrés utilisent `$fromAI(...)` (corrigé le 09/07).
⚠️ **Endpoints MCP n8n sans authentification** — à sécuriser (Bearer sur les nodes MCP Server Trigger).
- **Fichiers de référence locaux** :
- `WINDOWS/WINDOW_SOURCEV2.js`, `BATTERY/BATTERY_CLOUD_LOCKS.js`, `HYSTERESIS/COMPUTE.js` — copies de lecture (le code qui fait foi est dans n8n)
- `modified/GRID_HYSTERESIS_backup_20260709-*.json` — rollback avant P1
- `modified/EVCS_V2.6_backup_20260709-*.json` — rollbacks avant P2 puis P3 (2 fichiers)
## 2. Architecture Data-Flow
```
EVCS V2.6 (30s) :
HA Snapshot (template) → INPUT_PARSE → WINDOWS_SOURCEV2
→ BATTERY_CLOUD_LOCKS → DECISION_ENGINEV2 → HA_SERVICE_CALLS_OUTPUT
→ Build Actions → Execute HA Service
Grid Hysteresis (1min) :
HA Snapshot → Compute Grid Hysteresis → Build Actions → Execute HA Service
```
Communication inter-workflows via helpers HA :
`input_text.victron_evcs_source_mode` (labels FR), `input_boolean.victron_evcs_night_grid_takeover_needed`,
`input_text.victron_evcs_surplus_state`, `input_select.victron_evcs_strategy`.
## 3. Patchs déployés le 09/07/2026 (session opencode)
### P1 — Grid Hysteresis (déployé, vérifié)
1. **Entité fantôme corrigée** : le snapshot lisait `input_select.victron_evcs_user_strategy`
(inexistante → toujours `unknown`). Corrigé en `input_select.victron_evcs_strategy`.
Conséquence avant fix : les overrides `force_grid_now`/`force_charge_now` et la coupure
« voiture pleine » ne fonctionnaient JAMAIS dans l'hystérésis.
2. **Switch EDF `unavailable`** : nouvel état `switch_unavailable` = aucun ordre, aucun timestamp,
réévaluation complète au retour du switch. Avant : spam `turn_on` chaque minute + rallumage
aveugle à la reconnexion (incident du 08-09/07 : disjoncteur physique coupé par l'utilisateur,
EDF rallumé à 00:41 au retour, batterie chargée sur réseau à SOC 48 %).
3. **`last_on_at`** : réécrit uniquement sur vraie transition (latch OFF→ON), plus à chaque retry.
Avant : `hold_on_min_runtime` pouvait durer éternellement.
4. **Bug latent corrigé** : le bloc `turn_off_force_grid_vehicle_full` (V2.5) était écrasé par la
chaîne principale (code mort). Flag `forcedCut` ajouté.
### P2 — EVCS V2.6 : continuité takeover nuit / anti-créneaux (déployé, vérifié en réel)
- Le snapshot relit `night_takeover_prev` (= helper takeover du cycle précédent).
- `nightTakeoverLatched` dans `WINDOWS_SOURCEV2` **et** `BATTERY_CLOUD_LOCKS` :
une fois le réseau HC engagé en session nuit, on y reste jusqu'à fin de session
(fenêtre/débranchement) ou SOC ≥ 90 (`NIGHT_TAKEOVER_RELEASE_SOC_PCT`).
- Supprime le cycle « château fort » : batterie→jeep→seuil 20 %→EDF ON→30 %→EDF OFF→batterie...
- Vérifié en production dans la nuit du 09/07 : takeover engagé à SOC 35 %, EDF resté ON,
Jeep en charge continue (`hold_min_run`).
### P3 — EVCS V2.6 : provocation PV batteries pleines (déployé, dry-run complet)
- Problème (ancien sujet de ce HANDOFF) : sans injection, batteries pleines ⇒ MPPT bridé à la
conso maison ⇒ aucun seuil PV observable ; en plus `forecastBufferKwh` (10,5 kWh véhicule
soustraits) toujours ≤ 0 l'après-midi, et `cloudDebt` en deadlock (batterie pleine = courant
nul = dette jamais remboursée). Sonde afternoon inopérante (`probe_enabled = off` + seuil PV 1000 W).
- Fix : `pvProvocationEligible = SOC ≥ 90 && batterie non déchargée` → `solar_potential`
(intégré à `isHighBatteryForPotential`, donc actif sur les 4 profils jour). Gardes aval intactes.
- **Attention réglage** : `soc_start_min` (HA) = 94 ⇒ plancher de démarrage hors fenêtre.
Le déclenchement pratique est donc SOC ≥ 94 %. Baisser ce helper à 90 pour aligner.
### P4 — Maintien SOC adapté au forecast pendant le takeover nuit (déployé 09/07 après-midi)
- **Découverte clé** : `Victron - Auto Charge Manager V1.2` (id `cTeeHy4ePiVeELLh`) est le
propriétaire des limites DVCC (`MaxChargeCurrent`/`MaxChargeVoltage` via MQTT,
feedback : `sensor.victron_max_charge_current_setting`). Son mode « nuit idle » laissait
30 A de charge batterie dès que EDF était ON → batterie rechargée à fond pendant un takeover.
- **ACM patché** : nouvelle branche prioritaire `evcs_takeover_hold` / `evcs_takeover_recover` :
si `night_grid_takeover_needed` ON + EDF ON → SOC < cible-1 : recharge 30 A ; sinon **maintien
2 A / 50.1 V** (maison + VE sur HC, batterie en pause). Cible lue depuis
`input_number.victron_evcs_night_target_soc` (écrite par le workflow EVCS : forecast fort→35 %,
moyen→55 %, faible→85 % — donc été ~35, hiver ~85 comme demandé).
- **EVCS patché** : avant minuit (20h→8h), la cible matin utilise
`max(today_remaining, tomorrow_kwh)` (sinon today_remaining≈0 à 23h → cible 85 % à tort en été).
Ajout `tomorrow_kwh` au snapshot + `parsed.tomorrowKwh`.
- Dry-runs : 8 scénarios ACM + 2 scénarios cible EVCS validés (voir session 09/07).
### Autres réalisations du 09/07 (après-midi)
- **Borne fantôme résolue** : firmware EVCS 2.05 → **2.08** (session repartie de zéro,
détection véhicule correcte). Les 19 kWh affichés étaient un cumul de session non réinitialisé.
- **Dashboard « EDF Urgence »** créé via API HA (sidebar) : scripts `script.edf_urgence_off`
(hystérésis OFF + latch OFF + stratégie stop + switch OFF — rien ne rallume),
`script.edf_force_on`, `script.edf_rendre_la_main` + cartes d'état et « pourquoi ».
- **Incident Synology** : NAS éteint dans la nuit (cause à chercher dans les logs DSM) →
n8n + HA morts → EDF resté ON toute la nuit, batterie chargée à 96 % sur réseau.
⚠️ SPOF structurel : tout le contrôle vit sur le Synology.
### P5 — Plafond courant EVCS configurable quand EDF est ON (déployé 09/07 soir)
- Le plafond 20 A venait de `cfg.evcsDedicatedMaxW` (4900−300 W / 230 V) codé en dur dans INPUT_PARSE.
- Nouveau helper **`input_number.evcs_plafond_courant_edf_on`** (6–32 A, défaut 20) : quand
**AC-IN est physiquement présent** (`acin1_available`, pas juste le switch), le plafond = helper ;
hors réseau, retour au plafond câblé ~20 A. Dry-run 5 scénarios OK (dont disjoncteur coupé).
- Subtilité : `victron_evcs_current_max_auto_a` (22) plafonne EN PLUS les modes auto ;
en force_grid/force_charge seul le nouveau helper compte.
- **Boutons dashboards** (EVCS Master = fichier YAML `/mnt/ARCH3/docker/HA/dashboards/evcs_master.yaml`
édité via NFS ; EDF Urgence = API) : carte « Plafonds courant EVCS » + boutons
**⚡ BOOST EDF 28A** (`script.evcs_boost_edf` : plafonds 28/28 + force_grid_now + consigne 28)
et **Retour normal** (`script.evcs_boost_off` : 20/22 + auto_solar_plus).
- Sémantique clarifiée : « Courant demandé » = « Consigne envoyée » = `number.victron_evcs_set_current_command`
(réécrit toutes les 30 s par le workflow) ; « Consigne lue » = readback borne. En force_charge_now,
PV ≥ 700 W ⇒ source « Solaire » ⇒ gardes nuages/batterie freinent : pour une grosse charge, force_grid_now.
### P6 — Override « abondance » du plancher SOC de démarrage (déployé 12/07 midi)
- **Symptôme** : 12/07 midi, PV 8,9 kW, batterie en charge +125 A, Jeep branchée → EVCS bloqué
`idle_wait_conditions`. Cause unique (dry-run) : plancher SOC fenêtre midi
(`input_number.victron_evcs_midday_soc_min` = 75) > SOC 66.
- **Fix (`BATTERY_CLOUD_LOCKS`)** : si `pvPowerAvg10m ≥ 4000 W` **et** `batteryCurrentA ≥ +20 A`
(abondance physique : la batterie monte même avec l'EVCS), plancher de démarrage abaissé à
**40 %** (`effectiveSocStartFloor`). Jours nuageux : planchers d'origine intacts.
Debug exporté : `solarAbundance`, `effectiveSocStartFloor` (safeguards).
- Vérifié en réel : déploiement 12:43 → `start_min_current` immédiat → charge 12 A.
- **Observation borne** : après des heures branchée en statut 6, la Jeep a mis ~2,5 min à
s'engager ; le workflow renvoie `turn_on` toutes les 30 s (retry intégré suffisant).
- Planchers de référence : matin 35, midi 75, soir ?, hors fenêtre 94 (`soc_start_min`).
### Maintenance du 09/07 soir (analyse des messages d'erreurs)
- **W-105 borne** (surcharge) : survenu en charge 20 A sur onduleur seul (disjoncteur EDF coupé).
Déjà couvert : gardes vebus_overload + P5 (plafond retombe à 20 A si AC-IN absent).
- **`saveDataErrorExecution: all`** activé sur les 8 workflows critiques (diagnostic des
prochaines erreurs avec données des nodes ; Water Heater V2.3 avait erré 4× le 09/07 20:20-20:35,
cause inconnue faute de données, rétabli seul).
- **Garage Thermal** : la prise `switch.clim_hygro_socket_1` est débranchée volontairement par
moments → l'appel au switch est maintenant conditionné à sa disponibilité (`execute: switchAvailable`).
Plus de warnings « missing entity » toutes les 10 min. Backup `modified/GARAGE_backup_*`.
- **Templates MQTT durcis** (33 occurrences, `configuration.yaml` + `packages/victron_vebus_mqtt.yaml`) :
garde `value_json is defined` → plus d'erreurs `value_json is undefined` aux reconnexions MQTT.
Backups `SAVE_CONFIG/*.bak.20260709-234800`, `check_config` valid, `mqtt.reload` fait.
⚠️ Après un `mqtt.reload`, les sensors restent `unknown` jusqu'à republication Venus :
publier `R/c0619ab34ac5/keepalive` (payload vide) force la republication complète.
## 4. Root Cause de l'incident « EDF ON à 48 % sans charge Jeep » (nuit 08→09/07)
Chaîne complète reconstituée via l'historique HA :
1. Borne fantôme au matin du 08/07 (« véhicule en charge » sans véhicule) + `force_grid_now`
de la veille ⇒ EDF maintenu ON (la coupure voiture-pleine était du code mort, cf. P1.1/P1.4).
2. L'utilisateur coupe le **disjoncteur physique** ⇒ switch `unavailable` pendant ~5 h.
3. Le workflow reste coincé en `hold_on_min_runtime` (P1.3) en spammant `turn_on` (P1.2).
4. 00:40:54 : le switch revient ⇒ 00:41:03 EDF ON sans cause actuelle ⇒ Multiplus charge la
batterie sur réseau (2980 W) ⇒ coupé à 01:06 (min_on 25 min, SOC ≥ 30).
Tous les maillons sont corrigés par P1.
## 5. Sémantique utile (pour ne pas re-chercher)
- **Latch** (`input_boolean.victron_grid_hysteresis_latch`) : « c'est l'automate qui a allumé EDF
et il défend cet état ». Reprise en main manuelle : stratégie `stop` + hystérésis OFF + latch OFF.
- **Fenêtres EVCS (helpers HA, corrigées/vérifiées le 09/07)** : `night_hc` **01:24→07:24** (HC réelles),
`morning_solar` 07:30→11:00, `midday_hc_or_solar` **12:24→14:24** (HC réelles),
`evening_high_soc` 17:00→20:30, sinon `outside_window`.
L'Auto Charge Manager utilisait des fenêtres codées en dur légèrement fausses
(01:30-07:15 / 12:25-14:25) — alignées sur 01:24-07:24 / 12:24-14:24 le 09/07.
- **Seuils hystérésis réseau (HA, valeurs réelles)** : ON ≤ 20 %, OFF ≥ 30 %, min_on 25 min.
- **Cibles nuit** : hard floor 22 % ; cible matin selon forecast : ≥18 kWh→35 %, ≥10→55 %, sinon 85 %.
`nightSessionActive = fenêtre nuit && véhicule branché` (⚠️ pas besoin de charge active).
- **Statut borne 6** = branchée en attente de démarrage (« Inconnu (6) » sur le dashboard).
- **Labels FR** dans `source_mode` : `Réseau forcé`, `Réseau HC autorisé`, `Assistance batterie nuit`,
`Bloqué`, `Solaire`, `Potentiel solaire` — l'hystérésis matche ces labels.
## 6. Méthode de travail validée (à réutiliser)
1. Backup : `GET /api/v1/workflows/<id>` → `modified/*_backup_<stamp>.json`.
2. Patch des `jsCode` par remplacement exact (script node), `node --check` sur chaque node.
3. **Dry-run local de la chaîne complète** : snapshot réel via `POST /api/template` HA,
exécution séquentielle des 5 nodes avec shim `$input`, horloge simulable en surchargeant
`global.Date` (voir session du 09/07).
4. `PUT /api/v1/workflows/<id>` (⚠️ filtrer `settings` : seuls
`executionOrder/timezone/save*/executionTimeout/errorWorkflow` sont acceptés).
5. Vérification live : helpers HA 40-70 s après.
## 7. Backlog / Point du 10/07 au réveil
1. **Vérifier la nuit de test P2+P4** (fenêtre HC 01:24→07:24, forecast fort → cible 35 %) :
- takeover engagé quand SOC ≤ 35 → EDF ON **continu** (pas de créneaux) ;
- `input_text.victron_charge_mode` = `evcs_takeover_recover` puis **`evcs_takeover_hold`** ;
- `sensor.victron_max_charge_current_setting` = **2** pendant le hold (batterie tenue ~35 %) ;
- 07:24 : fin de fenêtre → EDF coupé (`turn_off_recovered`), ACM repasse en profil jour.
Historique utile : `switch.edf_general_ok_switch`, `sensor.victron_battery_soc`,
`input_text.victron_charge_mode` entre 01:00 et 08:00.
2. **Cause de l'extinction du Synology** (DSM → Centre des journaux) + réflexion SPOF :
le fail-safe physique actuel est « EDF ON + charge batterie » quand le NAS meurt.
3. Baisser `input_number.victron_evcs_soc_start_min` 94 → 90 (aligner avec P3) si souhaité.
4. **Sécuriser** : auth Bearer sur les 5 MCP Server Triggers n8n + révoquer/déplacer le jeton HA.
5. **À investiguer** : « conditions de démarrage nuit batterie non réunies » quand la batterie
suffit pour la nuit (conditions de départ `battery_assist_night`).
6. Optionnel : brancher un keepalive/republish Venus automatique après `mqtt.reload`
(cf. note maintenance §3).
## 8. Historique des sessions
- **12/06/2026** : analyse blocage outside_window, déploiement afternoon_probe (V2.6),
fix clignotement EDF (`edf_general_ok_on` conditionné au sourceMode).
- **09/07/2026 (nuit)** : incident EDF nocturne analysé (root cause complète), MCP option 1 en place,
P1 (hystérésis robuste + entité stratégie), P2 (anti-créneaux nuit), P3 (provocation PV).
- **09/07/2026 (journée/soir)** : incident Synology (NAS éteint → EDF ON toute la nuit, batterie 96 %),
firmware borne 2.08 (fantôme résolu), dashboard EDF Urgence + boost 28 A, P4 (maintien SOC
au forecast pendant takeover, via Auto Charge Manager), P5 (plafond EDF ON configurable),
fenêtres HC corrigées (01:24-07:24 / 12:24-14:24), maintenance erreurs (save error executions,
prise garage conditionnelle, 33 templates MQTT durcis).
- **12/07/2026** : P6 (override abondance du plancher SOC midi — PV fort + batterie en charge
⇒ démarrage dès SOC 40) ; validation en réel à 12:43, charge Jeep engagée.
- **17/07/2026 (après-midi)** : **Session de débogage majeure** — résolution d'un enchaînement de bugs
bloquants empêchant la charge solaire de la Jeep :
**Bug 1 — Typo critique dans DECISION_ENGINEV2** :
- Ligne ~333 : `noDemandnightSessionNeedsClearScoreOff` (variable inexistante) au lieu de `noDemandScoreOff`
- Conséquence : `evcsVehicleNoDemandSemantic` restait `false` même quand la voiture était pleine
- Le filtre "véhicule plein/pause" ne fonctionnait jamais → blocage du système
**Bug 2 — Artefacts de corruption dans WINDOWS_SOURCEV2** :
- 3 occurrences de `tool_f6d3316d2001k4aAfEjbKGNUX2` (résidu de copier-coller ou debug)
- Provoquaient des erreurs `is not defined` à l'exécution
- Workflow en ERROR continu depuis 14:49
**Bug 3 — Session nuit jamais clearée** :
- `input_boolean.victron_evcs_night_session_active` restait `on` depuis 23:24 la veille
- Même après la fenêtre HC (01:24-07:24), le profil restait `night_hc` → `battery_assist_night`
- En plein jour avec grand soleil, le système pensait être en "assistance batterie nuit"
- Clearé manuellement à 14:45
**Bug 4 — EDF rebranché automatiquement en journée** :
- Condition `edf_general_ok_on` déclenchée dès `sourceMode` = `grid_forced` ou `grid_hc_allowed`
- Même en plein jour avec du soleil, EDF se rebranchait si le workflow passait en ces modes
- Correction : ajout de `context.profile === 'night_hc'` pour ne rebrancher que la nuit
**Configuration ajustée** :
- `battery_discharge_soft_a` : 0A (au lieu de -8A) — permet le démarrage sans décharge batterie
- `battery_neutral_band_a` : 0A (au lieu de -2A) — tolérance nulle
- `soc_start_min` : 80% (au lieu de 94%) — seuil de démarrage plus réaliste
- `night_target_soc` : 70% (au lieu de 85%) — laisse plus de marge pour le PV
**Mode sonde activé** :
- `probe_enabled` : ON — permet le démarrage à 6A pour "provoquer" la production PV
- Particulièrement utile en autoconsommation sans injection où le PV ne produit que si chargé
**Résultat** :
- Workflow réparé et fonctionnel
- Jeep en charge à 17A avec le soleil (16:58)
- EDF OFF (ne se rebranche que la nuit)
- Système 100% auto en `auto_solar_plus`
- DeliveryRatio à 99.9% — charge stable et efficace
**À noter** : Le courant de sonde (`probeCurrentA`) est codé en dur dans le workflow n8n (nœud SETUP/PARAMS),
pas dans un helper HA. Pour le changer de 6A à 10A, modifier directement dans l'interface n8n.
Mais le workflow augmente automatiquement le courant (17A atteints), donc ce n'est pas critique.
## ⚙️ CONFIG LOCALE — Qwen3.6-35B sur DGX Spark (≠ gros modèle cloud)
> ⚠️ Cette config est CALIBRÉE pour le modèle LOCAL Qwen3.6-35B-A3B (3B actifs,
> contexte limité). NE PAS appliquer telle quelle à un gros modèle cloud
> (FABLE/Claude), qui n'a besoin ni du bornage strict ni du découpage par nœud.
### Infra (validée le 16/07/2026)
- DGX Spark = 10.10.10.8, endpoint vLLM unique sur port 8000.
- UN SEUL modèle à la fois (contrainte NVRAM 1 Spark) → swap via scripts :
- `use-code` : docker compose down (VL) + sparkrun run qwen3.6-35b sur :8000
- `use-vl` : sparkrun stop --all + docker compose up (Qwen3-VL, vision)
- Recette : @official/qwen3.6-35b-a3b-fp8-mtp-vllm --tp 1 --port 8000
--gpu-mem 0.8 --served-model-name qwen3.6-35b
(MTP + flashinfer + tool-parser qwen3_coder déjà inclus dans la recette).
- Qwen3-VL (docker /opt/vllm) réservé à l'analyse de captures d'écran.
### opencode.json (poste Ubuntu, PAS la Spark)
- baseURL: http://10.10.10.8:8000/v1, apiKey "EMPTY".
- Modèle "qwen3.6-35b" : options { temperature: 0.15, reasoningEffort: "none" },
cost tout à 0 (sinon compteur fantôme FABLE), limit context 65536.
- permission.bash : "curl *": "deny", "wget *": "deny" (bloque hallucinations HTTP).
- Les 6 MCP (n8n-mcp, ha-get-state, ha-set-helper, grid-set-state,
victron-set-charge-limits, pv-get-forecast) : NE JAMAIS toucher ce bloc.
### RÈGLES DE TRAVAIL spécifiques au local (dans AGENTS.md)
- Contexte trop petit pour un workflow entier (~357K = ~102K tokens > 65K).
=> JAMAIS charger le workflow complet. UN nœud / UNE tâche à la fois.
- n8n-mcp_get_workflow renvoie le workflow entier => opencode l'écrit dans
~/.local/share/opencode/tool-output/tool_* (tronqué). Le fichier commence par
"Retrieved workflow: <nom>" AVANT le JSON => json.load ÉCHOUE.
Extraction validée : grep -n '"name":"NOEUD"' <fichier> puis sed -n 'X,Yp'.
(ripgrep/rg N'EST PAS installé sur la Spark, utiliser grep.)
- reasoningEffort none coupe le thinking (le tag /no_think en tête de chat NE
marche pas : opencode l'intercepte comme slash-command).
- Skill n8n installée dans .opencode/skills/ — surveiller les compactions.
### PÉRIMÈTRE (le plus important)
- LOCAL Qwen3.6 = tâches CIBLÉES : lire 1 nœud, repérer une faille logique,
patch simple, dry-run. Rapide, gratuit, fiable SI bien borné.
- CLOUD (FABLE/gros modèle) = refontes multi-nœuds, analyse d'un workflow
ENTIER, orchestration MCP longue. Ne pas forcer le local dessus (il boucle).
## 🧩 SKILLS n8n — validées le 16/07/2026 (Qwen3.6 local, PAS gros modèle)
> ⚠️ Config calibrée pour Qwen3.6-35B-A3B local (contexte 65K). Les skills
> aident au tool-calling n8n. Sur gros modèle cloud, elles sont facultatives.
### Installation
- Dépôt : github.com/viliamvolosv/n8n-opencode-skill
- Emplacement OBLIGATOIRE : .opencode/skills/<nom-skill>/SKILL.md
(dossier "skills" PLURIEL ; le repo clone dans un sous-dossier "skill"
SINGULIER → il FAUT remonter : mv n8n-opencode-skill/skill/* .)
- opencode doit être lancé DEPUIS le projet :
cd /mnt/projects/EVCS/EVCS && opencode
(sinon .opencode/skills/ n'est pas découvert → skills invisibles).
- Chargement PROGRESSIF : au repos les 8 skills ne coûtent que ~12K tokens
(18%). Le SKILL.md complet (~16K) ne se charge QUE si la skill est invoquée.
### Skills ACTIVES (5, validées par test réel sur DECISION_ENGINEV2)
- n8n-mcp-tools-expert → récup config nœud via MCP (fiabilise les 6 MCP)
- n8n-code-javascript → analyse/patch du jsCode (vars in/out, utilitaires)
- n8n-expression-syntax → vérif syntaxe {{ }}
- n8n-validation-expert → refs de nœuds, cohérence types, conditions
- n8n-node-configuration → conformité params/mode/position
### Skills DÉSACTIVÉES (déplacées dans .opencode/skills-off/)
- customize-opencode (méta, hors sujet EVCS)
- n8n-code-python (on fait du JS)
- n8n-workflow-patterns (exige le workflow ENTIER → risque contexte)
→ réactivables en les remettant dans skills/ , ou à réserver au cloud.
### Résultat du test de validation (5 skills, DECISION_ENGINEV2)
- Les 5 skills invoquées et nommées correctement, analyse d'ingénieur juste
(16 vars, 7 derived, 22 conditions, pipeline complet reconstitué).
- UNE SEULE compaction (pic 88% au parsing du workflow 357K, reprise propre).
- 2 AttributeError Python transitoires (structure connexions n8n) → auto-corrigés.
- Coût : $0.00.
### RÈGLE contexte associée (dans AGENTS.md)
- Analyse pipeline multi-nœuds : grep -n "name"/"jsCode" + sed sur la plage,
JAMAIS le workflow JSON entier en mémoire Python. Traite 1 nœud, résume, libère.
- Le fichier tool-output commence par "Retrieved workflow: <nom>" AVANT le JSON
→ parade validée : json.loads(text[text.index('{'):]).
### PÉRIMÈTRE (rappel)
- Local Qwen3.6 + skills = analyse ciblée 1 nœud, validation, patch simple. ✅
- Cloud (FABLE/gros modèle) = refonte multi-nœuds massive, workflow entier.
## 9. Session du 18/07/2026 (matin)
### Corrections documentation (MANUEL_COMPLET.md)
- **outside_window** : décrit comme "Mode probe (sonde) pour relancer le PV si besoin" (pas bloquant)
- **Cloud Debt & Probe** : nouvelle section 11.4 complète avec explication dette nuageuse, mode probe 10A, bypass en auto_solar_plus / force_charge_now
- **Stratégies** : clarifié `force_grid_now` = préservation batterie (SOC<22% → recover 35%/50%/75%) vs `force_charge_now` = secours manuel
- **Heure de départ** : nouvelle section 11.5 "Logique Heure de départ (Night EVCS)"
### Modification workflow
- **probeCurrentA** : augmenté de 6A à 10A dans le nœud INPUT_PARSE (fallback)
- Rationale : 6A trop faible pour "provoquer" la production PV, 10A plus efficace
### Versionnage Git
- Export du workflow dans `workflows/evcs-surplus-controller-v2.6.json`
- Commit & push effectués
## 10. Session du 04/08/2026 (matin) — Fix grid HC 1h30 + libération takeover
### Problèmes诊断qués
1. **Charge HC ne démarre pas à 1h30** : Le grid ne se mettait pas en route à l'ouverture de la fenêtre HC
2. **Grid ne rend pas la main après charge** : EDF restait ON après la fin de charge (SOC 71% < 90%)
### Root Causes identifiées
1. **`gridAllowedBySoc`** (BATTERY_CLOUD_LOCKS) : autorise le grid seulement si `soc <= 35%` ou `forceGridRequested`. En profile `night_hc` avec SOC > 35%, le grid était bloqué.
2. **`nightTakeoverLatched`** (WINDOWS_SOURCEV2) : reste latché tant que `soc < 90%` (hardcodé). Pas de condition "véhicule chargé" → le takeover ne se libérait jamais.
### Corrections appliquées
#### Fix 1: `gridAllowedBySoc` — Autoriser le grid en profile night_hc
**Node:** `BATTERY_CLOUD_LOCKS`
```javascript
// AVANT:
const gridAllowedBySoc = parsed.soc <= 35 || strategy.forceGridRequested;
// APRÈS:
const gridAllowedBySoc =
strategy.forceGridRequested ||
context.profile === 'night_hc' ||
parsed.soc <= 35;
```
#### Fix 2: `nightTakeoverLatched` — Libérer si charge terminée
**Node:** `WINDOWS_SOURCEV2`
```javascript
// AVANT:
const nightTakeoverLatched =
nightSessionActive &&
nightTakeoverPrev &&
parsed.soc < NIGHT_TAKEOVER_RELEASE_SOC_PCT;
// APRÈS:
const vehicleChargeCompleted =
!parsed.evcsChargingSwitchOn &&
parsed.soc >= 50;
const nightTakeoverLatched =
nightSessionActive &&
nightTakeoverPrev &&
(
parsed.soc < NIGHT_TAKEOVER_RELEASE_SOC_PCT ||
vehicleChargeCompleted
);
```
### Méthode de déploiement
- **MCP n8n-mcp_update_workflow** : Erreur 1003 (request/body must NOT have additional properties) — bug du MCP
- **Bypass** : Script `tools/n8n-rest-update-test.mjs` créé, utilise REST API n8n directement avec token
- PUT réussi avec body minimal : `{name, nodes, connections, settings, staticData}`
- Settings doit être minimal : `{"executionOrder":"v1","callerPolicy":"workflowsFromSameOwner"}`
### Backup
- `backup-workflow-ATswvGfG4KJUls0K-1785849621547.json`
### Vérification
- Les 2 patches sont dans le workflow en production
- À surveiller : prochaine nuit HC (01:24-07:24) pour valider le comportement
---
*Document mis à jour le : 04/08/2026 (~10:00) — Fix gridAllowedBySoc + nightTakeoverLatched via REST API*
*État live à la clôture : Workflow patché, à tester en conditions réelles*
## 11. Session du 07/08/2026 — P7 : Charge HC nuit automatique (VW hybride) + outillage push
### Problème (nuits du 05-07/08, observé sur l'historique HA)
- VW hybride branchée le soir + stratégie `auto_solar_plus` → **aucune charge en HC**.
01:24 : `idle_wait_conditions` / « Conditions de démarrage nuit batterie non réunies »
(SOC 53 %, target=trigger=35 %). L'utilisateur devait intervenir manuellement
(force_charge_now, ou BOOST EDF 28A à 05:50 le 07/08).
### Root causes
1. **`battery_assist_night` ne démarre jamais** : `batteryAssistNightStartOk` exige
`batteryCurrentA >= profileBatteryDischargeFloorA` (= `battery_discharge_soft_a` = 0 A).
La nuit, la maison tire sur la batterie (~ −14 A) → condition impossible.
→ le système attendait que le SOC dérive jusqu'au trigger 35 % (des heures) avant le grid.
(= backlog §7.5 « conditions de démarrage nuit batterie non réunies », résolu.)
2. **`allow_grid_hc_night` (toggle « Réseau HC nuit ») était décoratif** : parsé dans
INPUT_PARSE (`params.allowGridHcNight`) mais utilisé nulle part
(contrairement à `allowGridHcMidday`).
### Fix P7 (déployé 07/08 ~15:45, dry-run 7 scénarios validés)
Dans **WINDOWS_SOURCEV2** et **BATTERY_CLOUD_LOCKS** (logique nuit dupliquée) :
```javascript
const nightGridHcPreferred =
nightSessionActive && // fenêtre HC nuit + véhicule branché
params.allowGridHcNight; // toggle "Réseau HC nuit" ON
```
- `batteryAssistAllowed` : ajout `&& !nightGridHcPreferred`
- `nightGridTakeoverNeeded` : ajout `nightGridHcPreferred ||` dans le OR
**Effet** : dès l'ouverture de la fenêtre HC (01:24) avec véhicule branché,
`nightPreferredSourceMode` = `grid_hc_allowed` (ou `grid_forced` si SOC < cible matin)
→ `edf_general_ok_on` allume EDF → takeover helper ON → ACM `evcs_takeover_hold`
(batterie tenue 2 A) → `gridStartOk` → `start_grid_hc_min_current` puis step-up.
- Toggle « Réseau HC nuit » OFF → comportement historique (battery assist) intact.
- Jour / pas de véhicule / force_charge_now : aucun changement (dry-run vérifié).
- Garde-fous intacts : hard floor 22 %, recovery urgent, latch anti-créneaux P2, P4/P5.
### Outillage (résout le blocage minimax « impossible de pousser dans n8n »)
- **`tools/n8n-pull.mjs <id> <fichier>`** : GET workflow → JSON local (clé API lue
automatiquement dans `~/.config/opencode/opencode.json`).
- **`tools/n8n-push.mjs <fichier> [id]`** : PUT REST (body minimal validé 04/08) avec
**backup serveur auto pré-push** (`modified/prepush-*.json`) et **`node --check`
sur chaque jsCode** (abandon si erreur de syntaxe).
- **`tools/patch-20260807-night-hc-grid.mjs`** : le patch P7 (remplacements exacts, rejoue-able).
- **`tools/dryrun-20260807-night-hc.mjs`** : harnais dry-run WINDOWS→BCL avec snapshot simulé.
- Cycle git : commit backup AVANT (`1debfe3`) → patch → push → commit APRÈS.
Rollback : `git checkout 1debfe3 -- workflows/evcs-surplus-controller-v2.6.json && node tools/n8n-push.mjs workflows/evcs-surplus-controller-v2.6.json`.
### À surveiller (prochaine nuit HC 01:24→07:24, VW branchée)
1. 01:24-01:25 : `source_mode` → `Réseau HC autorisé`, EDF ON, takeover helper ON ;
2. `start_grid_hc_min_current` → montée en courant (plafond EDF ON = helper 20 A / boost 28 A) ;
3. ACM en `evcs_takeover_hold` (batterie ~2 A, SOC stable) ;
4. 07:24 : fin de fenêtre → EDF OFF, retour profil jour.
---
*Document mis à jour le : 07/08/2026 (~15:50) — P7 charge HC nuit auto + tools n8n-pull/n8n-push*
## 12. Session du 07/08/2026 (soir) — R1 : Déduplication du moteur nuit
### Contexte
Refactor n°1 du plan de maintenabilité (conseil validé avec l'utilisateur) :
le moteur nuit complet (~161 lignes : nightSessionActive, batteryAssistAllowed,
nightGridTakeoverNeeded, nightPreferredSourceMode, cibles/triggers, latch...)
était calculé DEUX FOIS, dans WINDOWS_SOURCEV2 ET BATTERY_CLOUD_LOCKS.
Chaque patch nuit devait être appliqué 2× (P2, fix 04/08, P7) → risque de
divergence silencieuse entre sourceMode (WINDOWS) et conditions (BCL).
### Fix R1 (déployé 07/08 ~19:03, A/B validé)
- **WINDOWS_SOURCEV2 = unique calculateur.** Reprend les 4 éléments que seul BCL
calculait : `nightSessionStartMs`, `nightSessionNeedsCapture` version stricte
(critère startMs inclus), `batteryRecoveryAllowedNow`, `batteryTargetReached`.
Export `nightEvcsPreview` étendu (+ `nightTakeoverLatched`, `nightGridHcPreferred`).
- **BATTERY_CLOUD_LOCKS consomme** `prev.nightEvcsPreview` (bloc de 161 lignes
remplacé par ~50 lignes de lecture avec fallbacks sûrs : preview absent
→ session inactive / 'blocked'). −1,7K chars.
- Le shape de l'export `nightEvcs` de BCL (consommé par DECISION_ENGINEV2 et
HA_SERVICE_CALLS_OUTPUT) est strictement inchangé.
- **RÈGLE DÉSORMAIS : toute évolution de la logique nuit se fait UNIQUEMENT
dans WINDOWS_SOURCEV2.** BCL ne contient plus aucune logique nuit.
### Validation A/B (tools/ab-20260807-r1.mjs)
- Pipeline complet WINDOWS→BCL exécuté avant (git HEAD) / après (patché) sur les
7 scénarios golden + 2 cas limites (capture session history vide, clear résiduel).
- Sorties BCL **strictement identiques** (nightEvcs, conditions, safeguards, context).
- Fallback preview absent : pas de crash, retombe en 'blocked'.
- Piège rencontré : Date.now() dans le harnais → faux diffs de quelques ms ;
corrigé en construisant le snapshot une fois puis clone JSON par exécution.
### Outillage ajouté
- `tools/patch-20260807-r1-dedup-night.mjs` : le refactor (rejouable).
- `tools/ab-20260807-r1.mjs` : harnais A/B git HEAD vs working tree — réutilisable
pour tout futur refactor (modèle pour R3, la suite de tests golden).
### Prochaines étapes du plan validé
- R3 : suite de tests golden (~15 scénarios, exécutée avant chaque push).
- R2 : git source de vérité (src/nodes/*.js + assemblage dans n8n-push.mjs).
- Puis agent IA planificateur en mode ombre (jamais dans la boucle 30 s).
---
*Document mis à jour le : 07/08/2026 (~19:10) — R1 déployé, comportement inchangé (A/B), production stable*
## 13. Session du 07/08/2026 (soir) — R3 : Suite de tests golden
### Ce qui existe désormais (`tests/golden/`)
- **`run.mjs`** : exécute le VRAI code des nodes en chaîne complète
(INPUT_PARSE → WINDOWS_SOURCEV2 → BATTERY_CLOUD_LOCKS → DECISION_ENGINEV2
→ HA_SERVICE_CALLS_OUTPUT) dans une sandbox vm avec **horloge simulée**
(Date épinglée par scénario) sur un **snapshot HA réel** modifié par overrides.
- **`fixture-snapshot.json`** : snapshot production capturé depuis l'exécution
n8n 975833 (173 champs, re-capturable via `tools/fetch-snapshot-fixture.mjs`).
- **`scenarios.mjs`** : 16 scénarios avec attendus FIGÉS après revue manuelle :
6 nuit HC (P7 ON/OFF, sous-cible, hard floor, sans véhicule, EDF ON→start),
4 solaire (matin fort, matin nuageux, abondance P6, provocation P3),
garde SOC midi HC, force_grid, force_charge nuit, stop, overload, débranché.
### Usage
- `node tests/golden/run.mjs` → 16/16 ✅ ou liste des diffs + exit 1
- `--print` : affiche les résumés obtenus (pour autoriser une évolution)
- `--json` : sortie machine (utilisée pour figer les attendus)
- `--only=<motif>` : filtre par id de scénario
### Intégration au déploiement (le point clé)
`tools/n8n-push.mjs` exécute la suite AVANT tout PUT du workflow EVCS :
**golden rouge = rien n'est poussé**. Échappatoire consciente : `--skip-golden`.
Évolution volontaire du comportement → mettre à jour les attendus dans
`scenarios.mjs` DANS LE MÊME COMMIT que le patch.
### Validation par mutation
P7 volontairement cassé (`nightGridHcPreferred = false`) → la suite détecte
2 échecs avec diffs précis (sourceMode, takeover, EDF) et bloque le push ;
restauration → 16/16. Le filet fonctionne.
### Résumé testé par scénario
profile, sourceMode, state, decision, desiredCharging, currentA,
takeoverNeeded, action EDF ON, service helper takeover.
### Notes de comportement figées (découvertes en écrivant les attendus)
- SOC 20 la nuit avec maison sur batterie (−14 A) → `battery_current_hard_stop`
(bande lt25 : hard −8 A) + demande réseau simultanée. Protection cohérente.
- `force_charge_now` la nuit (toggle HC ON) → `start_manual_local_min_current`
ET takeover helper ON (session nuit active) : EDF peut venir en renfort. Voulu.
- Garde SOC>35 midi : sourceMode `grid_hc_allowed` mais `idle_wait_conditions`
(le grid HC midi ne s'active que SOC ≤ 35) — comportement épinglé par le test.
### Prochaines étapes du plan
- R2 : git source de vérité (src/nodes/*.js + assemblage dans n8n-push).
- Agent IA planificateur en mode ombre (après R2).
- Enrichir la suite au fil des incidents : chaque bug corrigé = +1 scénario.
---
*Document mis à jour le : 07/08/2026 (~19:20) — R3 en place, 16/16, push bloquant*
## 14. Session du 07/08/2026 (soir) — P8 : Libération EDF quand le véhicule est plein
### Spécification utilisateur
La nuit : charge dès 01:24 (P7) ✓, puis **quand la charge est finie → EDF OFF,
sauf si la batterie solaire est sous le seuil** (alors EDF reste pour la recharger).
### Bugs découverts en traçant la spec
1. **Fix 04/08 logiquement inversé** : `(soc < 90 || vehicleChargeCompleted)` GARDAIT
le latch takeover après la charge au lieu de le libérer. Jamais corrigé en réalité.
2. **P7 ne relâchait pas** : `nightGridHcPreferred` restait vrai tant que véhicule
branché → l'EVCS redemandait EDF ON toutes les 30 s jusqu'à 07:24.
3. **Signal "voiture pleine" instable** : le VehicleDemandFilter retombe à false dès
que le relais s'ouvre (session désarmée) → impossible de s'appuyer dessus seul.
### Solution : mémoire AUTO-ENTRETENUE via l'état surplus (P8)
Chaîne (nouveau champ snapshot `prev_surplus_state` ← input_text.victron_evcs_surplus_state) :
```
charge → VDF détecte voiture pleine → state 'vehicle_full_or_paused'
cycle N+1 : WINDOWS voit ce state → vehicleChargeCompleted=true
→ DECISION émet 'night_session_done_idle'
cycles suivants : 'night_session_done_idle' s'auto-entretient
fin : débranchement (state vehicle_not_connected) ou fin de fenêtre 07:24
```
Effets quand vehicleChargeCompleted :
- latch libéré (fix inversion 04/08 : `soc < 90 && !completed`)
- préférence P7 relâchée ; sourceMode nuit → 'blocked' (zéro tentative de charge)
- SOC > cible matin : takeover helper OFF → hystérésis coupe EDF (turn_off_recovered)
- SOC ≤ cible : takeover helper ON → hystérésis maintient EDF (override) + ACM
recharge la batterie à la cible → puis relâche. ⚠️ Le nom 'night_session_done_idle'
ne doit matcher AUCUN pattern no-demand de l'hystérésis (full_or_paused /
not_requesting_power / charge_complete / no_demand) — c'est ce qui permet
à l'override batterie-basse de fonctionner. NE PAS RENOMMER sans vérifier.
### Nœuds modifiés (tools/patch-20260807-p8-release-full.mjs, rejouable)
- Snapshot template : +prev_surplus_state
- WINDOWS_SOURCEV2 : vehicleChargeCompleted collant, latch corrigé, P7 conditionné,
preferred 'blocked' si plein, export preview
- BATTERY_CLOUD_LOCKS : passthrough du flag dans nightEvcs
- DECISION_ENGINEV2 : branche 'night_session_done_idle' (avant sourceMode blocked)
### Validation
- 3 nouveaux scénarios golden (19 au total) : transition vehicle_full→done_idle
(takeover turn_off), auto-entretien, batterie basse (takeover turn_on maintenu).
- Les 16 scénarios existants inchangés (aucune régression).
- Push via pipeline bloquant (golden 19/19), production stable.
### Comportement attendu cette nuit (VW branchée, checklist complète)
1. 01:24-01:25 : grid_hc_allowed → EDF ON → charge 6 A puis montée (P7)
2. VW pleine : vehicle_full_or_paused → night_session_done_idle → takeover OFF
→ EDF coupé par l'hystérésis ≤ 1 min après (si min_on 25 écoulé et SOC ≥ 30)
3. Batterie sous cible matin (35 été) : EDF maintenu + recharge ACM jusqu'à cible
4. 07:24 : fin de fenêtre → retour profil jour
## 15. Session du 07/08/2026 (soir) — R2 : Git = source de vérité du code des nodes
### Nouveau layout
- **`src/nodes/`** = SOURCE DE VÉRITÉ : INPUT_PARSE.js, WINDOWS_SOURCEV2.js,
BATTERY_CLOUD_LOCKS.js, DECISION_ENGINEV2.js, HA_SERVICE_CALLS_OUTPUT.js,
BUILD_ACTIONS.js, HA_SNAPSHOT_TEMPLATE.json.tpl.
- `workflows/evcs-surplus-controller-v2.6.json` = artefact déployable, reconstruit
automatiquement depuis src/ à chaque push (src GAGNE toujours).
- `tools/lib-node-map.mjs` (cartographie), `tools/n8n-extract.mjs` (JSON→src,
uniquement pour resynchroniser après une édition UI n8n — à éviter),
`tools/n8n-build.mjs [--check]` (src→JSON / vérif synchro).
- `archive/` : toutes les copies historiques (decoupesurplus/, WINDOWS/, BATTERY/,
DECISION/, HYSTERESIS/, EVCSV2.2.json, surplus.js...) — copies mortes, dérivées.
- `.gitignore` : backup-workflow-*.json + modified/prepush-*.json (rollbacks locaux).
### WORKFLOW DE MODIFICATION DÉSORMAIS (règle inscrite dans AGENTS.md)
```
éditer src/nodes/<NODE>.js
node tools/n8n-push.mjs workflows/evcs-surplus-controller-v2.6.json
→ build src→JSON → node --check → GOLDEN 19/19 (bloquant) → backup serveur → PUT
git commit (src + json + scénarios si évolution voulue) && git push
```
JAMAIS d'édition directe du JSON ou de l'UI n8n (écrasée au prochain push).
### Validations effectuées
- Extract/build idempotents (extract → build → --check : zéro diff).
- Test mutation : édition src → build injecte 1 node → restauration git+extract OK.
- Pipeline complet de push exécuté en réel : build (no-op) → golden 19/19 → PUT OK.
- Nota : tailles `ls` ≠ longueur de code (octets UTF-8 vs caractères, accents FR).
### Plan de maintenabilité : état
- R1 ✅ déduplication moteur nuit (07/08)
- R3 ✅ suite golden 19 scénarios bloquante (07/08)
- R2 ✅ git source de vérité + build/push (07/08)
- Suivant : agent IA planificateur en MODE OMBRE (jamais dans la boucle 30 s) —
helpers advisor_*, validation bornée des sorties, kill-switch, cf. HANDOFF §13/§14.
## 16. Session du 07/08/2026 (nuit) — Agent IA planificateur en MODE OMBRE
### Ce qui tourne désormais
Workflow n8n **"EVCS Advisor Shadow V1"** (id `tdfInZns7cqo8fU2`, actif, 15 min) :
```
Schedule 15min → HA Snapshot compact (template Jinja {{ {...} | tojson }})
→ BUILD_PROMPT (kill-switch input_boolean.evcs_advisor_enabled, prompt FR ~4K chars)
→ IF_ENABLED → LLM_LOCAL_MODELS (GET 10.10.10.8:8000/v1/models, 5s)
→ LLM_LOCAL (modèle DYNAMIQUE = celui chargé sur la Spark, max_tokens 6000)
→ IF_LOCAL_OK → sinon LLM_CLOUD (OpenRouter anthropic/claude-haiku-4.5,
credential chiffré "OpenRouter Advisor" id eOM9xofDHT0KMoeY — clé JAMAIS dans git)
→ VALIDATE_PARSE (strip <think>, JSON strict, enums+bornes, invalide = REJET)
→ BUILD_HA_CALLS (helpers advisor_* + divergence + compteurs + logbook)
→ Execute HA Service
```
### GARANTIE MODE OMBRE (structurelle, testée)
BUILD_HA_CALLS n'écrit QUE vers `input_text/input_number.evcs_advisor_*` et
logbook. Le test `tests/advisor/run.mjs` ÉCHOUE si une écriture sort de ce
périmètre (16 tests : kill-switch, réponses valides/hors-bornes/non-JSON/
<think>/offline, périmètre ombre, compteurs accord/divergence).
Suite intégrée au push : `n8n-push.mjs workflows/evcs-advisor-shadow-v1.json`.
### Helpers HA (package NFS /mnt/ARCH3/docker/HA/packages/evcs_advisor_shadow.yaml)
`evcs_advisor_enabled` (kill-switch) · `advisor_strategy` · `advisor_night_target_soc`
(22-90) · `advisor_current_max_a` (6-28) · `advisor_ev_charge_tonight` · `advisor_reason`
· `advisor_status` (ok/invalid/llm_offline) · `advisor_divergence` · `advisor_last_run`
· `advisor_model` · `advisor_agree_count` / `advisor_diverge_count`.
Carte dashboard « 🤖 Conseiller IA (ombre) » ajoutée à evcs_master.yaml (backup dans SAVE/).
### Première recommandation réelle (07/08 22:40, via qwen3-vl local)
`auto_solar_plus` | cible 35% | charge VE cette nuit: oui | confiance 90% |
« Prévision solaire forte (40.7 kWh), cible SOC matin basse adaptée. Véhicule
chargé en HC avec réseau EDF. » → **accord avec le réel** (et avec P7/P8 !).
### Pièges rencontrés (pour mémoire)
1. Template /api/template DOIT être du Jinja : `{{ { ... } | tojson }}` —
sans wrapper, HA renvoie le texte brut non rendu.
2. Modèle local = qwen3-vl **Thinking** : max_tokens 2500 → JSON tronqué par le
raisonnement → rejeté proprement (`invalid: JSON non terminé`). Fix : 6000.
(La validation stricte a fait exactement son travail dès le 1er run réel.)
3. MCP activate_workflow → erreur 415 : activer via REST POST .../activate body {}.
4. n8n-push : le fallback d'id par défaut (EVCS) était DANGEREUX avec un 2e
workflow → supprimé, l'id vient du champ `id` du JSON ou de l'argument.
### Outillage multi-workflows (extension R2)
`lib-node-map.mjs` : WORKFLOWS[] (evcs + advisor) · build/extract/push acceptent
un fichier cible · `n8n-create.mjs` (POST création) · `gen-advisor-workflow.mjs`
(squelette advisor rejouable, credentials/ids inclus).
### Note LLM local (analyse du 07/08, décision : statu quo surveillé)
qwen3-vl-Thinking "fait le job" mais est sous-optimal pour l'advisor : variante
Thinking (raisonnement non désactivable, 40-60 s/run, risque marginal de JSON
tronqué — rejeté proprement par la validation). Le bon modèle local est
qwen3.6-35b quand il est chargé (use-code).
**Option prête si les compteurs se dégradent** (décision utilisateur : ne rien
changer pour l'instant) : routage intelligent = 1 IF après LLM_LOCAL_MODELS
(local seulement si l'id du modèle chargé n'est pas vl/thinking, sinon cloud
direct) + éventuellement cloud moins cher (gemini-2.5-flash-lite ~1€/mois vs
haiku-4.5 ~9€/mois plein temps). Implémentation ~30 min, tests advisor inchangés.
### Phase 3 — critères de bascule (dans 2-3 semaines d'observation)
1. Taux d'accord stratégie ≥ 80% (compteurs agree/diverge sur la carte) ;
2. Zéro sortie invalide NON rejetée (advisor_status) ;
3. Divergences analysées = soit l'agent a raison (améliorer les règles),
soit il a tort (améliorer le prompt) — les deux font progresser le système.
→ Alors premier câblage réel LIMITÉ : night_target_soc uniquement, derrière un
nouveau toggle `advisor_apply_night_target`, le reste inchangé.
## 17. Session du 08/08/2026 — Incident rebond EDF nuit 07-08/08 : fixes A/B/C/D
### Incident (captures HA analysées, faits dans docs/vision-notes.md)
P7 a chargé la VW dès 01:24 ✓ (session 1h32m). Ensuite :
1. Fin de charge détectée très tardivement (~04:15 au lieu de ~02:56) ;
2. Rebond EDF ON/OFF période ~10 min autour de SOC=35 (= cible matin) + flapping
ACM Charging/passthru 2-4 s (03:58→04:16) ;
3. Débranchement VW (04:17) → takeover bien relâché mais EDF maintenu
(hold_on_min_runtime 25 min) → arrêt manuel utilisateur 04:25 (stopped_user).
### Root causes
- **A — point dégénéré SOC==cible** (WINDOWS_SOURCEV2) : engage takeover
`soc <= cible`, release `soc > cible`, recovery ACM `soc < cible` (strict),
et l'ACM recharge PILE à la cible → aucune position stable, oscillation.
- **B — snapshot incomplet** : `evcs_night_session_start_soc/_at` jamais relus
(absents du template) → `nightSessionNeedsCapture` vrai à CHAQUE cycle →
"Night Session Start At" réécrit toutes les 30 s toute la nuit (visible logs),
budget décharge nuit faussé (start SOC glissant).
- **C — hystérésis grid** : pas de coupure anticipée au débranchement
(min_on 25 min s'applique même véhicule parti et SOC ≥ seuil OFF).
- **D — détection fin de charge fragile** : VDF armé uniquement session
pleinement active ; stop par garde (ex battery_current_hard_stop) → filtre
désarmé → 'vehicle_full_or_paused' inatteignable pendant longtemps.
### Fixes déployés (production 08/08 ~14:00, golden 23/23)
- **Fix A** (WINDOWS_SOURCEV2 + BCL passthrough + DECISION) : nouvel état
auto-entretenu **`night_recovery_done_idle`** émis quand
vehicleChargeCompleted && SOC ≥ cible. Takeover relâché DÉFINITIVEMENT ;
ré-engagement uniquement si SOC ≤ cible − 2 (bande NIGHT_RECOVERY_REARM_BAND_PCT).
⚠ Le nom ne matche aucun pattern no-demand de l'hystérésis (comme done_idle).
Structure takeover : completed ? !nightRecoveryDone : (trigger || urgent || latch).
- **Fix B** (HA_SNAPSHOT_TEMPLATE) : +`evcs_night_session_start_soc`,
+`evcs_night_session_start_at` (helpers input_text existants, vérifiés via MCP).
- **Fix C** (workflow hystérésis 7iIlu7LLTW7TZA8o, patch
tools/patch-20260808-c-unplug-cut.mjs rejouable) : nouvel état
**`turn_off_vehicle_unplugged`** = véhicule débranché (es=vehicle_not_connected)
+ latch ON + grid ON + pas d'override + SOC ≥ seuil OFF → coupure immédiate
(bypass min_on) + notification. Dry-run 4 cas validé (incident/SOC bas/
takeover ON/recovery_done) AVANT push. Le fichier local du workflow est
désormais suivi : workflows/victron-grid-hysteresis-v1.json.
- **Fix D** (WINDOWS_SOURCEV2) : `evcs_status_raw == 3` (Victron "charged")
vaut fin de charge immédiate, sans attendre le VDF.
### Golden : 19 → 23 scénarios
- 2 attendus mis à jour (transition/auto-entretien : SOC ≥ cible → état
`night_recovery_done_idle` désormais) ;
- 4 nouveaux : `night-recovery-atteinte-soc-cible` (LE cas du rebond),
`night-recovery-done-bande-collante` (34 % dans la bande),
`night-recovery-rearm-sous-bande` (32 % → ré-engagement),
`night-vw-statut-charged` (Fix D).
- Piège outillage : `tests/golden/run.mjs` exécute le JSON BUILDÉ
(workflows/*.json), pas src/ → toujours `node tools/n8n-build.mjs` avant un
run golden manuel (n8n-push le fait automatiquement).
### Documentation
- MANUEL_COMPLET.md §11.6 : GRAFCET complet du cycle nuit (G1 EVCS / G2
hystérésis) + états surplus fin de nuit + résumé incident.
- docs/vision-notes.md : OCR/faits des 9 captures de l'incident.
### À surveiller (prochaine nuit HC, VW branchée)
1. Fin de charge : passage à vehicle_full_or_paused OU night_recovery_done_idle
rapide (Fix D si la VW publie le statut 3) ;
2. SOC à la cible → UNE coupure EDF, puis plus aucun ON/OFF (sauf SOC ≤ cible−2) ;
3. "Night Session Start At" écrit UNE fois en début de session (Fix B) ;
4. Débranchement → EDF OFF ≤ 1 min (turn_off_vehicle_unplugged, Fix C).
---
*Document mis à jour le : 08/08/2026 (~14:15) — fixes A/B/C/D déployés, golden 23/23*
## 18. Session du 09/08/2026 — Incident nuit 08-09/08 : le rebond est revenu par un AUTRE chemin (Fix E)
### Incident (captures HA, VW branchée, HC, VW pleine vers ~03:00)
Fix A/D du 08/08 ont bien fonctionné CÔTÉ WINDOWS (status 3 → completed →
takeover Désactivé, source blocked). MAIS l'état EVCS observé était
`hold_min_run_source_lost` / `stop_source_lost` — jamais `night_recovery_done_idle`.
Cycles ~30 min de 03:00 à 05:40 : EDF ON (2531 W réseau, SOC 51 % > cible 35)
→ arrêt urgence utilisateur 05:43 (EDF URGENCE OFF + stratégie stop) → débranchement.
### Root cause (ordre des branches DECISION)
1. Status 3 arrive PENDANT la session armée (relais fermé, chg=1/perm=1/l=1).
2. WINDOWS → sourceMode 'blocked' (Fix D) ; mais dans DECISION la branche
session ACTIVE traite 'blocked' comme « source perdue » :
`hold_min_run_source_lost` (25 min de session zombie !) puis `stop_source_lost`.
La branche P8 (état collant) est dans la chaîne NON-active → jamais atteinte.
3. Au stop, le relais s'ouvre → le statut EVCS RETOMBE (3 → 1/6, même piège que
le VDF documenté en P8) → completed=false → re-P7 → takeover ON 1 cycle →
`edf_general_ok_on` rallume EDF + restart → status 3 → goto 2. Rebond ~30 min.
4. L'hystérésis adoptait l'EDF ON du cycle de flap puis `hold_on_min_runtime`
25 min (ov=0/eg=0 ensuite, mais latch pris) → EDF ON quasi permanent.
### Fix E (déployé 09/08 ~06:25, golden 25/25)
- **DECISION** : nouvelle PREMIÈRE branche du bloc session-active (avant VDF) :
`night_hc && vehicleChargeCompleted && !force*` → setOff immédiat avec l'état
collant (`night_recovery_done_idle` si SOC ≥ cible, sinon
`night_session_done_idle`), decision `stop_vehicle_full_or_paused`.
→ l'état collant est écrit AU PREMIER cycle où status=3, relais encore fermé ;
les retombées de statut ensuite sont sans effet (prev state dans la liste).
- **WINDOWS** : garde puissance sur Fix D (`status==3 && evcsAcPowerW <= 300 W`)
pour ne jamais latcher sur un glitch en pleine charge.
- **Hystérésis** (patch tools/patch-20260809-e2-recovery-done-cut.mjs) :
`turn_off_recovery_done` = es=night_recovery_done_idle + pas d'override +
SOC ≥ seuil OFF → coupure EDF immédiate (bypass min_on 25). Sûr car l'état
est collant (le takeover ne peut pas revenir, sauf SOC ≤ cible−2 → override).
Dry-run 5 cas validé avant push.
### Golden : 23 → 25
- `night-charged-en-session-active` : status 3 en session ARMÉE → arrêt propre
+ latch immédiat (LE chemin de l'incident) ;
- `night-flap-statut-apres-latch` : relais ouvert, statut retombé à 1,
prev=night_recovery_done_idle → collant, aucun re-P7/EDF.
### Comportement attendu au REBRANCHEMENT d'une VW pleine en HC
1. Session nuit → P7 → EDF ON → tentative de charge (inévitable : le système
ne peut pas savoir que la VW est pleine sans fermer le relais) ;
2. VW refuse (0 W, status 3) → ≤ 1 min : `night_recovery_done_idle` collant,
takeover OFF, session stoppée proprement ;
3. Hystérésis : `turn_off_recovery_done` → EDF OFF ≤ 1 min (plus de min_on 25) ;
4. Plus RIEN jusqu'au débranchement / fin de fenêtre (flaps de statut ignorés).
Total attendu : EDF ON ~2-3 min UNE seule fois, puis silence.
---
*Document mis à jour le : 09/08/2026 (~06:35) — Fix E déployé (EVCS + hystérésis), golden 25/25*
## 19. Session du 09/08/2026 (suite) — Fix F : la mémoire "charge terminée" est prioritaire sur les gardes
### Test de rebranchement utilisateur (~06:33, VW rebranchée en HC)
1. ✅ Fix E validé en réel : 06:51:00 `night_recovery_done_idle` latché,
06:51:03 `turn_off_recovery_done` → EDF coupé immédiatement (bypass min_on).
2. ❌ 06:53-06:56 : la VW a relancé une charge (status 2) qui, EDF OFF, tirait
sur la batterie (−47 A) → garde `mustStopHard` → état
`battery_current_hard_stop` ÉCRIT PAR-DESSUS le collant dans le helper →
completed perdu → re-P7 → EDF rallumé (hold_on_evcs_override, re-min_on 25).
→ Le collant ne survivait à AUCUN état émis par une branche plus prioritaire.
### Fix F (déployé 09/08 ~07:00, golden 26/26)
- DECISION : nouvelle branche dans la cascade PRINCIPALE, entre
`vehicle_not_connected` et `mustStopHard` :
`night_hc && vehicleChargeCompleted && !force*` → setOff avec l'état collant
(recovery_done/done_idle), `mustStop` si la borne est encore armée.
→ l'état collant est réécrit à CHAQUE cycle tant que VW branchée + fenêtre,
il survit aux micro-charges/gardes. Aucune perte de protection (le setOff
coupe chg/perm/latch comme un hard stop).
- La branche Fix E interne (session active) supprimée : remplacée par la
branche haute. La branche P8 historique (chaîne non-active) reste en filet.
- Golden 26 : `night-hard-stop-necrase-pas-done` (micro-charge −60 A sur
batterie avec prev=recovery_done → l'état reste recovery_done, stop ferme).
### Leçon d'architecture (pour le futur)
La "mémoire" portée par input_text.victron_evcs_surplus_state est fragile :
tout état émis l'écrase. Fix F la protège en réécrivant chaque cycle, mais la
solution PROPRE à terme est un helper dédié (input_boolean
victron_evcs_night_charge_done, package YAML + reload HA) si un nouveau cas
d'écrasement apparaît.
### Relecture de la nuit 08-09/08 (après coup)
La VW n'a pris que 5 kWh cette nuit, morcelés par les stops zombies (§18) :
elle n'était probablement PAS pleine. Les "micro-charges" du matin étaient de
vraies reprises. À 07:00 (post-Fix F) : charging_step_up_grid_hc = charge HC
nominale jusqu'à la fin de fenêtre 07:24. Le collant ne se posera que sur un
VRAI signal de fin (VDF no-demand ou status 3) — et tiendra désormais.
---
*Document mis à jour le : 09/08/2026 (~07:05) — Fix F déployé, golden 26/26*
## 20. Session du 09/08/2026 — PREMIER CÂBLAGE RÉEL de l'advisor : cible SOC matin
### Décision utilisateur
Météo Bretagne au beau fixe (47-58 kWh/j prévus) = conditions idéales pour
tester le câblage avec risque minimal. Périmètre STRICTEMENT limité au plan
§16 : `night_target_soc` uniquement, derrière un toggle, rollback 1 clic.
### Ce qui est câblé
`batteryMorningTargetPct` (WINDOWS_SOURCEV2) suit
`input_number.evcs_advisor_night_target_soc` SI ET SEULEMENT SI :
1. `input_boolean.evcs_advisor_apply_night_target` = ON (nouveau toggle,
initial OFF, package evcs_advisor_shadow.yaml + carte dashboard) ;
2. `input_text.evcs_advisor_status` = 'ok' (exact) ;
3. recommandation fraîche ≤ 120 min (parse `DD/MM HH:mm` de advisor_last_run,
année courante, garde passage d'année — parseAdvisorRunToMs INPUT_PARSE) ;
4. valeur clampée [max(22, hard floor), 90].
Sinon → règles forecast historiques (`batteryMorningTargetRulePct`), aucun
changement de comportement. Le toggle OFF = état d'origine.
### Chaîne technique
- Snapshot : +advisor_apply_night_target, +advisor_night_target_soc,
+advisor_status, +advisor_last_run (states() d'une entité absente →
'unknown' → toggle vu OFF → sûr AVANT le reload HA).
- INPUT_PARSE : params.advisorApplyNightTarget / advisorNightTargetSocPct /
advisorStatusOk / advisorLastRunMs.
- WINDOWS : override borné + export advisorNightTargetApplied +
batteryMorningTargetRulePct (preview) ; BCL passthrough ; DECISION : la
reason affiche `target=X% (ADVISOR)` quand appliqué → visible logbook/carte.
- Impact aval automatique : gridTakeoverTriggerPct, grid_forced vs hc_allowed,
recovery post-charge, bande anti-rebond — tous dérivés de la cible.
### Golden 26 → 29
- `night-advisor-target-applique` : SOC 30, advisor 22 ON/ok/frais →
grid_hc_allowed (au lieu de grid_forced par la règle 35) ;
- `night-advisor-target-stale` : > 120 min → fallback règles (grid_forced) ;
- `night-advisor-toggle-off` : toggle OFF → règles inchangées.
### Sécurités NON modifiées
Hard floor 22 %, hysteresis grid ON ≤ 20 %, recovery urgent départ, latch
anti-créneaux, collant fin de charge (Fix A/E2/F). L'advisor ne peut RIEN
piloter d'autre (VALIDATE_PARSE borne night_target_soc 22-90 côté advisor,
clamp identique côté contrôleur).
### Rollback (3 niveaux)
1. Toggle « APPLIQUER cible SOC matin » OFF → effet au cycle suivant (30 s) ;
2. Kill-switch advisor OFF → plus de runs → fraîcheur > 120 min → fallback
auto en ≤ 2 h même si le toggle reste ON ;
3. `git revert` du commit + `node tools/n8n-push.mjs workflows/evcs-surplus-controller-v2.6.json`.
### Surveillance (2-3 nuits)
- Carte Conseiller IA : « Cible SOC matin (agent) » vs « (réel) » doivent
converger quand le toggle est ON ;
- Logbook « Victron EVCS - Reason » la nuit : `target=X% (ADVISOR)` ;
- Le matin : SOC batterie au lever du soleil ≈ cible advisor, remontée solaire OK ;
- Si SOC matin < 22 % ou EDF nuit anormal → toggle OFF + capture + analyse.
### Action utilisateur requise après ce déploiement
Recharger HA pour créer le toggle : Outils développeur → YAML →
« ENTRÉES BOOLÉENNES (input_boolean) » Recharger (ou redémarrage HA), puis
activer le toggle sur la carte Conseiller IA (dashboard EVCS Master rechargé).
---
*Document mis à jour le : 09/08/2026 (~07:35) — câblage advisor night_target déployé (toggle OFF par défaut), golden 29/29*
## 21. Session du 09/08/2026 — P9 : charge réseau avant départ + sélecteur véhicule VW/JEEP
### Contexte clarifié par l'utilisateur (IMPORTANT pour comprendre les incidents)
DEUX véhicules alternent sur la borne :
- **VW** (hybride) : départ ~08:50, ne publie PAS franchement "charged" (statut
3 rare/fugace) → détection fin de charge = VDF surtout ;
- **JEEP** : départ ~06:30, publie "charged" franchement.
Relecture des incidents : nuit 08-09/08 = JEEP (rebonds §18) ; matin 09/08 =
VW déjà pleine (chargée la veille en solaire) rebranchée à la place de la JEEP.
L'heure de départ unique (08:50) était un réglage VW ; la JEEP part à 06:30
(dans la fenêtre HC → couverte par la mécanique nuit existante).
### Nouveaux helpers (package /mnt/ARCH3/docker/HA/packages/victron_evcs_vehicles.yaml)
- `input_select.victron_evcs_vehicle` (VW/JEEP, défaut VW) — à sélectionner au
branchement. NB : l'ancien `input_select.victron_evcs_target_vehicle`
(None/Jeep/Zero, créé UI) n'était câblé nulle part → retiré de la carte,
remplacé par le nouveau. À supprimer un jour via l'UI.
- `input_datetime.victron_evcs_departure_time_jeep` (06:30). L'existant
`victron_evcs_departure_time` (08:50) devient l'heure VW.
- `input_boolean.victron_evcs_allow_grid_departure_urgent` (toggle P9, OFF).
Carte "Paramètres utilisateur" (evcs_master.yaml) mise à jour.
⚠ RELOAD HA requis (input_boolean + input_datetime + input_select).
### P9 (golden 33/33, déployé 09/08 ~08:27)
- INPUT_PARSE : `departureTimeMin` suit le véhicule sélectionné (JEEP →
departure_time_jeep, fallback VW) ; impacte aussi departurePriorityActive
et batteryRecoveryUrgent nuit (donc la JEEP à 06:30 est mieux servie).
- WINDOWS : fenêtre `departureWindowActive` = toggle ON + véhicule branché +
HORS fenêtre HC + départ dans ≤ 120 min. Si charge non terminée →
`departureUrgentActive` → sourceMode `grid_hc_allowed` (prend le pas sur
blocked/solar_potential/hybrid_probe, JAMAIS sur solar_only ni stop/force*).
Fin de charge (VDF vehicle_full_or_paused relu, ou statut 3) →
`departureChargeDone` → sourceMode blocked + état collant.
- BCL : `gridAllowedBySoc` inclut departureUrgentActive (sinon SOC>35 bloquait).
- DECISION : branche collante `departure_done_idle` (même principe que Fix F,
au-dessus des gardes, mustStop si borne armée).
- HA_SERVICE_CALLS : helper takeover tenu ON pendant departureUrgentActive
(override hystérésis + maintien batterie ACM pendant la charge pré-départ).
- Hystérésis (patch tools/patch-20260809-p9-departure-cut.mjs) : coupure
immédiate E2 étendue à `departure_done_idle`.
### Golden 29 → 33
p9-depart-vw-grid (fenêtre active, SOC 45 > 35 → réseau quand même) ·
p9-depart-jeep-passe (sélecteur : départ JEEP passé → rien) ·
p9-done-idle-collant · p9-toggle-off (comportement historique).
### Mode d'emploi utilisateur
1. Recharger HA (YAML : input_boolean, input_datetime, input_select) ;
2. Sur la carte : sélectionner le véhicule au branchement (VW ou JEEP) ;
3. Activer « ⚡ Réseau avant départ (P9) » ;
4. La JEEP profite de sa vraie heure 06:30 dès cette nuit (recovery urgent
nuit) ; la VW aura sa fenêtre 06:50-08:50 hors HC si sous-chargée.
Rollback : toggle P9 OFF (30 s) ; sélecteur sans effet si P9 OFF (sauf heure
de départ pour la priorité/urgence nuit — voulu).
---
*Document mis à jour le : 09/08/2026 (~08:35) — P9 + sélecteur véhicule déployés, golden 33/33*
## 22. Session du 09/08/2026 — P10 : mémoire "véhicule plein" en journée
### Incident (captures utilisateur 09:49-09:57)
VW pleine rebranchée ~07:00. À 09:49, PV fort (5,5 kW) → le surplus solaire
lance une tentative (morning_solar, solar_only) : 6 A commandés, 0 W pris,
VRM affiche "Charged". Sans mémoire jour, le cycle se répète toutes les
~15 min TOUTE la journée (VDF stop → block → re-start). Zéro coût énergie
(solaire, EDF OFF) mais usure relais borne/VW + bruit. Les états collants
P8/P9 étaient scopés nuit / fenêtre départ.
### Fix P10 (déployé 09/08 10:04, golden 36/36, validé live à 10:04:30)
- WINDOWS : `dayVehicleFullIdle` collant = véhicule branché + hors fenêtre HC
+ (prev ∈ {day_vehicle_full_idle, vehicle_full_or_paused,
night_recovery_done_idle, night_session_done_idle, departure_done_idle}
OU statut 3 ≤ 300 W). sourceMode → 'blocked' (y compris face à solar_only :
charger un véhicule plein ne sert à rien).
- HÉRITAGES bidirectionnels : le jour hérite des états done de la nuit
(07:24 → aucune re-tentative matinale) ; la nuit hérite du jour
(vehicleChargeCompleted inclut day_vehicle_full_idle → pas de tentative
01:24 si resté branché plein).
- DECISION : branche collante 'day_vehicle_full_idle' au-dessus des gardes
(principe Fix F), stop propre si session armée.
- Libération : DÉBRANCHEMENT (vehicle_not_connected) — ou échappatoires
force_charge_now / force_grid_now (testé golden).
- Hystérésis : nom sans pattern no-demand ✓, aucun changement nécessaire
(EDF pas impliqué dans ce chemin).
### Golden 33 → 36
day-vw-pleine-statut3 (session armée 0 W statut 3 → collant) ·
day-full-heritage-nuit (prev=night_recovery_done_idle → pas de solaire) ·
day-full-echappatoire-force-charge (force_charge_now relance).
### Validation live
10:04:14 PUT → 10:04:30 : `day_vehicle_full_idle` latché sur la VW pleine
en cours de tentative ; statut retombé à 6 (relais ouvert) sans relance.
### Cycle de vie complet d'un véhicule branché (résumé)
```
branché → tentatives légitimes (nuit P7 / jour solaire / P9 départ)
→ plein détecté (statut 3 ou VDF)
→ collant : night_*_done_idle / departure_done_idle / day_vehicle_full_idle
(héritages entre périodes, réécrit chaque cycle au-dessus des gardes)
→ DÉBRANCHEMENT = seule remise à zéro (+ force_* en échappatoire)
```
---
*Document mis à jour le : 09/08/2026 (~10:10) — P10 déployé et validé live, golden 36/36*
## 23. Session du 12/08/2026 — P11 : reset du collant au changement de véhicule
### Incident (nuit 12/08, captures utilisateur)
JEEP branchée 01:24, chargée en HC (cible 35% ADVISOR), fin de charge franche
→ état collant `night_recovery_done_idle`. Utilisateur débranche la JEEP et
branche la **VW** à sa place. L'état collant a SURVÉCU (le collant n'était
remis à zéro que par `vehicle_not_connected`, non vu de façon franche lors du
swap) → la VW était bloquée « charge véhicule terminée » alors que c'était
celle de la JEEP. La VW (déchargée) ne chargeait pas.
### Cause de conception
Tous les états collants "plein" (P8 night_*, P9 departure_*, P10 day_*) se
libéraient UNIQUEMENT au débranchement (vehicle_not_connected). Un remplacement
de véhicule rapide (ou un statut EVCS qui ne repasse pas franchement
"déconnecté") laissait le collant en place → nouveau véhicule bloqué.
### Fix P11 (déployé 12/08 05:00, golden 39/39, validé live à 05:00:30)
Signal fiable : `sensor.victron_evcs_charging_time` repart de 0 à CHAQUE
nouvelle connexion physique.
- Snapshot : +`evcs_charging_time` ; INPUT_PARSE : `parseChargingTimeToSec`
("1h 49m 53s"/"8m 27s"/"0s" → secondes), `parsed.evcsChargingTimeSec`.
- WINDOWS : `sessionFresh` = véhicule branché ET charging_time ≤ 90 s.
Neutralise `vehicleChargeCompleted`, `dayVehicleFullIdle`,
`departureChargeDone` ET `evcsStatusCharged` (statut 3 résiduel ignoré le
temps que la borne renégocie) → le système RETESTE le véhicule neuf.
- Export `sessionFresh` (traçabilité).
### Golden 36 → 39
p11-changement-vehicule-nuit (prev=night_recovery_done_idle + charging_time 15s
→ night_wait_grid_takeover, retest) · p11-changement-vehicule-jour
(day_vehicle_full_idle + 20s → solaire repart) · p11-session-etablie-reste-collant
(charging_time 2h05 → reste collant, pas de reset abusif).
### Validation live
05:00:00 PUT → 05:00:30 : la VW est passée de `night_recovery_done_idle`
(bloqué) à `night_wait_grid_takeover` / source « Réseau HC autorisé ».
### Récapitulatif des remises à zéro du collant "plein"
1. DÉBRANCHEMENT (vehicle_not_connected) ;
2. CHANGEMENT DE VÉHICULE / nouvelle connexion (charging_time ≤ 90 s, P11) ;
3. force_charge_now / force_grid_now (échappatoires manuelles).
## 26. Session du 27/08/2026 — L'oscillateur du takeover nuit, et deux acteurs non lus
### L'oscillateur (corrigé, golden 40 → 42, déployé 08:32)
Nuit du 26-27/08, Jeep pleine restée branchée, aucun besoin de charge : trois
sessions EDF (04:24→05:16, 05:31→06:50, 07:05→07:30), **2,5 kWh importés** un
matin annoncé à 43 kWh de solaire. La nuit du 20-21/08 en comptait six. Le
helper `night_grid_takeover_needed` précède chaque bascule du contacteur de 3 s :
la cause est entièrement dans le moteur de nuit EVCS, pas dans l'hystérésis
(`grid_soc_on`/`grid_soc_off` valent toujours 20/30, jamais approchés).
Cause, dans `WINDOWS_SOURCEV2.js` :
```
gridTakeoverTriggerPct = max(plancher 22, cible 35) = 35 → engage si SOC <= 35
nightRecoveryDone : SOC >= 35 → relâche
NIGHT_RECOVERY_REARM_BAND_PCT = 2 → réengage à 33
```
Engagement et relâchement sur **le même nombre**, la bande de 2 points du Fix A
ne faisant que retarder le réarmement. Or 2 points de SOC ≈ 0,34 kWh ≈ 31 min à
la charge nocturne (~650 W, parfaitement plate) : ce n'est pas une hystérésis,
c'est la période d'une horloge. C'est le propriétaire qui a mis le doigt dessus
en observant que le phénomène était « super stable car pas de variation de
consommation » — la platitude de la charge est ce qui rend le cycle régulier.
**Correctif** : la recovery batterie post-charge est UNE SEULE passe par session
de nuit (`nightRecoveryDone = ... || nightRecoveryDonePrev`, sans bande). C'était
déjà l'intention affichée du Fix A (« relâché DÉFINITIVEMENT »). Aucun seuil de
ré-engagement n'est ajouté : `batteryBelowHardFloor` reste un terme indépendant
de `nightGridTakeoverNeeded`, et l'hystérésis garde sa bande 20/30. Le vrai
critère de ré-engagement est un budget d'énergie restant à couvrir — il
appartient au bilan énergétique, pas à une constante de plus ici.
Golden : `night-recovery-rearm-sous-bande` (qui épinglait l'oscillateur) devient
`night-recovery-done-pas-de-reengagement` ; +`night-oscillateur-nuit-2627`
(le relevé exact de la nuit) ; +`night-recovery-done-plancher-dur` (le filet
de sécurité doit rester : SOC 21 → ré-engagement). Preuve par mutation : bande
restaurée → échec du nouveau scénario ; retirée → 42/42.
### D'où venait le 35 %, et pourquoi ce n'est pas une réserve
`batteryMorningTargetPct` vient des règles de prévision (≥ 18 kWh → 35 %,
≥ 10 → 55 %, sinon 85 %). C'est une **optimisation économique** — « demain il
fera beau, la batterie peut descendre » — et non une réserve de sécurité, ce que
le propriétaire croyait. Point de conception qui en découle : **une réserve
contre la perte du réseau ne peut pas être constituée avec le réseau** ; elle se
charge sur le solaire et se décide le soir. Voir §27 (à écrire) pour la réserve
d'orage.
### Acteur non lu n°1 — les charges programmées de l'ESS (IMPORTANT)
Le GX a **trois fenêtres de charge réseau programmées, actives tous les jours**,
que rien dans nos workflows ne lit :
| # | Départ | Durée | Cible SOC | Actif |
|---|---|---|---|---|
| 0 | 01:35 | 365 min (→ 07:40) | **45 %** | on |
| 1 | 07:30 | 298 min (→ 12:28) | **45 %** | on |
| 2 | 12:24 | 120 min (→ 14:24) | **55 %** | on |
| 3 | 14:24 | 120 min | 85 % | off |
| 4 | 23:08 | 60 min | 50 % | off |
Entités : `switch.gx_device_ess_batterylife_schedule_charge_N_enabled`,
`time..._start`, `number..._duration`, `number..._soc`.
`sensor.gx_device_ess_batterylife_state` = `self_consumption` hors réseau.
Le GX veut donc 45 % la nuit quand EDF est présent, quand notre cible dit 35 % :
**deux automates avec deux consignes différentes sur la même batterie**.
**Origine (donnée par le propriétaire) et décision du 27/08** : ces trois
fenêtres étaient un contournement manuel, posé d'abord quand le système
n'arrivait plus à tenir un seuil de 20 %, puis réutilisé l'hiver pour garantir
un minimum de charge en heures creuses et éviter de rallumer EDF en heures
pleines. Inutiles l'été. **Les cinq programmations sont désormais désactivées**
(vérifié : `..._charge_0..4_enabled` = off). Le contrôleur EVCS est le seul
propriétaire de la décision réseau.
⚠️ **Le besoin qu'elles couvraient l'hiver reste entier.** Il doit être repris
par le bilan énergétique, pas par une reprogrammation manuelle — voir la règle
saisonnière ci-dessous.
### Règle saisonnière de la cible du matin (propriétaire, 27/08)
« 20 % ou 35 % minimum c'est bien l'été mais l'hiver c'est idiot, ça va plutôt
être 80 % le matin ». Le raisonnement est en **heure solaire** : en été 9 h
locales valent 7 h TU, en hiver 10 h locales valent 9 h TU, soit **~2 h de
consommation supplémentaires à tenir sur batterie** avant le relais du solaire,
et une montée hivernale plus lente et plus basse.
Conséquence : la cible du matin ne doit être ni une constante ni un choix entre
trois paliers. Elle se déduit de l'heure réelle du croisement
production/consommation du lendemain et de l'énergie à couvrir d'ici là — ce
que le bilan sait déjà calculer. C'est le chantier qui remplace à la fois les
paliers 35/55/85 et les charges programmées de l'ESS.
### Acteur non lu n°2 — l'export d'excédent PV
`switch.gx_device_dc_coupled_pv_feed_in_excess` = off ·
`switch.gx_device_ac_coupled_pv_feed_in_excess` = off · `ess_max_feed_in_power`
= −1. Cohérent avec « sans injection », à confirmer comme voulu.
### Ce qui n'est PAS démontré : le bridage du MPPT par la présence d'EDF
Tentative de preuve sur la seule fenêtre EDF-ON diurne conservée en base
(21/08, 07:24→08:07) : PV moyenne 91 W (max 218) contre **907 W** (max 1464)
sur les 43 min suivantes, EDF coupé, à consommation identique (668 vs 637 W).
Écart spectaculaire — **mais l'enveloppe solaire passe elle-même de 0,17 kWh à
07 h à 1,03 kWh à 08 h**, soit un facteur 6 qui explique l'essentiel. La mesure
est donc confondue par le lever du soleil et **ne prouve rien**. Ne pas décider
d'une mise à jour firmware sur cette base. Test propre à faire : fermer EDF
10 min en milieu de journée, batterie non pleine, et comparer.
### Test contrôlé EDF↔MPPT du 27/08 10:47-10:56 (relevé : `docs/mesures/20260827-test-edf-mppt.log`)
Protocole : 2 min de référence, hystérésis désactivée, contacteur EDF fermé
5 min, puis restauration complète (EDF ouvert, latch remis à zéro, hystérésis
réactivée) et 2 min d'observation. Échantillon toutes les 15 s de PV, courant
batterie, conso, import réseau, **limite DVCC** et mode ACM. Conditions :
SOC 71 %, batterie absorbant +129 A, DVCC 130 A — donc MPPT avec un preneur.
**Ce que le test établit :**
1. **La limite DVCC est restée à 130 A d'un bout à l'autre** et le mode ACM à
`day_pv_boost`. Le bridage éventuel ne vient donc PAS de l'Auto Charge
Manager — hypothèse écartée.
2. **Aucun bridage du MPPT observé dans ces conditions** : PV moyenne 3260 W
pendant la fenêtre EDF ON contre 2568 W après la coupure. Le solaire
déclinait de lui-même (la référence est passée de 7708 à 4460 W en 105 s
AVANT toute action — passage nuageux). La comparaison avant/pendant est
donc inexploitable ; seule pendant/après l'est, et elle ne montre pas de
bridage.
3. **Découverte non cherchée, celle-là sans ambiguïté : EDF fermé fait importer
du réseau pour charger la batterie**, en rampe continue 0 → 983 → 2139 →
3335 → **4113 W** en 3 min 30, alors que la consommation maison n'était que
de ~880 W. Preuve directe : à la coupure, le courant batterie s'effondre de
**104,6 A à 25,7 A en 15 s** — ~80 A (≈ 4 kW) venaient donc du réseau, pas
du soleil. Les cinq programmations ESS étaient déjà désactivées depuis
~10 min : ce n'est pas elles. Le propriétaire de cet ordre reste à
identifier (`minimum SOC` de l'ESS n'est pas exposé côté HA).
**Ce que le test N'établit PAS.** Le propriétaire dispose de plusieurs mois
d'observation concordante d'un bridage du MPPT par la présence d'EDF. Ce test
ne le contredit pas : il ne reproduit pas sa condition. Avec la batterie à
71-75 % absorbant 130 A, le MPPT a toujours eu un preneur, donc rien ne pouvait
le censurer — c'est exactement l'équation de censure
`PV_mesurée = min(ressource, conso + absorption batterie)`. Le cas à tester est
**batterie pleine ou quasi pleine (SOC ≥ 90 %) avec EDF fermé** : là, le réseau
sert les charges, la batterie ne peut plus absorber, et la PV n'a plus aucun
débouché. Test à refaire dans ces conditions, 3 min suffisent.
**Conséquence immédiate, indépendante de la question du bridage** : chaque
minute d'EDF fermé coûte de l'énergie réseau qui part dans la batterie à
plusieurs kW, sans que personne ne l'ait demandé. C'est l'explication mécanique
des nuits où la batterie finit à 96 % sur réseau (incident du 09/07), et un
argument de plus pour la « session réseau unique et dimensionnée ».
**Aucune mise à jour firmware n'est justifiée par ces mesures.**
### PREUVE (27/08 11:38-11:51) : la présence d'EDF substitue le réseau au solaire
Relevé : `docs/mesures/20260827-test-edf-mppt-alterne.log`. Protocole alterné
**OFF 3 min / ON 3 min / OFF 3 min / ON 3 min**, échantillon 15 s, avec cette
fois **la tension batterie et l'état des chargeurs MPPT**
(`sensor.*_mppt_operation_mode`, `*_pv_tracker_solar_N_operation_mode`) — des
capteurs qu'aucun workflow ne lisait et qui disent explicitement si un chargeur
cherche son point de puissance maximale (`mppt_active`) ou se retient
(`voltage_current_limited`).
| Phase | PV moyen | Réseau | Conso |
|---|---:|---:|---:|
| OFF_1 | **3 858 W** | 0 | 2 223 W |
| ON_1 (établi) | **1 112 W** | 2 287 W | 2 258 W |
| OFF_2 | **4 025 W** | 0 | 2 226 W |
| ON_2 (établi) | **1 043 W** | 2 717 W | 2 590 W |
| FIN (OFF) | **4 358 W** | 0 | 2 249 W |
Transitions : bascule ~75-90 s après la fermeture du contacteur, retour en
~32 s après l'ouverture, **quatre fois de suite**. DVCC constant (110 A /
51,2 V), SOC 86→88, batterie loin de la saturation. La PV moyenne est plus
HAUTE à la fin du test qu'au début (4 358 contre 3 858) : aucune tendance
météo ne peut expliquer les creux. **L'hypothèse « nuages » du test précédent
est morte.**
Perte : ~2 900 W, soit **73 %** de la production, reprise par le réseau au watt
près, à consommation inchangée. Ce n'est PAS la censure normale par manque de
preneur (il y avait 2 250 W de charges et une batterie autorisée à 110 A, que
le solaire alimentait intégralement 90 s plus tôt) : **le réseau ne comble pas
un déficit, il prend la place.** Résidu pendant les phases ON remarquablement
constant (~1 040 W), ce qui ressemble à un plancher et non à une réduction
proportionnelle.
**Cadrage donné par le propriétaire, à respecter** : le bridage quand la
batterie se remplit — et même avant, pour limiter le courant de charge — est le
comportement NORMAL d'une installation en autoconsommation sans injection, il
le connaît, ce n'est pas le sujet. L'anomalie est la substitution ci-dessus,
apparue **depuis le passage des deux MultiPlus II en maître/esclave
(parallèle)**, avec plusieurs mois de recul.
**Piste (configuration avant firmware)** : un système parallèle ayant perdu son
comportement ESS retombe en passthrough + chargeur — le réseau alimente les
charges et charge la batterie, le solaire ne faisant plus que compléter, ce qui
est exactement le symptôme. À vérifier côté VEConfigure/console GX, non visible
depuis HA : (1) assistant ESS présent et identiquement configuré sur **les deux
unités** ; (2) point de consigne réseau (l'import se cale exactement sur les
charges) ; (3) affectation du compteur réseau après passage en parallèle.
**Conséquence chiffrée pour l'autonomie** : chaque heure de contacteur fermé
coûte ~2,3 kW de solaire gratuit remplacé par du payant, et cela **quel que
soit le niveau de la batterie** — à cumuler avec l'import de ~4 kW vers la
batterie observé au test de 10:47. Le dimensionnement des batteries prévu cet
hiver doit se faire APRÈS ce correctif, sous peine de dimensionner sur une
installation qui perd les trois quarts de sa production dès que le réseau est
présent.
### Réserve d'orage (déployé 27/08 12:04, mode ombre, tests 42/42)
Besoin : les coupures réseau d'orage sont fréquentes sur ce site, et le
propriétaire veut garder de quoi tenir jusqu'au retour du solaire.
**Point de départ, souvent mal posé** : une coupure n'arrête PAS
l'installation. Le solaire et la batterie continuent, seul le filet de secours
disparaît. La question n'est donc pas « combien stocker pour survivre » mais
« la batterie traverse-t-elle la prochaine fenêtre de déficit SANS réseau ».
C'est **exactement la couverture prudente (p80) que le bilan calcule déjà** :
aucune constante nouvelle. Le profil prudent est le bon choix ici précisément
parce qu'une coupure retire la possibilité de rattraper une sous-estimation.
Chaîne : `HA_SNAPSHOT_TEMPLATE` lit la vigilance **sans connaître la commune**
(`states.sensor | selectattr('entity_id','search','_weather_alert')`) →
`PREPARE_BALANCE` passe-plat → `BALANCE` calcule le bloc `storm` →
`BUILD_HA_CALLS` publie 5 helpers `evcs_balance_storm_*`.
Fenêtre retenue : en déficit, celle qu'on traverse (couverture prudente) ; en
surplus, la prochaine (`nextDeficit`) — car à 15 h la réponse « rien à couvrir »
est exacte et inutile, le risque étant la nuit qui vient.
Seuil d'activation : **vigilance orange**, c'est-à-dire l'échelle officielle de
Météo-France (vert/jaune/orange/rouge) et non un nombre choisi ici.
Helpers (package `evcs_energy_balance.yaml`, sauvegarde
`*.bak.20260827`, rechargés et vérifiés) : `evcs_balance_storm_level` ·
`_storm_active` · `_storm_reserve_kwh` · `_storm_reserve_soc` (borné à 100 pour
la plage de l'entrée) · `_storm_reason`.
Tests 38 → 42. Deux résultats notables figés :
- 24/08 06:43, SOC 21 % : réserve 2,42 kWh (SOC 38,1 %) → **manque 2,57 kWh**
pour tenir la matinée sans réseau ;
- 24/08 15:30, SOC 95 %, vigilance rouge : la fenêtre qui compte devient celle
de 19 h, 12,77 kWh, **SOC requis 107,1 %** — même pleine, la batterie
actuelle ne passe pas la nuit sans réseau. Valeur volontairement NON écrêtée :
c'est le signal de dimensionnement pour l'achat de batteries de cet hiver.
Garantie mode ombre étendue : le fixture synthétique `BALANCE_OK` de
`scenarios-shadow.mjs` ne contenait pas `storm`, donc les nouvelles écritures
n'étaient PAS couvertes par le test de périmètre. Corrigé, plus une assertion
explicite `wroteStorm: 5`. Preuve par mutation : une écriture détournée vers
`input_boolean.victron_evcs_night_grid_takeover_needed` fait échouer la suite
(`outOfScope` la nomme).
**RESTE À FAIRE — action utilisateur** : l'intégration **Météo-France n'est pas
installée** (HA n'a que `met` / Met.no). Elle est gratuite, sans clé d'API,
intégrée au cœur de HA. Le flux de configuration a été testé par API : il
réclame un **nom de commune** et refuse les coordonnées (`{"city":"empty"}` sur
48.7732657 / -3.5187179) — donc à ajouter par l'interface, ou à me donner la
commune. Tant qu'elle est absente, le bilan publie `indisponible`, `active`
reste faux, et la réserve est quand même chiffrée : **rien ne casse**.
### Historique HA plus court que configuré
`configuration.yaml` déclare `purge_keep_days: 90`, mais l'historique brut
s'arrête au **20/08** (~7 jours) : testé vide au 01/06, 29/07, 10/08, 17/08 ;
1 point au 20/08, 7 au 24/08. Base recréée, ou purge qui ne respecte pas la
consigne — à investiguer. Les statistiques long terme (horaires, conservées
indéfiniment) sont un stock distinct et probablement intactes.
## Diagnostic en attente de vérification (Qwen, 22/08)
Voici mon analyse et plan de correction :
Diagnostic
Le bug est dans HA_SERVICE_CALLS_OUTPUT.js:987-996 :
edf_general_ok_on: {
execute: ['grid_forced', 'grid_hc_allowed'].includes(context.sourceMode) && !parsed.gridAvailable,
...
}
Il n'existe pas de edf_general_ok_off. Le workflow ne coupe JAMAIS activement EDF. Il ne fait que le rallumer quand sourceMode est grid ET que le réseau n'est pas encore disponible. Quand sourceMode revient à solar_only/blocked, le switch reste ON dans l'état qu'il avait.
Conséquence : après la fenêtre HC nocturne (~06:00), EDF reste allumé. La présence du réseau bloque la production PV (Victron plafonne à ~100 W). Le débranchement du véhicule résout parce que c'est le seul mécanisme qui coupe physiquement le switch.
Plan de correction
Fichier : src/nodes/HA_SERVICE_CALLS_OUTPUT.js
Ajouter un call edf_general_ok_off juste après edf_general_ok_on (ligne 996) :
edf_general_ok_off: {
execute:
!['grid_forced', 'grid_hc_allowed'].includes(context.sourceMode) &&
parsed.gridAvailable &parsed.evcsVehicle
**Verdict (Claude, 22/08) : diagnostic ÉCARTÉ, voir §24 ci-dessous.** Le
symptôme est correctement identifié (EDF ne se coupe jamais activement après
la fenêtre HC) mais la cause et le correctif proposés sont faux : le bug n'est
pas dans `HA_SERVICE_CALLS_OUTPUT.js` (qui n'a jamais eu vocation à couper EDF
— la coupure est le rôle exclusif du workflow séparé "Victron - Battery / Grid
Hysteresis Fix V1"), et ajouter un `edf_general_ok_off` côté EVCS créerait un
deuxième écrivain non coordonné sur `switch.edf_general_ok_switch` (courses
30s/1min entre les deux workflows, le hysteresis reprenant la main au cycle
suivant via son propre `latchIsOn` et rallumant probablement EDF quand même).
Le vrai bug et son correctif (Fix G, côté hystérésis) sont documentés en §24.
---
*Document mis à jour le : 22/08/2026 (~18:00) — Fix G déployé (hystérésis), diagnostic Qwen écarté, golden-hysteresis 7/7*
## 24. Session du 22/08/2026 — Fix G : coupure EDF quand l'override EVCS disparaît mi-session (hystérésis)
### Incident (nuit 21-22/08)
Le takeover nuit (P7) a correctement rechargé la batterie à ~35 % via EDF.
Quand la fenêtre HC s'est fermée (~06:00-07:24) avec le véhicule **encore
branché en session active** (pas encore flag "charge terminée"), EDF est resté
allumé — le réseau a plafonné le MPPT Victron à ~100 W, privant la maison de
600-4000 W de solaire disponible (elle tirait ~1000 W d'EDF). Seul le
débranchement du véhicule a libéré EDF (2179 W de PV immédiatement après).
### Root cause
Côté EVCS (`WINDOWS_SOURCEV2.js`/`DECISION_ENGINEV2.js`) : **pas le bug**.
Chaque cycle, `nightSessionActive` et tout le moteur nuit sont activement
recalculés à `false`/`'blocked'` dès la fermeture de fenêtre — `helper
night_grid_takeover_needed` passe à `off` et `source_mode` à `'Bloqué'`
correctement et immédiatement, même mi-session. MAIS les deux branches
"collantes" de fin de session (Fix F, exige `profile === 'night_hc'` ; P10
`day_vehicle_full_idle`, exige que le `surplus_state` précédent soit déjà un
pattern "terminé") ne se déclenchent PAS sur le cycle exact où la fenêtre se
ferme mi-session non-terminée. Le système retombe sur des états génériques
transitoires (`stop_source_lost` puis `idle_wait_source`).
Côté **Grid Hysteresis** (`workflows/victron-grid-hysteresis-v1.json`, node
"Compute Grid Hysteresis") : **c'est là le bug**. Les règles de coupure
immédiate existantes (Fix C `turn_off_vehicle_unplugged`, Fix E2
`turn_off_recovery_done`) ne matchent que des noms exacts de `surplus_state`
(`vehicle_not_connected`, `night_recovery_done_idle`, `departure_done_idle`) —
aucune ne matche `stop_source_lost`/`idle_wait_source` (ni `day_vehicle_full_idle`
lui-même). Sans matche, le code retombe sur la boucle générique qui maintient
EDF jusqu'au seuil statique `grid_soc_off` (35 %, `input_number.victron_grid_soc_off`),
totalement découplé de la cible dynamique de l'EVCS — potentiellement des
heures, d'autant qu'EDF-ON lui-même bride la recharge solaire organique.
### Fix G (déployé 22/08 ~18:00, golden-hysteresis 7/7)
Au lieu d'ajouter encore un nom d'état à une liste blanche fragile, nouvelle
règle **générique** dans "Compute Grid Hysteresis", basée sur `evcsGridOverride`
(déjà fiable) plutôt que sur le nom de `surplus_state` — couvre donc aussi
automatiquement tout futur nom d'état non énuméré :
```javascript
if (
!forcedCut && latchIsOn && gridIsOn &&
!evcsGridOverride &&
soc > gridSocOn && // 22%, PAS gridSocOff 35% — protège le cas
// indépendant "SOC bas" qui doit tenir
// jusqu'à gridSocOff
evcsSourceModeLooksValid // garde anti-glitch (lecture HA vide/
// unavailable/unknown → pas d'action)
) {
desiredGrid = 'off'; desiredLatch = 'off';
state = 'turn_off_evcs_override_released';
forcedCut = true;
}
```
Coupure immédiate, bypass min_on (25 min), comme Fix C/E2. Décision validée
avec l'utilisateur : vitesse de libération prioritaire sur le risque résiduel
(un glitch de lecture HA sur un seul cycle pourrait causer une coupure/
rallumage isolé, auto-diagnosticable dans le logbook — pas de chatter soutenu
car il faudrait une lecture fausse à *chaque* cycle).
### Nouvelle suite de tests : `tests/golden-hysteresis/` (7 scénarios, première
suite pour ce workflow — 0 → 7)
`g-incident-gap-mid-session`/`g-incident-gap-idle-wait-source` (les deux noms
d'état zombies de l'incident, min_on non écoulé) · `g-incident-replay-hc-boundary`
(rejeu complet, min_on déjà écoulé aussi — prouve que ce n'est pas seulement un
bypass min_on) · `g-baseline-low-soc-no-override` (garde non-régression : SOC
bas indépendant doit continuer à tenir) · `g-regression-fixc-unplugged` /
`g-regression-fixe2-recovery-done` (Fix C/E2 toujours prioritaires, pas
absorbés par Fix G) · `g-glitch-guard-unavailable` (garde anti-glitch).
Lancée AVANT patch : 3 échecs (les scénarios d'incident, stuck sur
`hold_on_min_runtime`/`hold_on_until_soc_off`) + 4 succès (gardes déjà bonnes)
— confirme que le harnais détecte vraiment le bug. Après patch : 7/7.
Câblée dans `tools/n8n-push.mjs` (nouveau `isHysteresisWorkflow`), bloquante
au push comme les 2 autres workflows.
### Outillage
`tools/patch-20260822-g-override-released-cut.mjs` (rejouable, même patron que
`patch-20260809-e2-recovery-done-cut.mjs`) · `tests/golden-hysteresis/{fixture-snapshot.json,run.mjs,scenarios.mjs}`.
### Déploiement
`node tools/n8n-push.mjs workflows/victron-grid-hysteresis-v1.json` → backup
serveur auto (`modified/prepush-7iIlu7LLTW7TZA8o-*.json`) → syntaxe OK →
golden-hysteresis 7/7 → PUT OK. `grid_min_on_minutes` réel vérifié via MCP
`ha-get-state` avant de figer le fixture : **25** (pas le fallback 45 du code).
### Hors périmètre (volontairement)
Aucun changement côté `src/nodes/*` (EVCS V2.6) — le fix est entièrement côté
hystérésis. Amélioration cosmétique future possible, non incluse ici :
remplacer les noms zombies `stop_source_lost`/`idle_wait_source` par un état de
transition dédié pour la lisibilité dashboard/logbook.
### À surveiller (prochaine fermeture de fenêtre HC avec véhicule encore
branché mi-session)
1. Logbook doit montrer `turn_off_evcs_override_released` peu après la
fermeture de fenêtre si le véhicule n'est pas encore flag "terminé" ;
2. La PV doit remonter (MPPT non-plafonné) immédiatement après ;
3. Aucune coupure/rallumage répété (`turn_off_evcs_override_released` suivi de
près par `turn_on_evcs_override` répétitivement) — signe d'un glitch de
lecture HA à investiguer si observé.
---
*Document mis à jour le : 22/08/2026 (~18:05) — Fix G déployé et validé (syntaxe + golden-hysteresis 7/7 + PUT OK), à confirmer en conditions réelles à la prochaine fermeture de fenêtre HC mi-session
## 25. Session du 24/08/2026 — Fix G retiré + diagnostic des deux oscillateurs
### Fix G retiré de la production (déployé 24/08 12:18, golden-hysteresis 7/7)
Fix G (§24) a produit en production un yoyo EDF de 2-4 min la nuit du 23-24/08,
véhicule débranché : coupure à SOC 21 % (Fix G, bypass min_on) puis rallumage à
SOC 20 % (branche `hold_on_low_soc`, bypass cooldown), MPPT qui flappe à chaque
bascule. Logbook : `turn_off_evcs_override_released` 06:43:03 → `hold_on_low_soc`
06:45:03 → `turn_on_low_soc_cooldown_bypassed` 06:52:03.
**Erreur de conception** : le garde `soc > gridSocOn` ne protégeait que l'INSTANT
de l'engagement sur SOC bas (`soc <= 20`), pas la PHASE DE REMONTÉE 20 → 30 qui
est 100 % de la durée de vie de ce cas. Fix G a donc remplacé l'hystérésis 20/30
par une hystérésis 20/20,1.
**Erreur de test** : le fixture `tests/golden-hysteresis/` portait `grid_soc_on:
"22"` / `grid_soc_off: "35"` — les *valeurs de repli du code*, pas la production
(vérifiée via MCP : **20 / 30**, inchangées depuis le 07/08). Et le scénario de
non-régression `g-baseline-low-soc-no-override` utilisait SOC 18 %, c'est-à-dire
le seul point que Fix G épargnait. → **Règle : figer un fixture de test sur des
valeurs lues en live via MCP, jamais sur les fallbacks du code.**
Retrait : `tools/patch-20260824-revert-g.mjs` (rejouable) — le jsCode résultant
est byte-identique à la version pré-Fix-G (commit `0419c74`), vérifié.
Suite réécrite (7 scénarios, seuils réels 20/30) avec les garde-fous manquants
`h-recovery-band-no-cut-21` / `-25` : la remontée batterie sur réseau ne doit
JAMAIS être interrompue avant `grid_soc_off`. Test de mutation : Fix G réappliqué
→ 4/7 échecs, retiré → 7/7. Le filet attrape désormais la régression.
### Diagnostic : deux oscillateurs, même pathologie
Détail complet dans `docs/MANUEL_COMPLET.md` §11.8. Résumé :
**A — Yoyo EDF.** Le problème de fond n'est pas le réglage de `grid_soc_off`
mais le fait qu'il n'a aucun sens physique : le 24/08, la PV ne couvre la conso
maison qu'à partir de ~09:00 (0,07 → 0,63 → 2,74 kWh/h contre 0,75 kWh/h de
conso). Couper EDF à 06:43 à 21 % rend le yoyo inévitable quels que soient les
seuils. Le workflow hystérésis lit 25 champs et **aucune prévision solaire**.
Cible : session réseau unique dimensionnée jusqu'au croisement PV/conso,
arbitrée par le conseiller IA. NB : mesurer la vraie capacité PV impose de
couper EDF (sinon le MPPT est bridé et on ne voit rien).
**B — Oscillation charge EVCS en plein soleil** (24/08 12:25 → 12:28).
Ce n'est PAS une limite 5 kVA (aucun `ve_bus_overload` depuis le 20/08,
`ac_loads_max_safe_w` = 9000, AC-out L1 à 5335 W sans overload, plafond calculé
~20 A) et **pas non plus une bande morte batterie trop étroite** : le courant
batterie était à **+127 A** au moment de l'arrêt. Les deux atténuations que
j'avais d'abord proposées (`battery_discharge_soft_a` à −12 A,
`battery_neutral_band_a` à 3-5 A) n'avaient donc **aucun effet sur ce cas** —
l'utilisateur l'a signalé, à raison, avant que je les applique.
Cause réelle : **inversion du plancher de démarrage et du plancher de maintien**.
À 12:24:00 le profil bascule vers `midday_hc_or_solar` ; avec SOC 58 % le
plancher de démarrage vaut `min(midday_soc_min 75, abondance P6 40)` = **40**
et le plancher de maintien `max(soc_stop_min 29, 75-15)` = **60**. Le système a
le droit de DÉMARRER ce qu'il n'a pas le droit de MAINTENIR → cycle limite à la
période de `min_run_minutes` (3 min) sur toute la bande [40 %, 60 %], et ~2,6 kW
de PV écrêtés à chaque creux (PV 10,24 kW → 7,64 kW quand la borne s'arrête).
### Décisions de conception validées avec l'utilisateur
- La conso se **lit**, elle ne se devine pas ; filtrage numérique dynamique des
pointes (improbables la nuit hors charge EVCS ; pas de moyenne quand aucun
véhicule n'est branché).
- SOC : lissage pour les décisions de confort, **valeur instantanée conservée
pour la sécurité** (le risque prime).
- Les seuils fixes doivent devenir dynamiques (saison, prévision) et être
arbitrés par le conseiller IA : « un automatisme c'est trop rigide pour
traiter tous ces cas tordus ». Ne plus empiler de branches codées en dur.
- Exploiter les messages d'état réels du Victron et de la borne au lieu de
s'auto-limiter à l'aveugle sur un proxy.
---
### Correctif déployé 24/08 12:45 — invariant maintien <= démarrage
`src/nodes/BATTERY_CLOUD_LOCKS.js`, deux volets indissociables :
1. `effectiveSocHoldFloor = min(règle du profil, plancher de démarrage effectif)`
— on ne peut plus être autorisé à démarrer ce qu'on n'est pas autorisé à
maintenir. Sans abondance, comportement inchangé.
2. L'abondance P6 se PROUVE au démarrage (`solarAbundanceStart` : pvAvg >= 4000
ET batterie >= +20 A) mais se MAINTIENT en charge (`solarAbundanceHold` :
pvAvg >= 4000 ET batterie >= 0). Sans ce dédoublement, la charge du véhicule
détourne le surplus, fait tomber le courant batterie sous +20 A et détruit sa
propre condition d'autorisation — l'oscillation reviendrait dès que le PV
n'est que moyennement fort.
Golden 39 → 40 : `midday-inversion-start-hold` (relevé VRM du 24/08 12:25 :
PV 10,24 kW, charges 3815 W dont EVCS 3502 W, batterie +127 A, SOC 58 %).
Preuve par mutation : code d'avant → `stop` / « Conditions de maintien perdues » ;
après → `charging_step_up_battery` à 19 A. Les 39 scénarios existants inchangés.
**Leçon transverse** : trois oscillateurs identifiés le même jour (Fix G,
abondance P6 auto-destructrice, inversion démarrage/maintien) — même pathologie
à chaque fois, des seuils empilés par couches successives, chacun raisonnable
isolément, dont personne ne vérifie la cohérence d'ensemble. D'où le chantier
du bilan énergétique partagé plutôt qu'un garde de plus.
---
*Document mis à jour le : 24/08/2026 (~12:50) — Fix G retiré (golden-hysteresis 7/7, seuils réels 20/30), inversion démarrage/maintien corrigée (golden 40/40), diagnostic des deux oscillateurs*
## 27. Session du 30/08/2026 — Deux propriétaires GX de la décision réseau, jamais lus
Le propriétaire demandait une procédure détaillée avant d'intervenir sur les
deux MultiPlus-II en parallèle (piste VEConfigure de la §26). Les lectures
préalables côté GX déplacent la priorité : **il est probable qu'aucune
intervention VEConfigure ne soit nécessaire.**
Procédure complète : `docs/PROCEDURE_ESS_PARALLELE.md`.
### Trouvaille 1 — Consigne de puissance réseau à 4200 W (défaut : 50 W)
`number.gx_device_ac_power_setpoint` = **4200 W**, miroir per-phase
`number.airtria_mpii_v1_ac_power_setpoint_l1` = 4200 W. C'est le *Grid setpoint*
ESS (`/Settings/CGwacs/AcPowerSetPoint`) : la puissance que le système
s'efforce en permanence de **prélever sur le réseau** quand AC-IN est présent.
L'ESS régulant la puissance au point de raccordement, le seul levier pour faire
place à cet import quand maison et batterie sont déjà servies est de **brider
les MPPT via DVCC**. C'est le mécanisme exact de la substitution prouvée le
27/08, et l'explication du résidu constant (~1040 W).
Les deux campagnes se réconcilient arithmétiquement :
- 10:47, SOC 71 %, conso 877 W → import monte à **4113 W** ≈ consigne ;
- 11:38, SOC 86 %, batterie en absorption n'acceptant que ~1070 W →
`conso 2258 + batterie 1070 − PV 1043 = 2285 W` contre **2287 W** mesurés.
Valeur inchangée sur tout l'historique disponible (7 j) ; `user_id: null` sur le
dernier changement = simple recréation d'entité au redémarrage HA du 24/08.
Aucun code du dépôt n'écrit cette entité (grep). Hypothèse la plus probable :
ancien contournement manuel, même famille que les charges programmées ESS
désactivées le 27/08. **À confirmer sur l'écran du GX avant de conclure** — ne
pas se fier au seul nom de l'entité HA.
Correction proposée : **50 W** (défaut Victron, légèrement positif pour ne
jamais injecter). Réversible en dix secondes, sans VEConfigure.
### Trouvaille 2 — SOC minimal ESS à 40 %, au-dessus de notre cible de nuit
`number.gx_device_ess_min_soc_limit` = 40 %, `sensor.gx_device_active_soc_limit`
= 40 % (BatteryLife ne fait donc pas dériver la limite au-dessus).
Sous 40 %, l'ESS recharge la batterie sur le réseau **de sa propre initiative**.
Ce n'est pas théorique : `sensor.gx_device_ess_batterylife_state` passe à
`recharge` les 29/08 04:08 et 30/08 01:21, précédé de
`soc_below_battery_life_dynamic_soc_limit`.
Or le contrôleur vise **35 %** le matin en été : le nôtre relâche à 35, le GX
reprend à 40. **Troisième automate contradictoire sur la même batterie**, après
les charges programmées. Arbitrage à rendre avec le propriétaire — piste
préférée : laisser ce plancher comme filet de sécurité bas (~20 %) et confier la
vraie cible au bilan énergétique. Rien changé sans son accord.
### Trouvaille 3 — L'assistant ESS tourne, aucune erreur VE.Bus
États BatteryLife vivants et cohérents → la boucle ESS fonctionne, elle n'est
pas absente. Toutes les alarmes VE.Bus à `no_alarm`, aucune erreur
d'installation. L'hypothèse « assistant perdu ou dépareillé entre les deux
unités après le passage en parallèle » est **affaiblie** sans être éliminée (un
assistant présent mais mal paramétré ne lève aucune alarme) — d'où le maintien
de la partie VEConfigure en seconde intention dans la procédure.
### Méthode retenue dans la procédure
Étapes classées par risque croissant : lectures gratuites → réglages GX
réversibles → **rejeu du protocole alterné du 27/08** comme critère objectif →
VEConfigure seulement en cas d'échec, avec sauvegarde `.rvms` obligatoire, une
correction à la fois, et interdiction absolue de relancer le VE.Bus System
Configurator. Firmware : toujours pas justifié par les mesures, et à ne pas
mêler au diagnostic.
---
*Document mis à jour le : 30/08/2026 — consigne réseau 4200 W et SOC minimal ESS 40 % identifiés comme propriétaires non lus de la décision réseau ; procédure d'intervention rédigée*
## 28. Session du 31/08/2026 — La substitution ne se reproduit plus (batterie preneuse). Cas « batterie pleine » encore ouvert
Première vérification de la correction de la consigne réseau (4200 → 50 W,
§27), sur une nuit complète puis deux campagnes alternées.
### La nuit 30-31/08 : l'import colle à la consommation
Une seule session réseau, `switch.edf_general_ok_switch` ON à **05:04:10**
(hystérésis, SOC 21 ≤ seuil 20) et OFF à **10:08:04** (SOC 30 ≥ seuil OFF).
Aucun rebond, aucun créneau.
| heure | import réseau | conso maison | courant batterie |
|---|---:|---:|---:|
| 05:15 | 780 W | 732 W | 0 A |
| 06:30 | 507 W | 477 W | −1 A |
| 07:45 | 781 W | 751 W | +4 A |
À comparer au test du 27/08 10:47 où, pour 877 W de consommation, l'import
montait en rampe jusqu'à **4113 W** pour gaver la batterie. Le comportement a
disparu. Détail de la fermeture : de 05:04 à 05:13 l'import est resté à
**24-56 W** (la consigne) pendant que la batterie continuait de servir la maison
à −15 A ; à 05:14, SOC atteignant le plancher ESS de 20 %, l'ESS a basculé —
import = charge maison, batterie à 0 A, SOC plat à 20 % jusqu'à 08:00. Manuel.
### Le matin : réseau présent, production libre
De 08:40 à la coupure de 10:08, contacteur toujours fermé :
| heure | PV | import | conso | I batterie |
|---|---:|---:|---:|---:|
| 08:45 | 1 303 W | **18 W** | 1 109 W | +2 A |
| 09:25 | 3 425 W | **39 W** | 805 W | +50 A |
| 10:05 | **5 244 W** | **47 W** | 952 W | +83 A |
`sensor.mppt_450_200_hq2449yr9pt_mppt_operation_mode` est resté `mppt_active`
sur toute la fenêtre : le chargeur déclare lui-même chercher son point de
puissance maximale pendant que le réseau est présent.
### Campagnes alternées (`tools/test-edf-alterne.mjs`)
Journaux : `docs/mesures/20260831-1043-test-edf-alterne.log` (phases 180 s) et
`20260831-1101-test-edf-alterne.log` (phases 240 s).
Transitions exploitables, chaque phase fermée contre ses voisines ouvertes :
| phase | PV fermée | PV voisines ouvertes | écart | trackers bridés |
|---|---:|---:|---:|---:|
| 10:47 ON_1 | 7 168 W | 7 131 W | **+0,5 %** | 2,7/7 (DVCC, I=124 A) |
| 11:13 ON_2 | 7 920 W (max 8 759) | 7 002 W | **+13,1 %** | 4,5/7 (DVCC, I=125 A) |
Le 27/08, la même manœuvre faisait tomber la PV de 3 885 à 1 045 W en 90 s avec
six trackers sur sept en `voltage_current_limited`. **La substitution ne se
reproduit plus à batterie preneuse.** Le bridage résiduel observé est le bridage
NORMAL : il apparaît quand le courant batterie approche la limite DVCC (125 sur
130 A) et il s'accompagne d'un import réseau nul — critère de discrimination
utilisable : bridage normal ⇒ import nul ; bridage anormal ⇒ l'import remplace
la production.
### Mécanisme identifié : un transitoire de reprise, qui retombe à la consigne
À chaque fermeture du contacteur, le réseau reprend d'un coup **toute** la charge
de la maison et la totalité du solaire bascule vers la batterie. Bilan vérifié au
watt près (10:48:15 : PV 7222 + grid 2120 − conso 2249 = 7093 W contre 7107 W de
charge batterie mesurée). Puis l'import **décroît jusqu'à la consigne** :
| campagne | import à la fermeture | après ~2 min 15 |
|---|---:|---:|
| 11:05 ON_1 | 1 985 W | **25 W**, stable |
| 11:13 ON_2 | 2 600 W | **52 W** puis −41 W |
Coût ≈ 50-60 Wh par fermeture. Avec l'ancienne consigne à 4200 W, cette reprise
n'avait aucune raison de redescendre : elle se stabilisait sur « le réseau
alimente tout, on bride le solaire pour faire de la place ». C'est
vraisemblablement le même mécanisme, dont la correction de consigne a rétabli
l'extinction. Corollaire : chaque fermeture coûte, ce qui plaide pour une
session réseau unique et dimensionnée plutôt que des créneaux répétés.
Note : aucun transitoire à la fermeture de 05:04 (PV nulle) — il n'y a rien à
rediriger quand il n'y a pas de solaire.
### CE QUI N'EST TOUJOURS PAS TESTÉ — batterie pleine (à faire)
Tout ce qui précède s'est mesuré à SOC 40-54 %, batterie absorbant 60 à 125 A.
Le propriétaire maintient, sur plusieurs mois d'observation, que le cas décisif
est **batterie pleine** : plus de tampon, donc le solaire qui alimentait la
maison n'a plus de destination quand le réseau la reprend. Discrimination
attendue, contacteur fermé, maison ~2 kW, SOC ≥ 90 % :
| observation | conclusion |
|---|---|
| PV ≈ conso (~2 kW), import ≈ 50 W | corrigé — écrêtage normal, le solaire sert la maison |
| PV ≈ 0-1 kW, import ≈ conso | non corrigé — éviction ; VEConfigure (§27, `docs/PROCEDURE_ESS_PARALLELE.md` étape 1) |
Le 27/08 à 11:38, SOC 86-88 %, batterie n'acceptant plus que ~1 kW, on avait
PV 1043 W pour une maison à 2258 W et un import de 2287 W : le solaire SOUS la
consommation et le réseau AU-DESSUS. C'est le point le plus proche du cas
décisif dont on dispose, et il est du mauvais côté. Trois minutes de protocole
alterné à SOC ≥ 90 suffiront à trancher.
### Outillage ajouté
- `tools/test-edf-alterne.mjs` — protocole alterné OFF/ON/OFF/ON/OFF, phases et
pas d'échantillonnage paramétrables. Mesure PV, tension et courant batterie,
conso, import, SOC, limite DVCC, présence physique AC-IN et le mode des 7
chargeurs solaires. **Désactive l'hystérésis pendant le test et restaure tout
à la fin, sur Ctrl-C et sur erreur.** Journal horodaté dans `docs/mesures/`.
- `tools/analyse-test-alterne.mjs` — résumé par phase et verdict.
Deux pièges d'instrumentation corrigés en cours de route, à ne pas réintroduire :
1. **Garde météo.** Les phases OUVERTES voient toutes la même ressource : si
elles divergent entre elles (33 % et 42 % sur les deux campagnes du 31/08),
la moyenne ON contre OFF est ininterprétable. L'outil refuse désormais de
conclure dans ce cas et compare chaque phase fermée à ses voisines
immédiates. C'est ce piège qui avait pollué le premier test du 27/08.
Contrôle interne utile : si la PV chute AVANT l'apparition de l'import, ce
sont des nuages (cas de la phase ON_2 du 31/08 10:53, chute 6995 → 3607 W
avec l'import encore à zéro).
2. **Biais du parseur.** La lecture des modes MPPT était ancrée sur `MPPT `, ce
qui faisait tomber en silence toute ligne dont le PREMIER tracker était bridé
— précisément les échantillons à mesurer — et biaisait le résultat vers
« aucun bridage ». Le bloc de modes est le dernier champ : l'ancrer sur
`edf on|off`, jamais sur le mot `MPPT`.
### Signal indépendant : la jauge de SOC s'effondre en bas d'échelle
Entre 04:00 et 04:20, le SOC est passé de **30 à 22 %** alors que la batterie ne
débitait que ~11 A (≈ 550 W), tension stable à 49 V. Huit points sur un parc de
~15 kWh représentent 1,2 kWh, soit plus de deux heures à ce débit — pas vingt
minutes. La jauge ne mesure plus, elle réestime. Comportement classique d'un
parc vieillissant près du genou de décharge, cohérent avec le module en panne
connu (7 modules actifs sur 8, cf. MANUEL §0).
Conséquence : tous les seuils des deux workflows (20, 30, 22, 35) sont exprimés
dans une grandeur non linéaire en bas d'échelle. À verser au chantier du bilan
énergétique ; ne pas « corriger » par un seuil de plus.
### LE CAS DÉCISIF, TESTÉ — batterie pleine (campagne 12:58, SOC 90-92 %)
Journal : `docs/mesures/20260831-1319-test-edf-alterne.log`. Conditions de
départ exactement celles que le propriétaire désignait : SOC 90 %, courant
batterie **+22,8 A**, DVCC retombée à 80 A, production déjà écrêtée à
3 691 W (2 301 W de maison + ~1,1 kW de batterie) alors que la même
installation produisait 9 112 W le matin. Plus de tampon.
**Le phénomène qu'il décrit se produit — et il se résorbe.** ON_2, le cas le
plus net :
| heure | PV | import | conso |
|---|---:|---:|---:|
| 13:11:57 | 2 663 W | 0 | 1 899 W |
| **13:12:12** | **559 W** | **1 777 W** | 1 886 W |
| 13:12:57 | 1 405 W | 1 136 W | 1 877 W |
| 13:13:27 | 1 912 W | 683 W | 1 891 W |
| 13:14:27 | 3 008 W | 258 W | 2 362 W |
| 13:14:42 | **3 158 W** | **112 W** | 2 373 W |
Effondrement de **79 %** en un seul pas de mesure, simultané à l'apparition de
l'import (et non avant, contrairement aux passages nuageux du matin : le
critère de discrimination fonctionne). Puis récupération en miroir, watt pour
watt, sur ~2 min 30 : l'import redescend à 112 W et la production revient à
3 158 W, **au-dessus** de son niveau d'avant fermeture. Même séquence en ON_1
(3 369 → 1 890 W avec import 2 083 W, retour à 3 315 W avec import 8 W).
Une fois le transitoire exclu (165 s), l'état établi ne montre aucune
substitution : **−7,0 %** et **+2,0 %** contre les phases ouvertes voisines,
import résiduel 443 et 479 W et toujours décroissant. Les phases ouvertes
déclinent d'elles-mêmes (3 505 → 2 808 → 2 488 W, milieu d'après-midi), ce qui
explique le −7 %.
**Conclusion.** L'observation de plusieurs mois du propriétaire est exacte : à
batterie pleine, fermer le contacteur tue la production. Ce qui était faux,
c'est la permanence. Le mécanisme est le même transitoire de reprise qu'à
batterie preneuse — le réseau reprend la maison, le solaire cherche une
destination — sauf que sans tampon la seule variable d'ajustement est le
bridage des MPPT, donc l'effet devient spectaculaire au lieu d'être invisible.
Avec l'ancienne consigne à 4200 W, ce transitoire n'avait aucune raison de
se terminer : le régulateur ne pouvait atteindre sa cible qu'en maintenant le
solaire bridé. **C'est l'explication complète de « pas de grid sinon le
solaire ne sert à rien », et la correction de consigne y met fin.**
Coût par fermeture du contacteur, à batterie pleine : ~50 Wh d'import
transitoire **plus** ~100 Wh de production perdue pendant les 2 min 30 de
bridage, soit environ **150 Wh**. À batterie preneuse, seulement les ~50 Wh.
C'est l'argument chiffré en faveur d'une **session réseau unique et
dimensionnée** plutôt que de créneaux répétés : chaque ouverture-fermeture se
paie, et se paie trois fois plus cher quand la batterie est pleine.
Ce qui reste vrai et inchangé : à batterie pleine et régime établi, la
production est écrêtée à la consommation (6 à 7 trackers sur 7 en
`voltage_current_limited` dans TOUTES les phases, ouvertes comme fermées).
C'est le bridage normal d'une installation sans injection, il ne dépend pas du
réseau, et il ne doit pas être confondu avec l'éviction.
**Aucune intervention VEConfigure ni firmware n'est justifiée par ces mesures.**
L'étape 1 de `docs/PROCEDURE_ESS_PARALLELE.md` reste en réserve, sans objet
actuel.
---
*Document mis à jour le : 31/08/2026 (~13:30) — cas batterie pleine testé : l'effondrement existe mais dure 2 min 30 et se résorbe entierement ; la substitution permanente est morte avec la consigne a 4200 W ; ni VEConfigure ni firmware justifies*
## 29. Session du 31/08/2026 (suite) — Fenêtre d'établissement du réseau
### Le défaut
Mesure du 31/08 (§28) : à la fermeture du contacteur, le réseau reprend d'un
coup toute la charge de la maison et le solaire bascule vers la batterie — ou se
fait brider s'il n'y a plus de tampon (2 663 → 559 W). L'import redescend
ensuite à la consigne en **~150 s**, deux fois mesuré (1 985 → 25 W, 2 600 → 52 W).
Pendant cette phase, PV, import et courant batterie ne décrivent **aucun régime
permanent**. Or la boucle EVCS tourne toutes les 30 s : elle y prend cinq
décisions. Pire, `INPUT_PARSE` lit `pv_power_10m_avg_w`, une moyenne glissante
de dix minutes : un creux de 2,5 kW pendant 2 min 30 la tire d'environ 600 W et
l'effet persiste une dizaine de minutes après le retour à la normale. Les seuils
de démarrage et de maintien solaire se jouent dans cette plage.
Boucle de rattrapage qui en résulte : réseau présent → le système lit « plus de
soleil » → il conclut qu'il a besoin du réseau → il le garde. La conclusion
confirme sa prémisse. **Quatrième oscillateur**, même famille que les trois du
24/08.
### Le correctif
Aucun seuil nouveau : le système publiait déjà l'information et personne ne la
lisait — l'horodatage de la dernière transition du réseau.
- **Gabarit snapshot** : `acin1_last_changed_ts`, `grid_switch_last_changed_ts`
(`as_timestamp(...last_changed, 0)`, avec garde `is not none` pour une entité
absente), plus `grid_settle_seconds` (helper **optionnel**
`input_number.evcs_grid_settle_seconds` ; absent → 180 s).
⚠️ Un commentaire Jinja `{# #}` est **interdit** à l'intérieur de l'expression
`{{ {...} | tojson }}` : `TemplateSyntaxError: unexpected char '#'`.
- **INPUT_PARSE** : `parsed.gridTransitionAgeSec` = âge de la **plus récente**
des deux transitions (AC-IN physique et contacteur). Horodatage absent ou nul
→ `Infinity` → aucune fenêtre, comportement historique. On ne devine jamais
une transition qu'on n'a pas observée. `params.gridSettleSeconds` borné [0,900].
- **WINDOWS_SOURCEV2** : `derived.gridSettling = gridAvailable && age < seuil`.
- **BATTERY_CLOUD_LOCKS** : pendant la fenêtre, `rawSolarStartOk` est faux (on ne
démarre pas sur des mesures fausses), les deux termes de mesure de
`rawSolarHoldOk` (plancher de décharge, seuil PV) sont neutralisés (on ne laisse
pas une charge en cours s'arrêter à cause d'elles), `probeFailedNow` est
suspendu (le réseau masque la production, la sonde n'échoue pas) et
`solarShouldStepUp` est bloqué. **Les step-down ne sont pas bridés** : c'est la
direction sûre.
- **HA_SERVICE_CALLS_OUTPUT** : trace `ETABLISSEMENT reseau Xs/Ys` dans la raison,
sinon une lecture du logbook pendant le transitoire donne l'impression que le
système décide n'importe quoi — alors qu'il décide de ne pas décider.
### ASYMÉTRIE VOULUE (le point à ne pas casser)
La fenêtre ne s'ouvre qu'à l'**arrivée** du réseau. À sa disparition, les
onduleurs reprennent instantanément toute la charge : les gardes doivent agir
**plus vite, jamais moins vite**. `gridSettling` exige donc `parsed.gridAvailable`.
De même, la fenêtre suspend le **confort**, jamais le **risque** :
`commonSafety*`, planchers SOC, surcharge et arrêt dur batterie restent évalués
normalement — application directe de la règle « lissage pour le confort,
instantané pour la sécurité ».
### Golden 42 → 48
Chaque cas est apparié à son **contrefactuel hors fenêtre** ; sans cet
appariement le test ne prouverait rien (première rédaction : les trois scénarios
donnaient le même résultat, le maintien passant par la tolérance nuageuse).
| scénario | attendu |
|---|---|
| `etablissement-maintien-charge` (30 s, PV 600 W, batt −15 A) | `charging_step_down_battery` 14 A — continue |
| `etablissement-termine-arret-normal` (400 s, idem) | `stop_conditions_lost` |
| `etablissement-securite-non-suspendue` (30 s, batt −40 A) | `battery_current_hard_stop` |
| `etablissement-ouverture-aucune-fenetre` (réseau disparu 30 s) | `stop_conditions_lost` |
| `etablissement-pas-de-demarrage` (30 s, PV 4,5 kW) | `idle_wait_conditions` |
| `etablissement-demarrage-hors-fenetre` (400 s, idem) | `start_min_current` |
Les 42 scénarios existants sont **inchangés** : leur fixture ne porte pas les
horodatages, donc `Infinity` → aucune fenêtre. Le repli est sûr par construction.
Preuve par mutation : `gridSettling = false` → échec des deux scénarios qui
testent le correctif (`maintien-charge`, `pas-de-demarrage`), les quatre autres
restant verts puisqu'ils n'en dépendent pas. Restauration → 48/48.
### Suite prévue (ordre convenu avec le propriétaire)
1. ✅ fenêtre d'établissement ;
2. séquence d'ouverture — baisser la consigne de la borne **avant** d'ouvrir le
contacteur, jamais après. Aujourd'hui l'hystérésis ouvre et le contrôleur
EVCS l'apprend jusqu'à 30 s plus tard, pendant lesquelles les deux MultiPlus
portent la voiture au courant majoré (jusqu'à 28 A en boost) plus la maison :
c'est le mécanisme du W-105 déjà consigné ;
3. priorité solaire pour la voiture à batterie pleine (coût marginal nul :
6-7 trackers sur 7 bridés = ressource refusée) et interdiction de fermer le
contacteur pour charger une voiture en journée batterie pleine (~150 Wh).
---
*Document mis à jour le : 31/08/2026 (~18:20) — fenêtre d'établissement déployée dans src/, golden 48/48, PUT n8n en attente de validation*
## 30. Session du 31/08/2026 (suite) — Séquence d'ouverture du contacteur
### Le défaut, en deux morceaux
Le plafond de courant EVCS majoré (P5, `input_number.evcs_plafond_courant_edf_on`,
jusqu'à **28 A en BOOST**) n'est légitime que tant que le réseau alimente AC-IN :
hors réseau, les deux MultiPlus portent seuls la voiture **plus** la maison.
C'est le mécanisme du W-105 déjà consigné (§3, maintenance du 09/07).
1. **Personne ne baissait avant l'ouverture.** C'est l'hystérésis (cycle 1 min)
qui ouvre le contacteur ; le contrôleur EVCS ne l'apprenait qu'après coup, via
`parsed.acInAvailable`. Les deux automates n'ont aucune poignée de main.
2. **Et quand il l'apprenait, il ne baissait pas vite.** La branche
`charging_step_down_system_cap` de `DECISION_ENGINEV2` exigeait
`lifecycle.currentCooldownElapsed` — un compteur de **confort** — et ne
descendait que de `currentStepDownA` par cycle. De 28 A à 20 A : plusieurs
cooldowns. L'exposition ne se comptait donc pas en secondes mais en minutes.
### Le correctif
**Aucune poignée de main entre les deux automates, aucun helper nouveau.** Le
plafond majoré exige désormais que **l'EVCS demande lui-même le réseau** :
```js
const evcsGridCeilingAllowed =
parsed.acInAvailable &&
(sourceMode === 'grid_forced' || sourceMode === 'grid_hc_allowed' || forceGridRequested);
```
L'ordonnancement devient mécanique : la demande de l'EVCS tombe au cycle où la
fenêtre se ferme (30 s), alors que l'hystérésis ne peut ouvrir qu'**ensuite**
(sa branche `turn_off_recovered` exige justement que l'override ait disparu, puis
min_on écoulé, sur un cycle d'une minute). L'EVCS baisse donc toujours avant,
sans que rien ne soit coordonné.
⚠️ **Le calcul du plafond a été DÉPLACÉ après le calcul de `sourceMode`** dans
`WINDOWS_SOURCEV2` (juste avant `const operatingMode = profile;`). Vérifié avant
de bouger : `physicalCapA`, `canRunAtMinCurrent` et les quatre variables
intermédiaires n'étaient utilisés qu'à leur définition et dans l'export — rien
entre les deux ne les lisait. Ne pas les remonter.
Second volet, dans `DECISION_ENGINEV2` : **un plafond n'est pas une cible qu'on
approche, c'est une limite qu'on respecte.** La réduction ne dépend plus du
cooldown et descend directement au plafond (`decision: 'step_down_fast'`, valeur
déjà utilisée par la branche surcharge). Réduire est toujours sûr ; c'est la
montée qui doit être temporisée, et elle l'est.
### Portée réelle, et ce qui reste exposé
Avec le helper à sa valeur par défaut (20 A), **le comportement est strictement
inchangé** : le plafond valait déjà 20 A dans les deux branches. Le correctif ne
mord que sur le BOOST 28 A, c'est-à-dire exactement le cas dangereux.
Reste irréductible : une ouverture **manuelle** (EDF URGENCE) ou une perte
physique du réseau ne se laissent pas anticiper. L'exposition y passe de
plusieurs minutes à **≤ 30 s plus le temps de rampe de la borne**, ce qui est le
minimum atteignable sans signal matériel.
### Golden 48 → 51
| scénario | attendu |
|---|---|
| `ouverture-plafond-tombe-hors-demande-reseau` (AC-IN présent, BOOST 28, matin solaire, cooldown NON écoulé) | `charging_step_down_system_cap` → **20 A immédiatement** |
| `ouverture-plafond-maintenu-en-demande-reseau` (CONTREFACTUEL, `force_grid_now`) | `charging_hold_grid` **28 A**, BOOST préservé |
| `ouverture-reseau-disparu-reduction-immediate` (AC-IN absent, cooldown non écoulé) | réduction immédiate à 20 A |
Preuve par mutation, les deux volets séparément :
- plafond majoré reconditionné au seul `acInAvailable` → 1 échec ciblé ;
- réduction remise sous `currentCooldownElapsed` → 2 échecs ciblés ;
- restauration → 51/51.
### Suite
3. priorité solaire pour la voiture à batterie pleine (coût marginal nul :
6-7 trackers sur 7 bridés = ressource refusée) et interdiction de fermer le
contacteur pour charger une voiture en journée batterie pleine (~150 Wh).
---
*Document mis à jour le : 31/08/2026 (~19:00) — séquence d'ouverture corrigée, golden 51/51, PUT n8n en attente de validation*
## 31. Session du 01/09/2026 (nuit) — La consigne réseau appartient désormais au contrôleur
### L'incident : charge HC démarrée puis morte en soixante secondes
Fenêtre HC ouverte normalement, tout notre pilotage a fait son travail :
| 01:24:03-04 | latch hystérésis ON, EDF fermé, AC-IN présent |
| 01:24:00 | takeover nuit ON, source `grid_hc_allowed` (P7) |
| 01:25:15 | ACM en `evcs_takeover_hold`, DVCC à 2 A |
| 01:25:30 | `start_grid_hc_min_current` — la charge démarre |
| 01:25:42 | le réseau prend la voiture : import **1 450 W** |
| 01:26:33 | import retombé à 763 W puis **négatif**, batterie de −7 à **−25 A** |
| 01:26:30 | garde « batterie trop sollicitée (−21,5 A) » → arrêt + verrou 17 min |
| 01:44 → 01:47 | `battery_current_hard_stop` puis `idle_wait_conditions` |
### Cause : l'ESS n'utilise le réseau que si on le lui demande
`ac_power_setpoint` = **50 W** (corrigé le 31/08 depuis 4 200 W),
`ess_min_soc_limit` = 20 %, SOC = 50 % ⇒
`ess_batterylife_state` = `self_consumption_soc_above_min`. Au-dessus du SOC
minimal, l'ESS fait servir la batterie et ignore le réseau. Le pic à 1 450 W
était le transitoire de reprise, pas le régime : en 50 s le régulateur a rendu
la charge à la batterie en convergeant vers les 50 W.
Preuve indépendante, à 01:49 sans voiture : contacteur fermé, maison 761 W,
tuile Réseau « – », **batterie −803 W**. Le réseau était branché et inerte.
**Le garde a eu raison** : la pente était monotone, sa fin était « la batterie
porte la voiture toute la nuit ». Le neutraliser aurait été pire.
**Et la consigne à 4 200 W du propriétaire était PORTEUSE** : c'est elle qui
faisait fonctionner toute la mécanique de prise de relais nocturne (P4/P7).
J'avais écrit en la retirant que le besoin couvert restait entier et devait être
repris ailleurs ; je ne l'avais pas repris. Règle qui en découle, désormais en
mémoire : ne jamais retirer un de ces contournements avant d'avoir mis son
remplaçant adaptable en production.
### Correctif (déployé 01/09 02:10, golden 56/56)
Le contrôleur écrit lui-même `number.gx_device_ac_power_setpoint`.
- **Snapshot** : `grid_setpoint_w`, `grid_setpoint_idle_w` (helper optionnel
`input_number.evcs_grid_setpoint_idle_w`, absent → 50 W, le défaut Victron).
- **INPUT_PARSE** : `parsed.gridSetpointW`, `params.gridSetpointIdleW` [0, 500].
- **HA_SERVICE_CALLS_OUTPUT** :
`gridSessionActive = acInAvailable && sourceMode ∈ {grid_forced, grid_hc_allowed}`
— donc P7 nuit, P9 départ et `force_grid_now`, et rien d'autre.
Besoin = `houseNonEvcsW + physicalCapA × acVoltageRef` : une grandeur
**mesurée**, pas une constante. Hors session, retour à la valeur de repos.
- **BUILD_ACTIONS** : appel `grid_power_setpoint` (`number.set_value`).
- Trace `Consigne reseau=XW` dans la raison pendant une session.
**La bande d'écriture est ASYMÉTRIQUE, et c'est le point.** Une consigne trop
basse fait porter la différence par la batterie — c'est le défaut de cette nuit,
corrigé sans attendre. Une consigne trop haute ne coûte rien : l'ESS ne prélève
que ce qu'il peut placer. On écrit donc avec une marge d'**un pas de courant
EVCS** (`currentStepUpA × acVoltageRef` ≈ 230 W — grandeur du système, pas un
chiffre inventé) et on ne redescend qu'au-delà de deux marges. Première version
sans cette asymétrie : réécriture toutes les 30 s en poursuivant le bruit de la
maison. Après : **deux écritures en 25 minutes**.
⚠️ **Deux leviers, deux rôles, à ne pas confondre** : la consigne dit ce que le
réseau a le DROIT de servir ; **DVCC (Auto Charge Manager) dit ce que la batterie
a le droit de prendre**. C'est DVCC à 2 A qui empêche une consigne généreuse de
gaver la batterie pendant un `evcs_takeover_hold`, et c'est DVCC à 30 A qui
autorise la recharge pendant un `evcs_takeover_recover`. Le couple se compose
correctement, mais la générosité de la consigne DÉPEND de ce cap : ne pas
supprimer l'un sans revoir l'autre.
### Golden 51 → 56
`summarize()` expose `gridSetpointW` (valeur écrite ce cycle, `null` si aucune
écriture). Scénarios : session HC → 5 525 W · contrefactuel hors session → 50 W ·
consigne déjà généreuse (5 600) → aucune écriture · **consigne 45 W sous le
besoin → correction immédiate** (le défaut de la nuit) · dérive externe à
4 000 W hors session → ramenée à 50 W.
Preuve par mutation : consigne figée au repos → 2 échecs ciblés ; branche
« trop basse » neutralisée → 1 échec ciblé ; restauration → 56/56.
### Vérification en réel
02:10 → 02:13, contacteur fermé : grid 4 350 W, **batterie ~0 A**, EVCS 3 500 W,
SOC stable à 49 %, consigne écrite à 5 276 puis 5 616 W. La voiture charge sur
le réseau en HC sans toucher à la réserve — ce que P7 était censé faire depuis
le 07/08 et qui ne tenait que par un réglage manuel.
### Risque résiduel assumé
Si le contrôleur s'arrête en pleine session (SPOF Synology, cf. §3), la consigne
reste haute et la substitution de journée peut revenir. C'est le même mode de
défaillance que le reste de la chaîne, mais il est nouveau pour ce paramètre :
à reprendre le jour où le SPOF sera traité.
### Non fait volontairement
La fenêtre d'établissement (§29) ne couvre que le chemin solaire ; les gardes
réseau ne sont pas gardés. Ce n'est pas la cause de cet incident et, la consigne
étant désormais correcte, le transitoire observé au redémarrage de 01:58 était
de 20 s avec la batterie à −1,6 A. À traiter séparément si un cas se présente.
---
*Document mis à jour le : 01/09/2026 (~02:15) — consigne de puissance réseau pilotée par le contrôleur, golden 56/56, charge HC vérifiée en réel*
## 32. Session du 01/09/2026 (nuit) — État réel de la résilience, faits vérifiés
Relevés avant toute proposition de secours (second Synology 10.10.10.22).
### Ce qui est déjà hors du NAS principal
`/mnt/projects` est monté depuis **10.10.10.22** : le dépôt git (src/nodes,
golden, tools, docs) ne dépend donc PAS de 10.10.10.17. Gitea vit sur la même
machine. La moitié du travail de résilience est faite sans qu'on l'ait cherché.
`/mnt/ARCH3/docker` (HA + n8n) est sur **10.10.10.17** uniquement, non répliqué.
### Aucune sauvegarde Home Assistant n'existe
Pas de dossier `backups/` dans `/mnt/ARCH3/docker/HA` (vérifié : seuls `SAVE/`
et `SAVE_CONFIG/`, créés à la main lors de nos sessions). La sauvegarde intégrée
de HA n'a jamais produit d'archive ici.
### ⚠️ La clé de chiffrement n8n tient dans 56 octets et n'a aucune copie
`N8N_ENCRYPTION_KEY` est **absent de `compose.yaml`** : n8n l'a générée au
premier démarrage et elle vit uniquement dans `/mnt/ARCH3/docker/n8n/data/config`
(56 octets, `encryptionKey`, 06/02/2026). Sans ce fichier, une copie parfaite de
`database.sqlite` (1,69 Go) ne redonne **aucun credential** — OpenRouter compris.
C'est l'objet le plus fragile de toute l'installation.
### Volumétrie, et c'est elle qui commande la stratégie
| | taille | nature |
|---|---:|---|
| `.storage` + `packages` + `dashboards` + yaml | **~6 Mo** | identité : entités, helpers, cartes |
| `custom_components` | 52 Mo | réinstallable (HACS), versions à figer |
| `home-assistant_v2.db` | 4,39 Go | historique |
| `n8n/data/database.sqlite` | 1,69 Go | workflows (déjà en git) + credentials |
Les deux bases sont en **mode WAL** (`-wal`/`-shm` présents) : un `cp` à chaud
sur NFS produit une sauvegarde corrompue **en silence**. Il faut
`sqlite3 .backup` / `VACUUM INTO`, ou conteneur arrêté.
### ⚠️ Le contacteur EDF est un Tuya CLOUD — SPOF distinct du NAS
`switch.edf_general_ok_switch` : `platform: tuya`,
`unique_id: tuya.bf3d7928e069594e5fzfi9switch` (intégration `tuya`, projet
cloud `gg-1060...`, pas `localtuya`). Ouvrir ou fermer le réseau **passe donc
par internet**. Or les coupures d'orage de ce site emportent souvent la
connexion : le contacteur peut devenir incommandable exactement quand il faut
agir, et le tableau « EDF Urgence » avec lui.
### En revanche, le GX est une troisième machine, indépendante des deux NAS
Intégration `victron_gx` = **Venus OS `c0619ab34ac5` sur 10.10.10.9:1883**, et
le broker MQTT est sur cette même adresse. Consigne de puissance réseau, DVCC,
courant EVCS, arrêt de la borne, SOC, PV, états MPPT : tout est lisible et
écrivable **en MQTT local, sans HA et sans cloud**. C'est là-dessus qu'un chien
de garde doit s'appuyer — pas sur HA, et surtout pas sur Tuya.
### Backlog levé sans qu'on le remarque : Météo-France est installée
`meteo_france` — Pleumeur-Bodou, Bretagne (22).
`sensor.meteo_france_alert_for_department_22_22_weather_alert` = `Vert`, et la
réserve d'orage (§26) tourne : `storm_level` vert, réserve **4,95 kWh**
(SOC 55 %), manque 0,9 kWh au SOC actuel de 49 %. L'action utilisateur en
attente depuis le 27/08 est donc caduque.
### Risque nouveau introduit le 01/09
Le contrôleur écrit maintenant la consigne réseau (§31). S'il s'arrête en pleine
session, elle reste haute et la substitution de journée revient. Ce risque
n'existait pas avant cette nuit : il justifie à lui seul un chien de garde.
## 33. Session du 01/09/2026 (nuit) — Chien de garde EVCS sur le second Synology
### Ce qu'il protège
Le contrôleur pilote depuis §31 la consigne de puissance réseau de l'ESS. S'il
s'arrête **en pleine session**, elle reste haute et le réseau se substitue au
solaire toute la journée suivante. Ce risque est né cette nuit ; le chien de
garde existe d'abord pour lui. Il couvre au passage l'incident du 09/07
(NAS éteint la nuit, EDF laissé fermé jusqu'au matin).
### Le signal de vie : surtout PAS un helper Home Assistant
Piège trouvé en le construisant, et vérifié : **HA ne rafraîchit pas
l'horodatage d'un `input_text` réécrit à l'identique.** À 02:52,
`victron_evcs_surplus_state` paraissait figé depuis 2 440 s alors que le
contrôleur tournait normalement (`charging_hold_grid` stable depuis 02:12, et la
raison tronquée à 255 caractères perdait justement les champs variables). Un
helper HA est un mauvais battement de cœur.
Signal retenu : **l'API d'exécutions de n8n**
(`GET /api/v1/executions?workflowId=...&limit=3`). Elle prouve que la boucle
s'exécute, et son absence couvre indifféremment « n8n arrêté », « NAS éteint »
et « réseau coupé ». Zéro modification du contrôleur, zéro helper à créer.
Cas supplémentaire traité : trois exécutions consécutives en `error` = le
contrôleur tourne à vide (typiquement HA inaccessible) → considéré mort.
### Ce qu'il fait, et surtout ce qu'il ne fait pas
- Il ne prend **aucune** décision d'énergie : ni seuil, ni démarrage. Il remet
l'état sûr par défaut (consigne → 50 W, borne arrêtée) et se tait.
- Il ne touche **pas** au contacteur EDF. C'est un Tuya *cloud* (§32), donc un
actionneur non fiable en secours ; et ce n'est pas nécessaire : contacteur
fermé + consigne au repos = autoconsommation normale, la maison passe sur la
batterie jusqu'au SOC minimal puis le réseau reprend seul. État sûr.
- Il n'utilise **ni HA ni le cloud** pour agir : MQTT direct sur le GX Venus
(10.10.10.9, portail `c0619ab34ac5`), troisième machine indépendante des deux
NAS. Topics vérifiés en lecture avant tout code :
`settings/0/Settings/CGwacs/AcPowerSetPoint`, `evcharger/40/StartStop`,
`evcharger/40/Ac/Power`, `battery/512/Soc`, `system/0/Ac/Grid/L1/Power`.
Broker sans authentification, keepalive `R/<portail>/keepalive` obligatoire.
- **Une seule action par panne**, puis verrou jusqu'au retour d'un signe de vie.
Sans ce verrou, une coupure réseau entre les deux NAS ferait rejouer l'action
à chaque cycle pendant que le contrôleur, vivant, la défait : un oscillateur
de plus, dans un projet qui en a déjà corrigé quatre.
### Réglages
Seuil de silence **600 s** (20 cycles manqués) : assez long pour ignorer un
hoquet, assez court pour qu'une panne nocturne ne coûte pas une journée de
production. Période 60 s. Consigne de repos 50 W (défaut Victron).
### Tests passés
1. Contrôleur vivant → aucune écriture, une ligne de veille par heure.
2. Panne simulée (`--simulate-dead --dry-run`) → détection, deux écritures
annoncées et supprimées, message d'alerte complet avec l'état du GX.
3. Verrou : 5 cycles de panne consécutifs → **1 alerte, 2 écritures**, puis
silence.
### Déploiement (action utilisateur)
`deploy/watchdog/compose.yaml`, à lancer **sur 10.10.10.22 uniquement** :
```
cd /volume2/projects/EVCS/EVCS/deploy/watchdog
cp watchdog.env.exemple watchdog.env # y coller la clé API n8n
docker compose up -d && docker compose logs -f
```
Le dépôt est monté en **lecture seule** ; seul le journal est écrit, hors dépôt.
`node_modules` (14 Mo, `mqtt` 5.15.2) est **sans dépendance native** — le
répertoire installé depuis le poste fonctionne tel quel dans le conteneur
alpine, et il vit déjà physiquement sur 10.10.10.22 (`/mnt/projects` = export
`10.10.10.22:/volume2/projects`).
⚠️ **Ne jamais le lancer sur 10.10.10.17** : un chien de garde hébergé par la
machine qu'il surveille meurt avec elle.
### Ce qu'il ne couvre pas encore
Le contacteur EDF reste incommandable si internet tombe (Tuya cloud). Un
contacteur local — ou un `localtuya` — est le prochain vrai gain de résilience.
La sauvegarde (6 Mo d'identité vers Gitea, 6,1 Go d'historique en hebdomadaire
avec `sqlite3 .backup`) et le secours froid n8n restent à faire.
## 34. Session du 01/09/2026 (nuit) — Sauvegarde Home Assistant : elle n'existait pas, elle existe
### Le point de départ
Aucune sauvegarde HA n'avait jamais été faite (§32). Et **non, git ne la
couvrait pas** : le dépôt contient le code du contrôleur, pas Home Assistant.
Un seul fichier HA y était versionné (`dashboards/bilan_energetique.yaml`).
Les helpers, les entités, les paquets, les cartes, les intégrations n'avaient
aucune copie nulle part.
### La trouvaille qui a simplifié le problème
`backup/agents/info` déclare **quatre destinations**, et le croisement avec les
entrées `synology_dsm` de `.storage/core.config_entries` donne leur hôte réel —
le nom seul ne dit pas où chacune écrit :
| agent | nom | hôte |
|---|---|---|
| `backup.local` | local | 10.10.10.17 |
| `synology_dsm.1660NZN396007` | ARCHIVE2 | **10.10.10.22** |
| `synology_dsm.14B0MPN914514` | ARCHIVE | **10.10.10.50** |
| `synology_dsm.20A0SBRVM9PF2` | ARCHIVE3 | 10.10.10.17 ⚠️ à ne PAS utiliser |
Home Assistant sait donc **pousser lui-même ses sauvegardes vers deux autres
machines**. Aucun script de recopie, aucune tâche planifiée, aucun montage NFS
supplémentaire : il n'y avait qu'à le configurer. (Troisième machine 10.10.10.50
découverte au passage — elle n'apparaissait dans aucun document du projet.)
### Configuration appliquée (`tools/ha-backup-config.mjs`, rejouable)
La configuration de sauvegarde **n'est pas exposée par l'API REST** : elle vit
dans `.storage/backup` et ne se règle que par l'interface ou le **websocket**.
Le script passe par le websocket, ce qui la rend reproductible et relisible.
- **Destinations** : local + 10.10.10.22 + 10.10.10.50. ARCHIVE3 (10.10.10.17)
volontairement exclu : une sauvegarde qui ne survit pas à la panne qu'elle
couvre ne protège rien.
- **Base de données EXCLUE.** Ce qu'il faut restaurer vite, c'est l'identité du
système — 20 Mo compressés. L'historique pèse 4,4 Go, change en permanence et
n'a aucune valeur d'urgence : il se sauvegarde à part, plus rarement, et avec
les précautions SQLite (mode WAL, jamais un `cp` à chaud).
- **Quotidienne à 04:45**, rétention **7 copies**.
- **Sans mot de passe**, choix assumé : ces archives restent sur le réseau local
du propriétaire, et une sauvegarde restaurable vaut mieux qu'une sauvegarde
chiffrée dont la clé est perdue le jour où elle sert.
### Vérifiée, pas seulement déclenchée
Première archive : `Automatic backup 2026.8.1` (a9d35ac9), 01/09 03:11,
**20,3 Mo, présente sur les trois machines**. Contenu contrôlé archive en main :
`.storage` 39 fichiers · `packages` 18 · `dashboards` 30 ·
`custom_components` 2 283 · `configuration.yaml`, `secrets.yaml`,
`automations.yaml`, `scripts.yaml` · et **zéro occurrence** de
`home-assistant_v2.db`, comme voulu.
`tools/ha-backup-list.mjs` affiche pour chaque sauvegarde les hôtes où elle est
réellement présente — c'est cette colonne-là qui compte.
### Ce que cette sauvegarde ne couvre PAS
1. **n8n.** Ni ses workflows (déjà en git, donc couverts autrement), ni sa base
`database.sqlite` (1,69 Go), ni surtout **la clé de chiffrement de 56 octets**
(§32) sans laquelle aucun credential n'est récupérable. C'est le prochain
trou à boucher, et le plus petit.
2. **L'historique HA** (4,4 Go) : perdu en cas de restauration. Conséquence
réelle pour le chantier du bilan énergétique, qui s'appuie sur les
statistiques long terme. À traiter séparément, en hebdomadaire.
### Validation de bout en bout, nuit du 01/09 (fin de session)
**Chemin d'écriture du chien de garde DÉMONTRÉ**, pas seulement simulé
(`tools/watchdog-selftest.mjs`, rejouable) : écriture réelle d'une sonde de
777 W sur `W/<portail>/settings/0/Settings/CGwacs/AcPowerSetPoint`, relecture
conforme, remise de la valeur d'origine par le script lui-même. Le `--dry-run`
supprimait justement ce maillon-là ; sans cette preuve, le chien de garde
n'était que probablement fonctionnel. Test fait EDF ouvert, donc consigne
inerte : risque nul.
**Et la séquence de fin de session s'est déroulée dans le bon ordre**, sans
que personne l'ait orchestrée :
| 03:31:30 | fin de charge → `night_recovery_done_idle`, et **la consigne retombe à 50 W dans le même cycle** |
| 03:32:04 | 34 s plus tard, l'hystérésis ouvre le contacteur (`turn_off_recovery_done`) |
La demande baisse AVANT que le réseau parte — le principe de la séquence
d'ouverture (§30), obtenu ici par construction et non par coordination. C'est
la garantie que le risque introduit en §31 (consigne laissée haute) ne se
matérialise pas en fonctionnement normal ; le chien de garde ne couvre plus que
le cas de la panne.
**Bilan de la nuit HC** : charge démarrée à 01:58 après correction, menée à son
terme à 03:31 sans jamais entamer la réserve (batterie tenue entre −2 et +1 A
pendant toute la session), SOC stable à 49 %.
**Clé API n8n** : désormais aussi dans `.env` à la racine (convention du
propriétaire), en plus de `~/.config/opencode/opencode.json`. Le repli du chien
de garde sur `.env` est vérifié — utile pour tout déploiement hors poste de
travail.
## 35. Session du 01/09/2026 — La table des bandes SOC déclarait un temps que personne ne lisait
### Bilan de la nuit 31/08→01/09 (contexte de la découverte)
Deux sessions réseau : 01:24→03:32 (charge HC, **6,51 kWh importés**, dont
**5,14 kWh dans la VW**, batterie tenue entre −2 et +2 A, SOC plat à 49 %) et
07:12→09:51 (batterie tombée à 20 %, reprise par l'hystérésis, **0,70 kWh**).
À l'ouverture du contacteur de 09:51, en plein jour : PV 2,7 kW avant, 2,7 kW
après, import résiduel 40 W — **aucune éviction du solaire**, la correction de
consigne du 31/08 tient. Journée : 27,85 kWh produits, 6,81 kWh dans la voiture
en solaire, 0,39 kWh de réseau.
**Le point faible de cette nuit n'est pas un bug, c'est la définition de la
cible.** À 03:31 le SOC (49 %) dépassait la cible du matin (35 %) → recovery
done, EDF relâché. Trois heures quarante plus tard la batterie était à 20 % et
l'hystérésis a dû rallumer. Pour traverser jusqu'au relais solaire (~09:00) il
aurait fallu partir de **~64 %**. La cible est un niveau à un instant ; ce qu'il
faut est une énergie à couvrir jusqu'au croisement PV/conso. C'est le chantier
du bilan énergétique, pas un seuil de plus.
### Le défaut : un critère physique évalué sur du bruit
Deux arrêts `stop_conditions_lost` le 01/09, à **14:26:00** et **14:56:30**,
véhicule en charge à 20 A sous 6,6 kW de PV.
La règle de maintien exige `courant batterie ≥ plancher`, plancher effectif
**0 A** (`input_number.victron_evcs_battery_discharge_soft_a`, curseur borné
à [−50, 0]). Or à SOC 94 la batterie est en float. Mesure brute au pas de 2 s
dans la minute précédant l'arrêt :
```
14:25:34 +1,8 14:25:44 0,0 14:25:54 −0,4
14:25:38 −1,0 14:25:46 −0,4 14:25:56 −1,6
14:25:40 −2,6 14:25:50 +1,0 14:25:58 −0,7
```
Ce n'est pas une décharge, **c'est le bruit de régulation d'une batterie qui ne
fait rien**. Le contrôleur en prélève un échantillon toutes les 30 s et le
compare à zéro : pile ou face. Les états `hold_min_run` qui alternent avec
`charging_hold` toutes les 1 à 2 minutes dans le journal SONT ces tirages
perdants — sauvés uniquement parce que le `min_run` (29 min) n'était pas
écoulé. Quand un échantillon négatif tombe après le `min_run`, la charge
s'arrête. Le second arrêt avait une cause réelle mais fugace : pointe maison à
**8 016 W** pendant quelques secondes, batterie à −42 A.
**Coût mesuré :** à l'arrêt la PV s'effondre de 6 633 à **2 554 W** en 30 s —
batterie pleine, le véhicule était le seul preneur — et la reprise repart à 6 A
pour mettre **21 minutes** à revenir à 20 A. ≈ **0,45 kWh de production refusée
par arrêt**. Fréquence : 1 le 26/08, 4 le 27, 6 le 28, 4 le 29, 4 le 30.
### Ce que le système savait déjà, et que rien ne lisait
`INPUT_PARSE` construit depuis toujours une table de politique batterie par
bande de SOC, avec **deux régimes explicitement temporels** :
| bande | souple | tolérance souple | dur | tolérance dure |
|---|---:|---:|---:|---:|
| > 90 | −50 A | **12 min** | −100 A | 20 s |
| 80-90 | −40 A | 10 min | −80 A | 20 s |
| 60-80 | −20 A | 6 min | −50 A | 15 s |
| 40-60 | −12 A | 4 min | −30 A | 10 s |
| 25-40 | −5 A | 2 min | −15 A | 5 s |
| < 25 | −3 A | 1 min | −8 A | 2 s |
`batterySoftToleranceMinutes` et `batteryHardToleranceSeconds` étaient calculés
(BATTERY_CLOUD_LOCKS l.185-186), exportés dans le debug, et **branchés sur
aucune décision**. Toute la dimension temporelle de la politique batterie était
morte. Troisième mécanisme dans ce cas — `houseSpikeDetected` n'allonge que la
tolérance *nuage*, jamais la garde batterie, et son entrée
`house_ac_power_prev_w` lit **le même capteur** que `house_ac_power_w` dans le
gabarit de snapshot, donc il vaut structurellement `false` (non corrigé ici,
la tolérance rend ce chemin redondant — à traiter séparément).
### Correctif (déployé 01/09 20:08, golden 61/61)
**Purement temporel : aucun seuil n'est déplacé, aucune constante ajoutée.**
On applique la table telle qu'elle est écrite — un dépassement souple doit
DURER pour compter.
- Nouveau helper de persistance `input_text.victron_evcs_battery_soft_breach_at`
(paquet `victron_evcs_battery_dynamic.yaml`, sauvegarde dans `packages/SAVE/`,
créé par `input_text.reload` — **aucun redémarrage HA**). Vide = pas de
dépassement. Écrit par le contrôleur, uniquement sur changement réel : la date
de début doit rester figée, sinon le compteur ne monte jamais.
- `BATTERY_CLOUD_LOCKS` : `batterySoftBreachNow` / `...StartedMs` /
`...Minutes`, et `batterySoftDischargeHoldOk` = pas de dépassement, ou
dépassement plus court que la tolérance de sa bande.
- Branché sur les **trois conditions de MAINTIEN** qui utilisaient le test
instantané : `rawSolarHoldOk`, `batteryAssistSolarHoldOk`, `probeHoldGuardOk`.
(`gridHoldOk` et `batteryAssistNightHoldOk` utilisaient déjà le seuil DUR.)
**Ce qui n'est PAS touché, et pourquoi.** Le plancher de **démarrage** reste
instantané : ne pas lancer une charge sur une batterie qui se décharge déjà,
c'est la frontière « délestage et non charge prioritaire ». Le garde **dur**
reste instantané : `hardToleranceSec` n'est volontairement pas branché, cela ne
pourrait que ralentir une sécurité. La tolérance est du confort, jamais du
risque.
**Pourquoi ce filtre discrimine.** Le bruit change de signe en quelques
secondes, donc le compteur se remet à zéro de lui-même et n'atteint jamais la
tolérance. Une vraie décharge reste négative à chaque échantillon et finit par
la dépasser. C'est *parce que* le signal est du bruit que le filtre marche.
### Golden 56 → 61
`batt-float-bruit-ne-coupe-pas` (relevé du 14:26 : SOC 94, −1 A → `charging_hold`
à 20 A) · `batt-decharge-soutenue-coupe` (CONTREFACTUEL, mêmes mesures mais
dépassement de 20 min > 12 → `stop_conditions_lost`) · `batt-pointe-maison-ne-coupe-pas`
(relevé du 14:56, −42 A → réduction graduée au plafond système 18 A, **pas** un
arrêt) · `batt-decharge-dure-arret-immediat` (−120 A < seuil dur −100 →
`battery_current_hard_stop`, aucune tolérance) · `batt-plancher-demarrage-reste-instantane`
(véhicule à l'arrêt, −1 A → `idle_wait_conditions`).
Les trois scénarios de la fenêtre d'établissement portent désormais **le même
dépassement en cours** (10 min > tolérance 4 min de leur bande) : sans cela, la
tolérance les rendait tous identiques et la paire ne prouvait plus rien.
Preuve par mutation, les deux sens séparément :
- tolérance supprimée (comportement d'avant) → 2 échecs ciblés, et
`etablissement-maintien-charge` reste vert (c'est bien la fenêtre qui le
protège, pas la tolérance) ;
- tolérance infinie → 3 échecs ciblés, et l'arrêt dur reste vert (la sécurité ne
dépend pas de la tolérance) ;
- restauration → 61/61.
### À surveiller
1. Journal : plus aucun `stop_conditions_lost` en pleine charge solaire à
batterie pleine ; la trace `Batt souple X/12min` n'apparaît que pendant un
dépassement réel.
2. `input_text.victron_evcs_battery_soft_breach_at` : vide la plupart du temps,
écrit une fois au début d'un dépassement, effacé à la fin. S'il reste rempli
en permanence, c'est un vrai déficit à instruire, pas un bug du filtre.
3. Debug : `batt_soft_breach_now`, `batt_soft_breach_minutes`,
`batt_soft_discharge_hold_ok`.
---
*Document mis à jour le : 01/09/2026 (~20:15) — tolérance temporelle de la politique batterie branchée, golden 61/61*
## 36. Session du 03/09/2026 — Un demarrage se decide sur l'excedent, pas sur la production
### Le releve
Le 03/09, la voiture est branchee a **19:44**. Le controleur lance la charge
dans la seconde. En 33 minutes (19:44 → 20:17, arret par `stop_source_lost`) :
| | valeur integree |
|---|---:|
| pris par la voiture | **0,897 kWh** |
| rendu par la batterie | **1,479 kWh** |
| produit par le PV | 0,327 kWh |
| SOC | **94 % → 85 %** |
Au meme moment, le bilan energetique publiait : croisement PV/conso a **9 h**,
**11,66 kWh a couvrir**, SOC requis **99,7 %**, `grid_needed` ON. Dans sa propre
arithmetique (plancher 22 %, 15 kWh utiles), la charge du soir a fait passer le
manque de la nuit de **0,86 kWh a 2,21 kWh** — elle l'a plus que double, pour
mettre dans la voiture 0,9 kWh qu'elle allait de toute facon reprendre en heures
creuses a 01:24. La fenetre du soir ferme a 20:30 ; il n'y a rien entre les deux.
Precedent immediat, nuit du 02-03/09 : EDF relache a 02:30 a 45 %, batterie a
20 % vers 05:45, reseau maintenu jusqu'a 10:33 — dont **1,49 kWh importes entre
07:24 et 10:33, hors heures creuses**. Marge perdue le soir, rachetee au plein
tarif le matin.
### La cause
`pv_start_min_w` (700 W) est une **puissance PV absolue**. A 19:44 : PV 758 W,
maison 1202 W, batterie deja a −12,2 A. Le controleur a lu « 758 ≥ 700, donc il
y a du soleil » et a lance 1,6 kW. **L'excedent valait −444 W.**
Verification dans la vraie chaine (rejeu du releve) : `rawSolarStartOk` ET
`batteryAssistSolarStartOk` etaient TOUS DEUX vrais. Un correctif sur le seul
seuil PV n'aurait donc rien change — l'assistance batterie aurait demarre la
charge de toute facon. Les deux chemins devaient etre traites ensemble.
Deux temporisations ont ensuite prolonge la faute, sans en etre la cause :
`charging_hold_cloud_tolerance` a pris **le coucher du soleil pour un passage
nuageux** (19:46 → 20:00), puis, la tolerance souple du 01/09 epuisee a 19:57,
`min_run` (29 min) a maintenu la charge **16 minutes de plus**, conditions de
maintien deja declarees perdues. Non traite ici.
### Le correctif (deploye 03/09 20:51, golden 64/64)
Une seule grandeur, dans `BATTERY_CLOUD_LOCKS.js`. `derived.houseNonEvcsW`
existait deja (calcule dans WINDOWS_SOURCEV2, deja lu par BCL) : **aucun champ
de snapshot, aucun helper, aucune constante nouvelle.**
```js
const solarSurplusW = parsed.pvPowerW - derived.houseNonEvcsW;
```
Sans injection et reseau absent, cette grandeur vaut la puissance batterie au
signe pres. C'est ce qui separe les deux situations que la mesure PV seule
confond (`PV_mesuree = min(ressource, conso + absorption batterie)`) :
- **CENSURE** — batterie pleine, MPPT bride faute de puits : la production se
cale sur la maison, l'excedent vaut ~0, la batterie ne bouge pas. Il y a de la
ressource REFUSEE : la provoquer avec la voiture est legitime (raison d'etre
de P3 et de l'assistance batterie).
- **DEFICIT** — soir, nuages : l'excedent est NEGATIF, la batterie porte deja la
maison. Charger la voiture, c'est la vider.
Zero n'est donc pas un seuil de plus, c'est la frontiere physique entre les deux.
Deux tests, parce que les deux chemins n'ont pas le meme metier :
- **chemin « soleil reel »** (`rawSolarStartOk`) : excedent ≥ `pv_start_min_w`.
Meme helper, meme valeur, appliquee a la bonne grandeur ;
- **chemin « provocation »** (assistance batterie, ou sourceMode
`solar_potential`) : excedent ≥ **0**. On n'exige pas d'excedent mesurable —
il est censure par construction — seulement que la batterie ne porte pas deja
la maison.
⚠️ **PORTEE : DEMARRAGE UNIQUEMENT.** Les conditions de MAINTIEN sont
inchangees : une charge etablie a deja son plancher de decharge et sa tolerance
temporelle (§35), et couper une charge en cours sur une mesure d'excedent
bruitee recreerait l'oscillateur qu'on venait d'eteindre.
Verification empirique de la frontiere sur le releve du 03/09 : de 17:00 a 19:41,
`pv_censored` ON et excedent **toujours positif** (+14 a +212 W) ; a partir de
19:42, excedent negatif. La frontiere a zero separe exactement les deux regimes
sur la mesure reelle.
### Golden 61 → 64, et un scenario corrige
La paire de releves REELS, prise a 54 min d'intervalle, est le coeur du test :
| scenario | PV | maison | batterie | excedent | attendu |
|---|---:|---:|---:|---:|---|
| `excedent-censure-demarre` (18:50) | 769 W | 755 W | 0 A | **+14 W** | `start_min_current` 6 A |
| `excedent-deficit-ne-demarre-pas` (19:44) | 758 W | 1202 W | −12,2 A | **−444 W** | `idle_wait_conditions` |
**11 W d'ecart sur la lecture PV.** L'ancien code voyait la meme chose dans les
deux. Sans cette paire, rien ne prouverait que la nouvelle grandeur discrimine
ce que l'ancienne confondait.
Troisieme scenario, `excedent-grosse-production-grosse-maison` : PV 3000 W,
maison 2800 W, SOC 60 — pas de censure possible, la production mesuree EST la
ressource et elle est deja prise. Il exerce le chemin « soleil reel », que la
paire ne touche pas (elle passe par l'assistance).
**`apresmidi-provocation-p3` a ete corrige** : il portait PV 300 W, maison 600 W,
batterie +2 A, reseau ouvert — un trou de 300 W que rien n'alimente, etat
physiquement inatteignable. Il passait quand meme, precisement parce que le
demarrage ne regardait que la production et jamais le bilan. Remis coherent en
gardant son intention (production sous `pv_hold_min` 500 ET sous `pv_start_min`
700, donc seul `solar_potential` peut demarrer) : PV 430 W, maison 400 W,
excedent +30 W — la vraie signature de la censure.
Preuve par mutation, dans les deux sens : retour au seuil absolu → 2 echecs
cibles ; garde neutralisee → les 2 memes. Dans les deux cas
`excedent-censure-demarre` reste VERT, ce qui prouve qu'il tient par le chemin
assistance et non par la garde. Restauration → 64/64.
### Limite connue
Si `sensor.victron_consumption_power_l1` passe `unavailable`, la maison est lue
a 0 et l'excedent redevient la production brute — c'est-a-dire le comportement
d'avant ce correctif. Degradation vers l'existant, jamais pire.
### Ce que ce correctif ne fait PAS (suite convenue avec le proprietaire)
2. **Lire `input_boolean.evcs_balance_pv_censored`** pour conditionner la
provocation `solar_potential`, au lieu de l'approximer par SOC ≥ 90. Le
03/09 la censure s'est arretee a 19:30 (`batterie_pleine_sans_puits` →
`production non bridee` a 20:00) : l'approximation disait « oui » au moment
exact ou la chose approximee devenait fausse.
3. **Lire `evcs_balance_required_soc` / `_energy_to_cover_kwh`** pour que le
plancher du soir cesse d'etre un niveau fixe (`evening_soc_start_min` = 85).
⚠️ **CONSTAT STRUCTUREL, a garder en tete pour la suite** :
`grep evcs_balance workflows/evcs-surplus-controller-v2.6.json` → **0**, idem
pour l'hysteresis. Le bilan energetique calcule et publie l'heure de croisement,
l'energie a couvrir, le SOC requis, `grid_needed` et `pv_censored` — et **aucun
des deux workflows qui decident n'en lit une seule valeur**. Meme contradiction
sur la cible du matin : `night_target_soc` = 35 % (regle sur le TOTAL de demain,
33,9 kWh ≥ 18 → « fort ») contre 99,7 % pour le bilan, qui regarde QUAND
l'energie arrive — demain matin est creux : 0,03 kWh a 7 h, 0,77 a 8 h, 1,81 a
9 h. Deux nombres, memes donnees, facteur 3.
---
*Document mis a jour le : 03/09/2026 (~20:55) — demarrage sur l'excedent, golden 64/64, deploye*
## 37. Session du 03/09/2026 (suite) — La provocation solaire demandee aux chargeurs eux-memes
### Le trou que laissait §36
Le correctif sur l'excedent bloque bien l'incident de 19:44 (excedent −444 W).
Mais **un excedent proche de zero est AMBIGU** : c'est la signature de la
censure ET celle de l'equilibre de fin de journee. Le releve du 03/09 contient
les deux, avec le meme excedent :
| | PV | maison | excedent | batterie |
|---|---:|---:|---:|---:|
| 18:50 — censure | 769 W | 755 W | **+14 W** | 0 A |
| 19:41 — equilibre | 817 W | 816 W | **+1 W** | −1,4 A |
Rejeu du 19:41 apres §36 : `batteryAssistSolarStartOk` **encore vrai**. Trois
minutes avant l'incident, le systeme aurait commis la meme faute. Aucun seuil
de puissance ne separe ces deux etats.
### Pourquoi ce n'est PAS `pv_censored` qu'il fallait lire
Le plan annoncait « lire `input_boolean.evcs_balance_pv_censored` ». Lecture du
code (`src/energy-balance/CENSORSHIP.js`) : ce drapeau est un **OU de motifs**,
et l'un d'eux est exactement le proxy qu'on voulait remplacer —
`socPct >= 90 && batteryPowerW < 500` (motif `batterie_pleine_sans_puits`).
Le remplacer par lui serait remplacer un proxy par un sur-ensemble qui le
contient.
Chronologie du 03/09, verifiee : le bilan tourne toutes les **5 min** ;
`pv_censored` est reste **ON jusqu'au calcul de 19:50**, soit **5 minutes APRES
le demarrage de la charge**. Ce qui a fini par le faire basculer n'est pas la
censure mais `sun_elevation` passant sous 10°. **Le point 2 tel qu'annonce
n'aurait pas bloque l'incident.**
### Le signal qui marche : le mode de fonctionnement des chargeurs
`sensor.*_mppt_operation_mode` et `*_pv_tracker_solar_N_operation_mode` — sept
entites, jamais lues par aucun workflow (deja signalees §28). Chaque chargeur
declare lui-meme son regime :
- `mppt_active` = je suis a mon point de puissance maximale, **je donne tout** ;
- `voltage_current_limited` / `off` = **je me retiens**, faute de preneur.
Le 03/09 : sept trackers `voltage_current_limited` jusqu'a 19:10, tous
`mppt_active` a partir de **19:20**. Soit **24 minutes avant** `pv_censored`,
sans aucun proxy — ni SOC, ni elevation solaire, ni seuil de puissance. Signal
DIRECT (le materiel), INSTANTANE (30 s, la cadence de la boucle).
Contre-verification indispensable, le 01/09 a 14:22-14:34 : les trackers sont
restes `voltage_current_limited` **PENDANT** une charge de 3,4 kW sous 6,9 kW de
PV. A midi la ressource depasse ce que la voiture peut prendre : le signal ne
s'auto-annule donc pas, et `voltage_current_limited` en cours de charge veut
dire « il reste de la ressource refusee ».
### Le correctif (deploye 03/09 21:40, golden 66/66)
- **Gabarit snapshot** : `mppt_operation_modes`, collecte par
`states.sensor | selectattr('entity_id','search','mppt') | selectattr('entity_id','search','operation_mode')`
— **aucun entity_id code en dur**, un chargeur ajoute demain est pris en
compte tout seul. Expression testee sur `/api/template` AVANT integration,
puis gabarit complet re-rendu : 191 champs, JSON valide, aucun null.
- **INPUT_PARSE** : `mpptReadableCount` / `mpptAtMaxPowerCount` /
`mpptHoldingBackCount`. Les lectures `unavailable`/`unknown`/vides sont
ecartees — on ne devine jamais un etat.
- **BATTERY_CLOUD_LOCKS** :
`solarProvocationJustified = !(lectures exploitables && aucun tracker ne se retient)`,
exige par `batteryAssistSolarStartOk` et par la branche `solar_potential` de
`solarSurplusStartOk`.
⚠️ **DEMARRAGE UNIQUEMENT, et c'est structurel, pas une precaution.** Gager le
`sourceMode` ou le MAINTIEN sur ce signal ferait que la charge, en donnant un
preneur au MPPT, **detruirait sa propre condition d'autorisation** — exactement
le bistable de l'abondance P6 corrige le 24/08, et le chemin `stop_source_lost`
de la §36. La garde n'est lue que quand la voiture est a l'arret, instant ou
l'etat des trackers decrit la situation SANS elle.
**Repli** : aucune lecture exploitable => `mpptReadableCount === 0` =>
`solarProvocationJustified` vrai => comportement anterieur. Degradation vers
l'existant, jamais vers une supposition.
### Golden 64 → 66
- `provocation-trackers-a-fond-ne-demarre-pas` : le releve du 19:41 (excedent
+1 W, SOC 94, sept trackers `mppt_active`) → `idle_wait_conditions` ;
- `provocation-trackers-illisibles-comportement-anterieur` : meme releve,
lectures `unavailable` → `start_min_current` (le repli est teste, pas
seulement affirme).
- `excedent-censure-demarre` et `apresmidi-provocation-p3` portent desormais
l'etat reel des trackers (limites), et continuent de demarrer.
Mutation, trois directions : garde neutralisee → 1 echec cible (le 19:41
seulement) ; provocation toujours refusee → 3 echecs (les cas qui provoquent
legitimement) ; lecture illisible traitee comme « a fond » → 1 echec (celui du
repli). Restauration → 66/66.
### Reste du plan
3. **Lire `evcs_balance_required_soc` / `_energy_to_cover_kwh`** pour que le
plancher du soir cesse d'etre un niveau fixe (`evening_soc_start_min` = 85).
Le constat structurel de la §36 reste entier : `grep evcs_balance` sur les
deux workflows de decision renvoie toujours **0**.
---
*Document mis a jour le : 03/09/2026 (~21:45) — provocation solaire gagee sur le mode des chargeurs, golden 66/66, deploye*
## 38. Session du 03/09/2026 (suite) — Le plancher du soir cesse d'etre un niveau fixe
### Le constat, enfin traite
`grep evcs_balance` sur les deux workflows de decision renvoyait **0** depuis la
mise en service du bilan (26/08). Il calculait l'heure de croisement,
l'energie a couvrir et le SOC requis, et personne ne l'ecoutait.
Le 03/09 a 19:44, les deux nombres coexistaient dans le meme systeme :
| source | valeur | nature |
|---|---:|---|
| `evening_soc_start_min` | **85 %** | un NIVEAU, fixe, qui ne sait rien de la nuit |
| `evcs_balance_required_soc` | **99,7 %** | l'ENERGIE a couvrir jusqu'au croisement de 9 h |
SOC 94 : le premier autorisait la charge, le second disait que la batterie ne
passerait meme pas la nuit toute seule. La charge du soir a fait passer le
manque de 0,86 a 2,21 kWh.
### Le correctif (deploye 03/09 22:18, golden 70/70)
Dans `BATTERY_CLOUD_LOCKS.js`, le plancher de DEMARRAGE du profil
`evening_high_soc` devient :
```js
eveningSocStartFloorPct = Math.max(params.eveningSocStartMin, params.balanceRequiredSocPct)
```
**SENS UNIQUE, ET C'EST DELIBERE.** Le bilan ne peut que RENFORCER le plancher,
jamais l'abaisser. L'assouplir — il dira souvent moins de 85 % par forte
prevision d'ete — autoriserait des charges qui n'ont pas lieu aujourd'hui :
c'est une decision distincte, avec ses propres risques, qui doit etre validee
pour elle-meme et non glissee dans ce correctif. Corollaire agreable : le `max`
rend la garde robuste **sans borne ni ecretage** — une valeur nulle, negative ou
aberrante ne peut pas relacher le plancher.
**PORTEE : demarrage.** Le plancher de maintien reste
`min(profileSocHoldFloor, ...)`, donc inchange a 80 % : relever le plancher de
demarrage ne coupe aucune charge en cours. Troisieme fois de suite que la
distinction demarrage/maintien est ce qui rend un correctif sur.
**L'abondance P6 continue de primer** (PV moyen ≥ 4 kW ET batterie a ≥ +20 A) :
c'est une PREUVE MESUREE que la ressource depasse ce que le systeme absorbe,
donc le cas ou la voiture est effectivement gratuite.
### Gardes et repli
- fraicheur ≤ **30 min** (six cycles du bilan, qui tourne toutes les 5 min),
avec rejet d'un horodatage dans le futur de plus de 5 min ;
- `evcs_balance_status` exactement `ok` ;
- valeur numerique finie.
Sinon : regle historique inchangee.
**Aucun nouveau toggle : le kill-switch existe deja.**
`input_boolean.evcs_balance_enabled` a OFF arrete les ecritures du bilan
(`BUILD_HA_CALLS` : `if (!enabled) return []`), `balance_last_run` se fige, la
garde de fraicheur expire et le plancher retombe seul sur 85 % en une
demi-heure. Un helper de plus aurait fait doublon.
### Chaine technique
`HA_SNAPSHOT_TEMPLATE` : +`balance_required_soc`, +`balance_energy_to_cover_kwh`,
+`balance_status`, +`balance_last_run` (gabarit complet re-rendu : **195 champs,
JSON valide, aucun null**). `INPUT_PARSE` : `params.balance*`, en reutilisant
`parseAdvisorRunToMs` — l'advisor et le bilan publient le meme format
`DD/MM HH:mm`. `HA_SERVICE_CALLS_OUTPUT` : trace
`Plancher soir=X% (BILAN, Y kWh a couvrir)`, emise uniquement quand le bilan
durcit le plancher — sans elle, un blocage un soir de SOC 94 % est
incomprehensible a la lecture du logbook.
### Golden 66 → 70
Les quatre scenarios ont un VRAI excedent (PV 2000 W, maison 1000 W) et des
chargeurs qui se retiennent : les gardes de §36 et §37 sont passantes, le
plancher est le SEUL discriminant.
| scenario | SOC | bilan | attendu |
|---|---:|---|---|
| `bilan-plancher-soir-durci` | 94 | 99,7 %, frais | pas de charge |
| `bilan-plancher-soir-ne-relache-pas` | 70 | 60 %, frais | pas de charge (85 % reste maitre) |
| `bilan-perime-regle-historique` | 94 | 99,7 %, vieux de 2 h | charge |
| `bilan-statut-degrade-regle-historique` | 94 | 99,7 %, statut non ok | charge |
Mutation, quatre directions — bilan ignore / `Math.max` remplace par une
affectation directe / garde de fraicheur supprimee / garde de statut supprimee :
**chacune fait tomber exactement UN scenario, et un different a chaque fois.**
Chaque garde a donc son test dedie, aucun ne couvre pour un autre.
Restauration → 70/70.
### Observation
A 20:15 le bilan demandait 99,7 % (11,66 kWh a couvrir) ; a 22:15, **81,9 %**
(8,98 kWh). Le plancher suit la nuit qui s'ecoule — ce qu'un niveau fixe ne fait
pas, et c'est tout l'interet.
### Reste ouvert
- La **cible du matin** (`night_target_soc`) reste sur la regle des paliers
35/55/85, deduite du TOTAL de demain et aveugle a l'heure d'arrivee de
l'energie. Le 03/09 : 35 % cote controleur contre 99,7 % cote bilan. C'est le
meme defaut que celui corrige ici, sur l'autre bout de la nuit.
- Le workflow d'hysteresis ne lit toujours aucun helper du bilan : ses seuils
`grid_soc_on`/`grid_soc_off` (20/30) restent des niveaux fixes (§11.8.A).
- `houseSpikeDetected` reste structurellement faux (§35).
---
*Document mis a jour le : 03/09/2026 (~22:25) — plancher du soir issu du bilan energetique, golden 70/70, deploye*