# 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 | ~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** : `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`.
