etm-powersync-app/INTERFACE_etmvariableload.md
Patrick Schurig a4487324dc docs: resync du miroir INTERFACE_etmvariableload — rév. 2 → rév. 3 + LM-200/201/202
Ce miroir était resté en rév. 2 alors que le canonique du dépôt moteur est en rév. 3 depuis
le 2026-08-09. La divergence portait sur le point le plus structurant : la rév. 2 plaçait la
combinatoire watts→relais « dans le thing », ce que la rév. 3 renverse — un integration-plugin
nymea ne peut PAS piloter les things d'un autre plugin, donc la combinatoire vit côté
experience-plugin, et l'écran « Configurer » change (§10, annoncé « impact app majeur »).
Le bandeau désignait en outre un chemin canonique faux (racine au lieu de docs/).

Contenu neuf pour l'app, au-delà du rattrapage de révision :

- §4-bis — LM-201, UN CANAL DE COMMANDE, UNE CHARGE. Décide de ce que l'écran « Configurer »
  doit CRÉER : une PAC chauffage+ECS sans ballon séparé = UNE charge, pas deux. Avec ballon
  séparé = deux. Une PAC qui fait aussi du froid = toujours une seule.
- §4-bis — LM-202, thermostats et zones NE SONT PAS des charges : ils ouvrent une vanne, ils ne
  portent pas la consommation. Leur écran, leur namespace, jamais LoadConfig.
- §4-ter — LM-200, mécanismes recevables selon l'organe, et le piège d'UI « ma PAC est pilotée
  par deux relais donc relay-router » : les contacts SG-Ready SONT des things power, mais ils
  signalent au lieu de porter la charge.
- domain est explicitement SANS force normative — ne construire aucune règle dessus. Le moteur
  n'interdit rien : c'est l'app qui guide au moment du choix, seul endroit fiable puisqu'elle
  sait quel appareil l'installateur déclare.
- Lot B-bis : loads[] de GetLoadTelemetry est désormais INCLUS dans GetLoadConfig ; la règle de
  détection « loadId sans équivalent » est retirée et aucun configurable:false n'existe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-25 21:44:19 +02:00

351 lines
20 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.

<!-- ⚠️ MIROIR — NE PAS ÉDITER ICI. Canonique : etm-powersync-energy-plugin-etm/docs/INTERFACE_etmvariableload.md. Éditer le canonique, puis resync. -->
<!-- Resync 2026-08-25 : ce miroir était resté en rév. 2 alors que le canonique était en rév. 3 — -->
<!-- soit tout le déplacement de frontière optimiseur/routeur (§10, écran « Configurer »). Vérifier -->
<!-- l'identité avec le canonique AVANT de coder : diff des deux fichiers, corps hors bandeau. -->
# INTERFACE charge pilotée — contrat · **rév. 3**
> **Source de vérité unique.** Déposé **à l'identique dans les deux repos**
> (`etm-powersync-app` et `etm-powersync-energy-plugin-etm`). Toute divergence se tranche **ici**,
> pas dans le code. Un agent qui a besoin d'un champ absent met à jour ce doc **d'abord**, dans
> les deux repos.
>
> ### ⚠️ rév. 3 — déplacement de frontière (lire avant de coder)
> La rév. 2 plaçait la combinatoire matérielle « dans le thing ». **Vérification faite : un
> integration-plugin nymea ne peut PAS piloter les things d'un autre plugin** (`thingManager`
> y est privé ; seuls le cœur, le moteur de règles, les scripts et les **experience-plugins**
> commandent des things tiers). Donc la combinatoire watts→relais **ne peut vivre que côté
> experience-plugin** — là où l'arbitre a déjà le `ThingManager`.
>
> **rév. 3 acte cette réalité** : un relais est un **thing `power` nu** (comme n'importe quel
> relais Modbus/MQTT) ; la combinaison watts→relais vit dans une couche **ROUTEUR** distincte,
> **sous** l'optimiseur, côté experience-plugin. L'optimiseur reste **watt-pur** et ne connaît
> **jamais** un relais. **Impact app majeur en §10 — l'écran « Configurer » change.**
---
## 1. Principe & frontière (rév. 3)
**Trois couches, la frontière entre OPTIMISEUR et ROUTEUR.**
| Couche | Où | Raisonne en | Connaît |
|---|---|---|---|
| **Optimiseur** | `RuleBasedScheduler` / `SocketScheduler` (GPL ou socket) | **watts** | priorités, `powerLevels` (W), surplus, besoins. **Jamais** un relais. |
| **— FRONTIÈRE —** | | | l'optimiseur émet `LoadAction{Setpoint, powerW}` ; rien d'autre ne descend |
| **Routeur** | couche adaptateur, **experience-plugin** (a le `ThingManager`) | watts → **relais** | la liste de relais, la combinatoire, `minOn/minOff`, l'agrégation `currentPowerW` |
| **Relais** | things `power` (plugins GPIO / Modbus / MQTT) | on/off | leur propre matériel |
> **Invariant directeur (rév. 3).** L'**optimiseur** raisonne uniquement en watts : aucun relais,
> aucun index de combinaison ne franchit la frontière vers le haut. Le **routeur** traduit le
> setpoint W en commutation de relais et n'expose vers le haut que des watts (`powerLevels`,
> `currentPowerW`). **L'optimiseur décide de la puissance ; le routeur décide des relais.**
---
## 2. Portée
| Charge | Couche d'exécution | Nature |
|---|---|---|
| **ECS résistif multipalier** | **`RelayRouter`** (rév. 3) | N relais `power` → combinaison watts |
| **ECS simple on/off** | `RelayRouter` (cas dégénéré : 1 relais, niveaux `[0, nominal]`) | 1 relais `power` |
| **EV charger / Routeur PV / triac** (modulation continue) | **`EtmVariableLoadAdapter`** (inchangé depuis rév. 2) | 1 thing à puissance pilotable, setpoint direct |
| **PAC SG-Ready** | `SgReadyAdapter` (modèle à **états**, §8) | **exclue** — pas une charge en watts |
Les deux exécuteurs (`RelayRouter`, `EtmVariableLoadAdapter`) parlent à l'optimiseur **la même
langue** : `LoadAction{Setpoint, powerW}`. Chacun exécute selon sa nature — combinaison de relais
pour l'un, écriture d'un setpoint continu pour l'autre. Un triac n'est **pas** un routeur de
relais (pas de combinatoire) : on ne le force pas dans ce moule.
---
## 3. Les exécuteurs sous l'optimiseur
### 3.a Cas relais (ECS multipalier) — `RelayRouter`
- **Pas de thing `etmvariableload` unique.** La charge logique est une **liste de relais `power`**.
- Chaque relais est un thing implémentant l'interface nymea **`power`** (state `power` bool
writable ; optionnellement `currentPower` W en lecture). Fourni par le plugin device
(GPIO/Modbus/MQTT) — **rien de spécifique ECS dans ce plugin**.
- Le `RelayRouter` (experience-plugin) :
1. reçoit `LoadAction{Setpoint, powerW}` de l'optimiseur ;
2. **mappe** `powerW` → la combinaison de relais la plus haute ≤ `powerW` ;
3. applique l'anti-rebond **`minOn`/`minOff`** (ici, pas dans le thing) et l'ordre
**off-before-on** (pas de sur-puissance transitoire sur une transition non-cascadée) ;
4. commande chaque relais via `ThingManager::executeAction` (state `power`) ;
5. **agrège `currentPowerW`** = somme des `currentPower` des relais ON, **ou** à défaut de
mesure (relais GPIO bool sans wattmètre) la **somme des nominaux commandés**. Dans ce
dernier cas `currentPowerW` reflète le *commandé*, pas le *mesuré* — acceptable, à garder
en tête pour le diagnostic terrain.
- **`powerLevels`/`maxPowerW` sont DÉRIVÉS** des combinaisons atteignables de la liste de
relais — **source de vérité unique = les relais**. Le routeur les calcule et les expose à
l'optimiseur ; l'assistant app fait le même calcul pour l'affichage.
### 3.b Cas continu (EV / routeur PV) — interface device `etmvariableload`
Inchangé rév. 2 : 1 thing exposant un state `powerSetpoint` (write) + `currentPowerW` (read),
piloté par `EtmVariableLoadAdapter`. (`maxPowerW`/`powerLevels` en read optionnels.)
| State | Accès | Type | Sémantique |
|---|---|---|---|
| `powerSetpoint` | **write** | uint W | consigne de l'optimiseur |
| `currentPowerW` | read | uint W | puissance réellement appliquée |
| `maxPowerW` | read | uint W | plafond physique |
---
## 4. `LoadConfig` — config app → energymanager (rév. 3)
Émis par l'écran « Configurer charge pilotée » ; persisté **côté plugin**
(`/var/lib/nymea/energy-load-configuration.json`), jamais en SharedPreferences. App = éditeur :
`NymeaEnergy.GetLoadConfig` / `SetLoadConfig`.
### Cas relais (`adapter: "relay-router"`, mode `fixed`)
```jsonc
{
"id": "ecs-chauffe-eau", // charge LOGIQUE (plus un thingId unique)
"label": "Chauffe-eau",
"adapter": "relay-router",
"mode": "fixed",
"priority": 2, // rang ; 1 = servi en premier
"enabled": true,
"domain": "ecs", // o: — catégorie d'INTENTION, cf. ci-dessous
"needs": { "dailyDeadline": "06:00", "minEnergyWhPerDay": 4000 },
"relays": [ // ← la combinatoire vit ICI (routeur), pas chez l'optimiseur
{ "thingId": "{uuid-relais-500W}", "powerW": 500 },
{ "thingId": "{uuid-relais-1000W}", "powerW": 1000 },
{ "thingId": "{uuid-relais-2000W}", "powerW": 2000 }
],
"minOnS": 60,
"minOffS": 60
// powerLevels / maxPowerW : ABSENTS de la config — DÉRIVÉS de relays[] par le routeur.
}
```
### Cas continu (`adapter: "etmvariableload"`, mode `dynamic`)
```jsonc
{
"id": "{uuid-thing-triac}", "label": "Routeur PV salon", "adapter": "etmvariableload",
"mode": "dynamic", "maxPowerW": 3000, "priority": 1, "enabled": true
}
```
Règles :
- **`relays[]` ne franchit jamais la frontière** : il vit dans le routeur. L'optimiseur ne reçoit
que `powerLevels`/`maxPowerW` (watts dérivés) via le contexte.
- `relays[].thingId` = ThingId du thing `power`. **Choix par PICKER côté app** (voir §10) — le
picker est **UI-only** et ne fait qu'aider la saisie : il résout *nom du relais → ThingId* et
écrit des ThingIds dans `relays[]`. **Aucun champ supplémentaire au contrat.**
- `minOnS`/`minOffS` : verrous anti-rebond (protection relais / compresseur), **par charge**.
- `enabled:false` → rôle déclaré mais **exclu** de l'arbitrage (aucune action, aucun adaptateur
construit).
- **`domain` (optionnel)** — énumération FERMÉE : `ecs` · `ev` · `heating` · `hvac` · `battery`.
- **Pure métadonnée d'intention.** L'arbitre ne la lit pas ; aucun comportement moteur n'en
dépend. Elle enregistre le choix de l'installateur, qui sélectionne une catégorie puis lui
rattache ses Things.
- **Des codes, jamais des libellés affichables** — même raison que pour les motifs de décision
(locale nymea par connexion, handler hors catalogue de traduction ; cf. `INTERFACE.md`).
Le nom montré à l'utilisateur est fabriqué par l'app.
- **Absente ou vide → charge « non classée ».** Le plugin ne la range pas d'office : il n'a
aucun moyen de deviner juste, et un classement inventé serait indistinguable d'un
classement choisi.
**Forme exacte, vérifiée au banc** : `GetLoadConfig` sérialise via le **méta-objet**, donc
**toutes** les propriétés déclarées sortent, y compris vides — une charge non classée revient
avec `"domain": ""`, exactement comme `"sgReady": {}`, `"powerLevels": []` ou
`"needs": {"dailyDeadline": ""}` sur une charge qui ne les porte pas. C'est le corollaire
LM-302, et c'est ce qui rend l'aller-retour `Get` → `Set` possible. La forme **persistée**
(fichier JSON), elle, omet la clé. Le client doit donc traiter `""` **et** l'absence comme
« non classée » — et ne jamais afficher la chaîne vide comme un domaine.
- `SetLoadConfig` **rejette en bloc** une valeur hors énumération. Une valeur libre acceptée
aujourd'hui deviendrait une valeur à supporter le jour où la couche domaine en fera son
discriminant.
- Elle **ne préempte pas** la couche domaine de `specs/spec_loadmodel.md` : elle en deviendra
le discriminant quand cette couche arrivera. L'énumération de ce spec (§2 :
`Ecs`/`Hvac`/`Ev`/`SmartHome`) est une intention plus ancienne ; c'est celle ci-dessus qui
est implémentée.
- ⚠️ **`domain` n'a AUCUNE force normative aujourd'hui, et n'en aura pas à court terme.**
Il groupe l'ordre de service, rien d'autre. **Ne construire aucune règle dessus** — ni
côté app, ni côté moteur. Ce que `heating` et `hvac` désignent exactement reste ouvert et
ne se saura qu'en dessinant la PAC communicante.
### 4-bis. Ce qui fait une charge — **un canal de commande, une charge**
> **Règle LM-201** (`specs/spec_loadmodel.md` §2). Elle décide de ce que l'écran
> « Configurer » doit **créer**, et c'est la question que l'app pose en premier.
**Une charge existe quand il y a un canal de commande distinct pour un service.** Le domaine
ne décrit pas la **machine**, il décrit **ce qu'on arbitre** — une PAC air/eau fait chauffage,
rafraîchissement et ECS, et cela ne fait pas trois charges.
| Installation | Ce que l'app doit créer | Pourquoi |
|---|---|---|
| PAC chauffage + ECS, **sans ballon séparé** | **UNE** charge (`heating`) | un seul canal SG-Ready commande la machine entière |
| PAC + **ballon séparé** | **DEUX** charges (`heating`, `ecs`) | le ballon a son **propre** canal |
| PAC qui fait **aussi du froid** | **UNE** charge | même machine, même canal, même rang — ce qui change est la saison, pas la configuration |
- **Ne pas créer de charge ECS sur une PAC sans ballon séparé.** La répartition entre chauffage
et ECS appartient à la **régulation de la PAC** ; il n'y a rien à arbitrer indépendamment.
- **Deux charges ne peuvent pas revendiquer le même Thing.** `SetLoadConfig` **rejette en
bloc** (LM-302-b / ECS-110-b). Ce n'est pas une limite technique : deux charges sur un canal
unique recevraient deux consignes pour un seul organe, et le budget compterait la même
puissance deux fois.
**Les thermostats et les zones ne sont PAS des charges (LM-202).** Un thermostat ouvre une
**vanne** ; il ne porte pas la consommation, qui reste à la machine. Les déclarer produirait
des entrées à **0 W** dans le waterfall pendant que la PAC est comptée ailleurs. Zones et
thermostats sont de la **configuration de confort** — ils disent **où va la chaleur**, pas
**combien on en achète**. Ils vivent dans leur écran et leur namespace, **jamais** dans
`LoadConfig`.
### 4-ter. Mécanismes recevables selon l'organe (LM-200)
**Une PAC se pilote de deux façons, et de deux seulement** : `sg-ready` (deux contacts **secs**
de signalisation, 4 états normés) ou **communicante** via un plugin nymea — ce dernier mécanisme
**n'existe pas encore**.
**Ni `relay-router`, ni `etmvariableload`** : le premier couperait l'**alimentation** du
compresseur et lui retirerait son autorité sur son propre cycle (dégivrage, retour d'huile,
temporisation de redémarrage) ; le second suppose un cadran en watts qu'un compresseur n'est pas.
> ⚠️ **Piège d'UI : « ma PAC est pilotée par deux relais, donc `relay-router` ».** Non. Les
> contacts SG-Ready **sont** des things `power` — au banc `.75`, K1/K2 en sont deux. Mais ils
> **signalent**, ils ne **portent** pas la charge. Voir des relais dans une configuration ne dit
> **rien** du mécanisme qui convient. Le picker de relais de §10 sert les deux mécanismes.
Le moteur **n'interdit pas** ces combinaisons — `domain` est facultatif et sans force normative,
un refus serait contournable en effaçant le champ. **C'est donc l'app qui guide au moment du
choix**, et c'est le seul endroit où le guidage est fiable : elle sait quel appareil
l'installateur est en train de déclarer, le moteur ne le sait pas.
---
## 5. Règle d'arrondi (optimiseur) — inchangée
À chaque cycle, pour une charge dont c'est le tour dans la priorité :
- **Fixed** : `level = max( l ∈ powerLevels | l ≤ budget )` puis `Setpoint(level)`. L'optimiseur
connaît la granularité **dérivée** → pas de sur-allocation, **zéro cycle de retard**. (Le routeur
traduit ensuite `level` → relais.)
- **Dynamic** : `Setpoint( clamp(budget, 0, maxPowerW) )`.
- **Résidu** : lire `currentPowerW` **de début de cycle** (télémétrie de l'exécuteur — PAS de
relecture post-setpoint : invariant 8). Le résidu `budget − currentPowerW` repart vers la
charge suivante de la priorité **du même cycle**.
**Déclaré vs réel :** `powerLevels`/`maxPowerW` (dérivés des relais) = référence de planification.
`currentPowerW` = juge runtime. Si le réel plafonne durablement sous le déclaré → erreur de
câblage/déclaration, l'installateur corrige. L'optimiseur **n'écrase jamais** la déclaration.
---
## 6. `LoadAction` — **deux** kinds (inchangé)
| Kind | Pour | Porte |
|---|---|---|
| `Setpoint` | EV / ECS / routeur PV (via `RelayRouter` **ou** `EtmVariableLoadAdapter`) | `powerW` |
| *état SG-Ready* | PAC (interface à états, §8) | `state` (1–4) |
| `Constraint` | batterie (déféré 3f) | conservé |
L'index de combinaison de relais **n'existe pas** au niveau `LoadAction` : il est **privé au
routeur**. Le kind `Stage` reste supprimé (rév. 2). `reason` reste porté par chaque `LoadAction`.
---
## 7. Expo du `reason` — ⚠️ REMPLACÉ par `NymeaEnergy.GetLoadTelemetry`
> **Ce paragraphe décrit une intention qui n'a jamais été implémentée, et qui ne le sera pas.**
> `loadActions[]` n'existe pas dans `GetChargingSchedules` ni dans `ChargingSchedulesChanged`.
> Le canal réel de la *decision card* est `NymeaEnergy.GetLoadTelemetry` + sa notification
> `LoadTelemetryChanged` (contrat : `INTERFACE.md`).
>
> Une différence de fond, et non de forme : le `reason` ci-dessous est une **phrase française
> composée par la box**. C'est intenable — la locale nymea est par CONNEXION, et ce handler est
> hors du catalogue de traduction. La télémétrie transporte donc `{code, params}`, et la phrase
> est fabriquée par le client. La ligne française du journal, elle, est **rendue depuis le même
> code** par une fonction unique (`renderFr`), pour qu'elle ne puisse pas en diverger.
Forme historique, conservée pour mémoire :
## 7-bis. Ancienne forme (mémoire)
Dans `GetChargingSchedules` / `ChargingSchedulesChanged` :
```jsonc
"loadActions": [
{ "loadId": "ecs-chauffe-eau", "setpointW": 1500, "currentPowerW": 1480, "reason": "Surplus PV 1.6 kW — ECS rang 2" },
{ "loadId": "uuid-pac", "state": 3, "reason": "Surplus PV — PAC en état 3" }
]
```
`setpointW`/`currentPowerW` optionnels (absents pour une charge à états) ; `loadId` + `reason`
**toujours** présents. C'est le canal de la **decision card** de l'app.
---
## 8. PAC SG-Ready — modèle à états (conservé, inchangé)
Hors charge en watts. `SgReadyAdapter` (états 1–4, combos K1/K2, hystérésis, `minStateHold`)
n'est pas réécrit. L'arbitre lui envoie un **état**, pas un setpoint W. Repli L2 : état 2 (normal,
mains off), jamais état 1 (blocage).
---
## 9. Sécurité — repli L2 (rév. 3)
- **Watchdog L2** (`evaluateMeterFreshness`, 90 s, `applyDegradedMode`) inchangé. En dégradé :
- charges en watts (`RelayRouter` **et** `EtmVariableLoadAdapter`) → `Setpoint(0)` `force=true`.
Pour le `RelayRouter`, ça coupe **tous** les relais (`force` bypasse `minOn/minOff`).
- PAC → état 2.
- Ordre `update()` (sécurité avant planif) intact. `enabled:false` → exclu de l'arbitrage.
---
## 10. IMPACT APP — écran « Configurer charge pilotée » (⚠️ lire avant de coder l'UI)
**Ce qui change pour `etm-powersync-app` en rév. 3.** L'agent app doit lire ceci **avant** de
toucher à l'écran Configurer, sinon il code contre une frontière périmée (rév. 2).
- **AVANT (rév. 2)** : configurer une ECS = choisir **1 thing `etmvariableload`** + saisir des
`powerLevels`.
- **APRÈS (rév. 3)** : configurer une ECS = **ajouter une liste de relais** (things `power`) avec,
pour chacun, sa **puissance (W)**, + les verrous **`minOnS`/`minOffS`**. Plus de « 1 thing
etmvariableload » pour le cas relais.
- **Picker relais (UI-only)** : l'app présente les things implémentant l'interface `power`
(via l'API d'intégration nymea, équivalent `findConfiguredThings("power")`) pour que
l'installateur **choisisse ses relais par NOM** (taper un UUID au doigt = erreur garantie).
Le picker **résout nom → ThingId** et écrit des ThingIds dans `relays[]`. Il **n'ajoute aucun
champ au contrat** (comme l'assistant résistances).
- **Assistant résistances (UI-only, conservé)** : à partir des relais et de leurs puissances,
calcule et **affiche** les `powerLevels` atteignables (combinaisons) — pour que l'installateur
voie ce qu'il obtient. Ces `powerLevels` ne sont **pas** envoyés (dérivés côté plugin).
- **`SetLoadConfig`** reçoit donc `relays[]` (+ `minOnS`/`minOffS`) pour une charge relais ; le cas
continu (triac) garde un `thingId` + `maxPowerW`. Voir §4.
---
## 11. Dépendances liées (côté plugin)
- Handler `nymeaenergyjsonhandler` : `Get/SetLoadConfig` + persistance (fait ; schéma étendu rév. 3).
- `loadActions[]` dans `GetChargingSchedules` / `ChargingSchedulesChanged` (fait).
- Arbitre : construit `RelayRouter` (si `relays[]`) **ou** `EtmVariableLoadAdapter` (si continu)
depuis `LoadConfig` ; repli L2 unifié sur les exécuteurs en watts.
---
## Changelog
- **rév. 1** : `etmvariableload` interface unique, watts, un seul kind d'action.
- **rév. 2** : PAC SG-Ready **exclue** (modèle à états séparé) ; `LoadAction` garde **deux** kinds
(`Setpoint` + état SG-Ready) ; suppression du kind `Stage`.
- **lot B (télémétrie)** : `domain` ajouté à `LoadConfig` (§4, optionnel, énumération fermée) ;
§7 remplacé — le `reason` sort désormais en `{code, params}` par
`NymeaEnergy.GetLoadTelemetry` / `LoadTelemetryChanged`, plus jamais en phrase composée.
- **rév. 3** : **frontière déplacée** entre optimiseur (watt-pur) et **routeur** (watts→relais,
experience-plugin) ; un relais = thing `power` nu ; `LoadConfig` du cas ECS devient une **liste
de relais** (`relays[]` + `minOnS`/`minOffS`) ; `powerLevels` **dérivés** ; cas continu
(`EtmVariableLoadAdapter`) inchangé. **Impact app §10.**
- **lot B-bis (la PAC entre en configuration)** : plus **aucun** adaptateur codé en dur —
`loads[]` de `GetLoadTelemetry` est **inclus** dans `GetLoadConfig`, par construction ; la
règle de détection « un `loadId` sans équivalent dans `GetLoadConfig` » est **retirée**
d'`INTERFACE.md` et aucun `configurable: false` n'a été ajouté. `pac-terrain` est devenue une
`LoadConfig` `sg-ready` ordinaire.
- **§4-bis / §4-ter ajoutés** : **LM-201** — *un canal de commande, une charge* — qui décide de
ce que l'écran « Configurer » doit **créer** (une PAC sans ballon séparé = **une** charge, pas
deux) ; **LM-202** — thermostats et zones **ne sont pas** des charges ; **LM-200** — les
mécanismes recevables selon l'organe, et le piège « pilotée par deux relais donc
`relay-router` ». `domain` est explicitement **sans force normative** : c'est l'**app** qui
guide au moment du choix, le moteur ne le peut pas.