13 KiB
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 :
- Interface interne entre l'arbitrage central et la stratégie par défaut (rule-based).
- Spécification du protocole socket pour un optimiseur en process séparé.
- 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, surpowerBalanceChanged) 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
reasonlisible (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é parroot:powersync, mode0660.tcp://<host>:<port>— 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 :
{"jsonrpc":"2.0","id":1,"method":"Handshake","params":{
"protocolVersion":"1.0",
"engine":"etm-powersync-energy-plugin-etm/<version>",
"token":"<secret partagé, optionnel — requis si configuré côté plugin>"
}}
Réponse attendue :
{"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:truesi l'optimiseur produit des plans multi-créneaux ;falses'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 endpointtcp://.- 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, notificationEms.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)
{"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, avecconfidence(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).nullen France v1.forecast: contenu défini parCONTRACT_A_openmeteo.md. Peut êtrenull(l'optimiseur peut sourcer ses propres prévisions — il est libre).
6. Réponse : plan par créneaux
{"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
nowest 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). reasonabsent 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 :
{"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 notificationUpdateLearned(sans id, fire-and-forget) ; le plugin les persiste et les retransmet dans les contextes suivants. - Tant que
confidence < 0.7(seuil indicatif), lesreasonconcerné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
protocolVersionen 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.