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

21 KiB
Raw Blame History

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 6298d5d54ba229
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 0LoadAction.force=false (bypass verrous sécurité)
  • Morceau 1EcsRelayAdapter (.h + .cpp) : pilote N Things powerswitch, applyRelayStage(), verrous minOnS/minOffS, bypass si force==true
    • Enregistrement explicite via EnergyArbitrator::registerEcsAdapter() (tests + config)
  • Morceau 2buildContext() : 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 sapplyDegradedMode()
  • 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 (ChargingActionLoadAction, 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.