# 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, "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). --- ## 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` — tableau additif `loadActions[]` (inchangé) 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`. - **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.**