Patrick Schurig d9c16d34ea docs+deb: 1.15.2+etm9 — frontière RPC, clés internes optionnelles
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 06:32:32 +02:00

317 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# SPEC — Modèle de charges (domaine × mécanisme)
Version : 0.1.2
Dépôt : `etm-powersync-energy-plugin-etm`
Statut : **intention de conception figée.** §3 (schéma persisté) est **en cours
d'implémentation** depuis le 2026-08-09 : `relay-router`, `etmvariableload` et
`sg-ready` sont trois charges utiles discriminées par `adapter`. Le reste du
document reste une intention.
> **0.1.2** — LM-104 ajouté (§1) : « supposer conservateur pour annoncer, tenter
> systématiquement pour agir ». Principe dégagé des tests d'ECS-411 et ECS-414,
> inscrit ici parce qu'il vaut pour tout mécanisme et non pour l'ECS seul.
> §3 : le mécanisme `sg-ready` a sa charge utile et cesse d'être une intention.
> **0.1.1** — passe de vérification contre le code. Quatre précisions :
> préséance des verrous (LM-303), portée de l'union discriminée (LM-302),
> citation de règle (LM-102), `EvAdapter` inscrit au renommage (LM-601).
> Aucune décision de conception modifiée.
> **Ce document ne déclenche aucun travail.** L'ordre de `specs/spec_ecs.md` §2
> reste seul en vigueur, et l'étape 1 reste bloquée par ses arbitrages §13-1 et
> §13-2. Une seule chose ici est urgente : la **forme du schéma persisté** (§3),
> parce que c'est la seule partie coûteuse à rattraper une fois que le banc et la
> beta auront écrit des données. Le reste s'implémente au fil des besoins.
---
## §1 — Deux couches
**LM-100** — Le modèle de charges se découpe en deux couches indépendantes :
- **Couche domaine** — `Ecs`, `Hvac`, `Ev`, `SmartHome`. Détient le modèle de la
charge (état thermique, échéance de départ, cycle), en déduit la demande et
`available`. **Ne parle à aucun matériel.**
- **Couche mécanisme** — `Relay`, `Variable`, `SgReady`, `ModbusSetpoint`.
Traduit une enveloppe en watts vers le matériel et tient les verrous.
**Écrite une seule fois, partagée par tous les domaines.**
**LM-101** — Une charge configurée choisit **un domaine et un mécanisme**. Le
domaine ne détermine pas le mécanisme et réciproquement.
**LM-102** — Aucune des deux couches ne répartit de budget. La couche domaine se
retire de l'allocation via `available` ; elle ne la négocie pas. Règles absolues
1 et 2 d'`AGENTS.md` inchangées.
**LM-104 — Supposer conservateur pour annoncer, tenter systématiquement pour agir.**
Un adaptateur qui ne peut pas LIRE l'état d'un organe DOIT le supposer dans l'état
le plus consommateur (contact fermé, palier engagé) quand il **annonce** — dans
`telemetry()`, `toLoadContext()` ou la déduction de son état courant au démarrage.
Le même adaptateur DOIT néanmoins **émettre l'écriture** vers cet organe quand il
agit, sans jamais la court-circuiter au motif que l'état supposé coïncide déjà avec
la cible.
Les deux moitiés sont indissociables, et c'est l'omission de la seconde qui est
piégeuse : supposer « fermé » puis en déduire « donc rien à écrire » transforme une
hypothèse de prudence en **masquage de panne** — l'organe reste dans son état réel,
inconnu, et aucun verdict d'échec ne se déclenche. La prudence porte sur ce qu'on
DIT de l'installation, jamais sur ce qu'on lui ENVOIE.
Portée : tout mécanisme, présent et futur. `Relay` (déduction de palier au
démarrage), `SgReady` (relevé du motif de contacts réellement fermés), et par
construction `ModbusSetpoint` dès qu'un registre devient illisible. Origine :
deux défauts symétriques trouvés par les tests d'ECS-411 puis d'ECS-414 —
d'abord l'hypothèse inverse (« injoignable donc ouvert »), puis son corollaire
(« supposé fermé donc jamais écrit »).
**Justification.** Un nommage par domaine seul (`EcsAdapter`, `HvacAdapter`)
obligerait à écrire l'encodage SG-Ready deux fois (ECS et HVAC), la combinatoire
de relais deux fois (ECS et EV en prise commandée), et le transport Modbus deux
fois (HVAC et EV). Un nommage par mécanisme seul perdrait les comportements
propres au domaine, qui sont réels : stockage différable pour l'ECS, confort
immédiat pour le HVAC, échéance de départ pour l'EV, cycle non modulable pour le
SmartHome.
---
## §2 — Matrice
| Domaine | Mécanismes attendus |
|---|---|
| `Ecs` | `Relay` (résistance à paliers) · `SgReady` (ballon thermodynamique) · `Variable` (triac) |
| `Hvac` | `SgReady` (PAC) · `ModbusSetpoint` (PAC, clim, VMC) |
| `Ev` | `Relay` (prise commandée : GreenUp, Witty) · `ModbusSetpoint` (borne) |
| `SmartHome` | `Relay` — voir §7, sémantique différente |
Les cases vides ne sont pas interdites, elles ne sont simplement pas prévues.
---
## §3 — Schéma de configuration (à figer)
**LM-300**`LoadConfig` est une **union discriminée** par le mécanisme :
```
LoadConfig {
id, nom, enabled
domaine : Ecs | Hvac | Ev | SmartHome
typeAppareil : affine le domaine (résistif | thermodynamique | …)
groupe, prioritéGroupe, prioritéMembre
mécanisme : Relay | Variable | SgReady | ModbusSetpoint ← discriminant
chargeUtile : LoadConfigRelay | LoadConfigVariable | …
verrous : minOnS, minOffS
facettes[] : voir §4
}
```
**État d'implémentation (2026-08-09).** Trois charges utiles existent, discriminées
par le champ `adapter` du schéma persisté — `relay-router`, `etmvariableload`,
`sg-ready`. Le domaine, le groupe et les facettes ne sont pas encore portés : ce
qui est figé, c'est la **forme**, celle qui coûte cher à rattraper.
```
adapter = "sg-ready" → sgReady {
states[] { state : 1..4, relays[] : thingId, estimatedPowerW }
minStateHoldS
}
```
`estimatedPowerW` porte volontairement ce nom : c'est une **estimation**, jamais un
engagement — une PAC ne consomme pas la même chose à 5 °C et à +12 °C. Le champ
sert à ordonner et à budgéter, la mesure reste la source de vérité (ECS-500).
L'état 2 est **obligatoire** : c'est le plancher de repli du mode dégradé L2 et de
la désactivation (ECS-413/414). Une configuration qui ne peut pas l'exprimer est
refusée, pas construite.
**LM-301** — Ajouter un mécanisme DOIT se limiter à un type de charge utile et
une branche de fabrique. Rien d'autre ne bouge. **Vérifié à l'usage** : l'ajout de
`sg-ready` a touché exactement une charge utile, une branche de `isValid()`, une
branche de fabrique dans `rebuildLoadAdapters()`, et un champ optionnel au schéma
RPC. Aucun code d'arbitrage n'a bougé.
**LM-302-b — Ce qu'une charge utile ne peut pas valider.** L'exclusivité d'un Thing est
une propriété de l'**ensemble** des charges, pas d'une charge : aucune charge utile ne
peut la vérifier, quelle que soit sa rigueur. Elle vit donc au niveau du store
(`validateSet()`), et tout mécanisme futur DOIT déclarer les Things qu'il revendique via
`LoadConfig::claimedThingIds()` — c'est le seul point à étendre, et l'oublier rend le
mécanisme invisible à la vérification. Voir ECS-110-b.
**LM-302** — Chaque charge utile valide la sienne. Les états invalides doivent
être inexprimables : pas de registre Modbus dans une configuration relais.
**Portée exacte.** L'union discriminée s'applique aux **types C++** et au
**schéma persisté**. Elle ne s'applique **pas** au schéma JSON-RPC : nymea valide
les paramètres *avant* le handler, si bien que deux formes exclusives obligent à
marquer tous les champs spécifiques en optionnel (`"o:"`), faute de quoi l'une
des deux serait rejetée en amont. La contrainte est déjà documentée dans
`nymeaenergyjsonhandler.cpp:155-160` pour deux mécanismes ; elle s'aggrave à
quatre. **Le mur a été touché le 2026-08-10**, exactement où ce paragraphe l'annonçait, et d'une
façon qu'il n'avait pas anticipée : ce n'est pas seulement que deux formes exclusives
obligent à marquer les champs `"o:"`, c'est que `pack<LoadConfig>()` sérialise **toutes**
les propriétés déclarées — une charge `relay-router` sort donc de `GetLoadConfig` avec un
`sgReady` vide. Exiger une clé **à l'intérieur** d'une charge utile optionnelle casse
alors le va-et-vient de l'app sur les charges des **autres** mécanismes. Corollaire pour
tout mécanisme futur : les clés internes d'une charge utile se marquent `"o:"` elles
aussi. À la frontière RPC, la validation reste donc **à l'exécution**
(`LoadConfig::isValid()`). Ne pas tenter un schéma RPC strict : c'est un mur
connu.
**LM-303 — Verrous.** `typeAppareil` fournit des **défauts** (résistif ≈ 60 s,
thermodynamique ≈ 300 s), l'installateur peut les modifier, et c'est la valeur
**stockée** qui fait foi. Jamais de temporisation codée en dur.
**Préséance.** Les verrous déclarés au niveau de la **charge** s'appliquent par
défaut. Une charge utile PEUT en porter de plus fins — par relais, par registre —
et ceux-là **priment** sur le défaut de charge, pour le seul actionneur qu'ils
désignent.
Cette règle laisse `spec_ecs.md` §13-3 (ECS-307, grain des temporisations par
relais) entièrement ouvert : ajouter plus tard un champ optionnel dans une charge
utile est rétro-compatible, quelle que soit la réponse. Le schéma persisté n'a
donc pas à être « rendu tolérant » — c'est la préséance qui devait être écrite,
et elle l'est ici.
---
## §4 — Facettes : une machine, plusieurs fonctions
**LM-400** — Une machine physique est **une seule charge** : un rang, un jeu de
verrous, une ligne de budget. Une PAC assurant chauffage et ECS n'est **jamais**
déclarée deux fois.
**Justification.** Deux déclarations produiraient une double allocation du
surplus pour une seule consommation, deux rangs pour un seul comportement, et
deux jeux de verrous sur un seul compresseur — la protection anti court-cycling
tomberait précisément là où elle coûte le plus cher.
**LM-401** — Une charge PEUT porter **plusieurs facettes** au-dessus d'un
actionnement unique. Une PAC mixte porte une facette ECS (consigne, température
ballon) et une facette chauffage, avec un seul mécanisme.
**LM-402** — Les facettes servent à la lecture, au suivi et au calcul
d'`available`. Elles **ne portent pas de rang**. Une charge à deux facettes ne
peut pas être classée « après les chauffe-eau mais avant la clim » : si cette
granularité est nécessaire, il faut deux machines, pas deux entrées.
**LM-403 — Le mode est une sortie, pas une entrée.** SG-Ready applique un niveau
d'encouragement à la machine entière ; le choix de la fonction appartient au
régulateur interne. Le HEMS **observe** le mode courant, il ne le sélectionne
pas. Aucune conception ne DOIT supposer un aiguillage.
Ce que le mode apporte : `available` devient honnête. Machine en mode chauffage
→ la facette ECS n'absorbera rien quoi qu'on encourage → le waterfall passe au
suivant au lieu de le deviner.
**LM-404 — Biais par consigne : option avancée, hors périmètre pour l'instant.**
Monter `hotWaterSetpointTemperature` place le ballon sous consigne et la machine
bascule d'elle-même. C'est indirect, lent, et surtout **persistant** : une
consigne écrite le reste après un plantage ou un redémarrage, et elle écrase un
réglage d'installateur. Toute implémentation future DOIT comporter des bornes
dures, une politique de restauration et un chien de garde. À ne rouvrir que si le
terrain le justifie.
**LM-405 — Décomposition côté ems, pas côté plugin.** Le découpage en facettes
DOIT se faire dans l'ems au-dessus des états plats du thing, et non en forkant
les plugins constructeurs. Chaque constructeur expose une structure différente ;
l'abstraction doit vivre là où elle est indépendante du constructeur, sous peine
de la refaire N fois et de gérer deux formes du même concept.
**LM-406** — Si l'installation n'a pas de chauffe-eau distinct, il n'y a pas de
charge ECS : la facette ECS s'accroche à la charge PAC.
---
## §5 — Groupes et double rang
**LM-500** — Un groupe est une **clé de tri**, jamais un détenteur de budget. Le
waterfall trie sur le couple `(prioritéGroupe, prioritéMembre)` et reste
inchangé. Un groupe qui reçoit une allocation puis la redistribue est un second
décideur (règle absolue 1).
**LM-501** — Classer un ECS, une PAC et une clim les uns par rapport aux autres
**fonctionne déjà** avec la liste plate triée par `priority`. Les groupes ne sont
nécessaires qu'à l'échelle : plusieurs chauffe-eau, plusieurs bornes, plusieurs
zones, quand on veut déplacer une famille entière sans renuméroter.
**LM-502 — Plafond de groupe : optionnel.** Justifié quand les membres partagent
une limite électrique réelle (bornes sur un même câble ou un même abonnement).
Sans objet pour un groupe thermique, dont les membres sont sur des circuits
distincts. C'est le seul point où la boucle du waterfall change vraiment : elle
doit suivre l'engagement cumulé du groupe.
**LM-503** — Deux notions à **ne pas** confondre avec un rang :
- **Exclusion mutuelle** (clim et PAC en opposition dans une même zone) : une
contrainte entre appareils.
- **Équité interne** (trois chauffe-eau à rang égal, dont le troisième resterait
froid tout l'été) : une politique de rotation dans le tri.
**LM-504** — « HVAC » couvre chauffage, ventilation et climatisation ; y ranger
l'eau chaude sanitaire est un abus. Libellé recommandé côté installateur :
« Thermique » ou « Chauffage & eau chaude ».
---
## §6 — Nommage
**LM-600** — Les classes d'actionnement portent le nom du **mécanisme**, jamais
du domaine.
**LM-601** — Un renommage de cohérence est souhaitable. Trois classes sont
concernées, avec des échéances distinctes :
| Classe | Défaut | Débloqué après |
|---|---|---|
| `RelayRouter` | Ne suit pas la convention `*Adapter` de ses voisins | étape 3 de `spec_ecs.md` |
| `EtmVariableLoadAdapter` | Préfixe `Etm` redondant dans un dépôt ETM | étape 3 de `spec_ecs.md` |
| `EvAdapter` | **Nommé d'après un domaine** (§2 range `Ev` en domaine) | **3g** |
`EvAdapter` n'est pas une classe de domaine égarée dans la couche mécanisme :
c'est un **adaptateur de mécanisme mal nommé**. Il parle à l'interface
`evcharger` de nymea — un mécanisme au même titre que `SgReady`. Le correctif est
un renom, pas un redécoupage.
Aucun de ces renommages ne se fait avant son échéance. Pour `RelayRouter`,
renommer juste avant la restructuration de `m_relayMapping` double le bruit dans
l'historique ; pour `EvAdapter`, le câblage lui-même change en 3g (l'adaptateur
n'est aujourd'hui pas dispatché), et renommer une classe dont le branchement va
bouger produit le même bruit pour rien.
`BatteryAdapter` n'entre pas dans ce tableau : LM-700 le traite hors moule.
---
## §7 — Cas hors moule
**LM-700 — Batterie.** Une batterie n'est pas une charge avec un rang : elle est
aussi une **source**, capable de financer les autres charges (grid-funding, 3f).
Elle intervient à un autre moment de la cascade et ne se modélise pas comme un
consommateur classé. Onduleur hybride et AC-coupling sont bien deux mécanismes.
**LM-701 — SmartHome.** Un cycle de lave-vaisselle ne se descend pas à 0 W en
cours de route : c'est un engagement pris au démarrage, pas un verrou `minOn`.
La sémantique d'action n'est pas une enveloppe en watts mais un **décalage
temporel** — démarrer maintenant ou plus tard. À traiter comme un type d'action
distinct, pas comme une variante.
---
## §8 — Points ouverts
1. `typeAppareil` — quelle énumération exacte, et jusqu'où elle affine le
domaine.
2. Représentation persistée des facettes (§4) : imbriquées dans la charge, ou
table séparée référençant la charge.
3. Sémantique d'action du SmartHome (LM-701) : à spécifier avant toute
implémentation, elle ne se déduit pas du reste.
4. Rotation d'équité (LM-503) : politique à définir si le besoin se confirme.
---
## §9 — Ce que ce document ne change pas
- L'ordre de `specs/spec_ecs.md` §2 et ses arbitrages bloquants.
- Les règles absolues 1 à 10 d'`AGENTS.md`.
- Le découpage en vigueur : le scheduler alloue, `RelayRouter` traduit.
- Rien dans le code aujourd'hui.