# 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` | **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. ### Ce que le moteur sait faire aujourd'hui - **Arbitrage central unique** : un budget de surplus net signé, cascade par **priorité** (rang). - **Charges** : EV (proxy amont, décision B), **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. À documenter dans le protocole publié + README architecture. **Prochaine session doc** (pas avant le terrain). - **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. - **3g** transplantation EV dans le waterfall unifié → toutes charges classables ensemble. - **Couche config priorités** (UI Flutter drag-and-drop) — cf. `## ROADMAP`. Note : la déclaration des **charges pilotées** est configurable et persistée depuis `7184fe4` (`LoadConfigStore`, RPC `Get`/`SetLoadConfig`, reconstruction à chaud sur `changed()`). Reste codé en dur : le **`SgReadyAdapter` du banc** (`energypluginnymea.cpp:66-76`) — cf. `docs/TEST_TERRAIN.md` §1.d. **PRÉCONDITION à ce basculement** : retirer d'abord `Q_ASSERT(m_stateRelays.contains(2))` (`sgreadyadapter.cpp:36`) au profit d'un refus explicite. Cette assertion garde l'état 2 — le repli sûr du mode dégradé L2 — et disparaît du binaire release (`QT_NO_DEBUG`). Tant que la construction est codée en dur, elle ne peut pas échouer ; dès que l'état vient de la config, elle devient le seul garde-fou, et il n'existera pas chez le client. **Avant le basculement, pas après** — cf. `specs/spec_ecs.md` ECS-110. - **arm64** cross-compile validé en pré-déploiement (infra CI `etm-powersync-deploy`). - **`Doxyfile` + job CI `doxygen -W`** (automatiser l'audit doc). **PROCHAINE ACTION** : **test terrain vendredi** (`docs/TEST_TERRAIN.md`, Palier 1), puis **passe contrats** (OPTIMIZER_PROTOCOL + README). **Remotes git** : - `origin` (`https://git.etm-powersync.fr/...`) = remote de travail — push normal - `etm-public` (`gitea-lan:...powersync-energy-plugin-etm`) = miroir public GPL → push **MANUEL par Patrick uniquement** (`sync-public.sh`) - `etm-pro` = reliquat historique — ne pas utiliser, cartographie à clarifier --- ## 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** | `+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`. - 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: +etm* aligné sur la box cible # Depends: libnymea-energy (>= ...) — 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 (ECS-404) 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, limite beta.** `EvAdapter::applyAction()` est **implémenté** (`evadapter.cpp:62-94`) mais **jamais appelé** : le dispatch 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`). Les décisions EV restent prises par les méthodes amont, en proxy, **avant** le waterfall ; l'EV n'entre donc pas dans le tri par priorité. `descriptor()` et `telemetry()` sont utilisés dès maintenant pour le `SurplusContext`. **3g est un travail de câblage, pas d'écriture.** 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. 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. ## 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** : `EvAdapter::descriptor()` met `priority = 100` (`evadapter.cpp:24`), reliquat de l'ancienne convention « poids, valeur haute = premier ». Inoffensif en beta : l'EV est servi par le proxy *avant* le waterfall ECS et n'entre pas dans le tri ECS (ascendant, rang 1 = premier servi, protocole §5). À reconcilier quand l'EV rejoindra le waterfall unifié : la priorité devient un **rang** (1, 2, 3…), pas un poids — sinon `priority=100` placerait l'EV en dernier d'un tri ascendant. --- ### 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. ## 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. - **LIMITE beta** : le VE reste **hors du tri** (décidé par l'amont *avant* le waterfall, décision B) → on ne peut pas classer le VE derrière l'ECS. - **MANQUE pour des priorités réglables par le client** : - (a) **3g** : transplanter le VE dans le waterfall unifié → toutes les charges (VE, ECS, SG-Ready, batterie) classables ensemble. - (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. Restent hors config : le VE (fixé à `priority = 100`, `evadapter.cpp:24` — cf. « Dette 3g ») et le `SgReadyAdapter` du banc (`energypluginnymea.cpp:66-76`). - **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`). - `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`.