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