From a4487324dcf58c40251c3039b4e9a3a457e47b39 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Tue, 25 Aug 2026 21:44:19 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20resync=20du=20miroir=20INTERFACE=5Fetmv?= =?UTF-8?q?ariableload=20=E2=80=94=20r=C3=A9v.=202=20=E2=86=92=20r=C3=A9v.?= =?UTF-8?q?=203=20+=20LM-200/201/202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff --- INTERFACE_etmvariableload.md | 391 +++++++++++++++++++++++++---------- 1 file changed, 285 insertions(+), 106 deletions(-) diff --git a/INTERFACE_etmvariableload.md b/INTERFACE_etmvariableload.md index 08d5c6b..894a18d 100644 --- a/INTERFACE_etmvariableload.md +++ b/INTERFACE_etmvariableload.md @@ -1,171 +1,350 @@ - + + + + -# INTERFACE `etmvariableload` — contrat charge pilotée · **rév. 2** +# 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. 2 — corrige la rév. 1 sur un point structurant :** `etmvariableload` ne couvre **que les -> charges à puissance pilotable** (EV, ECS résistif, routeur PV). **La PAC SG-Ready en est exclue** : -> elle reste un modèle **à états**, interface séparée et conservée (voir §8). `LoadAction` garde -> donc **deux** kinds (`Setpoint` ET l'état SG-Ready), pas un seul. +> ### ⚠️ 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 +## 1. Principe & frontière (rév. 3) -Trois niveaux, trois responsables. **Aucune notion de matériel ne remonte au-dessus du thing.** +**Trois couches, la frontière entre OPTIMISEUR et ROUTEUR.** -| Niveau | Où | Quoi | +| 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 | |---|---|---| -| **Câblage** | thing / plugin device (wizard Things) | GPIO, registres Modbus, quelle résistance sur quelle phase | -| **Déclaration** | config charge pilotée (app) | mode, paliers atteignables **en W**, priorité, besoins | -| **Runtime** | interface `etmvariableload` | setpoint W envoyé ↔ puissance réelle remontée | +| **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 | -> **Invariant directeur.** L'energymanager raisonne **uniquement en watts**. Aucune résistance, -> aucun relais, aucun index de palier matériel ne franchit l'app ni n'atteint l'arbitre. -> **Le thing décide du matériel ; l'energymanager décide de la puissance.** +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. --- -## 2. Portée de `etmvariableload` +## 3. Les exécuteurs sous l'optimiseur -**EST une `etmvariableload`** (charge à puissance pilotable) : -- **EV charger** (setpoint courant/puissance), -- **ECS résistif** — classique (on/off) ou multipalier, -- **Routeur PV / triac** (modulation continue). +### 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. -**N'EST PAS une `etmvariableload`** : -- **PAC SG-Ready** → modèle à **états** (2 bits, hystérésis, `minStateHold`). Interface distincte, - conservée telle quelle. Voir §8. Un signal SG-Ready est une *suggestion d'état* à la PAC, pas - une consigne de puissance en watts — le forcer en setpoint W serait faux. +### 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 | --- -## 3. Interface device `etmvariableload` +## 4. `LoadConfig` — config app → energymanager (rév. 3) -Implémentée par le thing (ECS / EV / routeur). **Côté moteur, seul l'`EtmVariableLoadAdapter` -consommateur est livré** ; la déclaration de l'interface nymea + le driver (résistances/triac/ -anti-rebond/phases) sont une **session device dédiée, hors repo moteur**. - -| State | Accès | Type | Unité | Sémantique | -|---|---|---|---|---| -| `maxPowerW` | read | uint | W | Plafond physique de la charge. | -| `powerLevels` | read | list<uint> | W | Paliers atteignables, triés croissants, **`0` inclus**. Présent en *fixed*. Vide/absent ⇒ modulation continue. | -| `powerSetpoint` | **write** | uint | W | Consigne demandée par l'energymanager. | -| `currentPowerW` | read | uint | W | Puissance **réellement** appliquée. Ferme la boucle de quantification. | - -**Le thing, en recevant un `powerSetpoint` :** choisit la combinaison matérielle concrète, -applique ses **verrous anti-rebond** (minOn/minOff) et son **équilibrage de phases**, puis publie -`currentPowerW`. L'energymanager n'a aucune visibilité là-dessus — c'est voulu. - ---- - -## 4. `LoadDescriptor` — config app → energymanager - -Émis par l'écran « Configurer charge pilotée » (sous-ensemble de `OPTIMIZER_PROTOCOL.md §5`, -noms identiques) : +É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": "uuid-thing-ecs", - "label": "Chauffe-eau", - "adapter": "etmvariableload", - "mode": "fixed", // "fixed" | "dynamic" - "powerLevels": [0, 600, 1200, 1800, 2400], // REQUIS si fixed — absent si dynamic - "maxPowerW": 3000, // REQUIS si dynamic ; plafond aussi en fixed - "priority": 2, // rang ; 1 = servi en premier - "enabled": true, - "needs": { "dailyDeadline": "06:00", "minEnergyWhPerDay": 4000 } + "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. } ``` -- `powerLevels` saisi par l'installateur — directement ou via l'**assistant résistances** (UI-only : - additionne les unités, pré-remplit, puis disparaît ; **aucune résistance n'est stockée ni envoyée**). -- Config **persistée côté plugin** (`/var/lib/nymea/energy-load-configuration.json`), jamais en - SharedPreferences. App = éditeur : `NymeaEnergy.GetLoadConfig` / `SetLoadConfig`. +### 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 (energymanager) +## 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**. -- **Fixed** : `level = max( l ∈ powerLevels | l ≤ budget )` puis `setPowerSetpoint(level)`. - L'arbitre connaît la **granularité déclarée** → pas de sur-allocation, **zéro cycle de retard**. -- **Dynamic** : `setPowerSetpoint( clamp(budget, 0, maxPowerW) )`. -- **Résidu** : lire le `currentPowerW` **de début de cycle** (télémétrie de l'adapter, comme la - Correction B — **PAS** une relecture post-setpoint : invariant 8, aucune boucle de feedback). - Le résidu `budget − currentPowerW` repart vers la charge suivante de la priorité **du même cycle**. - -**Déclaré vs réel :** la **déclaration** (`powerLevels`/`maxPowerW`) est la référence de -**planification**. `currentPowerW` est le **juge de paix runtime**. Si le réel plafonne durablement -sous le déclaré → erreur de déclaration, l'installateur corrige. L'energymanager **n'écrase jamais** -la déclaration. +**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 +## 6. `LoadAction` — **deux** kinds (inchangé) | Kind | Pour | Porte | |---|---|---| -| `Setpoint` | EV / ECS / routeur (`etmvariableload`) | `powerW` | +| `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é | -- **Supprimer `Stage`** (ECS → `Setpoint`). Retirer `stage/minStage/maxStage` de - `LoadContextTelemetry` (le verrou ECS redescend dans le thing). -- **Conserver le kind d'état SG-Ready** et `state/minState/maxState` pour la PAC — son - `minStateHold` (protection court-cycling compresseur) reste dans son chemin, **inchangé**. -- `reason` reste porté par chaque `LoadAction` (déjà le cas). +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` — tableau additif `loadActions[]` +## 7. Expo du `reason` — ⚠️ REMPLACÉ par `NymeaEnergy.GetLoadTelemetry` -Sérialiser dans `GetChargingSchedules` / `ChargingSchedulesChanged` un **champ additif** -(rétro-compatible, ne touche pas la structure EV `ChargingAction`) : +> **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": "uuid-ecs", "setpointW": 1200, "currentPowerW": 1180, "reason": "Surplus PV 1.3 kW — ECS rang 2" }, - { "loadId": "uuid-pac", "state": 3, "reason": "Surplus PV — PAC en état 3" } + { "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" } ] ``` - -- Couvre **toutes** les charges, y compris la PAC. `setpointW`/`currentPowerW` **optionnels** - (absents pour une charge à états) ; `loadId` + `reason` **toujours** présents. -- Nécessite que l'arbitre **retienne le dernier plan** (aujourd'hui loggé puis jeté, - `energyarbitrator.cpp:140-153`) — petite addition d'état. -- C'est le canal que consomme la **decision card** de l'app : « pour chaque charge, le pourquoi ». +`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é) +## 8. PAC SG-Ready — modèle à états (conservé, inchangé) -Hors `etmvariableload`. Le `SgReadyAdapter` existant (états 1–4, combos K1/K2, hystérésis, -`minStateHold`) **n'est pas réécrit**. L'arbitre lui envoie un **état** (kind §6), pas un setpoint W. -Repli L2 : état 2 (normal, mains off), jamais état 1 (blocage) — comportement actuel conservé. - -> Une PAC pilotée par Modbus qui exposerait une vraie puissance pilotable pourra, *plus tard*, -> implémenter `etmvariableload`. Tant qu'on pilote par signal SG-Ready, c'est un modèle à états. +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é (déjà en place) +## 9. Sécurité — repli L2 (rév. 3) -- **Watchdog L2** (`evaluateMeterFreshness`, 90 s, `applyDegradedMode`) existant. En dégradé, - repli charge pilotée = **`setPowerSetpoint(0)` `force=true`** (remplace l'ECS stage-0 actuel) ; - PAC → état 2. Conserver l'ordre `update()` (sécurité avant planif) intact. -- `enabled:false` → rôle déclaré mais **exclu** de l'arbitrage (aucune action envoyée). +- **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. Dépendances liées (à coder en même temps, côté plugin) +## 10. IMPACT APP — écran « Configurer charge pilotée » (⚠️ lire avant de coder l'UI) -- Handler `nymeaenergyjsonhandler` : `Get/SetLoadConfig` + persistance. -- `loadActions[]` dans `GetChargingSchedules` / `ChargingSchedulesChanged`. -- Câbler l'arbitre pour construire ses adapters depuis `LoadConfig` au lieu du registre en dur - (`energypluginnymea.cpp:58-80` — le « 3g »). +**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.