diff --git a/docs/fonctionnalites/moteur.de.md b/docs/fonctionnalites/moteur.de.md
new file mode 100644
index 0000000..a3f238b
--- /dev/null
+++ b/docs/fonctionnalites/moteur.de.md
@@ -0,0 +1,87 @@
+# Wie die Engine entscheidet
+
+
+
+Die Energie-Engine der Box steuert Ihre Verbraucher selbstständig: Wallboxen,
+Warmwasserbereiter, SG-Ready-Wärmepumpe, Relais. Sie ist frei (GPL-3.0) und entscheidet in
+der **Community**-Version. Diese Seite beschreibt ihre Logik für Installateure und
+Integratoren; die folgenden Seiten erläutern die einzelnen Funktionen.
+
+## Zwei Schleifen, zwei Prioritäten
+
+Die Engine trennt Sicherheit und Verteilung. Es sind zwei getrennte Schleifen, mit
+unterschiedlichem Takt und unterschiedlicher Priorität.
+
+- **Die Sicherheitsschleife, in Echtzeit.** Bei jeder Änderung der gemessenen Bilanz liest
+ sie den Strom jeder Phase am Netzanschlusszähler. Überschreitet eine Phase die
+ eingestellte Grenze, regelt sie die Verbraucher sofort ab ([Lastabwurf](delestage.md))
+ und gibt wieder frei, sobald genug Reserve da ist. Sie wartet nicht auf den Zyklus und
+ hat Vorrang vor allem anderen.
+- **Der Verteilungszyklus, etwa einmal pro Minute.** Er verteilt die verfügbare Leistung
+ auf die Verbraucher. Nur hier greift eine Strategie ein: die eingebauten Regeln oder,
+ falls angeschlossen, ein [Optimierer](../optimiseur/index.md).
+
+Antwortet der Netzanschlusszähler nicht mehr, setzt die Engine die Verteilung aus und geht
+in den Sicherheits-Rückfall: ohne Messung keine Entscheidung.
+
+## Die Regeln, in ihrer Reihenfolge
+
+In jedem Zyklus durchläuft die Engine dieselben Schritte, in derselben Reihenfolge.
+
+1. **Messen.** Ausgangspunkt ist der Messwert des Netzanschlusszählers. Die Engine rechnet
+ hinzu, was die von ihr gesteuerten Verbraucher bereits aufnehmen: Ein laufender
+ Verbraucher wird nicht gegen sich selbst gerechnet.
+2. **Berücksichtigen, was festliegt.** Ein gesperrter Verbraucher (Mindestlauf- oder
+ Mindeststillstandszeit, Wartezeit zwischen zwei Stromänderungen) behält seine
+ zugesagte Leistung, und sie wird vom Budget abgezogen. Ein nicht verfügbarer
+ Verbraucher erhält keinen Sollwert, sein Verbrauch wird aber weiter gezählt.
+3. **Bedarfe mit Frist bedienen.** Eine tägliche Pflicht (etwa eine Mindestenergie für den
+ Warmwasserbereiter bis zu einer bestimmten Uhrzeit) oder eine einmalige Anforderung aus
+ der App kann, wenn die Frist naht und der Überschuss nicht gereicht hat, den Rest aus
+ dem Netz beziehen — sofern der Installateur das erlaubt hat.
+4. **Den Überschuss kaskadiert verteilen.** Die Verbraucher werden in der von Ihnen
+ festgelegten **Priorität** bedient (1 = zuerst). Jeder erhält, was vom Budget übrig
+ ist, innerhalb seiner Grenzen, und das Budget wird entsprechend verringert. Es gibt
+ **ein einziges** Budget: Ohne es sähen drei Manager jeweils „2 kW Überschuss“ und
+ zögen zusammen 6.
+5. **Abschalten, was nicht starten kann.** Unterhalb seiner Mindestleistung bleibt ein
+ Verbraucher aus, statt halb zu laufen.
+
+### Die Grenzen jedes Verbrauchers
+
+Jeder Verbraucher hat vom Installateur eingetragene Grenzen, die **jede** Entscheidung
+einhält, gleich welche Strategie:
+
+- eine **Unter-** und eine **Obergrenze** der Leistung (bzw. des Stroms bei einer Wallbox);
+- **Sperren gegen Takten**: Mindestlauf- und Mindeststillstandszeit, Wartezeit zwischen
+ zwei Änderungen;
+- bei einer SG-Ready-Wärmepumpe: der Zustand wird nach der geschätzten Leistung jedes
+ Zustands gewählt, mit einer Mindesthaltedauer.
+
+## Der Grund jeder Entscheidung
+
+Die Engine trifft keine Entscheidung, ohne ihren Grund zu nennen. Jede Entscheidung trägt
+einen nicht leeren **Grund** (`decisionReason`): einen stabilen Code mit Parametern, den
+die App in Ihrer Sprache anzeigt — „Überschuss reicht nicht“, „unter der
+Mindestleistung“, „Ladefrist“… Die Liste der Codes ist mit dem
+[Optimierer-Protokoll](../optimiseur/protocole.md#reponse) veröffentlicht.
+
+## Die Messung, nie das Ergebnis der Steuerung
+
+Der Überschuss wird aus der **Rohmessung** berechnet: Erzeugung und Zähler, so wie sie
+gemessen werden. Nie aus der Nettobilanz nach der Steuerung: Die Engine würde sich selbst
+lesen und schwingen.
+
+## Ohne Internet
+
+All das läuft auf der Box. Ohne Internet steuert die Engine, wendet die Sicherheit an und
+antwortet der App in Ihrem lokalen Netz. Nur die [PV-Prognose](prevision-pv.md) setzt
+aus — und sie dient in der Community-Version nicht zur Entscheidung, ebenso wenig wie der
+[eingegebene Tarif](../tarifs.md): Beide werden angezeigt und gespeichert, aber von den
+Regeln nie verwendet.
+
+## Und ein Optimierer?
+
+Ein Optimierer kann anhand der Prognosen einen Plan über mehrere Stunden vorschlagen. Er
+ersetzt den Verteilungsschritt, **nie** die Sicherheitsschleife oder die Grenzen. Siehe
+Abschnitt [Optimierer](../optimiseur/index.md).
diff --git a/docs/fonctionnalites/moteur.en.md b/docs/fonctionnalites/moteur.en.md
new file mode 100644
index 0000000..3a09fc1
--- /dev/null
+++ b/docs/fonctionnalites/moteur.en.md
@@ -0,0 +1,82 @@
+# How the engine decides
+
+
+
+The box's energy engine drives your loads on its own: EV chargers, water heater,
+SG-Ready heat pump, relays. It is free software (GPL-3.0) and it is what decides in the
+**Community** edition. This page describes its logic for installers and integrators; the
+following pages detail each feature.
+
+## Two loops, two priorities
+
+The engine keeps safety apart from allocation. They are two separate loops, running at
+different rates and with different priorities.
+
+- **The safety loop, in real time.** On every change in the measured balance, it reads the
+ current of each phase at the grid meter. If a phase exceeds the configured limit, it
+ cuts loads back immediately ([load shedding](delestage.md)), then releases them once
+ there is headroom again. It does not wait for the cycle and overrides everything else.
+- **The allocation cycle, about once a minute.** It shares the available power between
+ the loads. This is the only place a strategy acts: the built-in rules, or an
+ [optimiser](../optimiseur/index.md) if one is connected.
+
+If the grid meter stops answering, the engine suspends allocation and falls back to safety
+mode: no measurement, no decision.
+
+## The rules, in order
+
+On every cycle, the engine runs the same steps, in the same order.
+
+1. **Measure.** The starting point is what the grid meter measures. The engine adds back
+ what the loads it drives are already drawing: a running load is not counted against
+ itself.
+2. **Account for what is locked.** A load under a lock (minimum on or off time, delay
+ between two current changes) keeps the power it has committed, and that power is taken
+ out of the budget. An unavailable load receives no setpoint, but what it draws is still
+ counted.
+3. **Serve needs with a deadline.** A daily obligation (for example a minimum energy for
+ the water heater before a given time) or a one-off request made from the app may, as
+ the deadline nears and the surplus has not been enough, take the remainder from the
+ grid — if the installer has allowed it.
+4. **Cascade the surplus.** Loads are served in the **priority** order you set (1 = served
+ first). Each receives what is left of the budget, within its bounds, and the budget is
+ reduced accordingly. There is **one** budget: without it, three managers would each see
+ "2 kW of surplus" and draw 6 between them.
+5. **Stop what cannot start.** Below its minimum power, a load stays off rather than
+ running at half.
+
+### The bounds of each load
+
+Each load carries bounds entered by the installer, which **every** decision respects,
+whatever the strategy:
+
+- a power **floor** and **ceiling** (or current, for an EV charger);
+- **anti-flapping locks**: minimum on and off time, delay between two changes;
+- for an SG-Ready heat pump: the state is chosen from the estimated power of each state,
+ with a minimum hold time.
+
+## The reason for every decision
+
+The engine takes no decision without stating why. Every decision carries a non-empty
+**reason** (`decisionReason`): a stable code and its parameters, which the app displays in
+your language — "surplus insufficient", "below minimum power", "charging deadline"… The
+list of codes is published with the [optimiser protocol](../optimiseur/protocole.md#reponse).
+
+## The measurement, never the result of control
+
+Surplus is computed from the **raw measurement**: production and meter, as measured. Never
+from the net balance after control: the engine would be reading itself, and oscillate.
+
+## Without internet
+
+All of the above runs on the box. Without internet, the engine keeps driving the loads,
+enforces safety and answers the app on your local network. Only the
+[PV forecast](prevision-pv.md) stops — and it is not used for decisions in the Community
+edition, any more than the [entered tariff](../tarifs.md): both are displayed and
+recorded, never used by the rules.
+
+## What about an optimiser?
+
+An optimiser can propose a plan over several hours, based on the forecasts. It replaces
+the allocation step, **never** the safety loop or the bounds. See the
+[Optimizer](../optimiseur/index.md) section.
diff --git a/docs/optimiseur/contrat.de.md b/docs/optimiseur/contrat.de.md
new file mode 100644
index 0000000..cbaa51b
--- /dev/null
+++ b/docs/optimiseur/contrat.de.md
@@ -0,0 +1,55 @@
+# Vertrag
+
+Der **maschinenlesbare Vertrag** beschreibt die Form jeder Nachricht des
+[Protokolls](protocole.md): ein [OpenRPC](https://spec.open-rpc.org/)-Dokument, dessen
+Nachrichten in JSON Schema beschrieben sind, ergänzt um `x-etm-…`-Erweiterungen für das,
+was OpenRPC nicht ausdrücken kann — Transport, Rückfall-Automat, benannte Prüfregeln,
+Toleranzen, geschlossene Listen.
+
+Die Engine bringt ihn mit, und ihre Tests wie die Konformitäts-Testsuite prüfen ihn auf
+beiden Seiten. Die Tabellen der Seite [Protokoll](protocole.md) werden daraus erzeugt.
+
+## Den Vertrag herunterladen {#telecharger}
+
+[:material-download: `etm-powersync-optimizer-protocol.openrpc.json` herunterladen](etm-powersync-optimizer-protocol.openrpc.json){ .md-button }
+
+Die Website liefert die Datei **unverändert**, Byte für Byte, aus dem Standard-Branch des
+Engine-Repositorys. Ihr Steckbrief:
+
+
+
+| | 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` |
+
+
+
+## Der Vertrag Ihrer Box: `NymeaEnergy.GetOptimizerProtocol` {#getoptimizerprotocol}
+
+Der hier veröffentlichte Vertrag folgt der Entwicklung der Engine; Ihre Box kann eine
+ältere Version tragen. Um **den** Vertrag der installierten Engine zu erfahren, fragen Sie
+die Box: Die JSON-RPC-Methode `NymeaEnergy.GetOptimizerProtocol` liefert den, den sie
+mitbringt. Dieser ist für einen an diese Box angebundenen Optimierer maßgeblich.
+
+Die Methode ist in
+[`INTERFACE.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/INTERFACE.md)
+im Engine-Repository beschrieben, zusammen mit den übrigen `NymeaEnergy.*`-Methoden des
+Optimierers (Verbindungspunkt, gemeinsames Geheimnis, Zustand). Die Verbindung zur API
+beschreibt die Seite [JSON-RPC-API](../integrations/jsonrpc-api.md).
+
+## Nachrichten prüfen {#valider}
+
+Das Engine-Repository enthält einen Validator, `tools/etm-powersync-schema-check` (Python,
+Standardbibliothek): Er prüft eine Nachricht, eine vollständig aufgezeichnete Sitzung oder
+Aufzeichnungen gegen den Vertrag und wendet auf Wunsch die benannten Prüfregeln an.
diff --git a/docs/optimiseur/contrat.en.md b/docs/optimiseur/contrat.en.md
new file mode 100644
index 0000000..39836e2
--- /dev/null
+++ b/docs/optimiseur/contrat.en.md
@@ -0,0 +1,54 @@
+# Contract
+
+The **machine-readable contract** describes the shape of every message of the
+[protocol](protocole.md): an [OpenRPC](https://spec.open-rpc.org/) document whose messages
+are written in JSON Schema, with `x-etm-…` extensions for what OpenRPC cannot express —
+transport, fallback state machine, named validation rules, tolerances, closed lists.
+
+The engine embeds it, and both its own tests and the conformance suite check it on both
+sides. The tables on the [Protocol](protocole.md) page are generated from it.
+
+## Download the contract {#telecharger}
+
+[:material-download: Download `etm-powersync-optimizer-protocol.openrpc.json`](etm-powersync-optimizer-protocol.openrpc.json){ .md-button }
+
+The site serves the file **as is**, byte for byte, from the default branch of the engine
+repository. Its record:
+
+
+
+| | 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` |
+
+
+
+## Your box's contract: `NymeaEnergy.GetOptimizerProtocol` {#getoptimizerprotocol}
+
+The contract published here follows the engine's development; your box may carry an
+earlier version. To get **the** contract of the installed engine, ask the box: the
+JSON-RPC method `NymeaEnergy.GetOptimizerProtocol` returns the one it embeds. That is the
+one that is authoritative for an optimiser connected to that box.
+
+The method is described in
+[`INTERFACE.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/INTERFACE.md),
+in the engine repository, along with the other optimiser `NymeaEnergy.*` methods
+(endpoint, shared secret, status). Connecting to the API is described on the
+[JSON-RPC API](../integrations/jsonrpc-api.md) page.
+
+## Validating messages {#valider}
+
+The engine repository provides a validator, `tools/etm-powersync-schema-check` (Python,
+standard library): it checks a single message, a complete recorded session or traces
+against the contract, and applies the named validation rules on request.
diff --git a/docs/optimiseur/ecrire.de.md b/docs/optimiseur/ecrire.de.md
new file mode 100644
index 0000000..b928dbb
--- /dev/null
+++ b/docs/optimiseur/ecrire.de.md
@@ -0,0 +1,102 @@
+# Einen Optimierer schreiben
+
+Ein Optimierer ist ein **Server**, der das [Protokoll](protocole.md) spricht: Er antwortet
+auf den Handshake und dann auf jedes `GetPlan` mit einem Plan aus einem oder mehreren
+Zeitfenstern oder mit einer Enthaltung. Die Sprache ist frei. Ein minimaler Optimierer
+passt in rund fünfzig Zeilen Python.
+
+Minimale Sitzung, zur Veranschaulichung (die genaue Form steht im [Vertrag](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":"mein-optimierer","planning":false}}
+→ {"jsonrpc":"2.0","id":2,"method":"GetPlan","params":{ …Kontext… }}
+← {"jsonrpc":"2.0","id":2,"result":{"abstain":true,"reason":"premier calcul en cours"}}
+```
+
+Drei Punkte, die man nicht übersehen sollte:
+
+- **eine Aktion je verfügbarem Verbraucher** im aktuellen Zeitfenster, jede mit ihrem
+ **Grund** als Text (auf Französisch): sonst wird der ganze Plan abgelehnt;
+- **schnell antworten**: den zuletzt zwischengespeicherten Plan liefern oder sich
+ enthalten, und im Hintergrund rechnen;
+- **nichts anderswo holen**: Alles, was der Optimierer braucht, Prognosen inbegriffen,
+ steht im Kontext.
+
+## Der Referenz-Optimierer {#reference}
+
+Das Engine-Repository enthält einen freien Referenz-Optimierer (GPL-3.0) in Python, nur
+mit der Standardbibliothek:
+[`tools/etm-powersync-ref-optimizer/`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/tree/main/tools/etm-powersync-ref-optimizer).
+Er antwortet mit einem Plan aus einem Zeitfenster, der die Kaskade der Engine-Regeln
+nachbildet, und liest dabei nur den Kontext. Beim Handshake meldet er sich als
+`powersync-optimizer`.
+
+Er ist Ausgangspunkt für einen eigenen Optimierer und Vergleichsmaßstab: Er lauscht
+standardmäßig am lokalen Socket oder an `tcp://` mit einem gemeinsamen Geheimnis, das aus
+einer Datei gelesen wird. Er kann auch Fehler einspeisen (Enthaltung, Langsamkeit,
+ungültiger Plan, andere Hauptversion…), um den Rückfall der Engine zu testen.
+Anleitung: [seine `README.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/tools/etm-powersync-ref-optimizer/README.md).
+
+## Die Engine an den eigenen Optimierer anbinden {#brancher}
+
+Drei JSON-RPC-Methoden der Box, dem Administrator vorbehalten, beschrieben in
+[`INTERFACE.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/INTERFACE.md):
+
+- `NymeaEnergy.SetOptimizerEndpoint` — wohin verbinden (`unix:///…` oder `tcp://host:port`);
+- `NymeaEnergy.SetOptimizerToken` — das gemeinsame Geheimnis, dasselbe wie beim Optimierer;
+- `NymeaEnergy.GetOptimizerStatus` — Zustand der Verbindung: Optimierer lebt, aktive
+ Strategie, gemeldeter Name, letzter Fehlercode.
+
+Über `tcp://` ohne Verschlüsselung läuft das Geheimnis im Klartext durch das lokale Netz.
+
+## Die Konformitäts-Testsuite {#conformite}
+
+Die Suite `etm-powersync-optimizer-conformance` startet **Ihren** Optimierer und schließt
+die **echte** Engine daran an, mit simulierten Geräten. Sie prüft, eine Funktion je Punkt:
+
+1. der Handshake wird angenommen;
+2. jedes `GetPlan` wird innerhalb der Frist der Engine beantwortet;
+3. die gesamte Sitzung, in beide Richtungen, entspricht dem von der Engine mitgebrachten
+ Vertrag, Prüfregeln inbegriffen;
+4. jeder Plan wird angewendet: Der Optimierer steuert;
+5. die Sicherheitsschleife bleibt obenauf: Ein Sollwert außerhalb der Grenzen wird
+ gekappt, eine Phasenüberlast wird abgeworfen.
+
+Die **Qualität** der Pläne bewertet sie nicht.
+
+### Gegen den eigenen Optimierer ausführen
+
+Die Engine mit ihren Tests bauen, dann den Befehl angeben, der Ihren Server startet;
+`{endpoint}` wird darin durch die Adresse ersetzt, an der gelauscht werden soll:
+
+```sh
+export ETM_POWERSYNC_OPTIMIZER_CMD='exec /pfad/zu/mein-optimierer --listen {endpoint}'
+export ETM_POWERSYNC_CONFORMANCE_EXPECT=plan # oder abstain
+```
+
+Jede Funktion läuft in einem eigenen Prozess. Der vollständige Befehl (Netzisolierung
+empfohlen), die übrigen Variablen (gemeinsames Geheimnis, Startfrist…) und das Lesen der
+Fehlschläge stehen in der
+[`README.md` der Suite](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/tests/auto/conformance/README.md).
+
+### Erwarteter Modus: `plan` oder `abstain` {#mode-attendu}
+
+`ETM_POWERSYNC_CONFORMANCE_EXPECT` sagt der Suite, was Ihr Optimierer tut:
+
+- **`plan`** (Standard): Er steuert. Jeder Zyklus muss seinen Plan anwenden.
+- **`abstain`**: Er enthält sich in jedem Zyklus — das ist erlaubt und konform. Die Suite
+ prüft dann, dass die Engine in jedem Zyklus nach ihren Regeln steuert, ohne Fehler zu
+ zählen, und dass die Sicherheit weiter greift; die Prüfung der Kappung entfällt, da es
+ keinen Sollwert zu kappen gibt.
+
+Jeder andere Wert lässt die Suite scheitern. **Der Modus wird nicht aus dem Handshake
+abgeleitet**: `planning` und `capabilities` beschreiben die Form der Pläne, nicht, ob
+welche erzeugt werden. Der Modus wird angegeben, nicht erraten.
+
+## Auf denselben Eingaben vergleichen {#traces}
+
+Die Engine kann auf der Box jeden Zyklus aufzeichnen (standardmäßig aus,
+`NymeaEnergy.SetOptimizerTrace`). Das Werkzeug `tools/etm-powersync-trace-replay` spielt
+diese Aufzeichnungen einem Kandidaten-Optimierer vor und fasst die Abweichungen zum
+aufgezeichneten Plan zusammen. Die Aufzeichnungen bleiben vor Ort.
diff --git a/docs/optimiseur/ecrire.en.md b/docs/optimiseur/ecrire.en.md
new file mode 100644
index 0000000..dcdda49
--- /dev/null
+++ b/docs/optimiseur/ecrire.en.md
@@ -0,0 +1,99 @@
+# Writing an optimizer
+
+An optimiser is a **server** that speaks the [protocol](protocole.md): it answers the
+handshake, then every `GetPlan` with a plan of one or more slots, or with an abstention.
+Any language will do. A minimal optimiser fits in about fifty lines of Python.
+
+Minimal session, for illustration (the exact shape is in the [contract](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":"my-optimiser","planning":false}}
+→ {"jsonrpc":"2.0","id":2,"method":"GetPlan","params":{ …context… }}
+← {"jsonrpc":"2.0","id":2,"result":{"abstain":true,"reason":"premier calcul en cours"}}
+```
+
+Three things not to miss:
+
+- **one action per available load** in the current slot, each with its **reason** as
+ text: otherwise the whole plan is rejected (the reason text is in French);
+- **answer quickly**: serve the last cached plan, or abstain, and compute in the
+ background;
+- **fetch nothing elsewhere**: everything the optimiser needs, forecasts included, is in
+ the context.
+
+## The reference optimiser {#reference}
+
+The engine repository contains a free reference optimiser (GPL-3.0), in Python, standard
+library only:
+[`tools/etm-powersync-ref-optimizer/`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/tree/main/tools/etm-powersync-ref-optimizer).
+It answers a one-slot plan that reproduces the cascade of the engine's rules, reading only
+the context. At handshake it announces itself as `powersync-optimizer`.
+
+It is a starting point for writing your own, and a point of comparison: it listens on the
+local socket by default, or on `tcp://` with a shared secret read from a file. It can also
+inject faults (abstention, slowness, invalid plan, different major version…) to test the
+engine's fallback. Instructions:
+[its `README.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/tools/etm-powersync-ref-optimizer/README.md).
+
+## Pointing the engine at your optimiser {#brancher}
+
+Three JSON-RPC methods of the box, reserved to the administrator, described in
+[`INTERFACE.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/INTERFACE.md):
+
+- `NymeaEnergy.SetOptimizerEndpoint` — where to connect (`unix:///…` or `tcp://host:port`);
+- `NymeaEnergy.SetOptimizerToken` — the shared secret, the same as the optimiser's;
+- `NymeaEnergy.GetOptimizerStatus` — connection status: optimiser alive, active strategy,
+ announced name, last error code.
+
+Over `tcp://` without encryption, the secret travels in clear text on the local network.
+
+## The conformance suite {#conformite}
+
+The `etm-powersync-optimizer-conformance` suite starts **your** optimiser and connects the
+**real** engine to it, on simulated devices. It checks, one function per point:
+
+1. the handshake is accepted;
+2. every `GetPlan` is answered within the engine's timeout;
+3. the whole session, both ways, conforms to the contract embedded in the engine,
+ validation rules included;
+4. every plan is applied: the optimiser is in control;
+5. the safety loop stays on top: an out-of-bounds setpoint is clamped, a phase overload is
+ shed.
+
+It does not judge the **quality** of the plans.
+
+### Running it against your optimiser
+
+Build the engine with its tests, then give the command that starts your server;
+`{endpoint}` in it is replaced by the address to listen on:
+
+```sh
+export ETM_POWERSYNC_OPTIMIZER_CMD='exec /path/to/my-optimiser --listen {endpoint}'
+export ETM_POWERSYNC_CONFORMANCE_EXPECT=plan # or abstain
+```
+
+Each function runs in its own process. The full command (network isolation recommended),
+the other variables (shared secret, start-up delay…) and how to read failures are in the
+[suite's `README.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/tests/auto/conformance/README.md).
+
+### Expected mode: `plan` or `abstain` {#mode-attendu}
+
+`ETM_POWERSYNC_CONFORMANCE_EXPECT` tells the suite what your optimiser does:
+
+- **`plan`** (default): it is in control. Every cycle must apply its plan.
+- **`abstain`**: it abstains on every cycle — that is allowed, and conformant. The suite
+ then checks that the engine drives by its rules on every cycle, without counting a
+ failure, and that safety still holds; the clamping check is skipped, as there is no
+ setpoint to clamp.
+
+Any other value makes the suite fail. **The mode is not inferred from the handshake**:
+`planning` and `capabilities` describe the shape of plans, not whether any are produced.
+The mode is given, not guessed.
+
+## Comparing on the same inputs {#traces}
+
+The engine can record, on the box, a trace of every cycle (off by default,
+`NymeaEnergy.SetOptimizerTrace`). The `tools/etm-powersync-trace-replay` tool replays these
+traces against a candidate optimiser and summarises the differences from the recorded plan.
+Traces stay on site.
diff --git a/docs/optimiseur/ecrire.md b/docs/optimiseur/ecrire.md
index 64ffa9a..90639d8 100644
--- a/docs/optimiseur/ecrire.md
+++ b/docs/optimiseur/ecrire.md
@@ -17,7 +17,7 @@ Session minimale, à titre d'illustration (la forme exacte est dans le [contrat]
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é ;
+ **motif** en texte, en français : 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
diff --git a/docs/optimiseur/index.de.md b/docs/optimiseur/index.de.md
new file mode 100644
index 0000000..8fbe1fb
--- /dev/null
+++ b/docs/optimiseur/index.de.md
@@ -0,0 +1,69 @@
+# Optimierer
+
+IN ARBEIT
+
+Das Protokoll zwischen Engine und Optimierer ist veröffentlicht (Version 1.1). Die
+Anbindung eines Optimierers wird auf unserer Testanlage erprobt; die Box braucht keinen,
+um zu funktionieren.
+
+## Wozu ein Optimierer dient
+
+Die [Regeln der Engine](../fonctionnalites/moteur.md) entscheiden über die Gegenwart: den
+Überschuss dieser Minute, verteilt nach Ihren Prioritäten. Ein Optimierer schaut weiter
+voraus. Aus den Prognosen, die ihm die Engine übergibt — PV-Erzeugung, Grundverbrauch,
+eingegebener Tarif —, schlägt er einen **Plan in Zeitfenstern** über mehrere Stunden vor:
+den Warmwasserbereiter in die erwartete Erzeugungsspitze legen, Reserve für das Laden am
+Abend lassen usw.
+
+## Der Optimierer berät, die Engine entscheidet
+
+- Die Engine führt nur das **aktuelle Zeitfenster** des Plans aus. Der Rest wird als
+ Prognose angezeigt und im nächsten Zyklus neu angefragt.
+- Jeder Sollwert wird durch die Grenzen des Verbrauchers (Unter-, Obergrenze, Sperren
+ gegen Takten) und durch die **Sicherheitsschleife** in Echtzeit **gedeckelt**; diese
+ bleibt bei der Engine und hat Vorrang vor allem. Ein Optimierer kann keine Sperre
+ umgehen.
+- Jede Aktion muss ihren **Grund** tragen. Eine Aktion ohne Grund wird verworfen.
+- Ein fehlerhafter Plan, oder einer, der einen unbekannten Verbraucher anspricht, wird
+ vollständig abgelehnt.
+
+Ein fehlerhafter Optimierer kann also schlimmstenfalls eine schlechte Verteilung
+vorschlagen — begrenzt durch die Engine.
+
+## Die Box funktioniert ohne Optimierer
+
+Optimierer fehlt, schweigt, enthält sich, Plan abgelaufen oder abgelehnt: Die Engine kehrt
+für den Zyklus zu ihren **Regeln** (`rules`) zurück, ohne Unterbrechung der Steuerung. Die
+App zeigt jederzeit, welche Strategie steuert. Die Community-Version funktioniert
+vollständig so.
+
+## Seine Daten kommen nur von der Engine
+
+- Die Engine verbindet sich mit dem Optimierer, nie umgekehrt: Die Box öffnet keinen Port.
+- Der Optimierer läuft auf der Box (lokaler Socket) oder auf einem Rechner in Ihrem lokalen
+ Netz (TCP). Im lokalen Netz wird ein gemeinsames Geheimnis (Token) empfohlen.
+- Er erhält **nur** den Kontext, den die Engine sendet, Prognosen inbegriffen: Er ruft
+ weder einen Wetterdienst noch einen Tarifanbieter auf und öffnet keine Verbindung nach
+ außen.
+- Was er lernt, bleibt vor Ort.
+
+## Wer einen Optimierer schreiben kann
+
+Jeder. Das Protokoll ist offen und durch einen [maschinenlesbaren Vertrag](contrat.md)
+unter GPL-3.0 beschrieben. Ein freier **Referenz-Optimierer** und eine
+**Konformitäts-Testsuite** erlauben es, einen eigenen zu schreiben und gegen die echte
+Engine zu prüfen — siehe [Einen Optimierer schreiben](ecrire.md).
+
+## Der ETM-Optimierer
+
+ETM-Schurig bietet einen eigenen, proprietären Optimierer an, enthalten in der Pro-Edition.
+Er wird über dasselbe Protokoll angebunden, mit denselben Grenzen: Er berät, die Engine
+entscheidet. Beim Handshake meldet er sich als `etm-optimizer`; der Referenz-Optimierer
+meldet sich als `powersync-optimizer`.
+
+## In diesem Abschnitt
+
+- [Protokoll](protocole.md) — Transport, Handshake, Kontext, Plan, Rückfall,
+ Prüfregeln.
+- [Vertrag](contrat.md) — die OpenRPC-Datei, die für die Form der Nachrichten maßgeblich ist.
+- [Einen Optimierer schreiben](ecrire.md) — Referenz-Optimierer und Konformitäts-Testsuite.
diff --git a/docs/optimiseur/index.en.md b/docs/optimiseur/index.en.md
new file mode 100644
index 0000000..5986eec
--- /dev/null
+++ b/docs/optimiseur/index.en.md
@@ -0,0 +1,63 @@
+# Optimizer
+
+IN PROGRESS
+
+The protocol between the engine and an optimiser is published (version 1.1). Connecting
+an optimiser is being tested on our test installation; the box does not need one to work.
+
+## What an optimiser is for
+
+The [engine's rules](../fonctionnalites/moteur.md) decide on the present: this minute's
+surplus, shared according to your priorities. An optimiser looks further ahead. From the
+forecasts the engine hands it — PV production, base consumption, entered tariff — it
+proposes a **plan in time slots** over several hours: shift the water heater to the
+expected production peak, keep headroom for evening charging, and so on.
+
+## The optimiser advises, the engine decides
+
+- The engine only executes the **current slot** of the plan. The rest is shown as a
+ forecast, and requested again on the next cycle.
+- Every setpoint is **capped** by the load's bounds (floor, ceiling, anti-flapping locks)
+ and by the real-time **safety loop**, which stays with the engine and overrides
+ everything. An optimiser cannot bypass a lock.
+- Every action must carry its **reason**. An action without one is rejected.
+- A malformed plan, or one that targets an unknown load, is rejected as a whole.
+
+At worst, a faulty optimiser can therefore only propose a poor allocation — bounded by the
+engine.
+
+## The box works without an optimiser
+
+Optimiser missing, silent, abstaining, plan expired or rejected: the engine returns to its
+**rules** (`rules`) for that cycle, with no interruption of control. The app shows at all
+times which strategy is in charge. The Community edition works entirely this way.
+
+## Its data come from the engine only
+
+- The engine connects to the optimiser, never the other way round: the box opens no port.
+- The optimiser runs on the box (local socket) or on a machine in your local network
+ (TCP). On the local network, a shared secret (token) is recommended.
+- It receives **only** the context the engine sends, forecasts included: it calls neither
+ a weather service nor a tariff provider, and opens no connection to the outside.
+- What it learns stays on site.
+
+## Who can write an optimiser
+
+Anyone. The protocol is open and described by a [machine-readable contract](contrat.md)
+published under GPL-3.0. A free **reference optimiser** and a **conformance suite** let you
+write your own and check it against the real engine — see
+[Writing an optimizer](ecrire.md).
+
+## The ETM optimiser
+
+ETM-Schurig offers its own proprietary optimiser, included in the Pro edition. It connects
+through this same protocol, within the same limits: it advises, the engine decides. At
+handshake it announces itself as `etm-optimizer`; the reference optimiser announces itself
+as `powersync-optimizer`.
+
+## In this section
+
+- [Protocol](protocole.md) — transport, handshake, context, plan, fallback, validation
+ rules.
+- [Contract](contrat.md) — the OpenRPC file that is authoritative on the shape of messages.
+- [Writing an optimizer](ecrire.md) — reference optimiser and conformance suite.
diff --git a/docs/optimiseur/protocole.de.md b/docs/optimiseur/protocole.de.md
new file mode 100644
index 0000000..ce641b6
--- /dev/null
+++ b/docs/optimiseur/protocole.de.md
@@ -0,0 +1,860 @@
+# Protokoll
+
+Das Protokoll verbindet die **Energie-Engine** der Box mit einem **Optimierer**, über einen
+eigenen Kanal. Es ist nicht die API der App: Diese läuft über die
+[JSON-RPC-API](../integrations/jsonrpc-api.md).
+
+!!! info "Was maßgeblich ist"
+ - **Die Form** der Nachrichten (Felder, Typen, zulässige Werte, was die Engine
+ ablehnt) legt der [maschinenlesbare Vertrag](contrat.md) fest. Die Tabellen dieser
+ Seite werden bei jeder Veröffentlichung daraus **erzeugt**: Rahmen auf Englisch,
+ Beschreibungen unverändert aus dem Vertrag, auf Französisch. Sie sind in allen drei
+ Sprachen identisch.
+ - **Die Bedeutung** ist ausführlich, auf Französisch, in
+ [`OPTIMIZER_PROTOCOL.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/docs/OPTIMIZER_PROTOCOL.md)
+ im Repository der Engine beschrieben. Diese Seite fasst das Wesentliche zusammen; bei
+ Abweichungen gelten die Dokumente der Engine.
+
+## Transport und Rollen {#transport}
+
+- Der Optimierer ist der **Server**, die Engine der **Client**: Die Engine verbindet sich
+ und stellt die Fragen. Die Box öffnet keinen Port.
+- Zwei Transporte, dieselben Nachrichten: `unix://` (lokaler Socket, Standard) und
+ `tcp://` (Rechner im lokalen Netz).
+- JSON-RPC 2.0, eine Nachricht pro Zeile (NDJSON), UTF-8.
+- Bricht die Verbindung ab, verbindet sich die Engine mit wachsender Wartezeit neu und
+ steuert in der Zwischenzeit nach ihren Regeln.
+
+
+
+| 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}
+
+Bei jeder Verbindung sendet die Engine `Handshake` mit ihrer Protokollversion und, falls
+konfiguriert, dem gemeinsamen Geheimnis. Der Optimierer antwortet mit seiner Version und,
+wenn er möchte, seinem Namen und seinen Fähigkeiten. Andere **Hauptversion**, abgelehntes
+Geheimnis oder unlesbare Antwort: Die Verbindung wird abgelehnt, und die Engine steuert
+nach ihren Regeln. Kündigt der Optimierer die Aktionsarten (`kinds`) an, die er sendet,
+lehnt die Engine danach jede Aktion einer anderen Art ab.
+
+
+
+#### `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).
+
+
+
+## Der Kontext: `GetPlan` {#contexte}
+
+In jedem Zyklus und bei Ereignissen (Fahrzeug angesteckt, neue Prognose, geänderte
+Konfiguration…) sendet die Engine `GetPlan` mit einem **Kontext**: Zustand der Anlage und
+des Netzanschlusszählers, Erzeugung, Speicher, die verteilten Verbraucher mit ihren
+Grenzen, Sperren und Bedarfen, Prognosen auf einem einzigen Zeitraster.
+
+Einige Leseregeln, die Entscheidungen verändern:
+
+- der Zähler wird als **Rohmessung** übertragen, nie als Bilanz nach der Steuerung;
+- ein unbekannter Wert ist `null`, **nie 0**;
+- ein nicht verfügbarer Verbraucher (`available: false`) darf keinen Sollwert erhalten,
+ verbraucht aber: Er wird weiter gezählt;
+- laufende Sperren (`lockMinPowerW`, `lockMaxPowerW`) sagen, was der Verbraucher **jetzt**
+ kann; die Engine wendet sie in jedem Fall an;
+- was ein Verbraucher misst, steht in `measurement.source`, nie in seinem Mechanismus
+ (`adapter`); eine angegebene Leistung ist nie eine Messung.
+
+Die von der Engine gesendete Form ist **geschlossen**: Sie sendet kein Feld außerhalb des
+Vertrags.
+
+
+
+#### `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).
+
+
+
+## Die Antwort: ein Plan oder eine Enthaltung {#reponse}
+
+Der Optimierer antwortet **schnell** — mit seinem zuletzt zwischengespeicherten Plan, wenn
+er lange rechnet — und rechnet im Hintergrund neu. Er antwortet entweder:
+
+- mit einem **Plan in Zeitfenstern**: Jedes Fenster enthält eine Aktion je verfügbarem
+ Verbraucher (Sollwert in Watt oder Ampere, oder SG-Ready-Zustand), jede mit ihrem Grund;
+ nur das aktuelle Fenster wird ausgeführt, und `validUntil` begrenzt die Gültigkeit des
+ Plans;
+- mit einer **Enthaltung** (`abstain: true`): „jetzt kein Plan“. Die Engine steuert für
+ diesen Zyklus nach ihren Regeln, ohne den Optimierer als ausgefallen zu betrachten.
+
+Der Grund einer Aktion hat zwei Formen: einen freien Text auf Französisch,
+**verpflichtend**, für die Aufzeichnungen; und optional einen Code aus dem Katalog
+(`reasonCode`) mit seinen Parametern. An die App wird **nur der Code** übertragen, und die
+App übersetzt ihn. Ein unbekannter Code geht als `OPTIMIZER_DECISION` hinaus.
+
+Die von der Engine empfangene Form ist **offen**: Ein unbekanntes Feld wird ignoriert.
+
+
+
+#### `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
+
+
+
+### Zulässige Grund-Codes {#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}
+
+Benachrichtigung vom Optimierer an die Engine, ohne Antwort, um mitzuteilen, was er über
+die Verbraucher gelernt hat. In Version 1.1 der Engine wird sie empfangen und protokolliert,
+aber noch nicht ausgewertet.
+
+
+
+#### `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). |
+
+
+
+## Rückfall-Automat {#repli}
+
+Es gibt keinen eigenen Herzschlag: Eine gültige Antwort auf `GetPlan` bedeutet, dass der
+Optimierer lebt. Fehler werden je Anfrage gezählt; nach mehreren aufeinanderfolgenden
+Fehlern gilt der Optimierer als abwesend, und die Engine steuert bis zum nächsten gültigen
+Plan nach ihren Regeln. Die App sieht diesen Zustand laufend (`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` |
+
+
+
+## Prüfregeln {#regles}
+
+Das JSON Schema des Vertrags beschreibt die Form. Danach prüft die Engine jede Antwort
+gegen den Kontext **ihrer** Anfrage, Regel für Regel, in der Reihenfolge unten; die erste,
+die scheitert, liefert ihren Code (`fallbackCode`, in der App sichtbar).
+
+
+
+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. |
+
+
+
+### Toleranzen {#tolerances}
+
+Was die Engine über den Vertrag hinaus akzeptiert. Ein konformer Optimierer nutzt das nicht.
+
+
+
+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).
+
+
+
+## Geschlossene Listen {#listes-fermees}
+
+Zulässige Werte, aus dem Code der Engine neu erzeugt.
+
+
+
+#### `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/optimiseur/protocole.en.md b/docs/optimiseur/protocole.en.md
new file mode 100644
index 0000000..6b8ef51
--- /dev/null
+++ b/docs/optimiseur/protocole.en.md
@@ -0,0 +1,856 @@
+# Protocol
+
+The protocol links the box's energy **engine** to an **optimiser**, over a dedicated
+channel. It is not the app's API: the app goes through the
+[JSON-RPC API](../integrations/jsonrpc-api.md).
+
+!!! info "What is authoritative"
+ - **The shape** of messages (fields, types, allowed values, what the engine rejects) is
+ set by the [machine-readable contract](contrat.md). The tables on this page are
+ **generated** from it on every publication: framing in English, descriptions taken
+ as-is from the contract, in French. They are identical in all three languages.
+ - **The meaning** is described in detail, in French, in
+ [`OPTIMIZER_PROTOCOL.md`](https://github.com/etmschurig/etm-powersync-energy-plugin-etm/blob/main/docs/OPTIMIZER_PROTOCOL.md),
+ in the engine repository. This page gives the essentials; where they differ, the
+ engine's documents prevail.
+
+## Transport and roles {#transport}
+
+- The optimiser is the **server**, the engine is the **client**: the engine connects and
+ asks the questions. The box opens no port.
+- Two transports, same messages: `unix://` (local socket, default) and `tcp://` (machine
+ on the local network).
+- JSON-RPC 2.0, one message per line (NDJSON), UTF-8.
+- If the connection drops, the engine reconnects with a growing delay and drives the loads
+ by its rules in the meantime.
+
+
+
+| 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}
+
+On every connection, the engine sends `Handshake` with its protocol version and, if
+configured, the shared secret. The optimiser answers with its version and, if it wishes,
+its name and capabilities. A different **major** version, a refused secret or an
+unreadable answer: the connection is refused, and the engine drives by its rules. If the
+optimiser announces the action kinds (`kinds`) it emits, the engine then rejects any action
+of another kind.
+
+
+
+#### `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).
+
+
+
+## The context: `GetPlan` {#contexte}
+
+On every cycle, and on events (vehicle plugged in, new forecast, configuration
+change…), the engine sends `GetPlan` with a **context**: state of the site and of the grid
+meter, production, battery, the arbitrated loads with their bounds, locks and needs, and
+forecasts on a single time grid.
+
+A few reading rules that change decisions:
+
+- the meter is sent as a **raw measurement**, never as the balance after control;
+- an unknown value is `null`, **never 0**;
+- an unavailable load (`available: false`) must receive no setpoint, but it consumes: it
+ is still counted;
+- current locks (`lockMinPowerW`, `lockMaxPowerW`) say what the load can do **now**; the
+ engine enforces them regardless;
+- what a load measures is read from `measurement.source`, never from its mechanism
+ (`adapter`); a declared power is never a measurement.
+
+The shape sent by the engine is **closed**: it emits no field outside the contract.
+
+
+
+#### `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).
+
+
+
+## The answer: a plan or an abstention {#reponse}
+
+The optimiser answers **quickly** — with its last cached plan if it computes for long —
+and recomputes in the background. It answers either:
+
+- a **plan in time slots**: each slot carries one action per available load (setpoint in
+ watts or amps, or SG-Ready state), each with its reason; only the current slot is
+ executed, and `validUntil` bounds the plan's lifetime;
+- an **abstention** (`abstain: true`): "no plan right now". The engine drives by its rules
+ for that cycle, without treating the optimiser as failed.
+
+An action's reason has two forms: a free text in French, **mandatory**, for the traces;
+and, optionally, a code from the catalogue (`reasonCode`) with its parameters. Only **the
+code** is passed on to the app, which translates it. An unknown code goes out as
+`OPTIMIZER_DECISION`.
+
+The shape received by the engine is **open**: an unknown field is ignored.
+
+
+
+#### `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
+
+
+
+### Emittable reason codes {#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 sent by the optimiser to the engine, with no answer, to pass on what it has
+learned about the loads. In version 1.1 of the engine, it is received and logged, but not
+yet used.
+
+
+
+#### `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). |
+
+
+
+## Fallback state machine {#repli}
+
+There is no separate heartbeat: a valid answer to `GetPlan` means the optimiser is alive.
+Failures are counted per request; after several consecutive failures, the optimiser is
+declared absent and the engine drives by its rules until the next valid plan. The app sees
+this state continuously (`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` |
+
+
+
+## Validation rules {#regles}
+
+The contract's JSON Schema states the shape. The engine then judges each answer against
+the context of **its** request, rule by rule, in the order below; the first that fails
+gives its code (`fallbackCode`, visible in the app).
+
+
+
+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. |
+
+
+
+### Tolerances {#tolerances}
+
+What the engine accepts beyond the contract. A conformant optimiser does not rely on it.
+
+
+
+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).
+
+
+
+## Closed lists {#listes-fermees}
+
+Allowed values, regenerated from the engine's code.
+
+
+
+#### `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`
+
+