Constat d'origine, rejoué et daté au banc : 90 s d'écoute, arbitre actif, ~2 kW exportés
→ ZÉRO notification NymeaEnergy.*. L'état runtime de l'arbitre (allocation, budget, verrous,
paliers, disponibilité, motifs) ne franchissait aucune frontière RPC : il vivait exclusivement
dans qCInfo(dcNymeaEnergy). Expliquer les décisions au client n'avait donc aucun transport.
Après déploiement, même geste : 13 notifications en 480 s.
Aucune phrase composée par la box. La locale nymea est PAR CONNEXION (JSONRPC.Hello o:locale),
et NymeaEnergyJsonHandler est un JsonHandler sans pluginId — donc hors du catalogue de
traduction : rien ne rattraperait une phrase française émise là. La box transporte un CODE et
des PARAMÈTRES ; la phrase est fabriquée par le client.
LoadAction::reason devient DecisionReason{code, params}. La ligne française du journal est
RENDUE depuis ce couple par renderFr(), source unique — jamais maintenue en parallèle. Les 19
codes sont EXTRAITS des sites existants, pas inventés ; les motifs d'ECS-309 et ECS-309-b sont
repris mot pour mot. Prouvé par comparaison de traces (1726 lignes de journal identiques en
simulation), par une table d'or qui échoue au premier code sans phrase attendue, et au banc sur
7 motifs distincts. Le libellé de la charge n'entre pas dans la charge utile : il est substitué
au RENDU, par l'appelant qui le connaît.
Les verrous sortent en SECONDES RESTANTES, pas en watts. 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. Le mécanisme sort en union discriminée
(relay|variable|sgReady), toutes clés internes optionnelles au schéma (LM-302).
ILoadAdapter::runtimeView() est PURE VIRTUELLE à dessein — mais la pure virtuelle force à
RÉPONDRE, pas à répondre quelque chose. testRuntimeViewIsSubstantial() est le second verrou :
les quatre adaptateurs, amenés dans un état non trivial, doivent rendre les valeurs runtime
réelles, les verrous en secondes ET leur expiration, et les codes de défaut. Deux mutants
« vue vide » introduits puis retirés : le test échoue sur chacun.
budget publié : surplusW, evReservedW, recreditedW, allocatedW, remainingW, avec l'identité
surplus + recrédit − alloué == restant. recreditedW n'est pas décoratif : sans lui l'identité
serait fausse DÈS QU'UNE CHARGE CONSOMME, et le client chercherait un écart inexplicable. Le
banc l'a montré aussitôt — surplusW −250 W avec recreditedW 3000 W — rendant lisible l'artefact
de banc documenté dans AGENTS.md (relais sans mesure + rootmeter aveugle).
Champs omis, jamais nuls : timestamp absent = aucun cycle depuis le démarrage ; budget absent =
mode dégradé, planification suspendue. Publier un zéro se lirait « exécuté, rien à allouer ».
Cadence : changement qualitatif ou variation d'allocation > 50 W, plus un battement de 60 s.
C'est le battement qui rend un arbitre FIGÉ détectable — les notifications continuent tandis que
le timestamp cesse d'avancer. Observé 3 fois au banc. Volume mesuré : ≈ 1,7 Mo/jour, un client.
GetChargingSchedules rend enfin degradedMode : il était POUSSÉ en notification seule, donc
illisible par un client connecté après la bascule (famille ECS-411). Règles absolues 11 et 12
ajoutées à AGENTS.md.
LoadConfig : champ optionnel domain (ecs·ev·heating·hvac·battery), PURE métadonnée d'intention
que l'arbitre ne lit pas. Absente ou vide = non classée. Forme exacte découverte au banc et
corrigée dans la doc ET le test : GetLoadConfig sérialise via le méta-objet, donc la propriété
sort TOUJOURS, à "" — comme sgReady:{} ou powerLevels:[]. C'est le corollaire LM-302.
Règle 7-c, deux silences fermés : les retours idempotents du RelayRouter et du SgReadyAdapter
étaient MUETS — « consigne reçue, palier déjà bon » ne se distinguait pas de « l'action n'est
jamais parvenue à l'adaptateur », et c'est le cas ordinaire qu'on vient vérifier.
⚠️ Porté au contrat : loads[] n'est PAS en correspondance 1:1 avec GetLoadConfig. « pac-terrain »
est la PAC SG-Ready CODÉE EN DUR d'energypluginnymea.cpp (K1/K2 = Relay4/Relay5, {3:1500,
4:3000}) — arbitrée et publiée, mais inconfigurable : ni rang, ni domain, ni enabled:false.
INTERFACE.md porte la règle de détection pour l'app. Son sort reste À TRANCHER (RELEVE_LOTB §8-bis b).
Déploiement : la recette de TEST_TERRAIN.md §b portait un glob « dpkg -i /tmp/…_*_arm64.deb »,
de la même famille que celui qui a rétrogradé .75 de +etm12 à +etm9. Remplacée par
etm-powersync-deploy/deploy.sh (archivage avant dépôt, installation par nom, refus de
rétrogradation, contrôle de version après coup).
Vérifié sur .75 (1.15.2+etm13 puis +etm14) : critères 1, 2, 4, 5, 6, 7, 8 du brief. Critère 3
(épisode dégradé) vérifié en simulation seulement — le déclencher demande de faire taire un
simulateur SunSpec partagé, hors périmètre. Relevé complet, critère par critère, avec ce qui n'a
PAS été vérifié sur la machine et pourquoi : docs/RELEVE_LOTB.md.
Suites : simulation 38/38, charging 46/46, spotmarket 32/32, y compris en build DEBUG où les
Q_ASSERT_X du validateur JSON-RPC sont actifs (le schéma déclaré correspond donc à la charge
utile émise, retours ET notifications). Cross-arm64 : 0 avertissement. Doxygen : 10, inchangé.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MfvSskkYrrFriYcj4683D
849 lines
31 KiB
Markdown
849 lines
31 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.
|
||
>
|
||
> **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.
|
||
|
||
| 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 (0=Lun … 6=Dim, liste vide = pas de répétition) |
|
||
| `targetPercentage` | `Uint` | ✓ | Niveau batterie cible en % (défaut : 80) |
|
||
| `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 champs sont ignorés.
|
||
|
||
**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.
|
||
|
||
---
|
||
|
||
### 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)
|
||
"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.** Uniquement les charges **arbitrées par le waterfall** : `relay-router`,
|
||
`etmvariableload`, `sg-ready`. Le VE n'y figure pas en beta — il est décidé par l'amont **avant**
|
||
le waterfall (décision B, `AGENTS.md`), et sa puissance est réservée dans `budget.evReservedW`,
|
||
pas allouée depuis le budget publié. L'y ajouter ferait une somme `loads[]` qui ne retomberait
|
||
pas sur `budget.allocatedW`. Il rejoindra la liste avec l'étape **3g**.
|
||
|
||
#### ⚠️ `loads[]` n'est PAS en correspondance 1:1 avec `GetLoadConfig`
|
||
|
||
**Une charge peut être arbitrée et publiée ici sans exister dans `GetLoadConfig`.** Un client
|
||
qui suppose l'inverse affichera une charge sans nom, sans domaine et sans écran de réglage — ou
|
||
plantera en cherchant sa configuration.
|
||
|
||
Cas réel, constaté sur le banc `.75` le 2026-08-25 :
|
||
|
||
| | `GetLoadConfig` | `GetLoadTelemetry` |
|
||
|---|---|---|
|
||
| `chauffe-eau` (relay-router) | ✔ | ✔ |
|
||
| `pac-terrain` (sg-ready) | **absent** | ✔ |
|
||
|
||
`pac-terrain` est une PAC SG-Ready **codée en dur** dans `energypluginnymea.cpp` (ThingIds
|
||
K1/K2, états `{3: 1500 W, 4: 3000 W}`, `minStateHoldS: 300`), enregistrée par
|
||
`registerSgReadyAdapter()` hors de toute configuration. Elle est **pleinement arbitrée** — elle
|
||
consomme du budget, elle porte des verrous, elle apparaît dans la télémétrie — mais elle est
|
||
**inconfigurable** : ni renommage, ni rang, ni `domain`, ni `enabled: false`.
|
||
|
||
**Règle pour le client :** `loadId` est la clé. Une entrée de `loads[]` dont le `loadId` n'a pas
|
||
d'équivalent dans `GetLoadConfig` est une **charge non configurable** :
|
||
- afficher son `loadId` comme identité (il n'y a pas de libellé à en tirer) ;
|
||
- la traiter comme **non classée** (aucun `domain` ne peut lui être attaché) ;
|
||
- ne proposer **aucun** écran de réglage pour elle.
|
||
|
||
Ce n'est pas une propriété permanente du contrat : c'est l'état actuel du banc, hérité de
|
||
l'époque où le mécanisme `sg-ready` n'était pas configurable (il l'est depuis LM-300). La
|
||
décision de la faire entrer dans la configuration — ou de la faire disparaître — n'est pas
|
||
prise. Elle est suivie dans `docs/RELEVE_LOTB.md` §9. **Prévoir le cas, ne pas le supposer
|
||
transitoire.**
|
||
|
||
---
|
||
|
||
#### 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` | — | Pas de surplus, recharge suspendue |
|
||
| `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** |
|
||
| `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 (normal, mains off) |
|
||
| `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) |
|
||
|
||
> `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.
|
||
|
||
**`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` est partiel : seuls les champs présents sont appliqués, sauf `evChargerId` qui 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).
|