QTimer 30s indépendant des signaux ; m_lastMeterUpdate picoté sur powerBalanceChanged.
Silence >90s → mode dégradé (appliqué à la TRANSITION uniquement) :
- ECS palier 0 force=true ;
- EV : clamp courant minimum SEULEMENT si déjà en charge (pas d'activation forcée ;
"jamais 0 A si branché" relève du failsafe L1, pas du repli logiciel).
update() suspend la planification + le dispatch tant que m_degradedMode (sécurité L4
en position 3 reste active) → pas de rallumage sur le cache d'un compteur mort, pas
d'oscillation. Reprise au retour du compteur.
SAFETY.md §L2 : nuance maintenu/démarré + suspension planification. AGENTS.md morceau 7 :
exiger ECS reste à 0 sur plusieurs cycles. SG-Ready/Batterie déférés 3e/3f ;
flag degradedMode exposé en 3c-6. Build 0 erreur / 0 warning.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
18 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 — EcsRelayAdapter + waterfall ECS | 🔄 EN COURS | (wip) |
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 (morceaux déjà compilés, 0 erreur / 0 warning) :
- Morceau 0 —
LoadAction.force=false(bypass verrous sécurité) ✅ - Morceau 1 —
EcsRelayAdapter(.h + .cpp) : pilote N Things powerswitch,applyRelayStage(), verrousminOnS/minOffS, bypass siforce==true✅- Enregistrement explicite via
EnergyArbitrator::registerEcsAdapter()(tests + config)
- Enregistrement explicite via
- Morceau 2 —
buildContext():SurplusMeterbrut (exportW = max(0, -meter->currentPower())),loads[]EV + ECS,SurpusPvdéféré 3d ✅
PROCHAINE ACTION — suite 3c :
- Morceau 3 : waterfall ECS dans
RuleBasedScheduler::getPlan()(voir PLAN 3C ci-dessous) - Morceaux 4-7 : voir PLAN 3C
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
PLAN 3C (validé, morceaux 0-2 déjà compilés)
Plan approuvé par Patrick. Corrections A (double déduction EV) et B (anti-clignotement ECS) intégrées dans le design ci-dessous.
Morceaux déjà compilés (0 erreur/warning)
| # | Fichier(s) | Ce qui a été fait |
|---|---|---|
| 0 | types/loadaction.h |
bool force = false — bypass verrous sécurité |
| 1 | adapters/ecsrelayadapter.h/.cpp |
Adaptateur N-paliers powerswitch, anti-rebond, applyRelayStage(), etm.pri |
| 2 | energyarbitrator.h/.cpp |
buildContext() : SurplusMeter brut, loads[] EV+ECS, registerEcsAdapter(), m_ecsAdapters |
Morceaux à venir
3 — Waterfall ECS dans RuleBasedScheduler::getPlan()
Après la boucle EV proxy, ajouter :
// Déduction unique (correction A) — ctx.meter.exportW = mesure brute
evReservedW = Σ EV en charge dans slot.actions : max(0, commandedA×phases×230 − ev->currentPower())
remainingSurplusW = max(0, ctx.meter.exportW − evReservedW)
// Tri loads ECS par priorité ASC (priority 1 = servi en premier ; protocole §5 + annexe C)
pour chaque LoadContext lc où lc.adapter == "relay-stages" :
budgetCharge = remainingSurplusW + lc.telemetry.currentPowerW // correction B anti-clignotement
bestStage = palier le plus haut dont stages[i] ≤ budgetCharge
reason = "Surplus PV — ECS palier N (W)" ou "Surplus insuffisant — ECS éteint"
remainingSurplusW = remainingSurplusW + lc.telemetry.currentPowerW − stages[bestStage]
// Grid funding : dormant jusqu'à 3f (commente, n'implémente pas)
4 — syncAdapters() extension + applyActionsToAdapters(Slot) dans update()
syncAdapters(): commentaire "découverte ECS via interface 'ecsrelay' déférée 3g"applyActionsToAdapters(Slot): itère slot.actions, dispatche viam_ecsAdapterspour kind==Stage
5 — Watchdog L2 (cf. SAFETY.md §L2)
QTimer m_meterWatchdog30 s — picoté surpowerBalanceChangedSIGNAL pourm_lastMeterUpdateonMeterWatchdogTick(): sinow − m_lastMeterUpdate > 90 s→applyDegradedMode()applyDegradedMode(): ECS stage 0 force=true + EV courant minimum, reason="Compteur muet..."
6 — degradedMode() + notification + INTERFACE.md
virtual bool degradedMode() constdansSmartChargingManager(retourne false,// [ETM])- Override dans
EnergyArbitrator - Champ
"degradedMode": booldansChargingSchedulesChanged(additif, rétro-compatible) - Mise à jour
docs/INTERFACE.md+ limite dansdocs/SAFETY.md: "valeur strictement constante non détectée"
7 — Mock powerswitch + tests
- Mock JSON : ThingClass
mockPowerSwitch(étatpowerbool, étatcurrentPowerdouble) energytestbase.h:mockPowerSwitchThingClassIdtestEcsSurplusPV: cas normal + cas "ECS déjà au palier 1, surplus stable → Y RESTE" (anti-clignotement)testMeterSilentFallback: compteur muet 90 s → mode dégradé → ECS off ; vérifier que l'ECS RESTE à 0 sur plusieurs cyclesupdate()pendant le silence (planification suspendue, pas de rallumage sur cache mort) ; dégel → reprise normale +degradedMode=false. Repli EV : seul un VE déjà en charge est clampé au minimum (pas d'activation forcée). Seam de test : piloteronMeterWatchdogTick()/m_lastMeterUpdatesans 90 s d'horloge réelle.
Arithmétique du budget (corrections A + B)
Correction A — déduction EV unique, dans le scheduler :
exportW dans ctx.meter = mesure brute (invariant 8, protocole §5)
evReservedW = déduction dans getPlan() APRÈS proxy EV, avec m_chargingActions fraîches
Pas de déduction dans buildContext().
Exemple : PV 9 kW, EV stable 7360 W, export mesuré 1140 W → evReservedW=0, budget ECS=1140 W ✓
Correction B — anti-clignotement par recrédit de la conso actuelle :
budgetCharge = remainingSurplusW + lc.telemetry.currentPowerW
Identique à SCM ~l.1245 pour l'EV. Sans ce recrédit : ECS palier 1 → export chute → palier 0 → oscillation.
⚠️ 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
(in-process, V1, GPL) (client unix://|tcp://, V1 aussi —
plan à 1 créneau personne en face en beta : repli rules)
│
│ distribue le budget en LoadAction typées
┌──────────┬────┴─────┬──────────────┐
EvAdapter EcsRelayAdapter SgReadyAdapter BatteryAdapter
(setpoint, (stage 0/1/2) (state 1-4) (constraint +
iface setpoint W réseau)
evcharger
nymea)
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.
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. EcsRelayAdapter (paliers) ; 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 inactif jusqu'à 3g — mais descriptor() et
telemetry() sont utilisés dès maintenant pour le SurplusContext.
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.
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 (à coder en 3c) | 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.
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).- Carte globale du workspace :
../AGENTS.md.