Constaté en construisant 1.15.2+etm7 depuis un git archive : qmake fige la liste des .qm avant que lrelease ne les produise, et dh_install échoue en missing files. Le clone canonique masque le défaut, ses .qm survivant d'un build antérieur. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
34 KiB
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.mdfait 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 DESIGNEvAdapter+RuleBasedSchedulerimplémentés- Build : 0 erreur / 0 warning
ETM_ARBITRATORactif dansenergyplugin.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(), verrousminOnS/minOffS, bypass siforce == true), enregistré explicitement auprès de l'arbitre. Classe remplacée depuis : voir la ligne rév. 3 du tableau. buildContext():SurplusMeterbrut (exportW = max(0, -meter->currentPower())),loads[]EV + charges pilotées ;SurplusPvdé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 duSurplusContexten 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,lockWindowsymé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) — commitdfdd988. - DoD 3e : amd64 0/0 ✓ · simulation 20/20 ✓ ·
decisionReasonfrançais (forcé/reco/normal/ verrou) ✓ · arm64 → CI (idem 3c). - Audit Doxygen fait (5 findings → 0 :
\param now, docs périmées 3c/3e) — commit51760a7. docs/TEST_TERRAIN.mdcréé : 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—RelayRouterpour une combinaison de relais,EtmVariableLoadAdapterpour une consigne continue), SG-Ready PAC (4 états, kindState) — toutes les charges non-EV classables ensemble sur le budget partagé. - Sécurité : protection compresseur (verrous
minOn/minOffpar charge tenus dans l'adaptateur, seam de temps unifiénow = ctx.timestamp; fenêtre d'étatsminState/maxStateexposée au scheduler pour SG-Ready) ; watchdog L2 (compteur muet >90 s → mode dégradé conservateur, planif suspendue, reprise par recalcul) ;verifyOverloadProtection()amont intacte ;degradedModenotifié. - Local-first : zéro cloud (invariant 10).
DÉFÉRÉ (ordre indicatif)
- Passe README + contrats :
OPTIMIZER_PROTOCOL.mdne 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 formerelay-routerdeLoadConfig.minStage/maxStagene 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 depuis7184fe4(LoadConfigStore, RPCGet/SetLoadConfig, reconstruction à chaud surchanged()). Reste codé en dur : leSgReadyAdapterdu banc (energypluginnymea.cpp:66-76) — cf.docs/TEST_TERRAIN.md§1.d. PRÉCONDITION à ce basculement : retirer d'abordQ_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.mdECS-110. - arm64 cross-compile validé en pré-déploiement (infra CI
etm-powersync-deploy). Doxyfile+ job CIdoxygen -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 normaletm-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 | <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-arm64suretm-powersync-dev(amd64) - Commande :
dpkg-buildpackage -aarm64 -b -uc -usdans/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) : nymea1.15.2+202606191336~trixie1, Qt6.8.2+dfsg-9+deb13u2— et non1.15.0/Qt5 comme l'indiquait cette section. La chaîne est Qt6 de bout en bout, cf. ci-dessous. lrelease: fourni parqt6-tools-dev-tools— nécessaire si absent du conteneur- Arbre vierge : lancer
lreleaseAVANT le premierdpkg-buildpackage. Constaté le 2026-08-09 en construisant1.15.2+etm7depuis ungit archive.energyplugin.prorésout$$files(translations/*.qm)au moment deqmake; sur un arbre sans.qm, la liste est vide, les traductions ne sont pas installées, etdh_installéchoue enmissing filesaprès quelreleaseles a pourtant fabriquées. Le clone canonique/root/etm-powersync-energy-plugin-etmmasque le défaut : ses.qmtraînent d'un build antérieur. Remède :lrelease energyplugin/translations/*.ts, puis supprimer.qmake.stashet lesMakefileavant 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.debde prod. etm-powersync-energy-plugin-etm.pro: tests sousbuild_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 lesrulesportentdh $@ --buildsystem=qmake6.debian-qt5/rules(--buildsystem=qmake) n'est plus la référence ; il conserve la même exclusion, plus unoverride_dh_missingabsent de qt6. Vérifié au build du 2026-08-08 : aucun paquetnymea-energy-testsproduit.- Un seul changelog pour les deux arbres :
debian-qt6/changelogest un lien symbolique versionné vers../debian-qt5/changelog, commecopyrightetnymea-energy-tests.install.in. Écrire dansdebian/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:arm641.15.0 n'exporte pasenableNotifications→ link fail cross.
Vérification post-build obligatoire
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 pascurrentPower(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 :
- 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.
- 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 lesLoadActionreçues. Aucune logique de répartition dedans. - 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. - La boucle de sécurité est intouchable :
verifyOverloadProtection()(temps réel) + bornes par adaptateur écrêtent TOUTE sortie de stratégie, interne ou socket. - 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(). - Repli toujours fonctionnel : optimiseur absent/mort/abstain → rule-based.
Capabilities (
tier,optimizerExpected,optimizerAlive,activeStrategy) reflètent l'état en continu. decisionReasonnon vide, en français, sur chaque action. Action sans reason = rejetée.- Pas de boucle de feedback : surplus = PV mesurée + compteur, jamais le net après pilotage.
- Aucun composant propriétaire ici (Héos = repo privé
etm-powersync-optimizer). Ce repo doit compiler et tourner seul, GPL pur. - 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://outcp://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 mvdu.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, mappingLoadAction→adaptateurs, où vitIScheduler. 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.shqui 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é parRelayRouteren 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 :
-
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 modifiernymeaenergyjsonhandler.h/.cpp— violation de la règle "Modifier le code amont uniquement pour corriger des bugs". -
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é.
-
simulationCallUpdate() polymorphe : appelle
update()virtuel → redirige automatiquement versEnergyArbitrator::update(). Les tests amont passent sans modification. -
Minimal upstream diff : seuls les attributs
protected/virtualchangent danssmartchargingmanager.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).
- Charges en watts : la fenêtre est interne à l'adaptateur
(
minOnS/minOffSsont 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 signalmeterChanged) 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.)
decisionReasonnon vide + notificationEnergyManagerChangedavecdegradedMode.
verifyOverloadProtection()(L4) est déclenchée par deux mécanismes : (a) signalpowerBalanceChanged(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 dansupdate()est INTOUCHABLE — même dansEnergyArbitrator::update().
DÉFINITION DE FAIT (par étape de phase 3)
- Compile amd64 et cross arm64.
- Scénario de simulation ajouté/étendu qui démontre le comportement (le harnais
docker-simulation.sh+tests/autohérités sont le banc de test). decisionReasonvisibles dans les logs de simulation.- Aucune régression des tests amont existants.
- 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]horsetm/: ils échappent au périmètre duDoxyfile(frontière = un répertoire) et se documentent à la main. Leur inventaire est tenu dansenergyplugin/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) :priorityest exposé et persisté par charge, viaGetLoadConfig/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é.
- FAIT côté moteur (
- État actuel : pour les charges pilotées (
relay-router,etmvariableload),priorityest 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*Adapterne 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 leSgReadyAdapterdu 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 futureEms).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.