Patrick Schurig cbee13e455 [3c-clôture] ÉTAT : 3c FAITE (suite 18/18 + charging 46/46), arm64 cross délégué CI
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:27:30 +02:00

380 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# AGENTS.md — etm-powersync-energy-plugin-etm
Moteur HEMS. Fork GPL de `nymea-energy-plugin-nymea`, étendu de l'optimisation EV
vers un gestionnaire d'énergie complet (EV, ECS, PAC SG-Ready, batterie).
- **Licence** : GPL-3.0 · **Miroir public** : OUI
- **Branche de travail** : `feature/beta-rulebased`
- **Document d'interface faisant autorité** : `docs/OPTIMIZER_PROTOCOL.md` (le contrat
stratégie/arbitrage — interne ET socket). `INTERFACE.md` fait autorité sur l'API JSON-RPC.
## ÉTAT
| Phase | Statut | Commit(s) |
|-------|--------|-----------|
| 0 — analyse fork / structure | ✅ FAITE | `f4d5b20` |
| 1 — renommage .pro + métadonnées debian | ✅ FAITE | `f4d5b20` |
| 2 — design arbitre validé | ✅ FAITE | `074fa71` |
| 3a — structs protocole + interfaces | ✅ FAITE | `4ae1939` |
| 3b — EnergyArbitrator + scheduler + adapter | ✅ FAITE — iso-fonctionnalité prouvée | `5f49e4c`, `d8ebd65`, `[3b-iv]` |
| 3c — EcsRelayAdapter + waterfall ECS | ✅ FAITE — suite 18/18 + charging 46/46 | `6298d5d``54ba229` |
| 3e — SgReadyAdapter | ⏳ À VENIR (plan en discussion) | — |
**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 (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()`, verrous `minOnS/minOffS`, bypass si `force==true`
- Enregistrement explicite via `EnergyArbitrator::registerEcsAdapter()` (tests + config)
- **Morceau 2** — `buildContext()` : `SurplusMeter` brut (`exportW = max(0, -meter->currentPower())`),
`loads[]` EV + ECS, `SurpusPv` déféré 3d ✅
**3c CLÔTURÉE** (commits `6298d5d` waterfall → `54ba229` testMeterSilentFallback) :
- Morceaux 3-7 faits. Waterfall ECS (tri priorité ASC = rang) + dispatch + watchdog L2
(mode dégradé conservateur, planif suspendue) + degradedMode/notification + tests.
- **Correctif clé** `[3c-3-fix]` : surplus **net signé** (délestage en import) + **clamp
lock-aware** `minStage/maxStage` (protection compresseur) ; **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.
**PROCHAINE ACTION** : 3e — `SgReadyAdapter` (plan en discussion, validation avant code).
Puis plugin device Waveshare D8 (séparé, sous l'adaptateur). 3d (SocketScheduler) et
3f (BatteryAdapter) restent planifiés.
**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
---
## 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 via `m_ecsAdapters` pour kind==Stage
**5 — Watchdog L2** (cf. SAFETY.md §L2)
- `QTimer m_meterWatchdog` 30 s — picoté sur `powerBalanceChanged` SIGNAL pour `m_lastMeterUpdate`
- `onMeterWatchdogTick()` : si `now 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() const` dans `SmartChargingManager` (retourne false, `// [ETM]`)
- Override dans `EnergyArbitrator`
- Champ `"degradedMode": bool` dans `ChargingSchedulesChanged` (additif, rétro-compatible)
- Mise à jour `docs/INTERFACE.md` + limite dans `docs/SAFETY.md` : "valeur strictement constante non détectée"
**7 — Mock powerswitch + tests**
- Mock JSON : ThingClass `mockPowerSwitch` (état `power` bool, état `currentPower` double)
- `energytestbase.h` : `mockPowerSwitchThingClassId`
- `testEcsSurplusPV` : 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 cycles `update()` 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 : piloter `onMeterWatchdogTick()` / `m_lastMeterUpdate` sans 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 :
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. 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** :
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 le palier idéal selon le budget (peut vouloir « palier 0 »).
- L'**adaptateur** borne ce choix via `minStage`/`maxStage` (fenêtre `lockWindow()` évaluée
au temps de cycle) : une charge verrouillée ON garde son palier ; l'import transitoire
est **borné par minOn**, pas illimité. Le scheduler clampe et décrémente le budget au
palier réel (puissance engagée non-coupable) → budget correct pour les charges suivantes.
- `minOnS`/`minOffS` sont des **paramètres par charge** (constructeur `EcsRelayAdapter`,
config installateur) — **jamais codés en dur**.
**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 | ~300600 s | ~300 s | **protection compresseur** (anti court-cycling) |
| SG-Ready PAC (3e) | `minStateHoldS` ~900 s | — | maintien d'état imposé constructeur |
**Seam de temps** : `minStage`/`maxStage` (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`
(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 (à 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 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.
## 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`).
- Carte globale du workspace : `../AGENTS.md`.