Patrick Schurig ed877d86d2 docs: la trace du partage EV_GRID_START, verbatim, pour l'agent app
Il ne peut pas produire ce régime sur le banc : pluggedIn est en lecture seule,
aucune action RPC ne l'écrit, et sans assignedCarId la borne reste hors loads[].
Le partage est une propriété du CODE, pas du matériel — le banc n'ajouterait que
la confirmation que le binaire arm64 fait pareil.

La charge utile de testEvGridStartIsAnnouncedAndBounded est donc reproduite telle
quelle dans le brief, avec son critère vérifiable ligne à ligne :

  params.budgetW 900 → counts["budget.allocatedW"] 900 → budget.allocatedW 900
  params.gridW   480 → counts["draw.committedW"]   480 → draw.committedW   480
  Σ counts = 1380 == targetW == allocatedW

Les deux moitiés sont dans les deux compteurs, et les deux compteurs sont de même
nature. budget.evReservedW vaut 0 : il ne reçoit plus de tranche d'allocation, il
est redevenu la correction de latence qu'il a toujours été.

La seconde borne de la trace montre l'autre moitié de la règle — targetW: 0 avec
counts PRÉSENT et sa destination à zéro. C'est le cas le plus fréquent, celui
qu'un counts vide aurait perdu.

+etm32 est posé sur .75 et l'identité s'y vérifie sur des données réelles : Σ
counts[budget.allocatedW] = 1700 = budget.allocatedW. Le régime EV_GRID_START n'y
est pas reproductible et ne sera pas fabriqué — counts en serait la seule preuve,
et il l'est déjà dans la trace.

L'instrumentation qui a produit la trace était temporaire et a été retirée ;
l'arbre est identique à avant. Elle n'aurait rien affiché sans
QT_LOGGING_RULES="*.debug=true" — les règles par défaut suppriment qDebug sans
catégorie, ce qui a d'abord donné une sortie vide et un test qui passait.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-30 10:25:34 +02:00

1108 lines
75 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.

# AGENTS.md — etm-powersync-energy-plugin-etm
Moteur HEMS. Fork GPL de `nymea-energy-plugin-nymea`, étendu de l'optimisation EV
vers un gestionnaire d'énergie complet (EV, ECS, PAC SG-Ready, batterie).
- **Licence** : GPL-3.0 · **Miroir public** : OUI
- **Branche de travail** : `feature/beta-rulebased`
- **Document d'interface faisant autorité** : `docs/OPTIMIZER_PROTOCOL.md` (le contrat
stratégie/arbitrage — interne ET socket). `INTERFACE.md` fait autorité sur l'API JSON-RPC.
## ÉTAT
| Phase | Statut | Commit(s) |
|-------|--------|-----------|
| 0 — analyse fork / structure | ✅ FAITE | `f4d5b20` |
| 1 — renommage .pro + métadonnées debian | ✅ FAITE | `f4d5b20` |
| 2 — design arbitre validé | ✅ FAITE | `074fa71` |
| 3a — structs protocole + interfaces | ✅ FAITE | `4ae1939` |
| 3b — EnergyArbitrator + scheduler + adapter | ✅ FAITE — iso-fonctionnalité prouvée | `5f49e4c`, `d8ebd65`, `[3b-iv]` |
| 3c — adaptateur ECS à paliers + waterfall ECS | ✅ FAITE — suite 18/18 + charging 46/46 | `6298d5d`→`54ba229` |
| 3e — SgReadyAdapter | ✅ FAITE — suite 19/19 | `83d5ad9`→`d8079e8` |
| rév. 3 — frontière optimiseur↔routeur | ✅ FAITE — `RelayRouter`, `LoadConfig`/`LoadConfigStore`, kind `Stage` retiré | `5100674`, `e16aca4`, `5585a5c`, `7184fe4`, `88626cf` |
| lot B — frontière de TÉLÉMÉTRIE + `o:domain` | ✅ FAITE — `GetLoadTelemetry`/`LoadTelemetryChanged`, motifs en `{code, params}`, `degradedMode` lisible | `1.15.2+etm13` → `+etm14` |
| lot B-bis — la PAC entre en CONFIGURATION | ✅ FAITE — plus aucun adaptateur codé en dur ; `loads[]` ⊆ `GetLoadConfig` par construction | `1.15.2+etm15` |
| 3g-1 — les bornes entrent dans le waterfall | ✅ FAITE — décision partagée, motifs communs ; **mais la décision n'atteignait pas le matériel** | `1.15.2+etm20` → `+etm22` |
| 3g-2 — une charge, un commandeur | ✅ FAITE **et vérifiée sur machine** — dispatch EV réel, `evFloorW()` = exigence réelle, rang de borne configurable | `1.15.2+etm23` |
**Détail 3b** :
- `EnergyArbitrator : public SmartChargingManager` — justification dans `## DÉCISIONS DE DESIGN`
- `EvAdapter` + `RuleBasedScheduler` implémentés
- Build : **0 erreur / 0 warning**
- `ETM_ARBITRATOR` **actif** dans `energyplugin.pri`
- Iso-fonctionnalité prouvée :
- Simulation : 226 lignes décisions identiques (Theoretically / Surplus / Current load), diff = 0
- Tests charging : 57 lignes décisions identiques, diff = 0 ; 46/46 PASS ref ET ETM
- [Arbitre] présents avec raisons françaises pour les 4 cas (idle, surplus PV, aWATTar, deadline)
**Détail 3c (clôturée, commits `6298d5d` waterfall → `54ba229` testMeterSilentFallback)** :
- `LoadAction.force = false` — bypass des verrous en repli sécurité.
- Adaptateur ECS à N paliers powerswitch (`applyRelayStage()`, verrous
`minOnS`/`minOffS`, bypass si `force == true`), enregistré explicitement auprès
de l'arbitre. **Classe remplacée depuis** : voir la ligne rév. 3 du tableau.
- `buildContext()` : `SurplusMeter` brut (`exportW = max(0, -meter->currentPower())`),
`loads[]` EV + charges pilotées ; `SurplusPv` déféré.
- Waterfall (tri priorité ASC = rang) + dispatch + watchdog L2 (mode dégradé
conservateur, planification suspendue) + `degradedMode`/notification + tests.
- **Correctif clé** `[3c-3-fix]` : surplus **net signé** (délestage en import) ;
clamp lock-aware ; **seam de temps unifié** (`now = ctx.timestamp`,
`lockWindow()` source unique) ; watchdog injectable
(`recordMeterUpdate`/`evaluateMeterFreshness`, déclencheurs sous
`#ifndef ENERGY_SIMULATION`).
- Tests : `testEcsSurplusPV` (4 régimes) + `testMeterSilentFallback` (stabilité +
reprise). Suite simulation **18/18**, charging **46/46**, plugin prod 0/0.
- **arm64 cross : NON vérifié dans le sandbox dev** (pas de toolchain /
Qt6-aarch64 / docker) → relève de l'infra de build CI (`etm-powersync-deploy`).
À confirmer là-bas.
> Le clamp lock-aware de 3c passait par `telemetry.minStage`/`maxStage`. Ces
> champs ont été retirés du `SurplusContext` en rév. 3 ; le verrou est aujourd'hui
> interne à l'adaptateur (`relayrouter.cpp:193-203`). Écarts connus entre cette
> cible et le code : `specs/spec_ecs.md` §1.
**3e CLÔTURÉE** (commits `83d5ad9` types → `d8079e8` testSgReadySurplus) :
- `SgReadyAdapter` : 4 états normés (kind:State), encodage 2 bits, `lockWindow` symétrique
(`minStateHold`, protection court-cycling PAC), **atomicité de transition** (`transientHarm` :
passe par le neutre/reco, jamais par blocage/forcé — contrat transport déporté).
- Scheduler : **mapping sémantique** (≥P4×1,2→forcé, hystérésis 1,2/1,0 ; ≥P3→reco ; sinon
normal ; état 1 jamais via surplus) + **waterfall UNIFIÉ** ECS+SG-Ready (un seul budget,
trié par priorité). Mode dégradé L2 → état 2 (mains off, jamais blocage ; SAFETY.md corrigé).
- Tests : `testSgReadySurplus` (montée · hystérésis · court-cycling · **budget partagé ECS↔PAC
avec inversion de priorité**) + `testEcsRelayTopologies` (ECS 1 relais + 3 relais
**non-cascadé** 1500→2000, off-before-on) — commit `dfdd988`.
- **DoD 3e** : amd64 0/0 ✓ · simulation **20/20** ✓ · `decisionReason` français (forcé/reco/normal/
verrou) ✓ · arm64 → CI (idem 3c).
- **Audit Doxygen** fait (5 findings → 0 : `\param now`, docs périmées 3c/3e) — commit `51760a7`.
- **`docs/TEST_TERRAIN.md`** créé : procédure Palier 1 (14 tests) pour le banc nymea-dev arm64.
**Détail lot B (frontière de télémétrie)** :
- **Le constat** : 90 s d'écoute sur `.75`, arbitre actif, ~2 kW exportés → **zéro notification
`NymeaEnergy.*`**. L'état runtime de l'arbitre vivait exclusivement dans
`qCInfo(dcNymeaEnergy)`. Ce lot lui donne un transport.
- `NymeaEnergy.GetLoadTelemetry` + `LoadTelemetryChanged`, **même charge utile**. Contrat :
`INTERFACE.md`.
- `LoadAction::reason` est un **`DecisionReason {code, params}`**, plus une `QString`. La ligne
française du journal est **rendue** depuis ce couple par `renderFr()` — source unique, jamais
maintenue en parallèle (`etm/types/decisionreason.h`).
- `ILoadAdapter::runtimeView(now)` : nouvelle méthode **pure virtuelle** — défaut, verrou en
secondes restantes, charge utile de mécanisme. Pure à dessein : un mécanisme ajouté qui
hériterait d'un défaut vide sortirait de la télémétrie sans bruit.
- `PlanBudget` (interne au plan, jamais sérialisé vers l'optimiseur socket, comme
`LoadAction::funding`) : `surplusW`, `evReservedW`, `recreditedW`, `allocatedW`, `remainingW`.
- `LoadConfig::domain` — métadonnée d'intention, énumération fermée, **non lue par l'arbitre**.
- ⚠️ **`loads[]` n'était PAS en correspondance 1:1 avec `GetLoadConfig`** — le `SgReadyAdapter`
du banc, codé en dur, était arbitré et publié tout en restant inconfigurable. **Refermé par le
lot B-bis** (ci-dessous) : la règle de détection écrite dans `INTERFACE.md` à `+etm14` a été
RETIRÉE, et aucun `configurable: false` n'a jamais été ajouté — il n'aurait plus de porteur.
- **Déploiement sur box : `etm-powersync-deploy/deploy.sh`, jamais un `dpkg -i` à la main.**
Un glob sur `/var/lib/etm-deploy/*.deb` a rétrogradé `.75` de `+etm12` à `+etm9` le
2026-08-25. Détail : `DEPLOY.md` §7.6 (dépôt deploy) et `docs/RELEVE_LOTB.md` §8-bis (a).
**Détail lot B-bis (la PAC entre en configuration)** :
- **Le bloc `registerSgReadyAdapter()` d'`energypluginnymea.cpp` est SUPPRIMÉ**, avec la méthode
d'enregistrement, la table `m_sgReadyAdapters` et `claimedRelays()`. Il n'existe plus **aucun**
chemin d'enregistrement en dur : `m_loadAdapters` est la table UNIQUE des charges arbitrées, et
toutes ses entrées viennent de `LoadConfigStore`. `GetLoadTelemetry` publie donc exactement
l'ensemble de `GetLoadConfig` — **par construction, pas par discipline**. L'inclusion ne vaut que
dans ce sens : une charge `enabled: false` est configurée sans être publiée (contrat §9).
- **La PAC du banc est devenue une `LoadConfig` `sg-ready` ordinaire** (`pac-terrain`, K1/K2,
rang 2). Ses `estimatedPowerW` — `{3: 1500, 4: 3000}` — sont **reconduits du bloc supprimé, donc
PROVISOIRES et NON MESURÉS** : ils décrivaient une PAC inventée, et aucune PAC n'est sous tension
sur K1/K2. Marqués comme tels dans `docs/TEST_TERRAIN.md` §1.d, à remplacer par la machine
réellement câblée — `estimatedPowerW` sert de base au recrédit budget, une estimation fausse
décale l'allocation de tout le waterfall, pas seulement celle de la PAC.
- **TROU DE SÉCURITÉ refermé au passage** : le repli L2 envoyait un `Setpoint(0)` à toute la table
des charges pilotées et un `State(2)` à la table séparée de la PAC en dur. Une PAC de
**configuration** vit dans la première : elle recevait un `Setpoint` que `SgReadyAdapter` rejette
d'entrée — et rien d'autre. Sous compteur muet elle restait en **état 4 (forcé)**, quand
`SAFETY.md` §L2 exige l'état 2. Invisible dans la charge utile (le motif `DEGRADED_L2`
s'affichait bien, seule l'ÉCRITURE manquait). Règle qui en sort : dès qu'un mécanisme rejoint la
table commune, tout code qui fabrique une action lit `supportedKinds` au lieu de présumer du kind.
- **ECS-411 appliqué au SG-Ready** : l'état de départ est **relu sur les contacts**, plus supposé
à 2. Le vrai piège est le corollaire LM-104 — `applyAction(état 2)` sur un adaptateur qui se
*croit* en 2 est idempotent, donc muet, donc les contacts restent en 4 : l'hypothèse de prudence
devient un masquage de panne. Le relevé publie ce qui a été **lu**, contact par contact, avec
l'issue — y compris quand l'issue est l'état neutre (ECS-411-b) — et passe en avertissement si
un contact est injoignable.
- **ECS-412 appliqué au `minStateHold`** : un `m_lastSwitch` nul vaut « commutation venant d'avoir
lieu », jamais « jamais commuté » — sans quoi un redémarrage de `nymead` pendant que le
compresseur tourne autoriserait une coupure immédiate. L'armement est **paresseux** : la première
décision reçue pose l'estampille et le verrou expire. Jamais permanent — c'est le blocage
circulaire corrigé côté routeur le 2026-08-09. Conséquence à connaître : **toute reconstruction
d'adaptateur réarme le verrou pour sa durée** (900 s sur une PAC réelle).
- **La suppression du bloc en dur ne passe PAS par `applySafeState()`** — et ne le peut pas : c'est
un événement de **compilation**, pas d'exécution ; ECS-413 ne s'applique qu'à un adaptateur qui
sort d'une configuration vivante. Le filet est **procédural** et documenté en `TEST_TERRAIN.md`
§1.d : **poser la config sg-ready AVANT d'installer `+etm15`**, sinon K1/K2 se retrouvent sans
propriétaire, dans leur dernier état commandé — possiblement fermés.
### Ce que le moteur sait faire aujourd'hui
- **Arbitrage central unique** : un budget de surplus net signé, cascade par **priorité** (rang).
- **Charges** : EV **dans le waterfall** (3g-1/3g-2 : même budget, même rang, mêmes motifs ;
seuls l'échéance et le tarif dynamique restent au proxy, financés au réseau — et une borne
**sans voiture assignée** y entre aussi depuis LM-1208-b : elle perd l'échéance et la cible
d'énergie, pas l'arbitrage),
**charges pilotées en watts** (kind `Setpoint` —
`RelayRouter` pour une combinaison de relais, `EtmVariableLoadAdapter` pour une consigne
continue), **SG-Ready PAC** (4 états, kind `State`) — toutes les charges non-EV classables
ensemble sur le budget partagé.
- **Sécurité** : protection compresseur (verrous `minOn`/`minOff` par charge tenus dans
l'adaptateur, seam de temps unifié `now = ctx.timestamp` ; fenêtre d'états
`minState`/`maxState` exposée au scheduler pour SG-Ready) ; watchdog L2
(compteur muet >90 s → mode dégradé conservateur, planif suspendue,
reprise par recalcul) ; `verifyOverloadProtection()` amont intacte ; `degradedMode` notifié.
- **Local-first** : zéro cloud (invariant 10).
### DÉFÉRÉ (ordre indicatif)
- **Passe README + contrats** : `OPTIMIZER_PROTOCOL.md` ne reflète PAS encore plusieurs ajouts
— `LoadAction.force` (bypass sécurité L2), `telemetry.minState/maxState` (fenêtre de verrou
SG-Ready), `degradedMode` (notification), et la forme `relay-router` de `LoadConfig`.
`minStage`/`maxStage` ne sont plus à documenter : les champs ont été retirés du contexte en
rév. 3. **Ajout du lot B** : `PlanBudget` (comptabilité interne du plan, non sérialisée) et
`ILoadAdapter::runtimeView()` n'y figurent pas non plus. À documenter dans le protocole publié
+ README architecture. `INTERFACE.md` est, lui, à jour du lot B.
**Prochaine session doc** (pas avant le terrain).
- **Silences de REFUS restants (règle 7-c/7-d), signalés et NON corrigés au lot B.** Trois
refus rendent la main sans une ligne, alors que l'application correspondante est en `qCInfo` :
`relayrouter.cpp` (charge en défaut → action refusée), `etmvariableloadadapter.cpp` (idem), et
`sgreadyadapter.cpp` **deux fois** (charge en défaut, et encodage sans état 2 → `m_usable`
faux, donc PLUS AUCUNE commande depuis la construction). Écartés du lot B à dessein : le
défaut est déjà annoncé une fois en `qCCritical` à son apparition, et il est désormais publié
en continu par `GetLoadTelemetry` (`faultCode`) — mais l'asymétrie de journal reste, et le cas
`m_usable` n'a, lui, **aucune** annonce à sa naissance. À traiter avec ECS-414.
- **Waveshare D8** : plugin DEVICE (8 powerswitch) sous l'adaptateur — **session dédiée**
(chantier transport Modbus RTU/RS485).
- **V2C** (borne EV) : intégration — **session dédiée**.
- **3d** SocketScheduler (handshake/heartbeat/repli optimiseur).
- **3f** BatteryAdapter (constraints + charge réseau plafonnée) + waterfall grid-funding.
**Sa forme est décidée** : `specs/spec_loadmodel.md` **§14** — le stockage devient une charge
rangée du waterfall (« l'eau chaude avant la batterie » se règle au glisser-déposer), le seuil
`batteryLevelConsideration` cesse d'être un rang pour redevenir une réserve, et le double
commandeur se **mesure** (`controlHealthy`) au lieu de se deviner.
- ~~**3g** transplantation EV dans le waterfall unifié~~ — **FAITE** (3g-1 + 3g-2). Reste la
moitié MATÉRIELLE de LM-1204-b, qui exige deux bornes réelles au banc.
- **Couche config priorités** (UI Flutter drag-and-drop) — cf. `## ROADMAP`.
Note : **toutes** les charges non-EV sont configurables et persistées — les charges pilotées
depuis `7184fe4`, la PAC SG-Ready depuis le **lot B-bis** (`LoadConfigStore`, RPC
`Get`/`SetLoadConfig`, reconstruction à chaud sur `changed()`). Plus rien n'est codé en dur, et
c'est un invariant de contrat, pas une propreté : cf. `docs/TEST_TERRAIN.md` §1.d. Ce qui reste
déféré ici n'est donc que l'**UI** de classement. La précondition ECS-110 est levée : le
`Q_ASSERT(m_stateRelays.contains(2))` a cédé la place au refus explicite `m_usable`, présent
dans le binaire release — c'était bien **avant** le basculement, pas après.
- **arm64** cross-compile validé en pré-déploiement (infra CI `etm-powersync-deploy`).
- **`Doxyfile` + job CI `doxygen -W`** (automatiser l'audit doc).
> **FAIT (2026-08-28)** : `assignedCarId` est **retiré** du filtre d'entrée du waterfall
> (`evParticipatesInWaterfall`) — `specs/spec_loadmodel.md` **LM-1208-b**. Restent deux
> exclusions, et elles ne disent pas la même chose : le mode manuel est une INTENTION,
> l'absence de véhicule branché un CONSTAT. **LM-1208-c** est venu avec : le proxy ne prépare
> pas une borne sans voiture, elle entre donc dans le waterfall sans `ChargingProcessInfo`, et
> l'accesseur rendait un défaut *plausible* (1 phase, pas de bascule) au lieu de dire « absent »
> — une borne triphasée annoncée monophasée, budget divisé par trois, en silence. D'où
> `internalHasProcessInfo()` et le repli sur le `phaseCount` de la borne.
> Éprouvé par `testChargerWithoutCarIsArbitrated`, **vérifié échouant sans le correctif**
> (3 680 W au lieu de 6 000). Suites : simulation 29/29, charging 16/16, loadmodel 18/18,
> spotmarket 7/7 — amd64 0 erreur / 0 avertissement.
**DÉPLOYÉ SUR `.75` — `1.15.2+etm32`, 2026-08-30.** `counts` est en ligne et l'identité de
réconciliation se vérifie sur des données réelles : Σ counts[`budget.allocatedW`] = 1 700 =
`budget.allocatedW`, Σ counts[`draw.committedW`] = 0 = `draw.committedW`.
> **`EV_GRID_START` n'est PAS reproductible au banc, et il ne faut pas le fabriquer.**
> `pluggedIn` est en lecture seule — aucune action RPC ne l'écrit — et sans `assignedCarId` la
> borne reste hors `loads[]`. Le partage entre les deux registres est une propriété du **code**,
> pas du matériel : la suite de simulation en porte la trace complète
> (`testEvGridStartIsAnnouncedAndBounded`), reproduite verbatim dans
> `docs/BRIEF_depuis_plugin.md`. Le banc n'ajouterait que la confirmation que le binaire arm64
> fait pareil.
**DÉPLOYÉ SUR `.75` — `1.15.2+etm27`, 2026-08-29.** Cross-build arm64 (`build-etm27`), posé par
`deploy.sh`, version relue, `nymead` actif. Vérifié sur machine : aller-retour `Get`→`Set`
verbatim **neutre** ; `SetLoadConfig` à rangs doublés **refusé** avec sa raison, 4 charges
intactes et rangs inchangés ; `rankOrigin: "auto"` fabriqué par un client **refusé** ;
`rankOrigin` publié à `""` sur les quatre entrées.
> **Deux vérifications ne sont PAS observables sur `.75`, et il ne faut pas fabriquer la
> condition** (même arbitrage que le `meterThingId`) :
> - **La migration d'une config à rangs doublés.** La config du banc porte les rangs 1-2-3-4 :
> elle a été réparée à la main le 2026-08-28 au soir. Le cas est éprouvé par
> `testRankUniquenessRefusesWriteButNeverDiscards`, qui écrit un vrai JSON à rangs doublés,
> recharge et vérifie la survie des deux entrées — le banc n'ajouterait que « le binaire arm64
> fait pareil ». **Il se rencontrera sur une box cliente non migrée, et c'est là qu'il
> comptera.**
> - ~~**`rankOrigin: "auto"`**~~ — **OBSERVÉ le 2026-08-29** : une seconde wallbox simulée
> appairée après `+etm27` a été provisionnée au **rang 5**, aucun autre rang modifié, avec
> `rankOrigin=auto` et la ligne de journal complète. L'écho verbatim d'une entrée portant
> `"auto"` — y compris avec un **renommage ailleurs** — est **accepté** : un renommage n'est
> jamais bloqué par une borne auto-provisionnée. Le seul refus est l'**écho périmé** (payload
> mis en cache avant un déplacement qui a fait tomber la marque), et le remède est de relire.
>
> ⚠️ **L'AUTO-PROVISIONNEMENT NE TOURNE QU'À LA CRÉATION DE L'ADAPTATEUR** — mesuré le
> 2026-08-29, et ma note de la veille disait le contraire. Retirer l'entrée d'une borne dont
> le Thing vit ne la fait **PAS** recréer : `syncAdapters()` n'appelle
> `provisionEvChargerConfig()` que dans la branche `!m_adapters.contains(id)`, et l'adaptateur
> survit à la suppression de l'entrée. Constaté : rien recréé en 140 s.
>
> **Conséquence** : la borne reste **arbitrée** mais **absente de `GetLoadConfig`**, à un rang
> de queue non réglable (`evPriority()` → 1000). C'est le trou de 3g-2 — « arbitrée mais
> absente de la configuration » — rouvert par le côté client. Il ne se referme qu'au
> **redémarrage de `nymead`**.
>
> **CORRIGÉ en `1.15.2+etm28`** (LM-1209-e) : le rattrapage est cyclique, sa condition est
> « cette borne n'a pas d'entrée » et non « l'adaptateur vient de naître ». Une entrée
> supprimée revient au cycle suivant, **en queue et en `rankOrigin: "auto"`** — le rang posé
> par l'installateur est perdu avec l'entrée, ce qui fait de la suppression un geste
> déclassant, à ne pas confondre avec `enabled: false`. Le REFUS de provisionnement, lui, ne
> s'annonce qu'une fois par borne : cyclique, il noierait sinon le journal.
**DÉPLOYÉ SUR `.75` — `1.15.2+etm26`, 2026-08-28.** Cross-build arm64 dans `build-cross-arm64`
(bundle git → `/root/build-etm26`), posé par `deploy.sh`, version relue sur la box, `nymead`
actif. `measuredW` a bien disparu de la charge utile ; `measurement {source, powerW}` est publié.
> **Ce que la box publie est la CONFIGURATION VOULUE, pas un angle mort** — et il ne faut pas la
> défaire pour « mieux voir » (consigne Patrick, 2026-08-28) :
> - `chauffe-eau` et `pac-terrain` portent chacun un `meterThingId` vers un compteur RÉEL et
> distinct (`ECS-Meter` 1 500 W, `PAC-Meter` 800 W). Ils publient donc `source: "meter"`, et
> le chemin `device` n'est jamais atteint. **La `pac-terrain` ne publie donc PAS `none`** : le
> compteur explicite l'emporte, y compris sur un `sg-ready`. LM-1105-b décrit ce que la PAC
> publierait *sans* compteur dédié.
> - Les **deux bornes sont `pluggedIn: false`** (V2C Trydan en `ChargingModeEcoWithMinCurrent`,
> wallbox simulée en `ChargingModeEco`, aucune voiture assignée — donc plus exclues par ce
> motif depuis `+etm25`). Elles sont **absentes de `loads[]`**, pas en `none` : la V2C ne peut
> donc pas montrer son `source: "meter"` tant que rien n'y est branché.
>
> ⚠️ **NE PAS retirer ces `meterThingId` pour observer `device` / `none`.** C'est techniquement
> possible — champ souple, aucun rebuild ni réarmement de verrou — et c'est quand même à ne pas
> faire : ces compteurs ont été posés le 2026-08-27, `meter` qui l'emporte EST le comportement
> voulu, et les deux chemins masqués sont **déjà éprouvés en simulation, vérifiés échouant**
> (`testMeasurementSourceIsPublished`).
>
> **Corollaire de « à guetter, jamais à fabriquer » : le banc sert les cas qu'on ne sait pas
> fabriquer, pas à rejouer ce qu'on a démontré.** Défaire une configuration juste pour la voir
> autrement échange une preuve contre une observation — et laisse la box dans un état que
> personne n'a voulu.
>
> Note de méthode, tirée du même passage : trois attentes de vérification étaient fausses parce
> qu'elles raisonnaient **par mécanisme** là où la **configuration** décide. C'est la même erreur
> que LM-1106 corrige côté client (« décider sur `source`, jamais sur `adapter` ») — elle se
> refait aussi bien en lisant un banc qu'en écrivant une app.
**À VÉRIFIER AU BANC, distinct et non tranché** : **le compteur racine lit ~1,5 kW de moins que
la somme des charges mesurées** (3 250 W contre 3 980 W pour la seule V2C, plus 800 W de PAC).
Même famille que l'aveuglement documenté aux résistances ECS. Ce n'est pas un problème
d'arbitrage — câblage ou vue SunSpec — mais un budget juste suppose un compteur qui voit tout.
**FAIT (2026-08-28, `1.15.2+etm26`)** — **la mesure par charge se lit sur l'appareil
piloté**, `specs/spec_loadmodel.md` **LM-1105/LM-1106**, sur un constat d'usage de l'agent app.
`meterThingId` était la SEULE source : il fallait désigner une borne comme son propre compteur
pour qu'elle publie ce qu'elle mesure déjà. Il reste **prioritaire** — un réglage sans effet est
pire que pas de réglage — mais cesse d'être requis.
Ce que la vérification a trouvé, et qui décide de la forme : `telemetry().currentPowerW` porte
**déjà trois régimes** sous un seul nom — mesuré (`evcharger`), mesuré OU palier nominal
(`relay-router`, dont le booléen `metered` est calculé puis jeté), déclaré (`sg-ready`,
délibérément, invariant 8). D'où `measurement {source, o:powerW}` en remplacement de
`measuredW`, avec `source: "none"` comme état positif. **Contrat déplacé** : `INTERFACE.md`.
`ILoadAdapter::deviceMeasurement()` est **pure virtuelle**, comme `runtimeView()` : un mécanisme
ajouté qui hériterait d'un « non mesurable » par défaut sortirait de la mesure sans bruit, et son
écran afficherait « pas mesurable » avec l'autorité d'un constat.
Deux affinements imposés par l'implantation, tous deux de modèle : **LM-1105-a** — une mesure
partielle n'est pas une mesure (relais mixtes → `none`) ; **LM-1105-b** — la règle présuppose que
le Thing piloté est TRAVERSÉ par la puissance, ce qu'un contact SG-Ready n'est pas : `sg-ready`
est `none` par construction, et c'est le seul mécanisme où `meterThingId` reste la seule voie.
Éprouvé par `testMeasurementSourceIsPublished` (trois sources + les deux règles de priorité),
la règle « capacité jugée sur la classe » **vérifiée échouant** quand on la juge sur les relais
actifs.
> Ce lot ne touche PAS ce que l'arbitre lit : LM-1101 tient, et le recrédit anti-clignotement
> continue de lire `LoadContext::telemetry.currentPowerW`, qui vient de l'adaptateur et n'a
> jamais transité par `meterThingId`. Ne pas y voir une réouverture de la correction B.
> **FAIT (2026-08-28, `1.15.2+etm27`)** — **le rang par défaut des bornes auto-provisionnées**.
> Tranché : **insertion en queue** (`1 + max(rangs)`), jamais en tête —
> `specs/spec_loadmodel.md` **LM-1209**. L'auto-provisionnement remplit un trou, il ne décide
> pas : le rang est un choix de l'installateur, et une détection ne réordonne pas ce qu'un
> humain a décidé. Ne contredit pas le défaut « le VE d'abord », qui décrit une mise en service
> INITIALE et non un ajout ultérieur.
>
> Trois pièces avec : **LM-1209-a** l'unicité de rang garantie plutôt que supposée (et la mise
> en garde qui va avec — l'égalité ne coûte pas le déterminisme, le tri est total sur
> `(rang, identifiant)` depuis 3g-2 ; elle coûte la GOUVERNABILITÉ) ; **LM-1209-b** insérer ne
> renumérote personne ; **LM-1209-c** `o:rankOrigin` — la création se dit.
>
> **Et une règle générale en est sortie, LM-1209-a-bis** : *une vérification qui ÉCARTE et une
> vérification qui REFUSE ne peuvent pas être la même fonction.* Mettre l'unicité de rang dans
> `validateSet()` — que `load()` utilise pour écarter — aurait fait perdre des charges au
> démarrage sur une configuration héritée, sans `applySafeState`. `validateRanks()` est donc
> séparée et n'est appelée que par `setConfigs()`. Migration : au chargement, **signalé
> bruyamment, conservé, réparé à la première écriture**.
**PROCHAINE ACTION** : rien de tranché en attente.
**§10 — DÉBLOQUÉ (2026-08-29).** L'agent app a rendu sa maquette —
`etm-powersync-app/docs/mockups/deux_passes_eco_confort_mockup.html`, **elle fait foi** — et 8.5
est tranché. Décisions et exigences : `specs/spec_loadmodel.md` **LM-1010**. Précédence des
motifs et réponse à R4 : `docs/DESIGN_10.md`.
> ⚠️ **PRÉCONDITION DU §10, et elle n'est pas dans la maquette** *(Patrick, 2026-08-30)* : **le
> §10 ne part pas sans l'extension du DÉLESTAGE au waterfall.** C'est LM-1006 couplage 1 — si
> tous les planchers éco se servent au réseau le même matin sans soleil, ils s'additionnent sur
> le disjoncteur de branchement. Or la protection de surcharge d'aujourd'hui ne déleste **que
> les bornes**. Éco et délestage sont **le même lot** ; les séparer livrerait un plancher
> capable de faire disjoncter une installation.
**FAIT — R2 : `funding` est descendu au NIVEAU** (`1.15.2+etm30`, posé sur `.75`). `levels[]` est
en ligne, `funding` a disparu du niveau de la charge, et l'identité de réconciliation est écrite
dans `INTERFACE.md` — **vérifiée sur la vraie box** : Σ targetW(surplus) = 5 000 =
`budget.allocatedW`.
**PROCHAINE EXIGENCE : R3 — chaque watt dans exactement un compteur, mapping publié.**
**BLOQUÉE, et le blocage est un résultat** : une des trois destinations n'en est pas une.
> **`budget.evReservedW` n'est PAS un registre d'allocation.** C'est un **terme de correction du
> budget**, et il mélange deux natures : la correction A — commandé non encore mesuré, calculé
> *avant* la cascade (`rulebasedscheduler.cpp:90-106`) — et la part réseau d'`EV_GRID_START`,
> qui est une vraie tranche d'allocation (`:355`).
>
> Donc une borne servie au réseau qui tire déjà ce qu'on lui a commandé y contribue **0** alors
> que son `targetW` vaut 7 000 W : `Σ counts == targetW` serait **vrai au premier cycle et faux
> ensuite**. Ça passerait les tests et dériverait en production — la pire forme d'échec.
>
> **Ce que R3 a mis au jour** : les watts **achetés** n'ont aucun registre aujourd'hui.
> `draw.committedW` devient donc un **préalable** à R3, pas une suite. Il est petit — somme des
> `targetW` financés au réseau — et ne change **aucune arithmétique** : la ligne 355 est de la
> publication seule. Ce qui change est le **sens** d'`evReservedW`, qui redevient une correction.
> **Proposé à l'app** (brief §10), en attente de sa réponse : c'est son identité de
> réconciliation.
*Ancienne prochaine action, faite :*
**R2 — `funding` descend au NIVEAU.** C'est la seule des huit
exigences qui soit un **changement de forme d'un champ existant** et non un ajout : chaque écran
qui s'appuiera d'ici là sur un `funding` par charge en augmentera le coût de déplacement, les
sept autres attendent sans se renchérir. Au §10, la même charge sera financée **au réseau pour
son éco et au surplus pour son confort dans le même cycle** — un `funding` par charge devient
indécidable, et c'est lui qui porte l'identité de réconciliation de l'app.
> **À corriger dans la maquette avant de coder** : elle pose que `allocatedW` publie « ce qui a
> été **commandé** ». C'est faux — `buildTelemetry()` publie le plan, et `applyActionsToAdapters()`
> reçoit le slot **`const`**. `allocatedW` est du **décidé**, comme `targetW` le sera. L'écart que
> l'écran veut nommer est entre `allocatedW` et la charge utile de **mécanisme**, déjà publiée.
> Détail et mesure : `docs/DESIGN_10.md`.
**§10 — l'ancien point ouvert, pour mémoire.** Le design est écrit :
**`docs/DESIGN_10.md`**. Sur les cinq points à trancher, **quatre le sont** — réserve batterie
(8.1), repli dégradé (8.3), plafond §14a portant les deux formes sans conversion (8.2), cible de
confort en comparateur (8.4).
Reste **8.5 — l'ordre de service apparent** : un plancher éco de rang 4 passe devant un confort
de rang 1, ce qui est voulu et aura l'air d'un bug. **Question posée à l'agent app** le
2026-08-29, `docs/BRIEF_depuis_plugin.md` §9 — sur quoi la liste est ordonnée, une entrée ou
deux par charge, et ce qu'on voit d'une charge dont l'éco est servi et le confort non.
> ⚠️ **Le §10 ne se code pas avant cette réponse.** La troisième question n'a aucun équivalent
> dans la télémétrie d'aujourd'hui — un motif, une allocation, un seul de chaque. Commencer par
> le moteur reviendrait à inventer une charge utile puis à demander à l'écran de s'en arranger.
Deux notes de conception le contraignent, à relire avant de coder :
**LM-1006-1** (le délestage est un plafond de soutirage à trois sources) et **LM-1009**
(une échéance ne vaut que ce que son avancement se mesure — `sessionEnergy` est publiée et lue
par personne, tandis que le pourcentage comparé à la cible est estimé par le moteur lui-même).
LM-1009 porte **trois décisions arrêtées** — avancement en énergie livrée, obligation par session
d'abord, estimation étiquetée à la publication — et **deux conditions de livraison** : dégrader
visiblement sur les bornes sans `sessionEnergy`, et savoir dire lequel des deux régimes
s'applique. Ce ne sont pas des recommandations.
Ensuite : **passe contrats** (OPTIMIZER_PROTOCOL + README).
> Le **trou de `pluggedIn`** trouvé au même passage est CORRIGÉ — une borne sans véhicule
> branché est hors arbitrage, elle n'absorbe plus le budget de la cascade.
> **Le chantier de documentation a exhumé son premier défaut réel** (2026-08-29). `ECS-111` —
> « on publie la puissance d'un relais disparu » — dormait dans une table de statut supprimée
> depuis, et est ressortie dans les quatorze règles « spécifiées seules en territoire livré »
> que `tools/gen-rules.py` a listées. La lecture a montré que le chiffre fantôme n'était pas
> seulement affiché : il alimentait le **recrédit**, donc le **budget**, pendant qu'`available`
> restait vrai. Ni un test ni le banc ne l'avaient vu. **Corrigé le jour même** (`ECS-111`,
> option (a) — sortie de l'arbitrage, maintien dans la comptabilité).
**Restent à guetter au banc, jamais à fabriquer** : `EV_GRID_START` (fenêtre
`[plancher × 0,5 ; plancher[`), `BATTERY_RESERVE` avec un surplus réel sous le seuil, et
LM-1204-b avec **deux bornes réelles** — la Terra AC ne répond toujours pas à l'ARP.
### Sauvegarder l'historique quand la forge est injoignable
**Un bundle COPIÉ ailleurs n'est pas une sauvegarde. Un bundle CLONÉ à blanc depuis ailleurs en
est une.** La différence ne se voit que le jour où elle compte, et ce jour-là il est trop tard
pour la découvrir.
```bash
# 1. produire — --all, pas une branche : les tags et les autres branches comptent aussi
git bundle create ~/etm-plugin-$(date +%Y%m%d-%H%M).bundle --all
# 2. déposer sur une AUTRE machine (le banc fait l'affaire)
ssh etm@192.168.1.75 'mkdir -p ~/git-secours'
scp ~/etm-plugin-*.bundle etm@192.168.1.75:~/git-secours/
# 3. PROUVER qu'il est restaurable — depuis la machine de destination, pas depuis celle-ci
ssh etm@192.168.1.75 'cd ~/git-secours && B=$(ls -t *.bundle | head -1) &&
git bundle verify "$B" && git clone -q --bare "$B" /tmp/v &&
git --git-dir=/tmp/v log --oneline -1 &&
git --git-dir=/tmp/v rev-list --count HEAD && rm -rf /tmp/v'
```
L'étape 3 est celle qu'on saute. Elle coûte dix secondes et c'est la seule qui distingue
« restaurable depuis une autre machine » de « présent sur une autre machine ».
> **Quand.** Dès que la forge est injoignable et que des commits s'accumulent en local. Le
> 2026-08-30, 64 commits ne vivaient que sur `etm-powersync-dev`, fibre coupée — et le bundle
> aussi, tant qu'il y restait.
>
> **Ce que ça ne remplace pas** : un `git push`. Un bundle est une photo, pas un remote — il ne
> se met pas à jour, et personne d'autre ne travaille dessus. À rafraîchir à chaque session tant
> que la forge ne répond pas.
### Lire le journal de la box — et ce qu'une absence prouve
**`journalctl -u nymead`, SANS `sudo`.** L'utilisateur `etm` est dans le groupe `adm`, qui
donne accès au journal. `sudo journalctl` **échoue** — pas de TTY pour saisir le mot de passe —
et l'échec part sur **stderr** : `stdout` est vide, le `grep` en aval ne trouve rien, et la
commande rend 0. **Un relevé conduit ainsi ne dit rien et en a l'air.**
> Constaté le 2026-08-29, sur mes propres relevés des deux jours précédents : deux recherches
> « la ligne X apparaît-elle ? » n'avaient **rien lu du tout**. Et l'explication que j'avais
> donnée à l'absence — « cette ligne est en `qCDebug`, le niveau n'est pas actif » — était
> **fausse** : les lignes `D | NymeaEnergy:` sortent bel et bien dans le journal de `.75`.
> Une cause plausible avancée pour un silence qu'on n'a pas mesuré, c'est le corollaire LM-104
> appliqué à l'instrument : l'hypothèse de prudence devient un masquage de panne.
**RÈGLE — une absence ne s'affirme que dans un flux dont on a prouvé qu'il coule.** Citer une
ligne qu'on a LUE dans la même fenêtre, puis dire ce qui n'y est pas. `docs/RELEVE_LOTD.md`
§ (09:14:55) est le modèle : il cite la ligne `0 créée(s), 0 mise(s) à jour, 2 INCHANGÉE(S)`
qu'il a lue, **puis** conclut « aucune ligne *construit depuis config*, aucun ECS-413, aucune
commutation ». L'absence y porte, parce que la présence l'accompagne.
> **Audit du 2026-08-29** : les relevés du dépôt ont été repris sur ce critère. **Aucun ne
> repose sur un journal vide** — `RELEVE_LOTBbis.md` §267 parle d'un dry-run `reprepro`, pas du
> journal. Le seul défaut trouvé était dans la **procédure** : `docs/TEST_TERRAIN.md` prescrivait
> `sudo journalctl` dans son helper `logs()`, celui qu'un opérateur lance pendant un essai —
> corrigé. Sa ligne 51, elle, était déjà juste : le document se contredisait.
**Remotes git — cartographie VÉRIFIÉE le 2026-08-30** (la précédente était fausse sur deux
points, et l'un d'eux aurait fait échouer tout push) :
| Remote | URL | État |
|---|---|---|
| `origin` | `https://git.etm-powersync.fr/ETM-Schurig/etm-powersync-energy-plugin-etm.git` | dépôt de travail, mais **l'HTTPS n'authentifie pas** |
| **`origin-ssh`** | `gitea-lan:ETM-Schurig/etm-powersync-energy-plugin-etm.git` | **LE MÊME dépôt, par SSH — c'est la route qui marche** |
| `etm-public` | `gitea-lan:ETM-Schurig/powersync-energy-plugin-etm.git` | ⚠️ **le dépôt N'EXISTE PAS** — `Cannot find repository` |
| `etm-pro` | `gitea-lan:...-etmpro.git` | reliquat historique — ne pas utiliser |
**Pousser : `git push origin-ssh feature/beta-rulebased`.**
> **`gitea-lan` = `192.168.1.113`**, et c'est la **seule route SSH qui fonctionne** : le port 22
> de `git.etm-powersync.fr` est filtré. Le même Gitea sert les deux noms — d'où le fait qu'un
> `origin` en HTTPS et un `origin-ssh` en SSH désignent **le même dépôt**, pas deux.
>
> **Ce qui était faux avant** : « `gitea-lan` = miroir public GPL, ne jamais pousser
> directement ». Le chemin `powersync-energy-plugin-etm.git` (sans le préfixe `etm-`) ne
> correspond à **aucun dépôt** — un push y aurait échoué, pas publié. La prudence était donc
> fondée sur une cartographie inexacte, et elle bloquait la seule route disponible.
>
> **Ce qui reste vrai** : la publication du miroir public GPL est un geste **manuel de
> Patrick** (`sync-public.sh`). Vérifier l'ascendance avant tout push
> (`git merge-base --is-ancestor <distant> HEAD`) : c'est ce qui distingue une avance rapide
> d'un écrasement.
---
## INVARIANTS BUILD / PACKAGING (ne pas re-questionner)
### Nom et version du paquet Debian
| Champ | Valeur | Raison |
|---|---|---|
| **Source / Package** | `powersync-energy-plugin-nymea` | Fork ETM Voie B (FORK-WORKFLOW.md) — distingué de l'amont `nymea-energy-plugin-nymea` |
| **Version** | `<nymea>+etmN` (N incrémenté à chaque release) | Ex. `1.15.2+etm1` si la box cible tourne nymea 1.15.2 |
| **Distribution** | `trixie` | OS cible hems (arm64 Debian 13 Trixie) |
| **Provides/Conflicts/Replaces** | `nymea-energy-plugin-nymea` | Voie B obligatoire : remplace l'amont proprement, sans double chargement |
| **TARGET `.so`** | `libnymea_energypluginnymea.so` | Inchangé (nom de chargement nymea fixé par l'amont) |
### Build cross arm64
- **Hôte** : conteneur LXD `build-cross-arm64` sur `etm-powersync-dev` (amd64)
- **Commande** : `dpkg-buildpackage -aarm64 -b -uc -us` dans `/root/etm-powersync-energy-plugin-etm`
- **Dépendances arm64 requises** : `libnymea-dev:arm64`, `libnymea-energy-dev:arm64`,
`libnymea-tests-dev:arm64`, `nymea-experience-plugin-energy:arm64`, `qt6-base-dev:arm64`.
**Versions constatées au build du 2026-08-08** (`1.15.2+etm3`) : nymea
`1.15.2+202606191336~trixie1`, Qt `6.8.2+dfsg-9+deb13u2` — et non `1.15.0`/Qt5 comme
l'indiquait cette section. La chaîne est **Qt6** de bout en bout, cf. ci-dessous.
- **`lrelease`** : fourni par `qt6-tools-dev-tools` — nécessaire si absent du conteneur
- **Arbre vierge : lancer `lrelease` AVANT le premier `dpkg-buildpackage`.** Constaté le
2026-08-09 en construisant `1.15.2+etm7` depuis un `git archive`. `energyplugin.pro`
résout `$$files(translations/*.qm)` au moment de `qmake` ; sur un arbre sans `.qm`, la
liste est vide, les traductions ne sont pas installées, et `dh_install` échoue en
`missing files` **après** que `lrelease` les a pourtant fabriquées. Le clone canonique
`/root/etm-powersync-energy-plugin-etm` masque le défaut : ses `.qm` traînent d'un build
antérieur. Remède : `lrelease energyplugin/translations/*.ts`, puis supprimer
`.qmake.stash` et les `Makefile` avant de relancer. Ce n'est pas un défaut du code.
- **Ne pas builder sur le Pi** : Zero 2 W, 512 Mo → OOM garanti
### Tests hors paquet deb
- Les tests (`tests/`) ne sont **pas compilés** dans le `.deb` de prod.
- `etm-powersync-energy-plugin-etm.pro` : tests sous `build_tests { SUBDIRS += tests }` uniquement.
- `debian-qt6/rules` : `DH_OPTIONS=-N nymea-energy-tests` — exclut le paquet tests de tous les
outils dh. **C'est bien qt6 qui construit** : `debian` → `debian-qt6`, dont les `rules`
portent `dh $@ --buildsystem=qmake6`. `debian-qt5/rules` (`--buildsystem=qmake`) n'est plus
la référence ; il conserve la même exclusion, plus un `override_dh_missing` absent de qt6.
Vérifié au build du 2026-08-08 : **aucun paquet `nymea-energy-tests` produit**.
- **Un seul changelog pour les deux arbres** : `debian-qt6/changelog` est un lien symbolique
versionné vers `../debian-qt5/changelog`, comme `copyright` et `nymea-energy-tests.install.in`.
Écrire dans `debian/changelog` écrit donc l'unique fichier réel. Ne pas en déduire une
divergence entre les deux arbres — il n'y en a pas.
- Pour compiler les tests localement : `qmake CONFIG+=build_tests && make`.
- ⚠️ **NE JAMAIS lancer la suite de simulation en un seul processus.** Chaque test instancie un
serveur nymea complet et rien n'est rendu entre deux : en un processus le binaire atteint 7-8
Gio de RSS et l'OOM killer emporte la session graphique (constaté deux fois le 2026-08-25 ;
`total-vm` identique à 200 ko près, donc déterministe). Protocole : **un processus par test**
(`-functions` puis boucle, agrégation à la main), chacun encadré par
`systemd-run --user --scope -p MemoryMax=2G -p MemorySwapMax=0` — un test qui dérape meurt
seul. Vaut pour **toute** validation, y compris les vérifications intermédiaires. Ainsi lancée,
la suite tient en 61-66 Mo par test (413 Mo pour `run`, data-driven).
- **Contrôler que le run teste vraiment quelque chose.** Les chemins de plugins mock dépendent du
`cwd` : lancé au mauvais endroit, le binaire enchaîne 33 `ThingErrorThingClassNotFound` en 16 s
sans rien exécuter — ce qui ressemble à un run rapide et propre. Vérifier dans le préambule que
`Tests: Mock plugin path:` pointe vers un `.so` existant, et que le nombre de tests passés est
cohérent.
- **Port 4847 : instabilité connue du harnais nymea.** Le plugin mock système rouvre un port FIXE
à chaque cycle `cleanupTestCase()`/`initTestCase()` ; les tests qui réinstancient un serveur en
cours de route échouent par intermittence sur `Failed to open HTTP port. Port in use?`. Rejouer
une fois et étiqueter, jamais masquer. Même famille que la cause de fond ci-dessous.
- Cause racine : `libnymea-tests:arm64` 1.15.0 n'exporte pas `enableNotifications` → link fail cross.
### Vérification post-build obligatoire
```bash
dpkg-deb -I powersync-energy-plugin-nymea_*.deb | grep -E "Architecture|Version|Depends|Provides|Conflicts|Replaces"
# Attendu : Architecture: arm64 ; Version: <nymea>+etm* aligné sur la box cible
# Depends: libnymea-energy (>= <nymea>...) — la version doit matcher la box
# Provides/Conflicts/Replaces: nymea-energy-plugin-nymea (Voie B obligatoire)
```
---
## PLAN 3C — CLÔTURÉ
Le plan de mise en œuvre a été exécuté (`6298d5d` → `54ba229`) puis remanié en rév. 3.
Il n'est plus reproduit ici : son pseudocode nommait une classe supprimée
(`EcsRelayAdapter`) et un identifiant d'adaptateur qui n'a jamais existé dans le code
livré (`"relay-stages"` ; la valeur réelle est `"relay-router"`, `relayrouter.cpp:74`).
Autorité courante sur l'ECS : `specs/spec_ecs.md`.
Deux décisions de ce plan restent **en vigueur** et ne doivent pas être redécidées.
### Correction A — déduction EV unique, dans le scheduler
`ctx.meter.exportW` est la mesure **brute** (règle absolue 8). Les consignes EV du cycle
courant ne sont pas encore visibles au compteur : leur puissance commandée non encore
mesurée est réservée dans `getPlan()`, après le proxy EV — jamais dans `buildContext()`.
Implémentation : `rulebasedscheduler.cpp:86-97`.
Exemple : PV 9 kW, EV stable 7360 W, export mesuré 1140 W → `evReservedW = 0`,
budget charge = 1140 W.
### Correction B — anti-clignotement par recrédit de la consommation courante
```
budgetCharge = remainingSurplusW + lc.telemetry.currentPowerW
```
Sans ce recrédit : la charge monte d'un palier → l'export chute → elle redescend →
oscillation. Même mécanique que l'EV en amont.
Implémentation : `rulebasedscheduler.cpp:186`.
> **NE PAS « corriger » cette correction sur la foi d'un emballement observé au
> banc.** Sur le banc `.75`, deux conditions se composent : les relais GPIO
> n'exposent pas `currentPower` (donc le recrédit crédite le nominal commandé) et
> le rootmeter est une vue SunSpec **aveugle** aux résistances câblées sur ces
> broches (donc la conso ECS ne fait jamais chuter l'export). Le budget
> double-compte alors et le palier grimpe jusqu'au plafond — constaté le
> 2026-08-09, quatorze plateaux sans une commutation.
>
> C'est un **artefact de banc**. Sur une installation réelle l'ECS est derrière le
> compteur réseau : sa consommation fait réellement chuter l'export, et le recrédit
> compense exactement ce que la charge vient de retirer. Le supprimer casserait
> l'anti-oscillation sans rien régler. Cf. `docs/RELEVE_ECS306.md` §4.2.
---
> ⚠️ Tout plan antérieur mentionnant « créer etm/ avec PowerSyncClient et
> StaticHcHpProvider comme première étape » ou « injecter l'optimiseur dans
> SmartChargingManager » est **INVALIDE et ABANDONNÉ**. Ne pas le reprendre,
> quelle qu'en soit la source (fichier, mémoire de session, contexte).
---
## ARCHITECTURE CIBLE (non négociable)
```
┌──────────────────────────────┐
│ ARBITRAGE CENTRAL │ ← généralisation du
│ budget de surplus UNIQUE │ SmartChargingManager amont
│ waterfall par priorités │
└──────┬───────────────────────┘
│ IScheduler (= contrat OPTIMIZER_PROTOCOL)
┌───────────┴────────────┐
RuleBasedScheduler SocketScheduler [3d]
(in-process, V1, GPL) (client unix://|tcp://
plan à 1 créneau repli rules si absent/mort)
│
│ ↓ LoadAction — enveloppe en W (Setpoint),
│ ou State (SG-Ready)
│ ↑ LoadContext — télémétrie + bornes de verrou
│
┌──────────┬─────────┴─────┬────────────────┬──────────────────┐
EvAdapter RelayRouter EtmVariableLoad SgReadyAdapter BatteryAdapter
W → combi- Adapter W → état 1-4 contrainte +
[câblage naison de W → consigne setpoint W
différé relais, continue réseau
3g] verrous [3f]
│ │ │ │ │
└────────────┴──────────────┴─────────────────┴────────────────────┘
│
Things nymea
(evcharger · powerswitch · états
powerSetpoint/currentPowerW)
```
**Légende** — sans marque : en place. `[3d]` `[3f]` : prévu, **non écrit** (cf.
`## DÉFÉRÉ`). `[3g]` : classe écrite, câblage différé (voir « EV » ci-dessous).
**Contrat descendant.** Le scheduler distribue une **enveloppe en watts**. Le kind `Stage`
a été supprimé (`5100674`) : **aucun index de palier ni identifiant de relais ne descend —
seulement des watts, fussent-ils arrondis sur les paliers déclarés**
(`buildSetpointAction()`, `rulebasedscheduler.cpp:196-205`). Seul SG-Ready conserve un kind
`State` : 4 états normés constructeur, non exprimables en watts.
**Contrat montant.** Chaque adaptateur remonte sa télémétrie **et les bornes que ses
verrous imposent au cycle courant**, afin que l'arbitre décrémente le budget du palier
réellement applicable.
Écarts connus entre cette cible et le code : `specs/spec_ecs.md` §1.
**Traduction, pas répartition.** Un adaptateur convertit une enveloppe en consigne
matérielle — `RelayRouter` retient la combinaison de relais dont la puissance approche
l'enveloppe par le bas (`relayrouter.cpp:181-191`), `EtmVariableLoadAdapter` module en
continu (`etmvariableloadadapter.cpp:91`), `SgReadyAdapter` mappe sur un état normé. Aucun
ne s'attribue de budget : la règle 2 est respectée.
**Instanciation.** `RelayRouter` et `EtmVariableLoadAdapter` sont construits par
`EnergyArbitrator::rebuildLoadAdapters()` depuis `LoadConfigStore` (persisté, RPC
`Get`/`SetLoadConfig`, reconstruction à chaud) — `energyarbitrator.cpp:137-175`.
`SgReadyAdapter` est encore **codé en dur** (`energypluginnymea.cpp:66-76` : `ThingId`
K1/K2, paliers `{3: 1500, 4: 3000}`), à basculer sur la config avec la couche priorités.
Réserve sur `EtmVariableLoadAdapter` : il est exercé de bout en bout par
`testLoadConfigBuildsAdapters`, mais **aucun matériel réel n'implémente encore l'interface
`etmvariableload`** — seul le mock la porte
(`tests/mocks/plugins/energymocks/integrationpluginenergymocks.json:865-888`).
**EV — transplanté (3g-1 + 3g-2).** `EvAdapter::applyAction()` est appelé : le dispatch route
vers `m_adapters` les bornes que le waterfall a commandées, nommées par le scheduler dans
`Plan::waterfallEvIds`. Les bornes servies par la cascade **ne repassent plus** par
`adjustEvChargers()` — deux crochets virtuels (`evSurplusPlannedByWaterfall`,
`evCommandedByWaterfall`) les en sortent, et c'est ce qui garantit « une charge, un commandeur ».
**Ce qui RESTE au proxy** : l'échéance de départ et le tarif dynamique (financement réseau,
DESIGN_3g §3.1), la protection de surcharge, l'estimation de SoC, le filtrage d'entrée. Une
borne servie par ces chemins garde son dispatch amont, allowance de compteur comprise.
**Brèche assumée** : une borne peut DÉMARRER en soutirant, sous `acquisitionTolerance` — c'est le
comportement du proxy, rendu visible (motif `EV_GRID_START`, `funding: grid`, comptabilité
partagée). Bornée : surplus réel exigé, au plus une borne par cycle, sous le plafond L4.
Détail et limites : `docs/DESIGN_3g.md` §3.1-bis.
Règles absolues :
1. **UN seul arbitre.** Le budget de surplus est une ressource unique, arbitrée à UN
endroit. **INTERDIT : managers frères par type de charge** (EcsManager,
BatteryManager à côté du SmartChargingManager) — deux décideurs sur le même surplus
= sur-engagement et oscillations.
2. **Les LoadAdapters exécutent, ils ne décident pas.** Un adaptateur : parle à son
matériel, déclare ses capacités/contraintes (`declared`, `limits`, types d'action),
expose sa télémétrie, applique les `LoadAction` reçues. Aucune logique de
répartition dedans.
3. **Le SmartChargingManager amont est EV-spécifique** : il se GÉNÉRALISE en arbitrage
multi-charges (`ChargingAction` → `LoadAction`, bornes EV → adaptateurs). On ne
branche PAS l'optimiseur dans le manager EV tel quel.
4. **La boucle de sécurité est intouchable** : `verifyOverloadProtection()` (temps
réel) + bornes par adaptateur écrêtent TOUTE sortie de stratégie, interne ou socket.
5. **Plan par créneaux** (OPTIMIZER_PROTOCOL §6) : seul le créneau courant est exécuté.
Le rule-based répond un plan à 1 créneau. Modèle async = **cache** : le plan du
cycle précédent s'applique, le recalcul se fait en fond. Jamais d'attente dans
`update()`.
6. **Repli toujours fonctionnel** : optimiseur absent/mort/abstain → rule-based.
Capabilities (`tier`, `optimizerExpected`, `optimizerAlive`, `activeStrategy`)
reflètent l'état en continu.
7. **`decisionReason` non vide, en français, sur chaque action.** Action sans reason
= rejetée.
- **7-b — le motif nomme le mécanisme qui a produit l'issue** (ECS-309), et il ne le
nomme que si ce mécanisme a réellement agi (ECS-309-b). Un motif faux est pire qu'un
motif absent : il envoie diagnostiquer le mauvais problème, avec l'autorité d'une
explication.
- **7-c — un silence ne doit jamais être ambigu.** Journaliser **l'issue, pas seulement
l'issue remarquable**. Un traitement qui ne s'annonce que dans son cas notable rend
« exécuté, rien à signaler » indistinguable de « pas exécuté » — et c'est justement
dans le cas ordinaire qu'on vient vérifier qu'il a bien eu lieu.
**Journaliser ce qui a été LU, pas seulement ce qui en est déduit** : une conclusion
juste tirée d'une lecture incertaine se relit comme une conclusion sûre.
> ECS-411 déduisait le palier de l'état des relais mais ne l'annonçait qu'au palier
> non nul (`if (i > 0)`). Au palier 0 — le cas de l'armement minOff, celui qu'on vient
> précisément observer — le journal était muet, et « relais lus, tous ouverts » ne se
> distinguait pas de « reprise non exécutée ». Constaté au banc le 2026-08-13.
> Le relevé détaillé exigé par 7-c a immédiatement révélé un second défaut, de fond
> celui-là : un relais annoncé à `(0W)` parce que sa nominale était redéduite des
> encodages solo, que la déduplication peut évincer — d'où un palier 0 annoncé pendant
> que 1500 W circulaient. **Rendre la lecture visible est ce qui rend le défaut
> trouvable** ; c'est le sens de la règle, pas un effet secondaire.
- **7-d — un refus DOIT être au moins aussi visible que l'application correspondante.**
Jamais un refus en `qCDebug` quand le succès qu'il remplace est en `qCInfo`.
L'asymétrie est le défaut, pas le niveau : un resserrement de la journalisation fait
alors disparaître le refus **avant** le succès, et il ne reste au journal que les
décisions qui ont abouti. Or **un refus est plus informatif qu'une application
réussie** — il dit qu'une décision a été prise et n'a PAS été exécutée.
> `sgreadyadapter.cpp:150` journalisait le refus par verrou `minStateHold` en
> `qCDebug` quand l'application, dix lignes plus bas, était en `qCInfo`. Corrigé le
> 2026-08-13 ; c'était le seul cas, les autres `qCDebug` du moteur étant symétriques
> (traces d'enregistrement et de construction, où les deux branches sont au même
> niveau). Le marqueur « mode dégradé L2 actif » reste en `qCDebug` à dessein :
> l'entrée est en `qCWarning` et la sortie en `qCInfo`, donc l'épisode reste
> délimité sans une ligne par cycle.
- **Une issue rendue à un opérateur distant se journalise là où elle est décidée**, pas
dans chaque implémentation. `ClearLoadFault` est le seul levier de reprise à distance :
son issue est écrite dans l'arbitre, qui couvre tous les adaptateurs d'un coup et ne
peut pas être oublié par un adaptateur futur. Quand le type de retour permet de forcer
la réponse — `clearFault()` rend un `bool` — le préférer à la discipline.
- Les tests de ces quatre points portent sur le **TEXTE** publié, jamais sur la
non-vacuité d'une chaîne : un test « motif non vide » n'aurait rien vu, dans aucun
de ces cas.
8. **Pas de boucle de feedback** : surplus = PV mesurée + compteur, jamais le net
après pilotage.
9. **Aucun composant propriétaire ici** (Héos = repo privé `etm-powersync-optimizer`).
Ce repo doit compiler et tourner seul, GPL pur.
10. **ZÉRO cloud** — aucun appel réseau sortant vers un service distant (ni n8n, ni mail,
ni push tiers). Le système fonctionne sans internet (autoconsommation, local-first).
Toute alerte est **locale** : notification nymea in-app + signalisation physique
(buzzer/relais via règle nymea). Le moteur expose l'état, il ne contacte personne.
Exception : le plugin est CLIENT d'un optimiseur sur socket local/LAN (OPTIMIZER_PROTOCOL,
`unix://` ou `tcp://` du réseau de l'installation) — jamais un service cloud externe.
11. **Tout élément runtime a un `Get*` ET un `*Changed`. Jamais une notification seule.**
Une notification sans lecture laisse le client connecté APRÈS le changement dans un
silence ambigu : il ne peut pas distinguer « rien n'a changé » de « j'ai raté la
transition ». C'est la famille ECS-411, appliquée à la frontière RPC.
> `degradedMode` était poussé par `ChargingSchedulesChanged` sans figurer dans
> `GetChargingSchedules`. Corrigé au lot B.
- **Corollaire — la charge utile d'un `Get*` et celle de son `*Changed` se déclarent UNE
fois.** Deux déclarations divergent, et c'est l'appel de reprise à froid qui casse : celui
qu'on n'exerce pas tous les jours. Cf. `NymeaEnergyJsonHandler::loadTelemetrySchema()`.
- **Corollaire — le `Get*` rend ce qui a été PUBLIÉ, il ne recalcule pas.** Un recalcul
donne des grandeurs fraîches sous un horodatage périmé : un état qui n'a jamais existé, et
que le client ne peut rapprocher d'aucune notification.
12. **Aucune phrase en langue naturelle dans une charge utile RPC.** La box transporte un
**code** et des **paramètres** ; la phrase est fabriquée par le client, dans la langue de
son utilisateur. La locale nymea est **par connexion** (`JSONRPC.Hello` accepte `o:locale`,
rangée dans `m_clientLocales`) : deux utilisateurs d'une même box peuvent avoir deux
langues, ce qu'une phrase composée en C++ ne peut pas servir. Et
`NymeaEnergyJsonHandler` est un `JsonHandler` **sans `pluginId`** : structurellement hors du
catalogue de traduction nymea — rien ne rattraperait une phrase française émise là.
- Le **libellé de la charge** est de la langue naturelle : il n'entre pas dans les
paramètres, il est substitué au **rendu**, par l'appelant qui le connaît.
- Le **journal reste en français**, mais **rendu depuis le code** par une fonction unique
(`renderFr`). Une ligne maintenue en parallèle du code divergerait, et c'est le
diagnostic terrain qui deviendrait faux.
- Corollaire de forme : un champ absent vaut mieux qu'un champ nul ou forgé. `timestamp`
absent = aucun cycle ; `budget` absent = planification suspendue. Un zéro publié se lirait
« exécuté, rien à faire ».
## CE QUI TIENT ET CE QUI S'ÉRODE
**La différence n'a jamais été la qualité de l'argument — c'est de savoir s'il existe une machine
qui le rejoue.**
Une semaine d'août 2026 l'a montré six fois, et toujours dans le même sens : les décisions bien
raisonnées **et non exécutables** ont dérivé sans bruit, celles qu'un programme rejouait ont tenu.
| Ce qui a dérivé | Ce qui a tenu |
|---|---|
| la table d'état de `spec_ecs.md` §1 — écrite à la main, déclarait ECS-410/411/412 « absents » alors qu'ils étaient testés | l'index des règles, **généré**, qui casse sur un orphelin |
| la table §9 « Traçabilité » — nommait **huit tests qui n'ont jamais existé** | `ci-quality.sh`, qui régénère et refuse |
| « l'entrée supprimée sera recréée » — écrit au brief, **faux**, jamais exercé | `testDeletedEvChargerConfigComesBack`, vérifié échouant |
| `ECS-111` — défaut connu, décrit dans la spec, **jamais corrigé pendant des mois** | le test qui fige la charge, vérifié échouant |
| l'absence de `draw.authorisedW` — défendue en prose à **trois** endroits | `testLoadTelemetryRpc`, qui casse si on ajoute le champ |
| `rankOrigin` « auto » et la précédence des motifs, laissés à déduire de chaque côté | la règle écrite **une fois**, côté moteur, et le refus RPC qui l'applique |
**La règle pratique qui en sort** : quand une décision est prise, demander *quelle machine la
rejouera* — un test, un garde-fou de générateur, un refus à la frontière RPC. S'il n'y en a
aucune, la décision est une intention, et elle se périmera à la vitesse du code qu'elle décrit.
> **Corollaire, éprouvé lui aussi** : un garde-fou qu'on n'a **jamais vu échouer** ne protège
> rien. Chaque test d'invariant de cette semaine a été **vérifié échouant** avant d'être déclaré
> prêt — c'est la seule façon de savoir qu'il regarde ce qu'on croit.
## RÉPONSES FIGÉES (ne plus poser ces questions)
- Plages HC/HP et tarifs : **configuration JSON**, jamais hardcodé. Prévoir Tempo
(6 types de jours), pas seulement HC/HP.
- Async : **modèle cache** (cf. règle 5).
- Bugs upstream : **commits séparés** du code ETM, message préfixé `[upstream-fix]`.
Candidats PR nymea (fix phases EV, Keba) = patchs isolés, propres, upstreamables.
- `protocolVersion` : **constante `"1.0"`**, pas un paramètre de config.
- Renommage : FAIT (Phase 1, commit f4d5b20). TARGET et noms de paquets debian
INCHANGÉS (.so drop-in remplaçant l.amont — garantit un seul plugin énergie chargé).
## WORKFLOW OBLIGATOIRE
Chaque phase produit un livrable VALIDÉ PAR PATRICK avant la suivante. Jamais de code
avant validation du design de la phase.
- **Phase 0 — Analyse (en cours)** : répondre par écrit, code lu à l'appui :
(a) quelles charges SmartChargingManager pilote-t-il (types manipulés) ;
(b) ChargingAction peut-il exprimer « ECS palier 1 » / « batterie décharge
interdite » — citer ses champs ; (c) avec des managers séparés, où vivrait le
budget unique. Zéro code, zéro plan d'implémentation.
- **Phase 1 — Renommage** : `git mv` du `.pro`, TARGET, debian/. Un commit, revue.
- **Phase 2 — Design de l'arbitrage généralisé** : interface `LoadAdapter` (méthodes,
ce qu'un adaptateur déclare), flux du budget, mapping `LoadAction`→adaptateurs,
où vit `IScheduler`. Texte + signatures, pas d'implémentation. Validation Patrick.
- **Phase 3 — Implémentation par étapes** (chacune : compile amd64 + cross arm64,
et un scénario `docker-simulation.sh` qui la prouve = DoD) :
3a. structs du protocole (contexte, plan, actions) ;
3b. arbitre + RuleBasedScheduler + EvAdapter (iso-fonctionnel avec l'amont sur EV) ;
3c. adaptateur ECS à paliers (livré, puis remplacé par `RelayRouter` en rév. 3) ;
3d. SocketScheduler (handshake/heartbeat/repli,
testé contre un optimiseur factice ~50 lignes) ; 3e. SgReadyAdapter ;
3f. BatteryAdapter (constraints + charge réseau plafonnée).
- **Bugs upstream** : au fil de l'eau, commits `[upstream-fix]` séparés.
## DÉCISIONS DE DESIGN (écarts et justifications)
### 3b révisé — délégation EV à l'amont (beta assumée)
**Décision Patrick** : hybride étagé pour la beta.
**En beta** : les décisions EV restent dans les méthodes amont
`planSurplusCharging` / `planSpotMarketCharging` (`SmartChargingManager`), inchangées.
`RuleBasedScheduler::getPlan()` les appelle en **proxy** et reformate leurs sorties
(`ChargingActions`) en `LoadAction` pour le log `[Arbitre]`.
`EvAdapter::applyAction()` est **implémenté** (`evadapter.cpp:62-94`) mais **jamais
appelé** : `applyActionsToAdapters()` ne route les `Setpoint` que vers les charges pilotées,
et les `EvAdapter` vivent dans une table séparée jamais parcourue
(`energyarbitrator.cpp:288`). `descriptor()` et `telemetry()` sont utilisés dès maintenant
pour le `SurplusContext`. **3g est un travail de câblage, pas d'écriture** — ne pas
planifier la réécriture d'un adaptateur qui existe.
**Pipeline ETM réel** (waterfall budget Surplus/Grid, `applyAction`) arrive en **3c**
pour les charges non-EV (ECS, SG-Ready), alimenté par le surplus *restant* après
déduction de l'`addedPower` des consignes EV du cycle courant (pas encore visible
au compteur).
**Limitations beta assumées** :
- EV toujours prioritaire ; waterfall appliqué uniquement aux charges non-EV.
- Le classement drag-and-drop (priorités) ne portera que sur les charges non-EV.
**Étape 3g (post-beta)** : transplantation réelle de la logique EV dans
`RuleBasedScheduler` → priorités libres entre toutes les charges (EV, ECS, SG-Ready,
batterie).
**Dette 3g — convention de priorité EV : RÉGLÉE en 3g-2.** `priority = 100` en dur a cédé la
place à `EnergyArbitrator::evPriority()`, qui lit le rang dans `LoadConfig` (LM-1207 : le rang du
VE vit là où vivent tous les rangs, jamais dans un second système). Toute borne détectée reçoit
d'office une entrée `evcharger` — rang, libellé, `domain: ev`, et **aucune charge utile** : ses
limites viennent du Thing. Le tri est **total** (à rang égal, l'identifiant départage), donc
reproductible d'un cycle à l'autre. Contrat : `INTERFACE.md`.
---
### 3b-iii — EnergyArbitrator hérite de SmartChargingManager
**Design validé en session** : "nouvelle classe dans etm/, n'étend pas SmartChargingManager".
**Écart implémenté** : `EnergyArbitrator : public SmartChargingManager`.
**Justification** :
1. **Contrainte NymeaEnergyJsonHandler** : ce handler amont prend un
`SmartChargingManager*` dans son constructeur.
Sans héritage, toute solution propre (interface commune, pointeur générique)
nécessiterait de modifier `nymeaenergyjsonhandler.h/.cpp` — violation de la règle
"Modifier le code amont uniquement pour corriger des bugs".
2. **verifyOverloadProtection() intacte** : héritée bit-pour-bit, connectée aux mêmes
signaux via le constructeur du parent. Zéro risque de régression sur la sécurité.
3. **simulationCallUpdate() polymorphe** : appelle `update()` virtuel → redirige
automatiquement vers `EnergyArbitrator::update()`. Les tests amont passent sans
modification.
4. **Minimal upstream diff** : seuls les attributs `protected`/`virtual` changent dans
`smartchargingmanager.h` (marqués `// [ETM]`). Zéro logique upstream modifiée.
**Risque accepté** : `EnergyArbitrator` a accès à l'état privé de SCM via les
accesseurs `internal*`. La discipline AGENTS (LoadAdapters exécutent, ne décident pas ;
un seul arbitre) compense. Si SCM était refactorisé en amont pour exposer une interface
publique propre, l'héritage pourrait être remplacé par composition.
---
### Verrous minOn/minOff — protection compresseur (décision Patrick)
Le délestage du waterfall est **strict au niveau budget** (surplus net signé : en import,
budget négatif → palier 0). Mais une charge à compresseur (PAC, ballon thermodynamique)
ou un VE ont un **temps de fonctionnement minimum incompressible** : ce n'est pas du
confort, c'est de la **protection matérielle** (le court-cycling détruit le compresseur).
**Séparation des responsabilités** :
- Le **scheduler** décide l'enveloppe idéale selon le budget (peut vouloir 0 W).
- L'**adaptateur** borne ce choix par sa fenêtre de verrou, évaluée au temps de cycle :
une charge verrouillée ON garde son palier ; l'import transitoire est **borné par
minOn**, pas illimité.
- Charges en watts : la fenêtre est **interne** à l'adaptateur
(`RelayRouter::lockWindow()`, `relayrouter.cpp:193-203`) et n'est pas exposée au
scheduler.
- SG-Ready : la fenêtre **est** exposée, via `telemetry.minState`/`maxState`
(`surpluscontext.h:66-71`), et le scheduler clampe dessus
(`rulebasedscheduler.cpp:254-264`).
- `minOnS`/`minOffS` sont des **paramètres par charge**, lus depuis la configuration
persistée (`LoadConfig`, `energyarbitrator.cpp:162`) — **jamais codés en dur**.
Écarts connus entre cette cible et le code : `specs/spec_ecs.md` §1.
**Défauts indicatifs par type** (à affiner à la mise en service) :
| Type de charge | minOn | minOff | Raison |
|----------------|-------|--------|--------|
| Ballon résistif (ECS simple) | ~60 s | ~60 s | anti-rebond relais seul |
| Ballon thermodynamique / PAC | ~300–600 s | ~300 s | **protection compresseur** (anti court-cycling) |
| SG-Ready PAC (3e) | `minStateHoldS` ~900 s | — | maintien d'état imposé constructeur |
**Seam de temps** : la fenêtre de verrou (décision) ET le verrou de `applyAction`
(exécution) partagent le **même `now = ctx.timestamp`** via `lockWindow()` — source unique,
divergence impossible par construction, injectable en simulation. Voir
`iloadadapter.h:30-35` (contrat « temps = paramètre, jamais l'horloge »).
---
## MODÈLE DE SÉCURITÉ (décision Patrick — immuable)
Cinq couches indépendantes. Chacune est conçue pour qu'une défaillance des couches
supérieures n'affecte pas les couches inférieures. Voir `docs/SAFETY.md` pour le détail.
| Couche | Qui | Quoi |
|--------|-----|------|
| **L0** | Disjoncteur / Linky matériel | Coupure physique — hors logiciel |
| **L1** | Failsafe natif des bornes | Config installateur, checklist ETM |
| **L2** | Watchdog fraîcheur compteur (en place) | `QTimer` piloté : si `lastMeterUpdate > 90 s` → mode dégradé (EV min/off, ECS off, pas de charge réseau batterie), `decisionReason` explicite, notification nymea. Scénario simulation dédié : "compteur muet → repli". |
| **L3** | Watchdog systemd sur nymead | Repo `etm-powersync-deploy`, hors scope ici |
| **L4** | Logique signal-driven existante | Boucle `update()` déclenchée par événements |
**Règles de code** :
- Le watchdog L2 est piloté par **`QTimer`** (pas par signal `meterChanged`) pour
rester actif même si le signal ne fire plus.
- Mode dégradé = consignes **de repli** (EV au minimum si pluggedIn, ECS off, etc.)
+ `decisionReason` non vide + notification `EnergyManagerChanged` avec `degradedMode`.
- `verifyOverloadProtection()` (L4) est déclenchée par **deux mécanismes** :
(a) signal `powerBalanceChanged` (temps réel — SCM.cpp ligne 127, mécanisme principal) ;
(b) appel cyclique en position 3 d'`update()` (SCM.cpp ligne 313, filet périodique).
La position dans `update()` est **INTOUCHABLE** — même dans `EnergyArbitrator::update()`.
---
## DÉFINITION DE FAIT (par étape de phase 3)
1. Compile amd64 et cross arm64.
2. Scénario de simulation ajouté/étendu qui démontre le comportement (le harnais
`docker-simulation.sh` + `tests/auto` hérités sont le banc de test).
3. `decisionReason` visibles dans les logs de simulation.
4. Aucune régression des tests amont existants.
5. Toute classe/méthode **publique** de `etm/` porte un commentaire Doxygen :
`\brief`, `\param`, `\return`, et surtout le **contrat de comportement**
(invariants, écrêtage, hypothèses que l'appelant peut faire).
Les headers 3a servent de modèle — les convertir au format Doxygen lors du passage 3b.
**Y compris les ajouts `// [ETM]` hors `etm/`** : ils échappent au périmètre du
`Doxyfile` (frontière = un répertoire) et se documentent à la main. Leur inventaire
est tenu dans `energyplugin/smartchargingmanager.h`, au marqueur `[ETM] BEGIN` —
toute nouvelle marque `[ETM]` dans un en-tête amont s'y ajoute.
6. **Génération de la doc : `./tools/gen-doc.sh`, et rien d'autre.** Une commande
unique, le `Doxyfile` du dépôt, épinglé. Un compte d'avertissements ne vaut que
rattaché à une configuration exacte — 163 sans générateur de sortie contre 113 avec
HTML+XML sur le même arbre — donc un chiffre produit « à sa façon » ne se compare à
rien. Le script signale aussi une version de doxygen différente de la référence.
- **Dépendances : `doxygen` et `graphviz`, de la CIBLE DOC uniquement.** Le paquet se
construit sans elles ; le conteneur `build-cross-arm64` n'a rien à faire de
Graphviz. Ne pas les ajouter aux `Build-Depends`.
- `CALL_GRAPH`/`CALLER_GRAPH` restent à `NO` **délibérément** : le moteur est
signal-driven, `powerBalanceChanged → verifyOverloadProtection()` est une connexion
Qt et non un appel. Un graphe d'appel montrerait `update()` en point d'entrée
orphelin et raterait le mécanisme principal de la couche L4.
- Le graphe d'héritage est **tronqué par construction** : `SmartChargingManager` est
hors `INPUT`. L'arête est dessinée, mais la boîte est un cul-de-sac — sans membres,
sans ancêtres, non cliquable. Ne pas élargir le périmètre pour « réparer » : cela
ferait entrer tout l'amont dans la mesure.
## ROADMAP — configuration des priorités par l'utilisateur (post-beta)
- **ACQUIS (3e)** : le waterfall trie déjà ECS + SG-Ready ensemble par `priority` (rang
ASC, 1 = servi en premier), **budget unifié** qui cascade à travers toutes ces charges.
- **ACQUIS (3g-1/3g-2)** : le VE est **dans le tri**. Toutes les charges — VE, ECS, SG-Ready —
partagent un budget et un rang unique, et le VE peut être classé derrière l'ECS.
- **MANQUE pour des priorités réglables par le client** :
- (a) ~~**3g**~~ — **fait**. Le rang d'une borne est un champ de `LoadConfig`, modifiable à
chaud par `SetLoadConfig`, comme celui de toute autre charge.
- (b) **Couche « config priorités »** — le pont moteur↔UI, un morceau à part entière
(ni 3e ni 3g), aujourd'hui **à moitié livré** :
- **FAIT côté moteur** (`7184fe4`) : `priority` est exposé et persisté par charge,
via `GetLoadConfig` / `SetLoadConfig` (`LoadConfigStore`), et appliqué à chaud —
`changed()` → `rebuildLoadAdapters()`.
- **RESTE côté app** : l'UI Flutter de réordonnancement (drag-and-drop), qui consomme
ces deux appels. Aucun travail moteur n'y est attaché.
- **État actuel** : pour les **charges pilotées** (`relay-router`, `etmvariableload`),
`priority` est un champ de la configuration persistée (`loadconfig.h:73`), transmis à
l'adaptateur lors de sa construction (`energyarbitrator.cpp:162,167`), et modifiable
**à chaud**. `register*Adapter` ne fixe plus rien : il enregistre un adaptateur déjà
construit. Le `SgReadyAdapter` du banc y est entré au lot B-bis, et **le VE en 3g-2** : plus
rien n'est hors configuration.
- **Argument démo nymea** : le client réordonne ses charges, le surplus suit.
## RÉFÉRENCES
- `docs/OPTIMIZER_PROTOCOL.md` — le contrat. §5 (SurplusContext), §6 (plan/actions),
§7 (repli), annexe C (priorités).
- `README.md` — architecture (deux boucles, frontière), `etm_powersync_energy.svg`.
- `INTERFACE.md` — API JSON-RPC existante (`NymeaEnergy`, cible future `Ems`).
- `docs/RELEVE_LOTB.md` — relevé de vérification du lot B (frontière de télémétrie + `o:domain`)
sur `.75`, critère par critère, avec ce qui n'a PAS été vérifié sur la machine et pourquoi.
- `docs/RELEVE_3g1.md` — relevé MACHINE de 3g-1 sur `.75` : ce que le banc a dit, y compris les
trois défauts qu'il a révélés et que 3g-2 corrige.
- `docs/RECAP_3g2.md` — 3g-2 : ce que le lot corrige, et pourquoi.
- `docs/DESIGN_10.md` — design du §10 (éco/confort + délestage), **à valider**. Le plafond de
soutirage devient un objet, les deux passes calculent des cibles et une seule commande sort.
- `docs/PREPA_BANC_20260828.md` — préparation du banc pour la V2C Trydan et la Skoda Enyaq, avec
**les deux blocages silencieux** trouvés la veille (`phaseCount` absent, `sessionEnergy`
délibérément non exposée).
- `docs/BRIEF_depuis_plugin.md` — **brief ÉMIS vers l'agent app** après 3g-2 : ce qui bouge pour
l'écran (l'inclusion rétablie, la mise en garde sur `allocatedW` qui tombe, `EV_GRID_START`,
le rang de borne au glisser-déposer).
- `docs/RELEVE_3g2.md` — relevé MACHINE de 3g-2 sur `.75` (`+etm23`) : le doublet disparu, le
plancher passé de 2 070 à 4 140 W, l'entrée `evcharger` auto-provisionnée — **et les deux
défauts que le passage a trouvés**, dont le trou de `pluggedIn`.
- `docs/RELEVE_LOTBbis.md` — relevé de vérification du lot B-bis (la PAC entre en configuration)
sur `.75` : l'ordre de migration exercé, la concordance des deux frontières, `applySafeState`
au retrait, l'expiration du verrou à froid — et §7, les puissances SG-Ready qui ne sont **pas**
des données d'installation.
- `specs/spec_ecs.md` — **autorité sur l'ECS multi-palier** : état des exigences après
audit, ordre de traitement, arbitrages ouverts. Subordonné à ce fichier.
- `specs/spec_loadmodel.md` — modèle de charges (domaine × mécanisme).
**Intention de conception : ne déclenche aucun travail.**
- Carte globale du workspace : `../AGENTS.md`.