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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
20 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,
"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.
}
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).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 :
GetLoadConfigsé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-retourGet→Setpossible. 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. SetLoadConfigrejette 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. - ⚠️
domainn'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 queheatingethvacdé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.
SetLoadConfigrejette 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 thingspower— 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 (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 — ⚠️ REMPLACÉ par NymeaEnergy.GetLoadTelemetry
Ce paragraphe décrit une intention qui n'a jamais été implémentée, et qui ne le sera pas.
loadActions[]n'existe pas dansGetChargingSchedulesni dansChargingSchedulesChanged. Le canal réel de la decision card estNymeaEnergy.GetLoadTelemetry+ sa notificationLoadTelemetryChanged(contrat :INTERFACE.md).Une différence de fond, et non de forme : le
reasonci-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 :
"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. - lot B (télémétrie) :
domainajouté àLoadConfig(§4, optionnel, énumération fermée) ; §7 remplacé — lereasonsort désormais en{code, params}parNymeaEnergy.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
powernu ;LoadConfigdu cas ECS devient une liste de relais (relays[]+minOnS/minOffS) ;powerLevelsdérivés ; cas continu (EtmVariableLoadAdapter) inchangé. Impact app §10. - lot B-bis (la PAC entre en configuration) : plus aucun adaptateur codé en dur —
loads[]deGetLoadTelemetryest inclus dansGetLoadConfig, par construction ; la règle de détection « unloadIdsans équivalent dansGetLoadConfig» est retirée d'INTERFACE.mdet aucunconfigurable: falsen'a été ajouté.pac-terrainest devenue uneLoadConfigsg-readyordinaire. - §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».domainest explicitement sans force normative : c'est l'app qui guide au moment du choix, le moteur ne le peut pas.