VERDICT SUR L'ÉCHO, sur une marque FRAÎCHE et sans rien déplacer avant : écho verbatim inchangé -> EnergyErrorNoError, configuration identique, marque survivante ; écho verbatim + un renommage ailleurs -> EnergyErrorNoError. Nos deux relevés contradictoires s'expliquent : le premier essai suivait le déplacement qui avait fait tomber la marque, l'app écrivait donc un "auto" que le persisté ne portait plus — cas de l'écho périmé, refus correct. sansRankOriginAuto() peut partir ; relire avant de réécrire le remplace. CORRECTION LOURDE — le brief promettait à l'app l'inverse de la vérité. « Supprimer l'entrée d'une borne dont le Thing existe encore la fait recréer au cycle suivant » est FAUX : mesuré sur .75, entrée retirée, Thing vivant, rien recréé en 140 s. syncAdapters() n'appelle provisionEvChargerConfig() que dans la branche !m_adapters.contains(id), et l'adaptateur est construit depuis le Thing — il survit à la suppression de l'entrée. Ce que ça produit est le trou de 3g-2 rouvert par le côté client : la borne reste ARBITRÉE mais ABSENTE de GetLoadConfig, à un rang de queue non réglable (evPriority() → 1000). Il ne se referme qu'au redémarrage de nymead. Un correctif est proposé — reprovisionner toute borne sans entrée, pas seulement à la naissance de l'adaptateur — et NON écrit : c'est un changement de comportement, la décision appartient à Patrick. LM-1209-d — le rang est un ordre TOTAL, les domaines ne le partitionnent pas. L'insertion en queue entrelace les domaines ; la contiguïté n'a jamais été un invariant et rien dans le moteur n'en a besoin. L'exiger serait CONTRADICTOIRE avec la raison même de LM-1209 : insérer une charge heating en respectant la contiguïté décalerait toutes les ev suivantes, soit une détection qui réordonne une installation qu'un humain a classée. Le même raisonnement vaut côté client : grouper à l'affichage oui, à l'écriture non — et la note d'entrelacement ne devrait pas être un avertissement, une liste entrelacée n'ayant rien d'incohérent. UN REFUS ARRIVE EN status: "success" — le verdict est dans params.energyError. Porté en tête d'INTERFACE.md avec l'extrait qui va bien : une sonde qui teste status annonce « accepté » sur une écriture rejetée, 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. Banc rendu intact : 5 charges, rangs 1-5, marque "auto" en place au rang 5. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
1202 lines
55 KiB
Markdown
1202 lines
55 KiB
Markdown
# 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 »).
|
||
|
||
> **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` | Recharge immédiate au maximum disponible |
|
||
| `ChargingModeEco` | Recharge sur surplus solaire uniquement |
|
||
| `ChargingModeEcoWithTargetTime` | Eco + deadline de fin (`endDateTime` + `targetPercentage`) |
|
||
| `ChargingModeEcoWithMinCurrent` | Eco + courant minimum garanti (6 A) si pas de surplus |
|
||
| `ChargingModeEcoMinWithTargetTime` | Eco + courant minimum + deadline |
|
||
|
||
### `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 }
|
||
```
|
||
> `0` signifie non configuré — tout le smart charging est désactivé.
|
||
|
||
---
|
||
|
||
#### `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)
|
||
"funding": "surplus", // o: — « surplus » ou « grid », voir plus bas
|
||
"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.
|
||
>
|
||
> **`funding` (`o:`, depuis 3g-1)** — `"surplus"` ou `"grid"`. Il existe parce que les bornes
|
||
> peuvent être dans `loads[]` pour deux raisons différentes : la **cascade** (surplus, comptée
|
||
> dans `budget.allocatedW`) ou le **proxy** — échéance de départ, tarif dynamique — qui soutire
|
||
> au réseau et dont la puissance vit dans `budget.evReservedW`. Sommer `allocatedW` sans
|
||
> distinguer les deux donne un écart qu'aucun client ne peut interpréter : défaut, ou borne
|
||
> servie au réseau ? **Omis en mode dégradé** : sans plan, il n'y a pas de financement.
|
||
>
|
||
> `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 |
|
||
| `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 |
|
||
|
||
---
|
||
|
||
### 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
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 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).
|