Patrick Schurig c2f08cb804 fix(3g-1): une borne sans véhicule branché est hors arbitrage
Trouvé au banc le 2026-08-27, en exerçant 3g-2 (docs/RELEVE_3g2.md §3.1).

Une wallbox en Eco, voiture assignée, mais `pluggedIn: false`, s'est vu allouer
4 853 W qu'elle ne pouvait pas tirer — `currentPower: 0`, `charging: false`. La
PAC derrière elle n'a survécu que sur son propre recrédit. Trois cycles
consécutifs : 2 350 / 4 853 / 5 464 W retirés du budget et donnés à personne.

evParticipatesInWaterfall() reprenait le filtre d'entrée du proxy — voiture
assignée, mode non manuel — mais pas `pluggedIn`. Le proxy n'en avait pas
besoin : son allocation n'était pas une cascade, donc gaspiller ne privait
personne. En cascade, si. L'asymétrie était déjà visible dans le code : la
boucle EV du scheduler, qui sert les financements réseau, teste
`!ev->pluggedIn()` depuis toujours ; seul le chemin waterfall ne le faisait pas.

C'est le défaut du relevé 3g-1 §3.4 par une autre porte : là le plancher
mentait, ici c'est le filtre d'entrée. Défaut de 3g-1, révélé par 3g-2 parce
que la décision atteint enfin le matériel — et corrigé à part du lot, comme les
quatre précédents.

Hors arbitrage, et non « servie à 0 avec un motif » : même famille que les deux
autres exclusions (DESIGN_3g §4). Une borne débranchée ne consomme rien, sa
consommation nulle est déjà au compteur, et lui donner un rang la ferait
apparaître dans la cascade pour n'y rien faire. Le journal l'annonce comme les
deux autres cas.

testUnpluggedChargerTakesNoBudget, VÉRIFIÉ ÉCHOUANT SANS LE CORRECTIF : la
borne débranchée disparaît du contexte et la charge suivante reçoit les 3 000 W
entiers ; la seconde moitié rebranche le véhicule pour prouver que c'est bien
`pluggedIn` qui décide, et non une exclusion fortuite.

Suite complète : simulation 28/28, charging 16/16, loadmodel 16/16,
spotmarket 7/7. Build amd64 0/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
2026-08-27 12:52:21 +02:00

1046 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
>
> **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` — 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 produit une **absence de mesure**, pas une configuration
invalide.
- **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.
**`measuredW`** *(o:)* — puissance lue sur le compteur dédié, quand la charge en déclare un.
Publiée **à côté** de `allocatedW`, jamais fondue dedans : `allocatedW` est ce que le waterfall
a **alloué**, `measuredW` ce qu'un compteur a **vu**. Les confondre rendrait la somme des
allocations irréconciliable avec `budget.allocatedW`. Absente si la charge ne déclare pas de
compteur, ou si le Thing ne porte pas l'état.
**`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).