Patrick Schurig ccf05cea87 feat(R6): la mesurabilité se déclare à la saisie, et l'inventaire du mock
Le signal vit dans GetLoadConfig, en r:, et non dans la télémétrie.
`measurement` n'existe que pour les charges arbitrées ; or une charge qu'on
DÉCLARE est précisément celle qui n'y est pas encore. L'écran serait muet au
moment exact où il doit parler — celui de la saisie, avant qu'une obligation
n'achète à l'aveugle tous les jours.

Trois causes, trois gestes : noMeter se répare en une manipulation,
meterWithoutEnergy demande de remplacer le compteur, noSessionEnergy ne se
répare pas. Sans la cause, l'avertissement ne peut dire que « ça ne marchera
pas ».

Et meterThingId ne permet pas de le déduire : un compteur peut publier
currentPower sans totalEnergyConsumed — mesuré sur l'ECS du banc, reproduit
au test avec un powerSwitch désigné comme compteur. La puissance ne suffit
pas, il faut une énergie. Contre-épreuve : déduction par meterThingId seul,
le cas du banc passe pour mesurable.

INVENTAIRE À FROID DU MOCK. Trois fois en trois jours qu'un cas réel s'est
révélé inatteignable ; ce n'est plus un incident, c'est une propriété du banc
— il est plus homogène que la réalité, ayant été construit pour faire marcher
des scénarios et non pour représenter la diversité du parc.

Quatre cas restent structurellement introuvables, dont `source: none` pour
une charge SAINE — toutes les classes pilotables portent une puissance, si
bien qu'une charge non mesurée mais en bon état est impossible à fabriquer,
alors qu'un relais sec est banal sur le terrain. Et la branche `temperature`,
écrite et jamais exécutée faute d'une classe qui porte l'état.

La règle : avant d'écrire un test qui exerce une ABSENCE, vérifier que le
mock peut la produire. Une assertion sur un cas inatteignable ne passe pas,
elle NE S'EXÉCUTE PAS — ce qui est pire, car elle compte comme verte. Le
symptôme se reconnaît : la contre-épreuve ne mord pas.

Deux ajouts dans AGENTS.md. L'asymétrie des latences est une PROPRIÉTÉ, pas
une chance : le refus qui coûte de l'argent est celui de la descente, et
c'est le plus vite constaté — ce qui n'est vrai que parce que le seuil vient
du mécanisme. Et la question opératoire appliquée aux trois autres identités,
avec un résultat inégal qui est le but : celle de `draw` repose sur le fait
que le compteur voie toute la maison, ce que rien ne vérifie ;
Σ levels[].targetW == estimatedPowerW est interne au plan et ne dit rien du
matériel — il a fallu un champ hors de la formule pour relier les deux.

Suite : simulation 61/61, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-01 17:27:56 +02:00

80 KiB
Raw Blame History

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.

rep = call('NymeaEnergy.SetLoadConfig', {...})
ok  = rep['params']['energyError'] == 'EnergyErrorNoError'   # ← le verdict est ICI
#     rep['status'] == 'success'  ne dit QUE « la requête a été traitée »

C'est la convention JSON-RPC de nymea, pas une particularité de ce plugin — mais toute sonde écrite à la va-vite retombe dedans, et une sonde qui se trompe de champ produit un relevé qui a l'air d'un résultat. Même famille que le journal lu par un sudo qui échoue en silence (AGENTS.md, « Lire le journal de la box »).

⚑ UN CLIENT TESTE LA PRÉSENCE D'UN CHAMP, JAMAIS UNE VERSION

(Règle posée par l'agent app le 2026-08-30, en déplaçant son exception EV_GRID_START : il ne l'a pas retirée, il l'a mise sur la bonne frontière — elle survit là où la charge utile n'a pas de counts, c'est-à-dire sur les installations encore en +etm2x.)

Son argument, et il vaut pour tout ce qui sera ajouté ici : ce qui a rendu l'exception caduque n'est pas une version, c'est une charge utile. Un champ présent décrit ce que la box sait faire ; un numéro de version décrit ce qu'on croit qu'elle sait faire. La première frontière est stable, la seconde ne l'est pas — une box peut être rétrogradée, un paquet reconstruit, une branche déployée hors séquence.

Conséquence pour ce contrat : chaque ajout se conçoit pour être détectable par sa présence, et chaque absence porte un sens écrit. C'est déjà le cas de levels[] (absent = box antérieure ou mode dégradé), measurement.source ("none" publié plutôt que la clé retirée), draw (absent = pas de plan), rankOrigin (absent = rien d'affirmé) et decision.level (présent exactement quand levels[] l'est).

Les numéros de version cités dans ce document datent les changements — ils ne sont pas des conditions à tester.

Namespace : NymeaEnergy Versions enregistrées : 0–8 (registerExperienceHandler(..., 0, 8)) Transport : WebSocket JSON-RPC 2.0, port 4444 (nymea standard)


Enums

ChargingMode

Valeur Description
ChargingModeNormal 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 :

{ "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 :

{ "phasePowerLimit": 25 }

Returns :

{ "energyError": "EnergyErrorNoError" }

Paramètres de recharge

NymeaEnergy.GetAcquisitionTolerance

Retourne le seuil de surplus nécessaire pour démarrer une recharge Eco.

Params : aucun

Returns :

{ "acquisitionTolerance": 0.5 }

Valeur entre 0.0 et 1.0. Plus la valeur est haute, plus il faut de surplus.


NymeaEnergy.SetAcquisitionTolerance

Params :

{ "acquisitionTolerance": 0.5 }

Returns :

{ "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 :

{ "batteryLevelConsideration": 0.9 }

0.0 = stockage ignoré, 1.0 = surplus réduit intégralement de la puissance de charge batterie.


NymeaEnergy.SetBatteryLevelConsideration

Params :

{ "batteryLevelConsideration": 0.9 }

Returns :

{ "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 :

{ "lockOnUnplug": false }

NymeaEnergy.SetLockOnUnplug

Params :

{ "lockOnUnplug": true }

Returns :

{ "energyError": "EnergyErrorNoError" }

Configuration EV

NymeaEnergy.GetChargingInfos

Retourne la configuration de recharge de toutes les bornes, ou d'une seule.

Params :

{ "o:evChargerId": "uuid-de-la-borne" }

Returns :

{
  "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 :

{
  "chargingInfo": {
    "evChargerId": "uuid-de-la-borne",
    "chargingMode": "ChargingModeEcoWithTargetTime",
    "endDateTime": "2025-06-15T08:00:00+02:00",
    "targetPercentage": 80
  }
}

Returns :

{ "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 :

{
  "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.)

{ "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.

{ "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é.

{
  "timestamp": "2026-08-25T14:32:07Z",   // fin du DERNIER cycle d'arbitrage — o:, voir plus bas
  "degradedMode": false,
  "budget": {                            // o: — absent en mode dégradé, voir plus bas
    "surplusW":    8348,                 // budget entrant du waterfall non-EV (net signé − réservation EV)
    "evReservedW":    0,                 // puissance EV commandée ce cycle, pas encore vue au compteur
    "recreditedW":  1500,                // recrédits anti-clignotement rendus aux charges (correction B)
    "allocatedW":  3500,                 // somme des allocations du cycle
    "remainingW":  6348                  // résidu de fin de cascade
  },
  "loads": [
    {
      "loadId":     "ecs-chauffe-eau",
      "allocatedW": 3500,                       // ce que le waterfall a ALLOUÉ (pas la mesure)
      "levels": [                               // une entrée par passe RÉELLEMENT parcourue
        { "level": "comfort", "targetW": 3500, "funding": "surplus" }
      ],
      "available":  true,
      "faultCode":  "WRITE_FAILED",             // o: — absent = aucun défaut
      "lock":     { "kind": "minOff", "remainingS": 42 },   // o: — absent = aucun verrou actif
      "mechanism": { "kind": "relay", "stageW": 3500, "maxStageW": 4500 },   // union discriminée
      "decision": { "code": "LOCK_MIN_OFF",
                    "params": { "budgetW": 8348, "requestedW": 3500 } }
    }
  ]
}

Identité vérifiable : surplusW + recreditedW − allocatedW == remainingW. recreditedW n'est pas décoratif : le waterfall rend à chaque charge sa propre consommation de début de cycle avant d'arrondir (anti-clignotement, AGENTS.md correction B). Sans ce terme, surplus − alloué == restant serait faux dès qu'une charge consomme déjà, c'est-à-dire dans le cas normal.

Champs omis, jamais nuls (règle maison, cf. GetEnergyRatios) :

  • timestamp absent = aucun cycle d'arbitrage depuis le démarrage du service. En forger un se lirait « cycle exécuté, rien à allouer » — exactement le mensonge que ce champ existe pour empêcher. Présent, c'est la fin du dernier cycle : c'est la preuve de fraîcheur.
  • budget absent = mode dégradé L2 : la planification est suspendue, il n'existe aucun budget pour ce cycle. Publier des zéros se lirait « arbitrage exécuté, rien à allouer ».

Charges présentes. Toutes les charges arbitrées par le waterfall : relay-router, etmvariableload, sg-ready — et, depuis 3g-1, les bornes de recharge.

Changement de contrat en 3g-1. Le VE ne figurait pas dans loads[] : il était décidé en amont, et sa puissance seulement réservée dans budget.evReservedW. Il y figure désormais, son allocation décrémente la cascade comme celle de n'importe quelle charge, et la somme des allocatedW des charges financées au surplus retombe sur budget.allocatedW — ce qui n'était pas vrai avant.

levels[] — une entrée par passe RÉELLEMENT parcourue, et c'est là que vit le financement. (R2 de la maquette des deux passes, 2026-08-29.)

Clé Sens
level "eco" ou "comfort" — quel BESOIN cette entrée sert
targetW ce que le niveau a DÉCIDÉ (R4) — à ne pas confondre avec mechanism.*, qui dit ce qui a été appliqué
funding "surplus" ou "grid" — l'ORIGINE des watts de ce niveau. "grid" dès qu'un seul watt est acheté (LM-1013)
counts { destination → watts } — le REGISTRE où ils sont comptés. Σ counts == targetW, toujours

R1 — levels[] ne contient que les passes RÉELLEMENT parcourues, et l'absence n'est pas le zéro. Les deux moitiés se répondent, et publier l'une sans l'autre les rendrait indistinguables :

niveau absent la passe n'a pas été parcourue — une charge sans obligation éco n'aura jamais d'entrée "eco"
niveau à targetW: 0 la passe a été parcourue, elle n'avait rien à prendre — la charge a été examinée, et son motif le dit

Un zéro forgé à la place d'une absence détruirait la première information ; une absence à la place d'un zéro détruirait la seconde. Aujourd'hui la passe unique est toujours parcourue dès qu'une charge est arbitrée : c'est le mode dégradé qui porte le cas « aucune passe », la planification y étant suspendue — levels[] y est absent, et decision.level avec lui.

Épinglé par testLevelAbsenceAndZeroDifferInMeaning, vérifié échouant dans les deux sens.

R7 — decision.level accompagne levels[] : les deux présents, ou les deux absents. decision reste au niveau de la charge — un client qui l'a toujours lu continue de le lire — mais il reflète le motif d'un niveau. Sans le dire, chaque client réinventerait sa règle de fusion (« le motif du dernier niveau servi ? du premier refusé ? ») et deux écrans afficheraient deux motifs pour le même cycle. Aujourd'hui il n'y a qu'un niveau et la question ne se pose pas — c'est précisément pourquoi le champ arrive maintenant : quand elle se posera, les clients le liront déjà.

Aujourd'hui il y a exactement UN niveau, et c'est "comfort". La passe unique d'aujourd'hui est celle qui, au §10, servira le confort depuis le surplus ; la passe éco est la nouveauté du lot, pas la passe existante rebaptisée. Une charge sans obligation éco n'aura jamais d'entrée "eco" à zéro : l'absence est l'information, et un zéro forgé la détruirait.

levels[] entièrement absent = box antérieure à +etm30, ou mode dégradé — sans plan, il n'y a pas de financement.

⚠️ funding N'EXISTE PLUS au niveau de la charge, et il ne revient pas en résumé. La raison n'est pas la propreté : au §10 la même charge sera financée au réseau pour son éco et au surplus pour son confort dans le même cycle, et un résumé n'aurait alors aucune valeur vraie à porter — « mixte » serait un troisième code que personne n'a demandé, « grid si l'un l'est » une convention que chaque client redériverait autrement. Deux endroits pour un fait divergent.

Pourquoi decision reste, lui, au niveau de la charge : parce qu'on sait dire de quel niveau il parle (decision.level, R7). On conserve un champ hérité quand on peut en dire quelque chose de vrai, jamais par symétrie.

draw — le registre des watts ACHETÉS

Publié dans la même trame que budget : les deux décrivent le même cycle, et les lire à deux instants ferait comparer deux mondes.

// SANS plafond de soutirage réglé — quatre champs ABSENTS, jamais à 0 :
"draw": { "committedW": 1400 }

// AVEC un plafond en vigueur :
"draw": { "committedW": 1400, "authorisedW": 3450, "remainingW": 2050,
          "binding": "connection", "perPhaseBound": true }

committedW est publié TOUJOURS : le registre des watts achetés existe à chaque cycle, et zéro y est une valeur. Les quatre autres ne sont publiés QUE si un plafond est en vigueur. C'est la même règle des deux côtés — le zéro est une valeur quand la grandeur existe, l'absence en est une quand elle n'existe pas. Un authorisedW: 0 assorti d'un binding par défaut afficherait un plafond de soutirage qui n'existe pas, avec sa source.

Champ Ce qu'il dit
authorisedW Autorisation du cycle, SIGNÉE. Négative ⇒ la maison est déjà au-delà du plafond, et l'ampleur du dépassement se lit directement. L'écrêter à zéro confondrait « pile à la limite » et « on dépasse de 1200 W ».
remainingW authorisedW − committedW. L'identité tient toujours — c'est ce qui rend la charge utile vérifiable. Peut être négative : les bornes VE engagent du soutirage borné par LEUR phase, pas par la marge de la maison.
binding Code opaque de la source qui borne : connection | gridOperator | selfImposed. Ce qu'on a le droit de faire d'un dépassement en dépend.
perPhaseBound Le plafond qui borne est-il par phase ? Un plafond total n'a pas de phase coupable, et le dire évite d'envoyer mesurer au hasard sur une triphasée.

progress.purchasedWh et progress.measuredSince

"progress": { "regime": "measured", "targetWh": 4000, "deliveredWh": 500,
              "purchasedWh": 3200, "measuredSince": "2026-09-01T09:14:00Z" }

purchasedWh est publié dans TOUS les régimes, unmeasurable compris. Ce n'est pas une entorse à la règle qui interdit deliveredWh là-bas — c'en est le complément exact :

ce que c'est connu sans mesure ?
deliveredWh ce qui a été livré à la charge non — c'est une observation qu'on ne peut pas faire
purchasedWh ce qui a été acheté au réseau oui — c'est notre propre commande

Sans lui, « ce que ça coûte par jour » redeviendrait une estimation fabriquée à partir du taux. C'est un cumul sur la fenêtre d'obligation : remis à zéro à la bascule de période (LM-1014-b).

measuredSince ferme un piège d'affichage, et il faut savoir lequel

Quand une obligation bascule de unmeasurable à measured en cours de période, la base d'avancement est capturée à cet instant : deliveredWh repart de 0 pendant que targetWh reste la cible entière. Une barre afficherait alors « 0,5 sur 4,0 » après une bascule où 3 kWh ont pu être achetés — crédible, et faux.

Rebaser targetWh serait pire : on perdrait l'obligation réelle — l'utilisateur a demandé 4 kWh, pas 1 — et l'écran ne pourrait plus dire ce qui reste dû.

measuredSince prend la seconde sortie : l'écran dit « mesuré depuis 09:14 », et purchasedWh couvre la période d'avant. Les deux se répondent, et ensemble ils disent la vérité complète. Le champ est absent tant que rien n'a jamais été mesuré dans la période.

progressMeasurable / progressUnmeasurableCause — dans GetLoadConfig, pas dans la télémétrie

// GetLoadConfig → loadConfigs[]
{ "id": "…", "adapter": "relay-router", …,
  "progressMeasurable": false, "progressUnmeasurableCause": "meterWithoutEnergy" }

Pourquoi ici et pas dans loads[]. measurement n'existe que pour les charges arbitrées. Or une charge qu'on déclare est précisément celle qui n'y est pas encore — pas arbitrée, ou créée à l'instant. L'écran serait muet au moment exact où il doit parler : celui où l'installateur saisit une obligation qui, sans mesure, achètera à l'aveugle tous les jours.

Cause Ce qu'elle veut dire Le geste
noMeter aucun compteur rattaché réparable en une manipulation
meterWithoutEnergy le compteur ne cumule pas l'énergie à remplacer
noSessionEnergy la borne ne compte pas sa session rien à faire — c'est le matériel

Sans la cause, l'avertissement ne peut dire que « ça ne marchera pas ». Même structure que les trois sources de DRAW_CAP : trois causes, trois gestes.

meterThingId ne permet PAS de le déduire. Un compteur peut publier currentPower sans totalEnergyConsumed — mesuré le 2026-09-01 sur l'ECS du banc. Le champ est renseigné, le compteur existe, et pourtant rien ne cumule : la puissance ne suffit pas, il faut une énergie. Un client qui déduirait la mesurabilité de la présence du champ se tromperait exactement sur ce cas.

progressMeasurable est en r: — lecture seule. C'est un constat sur le matériel, pas un réglage : un client qui l'écrirait affirmerait quelque chose sur une ThingClass.

command — l'écart entre le commandé et le mesuré

Publié seulement quand une source mesure (measurement.source ≠ none) et qu'une commande est partie. Absent sinon — pas divergent: false, qui affirmerait une concordance qu'on ne peut pas constater. Une charge jamais commandée n'est pas une charge commandée à zéro.

"command": { "expectedW": 3680, "observedW": 800, "divergent": true, "since": "2026-09-01T…" }

Ce que l'écart vaut dépend de la source, dans les DEUX sens :

concordance divergence
device ne prouve rien — plusieurs bornes renvoient leur consigne en guise de puissance la charge n'obéit pas, et elle le dit elle-même
meter preuve — un tiers le constate trois hypothèses : la charge, le compteur, le câblage

Un écart n'est PAS un défaut, et ce champ ne le qualifie pas. Une borne qui n'obéit pas parce que le véhicule limite, parce que le câble est en 16 A, ou parce qu'elle est en mode manuel, produit le même nombre qu'une commande perdue. L'écart se publie, il ne se diagnostique pas : la cause n'est pas dans les watts. Un écran qui en ferait une alerte accuserait des installations saines.

since est ce qui rend le champ utile. Sans lui, une divergence permanente ne se distingue pas d'un déphasage d'un cycle entre l'écriture et sa mesure — et c'est toute la différence entre un fonctionnement normal et le cas relevé le 2026-08-31, où l'écart durait indéfiniment parce que plus personne ne commandait.

divergent est publié plutôt que laissé au client : la tolérance (bruit de mesure, arrondi à l'ampère, déphasage d'un cycle) est une connaissance du moteur. La laisser au client ferait réinventer un seuil par écran, et deux écrans ne diraient pas la même chose du même cycle.

Le point de rupture se lit sur DRAW_CAP, jamais sur un solde tombé à zéro

draw.remainingW à 0 dit qu'il ne reste rien à cet instant — ce qui arrive aussi quand tout a été alloué normalement, sans qu'aucun plafond n'ait rien refusé. Un écran qui guetterait ce solde signalerait une rupture là où il n'y a qu'un budget consommé, et manquerait la vraie rupture le cycle où le plafond mord sans que le solde soit exactement nul.

DRAW_CAP est le signal, et lui seul : il n'est émis que lorsqu'une décision a réellement été refusée par un plafond, et il porte laquelle — binding / source distingue le gestionnaire de réseau, la protection du branchement et la limite auto-imposée. Trois causes, trois gestes, et aucun ne se déduit d'un nombre à zéro.

⚠️ budget.remainingW == 0 et draw.remainingW != 0 dans la MÊME trame : c'est NORMAL

Sous la réserve batterie, le budget de surplus est annulé — pas réduit (LM-1203-b : servir « un peu » retarderait la recharge sans probablement démarrer personne). Mais la réserve n'annule que le surplus. L'autorisation de soutirage est une contrainte physique du branchement : aucune règle de stockage ne la modifie.

Un client verra donc, légitimement, un budget à zéro à côté d'une autorisation intacte. Ce n'est pas une incohérence, et il aurait raison de la signaler si personne ne la lui avait dite — c'est pourquoi elle est écrite ici, et non en note de bas de page. Les deux grandeurs répondent à deux questions différentes : « puis-je dépenser sans acheter ? » et « le branchement supporte-t-il que j'achète ? »

Épinglé par testDrawAuthorisationSurvivesTheBatteryReserve, qui dit aussi à quelle condition il devient faux : le jour où l'on déciderait qu'une batterie basse interdit d'acheter au réseau — une décision de modèle, pas une correction.

Ce qu'il compte : ce que le moteur a décidé d'acheter selon son modèle de financement, pas ce que le compteur verra passer. Pour une action du proxy — échéance, tarif dynamique — toute l'allocation est comptée comme achetée : l'échéance décide de charger qu'il y ait du soleil ou non, et aucun partage n'est calculé. Là où le moteur sait partager, il partage : EV_GRID_START ne verse ici que sa part réseau, sa part surplus allant dans budget.allocatedW.

Omis en mode dégradé, comme budget : sans plan, il n'y a pas de soutirage décidé.

⚠️ budget.evReservedW n'est PAS un registre — et ne se réconcilie jamais

(Définition demandée par l'app le 2026-08-30, qui l'affichait sous « Réservé au véhicule » — un libellé qui affirme une réservation là où il y a une correction.)

evReservedW est une CORRECTION du budget de surplus. Il vaut la somme des puissances commandées mais pas encore visibles au compteur pour les charges servies au réseau. Il tend vers 0 à mesure que la mesure rattrape la commande, sans qu'aucune décision n'ait changé : c'est un rattrapage de latence, pas un montant mis de côté.

budget.allocatedW budget.evReservedW draw.committedW
Nature registre d'allocation (surplus) correction de budget registre des watts achetés
Réconciliable oui non, et jamais oui
Varie sans décision non oui — décroît quand la mesure rattrape non

Ne l'additionnez à rien. Le sommer avec des allocations compare deux natures, et le total obtenu serait vrai au premier cycle après un changement, faux après convergence — donc juste dans un test portant sur un cycle isolé, et faux chez le client. Une identité dépendante du temps est pire que pas d'identité : elle porte le sceau d'une identité publiée.

Depuis 1.15.2+etm31 il n'a plus qu'UNE nature. Il recevait aussi la part réseau d'EV_GRID_START — une vraie tranche d'allocation — ce qui rendait toute définition du champ fausse pour l'un des deux termes. Cette part est passée dans draw.committedW.

Les TROIS nombres d'une charge — décidé, appliqué, mesuré

(Rendu explicite le 2026-08-30, après qu'une prémisse de la maquette app eut confondu les deux premiers. Trois nombres coexistent pour une même charge dans un même cycle, ils disent trois choses différentes, et aucun ne se déduit d'un autre.)

Où Ce qu'il dit Ce qu'un écart avec le suivant signifie
DÉCIDÉ allocatedW, et levels[].targetW ce que la cascade a alloué → l'arrondi du mécanisme : 1 400 W décidés tombent sur un palier de 1 500 W
APPLIQUÉ mechanism.stageW · setpointW · state · currentA ce qui a été commandé au matériel → la charge n'obéit pas : véhicule qui refuse, thermostat ouvert, verrou qui a mordu
MESURÉ measurement.powerW (avec sa source) ce qu'un appareil ou un compteur a vu —

⚠️ allocatedW est du DÉCIDÉ, pas du commandé. buildTelemetry() publie le plan, et applyActionsToAdapters() reçoit le slot const : rien n'est réécrit après le dispatch. Mesurable — testUnpluggedChargerTakesNoBudget attend allocatedW == 3000 sur une borne dont le courant commandé donne 2 990 W.

Conséquence : Σ levels[].targetW == allocatedW tient exactement, les deux étant du décidé. L'écart « décidé → appliqué » ne se lit donc pas entre eux, mais entre allocatedW et la charge utile de mécanisme.

Et l'arrondi ne se répartit pas entre les niveaux. Toute clé de répartition serait une fiction, dont un client hériterait comme d'un fait : l'écart appartient à la charge, pas à l'un de ses niveaux.

Pourquoi trois et pas deux. Chaque frontière répond à une question différente, et les confondre envoie diagnostiquer le mauvais problème (règle 7-b). Décidé ≠ appliqué est une affaire de format — le mécanisme ne sait pas prendre n'importe quelle valeur. Appliqué ≠ mesuré est une affaire d'obéissance — et ce que cet écart autorise à conclure dépend encore de measurement.source (§11) : sous device il accuse la charge, sous meter il ouvre trois hypothèses, sous none il n'est pas calculable.

counts — la ventilation, et pourquoi elle ne se déduit pas de funding

"levels": [
  { "level": "comfort", "targetW": 1380, "funding": "grid",
    "counts": { "budget.allocatedW": 900, "draw.committedW": 480 } }
]

funding dit d'où les watts VIENNENT, counts dit dans quel registre ils TOMBENT. Ce sont deux questions, et aucune ne se dérive de l'autre : ci-dessus, funding vaut "grid" — un watt est acheté, la règle suffit — pendant que counts porte deux destinations.

Clé Ce qu'elle compte
budget.allocatedW watts de surplus alloués par la cascade
draw.committedW watts achetés au réseau

Clés OPAQUES, jamais recyclées. Une clé inconnue de votre version s'affiche « destination inconnue » et laisse Σ counts == targetW vérifiable ; elle ne vaut jamais zéro. Le moteur s'interdit de réattribuer un identifiant à un sens nouveau — même règle que les numéros de règle retirés : recycler ferait ranger des watts dans le mauvais compteur sans qu'aucune erreur ne le signale.

Toujours publié : à une seule destination, et à zéro. counts: {} satisferait l'identité en perdant la destination, sur le cas le plus fréquent. Un zéro avec sa destination dit « cette grandeur existe et vaut 0 » ; l'absence dirait « elle n'existe pas ». C'est la même distinction que draw: {"committedW": 0} — zéro est une valeur quand la grandeur existe, l'absence en est une quand elle n'existe pas.

Qui fait foi : counts pour la comptabilité, decision.params pour la phrase du motif. params.budgetW/gridW d'un EV_GRID_START expliquent pourquoi la charge démarre malgré un budget insuffisant ; counts dit où les watts sont comptés. Si les deux divergeaient, c'est counts qui décrit le bilan.

L'identité de réconciliation — écrite ici, jamais reconstruite

Σ charges  Σ niveaux  counts["budget.allocatedW"]  ==  budget.allocatedW
Σ charges  Σ niveaux  counts["draw.committedW"]    ==  draw.committedW
Σ counts d'un niveau                               ==  niveau.targetW

Elle ne porte plus sur funding. Sommer les targetW des niveaux funding == "surplus" était juste tant qu'un niveau n'avait qu'une destination ; un EV_GRID_START la rendait fausse. La ventilation supprime le détour : on somme des compteurs, pas des origines.

Elle a changé de forme avec R2 : elle portait sur allocatedW des charges dont le funding valait "surplus", elle porte maintenant sur les niveaux. Un client qui la reconstruirait depuis l'ancienne forme divergerait au premier cas mixte — c'est-à-dire au premier cycle du §10.

EV_GRID_START n'est plus une exception depuis +etm31. Ce motif partage une allocation entre deux registres, et les deux sont désormais des registres : params.budgetW va dans budget.allocatedW, params.gridW dans draw.committedW — et budgetW + gridW == allocatedW toujours. Sa contribution au surplus reste decision.params.budgetW, pas targetW.

Le partage lui-même ne disparaîtra pas — c'est un fait, une part est achetée et l'autre non. Ce qui disparaît est l'exception : les deux moitiés tombent maintenant dans des compteurs de même nature. counts (R3) publiera ce partage par niveau plutôt que de le laisser déduire du motif.

budget.evReservedW subsiste, mais restreint : il ne compte plus que les bornes servies par un financement réseau (échéance de départ, spot market), qui restent décidées en amont. Une borne servie par la cascade y serait comptée deux fois.

Deux bornes ne se partageaient pas le budget avant 3g-1 : l'allocation était calculée une fois et offerte entière à chacune. Ce n'est plus le cas — la première est servie, la seconde reçoit le reliquat, et 0 si ce reliquat est sous son plancher.

✅ Le rang d'une borne est configurable depuis 3g-2, et il vit là où vivent tous les rangs : LoadConfig.priority (LM-1206/LM-1207). Il n'existe pas de second système de priorité — un classement propre aux bornes ne saurait pas exprimer « VE1 > ECS > VE2 », et deux classements qui ne peuvent pas s'interclasser sont un défaut, pas deux fonctionnalités. ChargingInfo ne porte donc aucun champ de rang, et n'en portera pas.

Le tri est total : à rang égal, l'identifiant départage. Laquelle est servie est donc reproductible d'un cycle à l'autre, même avant tout réglage.

(Jusqu'à 3g-1, EvAdapter::descriptor() fixait priority = 100 pour toutes : même clé de tri, std::sort non stable, et laquelle démarrait n'était ni réglable ni reproductible.)

Bornes exclues de loads[] : celles sans voiture assignée, celles en mode manuel (ChargingModeNormal), et celles sans véhicule branché. Elles sont hors arbitrage, pas servies en dernier — leur consommation est déjà au compteur racine, donc déjà dans le budget.

pluggedIn a rejoint ce filtre après le banc du 2026-08-27 : une borne débranchée ne peut rien tirer de ce qu'on lui alloue, et en cascade cette allocation est prise aux charges suivantes. 4 853 W mesurés donnés à personne (docs/RELEVE_3g2.md §3.1).

loads[] ⊆ GetLoadConfig — toute charge publiée est configurable

Tout loadId de loads[] a une entrée dans GetLoadConfig. C'est un invariant de construction, pas une discipline : depuis le lot B-bis, toute charge arbitrée est bâtie depuis la configuration persistée (LoadConfigStore), et il n'existe plus aucun chemin d'enregistrement en dur — le moteur n'a qu'une table de charges, alimentée par un seul endroit. Le client peut donc résoudre le libellé, le domain et le rang de chaque charge publiée, et lui proposer son écran de réglage.

L'inclusion ne vaut que dans ce sens. GetLoadConfig peut contenir des charges absentes de loads[] : celles déclarées enabled: false, dont le rôle est décrit mais qui sont exclues de l'arbitrage (contrat §9). Aucun adaptateur n'est construit pour elles, elles ne consomment aucun budget, elles n'ont donc aucune télémétrie à publier. Une charge configurée absente de loads[] se lit « désactivée », jamais « perdue ».

⚠️ 3g-1 avait ROMPU cet invariant, et 3g-2 le rétablit. En faisant entrer les bornes dans loads[], 3g-1 publiait des charges qui ne figuraient dans aucune GetLoadConfig — c'est-à-dire exactement la charge fantôme que le lot B-bis avait supprimée, réapparue par l'autre bout.

Depuis 3g-2, toute borne détectée reçoit d'office une entrée LoadConfig de mécanisme evcharger (voir ci-dessous). L'inclusion est de nouveau vraie par construction.

Le mécanisme evcharger dans GetLoadConfig / SetLoadConfig (3g-2)

Une entrée evcharger porte le rang, et rien d'autre :

Champ Valeur
id ThingId de la borne
adapter "evcharger"
mode "dynamic" (une borne module ; "fixed" est refusé)
domain "ev"
label, priority, enabled comme toute autre charge

Toute charge utile est refusée : relays[], sgReady, powerLevels, maxPowerW, minPowerW, minOnS, minOffS. Les limites d'une borne — courant minimal et maximal, nombre de phases, disponibilité — viennent du Thing et changent avec le véhicule branché ; les déclarer ici en ferait une seconde source de vérité, périmée dès le premier débranchement.

  • Création automatique. Une borne détectée entre en configuration sans intervention, à un rang de tête (le plus petit rang existant moins un, borné à 1) qui ne réécrit le rang de personne. C'est un point de départ de mise en service, pas une politique : l'app pose les vrais rangs. La création est journalisée, une ligne par borne, à la création seulement.
  • Aucun adaptateur n'en est construit : celui de la borne vient du Thing. Un aller-retour GetLoadConfig → SetLoadConfig verbatim ne change donc rien et ne reconstruit rien — aucun verrou n'est réarmé.
  • L'entrée survit à son Thing. Borne remplacée, ThingId changé, appareil déposé : l'entrée n'est pas supprimée — c'est une configuration, et l'installateur a pu la classer. Elle reste publiée dans loads[], available: false, faultCode: "THING_MISSING", motif LOAD_UNAVAILABLE, et ne retient aucun budget. La supprimer d'office rendrait « borne remplacée » indistinguable de « borne jamais configurée ».
  • Exclusivité. L'entrée revendique le Thing de la borne : la déclarer en plus comme charge pilotée est refusé par SetLoadConfig (deux commandeurs sur un même organe, LM-201).

La règle de détection de +etm14 est RETIRÉE. Cette section documentait jusqu'ici l'inverse : une charge pouvait être arbitrée et publiée sans exister dans GetLoadConfig, et le client était invité à la détecter par différence d'ensembles — un loadId sans équivalent valant « charge non configurable » : afficher le loadId faute de libellé, la traiter comme non classée, ne proposer aucun écran de réglage. Cette règle n'avait qu'un porteur, pac-terrain, la PAC SG-Ready codée en dur du banc .75. Le lot B-bis en fait une LoadConfig ordinaire et supprime le bloc d'enregistrement : la règle est sans objet. Aucun champ configurable: false n'a été ajouté — il n'aurait plus rien à signaler ; ne pas l'attendre dans la charge utile. Un client qui a déjà implémenté la règle de détection peut la conserver sans dommage : elle ne se déclenchera plus.


Codes — aucune phrase composée par la box

La box transporte un code et des paramètres. La phrase est fabriquée par le client, dans la langue de son utilisateur.

Ce n'est pas une préférence de style. La locale nymea est par connexion (JSONRPC.Hello accepte o:locale) : deux utilisateurs d'une même box peuvent avoir deux langues, ce qu'une phrase composée en C++ ne peut pas servir. Par ailleurs NymeaEnergyJsonHandler est un JsonHandler sans pluginId : il est structurellement hors du catalogue de traduction nymea, et rien ne rattraperait une phrase française émise ici.

decision.code — catalogue fermé. params ne contient que des nombres et des booléens ; le libellé de la charge n'y figure jamais (le client le résout via GetLoadConfig).

Code params Sens
EV_SURPLUS — VE rechargé sur surplus PV
EV_SPOT_MARKET — VE rechargé sur créneau tarifaire favorable
EV_DEADLINE — VE prioritaire, échéance approchante
EV_ECO_MIN — Pas de surplus, courant minimum maintenu (mode EcoMin)
EV_IDLE — Retiré en 3g-1 — remplacé par BELOW_MIN_POWER / SURPLUS_INSUFFICIENT, qui disent pourquoi la borne ne charge pas
EV_SURPLUS — Retiré en 3g-1 — la recharge sur surplus produit désormais SURPLUS_SETPOINT, comme toute autre charge
EV_ECO_MIN — Retiré en 3g-1 — le courant minimum est devenu le plancher du §12, exprimé une seule fois pour tous les mécanismes
LOAD_UNAVAILABLE frozenW Charge en défaut : aucune commande émise, frozenW reste compté au budget. Levée par ClearLoadFault
LOCK_MIN_ON appliedW, budgetW Le verrou minOn a relevé la consigne : puissance engagée non coupable
LOCK_MIN_OFF budgetW, requestedW Le verrou minOff interdit tout enclenchement — le budget, lui, payait requestedW
LOCK_MIN_OFF_CAPPED appliedW, budgetW, requestedW Le verrou minOff plafonne sans interdire
SURPLUS_SETPOINT budgetW, setpointW, stepped Consigne servie par le surplus ; stepped = arrondie sur un palier déclaré
SURPLUS_INSUFFICIENT budgetW Le budget ne payait aucun palier — aucun verrou n'a mordu
BATTERY_RESERVE socPercent, reservePercent, withheldW Le surplus existe mais la réserve de stockage l'interdit : aucune charge n'est servie. Depuis 3g-2 withheldW compte les deux termes retenus à cette charge — le surplus annulé et sa propre consommation, que le recrédit lui aurait rendue
BELOW_MIN_POWER budgetW, minPowerW, o:state Budget sous le premier palier atteignable : 0 W, et le budget passe à la suivante. state nommé pour les mécanismes à états
EV_GRID_START (3g-2) budgetW, floorW, gridW La borne démarre en soutirant : le surplus ne paie pas son plancher, mais acquisitionTolerance autorise l'appoint. gridW = floorW − budgetW est ce qui vient du réseau. Voir la note de comptabilité ci-dessous
PHASE_LIMIT (3g-2) limitW, requiredW C'est la limite de phase qui interdit, pas le budget : la place libre laissée par la protection de surcharge est sous ce que la borne exige. Geste attendu : regarder l'abonnement et les autres consommateurs, pas attendre le soleil
DRAW_CAP (délestage §3) marginW, overshootW, source, o:phase L'autorisation de soutirage est épuisée — une seconde ressource, distincte du surplus. source est un code opaque : connection (le branchement), gridOperator (imposé), selfImposed (auto-imposé) ; ce qu'on a le droit d'en faire en dépend. marginW est signé et overshootW dit de combien on dépasse. phase (A/B/C) n'est présent que si le plafond est par phase : son absence dit qu'aucune phase n'est coupable, elle ne vaut pas « phase A ». Précédence (LM-1011) : tant qu'il reste de l'autorisation, même insuffisante, le motif est BELOW_MIN_POWER — DRAW_CAP est réservé à l'autorisation nulle
ECO_FLOOR_GRID (§10) gridW, remainingWh, targetWh, remainingS, regime, atMaximum L'obligation éco est servie en achetant. Les six champs donnent la trajectoire, pas l'instant : la cadence de l'étalement fait un mur — 2 kWh restants valent 333 W à six heures de l'échéance et le plafond dès seize minutes. « 1000 W achetés » seul ne permet pas de prévoir la facture ; avec remainingWh et remainingS, si. atMaximum dit que l'étalement a cessé (point de non-retour), regime sur quoi on achète
ECO_FLOOR_MET (§10) deliveredWh, targetWh L'obligation est tenue. INTERDIT sous regime: unmeasurable (R6) : dire « tenue » suppose de savoir ce qui a été livré. Une échéance qui cesse d'être mesurable et continue de s'afficher comme tenue est pire qu'une échéance absente — elle rassure
ECO_FLOOR_MISSED (§10) targetWh, cause, o:missingWh Une période d'obligation s'est close sans avoir été tenue. cause ∈ drawCap | loadCapacity | unmeasurable | unavailable — trois causes, trois gestes : revoir l'abonnement, lire la fiche technique de la charge, ou regarder ce que la borne publie. Sans elle, l'écran n'affiche qu'un reproche sans recours. missingWh est absent sous unmeasurable : on ne sait pas ce qui a été livré, donc pas ce qui manquait — un zéro se lirait « il ne manquait rien ». Motif d'ÉVÉNEMENT : publié le seul cycle de la bascule
RECREDIT_SUSPENDED refusedSinceS, withheldW Le recrédit anti-clignotement est suspendu : la charge ne suit pas la commande depuis plus longtemps que la latence de son mécanisme. Les watts ne sont pas rendus au budget, donc toutes les charges suivantes reçoivent moins — et sans ce motif, on chercherait un défaut de cascade là où une charge n'obéit pas. Un écart RÉCENT est une latence, pas un refus : le seuil vient du mécanisme (V2C mesurée : montée ~30 s, descente ~7 s), jamais d'une constante

remainingWh sous unmeasurable : la cible INTACTE, et il faut le lire ainsi

Sous regime: unmeasurable, remainingWh == targetWh. Rien n'est soustrait, parce qu'on ne sait rien de ce qui a été livré.

2 000 Wh restants sur une cible de 2 000 Wh signifie « rien de mesurable », PAS « rien livré ». Les deux lectures commandent des phrases d'écran opposées — l'une dit qu'il n'y a aucune information, l'autre accuse une charge de n'avoir rien reçu — et l'écran ne peut trancher que si le contrat le dit. Il le dit ici.

Pourquoi rien n'est soustrait. Le moteur dispose bien d'une intégration interne. La soustraire ferait franchir la frontière à une estimation sous l'allure d'un fait : le chemin exact de carBatteryLevel, refusé en LM-1009 D3. Et que regime voyage dans les mêmes params ne suffirait pas — un client qui lit remainingWh sans regarder regime obtiendrait un nombre calculé, sans rien pour le lui dire.

Identité vérifiable sous measured — troisième contrôle du même genre que Σ counts == targetW et authorisedW − committedW == remainingW :

progress.deliveredWh + params.remainingWh == params.targetWh

Elle ne tient QUE sous measured : sous unmeasurable, deliveredWh est absent, et c'est l'égalité remainingWh == targetWh qui prend le relais. Un client peut donc vérifier la cohérence dans les deux régimes — mais pas avec la même formule, et c'est le régime qui dit laquelle appliquer. | LOCK_MIN_STATE_HOLD | state | Verrou minStateHold : état PAC maintenu (protection court-cycling) | | SG_FORCED | budgetW, estimatedW | PAC en état 4 (forcé) | | SG_RECOMMENDED | budgetW, estimatedW | PAC en état 3 (recommandation) | | SG_NORMAL | budgetW | PAC en état 2 : aucun surplus à allouer (budget ≤ 0). Un budget positif mais sous l'état 3 donne BELOW_MIN_POWER, pas celui-ci | | DEGRADED_L2 | — | Consigne de repli du watchdog L2 (compteur muet) | | SAFE_STATE_RELAY | — | État sûr avant retrait : tous relais ouverts (ECS-413) | | SAFE_STATE_SETPOINT | — | État sûr avant retrait : consigne 0 W (ECS-413) | | SAFE_STATE_SG_READY | — | État sûr avant retrait : état 2, jamais le blocage (ECS-413) |

Comptabilité d'un EV_GRID_START — c'est le seul cas où l'allocatedW d'une charge se partage entre les deux compteurs du budget, et les deux termes sont dans le motif : budgetW vient du surplus et entre dans budget.allocatedW ; gridW vient du réseau et entre dans budget.evReservedW. budgetW + gridW == allocatedW, toujours. L'entrée porte funding: "grid" : une part de cette puissance est achetée.

Borné par construction : un démarrage sous tolérance exige du surplus réel (budgetW > 0) et consomme tout le budget restant — donc au plus une borne par cycle peut soutirer. Le plafond de la protection de surcharge s'applique avant, et la couche L4 garde le dernier mot.

LOCK_MIN_ON / LOCK_MIN_OFF / SURPLUS_INSUFFICIENT se lisent sur le même axe : le verrou a relevé la consigne, il l'a rabaissée, ou aucun verrou n'a mordu et c'est le budget. La distinction est le fruit d'ECS-309 et ECS-309-b (corrections de terrain) — un motif faux envoie diagnostiquer le mauvais problème, avec l'autorité d'une explication.

measurement (o:) — ce qui est observé de la charge, avec sa source. (Remplace measuredW — voir la note de migration ci-dessous. Décidé le 2026-08-28, specs/spec_loadmodel.md LM-1105/LM-1106.)

"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 :

{
  "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 :

{
  "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 :

{
  "enabled": true,
  "o:providerId": "5196b3cc-b2ee-46d6-b63a-7af2cf70ba67"
}

Si enabled: true, providerId est obligatoire et doit être un UUID valide.

Returns :

{ "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 :

{
  "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

{ "phasePowerLimit": 25 }

NymeaEnergy.AcquisitionToleranceChanged

{ "acquisitionTolerance": 0.5 }

NymeaEnergy.BatteryLevelConsiderationChanged

{ "batteryLevelConsideration": 0.9 }

NymeaEnergy.LockOnUnplugChanged

{ "lockOnUnplug": true }

NymeaEnergy.ChargingInfoAdded

Émis quand une nouvelle borne est détectée et sa ChargingInfo créée.

{ "chargingInfo": { ... } }

NymeaEnergy.ChargingInfoRemoved

Émis quand une borne est supprimée.

{ "evChargerThingId": "uuid-de-la-borne" }

NymeaEnergy.ChargingInfoChanged

Émis à chaque modification de la ChargingInfo (config ou état).

{ "chargingInfo": { ... } }

NymeaEnergy.ChargingSchedulesChanged

Émis à chaque recalcul du planning (cycle ~1 min), et à chaque transition du mode dégradé L2 (watchdog fraîcheur compteur).

{
  "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.

{
  "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).

{ "enabled": true, "available": true }

NymeaEnergy.SpotMarketScoreEntriesChanged

Émis quand les cotations sont mises à jour par le provider.

{ "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 :

{
  "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).