From 09043fb7c716ac7c0de817540fbe64551c3c1676 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 23 Oct 2026 11:11:55 +0200 Subject: [PATCH] =?UTF-8?q?Protocole=20optimiseur=201.3=20:=20forme=20norm?= =?UTF-8?q?=C3=A9e=20d'error.data,=20le=20token=20avant=20la=20majeure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contrat (docs/etm-powersync-optimizer-protocol.openrpc.json) : - composants InvalidContextData, ContextProblem, ContextProblemCode : error.data du -32602 de GetPlan = {"problems":[{path, problem}]}, 1 à 50 problèmes, truncated au-delà, chemin depuis params (motif), nature dans une liste fermée (missing, type, range, format, enum, constraint), formes fermées : aucune valeur reçue ne peut s'y glisser ; - x-etm-data-schema et x-etm-data-required-from (1.3) sur cette erreur ; - Handshake : token faux et autre majeure dans le même handshake → -32001, le token d'abord ; - tolérance : error.data jamais lu par le moteur. Prose (docs/OPTIMIZER_PROTOCOL.md) : v1.3, changelog §0 avec la compatibilité 1.2 ↔ 1.3, §2 (un lot reçu par le moteur reste sans réponse, à dessein), §3 (le token d'abord), §7 (forme d'error.data, et ce que le moteur en fait : rien), annexes A, D, E, G. Blocs générés régénérés. Générateur : un `|` dans une cellule (alternative d'un motif) est échappé ; une forme de l'optimiseur fermée à dessein dit pourquoi (x-etm-closed-why) au lieu de « émise par le moteur ». Test ajouté, vérifié échouant avec l'ancien générateur. Moteur : version annoncée au handshake 1.3 (OptimizerProtocol::ProtocolVersion, OptimizerClient::kProtocolVersion). Rien d'autre ne change dans le client. Tests qui citent la version mis à jour (optimizerclient, socketscheduler, test du validateur). Co-Authored-By: Claude Opus 5.5 --- docs/OPTIMIZER_PROTOCOL.md | 155 +++++++++++++++--- ...-powersync-optimizer-protocol.openrpc.json | 87 ++++++++-- energyplugin/etm/scheduler/optimizerclient.h | 2 +- .../etm/scheduler/optimizerprotocol.h | 2 +- .../optimizerclient/testoptimizerclient.cpp | 6 +- .../socketscheduler/testsocketscheduler.cpp | 2 +- .../etm-powersync-gen-protocol-doc-test.py | 13 ++ .../tools/etm-powersync-schema-check-test.py | 5 +- tools/etm-powersync-gen-protocol-doc | 17 +- 9 files changed, 244 insertions(+), 45 deletions(-) diff --git a/docs/OPTIMIZER_PROTOCOL.md b/docs/OPTIMIZER_PROTOCOL.md index 3969935..88e97ae 100644 --- a/docs/OPTIMIZER_PROTOCOL.md +++ b/docs/OPTIMIZER_PROTOCOL.md @@ -1,4 +1,4 @@ -# OPTIMIZER_PROTOCOL — v1.2 +# OPTIMIZER_PROTOCOL — v1.3 Contrat entre le moteur d'énergie **etm-powersync-energy-plugin-etm** (couche A, GPL-3.0) et un **optimiseur** externe (couche B : l'optimiseur ETM, ou toute implémentation tierce). @@ -8,7 +8,7 @@ Ce document fait autorité. Il sert trois usages : 2. Spécification du protocole socket pour un optimiseur en process séparé. 3. Documentation publique : n'importe qui peut implémenter son propre optimiseur. -Statut : **v1.2** — figé à la fin de la beta. Versionné en semver (`protocolVersion`). +Statut : **v1.3** — figé à la fin de la beta. Versionné en semver (`protocolVersion`). > ### La forme est un contrat MACHINE > @@ -42,6 +42,34 @@ Statut : **v1.2** — figé à la fin de la beta. Versionné en semver (`protoco ## 0. Changelog +### 1.3 (2026-10) +Mineure. Norme la **forme d'`error.data`** de l'erreur `-32602` à un `GetPlan` dont le contexte est +invalide, et écrit deux règles que la 1.2 laissait à déduire. **Côté moteur, seule la version +annoncée change** : il n'émet aucun message nouveau, juge les réponses exactement comme en 1.2, et +ne lit pas `error.data` (§7). + +**Compatibilité.** Même majeure : un moteur 1.3 accepte un optimiseur 1.0 à 1.2, et un optimiseur +1.3 accepte un moteur 1.0 à 1.2 (seule la majeure est comparée, §3). Les deux règles nouvelles +portent sur des situations que le moteur ne produit pas — un contexte invalide, un handshake d'une +autre majeure. Un optimiseur 1.2 qui renvoie un autre `data`, ou qui juge la majeure avant le token, +**continue de fonctionner avec ce moteur** : le moteur ne lit pas `error.data`, et traite toute +réponse `error` au handshake comme un refus, quel que soit son code. Il n'est pas conforme 1.3 sur +ces points, et s'aligne avant de s'annoncer 1.3 — la suite de conformité l'exige d'un optimiseur qui +s'annonce 1.3 ou plus, et se contente d'un avertissement pour un optimiseur 1.2 (annexe G). + +- §7 **`error.data` d'un contexte invalide** : `{"problems":[{"path","problem"}]}` — liste non vide + d'au plus 50 problèmes (`truncated: true` à côté s'il y en avait davantage) ; `path` depuis + `params`, points et crochets ; `problem` dans une liste fermée (`missing`, `type`, `range`, + `format`, `enum`, `constraint`). Jamais la valeur reçue, dans aucun champ. Le moteur ignore un + code qu'il ne connaît pas (§9) et ne lit pas `error.data` pour décider. +- §3 **Token faux et autre majeure dans le même handshake** : le **token est jugé d'abord** — + `-32001`, puis fermeture. +- §2 **Un lot reçu par le moteur reste sans réponse** : c'est l'intention, désormais écrite (le + comportement est celui de 1.2). +- Contrat machine : composants `InvalidContextData`, `ContextProblem`, `ContextProblemCode` ; + `x-etm-data-schema` et `x-etm-data-required-from` sur l'erreur `-32602` de `GetPlan` ; ordre + token puis majeure dans les erreurs du `Handshake`. + ### 1.2 (2026-10) Mineure. Norme les points que le premier optimiseur réel a trouvés ouverts : erreurs renvoyées par l'optimiseur, fermeture après un refus, token non exigé, sens d'`UpdateLearned`, longueur de ligne, @@ -156,7 +184,10 @@ valeur avait été écrite sans définition ; **aucun émetteur ne l'a jamais pr (`[…]`) est interdit en émission. Reçu par l'optimiseur : erreur `-32600` à `id: null`, aucun message du lot n'est traité, connexion conservée. Reçu par le moteur : ligne écartée (`invalid-json` : ce n'est pas un objet), comptée, connexion conservée ; une réponse contenue - dans le lot n'est pas lue, et la requête qu'elle visait finit en délai dépassé (§4). + dans le lot n'est pas lue, et la requête qu'elle visait finit en délai dépassé (§4). **Le moteur + n'y répond pas, et c'est voulu** (écrit en 1.3) : client du protocole, il ne renvoie jamais de + réponse sur ce socket — une erreur à `id: null` n'aurait personne pour l'attendre. Le délai + dépassé de la requête visée est le seul effet visible, et il est compté (§7). - **Longueur de ligne : `maxLineBytes` = 1 Mio (1 048 576 octets, `\n` non compté), dans les deux sens** (1.2). Reçue trop longue par l'optimiseur : jetée sans être gardée en mémoire, erreur `-32600` à `id: null`, connexion conservée. Reçue par le moteur : ligne écartée @@ -176,7 +207,7 @@ valeur avait été écrite sans définition ; **aucun émetteur ne l'a jamais pr ```json {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{ - "protocolVersion":"1.2", + "protocolVersion":"1.3", "engine":"etm-powersync-energy-plugin-etm/", "token":"" }} @@ -186,7 +217,7 @@ Réponse attendue : ```json {"jsonrpc":"2.0","id":1,"result":{ - "protocolVersion":"1.2", + "protocolVersion":"1.3", "name":"etm-optimizer", "planning":true, "capabilities":{ @@ -224,6 +255,11 @@ Réponse attendue : est fermé **des deux côtés** : l'optimiseur ferme après avoir envoyé l'erreur, le moteur à sa réception. Le moteur traite **toute** réponse `error` au handshake comme un refus, quel que soit son code (`handshake-refused`), et retente au backoff (§2). +- **Le token d'abord** (1.3). Un handshake qui porte à la fois un token faux (ou absent alors + qu'il est exigé) et une autre majeure reçoit **`-32001`**, puis la connexion est fermée : + l'optimiseur juge le token avant la version. Un pair qui ne présente pas le secret n'apprend + rien d'autre — pas même quelle majeure l'optimiseur parle. `-32602` ne répond donc qu'à un + handshake dont le token est accepté, ou qui n'en exige pas. - **`GetPlan` avant un handshake réussi** (1.2) : l'optimiseur répond `-32600` et **garde** la connexion — le client peut encore envoyer `Handshake`. Le moteur n'en envoie jamais : il n'appelle `GetPlan` qu'une fois le handshake accepté. @@ -236,7 +272,7 @@ Réponse attendue : | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| -| `protocolVersion` | `ProtocolVersion` | oui | `"1.2"` | Version du moteur ; seule la majeure est comparée, des deux côtés (une autre majeure : l'optimiseur répond -32602 et ferme). | +| `protocolVersion` | `ProtocolVersion` | oui | `"1.3"` | Version du moteur ; seule la majeure est comparée, des deux côtés (une autre majeure : l'optimiseur répond -32602 et ferme — -32001 si le token est faux aussi, 1.3). | | `engine` | string | oui | motif `^etm-powersync-energy-plugin-etm/` | Moteur et sa version. | | `token` | string | non | ≥ 16 car. | 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é. | @@ -1227,15 +1263,69 @@ une abstention, à ce qu'il ne peut pas traiter. En particulier : protocole. Une situation que le tableau ne nomme pas prend le code standard qui lui correspond (`-32603` erreur interne, par exemple). +**Forme d'`error.data` pour un contexte invalide** (1.3). Exigée d'un optimiseur qui s'annonce 1.3 ou +plus : + +```json +{"jsonrpc":"2.0","id":42,"error":{"code":-32602,"message":"contexte invalide","data":{ + "problems":[{"path":"params.loads[0].id","problem":"missing"}, + {"path":"params.forecast.pv[3].from","problem":"format"}]}}} +``` + +- `problems` : liste **non vide**, de **50 entrées au plus**. S'il y a davantage de problèmes, les 50 + premiers, et **`"truncated": true`** à côté de `problems` — champ absent sinon. +- `path` : chemin du champ fautif **depuis `params`**, noms séparés par des points, indices de liste + entre crochets (`params.forecast.pv[3].from`, `params.loads[0].declared.estimatedPowerW.3`). Pour + un champ absent, le chemin où il était attendu. +- `problem` : un code d'une **liste fermée** — `missing` (obligatoire absent), `type` (type faux), + `range` (hors bornes), `format` (chaîne mal formée : instant, durée…), `enum` (valeur hors liste), + `constraint` (règle entre champs, ordre des créneaux…). +- **Jamais la valeur reçue**, dans aucun champ : aucun autre champ que ceux-là, un chemin qui suit son + motif, une nature prise dans la liste. La forme fermée rend la recopie inexprimable — c'est le but : + une valeur du site n'a rien à faire dans une erreur qu'un journal peut garder. +- **Ce que le moteur en fait : rien.** Il ne lit pas `error.data` pour décider — l'erreur reste un + échec `rpc-error`, règle des deux échecs inchangée —, ne le journalise pas (le code JSON-RPC seul), + et ne le recopie pas dans la trace (annexe D : la ligne porte `rpc-error`). Un code `problem` qu'il + ne connaît pas, ou un `data` d'une autre forme, n'y change donc rien (§9). La forme sert celui qui + lit l'échange : le validateur, la suite de conformité, l'installateur qui capture une session. + + + + +**`InvalidContextData`** — error.data de la réponse -32602 à GetPlan (§7, 1.3). Exigé d'un optimiseur qui s'annonce 1.3 ou plus ; un optimiseur 1.2 peut répondre un autre data, le moteur ne le lit pas. Aucune valeur reçue n'y figure, dans aucun champ : la forme est fermée, le chemin suit un motif, la nature est un code d'une liste fermée. + +| Champ | Type | Obligatoire | Valeurs | Sens | +|---|---|---|---|---| +| `problems` | [`ContextProblem`] | oui | | Les problèmes trouvés, de 1 à 50 ; s'il y en a davantage, les 50 premiers, et truncated. | +| `truncated` | boolean | non | `true` | Présent, et vrai, seulement si des problèmes ont été omis (problems en compte alors 50) ; absent sinon. | + +Champs non déclarés : **refusés** (forme de l'optimiseur fermée à dessein : aucune valeur reçue ne doit pouvoir s'y glisser). + + + + + + +**`ContextProblem`** — Un problème du contexte GetPlan (1.3) : où, et de quelle nature — jamais la valeur reçue. + +| Champ | Type | Obligatoire | Valeurs | Sens | +|---|---|---|---|---| +| `path` | string | oui | motif `^params(\.[A-Za-z0-9_]+\|\[[0-9]+\])*$` | Chemin du champ fautif depuis params : noms séparés par des points, indices de liste entre crochets (params.forecast.pv[3].from). Champ absent : son chemin attendu. | +| `problem` | string | oui | `missing` · `type` · `range` · `format` · `enum` · `constraint` | Nature d'un problème du contexte (1.3), liste fermée : missing (obligatoire absent), type (type faux), range (hors bornes), format (chaîne mal formée : instant, durée…), enum (valeur hors liste), constraint (règle entre champs, ordre des créneaux…). Un code inconnu est ignoré par le moteur (§9). | + +Champs non déclarés : **refusés** (forme de l'optimiseur fermée à dessein : aucune valeur reçue ne doit pouvoir s'y glisser). + + + | Code | Méthode | `message` | Connexion après l'erreur | Quand | |---|---|---|---|---| -| `-32001` | Handshake | token refusé | fermée | Token configuré côté optimiseur, et absent ou différent dans le Handshake. Un token envoyé alors que l'optimiseur n'en exige pas est ACCEPTÉ. | -| `-32602` | Handshake | majeure incompatible | fermée | Majeure de params.protocolVersion différente de celle de l'optimiseur (1.2). | +| `-32001` | Handshake | token refusé | fermée | Token configuré côté optimiseur, et absent ou différent dans le Handshake — jugé AVANT la majeure (1.3) : token faux et autre majeure dans le même Handshake → -32001. Un token envoyé alors que l'optimiseur n'en exige pas est ACCEPTÉ. | +| `-32602` | Handshake | majeure incompatible | fermée | Majeure de params.protocolVersion différente de celle de l'optimiseur (1.2), le token étant accepté ou non exigé (1.3 : le token est jugé d'abord). | | `-32600` | GetPlan | handshake requis | conservée | GetPlan reçu avant un Handshake réussi sur cette connexion (1.2). Le moteur n'en envoie jamais : il attend d'être prêt. | -| `-32602` | GetPlan | contexte invalide | conservée | Contexte GetPlan non conforme au contrat (1.2). error.data liste les problèmes : pour chacun, le chemin du champ fautif et la nature du problème, JAMAIS la valeur reçue. Une erreur et non une abstention : un contexte invalide est un défaut du moteur, une abstention le cacherait. | +| `-32602` | GetPlan | contexte invalide | conservée | Contexte GetPlan non conforme au contrat (1.2). error.data liste les problèmes : pour chacun, le chemin du champ fautif et la nature du problème, JAMAIS la valeur reçue. Forme normée en 1.3 : InvalidContextData. Une erreur et non une abstention : un contexte invalide est un défaut du moteur, une abstention le cacherait. | | `-32603` | GetPlan | erreur interne | conservée | Code JSON-RPC 2.0 standard pour toute autre panne de l'optimiseur. | | `-32700` | toute | erreur d'analyse | conservée | Ligne qui n'est pas du JSON (ou pas de l'UTF-8). Réponse à id null. Code standard JSON-RPC 2.0. | | `-32600` | toute | lot interdit | conservée | Ligne qui est un lot JSON-RPC ([…]) (1.2). Une seule réponse, à id null ; aucun message du lot n'est traité. | @@ -1247,7 +1337,7 @@ Ce que l'OPTIMISEUR répond à un message qu'il ne peut pas traiter, quelle que Ce que fait le moteur d'une réponse error : - **Handshake** : Toute réponse error au Handshake, quel que soit son code : handshake-refused, le moteur ferme, nouvelle tentative au backoff. -- **GetPlan** : Toute réponse error à GetPlan, quel que soit son code : échec de la requête (rpc-error), compté par la règle des échecs consécutifs (§7). +- **GetPlan** : Toute réponse error à GetPlan, quel que soit son code : échec de la requête (rpc-error), compté par la règle des échecs consécutifs (§7). error.data n'est PAS lu : ni décision, ni journal (le code seul), ni trace (le code de l'échec seul, rpc-error). @@ -1278,7 +1368,7 @@ Ce que le moteur refuse EN PLUS de la forme, dans l'ordre de ses contrôles (Opt | `expired` | GetPlan | expired | `expired` | non | request | non | validUntil ≤ timestamp de la requête. | | `no-current-slot` | GetPlan | abstention | `no-current-slot` | non | request | non | aucun créneau ne couvre le timestamp de la requête. | | `incomplete-slot` | GetPlan | invalid | `incomplete-slot` | oui | request | non | une charge available de la requête n'a pas d'action dans le créneau courant (décision Q1). | -| `handshake-refused` | Handshake | connection-refused | `no-plan` | non | — | non | réponse error, quel que soit son code (token refusé : -32001 ; majeure incompatible : -32602, Handshake.errors) → le moteur ferme, nouvelle tentative au backoff. | +| `handshake-refused` | Handshake | connection-refused | `no-plan` | non | — | non | réponse error, quel que soit son code (token refusé : -32001, jugé d'abord ; majeure incompatible : -32602, Handshake.errors) → le moteur ferme, nouvelle tentative au backoff. | | `invalid-handshake` | Handshake | connection-refused | `no-plan` | non | — | oui | result non objet, protocolVersion absent ou vide, capabilities présent et non objet. | | `major-mismatch` | Handshake | connection-refused | `no-plan` | non | — | non | MAJEURE de protocolVersion (ce qui précède le premier point) différente de celle du moteur. | @@ -1298,7 +1388,7 @@ qu'elle affiche de ces trois champs. - **Sens unique : de l'optimiseur vers le moteur** (1.2). Le moteur n'en envoie jamais. Reçue par un optimiseur : en notification (sans `id`), ignorée sans réponse ; en requête (avec `id`), erreur `-32601` (méthode inconnue). -- **La forme d'`UpdateLearned` n'est pas normée en 1.2.** Le tableau ci-dessous dit ce que le +- **La forme d'`UpdateLearned` n'est pas normée** (ni en 1.2, ni en 1.3). Le tableau ci-dessous dit ce que le contrat admet, pas ce qu'un moteur en fera : la forme sera fixée avec la première stratégie qui apprend. - Tant que `confidence < 0.7` (seuil indicatif), l'app peut afficher un badge @@ -1312,7 +1402,7 @@ qu'elle affiche de ces trois champs. | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| -| `loads` | [object] | non | | Non normé en 1.2 : intention §8 (learned + confidence par charge). | +| `loads` | [object] | non | | Non normé (1.2, 1.3) : intention §8 (learned + confidence par charge). | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). @@ -1326,7 +1416,7 @@ Champs non déclarés : **refusés** (forme émise par le moteur, fermée). majeure** et elle seule (§3) : le moteur refuse un optimiseur d'une autre majeure, et l'optimiseur un moteur d'une autre majeure (`-32602`, 1.2). Toute mineure est acceptée. - Une mineure n'ajoute que des champs optionnels, ou des règles sur des situations que l'autre - côté conforme ne produit pas (1.2 en est un exemple, §0). Elle se lit comme une liste de choses + côté conforme ne produit pas (1.2 et 1.3 en sont des exemples, §0). Elle se lit comme une liste de choses à aligner avant de s'annoncer de cette mineure, pas comme une rupture. - Champs inconnus dans `params`/`result` : **ignorés silencieusement**. - Changements incompatibles (renommage, sémantique) = majeure + entrée changelog §0. @@ -1336,13 +1426,13 @@ Champs non déclarés : **refusés** (forme émise par le moteur, fermée). ## Annexe A — Session minimale ``` -→ {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{"protocolVersion":"1.2","engine":"etm-powersync-energy-plugin-etm/1.15.2+etm71"}} -← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.2","name":"mon-optimiseur","planning":false}} +→ {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{"protocolVersion":"1.3","engine":"etm-powersync-energy-plugin-etm/"}} +← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.3","name":"mon-optimiseur","planning":false}} → {"jsonrpc":"2.0","id":2,"method":"GetPlan","params":{ ...SurplusContext... }} ← {"jsonrpc":"2.0","id":2,"result":{"planId":"x","strategy":"custom","slots":[{ "from":"...","to":"...","actions":[...] }]}} ``` -Ce que l'optimiseur répond à ce que le moteur n'envoie jamais (1.2, §2, §3, §7) — c'est ce +Ce que l'optimiseur répond à ce que le moteur n'envoie jamais (1.2, 1.3 ; §2, §3, §7) — c'est ce qu'éprouve la suite de conformité, par une connexion de sonde : ``` @@ -1354,6 +1444,26 @@ qu'éprouve la suite de conformité, par une connexion de sonde : ← {"jsonrpc":"2.0","id":3,"error":{"code":-32602,"message":"majeure incompatible"}} puis fermeture ``` +Sur une session ouverte, un contexte invalide (1.3) — ici un `timestamp` sans décalage et rien +d'autre : + +``` +→ {"jsonrpc":"2.0","id":4,"method":"GetPlan","params":{"timestamp":"2026-10-25T02:30:00"}} +← {"jsonrpc":"2.0","id":4,"error":{"code":-32602,"message":"contexte invalide","data":{"problems":[ + {"path":"params.timestamp","problem":"format"},{"path":"params.trigger","problem":"missing"},…]}}} +``` + +L'optimiseur qui ne lit que l'instant s'arrête au premier problème ; celui qui valide tout le +contexte les liste tous, 50 au plus. Le chemin dit où ; la valeur `2026-10-25T02:30:00` n'apparaît +nulle part. + +Token faux **et** autre majeure (1.3, si l'optimiseur exige un token) : le token d'abord. + +``` +→ {"jsonrpc":"2.0","id":5,"method":"Handshake","params":{"protocolVersion":"2.0","engine":"…","token":""}} +← {"jsonrpc":"2.0","id":5,"error":{"code":-32001,"message":"token refusé"}} puis fermeture +``` + Un optimiseur conforme minimal tient en ~50 lignes de Python : répondre au handshake, renvoyer un plan à un créneau (ou `abstain`), et les erreurs du §7. @@ -1414,7 +1524,9 @@ But : comparer des optimiseurs **sur les mêmes entrées** et rejouer un inciden telle requête n'est pas envoyée (soumise au client, elle a pour réponse `request-too-long`), elle n'est pas recopiée, et le rejeu passe la ligne ; `response` : ce qui a répondu à **cette** requête — `result` brut, ou `error.code` (`no-response` si elle a été - remplacée sans réponse) ; `executed` : actions appliquées au cycle ; `latencyMs` : optimiseur + remplacée sans réponse) ; `error.code` est le code d'échec du **moteur** (`rpc-error` pour une + réponse `error` de l'optimiseur, `timeout`…), jamais le code JSON-RPC, et `error.data` n'est + jamais recopié (§7) ; `executed` : actions appliquées au cycle ; `latencyMs` : optimiseur seulement. La réponse à la requête N sert au cycle N+1 : la ligne N l'attend. - Jamais le token (il ne voyage que dans le handshake ; recopié par une réponse, il est masqué). - **Rejeu** : `tools/etm-powersync-trace-replay` relit `request`, l'envoie à un optimiseur @@ -1426,7 +1538,7 @@ But : comparer des optimiseurs **sur les mêmes entrées** et rejouer un inciden ## Annexe E — Optimiseur de référence `tools/etm-powersync-ref-optimizer/` : serveur Python minimal (bibliothèque standard seule) -conforme 1.2, `unix:///run/powersync/optimizer.sock` par défaut, `tcp://` accepté ; répondant un +conforme 1.3, `unix:///run/powersync/optimizer.sock` par défaut, `tcp://` accepté ; répondant un plan à un créneau qui reproduit le waterfall rule-based. Usages : test de bout en bout du `SocketScheduler` (suite `etm-powersync-refoptimizer`) ; point de comparaison du banc ; exemple communautaire. Mode d'emploi et modes d'injection de défauts : son `README.md`. @@ -1477,5 +1589,8 @@ Ils deviendront normatifs avec le lot qui les implémente. - **Conformité d'un optimiseur quelconque** : suite `etm-powersync-optimizer-conformance` (`tests/auto/conformance/README.md`) — le vrai client du moteur branché sur l'optimiseur que lance `ETM_POWERSYNC_OPTIMIZER_CMD`. Depuis 1.2, elle éprouve aussi, par une connexion de sonde, - les erreurs du §7 que le moteur ne provoque pas (annexe A). + les erreurs du §7 que le moteur ne provoque pas (annexe A). Depuis 1.3, la forme d'`error.data` + d'un contexte invalide et l'ordre token puis majeure : **exigés** d'un optimiseur qui s'annonce + 1.3 ou plus, **avertis** seulement pour un optimiseur 1.2 ; une valeur reçue recopiée dans + `error.data` est refusée quelle que soit la version (la règle date de 1.2). - **Prose générée** : `tools/etm-powersync-gen-protocol-doc` (`--check` dans `tools/ci-quality.sh`). diff --git a/docs/etm-powersync-optimizer-protocol.openrpc.json b/docs/etm-powersync-optimizer-protocol.openrpc.json index 16bc9a5..f637c03 100644 --- a/docs/etm-powersync-optimizer-protocol.openrpc.json +++ b/docs/etm-powersync-optimizer-protocol.openrpc.json @@ -2,12 +2,12 @@ "openrpc": "1.3.2", "info": { "title": "etm-powersync — protocole moteur ↔ optimiseur", - "version": "1.2", + "version": "1.3", "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- ERREURS de l'optimiseur (1.2) : par méthode dans methods[].errors, et pour un message qu'il ne peut pas traiter (ligne illisible, lot, ligne trop longue, méthode inconnue) dans x-etm-message-errors ;\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." + "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- ERREURS de l'optimiseur (1.2) : par méthode dans methods[].errors, et pour un message qu'il ne peut pas traiter (ligne illisible, lot, ligne trop longue, méthode inconnue) dans x-etm-message-errors ; forme de error.data d'une erreur, quand elle est normée (1.3) : x-etm-data-schema de l'erreur, exigée à partir de x-etm-data-required-from ;\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": [ { @@ -33,7 +33,7 @@ { "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é. Refus (1.2) : token absent ou différent → -32001 ; majeure incompatible → -32602 ; l'optimiseur ferme après avoir envoyé l'erreur, le moteur à sa réception. Un token envoyé alors que l'optimiseur n'en exige pas est accepté, sans erreur.", + "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é. Refus (1.2) : token absent ou différent → -32001 ; majeure incompatible → -32602 ; les deux fautes dans le même handshake → -32001, le token est jugé AVANT la majeure (1.3) ; l'optimiseur ferme après avoir envoyé l'erreur, le moteur à sa réception. Un token envoyé alors que l'optimiseur n'en exige pas est accepté, sans erreur.", "paramStructure": "by-name", "params": [ { @@ -42,7 +42,7 @@ "schema": { "$ref": "#/components/schemas/ProtocolVersion" }, - "description": "Version du moteur ; seule la majeure est comparée, des deux côtés (une autre majeure : l'optimiseur répond -32602 et ferme)." + "description": "Version du moteur ; seule la majeure est comparée, des deux côtés (une autre majeure : l'optimiseur répond -32602 et ferme — -32001 si le token est faux aussi, 1.3)." }, { "name": "engine", @@ -71,13 +71,13 @@ { "code": -32001, "message": "token refusé", - "data": "Token configuré côté optimiseur, et absent ou différent dans le Handshake. Un token envoyé alors que l'optimiseur n'en exige pas est ACCEPTÉ.", + "data": "Token configuré côté optimiseur, et absent ou différent dans le Handshake — jugé AVANT la majeure (1.3) : token faux et autre majeure dans le même Handshake → -32001. Un token envoyé alors que l'optimiseur n'en exige pas est ACCEPTÉ.", "x-etm-connection": "fermée" }, { "code": -32602, "message": "majeure incompatible", - "data": "Majeure de params.protocolVersion différente de celle de l'optimiseur (1.2).", + "data": "Majeure de params.protocolVersion différente de celle de l'optimiseur (1.2), le token étant accepté ou non exigé (1.3 : le token est jugé d'abord).", "x-etm-connection": "fermée" } ], @@ -193,7 +193,11 @@ { "code": -32602, "message": "contexte invalide", - "data": "Contexte GetPlan non conforme au contrat (1.2). error.data liste les problèmes : pour chacun, le chemin du champ fautif et la nature du problème, JAMAIS la valeur reçue. Une erreur et non une abstention : un contexte invalide est un défaut du moteur, une abstention le cacherait.", + "data": "Contexte GetPlan non conforme au contrat (1.2). error.data liste les problèmes : pour chacun, le chemin du champ fautif et la nature du problème, JAMAIS la valeur reçue. Forme normée en 1.3 : InvalidContextData. Une erreur et non une abstention : un contexte invalide est un défaut du moteur, une abstention le cacherait.", + "x-etm-data-schema": { + "$ref": "#/components/schemas/InvalidContextData" + }, + "x-etm-data-required-from": "1.3", "x-etm-connection": "conservée" }, { @@ -205,12 +209,12 @@ ], "x-etm-direction": "engine→optimizer", "x-etm-timeout-s": 10, - "x-etm-errors-engine": "Toute réponse error à GetPlan, quel que soit son code : échec de la requête (rpc-error), compté par la règle des échecs consécutifs (§7)." + "x-etm-errors-engine": "Toute réponse error à GetPlan, quel que soit son code : échec de la requête (rpc-error), compté par la règle des échecs consécutifs (§7). error.data n'est PAS lu : ni décision, ni journal (le code seul), ni trace (le code de l'échec seul, rpc-error)." }, { "name": "UpdateLearned", "summary": "Estimations apprises de l'optimiseur (§8) — notification, sans id.", - "description": "Envoyée par l'OPTIMISEUR, et seulement par lui (1.2) : sens inverse des deux autres, sans id, sans réponse. Reçue PAR UN OPTIMISEUR : notification ignorée ; requête avec id → -32601 (x-etm-message-errors). État 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.2 : elle sera fixée avec la première stratégie qui apprend.", + "description": "Envoyée par l'OPTIMISEUR, et seulement par lui (1.2) : sens inverse des deux autres, sans id, sans réponse. Reçue PAR UN OPTIMISEUR : notification ignorée ; requête avec id → -32601 (x-etm-message-errors). État du moteur : reçue, journalisée une fois, IGNORÉE — rien n'est persisté, le contenu n'est pas lu. Forme NON NORMÉE (1.2, 1.3) : elle sera fixée avec la première stratégie qui apprend.", "paramStructure": "by-name", "params": [ { @@ -223,7 +227,7 @@ "additionalProperties": true } }, - "description": "Non normé en 1.2 : intention §8 (learned + confidence par charge)." + "description": "Non normé (1.2, 1.3) : intention §8 (learned + confidence par charge)." } ], "x-etm-direction": "optimizer→engine", @@ -234,7 +238,7 @@ "schemas": { "ProtocolVersion": { "description": "Version du protocole parlée par le moteur (GEN : OptimizerProtocol::ProtocolVersion).", - "const": "1.2" + "const": "1.3" }, "Trigger": { "description": "Cause de la requête (§5.1). GEN : SocketScheduler::knownTriggers() — du moins au plus significatif.", @@ -1804,6 +1808,62 @@ "$ref": "#/components/schemas/Plan" } ] + }, + "InvalidContextData": { + "description": "error.data de la réponse -32602 à GetPlan (§7, 1.3). Exigé d'un optimiseur qui s'annonce 1.3 ou plus ; un optimiseur 1.2 peut répondre un autre data, le moteur ne le lit pas. Aucune valeur reçue n'y figure, dans aucun champ : la forme est fermée, le chemin suit un motif, la nature est un code d'une liste fermée.", + "type": "object", + "properties": { + "problems": { + "type": "array", + "minItems": 1, + "maxItems": 50, + "items": { + "$ref": "#/components/schemas/ContextProblem" + }, + "description": "Les problèmes trouvés, de 1 à 50 ; s'il y en a davantage, les 50 premiers, et truncated." + }, + "truncated": { + "const": true, + "description": "Présent, et vrai, seulement si des problèmes ont été omis (problems en compte alors 50) ; absent sinon." + } + }, + "required": [ + "problems" + ], + "additionalProperties": false, + "x-etm-closed-why": "forme de l'optimiseur fermée à dessein : aucune valeur reçue ne doit pouvoir s'y glisser" + }, + "ContextProblem": { + "description": "Un problème du contexte GetPlan (1.3) : où, et de quelle nature — jamais la valeur reçue.", + "type": "object", + "properties": { + "path": { + "type": "string", + "pattern": "^params(\\.[A-Za-z0-9_]+|\\[[0-9]+\\])*$", + "description": "Chemin du champ fautif depuis params : noms séparés par des points, indices de liste entre crochets (params.forecast.pv[3].from). Champ absent : son chemin attendu." + }, + "problem": { + "$ref": "#/components/schemas/ContextProblemCode" + } + }, + "required": [ + "path", + "problem" + ], + "additionalProperties": false, + "x-etm-closed-why": "forme de l'optimiseur fermée à dessein : aucune valeur reçue ne doit pouvoir s'y glisser" + }, + "ContextProblemCode": { + "description": "Nature d'un problème du contexte (1.3), liste fermée : missing (obligatoire absent), type (type faux), range (hors bornes), format (chaîne mal formée : instant, durée…), enum (valeur hors liste), constraint (règle entre champs, ordre des créneaux…). Un code inconnu est ignoré par le moteur (§9).", + "type": "string", + "enum": [ + "missing", + "type", + "range", + "format", + "enum", + "constraint" + ] } } }, @@ -2105,7 +2165,7 @@ "countsAsFailure": false, "needs": [], "schemaAlsoCatches": false, - "description": "réponse error, quel que soit son code (token refusé : -32001 ; majeure incompatible : -32602, Handshake.errors) → le moteur ferme, nouvelle tentative au backoff." + "description": "réponse error, quel que soit son code (token refusé : -32001, jugé d'abord ; majeure incompatible : -32602, Handshake.errors) → le moteur ferme, nouvelle tentative au backoff." }, { "name": "invalid-handshake", @@ -2140,7 +2200,8 @@ "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)." + "state : 3.0 accepté comme 3 (entier au sens JSON Schema).", + "error.data d'une réponse error (1.3) : jamais lu — un code problem inconnu, un data d'une autre forme (optimiseur 1.2) ou absent ne changent rien : l'erreur reste un échec rpc-error." ] }, "x-etm-reason-params": { diff --git a/energyplugin/etm/scheduler/optimizerclient.h b/energyplugin/etm/scheduler/optimizerclient.h index c3a14bf..154ecf8 100644 --- a/energyplugin/etm/scheduler/optimizerclient.h +++ b/energyplugin/etm/scheduler/optimizerclient.h @@ -144,7 +144,7 @@ public: Q_ENUM(State) /*! \brief Version du protocole annoncée au handshake. Seule la majeure est comparée. */ - static constexpr const char *kProtocolVersion = "1.2"; + static constexpr const char *kProtocolVersion = "1.3"; static constexpr qint64 kHandshakeTimeoutMs = 5000; //!< §3, depuis le début de la tentative. static constexpr qint64 kRequestTimeoutMs = 10000; //!< §4, par requête \c GetPlan. static constexpr qint64 kBackoffInitialMs = 1000; //!< §2, premier délai de reconnexion. diff --git a/energyplugin/etm/scheduler/optimizerprotocol.h b/energyplugin/etm/scheduler/optimizerprotocol.h index 2ddfff1..fb2ada2 100644 --- a/energyplugin/etm/scheduler/optimizerprotocol.h +++ b/energyplugin/etm/scheduler/optimizerprotocol.h @@ -28,7 +28,7 @@ namespace OptimizerProtocol { //! Version du protocole parlée par ce sérialiseur (§3, §9). -constexpr const char *ProtocolVersion = "1.2"; +constexpr const char *ProtocolVersion = "1.3"; //! Ressource Qt du contrat OpenRPC embarqué (\c docs/etm-powersync-optimizer-protocol.openrpc.json). constexpr const char *SchemaResource = ":/etm-powersync/optimizer-protocol.openrpc.json"; diff --git a/tests/auto/optimizerclient/testoptimizerclient.cpp b/tests/auto/optimizerclient/testoptimizerclient.cpp index aa3b703..58f3b15 100644 --- a/tests/auto/optimizerclient/testoptimizerclient.cpp +++ b/tests/auto/optimizerclient/testoptimizerclient.cpp @@ -288,9 +288,9 @@ void TestOptimizerClient::testHandshakeAndPlan() QCOMPARE(hs.value("method").toString(), QStringLiteral("Handshake")); QCOMPARE(hs.value("id").toInteger(), 1); const QJsonObject params = hs.value("params").toObject(); - // Protocole 1.2 : la version ANNONCÉE au handshake est celle que le moteur sert + // Protocole 1.3 : la version ANNONCÉE au handshake est celle que le moteur sert // (GetOptimizerProtocol) et celle du contrat — deux constantes, une seule valeur. - QCOMPARE(params.value("protocolVersion").toString(), QStringLiteral("1.2")); + QCOMPARE(params.value("protocolVersion").toString(), QStringLiteral("1.3")); QCOMPARE(QString::fromLatin1(OptimizerClient::kProtocolVersion), QString::fromLatin1(OptimizerProtocol::ProtocolVersion)); QCOMPARE(params.value("engine").toString(), QStringLiteral("etm-powersync-energy-plugin-etm/9.9")); QVERIFY(!params.contains("token")); @@ -354,7 +354,7 @@ void TestOptimizerClient::testWrongMajorIsRefused() QCOMPARE(readySpy.count(), 0); QCOMPARE(client.requestPlan({}), quint64(0)); QVERIFY(waitFor([&] { return server.disconnections == 1; })); - QCOMPARE(countLog("optimiseur en 2.0, moteur en 1.2 — majeures différentes, refusé"), 1); + QCOMPARE(countLog("optimiseur en 2.0, moteur en 1.3 — majeures différentes, refusé"), 1); // Nouvelle tentative au backoff (§3), pas avant. clock.advance(999); diff --git a/tests/auto/socketscheduler/testsocketscheduler.cpp b/tests/auto/socketscheduler/testsocketscheduler.cpp index f978a17..2ba06f3 100644 --- a/tests/auto/socketscheduler/testsocketscheduler.cpp +++ b/tests/auto/socketscheduler/testsocketscheduler.cpp @@ -2341,7 +2341,7 @@ void TestSocketScheduler::testTheEngineSpeaksTheContractItServes() cycle(t0.addSecs(60 * i), -3000); const QVariantMap r = rpc("GetOptimizerProtocol"); - QCOMPARE(r.value("protocolVersion").toString(), QString("1.2")); + QCOMPARE(r.value("protocolVersion").toString(), QString("1.3")); const QJsonObject servi = QJsonObject::fromVariantMap(r.value("schema").toMap()); const QJsonObject embarque = QJsonDocument::fromJson(OptimizerProtocol::schemaDocumentBytes()).object(); QVERIFY(!embarque.isEmpty()); diff --git a/tests/tools/etm-powersync-gen-protocol-doc-test.py b/tests/tools/etm-powersync-gen-protocol-doc-test.py index b1814ee..a988b91 100644 --- a/tests/tools/etm-powersync-gen-protocol-doc-test.py +++ b/tests/tools/etm-powersync-gen-protocol-doc-test.py @@ -122,6 +122,19 @@ class TestGenProtocolDoc(unittest.TestCase): self.assertEqual(lancer(*self.args())[0], 0) self.assertIn('| `-32099` | toute | erreur ajoutée | conservée | contre-épreuve |', self.lire(self.prose)) + def test_la_forme_d_error_data_se_lit_dans_sa_table(self): + # Protocole 1.3 : le motif du chemin porte une alternative — un `|` nu fermerait la colonne, + # et la ligne aurait six cellules au lieu de cinq. Une forme de l'OPTIMISEUR fermée à dessein + # ne se dit pas « émise par le moteur ». + p = self.lire(self.prose) + ligne = next(l for l in p.splitlines() if l.startswith('| `path` | string |')) + self.assertEqual(len(ligne.replace('\\|', '').split('|')), 7, ligne) + self.assertIn('motif `^params(\\.[A-Za-z0-9_]+\\|\\[[0-9]+\\])*$`', ligne) + debut = p.index('') + bloc = p[debut:p.index('', debut)] + self.assertIn('**refusés** (forme de l\'optimiseur fermée à dessein', bloc) + self.assertNotIn('émise par le moteur', bloc) + def test_des_balises_cassees_sont_une_erreur(self): p = self.lire(self.prose) i = p.index('') diff --git a/tests/tools/etm-powersync-schema-check-test.py b/tests/tools/etm-powersync-schema-check-test.py index 7e2994b..616cd06 100644 --- a/tests/tools/etm-powersync-schema-check-test.py +++ b/tests/tools/etm-powersync-schema-check-test.py @@ -37,6 +37,9 @@ GOLDEN = sorted(glob.glob(os.path.join(RACINE, 'tests', 'auto', 'loadmodel', 'da TRACES = sorted(glob.glob(os.path.join(ICI, 'data', '*.ndjson'))) REQUETE = os.path.join(RACINE, 'tests', 'auto', 'loadmodel', 'data', 'optimizer', 'site-pv-ecs-borne-sgready.json') TOKEN = 'jeton-de-test-0123456789' +with open(SCHEMA, encoding='utf-8') as _f: + # La version du MOTEUR, celle que le contrat impose à ses Handshake (const) — jamais recopiée. + VERSION_MOTEUR = json.load(_f)['components']['schemas']['ProtocolVersion']['const'] # mode → (qui rejette, verdict sémantique attendu). « schéma: » : écart de forme ; # « règle » : la forme passe, une règle x-etm-semantic-rules refuse ; « — » : rien ne rejette. @@ -100,7 +103,7 @@ def session(mode, dossier): with open(REQUETE, encoding='utf-8') as f: params = json.load(f) s.sendall(json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'Handshake', - 'params': {'protocolVersion': '1.2', 'engine': 'etm-powersync-energy-plugin-etm/test', + 'params': {'protocolVersion': VERSION_MOTEUR, 'engine': 'etm-powersync-energy-plugin-etm/test', 'token': TOKEN}}).encode() + b'\n') hs = lire_ligne(s, tampon) if hs and str(hs.get('result', {}).get('protocolVersion', '')).startswith('1'): diff --git a/tools/etm-powersync-gen-protocol-doc b/tools/etm-powersync-gen-protocol-doc index 2ed8603..b0a3ef7 100755 --- a/tools/etm-powersync-gen-protocol-doc +++ b/tools/etm-powersync-gen-protocol-doc @@ -145,14 +145,15 @@ class Rendu: chemin = f'{prefixe}{nom}' r = self.resoudre(s) sens = self.sens(s) or (self.sens(r) if self.nom_ref(s) and r.get('type') not in ('object',) else '') + # Un `|` dans une cellule (alternative d'un motif) fermerait la colonne : échappé. out.append(f'| `{chemin}` | {self.type_de(s)} | {"oui" if nom in requis else "non"} | ' - f'{self.valeurs(s)} | {sens} |') + f'{self.valeurs(s).replace("|", chr(92) + "|")} | {sens} |') # Un objet EN LIGNE se déroule ; un composant nommé a sa propre table. if self.nom_ref(s) is None and isinstance(s, dict) and isinstance(s.get('properties'), dict): out += self.lignes(s['properties'], s.get('required', []), chemin + '.') return out - def table(self, props, requis, titre=None, description=None, ouvert=None): + def table(self, props, requis, titre=None, description=None, ouvert=None, ferme_pourquoi=None): out = [] if titre: out.append(f'**{titre}**' + (f' — {description}' if description else '')) @@ -162,8 +163,13 @@ class Rendu: out += self.lignes(props, requis) if ouvert is not None: out.append('') - out.append('Champs non déclarés : ' + ('**refusés** (forme émise par le moteur, fermée).' if ouvert is False - else 'ignorés (§9).')) + if ouvert is not False: + out.append('Champs non déclarés : ignorés (§9).') + else: + # Une forme fermée est d'ordinaire celle qu'émet le moteur ; une forme de l'optimiseur + # fermée À DESSEIN dit pourquoi (`x-etm-closed-why`, 1.3 : error.data). + out.append('Champs non déclarés : **refusés** (' + + (ferme_pourquoi or 'forme émise par le moteur, fermée') + ').') return out def composant(self, nom): @@ -184,7 +190,8 @@ class Rendu: if 'properties' not in s: return [f'`{nom}` : {self.type_de(s)} — {self.valeurs(s)}. {self.sens(s)}'.rstrip()] out = self.table(s['properties'], s.get('required', []), titre=f'`{nom}`', - description=self.sens(s) or None, ouvert=s.get('additionalProperties', True)) + description=self.sens(s) or None, ouvert=s.get('additionalProperties', True), + ferme_pourquoi=s.get('x-etm-closed-why')) if 'anyOf' in s and all(set(b) == {'required'} for b in s['anyOf']): out.append('Au moins un de : ' + ', '.join('`' + '`+`'.join(b['required']) + '`' for b in s['anyOf']) + '.') return out