11.1 étalement uniforme, avec les trois choses qui s'écrivent avec lui : recalculé à chaque cycle (une cadence figée ne rattrape pas et manque l'échéance en ayant l'air de travailler) ; il se durcit à l'approche — 2 kWh font 2 kW à une heure et 12 kW à dix minutes, donc la fenêtre d'achat massif arrive d'un coup et c'est le MOTIF qui doit le dire ; et un point de non-retour explicite, parce qu'un seuil nommé se discute là où une asymptote se subit. 11.2 oui aux quatre champs, et la subtilité entre dans le CONTRAT : sous la réserve batterie, draw.remainingW reste non nul pendant que budget.remainingW est à zéro. LM-1203-b vue depuis la frontière — la réserve annule le surplus, jamais l'autorisation de soutirage. 11.3 dissoute, 11.4 par champ explicite. Et un troisième mode d'érosion dans AGENTS.md, symétrique des deux autres : non pas une règle qui se périme faute d'être exécutable, mais une règle rendue IMMORTELLE par sa propre garde. draw.authorisedW a été refusé pour un motif juste, épinglé par un test, puis le motif est tombé — et la formulation a survécu, protégée par un test qui ne savait pas que le monde avait changé. Parade déjà pratiquée ici : quand un test fige une ABSENCE, son commentaire écrit la condition de levée. « Absent tant que rien ne le mesure » se relit et se lève ; « ne doit pas exister » se relit et s'obéit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
1150 lines
78 KiB
Markdown
1150 lines
78 KiB
Markdown
# 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.
|
||
|
||
> **Le versant CONTRAT du même principe** : **un contrat qu'aucun appelant n'exerce n'est pas un
|
||
> contrat tenu, c'est un contrat non testé.** ECS-415 en est le cas d'école — `applyAction()`
|
||
> renvoyait, sur refus, l'action *demandée* : un appelant y relisait l'état qu'il venait de
|
||
> demander et concluait au succès. Le défaut a vécu sans conséquence tant que **personne ne
|
||
> lisait ce retour** (le dispatch l'ignore), et il est devenu grave à la minute où un appelant
|
||
> s'y est fié — publiant 3 kW rendus par une PAC restée en état 4.
|
||
>
|
||
> Les deux se répondent : le garde-fou dit « une règle que rien ne rejoue se périme », le
|
||
> contrat dit « une garantie que personne ne consomme n'a jamais été vérifiée ». Avant
|
||
> d'exploiter une valeur de retour qu'aucun code n'exploitait, **l'épingler d'abord par un
|
||
> test** — le premier appelant est aussi le premier vérificateur.
|
||
|
||
> **Un troisième mode d'érosion, et il est le SYMÉTRIQUE des deux autres (2026-08-31).** Le
|
||
> garde-fou dit « une règle que rien ne rejoue se périme ». Voici l'inverse : **une règle rendue
|
||
> IMMORTELLE par sa propre garde.**
|
||
>
|
||
> Cas d'école, `draw.authorisedW`. Le champ a été refusé parce qu'il aurait affiché « un plafond
|
||
> de soutirage qui n'existe pas » — motif juste. Il a été défendu en prose à trois endroits, puis
|
||
> épinglé par un test qui casse si on l'ajoute. Puis le lot délestage a **créé** le plafond. Le
|
||
> motif était tombé ; la formulation, elle, a survécu — protégée par un test qui, lui, ne savait
|
||
> pas que le monde avait changé.
|
||
>
|
||
> **La parade est déjà pratiquée ailleurs dans ce dépôt** : le commentaire d'un test d'invariant
|
||
> doit dire **à quelle condition il devient faux**. Ceux qui portent « si ça casse, ce n'est pas
|
||
> le test qu'il faut corriger » ont bien vieilli, parce qu'ils nomment l'intention. Celui-là
|
||
> disait ce qu'il interdisait, jamais **à quel moment il aurait dû mourir**.
|
||
>
|
||
> **Règle pratique** : quand un test fige une ABSENCE, son commentaire écrit la condition de
|
||
> levée. « Ce champ reste absent **tant que** rien ne le mesure » se relit et se lève ; « ce
|
||
> champ ne doit pas exister » se relit et s'obéit.
|
||
|
||
> **Fait de méthode, relevé le 2026-08-30** : la règle d'ECS-415 était **déjà écrite**, dans le
|
||
> commentaire de la branche voisine du même fichier, **à trois lignes** de la branche fautive
|
||
> (« ce retour était MUET, si bien que "état déjà bon" ne se distinguait pas de "action jamais
|
||
> reçue" »). Elle ne s'était pas propagée pour autant.
|
||
>
|
||
> **Une règle écrite à un endroit ne se propage pas toute seule.** C'est ce qui justifie, quand
|
||
> un défaut de ce genre apparaît, de **balayer les autres implantations du même contrat** plutôt
|
||
> que de corriger localement — le balayage a trouvé le même travers sur la branche « motif
|
||
> vide » du `RelayRouter` et de l'`EtmVariableLoadAdapter`. Corollaire pour la relecture : la
|
||
> proximité d'un bon commentaire n'est **pas** une preuve que le code voisin le respecte.
|
||
|
||
## 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`.
|