Vérification nymea : un integration-plugin ne peut pas piloter les things d'un autre
plugin (thingManager privé ; seuls cœur/règles/scripts/experience-plugins le font).
Donc la combinatoire watts→relais ne peut vivre que côté experience-plugin.
rév. 3 acte le déplacement de frontière :
- Optimiseur watt-pur (ne connaît jamais un relais) | ROUTEUR (watts→relais,
experience-plugin, a le ThingManager) | relais = things "power" nus.
- ECS multipalier : LoadConfig devient une LISTE de relais power (relays[] +
minOnS/minOffS) ; powerLevels DÉRIVÉS des combinaisons (source = les relais).
- Cas continu (EV/triac, EtmVariableLoadAdapter) inchangé.
- §10 IMPACT APP explicite : l'écran « Configurer » passe à une liste de relais +
picker findConfiguredThings("power") (UI-only) — l'agent app doit lire rév. 3
avant de toucher l'UI.
À déposer À L'IDENTIQUE dans etm-powersync-app (miroir manuel, hors de ce repo).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
12 KiB
INTERFACE charge pilotée — contrat · rév. 3
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. 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 (
thingManagery 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à leThingManager.rév. 3 acte cette réalité : un relais est un thing
powernu (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
etmvariableloadunique. La charge logique est une liste de relaispower. - Chaque relais est un thing implémentant l'interface nymea
power(statepowerbool writable ; optionnellementcurrentPowerW en lecture). Fourni par le plugin device (GPIO/Modbus/MQTT) — rien de spécifique ECS dans ce plugin. - Le
RelayRouter(experience-plugin) :- reçoit
LoadAction{Setpoint, powerW}de l'optimiseur ; - mappe
powerW→ la combinaison de relais la plus haute ≤powerW; - 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) ; - commande chaque relais via
ThingManager::executeAction(statepower) ; - agrège
currentPowerW= somme descurrentPowerdes relais ON, ou à défaut de mesure (relais GPIO bool sans wattmètre) la somme des nominaux commandés. Dans ce dernier cascurrentPowerWreflète le commandé, pas le mesuré — acceptable, à garder en tête pour le diagnostic terrain.
- reçoit
powerLevels/maxPowerWsont 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)
{
"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)
{
"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 quepowerLevels/maxPowerW(watts dérivés) via le contexte.relays[].thingId= ThingId du thingpower. 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 dansrelays[]. 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 )puisSetpoint(level). L'optimiseur connaît la granularité dérivée → pas de sur-allocation, zéro cycle de retard. (Le routeur traduit ensuitelevel→ relais.) - Dynamic :
Setpoint( clamp(budget, 0, maxPowerW) ). - Résidu : lire
currentPowerWde début de cycle (télémétrie de l'exécuteur — PAS de relecture post-setpoint : invariant 8). Le résidubudget − currentPowerWrepart 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 :
"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 (
RelayRouteretEtmVariableLoadAdapter) →Setpoint(0)force=true. Pour leRelayRouter, ça coupe tous les relais (forcebypasseminOn/minOff). - PAC → état 2.
- charges en watts (
- 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 despowerLevels. - APRÈS (rév. 3) : configurer une ECS = ajouter une liste de relais (things
power) avec, pour chacun, sa puissance (W), + les verrousminOnS/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, équivalentfindConfiguredThings("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 dansrelays[]. 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
powerLevelsatteignables (combinaisons) — pour que l'installateur voie ce qu'il obtient. CespowerLevelsne sont pas envoyés (dérivés côté plugin). SetLoadConfigreçoit doncrelays[](+minOnS/minOffS) pour une charge relais ; le cas continu (triac) garde unthingId+maxPowerW. Voir §4.
11. Dépendances liées (côté plugin)
- Handler
nymeaenergyjsonhandler:Get/SetLoadConfig+ persistance (fait ; schéma étendu rév. 3). loadActions[]dansGetChargingSchedules/ChargingSchedulesChanged(fait).- Arbitre : construit
RelayRouter(sirelays[]) ouEtmVariableLoadAdapter(si continu) depuisLoadConfig; repli L2 unifié sur les exécuteurs en watts.
Changelog
- rév. 1 :
etmvariableloadinterface unique, watts, un seul kind d'action. - rév. 2 : PAC SG-Ready exclue (modèle à états séparé) ;
LoadActiongarde deux kinds (Setpoint+ état SG-Ready) ; suppression du kindStage. - rév. 3 : frontière déplacée entre optimiseur (watt-pur) et routeur (watts→relais,
experience-plugin) ; un relais = thing
powernu ;LoadConfigdu cas ECS devient une liste de relais (relays[]+minOnS/minOffS) ;powerLevelsdérivés ; cas continu (EtmVariableLoadAdapter) inchangé. Impact app §10.