Patrick Schurig aedcbf3cc2 fix(3g-1): les bornes étaient arbitrées mais ne franchissaient pas la frontière RPC
buildTelemetry() n'interrogeait que la table des charges de configuration et sautait l'EV
avec un commentaire devenu faux (« décidé en amont du waterfall »). Le journal montrait les
décisions des deux bornes ; GetLoadTelemetry n'en montrait aucune. Le changement de contrat
annoncé par +etm21 n'était donc pas livré — et LM-1204-b était inéprouvable, puisque son
discriminant est justement le motif publié.

Nouveau champ loads[].funding (« surplus » / « grid ») : dès lors que les bornes sont dans
loads[], deux d'entre elles peuvent y être pour des raisons différentes, l'une comptée dans
budget.allocatedW, l'autre dans budget.evReservedW. Sans lui, sommer allocatedW donne un
écart ininterprétable.

Constat non corrigé, écrit au contrat : le rang d'une borne n'est pas configurable
(priority = 100 en dur) et std::sort n'est pas stable — laquelle des deux bornes est servie
n'est ni réglable ni garanti reproductible.

Aussi : « batterie à 50 %% » — %% n'est pas une échappée pour QString::arg. Vu au banc.
Et retrait du code mort buildMinCurrentAction/buildIdleAction, seuls porteurs de EV_ECO_MIN
et EV_IDLE depuis +etm21.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-26 18:31:34 +02:00

39 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.

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 :

{ "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 champs sont ignorés.

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 — 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 produit une absence de mesure, pas une configuration invalide.
  • Ni l'un ni l'autre n'est exclusif, contrairement à relays[]. Ils mesurent, ils ne commandent pas : validateSet() les ignore, et deux charges peuvent partager une sonde de pièce ou un compteur de tableau divisionnaire.
  • Publiés toujours, persistés seulement s'ils valent quelque chose — exactement comme domain, et pour la même raison (corollaire LM-302). GetLoadConfig sérialise via le méta-objet : les deux clés sortent toujours, à "" quand rien n'est rattaché, comme sgReady: {} ou powerLevels: []. La forme persistée, elle, omet la clé. C'est ce qui rend l'aller-retour Get → Set neutre : un client qui renvoie verbatim ce qu'il a lu ne déclare rien par accident. Un client traite "" et l'absence comme « aucun Thing rattaché ».

Le compteur VÉRIFIE, il ne budgète pas. Commandé 3 000 W, mesuré 0 W sur plusieurs cycles : la charge ne fait pas ce qu'on lui demande. La mesure n'est pas injectée dans le budget, pour deux raisons dont chacune suffit — elle est en retard sur la commande (scrutation Modbus, moyennage du compteur), et l'y mettre brute réintroduirait l'oscillation que le recrédit anti-clignotement a supprimée ; et la charge est déjà comptée dans le compteur racine, dont ce compteur-ci n'est qu'une décomposition, pas une source à additionner.


Télémétrie d'arbitrage

NymeaEnergy.GetLoadTelemetry

Instantané de l'état runtime de l'arbitrage : budget de surplus, allocation par charge, disponibilité, code de défaut, verrou actif en secondes restantes, charge utile du mécanisme, et motif de décision sous forme de code.

Params : aucun

Returns : voir la charge utile ci-dessous — identique à celle de la notification LoadTelemetryChanged. Le Get ne recalcule rien : il rend l'objet qui a été publié.

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

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

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

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

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

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

funding (o:, depuis 3g-1) — "surplus" ou "grid". Il existe parce que les bornes peuvent être dans loads[] pour deux raisons différentes : la cascade (surplus, comptée dans budget.allocatedW) ou le proxy — échéance de départ, tarif dynamique — qui soutire au réseau et dont la puissance vit dans budget.evReservedW. Sommer allocatedW sans distinguer les deux donne un écart qu'aucun client ne peut interpréter : défaut, ou borne servie au réseau ? Omis en mode dégradé : sans plan, il n'y a pas de financement.

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

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

⚠️ Le rang d'une borne n'est pas configurable : EvAdapter::descriptor() fixe priority = 100 pour toutes. Deux bornes ont donc la MÊME clé de tri, et std::sort n'est pas stable : laquelle est servie n'est ni réglable, ni garanti reproductible d'un cycle à l'autre. La cascade est correcte — une seule démarre, l'autre reçoit son motif — mais laquelle ne se décide pas encore. À traiter par un lot dédié ; à ne pas présenter dans l'app comme un réglage tant que ce n'est pas le cas.

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

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 ».

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
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
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)

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

measuredW (o:) — puissance lue sur le compteur dédié, quand la charge en déclare un. Publiée à côté de allocatedW, jamais fondue dedans : allocatedW est ce que le waterfall a alloué, measuredW ce qu'un compteur a vu. Les confondre rendrait la somme des allocations irréconciliable avec budget.allocatedW. Absente si la charge ne déclare pas de compteur, ou si le Thing ne porte pas l'état.

temperatureC (o:) — température lue sur la sonde déclarée. Lecture seule, aucun effet sur les décisions. Son usage immédiat est le diagnostic de mise en service ; son usage durable est de constituer l'historique (température, puissance, heure) sans lequel les seuils éco/confort à venir ne pourront pas être réglés.

faultCode — absent quand la charge est saine.

Code Sens
WRITE_FAILED Échelle d'écriture épuisée (ECS-410/414) : plus aucune commande émise jusqu'à ClearLoadFault
THING_MISSING Le Thing piloté est absent de la configuration nymea
UNUSABLE_ENCODING PAC SG-Ready dont l'encodage ne permet pas d'exprimer l'état 2 : jamais pilotable (ECS-110)

lock.kind — absent quand aucun verrou ne mord. minOn · minOff · minStateHold. remainingS = secondes restantes.

Pourquoi des secondes et pas des watts. lockMinPowerW/lockMaxPowerW sont un artefact du mécanisme d'allocation, illisible pour un utilisateur ; le temps restant, lui, se lit. Ceci n'enfreint pas ECS-306 : cette exigence interdit qu'un index de palier franchisse la frontière d'allocation. Une valeur d'affichage sortante est un autre chemin — et stageW/state sont des watts et des états normés, pas des index.

mechanism — union discriminée par kind, comme LoadConfig. Chaque variante porte sa charge utile, et seulement la sienne. Toutes les clés internes sont optionnelles au schéma (règle LM-302) ; tout mécanisme ajouté embarque son test.

kind Clés
relay stageW (palier appliqué, W), maxStageW
variable setpointW, percent (0–100, dérivé), maxPowerW
sgReady state (1–4), estimatedPowerW (estimation déclarée, jamais un engagement)
evcharger chargingEnabled, pluggedIn, currentA, phaseCount — réservé 3g, non émis en beta

Spot market

NymeaEnergy.GetAvailableSpotMarketProviders

Retourne la liste des providers spot market disponibles.

Params : aucun

Returns :

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