Protocole optimiseur 1.3 : forme normée d'error.data, le token avant la majeure
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 <noreply@anthropic.com>
This commit is contained in:
parent
d296e5f323
commit
09043fb7c7
@ -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)
|
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).
|
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é.
|
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.
|
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
|
> ### 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
|
## 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)
|
### 1.2 (2026-10)
|
||||||
Mineure. Norme les points que le premier optimiseur réel a trouvés ouverts : erreurs renvoyées par
|
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,
|
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
|
(`[…]`) 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
|
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
|
(`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
|
- **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
|
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
|
`-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
|
```json
|
||||||
{"jsonrpc":"2.0","id":1,"method":"Handshake","params":{
|
{"jsonrpc":"2.0","id":1,"method":"Handshake","params":{
|
||||||
"protocolVersion":"1.2",
|
"protocolVersion":"1.3",
|
||||||
"engine":"etm-powersync-energy-plugin-etm/<version>",
|
"engine":"etm-powersync-energy-plugin-etm/<version>",
|
||||||
"token":"<secret partagé, optionnel — requis si configuré côté plugin>"
|
"token":"<secret partagé, optionnel — requis si configuré côté plugin>"
|
||||||
}}
|
}}
|
||||||
@ -186,7 +217,7 @@ Réponse attendue :
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{"jsonrpc":"2.0","id":1,"result":{
|
{"jsonrpc":"2.0","id":1,"result":{
|
||||||
"protocolVersion":"1.2",
|
"protocolVersion":"1.3",
|
||||||
"name":"etm-optimizer",
|
"name":"etm-optimizer",
|
||||||
"planning":true,
|
"planning":true,
|
||||||
"capabilities":{
|
"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
|
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
|
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).
|
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
|
- **`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
|
connexion — le client peut encore envoyer `Handshake`. Le moteur n'en envoie jamais : il
|
||||||
n'appelle `GetPlan` qu'une fois le handshake accepté.
|
n'appelle `GetPlan` qu'une fois le handshake accepté.
|
||||||
@ -236,7 +272,7 @@ Réponse attendue :
|
|||||||
|
|
||||||
| Champ | Type | Obligatoire | Valeurs | Sens |
|
| 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. |
|
| `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é. |
|
| `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
|
protocole. Une situation que le tableau ne nomme pas prend le code standard qui lui correspond
|
||||||
(`-32603` erreur interne, par exemple).
|
(`-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.
|
||||||
|
|
||||||
|
<!-- BEGIN GENERATED table:InvalidContextData -->
|
||||||
|
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||||||
|
|
||||||
|
**`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).
|
||||||
|
|
||||||
|
<!-- END GENERATED -->
|
||||||
|
|
||||||
|
<!-- BEGIN GENERATED table:ContextProblem -->
|
||||||
|
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||||||
|
|
||||||
|
**`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).
|
||||||
|
|
||||||
|
<!-- END GENERATED -->
|
||||||
|
|
||||||
<!-- BEGIN GENERATED errors -->
|
<!-- BEGIN GENERATED errors -->
|
||||||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||||||
|
|
||||||
| Code | Méthode | `message` | Connexion après l'erreur | Quand |
|
| 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É. |
|
| `-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). |
|
| `-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. |
|
| `-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. |
|
| `-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. |
|
| `-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é. |
|
| `-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 :
|
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.
|
- **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).
|
||||||
|
|
||||||
<!-- END GENERATED -->
|
<!-- END GENERATED -->
|
||||||
|
|
||||||
@ -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. |
|
| `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. |
|
| `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). |
|
| `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. |
|
| `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. |
|
| `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
|
- **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`),
|
un optimiseur : en notification (sans `id`), ignorée sans réponse ; en requête (avec `id`),
|
||||||
erreur `-32601` (méthode inconnue).
|
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
|
contrat admet, pas ce qu'un moteur en fera : la forme sera fixée avec la première stratégie qui
|
||||||
apprend.
|
apprend.
|
||||||
- Tant que `confidence < 0.7` (seuil indicatif), l'app peut afficher un badge
|
- 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 |
|
| 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).
|
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
|
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.
|
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
|
- 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.
|
à aligner avant de s'annoncer de cette mineure, pas comme une rupture.
|
||||||
- Champs inconnus dans `params`/`result` : **ignorés silencieusement**.
|
- Champs inconnus dans `params`/`result` : **ignorés silencieusement**.
|
||||||
- Changements incompatibles (renommage, sémantique) = majeure + entrée changelog §0.
|
- 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
|
## 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,"method":"Handshake","params":{"protocolVersion":"1.3","engine":"etm-powersync-energy-plugin-etm/<version>"}}
|
||||||
← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.2","name":"mon-optimiseur","planning":false}}
|
← {"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,"method":"GetPlan","params":{ ...SurplusContext... }}
|
||||||
← {"jsonrpc":"2.0","id":2,"result":{"planId":"x","strategy":"custom","slots":[{ "from":"...","to":"...","actions":[...] }]}}
|
← {"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 :
|
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
|
← {"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":"<faux>"}}
|
||||||
|
← {"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,
|
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.
|
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`),
|
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
|
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é
|
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.
|
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é).
|
- 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
|
- **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
|
## Annexe E — Optimiseur de référence
|
||||||
|
|
||||||
`tools/etm-powersync-ref-optimizer/` : serveur Python minimal (bibliothèque standard seule)
|
`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
|
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
|
`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`.
|
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`
|
- **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
|
(`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,
|
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`).
|
- **Prose générée** : `tools/etm-powersync-gen-protocol-doc` (`--check` dans `tools/ci-quality.sh`).
|
||||||
|
|||||||
@ -2,12 +2,12 @@
|
|||||||
"openrpc": "1.3.2",
|
"openrpc": "1.3.2",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "etm-powersync — protocole moteur ↔ optimiseur",
|
"title": "etm-powersync — protocole moteur ↔ optimiseur",
|
||||||
"version": "1.2",
|
"version": "1.3",
|
||||||
"license": {
|
"license": {
|
||||||
"name": "GPL-3.0-or-later",
|
"name": "GPL-3.0-or-later",
|
||||||
"url": "https://www.gnu.org/licenses/gpl-3.0.html"
|
"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": [
|
"servers": [
|
||||||
{
|
{
|
||||||
@ -33,7 +33,7 @@
|
|||||||
{
|
{
|
||||||
"name": "Handshake",
|
"name": "Handshake",
|
||||||
"summary": "Ouverture de session, à chaque (re)connexion (§3).",
|
"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",
|
"paramStructure": "by-name",
|
||||||
"params": [
|
"params": [
|
||||||
{
|
{
|
||||||
@ -42,7 +42,7 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/ProtocolVersion"
|
"$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",
|
"name": "engine",
|
||||||
@ -71,13 +71,13 @@
|
|||||||
{
|
{
|
||||||
"code": -32001,
|
"code": -32001,
|
||||||
"message": "token refusé",
|
"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"
|
"x-etm-connection": "fermée"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": -32602,
|
"code": -32602,
|
||||||
"message": "majeure incompatible",
|
"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"
|
"x-etm-connection": "fermée"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -193,7 +193,11 @@
|
|||||||
{
|
{
|
||||||
"code": -32602,
|
"code": -32602,
|
||||||
"message": "contexte invalide",
|
"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"
|
"x-etm-connection": "conservée"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -205,12 +209,12 @@
|
|||||||
],
|
],
|
||||||
"x-etm-direction": "engine→optimizer",
|
"x-etm-direction": "engine→optimizer",
|
||||||
"x-etm-timeout-s": 10,
|
"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",
|
"name": "UpdateLearned",
|
||||||
"summary": "Estimations apprises de l'optimiseur (§8) — notification, sans id.",
|
"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",
|
"paramStructure": "by-name",
|
||||||
"params": [
|
"params": [
|
||||||
{
|
{
|
||||||
@ -223,7 +227,7 @@
|
|||||||
"additionalProperties": true
|
"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",
|
"x-etm-direction": "optimizer→engine",
|
||||||
@ -234,7 +238,7 @@
|
|||||||
"schemas": {
|
"schemas": {
|
||||||
"ProtocolVersion": {
|
"ProtocolVersion": {
|
||||||
"description": "Version du protocole parlée par le moteur (GEN : OptimizerProtocol::ProtocolVersion).",
|
"description": "Version du protocole parlée par le moteur (GEN : OptimizerProtocol::ProtocolVersion).",
|
||||||
"const": "1.2"
|
"const": "1.3"
|
||||||
},
|
},
|
||||||
"Trigger": {
|
"Trigger": {
|
||||||
"description": "Cause de la requête (§5.1). GEN : SocketScheduler::knownTriggers() — du moins au plus significatif.",
|
"description": "Cause de la requête (§5.1). GEN : SocketScheduler::knownTriggers() — du moins au plus significatif.",
|
||||||
@ -1804,6 +1808,62 @@
|
|||||||
"$ref": "#/components/schemas/Plan"
|
"$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,
|
"countsAsFailure": false,
|
||||||
"needs": [],
|
"needs": [],
|
||||||
"schemaAlsoCatches": false,
|
"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",
|
"name": "invalid-handshake",
|
||||||
@ -2140,7 +2200,8 @@
|
|||||||
"diagnostics, expected : non objets ignorés ; valeurs inconnues filtrées avant l'app.",
|
"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).",
|
"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.",
|
"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": {
|
"x-etm-reason-params": {
|
||||||
|
|||||||
@ -144,7 +144,7 @@ public:
|
|||||||
Q_ENUM(State)
|
Q_ENUM(State)
|
||||||
|
|
||||||
/*! \brief Version du protocole annoncée au handshake. Seule la majeure est comparée. */
|
/*! \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 kHandshakeTimeoutMs = 5000; //!< §3, depuis le début de la tentative.
|
||||||
static constexpr qint64 kRequestTimeoutMs = 10000; //!< §4, par requête \c GetPlan.
|
static constexpr qint64 kRequestTimeoutMs = 10000; //!< §4, par requête \c GetPlan.
|
||||||
static constexpr qint64 kBackoffInitialMs = 1000; //!< §2, premier délai de reconnexion.
|
static constexpr qint64 kBackoffInitialMs = 1000; //!< §2, premier délai de reconnexion.
|
||||||
|
|||||||
@ -28,7 +28,7 @@
|
|||||||
namespace OptimizerProtocol {
|
namespace OptimizerProtocol {
|
||||||
|
|
||||||
//! Version du protocole parlée par ce sérialiseur (§3, §9).
|
//! 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).
|
//! Ressource Qt du contrat OpenRPC embarqué (\c docs/etm-powersync-optimizer-protocol.openrpc.json).
|
||||||
constexpr const char *SchemaResource = ":/etm-powersync/optimizer-protocol.openrpc.json";
|
constexpr const char *SchemaResource = ":/etm-powersync/optimizer-protocol.openrpc.json";
|
||||||
|
|||||||
@ -288,9 +288,9 @@ void TestOptimizerClient::testHandshakeAndPlan()
|
|||||||
QCOMPARE(hs.value("method").toString(), QStringLiteral("Handshake"));
|
QCOMPARE(hs.value("method").toString(), QStringLiteral("Handshake"));
|
||||||
QCOMPARE(hs.value("id").toInteger(), 1);
|
QCOMPARE(hs.value("id").toInteger(), 1);
|
||||||
const QJsonObject params = hs.value("params").toObject();
|
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.
|
// (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(QString::fromLatin1(OptimizerClient::kProtocolVersion), QString::fromLatin1(OptimizerProtocol::ProtocolVersion));
|
||||||
QCOMPARE(params.value("engine").toString(), QStringLiteral("etm-powersync-energy-plugin-etm/9.9"));
|
QCOMPARE(params.value("engine").toString(), QStringLiteral("etm-powersync-energy-plugin-etm/9.9"));
|
||||||
QVERIFY(!params.contains("token"));
|
QVERIFY(!params.contains("token"));
|
||||||
@ -354,7 +354,7 @@ void TestOptimizerClient::testWrongMajorIsRefused()
|
|||||||
QCOMPARE(readySpy.count(), 0);
|
QCOMPARE(readySpy.count(), 0);
|
||||||
QCOMPARE(client.requestPlan({}), quint64(0));
|
QCOMPARE(client.requestPlan({}), quint64(0));
|
||||||
QVERIFY(waitFor([&] { return server.disconnections == 1; }));
|
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.
|
// Nouvelle tentative au backoff (§3), pas avant.
|
||||||
clock.advance(999);
|
clock.advance(999);
|
||||||
|
|||||||
@ -2341,7 +2341,7 @@ void TestSocketScheduler::testTheEngineSpeaksTheContractItServes()
|
|||||||
cycle(t0.addSecs(60 * i), -3000);
|
cycle(t0.addSecs(60 * i), -3000);
|
||||||
|
|
||||||
const QVariantMap r = rpc("GetOptimizerProtocol");
|
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 servi = QJsonObject::fromVariantMap(r.value("schema").toMap());
|
||||||
const QJsonObject embarque = QJsonDocument::fromJson(OptimizerProtocol::schemaDocumentBytes()).object();
|
const QJsonObject embarque = QJsonDocument::fromJson(OptimizerProtocol::schemaDocumentBytes()).object();
|
||||||
QVERIFY(!embarque.isEmpty());
|
QVERIFY(!embarque.isEmpty());
|
||||||
|
|||||||
@ -122,6 +122,19 @@ class TestGenProtocolDoc(unittest.TestCase):
|
|||||||
self.assertEqual(lancer(*self.args())[0], 0)
|
self.assertEqual(lancer(*self.args())[0], 0)
|
||||||
self.assertIn('| `-32099` | toute | erreur ajoutée | conservée | contre-épreuve |', self.lire(self.prose))
|
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('<!-- BEGIN GENERATED table:InvalidContextData -->')
|
||||||
|
bloc = p[debut:p.index('<!-- END GENERATED -->', 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):
|
def test_des_balises_cassees_sont_une_erreur(self):
|
||||||
p = self.lire(self.prose)
|
p = self.lire(self.prose)
|
||||||
i = p.index('<!-- END GENERATED -->')
|
i = p.index('<!-- END GENERATED -->')
|
||||||
|
|||||||
@ -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')))
|
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')
|
REQUETE = os.path.join(RACINE, 'tests', 'auto', 'loadmodel', 'data', 'optimizer', 'site-pv-ecs-borne-sgready.json')
|
||||||
TOKEN = 'jeton-de-test-0123456789'
|
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:<mot-clé> » : écart de forme ;
|
# mode → (qui rejette, verdict sémantique attendu). « schéma:<mot-clé> » : écart de forme ;
|
||||||
# « règle » : la forme passe, une règle x-etm-semantic-rules refuse ; « — » : rien ne rejette.
|
# « 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:
|
with open(REQUETE, encoding='utf-8') as f:
|
||||||
params = json.load(f)
|
params = json.load(f)
|
||||||
s.sendall(json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'Handshake',
|
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')
|
'token': TOKEN}}).encode() + b'\n')
|
||||||
hs = lire_ligne(s, tampon)
|
hs = lire_ligne(s, tampon)
|
||||||
if hs and str(hs.get('result', {}).get('protocolVersion', '')).startswith('1'):
|
if hs and str(hs.get('result', {}).get('protocolVersion', '')).startswith('1'):
|
||||||
|
|||||||
@ -145,14 +145,15 @@ class Rendu:
|
|||||||
chemin = f'{prefixe}{nom}'
|
chemin = f'{prefixe}{nom}'
|
||||||
r = self.resoudre(s)
|
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 '')
|
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"} | '
|
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.
|
# 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):
|
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 + '.')
|
out += self.lignes(s['properties'], s.get('required', []), chemin + '.')
|
||||||
return out
|
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 = []
|
out = []
|
||||||
if titre:
|
if titre:
|
||||||
out.append(f'**{titre}**' + (f' — {description}' if description else ''))
|
out.append(f'**{titre}**' + (f' — {description}' if description else ''))
|
||||||
@ -162,8 +163,13 @@ class Rendu:
|
|||||||
out += self.lignes(props, requis)
|
out += self.lignes(props, requis)
|
||||||
if ouvert is not None:
|
if ouvert is not None:
|
||||||
out.append('')
|
out.append('')
|
||||||
out.append('Champs non déclarés : ' + ('**refusés** (forme émise par le moteur, fermée).' if ouvert is False
|
if ouvert is not False:
|
||||||
else 'ignorés (§9).'))
|
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
|
return out
|
||||||
|
|
||||||
def composant(self, nom):
|
def composant(self, nom):
|
||||||
@ -184,7 +190,8 @@ class Rendu:
|
|||||||
if 'properties' not in s:
|
if 'properties' not in s:
|
||||||
return [f'`{nom}` : {self.type_de(s)} — {self.valeurs(s)}. {self.sens(s)}'.rstrip()]
|
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}`',
|
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']):
|
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']) + '.')
|
out.append('Au moins un de : ' + ', '.join('`' + '`+`'.join(b['required']) + '`' for b in s['anyOf']) + '.')
|
||||||
return out
|
return out
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user