diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ce00f0d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,21 @@ +# 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 (ECS, PAC, batterie, relais). + +- **Licence** : GPL-3.0 · **Miroir public** : OUI +- **Agent** : energy-etm · **Branche** : feature/beta-rulebased · **Scope** : energyplugin/ + +## Invariants locaux +1. Tourne SANS `etm-powersync-optimizer` (socket absent → repli stratégie règles). +2. Sécurité jamais déléguée : `verifyOverloadProtection()` (temps réel) borne toute sortie de l'optimiseur. +3. Pas de boucle de feedback : surplus = PV mesurée + compteur, jamais le net. +4. `decisionReason` non vide, en français, sur chaque décision. +5. Aucun composant propriétaire ici (Héos vit dans `etm-powersync-optimizer`). +6. Première tâche (revue) : renommer `nymea-energy-plugin-nymea.pro` → `.pro` ETM + (+ TARGET, debian/). NE PAS toucher aux noms de paquets publiés. + +## Références +- `README.md` (architecture), `INTERFACE.md` (fait autorité sur l'API), `etm_powersync_energy.svg`. + +Carte globale et frontières : voir `../AGENTS.md`. diff --git a/CLAUDE.md b/CLAUDE.md index 7db8c07..cec4f16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,173 +1 @@ -# Agent Plugin — `powersync-energy-plugin-etm` (GPL3) -> Lire aussi le `CLAUDE.md` du dossier parent avant de commencer. - ---- - -## Mon rôle -Je suis le **plugin nymea Community** du HEMS ETM-PowerSync. -Je contiens toute la logique GPL3 : recharge EV sur surplus, tarif HP/HC, -protection surcharge, et le pont vers `powersync-optimizer` pour les tiers payants. - -**Licence : GPL3** — tout mon code est open-source assumé. -Origine : fork de nymea-energy-plugin-nymea (source nymea/Chargebyte, GPL3). -Pas de remote upstream Git public — mises à jour via portage manuel depuis -`etm-nymea/nymea-energy-plugin-nymea`. - ---- - -## Règle fondamentale - -``` -Ce repo = fonctionnalités Community UNIQUEMENT. -Zéro logique Auto / Predict AI dans ce repo. -Ces features passent exclusivement par PowerSyncClient → optimizer. -``` - ---- - -## Ce que je FOURNIS - -### API JSON-RPC `EnergyPlugin.*` -| Méthode | Rôle | Tier | -|---|---|---| -| `GetChargingInfos(evChargerId)` | Config recharge EV | Community | -| `SetChargingInfo(chargingInfo)` | Mettre à jour config borne EV | Community | -| `GetChargingSchedules(evChargerId)` | Planning calculé | Community | -| `GetAvailableSpotMarketProviders()` | Liste providers tarifs | Community | -| `SetSpotMarketConfiguration(enabled, providerId)` | Activer provider | Community | -| `GetSpotMarketScoreEntries(date)` | Cotations horaires | Community | -| `SetPhasePowerLimit(Uint)` | Protection surcharge | Community | -| `SetAcquisitionTolerance(Double)` | Seuil surplus | Community | -| `SetBatteryLevelConsideration(Double)` | Facteur batterie | Community | - -### Notifications push -`ChargingInfoAdded/Removed/Changed`, `ChargingSchedulesChanged`, -`SpotMarketConfigurationChanged`, `SpotMarketScoreEntriesChanged`, -`PhasePowerLimitChanged` - -### `.so` produit -``` -libnymea_energypluginnymea.so ← nom identique à l'upstream (drop-in replacement) -install : /usr/lib/nymea/energy/ -``` - ---- - -## Ce que je CONSOMME - -### Interfaces nymea (par interface, jamais par ThingClassId) -| Interface | États lus | Actions envoyées | -|---|---|---| -| `evcharger` | `chargingEnabled`, `maxChargingCurrent`, `pluggedIn`, `charging`, phases | `setChargingEnabled`, `setMaxChargingCurrent` | -| `electricvehicle` | `batteryLevel`, `maxChargingCurrent`, `capacity` | — | -| `rootmeter` / `energymeter` | `currentPowerPhaseA/B/C`, `currentPhaseA/B/C` | — | -| `energystorage` | `currentPower`, `batteryLevel` | — | - -### Signal déclencheur -`PowerBalanceEntryAdded` depuis `nymea-experience-plugin-energy` → cycle ~1 min. - -### Service propriétaire (optionnel) -`PowerSyncClient` → Unix socket `/run/powersync/optimizer.sock` -Si absent → mode Community local, aucune erreur. - ---- - -## Architecture interne - -``` -powersync-energy-plugin-etm/ -│ -├── [code upstream nymea/Chargebyte] ← ne pas modifier directement -│ ├── SmartChargingManager.* ← à corriger (bugs phase EV) -│ ├── SpotMarketManager.* ← aWATTar AT/DE ✅ -│ └── NymeaEnergyJsonHandler.* ← API JSON-RPC -│ -└── etm/ ← tout notre code ETM ici - ├── PowerSyncClient.* ← pont vers optimizer (Unix socket) - ├── tariff/ - │ └── StaticHcHpProvider.* ← HP/HC statique (Community) - └── [futures extensions Community] -``` - ---- - -## PowerSyncClient — le pont vers l'optimizer - -```cpp -class PowerSyncClient : public QObject { - Q_OBJECT -public: - // Vérifie si powersync-optimizer tourne - bool isAvailable() const; - - // Demande une décision d'optimisation (Auto/Predict AI) - OptimizationResult requestOptimization(const SurplusData &data); - - // Récupère la météo J+1 (Auto) - WeatherForecast getWeatherForecast(); - - // Récupère le tarif dynamique courant (Predict AI) - TariffData getDynamicTariff(); - -signals: - void availabilityChanged(bool available); - void optimizationResultReceived(OptimizationResult result); -}; -``` - -**Comportement du cycle principal :** -```cpp -void SmartChargingManager::runCycle() { - if (m_powerSyncClient->isAvailable()) { - // Auto / Predict AI — délègue à l'optimizer - auto result = m_powerSyncClient->requestOptimization(buildSurplusData()); - applyOptimizationResult(result); - } else { - // Community — logique GPL3 locale - planSurplusCharging(); // EV sur surplus - planSpotMarketCharging(); // EV sur aWATTar - } -} -``` - ---- - -## État actuel du code - -### ✅ Fonctionnel -- SmartChargingManager : recharge EV surplus (mode Eco) + aWATTar AT/DE -- Overload protection triphasée -- API JSON-RPC `EnergyPlugin.*` complète -- Détection appareils par interface (zéro UUID hardcodé) - -### ❌ À corriger en priorité -| Fichier | Problème | Priorité | -|---|---|---| -| `evcharger.cpp:171`, `smartchargingmanager.cpp:394,477,517` | Assume toujours phase A — faux pour EV sur phase B/C | 🔴 | -| `EnergyPluginNymea::init()` | Pas de guard si `EnergyManager*` null | 🟠 | -| `smartchargingmanager.cpp:59` | Récurrence hebdo non terminée | 🟠 | -| `smartchargingmanager.cpp:884` | Planification limitée à 24h | 🟠 | -| `smartchargingmanager.cpp:1835` | Actions EV non séquentielles, pas de retry | 🟠 | - -### ❌ À créer (code ETM dans `etm/`) -- `PowerSyncClient` (pont Unix socket vers optimizer) -- `StaticHcHpProvider` (tarif HP/HC statique — Community) - ---- - -## Règles de modification - -- Tout code ETM va dans `etm/` — jamais dans le code upstream -- Modifier le code upstream uniquement pour corriger des bugs (FIXME existants) -- Tout changement d'API `EnergyPlugin.*` → mettre à jour `INTERFACE.md` -- Ne jamais ajouter de logique Auto/Predict AI dans ce repo -- Build : `qmake energyplugin.pro && make -j$(nproc)` - ---- - -## Portage des mises à jour nymea/Chargebyte - -Quand une nouvelle version est disponible dans `etm-nymea/nymea-energy-plugin-nymea` : -1. `diff -r etm-nymea/nymea-energy-plugin-nymea/ powersync-energy-plugin-etm/` -2. Porter manuellement les corrections hors dossier `etm/` -3. Ne jamais écraser `etm/` +Lis AGENTS.md — il fait autorité sur ce repo. diff --git a/docs/OPTIMIZER_PROTOCOL.md b/docs/OPTIMIZER_PROTOCOL.md new file mode 100644 index 0000000..b56ec65 --- /dev/null +++ b/docs/OPTIMIZER_PROTOCOL.md @@ -0,0 +1,291 @@ +# OPTIMIZER_PROTOCOL — v1 (draft) + +Contrat entre le moteur d'énergie **etm-powersync-energy-plugin-etm** (couche A, GPL-3.0) +et un **optimiseur** externe (couche B : Héos, ou toute implémentation tierce). + +Ce document fait autorité. Il sert trois usages : +1. Interface interne entre l'arbitrage central et la stratégie par défaut (rule-based). +2. Spécification du protocole socket pour un optimiseur en process séparé. +3. Documentation publique : n'importe qui peut implémenter son propre optimiseur. + +Statut : **draft v1** — figé à la fin de la beta. Versionné en semver (`protocolVersion`). + +--- + +## 1. Philosophie + +- **Le plugin ne fait pas confiance à l'optimiseur.** Tout plan reçu est écrêté par les + bornes de sécurité du plugin (limites par charge, protection de surcharge, anti-flapping). + Un optimiseur bugué ou malveillant ne peut produire que des choix sous-optimaux *dans + des limites sûres* — jamais un état dangereux. +- **La boucle de sécurité n'est pas négociable.** `verifyOverloadProtection()` (temps réel, + sur `powerBalanceChanged`) s'exécute indépendamment et prime sur toute consigne. +- **Le système fonctionne sans optimiseur.** Optimiseur absent, mort ou abstentionniste → + repli automatique sur la stratégie rule-based interne (GPL). La maison reste pilotée. +- **Toute décision est expliquée.** Chaque action porte un `reason` lisible (français), + non vide. C'est une exigence du contrat, pas une option. + +## 2. Topologie et transport + +- **Le plugin est le CLIENT. L'optimiseur est le SERVEUR.** Le plugin se connecte à un + endpoint configuré et demande des plans. La box n'ouvre aucun port. +- Endpoint configurable : + - `unix:///run/powersync/optimizer.sock` — défaut. Optimiseur local (Héos). + Droits : socket possédé par `root:powersync`, mode `0660`. + - `tcp://:` — optimiseur sur le LAN (NAS, serveur). Pour les + implémentations communautaires (ex. pont Akkudoktor-EOS, annexe B). +- Protocole applicatif : **JSON-RPC 2.0, un message par ligne (NDJSON), UTF-8.** +- Le contrat est transport-agnostique : mêmes messages sur unix et tcp. +- Reconnexion : backoff exponentiel (1 s → 60 s max). Pendant la déconnexion : + stratégie de repli active, `optimizerAlive = false`. + +## 3. Handshake + +À chaque (re)connexion, le plugin envoie : + +```json +{"jsonrpc":"2.0","id":1,"method":"Handshake","params":{ + "protocolVersion":"1.0", + "engine":"etm-powersync-energy-plugin-etm/", + "token":"" +}} +``` + +Réponse attendue : + +```json +{"jsonrpc":"2.0","id":1,"result":{ + "protocolVersion":"1.0", + "name":"heos", + "planning":true +}} +``` + +- `protocolVersion` : même **majeure** requise. Majeure différente → le plugin refuse, + log explicite, repli rule-based. +- `planning` : `true` si l'optimiseur produit des plans multi-créneaux ; `false` s'il + ne répond qu'au présent (plan à un créneau). Informationnel. +- `token` : si configuré côté plugin et absent/incorrect côté serveur → connexion fermée. + Recommandé pour tout endpoint `tcp://`. +- Handshake invalide ou timeout (5 s) → repli, nouvelle tentative au backoff. + +## 4. Cycle et heartbeat + +- Le plugin appelle `GetPlan` à chaque cycle d'optimisation (~1/min) **et** sur + événement significatif (charge branchée/débranchée, changement de config). +- **Le heartbeat est implicite** : une réponse valide à `GetPlan` = optimiseur vivant. + Deux cycles consécutifs sans réponse valide (timeout par requête : 10 s) → + `optimizerAlive = false`, repli rule-based, notification `Ems.CapabilitiesChanged`. +- L'optimiseur peut être lent à *calculer* (EOS : minutes) — il doit alors répondre + vite avec son **dernier plan en cache** ou `abstain`, et recalculer en tâche de fond. + Une requête n'attend jamais un calcul long. + +## 5. Requête : `GetPlan(SurplusContext)` + +```json +{"jsonrpc":"2.0","id":42,"method":"GetPlan","params":{ + "timestamp":"2026-06-07T13:42:00+02:00", + "site":{ + "contractedPowerW":12000, + "gridCapW":null, + "phaseLimitA":[63,63,63] + }, + "meter":{"importW":0,"exportW":2310,"perPhaseA":[3.2,4.1,2.8]}, + "pv":{"currentW":3400}, + "battery":{ + "present":true,"socPercent":62,"powerW":-800, + "capacityWh":10000,"reserveSocPercent":15, + "maxChargeW":5000,"maxDischargeW":5000 + }, + "loads":[ + { + "id":"ecs-1","adapter":"relay-stages","label":"Chauffe-eau", + "declared":{"nominalPowerW":2400,"stages":[0,1200,2400]}, + "defaults":{"dailyEnergyWh":7000}, + "learned":{"dailyEnergyWh":6400,"confidence":0.35}, + "telemetry":{"state":1,"currentPowerW":1200,"lastSwitch":"2026-06-07T13:10:00+02:00"}, + "needs":{"dailyDeadline":"18:00","minEnergyWhPerDay":4000}, + "priority":2, + "limits":{"minOnS":300,"minOffS":300} + }, + { + "id":"ev-1","adapter":"evcharger","label":"Borne garage", + "declared":{"minA":6,"maxA":16,"phases":3}, + "telemetry":{"pluggedIn":true,"charging":true,"currentPowerW":4100,"sessionWh":5200}, + "needs":{"targetSocPercent":80,"deadline":"2026-06-08T07:00:00+02:00"}, + "priority":1, + "limits":{"chargingEnabledLockS":300,"currentChangeLockS":60} + }, + { + "id":"pac-1","adapter":"sg-ready","label":"PAC", + "declared":{"states":[1,2,3,4],"estimatedPowerW":{"3":1800,"4":2600}}, + "telemetry":{"state":2}, + "priority":3, + "limits":{"minStateHoldS":900} + }, + { + "id":"bat-1","adapter":"battery","label":"Batterie Fronius", + "priority":4 + } + ], + "forecast":{ "...":"objet Contract A (openmeteo) transmis tel quel, ou null" }, + "tariff":{ + "provider":"tempo","current":{"label":"HP_BLEU","priceCtkWh":16.1}, + "next":[{"from":"2026-06-08T06:00","label":"ROUGE","priceCtkWh":75.6}] + } +}} +``` + +Notes de schéma : +- `loads[].declared` : saisi par l'installateur (plaque signalétique, câblage). Fiable. +- `loads[].defaults` : valeurs approximatives par catégorie. Point de départ. +- `loads[].learned` : estimations de l'optimiseur, **renvoyées** au plugin pour + persistance et affichage, avec `confidence` (0–1). Le rule-based les ignore. +- `loads[].priority` : rang dans la liste ordonnée définie par le client (1 = premier + servi). Voir annexe C. +- `loads[].limits` : verrous anti-flapping. Le plugin LES APPLIQUE quoi qu'il arrive ; + ils sont transmis pour que l'optimiseur planifie intelligemment, pas pour qu'il + les fasse respecter. +- `site.gridCapW` : réservé (signaux réseau type §14a EnWG). `null` en France v1. +- `forecast` : contenu défini par `CONTRACT_A_openmeteo.md`. Peut être `null` + (l'optimiseur peut sourcer ses propres prévisions — il est libre). + +## 6. Réponse : plan par créneaux + +```json +{"jsonrpc":"2.0","id":42,"result":{ + "planId":"2026-06-07T13:42:00Z-a1", + "strategy":"heos-predict", + "slots":[ + { + "from":"2026-06-07T13:42:00+02:00","to":"2026-06-07T14:00:00+02:00", + "actions":[ + {"loadId":"ev-1","kind":"setpoint","currentA":10, + "reason":"Surplus PV 2,3 kW → VE prioritaire (cible 80% à 07:00)"}, + {"loadId":"ecs-1","kind":"stage","stage":1, + "reason":"Reliquat 0,9 kW → ECS palier 1 (besoin estimé 6,4 kWh/j, en apprentissage)"}, + {"loadId":"bat-1","kind":"constraint","charge":"allow","discharge":"forbid", + "reason":"Jour ROUGE demain : décharge bloquée, on préserve le SoC"} + ] + }, + { + "from":"2026-06-07T22:00:00+02:00","to":"2026-06-08T06:00:00+02:00", + "actions":[ + {"loadId":"bat-1","kind":"setpoint","powerW":3000,"source":"grid", + "reason":"Charge réseau HC avant jour ROUGE (plafond 3 kW : abonnement 12 kVA)"} + ] + } + ] +}} +``` + +Règles d'exécution côté plugin : +- **Seul le créneau couvrant `now` est exécuté.** Le reste du plan est informatif + (affichage app : « prévision programmée ») et sera re-demandé au cycle suivant. +- Plan vide ou sans créneau courant = abstention (voir §7). +- Toute action est **écrêtée** : bornes min/max de l'adaptateur, verrous temporels, + protection de surcharge. Une action écrêtée est exécutée au plus proche possible + et loggée (`clamped`). +- `reason` absent ou vide → action **rejetée**, log d'avertissement. (Exigence + decisionReason.) +- Un rule-based interne répond au même contrat avec un plan à un seul créneau. + +### Types d'action (`kind`) + +| `kind` | Charges | Champs | Exemple | +|---|---|---|---| +| `setpoint` | evcharger, battery | `currentA` ou `powerW` (+ `source:"grid"` pour charge réseau batterie) | borne à 10 A ; batterie +3000 W depuis réseau | +| `stage` | relay-stages (ECS…) | `stage` (entier, parmi `declared.stages`) | ECS palier 1 (1200 W) | +| `state` | sg-ready | `state` (1=verrouillage, 2=normal, 3=recommandation, 4=forcé) | PAC en état 3 | +| `constraint` | battery (v1) | `charge`/`discharge` : `allow`\|`forbid` | décharge interdite | + +## 7. Abstention et repli + +L'optimiseur peut répondre : + +```json +{"jsonrpc":"2.0","id":42,"result":{"abstain":true,"reason":"recalcul en cours"}} +``` + +Sémantique : « je n'ai pas de plan à proposer maintenant ». Le plugin bascule sur la +stratégie rule-based **pour ce cycle**, sans considérer l'optimiseur comme mort +(`optimizerAlive` reste `true`). C'est le comportement attendu pendant un recalcul, +un démarrage à froid, ou une absence de données. + +Tableau de repli complet : + +| Situation | optimizerAlive | Stratégie active | +|---|---|---| +| Réponse plan valide | true | optimiseur | +| `abstain` | true | rules (ce cycle) | +| Timeout / erreur / plan invalide ×2 | false | rules | +| Socket absent / fermé | false | rules | +| `optimizerExpected=false` (tier Community) | false | rules | + +L'objet capabilities exposé à l'app (`tier`, `optimizerExpected`, `optimizerAlive`, +`activeStrategy`) reflète cet état en continu ; tout changement émet une notification. + +## 8. Apprentissage et transparence + +- L'optimiseur peut renvoyer ses estimations (`learned` + `confidence`) dans une + notification `UpdateLearned` (sans id, fire-and-forget) ; le plugin les persiste + et les retransmet dans les contextes suivants. +- Tant que `confidence < 0.7` (seuil indicatif), les `reason` concernés mentionnent + l'apprentissage (« besoin estimé …, en apprentissage ») et l'app peut afficher un + badge « profil en apprentissage » sur la charge. +- Les données d'apprentissage restent **locales** (buffer circulaire, ~2 mois, + écrasement). Rien ne quitte le site. C'est un engagement produit, pas un détail. + +## 9. Versionnement + +- `protocolVersion` en semver. Même majeure = compatible. +- Champs inconnus dans `params`/`result` : **ignorés silencieusement** (tolérance + ascendante). Ajouts mineurs = nouveaux champs optionnels. +- Changements incompatibles (renommage, sémantique) = majeure + entrée changelog + dans ce fichier. + +--- + +## Annexe A — Session minimale + +``` +→ {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{"protocolVersion":"1.0","engine":"etm/0.9"}} +← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.0","name":"mon-optimiseur","planning":false}} +→ {"jsonrpc":"2.0","id":2,"method":"GetPlan","params":{ ...SurplusContext... }} +← {"jsonrpc":"2.0","id":2,"result":{"planId":"x","strategy":"custom","slots":[{ "from":"...","to":"...","actions":[...] }]}} +``` + +Un optimiseur conforme minimal tient en ~50 lignes de Python : répondre au handshake, +renvoyer un plan à un créneau (ou `abstain`). C'est le point d'entrée communautaire. + +## Annexe B — Exemple : brancher Akkudoktor-EOS + +EOS (github.com/Akkudoktor-EOS/EOS) est un optimiseur-planificateur HTTP (Python, +algorithme génétique, horizon 48 h). Intégration par un **pont** (~200 lignes) tournant +où l'on veut sur le LAN : + +``` +plugin (client) ──tcp://nas:7600──► eos-bridge ──HTTP──► serveur EOS +``` + +Le pont : (1) répond au Handshake ; (2) traduit `SurplusContext` → payload EOS +(séries horaires PV/conso/prix — il peut compléter avec les prévisions Akkudoktor) ; +(3) traduit le plan EOS → `slots[]`/`LoadAction` ; (4) sert le plan en cache et +recalcule en arrière-plan (EOS est lent, le protocole exige des réponses rapides) ; +(5) répond `abstain` pendant un recalcul initial. + +Les bornes de sécurité du plugin s'appliquent intégralement : un plan EOS aberrant +est écrêté, pas exécuté aveuglément. + +## Annexe C — Priorités : modèle utilisateur + +- **Liste ordonnée globale** (drag-and-drop dans l'app) : l'ordre de service du + surplus. Le rule-based l'exécute en cascade (waterfall) ; un planificateur s'en + sert comme préférence quand tout ne peut pas être servi. +- **Besoins par charge** (fiche équipement) : échéances (`deadline`, + `dailyDeadline`), minimums (`minEnergyWhPerDay`), cible (`targetSocPercent`). +- **Promotions conditionnelles** : une échéance qui approche ou un signal tarifaire + (Tempo ROUGE) peut faire passer une charge devant la liste — toujours expliqué + dans `reason`. + +Pas de poids, pas de pourcentages : un ordre, des besoins, des exceptions expliquées.