# 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`