Fusion de feature/protocole-1.3 dans release/etm74 (protocole optimiseur 1.3 : forme d'error.data, token avant la majeure)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HE6X3TcKdiuCucBHA2XX4n
This commit is contained in:
commit
9b974ff974
@ -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";
|
||||||
|
|||||||
@ -15,11 +15,13 @@ adaptateurs, sur les mocks de la suite `simulation` — et vérifie, une fonctio
|
|||||||
| `testARefusedHandshakeIsClosedByTheOptimizer` | protocole 1.2, §3 : handshake d'une autre majeure → `-32602`, puis l'optimiseur **ferme** ; si un token est attendu, token faux → `-32001`, fermé pareil |
|
| `testARefusedHandshakeIsClosedByTheOptimizer` | protocole 1.2, §3 : handshake d'une autre majeure → `-32602`, puis l'optimiseur **ferme** ; si un token est attendu, token faux → `-32001`, fermé pareil |
|
||||||
| `testABatchIsRefused` | protocole 1.2, §2 : un lot `[…]` → **une** erreur `-32600` à `id: null`, rien du lot traité, connexion gardée |
|
| `testABatchIsRefused` | protocole 1.2, §2 : un lot `[…]` → **une** erreur `-32600` à `id: null`, rien du lot traité, connexion gardée |
|
||||||
| `testALineOverTheLimitIsRefused` | protocole 1.2, §2 : une ligne de `maxLineBytes` + 1 octets → `-32600` à `id: null`, connexion gardée ; une ligne d'exactement `maxLineBytes` est lue |
|
| `testALineOverTheLimitIsRefused` | protocole 1.2, §2 : une ligne de `maxLineBytes` + 1 octets → `-32600` à `id: null`, connexion gardée ; une ligne d'exactement `maxLineBytes` est lue |
|
||||||
|
| `testAnInvalidContextIsRefusedWithItsProblems` | protocole 1.3, §7 : un `GetPlan` dont le contexte est invalide (`{"timestamp": "valeur-sonde:7Qz9"}`) → `-32602` à son `id`, connexion **gardée** ; `error.data` jugé par le validateur publié contre le contrat embarqué (`--responses-only`) : forme `{"problems":[{path, problem}]}` **exigée** d'un optimiseur qui s'annonce 1.3 ou plus, **avertie** (`AVERTISSEMENT`, test vert) d'un optimiseur 1.2 ; la valeur reçue recopiée dans `error.data` est **refusée** quelle que soit la version (règle de 1.2) |
|
||||||
|
| `testTheTokenIsCheckedBeforeTheMajor` | protocole 1.3, §3 : token faux **et** autre majeure dans le même handshake → `-32001` (le token d'abord), puis l'optimiseur ferme. Exigé à partir de 1.3 ; `-32602` d'un optimiseur 1.2 est averti. **SKIP** sans `ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE` : sans token attendu, l'ordre ne s'éprouve pas |
|
||||||
| `testTheSessionMatchesTheContractOffUtc` | la fonction 3, le moteur sous un fuseau **non UTC** (`Europe/Berlin`) et cadencé en heure locale comme en production, à travers le passage à l'heure d'hiver (02:58 CEST → 02:01 CET) : aucun échec de requête, session conforme au contrat embarqué (tout instant porte son décalage, §5.2) |
|
| `testTheSessionMatchesTheContractOffUtc` | la fonction 3, le moteur sous un fuseau **non UTC** (`Europe/Berlin`) et cadencé en heure locale comme en production, à travers le passage à l'heure d'hiver (02:58 CEST → 02:01 CET) : aucun échec de requête, session conforme au contrat embarqué (tout instant porte son décalage, §5.2) |
|
||||||
|
|
||||||
Ces deux dernières lignes valent pour le mode `plan` (défaut). Voir **Optimiseur qui s'abstient**.
|
Les lignes `testEveryPlanIsAccepted` et `testTheSafetyLoopStaysOnTop` valent pour le mode `plan` (défaut). Voir **Optimiseur qui s'abstient**.
|
||||||
|
|
||||||
Les fonctions 6 à 9 ne branchent pas le moteur : elles envoient, par une **connexion de sonde**, ce
|
Les fonctions 6 à 9, 11 et 12 ne branchent pas le moteur : elles envoient, par une **connexion de sonde**, ce
|
||||||
que son client n'envoie jamais, et lisent la réponse brute (un lot renvoyé se verrait). Elles
|
que son client n'envoie jamais, et lisent la réponse brute (un lot renvoyé se verrait). Elles
|
||||||
utilisent des requêtes dont la réponse est normée sans contexte — `UpdateLearned` avec `id` →
|
utilisent des requêtes dont la réponse est normée sans contexte — `UpdateLearned` avec `id` →
|
||||||
`-32601` (§8) — pour constater qu'un message a été, ou n'a pas été, traité.
|
`-32601` (§8) — pour constater qu'un message a été, ou n'a pas été, traité.
|
||||||
@ -42,7 +44,8 @@ export ETM_POWERSYNC_OPTIMIZER_CMD='exec /chemin/de/mon-optimiseur --listen {end
|
|||||||
for fn in testHandshakeIsAccepted testGetPlanIsAnsweredInTime testTheSessionMatchesTheContract \
|
for fn in testHandshakeIsAccepted testGetPlanIsAnsweredInTime testTheSessionMatchesTheContract \
|
||||||
testEveryPlanIsAccepted testTheSafetyLoopStaysOnTop testGetPlanBeforeTheHandshakeIsRefused \
|
testEveryPlanIsAccepted testTheSafetyLoopStaysOnTop testGetPlanBeforeTheHandshakeIsRefused \
|
||||||
testARefusedHandshakeIsClosedByTheOptimizer testABatchIsRefused testALineOverTheLimitIsRefused \
|
testARefusedHandshakeIsClosedByTheOptimizer testABatchIsRefused testALineOverTheLimitIsRefused \
|
||||||
testTheSessionMatchesTheContractOffUtc; do
|
testTheSessionMatchesTheContractOffUtc testAnInvalidContextIsRefusedWithItsProblems \
|
||||||
|
testTheTokenIsCheckedBeforeTheMajor; do
|
||||||
systemd-run --user --scope -q -p MemoryMax=2G \
|
systemd-run --user --scope -q -p MemoryMax=2G \
|
||||||
bwrap --dev-bind / / --unshare-net --tmpfs /tmp --bind "$PWD/../../.." "$PWD/../../.." \
|
bwrap --dev-bind / / --unshare-net --tmpfs /tmp --bind "$PWD/../../.." "$PWD/../../.." \
|
||||||
--bind <sources> <sources> --chdir "$PWD" ./target_wrapper.sh ./etm-powersync-optimizer-conformance "$fn"
|
--bind <sources> <sources> --chdir "$PWD" ./target_wrapper.sh ./etm-powersync-optimizer-conformance "$fn"
|
||||||
@ -61,6 +64,19 @@ commande vit dans le même réseau privé (la boucle locale y fonctionne, `tcp:/
|
|||||||
| `ETM_POWERSYNC_SOURCE_DIR` | sources du build | Racine où trouver le validateur et l'optimiseur de référence |
|
| `ETM_POWERSYNC_SOURCE_DIR` | sources du build | Racine où trouver le validateur et l'optimiseur de référence |
|
||||||
| `ETM_POWERSYNC_CONFORMANCE_EXPECT` | `plan` | Ce que la suite attend : `plan` (l'optimiseur commande) ou `abstain` (il s'abstient à chaque cycle). Toute autre valeur fait échouer |
|
| `ETM_POWERSYNC_CONFORMANCE_EXPECT` | `plan` | Ce que la suite attend : `plan` (l'optimiseur commande) ou `abstain` (il s'abstient à chaque cycle). Toute autre valeur fait échouer |
|
||||||
|
|
||||||
|
**Un optimiseur 1.2 face aux règles 1.3.** Une mineure ajoute des règles (protocole §9) : la suite
|
||||||
|
lit la version que l'optimiseur annonce au handshake et n'exige la forme d'`error.data` et l'ordre
|
||||||
|
token puis majeure qu'à partir de 1.3. En deçà, l'écart est écrit en `AVERTISSEMENT` et la fonction
|
||||||
|
reste verte — l'optimiseur fonctionne avec ce moteur, il s'aligne avant de s'annoncer 1.3. Pour
|
||||||
|
éprouver l'ordre, donner un token (`ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE`, et le même fichier à
|
||||||
|
l'optimiseur) :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
printf 'jeton-de-conformite-0123456789' > /chemin/token
|
||||||
|
export ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE=/chemin/token
|
||||||
|
export ETM_POWERSYNC_OPTIMIZER_CMD='exec /chemin/de/mon-optimiseur --listen {endpoint} --token-file /chemin/token'
|
||||||
|
```
|
||||||
|
|
||||||
L'attente de démarrage ouvre une connexion de sonde, refermée sans handshake : un serveur conforme
|
L'attente de démarrage ouvre une connexion de sonde, refermée sans handshake : un serveur conforme
|
||||||
la tolère. Un échec dit ce qui manque — code d'erreur du client, écart au contrat (chemin, mot-clé),
|
la tolère. Un échec dit ce qui manque — code d'erreur du client, écart au contrat (chemin, mot-clé),
|
||||||
cycles non appliqués avec leur `fallbackCode`, latences.
|
cycles non appliqués avec leur `fallbackCode`, latences.
|
||||||
@ -95,7 +111,7 @@ fondée sur ces champs jugerait faux dans les deux cas : le mode se donne, il ne
|
|||||||
La **qualité** des plans (aucune comparaison au rule-based : c'est `refoptimizer`, pour la référence
|
La **qualité** des plans (aucune comparaison au rule-based : c'est `refoptimizer`, pour la référence
|
||||||
seulement), les bornes VE (aucune borne au banc), `UpdateLearned` émis par l'optimiseur (ignoré par le moteur ; sa
|
seulement), les bornes VE (aucune borne au banc), `UpdateLearned` émis par l'optimiseur (ignoré par le moteur ; sa
|
||||||
forme n'est pas normée en 1.2), le token envoyé à un optimiseur qui n'en exige pas (§3, 1.2 : accepté ;
|
forme n'est pas normée en 1.2), le token envoyé à un optimiseur qui n'en exige pas (§3, 1.2 : accepté ;
|
||||||
le moteur n'envoie de token que si l'installateur en a posé un), la réponse à un contexte `GetPlan`
|
le moteur n'envoie de token que si l'installateur en a posé un), l'exhaustivité de la validation du
|
||||||
invalide (§7, 1.2 : `-32602` ; la forme d'`error.data` n'est pas normée), le comportement sous défaut
|
contexte par l'optimiseur (un seul contexte invalide est envoyé ; il en suffit d'un problème pour
|
||||||
de transport. Le contrat : `docs/etm-powersync-optimizer-protocol.openrpc.json`
|
juger la forme), le comportement sous défaut de transport. Le contrat : `docs/etm-powersync-optimizer-protocol.openrpc.json`
|
||||||
(prose : `docs/OPTIMIZER_PROTOCOL.md`, annexe G).
|
(prose : `docs/OPTIMIZER_PROTOCOL.md`, annexe G).
|
||||||
|
|||||||
@ -22,7 +22,11 @@
|
|||||||
// autre majeure (-32602 puis fermeture par l'optimiseur), lot (-32600 à id null, rien du lot
|
// autre majeure (-32602 puis fermeture par l'optimiseur), lot (-32600 à id null, rien du lot
|
||||||
// traité), ligne de plus de 1 Mio (-32600 à id null, connexion gardée) ;
|
// traité), ligne de plus de 1 Mio (-32600 à id null, connexion gardée) ;
|
||||||
// 10. le point 3, le moteur réglé sur un fuseau NON UTC (Europe/Berlin) et cadencé en heure
|
// 10. le point 3, le moteur réglé sur un fuseau NON UTC (Europe/Berlin) et cadencé en heure
|
||||||
// locale comme en production, à travers le passage à l'heure d'hiver.
|
// locale comme en production, à travers le passage à l'heure d'hiver ;
|
||||||
|
// 11 et 12 (protocole 1.3), par la sonde : un contexte GetPlan invalide → -32602 dont `error.data`
|
||||||
|
// a la forme normée, jugée par le validateur publié — exigée d'un optimiseur qui s'annonce 1.3
|
||||||
|
// ou plus, avertie pour un 1.2 ; la valeur reçue n'y est jamais recopiée. Token faux ET autre
|
||||||
|
// majeure dans le même handshake → -32001 (le token d'abord), puis fermeture.
|
||||||
//
|
//
|
||||||
// Mode attendu ($ETM_POWERSYNC_CONFORMANCE_EXPECT, défaut `plan`) : `abstain` juge un optimiseur
|
// Mode attendu ($ETM_POWERSYNC_CONFORMANCE_EXPECT, défaut `plan`) : `abstain` juge un optimiseur
|
||||||
// qui s'abstient toujours (§7, réponse permise) — le point 4 exige alors une abstention à chaque
|
// qui s'abstient toujours (§7, réponse permise) — le point 4 exige alors une abstention à chaque
|
||||||
@ -420,6 +424,8 @@ private slots:
|
|||||||
void testABatchIsRefused();
|
void testABatchIsRefused();
|
||||||
void testALineOverTheLimitIsRefused();
|
void testALineOverTheLimitIsRefused();
|
||||||
void testTheSessionMatchesTheContractOffUtc();
|
void testTheSessionMatchesTheContractOffUtc();
|
||||||
|
void testAnInvalidContextIsRefusedWithItsProblems();
|
||||||
|
void testTheTokenIsCheckedBeforeTheMajor();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void monter();
|
void monter();
|
||||||
@ -431,8 +437,8 @@ private:
|
|||||||
void sonder(std::unique_ptr<Sonde> &sonde);
|
void sonder(std::unique_ptr<Sonde> &sonde);
|
||||||
//! Un Handshake de sonde en \p version, avec \p token s'il n'est pas vide.
|
//! Un Handshake de sonde en \p version, avec \p token s'il n'est pas vide.
|
||||||
QByteArray handshake(int id, const QString &version, const QString &token) const;
|
QByteArray handshake(int id, const QString &version, const QString &token) const;
|
||||||
//! Handshake accepté sur \p sonde, ou échec du test.
|
//! Handshake accepté sur \p sonde, ou échec du test ; \p reponse : le `result` reçu, si demandé.
|
||||||
void ouvrirSession(Sonde &sonde, int id);
|
void ouvrirSession(Sonde &sonde, int id, QJsonObject *reponse = nullptr);
|
||||||
//! Lance l'optimiseur et y branche le moteur (directement, ou par le relais).
|
//! Lance l'optimiseur et y branche le moteur (directement, ou par le relais).
|
||||||
void brancher(bool parLeRelais);
|
void brancher(bool parLeRelais);
|
||||||
void cycle(const QDateTime &t);
|
void cycle(const QDateTime &t);
|
||||||
@ -846,7 +852,7 @@ QByteArray TestOptimizerConformance::handshake(int id, const QString &version, c
|
|||||||
.toJson(QJsonDocument::Compact);
|
.toJson(QJsonDocument::Compact);
|
||||||
}
|
}
|
||||||
|
|
||||||
void TestOptimizerConformance::ouvrirSession(Sonde &sonde, int id)
|
void TestOptimizerConformance::ouvrirSession(Sonde &sonde, int id, QJsonObject *reponse)
|
||||||
{
|
{
|
||||||
sonde.envoyer(handshake(id, QString::fromLatin1(OptimizerProtocol::ProtocolVersion), m_token));
|
sonde.envoyer(handshake(id, QString::fromLatin1(OptimizerProtocol::ProtocolVersion), m_token));
|
||||||
QByteArray ligne;
|
QByteArray ligne;
|
||||||
@ -854,8 +860,33 @@ void TestOptimizerConformance::ouvrirSession(Sonde &sonde, int id)
|
|||||||
const QJsonObject m = QJsonDocument::fromJson(ligne).object();
|
const QJsonObject m = QJsonDocument::fromJson(ligne).object();
|
||||||
QVERIFY2(m.value("id").toInt() == id && m.value("result").toObject().value("protocolVersion").isString(),
|
QVERIFY2(m.value("id").toInt() == id && m.value("result").toObject().value("protocolVersion").isString(),
|
||||||
qUtf8Printable("handshake de sonde refusé : " + QString::fromUtf8(ligne.left(300))));
|
qUtf8Printable("handshake de sonde refusé : " + QString::fromUtf8(ligne.left(300))));
|
||||||
|
if (reponse)
|
||||||
|
*reponse = m;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
//! « 1.3 » → 103 ; illisible → -1. Seules majeure et mineure comptent (§9).
|
||||||
|
int versionComparable(const QString &v)
|
||||||
|
{
|
||||||
|
bool okMajeure = false, okMineure = true;
|
||||||
|
const int majeure = v.section('.', 0, 0).toInt(&okMajeure);
|
||||||
|
const int mineure = v.contains('.') ? v.section('.', 1, 1).toInt(&okMineure) : 0;
|
||||||
|
return okMajeure && okMineure ? majeure * 100 + mineure : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
//! Version à partir de laquelle les règles 1.3 sont EXIGÉES ; en deçà, un écart est averti.
|
||||||
|
constexpr int kExigeDepuis = 103;
|
||||||
|
|
||||||
|
//! Vrai si \p annoncee est LISIBLE et antérieure à 1.3 — une version illisible est jugée 1.3.
|
||||||
|
bool avantRegles13(const QString &annoncee)
|
||||||
|
{
|
||||||
|
const int v = versionComparable(annoncee);
|
||||||
|
return v >= 0 && v < kExigeDepuis;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
void TestOptimizerConformance::testGetPlanBeforeTheHandshakeIsRefused()
|
void TestOptimizerConformance::testGetPlanBeforeTheHandshakeIsRefused()
|
||||||
{
|
{
|
||||||
// §3 (1.2) : GetPlan avant un handshake réussi → -32600, et la connexion est GARDÉE — le
|
// §3 (1.2) : GetPlan avant un handshake réussi → -32600, et la connexion est GARDÉE — le
|
||||||
@ -983,5 +1014,111 @@ void TestOptimizerConformance::testTheSessionMatchesTheContractOffUtc()
|
|||||||
jugerLaSession();
|
jugerLaSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 11 et 12 : protocole 1.3, par une connexion de sonde --------------------------------------------
|
||||||
|
|
||||||
|
void TestOptimizerConformance::testAnInvalidContextIsRefusedWithItsProblems()
|
||||||
|
{
|
||||||
|
// §7 (1.2) : un contexte GetPlan invalide → -32602, connexion gardée. §7 (1.3) : `error.data`
|
||||||
|
// = {"problems":[{path, problem}]}, jamais la valeur reçue. La forme est jugée par le VALIDATEUR
|
||||||
|
// PUBLIÉ, sur la session de sonde (--responses-only : la requête est fautive à dessein) et
|
||||||
|
// contre le contrat EMBARQUÉ ; c'est lui qui exige ou avertit selon la version que
|
||||||
|
// l'optimiseur a annoncée. Le contexte porte une valeur qu'aucun chemin conforme ne peut
|
||||||
|
// contenir : si elle revient dans `error.data`, c'est une recopie.
|
||||||
|
static const QString marqueur = QStringLiteral("valeur-sonde:7Qz9");
|
||||||
|
std::unique_ptr<Sonde> sonde;
|
||||||
|
sonder(sonde);
|
||||||
|
if (QTest::currentTestFailed())
|
||||||
|
return;
|
||||||
|
QJsonObject hsReponse;
|
||||||
|
ouvrirSession(*sonde, 1, &hsReponse);
|
||||||
|
if (QTest::currentTestFailed())
|
||||||
|
return;
|
||||||
|
const QString annoncee = hsReponse.value("result").toObject().value("protocolVersion").toString();
|
||||||
|
const QJsonObject getPlan{{"jsonrpc", "2.0"}, {"id", 2}, {"method", "GetPlan"},
|
||||||
|
{"params", QJsonObject{{"timestamp", marqueur}}}};
|
||||||
|
sonde->envoyer(QJsonDocument(getPlan).toJson(QJsonDocument::Compact));
|
||||||
|
QByteArray ligne;
|
||||||
|
QVERIFY2(sonde->recevoir(&ligne), "contexte invalide : aucune réponse (ou connexion fermée)");
|
||||||
|
const QString e = verifierErreur(ligne, 2, -32602);
|
||||||
|
QVERIFY2(e.isEmpty(), qUtf8Printable(e));
|
||||||
|
const QJsonObject reponse = QJsonDocument::fromJson(ligne).object();
|
||||||
|
// Garde indépendante du validateur : la valeur reçue n'est nulle part dans `error.data`.
|
||||||
|
const QJsonValue data = reponse.value("error").toObject().value("data");
|
||||||
|
const QByteArray dataTexte = QJsonDocument(QJsonArray{data}).toJson(QJsonDocument::Compact);
|
||||||
|
QVERIFY2(!dataTexte.contains(marqueur.toUtf8()),
|
||||||
|
"error.data recopie la valeur reçue (params.timestamp) — jamais la valeur reçue (§7)");
|
||||||
|
|
||||||
|
// La session de sonde, jugée par le validateur publié. Le token y devient des astérisques.
|
||||||
|
QJsonObject hsParams = QJsonDocument::fromJson(handshake(1, QString::fromLatin1(OptimizerProtocol::ProtocolVersion),
|
||||||
|
m_token)).object();
|
||||||
|
if (!m_token.isEmpty()) {
|
||||||
|
QJsonObject p = hsParams.value("params").toObject();
|
||||||
|
p.insert("token", QString(m_token.size(), QLatin1Char('*')));
|
||||||
|
hsParams.insert("params", p);
|
||||||
|
}
|
||||||
|
QTemporaryDir dir;
|
||||||
|
QVERIFY(dir.isValid());
|
||||||
|
const Contrat::Verdict v = Contrat::juger({hsParams, hsReponse, getPlan, reponse},
|
||||||
|
Contrat::ecrireSchema(dir, OptimizerProtocol::schemaDocumentBytes()),
|
||||||
|
{QStringLiteral("--responses-only")});
|
||||||
|
QVERIFY2(v.code == 0, qUtf8Printable(QStringLiteral("optimiseur en %1 — error.data NON CONFORME au contrat :\n")
|
||||||
|
.arg(annoncee.left(16)) + v.sortie.right(4000)));
|
||||||
|
// Le contrôle a bien eu lieu : un validateur qui ne trouverait plus la forme dans le contrat
|
||||||
|
// passerait au vert sans rien juger.
|
||||||
|
QVERIFY2(v.compte("GetPlan.error.data") == 1, qUtf8Printable(v.sortie.right(1000)));
|
||||||
|
const int avertissements = v.resume.value("avertissements").toInt();
|
||||||
|
for (const QString &l : v.sortie.split('\n'))
|
||||||
|
if (l.startsWith(QStringLiteral("AVERTISSEMENT")))
|
||||||
|
qWarning().noquote() << "[Conformité]" << l;
|
||||||
|
// Un avertissement n'est permis qu'à un optimiseur d'avant 1.3 : au-delà, c'est un écart.
|
||||||
|
QVERIFY2(avertissements == 0 || avantRegles13(annoncee), qUtf8Printable(v.sortie.right(1000)));
|
||||||
|
qInfo().noquote() << "[Conformité] contexte invalide : -32602, optimiseur en" << annoncee
|
||||||
|
<< (avertissements ? QStringLiteral("— forme d'error.data AVERTIE (%1), à aligner avant 1.3").arg(avertissements)
|
||||||
|
: QStringLiteral("— forme d'error.data conforme"));
|
||||||
|
// Connexion gardée : la sonde peut encore parler.
|
||||||
|
sonde->envoyer(R"({"jsonrpc":"2.0","id":3,"method":"UpdateLearned","params":{}})");
|
||||||
|
QVERIFY2(sonde->recevoir(&ligne), "après le contexte invalide : aucune réponse (ou connexion fermée)");
|
||||||
|
const QString e2 = verifierErreur(ligne, 3, -32601);
|
||||||
|
QVERIFY2(e2.isEmpty(), qUtf8Printable(e2));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestOptimizerConformance::testTheTokenIsCheckedBeforeTheMajor()
|
||||||
|
{
|
||||||
|
// §3 (1.3) : token faux ET autre majeure dans le même handshake → -32001, le token d'abord,
|
||||||
|
// puis l'optimiseur ferme. Exigé d'un optimiseur qui s'annonce 1.3 ou plus ; un optimiseur 1.2
|
||||||
|
// qui juge la majeure d'abord (-32602) est averti. Sans token attendu, rien à éprouver.
|
||||||
|
std::unique_ptr<Sonde> sonde;
|
||||||
|
sonder(sonde);
|
||||||
|
if (QTest::currentTestFailed())
|
||||||
|
return;
|
||||||
|
if (m_token.isEmpty())
|
||||||
|
QSKIP("aucun token attendu (ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE) : ordre token puis majeure non éprouvé");
|
||||||
|
QJsonObject hsReponse;
|
||||||
|
ouvrirSession(*sonde, 1, &hsReponse); // la version annoncée, sur une session acceptée
|
||||||
|
if (QTest::currentTestFailed())
|
||||||
|
return;
|
||||||
|
const QString annoncee = hsReponse.value("result").toObject().value("protocolVersion").toString();
|
||||||
|
const QString autreMajeure = QString::number(QString::fromLatin1(OptimizerProtocol::ProtocolVersion).section('.', 0, 0).toInt() + 1) + ".0";
|
||||||
|
Sonde faux(endpoint());
|
||||||
|
QVERIFY(faux.ouverte());
|
||||||
|
faux.envoyer(handshake(1, autreMajeure, m_token + QStringLiteral("-faux")));
|
||||||
|
QByteArray ligne;
|
||||||
|
QVERIFY2(faux.recevoir(&ligne), "token faux et autre majeure : aucune réponse avant la fermeture");
|
||||||
|
const QString e = verifierErreur(ligne, 1, -32001);
|
||||||
|
if (!e.isEmpty()) {
|
||||||
|
// La majeure jugée d'abord : -32602 (règle 1.2), un écart d'ordre seulement.
|
||||||
|
const bool majeureDabord = verifierErreur(ligne, 1, -32602).isEmpty();
|
||||||
|
QVERIFY2(majeureDabord && avantRegles13(annoncee),
|
||||||
|
qUtf8Printable(QStringLiteral("optimiseur en %1 : ").arg(annoncee.left(16)) + e));
|
||||||
|
qWarning().noquote() << "[Conformité] AVERTISSEMENT optimiseur en" << annoncee.left(16)
|
||||||
|
<< ": token faux et autre majeure → -32602, la majeure jugée avant le token —"
|
||||||
|
" -32001 exigé à partir de 1.3, à aligner avant de s'annoncer 1.3";
|
||||||
|
} else {
|
||||||
|
qInfo().noquote() << "[Conformité] token faux et autre majeure : -32001, le token d'abord (optimiseur en"
|
||||||
|
<< annoncee.left(16) + ")";
|
||||||
|
}
|
||||||
|
QVERIFY2(faux.fermeeParLautre(5000), "handshake refusé : l'optimiseur n'a pas fermé la connexion en 5 s");
|
||||||
|
}
|
||||||
|
|
||||||
QTEST_MAIN(TestOptimizerConformance)
|
QTEST_MAIN(TestOptimizerConformance)
|
||||||
#include "testconformance.moc"
|
#include "testconformance.moc"
|
||||||
|
|||||||
@ -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 -->')
|
||||||
|
|||||||
@ -13,7 +13,9 @@ Trois choses :
|
|||||||
« bons » sont valides, chaque mode « mauvais » est rejeté par le schéma OU par une règle
|
« bons » sont valides, chaque mode « mauvais » est rejeté par le schéma OU par une règle
|
||||||
sémantique nommée — et laquelle (`TABLE_MODES`, recopiée au rapport) ;
|
sémantique nommée — et laquelle (`TABLE_MODES`, recopiée au rapport) ;
|
||||||
3. le validateur lui-même ne peut pas se taire : un mot-clé non géré, une règle orpheline, un
|
3. le validateur lui-même ne peut pas se taire : un mot-clé non géré, une règle orpheline, un
|
||||||
champ ajouté, un mauvais type le font échouer.
|
champ ajouté, un mauvais type le font échouer ;
|
||||||
|
4. `error.data` du -32602 de GetPlan (protocole 1.3) : forme exigée d'un optimiseur 1.3, avertie
|
||||||
|
d'un optimiseur 1.2, recopie d'une valeur reçue refusée des deux.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
@ -37,6 +39,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.
|
||||||
@ -56,8 +61,16 @@ TABLE_MODES = {
|
|||||||
'unknown-load': ('règle', 'unknown-load'),
|
'unknown-load': ('règle', 'unknown-load'),
|
||||||
'forbidden-kind': ('règle', 'kind-mismatch'),
|
'forbidden-kind': ('règle', 'kind-mismatch'),
|
||||||
'missing-load': ('règle', 'incomplete-slot'),
|
'missing-load': ('règle', 'incomplete-slot'),
|
||||||
|
# Modes 1.3 : sur un contexte VALIDE et le bon token, ils se comportent comme `normal`.
|
||||||
|
'old-data': ('—', 'valide'),
|
||||||
|
'echo-value': ('—', 'valide'),
|
||||||
|
'major-first': ('—', 'valide'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Une valeur que nul chemin conforme ne peut contenir (tiret, deux-points) : si elle apparaît dans
|
||||||
|
# error.data, c'est une recopie.
|
||||||
|
MARQUEUR = 'valeur-sonde:7Qz9'
|
||||||
|
|
||||||
|
|
||||||
def verifier(*args):
|
def verifier(*args):
|
||||||
p = subprocess.run([sys.executable, '-I', OUTIL, *args], capture_output=True, text=True, timeout=60)
|
p = subprocess.run([sys.executable, '-I', OUTIL, *args], capture_output=True, text=True, timeout=60)
|
||||||
@ -100,7 +113,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'):
|
||||||
@ -114,6 +127,33 @@ def session(mode, dossier):
|
|||||||
return capture
|
return capture
|
||||||
|
|
||||||
|
|
||||||
|
def session_contexte_invalide(dossier, mode, annonce, params):
|
||||||
|
"""Handshake accepté puis UN GetPlan de contexte invalide ; rend le fichier capturé."""
|
||||||
|
sock_path = os.path.join(dossier, 'opt.sock')
|
||||||
|
capture = os.path.join(dossier, f'invalide-{mode}-{annonce}.ndjson')
|
||||||
|
proc = subprocess.Popen([sys.executable, '-I', '-B', REF, '--listen', f'unix://{sock_path}', '--mode', mode,
|
||||||
|
'--announce', annonce, '--capture', capture],
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
|
||||||
|
try:
|
||||||
|
assert proc.stdout.readline().startswith('ÉCOUTE'), 'optimiseur de référence muet'
|
||||||
|
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
s.connect(sock_path)
|
||||||
|
tampon = [b'']
|
||||||
|
s.sendall(json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'Handshake',
|
||||||
|
'params': {'protocolVersion': VERSION_MOTEUR,
|
||||||
|
'engine': 'etm-powersync-energy-plugin-etm/test'}}).encode() + b'\n')
|
||||||
|
assert lire_ligne(s, tampon)['result']['protocolVersion'] == annonce
|
||||||
|
s.sendall(json.dumps({'jsonrpc': '2.0', 'id': 2, 'method': 'GetPlan', 'params': params}).encode() + b'\n')
|
||||||
|
reponse = lire_ligne(s, tampon)
|
||||||
|
assert reponse['error']['code'] == -32602, reponse
|
||||||
|
s.close()
|
||||||
|
finally:
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait(5)
|
||||||
|
proc.stdout.close()
|
||||||
|
return capture
|
||||||
|
|
||||||
|
|
||||||
class TestCeQueLeMoteurEmet(unittest.TestCase):
|
class TestCeQueLeMoteurEmet(unittest.TestCase):
|
||||||
def test_les_references_du_serialiseur_sont_conformes(self):
|
def test_les_references_du_serialiseur_sont_conformes(self):
|
||||||
self.assertGreaterEqual(len(GOLDEN), 4, 'fichiers de référence du sérialiseur introuvables')
|
self.assertGreaterEqual(len(GOLDEN), 4, 'fichiers de référence du sérialiseur introuvables')
|
||||||
@ -236,5 +276,135 @@ class TestLeValidateurNeSeTaitPas(unittest.TestCase):
|
|||||||
self.assertNotIn('uniqueItems', sortie)
|
self.assertNotIn('uniqueItems', sortie)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDonneesDErreur(unittest.TestCase):
|
||||||
|
"""error.data du -32602 de GetPlan (protocole 1.3), sur de vraies sessions de l'optimiseur de
|
||||||
|
référence. La session porte un GetPlan de contexte invalide : le validateur la juge SANS
|
||||||
|
--semantic (sinon la réponse error est un refus, quel que soit son data)."""
|
||||||
|
|
||||||
|
# (mode, version annoncée) → (code de sortie, écarts > 0, avertissements > 0)
|
||||||
|
ATTENDU = {
|
||||||
|
('normal', '1.3'): (0, False, False),
|
||||||
|
('normal', '1.2'): (0, False, False),
|
||||||
|
('old-data', '1.3'): (1, True, False), # forme d'avant 1.3, annoncée 1.3 : refusée
|
||||||
|
('old-data', '1.2'): (0, False, True), # la même, annoncée 1.2 : avertie
|
||||||
|
('echo-value', '1.3'): (1, True, False),
|
||||||
|
('echo-value', '1.2'): (1, True, True), # forme avertie, RECOPIE refusée (règle de 1.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_forme_exigee_en_1_3_avertie_en_1_2_recopie_refusee(self):
|
||||||
|
params = copy.deepcopy(json.load(open(REQUETE, encoding='utf-8')))
|
||||||
|
params['timestamp'] = MARQUEUR # sans décalage, illisible : `format`
|
||||||
|
obtenu = {}
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
for (mode, annonce) in self.ATTENDU:
|
||||||
|
capture = session_contexte_invalide(d, mode, annonce, params)
|
||||||
|
code, r, sortie = verifier('--ndjson', capture, '--responses-only')
|
||||||
|
self.assertEqual(r['comptes'].get('GetPlan.error'), 1, sortie)
|
||||||
|
self.assertNotIn('GetPlan.params', r['comptes'])
|
||||||
|
self.assertEqual(r['comptes'].get('GetPlan.error.data'), 1, sortie)
|
||||||
|
obtenu[(mode, annonce)] = (code, r['ecarts'] > 0, r['avertissements'] > 0)
|
||||||
|
print(f' {mode:11s} {annonce} → code {code}, écarts {r["ecarts"]}, avertissements '
|
||||||
|
f'{r["avertissements"]}', file=sys.stderr)
|
||||||
|
if mode == 'echo-value':
|
||||||
|
self.assertIn('recopie la valeur reçue en params.timestamp', sortie)
|
||||||
|
self.assertNotIn(MARQUEUR, sortie, 'le validateur recopie lui-même la valeur')
|
||||||
|
if (mode, annonce) == ('old-data', '1.2'):
|
||||||
|
self.assertIn('AVERTISSEMENT', sortie)
|
||||||
|
self.assertIn('à aligner avant de s\'annoncer 1.3', sortie)
|
||||||
|
self.assertEqual(obtenu, self.ATTENDU)
|
||||||
|
|
||||||
|
def test_la_forme_vient_du_contrat(self):
|
||||||
|
# Contre-épreuve : sans x-etm-data-schema, la forme d'avant 1.3 annoncée 1.3 passe — c'est
|
||||||
|
# bien le contrat qui juge, pas une règle cachée dans l'outil.
|
||||||
|
with open(SCHEMA, encoding='utf-8') as f:
|
||||||
|
s = json.load(f)
|
||||||
|
for e in next(m for m in s['methods'] if m['name'] == 'GetPlan')['errors']:
|
||||||
|
e.pop('x-etm-data-schema', None)
|
||||||
|
e.pop('x-etm-data-required-from', None)
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
schema = os.path.join(d, 's.json')
|
||||||
|
with open(schema, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(s, f, ensure_ascii=False)
|
||||||
|
capture = session_contexte_invalide(d, 'old-data', '1.3', {'timestamp': MARQUEUR})
|
||||||
|
self.assertEqual(verifier('--ndjson', capture, '--responses-only')[0], 1)
|
||||||
|
code, r, sortie = verifier('--schema', schema, '--ndjson', capture, '--responses-only')
|
||||||
|
self.assertEqual(code, 0, sortie)
|
||||||
|
self.assertNotIn('GetPlan.error.data', r['comptes'])
|
||||||
|
|
||||||
|
def ecrire(self, d, nom, contenu):
|
||||||
|
chemin = os.path.join(d, nom)
|
||||||
|
with open(chemin, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(contenu, f, ensure_ascii=False)
|
||||||
|
return chemin
|
||||||
|
|
||||||
|
def juger(self, d, data, requete=None, version='1.3'):
|
||||||
|
args = ['--method', 'GetPlan', '--error',
|
||||||
|
self.ecrire(d, 'e.json', {'code': -32602, 'message': 'contexte invalide', 'data': data}),
|
||||||
|
'--handshake', self.ecrire(d, 'h.json', {'protocolVersion': version})]
|
||||||
|
if requete is not None:
|
||||||
|
args += ['--request', self.ecrire(d, 'q.json', requete)]
|
||||||
|
return verifier(*args)
|
||||||
|
|
||||||
|
def test_la_forme_ligne_a_ligne(self):
|
||||||
|
p = {'path': 'params.loads[0].id', 'problem': 'missing'}
|
||||||
|
cas = [
|
||||||
|
({'problems': [p]}, 0, None),
|
||||||
|
({'problems': [{'path': 'params', 'problem': 'type'}]}, 0, None),
|
||||||
|
({'problems': [{'path': 'params.loads[0].declared.estimatedPowerW.3', 'problem': 'range'}]}, 0, None),
|
||||||
|
({'problems': [p] * 50, 'truncated': True}, 0, None),
|
||||||
|
({'problems': []}, 1, '[minItems]'),
|
||||||
|
({'problems': [p] * 51}, 1, '[maxItems]'),
|
||||||
|
({'problems': [p] * 50, 'truncated': False}, 1, '[const]'),
|
||||||
|
({'problems': [p] * 3, 'truncated': True}, 1, '[x-etm-truncated]'),
|
||||||
|
({'problems': [dict(p, problem='absent')]}, 1, '[enum]'),
|
||||||
|
({'problems': [dict(p, path='/loads/0/id')]}, 1, '[pattern]'),
|
||||||
|
({'problems': [dict(p, path='loads[0].id')]}, 1, '[pattern]'),
|
||||||
|
({'problems': [dict(p, path='params.loads.0.id ')]}, 1, '[pattern]'),
|
||||||
|
({'problems': [dict(p, value=3)]}, 1, '[additionalProperties]'),
|
||||||
|
({'problems': [p], 'detail': 'x'}, 1, '[additionalProperties]'),
|
||||||
|
({'problems': [{'path': 'params.timestamp'}]}, 1, '[required]'),
|
||||||
|
({'problemes': ['params.timestamp : format']}, 1, '[required]'),
|
||||||
|
('contexte invalide', 1, '[type]'),
|
||||||
|
]
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
for data, code_attendu, mot in cas:
|
||||||
|
code, r, sortie = self.juger(d, data)
|
||||||
|
self.assertEqual(code, code_attendu, f'{data!r}\n{sortie}')
|
||||||
|
if mot:
|
||||||
|
self.assertIn(mot, sortie, data)
|
||||||
|
# data absent : écart en 1.3
|
||||||
|
chemin = self.ecrire(d, 'e.json', {'code': -32602, 'message': 'contexte invalide'})
|
||||||
|
code, _, sortie = verifier('--method', 'GetPlan', '--error', chemin)
|
||||||
|
self.assertEqual(code, 1, sortie)
|
||||||
|
self.assertIn('« data » manquant', sortie)
|
||||||
|
# Un autre code d'erreur n'a pas de forme normée : rien à juger.
|
||||||
|
chemin = self.ecrire(d, 'e.json', {'code': -32603, 'message': 'erreur interne', 'data': [1, 'x']})
|
||||||
|
code, r, sortie = verifier('--method', 'GetPlan', '--error', chemin)
|
||||||
|
self.assertEqual((code, r['ecarts']), (0, 0), sortie)
|
||||||
|
|
||||||
|
def test_la_recopie_ne_peut_pas_accuser_un_chemin_conforme(self):
|
||||||
|
# Valeurs de la requête qui sont AUSSI des noms de champ (domain « battery », trigger
|
||||||
|
# « forecast », meter.source « meter ») : un chemin conforme les contient, ce n'est pas une
|
||||||
|
# recopie. Une valeur à tiret ou à deux-points recopiée, elle, l'est.
|
||||||
|
requete = copy.deepcopy(json.load(open(REQUETE, encoding='utf-8')))
|
||||||
|
requete['trigger'] = 'forecast'
|
||||||
|
requete['loads'][0]['domain'] = 'battery'
|
||||||
|
data = {'problems': [{'path': 'params.battery.socPercent', 'problem': 'range'},
|
||||||
|
{'path': 'params.forecast.pv[3].from', 'problem': 'format'},
|
||||||
|
{'path': 'params.meter.source', 'problem': 'enum'}]}
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
code, r, sortie = self.juger(d, data, requete)
|
||||||
|
self.assertEqual(code, 0, sortie)
|
||||||
|
ident = requete['loads'][0]['id']
|
||||||
|
self.assertRegex(ident, '[^A-Za-z0-9_]')
|
||||||
|
code, r, sortie = self.juger(d, {'problemes': [f'charge {ident} : priorité hors bornes']}, requete, '1.2')
|
||||||
|
self.assertEqual(code, 1, sortie)
|
||||||
|
self.assertIn('recopie la valeur reçue en params.loads[0].id', sortie)
|
||||||
|
self.assertNotIn(ident, sortie.split('RÉSUMÉ')[0])
|
||||||
|
# Avant 1.2, la règle n'existait pas : avertie seulement.
|
||||||
|
code, r, sortie = self.juger(d, {'problemes': [f'charge {ident}']}, requete, '1.1')
|
||||||
|
self.assertEqual((code, r['ecarts'], r['avertissements'] > 0), (0, 0, True), sortie)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main(verbosity=2)
|
unittest.main(verbosity=2)
|
||||||
|
|||||||
@ -60,6 +60,9 @@ Python 3, bibliothèque standard seule, aucun accès réseau. Le contrat :
|
|||||||
./tools/etm-powersync-schema-check --method GetPlan --params requete.json
|
./tools/etm-powersync-schema-check --method GetPlan --params requete.json
|
||||||
./tools/etm-powersync-schema-check --method GetPlan --result plan.json --request requete.json --semantic
|
./tools/etm-powersync-schema-check --method GetPlan --result plan.json --request requete.json --semantic
|
||||||
./tools/etm-powersync-schema-check --ndjson session.ndjson --semantic
|
./tools/etm-powersync-schema-check --ndjson session.ndjson --semantic
|
||||||
|
./tools/etm-powersync-schema-check --ndjson sonde.ndjson --responses-only # requêtes fautives à dessein
|
||||||
|
./tools/etm-powersync-schema-check --method GetPlan --error erreur.json --request requete.json \
|
||||||
|
--handshake handshake.json # error.data d'un -32602 (protocole 1.3)
|
||||||
./tools/etm-powersync-schema-check --trace etm-powersync-optimizer-trace-2026-10-17.ndjson --semantic
|
./tools/etm-powersync-schema-check --trace etm-powersync-optimizer-trace-2026-10-17.ndjson --semantic
|
||||||
./tools/etm-powersync-schema-check --subset # le sous-ensemble de JSON Schema géré
|
./tools/etm-powersync-schema-check --subset # le sous-ensemble de JSON Schema géré
|
||||||
|
|
||||||
@ -77,6 +80,10 @@ ETM_POWERSYNC_CODE_LISTS=/tmp/listes.json <build>/tests/auto/protocolschema/etm-
|
|||||||
mot-clé est une erreur de schéma (code 2), jamais ignoré.
|
mot-clé est une erreur de schéma (code 2), jamais ignoré.
|
||||||
- **`--semantic`** applique `x-etm-semantic-rules` : ce que le validateur du moteur refuse en plus de
|
- **`--semantic`** applique `x-etm-semantic-rules` : ce que le validateur du moteur refuse en plus de
|
||||||
la forme, dans son ordre, sous le code qu'il publie (`fallbackCode`).
|
la forme, dans son ordre, sous le code qu'il publie (`fallbackCode`).
|
||||||
|
- **`error.data`** (protocole 1.3) : la forme d'`error.data` d'une erreur, quand le contrat la norme
|
||||||
|
(`x-etm-data-schema`, `-32602` de `GetPlan`), est exigée d'un optimiseur qui s'annonce
|
||||||
|
`x-etm-data-required-from` ou plus, et seulement AVERTIE en deçà (ligne `AVERTISSEMENT`, sans effet
|
||||||
|
sur le code de sortie) ; une valeur reçue recopiée dans `error.data` est un écart dès 1.2.
|
||||||
- **Code de sortie** : 0 conforme ; 1 écart de forme ou refus sémantique ; 2 erreur d'usage ou de
|
- **Code de sortie** : 0 conforme ; 1 écart de forme ou refus sémantique ; 2 erreur d'usage ou de
|
||||||
schéma.
|
schéma.
|
||||||
- Conformité d'un optimiseur de bout en bout, avec le vrai moteur : suite
|
- Conformité d'un optimiseur de bout en bout, avec le vrai moteur : suite
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# `etm-powersync-ref-optimizer` — optimiseur de référence
|
# `etm-powersync-ref-optimizer` — optimiseur de référence
|
||||||
|
|
||||||
Python 3, bibliothèque standard seule, GPL-3.0 comme le dépôt. Protocole : `docs/OPTIMIZER_PROTOCOL.md`
|
Python 3, bibliothèque standard seule, GPL-3.0 comme le dépôt. Protocole : `docs/OPTIMIZER_PROTOCOL.md`
|
||||||
1.2 (annexe E). Il répond un plan à **un créneau** qui reproduit le waterfall du rule-based (passe
|
1.3 (annexe E). Il répond un plan à **un créneau** qui reproduit le waterfall du rule-based (passe
|
||||||
confort : même ordre, même budget, mêmes écrêtages) en ne lisant **que** le contexte `GetPlan` — aucun
|
confort : même ordre, même budget, mêmes écrêtages) en ne lisant **que** le contexte `GetPlan` — aucun
|
||||||
accès réseau sortant, aucun fichier hors le token. Usages : test de bout en bout du moteur, point de
|
accès réseau sortant, aucun fichier hors le token. Usages : test de bout en bout du moteur, point de
|
||||||
comparaison d'un banc, exemple pour qui écrit son propre optimiseur.
|
comparaison d'un banc, exemple pour qui écrit son propre optimiseur.
|
||||||
@ -29,16 +29,16 @@ Il écrit `ÉCOUTE <endpoint>` sur la sortie standard quand il écoute (le port
|
|||||||
et son journal sur la sortie d'erreur. Plusieurs clients à la fois (un fil par connexion, aucun état
|
et son journal sur la sortie d'erreur. Plusieurs clients à la fois (un fil par connexion, aucun état
|
||||||
partagé) : le moteur et un rejeu peuvent lui parler ensemble. SIGTERM l'arrête proprement.
|
partagé) : le moteur et un rejeu peuvent lui parler ensemble. SIGTERM l'arrête proprement.
|
||||||
|
|
||||||
## Erreurs (protocole 1.2)
|
## Erreurs (protocole 1.2 et 1.3)
|
||||||
|
|
||||||
Codes JSON-RPC 2.0, plus `-32001` (protocole §7) :
|
Codes JSON-RPC 2.0, plus `-32001` (protocole §7) :
|
||||||
|
|
||||||
| Reçu | Réponse | Connexion |
|
| Reçu | Réponse | Connexion |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Handshake` sans le token attendu, ou avec un autre | `-32001` | fermée |
|
| `Handshake` sans le token attendu, ou avec un autre — **même d'une autre majeure** (1.3 : le token d'abord) | `-32001` | fermée |
|
||||||
| `Handshake` d'une autre majeure | `-32602` | fermée |
|
| `Handshake` d'une autre majeure, token accepté ou non exigé | `-32602` | fermée |
|
||||||
| `GetPlan` avant un handshake réussi | `-32600` | gardée |
|
| `GetPlan` avant un handshake réussi | `-32600` | gardée |
|
||||||
| `GetPlan` dont le `timestamp` est absent, illisible ou sans décalage | `-32602`, `error.data.problems` = `[{path, problem}]`, jamais la valeur | gardée |
|
| `GetPlan` dont le `timestamp` est absent, n'est pas une chaîne, ou est illisible ou sans décalage | `-32602`, `error.data` = `{"problems":[{"path":"params.timestamp","problem":"missing"\|"type"\|"format"}]}` (forme 1.3, protocole §7), jamais la valeur | gardée |
|
||||||
| ligne non JSON | `-32700`, `id: null` | gardée |
|
| ligne non JSON | `-32700`, `id: null` | gardée |
|
||||||
| lot `[…]` | `-32600`, `id: null`, aucun message du lot traité | gardée |
|
| lot `[…]` | `-32600`, `id: null`, aucun message du lot traité | gardée |
|
||||||
| ligne de plus de 1 Mio (`\n` non compté) | `-32600`, `id: null`, jetée sans être gardée en mémoire | gardée |
|
| ligne de plus de 1 Mio (`\n` non compté) | `-32600`, `id: null`, jetée sans être gardée en mémoire | gardée |
|
||||||
@ -80,7 +80,14 @@ Le rejeu compare, cycle par cycle, les actions du créneau courant au plan enreg
|
|||||||
`constraint`) · `missing-load` (une charge disponible absente du créneau courant) · `echo-token`
|
`constraint`) · `missing-load` (une charge disponible absente du créneau courant) · `echo-token`
|
||||||
(recopie le token dans `reason` et `planId`) · `protocol-1.0` (optimiseur 1.0 : sans champs 1.1) ·
|
(recopie le token dans `reason` et `planId`) · `protocol-1.0` (optimiseur 1.0 : sans champs 1.1) ·
|
||||||
`deviate` (cascade en ordre inverse : doit diverger) · `wrong-type` (`planId` numérique : le moteur
|
`deviate` (cascade en ordre inverse : doit diverger) · `wrong-type` (`planId` numérique : le moteur
|
||||||
l'accepte, le contrat OpenRPC le refuse — contre-épreuve de la suite de conformité).
|
l'accepte, le contrat OpenRPC le refuse — contre-épreuve de la suite de conformité) · `old-data`
|
||||||
|
(contexte invalide : `error.data` dans la forme d'avant 1.3, `{"problemes": [phrases]}`) ·
|
||||||
|
`echo-value` (contexte invalide : `error.data` recopie la valeur reçue) · `major-first` (la majeure
|
||||||
|
jugée avant le token).
|
||||||
|
|
||||||
|
`--announce VERSION` : la version annoncée au handshake, et dont la majeure est exigée du moteur
|
||||||
|
(défaut `1.3`). Avec `--announce 1.2`, il se présente en optimiseur 1.2 : la suite de conformité
|
||||||
|
n'exige plus de lui la forme 1.3 d'`error.data` ni l'ordre token puis majeure, elle avertit.
|
||||||
|
|
||||||
`--mode-file FICHIER` : le mode est relu dans ce fichier à chaque message — un test change de mode
|
`--mode-file FICHIER` : le mode est relu dans ce fichier à chaque message — un test change de mode
|
||||||
sans reconnexion. `--capture FICHIER` : chaque message reçu et envoyé, une ligne JSON-RPC par
|
sans reconnexion. `--capture FICHIER` : chaque message reçu et envoyé, une ligne JSON-RPC par
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
# Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
# Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||||
"""Optimiseur de référence (OPTIMIZER_PROTOCOL.md 1.2, annexe E).
|
"""Optimiseur de référence (OPTIMIZER_PROTOCOL.md 1.3, annexe E).
|
||||||
|
|
||||||
etm-powersync-ref-optimizer [--listen unix:///run/powersync/optimizer.sock | tcp://hôte:port]
|
etm-powersync-ref-optimizer [--listen unix:///run/powersync/optimizer.sock | tcp://hôte:port]
|
||||||
[--token-file FICHIER] [--mode MODE | --mode-file FICHIER]
|
[--token-file FICHIER] [--mode MODE | --mode-file FICHIER]
|
||||||
[--slow-s N] [--horizon-s S] [--capture FICHIER]
|
[--slow-s N] [--horizon-s S] [--capture FICHIER] [--announce VERSION]
|
||||||
etm-powersync-ref-optimizer --list-codes
|
etm-powersync-ref-optimizer --list-codes
|
||||||
|
|
||||||
Un serveur JSON-RPC 2.0 sur NDJSON qui répond au `Handshake` et à `GetPlan` par un plan à UN
|
Un serveur JSON-RPC 2.0 sur NDJSON qui répond au `Handshake` et à `GetPlan` par un plan à UN
|
||||||
@ -37,6 +37,16 @@ Modes (`--mode`, pour les tests ; un seul à la fois) :
|
|||||||
deviate cascade servie en ordre de rang INVERSE (contre-épreuve : doit diverger)
|
deviate cascade servie en ordre de rang INVERSE (contre-épreuve : doit diverger)
|
||||||
wrong-type `planId` numérique au lieu d'une chaîne : le moteur l'ACCEPTE (il le lit comme
|
wrong-type `planId` numérique au lieu d'une chaîne : le moteur l'ACCEPTE (il le lit comme
|
||||||
texte), le contrat OpenRPC le REFUSE — contre-épreuve de la suite de conformité
|
texte), le contrat OpenRPC le REFUSE — contre-épreuve de la suite de conformité
|
||||||
|
old-data contexte invalide : `error.data` dans la forme d'avant 1.3 (`{"problemes": [chaînes]}`,
|
||||||
|
celle du premier lot de l'optimiseur ETM) — refusée d'un optimiseur 1.3, avertie
|
||||||
|
d'un optimiseur 1.2 (`--announce 1.2`)
|
||||||
|
echo-value contexte invalide : `error.data` recopie la valeur reçue — refusé quelle que soit
|
||||||
|
la version annoncée (règle de 1.2)
|
||||||
|
major-first handshake : la majeure jugée AVANT le token (token faux et autre majeure →
|
||||||
|
-32602 au lieu de -32001) — refusé d'un optimiseur 1.3
|
||||||
|
|
||||||
|
`--announce VERSION` : la version annoncée au handshake, et dont la majeure est exigée du moteur
|
||||||
|
(défaut : celle que parle cet optimiseur, 1.3). Pour les contre-épreuves : un optimiseur 1.2.
|
||||||
|
|
||||||
`--capture FICHIER` : chaque message reçu et envoyé, une ligne JSON-RPC par message, dans l'ordre
|
`--capture FICHIER` : chaque message reçu et envoyé, une ligne JSON-RPC par message, dans l'ordre
|
||||||
(la session que `tools/etm-powersync-schema-check --ndjson` juge). Le token d'un handshake y est
|
(la session que `tools/etm-powersync-schema-check --ndjson` juge). Le token d'un handshake y est
|
||||||
@ -55,11 +65,14 @@ se verrait dans `ps`) et jamais affiché. S'il est configuré, un handshake sans
|
|||||||
autre, reçoit une erreur JSON-RPC -32001 puis la connexion est fermée. Sans --token-file, un
|
autre, reçoit une erreur JSON-RPC -32001 puis la connexion est fermée. Sans --token-file, un
|
||||||
token reçu est accepté sans être lu.
|
token reçu est accepté sans être lu.
|
||||||
|
|
||||||
Erreurs (protocole 1.2, §2, §3, §7 ; codes JSON-RPC 2.0, plus -32001) :
|
Erreurs (protocole 1.2 et 1.3, §2, §3, §7 ; codes JSON-RPC 2.0, plus -32001) :
|
||||||
|
token absent ou différent -32001, puis fermeture — jugé AVANT la majeure (1.3)
|
||||||
Handshake d'une autre majeure -32602, puis fermeture (comme -32001 : les deux côtés ferment)
|
Handshake d'une autre majeure -32602, puis fermeture (comme -32001 : les deux côtés ferment)
|
||||||
GetPlan avant un handshake réussi -32600, connexion gardée
|
GetPlan avant un handshake réussi -32600, connexion gardée
|
||||||
contexte GetPlan invalide -32602, `error.data.problems` = [{path, problem}], jamais la
|
contexte GetPlan invalide -32602, `error.data` = {"problems": [{path, problem}]} (1.3 :
|
||||||
valeur reçue — une erreur, pas une abstention
|
chemin depuis `params`, code d'une liste fermée, 50 au plus et
|
||||||
|
`truncated` au-delà), jamais la valeur reçue — une erreur, pas
|
||||||
|
une abstention
|
||||||
ligne non JSON -32700, id null
|
ligne non JSON -32700, id null
|
||||||
lot JSON-RPC (`[…]`) -32600, id null, aucun message du lot traité
|
lot JSON-RPC (`[…]`) -32600, id null, aucun message du lot traité
|
||||||
ligne de plus de 1 Mio -32600, id null, jetée sans être gardée en mémoire
|
ligne de plus de 1 Mio -32600, id null, jetée sans être gardée en mémoire
|
||||||
@ -88,8 +101,7 @@ import time
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
ENDPOINT_DEFAUT = 'unix:///run/powersync/optimizer.sock'
|
ENDPOINT_DEFAUT = 'unix:///run/powersync/optimizer.sock'
|
||||||
VERSION = '1.2'
|
VERSION = '1.3'
|
||||||
MAJEURE = VERSION.split('.')[0]
|
|
||||||
NOM = 'etm-powersync-ref-optimizer'
|
NOM = 'etm-powersync-ref-optimizer'
|
||||||
# Nom annoncé au handshake : `powersync-optimizer`, l'exemple GPL de référence. `etm-optimizer`
|
# Nom annoncé au handshake : `powersync-optimizer`, l'exemple GPL de référence. `etm-optimizer`
|
||||||
# est le nom de l'optimiseur ETM, propriétaire, hors de ce dépôt.
|
# est le nom de l'optimiseur ETM, propriétaire, hors de ce dépôt.
|
||||||
@ -101,9 +113,11 @@ CODE_REQUETE_INVALIDE = -32600
|
|||||||
CODE_METHODE_INCONNUE = -32601
|
CODE_METHODE_INCONNUE = -32601
|
||||||
CODE_PARAMS_INVALIDES = -32602
|
CODE_PARAMS_INVALIDES = -32602
|
||||||
TROP_LONGUE = object() # lire_ligne() : ligne de plus de LIGNE_MAX octets, déjà jetée
|
TROP_LONGUE = object() # lire_ligne() : ligne de plus de LIGNE_MAX octets, déjà jetée
|
||||||
|
PROBLEMES_MAX = 50 # §7, 1.3 : au-delà, les 50 premiers et `truncated`
|
||||||
|
|
||||||
MODES = ['normal', 'abstain', 'slow', 'invalid', 'expired', 'wrong-major', 'drop', 'unknown-load',
|
MODES = ['normal', 'abstain', 'slow', 'invalid', 'expired', 'wrong-major', 'drop', 'unknown-load',
|
||||||
'forbidden-kind', 'missing-load', 'echo-token', 'protocol-1.0', 'deviate', 'wrong-type']
|
'forbidden-kind', 'missing-load', 'echo-token', 'protocol-1.0', 'deviate', 'wrong-type',
|
||||||
|
'old-data', 'echo-value', 'major-first']
|
||||||
|
|
||||||
# Catalogue (energyplugin/etm/types/decisionreason.cpp) : codes qu'un optimiseur peut émettre et
|
# Catalogue (energyplugin/etm/types/decisionreason.cpp) : codes qu'un optimiseur peut émettre et
|
||||||
# leurs clés de paramètres documentées (INTERFACE.md). Le test d'intégration les confronte au
|
# leurs clés de paramètres documentées (INTERFACE.md). Le test d'intégration les confronte au
|
||||||
@ -328,13 +342,32 @@ def iso(t):
|
|||||||
|
|
||||||
|
|
||||||
def problemes_contexte(ctx):
|
def problemes_contexte(ctx):
|
||||||
"""Ce que cet optimiseur ne peut pas lire dans un contexte GetPlan : [{path, problem}], jamais
|
"""Ce que cet optimiseur ne peut pas lire dans un contexte GetPlan : [{path, problem}] (§7, 1.3),
|
||||||
la valeur reçue (§7, 1.2). Seul `timestamp` est exigé — tout le reste se lit avec un défaut."""
|
chemin depuis `params`, nature dans la liste fermée — jamais la valeur reçue. Seul `timestamp`
|
||||||
if instant(ctx.get('timestamp')) is None:
|
est exigé : tout le reste se lit avec un défaut."""
|
||||||
return [{'path': '/timestamp', 'problem': 'absent, illisible ou sans décalage horaire'}]
|
if 'timestamp' not in ctx:
|
||||||
|
return [{'path': 'params.timestamp', 'problem': 'missing'}]
|
||||||
|
if not isinstance(ctx['timestamp'], str):
|
||||||
|
return [{'path': 'params.timestamp', 'problem': 'type'}]
|
||||||
|
if instant(ctx['timestamp']) is None:
|
||||||
|
return [{'path': 'params.timestamp', 'problem': 'format'}] # illisible, ou sans décalage
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def donnees_contexte_invalide(problemes, ctx, mode):
|
||||||
|
"""`error.data` du -32602 de GetPlan. Forme 1.3 : les 50 premiers problèmes, `truncated` au-delà.
|
||||||
|
Les modes old-data et echo-value fabriquent ce qu'un optimiseur NON conforme répondrait."""
|
||||||
|
if mode == 'old-data':
|
||||||
|
# Forme du premier lot de l'optimiseur ETM (avant 1.3) : des phrases, sans liste fermée.
|
||||||
|
return {'problemes': [f'{p["path"]} : {p["problem"]}' for p in problemes]}
|
||||||
|
data = {'problems': problemes[:PROBLEMES_MAX]}
|
||||||
|
if len(problemes) > PROBLEMES_MAX:
|
||||||
|
data['truncated'] = True
|
||||||
|
if mode == 'echo-value':
|
||||||
|
data['problems'][0] = dict(data['problems'][0], received=ctx.get('timestamp'))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def plan(ctx, mode, horizon_s, token):
|
def plan(ctx, mode, horizon_s, token):
|
||||||
"""La réponse à un GetPlan (`result`) ; le contexte a passé problemes_contexte()."""
|
"""La réponse à un GetPlan (`result`) ; le contexte a passé problemes_contexte()."""
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
@ -498,19 +531,27 @@ class Connexion(socketserver.StreamRequestHandler):
|
|||||||
mode = mode_courant(cfg)
|
mode = mode_courant(cfg)
|
||||||
if methode == 'Handshake':
|
if methode == 'Handshake':
|
||||||
attendu = lire_token(cfg.token_file)
|
attendu = lire_token(cfg.token_file)
|
||||||
if attendu is not None and params.get('token') != attendu:
|
version = params.get('protocolVersion')
|
||||||
|
token_refuse = attendu is not None and params.get('token') != attendu
|
||||||
|
majeure_refusee = not isinstance(version, str) or version.split('.')[0] != cfg.announce.split('.')[0]
|
||||||
|
# §3, 1.3 : le token d'abord — un pair sans le secret n'apprend pas quelle majeure on
|
||||||
|
# parle. Le mode major-first inverse l'ordre (contre-épreuve de la conformité).
|
||||||
|
if mode == 'major-first' and majeure_refusee:
|
||||||
|
journal(f'handshake refusé : majeure incompatible ({str(version)[:32]}), mode major-first')
|
||||||
|
self.erreur(ident, CODE_PARAMS_INVALIDES, 'majeure incompatible')
|
||||||
|
return
|
||||||
|
if token_refuse:
|
||||||
# Le token reçu n'est jamais recopié ni journalisé.
|
# Le token reçu n'est jamais recopié ni journalisé.
|
||||||
journal('handshake refusé : token absent ou différent')
|
journal('handshake refusé : token absent ou différent')
|
||||||
self.erreur(ident, CODE_TOKEN_REFUSE, 'token refusé')
|
self.erreur(ident, CODE_TOKEN_REFUSE, 'token refusé')
|
||||||
return
|
return
|
||||||
version = params.get('protocolVersion')
|
if majeure_refusee:
|
||||||
if not isinstance(version, str) or version.split('.')[0] != MAJEURE:
|
|
||||||
journal(f'handshake refusé : majeure incompatible ({str(version)[:32]})')
|
journal(f'handshake refusé : majeure incompatible ({str(version)[:32]})')
|
||||||
self.erreur(ident, CODE_PARAMS_INVALIDES, 'majeure incompatible')
|
self.erreur(ident, CODE_PARAMS_INVALIDES, 'majeure incompatible')
|
||||||
return
|
return
|
||||||
token = attendu
|
token = attendu
|
||||||
pret = True
|
pret = True
|
||||||
reponse = {'protocolVersion': VERSION, 'name': NOM_ANNONCE, 'planning': False,
|
reponse = {'protocolVersion': cfg.announce, 'name': NOM_ANNONCE, 'planning': False,
|
||||||
'capabilities': {'resolutionS': cfg.horizon_s, 'kinds': ['setpoint', 'state'],
|
'capabilities': {'resolutionS': cfg.horizon_s, 'kinds': ['setpoint', 'state'],
|
||||||
'usesForecast': [], 'reasonCodes': True}}
|
'usesForecast': [], 'reasonCodes': True}}
|
||||||
if mode == 'wrong-major':
|
if mode == 'wrong-major':
|
||||||
@ -526,8 +567,9 @@ class Connexion(socketserver.StreamRequestHandler):
|
|||||||
continue
|
continue
|
||||||
problemes = problemes_contexte(params)
|
problemes = problemes_contexte(params)
|
||||||
if problemes:
|
if problemes:
|
||||||
journal(f'contexte invalide : {", ".join(p["path"] for p in problemes)}')
|
journal(f'contexte invalide : {", ".join(p["path"] + " " + p["problem"] for p in problemes)}')
|
||||||
self.erreur(ident, CODE_PARAMS_INVALIDES, 'contexte invalide', {'problems': problemes})
|
self.erreur(ident, CODE_PARAMS_INVALIDES, 'contexte invalide',
|
||||||
|
donnees_contexte_invalide(problemes, params, mode))
|
||||||
continue
|
continue
|
||||||
if mode == 'drop':
|
if mode == 'drop':
|
||||||
journal('mode drop : connexion fermée sans réponse')
|
journal('mode drop : connexion fermée sans réponse')
|
||||||
@ -582,7 +624,7 @@ def ecouter(endpoint):
|
|||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
p = argparse.ArgumentParser(prog=NOM, description='Optimiseur de référence (protocole 1.2, annexe E).')
|
p = argparse.ArgumentParser(prog=NOM, description='Optimiseur de référence (protocole 1.3, annexe E).')
|
||||||
p.add_argument('--listen', default=ENDPOINT_DEFAUT,
|
p.add_argument('--listen', default=ENDPOINT_DEFAUT,
|
||||||
help=f'unix:///chemin ou tcp://hôte:port (défaut : {ENDPOINT_DEFAUT})')
|
help=f'unix:///chemin ou tcp://hôte:port (défaut : {ENDPOINT_DEFAUT})')
|
||||||
p.add_argument('--token-file', help='fichier contenant le token attendu (jamais en argument)')
|
p.add_argument('--token-file', help='fichier contenant le token attendu (jamais en argument)')
|
||||||
@ -592,12 +634,16 @@ def main(argv=None):
|
|||||||
p.add_argument('--horizon-s', type=int, default=300,
|
p.add_argument('--horizon-s', type=int, default=300,
|
||||||
help='durée du créneau et de validUntil, en s (défaut 300)')
|
help='durée du créneau et de validUntil, en s (défaut 300)')
|
||||||
p.add_argument('--capture', help='écrit chaque message reçu et envoyé dans ce fichier (NDJSON)')
|
p.add_argument('--capture', help='écrit chaque message reçu et envoyé dans ce fichier (NDJSON)')
|
||||||
|
p.add_argument('--announce', default=VERSION,
|
||||||
|
help=f'version annoncée au handshake, sa majeure exigée du moteur (défaut : {VERSION})')
|
||||||
p.add_argument('--list-codes', action='store_true',
|
p.add_argument('--list-codes', action='store_true',
|
||||||
help='écrit le catalogue des codes émissibles (JSON) et sort')
|
help='écrit le catalogue des codes émissibles (JSON) et sort')
|
||||||
args = p.parse_args(argv)
|
args = p.parse_args(argv)
|
||||||
if args.list_codes:
|
if args.list_codes:
|
||||||
print(json.dumps(CODES_EMISSIBLES, sort_keys=True))
|
print(json.dumps(CODES_EMISSIBLES, sort_keys=True))
|
||||||
return 0
|
return 0
|
||||||
|
if not args.announce.split('.')[0].isdigit():
|
||||||
|
p.error(f'--announce : version semver attendue, pas « {args.announce} »')
|
||||||
def arreter(*_):
|
def arreter(*_):
|
||||||
raise KeyboardInterrupt
|
raise KeyboardInterrupt
|
||||||
signal.signal(signal.SIGTERM, arreter) # arrêt propre : le socket unix est retiré
|
signal.signal(signal.SIGTERM, arreter) # arrêt propre : le socket unix est retiré
|
||||||
|
|||||||
@ -6,7 +6,10 @@
|
|||||||
etm-powersync-schema-check [--schema FICHIER] --method M --params FICHIER.json
|
etm-powersync-schema-check [--schema FICHIER] --method M --params FICHIER.json
|
||||||
etm-powersync-schema-check [--schema FICHIER] --method M --result FICHIER.json
|
etm-powersync-schema-check [--schema FICHIER] --method M --result FICHIER.json
|
||||||
[--request PARAMS.json] [--handshake RESULT.json] [--semantic]
|
[--request PARAMS.json] [--handshake RESULT.json] [--semantic]
|
||||||
etm-powersync-schema-check [--schema FICHIER] --ndjson SESSION.ndjson [--semantic] [--requests-only]
|
etm-powersync-schema-check [--schema FICHIER] --method M --error FICHIER.json
|
||||||
|
[--request PARAMS.json] [--handshake RESULT.json]
|
||||||
|
etm-powersync-schema-check [--schema FICHIER] --ndjson SESSION.ndjson [--semantic]
|
||||||
|
[--requests-only | --responses-only]
|
||||||
etm-powersync-schema-check [--schema FICHIER] --trace TRACE.ndjson [--semantic]
|
etm-powersync-schema-check [--schema FICHIER] --trace TRACE.ndjson [--semantic]
|
||||||
etm-powersync-schema-check --subset
|
etm-powersync-schema-check --subset
|
||||||
|
|
||||||
@ -28,18 +31,35 @@ Ce que l'outil lit en plus du JSON Schema :
|
|||||||
- `x-etm-reason-params` : clés de `reasonParams` admises par `reasonCode` (contrôle de forme) ;
|
- `x-etm-reason-params` : clés de `reasonParams` admises par `reasonCode` (contrôle de forme) ;
|
||||||
- `x-etm-semantic-rules` (avec `--semantic`) : ce que le validateur du moteur refuse EN PLUS de
|
- `x-etm-semantic-rules` (avec `--semantic`) : ce que le validateur du moteur refuse EN PLUS de
|
||||||
la forme, dans le même ordre, sous le même nom. Une règle du schéma sans implantation ici, ou
|
la forme, dans le même ordre, sous le même nom. Une règle du schéma sans implantation ici, ou
|
||||||
l'inverse, est une erreur de schéma (code 2) : le contrôle ne disparaît pas en silence.
|
l'inverse, est une erreur de schéma (code 2) : le contrôle ne disparaît pas en silence ;
|
||||||
|
- `methods[].errors[].x-etm-data-schema` (protocole 1.3) : la forme de `error.data` d'une réponse
|
||||||
|
error de ce code — `InvalidContextData` pour le `-32602` de `GetPlan`. Exigée d'un optimiseur qui
|
||||||
|
s'annonce `x-etm-data-required-from` ou plus (version lue dans SON `Handshake`) ; plus ancienne,
|
||||||
|
un écart n'est qu'un AVERTISSEMENT, qui ne change pas le code de sortie. Une version inconnue (pas
|
||||||
|
de handshake) est jugée comme celle du contrat. En plus de la forme, deux contrôles :
|
||||||
|
`truncated` n'accompagne que 50 problèmes exactement ; et AUCUNE VALEUR REÇUE n'est recopiée dans
|
||||||
|
`error.data` (§7 — règle de 1.2, donc un écart dès 1.2, jamais un avertissement). Ce dernier
|
||||||
|
contrôle porte sur les valeurs textuelles de la requête qu'un chemin conforme ne peut pas
|
||||||
|
contenir (4 caractères au moins, dont un hors `[A-Za-z0-9_]` : instants, identifiants à tiret,
|
||||||
|
libellés) : il ne peut pas accuser un chemin conforme. Une recopie de nombre ou de mot nu lui
|
||||||
|
échappe ; la forme 1.3, fermée, la rend inexprimable.
|
||||||
|
|
||||||
Modes :
|
Modes :
|
||||||
- `--ndjson` : une session, un message JSON-RPC par ligne, dans les deux sens. Une réponse est
|
- `--ndjson` : une session, un message JSON-RPC par ligne, dans les deux sens. Une réponse est
|
||||||
rapprochée de sa requête par `id` ; un `GetPlan` est jugé avec SA requête et le dernier
|
rapprochée de sa requête par `id` ; un `GetPlan` est jugé avec SA requête et le dernier
|
||||||
`Handshake` accepté. `--requests-only` ne juge que ce qu'émet le moteur (requêtes).
|
`Handshake` accepté. `--requests-only` ne juge que ce qu'émet le moteur (requêtes) ;
|
||||||
|
`--responses-only` que ce qu'émet l'optimiseur (réponses) — pour une session de SONDE, dont les
|
||||||
|
requêtes sont fautives à dessein (un contexte invalide, pour juger le `-32602` qui lui répond).
|
||||||
- `--trace` : traces de décision du moteur (annexe D) — `request` contre les params de
|
- `--trace` : traces de décision du moteur (annexe D) — `request` contre les params de
|
||||||
`GetPlan` ; `response.result` contre son résultat quand la ligne vient d'un optimiseur
|
`GetPlan` ; `response.result` contre son résultat quand la ligne vient d'un optimiseur
|
||||||
(`latencyMs` présent) — le plan du rule-based tracé n'est pas une réponse d'optimiseur.
|
(`latencyMs` présent) — le plan du rule-based tracé n'est pas une réponse d'optimiseur. Une
|
||||||
|
erreur n'y porte que le code d'échec du moteur : rien à juger de son `data`.
|
||||||
|
- `--error` : l'objet `error` d'une réponse à `--method` ; son `data` est jugé comme en session,
|
||||||
|
la version tirée de `--handshake` (le `result` du Handshake), la requête de `--request`.
|
||||||
|
|
||||||
Sortie : une ligne par écart, puis `RÉSUMÉ {json}` (comptes par méthode et partie, écarts,
|
Sortie : une ligne par écart, une ligne `AVERTISSEMENT …` par écart toléré (forme normée après la
|
||||||
verdicts sémantiques). Code : 0 conforme ; 1 au moins un écart de forme, ou (avec `--semantic`) un
|
version de l'optimiseur), puis `RÉSUMÉ {json}` (comptes par méthode et partie, écarts,
|
||||||
|
avertissements, verdicts sémantiques). Code : 0 conforme ; 1 au moins un écart de forme, ou (avec `--semantic`) un
|
||||||
verdict qui REFUSE (`invalid`, `connection-refused`) ; 2 erreur d'usage ou de schéma.
|
verdict qui REFUSE (`invalid`, `connection-refused`) ; 2 erreur d'usage ou de schéma.
|
||||||
Les verdicts `abstention` et `expired` sont dits, sans échec : ce sont des réponses permises.
|
Les verdicts `abstention` et `expired` sont dits, sans échec : ce sont des réponses permises.
|
||||||
"""
|
"""
|
||||||
@ -125,6 +145,10 @@ class Validateur:
|
|||||||
racines.append((f'méthode {m.get("name")}, param {p.get("name")}', p.get('schema')))
|
racines.append((f'méthode {m.get("name")}, param {p.get("name")}', p.get('schema')))
|
||||||
if 'result' in m:
|
if 'result' in m:
|
||||||
racines.append((f'méthode {m.get("name")}, result', m['result'].get('schema')))
|
racines.append((f'méthode {m.get("name")}, result', m['result'].get('schema')))
|
||||||
|
for e in m.get('errors', []):
|
||||||
|
if 'x-etm-data-schema' in e:
|
||||||
|
racines.append((f'méthode {m.get("name")}, erreur {e.get("code")}, data',
|
||||||
|
e['x-etm-data-schema']))
|
||||||
for ou, s in racines:
|
for ou, s in racines:
|
||||||
self.auditer_schema(s, ou)
|
self.auditer_schema(s, ou)
|
||||||
|
|
||||||
@ -284,6 +308,16 @@ class Contrat:
|
|||||||
self.kinds = self.v.resoudre('#/components/schemas/ActionKind').get('enum', [])
|
self.kinds = self.v.resoudre('#/components/schemas/ActionKind').get('enum', [])
|
||||||
self.version = str(self.v.resoudre('#/components/schemas/ProtocolVersion').get('const', ''))
|
self.version = str(self.v.resoudre('#/components/schemas/ProtocolVersion').get('const', ''))
|
||||||
self.cles_motif = doc.get('x-etm-reason-params') or {}
|
self.cles_motif = doc.get('x-etm-reason-params') or {}
|
||||||
|
# (méthode, code) → (schéma de error.data, version de l'optimiseur à partir de laquelle il est exigé)
|
||||||
|
self.donnees_erreur = {}
|
||||||
|
for m in doc.get('methods', []):
|
||||||
|
for e in m.get('errors', []):
|
||||||
|
if 'x-etm-data-schema' in e:
|
||||||
|
depuis = version_lue(e.get('x-etm-data-required-from'))
|
||||||
|
if depuis is None:
|
||||||
|
raise ErreurSchema(f'{m["name"]}, erreur {e.get("code")} : x-etm-data-required-from '
|
||||||
|
f'absent ou illisible')
|
||||||
|
self.donnees_erreur[(m['name'], e.get('code'))] = (e['x-etm-data-schema'], depuis)
|
||||||
|
|
||||||
def methode(self, nom):
|
def methode(self, nom):
|
||||||
if nom not in self.methodes:
|
if nom not in self.methodes:
|
||||||
@ -330,6 +364,85 @@ class Contrat:
|
|||||||
return e
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
# --- error.data (protocole 1.3) ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def version_lue(v):
|
||||||
|
"""« 1.3 » → (1, 3) ; « 1 » → (1, 0) ; illisible → None."""
|
||||||
|
if not isinstance(v, str):
|
||||||
|
return None
|
||||||
|
morceaux = v.split('.')
|
||||||
|
try:
|
||||||
|
return int(morceaux[0]), int(morceaux[1]) if len(morceaux) > 1 else 0
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
TEXTE_HORS_CHEMIN = re.compile(r'[^A-Za-z0-9_]')
|
||||||
|
RECOPIE_DEPUIS = (1, 2) # « jamais la valeur reçue » : règle de 1.2 (§7)
|
||||||
|
MAX_PROBLEMES = 50
|
||||||
|
|
||||||
|
|
||||||
|
def valeurs_reconnaissables(params, chemin='params'):
|
||||||
|
"""Les valeurs textuelles de la requête qu'un chemin conforme ne peut pas contenir : (chemin, valeur)."""
|
||||||
|
if isinstance(params, dict):
|
||||||
|
for k, v in params.items():
|
||||||
|
yield from valeurs_reconnaissables(v, f'{chemin}.{k}')
|
||||||
|
elif isinstance(params, list):
|
||||||
|
for i, v in enumerate(params):
|
||||||
|
yield from valeurs_reconnaissables(v, f'{chemin}[{i}]')
|
||||||
|
elif isinstance(params, str) and len(params) >= 4 and TEXTE_HORS_CHEMIN.search(params):
|
||||||
|
yield chemin, params
|
||||||
|
|
||||||
|
|
||||||
|
def textes(v):
|
||||||
|
"""Toutes les chaînes d'un JSON, clés comprises."""
|
||||||
|
if isinstance(v, str):
|
||||||
|
yield v
|
||||||
|
elif isinstance(v, dict):
|
||||||
|
for k, x in v.items():
|
||||||
|
yield k
|
||||||
|
yield from textes(x)
|
||||||
|
elif isinstance(v, list):
|
||||||
|
for x in v:
|
||||||
|
yield from textes(x)
|
||||||
|
|
||||||
|
|
||||||
|
def juger_donnees_erreur(contrat, methode, erreur, requete, version):
|
||||||
|
"""error.data d'une réponse error : (écarts, avertissements), chacun (chemin, mot-clé, message).
|
||||||
|
|
||||||
|
`version` : protocolVersion annoncée par l'optimiseur au handshake (None : inconnue, jugée comme
|
||||||
|
celle du contrat). La forme n'est exigée qu'à partir de x-etm-data-required-from ; la recopie
|
||||||
|
d'une valeur reçue est un écart dès 1.2."""
|
||||||
|
if not isinstance(erreur, dict):
|
||||||
|
return [], []
|
||||||
|
forme = contrat.donnees_erreur.get((methode, erreur.get('code')))
|
||||||
|
if forme is None:
|
||||||
|
return [], []
|
||||||
|
schema, depuis = forme
|
||||||
|
v = version_lue(version) or version_lue(contrat.version)
|
||||||
|
if 'data' not in erreur:
|
||||||
|
ecarts_forme = [('/error', 'required', '« data » manquant')]
|
||||||
|
else:
|
||||||
|
data = erreur['data']
|
||||||
|
ecarts_forme = contrat.v.valider(data, schema, '/error/data')
|
||||||
|
if isinstance(data, dict) and data.get('truncated') is True and isinstance(data.get('problems'), list) \
|
||||||
|
and len(data['problems']) != MAX_PROBLEMES:
|
||||||
|
ecarts_forme.append(('/error/data/truncated', 'x-etm-truncated',
|
||||||
|
f'truncated avec {len(data["problems"])} problème(s) : seulement quand '
|
||||||
|
f'{MAX_PROBLEMES} sont donnés et d\'autres omis'))
|
||||||
|
recopies = []
|
||||||
|
if 'data' in erreur and isinstance(requete, dict):
|
||||||
|
vus = list(textes(erreur['data']))
|
||||||
|
for chemin, valeur in valeurs_reconnaissables(requete):
|
||||||
|
if any(valeur in t for t in vus):
|
||||||
|
recopies.append(('/error/data', 'x-etm-no-echo',
|
||||||
|
f'recopie la valeur reçue en {chemin} — jamais la valeur reçue (§7)'))
|
||||||
|
ecarts, avertissements = [], []
|
||||||
|
(ecarts if v >= depuis else avertissements).extend(ecarts_forme)
|
||||||
|
(ecarts if v >= RECOPIE_DEPUIS else avertissements).extend(recopies)
|
||||||
|
return ecarts, avertissements
|
||||||
|
|
||||||
|
|
||||||
# --- Règles sémantiques : le validateur du moteur, transcrit dans son ordre -----------------------
|
# --- Règles sémantiques : le validateur du moteur, transcrit dans son ordre -----------------------
|
||||||
|
|
||||||
REGLES_IMPLANTEES = ['abstain', 'malformed-plan', 'empty-plan', 'bad-time', 'slots-unordered',
|
REGLES_IMPLANTEES = ['abstain', 'malformed-plan', 'empty-plan', 'bad-time', 'slots-unordered',
|
||||||
@ -500,6 +613,7 @@ class Bilan:
|
|||||||
self.semantique = semantique
|
self.semantique = semantique
|
||||||
self.comptes = {}
|
self.comptes = {}
|
||||||
self.ecarts = []
|
self.ecarts = []
|
||||||
|
self.avertissements = []
|
||||||
self.verdicts = {}
|
self.verdicts = {}
|
||||||
self.refus = 0
|
self.refus = 0
|
||||||
|
|
||||||
@ -512,6 +626,22 @@ class Bilan:
|
|||||||
self.ecarts.append({'ou': ou, 'partie': partie, 'chemin': chemin, 'mot': mot, 'message': msg})
|
self.ecarts.append({'ou': ou, 'partie': partie, 'chemin': chemin, 'mot': mot, 'message': msg})
|
||||||
print(f'{ou} {partie} {chemin} : {msg} [{mot}]')
|
print(f'{ou} {partie} {chemin} : {msg} [{mot}]')
|
||||||
|
|
||||||
|
def avertir(self, ou, partie, ecarts, pourquoi):
|
||||||
|
for chemin, mot, msg in ecarts:
|
||||||
|
self.avertissements.append({'ou': ou, 'partie': partie, 'chemin': chemin, 'mot': mot, 'message': msg})
|
||||||
|
print(f'AVERTISSEMENT {ou} {partie} {chemin} : {msg} [{mot}] — {pourquoi}')
|
||||||
|
|
||||||
|
def juger_erreur(self, ou, methode, erreur, requete, version):
|
||||||
|
ecarts, avertissements = juger_donnees_erreur(self.c, methode, erreur, requete, version)
|
||||||
|
if (methode, erreur.get('code') if isinstance(erreur, dict) else None) not in self.c.donnees_erreur:
|
||||||
|
return
|
||||||
|
partie = f'{methode}.error.data'
|
||||||
|
self.noter(ou, partie, ecarts)
|
||||||
|
forme = self.c.donnees_erreur[(methode, erreur['code'])]
|
||||||
|
self.avertir(ou, partie, avertissements,
|
||||||
|
f'optimiseur en {str(version)[:16]}, forme exigée à partir de '
|
||||||
|
f'{forme[1][0]}.{forme[1][1]} : à aligner avant de s\'annoncer {forme[1][0]}.{forme[1][1]}')
|
||||||
|
|
||||||
def verdict(self, ou, code):
|
def verdict(self, ou, code):
|
||||||
if code is None:
|
if code is None:
|
||||||
code = 'valide'
|
code = 'valide'
|
||||||
@ -529,6 +659,7 @@ class Bilan:
|
|||||||
|
|
||||||
def resume(self):
|
def resume(self):
|
||||||
print('RÉSUMÉ ' + json.dumps({'comptes': self.comptes, 'ecarts': len(self.ecarts),
|
print('RÉSUMÉ ' + json.dumps({'comptes': self.comptes, 'ecarts': len(self.ecarts),
|
||||||
|
'avertissements': len(self.avertissements),
|
||||||
'verdicts': self.verdicts, 'refus': self.refus},
|
'verdicts': self.verdicts, 'refus': self.refus},
|
||||||
ensure_ascii=False, sort_keys=True))
|
ensure_ascii=False, sort_keys=True))
|
||||||
|
|
||||||
@ -553,10 +684,11 @@ class ErreurLigne:
|
|||||||
self.msg = msg
|
self.msg = msg
|
||||||
|
|
||||||
|
|
||||||
def session(b, chemin, requetes_seules):
|
def session(b, chemin, requetes_seules, reponses_seules=False):
|
||||||
"""Un message JSON-RPC par ligne ; réponses rapprochées par id."""
|
"""Un message JSON-RPC par ligne ; réponses rapprochées par id."""
|
||||||
attente = {} # id → (méthode, params)
|
attente = {} # id → (méthode, params)
|
||||||
handshake = None # dernier result de Handshake accepté
|
handshake = None # dernier result de Handshake accepté
|
||||||
|
version = None # protocolVersion de l'optimiseur, lue dans ce result (error.data, 1.3)
|
||||||
for n, m in lignes(chemin):
|
for n, m in lignes(chemin):
|
||||||
ou = f'ligne {n}'
|
ou = f'ligne {n}'
|
||||||
if isinstance(m, ErreurLigne) or not isinstance(m, dict):
|
if isinstance(m, ErreurLigne) or not isinstance(m, dict):
|
||||||
@ -569,7 +701,8 @@ def session(b, chemin, requetes_seules):
|
|||||||
if nom not in b.c.methodes:
|
if nom not in b.c.methodes:
|
||||||
b.noter(ou, 'enveloppe', [('/method', 'enum', f'méthode « {nom} » absente du contrat')])
|
b.noter(ou, 'enveloppe', [('/method', 'enum', f'méthode « {nom} » absente du contrat')])
|
||||||
continue
|
continue
|
||||||
b.noter(ou, f'{nom}.params', b.c.valider_params(nom, m.get('params', {})))
|
if not reponses_seules:
|
||||||
|
b.noter(ou, f'{nom}.params', b.c.valider_params(nom, m.get('params', {})))
|
||||||
if m.get('id') is not None:
|
if m.get('id') is not None:
|
||||||
attente[m['id']] = (nom, m.get('params'))
|
attente[m['id']] = (nom, m.get('params'))
|
||||||
continue
|
continue
|
||||||
@ -587,9 +720,11 @@ def session(b, chemin, requetes_seules):
|
|||||||
b.verdict(ou, code)
|
b.verdict(ou, code)
|
||||||
if code is None:
|
if code is None:
|
||||||
handshake = m['result']
|
handshake = m['result']
|
||||||
|
version = handshake.get('protocolVersion')
|
||||||
elif 'error' in m:
|
elif 'error' in m:
|
||||||
b.compter(f'{nom}.error')
|
b.compter(f'{nom}.error')
|
||||||
print(f'{ou} {nom} : réponse error (compte comme un échec de la requête, rpc-error)')
|
print(f'{ou} {nom} : réponse error (compte comme un échec de la requête, rpc-error)')
|
||||||
|
b.juger_erreur(ou, nom, m['error'], params, version)
|
||||||
if b.semantique:
|
if b.semantique:
|
||||||
b.refus += 1
|
b.refus += 1
|
||||||
else:
|
else:
|
||||||
@ -623,12 +758,15 @@ def main(argv=None):
|
|||||||
p.add_argument('--method', help='méthode du message (--params / --result)')
|
p.add_argument('--method', help='méthode du message (--params / --result)')
|
||||||
p.add_argument('--params', help='fichier JSON : les params d\'une requête')
|
p.add_argument('--params', help='fichier JSON : les params d\'une requête')
|
||||||
p.add_argument('--result', help='fichier JSON : le result d\'une réponse')
|
p.add_argument('--result', help='fichier JSON : le result d\'une réponse')
|
||||||
|
p.add_argument('--error', help='fichier JSON : l\'objet error d\'une réponse (forme de son data)')
|
||||||
p.add_argument('--request', help='fichier JSON : les params de la requête (règles sémantiques de GetPlan)')
|
p.add_argument('--request', help='fichier JSON : les params de la requête (règles sémantiques de GetPlan)')
|
||||||
p.add_argument('--handshake', help='fichier JSON : le result du Handshake (kinds annoncés)')
|
p.add_argument('--handshake', help='fichier JSON : le result du Handshake (kinds annoncés)')
|
||||||
p.add_argument('--ndjson', help='session JSON-RPC, un message par ligne')
|
p.add_argument('--ndjson', help='session JSON-RPC, un message par ligne')
|
||||||
p.add_argument('--trace', help='traces de décision du moteur (annexe D)')
|
p.add_argument('--trace', help='traces de décision du moteur (annexe D)')
|
||||||
p.add_argument('--semantic', action='store_true', help='applique x-etm-semantic-rules')
|
p.add_argument('--semantic', action='store_true', help='applique x-etm-semantic-rules')
|
||||||
p.add_argument('--requests-only', action='store_true', help='--ndjson : ne juge que les requêtes')
|
p.add_argument('--requests-only', action='store_true', help='--ndjson : ne juge que les requêtes')
|
||||||
|
p.add_argument('--responses-only', action='store_true',
|
||||||
|
help='--ndjson : ne juge que les réponses (session de sonde, requêtes fautives à dessein)')
|
||||||
p.add_argument('--subset', action='store_true', help='écrit le sous-ensemble géré et sort')
|
p.add_argument('--subset', action='store_true', help='écrit le sous-ensemble géré et sort')
|
||||||
a = p.parse_args(argv)
|
a = p.parse_args(argv)
|
||||||
if a.subset:
|
if a.subset:
|
||||||
@ -639,10 +777,12 @@ def main(argv=None):
|
|||||||
contrat = Contrat(lire_json(a.schema))
|
contrat = Contrat(lire_json(a.schema))
|
||||||
b = Bilan(contrat, a.semantic)
|
b = Bilan(contrat, a.semantic)
|
||||||
if a.ndjson:
|
if a.ndjson:
|
||||||
session(b, a.ndjson, a.requests_only)
|
if a.requests_only and a.responses_only:
|
||||||
|
p.error('--requests-only et --responses-only s\'excluent')
|
||||||
|
session(b, a.ndjson, a.requests_only, a.responses_only)
|
||||||
elif a.trace:
|
elif a.trace:
|
||||||
trace(b, a.trace)
|
trace(b, a.trace)
|
||||||
elif a.method and (a.params or a.result):
|
elif a.method and (a.params or a.result or a.error):
|
||||||
if a.params:
|
if a.params:
|
||||||
b.noter(a.params, f'{a.method}.params', contrat.valider_params(a.method, lire_json(a.params)))
|
b.noter(a.params, f'{a.method}.params', contrat.valider_params(a.method, lire_json(a.params)))
|
||||||
if a.result:
|
if a.result:
|
||||||
@ -656,8 +796,14 @@ def main(argv=None):
|
|||||||
b.verdict(a.result, juger_plan(r, req, hs, contrat.kinds))
|
b.verdict(a.result, juger_plan(r, req, hs, contrat.kinds))
|
||||||
elif a.semantic and a.method == 'Handshake':
|
elif a.semantic and a.method == 'Handshake':
|
||||||
b.verdict(a.result, juger_handshake({'result': r}, contrat.version))
|
b.verdict(a.result, juger_handshake({'result': r}, contrat.version))
|
||||||
|
if a.error:
|
||||||
|
contrat.methode(a.method)
|
||||||
|
hs = lire_json(a.handshake) if a.handshake else None
|
||||||
|
b.compter(f'{a.method}.error')
|
||||||
|
b.juger_erreur(a.error, a.method, lire_json(a.error), lire_json(a.request) if a.request else None,
|
||||||
|
hs.get('protocolVersion') if isinstance(hs, dict) else None)
|
||||||
else:
|
else:
|
||||||
p.error('rien à valider : --ndjson, --trace, ou --method avec --params / --result')
|
p.error('rien à valider : --ndjson, --trace, ou --method avec --params / --result / --error')
|
||||||
except ErreurSchema as e:
|
except ErreurSchema as e:
|
||||||
print(f'ERREUR DE SCHÉMA : {e}', file=sys.stderr)
|
print(f'ERREUR DE SCHÉMA : {e}', file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user