From 9322bc8a2f237ef1776e1c907fc0080f7366b2e4 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 17 Oct 2026 21:15:51 +0200 Subject: [PATCH] docs(protocole): contrat OpenRPC 1.3 du protocole optimiseur 1.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/etm-powersync-optimizer-protocol.openrpc.json décrit Handshake, GetPlan (SurplusContext complet : en-tête, état, forecast, loads[] par mécanisme) et UpdateLearned, relus champ par champ sur le sérialiseur, le client et le validateur du moteur. Requêtes du moteur : additionalProperties false. Réponses d'optimiseur : ouvertes (§9). Transport, sens, heartbeat, machine §7, règles sémantiques du validateur et tolérances : extensions x-etm-… Les listes fermées marquées x-etm-generated seront régénérées depuis le code. Co-Authored-By: Claude Opus 5.5 --- ...-powersync-optimizer-protocol.openrpc.json | 2178 +++++++++++++++++ 1 file changed, 2178 insertions(+) create mode 100644 docs/etm-powersync-optimizer-protocol.openrpc.json diff --git a/docs/etm-powersync-optimizer-protocol.openrpc.json b/docs/etm-powersync-optimizer-protocol.openrpc.json new file mode 100644 index 0000000..ff2d764 --- /dev/null +++ b/docs/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": { + "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": [] + }, + "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()" + } + ] + } +}