Fusion feature/protocole-1.2 : protocole optimiseur 1.2, règles Q2 à Q9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HE6X3TcKdiuCucBHA2XX4n
This commit is contained in:
commit
c831fb5c42
@ -1,4 +1,4 @@
|
||||
# OPTIMIZER_PROTOCOL — v1.1
|
||||
# OPTIMIZER_PROTOCOL — v1.2
|
||||
|
||||
Contrat entre le moteur d'énergie **etm-powersync-energy-plugin-etm** (couche A, GPL-3.0)
|
||||
et un **optimiseur** externe (couche B : l'optimiseur ETM, ou toute implémentation tierce).
|
||||
@ -8,7 +8,7 @@ Ce document fait autorité. Il sert trois usages :
|
||||
2. Spécification du protocole socket pour un optimiseur en process séparé.
|
||||
3. Documentation publique : n'importe qui peut implémenter son propre optimiseur.
|
||||
|
||||
Statut : **v1.1** — figé à la fin de la beta. Versionné en semver (`protocolVersion`).
|
||||
Statut : **v1.2** — figé à la fin de la beta. Versionné en semver (`protocolVersion`).
|
||||
|
||||
> ### La forme est un contrat MACHINE
|
||||
>
|
||||
@ -42,6 +42,40 @@ Statut : **v1.1** — figé à la fin de la beta. Versionné en semver (`protoco
|
||||
|
||||
## 0. Changelog
|
||||
|
||||
### 1.2 (2026-10)
|
||||
Mineure. Norme les points que le premier optimiseur réel a trouvés ouverts : erreurs renvoyées par
|
||||
l'optimiseur, fermeture après un refus, token non exigé, sens d'`UpdateLearned`, longueur de ligne,
|
||||
lots, propriétaire de `/run/powersync`. **Côté moteur, seule la version annoncée change** : il
|
||||
n'émet aucun message nouveau et juge les réponses exactement comme en 1.1.
|
||||
|
||||
**Compatibilité.** Même majeure : un moteur 1.2 accepte un optimiseur 1.0 ou 1.1, et un
|
||||
optimiseur 1.2 accepte un moteur 1.0 ou 1.1 (seule la majeure est comparée, §3). Les règles
|
||||
nouvelles portent sur ce que fait l'optimiseur dans des situations que le moteur ne produit pas —
|
||||
contexte invalide, `GetPlan` avant handshake, autre majeure, lot — et sur une qu'il peut produire,
|
||||
la ligne de plus de 1 Mio (§2, écart connu). Un optimiseur 1.1 qui y répond autrement continue de
|
||||
fonctionner avec ce moteur ; il n'est pas conforme 1.2 sur ces points, et s'aligne avant de
|
||||
s'annoncer 1.2.
|
||||
|
||||
- §7 **Contexte `GetPlan` invalide** : l'optimiseur répond une **erreur `-32602`**, pas une
|
||||
abstention ; `error.data` liste les chemins fautifs et la nature de chaque problème, jamais les
|
||||
valeurs. Le moteur la compte comme un échec (règle des échecs consécutifs inchangée).
|
||||
- §3, §7 **Codes d'erreur de l'optimiseur normés** : majeure incompatible au handshake → `-32602`
|
||||
puis fermeture ; `GetPlan` avant un handshake réussi → `-32600`, connexion gardée ; token faux
|
||||
ou absent → `-32001` (inchangé). Toute autre erreur : un code JSON-RPC 2.0 standard.
|
||||
- §3 **Après un handshake refusé, les deux côtés ferment** : l'optimiseur après avoir envoyé
|
||||
l'erreur, le moteur à sa réception.
|
||||
- §3 **Token envoyé alors que l'optimiseur n'en exige pas : accepté**, sans erreur.
|
||||
- §8 **`UpdateLearned`** : sens optimiseur → moteur uniquement ; reçu par un optimiseur,
|
||||
notification ignorée, requête avec `id` → `-32601`. Sa **forme reste non normée en 1.2**.
|
||||
- §2 **`maxLineBytes` (1 Mio) dans les deux sens** ; ligne trop longue reçue par l'optimiseur →
|
||||
`-32600`, connexion gardée.
|
||||
- §2 **Lots JSON-RPC interdits** dans les deux sens : un message par ligne ; un lot reçu → `-32600`.
|
||||
- §2 **`/run/powersync` appartient au paquet de l'optimiseur** ; le moteur ne le crée ni ne le
|
||||
suppose.
|
||||
- Contrat machine : `methods[].errors` complétés, `x-etm-message-errors` (nouveau),
|
||||
`x-etm-transport` (`maxLineBytesScope`, `batches`, `runtimeDirectory`) ; tableau des erreurs
|
||||
du §7 généré depuis lui.
|
||||
|
||||
### 1.1 (2026-10)
|
||||
Additif sur le fil : toutes les additions sont des **champs optionnels nouveaux** ; un
|
||||
optimiseur 1.0 reste conforme (champs inconnus ignorés, §9).
|
||||
@ -110,9 +144,28 @@ valeur avait été écrite sans définition ; **aucun émetteur ne l'a jamais pr
|
||||
- Endpoint configurable :
|
||||
- `unix:///run/powersync/optimizer.sock` — défaut. Optimiseur local.
|
||||
Droits : socket possédé par `root:powersync`, mode `0660`.
|
||||
**Le répertoire `/run/powersync` appartient au paquet de l'optimiseur** (1.2) :
|
||||
`RuntimeDirectory=powersync` de son unité systemd, groupe `powersync`. Le moteur ne le crée
|
||||
ni ne le suppose : sans optimiseur, il n'en a pas besoin — et il ne se connecte à rien tant
|
||||
qu'aucun endpoint n'est configuré (§7, `optimizerExpected=false`).
|
||||
- `tcp://<host>:<port>` — optimiseur sur le LAN (NAS, serveur). Pour les
|
||||
implémentations communautaires (ex. pont Akkudoktor-EOS, annexe B).
|
||||
- Protocole applicatif : **JSON-RPC 2.0, un message par ligne (NDJSON), UTF-8.**
|
||||
- **Pas de lot, dans les deux sens** (1.2). Chaque ligne porte un seul objet JSON-RPC ; un lot
|
||||
(`[…]`) est interdit en émission. Reçu par l'optimiseur : erreur `-32600` à `id: null`, aucun
|
||||
message du lot n'est traité, connexion conservée. Reçu par le moteur : ligne écartée
|
||||
(`invalid-json` : ce n'est pas un objet), comptée, connexion conservée ; une réponse contenue
|
||||
dans le lot n'est pas lue, et la requête qu'elle visait finit en délai dépassé (§4).
|
||||
- **Longueur de ligne : `maxLineBytes` = 1 Mio (1 048 576 octets, `\n` non compté), dans les deux
|
||||
sens** (1.2). Reçue trop longue par l'optimiseur : jetée sans être gardée en mémoire, erreur
|
||||
`-32600` à `id: null`, connexion conservée. Reçue par le moteur : ligne écartée
|
||||
(`line-too-long`), comptée, connexion conservée ; la requête en vol continue d'attendre — si sa
|
||||
réponse était cette ligne, elle finit en délai dépassé (10 s, échec compté, §4).
|
||||
> **Écart connu du moteur (1.2)** : il n'applique pas encore la borne à ce qu'il **émet**.
|
||||
> Rien ne borne la taille d'un contexte `GetPlan` (un `label` de charge très long suffit) ; une
|
||||
> telle requête part sur une seule ligne de plus de 1 Mio, un optimiseur conforme la refuse, et
|
||||
> la requête finit en délai dépassé. Éprouvé par `testAContextOverTheLineLimitIsStillSent`
|
||||
> (suite `optimizerclient`, échec attendu tant que l'écart vit).
|
||||
- Le contrat est transport-agnostique : mêmes messages sur unix et tcp.
|
||||
- Reconnexion : backoff exponentiel (1 s → 60 s max). Pendant la déconnexion :
|
||||
stratégie de repli active, `optimizerAlive = false`.
|
||||
@ -123,7 +176,7 @@ valeur avait été écrite sans définition ; **aucun émetteur ne l'a jamais pr
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"method":"Handshake","params":{
|
||||
"protocolVersion":"1.1",
|
||||
"protocolVersion":"1.2",
|
||||
"engine":"etm-powersync-energy-plugin-etm/<version>",
|
||||
"token":"<secret partagé, optionnel — requis si configuré côté plugin>"
|
||||
}}
|
||||
@ -133,7 +186,7 @@ Réponse attendue :
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"result":{
|
||||
"protocolVersion":"1.1",
|
||||
"protocolVersion":"1.2",
|
||||
"name":"etm-optimizer",
|
||||
"planning":true,
|
||||
"capabilities":{
|
||||
@ -146,14 +199,16 @@ Réponse attendue :
|
||||
}}
|
||||
```
|
||||
|
||||
- `protocolVersion` : même **majeure** requise. Majeure différente → le plugin refuse,
|
||||
log explicite, repli rule-based.
|
||||
- `protocolVersion` : même **majeure** requise, **des deux côtés**. Majeure différente dans la
|
||||
réponse → le plugin refuse, log explicite, repli rule-based. Majeure différente dans la requête
|
||||
→ l'optimiseur refuse (1.2) : erreur `-32602`, puis fermeture. Une autre mineure est acceptée
|
||||
des deux côtés.
|
||||
- `planning` : `true` si l'optimiseur produit des plans multi-créneaux ; `false` s'il
|
||||
ne répond qu'au présent (plan à un créneau). Informationnel.
|
||||
- `token` : si configuré côté plugin et absent/incorrect côté serveur → réponse `error`
|
||||
(code `-32001` recommandé ; le moteur traite **toute** réponse `error` au handshake comme un
|
||||
refus, `handshake-refused`) puis connexion fermée. Recommandé pour tout endpoint `tcp://`. **Forme** (E-T2-8) : chaîne de **16 caractères au
|
||||
moins** et de 4 096 octets au plus, imprimable, sans blanc ni caractère de contrôle (UTF-8
|
||||
- `token` : si configuré côté optimiseur et absent ou différent dans la requête → réponse `error`
|
||||
**`-32001`** (normé en 1.2 ; « recommandé » en 1.1) puis connexion fermée. **Un token envoyé
|
||||
alors que l'optimiseur n'en exige pas est accepté**, sans erreur (1.2). Recommandé pour tout
|
||||
endpoint `tcp://`. **Forme** (E-T2-8) : chaîne de **16 caractères au moins** et de 4 096 octets au plus, imprimable, sans blanc ni caractère de contrôle (UTF-8
|
||||
admis). Le plugin refuse d'en poser un plus court, et ne se connecte pas avec un fichier de
|
||||
token plus court (ni sans token) : il masque toute occurrence du token dans ce que l'optimiseur
|
||||
renvoie, et un token court corromprait un identifiant qui le contiendrait.
|
||||
@ -165,6 +220,14 @@ Réponse attendue :
|
||||
`result` non objet, `protocolVersion` absent ou vide, `capabilities` présent mais non objet
|
||||
(`invalid-handshake`) ; majeure différente (`major-mismatch`).
|
||||
- `name` : optionnel, informatif, affiché en mode installateur ; jamais contrôlé.
|
||||
- **Refus et fermeture** (1.2). Un handshake refusé — token (`-32001`) ou majeure (`-32602`) —
|
||||
est fermé **des deux côtés** : l'optimiseur ferme après avoir envoyé l'erreur, le moteur à sa
|
||||
réception. Le moteur traite **toute** réponse `error` au handshake comme un refus, quel que soit
|
||||
son code (`handshake-refused`), et retente au backoff (§2).
|
||||
- **`GetPlan` avant un handshake réussi** (1.2) : l'optimiseur répond `-32600` et **garde** la
|
||||
connexion — le client peut encore envoyer `Handshake`. Le moteur n'en envoie jamais : il
|
||||
n'appelle `GetPlan` qu'une fois le handshake accepté.
|
||||
- Les codes d'erreur de l'optimiseur sont rassemblés au §7, « Erreurs renvoyées par l'optimiseur ».
|
||||
|
||||
<!-- BEGIN GENERATED table:Handshake.params -->
|
||||
<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->
|
||||
@ -173,7 +236,7 @@ Réponse attendue :
|
||||
|
||||
| Champ | Type | Obligatoire | Valeurs | Sens |
|
||||
|---|---|---|---|---|
|
||||
| `protocolVersion` | `ProtocolVersion` | oui | `"1.1"` | Version du moteur ; seule la majeure est comparée. |
|
||||
| `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). |
|
||||
| `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é. |
|
||||
|
||||
@ -1152,6 +1215,42 @@ requête remplacée par une plus récente n'est pas un échec. Avant le premier
|
||||
`optimizerAlive` vaut `false`. Un plan invalide remplace le cache (l'ancien plan valide n'est
|
||||
plus appliqué) et un socket fermé l'abandonne (E-T3-9).
|
||||
|
||||
**Erreurs renvoyées par l'optimiseur** (1.2). Un optimiseur répond une erreur JSON-RPC, jamais
|
||||
une abstention, à ce qu'il ne peut pas traiter. En particulier :
|
||||
|
||||
- **Contexte `GetPlan` invalide → `-32602`**, pas `abstain`. `error.data` liste les problèmes :
|
||||
pour chacun, le **chemin** du champ fautif et la **nature** du problème (obligatoire absent,
|
||||
type faux, valeur hors domaine…) — **jamais la valeur reçue**. Un contexte invalide est un défaut
|
||||
du moteur ; une abstention le cacherait. Le moteur compte cette erreur comme un **échec** de la
|
||||
requête (`rpc-error`), selon la règle des deux échecs consécutifs ci-dessus, inchangée.
|
||||
- Les codes sont ceux de JSON-RPC 2.0, plus `-32001` (token refusé), seul code propre au
|
||||
protocole. Une situation que le tableau ne nomme pas prend le code standard qui lui correspond
|
||||
(`-32603` erreur interne, par exemple).
|
||||
|
||||
<!-- 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. -->
|
||||
|
||||
| Code | Méthode | `message` | Connexion après l'erreur | Quand |
|
||||
|---|---|---|---|---|
|
||||
| `-32001` | Handshake | token refusé | fermée | Token configuré côté optimiseur, et absent ou différent dans le Handshake. Un token envoyé alors que l'optimiseur n'en exige pas est ACCEPTÉ. |
|
||||
| `-32602` | Handshake | majeure incompatible | fermée | Majeure de params.protocolVersion différente de celle de l'optimiseur (1.2). |
|
||||
| `-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. |
|
||||
| `-32603` | GetPlan | erreur interne | conservée | Code JSON-RPC 2.0 standard pour toute autre panne de l'optimiseur. |
|
||||
| `-32700` | toute | erreur d'analyse | conservée | Ligne qui n'est pas du JSON (ou pas de l'UTF-8). Réponse à id null. Code standard JSON-RPC 2.0. |
|
||||
| `-32600` | toute | lot interdit | conservée | Ligne qui est un lot JSON-RPC ([…]) (1.2). Une seule réponse, à id null ; aucun message du lot n'est traité. |
|
||||
| `-32600` | toute | ligne trop longue | conservée | Ligne de plus de maxLineBytes octets, '\n' non compté (1.2). Jetée sans être analysée ni gardée en mémoire ; réponse à id null. |
|
||||
| `-32601` | toute | méthode inconnue | conservée | Requête (avec id) d'une méthode que l'optimiseur ne sert pas — dont UpdateLearned, qui ne va que de l'optimiseur au moteur (1.2). Une notification (sans id) : ignorée, sans réponse. |
|
||||
|
||||
Ce que l'OPTIMISEUR répond à un message qu'il ne peut pas traiter, quelle que soit la méthode (1.2). Connexion conservée dans tous les cas. Le moteur n'émet que des objets, un par ligne, des méthodes Handshake et GetPlan : il n'en provoque aucune, sauf la ligne trop longue (écart connu du moteur 1.2 : il ne borne pas encore ce qu'il émet, §2). Reçue par le moteur, une réponse error à id null est une ligne écartée (missing-id), connexion conservée. Toute autre erreur : un code JSON-RPC 2.0 standard.
|
||||
|
||||
Ce que fait le moteur d'une réponse error :
|
||||
|
||||
- **Handshake** : Toute réponse error au Handshake, quel que soit son code : handshake-refused, le moteur ferme, nouvelle tentative au backoff.
|
||||
- **GetPlan** : Toute réponse error à GetPlan, quel que soit son code : échec de la requête (rpc-error), compté par la règle des échecs consécutifs (§7).
|
||||
|
||||
<!-- END GENERATED -->
|
||||
|
||||
**Ce que le validateur refuse en plus de la forme.** Le JSON Schema du contrat dit la forme ; le
|
||||
moteur juge ensuite chaque réponse sur le contexte de SA requête, dans cet ordre, et le premier
|
||||
contrôle qui échoue donne son code (`fallbackCode` vers l'app, `lastErrorCode` pour un refus) :
|
||||
@ -1179,7 +1278,7 @@ Ce que le moteur refuse EN PLUS de la forme, dans l'ordre de ses contrôles (Opt
|
||||
| `expired` | GetPlan | expired | `expired` | non | request | non | validUntil ≤ timestamp de la requête. |
|
||||
| `no-current-slot` | GetPlan | abstention | `no-current-slot` | non | request | non | aucun créneau ne couvre le timestamp de la requête. |
|
||||
| `incomplete-slot` | GetPlan | invalid | `incomplete-slot` | oui | request | non | une charge available de la requête n'a pas d'action dans le créneau courant (décision Q1). |
|
||||
| `handshake-refused` | Handshake | connection-refused | `no-plan` | non | — | non | réponse error (token absent ou différent : -32001 recommandé) → connexion fermée, nouvelle tentative au backoff. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
||||
@ -1194,8 +1293,14 @@ qu'elle affiche de ces trois champs.
|
||||
|
||||
- L'optimiseur peut renvoyer ses estimations (`learned` + `confidence`) dans une
|
||||
notification `UpdateLearned` (sans id, fire-and-forget) ; le plugin les persiste
|
||||
et les retransmet dans les contextes suivants. **État 1.1 du moteur** : reçue, journalisée
|
||||
et les retransmet dans les contextes suivants. **État du moteur** : reçue, journalisée
|
||||
une fois, ignorée — la persistance est un lot ultérieur (`INTERFACE.md`).
|
||||
- **Sens unique : de l'optimiseur vers le moteur** (1.2). Le moteur n'en envoie jamais. Reçue par
|
||||
un optimiseur : en notification (sans `id`), ignorée sans réponse ; en requête (avec `id`),
|
||||
erreur `-32601` (méthode inconnue).
|
||||
- **La forme d'`UpdateLearned` n'est pas normée en 1.2.** Le tableau ci-dessous dit ce que le
|
||||
contrat admet, pas ce qu'un moteur en fera : la forme sera fixée avec la première stratégie qui
|
||||
apprend.
|
||||
- Tant que `confidence < 0.7` (seuil indicatif), l'app peut afficher un badge
|
||||
« profil en apprentissage ».
|
||||
- Les données d'apprentissage restent **locales**. Rien ne quitte le site.
|
||||
@ -1207,7 +1312,7 @@ qu'elle affiche de ces trois champs.
|
||||
|
||||
| Champ | Type | Obligatoire | Valeurs | Sens |
|
||||
|---|---|---|---|---|
|
||||
| `loads` | [object] | non | | Non normé en 1.1 : intention §8 (learned + confidence par charge). |
|
||||
| `loads` | [object] | non | | Non normé en 1.2 : intention §8 (learned + confidence par charge). |
|
||||
|
||||
Champs non déclarés : **refusés** (forme émise par le moteur, fermée).
|
||||
|
||||
@ -1217,7 +1322,12 @@ Champs non déclarés : **refusés** (forme émise par le moteur, fermée).
|
||||
|
||||
## 9. Versionnement
|
||||
|
||||
- `protocolVersion` en semver. Même majeure = compatible.
|
||||
- `protocolVersion` en semver. Même majeure = compatible. **Les deux côtés comparent la
|
||||
majeure** et elle seule (§3) : le moteur refuse un optimiseur d'une autre majeure, et
|
||||
l'optimiseur un moteur d'une autre majeure (`-32602`, 1.2). Toute mineure est acceptée.
|
||||
- Une mineure n'ajoute que des champs optionnels, ou des règles sur des situations que l'autre
|
||||
côté conforme ne produit pas (1.2 en est un exemple, §0). Elle se lit comme une liste de choses
|
||||
à aligner avant de s'annoncer de cette mineure, pas comme une rupture.
|
||||
- Champs inconnus dans `params`/`result` : **ignorés silencieusement**.
|
||||
- Changements incompatibles (renommage, sémantique) = majeure + entrée changelog §0.
|
||||
|
||||
@ -1226,14 +1336,26 @@ Champs non déclarés : **refusés** (forme émise par le moteur, fermée).
|
||||
## Annexe A — Session minimale
|
||||
|
||||
```
|
||||
→ {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{"protocolVersion":"1.1","engine":"etm-powersync-energy-plugin-etm/1.15.2+etm70"}}
|
||||
← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.1","name":"mon-optimiseur","planning":false}}
|
||||
→ {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{"protocolVersion":"1.2","engine":"etm-powersync-energy-plugin-etm/1.15.2+etm71"}}
|
||||
← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.2","name":"mon-optimiseur","planning":false}}
|
||||
→ {"jsonrpc":"2.0","id":2,"method":"GetPlan","params":{ ...SurplusContext... }}
|
||||
← {"jsonrpc":"2.0","id":2,"result":{"planId":"x","strategy":"custom","slots":[{ "from":"...","to":"...","actions":[...] }]}}
|
||||
```
|
||||
|
||||
Ce que l'optimiseur répond à ce que le moteur n'envoie jamais (1.2, §2, §3, §7) — c'est ce
|
||||
qu'éprouve la suite de conformité, par une connexion de sonde :
|
||||
|
||||
```
|
||||
→ {"jsonrpc":"2.0","id":1,"method":"GetPlan","params":{}} (avant le handshake)
|
||||
← {"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"handshake requis"}} connexion gardée
|
||||
→ [{"jsonrpc":"2.0","id":2,"method":"UpdateLearned","params":{}}] (un lot)
|
||||
← {"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"lot interdit"}} connexion gardée
|
||||
→ {"jsonrpc":"2.0","id":3,"method":"Handshake","params":{"protocolVersion":"2.0","engine":"…"}}
|
||||
← {"jsonrpc":"2.0","id":3,"error":{"code":-32602,"message":"majeure incompatible"}} puis fermeture
|
||||
```
|
||||
|
||||
Un optimiseur conforme minimal tient en ~50 lignes de Python : répondre au handshake,
|
||||
renvoyer un plan à un créneau (ou `abstain`).
|
||||
renvoyer un plan à un créneau (ou `abstain`), et les erreurs du §7.
|
||||
|
||||
## Annexe B — Exemple : brancher Akkudoktor-EOS
|
||||
|
||||
@ -1301,7 +1423,7 @@ But : comparer des optimiseurs **sur les mêmes entrées** et rejouer un inciden
|
||||
## Annexe E — Optimiseur de référence
|
||||
|
||||
`tools/etm-powersync-ref-optimizer/` : serveur Python minimal (bibliothèque standard seule)
|
||||
conforme 1.1, `unix:///run/powersync/optimizer.sock` par défaut, `tcp://` accepté ; répondant un
|
||||
conforme 1.2, `unix:///run/powersync/optimizer.sock` par défaut, `tcp://` accepté ; répondant un
|
||||
plan à un créneau qui reproduit le waterfall rule-based. Usages : test de bout en bout du
|
||||
`SocketScheduler` (suite `etm-powersync-refoptimizer`) ; point de comparaison du banc ; exemple
|
||||
communautaire. Mode d'emploi et modes d'injection de défauts : son `README.md`.
|
||||
@ -1338,7 +1460,9 @@ Ils deviendront normatifs avec le lot qui les implémente.
|
||||
- **Le contrat** : `docs/etm-powersync-optimizer-protocol.openrpc.json` (OpenRPC 1.3). Méthodes
|
||||
`Handshake`, `GetPlan`, `UpdateLearned` ; forme des messages en JSON Schema
|
||||
(`components.schemas`) ; ce qu'OpenRPC ne sait pas dire en extensions `x-etm-…` :
|
||||
`x-etm-transport` (unix/tcp, NDJSON, rôles, délais), `x-etm-direction` (sens d'une méthode),
|
||||
`x-etm-transport` (unix/tcp, NDJSON, rôles, délais, longueur de ligne, lots, répertoire du
|
||||
socket), `x-etm-direction` (sens d'une méthode), `methods[].errors` et `x-etm-message-errors`
|
||||
(erreurs de l'optimiseur, 1.2), `x-etm-errors-engine` (ce que le moteur fait d'une erreur),
|
||||
`x-etm-fallback-machine` (§7), `x-etm-semantic-rules` (ce que le validateur refuse en plus, avec
|
||||
`fallbackCode`), `x-etm-tolerances` (ce que le moteur accepte au-delà), `x-etm-reason-params`,
|
||||
`x-etm-generated` (listes fermées régénérées depuis le code). Forme côté moteur (requêtes)
|
||||
@ -1349,5 +1473,6 @@ Ils deviendront normatifs avec le lot qui les implémente.
|
||||
une session NDJSON (les deux sens) ou des traces (annexe D) ; `--semantic` applique les règles.
|
||||
- **Conformité d'un optimiseur quelconque** : suite `etm-powersync-optimizer-conformance`
|
||||
(`tests/auto/conformance/README.md`) — le vrai client du moteur branché sur l'optimiseur que
|
||||
lance `ETM_POWERSYNC_OPTIMIZER_CMD`.
|
||||
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).
|
||||
- **Prose générée** : `tools/etm-powersync-gen-protocol-doc` (`--check` dans `tools/ci-quality.sh`).
|
||||
|
||||
@ -2,18 +2,18 @@
|
||||
"openrpc": "1.3.2",
|
||||
"info": {
|
||||
"title": "etm-powersync — protocole moteur ↔ optimiseur",
|
||||
"version": "1.1",
|
||||
"version": "1.2",
|
||||
"license": {
|
||||
"name": "GPL-3.0-or-later",
|
||||
"url": "https://www.gnu.org/licenses/gpl-3.0.html"
|
||||
},
|
||||
"description": "Contrat MACHINE du protocole entre le moteur d'énergie etm-powersync-energy-plugin-etm (client) et un optimiseur externe (serveur) — docs/OPTIMIZER_PROTOCOL.md en est la prose. Ce document est la source de la forme des messages ; il est servi par le moteur qui tourne (NymeaEnergy.GetOptimizerProtocol) et les deux côtés sont testés contre lui.\n\nCe qu'OpenRPC ne dit pas, et où cela se trouve :\n- TRANSPORT : unix:// (défaut, socket local 0660) ou tcp:// (LAN), JSON-RPC 2.0, un message par ligne (NDJSON), UTF-8 — x-etm-transport ;\n- SENS : le moteur est le CLIENT, il se connecte et envoie Handshake puis GetPlan ; la box n'ouvre aucun port. UpdateLearned va dans l'autre sens (x-etm-direction) ;\n- HEARTBEAT IMPLICITE et machine de repli §7 — x-etm-fallback-machine ;\n- VALIDATION SÉMANTIQUE du plan : le JSON Schema dit la forme ; ce que le validateur du moteur refuse en plus (charge inconnue, créneau courant incomplet…) est listé, règle par règle, dans x-etm-semantic-rules avec son fallbackCode ;\n- listes fermées : régénérées DEPUIS LE CODE (x-etm-generated), jamais recopiées à la main.\n\nForme côté moteur (requêtes) : STRICTE — additionalProperties: false ; un champ ajouté au sérialiseur sans le schéma fait échouer les tests. Forme côté optimiseur (réponses) : ouverte — un champ inconnu est ignoré (§9) ; le schéma dit ce qu'un optimiseur CONFORME envoie, x-etm-tolerances ce que le moteur accepte en plus."
|
||||
"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."
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"name": "unix",
|
||||
"url": "unix:///run/powersync/optimizer.sock",
|
||||
"summary": "Défaut : optimiseur local, socket possédé par root:powersync, mode 0660."
|
||||
"summary": "Défaut : optimiseur local, socket possédé par root:powersync, mode 0660. Le répertoire /run/powersync appartient au paquet de l'optimiseur (x-etm-transport.runtimeDirectory)."
|
||||
},
|
||||
{
|
||||
"name": "tcp",
|
||||
@ -33,7 +33,7 @@
|
||||
{
|
||||
"name": "Handshake",
|
||||
"summary": "Ouverture de session, à chaque (re)connexion (§3).",
|
||||
"description": "Envoyé par le moteur (client) dès la connexion. Délai : 5 s depuis le DÉBUT de la tentative ; au-delà, repli et nouvelle tentative au backoff. Le message porte le secret : il n'est jamais journalisé.",
|
||||
"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.",
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
@ -42,7 +42,7 @@
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProtocolVersion"
|
||||
},
|
||||
"description": "Version du moteur ; seule la majeure est comparée."
|
||||
"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)."
|
||||
},
|
||||
{
|
||||
"name": "engine",
|
||||
@ -71,11 +71,19 @@
|
||||
{
|
||||
"code": -32001,
|
||||
"message": "token refusé",
|
||||
"data": "Recommandé (optimiseur de référence). Toute réponse error est traitée pareil : handshake-refused, connexion fermée."
|
||||
"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É.",
|
||||
"x-etm-connection": "fermée"
|
||||
},
|
||||
{
|
||||
"code": -32602,
|
||||
"message": "majeure incompatible",
|
||||
"data": "Majeure de params.protocolVersion différente de celle de l'optimiseur (1.2).",
|
||||
"x-etm-connection": "fermée"
|
||||
}
|
||||
],
|
||||
"x-etm-direction": "engine→optimizer",
|
||||
"x-etm-timeout-s": 5
|
||||
"x-etm-timeout-s": 5,
|
||||
"x-etm-errors-engine": "Toute réponse error au Handshake, quel que soit son code : handshake-refused, le moteur ferme, nouvelle tentative au backoff."
|
||||
},
|
||||
{
|
||||
"name": "GetPlan",
|
||||
@ -176,19 +184,33 @@
|
||||
}
|
||||
},
|
||||
"errors": [
|
||||
{
|
||||
"code": -32600,
|
||||
"message": "handshake requis",
|
||||
"data": "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.",
|
||||
"x-etm-connection": "conservée"
|
||||
},
|
||||
{
|
||||
"code": -32602,
|
||||
"message": "contexte invalide",
|
||||
"data": "Contexte GetPlan non conforme au contrat (1.2). error.data liste les problèmes : pour chacun, le chemin du champ fautif et la nature du problème, JAMAIS la valeur reçue. Une erreur et non une abstention : un contexte invalide est un défaut du moteur, une abstention le cacherait.",
|
||||
"x-etm-connection": "conservée"
|
||||
},
|
||||
{
|
||||
"code": -32603,
|
||||
"message": "erreur interne",
|
||||
"data": "Toute réponse error compte comme un échec de la requête (rpc-error, §7) ; le code n'est pas lu."
|
||||
"data": "Code JSON-RPC 2.0 standard pour toute autre panne de l'optimiseur.",
|
||||
"x-etm-connection": "conservée"
|
||||
}
|
||||
],
|
||||
"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)."
|
||||
},
|
||||
{
|
||||
"name": "UpdateLearned",
|
||||
"summary": "Estimations apprises de l'optimiseur (§8) — notification, sans id.",
|
||||
"description": "Envoyée par l'OPTIMISEUR (sens inverse des deux autres), sans id, sans réponse. État 1.1 du moteur : reçue, journalisée une fois, IGNORÉE — rien n'est persisté, le contenu n'est pas lu. Forme non normée en 1.1.",
|
||||
"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.",
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
@ -201,7 +223,7 @@
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"description": "Non normé en 1.1 : intention §8 (learned + confidence par charge)."
|
||||
"description": "Non normé en 1.2 : intention §8 (learned + confidence par charge)."
|
||||
}
|
||||
],
|
||||
"x-etm-direction": "optimizer→engine",
|
||||
@ -212,7 +234,7 @@
|
||||
"schemas": {
|
||||
"ProtocolVersion": {
|
||||
"description": "Version du protocole parlée par le moteur (GEN : OptimizerProtocol::ProtocolVersion).",
|
||||
"const": "1.1"
|
||||
"const": "1.2"
|
||||
},
|
||||
"Trigger": {
|
||||
"description": "Cause de la requête (§5.1). GEN : SocketScheduler::knownTriggers() — du moins au plus significatif.",
|
||||
@ -1799,6 +1821,8 @@
|
||||
"server": "optimizer"
|
||||
},
|
||||
"maxLineBytes": 1048576,
|
||||
"maxLineBytesScope": "les deux sens (1.2) : octets de la ligne, '\\n' non compté. Reçue trop longue : côté optimiseur -32600 (id null), connexion conservée ; côté moteur, ligne écartée (line-too-long), connexion conservée.",
|
||||
"batches": "interdits dans les deux sens (1.2) : un message JSON-RPC, un objet, par ligne. Un lot reçu : côté optimiseur -32600 (id null), aucun de ses messages traité ; côté moteur, ligne écartée (invalid-json), connexion conservée.",
|
||||
"handshakeTimeoutS": 5,
|
||||
"requestTimeoutS": 10,
|
||||
"backoffS": {
|
||||
@ -1808,8 +1832,38 @@
|
||||
},
|
||||
"requestsInFlight": 1,
|
||||
"ids": "entiers croissants sur toute la vie du client, à travers les reconnexions",
|
||||
"lineErrors": "ligne non JSON, sans id, id jamais émis, ligne > maxLineBytes : comptée, connexion CONSERVÉE",
|
||||
"tokenMasking": "toute occurrence du token dans un message reçu est remplacée par « [token masqué] » dès l'analyse"
|
||||
"lineErrors": "ligne non JSON, sans id, id jamais émis, ligne > maxLineBytes : comptée, connexion CONSERVÉE (côté moteur ; côté optimiseur : x-etm-message-errors)",
|
||||
"tokenMasking": "toute occurrence du token dans un message reçu est remplacée par « [token masqué] » dès l'analyse",
|
||||
"runtimeDirectory": "/run/powersync appartient au paquet de l'OPTIMISEUR (RuntimeDirectory=powersync de son unité, groupe powersync) (1.2). Le moteur ne le crée ni ne le suppose : sans optimiseur, il n'en a pas besoin."
|
||||
},
|
||||
"x-etm-message-errors": {
|
||||
"description": "Ce que l'OPTIMISEUR répond à un message qu'il ne peut pas traiter, quelle que soit la méthode (1.2). Connexion conservée dans tous les cas. Le moteur n'émet que des objets, un par ligne, des méthodes Handshake et GetPlan : il n'en provoque aucune, sauf la ligne trop longue (écart connu du moteur 1.2 : il ne borne pas encore ce qu'il émet, §2). Reçue par le moteur, une réponse error à id null est une ligne écartée (missing-id), connexion conservée. Toute autre erreur : un code JSON-RPC 2.0 standard.",
|
||||
"items": [
|
||||
{
|
||||
"code": -32700,
|
||||
"message": "erreur d'analyse",
|
||||
"data": "Ligne qui n'est pas du JSON (ou pas de l'UTF-8). Réponse à id null. Code standard JSON-RPC 2.0.",
|
||||
"x-etm-connection": "conservée"
|
||||
},
|
||||
{
|
||||
"code": -32600,
|
||||
"message": "lot interdit",
|
||||
"data": "Ligne qui est un lot JSON-RPC ([…]) (1.2). Une seule réponse, à id null ; aucun message du lot n'est traité.",
|
||||
"x-etm-connection": "conservée"
|
||||
},
|
||||
{
|
||||
"code": -32600,
|
||||
"message": "ligne trop longue",
|
||||
"data": "Ligne de plus de maxLineBytes octets, '\\n' non compté (1.2). Jetée sans être analysée ni gardée en mémoire ; réponse à id null.",
|
||||
"x-etm-connection": "conservée"
|
||||
},
|
||||
{
|
||||
"code": -32601,
|
||||
"message": "méthode inconnue",
|
||||
"data": "Requête (avec id) d'une méthode que l'optimiseur ne sert pas — dont UpdateLearned, qui ne va que de l'optimiseur au moteur (1.2). Une notification (sans id) : ignorée, sans réponse.",
|
||||
"x-etm-connection": "conservée"
|
||||
}
|
||||
]
|
||||
},
|
||||
"x-etm-fallback-machine": {
|
||||
"description": "Machine de repli §7 (OptimizerStatus) : heartbeat implicite — une réponse valide à GetPlan = optimiseur vivant. Les échecs se comptent PAR REQUÊTE.",
|
||||
@ -2050,7 +2104,7 @@
|
||||
"countsAsFailure": false,
|
||||
"needs": [],
|
||||
"schemaAlsoCatches": false,
|
||||
"description": "réponse error (token absent ou différent : -32001 recommandé) → connexion fermée, nouvelle tentative au backoff."
|
||||
"description": "réponse error, quel que soit son code (token refusé : -32001 ; majeure incompatible : -32602, Handshake.errors) → le moteur ferme, nouvelle tentative au backoff."
|
||||
},
|
||||
{
|
||||
"name": "invalid-handshake",
|
||||
|
||||
@ -144,7 +144,7 @@ public:
|
||||
Q_ENUM(State)
|
||||
|
||||
/*! \brief Version du protocole annoncée au handshake. Seule la majeure est comparée. */
|
||||
static constexpr const char *kProtocolVersion = "1.1";
|
||||
static constexpr const char *kProtocolVersion = "1.2";
|
||||
static constexpr qint64 kHandshakeTimeoutMs = 5000; //!< §3, depuis le début de la tentative.
|
||||
static constexpr qint64 kRequestTimeoutMs = 10000; //!< §4, par requête \c GetPlan.
|
||||
static constexpr qint64 kBackoffInitialMs = 1000; //!< §2, premier délai de reconnexion.
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
namespace OptimizerProtocol {
|
||||
|
||||
//! Version du protocole parlée par ce sérialiseur (§3, §9).
|
||||
constexpr const char *ProtocolVersion = "1.1";
|
||||
constexpr const char *ProtocolVersion = "1.2";
|
||||
|
||||
//! Ressource Qt du contrat OpenRPC embarqué (\c docs/etm-powersync-optimizer-protocol.openrpc.json).
|
||||
constexpr const char *SchemaResource = ":/etm-powersync/optimizer-protocol.openrpc.json";
|
||||
|
||||
@ -11,9 +11,18 @@ adaptateurs, sur les mocks de la suite `simulation` — et vérifie, une fonctio
|
||||
| `testTheSessionMatchesTheContract` | la session entière (les deux sens) est conforme au contrat OpenRPC **embarqué** dans le moteur, règles sémantiques comprises (`tools/etm-powersync-schema-check --semantic`) |
|
||||
| `testEveryPlanIsAccepted` | à chaque cycle, le plan est **appliqué** : l'optimiseur commande, `GetPlan` sans `fallbackCode` |
|
||||
| `testTheSafetyLoopStaysOnTop` | la boucle de sécurité reste au-dessus : une consigne hors bornes (réécrite en 100 kW par le relais de test) est **écrêtée** par l'adaptateur (`clamped`), une surcharge de phase est **délestée** par L4 |
|
||||
| `testGetPlanBeforeTheHandshakeIsRefused` | protocole 1.2, §3 : `GetPlan` avant un handshake réussi → erreur `-32600` à son `id`, connexion **gardée** (le handshake qui suit est accepté) |
|
||||
| `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 |
|
||||
| `testALineOverTheLimitIsRefused` | protocole 1.2, §2 : une ligne de `maxLineBytes` + 1 octets → `-32600` à `id: null`, connexion gardée ; une ligne d'exactement `maxLineBytes` est lue |
|
||||
|
||||
Ces deux dernières lignes 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
|
||||
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` →
|
||||
`-32601` (§8) — pour constater qu'un message a été, ou n'a pas été, traité.
|
||||
|
||||
Les fonctions 2 à 5 passent par un relais transparent (une ligne reçue = une ligne renvoyée) qui
|
||||
capture la session et mesure les délais ; la 5 est la seule où il réécrit une réponse. Banc : deux
|
||||
charges `etmvariableload` (0–2 000 W ; plancher 800, 3 000 W) et une PAC SG-Ready (P3 1 500 W,
|
||||
@ -30,7 +39,8 @@ cd <build>/tests/auto/conformance
|
||||
export NYMEA_PLUGINS_PATH=<build>/tests/mocks/plugins/energymocks:/usr/lib/x86_64-linux-gnu/nymea/plugins
|
||||
export ETM_POWERSYNC_OPTIMIZER_CMD='exec /chemin/de/mon-optimiseur --listen {endpoint}'
|
||||
for fn in testHandshakeIsAccepted testGetPlanIsAnsweredInTime testTheSessionMatchesTheContract \
|
||||
testEveryPlanIsAccepted testTheSafetyLoopStaysOnTop; do
|
||||
testEveryPlanIsAccepted testTheSafetyLoopStaysOnTop testGetPlanBeforeTheHandshakeIsRefused \
|
||||
testARefusedHandshakeIsClosedByTheOptimizer testABatchIsRefused testALineOverTheLimitIsRefused; do
|
||||
systemd-run --user --scope -q -p MemoryMax=2G \
|
||||
bwrap --dev-bind / / --unshare-net --tmpfs /tmp --bind "$PWD/../../.." "$PWD/../../.." \
|
||||
--bind <sources> <sources> --chdir "$PWD" ./target_wrapper.sh ./etm-powersync-optimizer-conformance "$fn"
|
||||
@ -65,7 +75,7 @@ toujours est conforme. Avec `ETM_POWERSYNC_CONFORMANCE_EXPECT=abstain` :
|
||||
- `testTheSafetyLoopStaysOnTop` vérifie toujours L4 sous le rule-based, sans consigne de
|
||||
l'optimiseur : la trace doit porter `shed` (la seule baisse des consignes ne suffit pas, l'import
|
||||
la produit déjà). Puis la fonction se termine en **SKIP** : aucune consigne à écrêter ;
|
||||
- les trois autres fonctions sont inchangées.
|
||||
- les autres fonctions sont inchangées.
|
||||
|
||||
À l'inverse, un optimiseur qui s'abstient en mode `plan` échoue à `testEveryPlanIsAccepted` et
|
||||
`testTheSafetyLoopStaysOnTop`. Chaque fonction affiche en tête le mode retenu (`[Conformité] mode
|
||||
@ -81,6 +91,9 @@ fondée sur ces champs jugerait faux dans les deux cas : le mode se donne, il ne
|
||||
## Ce qu'elle ne vérifie pas
|
||||
|
||||
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` (ignoré par le moteur 1.1), le
|
||||
comportement sous défaut de transport. Le contrat : `docs/etm-powersync-optimizer-protocol.openrpc.json`
|
||||
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é ;
|
||||
le moteur n'envoie de token que si l'installateur en a posé un), la réponse à un contexte `GetPlan`
|
||||
invalide (§7, 1.2 : `-32602` ; la forme d'`error.data` n'est pas normée), le comportement sous défaut
|
||||
de transport. Le contrat : `docs/etm-powersync-optimizer-protocol.openrpc.json`
|
||||
(prose : `docs/OPTIMIZER_PROTOCOL.md`, annexe G).
|
||||
|
||||
@ -16,7 +16,11 @@
|
||||
// sémantiques comprises — jugée par le validateur publié ;
|
||||
// 4. chaque plan ACCEPTÉ : l'optimiseur commande, `GetPlan` sans `fallbackCode` ;
|
||||
// 5. la boucle de sécurité au-dessus : une consigne hors bornes est écrêtée par l'adaptateur,
|
||||
// une surcharge de phase est délestée par L4, quoi que dise l'optimiseur.
|
||||
// une surcharge de phase est délestée par L4, quoi que dise l'optimiseur ;
|
||||
// 6 à 9 (protocole 1.2). Ce que le moteur n'envoie JAMAIS, envoyé par une connexion de SONDE
|
||||
// (sans le moteur) : GetPlan avant le handshake (-32600, connexion gardée), handshake d'une
|
||||
// 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).
|
||||
//
|
||||
// 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
|
||||
@ -52,6 +56,7 @@
|
||||
#include <QTemporaryDir>
|
||||
|
||||
#include <csignal>
|
||||
#include <memory>
|
||||
#include <unistd.h>
|
||||
|
||||
using namespace nymeaserver;
|
||||
@ -319,6 +324,69 @@ private:
|
||||
QElapsedTimer m_chrono;
|
||||
};
|
||||
|
||||
/*!
|
||||
* Connexion de SONDE (protocole 1.2) : des lignes écrites à la main, les réponses lues une à une,
|
||||
* sans le moteur — pour ce que son client n'envoie jamais. Une réponse est gardée BRUTE aussi : un
|
||||
* lot renvoyé (`[…]`) n'est pas un objet, et QJsonDocument::object() le rendrait vide.
|
||||
*/
|
||||
class Sonde
|
||||
{
|
||||
public:
|
||||
explicit Sonde(const QString &endpoint) : m_s(socketVers(endpoint, &m_proprietaire)) {}
|
||||
|
||||
bool ouverte() { return m_s && waitFor([this] { return connecte(m_s); }, 5000); }
|
||||
|
||||
void envoyer(const QByteArray &ligne)
|
||||
{
|
||||
m_s->write(ligne + '\n');
|
||||
if (auto *l = qobject_cast<QLocalSocket *>(m_s))
|
||||
l->flush();
|
||||
else if (auto *t = qobject_cast<QTcpSocket *>(m_s))
|
||||
t->flush();
|
||||
}
|
||||
|
||||
//! La ligne suivante (sans son '\n') ; faux si rien n'arrive en \p ms, ou si la connexion se ferme avant.
|
||||
bool recevoir(QByteArray *ligne, int ms = 5000)
|
||||
{
|
||||
waitFor([this] { lire(); return m_tampon.contains('\n') || !connecte(m_s); }, ms);
|
||||
lire();
|
||||
const int nl = m_tampon.indexOf('\n');
|
||||
if (nl < 0)
|
||||
return false;
|
||||
*ligne = m_tampon.left(nl);
|
||||
m_tampon.remove(0, nl + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
//! Vrai si l'AUTRE côté ferme en \p ms : la sonde ne ferme jamais d'elle-même.
|
||||
bool fermeeParLautre(int ms) { return waitFor([this] { lire(); return !connecte(m_s); }, ms); }
|
||||
|
||||
private:
|
||||
void lire() { m_tampon += m_s->readAll(); }
|
||||
|
||||
QObject m_proprietaire;
|
||||
QIODevice *m_s;
|
||||
QByteArray m_tampon;
|
||||
};
|
||||
|
||||
//! Une réponse error de la sonde : `{"jsonrpc":"2.0","id":<id>,"error":{"code":<code>,…}}`, objet seul.
|
||||
QString verifierErreur(const QByteArray &ligne, const QJsonValue &id, int code)
|
||||
{
|
||||
QJsonParseError e;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(ligne, &e);
|
||||
const QString vu = QString::fromUtf8(ligne.left(300));
|
||||
if (e.error != QJsonParseError::NoError || !doc.isObject())
|
||||
return QStringLiteral("réponse qui n'est pas UN objet JSON : ") + vu;
|
||||
const QJsonObject m = doc.object();
|
||||
if (!m.contains("error") || m.value("error").toObject().value("code").toInt() != code)
|
||||
return QStringLiteral("erreur %1 attendue : ").arg(code) + vu;
|
||||
const QJsonValue recu = m.value("id");
|
||||
const bool bonId = id.isNull() ? m.contains("id") && recu.isNull() : recu.isDouble() && recu.toDouble() == id.toDouble();
|
||||
if (!bonId)
|
||||
return QStringLiteral("id %1 attendu : ").arg(id.isNull() ? QStringLiteral("null") : QString::number(id.toInt())) + vu;
|
||||
return QString();
|
||||
}
|
||||
|
||||
QString cle(const QVariantMap &v)
|
||||
{
|
||||
return QString::fromUtf8(QJsonDocument::fromVariant(v).toJson(QJsonDocument::Compact));
|
||||
@ -336,9 +404,23 @@ private slots:
|
||||
void testTheSessionMatchesTheContract();
|
||||
void testEveryPlanIsAccepted();
|
||||
void testTheSafetyLoopStaysOnTop();
|
||||
void testGetPlanBeforeTheHandshakeIsRefused();
|
||||
void testARefusedHandshakeIsClosedByTheOptimizer();
|
||||
void testABatchIsRefused();
|
||||
void testALineOverTheLimitIsRefused();
|
||||
|
||||
private:
|
||||
void monter();
|
||||
//! Où l'optimiseur écoute ($ETM_POWERSYNC_OPTIMIZER_ENDPOINT, sinon un socket du test).
|
||||
QString endpoint() const;
|
||||
//! Le token que l'optimiseur attend ($ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE), vide sinon.
|
||||
QString lireToken();
|
||||
//! Points 6 à 9 : l'optimiseur seul, sans moteur ; \p sonde ouverte dessus.
|
||||
void sonder(std::unique_ptr<Sonde> &sonde);
|
||||
//! 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;
|
||||
//! Handshake accepté sur \p sonde, ou échec du test.
|
||||
void ouvrirSession(Sonde &sonde, int id);
|
||||
//! Lance l'optimiseur et y branche le moteur (directement, ou par le relais).
|
||||
void brancher(bool parLeRelais);
|
||||
void cycle(const QDateTime &t);
|
||||
@ -437,20 +519,32 @@ void TestOptimizerConformance::monter()
|
||||
m_arb->setOptimizerTraceDirectory(d);
|
||||
QCOMPARE(rpc("SetOptimizerTrace", {{"enabled", true}}).value("energyError").toString(), QString("EnergyErrorNoError"));
|
||||
// Token : celui que l'optimiseur attend, s'il en attend un.
|
||||
const QString fichier = variable("ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE");
|
||||
if (!fichier.isEmpty()) {
|
||||
QFile f(fichier);
|
||||
QVERIFY2(f.open(QIODevice::ReadOnly), qUtf8Printable("ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE illisible : " + fichier));
|
||||
m_token = QString::fromUtf8(f.readAll().trimmed());
|
||||
m_token = lireToken();
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
if (!m_token.isEmpty())
|
||||
QCOMPARE(rpc("SetOptimizerToken", {{"token", m_token}}).value("energyError").toString(), QString("EnergyErrorNoError"));
|
||||
}
|
||||
m_surplusW = 0;
|
||||
}
|
||||
|
||||
QString TestOptimizerConformance::endpoint() const
|
||||
{
|
||||
return variable("ETM_POWERSYNC_OPTIMIZER_ENDPOINT", QStringLiteral("unix://%1/optimiseur.sock").arg(m_dir.path()));
|
||||
}
|
||||
|
||||
QString TestOptimizerConformance::lireToken()
|
||||
{
|
||||
const QString fichier = variable("ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE");
|
||||
if (fichier.isEmpty())
|
||||
return QString();
|
||||
QFile f(fichier);
|
||||
[&] { QVERIFY2(f.open(QIODevice::ReadOnly), qUtf8Printable("ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE illisible : " + fichier)); }();
|
||||
return QString::fromUtf8(f.readAll().trimmed());
|
||||
}
|
||||
|
||||
void TestOptimizerConformance::brancher(bool parLeRelais)
|
||||
{
|
||||
const QString endpoint = variable("ETM_POWERSYNC_OPTIMIZER_ENDPOINT",
|
||||
QStringLiteral("unix://%1/optimiseur.sock").arg(m_dir.path()));
|
||||
const QString endpoint = this->endpoint();
|
||||
QString erreur;
|
||||
QVERIFY2(m_optimiseur.demarrer(endpoint, &erreur), qUtf8Printable(erreur));
|
||||
QString cible = endpoint;
|
||||
@ -710,5 +804,136 @@ void TestOptimizerConformance::testTheSafetyLoopStaysOnTop()
|
||||
}
|
||||
}
|
||||
|
||||
// --- 6 à 9 : protocole 1.2, par une connexion de sonde ----------------------------------------------
|
||||
|
||||
void TestOptimizerConformance::sonder(std::unique_ptr<Sonde> &sonde)
|
||||
{
|
||||
QVERIFY(m_dir.isValid());
|
||||
m_token = lireToken();
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
QString erreur;
|
||||
QVERIFY2(m_optimiseur.demarrer(endpoint(), &erreur), qUtf8Printable(erreur));
|
||||
sonde.reset(new Sonde(endpoint()));
|
||||
QVERIFY2(sonde->ouverte(), qUtf8Printable("la sonde ne se connecte pas à " + endpoint()));
|
||||
}
|
||||
|
||||
QByteArray TestOptimizerConformance::handshake(int id, const QString &version, const QString &token) const
|
||||
{
|
||||
QJsonObject params{{"protocolVersion", version}, {"engine", "etm-powersync-energy-plugin-etm/conformance"}};
|
||||
if (!token.isEmpty())
|
||||
params.insert("token", token);
|
||||
return QJsonDocument(QJsonObject{{"jsonrpc", "2.0"}, {"id", id}, {"method", "Handshake"}, {"params", params}})
|
||||
.toJson(QJsonDocument::Compact);
|
||||
}
|
||||
|
||||
void TestOptimizerConformance::ouvrirSession(Sonde &sonde, int id)
|
||||
{
|
||||
sonde.envoyer(handshake(id, QString::fromLatin1(OptimizerProtocol::ProtocolVersion), m_token));
|
||||
QByteArray ligne;
|
||||
QVERIFY2(sonde.recevoir(&ligne), "handshake de sonde sans réponse");
|
||||
const QJsonObject m = QJsonDocument::fromJson(ligne).object();
|
||||
QVERIFY2(m.value("id").toInt() == id && m.value("result").toObject().value("protocolVersion").isString(),
|
||||
qUtf8Printable("handshake de sonde refusé : " + QString::fromUtf8(ligne.left(300))));
|
||||
}
|
||||
|
||||
void TestOptimizerConformance::testGetPlanBeforeTheHandshakeIsRefused()
|
||||
{
|
||||
// §3 (1.2) : GetPlan avant un handshake réussi → -32600, et la connexion est GARDÉE — le
|
||||
// handshake qui suit, sur la même connexion, est accepté.
|
||||
std::unique_ptr<Sonde> sonde;
|
||||
sonder(sonde);
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
sonde->envoyer(R"({"jsonrpc":"2.0","id":1,"method":"GetPlan","params":{}})");
|
||||
QByteArray ligne;
|
||||
QVERIFY2(sonde->recevoir(&ligne), "GetPlan avant le handshake : aucune réponse (ou connexion fermée)");
|
||||
const QString e = verifierErreur(ligne, 1, -32600);
|
||||
QVERIFY2(e.isEmpty(), qUtf8Printable(e));
|
||||
ouvrirSession(*sonde, 2);
|
||||
}
|
||||
|
||||
void TestOptimizerConformance::testARefusedHandshakeIsClosedByTheOptimizer()
|
||||
{
|
||||
// §3 (1.2) : un handshake refusé est fermé des deux côtés. Ici, la moitié de l'OPTIMISEUR : il
|
||||
// ferme après avoir envoyé l'erreur (la sonde, elle, ne ferme pas). Majeure incompatible →
|
||||
// -32602 ; si l'optimiseur attend un token, un token faux → -32001, fermé pareil. La moitié du
|
||||
// moteur est éprouvée par la suite `optimizerclient` (testARefusedHandshakeIsClosedByTheEngine).
|
||||
std::unique_ptr<Sonde> sonde;
|
||||
sonder(sonde);
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
const QString autreMajeure = QString::number(QString::fromLatin1(OptimizerProtocol::ProtocolVersion).section('.', 0, 0).toInt() + 1) + ".0";
|
||||
sonde->envoyer(handshake(1, autreMajeure, m_token));
|
||||
QByteArray ligne;
|
||||
QVERIFY2(sonde->recevoir(&ligne), qUtf8Printable("handshake en " + autreMajeure + " : aucune réponse avant la fermeture"));
|
||||
QString e = verifierErreur(ligne, 1, -32602);
|
||||
QVERIFY2(e.isEmpty(), qUtf8Printable(e));
|
||||
QVERIFY2(sonde->fermeeParLautre(5000), "handshake refusé (-32602) : l'optimiseur n'a pas fermé la connexion en 5 s");
|
||||
|
||||
if (m_token.isEmpty()) {
|
||||
qInfo().noquote() << "[Conformité] aucun token attendu : refus -32001 non éprouvé";
|
||||
return;
|
||||
}
|
||||
Sonde faux(endpoint());
|
||||
QVERIFY(faux.ouverte());
|
||||
faux.envoyer(handshake(1, QString::fromLatin1(OptimizerProtocol::ProtocolVersion), m_token + QStringLiteral("-faux")));
|
||||
QVERIFY2(faux.recevoir(&ligne), "token faux : aucune réponse avant la fermeture");
|
||||
e = verifierErreur(ligne, 1, -32001);
|
||||
QVERIFY2(e.isEmpty(), qUtf8Printable(e));
|
||||
QVERIFY2(faux.fermeeParLautre(5000), "handshake refusé (-32001) : l'optimiseur n'a pas fermé la connexion en 5 s");
|
||||
}
|
||||
|
||||
void TestOptimizerConformance::testABatchIsRefused()
|
||||
{
|
||||
// §2 (1.2) : lots interdits. Un lot reçu → UNE réponse -32600 à id null, aucun de ses messages
|
||||
// traité, connexion gardée. Le lot porte une requête dont la réponse est normée (UpdateLearned
|
||||
// avec id → -32601, §8) : la traiter se verrait.
|
||||
std::unique_ptr<Sonde> sonde;
|
||||
sonder(sonde);
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
ouvrirSession(*sonde, 1);
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
sonde->envoyer(R"([{"jsonrpc":"2.0","id":2,"method":"UpdateLearned","params":{}}])");
|
||||
QByteArray ligne;
|
||||
QVERIFY2(sonde->recevoir(&ligne), "lot : aucune réponse (ou connexion fermée)");
|
||||
QString e = verifierErreur(ligne, QJsonValue::Null, -32600);
|
||||
QVERIFY2(e.isEmpty(), qUtf8Printable(e));
|
||||
// Connexion gardée, et la ligne suivante répond à la requête suivante — pas au contenu du lot.
|
||||
sonde->envoyer(R"({"jsonrpc":"2.0","id":3,"method":"UpdateLearned","params":{}})");
|
||||
QVERIFY2(sonde->recevoir(&ligne), "après le lot : aucune réponse (ou connexion fermée)");
|
||||
e = verifierErreur(ligne, 3, -32601);
|
||||
QVERIFY2(e.isEmpty(), qUtf8Printable(e));
|
||||
}
|
||||
|
||||
void TestOptimizerConformance::testALineOverTheLimitIsRefused()
|
||||
{
|
||||
// §2 (1.2) : maxLineBytes dans les deux sens, '\n' non compté. Un octet de trop → -32600 à id
|
||||
// null, la ligne n'est pas traitée, connexion gardée ; une ligne d'exactement maxLineBytes est
|
||||
// lue normalement.
|
||||
std::unique_ptr<Sonde> sonde;
|
||||
sonder(sonde);
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
ouvrirSession(*sonde, 1);
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
auto ligneDe = [](int id, int taille) {
|
||||
QByteArray l = QStringLiteral(R"({"jsonrpc":"2.0","id":%1,"method":"UpdateLearned","params":{}})").arg(id).toUtf8();
|
||||
return l + QByteArray(taille - l.size(), ' ');
|
||||
};
|
||||
sonde->envoyer(ligneDe(2, OptimizerClient::kMaxLineBytes + 1));
|
||||
QByteArray ligne;
|
||||
QVERIFY2(sonde->recevoir(&ligne, 10000), "ligne trop longue : aucune réponse (ou connexion fermée)");
|
||||
QString e = verifierErreur(ligne, QJsonValue::Null, -32600);
|
||||
QVERIFY2(e.isEmpty(), qUtf8Printable(e));
|
||||
sonde->envoyer(ligneDe(3, OptimizerClient::kMaxLineBytes));
|
||||
QVERIFY2(sonde->recevoir(&ligne, 10000), "ligne d'exactement maxLineBytes : aucune réponse (ou connexion fermée)");
|
||||
e = verifierErreur(ligne, 3, -32601);
|
||||
QVERIFY2(e.isEmpty(), qUtf8Printable(e));
|
||||
}
|
||||
|
||||
QTEST_MAIN(TestOptimizerConformance)
|
||||
#include "testconformance.moc"
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
// des sockets, jamais à laisser s'écouler un délai du client.
|
||||
|
||||
#include "etm/scheduler/optimizerclient.h"
|
||||
#include "etm/scheduler/optimizerprotocol.h"
|
||||
#include "../common/etm-powersync-fakeoptimizer.h"
|
||||
#include "../common/etm-powersync-contrat.h"
|
||||
|
||||
@ -52,6 +53,37 @@ int countLog(const QString &needle)
|
||||
return n;
|
||||
}
|
||||
|
||||
/*! Optimiseur BRUT : ce que le client écrit, octet par octet, sans le découpage du faux
|
||||
* optimiseur (qui ne rend que des objets, et ne verrait donc pas un lot). */
|
||||
struct RawOptimizer
|
||||
{
|
||||
QLocalServer server;
|
||||
QLocalSocket *socket = nullptr;
|
||||
QByteArray received;
|
||||
|
||||
QString listen(const QString &path)
|
||||
{
|
||||
QLocalServer::removeServer(path);
|
||||
return server.listen(path) ? QStringLiteral("unix://") + path : QString();
|
||||
}
|
||||
//! Lignes complètes reçues jusqu'ici, dans l'ordre (sans leur '\n').
|
||||
QList<QByteArray> lines()
|
||||
{
|
||||
if (!socket && server.hasPendingConnections())
|
||||
socket = server.nextPendingConnection();
|
||||
if (socket)
|
||||
received += socket->readAll();
|
||||
QList<QByteArray> l = received.split('\n');
|
||||
l.removeLast(); // ce qui suit le dernier '\n' : vide, ou une ligne pas encore finie
|
||||
return l;
|
||||
}
|
||||
void send(const QByteArray &line)
|
||||
{
|
||||
socket->write(line + '\n');
|
||||
socket->flush();
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
/*! Horloge simulée : le temps n'avance que par advance(), et les réveils échus sonnent dans
|
||||
@ -112,6 +144,11 @@ private slots:
|
||||
void testWriteToken();
|
||||
void testTokenNeverInAnyLog();
|
||||
void testAShortTokenFileOpensNoConnection();
|
||||
void testARefusedHandshakeIsClosedByTheEngine_data();
|
||||
void testARefusedHandshakeIsClosedByTheEngine();
|
||||
void testAReceivedBatchIsNeverUnpacked();
|
||||
void testEachLineSentIsOneObject();
|
||||
void testAContextOverTheLineLimitIsStillSent();
|
||||
|
||||
private:
|
||||
// Démarre le client et mène le handshake jusqu'à Ready.
|
||||
@ -249,7 +286,10 @@ void TestOptimizerClient::testHandshakeAndPlan()
|
||||
QCOMPARE(hs.value("method").toString(), QStringLiteral("Handshake"));
|
||||
QCOMPARE(hs.value("id").toInteger(), 1);
|
||||
const QJsonObject params = hs.value("params").toObject();
|
||||
QCOMPARE(params.value("protocolVersion").toString(), QStringLiteral("1.1"));
|
||||
// Protocole 1.2 : la version ANNONCÉE au handshake est celle que le moteur sert
|
||||
// (GetOptimizerProtocol) et celle du contrat — deux constantes, une seule valeur.
|
||||
QCOMPARE(params.value("protocolVersion").toString(), QStringLiteral("1.2"));
|
||||
QCOMPARE(QString::fromLatin1(OptimizerClient::kProtocolVersion), QString::fromLatin1(OptimizerProtocol::ProtocolVersion));
|
||||
QCOMPARE(params.value("engine").toString(), QStringLiteral("etm-powersync-energy-plugin-etm/9.9"));
|
||||
QVERIFY(!params.contains("token"));
|
||||
// Seule l'échéance du handshake est armée, à 5 s depuis le début de la tentative.
|
||||
@ -312,7 +352,7 @@ void TestOptimizerClient::testWrongMajorIsRefused()
|
||||
QCOMPARE(readySpy.count(), 0);
|
||||
QCOMPARE(client.requestPlan({}), quint64(0));
|
||||
QVERIFY(waitFor([&] { return server.disconnections == 1; }));
|
||||
QCOMPARE(countLog("optimiseur en 2.0, moteur en 1.1 — majeures différentes, refusé"), 1);
|
||||
QCOMPARE(countLog("optimiseur en 2.0, moteur en 1.2 — majeures différentes, refusé"), 1);
|
||||
|
||||
// Nouvelle tentative au backoff (§3), pas avant.
|
||||
clock.advance(999);
|
||||
@ -847,5 +887,133 @@ void TestOptimizerClient::testAShortTokenFileOpensNoConnection()
|
||||
QCOMPARE(server.connections, 1);
|
||||
}
|
||||
|
||||
void TestOptimizerClient::testARefusedHandshakeIsClosedByTheEngine_data()
|
||||
{
|
||||
QTest::addColumn<int>("code");
|
||||
QTest::newRow("token refusé, -32001") << -32001;
|
||||
QTest::newRow("majeure incompatible, -32602") << -32602;
|
||||
}
|
||||
|
||||
void TestOptimizerClient::testARefusedHandshakeIsClosedByTheEngine()
|
||||
{
|
||||
// Protocole 1.2 (Q4) : après un handshake refusé, les DEUX côtés ferment. Ce faux optimiseur
|
||||
// envoie l'erreur et GARDE son socket ouvert : la déconnexion qu'il constate vient du moteur.
|
||||
QFETCH(int, code);
|
||||
FakeOptimizer server(FakeOptimizer::Unix, m_dir.path());
|
||||
QVERIFY(server.listen());
|
||||
SimClock clock;
|
||||
OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock);
|
||||
|
||||
client.start();
|
||||
QJsonObject hs;
|
||||
QVERIFY(server.waitMessage(&hs));
|
||||
server.sendJson(QJsonObject{{"jsonrpc", "2.0"}, {"id", hs.value("id")},
|
||||
{"error", QJsonObject{{"code", code}, {"message", "refusé"}}}});
|
||||
QVERIFY(waitFor([&] { return server.disconnections == 1; }));
|
||||
QCOMPARE(client.state(), OptimizerClient::Waiting);
|
||||
QCOMPARE(client.lastErrorCode(), QStringLiteral("handshake-refused"));
|
||||
QCOMPARE(client.requestPlan({}), quint64(0));
|
||||
// Nouvelle tentative au backoff, pas avant : la fermeture n'est pas une reconnexion immédiate.
|
||||
settle();
|
||||
QCOMPARE(server.connections, 1);
|
||||
QCOMPARE(clock.remaining(), qint64(1000));
|
||||
}
|
||||
|
||||
void TestOptimizerClient::testAReceivedBatchIsNeverUnpacked()
|
||||
{
|
||||
// Protocole 1.2 (Q8) : lots interdits dans les deux sens. REÇU, un lot est une ligne écartée
|
||||
// (invalid-json : ce n'est pas un objet), comptée, connexion CONSERVÉE ; la réponse qu'il
|
||||
// contient n'est pas lue. La requête en vol attend donc toujours, et son délai la compte en échec.
|
||||
FakeOptimizer server(FakeOptimizer::Unix, m_dir.path());
|
||||
QVERIFY(server.listen());
|
||||
SimClock clock;
|
||||
OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock);
|
||||
bringUp(client, server);
|
||||
QSignalSpy planSpy(&client, &OptimizerClient::planReceived);
|
||||
QSignalSpy failSpy(&client, &OptimizerClient::planFailed);
|
||||
QSignalSpy errorSpy(&client, &OptimizerClient::protocolError);
|
||||
|
||||
const quint64 id = client.requestPlan({});
|
||||
QJsonObject req;
|
||||
QVERIFY(server.waitMessage(&req));
|
||||
const QByteArray reponse = QJsonDocument(QJsonObject{{"jsonrpc", "2.0"}, {"id", qint64(id)},
|
||||
{"result", QJsonObject{{"planId", "dans-un-lot"}}}})
|
||||
.toJson(QJsonDocument::Compact);
|
||||
server.sendRaw("[" + reponse + "]\n");
|
||||
QVERIFY(waitFor([&] { return errorSpy.count() == 1; }));
|
||||
QCOMPARE(errorSpy.at(0).at(0).toString(), QStringLiteral("invalid-json"));
|
||||
settle();
|
||||
QCOMPARE(planSpy.count(), 0);
|
||||
QCOMPARE(client.pendingRequestId(), id);
|
||||
QCOMPARE(client.state(), OptimizerClient::Ready);
|
||||
|
||||
clock.advance(OptimizerClient::kRequestTimeoutMs);
|
||||
QCOMPARE(failSpy.count(), 1);
|
||||
QCOMPARE(failSpy.at(0).at(1).toString(), QStringLiteral("timeout"));
|
||||
settle();
|
||||
QCOMPARE(client.state(), OptimizerClient::Ready);
|
||||
QCOMPARE(server.disconnections, 0);
|
||||
}
|
||||
|
||||
void TestOptimizerClient::testEachLineSentIsOneObject()
|
||||
{
|
||||
// Protocole 1.2 (Q8, sens moteur → optimiseur) : un message par ligne, jamais un lot. Le client
|
||||
// n'a qu'un point d'écriture, send(), qui sérialise UN objet ; vérifié ici sur les octets.
|
||||
RawOptimizer raw;
|
||||
const QString endpoint = raw.listen(m_dir.filePath("brut.sock"));
|
||||
QVERIFY(!endpoint.isEmpty());
|
||||
SimClock clock;
|
||||
OptimizerClient client(OptimizerEndpoint::parse(endpoint), QString(), "9.9", &clock);
|
||||
|
||||
client.start();
|
||||
QVERIFY(waitFor([&] { return raw.lines().size() == 1; }));
|
||||
raw.send(R"({"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.2"}})");
|
||||
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; }));
|
||||
const QVariantMap context{{"timestamp", "2026-10-16T10:00:00Z"},
|
||||
{"loads", QVariantList{QVariantMap{{"id", "a"}}, QVariantMap{{"id", "b"}}}}};
|
||||
QCOMPARE(client.requestPlan(context), quint64(2));
|
||||
QCOMPARE(client.requestPlan(context), quint64(3)); // remplace la précédente : une ligne de plus, pas un lot
|
||||
QVERIFY(waitFor([&] { return raw.lines().size() == 3; }));
|
||||
|
||||
const QList<QByteArray> lines = raw.lines();
|
||||
QVERIFY(raw.received.endsWith('\n'));
|
||||
for (int i = 0; i < lines.size(); ++i) {
|
||||
QJsonParseError e;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(lines.at(i), &e);
|
||||
QVERIFY2(e.error == QJsonParseError::NoError && doc.isObject() && lines.at(i).startsWith('{'),
|
||||
qPrintable(QStringLiteral("ligne %1 : pas un objet JSON seul").arg(i + 1)));
|
||||
QCOMPARE(doc.object().value("id").toInteger(), qint64(i + 1));
|
||||
QVERIFY(lines.at(i).size() <= OptimizerClient::kMaxLineBytes);
|
||||
}
|
||||
}
|
||||
|
||||
void TestOptimizerClient::testAContextOverTheLineLimitIsStillSent()
|
||||
{
|
||||
// ÉCART au protocole 1.2 (Q7, maxLineBytes dans les deux sens), constaté et NON CORRIGÉ dans le
|
||||
// lot qui norme la règle : requestPlan() n'applique pas kMaxLineBytes à ce qu'il ÉMET. Rien ne
|
||||
// borne non plus la taille d'un contexte en amont — un `label` de charge saisi par SetLoadConfig
|
||||
// suffit. Un optimiseur conforme jette la ligne (-32600) et la requête finit en délai dépassé.
|
||||
// Le jour où le moteur borne l'émission, ce test passe (XPASS) et doit être réécrit.
|
||||
RawOptimizer raw;
|
||||
const QString endpoint = raw.listen(m_dir.filePath("brut.sock"));
|
||||
QVERIFY(!endpoint.isEmpty());
|
||||
SimClock clock;
|
||||
OptimizerClient client(OptimizerEndpoint::parse(endpoint), QString(), "9.9", &clock);
|
||||
|
||||
client.start();
|
||||
QVERIFY(waitFor([&] { return raw.lines().size() == 1; }));
|
||||
raw.send(R"({"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.2"}})");
|
||||
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; }));
|
||||
const QVariantMap context{{"loads", QVariantList{QVariantMap{
|
||||
{"id", "ecs"}, {"label", QString(OptimizerClient::kMaxLineBytes, QLatin1Char('x'))}}}}};
|
||||
QCOMPARE(client.requestPlan(context), quint64(2));
|
||||
QVERIFY(waitFor([&] { return raw.lines().size() == 2; }, 10000));
|
||||
const QByteArray line = raw.lines().at(1);
|
||||
QVERIFY(line.contains("\"GetPlan\""));
|
||||
QEXPECT_FAIL("", "écart Q7 : le moteur émet une ligne de plus de maxLineBytes (rapport protocole 1.2)", Continue);
|
||||
QVERIFY2(line.size() <= OptimizerClient::kMaxLineBytes,
|
||||
qPrintable(QStringLiteral("GetPlan émis sur %1 octets").arg(line.size())));
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(TestOptimizerClient)
|
||||
#include "testoptimizerclient.moc"
|
||||
|
||||
@ -2166,7 +2166,7 @@ void TestSocketScheduler::testTheEngineSpeaksTheContractItServes()
|
||||
cycle(t0.addSecs(60 * i), -3000);
|
||||
|
||||
const QVariantMap r = rpc("GetOptimizerProtocol");
|
||||
QCOMPARE(r.value("protocolVersion").toString(), QString("1.1"));
|
||||
QCOMPARE(r.value("protocolVersion").toString(), QString("1.2"));
|
||||
const QJsonObject servi = QJsonObject::fromVariantMap(r.value("schema").toMap());
|
||||
const QJsonObject embarque = QJsonDocument::fromJson(OptimizerProtocol::schemaDocumentBytes()).object();
|
||||
QVERIFY(!embarque.isEmpty());
|
||||
|
||||
@ -106,6 +106,22 @@ class TestGenProtocolDoc(unittest.TestCase):
|
||||
self.assertEqual(code, 2, sortie)
|
||||
self.assertIn('orpheline', sortie)
|
||||
|
||||
def test_le_tableau_des_erreurs_suit_le_contrat(self):
|
||||
# Protocole 1.2 : methods[].errors puis x-etm-message-errors, et ce que fait le moteur.
|
||||
p = self.lire(self.prose)
|
||||
self.assertIn('| `-32602` | Handshake | majeure incompatible | fermée |', p)
|
||||
self.assertIn('| `-32600` | GetPlan | handshake requis | conservée |', p)
|
||||
self.assertIn('| `-32600` | toute | lot interdit | conservée |', p)
|
||||
self.assertIn('- **GetPlan** : Toute réponse error à GetPlan', p)
|
||||
doc = json.loads(self.lire(self.schema))
|
||||
doc['x-etm-message-errors']['items'].append({'code': -32099, 'message': 'erreur ajoutée',
|
||||
'data': 'contre-épreuve', 'x-etm-connection': 'conservée'})
|
||||
self.ecrire(self.schema, json.dumps(doc, ensure_ascii=False, indent=2) + '\n')
|
||||
code, sortie = lancer(*self.args('--check'))
|
||||
self.assertEqual(code, 1, sortie)
|
||||
self.assertEqual(lancer(*self.args())[0], 0)
|
||||
self.assertIn('| `-32099` | toute | erreur ajoutée | conservée | contre-épreuve |', self.lire(self.prose))
|
||||
|
||||
def test_des_balises_cassees_sont_une_erreur(self):
|
||||
p = self.lire(self.prose)
|
||||
i = p.index('<!-- END GENERATED -->')
|
||||
|
||||
@ -100,7 +100,7 @@ def session(mode, dossier):
|
||||
with open(REQUETE, encoding='utf-8') as f:
|
||||
params = json.load(f)
|
||||
s.sendall(json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'Handshake',
|
||||
'params': {'protocolVersion': '1.1', 'engine': 'etm-powersync-energy-plugin-etm/test',
|
||||
'params': {'protocolVersion': '1.2', 'engine': 'etm-powersync-energy-plugin-etm/test',
|
||||
'token': TOKEN}}).encode() + b'\n')
|
||||
hs = lire_ligne(s, tampon)
|
||||
if hs and str(hs.get('result', {}).get('protocolVersion', '')).startswith('1'):
|
||||
|
||||
@ -17,7 +17,9 @@ est écrit à la main. `<quoi>` :
|
||||
- `table:<Méthode>.params` — les params d'une méthode ;
|
||||
- `table:<Méthode>.result` — son résultat ;
|
||||
- `table:<Composant>` — un schéma de `components.schemas` (un `oneOf` donne une table par branche) ;
|
||||
- `rules` — `x-etm-semantic-rules` ; `reason-codes` — codes émissibles et leurs clés de paramètres.
|
||||
- `rules` — `x-etm-semantic-rules` ; `reason-codes` — codes émissibles et leurs clés de paramètres ;
|
||||
- `errors` — les erreurs que renvoie l'optimiseur : `methods[].errors`, puis `x-etm-message-errors`
|
||||
(protocole 1.2), et ce que le moteur fait d'une réponse error (`x-etm-errors-engine`).
|
||||
Une table déroule les objets EN LIGNE (`declared.minPowerW`) ; un `$ref` renvoie à la table de son
|
||||
composant. Un `<quoi>` inconnu, une balise sans fin, un bloc imbriqué : erreur (code 2).
|
||||
|
||||
@ -222,7 +224,28 @@ class Rendu:
|
||||
out.append(f'| `{code}` | {", ".join(f"`{k}`" for k in cles.get(code, [])) or "—"} |')
|
||||
return out
|
||||
|
||||
def erreurs(self):
|
||||
messages = self.doc.get('x-etm-message-errors', {})
|
||||
out = ['| Code | Méthode | `message` | Connexion après l\'erreur | Quand |', '|---|---|---|---|---|']
|
||||
moteur = []
|
||||
for m in self.doc.get('methods', []):
|
||||
for e in m.get('errors', []):
|
||||
out.append(f'| `{e["code"]}` | {m["name"]} | {self.sens({"description": e.get("message", "")})} | '
|
||||
f'{e.get("x-etm-connection", "—")} | {self.sens({"description": e.get("data", "")})} |')
|
||||
if m.get('x-etm-errors-engine'):
|
||||
moteur.append(f'- **{m["name"]}** : {self.sens({"description": m["x-etm-errors-engine"]})}')
|
||||
for e in messages.get('items', []):
|
||||
out.append(f'| `{e["code"]}` | toute | {self.sens({"description": e.get("message", "")})} | '
|
||||
f'{e.get("x-etm-connection", "—")} | {self.sens({"description": e.get("data", "")})} |')
|
||||
if messages.get('description'):
|
||||
out += ['', self.sens(messages)]
|
||||
if moteur:
|
||||
out += ['', 'Ce que fait le moteur d\'une réponse error :', ''] + moteur
|
||||
return out
|
||||
|
||||
def bloc(self, quoi):
|
||||
if quoi == 'errors':
|
||||
return self.erreurs()
|
||||
if quoi == 'rules':
|
||||
return self.regles()
|
||||
if quoi == 'reason-codes':
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# `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`
|
||||
1.1 (annexe E). Il répond un plan à **un créneau** qui reproduit le waterfall du rule-based (passe
|
||||
1.2 (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
|
||||
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.
|
||||
@ -29,6 +29,24 @@ 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
|
||||
partagé) : le moteur et un rejeu peuvent lui parler ensemble. SIGTERM l'arrête proprement.
|
||||
|
||||
## Erreurs (protocole 1.2)
|
||||
|
||||
Codes JSON-RPC 2.0, plus `-32001` (protocole §7) :
|
||||
|
||||
| Reçu | Réponse | Connexion |
|
||||
|---|---|---|
|
||||
| `Handshake` sans le token attendu, ou avec un autre | `-32001` | fermée |
|
||||
| `Handshake` d'une autre majeure | `-32602` | fermé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 |
|
||||
| ligne non JSON | `-32700`, `id: null` | 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 |
|
||||
| requête d'une autre méthode, dont `UpdateLearned` avec `id` | `-32601` | gardée |
|
||||
|
||||
Sans `--token-file`, un token reçu au handshake est accepté sans être lu. Une notification (sans
|
||||
`id`) est ignorée.
|
||||
|
||||
## Pointer le moteur dessus
|
||||
|
||||
```sh
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
"""Optimiseur de référence (OPTIMIZER_PROTOCOL.md 1.1, annexe E).
|
||||
"""Optimiseur de référence (OPTIMIZER_PROTOCOL.md 1.2, annexe E).
|
||||
|
||||
etm-powersync-ref-optimizer [--listen unix:///run/powersync/optimizer.sock | tcp://hôte:port]
|
||||
[--token-file FICHIER] [--mode MODE | --mode-file FICHIER]
|
||||
@ -52,7 +52,20 @@ même optimiseur, et une connexion morte n'en bloque pas une neuve.
|
||||
|
||||
Le token se lit dans un FICHIER (--token-file), relu à chaque handshake, jamais en argument (il
|
||||
se verrait dans `ps`) et jamais affiché. S'il est configuré, un handshake sans lui, ou avec un
|
||||
autre, reçoit une erreur JSON-RPC -32001 puis la connexion est fermée.
|
||||
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.
|
||||
|
||||
Erreurs (protocole 1.2, §2, §3, §7 ; codes JSON-RPC 2.0, plus -32001) :
|
||||
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
|
||||
contexte GetPlan invalide -32602, `error.data.problems` = [{path, problem}], jamais la
|
||||
valeur reçue — une erreur, pas une abstention
|
||||
ligne non JSON -32700, id null
|
||||
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
|
||||
requête d'une méthode non servie -32601 (dont UpdateLearned, qui ne va que vers le moteur) ;
|
||||
une notification (sans id) est ignorée
|
||||
Hors handshake refusé, la connexion est toujours gardée.
|
||||
|
||||
Au handshake, il s'annonce `powersync-optimizer` (exemple de référence GPL) ; `etm-optimizer` est
|
||||
l'optimiseur ETM, propriétaire, hors de ce dépôt.
|
||||
@ -75,13 +88,19 @@ import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
ENDPOINT_DEFAUT = 'unix:///run/powersync/optimizer.sock'
|
||||
VERSION = '1.1'
|
||||
VERSION = '1.2'
|
||||
MAJEURE = VERSION.split('.')[0]
|
||||
NOM = 'etm-powersync-ref-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.
|
||||
NOM_ANNONCE = 'powersync-optimizer'
|
||||
LIGNE_MAX = 1024 * 1024 # E-T2-1 : une ligne de plus de 1 Mio est écartée
|
||||
CODE_TOKEN_REFUSE = -32001 # E-T2-6
|
||||
LIGNE_MAX = 1024 * 1024 # E-T2-1, §2 : 1 Mio par ligne, '\n' non compté, dans les deux sens
|
||||
CODE_TOKEN_REFUSE = -32001 # E-T2-6, §3
|
||||
CODE_ANALYSE = -32700 # JSON-RPC 2.0 ; codes 1.2 : OPTIMIZER_PROTOCOL.md §7
|
||||
CODE_REQUETE_INVALIDE = -32600
|
||||
CODE_METHODE_INCONNUE = -32601
|
||||
CODE_PARAMS_INVALIDES = -32602
|
||||
TROP_LONGUE = object() # lire_ligne() : ligne de plus de LIGNE_MAX octets, déjà jetée
|
||||
|
||||
MODES = ['normal', 'abstain', 'slow', 'invalid', 'expired', 'wrong-major', 'drop', 'unknown-load',
|
||||
'forbidden-kind', 'missing-load', 'echo-token', 'protocol-1.0', 'deviate', 'wrong-type']
|
||||
@ -308,12 +327,18 @@ def iso(t):
|
||||
return t.isoformat(timespec='seconds')
|
||||
|
||||
|
||||
def problemes_contexte(ctx):
|
||||
"""Ce que cet optimiseur ne peut pas lire dans un contexte GetPlan : [{path, problem}], jamais
|
||||
la valeur reçue (§7, 1.2). Seul `timestamp` est exigé — tout le reste se lit avec un défaut."""
|
||||
if instant(ctx.get('timestamp')) is None:
|
||||
return [{'path': '/timestamp', 'problem': 'absent, illisible ou sans décalage horaire'}]
|
||||
return []
|
||||
|
||||
|
||||
def plan(ctx, mode, horizon_s, token):
|
||||
"""La réponse à un GetPlan (`result`), ou None pour « ne pas répondre » (drop)."""
|
||||
"""La réponse à un GetPlan (`result`) ; le contexte a passé problemes_contexte()."""
|
||||
t0 = time.monotonic()
|
||||
maintenant = instant(ctx.get('timestamp'))
|
||||
if maintenant is None:
|
||||
return {'abstain': True, 'reason': 'timestamp illisible ou sans décalage'}
|
||||
if mode == 'abstain':
|
||||
return {'abstain': True, 'reason': 'mode abstain (test)'}
|
||||
actions = waterfall(ctx, inverse=(mode == 'deviate'))
|
||||
@ -412,28 +437,60 @@ class Connexion(socketserver.StreamRequestHandler):
|
||||
self.wfile.write(ligne)
|
||||
self.wfile.flush()
|
||||
|
||||
def erreur(self, ident, code, message, data=None):
|
||||
e = {'code': code, 'message': message}
|
||||
if data is not None:
|
||||
e['data'] = data
|
||||
self.envoyer({'jsonrpc': '2.0', 'id': ident, 'error': e})
|
||||
|
||||
def lire_ligne(self):
|
||||
"""Une ligne sans son '\n' ; None en fin de flux ; TROP_LONGUE pour une ligne de plus de
|
||||
LIGNE_MAX octets, lue jusqu'à sa fin par morceaux, sans jamais être gardée entière."""
|
||||
morceau = self.rfile.readline(LIGNE_MAX + 1)
|
||||
if not morceau:
|
||||
return None
|
||||
if morceau.endswith(b'\n'):
|
||||
return morceau[:-1]
|
||||
if len(morceau) <= LIGNE_MAX:
|
||||
return morceau # dernière ligne, sans '\n', avant la fin du flux
|
||||
while True:
|
||||
morceau = self.rfile.readline(LIGNE_MAX + 1)
|
||||
if not morceau or morceau.endswith(b'\n'):
|
||||
return TROP_LONGUE
|
||||
|
||||
def handle(self):
|
||||
cfg = self.server.cfg
|
||||
pret = False
|
||||
token = None
|
||||
while True:
|
||||
try:
|
||||
ligne = self.rfile.readline(LIGNE_MAX + 2)
|
||||
ligne = self.lire_ligne()
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
if not ligne:
|
||||
if ligne is None:
|
||||
return
|
||||
if len(ligne) > LIGNE_MAX:
|
||||
if ligne is TROP_LONGUE:
|
||||
journal('ligne trop longue écartée')
|
||||
self.erreur(None, CODE_REQUETE_INVALIDE, 'ligne trop longue')
|
||||
continue
|
||||
if not ligne.strip():
|
||||
continue
|
||||
try:
|
||||
message = json.loads(ligne)
|
||||
except ValueError:
|
||||
journal('ligne non JSON écartée')
|
||||
self.erreur(None, CODE_ANALYSE, "erreur d'analyse")
|
||||
continue
|
||||
capturer(cfg, message)
|
||||
if not isinstance(message, dict) or 'method' not in message:
|
||||
if isinstance(message, list):
|
||||
journal('lot JSON-RPC écarté : un message par ligne')
|
||||
self.erreur(None, CODE_REQUETE_INVALIDE, 'lot interdit')
|
||||
continue
|
||||
if not isinstance(message, dict):
|
||||
self.erreur(None, CODE_REQUETE_INVALIDE, 'requête invalide')
|
||||
continue
|
||||
if 'method' not in message:
|
||||
continue # une réponse : le moteur n'en envoie pas, rien à répondre
|
||||
ident, methode = message.get('id'), message.get('method')
|
||||
params = message.get('params') if isinstance(message.get('params'), dict) else {}
|
||||
if ident is None:
|
||||
@ -444,8 +501,12 @@ class Connexion(socketserver.StreamRequestHandler):
|
||||
if attendu is not None and params.get('token') != attendu:
|
||||
# Le token reçu n'est jamais recopié ni journalisé.
|
||||
journal('handshake refusé : token absent ou différent')
|
||||
self.envoyer({'jsonrpc': '2.0', 'id': ident,
|
||||
'error': {'code': CODE_TOKEN_REFUSE, 'message': 'token refusé'}})
|
||||
self.erreur(ident, CODE_TOKEN_REFUSE, 'token refusé')
|
||||
return
|
||||
version = params.get('protocolVersion')
|
||||
if not isinstance(version, str) or version.split('.')[0] != MAJEURE:
|
||||
journal(f'handshake refusé : majeure incompatible ({str(version)[:32]})')
|
||||
self.erreur(ident, CODE_PARAMS_INVALIDES, 'majeure incompatible')
|
||||
return
|
||||
token = attendu
|
||||
pret = True
|
||||
@ -461,8 +522,12 @@ class Connexion(socketserver.StreamRequestHandler):
|
||||
journal(f'handshake accepté ({str(params.get("engine", "?"))[:80]}), mode {mode}')
|
||||
elif methode == 'GetPlan':
|
||||
if not pret:
|
||||
self.envoyer({'jsonrpc': '2.0', 'id': ident,
|
||||
'error': {'code': -32002, 'message': 'handshake attendu'}})
|
||||
self.erreur(ident, CODE_REQUETE_INVALIDE, 'handshake requis')
|
||||
continue
|
||||
problemes = problemes_contexte(params)
|
||||
if problemes:
|
||||
journal(f'contexte invalide : {", ".join(p["path"] for p in problemes)}')
|
||||
self.erreur(ident, CODE_PARAMS_INVALIDES, 'contexte invalide', {'problems': problemes})
|
||||
continue
|
||||
if mode == 'drop':
|
||||
journal('mode drop : connexion fermée sans réponse')
|
||||
@ -472,8 +537,7 @@ class Connexion(socketserver.StreamRequestHandler):
|
||||
self.envoyer({'jsonrpc': '2.0', 'id': ident,
|
||||
'result': plan(params, mode, cfg.horizon_s, token)})
|
||||
else:
|
||||
self.envoyer({'jsonrpc': '2.0', 'id': ident,
|
||||
'error': {'code': -32601, 'message': 'méthode inconnue'}})
|
||||
self.erreur(ident, CODE_METHODE_INCONNUE, 'méthode inconnue')
|
||||
|
||||
|
||||
class ServeurUnix(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
|
||||
@ -518,7 +582,7 @@ def ecouter(endpoint):
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
p = argparse.ArgumentParser(prog=NOM, description='Optimiseur de référence (protocole 1.1, annexe E).')
|
||||
p = argparse.ArgumentParser(prog=NOM, description='Optimiseur de référence (protocole 1.2, annexe E).')
|
||||
p.add_argument('--listen', default=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)')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user