# INTERFACE.md — API JSON-RPC du plugin `nymea-energy-plugin-nymea` > Ce fichier fait autorité sur l'interface exposée par le plugin. > Mettre à jour ce fichier à chaque modification de l'API. > > ⚠️ **UN REFUS ARRIVE EN `status: "success"`.** Le transport a réussi ; le verdict métier est > dans **`params.energyError`**. Une sonde qui teste `status` annonce donc « accepté » sur une > écriture **rejetée** — constaté le 2026-08-29 sur une sonde jetable, qui a conclu à un refus > de `SetLoadConfig` là où il n'y en avait pas, et inversement. > > ```python > rep = call('NymeaEnergy.SetLoadConfig', {...}) > ok = rep['params']['energyError'] == 'EnergyErrorNoError' # ← le verdict est ICI > # rep['status'] == 'success' ne dit QUE « la requête a été traitée » > ``` > > C'est la convention JSON-RPC de nymea, pas une particularité de ce plugin — mais toute sonde > écrite à la va-vite retombe dedans, et une sonde qui se trompe de champ produit un relevé qui > a l'air d'un résultat. Même famille que le journal lu par un `sudo` qui échoue en silence > (`AGENTS.md`, « Lire le journal de la box »). > ## ⚑ UN CLIENT TESTE LA PRÉSENCE D'UN CHAMP, JAMAIS UNE VERSION > > *(Règle posée par l'agent app le 2026-08-30, en déplaçant son exception `EV_GRID_START` : > il ne l'a pas retirée, il l'a mise sur la bonne frontière — elle survit là où la charge utile > n'a pas de `counts`, c'est-à-dire sur les installations encore en `+etm2x`.)* > > **Son argument, et il vaut pour tout ce qui sera ajouté ici** : ce qui a rendu l'exception > caduque n'est pas une **version**, c'est une **charge utile**. Un champ présent décrit ce que > la box sait faire ; un numéro de version décrit ce qu'on croit qu'elle sait faire. La première > frontière est stable, la seconde ne l'est pas — une box peut être rétrogradée, un paquet > reconstruit, une branche déployée hors séquence. > > **Conséquence pour ce contrat** : chaque ajout se conçoit pour être détectable **par sa > présence**, et chaque absence porte un sens écrit. C'est déjà le cas de `levels[]` (absent = > box antérieure **ou** mode dégradé), `measurement.source` (`"none"` publié plutôt que la clé > retirée), `draw` (absent = pas de plan), `rankOrigin` (absent = rien d'affirmé) et > `decision.level` (présent exactement quand `levels[]` l'est). > > Les numéros de version cités dans ce document datent les changements — **ils ne sont pas des > conditions à tester.** > **Namespace :** `NymeaEnergy` > **Versions enregistrées :** 0–8 (`registerExperienceHandler(..., 0, 8)`) > **Transport :** WebSocket JSON-RPC 2.0, port 4444 (nymea standard) --- ## Enums ### `ChargingMode` | Valeur | Description | |---|---| | `ChargingModeNormal` | **MANUEL** — la borne sort de l'arbitrage : le moteur ne commande **rien**. Seule la protection de surcharge la touche encore, et elle peut donc la couper. Ce n'est **pas** un « boost » : un boost commanderait le maximum | | `ChargingModeEco` | Recharge sur **surplus** solaire uniquement | | `ChargingModeEcoWithTargetTime` | Eco + deadline de fin (`endDateTime` + `targetPercentage`) | | `ChargingModeEcoWithMinCurrent` | **Surplus + minimum réseau** — le surplus d'abord, et le complément **acheté au réseau** jusqu'au plancher de la borne (`maxChargingCurrent.minValue × phaseCount × 230 V`). Ne promet **pas** que le minimum sera servi : le rang, le plafond de soutirage et la réserve batterie peuvent l'empêcher, et c'est le **motif** qui le rapporte | | `ChargingModeEcoMinWithTargetTime` | Surplus + minimum réseau + deadline | > **`MinCurrent` nomme l'exigence de la BORNE, pas une promesse du mode.** Les 6 A sous lesquels > elle refuse de charger sont une donnée de matériel. Le nom a longtemps laissé lire « le mode > maintient un courant minimum » — ce qu'aucun chemin de code ne faisait : le mode était réécrit > en `Eco` à chaque cycle chez l'amont, puis la branche qui l'appliquait est devenue > inatteignable quand le waterfall a pris la borne. **Trois modes annoncés, deux implémentés, et > celui qui manquait était celui que l'utilisateur croyait utiliser.** Voir > `docs/DESIGN_MIN_RESEAU.md`. > > **Les mots « garanti » et « prioritaire » sont bannis** à propos de ce mode, dans le contrat > comme à l'écran. Le premier promet une certitude que rien ne tient ; le second serait vrai face > à tout le confort et faux face à un autre besoin éco mieux rangé — donc faux exactement dans le > cas d'échec, celui qu'un libellé doit couvrir. ### `ChargingState` (lecture seule — calculé par le manager) | Valeur | Description | |---|---| | `ChargingStateIdle` | Pas de recharge active | | `ChargingStateSurplusCharging` | Recharge sur surplus solaire en cours | | `ChargingStateSpotMarketCharging` | Recharge sur créneau spot market en cours | | `ChargingStateTimeRequirement` | Recharge forcée pour atteindre la deadline | ### `ChargingActionIssuer` | Valeur | Description | |---|---| | `ChargingActionIssuerUnknown` | Émetteur non identifié | | `ChargingActionIssuerSurplusCharging` | Action émise par le surplus manager | | `ChargingActionIssuerSpotMarketCharging` | Action émise par le spot market manager | | `ChargingActionIssuerTimeRequirement` | Action émise pour honorer la deadline | | `ChargingActionIssuerOverloadProtection` | Action émise par la protection surcharge | ### `EnergyError` | Valeur | Description | |---|---| | `EnergyErrorNoError` | Succès | | `EnergyErrorMissingParameter` | Paramètre obligatoire absent | | `EnergyErrorInvalidParameter` | Valeur de paramètre invalide | --- ## Types ### `ChargingInfo` Configuration d'une borne EV. Seuls les champs marqués `(writable)` peuvent être modifiés via `SetChargingInfo` — les autres sont ignorés ou read-only. > ⚠️ **`SetChargingInfo` REMPLACE l'objet entier.** Un champ writable absent de l'appel n'est pas > préservé : il retombe sur son **défaut** (colonne ci-dessous). Voir `NymeaEnergy.SetChargingInfo`. | Champ | Type | Writable | Description | |---|---|---|---| | `evChargerId` | `Uuid` | — | Identifiant de la borne (obligatoire dans SetChargingInfo) | | `assignedCarId` | `Uuid` | ✓ | Véhicule associé (optionnel) | | `chargingMode` | `ChargingMode` | ✓ | Mode de recharge | | `endDateTime` | `DateTime` | ✓ | Deadline cible (EcoWithTargetTime / EcoMinWithTargetTime) | | `repeatDays` | `[Int]` | ✓ | Jours de répétition (**1=Lun … 7=Dim** ; hors [1, 7] → `EnergyErrorInvalidParameter`). Défaut : liste vide (pas de répétition) | | `targetPercentage` | `Uint` | ✓ | Niveau batterie cible en %. Défaut : **80** ; > 100 → `EnergyErrorInvalidParameter` | | `chargingState` | `ChargingState` | — | État calculé (lecture seule) | | `spotMarketChargingEnabled` | `Bool` | ✓ | Activer la recharge sur spot market | | `dailySpotMarketPercentage` | `Uint` | ✓ | % de la charge quotidienne à faire en heures spot bon marché | ### `ChargingAction` Action envoyée au charger physique (présente dans les schedules). | Champ | Type | Description | |---|---|---| | `chargingEnabled` | `Bool` | Activer/désactiver la recharge | | `maxChargingCurrent` | `Double` | Courant max en Ampères | | `desiredPhaseCount` | `Uint` | Nombre de phases souhaitées (1 ou 3) | | `issuer` | `ChargingActionIssuer` | Origine de la décision | ### `ChargingSchedule` Créneau de recharge planifié (résultat du calcul du manager). | Champ | Type | Description | |---|---|---| | `evChargerId` | `Uuid` | Borne concernée | | `startDateTime` | `DateTime` | Début du créneau | | `endDateTime` | `DateTime` | Fin du créneau | | `action` | `ChargingAction` | Action prévue sur ce créneau | ### `ScoreEntry` Entrée de cotation horaire du spot market, avec pondération relative. | Champ | Type | Description | |---|---|---| | `startDateTime` | `DateTime` | Début du créneau tarifaire | | `endDateTime` | `DateTime` | Fin du créneau tarifaire | | `value` | `Float` | Prix brut (€/MWh, selon provider) | | `weighting` | `Float` | Pondération relative : 0.0 = le plus cher, 1.0 = le moins cher | ### `SpotMarketProviderInfo` Fournisseur de données spot market disponible. | Champ | Type | Description | |---|---|---| | `providerId` | `Uuid` | Identifiant du provider | | `name` | `String` | Nom affiché | | `country` | `Uint` | Pays (`QLocale::Country`) | | `website` | `String` | URL du site du provider | **Providers enregistrés :** | Nom | `providerId` | |---|---| | aWATTar AT | `5196b3cc-b2ee-46d6-b63a-7af2cf70ba67` | | aWATTar DE | `0ca6ad88-e243-438d-a0f8-986cecf61834` | --- ## Méthodes > Notation : `o:` = paramètre optionnel. Tous les appels requièrent un token d'auth nymea. --- ### Overload protection #### `NymeaEnergy.GetPhasePowerLimit` Retourne la limite de courant par phase configurée. **Params :** aucun **Returns :** ```json { "phasePowerLimit": 25 } ``` > Valeur en **AMPÈRES par phase**, pas en watts — c'est la limite du branchement. > > **`0` = non configuré.** Ce que cela désactive, précisément, et la phrase précédente > (« tout le smart charging est désactivé ») était **fausse depuis la transplantation du > waterfall** : > > | | à `0` | > |---|---| > | cascade de surplus, paliers, planchers éco | **tournent normalement** — mesuré des semaines durant sur `.75` | > | `draw.*` (autorisation de soutirage) | **inerte** : `drawCaps()` ne publie aucun plafond, `houseDrawMargin()` rend « sans objet » | > | motif `DRAW_CAP` | **jamais émis** — un plafond publié serait une limite inventée | > | délestage de surcharge | **ne fait rien** : `shedWaterfallForOverload()` sort à la première ligne, faute de seuil à faire respecter | > > **Conséquence à connaître** : à `0`, le moteur peut engager du soutirage sans borne — et il le > fait depuis que les planchers éco achètent. C'est une valeur d'installation, pas un défaut à > `0` près : sans elle, la couche de protection existe et n'a rien à protéger. --- #### `NymeaEnergy.SetPhasePowerLimit` Définit la limite de courant par phase (en Ampères). `0` désactive le smart charging. **Params :** ```json { "phasePowerLimit": 25 } ``` **Returns :** ```json { "energyError": "EnergyErrorNoError" } ``` --- ### Paramètres de recharge #### `NymeaEnergy.GetAcquisitionTolerance` Retourne le seuil de surplus nécessaire pour démarrer une recharge Eco. **Params :** aucun **Returns :** ```json { "acquisitionTolerance": 0.5 } ``` > Valeur entre 0.0 et 1.0. Plus la valeur est haute, plus il faut de surplus. --- #### `NymeaEnergy.SetAcquisitionTolerance` **Params :** ```json { "acquisitionTolerance": 0.5 } ``` **Returns :** ```json { "energyError": "EnergyErrorNoError" } ``` > Retourne `EnergyErrorInvalidParameter` si hors de `[0.0, 1.0]`. --- #### `NymeaEnergy.GetBatteryLevelConsideration` Retourne le facteur de prise en compte du stockage batterie dans le calcul du surplus. **Params :** aucun **Returns :** ```json { "batteryLevelConsideration": 0.9 } ``` > 0.0 = stockage ignoré, 1.0 = surplus réduit intégralement de la puissance de charge batterie. --- #### `NymeaEnergy.SetBatteryLevelConsideration` **Params :** ```json { "batteryLevelConsideration": 0.9 } ``` **Returns :** ```json { "energyError": "EnergyErrorNoError" } ``` > Retourne `EnergyErrorInvalidParameter` si hors de `[0.0, 1.0]`. --- #### `NymeaEnergy.GetLockOnUnplug` Retourne si la borne doit être verrouillée à la déconnexion du véhicule. **Params :** aucun **Returns :** ```json { "lockOnUnplug": false } ``` --- #### `NymeaEnergy.SetLockOnUnplug` **Params :** ```json { "lockOnUnplug": true } ``` **Returns :** ```json { "energyError": "EnergyErrorNoError" } ``` --- ### Configuration EV #### `NymeaEnergy.GetChargingInfos` Retourne la configuration de recharge de toutes les bornes, ou d'une seule. **Params :** ```json { "o:evChargerId": "uuid-de-la-borne" } ``` **Returns :** ```json { "chargingInfos": [ { "evChargerId": "uuid-de-la-borne", "assignedCarId": "uuid-du-car", "chargingMode": "ChargingModeEco", "chargingState": "ChargingStateSurplusCharging", "endDateTime": "2025-06-15T08:00:00+02:00", "repeatDays": [1, 2, 3, 4, 5], "targetPercentage": 80, "spotMarketChargingEnabled": false, "dailySpotMarketPercentage": 0 } ] } ``` --- #### `NymeaEnergy.SetChargingInfo` Met à jour la configuration d'une borne. Seuls les champs `USER true` sont modifiables (voir type `ChargingInfo`) ; les autres (`chargingState`) sont ignorés. ⚠️ **L'objet donné REMPLACE le stocké — l'appel n'est pas partiel.** Un champ writable omis **retombe sur son défaut**, il n'est pas préservé : | Champ omis | Valeur obtenue | |---|---| | `assignedCarId` | aucune (null) | | `chargingMode` | `ChargingModeNormal` | | `endDateTime` | aucune | | `repeatDays` | liste vide | | `targetPercentage` | **80** | | `spotMarketChargingEnabled` | `false` | | `dailySpotMarketPercentage` | `0` | Et l'appel réussit : `EnergyErrorNoError`. Rien ne signale la perte. **Constaté sur le banc `.75` le 2026-08-26** : un appel ne portant que `chargingMode` a porté `targetPercentage` de 0 à 80. **Le geste correct est lire → modifier → renvoyer complet** : `GetChargingInfos`, changer le champ voulu, réémettre l'objet entier. Le renvoyer tel quel est sûr — `chargingState`, en lecture seule, est ignoré. **Params :** ```json { "chargingInfo": { "evChargerId": "uuid-de-la-borne", "chargingMode": "ChargingModeEcoWithTargetTime", "endDateTime": "2025-06-15T08:00:00+02:00", "targetPercentage": 80 } } ``` **Returns :** ```json { "energyError": "EnergyErrorNoError" } ``` --- #### `NymeaEnergy.GetChargingSchedules` Retourne le planning de recharge calculé (toutes les bornes, horizon ~24h), **et** l'état du mode dégradé L2. **Params :** aucun **Returns :** ```json { "chargingSchedules": [ { "evChargerId": "uuid-de-la-borne", "startDateTime": "2025-06-15T02:00:00+02:00", "endDateTime": "2025-06-15T04:30:00+02:00", "action": { "chargingEnabled": true, "maxChargingCurrent": 16.0, "desiredPhaseCount": 1, "issuer": "ChargingActionIssuerSpotMarketCharging" } } ], "degradedMode": false } ``` > `degradedMode` *(bool, [ETM])* : `true` quand le watchdog L2 a basculé en repli (compteur muet > > 90 s, planification suspendue). Il était auparavant **poussé en notification seule** par > `ChargingSchedulesChanged` : un client connecté après la bascule ne pouvait pas savoir dans > quel état il se trouvait. Règle de ce dépôt : **tout élément runtime a un `Get*` ET un > `*Changed`**, jamais une notification seule. --- #### `NymeaEnergy.GetLoadConfig` / `SetLoadConfig` — champ `domain` `LoadConfig` porte un champ optionnel `domain` : catégorie d'**intention** déclarée par l'installateur, énumération **fermée** — `ecs` · `ev` · `heating` · `hvac` · `battery`. - **Pure métadonnée.** L'arbitre ne la lit pas ; aucun comportement moteur n'en dépend. - **Un code, jamais un libellé** — même raison que pour `decision.code` ci-dessous. - **Absente ou vide = charge « non classée ».** Le plugin ne la range pas d'office : un classement inventé serait indistinguable d'un classement choisi. `GetLoadConfig` sérialise via le **méta-objet**, donc la propriété sort **toujours**, à `""` pour une charge non classée — comme `"sgReady": {}` ou `"powerLevels": []`. C'est ce qui rend l'aller-retour `Get` → `Set` possible (corollaire LM-302). La forme **persistée** omet la clé. Un client traite `""` **et** l'absence comme « non classée ». - `SetLoadConfig` **rejette en bloc** (`EnergyErrorInvalidParameter`) une valeur hors énumération. Détail complet : `docs/INTERFACE_etmvariableload.md` §4. --- #### `LoadConfig` — champ `o:minPowerW` (plancher de modulation) Le domaine d'une consigne n'est pas `[0, maxPowerW]` : c'est **`{0} ∪ [minPowerW, maxPowerW]`**, un ensemble **discontinu**. C'est ce que fait le matériel réel — une borne ne charge pas sous 6 A, une PAC n'a pas d'« état 3 à moitié ». - **Déclaré pour un seul mécanisme.** `etmvariableload` en mode `dynamic`, où rien ne permet de deviner le plancher. Sur `relay-router` et en mode `fixed`, il **se dérive** — plus petit palier non nul — et `SetLoadConfig` **refuse** de le déclarer en double : deux sources d'une même vérité divergent, et rien ne dirait laquelle fait foi. - **Refusé aussi s'il dépasse `maxPowerW`** : le domaine serait réduit à `{0}` et la charge ne démarrerait jamais, sans que rien ne dise pourquoi. **Conséquence sur l'allocation** — et c'est elle qui compte : quand le budget disponible est **sous le plancher**, la charge reçoit **0 W et le budget PASSE à la suivante**. Sans cette règle, une charge de rang 1 au plancher élevé retiendrait en otage un budget qu'elle ne peut pas utiliser, et tout le waterfall en aval resterait à sec pendant qu'elle-même ne démarre pas. > **« Il n'y a pas assez » contre « il y en a, mais pas au bon format ».** C'est toute la > distinction que le motif `BELOW_MIN_POWER` porte, et elle décide du geste : > `SURPLUS_INSUFFICIENT` et `SG_NORMAL` disent qu'il n'y a **rien** (budget ≤ 0) — on attend le > soleil. `BELOW_MIN_POWER` dit qu'il y a du budget mais qu'**aucun palier ne sait le prendre** > — on relit une fiche technique. > **Le cas qui rend la règle nécessaire** : 2 kW de surplus, deux bornes à 1,38 kW de plancher > (6 A monophasé). Réparties également, 1 kW chacune, **aucune ne démarre et les 2 kW partent au > réseau**. En cascade, la première prend 1,38 kW et charge ; la seconde reçoit le reliquat, > n'atteint pas son plancher, et reçoit 0. --- #### `LoadConfig` — champ `o:rankOrigin` D'où vient le **rang** d'une charge : d'un choix, ou d'un défaut d'insertion. *(§12 / LM-1209-c.)* ```jsonc { "id": "…", "priority": 3, "rankOrigin": "auto" } // o: — "auto" | "user" | absent ``` | Valeur | Ce qu'elle affirme | Qui l'écrit | |---|---|---| | `"auto"` | l'entrée a été **créée d'office** par le moteur, et **personne n'a jamais classé cette charge** — son rang est un défaut d'insertion (queue de liste) | **le moteur seul** | | `"user"` | le rang est **assumé** — la voie pour lever la marque **sans déplacer** la charge | un client | | *absent* | **rien n'est affirmé** — ni « choisi », ni « par défaut » | — | - **Un client ne peut pas écrire `"auto"`** là où le moteur ne l'a pas posé : ce serait fabriquer une affirmation sur un geste du moteur, et `SetLoadConfig` refuse. L'aller-retour **verbatim** d'une entrée qui la porte déjà reste licite — sans quoi la neutralité `Get` → `Set` serait rompue. - **La marque tombe quand le RANG change, jamais parce qu'une écriture arrive.** `SetLoadConfig` remplace en bloc : réordonner une **autre** charge renvoie forcément celle-ci, et l'omission de la clé **ne lève rien**. Pour lever sans déplacer, envoyer `"user"` explicitement. - Quand elle tombe, elle tombe vers l'**absence** — le moteur constate un déplacement, il ne sait pas si un humain ou un script l'a voulu. > **Pourquoi une énumération et pas un booléen.** `rankIsDefault: false` *affirmerait* que le rang > a été choisi, alors que c'est précisément ce qu'on ignore d'une configuration héritée. > **L'absence doit pouvoir porter le « on ne sait pas »**, et un booléen ne sait pas se taire. > Même motif de conception que `measurement.source` : publier le régime, et ne jamais laisser un > silence passer pour une affirmation. > **Ce qu'un client en fait** : afficher « rang par défaut » **uniquement** sur un `"auto"` > explicite — une entrée créée d'office prend une place dans l'ordre de service, et cela ne doit > pas se produire en silence. Sur tout le reste, ne rien affirmer. --- #### `LoadConfig` — champs `o:meterThingId` et `o:sensorThingId` Deux Things de **mesure**, optionnels, de même forme. Ils décrivent ce qu'on **observe** d'une charge — jamais ce qu'on lui **commande**. ```jsonc { "id": "chauffe-eau", …, "meterThingId": "{uuid}", // o: — compteur dédié à CETTE charge "sensorThingId": "{uuid}" } // o: — sonde du service rendu (température) ``` - **L'arbitre ne s'en sert pas pour décider.** Les adaptateurs ne voient même pas ces Things : ils sont résolus au moment de **publier** la télémétrie, et nulle part ailleurs. « Aucun effet sur les décisions » est donc vrai par construction, pas par discipline. - **Choix par INTERFACE nymea, jamais par plugin.** Le compteur se prend parmi les Things portant `energymeter` / `smartmeter`, la sonde parmi celles portant la température. Nommer un plugin rendrait le modèle faux au prochain qui rend le même service. - **Aucun contrôle d'existence à l'écriture.** Un `ThingId` encore inconnu est accepté : refuser imposerait un ordre aux opérations — déclarer le compteur avant la charge — que rien ne justifie. Un id qui ne résout pas fait **retomber sur la mesure de l'appareil** si elle existe (`measurement.source: "device"`), sinon produit `"none"` — jamais une configuration invalide. - **`meterThingId` n'est plus REQUIS pour obtenir une mesure** *(LM-1105, 2026-08-28)*. Quand le Thing piloté porte lui-même l'état de puissance — borne, `etmvariableload`, relais mesurant — le moteur le lit sans configuration, et publie `measurement.source: "device"`. Le champ reste **autorisé partout** et **prioritaire** quand il est renseigné : un compteur posé exprès est le seul à pouvoir contredire un appareil qui se déclare. Ce qui change est qu'il cesse d'être une corvée là où le moteur peut déduire — un client avisé le **masque par défaut** sur les charges dont `source` vaut déjà `device`, sans l'interdire. **Cette règle d'affichage se lit sur `measurement.source`, jamais sur `adapter`** : voir la note sous `measurement`. - **`priority` est UNIQUE, et le contrat le garantit** *(LM-1209-a)*. `SetLoadConfig` **refuse** en bloc un ensemble où deux charges portent le même rang. Ce que l'égalité coûte n'est PAS le déterminisme — le waterfall trie sur `(rang, identifiant)`, donc l'ordre est stable — c'est qu'il devient **non réglable** : il se décide sur des identifiants, c'est-à-dire sur personne. > **Une configuration HÉRITÉE à rangs doublés est conservée, jamais amputée.** Le contrôle > d'unicité ne s'applique qu'à l'écriture ; au chargement, le doublon est signalé au journal et > les deux charges restent. La première écriture le répare. L'inverse ferait disparaître des > charges au redémarrage — de la configuration *et* de l'arbitrage, sans état sûr. - **Ni l'un ni l'autre n'est exclusif**, contrairement à `relays[]`. Ils mesurent, ils ne commandent pas : `validateSet()` les ignore, et deux charges peuvent partager une sonde de pièce ou un compteur de tableau divisionnaire. - **Publiés toujours, persistés seulement s'ils valent quelque chose** — exactement comme `domain`, et pour la même raison (corollaire LM-302). `GetLoadConfig` sérialise via le méta-objet : les deux clés **sortent toujours**, à `""` quand rien n'est rattaché, comme `sgReady: {}` ou `powerLevels: []`. La forme **persistée**, elle, omet la clé. C'est ce qui rend l'aller-retour `Get` → `Set` neutre : un client qui renvoie verbatim ce qu'il a lu ne déclare rien par accident. **Un client traite `""` et l'absence comme « aucun Thing rattaché ».** > **Le compteur VÉRIFIE, il ne budgète pas.** Commandé 3 000 W, mesuré 0 W sur plusieurs > cycles : la charge ne fait pas ce qu'on lui demande. La mesure n'est **pas** injectée dans le > budget, pour deux raisons dont chacune suffit — elle est **en retard sur la commande** > (scrutation Modbus, moyennage du compteur), et l'y mettre brute réintroduirait l'oscillation > que le recrédit anti-clignotement a supprimée ; et la charge est **déjà comptée** dans le > compteur racine, dont ce compteur-ci n'est qu'une **décomposition**, pas une source à > additionner. --- ### Télémétrie d'arbitrage #### `NymeaEnergy.GetLoadTelemetry` Instantané de l'état runtime de l'arbitrage : budget de surplus, allocation par charge, disponibilité, code de défaut, verrou actif en **secondes restantes**, charge utile du mécanisme, et **motif de décision sous forme de code**. **Params :** aucun **Returns :** voir la charge utile ci-dessous — **identique** à celle de la notification `LoadTelemetryChanged`. Le `Get` ne recalcule rien : il rend l'objet qui a été publié. ```jsonc { "timestamp": "2026-08-25T14:32:07Z", // fin du DERNIER cycle d'arbitrage — o:, voir plus bas "degradedMode": false, "budget": { // o: — absent en mode dégradé, voir plus bas "surplusW": 8348, // budget entrant du waterfall non-EV (net signé − réservation EV) "evReservedW": 0, // puissance EV commandée ce cycle, pas encore vue au compteur "recreditedW": 1500, // recrédits anti-clignotement rendus aux charges (correction B) "allocatedW": 3500, // somme des allocations du cycle "remainingW": 6348 // résidu de fin de cascade }, "loads": [ { "loadId": "ecs-chauffe-eau", "allocatedW": 3500, // ce que le waterfall a ALLOUÉ (pas la mesure) "levels": [ // une entrée par passe RÉELLEMENT parcourue { "level": "comfort", "targetW": 3500, "funding": "surplus" } ], "available": true, "faultCode": "WRITE_FAILED", // o: — absent = aucun défaut "lock": { "kind": "minOff", "remainingS": 42 }, // o: — absent = aucun verrou actif "mechanism": { "kind": "relay", "stageW": 3500, "maxStageW": 4500 }, // union discriminée "decision": { "code": "LOCK_MIN_OFF", "params": { "budgetW": 8348, "requestedW": 3500 } } } ] } ``` **Identité vérifiable :** `surplusW + recreditedW − allocatedW == remainingW`. `recreditedW` n'est pas décoratif : le waterfall rend à chaque charge sa propre consommation de début de cycle avant d'arrondir (anti-clignotement, `AGENTS.md` correction B). Sans ce terme, `surplus − alloué == restant` serait faux **dès qu'une charge consomme déjà**, c'est-à-dire dans le cas normal. **Champs omis, jamais nuls** (règle maison, cf. `GetEnergyRatios`) : - `timestamp` **absent** = aucun cycle d'arbitrage depuis le démarrage du service. En forger un se lirait « cycle exécuté, rien à allouer » — exactement le mensonge que ce champ existe pour empêcher. Présent, c'est la **fin du dernier cycle** : c'est la preuve de fraîcheur. - `budget` **absent** = mode dégradé L2 : la planification est **suspendue**, il n'existe aucun budget pour ce cycle. Publier des zéros se lirait « arbitrage exécuté, rien à allouer ». **Charges présentes.** Toutes les charges **arbitrées par le waterfall** : `relay-router`, `etmvariableload`, `sg-ready` — et, **depuis 3g-1, les bornes de recharge**. > **Changement de contrat en 3g-1.** Le VE ne figurait pas dans `loads[]` : il était décidé en > amont, et sa puissance seulement *réservée* dans `budget.evReservedW`. Il y figure désormais, > son allocation décrémente la cascade comme celle de n'importe quelle charge, et la somme des > `allocatedW` **des charges financées au surplus** retombe sur `budget.allocatedW` — ce qui > n'était pas vrai avant. > **`levels[]`** — **une entrée par passe RÉELLEMENT parcourue**, et c'est là que vit le **financement**. *(R2 de la maquette des deux passes, 2026-08-29.)* | Clé | Sens | |---|---| | `level` | `"eco"` ou `"comfort"` — quel BESOIN cette entrée sert | | `targetW` | ce que le niveau a **DÉCIDÉ** (R4) — à ne pas confondre avec `mechanism.*`, qui dit ce qui a été **appliqué** | | `funding` | `"surplus"` ou `"grid"` — l'ORIGINE des watts de ce niveau. `"grid"` **dès qu'un seul watt est acheté** (LM-1013) | | `counts` | `{ destination → watts }` — le REGISTRE où ils sont comptés. **`Σ counts == targetW`, toujours** | **R1 — `levels[]` ne contient que les passes RÉELLEMENT parcourues, et l'absence n'est pas le zéro.** Les deux moitiés se répondent, et publier l'une sans l'autre les rendrait indistinguables : | | | |---|---| | **niveau absent** | la passe **n'a pas été parcourue** — une charge sans obligation éco n'aura jamais d'entrée `"eco"` | | **niveau à `targetW: 0`** | la passe **a été parcourue**, elle n'avait rien à prendre — la charge a été examinée, et son motif le dit | > Un zéro forgé à la place d'une absence détruirait la première information ; une absence à la > place d'un zéro détruirait la seconde. Aujourd'hui la passe unique est toujours parcourue dès > qu'une charge est arbitrée : c'est le **mode dégradé** qui porte le cas « aucune passe », la > planification y étant suspendue — `levels[]` y est **absent**, et `decision.level` avec lui. > > Épinglé par `testLevelAbsenceAndZeroDifferInMeaning`, **vérifié échouant** dans les deux sens. **R7 — `decision.level` accompagne `levels[]` : les deux présents, ou les deux absents.** `decision` reste au niveau de la charge — un client qui l'a toujours lu continue de le lire — mais il reflète le motif d'**un** niveau. Sans le dire, chaque client réinventerait sa règle de fusion (« le motif du dernier niveau servi ? du premier refusé ? ») et deux écrans afficheraient deux motifs pour le même cycle. Aujourd'hui il n'y a qu'un niveau et la question ne se pose pas — **c'est précisément pourquoi le champ arrive maintenant** : quand elle se posera, les clients le liront déjà. > **Aujourd'hui il y a exactement UN niveau, et c'est `"comfort"`.** La passe unique > d'aujourd'hui est celle qui, au §10, servira le confort depuis le surplus ; la passe éco est > la nouveauté du lot, pas la passe existante rebaptisée. Une charge **sans obligation éco** > n'aura jamais d'entrée `"eco"` à zéro : **l'absence est l'information**, et un zéro forgé la > détruirait. > > **`levels[]` entièrement absent** = box antérieure à `+etm30`, ou **mode dégradé** — sans > plan, il n'y a pas de financement. > ⚠️ **`funding` N'EXISTE PLUS au niveau de la charge**, et il ne revient pas en résumé. La > raison n'est pas la propreté : au §10 la même charge sera financée **au réseau pour son éco et > au surplus pour son confort dans le même cycle**, et un résumé n'aurait alors **aucune valeur > vraie** à porter — « mixte » serait un troisième code que personne n'a demandé, « grid si l'un > l'est » une convention que chaque client redériverait autrement. Deux endroits pour un fait > divergent. > > **Pourquoi `decision` reste, lui, au niveau de la charge** : parce qu'on sait dire de quel > niveau il parle (`decision.level`, R7). On conserve un champ hérité **quand on peut en dire > quelque chose de vrai**, jamais par symétrie. ### `draw` — le registre des watts ACHETÉS Publié **dans la même trame que `budget`** : les deux décrivent le même cycle, et les lire à deux instants ferait comparer deux mondes. ```jsonc // SANS plafond de soutirage réglé — quatre champs ABSENTS, jamais à 0 : "draw": { "committedW": 1400 } // AVEC un plafond en vigueur : "draw": { "committedW": 1400, "authorisedW": 3450, "remainingW": 2050, "binding": "connection", "perPhaseBound": true } ``` **`committedW` est publié TOUJOURS** : le registre des watts achetés existe à chaque cycle, et zéro y est une valeur. **Les quatre autres ne sont publiés QUE si un plafond est en vigueur.** C'est la même règle des deux côtés — *le zéro est une valeur quand la grandeur existe, l'absence en est une quand elle n'existe pas*. Un `authorisedW: 0` assorti d'un `binding` par défaut afficherait un plafond de soutirage **qui n'existe pas, avec sa source**. | Champ | Ce qu'il dit | |---|---| | `authorisedW` | Autorisation du cycle, **SIGNÉE**. Négative ⇒ la maison est **déjà au-delà** du plafond, et l'ampleur du dépassement se lit directement. L'écrêter à zéro confondrait « pile à la limite » et « on dépasse de 1200 W ». | | `remainingW` | `authorisedW − committedW`. **L'identité tient toujours** — c'est ce qui rend la charge utile vérifiable. Peut être négative : les bornes VE engagent du soutirage borné par LEUR phase, pas par la marge de la maison. | | `binding` | Code opaque de la source qui borne : `connection` \| `gridOperator` \| `selfImposed`. Ce qu'on a le droit de faire d'un dépassement en dépend. | | `perPhaseBound` | Le plafond qui borne est-il **par phase** ? Un plafond total n'a pas de phase coupable, et le dire évite d'envoyer mesurer au hasard sur une triphasée. | | `bindingPhase` | **Laquelle** — `A`, `B` ou `C`. Absent si le plafond est un total. Sans lui, l'écran ne peut dire que « bridé » **sans pouvoir dire par quoi**. | > ### ⚠️ Une installation qui EXPORTE peut être bridée — et l'écran ne doit jamais l'agréger > > Cas mesuré (`LM-1210-a`) : phase B saturée à 30 A sous un plafond de 25, phases A et C en > export, **compteur global en export**. La marge retenue est celle de B — **négative** — pendant > que le total afficherait un confort trompeur. > > C'est contre-intuitif au point qu'un installateur **cherchera l'erreur ailleurs**. D'où deux > interdits pour l'écran : > > 1. **ne jamais présenter une marge globale** — elle rassurerait à tort ; > 2. **ne jamais présenter la meilleure phase** — c'est la pire qui décide. > > `bindingPhase` est ce qui rend la phrase juste possible : « bridé sur la phase B », et non > « bridé » tout court. La phase figure aussi dans le motif `DRAW_CAP`, mais celui-ci n'est émis > **que si une charge est refusée** : un cycle qui borde sans rien refuser laisserait l'écran > muet. **Les deux endroits sont nécessaires et ne se recouvrent pas.** ### `progress.purchasedWh` et `progress.measuredSince` ```jsonc "progress": { "regime": "measured", "targetWh": 4000, "deliveredWh": 500, "purchasedWh": 3200, "measuredSince": "2026-09-01T09:14:00Z" } ``` **`purchasedWh` est publié dans TOUS les régimes, `unmeasurable` compris.** Ce n'est pas une entorse à la règle qui interdit `deliveredWh` là-bas — c'en est le complément exact : | | ce que c'est | connu sans mesure ? | |---|---|---| | `deliveredWh` | ce qui a été **livré à la charge** | **non** — c'est une observation qu'on ne peut pas faire | | `purchasedWh` | ce qui a été **acheté au réseau** | **oui** — c'est notre propre commande | Sans lui, « ce que ça coûte par jour » redeviendrait une estimation fabriquée à partir du taux. C'est un **cumul sur la fenêtre d'obligation** : remis à zéro à la bascule de période (`LM-1014-b`). > ### Un SECOND usage, sous `measured` cette fois — et il n'est pas redondant > > Le champ a été demandé pour `unmeasurable`. Mesuré sur le banc, il gagne un usage que personne > n'avait prévu : sous `measured`, `purchasedWh` **accumule** — 0 → 9,15 → 18,33 → 27,52 Wh — > pendant que `deliveredWh` peut rester **plat**. > > **Cette divergence est un contrôle que l'écran n'aurait pas su fabriquer.** De l'acheté qui > monte avec du livré plat signifie l'une de deux choses, et les deux méritent d'être vues : > soit **la charge ne consomme pas ce qu'on paie**, soit **le compteur ne compte pas**. > > C'est écrit ici parce que le jour où les deux valeurs coïncideront, quelqu'un jugera le champ > redondant et proposera de le retirer. Il ne l'est pas : **c'est précisément quand ils > divergent que la paire devient informative**, et un champ qui ne sert que dans l'anomalie n'a > pas l'air utile le reste du temps. > ### `measuredSince` ferme un piège d'affichage, et il faut savoir lequel > > Quand une obligation bascule de `unmeasurable` à `measured` **en cours de période**, la base > d'avancement est capturée à cet instant : `deliveredWh` repart de 0 pendant que `targetWh` > reste la cible **entière**. Une barre afficherait alors **« 0,5 sur 4,0 »** après une bascule > où 3 kWh ont pu être achetés — crédible, et faux. > > **Rebaser `targetWh` serait pire** : on perdrait l'obligation réelle — l'utilisateur a demandé > 4 kWh, pas 1 — et l'écran ne pourrait plus dire ce qui reste dû. > > `measuredSince` prend la seconde sortie : l'écran dit **« mesuré depuis 09:14 »**, et > `purchasedWh` couvre la période d'avant. **Les deux se répondent**, et ensemble ils disent la > vérité complète. Le champ est **absent** tant que rien n'a jamais été mesuré dans la période. > > **`measuredGaps` — présent seulement s'il y a eu des trous.** `measuredSince` date le PREMIER > instant mesuré de la période ; sur une borne dont le lien bat, la continuité qu'il suggère est > fausse. Ce compteur autorise la phrase honnête : **« mesuré par intermittence depuis 09:14 »**. > > **`deliveredWh` survit aux trous** : les tronçons mesurés d'une même période s'**additionnent** > (`LM-1014-c`). Sans cela, chaque retour de lien effaçait ce qui précède — l'écran > sous-estimait, et la passe éco **rachetait le déjà livré**. ### `progressMeasurable` / `progressUnmeasurableCause` — dans `GetLoadConfig`, pas dans la télémétrie ```jsonc // GetLoadConfig → loadConfigs[] { "id": "…", "adapter": "relay-router", …, "progressMeasurable": false, "progressUnmeasurableCause": "meterWithoutEnergy" } ``` **Pourquoi ici et pas dans `loads[]`.** `measurement` n'existe que pour les charges arbitrées. Or une charge qu'on **déclare** est précisément celle qui n'y est pas encore — pas arbitrée, ou créée à l'instant. L'écran serait muet **au moment exact où il doit parler** : celui où l'installateur saisit une obligation qui, sans mesure, achètera à l'aveugle tous les jours. | Cause | Ce qu'elle veut dire | Le geste | |---|---|---| | `noMeter` | aucun compteur rattaché | **réparable en une manipulation** | | `meterWithoutEnergy` | le compteur ne cumule pas l'énergie | **à remplacer** | | `noSessionEnergy` | la borne ne compte pas sa session | **rien à faire — c'est le matériel** | Sans la cause, l'avertissement ne peut dire que « ça ne marchera pas ». Même structure que les trois sources de `DRAW_CAP` : trois causes, trois gestes. > **`meterThingId` ne permet PAS de le déduire.** Un compteur peut publier `currentPower` **sans** > `totalEnergyConsumed` — mesuré le 2026-09-01 sur l'ECS du banc. Le champ est renseigné, le > compteur existe, et pourtant rien ne cumule : **la puissance ne suffit pas, il faut une > énergie**. Un client qui déduirait la mesurabilité de la présence du champ se tromperait > exactement sur ce cas. > > `progressMeasurable` est en `r:` — **lecture seule**. C'est un constat sur le matériel, pas un > réglage : un client qui l'écrirait affirmerait quelque chose sur une ThingClass. ### `command` — l'écart entre le commandé et le mesuré Publié **seulement** quand une source mesure (`measurement.source` ≠ `none`) **et** qu'une commande est partie. Absent sinon — pas `divergent: false`, qui affirmerait une concordance qu'on ne peut pas constater. Une charge jamais commandée n'est pas une charge commandée à zéro. ```jsonc "command": { "expectedW": 3680, "observedW": 800, "divergent": true, "since": "2026-09-01T…" } ``` **Ce que l'écart vaut dépend de la source, dans les DEUX sens :** | | concordance | divergence | |---|---|---| | `device` | **ne prouve rien** — plusieurs bornes renvoient leur consigne en guise de puissance | la charge n'obéit pas, et elle le dit elle-même | | `meter` | **preuve** — un tiers le constate | trois hypothèses : la charge, le compteur, le câblage | > **Un écart n'est PAS un défaut, et ce champ ne le qualifie pas.** Une borne qui n'obéit pas > parce que le véhicule limite, parce que le câble est en 16 A, ou parce qu'elle est en mode > manuel, produit **le même nombre** qu'une commande perdue. L'écart se publie, il ne se > diagnostique pas : **la cause n'est pas dans les watts.** Un écran qui en ferait une alerte > accuserait des installations saines. **`since` est ce qui rend le champ utile.** Sans lui, une divergence permanente ne se distingue pas d'un déphasage d'un cycle entre l'écriture et sa mesure — et c'est toute la différence entre un fonctionnement normal et le cas relevé le 2026-08-31, où l'écart durait indéfiniment parce que plus personne ne commandait. **`divergent` est publié plutôt que laissé au client** : la tolérance (bruit de mesure, arrondi à l'ampère, déphasage d'un cycle) est une connaissance du moteur. La laisser au client ferait réinventer un seuil par écran, et deux écrans ne diraient pas la même chose du même cycle. > ### Le point de rupture se lit sur `DRAW_CAP`, jamais sur un solde tombé à zéro > > `draw.remainingW` à 0 dit qu'il ne reste rien **à cet instant** — ce qui arrive aussi quand > tout a été alloué normalement, sans qu'aucun plafond n'ait rien refusé. Un écran qui guetterait > ce solde signalerait une rupture là où il n'y a qu'un budget consommé, et manquerait la vraie > rupture le cycle où le plafond mord sans que le solde soit exactement nul. > > **`DRAW_CAP` est le signal**, et lui seul : il n'est émis que lorsqu'une décision a réellement > été refusée par un plafond, et il porte **laquelle** — `binding` / `source` distingue le > gestionnaire de réseau, la protection du branchement et la limite auto-imposée. Trois causes, > trois gestes, et aucun ne se déduit d'un nombre à zéro. > ### ⚠️ `budget.remainingW == 0` et `draw.remainingW != 0` dans la MÊME trame : c'est NORMAL > > Sous la **réserve batterie**, le budget de surplus est **annulé** — pas réduit (`LM-1203-b` : > servir « un peu » retarderait la recharge sans probablement démarrer personne). Mais la réserve > n'annule **que le surplus**. L'**autorisation de soutirage** est une contrainte physique du > branchement : aucune règle de stockage ne la modifie. > > Un client verra donc, légitimement, un budget à zéro à côté d'une autorisation intacte. **Ce > n'est pas une incohérence**, et il aurait raison de la signaler si personne ne la lui avait > dite — c'est pourquoi elle est écrite ici, et non en note de bas de page. Les deux grandeurs > répondent à deux questions différentes : « puis-je dépenser sans acheter ? » et « le > branchement supporte-t-il que j'achète ? » > > Épinglé par `testDrawAuthorisationSurvivesTheBatteryReserve`, qui dit aussi à quelle condition > il devient faux : le jour où l'on déciderait qu'une batterie basse interdit d'acheter au > réseau — une décision de modèle, pas une correction. > **Ce qu'il compte** : ce que le moteur a **décidé d'acheter** selon son modèle de financement, > pas ce que le compteur verra passer. Pour une action du proxy — échéance, tarif dynamique — > **toute** l'allocation est comptée comme achetée : l'échéance décide de charger qu'il y ait du > soleil ou non, et aucun partage n'est calculé. Là où le moteur **sait** partager, il partage : > `EV_GRID_START` ne verse ici que sa part réseau, sa part surplus allant dans > `budget.allocatedW`. > > **Omis en mode dégradé**, comme `budget` : sans plan, il n'y a pas de soutirage décidé. ### ⚠️ `budget.evReservedW` n'est PAS un registre — et ne se réconcilie jamais *(Définition demandée par l'app le 2026-08-30, qui l'affichait sous « Réservé au véhicule » — un libellé qui affirme une réservation là où il y a une correction.)* **`evReservedW` est une CORRECTION du budget de surplus.** Il vaut la somme des puissances **commandées mais pas encore visibles au compteur** pour les charges servies au réseau. Il **tend vers 0** à mesure que la mesure rattrape la commande, **sans qu'aucune décision n'ait changé** : c'est un rattrapage de **latence**, pas un montant mis de côté. | | `budget.allocatedW` | `budget.evReservedW` | `draw.committedW` | |---|---|---|---| | Nature | registre d'allocation (surplus) | **correction** de budget | registre des watts achetés | | Réconciliable | oui | **non, et jamais** | oui | | Varie sans décision | non | **oui** — décroît quand la mesure rattrape | non | > **Ne l'additionnez à rien.** Le sommer avec des allocations compare deux natures, et le total > obtenu serait **vrai au premier cycle après un changement, faux après convergence** — donc > juste dans un test portant sur un cycle isolé, et faux chez le client. Une identité dépendante > du temps est **pire que pas d'identité** : elle porte le sceau d'une identité publiée. > > **Depuis `1.15.2+etm31` il n'a plus qu'UNE nature.** Il recevait aussi la part réseau > d'`EV_GRID_START` — une vraie tranche d'allocation — ce qui rendait toute définition du champ > fausse pour l'un des deux termes. Cette part est passée dans `draw.committedW`. ### Les TROIS nombres d'une charge — décidé, appliqué, mesuré *(Rendu explicite le 2026-08-30, après qu'une prémisse de la maquette app eut confondu les deux premiers. Trois nombres coexistent pour une même charge dans un même cycle, ils disent trois choses différentes, et **aucun ne se déduit d'un autre**.)* | | Où | Ce qu'il dit | Ce qu'un écart avec le suivant signifie | |---|---|---|---| | **DÉCIDÉ** | `allocatedW`, et `levels[].targetW` | ce que la cascade a **alloué** | → l'**arrondi** du mécanisme : 1 400 W décidés tombent sur un palier de 1 500 W | | **APPLIQUÉ** | `mechanism.stageW` · `setpointW` · `state` · `currentA` | ce qui a été **commandé au matériel** | → la charge **n'obéit pas** : véhicule qui refuse, thermostat ouvert, verrou qui a mordu | | **MESURÉ** | `measurement.powerW` (avec sa `source`) | ce qu'un appareil ou un compteur a **vu** | — | > ⚠️ **`allocatedW` est du DÉCIDÉ, pas du commandé.** `buildTelemetry()` publie le plan, et > `applyActionsToAdapters()` reçoit le slot **`const`** : rien n'est réécrit après le dispatch. > Mesurable — `testUnpluggedChargerTakesNoBudget` attend `allocatedW == 3000` sur une borne dont > le courant commandé donne 2 990 W. > > **Conséquence** : `Σ levels[].targetW == allocatedW` tient **exactement**, les deux étant du > décidé. L'écart « décidé → appliqué » ne se lit donc **pas** entre eux, mais entre > `allocatedW` et la charge utile de **mécanisme**. > > **Et l'arrondi ne se répartit pas entre les niveaux.** Toute clé de répartition serait une > fiction, dont un client hériterait comme d'un fait : l'écart appartient à la **charge**, pas à > l'un de ses niveaux. > **Pourquoi trois et pas deux.** Chaque frontière répond à une question différente, et les > confondre envoie diagnostiquer le mauvais problème (règle 7-b). Décidé ≠ appliqué est une > affaire de **format** — le mécanisme ne sait pas prendre n'importe quelle valeur. Appliqué ≠ > mesuré est une affaire d'**obéissance** — et ce que cet écart autorise à conclure dépend > encore de `measurement.source` (§11) : sous `device` il accuse la charge, sous `meter` il > ouvre trois hypothèses, sous `none` il n'est pas calculable. #### `counts` — la ventilation, et pourquoi elle ne se déduit pas de `funding` ```jsonc "levels": [ { "level": "comfort", "targetW": 1380, "funding": "grid", "counts": { "budget.allocatedW": 900, "draw.committedW": 480 } } ] ``` **`funding` dit d'où les watts VIENNENT, `counts` dit dans quel registre ils TOMBENT.** Ce sont deux questions, et aucune ne se dérive de l'autre : ci-dessus, `funding` vaut `"grid"` — un watt est acheté, la règle suffit — pendant que `counts` porte **deux** destinations. | Clé | Ce qu'elle compte | |---|---| | `budget.allocatedW` | watts de **surplus** alloués par la cascade | | `draw.committedW` | watts **achetés** au réseau | > **Clés OPAQUES, jamais recyclées.** Une clé inconnue de votre version s'affiche « destination > inconnue » et laisse `Σ counts == targetW` vérifiable ; elle ne vaut **jamais** zéro. Le moteur > s'interdit de réattribuer un identifiant à un sens nouveau — même règle que les numéros de > règle retirés : recycler ferait ranger des watts dans le mauvais compteur **sans qu'aucune > erreur ne le signale**. > > **Toujours publié : à une seule destination, et à zéro.** `counts: {}` satisferait l'identité > en **perdant la destination**, sur le cas le plus fréquent. Un zéro **avec** sa destination dit > « cette grandeur existe et vaut 0 » ; l'absence dirait « elle n'existe pas ». C'est la même > distinction que `draw: {"committedW": 0}` — zéro est une valeur quand la grandeur existe, > l'absence en est une quand elle n'existe pas. > > **Qui fait foi** : `counts` pour la **comptabilité**, `decision.params` pour la **phrase** du > motif. `params.budgetW`/`gridW` d'un `EV_GRID_START` expliquent *pourquoi* la charge démarre > malgré un budget insuffisant ; `counts` dit *où* les watts sont comptés. Si les deux > divergeaient, c'est `counts` qui décrit le bilan. ### L'identité de réconciliation — écrite ici, jamais reconstruite ``` Σ charges Σ niveaux counts["budget.allocatedW"] == budget.allocatedW Σ charges Σ niveaux counts["draw.committedW"] == draw.committedW Σ counts d'un niveau == niveau.targetW ``` **Elle ne porte plus sur `funding`.** Sommer les `targetW` des niveaux `funding == "surplus"` était juste tant qu'un niveau n'avait qu'une destination ; un `EV_GRID_START` la rendait fausse. La ventilation supprime le détour : on somme des **compteurs**, pas des origines. Elle a changé de forme avec R2 : elle portait sur `allocatedW` des charges dont le `funding` valait `"surplus"`, elle porte maintenant sur les **niveaux**. Un client qui la reconstruirait depuis l'ancienne forme divergerait **au premier cas mixte** — c'est-à-dire au premier cycle du §10. > **`EV_GRID_START` n'est plus une exception depuis `+etm31`.** Ce motif partage **une** > allocation entre deux registres, et les deux sont désormais des registres : `params.budgetW` > va dans `budget.allocatedW`, `params.gridW` dans **`draw.committedW`** — et > `budgetW + gridW == allocatedW` toujours. Sa contribution au surplus reste > `decision.params.budgetW`, pas `targetW`. > > Le partage lui-même ne disparaîtra pas — c'est un fait, une part est achetée et l'autre non. > Ce qui disparaît est l'**exception** : les deux moitiés tombent maintenant dans des compteurs > de même nature. `counts` (**R3**) publiera ce partage par niveau plutôt que de le laisser > déduire du motif. > > `budget.evReservedW` **subsiste**, mais restreint : il ne compte plus que les bornes servies > par un financement **réseau** (échéance de départ, spot market), qui restent décidées en > amont. Une borne servie par la cascade y serait comptée deux fois. > > **Deux bornes ne se partageaient pas le budget avant 3g-1** : l'allocation était calculée une > fois et offerte entière à chacune. Ce n'est plus le cas — la première est servie, la seconde > reçoit le reliquat, et 0 si ce reliquat est sous son plancher. > > ✅ **Le rang d'une borne est configurable depuis 3g-2**, et il vit là où vivent tous les rangs : > `LoadConfig.priority` (LM-1206/LM-1207). Il n'existe **pas** de second système de priorité — > un classement propre aux bornes ne saurait pas exprimer « VE1 > ECS > VE2 », et deux > classements qui ne peuvent pas s'interclasser sont un défaut, pas deux fonctionnalités. > `ChargingInfo` ne porte donc **aucun** champ de rang, et n'en portera pas. > > Le tri est **total** : à rang égal, l'identifiant départage. Laquelle est servie est donc > reproductible d'un cycle à l'autre, même avant tout réglage. > > *(Jusqu'à 3g-1, `EvAdapter::descriptor()` fixait `priority = 100` pour toutes : même clé de > tri, `std::sort` non stable, et laquelle démarrait n'était ni réglable ni reproductible.)* **Bornes exclues de `loads[]`** : celles sans voiture assignée, celles en mode manuel (`ChargingModeNormal`), et celles **sans véhicule branché**. Elles sont **hors arbitrage**, pas servies en dernier — leur consommation est déjà au compteur racine, donc déjà dans le budget. > `pluggedIn` a rejoint ce filtre après le banc du 2026-08-27 : une borne débranchée ne peut > rien tirer de ce qu'on lui alloue, et en cascade cette allocation est prise aux charges > suivantes. 4 853 W mesurés donnés à personne (`docs/RELEVE_3g2.md` §3.1). #### `loads[]` ⊆ `GetLoadConfig` — toute charge publiée est configurable **Tout `loadId` de `loads[]` a une entrée dans `GetLoadConfig`.** C'est un invariant de **construction**, pas une discipline : depuis le lot B-bis, toute charge arbitrée est bâtie depuis la configuration persistée (`LoadConfigStore`), et il n'existe plus aucun chemin d'enregistrement en dur — le moteur n'a qu'une table de charges, alimentée par un seul endroit. Le client peut donc résoudre le libellé, le `domain` et le rang de **chaque** charge publiée, et lui proposer son écran de réglage. **L'inclusion ne vaut que dans ce sens.** `GetLoadConfig` peut contenir des charges absentes de `loads[]` : celles déclarées `enabled: false`, dont le rôle est décrit mais qui sont **exclues de l'arbitrage** (contrat §9). Aucun adaptateur n'est construit pour elles, elles ne consomment aucun budget, elles n'ont donc aucune télémétrie à publier. Une charge configurée absente de `loads[]` se lit **« désactivée »**, jamais « perdue ». > ⚠️ **3g-1 avait ROMPU cet invariant, et 3g-2 le rétablit.** En faisant entrer les bornes dans > `loads[]`, 3g-1 publiait des charges qui ne figuraient dans aucune `GetLoadConfig` — c'est-à-dire > exactement la charge fantôme que le lot B-bis avait supprimée, réapparue par l'autre bout. > > **Depuis 3g-2, toute borne détectée reçoit d'office une entrée `LoadConfig`** de mécanisme > `evcharger` (voir ci-dessous). L'inclusion est de nouveau vraie par construction. ##### Le mécanisme `evcharger` dans `GetLoadConfig` / `SetLoadConfig` *(3g-2)* Une entrée `evcharger` porte **le rang, et rien d'autre** : | Champ | Valeur | |---|---| | `id` | ThingId de la borne | | `adapter` | `"evcharger"` | | `mode` | `"dynamic"` (une borne module ; `"fixed"` est refusé) | | `domain` | `"ev"` | | `label`, `priority`, `enabled` | comme toute autre charge | **Toute charge utile est refusée** : `relays[]`, `sgReady`, `powerLevels`, `maxPowerW`, `minPowerW`, `minOnS`, `minOffS`. Les limites d'une borne — courant minimal et maximal, nombre de phases, disponibilité — viennent du **Thing** et changent avec le véhicule branché ; les déclarer ici en ferait une seconde source de vérité, périmée dès le premier débranchement. - **Création automatique.** Une borne détectée entre en configuration sans intervention, à un rang de tête (le plus petit rang existant moins un, borné à 1) qui ne réécrit le rang de personne. C'est un point de départ de mise en service, pas une politique : **l'app pose les vrais rangs**. La création est journalisée, une ligne par borne, à la création seulement. - **Aucun adaptateur n'en est construit** : celui de la borne vient du Thing. Un aller-retour `GetLoadConfig` → `SetLoadConfig` **verbatim** ne change donc rien et ne reconstruit rien — aucun verrou n'est réarmé. - **L'entrée survit à son Thing.** Borne remplacée, ThingId changé, appareil déposé : l'entrée **n'est pas supprimée** — c'est une configuration, et l'installateur a pu la classer. Elle reste publiée dans `loads[]`, `available: false`, `faultCode: "THING_MISSING"`, motif `LOAD_UNAVAILABLE`, et ne retient aucun budget. La supprimer d'office rendrait « borne remplacée » indistinguable de « borne jamais configurée ». - **Exclusivité.** L'entrée revendique le Thing de la borne : la déclarer en plus comme charge pilotée est refusé par `SetLoadConfig` (deux commandeurs sur un même organe, LM-201). > **La règle de détection de `+etm14` est RETIRÉE.** Cette section documentait jusqu'ici > l'inverse : une charge pouvait être arbitrée et publiée **sans** exister dans `GetLoadConfig`, > et le client était invité à la détecter par différence d'ensembles — un `loadId` sans > équivalent valant « charge non configurable » : afficher le `loadId` faute de libellé, la > traiter comme non classée, ne proposer aucun écran de réglage. Cette règle n'avait qu'un > porteur, `pac-terrain`, la PAC SG-Ready **codée en dur** du banc `.75`. Le lot B-bis en fait > une `LoadConfig` ordinaire et supprime le bloc d'enregistrement : la règle est sans objet. > **Aucun champ `configurable: false` n'a été ajouté** — il n'aurait plus rien à signaler ; ne > pas l'attendre dans la charge utile. Un client qui a déjà implémenté la règle de détection > peut la conserver sans dommage : elle ne se déclenchera plus. --- #### Codes — aucune phrase composée par la box > La box transporte un **code** et des **paramètres**. La phrase est fabriquée par le client, > dans la langue de son utilisateur. Ce n'est pas une préférence de style. La locale nymea est **par connexion** (`JSONRPC.Hello` accepte `o:locale`) : deux utilisateurs d'une même box peuvent avoir deux langues, ce qu'une phrase composée en C++ ne peut pas servir. Par ailleurs `NymeaEnergyJsonHandler` est un `JsonHandler` **sans `pluginId`** : il est structurellement hors du catalogue de traduction nymea, et rien ne rattraperait une phrase française émise ici. **`decision.code`** — catalogue fermé. `params` ne contient que des nombres et des booléens ; le **libellé de la charge n'y figure jamais** (le client le résout via `GetLoadConfig`). | Code | `params` | Sens | |---|---|---| | `EV_SURPLUS` | — | VE rechargé sur surplus PV | | `EV_SPOT_MARKET` | — | VE rechargé sur créneau tarifaire favorable | | `EV_DEADLINE` | — | VE prioritaire, échéance approchante | | `EV_ECO_MIN` | — | Pas de surplus, courant minimum maintenu (mode EcoMin) | | ~~`EV_IDLE`~~ | — | **Retiré en 3g-1** — remplacé par `BELOW_MIN_POWER` / `SURPLUS_INSUFFICIENT`, qui disent *pourquoi* la borne ne charge pas | | ~~`EV_SURPLUS`~~ | — | **Retiré en 3g-1** — la recharge sur surplus produit désormais `SURPLUS_SETPOINT`, comme toute autre charge | | ~~`EV_ECO_MIN`~~ | — | **Retiré en 3g-1** — le courant minimum est devenu le **plancher** du §12, exprimé une seule fois pour tous les mécanismes | | `LOAD_UNAVAILABLE` | `frozenW` | Charge en défaut : aucune commande émise, `frozenW` reste compté au budget. Levée par `ClearLoadFault` | | `LOCK_MIN_ON` | `appliedW`, `budgetW` | Le verrou `minOn` a **relevé** la consigne : puissance engagée non coupable | | `LOCK_MIN_OFF` | `budgetW`, `requestedW` | Le verrou `minOff` interdit tout enclenchement — le budget, lui, payait `requestedW` | | `LOCK_MIN_OFF_CAPPED` | `appliedW`, `budgetW`, `requestedW` | Le verrou `minOff` plafonne sans interdire | | `SURPLUS_SETPOINT` | `budgetW`, `setpointW`, `stepped` | Consigne servie par le surplus ; `stepped` = arrondie sur un palier déclaré | | `SURPLUS_INSUFFICIENT` | `budgetW` | Le budget ne payait aucun palier — **aucun verrou n'a mordu** | | `BATTERY_RESERVE` | `socPercent`, `reservePercent`, `withheldW` | Le surplus **existe** mais la réserve de stockage l'interdit : **aucune** charge n'est servie. Depuis 3g-2 `withheldW` compte les **deux** termes retenus à cette charge — le surplus annulé **et** sa propre consommation, que le recrédit lui aurait rendue | | `BELOW_MIN_POWER` | `budgetW`, `minPowerW`, `o:state` | Budget **sous le premier palier atteignable** : 0 W, et le budget passe à la suivante. `state` nommé pour les mécanismes à états | | `EV_GRID_START` *(3g-2)* | `budgetW`, `floorW`, `gridW` | La borne **démarre en soutirant** : le surplus ne paie pas son plancher, mais `acquisitionTolerance` autorise l'appoint. `gridW = floorW − budgetW` est ce qui vient du réseau. Voir la note de comptabilité ci-dessous | | `PHASE_LIMIT` *(3g-2)* | `limitW`, `requiredW` | C'est la **limite de phase** qui interdit, pas le budget : la place libre laissée par la protection de surcharge est sous ce que la borne exige. Geste attendu : regarder l'abonnement et les autres consommateurs, pas attendre le soleil | | `DRAW_CAP` *(délestage §3)* | `marginW`, `overshootW`, `source`, `o:phase` | L'**autorisation de soutirage** est épuisée — une seconde ressource, distincte du surplus. `source` est un code opaque : `connection` (le branchement), `gridOperator` (imposé), `selfImposed` (auto-imposé) ; ce qu'on a le droit d'en faire en dépend. `marginW` est **signé** et `overshootW` dit de combien on dépasse. `phase` (`A`/`B`/`C`) n'est présent que si le plafond est **par phase** : son absence dit qu'aucune phase n'est coupable, elle ne vaut pas « phase A ». **Précédence (LM-1011)** : tant qu'il reste de l'autorisation, même insuffisante, le motif est `BELOW_MIN_POWER` — `DRAW_CAP` est réservé à l'autorisation **nulle** | | `ECO_FLOOR_GRID` *(§10)* | `gridW`, `atMaximum`, `o:remainingWh`, `o:targetWh`, `o:remainingS`, `o:regime`, `o:minAlwaysW`, `o:boundBy` | L'obligation éco est servie **en achetant**. Les six champs donnent la **trajectoire**, pas l'instant : la cadence de l'étalement fait un mur — 2 kWh restants valent 333 W à six heures de l'échéance et **le plafond dès seize minutes**. « 1000 W achetés » seul ne permet pas de prévoir la facture ; avec `remainingWh` et `remainingS`, si. `atMaximum` dit que l'étalement a cessé (point de non-retour), `regime` sur quoi on achète. **La LOI DE COMMANDE dépend du régime** — voir l'encadré ci-dessous | | `ECO_FLOOR_MET` *(§10)* | `deliveredWh`, `targetWh` | L'obligation est **tenue**. **INTERDIT sous `regime: unmeasurable`** (R6) : dire « tenue » suppose de savoir ce qui a été livré. Une échéance qui cesse d'être mesurable et continue de s'afficher comme tenue est pire qu'une échéance absente — elle rassure | | `ECO_FLOOR_MISSED` *(§10)* | `targetWh`, `cause`, `o:missingWh` | Une période d'obligation s'est close **sans avoir été tenue**. `cause` ∈ `drawCap` \| `loadCapacity` \| `unmeasurable` \| `unavailable` — **trois causes, trois gestes** : revoir l'abonnement, lire la fiche technique de la charge, ou regarder ce que la borne publie. Sans elle, l'écran n'affiche qu'un reproche sans recours. `missingWh` est **absent** sous `unmeasurable` : on ne sait pas ce qui a été livré, donc pas ce qui manquait — un zéro se lirait « il ne manquait rien ». Motif d'ÉVÉNEMENT : publié le seul cycle de la bascule | | `RECREDIT_SUSPENDED` | `refusedSinceS`, `withheldW` | Le recrédit anti-clignotement est **suspendu** : la charge ne suit pas la commande depuis plus longtemps que la latence de son mécanisme. Les watts ne sont **pas rendus au budget**, donc **toutes les charges suivantes reçoivent moins** — et sans ce motif, on chercherait un défaut de cascade là où une charge n'obéit pas. Un écart RÉCENT est une latence, pas un refus : le seuil vient du mécanisme (V2C mesurée : montée ~30 s, descente ~7 s), jamais d'une constante | | `DEROGATION_ACTIVE` *(§15)* | `maxW`, `gridW`, `endsAt`, `o:targetWh`, `o:deliveredWh`, `o:boundBy` | Cette charge est en **dérogation** : servie à son maximum, financée au réseau, en tête de passe éco. Ce n'est **pas un mode** — elle se superpose et s'efface, et `endsAt` (ISO 8601 UTC) est le **filet** qui permet à l'écran de montrer un compte à rebours plutôt qu'un état sans horizon. `targetWh` et `deliveredWh` sont **absents** quand aucune cible n'a été donnée : un `targetWh: 0` se lirait « cible atteinte ». `boundBy` dit **qui** a bridé — `drawCap` (le plafond de soutirage de la maison) ou `loadCeiling` (la charge est à son plafond de cycle ; pour une borne ce plafond porte déjà son autorisation de phase, et `maxW` est alors la vraie réponse à « pourquoi pas plus »). Sans lui, une dérogation servie à moitié se lit comme une charge qui ne suit pas | > ### La FIN d'une dérogation est un événement de TRAME, pas un motif > > Elle est publiée dans `derogationEnded[]` **à côté** de `loads[]`, et non dans la charge : > au débranchement la charge n'y est **déjà plus**, et l'événement disparaîtrait exactement quand > il explique le plus de choses. `decision` porte ce que le cycle a **décidé** ; une fin est un > **événement**, et l'y loger obligerait à choisir entre les deux. > > ```json > "derogationEnded": [ { "loadId": "{...}", "cause": "durationElapsed", "restoredMode": 3 } ] > ``` > > `cause` ∈ `targetReached` | `unplugged` | `measurementLost` | `durationElapsed` | > `modeChanged` | `cancelled`. **Six causes, six gestes** — cible atteinte : rien à faire ; > mesure perdue : regarder le lien ou le compteur ; débranchement ; durée écoulée ; > **mode changé : quelqu'un a repris le volant** ; annulée à la main. Une fin qui ne dirait pas > laquelle a mordu laisserait l'utilisateur relancer une dérogation que le lien empêchera encore. > > Sous `modeChanged`, `restoredMode` est **absent** : le mode mémorisé n'est PAS restitué, il > écraserait celui que l'utilisateur vient de choisir. Écrire un mode pendant une dérogation la > termine — le durable prime sur le ponctuel. > > `restoredMode` est le `ChargingMode` **mémorisé** auquel on revient : le client ne peut pas le > déduire, il n'a jamais vu celui d'avant la pose. Présent seulement si un mode avait été écrit. > > Le champ est **absent** hors du cycle de la fin — jamais une liste vide qui se lirait « rien ne > s'est terminé, on regarde ». > ### `ECO_FLOOR_GRID` sert DEUX besoins, et dit lequel a décidé > > Le plancher éco peut venir d'une **obligation en énergie** (une cadence) ou d'un **minimum > réseau** (une puissance demandée tant que la charge est là). Les deux produisent un `gridW`, et > rien dans le nombre ne dit lequel a fixé la consigne. > > | champ | présent quand | > |---|---| > | `remainingWh`, `targetWh`, `remainingS`, `regime` | il y a une **obligation en énergie** | > | `minAlwaysW` | un **minimum** est demandé | > | `boundBy` ∈ `obligation` \| `minimum` | un minimum est demandé — dit **lequel des deux mord** | > > **Les grandeurs d'obligation sont ABSENTES sur une charge à minimum seul**, jamais à zéro : un > `remainingS: 0` inventerait une échéance imminente et un `remainingWh: 0` une obligation > tenue — deux affirmations fausses au lieu d'un silence. `progress` est absent pour la même > raison : il ne décrit que l'obligation. > ### Sous `unmeasurable`, la cadence est CONSTANTE — la loi de commande change avec le régime > > Un écran qui suppose la même loi dans les deux régimes se trompera sur la trajectoire, et c'est > la trajectoire qu'il vend. > > | régime | loi | `atMaximum` | > |---|---|---| > | `measured` / `estimated` | `reste ÷ temps restant`, **recalculée** — elle se durcit à l'approche | peut passer à `true` | > | `unmeasurable` | `cible ÷ jour`, **constante** — elle ne bouge pas de la fenêtre | **jamais `true`** | > > **Pourquoi.** Sous `unmeasurable`, `remainingWh` est figé à la cible (encadré suivant). Un > rapport dont seul le dénominateur décroît ne suit plus un retard : **il suit l'horloge**. On > achèterait davantage parce que le temps passe, pas parce que quelque chose est en retard. > Chiffré : obligation 4 kWh, plafond 7 kW, fenêtre d'un jour — l'ancienne loi achetait > **19 010 Wh**, soit ×4,75 l'obligation, et le facteur grandissait avec la fenêtre. > > Sous la loi constante, **le total acheté sur la fenêtre vaut exactement la cible**. > > **Ce que l'écran peut donc affirmer, et ce qu'il ne peut pas.** Sous `unmeasurable`, `gridW` ne > montera pas : une phrase du type « l'achat va s'intensifier à l'approche de l'échéance » y > serait fausse. Et `atMaximum: false` n'y signifie pas « tout va bien » — il signifie qu'il n'y > a **pas d'étalement du tout**, donc rien qui puisse cesser. > ### `remainingWh` sous `unmeasurable` : **la cible INTACTE**, et il faut le lire ainsi > > Sous `regime: unmeasurable`, **`remainingWh == targetWh`**. Rien n'est soustrait, parce qu'on > ne sait rien de ce qui a été livré. > > **2 000 Wh restants sur une cible de 2 000 Wh signifie « rien de mesurable », PAS « rien > livré ».** Les deux lectures commandent des phrases d'écran opposées — l'une dit qu'il n'y a > aucune information, l'autre accuse une charge de n'avoir rien reçu — et l'écran ne peut > trancher que si le contrat le dit. Il le dit ici. > > **Pourquoi rien n'est soustrait.** Le moteur dispose bien d'une intégration interne. La > soustraire ferait franchir la frontière à une **estimation sous l'allure d'un fait** : le > chemin exact de `carBatteryLevel`, refusé en `LM-1009` D3. Et que `regime` voyage dans les > mêmes params ne suffirait pas — un client qui lit `remainingWh` sans regarder `regime` > obtiendrait un nombre calculé, sans rien pour le lui dire. > > **Identité vérifiable sous `measured`** — troisième contrôle du même genre que > `Σ counts == targetW` et `authorisedW − committedW == remainingW` : > > ``` > progress.deliveredWh + params.remainingWh == params.targetWh > ``` > > Elle ne tient QUE sous `measured` : sous `unmeasurable`, `deliveredWh` est absent, et c'est > l'égalité `remainingWh == targetWh` qui prend le relais. Un client peut donc vérifier la > cohérence dans les deux régimes — mais pas avec la même formule, et c'est le régime qui dit > laquelle appliquer. | `LOCK_MIN_STATE_HOLD` | `state` | Verrou `minStateHold` : état PAC maintenu (protection court-cycling) | | `SG_FORCED` | `budgetW`, `estimatedW` | PAC en état 4 (forcé) | | `SG_RECOMMENDED` | `budgetW`, `estimatedW` | PAC en état 3 (recommandation) | | `SG_NORMAL` | `budgetW` | PAC en état 2 : **aucun surplus à allouer** (budget ≤ 0). Un budget positif mais sous l'état 3 donne `BELOW_MIN_POWER`, pas celui-ci | | `DEGRADED_L2` | — | Consigne de repli du watchdog L2 (compteur muet) | | `SAFE_STATE_RELAY` | — | État sûr avant retrait : tous relais ouverts (ECS-413) | | `SAFE_STATE_SETPOINT` | — | État sûr avant retrait : consigne 0 W (ECS-413) | | `SAFE_STATE_SG_READY` | — | État sûr avant retrait : état 2, **jamais** le blocage (ECS-413) | > **Comptabilité d'un `EV_GRID_START`** — c'est le seul cas où l'`allocatedW` d'une charge se > **partage** entre les deux compteurs du budget, et les deux termes sont dans le motif : > `budgetW` vient du surplus et entre dans `budget.allocatedW` ; `gridW` vient du réseau et entre > dans `budget.evReservedW`. `budgetW + gridW == allocatedW`, toujours. L'entrée porte > `funding: "grid"` : une part de cette puissance est achetée. > > **Borné par construction** : un démarrage sous tolérance exige du surplus réel (`budgetW > 0`) > et consomme **tout** le budget restant — donc **au plus une borne par cycle** peut soutirer. > Le plafond de la protection de surcharge s'applique avant, et la couche L4 garde le dernier mot. > > `LOCK_MIN_ON` / `LOCK_MIN_OFF` / `SURPLUS_INSUFFICIENT` se lisent sur le **même axe** : le > verrou a relevé la consigne, il l'a rabaissée, ou aucun verrou n'a mordu et c'est le budget. > La distinction est le fruit d'ECS-309 et ECS-309-b (corrections de terrain) — un motif faux > envoie diagnostiquer le mauvais problème, avec l'autorité d'une explication. **`measurement`** *(o:)* — ce qui est **observé** de la charge, avec sa **source**. *(Remplace `measuredW` — voir la note de migration ci-dessous. Décidé le 2026-08-28, `specs/spec_loadmodel.md` **LM-1105/LM-1106**.)* ```jsonc "measurement": { "source": "meter" | "device" | "none", "powerW": 3980.0 // o: — absent si source == "none" } ``` | `source` | D'où vient `powerW` | |---|---| | `meter` | Un Thing tiers, désigné par `meterThingId`. **Prioritaire** quand il résout et porte l'état | | `device` | L'appareil piloté publie lui-même sa puissance — borne, `etmvariableload`, relais mesurant | | `none` | **Rien ne mesure cette charge.** `powerW` est alors ABSENT, jamais nul | **Quand vaut-il `none` ?** Trois cas, et le premier surprend : - **Toujours pour `sg-ready`**, même montée sur des relais qui mesurent. Un contact SG-Ready est un contact de **signal** : la PAC est alimentée ailleurs, son courant ne traverse jamais K1/K2, et un contact qui mesurerait quelque chose mesurerait **sa propre bobine**. C'est le seul mécanisme pour lequel `meterThingId` reste la seule voie vers une mesure. - **Topologie de relais MIXTE** — un relais mesurant, deux nus. Une somme partielle présentée comme la mesure de la charge annoncerait 500 W là où 1 500 W coulent, et l'écart enverrait chercher une panne inexistante. Une mesure partielle n'est pas une mesure. - **Thing sans l'état de puissance**, tout simplement. > **`source` ne dépend ni de la valeur ni de l'instant.** Un `relay-router` au palier 0 n'a aucun > relais fermé et publie `{source: "device", powerW: 0}` : **0 W est une mesure**, pas une > absence. La capacité se juge sur la ThingClass de **tous** les Things déclarés — sinon le bloc > de mesure apparaîtrait et disparaîtrait au rythme du thermostat. Publiée **à côté** de `allocatedW`, jamais fondue dedans : `allocatedW` est ce que le waterfall a **alloué**, `measurement.powerW` ce qui a été **vu**. Les confondre rendrait la somme des allocations irréconciliable avec `budget.allocatedW`. > **Pourquoi la source est dans la charge utile et pas déduite.** Un écart commandé↔mesuré ne > conclut pas la même chose selon elle : sous `device` il dit « la charge n'obéit pas » (commande > et mesure sortent de la même boîte) ; sous `meter` il ouvre trois hypothèses — la charge, le > compteur, ou le câblage ; sous `none` il n'est **pas calculable**, et le fabriquer depuis la > valeur nominale produirait un écart identiquement nul à tous les cycles, c'est-à-dire un > « tout concorde » permanent. > > `none` est un état **positif**, pas une absence : il dit « cette charge n'est pas mesurable » > — un `sg-ready` sur contacts secs ne le sera jamais — et permet à un client de **masquer** le > bloc de mesure au lieu d'afficher un vide. Sans lui, « pas mesurable » et « compteur en > panne » se ressemblent. > **L'asymétrie `device` / `meter`, et pourquoi elle vaut d'être dite à l'écran.** Sous > `device`, une **concordance** ne prouve pas grand-chose : plusieurs wallbox renvoient leur > consigne en guise de puissance, et « commandé == mesuré » n'y est alors qu'une tautologie. > Sous `meter`, une concordance est une **preuve** — commande et mesure viennent de deux boîtes > différentes. Autrement dit : **`device` détecte la désobéissance, `meter` est le seul à > pouvoir détecter un mensonge.** C'est ce qui justifie de laisser `meterThingId` **disponible** > sur une charge qui publie déjà sa puissance : masqué par défaut, jamais interdit. > **Une charge ABSENTE de `loads[]` n'est pas `none`.** *(Distinction remontée par l'agent app, > 2026-08-28, et confirmée sur le banc le jour même.)* `none` est **publié** : il AFFIRME « cette > charge n'est pas mesurable ». L'absence n'affirme rien — elle dit seulement que la charge n'a > **pas participé à l'arbitrage de ce cycle**, ce qui est une question sans rapport avec la > mesure. Une borne en mode manuel ou sans véhicule branché sort de `loads[]` (voir > `GetLoadTelemetry`, exclusions du waterfall) alors qu'elle publiera sa propre puissance dès le > cycle où elle y rentrera. > > Sans cette distinction, un client afficherait « pas mesurable — le compteur dédié est la seule > voie » sur une borne débranchée qui, une fois branchée, mesure très bien toute seule. > **Constaté tel quel sur `.75` en `+etm26`** : deux bornes `pluggedIn: false`, absentes de > `loads[]`, dont l'une porte `currentPower` et publiera `device`. > > La règle est simple parce que `measurement` n'est jamais omis d'une entrée présente : **la clé > existe pour toute charge de `loads[]`, et pour elle seule.** Donc — charge dans `loads[]` : > lire `measurement.source`. Charge absente : ne rien conclure sur sa mesurabilité, et ne rien > afficher qui l'affirme. > **Décidez sur `source`, jamais sur `adapter`.** « Masquer le compteur dédié sauf pour > `sg-ready` » paraît équivalent et ne l'est pas : un `relay-router` à topologie **mixte** > publie lui aussi `none`, et un client qui déciderait au mécanisme masquerait le champ sur un > ECS qui ne peut être mesuré **que** par un compteur dédié. La règle sûre tient en une ligne — > masquer par défaut quand `source == "device"`, proposer sinon — et elle couvre l'exception > SG-Ready sans qu'aucun client ait à la connaître. > > C'est la raison d'être de `source` : le régime est publié **pour** qu'il ne soit pas déduit. > Le déduire du mécanisme côté client remettrait dans l'app la déduction qu'on vient de retirer > du moteur. > **Migration depuis `measuredW`.** L'ancienne clé disparaît ; elle ne pouvait pas exprimer > trois états, et la doubler d'un second champ aurait obligé le client à joindre deux clés pour > lire une seule chose. `measuredW: X` devient `measurement: {source: …, powerW: X}` ; son > **absence** devient `measurement.source: "none"` — ce qui est précisément l'information qui > manquait. Un seul écran est concerné. **`temperatureC`** *(o:)* — température lue sur la sonde déclarée. **Lecture seule, aucun effet sur les décisions.** Son usage immédiat est le diagnostic de mise en service ; son usage durable est de **constituer l'historique** (température, puissance, heure) sans lequel les seuils éco/confort à venir ne pourront pas être réglés. **`faultCode`** — absent quand la charge est saine. | Code | Sens | |---|---| | `WRITE_FAILED` | Échelle d'écriture épuisée (ECS-410/414) : plus aucune commande émise jusqu'à `ClearLoadFault` | | `THING_MISSING` | Le Thing piloté est absent de la configuration nymea | | `UNUSABLE_ENCODING` | PAC SG-Ready dont l'encodage ne permet pas d'exprimer l'état 2 : jamais pilotable (ECS-110) | **`lock.kind`** — absent quand aucun verrou ne mord. `minOn` · `minOff` · `minStateHold`. `remainingS` = secondes restantes. > **Pourquoi des secondes et pas des watts.** `lockMinPowerW`/`lockMaxPowerW` sont un artefact > du mécanisme d'allocation, illisible pour un utilisateur ; le temps restant, lui, se lit. > Ceci **n'enfreint pas ECS-306** : cette exigence interdit qu'un index de **palier** franchisse > la frontière d'**allocation**. Une valeur d'**affichage** sortante est un autre chemin — et > `stageW`/`state` sont des watts et des états normés, pas des index. **`mechanism`** — union discriminée par `kind`, comme `LoadConfig`. Chaque variante porte sa charge utile, et **seulement la sienne**. Toutes les clés internes sont optionnelles au schéma (règle LM-302) ; tout mécanisme ajouté embarque son test. | `kind` | Clés | |---|---| | `relay` | `stageW` (palier appliqué, W), `maxStageW` | | `variable` | `setpointW`, `percent` (0–100, dérivé), `maxPowerW` | | `sgReady` | `state` (1–4), `estimatedPowerW` (**estimation** déclarée, jamais un engagement) | | `evcharger` | `chargingEnabled`, `pluggedIn`, `currentA`, `phaseCount` — **réservé 3g**, non émis en beta | --- ### Taux d'énergie — `GetEnergyRatios` / `EnergyRatiosChanged` | champ | présence | sens | |---|---|---| | `selfConsumptionRate` | **omis** si n/a | part de la production consommée sur place, % dans [0, 100] | | `autonomyRate` | **omis** si n/a | part de la consommation couverte sans soutirage, % | | `periodStartedAt` | **toujours** | epoch (s) du **minuit local** qui ouvre la période de reporting | | `measuredSince` | **toujours** | epoch (s) où la **baseline courante a été posée** | | `timeZone` | **toujours** | identifiant IANA du fuseau où la frontière a été construite | Les trois derniers sont présents **même quand les deux taux sont omis** : « la période a commencé à minuit et rien n'est encore mesurable » est une phrase vraie et utile, là où une réponse vide ne se distingue pas d'une panne. > #### Pourquoi la frontière est PUBLIÉE et non recalculée > > `System.GetTime` publie un **nom IANA**. Un décalage ne s'en déduit pas sans base de fuseaux, et > embarquer une base pour recalculer une frontière que le moteur connaît déjà serait le mauvais > geste — le recalcul resterait **faux les jours de changement d'heure**, où la journée fait 23 ou > 25 heures. Trois clients la recalculeraient de trois façons ; la seule qui compte est celle qui > a servi au calcul. **Même règle que `counts`.** > #### Les deux champs ne bornent pas la même chose, et ils arrivent dans la même trame > > **C'est le piège de cette charge utile, et il a déjà été tendu une fois.** Les deux dates sont > voisines dans le JSON ; leurs noms se ressemblent ; elles n'appartiennent pas au même écran. > > | champ | borne | sert à | > |---|---|---| > | `measuredSince` | **la borne basse des RATIOS** | l'écran des taux — c'est depuis là qu'on COMPTE | > | `periodStartedAt` | **la frontière de la JOURNÉE** | le bilan journalier — c'est là que la journée commence | > > **`periodStartedAt` n'est PAS la borne basse des ratios.** Les présenter comme journaliers alors > qu'ils ne comptent que depuis le dernier redémarrage serait exactement le silence que ce champ > prétend fermer. > > L'analogie avec `progress.measuredSince` de l'avancement éco s'arrête là, et c'est par là que > l'erreur passe : sur une charge, `measuredSince` répond « depuis quand on MESURE » et borne une > fenêtre dont `targetWh` donne l'autre bout. Ici il répond « depuis quand on COMPTE », et la > paire ne borne aucune fenêtre — les deux dates parlent de deux objets différents. > #### Pourquoi les deux ne coïncident pas, et ce que l'écart révèle > > La baseline se repose à **chaque redémarrage de nymead** et à chaque recul de compteur — donc en > plein jour. Un jour où nymead a redémarré à 14 h, ces ratios ne couvrent pas la journée : ils > couvrent **depuis 14 h**. > > Avec la seule frontière, l'écran écrirait « aujourd'hui » sur des chiffres qui ne parlent que de > l'après-midi. Avec la paire, il peut écrire la phrase vraie : **« depuis 14:00 »**. Quand les > deux coïncident — le cas normal — il n'y a rien à dire de plus, et c'est ce qui rend l'écart > lisible le jour où il apparaît. > > Même paire, même raison que `progress.measuredSince` / `targetWh` de l'avancement éco. > #### ⚠️ DÉFAUT CONNU — `measuredSince` DÉRIVE au changement de fuseau (2026-09-06) > > **Mesuré, non corrigé à ce jour.** Bascule `Europe/Paris` → `Europe/London` à `13:37:26Z`, > sans redémarrage de `nymead` : > > ``` > periodStartedAt : 1788645600 → 1788649200 (+3600 s) ← CORRECT, la journée suit le fuseau > measuredSince : 1788645607 → 1788649207 (+3600 s) ← FAUX, un instant absolu ne bouge pas > ``` > > **Cause** : `m_baselinePosee = now` conserve le `QDateTime` tel quel, en spécification > `Qt::LocalTime`. Un tel objet garde des composantes d'horloge murale et résout son époque > **au moment de la conversion**, avec le fuseau courant. Après une bascule, la même lecture > « 00:00:07 » rend une époque différente d'une heure. > > **Conséquence** : le champ affirme que la baseline a été posée une heure plus tard qu'en > réalité. Le champ posé pour rendre une couture lisible **est lui-même déplacé par la couture**. > > **Correctif** : `m_baselinePosee = now.toUTC()` — une ligne. Un instant absolu se stocke en > UTC ; `periodStartedAt`, lui, doit rester construit dans le fuseau courant, puisque c'est > précisément une frontière locale. Les deux champs n'ont pas la même nature, et le stockage doit > le refléter. > #### Ce que `timeZone` rend possible, et qui n'existait pas > > La période de reporting est la **troisième nature d'heure murale** du système, après l'habitude > du client et le calendrier réglementé (`LM-1005-b`). Elle suit l'installation, ce qui est > correct — **mais seulement si le fuseau de la box est celui de l'installation.** Relevé le > 2026-09-04 : `.75` tourne en `Europe/London`, donc son bilan journalier se remet à zéro à > **01:00 heure française**, et rien nulle part ne le disait. > > Publier le fuseau est ce qui rend cette vérification **possible** — c'est le seul des trois cas > où personne ne contrôlait quoi que ce soit. > #### `timeZone` qualifie une HORLOGE, il n'offre pas un choix — et ça change la phrase > > Le champ dit **dans quelle horloge la box interprétera les heures murales**. Ce n'est pas une > préférence attachée à l'échéance, et l'écran ne doit pas le laisser croire : > > | | | > |---|---| > | **dit la chose** | « 12:00 **heure de la box** (Europe/Paris) » | > | **laisse croire à un choix** | « Fuseau : Europe/Paris » | > > La nuance n'est pas cosmétique. Le fuseau **est** modifiable — mais c'est un réglage > **système**, qui déplace d'un coup **toutes** les heures murales de l'installation : les > échéances, la frontière de la journée de reporting, et demain les plages tarifaires. Un écran > qui le présente sous un champ d'échéance suggère qu'on choisit le fuseau **de cette > échéance-là**. Il n'y en a qu'un, et il vaut pour tout. > > #### L'avertissement de divergence est CONDITIONNEL, jamais permanent > > Il ne s'affiche que si le fuseau de la box **diffère** de celui de l'appareil. Un avertissement > qui apparaît toujours cesse d'être lu — et il cesse d'être lu **précisément le jour où il > devient vrai**. > > C'est la règle de l'absence qui informe, déjà appliquée deux fois dans ce contrat : > `progressUnmeasurableCause` est **absent** quand la charge est mesurable, et `phase` est > **absent** dans `DRAW_CAP` quand aucune phase n'est coupable. Un champ toujours présent ne > porte aucune information ; un avertissement toujours affiché non plus. --- ### Spot market #### `NymeaEnergy.GetAvailableSpotMarketProviders` Retourne la liste des providers spot market disponibles. **Params :** aucun **Returns :** ```json { "providers": [ { "providerId": "5196b3cc-b2ee-46d6-b63a-7af2cf70ba67", "name": "aWATTar AT", "country": 14, "website": "https://www.awattar.at" }, { "providerId": "0ca6ad88-e243-438d-a0f8-986cecf61834", "name": "aWATTar DE", "country": 82, "website": "https://www.awattar.de" } ] } ``` --- #### `NymeaEnergy.GetSpotMarketConfiguration` Retourne la configuration spot market courante. **Params :** aucun **Returns :** ```json { "enabled": true, "available": true, "o:providerId": "5196b3cc-b2ee-46d6-b63a-7af2cf70ba67" } ``` > `providerId` est absent si `enabled` est `false`. --- #### `NymeaEnergy.SetSpotMarketConfiguration` Active ou désactive le spot market et sélectionne le provider. **Params :** ```json { "enabled": true, "o:providerId": "5196b3cc-b2ee-46d6-b63a-7af2cf70ba67" } ``` > Si `enabled: true`, `providerId` est obligatoire et doit être un UUID valide. **Returns :** ```json { "energyError": "EnergyErrorNoError" } ``` > Retourne `EnergyErrorInvalidParameter` si `enabled: true` sans `providerId`, ou `providerId` inconnu. --- #### `NymeaEnergy.GetSpotMarketScoreEntries` Retourne les cotations pondérées du provider actif. Liste vide si désactivé ou non disponible. **Params :** aucun **Returns :** ```json { "spotMarketScoreEntries": [ { "startDateTime": "2025-06-15T00:00:00+02:00", "endDateTime": "2025-06-15T01:00:00+02:00", "value": 45.2, "weighting": 0.85 } ] } ``` --- ### `NymeaEnergy.SetLoadDerogation` **Une dérogation n'est pas un mode.** Elle se superpose au mode courant, sert la charge à son **maximum**, financée au **réseau**, en **tête de passe éco** — devant toute autre charge, quel que soit son rang — et **s'efface d'elle-même**. C'est le « boost » côté client. Elle ne passe donc **pas** par `SetChargingInfo`, qui écrit un réglage durable. **Params :** | Champ | Type | Description | |---|---|---| | `loadId` | String | Charge visée (`ThingId` pour une borne, `id` de `LoadConfig` sinon) | | `durationS` | Int | **Obligatoire**, borné `[60, 21600]`. C'est le **filet**, et il reste armé même quand `targetWh` est donné | | `targetWh` | Double | *(optionnel)* Énergie livrée **depuis la pose**. `0` ou absent = pas de cible | > **La durée est obligatoire, et c'est la règle du lot** : une dérogation qui ne sait pas dire > quand elle finit ne devrait pas exister (`LM-1500`). Elle reste armée même avec une cible, > parce qu'une cible repose sur un **instrument** et qu'un instrument peut se taire — à la perte > du lien Modbus, la V2C **fige** `sessionEnergy` au lieu de l'effacer. > **Une énergie, jamais un SoC.** Il n'y a pas de communication véhicule ; « 80 % » demanderait la > capacité, l'état de santé et le rendement (`LM-1009`). **Ce qui la borne** : l'autorisation de soutirage (`DRAW_CAP`) et le délestage — **elle prend la tête de la passe éco, elle ne surplombe pas la sûreté**. Servie la première *dans ce qui reste*, jamais au-delà. Le motif publie `boundBy: "drawCap"` quand le plafond a mordu. **Le mode de charge précédent est mémorisé et restitué à la fin.** Si la borne était en `ChargingModeNormal` (manuel, donc hors arbitrage), elle est basculée en éco pour la durée — seul cas où le mode est écrit — puis **rendue à `Normal`**. **Returns :** `{ "energyError": "EnergyErrorNoError" }` — `EnergyErrorInvalidParameter` si la charge est inconnue ou la durée hors bornes. --- ### `NymeaEnergy.ClearLoadDerogation` Lève une dérogation **avant son terme** et restitue le mode mémorisé. La fin est publiée avec la cause `cancelled` — une fin **par décision**, distincte des quatre fins par mécanisme. **Params :** `{ "loadId": "{...}" }` **Returns :** `{ "energyError": "EnergyErrorNoError" }` — `EnergyErrorInvalidParameter` si aucune dérogation n'était en cours sur cette charge. --- ## Notifications S'abonner via `JSONRPC.SetNotificationStatus` avec le namespace `"NymeaEnergy"`. --- ### `NymeaEnergy.PhasePowerLimitChanged` ```json { "phasePowerLimit": 25 } ``` --- ### `NymeaEnergy.AcquisitionToleranceChanged` ```json { "acquisitionTolerance": 0.5 } ``` --- ### `NymeaEnergy.BatteryLevelConsiderationChanged` ```json { "batteryLevelConsideration": 0.9 } ``` --- ### `NymeaEnergy.LockOnUnplugChanged` ```json { "lockOnUnplug": true } ``` --- ### `NymeaEnergy.ChargingInfoAdded` Émis quand une nouvelle borne est détectée et sa `ChargingInfo` créée. ```json { "chargingInfo": { ... } } ``` --- ### `NymeaEnergy.ChargingInfoRemoved` Émis quand une borne est supprimée. ```json { "evChargerThingId": "uuid-de-la-borne" } ``` --- ### `NymeaEnergy.ChargingInfoChanged` Émis à chaque modification de la `ChargingInfo` (config ou état). ```json { "chargingInfo": { ... } } ``` --- ### `NymeaEnergy.ChargingSchedulesChanged` Émis à chaque recalcul du planning (cycle ~1 min), **et** à chaque transition du mode dégradé L2 (watchdog fraîcheur compteur). ```json { "chargingSchedules": [ ... ], "o:degradedMode": false } ``` - `degradedMode` *(bool, optionnel — [ETM])* : `true` quand le compteur est muet depuis > 90 s et que les consignes de repli L2 sont actives (planification suspendue, ECS coupé, EV en charge clampé au minimum). Repasse à `false` au retour du compteur. Champ additif : les clients antérieurs l'ignorent. Détail : `docs/SAFETY.md` §L2. --- ### `NymeaEnergy.LoadTelemetryChanged` Émise quand l'état runtime d'arbitrage a **changé significativement**, **et** périodiquement (battement de cœur, 60 s) même sans changement. Charge utile **identique** à `GetLoadTelemetry` (schéma déclaré une seule fois côté plugin : deux déclarations divergeraient, et c'est l'appel de reprise à froid qui casserait). **« Changé significativement »** = mode dégradé, jeu de charges, `available`, `faultCode`, `lock.kind`, `mechanism.kind`, `decision.code`, ou variation d'allocation **> 50 W** (par charge ou sur le total). Sont volontairement **ignorés** : les secondes restantes d'un verrou (elles décroissent à chaque seconde) et les paramètres numériques d'un motif (le budget bouge à chaque cycle) — les comparer ferait notifier en permanence et le seuil ne servirait à rien. **Le battement de cœur est ce qui rend un arbitre FIGÉ détectable** : les notifications continuent d'arriver tandis que leur `timestamp` cesse d'avancer. Sans lui, « installation stable » et « moteur arrêté » sont le même silence. Toute notification relance le décompte du battement : la cadence totale reste d'environ **une notification par minute**. --- ### `NymeaEnergy.SpotMarketConfigurationChanged` Émis quand le provider actif, l'état enabled ou l'état available change. ```json { "enabled": true, "available": true, "o:providerId": "5196b3cc-b2ee-46d6-b63a-7af2cf70ba67" } ``` --- ### `NymeaEnergy.SpotMarketStatusChanged` Émis quand `enabled` ou `available` change (sans les détails du provider). ```json { "enabled": true, "available": true } ``` --- ### `NymeaEnergy.SpotMarketScoreEntriesChanged` Émis quand les cotations sont mises à jour par le provider. ```json { "spotMarketScoreEntries": [ ... ] } ``` --- ## Interfaces nymea consommées Le plugin détecte les appareils **par interface**, jamais par ThingClassId. | Interface | États lus | Actions envoyées | |---|---|---| | `evcharger` | `chargingEnabled`, `maxChargingCurrent`, `pluggedIn`, `charging`, `currentPhaseA/B/C`, `currentPowerPhaseA/B/C` | `setChargingEnabled`, `setMaxChargingCurrent` | | `electricvehicle` | `batteryLevel`, `maxChargingCurrent`, `capacity`, `minChargingCurrent`, `phaseCount` | — | | `rootmeter` / `energymeter` | `currentPowerPhaseA/B/C`, `currentPhaseA/B/C` | — | | `energystorage` | `currentPower`, `batteryLevel` | — | **Déclencheur du cycle :** signal `PowerBalanceEntryAdded` de `nymea-experience-plugin-energy` (~1 min). **Déclencheur overload :** signal `EnergyManager::powerBalanceChanged` (temps réel, découplé du cycle). --- ## EnergyManagerConfiguration Paramètres de tuning chargés **une seule fois au démarrage** depuis un fichier JSON. Pas de setters — un changement nécessite un redémarrage du daemon nymea. **Chemin (ordre de priorité) :** 1. `$NYMEA_ENERGY_MANAGER_CONFIG` (variable d'environnement) 2. `/var/lib/nymea/energy-manager-configuration.json` 3. Valeurs par défaut si aucun fichier trouvé **Format JSON :** ```json { "chargingEnabledLockDuration": 300, "chargingCurrentLockDuration": 10, "minimumScheduleDuration": 15, "spotMarketChargePredictableEnergyPercentage": 0.5 } ``` | Paramètre | Défaut | Unité | Rôle | |---|---|---|---| | `chargingEnabledLockDuration` | 300 | secondes | Anti-flapping : durée de verrouillage après changement ON/OFF | | `chargingCurrentLockDuration` | 10 | secondes | Anti-flapping : durée de verrouillage après changement de courant | | `minimumScheduleDuration` | 15 | minutes | Durée minimale d'un créneau spot market planifié | | `spotMarketChargePredictableEnergyPercentage` | 0.5 | ratio [0–1] | Fraction de l'énergie spot considérée "prédictible" dans le planning | --- ## Flux interne — `SmartChargingManager` ### Entrées de données ``` EnergyManager::logs()::powerBalanceEntryAdded (SampleRate1Min) └─→ update(now) ← cycle principal ~1/min EnergyManager::powerBalanceChanged ← temps réel └─→ verifyOverloadProtection(now) ← safety loop immédiate, découplée du cycle RootMeter (wraps Thing interface=rootmeter/energymeter) ├── currentPower() ← total W (négatif = surplus / export) ├── currentPowerPhaseA/B/C() ← W par phase └── currentPhaseA/B/C() ← A par phase ThingManager → interface "energystorage" ├── batteryLevel ← % (moyenne de tous les stockages) └── currentPower ← W total (+ = charge, − = décharge) EvCharger → interface "evcharger" + "electricvehicle" ├── currentPower(), maxChargingCurrent(), phaseCount() ├── meteredPhases() ← phases réelles via currentPhaseA/B/C live └── car: batteryLevel, capacity, minChargingCurrent, phaseCount ``` ### Pipeline `update()` — exécuté à chaque cycle ``` update(currentDateTime) │ ├─ 1. updateManualSoCsWithoutMeter() │ Estime le SoC à partir de l'énergie intégrée si pas de compteur sur le VE. │ ├─ 2. prepareInformation() │ - Filtre les EV chargers actifs (plugged, car assignée, mode ≠ Normal) │ - Calcule par charger : phases effectives, SoC, temps restant, phaseLimitPower │ - Reset m_chargingActions à OFF/minCurrent pour les 3 issuers │ ├─ 3. verifyOverloadProtection() │ - Lit rootMeter->currentPowerPhaseA/B/C() │ - Si une phase dépasse phasePowerLimit × 230 W → throttle immédiat (issuer=OverloadProtection) │ ├─ 4. verifyOverloadProtectionRecovery() │ - Ré-active les chargers throttlés si la marge est suffisante │ ├─ 5. planSpotMarketCharging() │ - SpotMarketManager::scheduleChargingTime() → TimeFrames (créneaux bon marché) │ - Remplit m_chargingSchedules + m_chargingActions[SpotMarket] = ON │ ├─ 6. planSurplusCharging() │ - currentLoad = rootMeter->currentPower() │ + correction batteries (fromBatteries) │ + puissance ajoutée dans ce cycle (addedPower) │ - allowanceInAmpere = −currentLoad / 230 │ - Si allowance ≥ minCurrent × acquisitionTolerance → chargingActions[Surplus] = ON │ └─ 7. adjustEvChargers() Applique la décision finale par priorité décroissante : 1. TimeRequirement → ON au max courant disponible (deadline imminente) 2. SurplusCharging → ON au courant surplus calculé 3. SpotMarketCharging → ON au courant max dans le créneau 4. EcoWithMinCurrent fallback → ON à 6 A (EcoMinChargingCurrent) 5. Idle → OFF │ ├── executeChargingAction() │ └─→ Thing::executeAction(setChargingEnabled, setMaxChargingCurrent) │ ├── emit chargingInfoChanged() ← met à jour ChargingState (lu par JSON-RPC) └── emit chargingSchedulesChanged() ← planning rafraîchi (lu par JSON-RPC) ``` ### Persistance des settings utilisateur | Données | Fichier | |---|---| | `phasePowerLimit`, `acquisitionTolerance`, `batteryLevelConsideration` | `EnergySettings` (QSettings INI, `energy.conf`) | | `ChargingInfo` par charger (mode, endDateTime, repeatDays, etc.) | même `EnergySettings`, groupe `ChargingInfos/` | | `lockOnUnplug` | `/var/lib/nymea/energy.conf` (QSettings séparé) | | SpotMarket `enabled`, `providerId` | `EnergySettings` (géré par `SpotMarketManager`) | ### Point d'injection pour `powersync-optimizer` Le point naturel est **entre `prepareInformation()` et `adjustEvChargers()`**. L'optimizer reçoit les données de contexte (`SurplusData`) et retourne une `ChargingAction` qui remplace ou complète les actions calculées localement. Voir `PowerSyncClient::requestOptimization()` dans `etm/`. --- ## Notes d'intégration - Les timestamps sont des `QDateTime` sérialisés ISO 8601 avec timezone. - Les puissances sont en **Watts**, les courants en **Ampères**. - `phasePowerLimit` est en **Ampères** (par phase), pas en Watts. - `weighting` des `ScoreEntry` : 1.0 = créneau le moins cher, 0.0 = le plus cher. - `SetChargingInfo` **n'est PAS partiel** : l'objet remplace le stocké, et tout champ writable omis retombe sur son défaut (`targetPercentage` → 80, `repeatDays` → vide, …), sans erreur. Lire avec `GetChargingInfos`, modifier, renvoyer complet. `evChargerId` est toujours requis. - `currentPower` du root meter est **négatif** quand il y a surplus solaire (export réseau). - Le plugin fonctionne sans `powersync-optimizer` (mode Community, dégradé proprement).