Côté energymanager : EtmVariableLoadAdapter consomme l'interface etmvariableload (states currentPowerW read / powerSetpoint write) pour les charges à puissance pilotable (EV / ECS résistif / routeur PV). La combinatoire matérielle, l'anti-rebond et les phases vivent dans le thing (contrat §1/§3) — l'adaptateur n'écrit qu'un setpoint W et relit currentPowerW. - LoadDescriptor : champs additifs powerLevels / maxPowerW (déclaration §3/§5). - Contrat partagé versionné : docs/INTERFACE_etmvariableload.md (rév. 2). - La déclaration de l'interface nymee + le driver thing relèvent d'une session device. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8.1 KiB
INTERFACE etmvariableload — contrat charge pilotée · rév. 2
Source de vérité unique. Déposé à l'identique dans les deux repos (
etm-powersync-appetetm-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 :
etmvariableloadne 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).LoadActiongarde donc deux kinds (SetpointET 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) :
{
"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 }
}
powerLevelssaisi 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 )puissetPowerSetpoint(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
currentPowerWde 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ésidubudget − currentPowerWrepart 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). Retirerstage/minStage/maxStagedeLoadContextTelemetry(le verrou ECS redescend dans le thing). - Conserver le kind d'état SG-Ready et
state/minState/maxStatepour la PAC — sonminStateHold(protection court-cycling compresseur) reste dans son chemin, inchangé. reasonreste porté par chaqueLoadAction(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) :
"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/currentPowerWoptionnels (absents pour une charge à états) ;loadId+reasontoujours 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'ordreupdate()(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[]dansGetChargingSchedules/ChargingSchedulesChanged.- Câbler l'arbitre pour construire ses adapters depuis
LoadConfigau lieu du registre en dur (energypluginnymea.cpp:58-80— le « 3g »).