# INTERFACE `etmvariableload` — contrat charge pilotée · **rév. 2** > **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. --- ## 1. Principe & frontière Trois niveaux, trois responsables. **Aucune notion de matériel ne remonte au-dessus du thing.** | Niveau | Où | Quoi | |---|---|---| | **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 | > **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.** --- ## 2. Portée de `etmvariableload` **EST une `etmvariableload`** (charge à puissance pilotable) : - **EV charger** (setpoint courant/puissance), - **ECS résistif** — classique (on/off) ou multipalier, - **Routeur PV / triac** (modulation continue). **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. Interface device `etmvariableload` 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) : ```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 } } ``` - `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`. --- ## 5. Règle d'arrondi (energymanager) À chaque cycle, pour une charge dont c'est le tour dans la priorité : - **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. --- ## 6. `LoadAction` — **deux** kinds | Kind | Pour | Porte | |---|---|---| | `Setpoint` | EV / ECS / routeur (`etmvariableload`) | `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). --- ## 7. Expo du `reason` — tableau additif `loadActions[]` Sérialiser dans `GetChargingSchedules` / `ChargingSchedulesChanged` un **champ additif** (rétro-compatible, ne touche pas la structure EV `ChargingAction`) : ```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" } ] ``` - 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 ». --- ## 8. PAC SG-Ready — modèle à états (conservé) 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. --- ## 9. Sécurité (déjà en place) - **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). --- ## 10. Dépendances liées (à coder en même temps, côté plugin) - 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 »).