# 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.