diff --git a/docs/fonctionnalites/moteur.md b/docs/fonctionnalites/moteur.md
new file mode 100644
index 0000000..3a3ca92
--- /dev/null
+++ b/docs/fonctionnalites/moteur.md
@@ -0,0 +1,85 @@
+# Comment le moteur décide
+
+
+
+Le moteur d'énergie de la box pilote seul vos charges : bornes de recharge, chauffe-eau,
+pompe à chaleur SG-Ready, relais. Il est libre (GPL-3.0) et c'est lui qui décide dans la
+version **Community**. Cette page décrit sa logique pour l'installateur et l'intégrateur ;
+les pages suivantes détaillent chaque fonction.
+
+## Deux boucles, deux priorités
+
+Le moteur sépare la sécurité de la répartition. Ce sont deux boucles distinctes, qui ne
+tournent pas au même rythme et n'ont pas la même priorité.
+
+- **La boucle de sécurité, en temps réel.** À chaque variation du bilan mesuré, elle lit
+ le courant de chaque phase au compteur de tête. Si une phase dépasse la limite
+ configurée, elle réduit immédiatement les charges ([délestage](delestage.md)), puis
+ relâche quand la marge revient. Elle n'attend pas le cycle et prime sur tout le reste.
+- **Le cycle de répartition, environ une fois par minute.** Il répartit la puissance
+ disponible entre les charges. C'est le seul endroit où une stratégie intervient : les
+ règles intégrées, ou un [optimiseur](../optimiseur/index.md) s'il est branché.
+
+Si le compteur de tête ne répond plus, le moteur suspend la répartition et passe en repli
+de sécurité : sans mesure, il ne décide pas.
+
+## Les règles, dans l'ordre
+
+À chaque cycle, le moteur applique les mêmes étapes, dans le même ordre.
+
+1. **Mesurer.** Le point de départ est ce que mesure le compteur de tête. Le moteur y
+ rajoute ce que consomment déjà les charges qu'il pilote : une charge en marche n'est
+ pas comptée contre elle-même.
+2. **Tenir compte de ce qui est figé.** Une charge sous verrou (durée minimale de marche
+ ou d'arrêt, délai entre deux changements de courant) garde sa puissance engagée, et
+ celle-ci est retirée du budget. Une charge indisponible ne reçoit aucune consigne, mais
+ ce qu'elle consomme reste compté.
+3. **Servir les besoins à échéance.** Une obligation quotidienne (par exemple une énergie
+ minimale pour le chauffe-eau avant une heure donnée) ou une demande ponctuelle faite
+ depuis l'application peut, quand l'échéance approche et que le surplus n'a pas suffi,
+ prendre le complément sur le réseau — si l'installateur l'a autorisé.
+4. **Répartir le surplus en cascade.** Les charges sont servies dans l'ordre de
+ **priorité** que vous avez fixé (1 = servie en premier). Chacune reçoit ce qui reste du
+ budget, dans ses bornes, et le budget est décrémenté d'autant. Le budget est **unique** :
+ sans lui, trois gestionnaires verraient chacun « 2 kW de surplus » et en tireraient 6.
+5. **Arrêter ce qui ne peut pas démarrer.** Sous sa puissance minimale, une charge reste
+ arrêtée plutôt que de fonctionner à moitié.
+
+### Les bornes de chaque charge
+
+Chaque charge porte des bornes saisies par l'installateur, que **toute** décision
+respecte, quelle que soit la stratégie :
+
+- un **plancher** et un **plafond** de puissance (ou de courant, pour une borne) ;
+- des **verrous anti-battement** : durée minimale de marche et d'arrêt, délai entre deux
+ changements ;
+- pour une PAC SG-Ready : l'état choisi selon la puissance estimée de chaque état, avec
+ une durée minimale de maintien.
+
+## Le motif de chaque décision
+
+Le moteur ne prend aucune décision sans en dire la raison. Chaque décision porte un
+**motif** (`decisionReason`) non vide : un code stable et ses paramètres, que
+l'application affiche dans votre langue — « surplus insuffisant », « sous la puissance
+minimale », « échéance de recharge »… La liste des codes est publiée avec le
+[protocole optimiseur](../optimiseur/protocole.md#reponse).
+
+## La mesure, jamais le résultat du pilotage
+
+Le surplus est calculé sur la **mesure brute** : production et compteur, tels qu'ils
+sont mesurés. Jamais sur le bilan net après pilotage : le moteur se lirait lui-même et
+oscillerait.
+
+## Sans internet
+
+Tout ce qui précède tourne sur la box. Sans internet, le moteur pilote, applique la
+sécurité et répond à l'application sur votre réseau local. Seule la
+[prévision PV](prevision-pv.md) s'interrompt — et elle ne sert pas à décider dans la
+version Community, pas plus que le [tarif saisi](../tarifs.md) : tous deux sont affichés
+et enregistrés, jamais utilisés par les règles.
+
+## Et un optimiseur ?
+
+Un optimiseur peut proposer un plan sur plusieurs heures, à partir des prévisions. Il
+remplace l'étape de répartition, **jamais** la boucle de sécurité ni les bornes. Voir la
+section [Optimiseur](../optimiseur/index.md).
diff --git a/docs/optimiseur/contrat.md b/docs/optimiseur/contrat.md
new file mode 100644
index 0000000..d7c8295
--- /dev/null
+++ b/docs/optimiseur/contrat.md
@@ -0,0 +1,54 @@
+# Contrat
+
+Le **contrat machine** décrit la forme de chaque message du [protocole](protocole.md) :
+un document [OpenRPC](https://spec.open-rpc.org/) dont les messages sont en JSON Schema,
+complété par des extensions `x-etm-…` pour ce qu'OpenRPC ne sait pas dire — transport,
+machine de repli, règles de validation nommées, tolérances, listes fermées.
+
+Le moteur l'embarque, et ses tests comme ceux de la suite de conformité le vérifient des
+deux côtés. Les tableaux de la page [Protocole](protocole.md) en sont générés.
+
+## Télécharger le contrat {#telecharger}
+
+[:material-download: Télécharger `etm-powersync-optimizer-protocol.openrpc.json`](etm-powersync-optimizer-protocol.openrpc.json){ .md-button }
+
+Le site sert le fichier **tel quel**, octet pour octet, depuis la branche par défaut du
+dépôt du moteur. Sa fiche :
+
+
+
+| | Contract |
+|---|---|
+| Title | etm-powersync — protocole moteur ↔ optimiseur |
+| Protocol version | `1.1` |
+| Format | OpenRPC `1.3.2`, JSON Schema in `components.schemas` |
+| License | GPL-3.0-or-later |
+| File | [`etm-powersync-optimizer-protocol.openrpc.json`](etm-powersync-optimizer-protocol.openrpc.json) |
+| Size | 67215 bytes |
+| SHA-256 | `29dfe5a3c94a06e94e95042c0ceefd4d6264fcca44265faa676a06fac1c07ded` |
+| Methods | `Handshake` · `GetPlan` · `UpdateLearned` |
+| Schemas | 47 |
+| Semantic rules | 19 |
+| Extensions (document) | `x-etm-fallback-machine` · `x-etm-generated` · `x-etm-reason-params` · `x-etm-semantic-rules` · `x-etm-tolerances` · `x-etm-transport` |
+| Extensions (methods) | `x-etm-direction` · `x-etm-notification` · `x-etm-timeout-s` |
+
+
+
+## Le contrat de votre box : `NymeaEnergy.GetOptimizerProtocol` {#getoptimizerprotocol}
+
+Le contrat publié ici suit le développement du moteur ; votre box peut porter une version
+antérieure. Pour connaître **le** contrat du moteur installé, demandez-le à la box : la
+méthode JSON-RPC `NymeaEnergy.GetOptimizerProtocol` rend celui qu'elle embarque. C'est
+celui-là qui fait foi pour un optimiseur branché sur cette box.
+
+La méthode est décrite dans
+[`INTERFACE.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/INTERFACE.md),
+dans le dépôt du moteur, avec les autres méthodes `NymeaEnergy.*` de l'optimiseur
+(point de connexion, secret partagé, état). La connexion à l'API est décrite sur la page
+[API JSON-RPC](../integrations/jsonrpc-api.md).
+
+## Valider des messages {#valider}
+
+Le dépôt du moteur fournit un validateur, `tools/etm-powersync-schema-check` (Python,
+bibliothèque standard) : il juge un message, une session complète enregistrée ou des
+traces contre le contrat, et applique sur demande les règles de validation nommées.
diff --git a/docs/optimiseur/ecrire.md b/docs/optimiseur/ecrire.md
new file mode 100644
index 0000000..64ffa9a
--- /dev/null
+++ b/docs/optimiseur/ecrire.md
@@ -0,0 +1,101 @@
+# Écrire un optimiseur
+
+Un optimiseur est un **serveur** qui parle le [protocole](protocole.md) : il répond au
+handshake, puis à chaque `GetPlan` par un plan à un ou plusieurs créneaux, ou par une
+abstention. Le langage est libre. Un optimiseur minimal tient en une cinquantaine de
+lignes de Python.
+
+Session minimale, à titre d'illustration (la forme exacte est dans le [contrat](contrat.md)) :
+
+```text
+→ {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{"protocolVersion":"1.1","engine":"etm-powersync-energy-plugin-etm/…"}}
+← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.1","name":"mon-optimiseur","planning":false}}
+→ {"jsonrpc":"2.0","id":2,"method":"GetPlan","params":{ …contexte… }}
+← {"jsonrpc":"2.0","id":2,"result":{"abstain":true,"reason":"premier calcul en cours"}}
+```
+
+Trois points à ne pas manquer :
+
+- **une action par charge disponible** dans le créneau en cours, chacune avec son
+ **motif** en texte : sinon le plan entier est refusé ;
+- **répondre vite** : servir le dernier plan en cache, ou s'abstenir, et calculer en tâche
+ de fond ;
+- **ne rien aller chercher ailleurs** : tout ce dont l'optimiseur a besoin, prévisions
+ comprises, est dans le contexte.
+
+## L'optimiseur de référence {#reference}
+
+Le dépôt du moteur contient un optimiseur de référence libre (GPL-3.0), en Python,
+bibliothèque standard seule :
+[`tools/etm-powersync-ref-optimizer/`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/tree/main/tools/etm-powersync-ref-optimizer).
+Il répond un plan à un créneau qui reproduit la cascade des règles du moteur, en ne lisant
+que le contexte. Il s'annonce `powersync-optimizer` au handshake.
+
+C'est un point de départ pour écrire le sien, et un point de comparaison : il écoute sur
+le socket local par défaut, ou sur `tcp://` avec un secret partagé lu dans un fichier. Il
+sait aussi injecter des défauts (abstention, lenteur, plan invalide, majeure différente…)
+pour tester le repli du moteur. Mode d'emploi :
+[son `README.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/tools/etm-powersync-ref-optimizer/README.md).
+
+## Brancher le moteur sur son optimiseur {#brancher}
+
+Trois méthodes JSON-RPC de la box, réservées à l'administrateur, décrites dans
+[`INTERFACE.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/INTERFACE.md) :
+
+- `NymeaEnergy.SetOptimizerEndpoint` — où se connecter (`unix:///…` ou `tcp://hôte:port`) ;
+- `NymeaEnergy.SetOptimizerToken` — le secret partagé, le même que celui de l'optimiseur ;
+- `NymeaEnergy.GetOptimizerStatus` — l'état de la connexion : optimiseur vivant, stratégie
+ active, nom annoncé, dernier code d'erreur.
+
+Sur `tcp://` sans chiffrement, le secret circule en clair sur le réseau local.
+
+## La suite de conformité {#conformite}
+
+La suite `etm-powersync-optimizer-conformance` lance **votre** optimiseur et branche dessus
+le **vrai** moteur, sur des appareils simulés. Elle vérifie, une fonction par point :
+
+1. le handshake est accepté ;
+2. chaque `GetPlan` reçoit sa réponse dans le délai du moteur ;
+3. la session entière, dans les deux sens, est conforme au contrat embarqué par le moteur,
+ règles de validation comprises ;
+4. chaque plan est appliqué : c'est l'optimiseur qui commande ;
+5. la boucle de sécurité reste au-dessus : une consigne hors bornes est écrêtée, une
+ surcharge de phase est délestée.
+
+Elle ne juge pas la **qualité** des plans.
+
+### La lancer contre son optimiseur
+
+Construire le moteur avec ses tests, puis indiquer la commande qui démarre votre serveur ;
+`{endpoint}` y est remplacé par l'adresse où écouter :
+
+```sh
+export ETM_POWERSYNC_OPTIMIZER_CMD='exec /chemin/de/mon-optimiseur --listen {endpoint}'
+export ETM_POWERSYNC_CONFORMANCE_EXPECT=plan # ou abstain
+```
+
+Chaque fonction se lance dans son propre processus. La commande complète (isolement
+réseau recommandé), les autres variables (secret partagé, délai de démarrage…) et la
+lecture des échecs sont dans le
+[`README.md` de la suite](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/tests/auto/conformance/README.md).
+
+### Mode attendu : `plan` ou `abstain` {#mode-attendu}
+
+`ETM_POWERSYNC_CONFORMANCE_EXPECT` dit à la suite ce que fait votre optimiseur :
+
+- **`plan`** (par défaut) : il commande. Chaque cycle doit appliquer son plan.
+- **`abstain`** : il s'abstient à chaque cycle — c'est permis, et conforme. La suite
+ vérifie alors que le moteur pilote par ses règles à chaque cycle, sans compter
+ d'échec, et que la sécurité tient toujours ; le contrôle d'écrêtage est sauté, faute de
+ consigne à écrêter.
+
+Toute autre valeur fait échouer la suite. **Le mode ne se déduit pas du handshake** :
+`planning` et `capabilities` décrivent la forme des plans, pas le fait d'en produire. Le
+mode se donne, il ne se devine pas.
+
+## Comparer sur les mêmes entrées {#traces}
+
+Le moteur peut enregistrer, sur la box, une trace de chaque cycle (désactivée par défaut,
+`NymeaEnergy.SetOptimizerTrace`). L'outil `tools/etm-powersync-trace-replay` rejoue ces
+traces sur un optimiseur candidat et résume les écarts avec le plan enregistré. Les traces
+restent sur le site.
diff --git a/docs/optimiseur/etm-powersync-optimizer-protocol.openrpc.json b/docs/optimiseur/etm-powersync-optimizer-protocol.openrpc.json
new file mode 100644
index 0000000..801705b
--- /dev/null
+++ b/docs/optimiseur/etm-powersync-optimizer-protocol.openrpc.json
@@ -0,0 +1,2178 @@
+{
+ "openrpc": "1.3.2",
+ "info": {
+ "title": "etm-powersync — protocole moteur ↔ optimiseur",
+ "version": "1.1",
+ "license": {
+ "name": "GPL-3.0-or-later",
+ "url": "https://www.gnu.org/licenses/gpl-3.0.html"
+ },
+ "description": "Contrat MACHINE du protocole entre le moteur d'énergie etm-powersync-energy-plugin-etm (client) et un optimiseur externe (serveur) — docs/OPTIMIZER_PROTOCOL.md en est la prose. Ce document est la source de la forme des messages ; il est servi par le moteur qui tourne (NymeaEnergy.GetOptimizerProtocol) et les deux côtés sont testés contre lui.\n\nCe qu'OpenRPC ne dit pas, et où cela se trouve :\n- TRANSPORT : unix:// (défaut, socket local 0660) ou tcp:// (LAN), JSON-RPC 2.0, un message par ligne (NDJSON), UTF-8 — x-etm-transport ;\n- SENS : le moteur est le CLIENT, il se connecte et envoie Handshake puis GetPlan ; la box n'ouvre aucun port. UpdateLearned va dans l'autre sens (x-etm-direction) ;\n- HEARTBEAT IMPLICITE et machine de repli §7 — x-etm-fallback-machine ;\n- VALIDATION SÉMANTIQUE du plan : le JSON Schema dit la forme ; ce que le validateur du moteur refuse en plus (charge inconnue, créneau courant incomplet…) est listé, règle par règle, dans x-etm-semantic-rules avec son fallbackCode ;\n- listes fermées : régénérées DEPUIS LE CODE (x-etm-generated), jamais recopiées à la main.\n\nForme côté moteur (requêtes) : STRICTE — additionalProperties: false ; un champ ajouté au sérialiseur sans le schéma fait échouer les tests. Forme côté optimiseur (réponses) : ouverte — un champ inconnu est ignoré (§9) ; le schéma dit ce qu'un optimiseur CONFORME envoie, x-etm-tolerances ce que le moteur accepte en plus."
+ },
+ "servers": [
+ {
+ "name": "unix",
+ "url": "unix:///run/powersync/optimizer.sock",
+ "summary": "Défaut : optimiseur local, socket possédé par root:powersync, mode 0660."
+ },
+ {
+ "name": "tcp",
+ "url": "tcp://{host}:{port}",
+ "summary": "Optimiseur sur le LAN. Token recommandé ; il circule en clair sans TLS (E16).",
+ "variables": {
+ "host": {
+ "default": "192.168.1.10"
+ },
+ "port": {
+ "default": "7400"
+ }
+ }
+ }
+ ],
+ "methods": [
+ {
+ "name": "Handshake",
+ "summary": "Ouverture de session, à chaque (re)connexion (§3).",
+ "description": "Envoyé par le moteur (client) dès la connexion. Délai : 5 s depuis le DÉBUT de la tentative ; au-delà, repli et nouvelle tentative au backoff. Le message porte le secret : il n'est jamais journalisé.",
+ "paramStructure": "by-name",
+ "params": [
+ {
+ "name": "protocolVersion",
+ "required": true,
+ "schema": {
+ "$ref": "#/components/schemas/ProtocolVersion"
+ },
+ "description": "Version du moteur ; seule la majeure est comparée."
+ },
+ {
+ "name": "engine",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^etm-powersync-energy-plugin-etm/"
+ },
+ "description": "Moteur et sa version."
+ },
+ {
+ "name": "token",
+ "required": false,
+ "schema": {
+ "$ref": "#/components/schemas/HandshakeToken"
+ }
+ }
+ ],
+ "result": {
+ "name": "handshake",
+ "schema": {
+ "$ref": "#/components/schemas/HandshakeResult"
+ }
+ },
+ "errors": [
+ {
+ "code": -32001,
+ "message": "token refusé",
+ "data": "Recommandé (optimiseur de référence). Toute réponse error est traitée pareil : handshake-refused, connexion fermée."
+ }
+ ],
+ "x-etm-direction": "engine→optimizer",
+ "x-etm-timeout-s": 5
+ },
+ {
+ "name": "GetPlan",
+ "summary": "Demande d'un plan pour le contexte courant (§4-§6).",
+ "description": "À chaque cycle (~1/min) et sur événement (trigger). Une requête en vol au plus : une plus récente remplace l'attente de la précédente (sa réponse est ignorée par id, ce n'est pas un échec). Délai : 10 s par requête. La réponse à la requête N sert au cycle N+1 (modèle cache). Le JSON Schema dit la FORME ; x-etm-semantic-rules dit ce que le validateur refuse en plus.",
+ "paramStructure": "by-name",
+ "params": [
+ {
+ "name": "timestamp",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2}(\\.[0-9]+)?)?(Z|[+-][0-9]{2}:[0-9]{2})$",
+ "description": "Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée."
+ },
+ "description": "Instant du cycle — le « maintenant » du validateur."
+ },
+ {
+ "name": "trigger",
+ "required": true,
+ "schema": {
+ "$ref": "#/components/schemas/Trigger"
+ }
+ },
+ {
+ "name": "site",
+ "required": true,
+ "schema": {
+ "$ref": "#/components/schemas/Site"
+ }
+ },
+ {
+ "name": "meter",
+ "required": true,
+ "schema": {
+ "$ref": "#/components/schemas/Meter"
+ }
+ },
+ {
+ "name": "pv",
+ "required": true,
+ "schema": {
+ "$ref": "#/components/schemas/Pv"
+ }
+ },
+ {
+ "name": "battery",
+ "required": true,
+ "schema": {
+ "$ref": "#/components/schemas/Battery"
+ }
+ },
+ {
+ "name": "loads",
+ "required": true,
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Load"
+ }
+ },
+ "description": "Charges arbitrées, ⊆ GetLoadConfig."
+ },
+ {
+ "name": "forecast",
+ "required": true,
+ "schema": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/Forecast"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "description": "null sans instantané de prévision."
+ },
+ {
+ "name": "tariff",
+ "required": true,
+ "schema": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/Tariff"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ }
+ ],
+ "result": {
+ "name": "plan",
+ "schema": {
+ "$ref": "#/components/schemas/GetPlanResult"
+ }
+ },
+ "errors": [
+ {
+ "code": -32603,
+ "message": "erreur interne",
+ "data": "Toute réponse error compte comme un échec de la requête (rpc-error, §7) ; le code n'est pas lu."
+ }
+ ],
+ "x-etm-direction": "engine→optimizer",
+ "x-etm-timeout-s": 10
+ },
+ {
+ "name": "UpdateLearned",
+ "summary": "Estimations apprises de l'optimiseur (§8) — notification, sans id.",
+ "description": "Envoyée par l'OPTIMISEUR (sens inverse des deux autres), sans id, sans réponse. État 1.1 du moteur : reçue, journalisée une fois, IGNORÉE — rien n'est persisté, le contenu n'est pas lu. Forme non normée en 1.1.",
+ "paramStructure": "by-name",
+ "params": [
+ {
+ "name": "loads",
+ "required": false,
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "description": "Non normé en 1.1 : intention §8 (learned + confidence par charge)."
+ }
+ ],
+ "x-etm-direction": "optimizer→engine",
+ "x-etm-notification": true
+ }
+ ],
+ "components": {
+ "schemas": {
+ "ProtocolVersion": {
+ "description": "Version du protocole parlée par le moteur (GEN : OptimizerProtocol::ProtocolVersion).",
+ "const": "1.1"
+ },
+ "Trigger": {
+ "description": "Cause de la requête (§5.1). GEN : SocketScheduler::knownTriggers() — du moins au plus significatif.",
+ "type": "string",
+ "enum": [
+ "cycle",
+ "forecast",
+ "config",
+ "load-change",
+ "intent"
+ ]
+ },
+ "Domain": {
+ "description": "Intention déclarée par l'installateur, jamais déduite du mécanisme, non lue par l'arbitre (§5.3). GEN : LoadConfig::knownDomains().",
+ "type": "string",
+ "enum": [
+ "ecs",
+ "ev",
+ "heating",
+ "hvac",
+ "battery"
+ ]
+ },
+ "ActionKind": {
+ "description": "Type d'action (§6). GEN : OptimizerPlan::kinds(). `constraint` est RÉSERVÉ (annexe F) : reconnu par le validateur, toujours refusé (règle kind-mismatch) faute de mécanisme porteur.",
+ "type": "string",
+ "enum": [
+ "setpoint",
+ "state",
+ "constraint"
+ ]
+ },
+ "ReasonCode": {
+ "description": "Codes de motif qu'un optimiseur peut émettre (§6, liste blanche). GEN : DecisionCode::optimizerEmittableCodes(). Clés de paramètres admises par code : x-etm-reason-params.",
+ "type": "string",
+ "enum": [
+ "SURPLUS_SETPOINT",
+ "SURPLUS_INSUFFICIENT",
+ "BELOW_MIN_POWER",
+ "EV_DEADLINE",
+ "SG_FORCED",
+ "SG_RECOMMENDED",
+ "SG_NORMAL",
+ "ECO_FLOOR_GRID",
+ "PRICE_SLOT",
+ "OPTIMIZER_DECISION"
+ ]
+ },
+ "PriceInclude": {
+ "description": "Composante incluse dans le prix (§5.2). GEN : TariffConfig::includes().",
+ "type": "string",
+ "enum": [
+ "energy",
+ "grid",
+ "taxes"
+ ]
+ },
+ "FallbackCode": {
+ "description": "Côté app (NymeaEnergy.GetPlan.fallbackCode, INTERFACE.md) : pourquoi l'optimiseur n'a pas commandé le cycle. Hors fil. GEN : SocketScheduler::knownFallbackCodes().",
+ "type": "string",
+ "enum": [
+ "no-plan",
+ "abstain",
+ "empty-plan",
+ "no-current-slot",
+ "expired",
+ "malformed-plan",
+ "bad-time",
+ "slots-unordered",
+ "malformed-action",
+ "unknown-load",
+ "unknown-kind",
+ "kind-mismatch",
+ "kind-not-announced",
+ "missing-reason",
+ "bad-value",
+ "duplicate-load",
+ "incomplete-slot",
+ "no-shadow-slot"
+ ]
+ },
+ "LastErrorCode": {
+ "description": "Côté app (NymeaEnergy.GetOptimizerStatus.lastErrorCode, INTERFACE.md) : dernier code d'erreur vu (connexion, requête, plan). Hors fil. GEN : SocketScheduler::knownLastErrorCodes().",
+ "type": "string",
+ "enum": [
+ "invalid-endpoint",
+ "token-too-short",
+ "connect-failed",
+ "closed-during-handshake",
+ "connection-lost",
+ "handshake-timeout",
+ "handshake-refused",
+ "invalid-handshake",
+ "major-mismatch",
+ "line-too-long",
+ "invalid-json",
+ "missing-id",
+ "unknown-id",
+ "timeout",
+ "rpc-error",
+ "invalid-response",
+ "disconnected",
+ "malformed-plan",
+ "bad-time",
+ "slots-unordered",
+ "malformed-action",
+ "unknown-load",
+ "unknown-kind",
+ "kind-mismatch",
+ "kind-not-announced",
+ "missing-reason",
+ "bad-value",
+ "duplicate-load",
+ "incomplete-slot"
+ ]
+ },
+ "HandshakeToken": {
+ "description": "Secret partagé (§3, E-T2-8) : 16 caractères au moins, 4 096 octets au plus (borne en octets non exprimable ici : contrôlée par le moteur), imprimable, sans blanc ni caractère de contrôle, UTF-8 admis. Envoyé seulement s'il est configuré ; jamais journalisé.",
+ "type": "string",
+ "minLength": 16,
+ "pattern": "^[^\\s\\x00-\\x1f\\x7f]+$"
+ },
+ "Capabilities": {
+ "description": "Capacités annoncées (§3, 1.1, optionnel). Affichées en mode installateur, réduites aux clés et valeurs connues (capacitesPubliees) ; un champ inconnu est ignoré (§9).",
+ "type": "object",
+ "properties": {
+ "maxHorizonH": {
+ "description": "Horizon maximal planifié, en heures.",
+ "type": "number",
+ "minimum": 0
+ },
+ "resolutionS": {
+ "description": "Pas des créneaux, en secondes.",
+ "type": "number",
+ "minimum": 0
+ },
+ "kinds": {
+ "description": "PRÉSENT, il restreint les kinds acceptés (règle kind-not-announced).",
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ActionKind"
+ }
+ },
+ "usesForecast": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "pv",
+ "baseload",
+ "price",
+ "temperature"
+ ]
+ }
+ },
+ "reasonCodes": {
+ "description": "L'optimiseur émet des reasonCode du catalogue.",
+ "type": "boolean"
+ }
+ },
+ "required": [],
+ "additionalProperties": true
+ },
+ "HandshakeResult": {
+ "type": "object",
+ "properties": {
+ "protocolVersion": {
+ "description": "Version semver de l'optimiseur ; même MAJEURE que le moteur exigée (règle major-mismatch).",
+ "type": "string",
+ "minLength": 1,
+ "pattern": "^[0-9]+(\\.[0-9]+)*$"
+ },
+ "name": {
+ "description": "Nom affiché (mode installateur), non contrôlé par le moteur (E-T7-3).",
+ "type": "string"
+ },
+ "planning": {
+ "description": "Vrai si l'optimiseur produit des plans multi-créneaux. Informatif.",
+ "type": "boolean"
+ },
+ "capabilities": {
+ "$ref": "#/components/schemas/Capabilities"
+ }
+ },
+ "required": [
+ "protocolVersion"
+ ],
+ "additionalProperties": true
+ },
+ "Site": {
+ "type": "object",
+ "properties": {
+ "contractedPowerW": {
+ "description": "Puissance souscrite (W) ; null si inconnue (E1), jamais 0.",
+ "type": [
+ "number",
+ "null"
+ ]
+ },
+ "gridCapW": {
+ "description": "RÉSERVÉ (signaux réseau type §14a EnWG) : toujours null en 1.1.",
+ "type": "null"
+ },
+ "phaseLimitA": {
+ "description": "Limite par phase en A ; null si inconnue.",
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "number"
+ }
+ }
+ },
+ "required": [
+ "contractedPowerW",
+ "gridCapW",
+ "phaseLimitA"
+ ],
+ "additionalProperties": false
+ },
+ "Meter": {
+ "type": "object",
+ "properties": {
+ "source": {
+ "description": "ThingId du compteur racine désigné ; null si inconnu.",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "importW": {
+ "description": "Puissance importée (W).",
+ "type": "number",
+ "minimum": 0
+ },
+ "exportW": {
+ "description": "Puissance exportée (W), mesure BRUTE (AGENTS.md règle 8) — jamais le net après pilotage.",
+ "type": "number",
+ "minimum": 0
+ },
+ "perPhaseA": {
+ "description": "Courant par phase (A).",
+ "type": "array",
+ "items": {
+ "type": "number"
+ }
+ }
+ },
+ "required": [
+ "source",
+ "importW",
+ "exportW",
+ "perPhaseA"
+ ],
+ "additionalProperties": false
+ },
+ "Pv": {
+ "type": "object",
+ "properties": {
+ "currentW": {
+ "description": "Production instantanée (W), positive ; null sans producteur (jamais 0).",
+ "type": [
+ "number",
+ "null"
+ ],
+ "minimum": 0
+ }
+ },
+ "required": [
+ "currentW"
+ ],
+ "additionalProperties": false
+ },
+ "BatteryAbsent": {
+ "description": "Aucun stockage déclaré : rien d'autre n'est émis.",
+ "type": "object",
+ "properties": {
+ "present": {
+ "const": false
+ }
+ },
+ "required": [
+ "present"
+ ],
+ "additionalProperties": false
+ },
+ "BatteryPresent": {
+ "type": "object",
+ "properties": {
+ "present": {
+ "const": true
+ },
+ "socPercent": {
+ "type": "number"
+ },
+ "powerW": {
+ "description": "Positif = charge, négatif = décharge.",
+ "type": "number"
+ },
+ "capacityWh": {
+ "description": "null si inconnue.",
+ "type": [
+ "number",
+ "null"
+ ]
+ },
+ "maxChargeW": {
+ "description": "null si inconnue.",
+ "type": [
+ "number",
+ "null"
+ ]
+ },
+ "maxDischargeW": {
+ "description": "null si inconnue.",
+ "type": [
+ "number",
+ "null"
+ ]
+ },
+ "reserveSocPercent": {
+ "description": "Réserve de secours (%) ; null si inconnue.",
+ "type": [
+ "integer",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "present",
+ "socPercent",
+ "powerW",
+ "capacityWh",
+ "maxChargeW",
+ "maxDischargeW",
+ "reserveSocPercent"
+ ],
+ "additionalProperties": false
+ },
+ "Battery": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/BatteryAbsent"
+ },
+ {
+ "$ref": "#/components/schemas/BatteryPresent"
+ }
+ ]
+ },
+ "TariffEntry": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "type": "string"
+ },
+ "priceCtkWh": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "label",
+ "priceCtkWh"
+ ],
+ "additionalProperties": false
+ },
+ "TariffNextEntry": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "type": "string"
+ },
+ "priceCtkWh": {
+ "type": "number"
+ },
+ "from": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2}(\\.[0-9]+)?)?(Z|[+-][0-9]{2}:[0-9]{2})$",
+ "description": "Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée."
+ }
+ },
+ "required": [
+ "label",
+ "priceCtkWh",
+ "from"
+ ],
+ "additionalProperties": false
+ },
+ "Tariff": {
+ "description": "Tarif 1.0, conservé pour compatibilité ; un optimiseur 1.1 lit forecast.price. Aucun écrivain aujourd'hui (E3) : null en pratique.",
+ "type": "object",
+ "properties": {
+ "provider": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "current": {
+ "$ref": "#/components/schemas/TariffEntry"
+ },
+ "next": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/TariffNextEntry"
+ }
+ }
+ },
+ "required": [
+ "provider",
+ "current",
+ "next"
+ ],
+ "additionalProperties": false
+ },
+ "Correction": {
+ "type": "string",
+ "enum": [
+ "temperature",
+ "ac-limit"
+ ]
+ },
+ "PvField": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "description": "ThingId du plan.",
+ "type": "string"
+ },
+ "corrections": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Correction"
+ }
+ }
+ },
+ "required": [
+ "id",
+ "corrections"
+ ],
+ "additionalProperties": false
+ },
+ "PvForecast": {
+ "type": "object",
+ "properties": {
+ "p50W": {
+ "description": "Puissance AC moyenne sur [t, t+pas), sommée sur les plans puis plafonnée à acLimitW ; null = case inconnue. Un plafond, pas une espérance (§5.2).",
+ "type": "array",
+ "items": {
+ "type": [
+ "number",
+ "null"
+ ]
+ }
+ },
+ "conversion": {
+ "description": "Seule valeur émise par le moteur GPL ; un consommateur ignore une valeur inconnue.",
+ "type": "string",
+ "enum": [
+ "physics"
+ ]
+ },
+ "corrections": {
+ "description": "[] = conversion brute.",
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Correction"
+ }
+ },
+ "weatherModel": {
+ "description": "Modèle qui a RÉPONDU ; null si inconnu.",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "issuedAt": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2}(\\.[0-9]+)?)?(Z|[+-][0-9]{2}:[0-9]{2})$",
+ "description": "Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée."
+ },
+ "contentChangedAt": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2}(\\.[0-9]+)?)?(Z|[+-][0-9]{2}:[0-9]{2})$",
+ "description": "Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée."
+ },
+ "acLimitW": {
+ "description": "Plafond AC du site (W), s'il est configuré.",
+ "type": "integer"
+ },
+ "perField": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/PvField"
+ }
+ }
+ },
+ "required": [
+ "p50W",
+ "conversion",
+ "corrections",
+ "weatherModel",
+ "issuedAt",
+ "contentChangedAt"
+ ],
+ "additionalProperties": false
+ },
+ "BaseloadLoadQuality": {
+ "type": "object",
+ "properties": {
+ "loadId": {
+ "type": "string"
+ },
+ "source": {
+ "type": "string",
+ "enum": [
+ "meter",
+ "device",
+ "none"
+ ]
+ },
+ "minutesBySource": {
+ "type": "object",
+ "propertyNames": {
+ "enum": [
+ "meter",
+ "device",
+ "none"
+ ]
+ },
+ "additionalProperties": {
+ "type": "integer"
+ }
+ },
+ "constantMeasurement": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "loadId",
+ "source",
+ "minutesBySource"
+ ],
+ "additionalProperties": false
+ },
+ "BaseloadQuality": {
+ "type": "object",
+ "properties": {
+ "windowDays": {
+ "type": "integer"
+ },
+ "minDays": {
+ "type": "integer"
+ },
+ "resolutionS": {
+ "type": "integer"
+ },
+ "daysCovered": {
+ "type": "object",
+ "properties": {
+ "workday": {
+ "type": "integer"
+ },
+ "weekend": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "workday",
+ "weekend"
+ ],
+ "additionalProperties": false
+ },
+ "minutes": {
+ "type": "integer"
+ },
+ "commandedMinutes": {
+ "type": "integer"
+ },
+ "negativeMinutes": {
+ "type": "integer"
+ },
+ "incompleteMinutes": {
+ "type": "integer"
+ },
+ "computedAt": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2}(\\.[0-9]+)?)?(Z|[+-][0-9]{2}:[0-9]{2})$",
+ "description": "Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée."
+ },
+ "loads": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/BaseloadLoadQuality"
+ }
+ }
+ },
+ "required": [
+ "windowDays",
+ "minDays",
+ "resolutionS",
+ "daysCovered",
+ "minutes",
+ "commandedMinutes",
+ "negativeMinutes",
+ "incompleteMinutes",
+ "loads"
+ ],
+ "additionalProperties": false
+ },
+ "Baseload": {
+ "type": "object",
+ "properties": {
+ "model": {
+ "description": "Le moteur GPL émet profile ou none (learned n'est pas émis).",
+ "type": "string",
+ "enum": [
+ "profile",
+ "none"
+ ]
+ },
+ "p50W": {
+ "type": "array",
+ "items": {
+ "type": [
+ "number",
+ "null"
+ ]
+ }
+ },
+ "quality": {
+ "$ref": "#/components/schemas/BaseloadQuality"
+ }
+ },
+ "required": [
+ "model",
+ "p50W"
+ ],
+ "additionalProperties": false
+ },
+ "Price": {
+ "type": "object",
+ "properties": {
+ "currency": {
+ "const": "EUR"
+ },
+ "importCtkWh": {
+ "type": "array",
+ "items": {
+ "type": [
+ "number",
+ "null"
+ ]
+ }
+ },
+ "exportCtkWh": {
+ "type": "array",
+ "items": {
+ "type": [
+ "number",
+ "null"
+ ]
+ }
+ },
+ "includes": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/PriceInclude"
+ }
+ },
+ "source": {
+ "description": "Tarif SAISI : une zone (fixed) ou des zones horaires (tou). tempo et day-ahead ne sont pas émis en 1.1.",
+ "type": "string",
+ "enum": [
+ "fixed",
+ "tou"
+ ]
+ },
+ "knownUntil": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2}(\\.[0-9]+)?)?(Z|[+-][0-9]{2}:[0-9]{2})$",
+ "description": "Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée."
+ }
+ },
+ "required": [
+ "currency",
+ "importCtkWh",
+ "exportCtkWh",
+ "includes",
+ "source",
+ "knownUntil"
+ ],
+ "additionalProperties": false
+ },
+ "Forecast": {
+ "description": "Prévisions sur UNE grille (§5.2) ; tableaux alignés index par index.",
+ "type": "object",
+ "properties": {
+ "start": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2}(\\.[0-9]+)?)?(Z|[+-][0-9]{2}:[0-9]{2})$",
+ "description": "Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée."
+ },
+ "resolutionS": {
+ "description": "Pas de la grille (s) — fait foi, jamais supposé.",
+ "type": "integer",
+ "minimum": 1
+ },
+ "steps": {
+ "description": "Nombre de cases ; toutes les séries ont cette longueur.",
+ "type": "integer",
+ "minimum": 0
+ },
+ "pv": {
+ "description": "null sans courbe (site non désigné, charge utile refusée, canal muet).",
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/PvForecast"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "baseload": {
+ "$ref": "#/components/schemas/Baseload"
+ },
+ "weather": {
+ "description": "Aucune source de température en 1.1 : toujours null.",
+ "type": "null"
+ },
+ "price": {
+ "description": "null sans tarif saisi.",
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/Price"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": [
+ "start",
+ "resolutionS",
+ "steps",
+ "pv",
+ "baseload",
+ "weather",
+ "price"
+ ],
+ "additionalProperties": false
+ },
+ "Measurement": {
+ "oneOf": [
+ {
+ "description": "Rien ne mesure : état positif, pas une erreur.",
+ "type": "object",
+ "properties": {
+ "source": {
+ "const": "none"
+ }
+ },
+ "required": [
+ "source"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "source": {
+ "type": "string",
+ "enum": [
+ "meter",
+ "device"
+ ]
+ },
+ "powerW": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "source",
+ "powerW"
+ ],
+ "additionalProperties": false
+ }
+ ],
+ "description": "Mesure par charge (§5.3, LM-1105/1106) : décider sur source, jamais sur adapter."
+ },
+ "EnergyTarget": {
+ "type": "object",
+ "properties": {
+ "by": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2}(\\.[0-9]+)?)?(Z|[+-][0-9]{2}:[0-9]{2})$",
+ "description": "Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée."
+ },
+ "minWh": {
+ "description": "Énergie minimale à avoir délivrée à l'échéance.",
+ "type": "number",
+ "exclusiveMinimum": 0
+ },
+ "source": {
+ "description": "learned n'est pas émis en 1.1.",
+ "type": "string",
+ "enum": [
+ "installer",
+ "user-intent"
+ ]
+ }
+ },
+ "required": [
+ "by",
+ "minWh",
+ "source"
+ ],
+ "additionalProperties": false
+ },
+ "LoadNeeds": {
+ "description": "Besoins (§5.3). Champs 1.0 émis tels quels quand ils sont déclarés ; un optimiseur 1.1 préfère energyTargets.",
+ "type": "object",
+ "properties": {
+ "dailyDeadline": {
+ "description": "Heure murale locale HH:MM (1.0).",
+ "type": "string",
+ "pattern": "^[0-9]{2}:[0-9]{2}$"
+ },
+ "minEnergyWhPerDay": {
+ "type": "integer",
+ "exclusiveMinimum": 0
+ },
+ "targetSocPercent": {
+ "type": "integer",
+ "exclusiveMinimum": 0
+ },
+ "deadline": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2}(\\.[0-9]+)?)?(Z|[+-][0-9]{2}:[0-9]{2})$",
+ "description": "Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée."
+ },
+ "energyTargets": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "$ref": "#/components/schemas/EnergyTarget"
+ }
+ }
+ },
+ "required": [],
+ "additionalProperties": false
+ },
+ "LoadLearned": {
+ "description": "Absent tant qu'aucun profil n'a été reçu (E12).",
+ "type": "object",
+ "properties": {
+ "dailyEnergyWh": {
+ "type": "number"
+ },
+ "confidence": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1
+ }
+ },
+ "required": [
+ "dailyEnergyWh",
+ "confidence"
+ ],
+ "additionalProperties": false
+ },
+ "LoadWatts": {
+ "description": "Charge pilotée en watts : combinaison de relais (relay-router) ou consigne continue (etmvariableload).",
+ "type": "object",
+ "properties": {
+ "id": {
+ "description": "Identifiant de la charge — clé des actions.",
+ "type": "string",
+ "minLength": 1
+ },
+ "label": {
+ "description": "Nom lisible.",
+ "type": "string"
+ },
+ "priority": {
+ "description": "Rang effectif (1 = premier servi).",
+ "type": "integer"
+ },
+ "domain": {
+ "$ref": "#/components/schemas/Domain"
+ },
+ "learned": {
+ "$ref": "#/components/schemas/LoadLearned"
+ },
+ "needs": {
+ "$ref": "#/components/schemas/LoadNeeds"
+ },
+ "adapter": {
+ "type": "string",
+ "enum": [
+ "relay-router",
+ "etmvariableload"
+ ]
+ },
+ "declared": {
+ "type": "object",
+ "properties": {
+ "minPowerW": {
+ "description": "0 = aucun plancher.",
+ "type": "number"
+ },
+ "maxPowerW": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "minPowerW",
+ "maxPowerW"
+ ],
+ "additionalProperties": false
+ },
+ "telemetry": {
+ "type": "object",
+ "properties": {
+ "currentPowerW": {
+ "description": "Puissance courante (W) telle que l'adaptateur la publie.",
+ "type": "number"
+ },
+ "available": {
+ "description": "Gouverne l'ALLOCATION, pas la COMPTABILITÉ (§5.3).",
+ "type": "boolean"
+ },
+ "measurement": {
+ "$ref": "#/components/schemas/Measurement"
+ },
+ "lockMinPowerW": {
+ "description": "Plancher imposé MAINTENANT par un verrou.",
+ "type": "number"
+ },
+ "lockMaxPowerW": {
+ "description": "Plafond imposé maintenant ; négatif = aucun plafond.",
+ "type": "number"
+ }
+ },
+ "required": [
+ "currentPowerW",
+ "available",
+ "lockMinPowerW",
+ "lockMaxPowerW"
+ ],
+ "additionalProperties": false
+ },
+ "limits": {
+ "type": "object",
+ "properties": {
+ "minOnS": {
+ "type": "integer"
+ },
+ "minOffS": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "minOnS",
+ "minOffS"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "id",
+ "adapter",
+ "label",
+ "priority",
+ "declared",
+ "telemetry",
+ "limits"
+ ],
+ "additionalProperties": false
+ },
+ "LoadEvCharger": {
+ "description": "Borne. Une borne CONFIGURÉE dont l'appareil manque n'a ni declared, ni limits, ni pluggedIn/charging : ses zéros diraient « inconnu ».",
+ "type": "object",
+ "properties": {
+ "id": {
+ "description": "Identifiant de la charge — clé des actions.",
+ "type": "string",
+ "minLength": 1
+ },
+ "label": {
+ "description": "Nom lisible.",
+ "type": "string"
+ },
+ "priority": {
+ "description": "Rang effectif (1 = premier servi).",
+ "type": "integer"
+ },
+ "domain": {
+ "$ref": "#/components/schemas/Domain"
+ },
+ "learned": {
+ "$ref": "#/components/schemas/LoadLearned"
+ },
+ "needs": {
+ "$ref": "#/components/schemas/LoadNeeds"
+ },
+ "adapter": {
+ "const": "evcharger"
+ },
+ "declared": {
+ "type": "object",
+ "properties": {
+ "minA": {
+ "type": "number"
+ },
+ "maxA": {
+ "type": "number"
+ },
+ "phases": {
+ "description": "Phases VISÉES ce cycle, pas celles de la plaque (E26).",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "minA",
+ "maxA",
+ "phases"
+ ],
+ "additionalProperties": false
+ },
+ "telemetry": {
+ "type": "object",
+ "properties": {
+ "currentPowerW": {
+ "description": "Puissance courante (W) telle que l'adaptateur la publie.",
+ "type": "number"
+ },
+ "available": {
+ "description": "Gouverne l'ALLOCATION, pas la COMPTABILITÉ (§5.3).",
+ "type": "boolean"
+ },
+ "measurement": {
+ "$ref": "#/components/schemas/Measurement"
+ },
+ "pluggedIn": {
+ "type": "boolean"
+ },
+ "charging": {
+ "type": "boolean"
+ },
+ "sessionWh": {
+ "description": "Absent si la borne ne compte pas sa session (LM-1009 C1).",
+ "type": "number"
+ }
+ },
+ "required": [
+ "currentPowerW",
+ "available"
+ ],
+ "additionalProperties": false
+ },
+ "limits": {
+ "type": "object",
+ "properties": {
+ "chargingEnabledLockS": {
+ "type": "integer"
+ },
+ "currentChangeLockS": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "chargingEnabledLockS",
+ "currentChangeLockS"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "id",
+ "adapter",
+ "label",
+ "priority",
+ "telemetry"
+ ],
+ "additionalProperties": false
+ },
+ "LoadSgReady": {
+ "description": "PAC SG-Ready : 4 états normés, kind state.",
+ "type": "object",
+ "properties": {
+ "id": {
+ "description": "Identifiant de la charge — clé des actions.",
+ "type": "string",
+ "minLength": 1
+ },
+ "label": {
+ "description": "Nom lisible.",
+ "type": "string"
+ },
+ "priority": {
+ "description": "Rang effectif (1 = premier servi).",
+ "type": "integer"
+ },
+ "domain": {
+ "$ref": "#/components/schemas/Domain"
+ },
+ "learned": {
+ "$ref": "#/components/schemas/LoadLearned"
+ },
+ "needs": {
+ "$ref": "#/components/schemas/LoadNeeds"
+ },
+ "adapter": {
+ "const": "sg-ready"
+ },
+ "declared": {
+ "type": "object",
+ "properties": {
+ "states": {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 4
+ }
+ },
+ "estimatedPowerW": {
+ "type": "object",
+ "propertyNames": {
+ "pattern": "^[1-4]$"
+ },
+ "additionalProperties": {
+ "type": "number"
+ }
+ }
+ },
+ "required": [
+ "states",
+ "estimatedPowerW"
+ ],
+ "additionalProperties": false
+ },
+ "telemetry": {
+ "type": "object",
+ "properties": {
+ "currentPowerW": {
+ "description": "Puissance courante (W) telle que l'adaptateur la publie.",
+ "type": "number"
+ },
+ "available": {
+ "description": "Gouverne l'ALLOCATION, pas la COMPTABILITÉ (§5.3).",
+ "type": "boolean"
+ },
+ "measurement": {
+ "$ref": "#/components/schemas/Measurement"
+ },
+ "state": {
+ "description": "État commandé (1-4) ; 0 = non applicable.",
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 4
+ },
+ "minState": {
+ "description": "Fenêtre d'états autorisée MAINTENANT par minStateHold.",
+ "type": "integer"
+ },
+ "maxState": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "currentPowerW",
+ "available",
+ "state",
+ "minState",
+ "maxState"
+ ],
+ "additionalProperties": false
+ },
+ "limits": {
+ "type": "object",
+ "properties": {
+ "minStateHoldS": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "minStateHoldS"
+ ],
+ "additionalProperties": false
+ }
+ },
+ "required": [
+ "id",
+ "adapter",
+ "label",
+ "priority",
+ "declared",
+ "telemetry",
+ "limits"
+ ],
+ "additionalProperties": false
+ },
+ "Load": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/LoadWatts"
+ },
+ {
+ "$ref": "#/components/schemas/LoadEvCharger"
+ },
+ {
+ "$ref": "#/components/schemas/LoadSgReady"
+ }
+ ],
+ "description": "Une charge arbitrée (§5.3) — la forme dépend du mécanisme (adapter)."
+ },
+ "ReasonParams": {
+ "description": "Paramètres du motif : valeurs nombre, booléen ou code [A-Za-z]+ ; clés admises par code : x-etm-reason-params. Toute autre clé est SUPPRIMÉE avant l'app (filtrée, pas refusée).",
+ "type": [
+ "object",
+ "null"
+ ],
+ "additionalProperties": {
+ "type": [
+ "number",
+ "boolean",
+ "string"
+ ],
+ "pattern": "^[A-Za-z]+$"
+ }
+ },
+ "SetpointAction": {
+ "type": "object",
+ "properties": {
+ "loadId": {
+ "description": "Charge visée : connue du contexte (règle unknown-load), une fois par créneau (duplicate-load).",
+ "type": "string",
+ "minLength": 1
+ },
+ "reason": {
+ "description": "Texte français, OBLIGATOIRE et non vide (règle missing-reason). Traces et journal seulement, jamais vers l'app.",
+ "type": "string",
+ "minLength": 1,
+ "pattern": "\\S"
+ },
+ "reasonCode": {
+ "$ref": "#/components/schemas/ReasonCode"
+ },
+ "reasonParams": {
+ "$ref": "#/components/schemas/ReasonParams"
+ },
+ "force": {
+ "description": "INTERDIT sur le fil (§6) : ignoré, signalé une fois."
+ },
+ "kind": {
+ "const": "setpoint"
+ },
+ "powerW": {
+ "description": "Consigne en watts (relay-router, etmvariableload ; ou borne).",
+ "type": "number",
+ "minimum": 0
+ },
+ "currentA": {
+ "description": "Borne : courant (0 = suspendre).",
+ "type": "number",
+ "minimum": 0
+ },
+ "phases": {
+ "description": "Borne : phases visées ; absent = inchangé.",
+ "enum": [
+ 1,
+ 3
+ ]
+ }
+ },
+ "required": [
+ "loadId",
+ "kind",
+ "reason"
+ ],
+ "additionalProperties": true,
+ "anyOf": [
+ {
+ "required": [
+ "powerW"
+ ]
+ },
+ {
+ "required": [
+ "currentA"
+ ]
+ }
+ ]
+ },
+ "StateAction": {
+ "type": "object",
+ "properties": {
+ "loadId": {
+ "description": "Charge visée : connue du contexte (règle unknown-load), une fois par créneau (duplicate-load).",
+ "type": "string",
+ "minLength": 1
+ },
+ "reason": {
+ "description": "Texte français, OBLIGATOIRE et non vide (règle missing-reason). Traces et journal seulement, jamais vers l'app.",
+ "type": "string",
+ "minLength": 1,
+ "pattern": "\\S"
+ },
+ "reasonCode": {
+ "$ref": "#/components/schemas/ReasonCode"
+ },
+ "reasonParams": {
+ "$ref": "#/components/schemas/ReasonParams"
+ },
+ "force": {
+ "description": "INTERDIT sur le fil (§6) : ignoré, signalé une fois."
+ },
+ "kind": {
+ "const": "state"
+ },
+ "state": {
+ "description": "1 = verrouillage, 2 = normal, 3 = recommandation, 4 = forcé.",
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 4
+ }
+ },
+ "required": [
+ "loadId",
+ "kind",
+ "reason",
+ "state"
+ ],
+ "additionalProperties": true
+ },
+ "ConstraintAction": {
+ "description": "RÉSERVÉ (annexe F) : forme décrite, toujours refusée (kind-mismatch).",
+ "type": "object",
+ "properties": {
+ "loadId": {
+ "description": "Charge visée : connue du contexte (règle unknown-load), une fois par créneau (duplicate-load).",
+ "type": "string",
+ "minLength": 1
+ },
+ "reason": {
+ "description": "Texte français, OBLIGATOIRE et non vide (règle missing-reason). Traces et journal seulement, jamais vers l'app.",
+ "type": "string",
+ "minLength": 1,
+ "pattern": "\\S"
+ },
+ "reasonCode": {
+ "$ref": "#/components/schemas/ReasonCode"
+ },
+ "reasonParams": {
+ "$ref": "#/components/schemas/ReasonParams"
+ },
+ "force": {
+ "description": "INTERDIT sur le fil (§6) : ignoré, signalé une fois."
+ },
+ "kind": {
+ "const": "constraint"
+ },
+ "charge": {
+ "enum": [
+ "allow",
+ "forbid"
+ ]
+ },
+ "discharge": {
+ "enum": [
+ "allow",
+ "forbid"
+ ]
+ }
+ },
+ "required": [
+ "loadId",
+ "kind",
+ "reason"
+ ],
+ "additionalProperties": true
+ },
+ "Action": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/SetpointAction"
+ },
+ {
+ "$ref": "#/components/schemas/StateAction"
+ },
+ {
+ "$ref": "#/components/schemas/ConstraintAction"
+ }
+ ]
+ },
+ "Expected": {
+ "description": "Trajectoire prévue : affichage et mesure d'écart, JAMAIS pour commander.",
+ "type": "object",
+ "properties": {
+ "gridImportW": {
+ "type": "number"
+ },
+ "gridExportW": {
+ "type": "number"
+ },
+ "batterySocPercent": {
+ "description": "RÉSERVÉ (annexe F) : ignoré."
+ }
+ },
+ "required": [],
+ "additionalProperties": true
+ },
+ "Slot": {
+ "type": "object",
+ "properties": {
+ "from": {
+ "description": "Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée.",
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2}(\\.[0-9]+)?)?(Z|[+-][0-9]{2}:[0-9]{2})$"
+ },
+ "to": {
+ "description": "Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée.",
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2}(\\.[0-9]+)?)?(Z|[+-][0-9]{2}:[0-9]{2})$"
+ },
+ "actions": {
+ "description": "Absent ou null = aucune action.",
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "$ref": "#/components/schemas/Action"
+ }
+ },
+ "expected": {
+ "$ref": "#/components/schemas/Expected"
+ }
+ },
+ "required": [
+ "from",
+ "to"
+ ],
+ "additionalProperties": true
+ },
+ "Diagnostics": {
+ "description": "Informatif : mode installateur et traces.",
+ "type": "object",
+ "properties": {
+ "solver": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "optimal",
+ "feasible",
+ "best-so-far",
+ "infeasible-relaxed"
+ ]
+ },
+ "gapPercent": {
+ "type": "number"
+ },
+ "computeMs": {
+ "type": "number",
+ "minimum": 0
+ },
+ "inputsUsed": {
+ "type": "object",
+ "properties": {
+ "pv": {
+ "type": "string",
+ "enum": [
+ "physics",
+ "hybrid",
+ "none"
+ ]
+ }
+ },
+ "required": [],
+ "additionalProperties": true
+ }
+ },
+ "required": [],
+ "additionalProperties": true
+ },
+ "Plan": {
+ "description": "Plan par créneaux (§6).",
+ "type": "object",
+ "properties": {
+ "abstain": {
+ "const": false
+ },
+ "planId": {
+ "type": "string"
+ },
+ "strategy": {
+ "description": "Chaîne libre, informative.",
+ "type": "string"
+ },
+ "computedAt": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2}(\\.[0-9]+)?)?(Z|[+-][0-9]{2}:[0-9]{2})$",
+ "description": "Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée."
+ },
+ "validUntil": {
+ "description": "Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2}(\\.[0-9]+)?)?(Z|[+-][0-9]{2}:[0-9]{2})$"
+ },
+ "slots": {
+ "description": "[] = abstention (empty-plan). Seul le créneau couvrant maintenant est exécuté.",
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Slot"
+ }
+ },
+ "diagnostics": {
+ "$ref": "#/components/schemas/Diagnostics"
+ }
+ },
+ "required": [
+ "slots"
+ ],
+ "additionalProperties": true
+ },
+ "Abstention": {
+ "description": "« Pas de plan maintenant » (§7) : repli pour ce cycle, pas un échec.",
+ "type": "object",
+ "properties": {
+ "abstain": {
+ "const": true
+ },
+ "reason": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "abstain"
+ ],
+ "additionalProperties": true
+ },
+ "GetPlanResult": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/Abstention"
+ },
+ {
+ "$ref": "#/components/schemas/Plan"
+ }
+ ]
+ }
+ }
+ },
+ "x-etm-transport": {
+ "description": "Ce qu'OpenRPC ne dit pas du fil (§2-§4).",
+ "schemes": [
+ "unix",
+ "tcp"
+ ],
+ "defaultEndpoint": "unix:///run/powersync/optimizer.sock",
+ "jsonrpc": "2.0",
+ "framing": "ndjson",
+ "encoding": "utf-8",
+ "roles": {
+ "client": "engine",
+ "server": "optimizer"
+ },
+ "maxLineBytes": 1048576,
+ "handshakeTimeoutS": 5,
+ "requestTimeoutS": 10,
+ "backoffS": {
+ "initial": 1,
+ "max": 60,
+ "factor": 2
+ },
+ "requestsInFlight": 1,
+ "ids": "entiers croissants sur toute la vie du client, à travers les reconnexions",
+ "lineErrors": "ligne non JSON, sans id, id jamais émis, ligne > maxLineBytes : comptée, connexion CONSERVÉE",
+ "tokenMasking": "toute occurrence du token dans un message reçu est remplacée par « [token masqué] » dès l'analyse"
+ },
+ "x-etm-fallback-machine": {
+ "description": "Machine de repli §7 (OptimizerStatus) : heartbeat implicite — une réponse valide à GetPlan = optimiseur vivant. Les échecs se comptent PAR REQUÊTE.",
+ "failuresBeforeDead": 2,
+ "rows": [
+ {
+ "situation": "réponse plan valide",
+ "optimizerAlive": "true",
+ "activeStrategy": "optimizer"
+ },
+ {
+ "situation": "abstain, plan vide, aucun créneau courant",
+ "optimizerAlive": "inchangé",
+ "activeStrategy": "rules (ce cycle)"
+ },
+ {
+ "situation": "plan expiré (validUntil dépassé)",
+ "optimizerAlive": "inchangé",
+ "activeStrategy": "rules jusqu'au prochain plan valide"
+ },
+ {
+ "situation": "timeout / erreur / plan invalide sur deux requêtes consécutives",
+ "optimizerAlive": "false",
+ "activeStrategy": "rules"
+ },
+ {
+ "situation": "socket absent / fermé / handshake refusé",
+ "optimizerAlive": "false",
+ "activeStrategy": "rules"
+ },
+ {
+ "situation": "aucun endpoint configuré",
+ "optimizerAlive": "false",
+ "activeStrategy": "rules"
+ },
+ {
+ "situation": "mode dégradé L2 (compteur muet > 90 s)",
+ "optimizerAlive": "inchangé",
+ "activeStrategy": "none"
+ }
+ ]
+ },
+ "x-etm-semantic-rules": {
+ "description": "Ce que le moteur refuse EN PLUS de la forme, dans l'ordre de ses contrôles (OptimizerPlan::valider, OptimizerClient::handleHandshakeReply). name = code du verdict. issue : abstention (repli ce cycle, pas un échec), expired, invalid (plan refusé en entier, compté comme échec), connection-refused (handshake). needs : ce qu'il faut connaître en plus de la réponse (la requête, le handshake).",
+ "rules": [
+ {
+ "name": "abstain",
+ "method": "GetPlan",
+ "issue": "abstention",
+ "fallbackCode": "abstain",
+ "countsAsFailure": false,
+ "needs": [],
+ "schemaAlsoCatches": false,
+ "description": "result.abstain vaut true."
+ },
+ {
+ "name": "malformed-plan",
+ "method": "GetPlan",
+ "issue": "invalid",
+ "fallbackCode": "malformed-plan",
+ "countsAsFailure": true,
+ "needs": [],
+ "schemaAlsoCatches": true,
+ "description": "slots absent ou non liste ; un créneau qui n'est pas un objet ; actions non liste.",
+ "lastErrorCode": "malformed-plan"
+ },
+ {
+ "name": "empty-plan",
+ "method": "GetPlan",
+ "issue": "abstention",
+ "fallbackCode": "empty-plan",
+ "countsAsFailure": false,
+ "needs": [],
+ "schemaAlsoCatches": false,
+ "description": "slots est une liste vide."
+ },
+ {
+ "name": "bad-time",
+ "method": "GetPlan",
+ "issue": "invalid",
+ "fallbackCode": "bad-time",
+ "countsAsFailure": true,
+ "needs": [],
+ "schemaAlsoCatches": true,
+ "description": "from/to illisibles ou sans décalage, ou from ≥ to ; validUntil illisible ou sans décalage.",
+ "lastErrorCode": "bad-time"
+ },
+ {
+ "name": "slots-unordered",
+ "method": "GetPlan",
+ "issue": "invalid",
+ "fallbackCode": "slots-unordered",
+ "countsAsFailure": true,
+ "needs": [],
+ "schemaAlsoCatches": false,
+ "description": "un créneau commence avant la fin du précédent.",
+ "lastErrorCode": "slots-unordered"
+ },
+ {
+ "name": "malformed-action",
+ "method": "GetPlan",
+ "issue": "invalid",
+ "fallbackCode": "malformed-action",
+ "countsAsFailure": true,
+ "needs": [],
+ "schemaAlsoCatches": true,
+ "description": "action qui n'est pas un objet, ou sans loadId (chaîne non vide).",
+ "lastErrorCode": "malformed-action"
+ },
+ {
+ "name": "unknown-load",
+ "method": "GetPlan",
+ "issue": "invalid",
+ "fallbackCode": "unknown-load",
+ "countsAsFailure": true,
+ "needs": [
+ "request"
+ ],
+ "schemaAlsoCatches": false,
+ "description": "loadId absent des loads[] de la requête.",
+ "lastErrorCode": "unknown-load"
+ },
+ {
+ "name": "unknown-kind",
+ "method": "GetPlan",
+ "issue": "invalid",
+ "fallbackCode": "unknown-kind",
+ "countsAsFailure": true,
+ "needs": [],
+ "schemaAlsoCatches": true,
+ "description": "kind hors ActionKind.",
+ "lastErrorCode": "unknown-kind"
+ },
+ {
+ "name": "kind-mismatch",
+ "method": "GetPlan",
+ "issue": "invalid",
+ "fallbackCode": "kind-mismatch",
+ "countsAsFailure": true,
+ "needs": [
+ "request"
+ ],
+ "schemaAlsoCatches": false,
+ "description": "kind différent de celui du mécanisme de la charge (setpoint : evcharger, relay-router, etmvariableload ; state : sg-ready ; constraint : aucun).",
+ "lastErrorCode": "kind-mismatch"
+ },
+ {
+ "name": "kind-not-announced",
+ "method": "GetPlan",
+ "issue": "invalid",
+ "fallbackCode": "kind-not-announced",
+ "countsAsFailure": true,
+ "needs": [
+ "handshake"
+ ],
+ "schemaAlsoCatches": false,
+ "description": "capabilities.kinds annoncé au handshake et kind absent de la liste.",
+ "lastErrorCode": "kind-not-announced"
+ },
+ {
+ "name": "missing-reason",
+ "method": "GetPlan",
+ "issue": "invalid",
+ "fallbackCode": "missing-reason",
+ "countsAsFailure": true,
+ "needs": [],
+ "schemaAlsoCatches": true,
+ "description": "reason absent, non textuel, ou vide une fois les caractères de contrôle retirés.",
+ "lastErrorCode": "missing-reason"
+ },
+ {
+ "name": "bad-value",
+ "method": "GetPlan",
+ "issue": "invalid",
+ "fallbackCode": "bad-value",
+ "countsAsFailure": true,
+ "needs": [
+ "request"
+ ],
+ "schemaAlsoCatches": true,
+ "description": "state absent ou hors 1-4 ; borne : currentA/powerW absents ou négatifs, phases hors {1, 3} ; charge en watts : powerW absent ou négatif.",
+ "lastErrorCode": "bad-value"
+ },
+ {
+ "name": "duplicate-load",
+ "method": "GetPlan",
+ "issue": "invalid",
+ "fallbackCode": "duplicate-load",
+ "countsAsFailure": true,
+ "needs": [],
+ "schemaAlsoCatches": false,
+ "description": "deux actions pour la même charge dans un créneau.",
+ "lastErrorCode": "duplicate-load"
+ },
+ {
+ "name": "expired",
+ "method": "GetPlan",
+ "issue": "expired",
+ "fallbackCode": "expired",
+ "countsAsFailure": false,
+ "needs": [
+ "request"
+ ],
+ "schemaAlsoCatches": false,
+ "description": "validUntil ≤ timestamp de la requête."
+ },
+ {
+ "name": "no-current-slot",
+ "method": "GetPlan",
+ "issue": "abstention",
+ "fallbackCode": "no-current-slot",
+ "countsAsFailure": false,
+ "needs": [
+ "request"
+ ],
+ "schemaAlsoCatches": false,
+ "description": "aucun créneau ne couvre le timestamp de la requête."
+ },
+ {
+ "name": "incomplete-slot",
+ "method": "GetPlan",
+ "issue": "invalid",
+ "fallbackCode": "incomplete-slot",
+ "countsAsFailure": true,
+ "needs": [
+ "request"
+ ],
+ "schemaAlsoCatches": false,
+ "description": "une charge available de la requête n'a pas d'action dans le créneau courant (décision Q1).",
+ "lastErrorCode": "incomplete-slot"
+ },
+ {
+ "name": "handshake-refused",
+ "method": "Handshake",
+ "issue": "connection-refused",
+ "fallbackCode": "no-plan",
+ "lastErrorCode": "handshake-refused",
+ "countsAsFailure": false,
+ "needs": [],
+ "schemaAlsoCatches": false,
+ "description": "réponse error (token absent ou différent : -32001 recommandé) → connexion fermée, nouvelle tentative au backoff."
+ },
+ {
+ "name": "invalid-handshake",
+ "method": "Handshake",
+ "issue": "connection-refused",
+ "fallbackCode": "no-plan",
+ "lastErrorCode": "invalid-handshake",
+ "countsAsFailure": false,
+ "needs": [],
+ "schemaAlsoCatches": true,
+ "description": "result non objet, protocolVersion absent ou vide, capabilities présent et non objet."
+ },
+ {
+ "name": "major-mismatch",
+ "method": "Handshake",
+ "issue": "connection-refused",
+ "fallbackCode": "no-plan",
+ "lastErrorCode": "major-mismatch",
+ "countsAsFailure": false,
+ "needs": [],
+ "schemaAlsoCatches": false,
+ "description": "MAJEURE de protocolVersion (ce qui précède le premier point) différente de celle du moteur."
+ }
+ ]
+ },
+ "x-etm-tolerances": {
+ "description": "Ce que le moteur ACCEPTE au-delà du schéma — un optimiseur conforme ne s'en sert pas.",
+ "items": [
+ "reasonCode absent ou hors ReasonCode : action acceptée, motif transmis sous OPTIMIZER_DECISION sans paramètres.",
+ "reasonParams : clé non admise pour le code (x-etm-reason-params) ou valeur refusée : clé supprimée, action acceptée.",
+ "planId, strategy : lus comme chaînes quel que soit leur type ; computedAt illisible : ignoré.",
+ "diagnostics, expected : non objets ignorés ; valeurs inconnues filtrées avant l'app.",
+ "abstain : toute valeur « vraie » au sens de QVariant::toBool (true, chaîne non vide autre que « false »/« 0 », nombre non nul).",
+ "champs inconnus (params et result) : ignorés (§9) ; force et champs réservés (annexe F) : ignorés et signalés une fois.",
+ "state : 3.0 accepté comme 3 (entier au sens JSON Schema)."
+ ]
+ },
+ "x-etm-reason-params": {
+ "BELOW_MIN_POWER": [
+ "budgetW",
+ "minPowerW",
+ "state"
+ ],
+ "ECO_FLOOR_GRID": [
+ "gridW",
+ "atMaximum",
+ "remainingWh",
+ "targetWh",
+ "remainingS",
+ "regime",
+ "minAlwaysW",
+ "boundBy"
+ ],
+ "EV_DEADLINE": [],
+ "OPTIMIZER_DECISION": [],
+ "PRICE_SLOT": [
+ "priceCtkWh",
+ "setpointW"
+ ],
+ "SG_FORCED": [
+ "budgetW",
+ "estimatedW"
+ ],
+ "SG_NORMAL": [
+ "budgetW"
+ ],
+ "SG_RECOMMENDED": [
+ "budgetW",
+ "estimatedW"
+ ],
+ "SURPLUS_INSUFFICIENT": [
+ "budgetW"
+ ],
+ "SURPLUS_SETPOINT": [
+ "budgetW",
+ "setpointW",
+ "stepped"
+ ]
+ },
+ "x-etm-generated": {
+ "description": "Listes fermées RÉGÉNÉRÉES depuis le code (tools/etm-powersync-gen-protocol-doc --enums-from) ; la suite etm-powersync-protocolschema échoue si le code et le schéma divergent.",
+ "items": [
+ {
+ "pointer": "/info/version",
+ "from": "OptimizerProtocol::ProtocolVersion"
+ },
+ {
+ "pointer": "/components/schemas/ProtocolVersion/const",
+ "from": "OptimizerProtocol::ProtocolVersion"
+ },
+ {
+ "pointer": "/components/schemas/Trigger/enum",
+ "from": "SocketScheduler::knownTriggers()"
+ },
+ {
+ "pointer": "/components/schemas/Domain/enum",
+ "from": "LoadConfig::knownDomains()"
+ },
+ {
+ "pointer": "/components/schemas/ActionKind/enum",
+ "from": "OptimizerPlan::kinds()"
+ },
+ {
+ "pointer": "/components/schemas/ReasonCode/enum",
+ "from": "DecisionCode::optimizerEmittableCodes()"
+ },
+ {
+ "pointer": "/x-etm-reason-params",
+ "from": "DecisionCode::optimizerParamKeys()"
+ },
+ {
+ "pointer": "/components/schemas/PriceInclude/enum",
+ "from": "TariffConfig::includes()"
+ },
+ {
+ "pointer": "/components/schemas/FallbackCode/enum",
+ "from": "SocketScheduler::knownFallbackCodes()"
+ },
+ {
+ "pointer": "/components/schemas/LastErrorCode/enum",
+ "from": "SocketScheduler::knownLastErrorCodes()"
+ }
+ ]
+ }
+}
diff --git a/docs/optimiseur/index.md b/docs/optimiseur/index.md
new file mode 100644
index 0000000..de15c0a
--- /dev/null
+++ b/docs/optimiseur/index.md
@@ -0,0 +1,66 @@
+# Optimiseur
+
+EN COURS
+
+Le protocole entre le moteur et un optimiseur est publié (version 1.1). La connexion d'un
+optimiseur est en test sur notre installation de test ; la box n'en a pas besoin pour
+fonctionner.
+
+## À quoi sert un optimiseur
+
+Les [règles du moteur](../fonctionnalites/moteur.md) décident sur le présent : le surplus
+de cette minute, réparti selon vos priorités. Un optimiseur regarde plus loin. À partir des
+prévisions que le moteur lui transmet — production PV, consommation de base, tarif saisi —
+il propose un **plan par créneaux** sur plusieurs heures : décaler le chauffe-eau vers le
+pic de production prévu, garder de la marge pour la recharge du soir, etc.
+
+## L'optimiseur conseille, le moteur décide
+
+- Le moteur n'exécute que le **créneau en cours** du plan. Le reste est affiché comme
+ prévision, et redemandé au cycle suivant.
+- Chaque consigne est **plafonnée** par les bornes de la charge (plancher, plafond,
+ verrous anti-battement) et par la **boucle de sécurité** en temps réel, qui reste au
+ moteur et prime sur tout. Un optimiseur ne peut pas contourner un verrou.
+- Chaque action doit porter son **motif**. Une action sans motif est rejetée.
+- Un plan mal formé, ou qui vise une charge inconnue, est refusé en entier.
+
+Un optimiseur défaillant ne peut donc, au pire, que proposer une mauvaise répartition —
+bornée par le moteur.
+
+## La box fonctionne sans optimiseur
+
+Optimiseur absent, muet, qui s'abstient, plan expiré ou refusé : le moteur revient à ses
+**règles** (`rules`) pour le cycle, sans interruption du pilotage. L'application indique à
+tout moment quelle stratégie pilote. La version Community fonctionne entièrement ainsi.
+
+## Ses données ne viennent que du moteur
+
+- C'est le moteur qui se connecte à l'optimiseur, jamais l'inverse : la box n'ouvre aucun
+ port.
+- L'optimiseur tourne sur la box (socket local) ou sur une machine de votre réseau local
+ (TCP). Sur le réseau local, un secret partagé (token) est recommandé.
+- Il ne reçoit **que** le contexte envoyé par le moteur, prévisions comprises : il
+ n'appelle ni service météo ni fournisseur de tarif, et n'ouvre aucune connexion vers
+ l'extérieur.
+- Ce qu'il apprend reste sur le site.
+
+## Qui peut écrire un optimiseur
+
+Tout le monde. Le protocole est ouvert et décrit par un [contrat machine](contrat.md)
+publié sous GPL-3.0. Un **optimiseur de référence** libre et une **suite de conformité**
+permettent d'écrire le sien et de le vérifier contre le vrai moteur — voir
+[Écrire un optimiseur](ecrire.md).
+
+## L'optimiseur ETM
+
+ETM-Schurig propose son propre optimiseur, propriétaire, inclus dans l'édition Pro. Il se
+branche par ce même protocole, avec les mêmes limites : il conseille, le moteur décide. Au
+handshake, il s'annonce `etm-optimizer` ; l'optimiseur de référence s'annonce
+`powersync-optimizer`.
+
+## Dans cette section
+
+- [Protocole](protocole.md) — transport, handshake, contexte, plan, repli, règles de
+ validation.
+- [Contrat](contrat.md) — le fichier OpenRPC qui fait foi sur la forme des messages.
+- [Écrire un optimiseur](ecrire.md) — optimiseur de référence et suite de conformité.
diff --git a/docs/optimiseur/protocole.md b/docs/optimiseur/protocole.md
new file mode 100644
index 0000000..f40d7c4
--- /dev/null
+++ b/docs/optimiseur/protocole.md
@@ -0,0 +1,855 @@
+# Protocole
+
+Le protocole relie le **moteur** d'énergie de la box à un **optimiseur**, sur un canal
+dédié. Ce n'est pas l'API de l'application : celle-ci passe par l'[API JSON-RPC](../integrations/jsonrpc-api.md).
+
+!!! info "Ce qui fait foi"
+ - **La forme** des messages (champs, types, valeurs permises, ce que le moteur refuse)
+ est fixée par le [contrat machine](contrat.md). Les tableaux de cette page en sont
+ **générés** à chaque publication : cadre en anglais, descriptions reprises telles
+ quelles du contrat, en français. Ils sont identiques dans les trois langues.
+ - **Le sens** est décrit en détail, en français, dans
+ [`OPTIMIZER_PROTOCOL.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/docs/OPTIMIZER_PROTOCOL.md),
+ dans le dépôt du moteur. Cette page en donne l'essentiel ; en cas d'écart, les
+ documents du moteur l'emportent.
+
+## Transport et rôles {#transport}
+
+- L'optimiseur est le **serveur**, le moteur est le **client** : le moteur se connecte et
+ pose les questions. La box n'ouvre aucun port.
+- Deux transports, mêmes messages : `unix://` (socket local, par défaut) et `tcp://`
+ (machine du réseau local).
+- JSON-RPC 2.0, un message par ligne (NDJSON), UTF-8.
+- Si la connexion tombe, le moteur se reconnecte avec un délai croissant et pilote par
+ ses règles en attendant.
+
+
+
+| Property | Value (contract) |
+|---|---|
+| Protocol version | `1.1` |
+| Application protocol | JSON-RPC `2.0` |
+| Framing | `ndjson` (one message per line) |
+| Encoding | `utf-8` |
+| Endpoint schemes | `unix://` · `tcp://` |
+| Default endpoint | `unix:///run/powersync/optimizer.sock` |
+| Client | `engine` |
+| Server | `optimizer` |
+| Max line size | 1048576 bytes |
+| Handshake timeout | 5 s |
+| Request timeout | 10 s |
+| Reconnect backoff | 1 s → 60 s, ×2 |
+| Requests in flight | 1 |
+| Request ids | entiers croissants sur toute la vie du client, à travers les reconnexions |
+| Line errors | ligne non JSON, sans id, id jamais émis, ligne > maxLineBytes : comptée, connexion CONSERVÉE |
+| Token masking | toute occurrence du token dans un message reçu est remplacée par « [token masqué] » dès l'analyse |
+
+**Servers**
+
+| Name | URL | Summary (contract) | Example values |
+|---|---|---|---|
+| `unix` | `unix:///run/powersync/optimizer.sock` | Défaut : optimiseur local, socket possédé par root:powersync, mode 0660. | |
+| `tcp` | `tcp://{host}:{port}` | Optimiseur sur le LAN. Token recommandé ; il circule en clair sans TLS (E16). | `host` = `192.168.1.10`, `port` = `7400` |
+
+**Methods**
+
+| Method | Direction | Kind | Timeout | Summary (contract) |
+|---|---|---|---|---|
+| `Handshake` | `engine→optimizer` | request | 5 s | Ouverture de session, à chaque (re)connexion (§3). |
+| `GetPlan` | `engine→optimizer` | request | 10 s | Demande d'un plan pour le contexte courant (§4-§6). |
+| `UpdateLearned` | `optimizer→engine` | notification (no id) | — | Estimations apprises de l'optimiseur (§8) — notification, sans id. |
+
+
+
+## Handshake {#handshake}
+
+À chaque connexion, le moteur envoie `Handshake` avec sa version du protocole et, s'il est
+configuré, le secret partagé. L'optimiseur répond sa version et, s'il le souhaite, son nom
+et ses capacités. Une **majeure** différente, un secret refusé ou une réponse illisible :
+la connexion est refusée, et le moteur pilote par ses règles. Si l'optimiseur annonce les
+types d'action (`kinds`) qu'il émet, le moteur refuse ensuite toute action d'un autre type.
+
+
+
+#### `Handshake` — params
+
+Ouverture de session, à chaque (re)connexion (§3).
+
+Envoyé par le moteur (client) dès la connexion. Délai : 5 s depuis le DÉBUT de la tentative ; au-delà, repli et nouvelle tentative au backoff. Le message porte le secret : il n'est jamais journalisé.
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `protocolVersion` | [`ProtocolVersion`](#schema-protocolversion) | yes | | Version du moteur ; seule la majeure est comparée. |
+| `engine` | string | yes | pattern `^etm-powersync-energy-plugin-etm/` | Moteur et sa version. |
+| `token` | [`HandshakeToken`](#schema-handshaketoken) | no | | |
+
+**result** (`handshake`): [`HandshakeResult`](#schema-handshakeresult)
+
+**Errors**
+
+| Code | Message | Note (contract) |
+|---|---|---|
+| `-32001` | token refusé | Recommandé (optimiseur de référence). Toute réponse error est traitée pareil : handshake-refused, connexion fermée. |
+
+#### `HandshakeToken` {#schema-handshaketoken}
+
+Secret partagé (§3, E-T2-8) : 16 caractères au moins, 4 096 octets au plus (borne en octets non exprimable ici : contrôlée par le moteur), imprimable, sans blanc ni caractère de contrôle, UTF-8 admis. Envoyé seulement s'il est configuré ; jamais journalisé.
+
+Type: string — ≥ 16 char. ; pattern `^[^\s\x00-\x1f\x7f]+$`
+
+#### `HandshakeResult` {#schema-handshakeresult}
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `protocolVersion` | string | yes | ≥ 1 char. ; pattern `^[0-9]+(\.[0-9]+)*$` | Version semver de l'optimiseur ; même MAJEURE que le moteur exigée (règle major-mismatch). |
+| `name` | string | no | | Nom affiché (mode installateur), non contrôlé par le moteur (E-T7-3). |
+| `planning` | boolean | no | | Vrai si l'optimiseur produit des plans multi-créneaux. Informatif. |
+| `capabilities` | [`Capabilities`](#schema-capabilities) | no | | |
+
+Undeclared fields: **ignored** (optimizer-emitted form, open).
+
+#### `Capabilities` {#schema-capabilities}
+
+Capacités annoncées (§3, 1.1, optionnel). Affichées en mode installateur, réduites aux clés et valeurs connues (capacitesPubliees) ; un champ inconnu est ignoré (§9).
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `maxHorizonH` | number | no | ≥ 0 | Horizon maximal planifié, en heures. |
+| `resolutionS` | number | no | ≥ 0 | Pas des créneaux, en secondes. |
+| `kinds` | [[`ActionKind`](#schema-actionkind)] | no | | PRÉSENT, il restreint les kinds acceptés (règle kind-not-announced). |
+| `usesForecast` | [string] | no | `pv` · `baseload` · `price` · `temperature` | |
+| `reasonCodes` | boolean | no | | L'optimiseur émet des reasonCode du catalogue. |
+
+Undeclared fields: **ignored** (optimizer-emitted form, open).
+
+
+
+## Le contexte : `GetPlan` {#contexte}
+
+À chaque cycle, et sur événement (branchement d'un véhicule, nouvelle prévision,
+modification de la configuration…), le moteur envoie `GetPlan` avec un **contexte** :
+état du site et du compteur de tête, production, batterie, charges arbitrées avec leurs
+bornes, verrous et besoins, prévisions sur une grille temporelle unique.
+
+Quelques règles de lecture, qui changent les décisions :
+
+- le compteur est transmis en **mesure brute**, jamais le bilan après pilotage ;
+- une valeur inconnue vaut `null`, **jamais 0** ;
+- une charge indisponible (`available: false`) ne doit recevoir aucune consigne, mais elle
+ consomme : elle reste comptée ;
+- les verrous en cours (`lockMinPowerW`, `lockMaxPowerW`) disent ce que la charge peut
+ faire **maintenant** ; le moteur les applique quoi qu'il arrive ;
+- ce que mesure une charge se lit dans `measurement.source`, jamais dans son mécanisme
+ (`adapter`) ; une puissance déclarée n'est jamais une mesure.
+
+La forme envoyée par le moteur est **fermée** : il n'émet aucun champ hors du contrat.
+
+
+
+#### `GetPlan` — params
+
+Demande d'un plan pour le contexte courant (§4-§6).
+
+À chaque cycle (~1/min) et sur événement (trigger). Une requête en vol au plus : une plus récente remplace l'attente de la précédente (sa réponse est ignorée par id, ce n'est pas un échec). Délai : 10 s par requête. La réponse à la requête N sert au cycle N+1 (modèle cache). Le JSON Schema dit la FORME ; x-etm-semantic-rules dit ce que le validateur refuse en plus.
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `timestamp` | string | yes | instant with offset | Instant du cycle — le « maintenant » du validateur. |
+| `trigger` | [`Trigger`](#schema-trigger) | yes | | |
+| `site` | [`Site`](#schema-site) | yes | | |
+| `meter` | [`Meter`](#schema-meter) | yes | | |
+| `pv` | [`Pv`](#schema-pv) | yes | | |
+| `battery` | [`Battery`](#schema-battery) | yes | | |
+| `loads` | [[`Load`](#schema-load)] | yes | | Charges arbitrées, ⊆ GetLoadConfig. |
+| `forecast` | [`Forecast`](#schema-forecast) \| null | yes | | null sans instantané de prévision. |
+| `tariff` | [`Tariff`](#schema-tariff) \| null | yes | | |
+
+**Errors**
+
+| Code | Message | Note (contract) |
+|---|---|---|
+| `-32603` | erreur interne | Toute réponse error compte comme un échec de la requête (rpc-error, §7) ; le code n'est pas lu. |
+
+#### `Site` {#schema-site}
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `contractedPowerW` | number \| null | yes | | Puissance souscrite (W) ; null si inconnue (E1), jamais 0. |
+| `gridCapW` | null | yes | | RÉSERVÉ (signaux réseau type §14a EnWG) : toujours null en 1.1. |
+| `phaseLimitA` | [number] \| null | yes | | Limite par phase en A ; null si inconnue. |
+
+Undeclared fields: **rejected** (engine-emitted form, closed).
+
+#### `Meter` {#schema-meter}
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `source` | string \| null | yes | | ThingId du compteur racine désigné ; null si inconnu. |
+| `importW` | number | yes | ≥ 0 | Puissance importée (W). |
+| `exportW` | number | yes | ≥ 0 | Puissance exportée (W), mesure BRUTE (AGENTS.md règle 8) — jamais le net après pilotage. |
+| `perPhaseA` | [number] | yes | | Courant par phase (A). |
+
+Undeclared fields: **rejected** (engine-emitted form, closed).
+
+#### `Pv` {#schema-pv}
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `currentW` | number \| null | yes | ≥ 0 | Production instantanée (W), positive ; null sans producteur (jamais 0). |
+
+Undeclared fields: **rejected** (engine-emitted form, closed).
+
+#### `Battery` {#schema-battery}
+
+One of: [`BatteryAbsent`](#schema-batteryabsent) · [`BatteryPresent`](#schema-batterypresent).
+
+#### `Load` {#schema-load}
+
+Une charge arbitrée (§5.3) — la forme dépend du mécanisme (adapter).
+
+One of: [`LoadWatts`](#schema-loadwatts) · [`LoadEvCharger`](#schema-loadevcharger) · [`LoadSgReady`](#schema-loadsgready).
+
+#### `Forecast` {#schema-forecast}
+
+Prévisions sur UNE grille (§5.2) ; tableaux alignés index par index.
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `start` | string | yes | instant with offset | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. |
+| `resolutionS` | integer | yes | ≥ 1 | Pas de la grille (s) — fait foi, jamais supposé. |
+| `steps` | integer | yes | ≥ 0 | Nombre de cases ; toutes les séries ont cette longueur. |
+| `pv` | [`PvForecast`](#schema-pvforecast) \| null | yes | | null sans courbe (site non désigné, charge utile refusée, canal muet). |
+| `baseload` | [`Baseload`](#schema-baseload) | yes | | |
+| `weather` | null | yes | | Aucune source de température en 1.1 : toujours null. |
+| `price` | [`Price`](#schema-price) \| null | yes | | null sans tarif saisi. |
+
+Undeclared fields: **rejected** (engine-emitted form, closed).
+
+#### `Tariff` {#schema-tariff}
+
+Tarif 1.0, conservé pour compatibilité ; un optimiseur 1.1 lit forecast.price. Aucun écrivain aujourd'hui (E3) : null en pratique.
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `provider` | string \| null | yes | | |
+| `current` | [`TariffEntry`](#schema-tariffentry) | yes | | |
+| `next` | [[`TariffNextEntry`](#schema-tariffnextentry)] | yes | | |
+
+Undeclared fields: **rejected** (engine-emitted form, closed).
+
+#### `BatteryAbsent` {#schema-batteryabsent}
+
+Aucun stockage déclaré : rien d'autre n'est émis.
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `present` | boolean | yes | `false` | |
+
+Undeclared fields: **rejected** (engine-emitted form, closed).
+
+#### `BatteryPresent` {#schema-batterypresent}
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `present` | boolean | yes | `true` | |
+| `socPercent` | number | yes | | |
+| `powerW` | number | yes | | Positif = charge, négatif = décharge. |
+| `capacityWh` | number \| null | yes | | null si inconnue. |
+| `maxChargeW` | number \| null | yes | | null si inconnue. |
+| `maxDischargeW` | number \| null | yes | | null si inconnue. |
+| `reserveSocPercent` | integer \| null | yes | | Réserve de secours (%) ; null si inconnue. |
+
+Undeclared fields: **rejected** (engine-emitted form, closed).
+
+#### `LoadWatts` {#schema-loadwatts}
+
+Charge pilotée en watts : combinaison de relais (relay-router) ou consigne continue (etmvariableload).
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `id` | string | yes | ≥ 1 char. | Identifiant de la charge — clé des actions. |
+| `label` | string | yes | | Nom lisible. |
+| `priority` | integer | yes | | Rang effectif (1 = premier servi). |
+| `domain` | [`Domain`](#schema-domain) | no | | |
+| `learned` | [`LoadLearned`](#schema-loadlearned) | no | | |
+| `needs` | [`LoadNeeds`](#schema-loadneeds) | no | | |
+| `adapter` | string | yes | `relay-router` · `etmvariableload` | |
+| `declared` | object | yes | | |
+| `declared.minPowerW` | number | yes | | 0 = aucun plancher. |
+| `declared.maxPowerW` | number | yes | | |
+| `telemetry` | object | yes | | |
+| `telemetry.currentPowerW` | number | yes | | Puissance courante (W) telle que l'adaptateur la publie. |
+| `telemetry.available` | boolean | yes | | Gouverne l'ALLOCATION, pas la COMPTABILITÉ (§5.3). |
+| `telemetry.measurement` | [`Measurement`](#schema-measurement) | no | | |
+| `telemetry.lockMinPowerW` | number | yes | | Plancher imposé MAINTENANT par un verrou. |
+| `telemetry.lockMaxPowerW` | number | yes | | Plafond imposé maintenant ; négatif = aucun plafond. |
+| `limits` | object | yes | | |
+| `limits.minOnS` | integer | yes | | |
+| `limits.minOffS` | integer | yes | | |
+
+Undeclared fields: **rejected** (engine-emitted form, closed).
+
+#### `LoadEvCharger` {#schema-loadevcharger}
+
+Borne. Une borne CONFIGURÉE dont l'appareil manque n'a ni declared, ni limits, ni pluggedIn/charging : ses zéros diraient « inconnu ».
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `id` | string | yes | ≥ 1 char. | Identifiant de la charge — clé des actions. |
+| `label` | string | yes | | Nom lisible. |
+| `priority` | integer | yes | | Rang effectif (1 = premier servi). |
+| `domain` | [`Domain`](#schema-domain) | no | | |
+| `learned` | [`LoadLearned`](#schema-loadlearned) | no | | |
+| `needs` | [`LoadNeeds`](#schema-loadneeds) | no | | |
+| `adapter` | string | yes | `"evcharger"` | |
+| `declared` | object | no | | |
+| `declared.minA` | number | yes | | |
+| `declared.maxA` | number | yes | | |
+| `declared.phases` | integer | yes | | Phases VISÉES ce cycle, pas celles de la plaque (E26). |
+| `telemetry` | object | yes | | |
+| `telemetry.currentPowerW` | number | yes | | Puissance courante (W) telle que l'adaptateur la publie. |
+| `telemetry.available` | boolean | yes | | Gouverne l'ALLOCATION, pas la COMPTABILITÉ (§5.3). |
+| `telemetry.measurement` | [`Measurement`](#schema-measurement) | no | | |
+| `telemetry.pluggedIn` | boolean | no | | |
+| `telemetry.charging` | boolean | no | | |
+| `telemetry.sessionWh` | number | no | | Absent si la borne ne compte pas sa session (LM-1009 C1). |
+| `limits` | object | no | | |
+| `limits.chargingEnabledLockS` | integer | yes | | |
+| `limits.currentChangeLockS` | integer | yes | | |
+
+Undeclared fields: **rejected** (engine-emitted form, closed).
+
+#### `LoadSgReady` {#schema-loadsgready}
+
+PAC SG-Ready : 4 états normés, kind state.
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `id` | string | yes | ≥ 1 char. | Identifiant de la charge — clé des actions. |
+| `label` | string | yes | | Nom lisible. |
+| `priority` | integer | yes | | Rang effectif (1 = premier servi). |
+| `domain` | [`Domain`](#schema-domain) | no | | |
+| `learned` | [`LoadLearned`](#schema-loadlearned) | no | | |
+| `needs` | [`LoadNeeds`](#schema-loadneeds) | no | | |
+| `adapter` | string | yes | `"sg-ready"` | |
+| `declared` | object | yes | | |
+| `declared.states` | [integer] | yes | ≥ 1 ; ≤ 4 | |
+| `declared.estimatedPowerW` | map of number | yes | | |
+| `telemetry` | object | yes | | |
+| `telemetry.currentPowerW` | number | yes | | Puissance courante (W) telle que l'adaptateur la publie. |
+| `telemetry.available` | boolean | yes | | Gouverne l'ALLOCATION, pas la COMPTABILITÉ (§5.3). |
+| `telemetry.measurement` | [`Measurement`](#schema-measurement) | no | | |
+| `telemetry.state` | integer | yes | ≥ 0 ; ≤ 4 | État commandé (1-4) ; 0 = non applicable. |
+| `telemetry.minState` | integer | yes | | Fenêtre d'états autorisée MAINTENANT par minStateHold. |
+| `telemetry.maxState` | integer | yes | | |
+| `limits` | object | yes | | |
+| `limits.minStateHoldS` | integer | yes | | |
+
+Undeclared fields: **rejected** (engine-emitted form, closed).
+
+#### `PvForecast` {#schema-pvforecast}
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `p50W` | [number \| null] | yes | | Puissance AC moyenne sur [t, t+pas), sommée sur les plans puis plafonnée à acLimitW ; null = case inconnue. Un plafond, pas une espérance (§5.2). |
+| `conversion` | string | yes | `physics` | Seule valeur émise par le moteur GPL ; un consommateur ignore une valeur inconnue. |
+| `corrections` | [[`Correction`](#schema-correction)] | yes | | [] = conversion brute. |
+| `weatherModel` | string \| null | yes | | Modèle qui a RÉPONDU ; null si inconnu. |
+| `issuedAt` | string \| null | yes | instant with offset | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. |
+| `contentChangedAt` | string \| null | yes | instant with offset | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. |
+| `acLimitW` | integer | no | | Plafond AC du site (W), s'il est configuré. |
+| `perField` | [[`PvField`](#schema-pvfield)] | no | | |
+
+Undeclared fields: **rejected** (engine-emitted form, closed).
+
+#### `Baseload` {#schema-baseload}
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `model` | string | yes | `profile` · `none` | Le moteur GPL émet profile ou none (learned n'est pas émis). |
+| `p50W` | [number \| null] | yes | | |
+| `quality` | [`BaseloadQuality`](#schema-baseloadquality) | no | | |
+
+Undeclared fields: **rejected** (engine-emitted form, closed).
+
+#### `Price` {#schema-price}
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `currency` | string | yes | `"EUR"` | |
+| `importCtkWh` | [number \| null] | yes | | |
+| `exportCtkWh` | [number \| null] | yes | | |
+| `includes` | [[`PriceInclude`](#schema-priceinclude)] | yes | | |
+| `source` | string | yes | `fixed` · `tou` | Tarif SAISI : une zone (fixed) ou des zones horaires (tou). tempo et day-ahead ne sont pas émis en 1.1. |
+| `knownUntil` | string | yes | instant with offset | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. |
+
+Undeclared fields: **rejected** (engine-emitted form, closed).
+
+#### `TariffEntry` {#schema-tariffentry}
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `label` | string | yes | | |
+| `priceCtkWh` | number | yes | | |
+
+Undeclared fields: **rejected** (engine-emitted form, closed).
+
+#### `TariffNextEntry` {#schema-tariffnextentry}
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `label` | string | yes | | |
+| `priceCtkWh` | number | yes | | |
+| `from` | string \| null | yes | instant with offset | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. |
+
+Undeclared fields: **rejected** (engine-emitted form, closed).
+
+#### `LoadLearned` {#schema-loadlearned}
+
+Absent tant qu'aucun profil n'a été reçu (E12).
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `dailyEnergyWh` | number | yes | | |
+| `confidence` | number | yes | ≥ 0 ; ≤ 1 | |
+
+Undeclared fields: **rejected** (engine-emitted form, closed).
+
+#### `LoadNeeds` {#schema-loadneeds}
+
+Besoins (§5.3). Champs 1.0 émis tels quels quand ils sont déclarés ; un optimiseur 1.1 préfère energyTargets.
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `dailyDeadline` | string | no | pattern `^[0-9]{2}:[0-9]{2}$` | Heure murale locale HH:MM (1.0). |
+| `minEnergyWhPerDay` | integer | no | > 0 | |
+| `targetSocPercent` | integer | no | > 0 | |
+| `deadline` | string | no | instant with offset | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. |
+| `energyTargets` | [[`EnergyTarget`](#schema-energytarget)] | no | ≥ 1 items | |
+
+Undeclared fields: **rejected** (engine-emitted form, closed).
+
+#### `Measurement` {#schema-measurement}
+
+Mesure par charge (§5.3, LM-1105/1106) : décider sur source, jamais sur adapter.
+
+**variant 1** — Rien ne mesure : état positif, pas une erreur.
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `source` | string | yes | `"none"` | |
+
+Undeclared fields: **rejected** (engine-emitted form, closed).
+
+**variant 2**
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `source` | string | yes | `meter` · `device` | |
+| `powerW` | number | yes | | |
+
+Undeclared fields: **rejected** (engine-emitted form, closed).
+
+#### `PvField` {#schema-pvfield}
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `id` | string | yes | | ThingId du plan. |
+| `corrections` | [[`Correction`](#schema-correction)] | yes | | |
+
+Undeclared fields: **rejected** (engine-emitted form, closed).
+
+#### `BaseloadQuality` {#schema-baseloadquality}
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `windowDays` | integer | yes | | |
+| `minDays` | integer | yes | | |
+| `resolutionS` | integer | yes | | |
+| `daysCovered` | object | yes | | |
+| `daysCovered.workday` | integer | yes | | |
+| `daysCovered.weekend` | integer | yes | | |
+| `minutes` | integer | yes | | |
+| `commandedMinutes` | integer | yes | | |
+| `negativeMinutes` | integer | yes | | |
+| `incompleteMinutes` | integer | yes | | |
+| `computedAt` | string | no | instant with offset | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. |
+| `loads` | [[`BaseloadLoadQuality`](#schema-baseloadloadquality)] | yes | | |
+
+Undeclared fields: **rejected** (engine-emitted form, closed).
+
+#### `EnergyTarget` {#schema-energytarget}
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `by` | string | yes | instant with offset | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. |
+| `minWh` | number | yes | > 0 | Énergie minimale à avoir délivrée à l'échéance. |
+| `source` | string | yes | `installer` · `user-intent` | learned n'est pas émis en 1.1. |
+
+Undeclared fields: **rejected** (engine-emitted form, closed).
+
+#### `BaseloadLoadQuality` {#schema-baseloadloadquality}
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `loadId` | string | yes | | |
+| `source` | string | yes | `meter` · `device` · `none` | |
+| `minutesBySource` | map of integer | yes | keys `meter` · `device` · `none` | |
+| `constantMeasurement` | boolean | no | | |
+
+Undeclared fields: **rejected** (engine-emitted form, closed).
+
+
+
+## La réponse : un plan ou une abstention {#reponse}
+
+L'optimiseur répond **vite** — avec son dernier plan en cache s'il calcule longtemps — et
+recalcule en tâche de fond. Il répond soit :
+
+- un **plan par créneaux** : chaque créneau porte une action par charge disponible
+ (consigne en watts ou en ampères, ou état SG-Ready), chacune avec son motif ; seul le
+ créneau en cours est exécuté, et `validUntil` borne la durée de vie du plan ;
+- une **abstention** (`abstain: true`) : « pas de plan maintenant ». Le moteur pilote par
+ ses règles pour ce cycle, sans considérer l'optimiseur comme en panne.
+
+Le motif d'une action a deux formes : un texte libre en français, **obligatoire**, pour
+les traces ; et, facultatif, un code du catalogue (`reasonCode`) avec ses paramètres.
+Vers l'application, **seul le code** est transmis, et l'application le traduit. Un code
+inconnu part sous `OPTIMIZER_DECISION`.
+
+La forme reçue par le moteur est **ouverte** : un champ inconnu est ignoré.
+
+
+
+#### `GetPlan` — result
+
+`plan`: [`GetPlanResult`](#schema-getplanresult)
+
+**Errors**
+
+| Code | Message | Note (contract) |
+|---|---|---|
+| `-32603` | erreur interne | Toute réponse error compte comme un échec de la requête (rpc-error, §7) ; le code n'est pas lu. |
+
+#### `GetPlanResult` {#schema-getplanresult}
+
+One of: [`Abstention`](#schema-abstention) · [`Plan`](#schema-plan).
+
+#### `Abstention` {#schema-abstention}
+
+« Pas de plan maintenant » (§7) : repli pour ce cycle, pas un échec.
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `abstain` | boolean | yes | `true` | |
+| `reason` | string | no | | |
+
+Undeclared fields: **ignored** (optimizer-emitted form, open).
+
+#### `Plan` {#schema-plan}
+
+Plan par créneaux (§6).
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `abstain` | boolean | no | `false` | |
+| `planId` | string | no | | |
+| `strategy` | string | no | | Chaîne libre, informative. |
+| `computedAt` | string | no | instant with offset | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. |
+| `validUntil` | string \| null | no | instant with offset | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. |
+| `slots` | [[`Slot`](#schema-slot)] | yes | | [] = abstention (empty-plan). Seul le créneau couvrant maintenant est exécuté. |
+| `diagnostics` | [`Diagnostics`](#schema-diagnostics) | no | | |
+
+Undeclared fields: **ignored** (optimizer-emitted form, open).
+
+#### `Slot` {#schema-slot}
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `from` | string | yes | instant with offset | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. |
+| `to` | string | yes | instant with offset | Instant ISO 8601 AVEC son décalage (Z ou ±hh:mm) — une heure murale nue est refusée. |
+| `actions` | [[`Action`](#schema-action)] \| null | no | | Absent ou null = aucune action. |
+| `expected` | [`Expected`](#schema-expected) | no | | |
+
+Undeclared fields: **ignored** (optimizer-emitted form, open).
+
+#### `Diagnostics` {#schema-diagnostics}
+
+Informatif : mode installateur et traces.
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `solver` | string | no | | |
+| `status` | string | no | `optimal` · `feasible` · `best-so-far` · `infeasible-relaxed` | |
+| `gapPercent` | number | no | | |
+| `computeMs` | number | no | ≥ 0 | |
+| `inputsUsed` | object | no | | |
+| `inputsUsed.pv` | string | no | `physics` · `hybrid` · `none` | |
+
+Undeclared fields: **ignored** (optimizer-emitted form, open).
+
+#### `Action` {#schema-action}
+
+One of: [`SetpointAction`](#schema-setpointaction) · [`StateAction`](#schema-stateaction) · [`ConstraintAction`](#schema-constraintaction).
+
+#### `Expected` {#schema-expected}
+
+Trajectoire prévue : affichage et mesure d'écart, JAMAIS pour commander.
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `gridImportW` | number | no | | |
+| `gridExportW` | number | no | | |
+| `batterySocPercent` | any | no | | RÉSERVÉ (annexe F) : ignoré. |
+
+Undeclared fields: **ignored** (optimizer-emitted form, open).
+
+#### `SetpointAction` {#schema-setpointaction}
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `loadId` | string | yes | ≥ 1 char. | Charge visée : connue du contexte (règle unknown-load), une fois par créneau (duplicate-load). |
+| `reason` | string | yes | ≥ 1 char. ; pattern `\S` | Texte français, OBLIGATOIRE et non vide (règle missing-reason). Traces et journal seulement, jamais vers l'app. |
+| `reasonCode` | [`ReasonCode`](#schema-reasoncode) | no | | |
+| `reasonParams` | [`ReasonParams`](#schema-reasonparams) | no | | |
+| `force` | any | no | | INTERDIT sur le fil (§6) : ignoré, signalé une fois. |
+| `kind` | string | yes | `"setpoint"` | |
+| `powerW` | number | no | ≥ 0 | Consigne en watts (relay-router, etmvariableload ; ou borne). |
+| `currentA` | number | no | ≥ 0 | Borne : courant (0 = suspendre). |
+| `phases` | enum | no | `1` · `3` | Borne : phases visées ; absent = inchangé. |
+
+Undeclared fields: **ignored** (optimizer-emitted form, open).
+
+At least one of: `powerW`, `currentA`.
+
+#### `StateAction` {#schema-stateaction}
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `loadId` | string | yes | ≥ 1 char. | Charge visée : connue du contexte (règle unknown-load), une fois par créneau (duplicate-load). |
+| `reason` | string | yes | ≥ 1 char. ; pattern `\S` | Texte français, OBLIGATOIRE et non vide (règle missing-reason). Traces et journal seulement, jamais vers l'app. |
+| `reasonCode` | [`ReasonCode`](#schema-reasoncode) | no | | |
+| `reasonParams` | [`ReasonParams`](#schema-reasonparams) | no | | |
+| `force` | any | no | | INTERDIT sur le fil (§6) : ignoré, signalé une fois. |
+| `kind` | string | yes | `"state"` | |
+| `state` | integer | yes | ≥ 1 ; ≤ 4 | 1 = verrouillage, 2 = normal, 3 = recommandation, 4 = forcé. |
+
+Undeclared fields: **ignored** (optimizer-emitted form, open).
+
+#### `ConstraintAction` {#schema-constraintaction}
+
+RÉSERVÉ (annexe F) : forme décrite, toujours refusée (kind-mismatch).
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `loadId` | string | yes | ≥ 1 char. | Charge visée : connue du contexte (règle unknown-load), une fois par créneau (duplicate-load). |
+| `reason` | string | yes | ≥ 1 char. ; pattern `\S` | Texte français, OBLIGATOIRE et non vide (règle missing-reason). Traces et journal seulement, jamais vers l'app. |
+| `reasonCode` | [`ReasonCode`](#schema-reasoncode) | no | | |
+| `reasonParams` | [`ReasonParams`](#schema-reasonparams) | no | | |
+| `force` | any | no | | INTERDIT sur le fil (§6) : ignoré, signalé une fois. |
+| `kind` | string | yes | `"constraint"` | |
+| `charge` | enum | no | `allow` · `forbid` | |
+| `discharge` | enum | no | `allow` · `forbid` | |
+
+Undeclared fields: **ignored** (optimizer-emitted form, open).
+
+#### `ReasonParams` {#schema-reasonparams}
+
+Paramètres du motif : valeurs nombre, booléen ou code [A-Za-z]+ ; clés admises par code : x-etm-reason-params. Toute autre clé est SUPPRIMÉE avant l'app (filtrée, pas refusée).
+
+Type: map of number \| boolean \| string \| null
+
+
+
+### Codes de motif émissibles {#codes-de-motif}
+
+
+
+| `reasonCode` | Accepted `reasonParams` keys |
+|---|---|
+| `SURPLUS_SETPOINT` | `budgetW`, `setpointW`, `stepped` |
+| `SURPLUS_INSUFFICIENT` | `budgetW` |
+| `BELOW_MIN_POWER` | `budgetW`, `minPowerW`, `state` |
+| `EV_DEADLINE` | — |
+| `SG_FORCED` | `budgetW`, `estimatedW` |
+| `SG_RECOMMENDED` | `budgetW`, `estimatedW` |
+| `SG_NORMAL` | `budgetW` |
+| `ECO_FLOOR_GRID` | `gridW`, `atMaximum`, `remainingWh`, `targetWh`, `remainingS`, `regime`, `minAlwaysW`, `boundBy` |
+| `PRICE_SLOT` | `priceCtkWh`, `setpointW` |
+| `OPTIMIZER_DECISION` | — |
+
+
+
+## `UpdateLearned` {#updatelearned}
+
+Notification envoyée par l'optimiseur au moteur, sans réponse, pour transmettre ce qu'il a
+appris des charges. Dans la version 1.1 du moteur, elle est reçue et journalisée, mais
+pas encore exploitée.
+
+
+
+#### `UpdateLearned` — params
+
+Estimations apprises de l'optimiseur (§8) — notification, sans id.
+
+Envoyée par l'OPTIMISEUR (sens inverse des deux autres), sans id, sans réponse. État 1.1 du moteur : reçue, journalisée une fois, IGNORÉE — rien n'est persisté, le contenu n'est pas lu. Forme non normée en 1.1.
+
+| Field | Type | Required | Values | Description (contract, French) |
+|---|---|---|---|---|
+| `loads` | [object] | no | | Non normé en 1.1 : intention §8 (learned + confidence par charge). |
+
+
+
+## Machine de repli {#repli}
+
+Il n'y a pas de battement de cœur séparé : une réponse valide à `GetPlan` signifie que
+l'optimiseur est vivant. Les échecs se comptent par requête ; après plusieurs échecs
+consécutifs, l'optimiseur est déclaré absent et le moteur pilote par ses règles jusqu'au
+prochain plan valide. L'application voit cet état en continu (`optimizerAlive`,
+`activeStrategy`).
+
+
+
+Machine de repli §7 (OptimizerStatus) : heartbeat implicite — une réponse valide à GetPlan = optimiseur vivant. Les échecs se comptent PAR REQUÊTE.
+
+Consecutive failed requests before `optimizerAlive = false`: **2**.
+
+| Situation (contract) | `optimizerAlive` | `activeStrategy` |
+|---|---|---|
+| réponse plan valide | `true` | `optimizer` |
+| abstain, plan vide, aucun créneau courant | `inchangé` | `rules (ce cycle)` |
+| plan expiré (validUntil dépassé) | `inchangé` | `rules jusqu'au prochain plan valide` |
+| timeout / erreur / plan invalide sur deux requêtes consécutives | `false` | `rules` |
+| socket absent / fermé / handshake refusé | `false` | `rules` |
+| aucun endpoint configuré | `false` | `rules` |
+| mode dégradé L2 (compteur muet > 90 s) | `inchangé` | `none` |
+
+
+
+## Règles de validation {#regles}
+
+Le JSON Schema du contrat dit la forme. Le moteur juge ensuite chaque réponse sur le
+contexte de **sa** requête, règle par règle, dans l'ordre ci-dessous ; la première qui
+échoue donne son code (`fallbackCode`, visible dans l'application).
+
+
+
+Ce que le moteur refuse EN PLUS de la forme, dans l'ordre de ses contrôles (OptimizerPlan::valider, OptimizerClient::handleHandshakeReply). name = code du verdict. issue : abstention (repli ce cycle, pas un échec), expired, invalid (plan refusé en entier, compté comme échec), connection-refused (handshake). needs : ce qu'il faut connaître en plus de la réponse (la requête, le handshake).
+
+| # | Rule | Method | Outcome | `fallbackCode` | `lastErrorCode` | Counts as failure | Also needs | Schema catches it too | What is rejected (contract) |
+|---|---|---|---|---|---|---|---|---|---|
+| 1 | `abstain` | `GetPlan` | `abstention` | `abstain` | — | no | — | no | result.abstain vaut true. |
+| 2 | `malformed-plan` | `GetPlan` | `invalid` | `malformed-plan` | `malformed-plan` | yes | — | yes | slots absent ou non liste ; un créneau qui n'est pas un objet ; actions non liste. |
+| 3 | `empty-plan` | `GetPlan` | `abstention` | `empty-plan` | — | no | — | no | slots est une liste vide. |
+| 4 | `bad-time` | `GetPlan` | `invalid` | `bad-time` | `bad-time` | yes | — | yes | from/to illisibles ou sans décalage, ou from ≥ to ; validUntil illisible ou sans décalage. |
+| 5 | `slots-unordered` | `GetPlan` | `invalid` | `slots-unordered` | `slots-unordered` | yes | — | no | un créneau commence avant la fin du précédent. |
+| 6 | `malformed-action` | `GetPlan` | `invalid` | `malformed-action` | `malformed-action` | yes | — | yes | action qui n'est pas un objet, ou sans loadId (chaîne non vide). |
+| 7 | `unknown-load` | `GetPlan` | `invalid` | `unknown-load` | `unknown-load` | yes | `request` | no | loadId absent des loads[] de la requête. |
+| 8 | `unknown-kind` | `GetPlan` | `invalid` | `unknown-kind` | `unknown-kind` | yes | — | yes | kind hors ActionKind. |
+| 9 | `kind-mismatch` | `GetPlan` | `invalid` | `kind-mismatch` | `kind-mismatch` | yes | `request` | no | kind différent de celui du mécanisme de la charge (setpoint : evcharger, relay-router, etmvariableload ; state : sg-ready ; constraint : aucun). |
+| 10 | `kind-not-announced` | `GetPlan` | `invalid` | `kind-not-announced` | `kind-not-announced` | yes | `handshake` | no | capabilities.kinds annoncé au handshake et kind absent de la liste. |
+| 11 | `missing-reason` | `GetPlan` | `invalid` | `missing-reason` | `missing-reason` | yes | — | yes | reason absent, non textuel, ou vide une fois les caractères de contrôle retirés. |
+| 12 | `bad-value` | `GetPlan` | `invalid` | `bad-value` | `bad-value` | yes | `request` | yes | state absent ou hors 1-4 ; borne : currentA/powerW absents ou négatifs, phases hors {1, 3} ; charge en watts : powerW absent ou négatif. |
+| 13 | `duplicate-load` | `GetPlan` | `invalid` | `duplicate-load` | `duplicate-load` | yes | — | no | deux actions pour la même charge dans un créneau. |
+| 14 | `expired` | `GetPlan` | `expired` | `expired` | — | no | `request` | no | validUntil ≤ timestamp de la requête. |
+| 15 | `no-current-slot` | `GetPlan` | `abstention` | `no-current-slot` | — | no | `request` | no | aucun créneau ne couvre le timestamp de la requête. |
+| 16 | `incomplete-slot` | `GetPlan` | `invalid` | `incomplete-slot` | `incomplete-slot` | yes | `request` | no | une charge available de la requête n'a pas d'action dans le créneau courant (décision Q1). |
+| 17 | `handshake-refused` | `Handshake` | `connection-refused` | `no-plan` | `handshake-refused` | no | — | no | réponse error (token absent ou différent : -32001 recommandé) → connexion fermée, nouvelle tentative au backoff. |
+| 18 | `invalid-handshake` | `Handshake` | `connection-refused` | `no-plan` | `invalid-handshake` | no | — | yes | result non objet, protocolVersion absent ou vide, capabilities présent et non objet. |
+| 19 | `major-mismatch` | `Handshake` | `connection-refused` | `no-plan` | `major-mismatch` | no | — | no | MAJEURE de protocolVersion (ce qui précède le premier point) différente de celle du moteur. |
+
+
+
+### Tolérances {#tolerances}
+
+Ce que le moteur accepte au-delà du contrat. Un optimiseur conforme ne s'en sert pas.
+
+
+
+Ce que le moteur ACCEPTE au-delà du schéma — un optimiseur conforme ne s'en sert pas.
+
+- reasonCode absent ou hors ReasonCode : action acceptée, motif transmis sous OPTIMIZER_DECISION sans paramètres.
+- reasonParams : clé non admise pour le code (x-etm-reason-params) ou valeur refusée : clé supprimée, action acceptée.
+- planId, strategy : lus comme chaînes quel que soit leur type ; computedAt illisible : ignoré.
+- diagnostics, expected : non objets ignorés ; valeurs inconnues filtrées avant l'app.
+- abstain : toute valeur « vraie » au sens de QVariant::toBool (true, chaîne non vide autre que « false »/« 0 », nombre non nul).
+- champs inconnus (params et result) : ignorés (§9) ; force et champs réservés (annexe F) : ignorés et signalés une fois.
+- state : 3.0 accepté comme 3 (entier au sens JSON Schema).
+
+
+
+## Listes fermées {#listes-fermees}
+
+Valeurs permises, régénérées depuis le code du moteur.
+
+
+
+#### `ProtocolVersion` {#schema-protocolversion}
+
+Version du protocole parlée par le moteur (GEN : OptimizerProtocol::ProtocolVersion).
+
+`1.1`
+
+Regenerated from the engine code: `OptimizerProtocol::ProtocolVersion`.
+
+#### `Trigger` {#schema-trigger}
+
+Cause de la requête (§5.1). GEN : SocketScheduler::knownTriggers() — du moins au plus significatif.
+
+`cycle` · `forecast` · `config` · `load-change` · `intent`
+
+Regenerated from the engine code: `SocketScheduler::knownTriggers()`.
+
+#### `Domain` {#schema-domain}
+
+Intention déclarée par l'installateur, jamais déduite du mécanisme, non lue par l'arbitre (§5.3). GEN : LoadConfig::knownDomains().
+
+`ecs` · `ev` · `heating` · `hvac` · `battery`
+
+Regenerated from the engine code: `LoadConfig::knownDomains()`.
+
+#### `ActionKind` {#schema-actionkind}
+
+Type d'action (§6). GEN : OptimizerPlan::kinds(). `constraint` est RÉSERVÉ (annexe F) : reconnu par le validateur, toujours refusé (règle kind-mismatch) faute de mécanisme porteur.
+
+`setpoint` · `state` · `constraint`
+
+Regenerated from the engine code: `OptimizerPlan::kinds()`.
+
+#### `ReasonCode` {#schema-reasoncode}
+
+Codes de motif qu'un optimiseur peut émettre (§6, liste blanche). GEN : DecisionCode::optimizerEmittableCodes(). Clés de paramètres admises par code : x-etm-reason-params.
+
+`SURPLUS_SETPOINT` · `SURPLUS_INSUFFICIENT` · `BELOW_MIN_POWER` · `EV_DEADLINE` · `SG_FORCED` · `SG_RECOMMENDED` · `SG_NORMAL` · `ECO_FLOOR_GRID` · `PRICE_SLOT` · `OPTIMIZER_DECISION`
+
+Regenerated from the engine code: `DecisionCode::optimizerEmittableCodes()`.
+
+#### `PriceInclude` {#schema-priceinclude}
+
+Composante incluse dans le prix (§5.2). GEN : TariffConfig::includes().
+
+`energy` · `grid` · `taxes`
+
+Regenerated from the engine code: `TariffConfig::includes()`.
+
+#### `FallbackCode` {#schema-fallbackcode}
+
+Côté app (NymeaEnergy.GetPlan.fallbackCode, INTERFACE.md) : pourquoi l'optimiseur n'a pas commandé le cycle. Hors fil. GEN : SocketScheduler::knownFallbackCodes().
+
+`no-plan` · `abstain` · `empty-plan` · `no-current-slot` · `expired` · `malformed-plan` · `bad-time` · `slots-unordered` · `malformed-action` · `unknown-load` · `unknown-kind` · `kind-mismatch` · `kind-not-announced` · `missing-reason` · `bad-value` · `duplicate-load` · `incomplete-slot` · `no-shadow-slot`
+
+Regenerated from the engine code: `SocketScheduler::knownFallbackCodes()`.
+
+#### `LastErrorCode` {#schema-lasterrorcode}
+
+Côté app (NymeaEnergy.GetOptimizerStatus.lastErrorCode, INTERFACE.md) : dernier code d'erreur vu (connexion, requête, plan). Hors fil. GEN : SocketScheduler::knownLastErrorCodes().
+
+`invalid-endpoint` · `token-too-short` · `connect-failed` · `closed-during-handshake` · `connection-lost` · `handshake-timeout` · `handshake-refused` · `invalid-handshake` · `major-mismatch` · `line-too-long` · `invalid-json` · `missing-id` · `unknown-id` · `timeout` · `rpc-error` · `invalid-response` · `disconnected` · `malformed-plan` · `bad-time` · `slots-unordered` · `malformed-action` · `unknown-load` · `unknown-kind` · `kind-mismatch` · `kind-not-announced` · `missing-reason` · `bad-value` · `duplicate-load` · `incomplete-slot`
+
+Regenerated from the engine code: `SocketScheduler::knownLastErrorCodes()`.
+
+#### `Correction` {#schema-correction}
+
+`temperature` · `ac-limit`
+
+
diff --git a/docs/reference.de.md b/docs/reference.de.md
index 4c86eb0..66140d0 100644
--- a/docs/reference.de.md
+++ b/docs/reference.de.md
@@ -1,3 +1,11 @@
-# Referenz
+# API
-> Stub — technische Referenz: Konfigurationsparameter, Modi, Glossar.
+Die Box lässt sich über zwei lokale Schnittstellen in Ihrem Netz anbinden:
+
+- die [JSON-RPC-API](integrations/jsonrpc-api.md) — Verbindung, Authentifizierung,
+ Konventionen — und ihre [vollständige Referenz](api/index.md), aus der Box erzeugt;
+- [MQTT](integrations/mqtt.md).
+
+!!! note "Und das Optimierer-Protokoll?"
+ Der Kanal zwischen Engine und Optimierer ist **keine** Integrations-API: Er hat ein
+ eigenes Protokoll, beschrieben im Abschnitt [Optimierer](optimiseur/protocole.md).
diff --git a/docs/reference.en.md b/docs/reference.en.md
index add5930..03f06c1 100644
--- a/docs/reference.en.md
+++ b/docs/reference.en.md
@@ -1,3 +1,11 @@
-# Reference
+# API
-> Stub — technical reference: configuration parameters, modes, glossary.
+The box integrates with your tools through two local interfaces, on your network:
+
+- the [JSON-RPC API](integrations/jsonrpc-api.md) — connection, authentication,
+ conventions — and its [full reference](api/index.md), generated from the box;
+- [MQTT](integrations/mqtt.md).
+
+!!! note "What about the optimiser protocol?"
+ The channel between the engine and an optimiser is **not** an integration API: it
+ has its own protocol, described in the [Optimizer](optimiseur/protocole.md) section.
diff --git a/docs/reference.md b/docs/reference.md
index 7f8f3e2..5200edd 100644
--- a/docs/reference.md
+++ b/docs/reference.md
@@ -1,3 +1,11 @@
-# Référence
+# API
-> Stub — référence technique : paramètres de configuration, modes, glossaire.
+La box s'intègre à vos outils par deux interfaces locales, sur votre réseau :
+
+- l'[API JSON-RPC](integrations/jsonrpc-api.md) — connexion, authentification,
+ conventions — et sa [référence complète](api/index.md), générée depuis la box ;
+- [MQTT](integrations/mqtt.md).
+
+!!! note "Et le protocole optimiseur ?"
+ Le canal entre le moteur et un optimiseur n'est **pas** une API d'intégration : il a
+ son propre protocole, décrit dans la section [Optimiseur](optimiseur/protocole.md).
diff --git a/mkdocs.yml b/mkdocs.yml
index c0ce722..bdf2348 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -85,8 +85,13 @@ plugins:
Accueil: Startseite
Installation: Installation
Appareils: Geräte
- Fonctionnalités: Funktionen
- Référence: Referenz
+ EMS — moteur à règles: EMS — Regelbasierte Engine
+ Comment le moteur décide: Wie die Engine entscheidet
+ Optimiseur: Optimierer
+ Protocole: Protokoll
+ Contrat: Vertrag
+ Écrire un optimiseur: Einen Optimierer schreiben
+ API: API
Aide: Hilfe
Compatibilité: Kompatibilität
Dépôt APT: APT-Repository
@@ -123,8 +128,13 @@ plugins:
Accueil: Home
Installation: Installation
Appareils: Devices
- Fonctionnalités: Features
- Référence: Reference
+ EMS — moteur à règles: EMS — rule engine
+ Comment le moteur décide: How the engine decides
+ Optimiseur: Optimizer
+ Protocole: Protocol
+ Contrat: Contract
+ Écrire un optimiseur: Writing an optimizer
+ API: API
Aide: Help
Compatibilité: Compatibility
Dépôt APT: APT repository
@@ -164,14 +174,22 @@ nav:
- Configuration: installation/configuration.md
- L'application: installation/application.md
- Appareils: appareils/
- - Fonctionnalités:
+ # Partie technique : trois sections qui suivent l'architecture (2026-10-18).
+ # Les fichiers n'ont pas bougé : seules les étiquettes changent, les URL restent.
+ - EMS — moteur à règles:
- fonctionnalites/index.md
+ - Comment le moteur décide: fonctionnalites/moteur.md
- Surplus solaire: fonctionnalites/surplus-solaire.md
- Délestage: fonctionnalites/delestage.md
- Gestion batterie: fonctionnalites/gestion-batterie.md
- Prévision PV: fonctionnalites/prevision-pv.md
- Tarifs dynamiques: tarifs.md
- - Référence:
+ - Optimiseur:
+ - optimiseur/index.md
+ - Protocole: optimiseur/protocole.md
+ - Contrat: optimiseur/contrat.md
+ - Écrire un optimiseur: optimiseur/ecrire.md
+ - API:
- reference.md
- API JsonRPC: integrations/jsonrpc-api.md
- Référence API: api/