etm-powersync-energy-plugin.../energyplugin/nymeaenergyjsonhandler.cpp
Patrick Schurig 4e7826ff8a fix(prévision PV): le RPC ne rendait plus rien, et la courbe courante était la mauvaise
Deux défauts de moi, révélés en quatre minutes par un plan de test que le
producteur a ajouté puis retiré pour une raison sans rapport.

1. MON ÉDITION DU CONTRAT AVAIT SUPPRIMÉ LA DÉCLARATION DU RPC. GetPvForecast
répondait {} — pas même une liste vide. En réécrivant sa description par une
substitution de texte, j'avais emporté les params.insert et returns.insert qui
la suivaient ; nymea valide la réponse contre le schéma déclaré, donc sans
returns la charge utile est éliminée en silence.

Aucun test ne couvre la surface RPC : 31 unitaires, la simulation et ci-quality
passaient pendant que la méthode ne rendait plus rien. Ce n'est pas un défaut de
logique mais de DÉCLARATION, et rien ici ne les regarde.

2. LA COURBE COURANTE ÉTAIT LA DERNIÈRE APPARUE, PAS LA DERNIÈRE VUE. Le retrait
du plan a fait réapparaître un contenu déjà connu ; son firstSeenAt n'a pas bougé
— c'est le principe du champ — mais le tri était fait dessus. Quatre minutes
après la disparition du plan fictif, le moteur servait encore la courbe de
36 kWh : une installation qui n'existe pas, à +80 % de la production mesurée.

Ce qui a cours est ce qu'on a vu EN DERNIER. Le tri passe sur lastSeenAt ;
firstSeenAt dit quand un contenu est apparu, pas s'il a cours.

Limite écrite plutôt que découverte : lastSeenAt est mis à jour en place, donc
une lecture historique traversant un RETOUR à un contenu antérieur est
approchée. L'exactitude demanderait une table d'épisodes — différée.

Et une correction sur mon relevé précédent : « huit fenêtres par jour, jusqu'à
une heure chacune » est DÉDUIT du code, pas mesuré — le producteur le dit
lui-même. Ce qui est certain est que la dérive était monotone et sans rappel.
L'écrire comme une mesure en ferait la prémisse de quelqu'un.

Fenêtre à écarter de toute comparaison prévu/réalisé : 08:07 → 08:11:36.

loadmodel 31/31, charging 48/48, spotmarket 32/32, simulation 69/69,
ci-quality 4/4, build 0 erreur 0 warning.

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

1265 lines
69 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

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

// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-energy-plugin-nymea.
*
* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "nymeaenergyjsonhandler.h"
#ifdef ETM_ARBITRATOR
#include "etm/energyarbitrator.h"
#include "etm/forecast/forecaststore.h"
#include "etm/types/decisionreason.h"
#endif
#include "types/charginginfo.h"
#include "smartchargingmanager.h"
#include "spotmarket/spotmarketmanager.h"
#include "etm/types/loadconfig.h"
#include "etm/config/loadconfigstore.h"
#include <energymanager.h>
#include <QLoggingCategory>
#include <QTimeZone>
Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy)
NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketManager, SmartChargingManager *smartChargingManager, LoadConfigStore *loadConfigStore, EnergyManager *energyManager, QObject *parent):
JsonHandler{parent},
m_spotMarketManager{spotMarketManager},
m_smartChargingManager{smartChargingManager},
m_loadConfigStore{loadConfigStore},
m_energyManager{energyManager}
{
registerEnum<ChargingInfo::ChargingMode>();
registerEnum<ChargingInfo::ChargingState>();
registerEnum<ChargingAction::ChargingActionIssuer>();
registerObject<ChargingInfo, ChargingInfos>();
registerObject<SpotMarketProviderInfo, SpotMarketProviderInfos>();
registerObject<ScoreEntry, ScoreEntries>();
registerObject<ChargingAction>();
registerObject<ChargingSchedule, ChargingSchedules>();
registerObject<LoadConfigNeeds>();
registerObject<LoadConfig, LoadConfigs>();
QVariantMap params, returns;
QString description;
params.clear(); returns.clear();
description = "Get the phase power consumption limit. 0 if unset. This needs to be set in order for the smart charging to do anything.";
returns.insert("phasePowerLimit", enumValueName(Uint));
registerMethod("GetPhasePowerLimit", description, params, returns, Types::PermissionScopeControlThings);
params.clear(); returns.clear();
description = "Set the phase power consumption limit. This is the maximum allowed Ampere per phase. A value of 0 means unset. All smart charging will be disabled.";
params.insert("phasePowerLimit", enumValueName(Uint));
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
registerMethod("SetPhasePowerLimit", description, params, returns);
params.clear(); returns.clear();
description = "Get the acquisition tolerance. This value will control how much solar surplus is required to start the charging.";
returns.insert("acquisitionTolerance", enumValueName(Double));
registerMethod("GetAcquisitionTolerance", description, params, returns, Types::PermissionScopeControlThings);
params.clear(); returns.clear();
description = "Set the acquisition tolerance for the charging.";
params.insert("acquisitionTolerance", enumValueName(Double));
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
registerMethod("SetAcquisitionTolerance", description, params, returns, Types::PermissionScopeControlThings);
params.clear(); returns.clear();
description = "Get the battery level consideration. This value will control how much the energy storage is taking in account";
returns.insert("batteryLevelConsideration", enumValueName(Double));
registerMethod("GetBatteryLevelConsideration", description, params, returns);
params.clear(); returns.clear();
description = "Set the battery level consideration for the charging.";
params.insert("batteryLevelConsideration", enumValueName(Double));
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
registerMethod("SetBatteryLevelConsideration", description, params, returns);
params.clear(); returns.clear();
description = "Get the lock on unplug setting.";
returns.insert("lockOnUnplug", enumValueName(Bool));
registerMethod("GetLockOnUnplug", description, params, returns, Types::PermissionScopeControlThings);
params.clear(); returns.clear();
description = "Set the lock on unplug setting.";
params.insert("lockOnUnplug", enumValueName(Bool));
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
registerMethod("SetLockOnUnplug", description, params, returns, Types::PermissionScopeControlThings);
params.clear(); returns.clear();
description = "Get the charging info for all EV chargers, or for a single one when "
"evChargerId is given. An unknown id yields an EMPTY list, never an error: "
"every configured charger has an entry, so an empty list means 'no such "
"charger' and nothing else.";
params.insert("o:evChargerId", enumValueName(Uuid));
returns.insert("chargingInfos", QVariantList() << objectRef<ChargingInfo>());
registerMethod("GetChargingInfos", description, params, returns, Types::PermissionScopeControlThings);
params.clear(); returns.clear();
description = "Set the charging info for an EV charger. The given object REPLACES the stored "
"one as a whole: a writable property omitted from the call is reset to its "
"default (assignedCarId: none, chargingMode: ChargingModeNormal, endDateTime: "
"none, repeatDays: none, targetPercentage: 80, spotMarketChargingEnabled: false, "
"dailySpotMarketPercentage: 0); omitting a property does NOT keep its current "
"value. Read the object with GetChargingInfos, change what you need, and send it "
"back complete. Read-only properties (chargingState) are ignored.";
params.insert("chargingInfo", objectRef<ChargingInfo>());
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
registerMethod("SetChargingInfo", description, params, returns, Types::PermissionScopeControlThings);
params.clear(); returns.clear();
description = "Get the list of available spot market data providers.";
returns.insert("providers", QVariantList() << objectRef<SpotMarketProviderInfo>());
registerMethod("GetAvailableSpotMarketProviders", description, params, returns, Types::PermissionScopeControlThings);
params.clear(); returns.clear();
description = "Set the current spotmarket configuration. If enabled, the provideId must be valid. You can get the available providers using GetAvailableSpotMarketProviders.";
params.insert("enabled", enumValueName(Bool));
params.insert("o:providerId", enumValueName(Uuid));
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
registerMethod("SetSpotMarketConfiguration", description, params, returns, Types::PermissionScopeControlThings);
params.clear(); returns.clear();
description = "Get the current spotmarket configuration.";
returns.insert("o:providerId", enumValueName(Uuid));
returns.insert("enabled", enumValueName(Bool));
returns.insert("available", enumValueName(Bool));
registerMethod("GetSpotMarketConfiguration", description, params, returns, Types::PermissionScopeControlThings);
params.clear(); returns.clear();
description = "Get the current score entries from the current spot market. If spot market is not enabled or not available, the list will be empty. The weighting of the available score entries goes from 0 (worst) to 1.0 (best).";
returns.insert("spotMarketScoreEntries", QVariantList() << objectRef<ScoreEntry>());
registerMethod("GetSpotMarketScoreEntries", description, params, returns, Types::PermissionScopeControlThings);
params.clear(); returns.clear();
description = "Get the planed charging schedules, and whether the L2 degraded mode is "
"currently active (meter silent > 90s, planning suspended).";
returns.insert("chargingSchedules", QVariantList() << objectRef<ChargingSchedule>());
// [ETM] degradedMode était POUSSÉ par ChargingSchedulesChanged sans être LISIBLE ici : un
// client connecté après la bascule ne pouvait pas savoir dans quel état il se trouvait.
// C'est la famille ECS-411 — un silence ambigu. Tout élément runtime a un Get* ET un
// *Changed ; jamais une notification seule.
returns.insert("degradedMode", enumValueName(Bool));
registerMethod("GetChargingSchedules", description, params, returns, Types::PermissionScopeControlThings);
// [ETM] Configuration des charges pilotées (interface etmvariableload, contrat §4).
params.clear(); returns.clear();
description = "Get the configured controllable loads (etmvariableload).";
returns.insert("loadConfigs", QVariantList() << objectRef<LoadConfig>());
registerMethod("GetLoadConfig", description, params, returns, Types::PermissionScopeControlThings);
params.clear(); returns.clear();
description = "Replace the full set of controllable load configs. Rejected as a whole "
"(energyError) if any entry is invalid; persisted and applied atomically.";
// Schéma inline (pas objectRef<LoadConfig>) : le contrat rév. 3 a DEUX formes exclusives
// (relay-router : relays[]+minOnS/minOffS — etmvariableload : powerLevels|maxPowerW).
// objectRef rendrait TOUS les champs requis → nymea rejetterait une des deux formes AVANT
// le handler. On marque donc TOUS les champs spécifiques "o:" ; la validation conditionnelle
// (par adapter/mode) vit dans LoadConfig::isValid(). Seuls id/label/adapter/mode/priority/
// enabled sont requis (présents dans les deux formes). GET reste typé objectRef<LoadConfig>.
QVariantMap needsItem;
needsItem.insert("o:dailyDeadline", enumValueName(String));
needsItem.insert("o:minEnergyWhPerDay", enumValueName(Uint));
QVariantMap relayItem;
relayItem.insert("thingId", enumValueName(String));
relayItem.insert("powerW", enumValueName(Uint));
QVariantMap loadItem;
loadItem.insert("id", enumValueName(String));
loadItem.insert("label", enumValueName(String));
loadItem.insert("adapter", enumValueName(String));
loadItem.insert("mode", enumValueName(String));
// Métadonnée d'INTENTION (§3 du lot) : un code d'énumération fermée — ecs · ev · heating ·
// hvac · battery — jamais un libellé affichable. Optionnelle : absente = charge non classée.
// L'arbitre ne la lit pas ; la validation de l'énumération vit dans LoadConfig::isValid(),
// comme pour « adapter » et « mode » (LM-302 : l'exigence réelle n'est jamais dans le schéma).
loadItem.insert("o:domain", enumValueName(String));
// §11 (LM-1100) — deux Things de MESURE, de même forme, optionnels. Le compteur sert à
// VÉRIFIER (commandé 3 000 W, mesuré 0 W → la charge ne fait pas ce qu'on lui demande) ;
// la sonde est en lecture seule et constitue l'historique. **Ni l'un ni l'autre n'entre
// dans une décision d'arbitrage**, et ni l'un ni l'autre n'est exclusif : ils mesurent,
// ils ne commandent pas, donc LM-201 ne s'applique pas et validateSet() les ignore.
// Le client les choisit par INTERFACE nymea (energymeter/smartmeter ; température),
// jamais par plugin.
loadItem.insert("o:meterThingId", enumValueName(Uuid));
loadItem.insert("o:sensorThingId", enumValueName(Uuid));
loadItem.insert("priority", enumValueName(Uint));
// §12 (LM-1209-c) — origine du RANG : "auto" | "user". Absent = rien d'affirmé, ni
// « choisi » ni « par défaut ». "auto" AFFIRME que le moteur a créé l'entrée d'office et
// que personne n'a jamais classé cette charge ; seul le moteur l'écrit, et un client qui
// la poserait fabriquerait une affirmation sur un geste du moteur (refusé). Un client lève
// la marque sans déplacer la charge en envoyant "user" — l'omission, elle, ne lève rien :
// SetLoadConfig remplace en bloc, et réordonner une AUTRE charge renvoie forcément
// celle-ci. Même motif de conception que measurement.source : le régime se publie, et une
// absence n'affirme rien.
loadItem.insert("o:rankOrigin", enumValueName(String));
loadItem.insert("enabled", enumValueName(Bool));
loadItem.insert("o:powerLevels", QVariantList() << enumValueName(Uint)); // etmvariableload fixed
loadItem.insert("o:maxPowerW", enumValueName(Uint)); // etmvariableload dynamic
// §12 (LM-1201) — plancher de modulation : la charge ne prend RIEN, ou au moins ceci.
// N'a de sens qu'en etmvariableload *dynamic* ; sur relay-router et en *fixed* le
// plancher se DÉRIVE des paliers, et LoadConfig::isValid() refuse de le déclarer en
// double (LM-302 : l'exigence réelle n'est jamais dans le schéma).
loadItem.insert("o:minPowerW", enumValueName(Uint));
// §10 / R6 — LA MESURABILITÉ DE L'AVANCEMENT, en LECTURE SEULE, et ICI plutôt que dans la
// télémétrie. `measurement` n'existe que pour les charges de `loads[]` ; 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 de la saisie.
//
// Trois causes, et chacune appelle un geste DIFFÉRENT — même structure que les trois sources
// de DRAW_CAP :
// · `noMeter` aucun compteur rattaché → réparable en une manipulation
// · `meterWithoutEnergy` compteur sans énergie totale → à remplacer
// · `noSessionEnergy` borne sans sessionEnergy → rien à faire, c'est le matériel
// Sans la cause, l'avertissement de saisie 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é le 2026-09-01 sur l'ECS du banc. Un client qui
// déduirait la mesurabilité de la présence du champ se tromperait sur ce cas précis.
loadItem.insert("r:progressMeasurable", enumValueName(Bool));
loadItem.insert("o:progressUnmeasurableCause", enumValueName(String));
loadItem.insert("o:needs", needsItem);
loadItem.insert("o:relays", QVariantList() << relayItem); // relay-router (rév. 3)
// LM-300 — charge utile du mécanisme sg-ready. estimatedPowerW porte son nom : c'est une
// ESTIMATION (une PAC ne consomme pas pareil à −5 °C et +12 °C), jamais un engagement.
QVariantMap sgStateItem;
sgStateItem.insert("state", enumValueName(Uint));
sgStateItem.insert("relays", QVariantList() << enumValueName(String));
sgStateItem.insert("estimatedPowerW", enumValueName(Double));
QVariantMap sgItem;
// « states » est OPTIONNEL ici, et c'est le mur documenté en LM-302 : GET sérialise la
// charge via le méta-objet, donc TOUTES les propriétés déclarées — y compris un sgReady
// VIDE pour un relay-router. Exiger « states » à l'intérieur casserait le va-et-vient
// Get → Set de l'app sur toute charge non SG-Ready. Défaut constaté au banc le
// 2026-08-10, sur la première tentative réelle de SetLoadConfig.
// L'exigence réelle (states non vide, état 2 présent) est tenue par LoadConfig::isValid().
sgItem.insert("o:states", QVariantList() << sgStateItem);
sgItem.insert("o:minStateHoldS", enumValueName(Uint));
loadItem.insert("o:sgReady", sgItem); // sg-ready (LM-300)
loadItem.insert("o:minOnS", enumValueName(Uint));
loadItem.insert("o:minOffS", enumValueName(Uint));
params.insert("loadConfigs", QVariantList() << loadItem);
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
registerMethod("SetLoadConfig", description, params, returns, Types::PermissionScopeControlThings);
// [Phase 2] Ratios canoniques autoconsommation / autonomie (% [0,100]). Champ omis
// (jamais null) quand n/a (dénominateur ≤ 0). Source canonique de l'energymanager —
// remplace le seam interim app-side.
params.clear(); returns.clear();
description = "Get the current energy ratios (self-consumption / autonomy) in percent, "
"derived from the energy manager's cumulative counters over the current local "
"day. A rate field is omitted (never null) when not available (denominator <= 0).";
returns = energyRatiosSchema();
registerMethod("GetEnergyRatios", description, params, returns, Types::PermissionScopeControlThings);
// [ETM] ECS-410 — levée du verrou de défaut d'une charge. Délibérée et journalisée : le
// défaut est collant par conception (« cesser toute commande jusqu'à intervention »), sa
// levée doit donc être un acte tracé, pas un effet de bord d'un autre appel.
params.clear(); returns.clear();
description = "Clear the fault latch of a controllable load (ECS-410). Deliberate operator "
"action: the fault never clears by itself.";
params.insert("loadId", enumValueName(String));
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
registerMethod("ClearLoadFault", description, params, returns, Types::PermissionScopeControlThings);
// [ETM] Frontière de télémétrie d'arbitrage. AUCUNE phrase composée ici : des codes et des
// paramètres. La locale nymea est par CONNEXION (JSONRPC.Hello o:locale) et ce handler n'a
// pas de pluginId, donc il est hors du catalogue de traduction : une phrase française émise
// ici ne serait rattrapée par rien. Cf. etm/types/decisionreason.h.
params.clear(); returns.clear();
description = "Get a snapshot of the arbitration runtime state: surplus budget, per-load "
"allocation, availability, fault code, active lock (seconds remaining), "
"mechanism payload and decision reason. Reason and fault are CODES with "
"numeric params - never human-readable sentences: the client renders them in "
"the user's language. Identical payload to the LoadTelemetryChanged "
"notification. 'timestamp' is the end of the last arbitration cycle and is "
"omitted while no cycle has run yet.";
returns = loadTelemetrySchema();
registerMethod("GetLoadTelemetry", description, params, returns, Types::PermissionScopeControlThings);
// --- Prévision PV ------------------------------------------------------------------------
//
// UN `Get*`, ET PAS DE `*Changed` — amendement assumé à l'invariant 11, qui vise l'ÉTAT DE
// L'ARBITRE : celui-là bouge à chaque cycle, et un client déconnecté s'en retrouverait en
// retard sans pouvoir se resynchroniser. Une courbe HISTORISÉE n'a pas ce problème : elle se
// lit sur demande, comme GetPowerBalanceLogs. Notifier chaque révision produirait un message
// par heure sur une donnée que personne n'attend en direct.
params.clear(); returns.clear();
description = "Read the stored PV production FORECAST for a window. This is a forecast, "
"NEVER a measurement: the conversion ignores shading, soiling, module "
"temperature and inverter clipping - all optimistic - so the curve is a "
"CEILING, systematically above what will be produced. Hours with no forecast "
"are ABSENT, never zero: a zero would read as 'we expect night'. 'knownAt' "
"makes the read reproducible - it returns the curve as it was known at that "
"instant. A revision exists only when the FORECAST CHANGED; the provider's "
"run id does NOT identify the content (it comes from a domain that supplies "
"none of the curve's values), so revisions are keyed on the content itself. "
"'firstSeenAt' is when this content first appeared - it ORDERS revisions; "
"'lastSeenAt' is the last time it was republished unchanged - FRESHNESS. "
"Neither is an emission time, and no horizon may be computed from them. "
"The provider blends two model domains, so the FIRST HOURS ARE FRESHER THAN "
"THE REST - a single age cannot date the whole curve, which is why none is "
"published per slot. And beware what a near horizon buys: it reduces the "
"METEOROLOGICAL error, never the CONVERSION bias - shading, soiling, module "
"temperature and inverter clipping are as wrong at three hours as at "
"thirty-six, and all in the optimistic direction. 'Nearer forecast is more "
"reliable' must NOT be shown as 'nearer figure is more accurate'.";
params.insert("from", enumValueName(String)); // ISO 8601 UTC
params.insert("to", enumValueName(String)); // ISO 8601 UTC
params.insert("o:knownAt", enumValueName(String)); // ISO 8601 UTC ; absent = maintenant
QVariantMap pointItem;
pointItem.insert("targetAt", enumValueName(String)); // ISO 8601 UTC
pointItem.insert("forecastWh", enumValueName(Double));
// DEUX INSTANTS, DEUX NOMS. `firstSeenAt` est l'apparition du contenu — il ORDONNE les
// révisions. `lastSeenAt` est la dernière republication à l'identique — la FRAÎCHEUR.
// Aucun des deux n'est un instant d'ÉMISSION, et aucun horizon ne s'en calcule.
pointItem.insert("firstSeenAt", enumValueName(String)); // ISO 8601 UTC
pointItem.insert("lastSeenAt", enumValueName(String)); // ISO 8601 UTC
returns.insert("forecastPoints", QVariantList() << pointItem);
registerMethod("GetPvForecast", description, params, returns, Types::PermissionScopeControlThings);
// Désignation GLOBALE, sur le modèle d'Energy.SetRootMeter — même nature : une source unique
// pour toute l'installation, désignée une fois. La prévision est une propriété du SITE, pas
// d'une charge : il n'y a qu'un ciel au-dessus de l'installation, et un réglage par charge
// offrirait un choix dont aucune valeur différenciée n'a de sens.
params.clear(); returns.clear();
description = "Read which site's PV forecast is kept, and which sites COULD be. "
"'forecastSiteThingId' is omitted when none is designated - a NORMAL state, "
"not a fault. 'candidateSiteThingIds' lists every site with at least one plane "
"publishing a curve; it is what a configuration screen offers. When exactly "
"one candidate exists and nothing has ever been chosen, the engine designates "
"it by itself - designating is not CHOOSING when there is nothing to choose. "
"NOTHING is ever designated automatically: the choice is persisted, and the "
"day a second site appears it would look deliberate when nobody made it. "
"'fault' carries MISSING_FORECAST_SITE when planes publish but no site is "
"designated - it is ABSENT when no site exists at all, since announcing a "
"missing designation to an installation that has nothing to designate would "
"be an unsatisfiable warning.";
returns.insert("o:forecastSiteThingId", enumValueName(Uuid));
returns.insert("candidateSiteThingIds", QVariantList() << enumValueName(Uuid));
// Le MOTIF, présent seulement quand il y a un geste à faire : des plans existent et aucun
// site n'est désigné. Un CODE, jamais une phrase — le client la fabrique dans sa langue.
// ABSENT quand aucun site n'existe : annoncer une désignation manquante à une installation
// sans plugin météo serait un avertissement qu'on ne peut pas satisfaire.
returns.insert("o:fault", enumValueName(String)); // MISSING_FORECAST_SITE
registerMethod("GetForecastSite", description, params, returns, Types::PermissionScopeControlThings);
params.clear(); returns.clear();
description = "Designate the site whose PV forecast is kept. ALL the PV planes under that "
"site are SUMMED - not chosen between: a site's production is physically the "
"sum of its planes, and the curve is superimposed on the MEASURED total. "
"Keeping one plane would put the forecast systematically below the "
"measurement, which reads as an under-performing installation. Planes from "
"DIFFERENT runs are never summed: a partly refreshed roof published as the "
"whole would be a chimera, so the engine waits for the next pass. Pass an "
"empty id to keep no forecast at all.";
params.insert("o:forecastSiteThingId", enumValueName(Uuid));
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
registerMethod("SetForecastSite", description, params, returns, Types::PermissionScopeControlThings);
// --- Dérogations -------------------------------------------------------------------------
//
// Une dérogation n'est PAS un mode : elle ne passe donc pas par SetChargingInfo, qui écrit
// un réglage durable. Deux verbes, et une DURÉE OBLIGATOIRE — c'est la règle du lot : une
// dérogation qui ne sait pas dire quand elle finit ne devrait pas exister, et la faire
// respecter au moment de la pose est le seul endroit où c'est encore possible.
params.clear(); returns.clear();
description = "Grant a DEROGATION on a load: it is served at its maximum, funded from the "
"grid, ahead of every other eco need regardless of rank. It is NOT a mode - it "
"overlays the current one and clears itself. 'durationS' is MANDATORY and "
"bounded [60, 21600]: it is the safety net, and it stays armed even when a "
"target is given, because a target relies on an instrument and an instrument "
"can fall silent. 'targetWh' is optional energy delivered SINCE the grant - "
"energy, never SoC. Draw authorisation and overload shedding still bound it. "
"The previous charging mode is memorised and restored when it ends.";
params.insert("loadId", enumValueName(String));
params.insert("durationS", enumValueName(Int));
params.insert("o:targetWh", enumValueName(Double));
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
registerMethod("SetLoadDerogation", description, params, returns, Types::PermissionScopeControlThings);
params.clear(); returns.clear();
description = "Cancel a derogation before its term and restore the memorised charging mode. "
"Returns EnergyErrorInvalidParameter if no derogation was running on that load.";
params.insert("loadId", enumValueName(String));
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
registerMethod("ClearLoadDerogation", description, params, returns, Types::PermissionScopeControlThings);
// Notifications
params.clear();
description = "Emitted whenever the phase power limit configuration changes.";
params.insert("phasePowerLimit", enumValueName(Uint));
registerNotification("PhasePowerLimitChanged", description, params);
connect(m_smartChargingManager, &SmartChargingManager::phasePowerLimitChanged, this, [=](int phasePowerLimit){
emit PhasePowerLimitChanged({{"phasePowerLimit", phasePowerLimit}});
});
params.clear();
description = "Emitted whenever the acquisition tolerance changes.";
params.insert("acquisitionTolerance", enumValueName(Double));
registerNotification("AcquisitionToleranceChanged", description, params);
connect(m_smartChargingManager, &SmartChargingManager::acquisitionToleranceChanged, this, [=](double acquisitionTolerance){
emit AcquisitionToleranceChanged({{"acquisitionTolerance", acquisitionTolerance}});
});
params.clear();
description = "Emitted whenever the battery level consideration changes.";
params.insert("batteryLevelConsideration", enumValueName(Double));
registerNotification("BatteryLevelConsiderationChanged", description, params);
connect(m_smartChargingManager, &SmartChargingManager::batteryLevelConsiderationChanged, this, [=](double batteryLevelConsideration){
emit BatteryLevelConsiderationChanged({{"batteryLevelConsideration", batteryLevelConsideration}});
});
params.clear();
description = "Emitted whenever the lock on unplug setting changes.";
params.insert("lockOnUnplug", enumValueName(Bool));
registerNotification("LockOnUnplugChanged", description, params);
connect(m_smartChargingManager, &SmartChargingManager::lockOnUnplugChanged, this, [=](bool lockOnUnplug){
emit LockOnUnplugChanged({{"lockOnUnplug", lockOnUnplug}});
});
params.clear();
description = "Emitted whenever a charging info is added.";
params.insert("chargingInfo", objectRef<ChargingInfo>());
registerNotification("ChargingInfoAdded", description, params);
params.clear();
description = "Emitted whenever a charging info is removed.";
params.insert("evChargerThingId", enumValueName(Uuid));
registerNotification("ChargingInfoRemoved", description, params);
params.clear();
description = "Emitted whenever a charging info changes.";
params.insert("chargingInfo", objectRef<ChargingInfo>());
registerNotification("ChargingInfoChanged", description, params);
params.clear();
description = "Emitted whenever a spot market configuration has changed.";
params.insert("o:providerId", enumValueName(Uuid));
params.insert("enabled", enumValueName(Bool));
params.insert("available", enumValueName(Bool));
registerNotification("SpotMarketConfigurationChanged", description, params);
params.clear();
description = "Emitted whenever the planed charging schedules have changed.";
params.insert("chargingSchedules", QVariantList() << objectRef<ChargingSchedule>());
// [ETM] degradedMode : true quand le watchdog L2 a basculé en repli (compteur muet).
// Optionnel (additif, rétro-compatible) ; émis aussi aux transitions du mode dégradé.
params.insert("o:degradedMode", enumValueName(Bool));
registerNotification("ChargingSchedulesChanged", description, params);
// Charing manager
connect(m_smartChargingManager, &SmartChargingManager::chargingInfoAdded, this, [=](const ChargingInfo &chargingInfo) {
QVariantMap params;
params.insert("chargingInfo", pack(chargingInfo));
emit ChargingInfoAdded(params);
});
connect(m_smartChargingManager, &SmartChargingManager::chargingInfoRemoved, this, [=](const ThingId &evChargerThingId){
QVariantMap params;
params.insert("evChargerThingId", evChargerThingId);
emit ChargingInfoRemoved(params);
});
connect(m_smartChargingManager, &SmartChargingManager::chargingInfoChanged, this, [=](const ChargingInfo chargingInfo){
QVariantMap params;
params.insert("chargingInfo", pack(chargingInfo));
emit ChargingInfoChanged(params);
});
connect(m_smartChargingManager, &SmartChargingManager::chargingSchedulesChanged, this, [=](){
QVariantMap params;
QVariantList schedules;
foreach (const ChargingSchedule &schedule, m_smartChargingManager->chargingSchedules()) {
schedules << pack<ChargingSchedule>(schedule);
}
params.insert("chargingSchedules", schedules);
params.insert("degradedMode", m_smartChargingManager->degradedMode()); // [ETM] L2
emit ChargingSchedulesChanged(params);
});
// [ETM] LoadConfigChanged : émise après toute persistance réussie (SetLoadConfig ou
// édition externe du fichier relue). Porte la liste complète à jour.
params.clear();
description = "Emitted whenever the controllable load configuration changed.";
params.insert("loadConfigs", QVariantList() << objectRef<LoadConfig>());
registerNotification("LoadConfigChanged", description, params);
if (m_loadConfigStore) {
connect(m_loadConfigStore, &LoadConfigStore::changed, this, [this](){
QVariantMap params;
QVariantList list;
foreach (const LoadConfig &c, m_loadConfigStore->configs())
list << pack<LoadConfig>(c);
params.insert("loadConfigs", list);
emit LoadConfigChanged(params);
});
}
// [ETM] LoadTelemetryChanged : MÊME charge utile que GetLoadTelemetry (schéma partagé, une
// seule déclaration). Émise sur changement SIGNIFICATIF (mode dégradé, jeu de charges,
// disponibilité, défaut, nature de verrou, mécanisme, code de motif, ou variation
// d'allocation au-delà du seuil) ET à intervalle périodique même sans changement — c'est
// ce battement qui rend un arbitre FIGÉ détectable, le timestamp cessant d'avancer.
params.clear();
description = "Emitted whenever the arbitration runtime state changed significantly, and "
"periodically (heartbeat) even when unchanged, so that a frozen arbitrator is "
"detectable by a timestamp that stops advancing. Same payload as "
"GetLoadTelemetry.";
params = loadTelemetrySchema();
registerNotification("LoadTelemetryChanged", description, params);
#ifdef ETM_ARBITRATOR
if (EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager)) {
connect(arb, &EnergyArbitrator::loadTelemetryChanged, this, [this](const QVariantMap &t) {
Q_UNUSED(t) // on republie la vue NORMALISÉE, pas la carte brute : c'est
// currentLoadTelemetry() qui garantit les clés requises du schéma.
emit LoadTelemetryChanged(currentLoadTelemetry());
});
}
#endif
// [Phase 2] EnergyRatiosChanged : émise sur powerBalanceChanged() de l'energymanager,
// uniquement quand une valeur (ou sa disponibilité) change. Mêmes champs que GetEnergyRatios.
params.clear();
description = "Emitted whenever the energy ratios (self-consumption / autonomy) change. "
"A rate field is omitted (never null) when not available.";
params = energyRatiosSchema();
registerNotification("EnergyRatiosChanged", description, params);
if (m_energyManager) {
connect(m_energyManager, &EnergyManager::powerBalanceChanged,
this, &NymeaEnergyJsonHandler::onPowerBalanceChanged);
}
// Spot market manager
params.clear();
description = "Emitted whenever the spot market manager status changed.";
params.insert("enabled", enumValueName(Bool));
params.insert("available", enumValueName(Bool));
registerNotification("SpotMarketStatusChanged", description, params);
connect(m_spotMarketManager, &SpotMarketManager::enabledChanged, this, [this](bool){
sendSpotMarketConfigurationChangedNotification();
});
connect(m_spotMarketManager, &SpotMarketManager::availableChanged, this, [this](bool){
sendSpotMarketConfigurationChangedNotification();
});
connect(m_spotMarketManager, &SpotMarketManager::currentProviderChanged, this, [this](SpotMarketDataProvider *){
sendSpotMarketConfigurationChangedNotification();
});
params.clear();
description = "Emitted whenever the score entries of the current spot market provider changed. The weighting of the available score entries goes from 0 (worst) to 1.0 (best).";
params.insert("spotMarketScoreEntries", QVariantList() << objectRef<ScoreEntry>());
registerNotification("SpotMarketScoreEntriesChanged", description, params);
connect(m_spotMarketManager, &SpotMarketManager::scoreEntriesUpdated, this, [=](){
QVariantList entries;
if (m_spotMarketManager->currentProvider() && m_spotMarketManager->enabled()) {
// Get all scores weighted (we want all available)
ScoreEntries weightedEntries = m_spotMarketManager->weightedScoreEntries();
foreach (const ScoreEntry &entry, weightedEntries) {
entries << pack<ScoreEntry>(entry);
}
}
QVariantMap params;
params.insert("spotMarketScoreEntries", entries);
emit SpotMarketScoreEntriesChanged(params);
});
}
QString NymeaEnergyJsonHandler::name() const
{
return "NymeaEnergy";
}
JsonReply *NymeaEnergyJsonHandler::GetPhasePowerLimit(const QVariantMap &params)
{
Q_UNUSED(params)
QVariantMap returns;
returns.insert("phasePowerLimit", m_smartChargingManager->phasePowerLimit());
return createReply(returns);
}
JsonReply *NymeaEnergyJsonHandler::SetPhasePowerLimit(const QVariantMap &params)
{
int phasePowerLimit = params.value("phasePowerLimit").toUInt();
m_smartChargingManager->setPhasePowerLimit(phasePowerLimit);
QVariantMap returns;
returns.insert("energyError", enumValueName(EnergyManager::EnergyErrorNoError));
return createReply(returns);
}
JsonReply *NymeaEnergyJsonHandler::GetAcquisitionTolerance(const QVariantMap &params)
{
Q_UNUSED(params)
return createReply({{"acquisitionTolerance", m_smartChargingManager->acquisitionTolerance()}});
}
JsonReply *NymeaEnergyJsonHandler::SetAcquisitionTolerance(const QVariantMap &params)
{
double acquisitionTolerance = params.value("acquisitionTolerance").toDouble();
if (acquisitionTolerance < 0.0 || acquisitionTolerance > 1.0)
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
m_smartChargingManager->setAcquisitionTolerance(acquisitionTolerance);
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorNoError)}});
}
JsonReply *NymeaEnergyJsonHandler::GetBatteryLevelConsideration(const QVariantMap &params)
{
Q_UNUSED(params)
return createReply({{"batteryLevelConsideration", m_smartChargingManager->batteryLevelConsideration()}});
}
JsonReply *NymeaEnergyJsonHandler::SetBatteryLevelConsideration(const QVariantMap &params)
{
double batteryLevelConsideration = params.value("batteryLevelConsideration").toDouble();
if (batteryLevelConsideration < 0.0 || batteryLevelConsideration > 1.0)
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
m_smartChargingManager->setBatteryLevelConsideration(batteryLevelConsideration);
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorNoError)}});
}
JsonReply* NymeaEnergyJsonHandler::GetChargingInfos(const QVariantMap &params)
{
// Le paramètre était DÉCLARÉ au schéma, DOCUMENTÉ (« for all or a single EV charger ») et
// Q_UNUSED : accepté, sans effet, sans erreur. Un client qui s'y fiait lisait la
// configuration d'une AUTRE borne en croyant lire la sienne — et rien ne le lui disait.
//
// Des trois issues possibles, celle-là était la pire : un paramètre absent se voit, un
// paramètre refusé se voit, un paramètre ignoré ne se voit pas. Il filtre désormais.
ChargingInfos infos = m_smartChargingManager->chargingInfos();
if (params.contains("evChargerId")) {
const ThingId demande(params.value("evChargerId").toUuid());
ChargingInfos retenues;
for (const ChargingInfo &info : infos)
if (info.evChargerId() == demande)
retenues.append(info);
infos = retenues;
}
QVariantMap returns;
returns.insert("chargingInfos", pack(infos));
return createReply(returns);
}
JsonReply* NymeaEnergyJsonHandler::SetChargingInfo(const QVariantMap &params, const JsonContext &context)
{
ChargingInfo chargingInfo = unpack<ChargingInfo>(params.value("chargingInfo"));
chargingInfo.setLocale(context.locale());
EnergyManager::EnergyError status = m_smartChargingManager->setChargingInfo(chargingInfo);
QVariantMap returns;
returns.insert("energyError", enumValueName(status));
return createReply(returns);
}
JsonReply *NymeaEnergyJsonHandler::GetLockOnUnplug(const QVariantMap &params)
{
Q_UNUSED(params)
return createReply({{"lockOnUnplug", m_smartChargingManager->lockOnUnplug()}});
}
JsonReply *NymeaEnergyJsonHandler::SetLockOnUnplug(const QVariantMap &params)
{
bool lockOnUnplug = params.value("lockOnUnplug").toBool();
m_smartChargingManager->setLockOnUnplug(lockOnUnplug);
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorNoError)}});
}
JsonReply *NymeaEnergyJsonHandler::GetAvailableSpotMarketProviders(const QVariantMap &params)
{
Q_UNUSED(params)
QVariantMap returns;
returns.insert("providers", pack(m_spotMarketManager->availableProviders()));
return createReply(returns);
}
JsonReply *NymeaEnergyJsonHandler::GetSpotMarketConfiguration(const QVariantMap &params)
{
Q_UNUSED(params)
QVariantMap returns;
returns.insert("enabled", m_spotMarketManager->enabled());
returns.insert("available", m_spotMarketManager->available());
if (!m_spotMarketManager->currentProviderId().isNull())
returns.insert("providerId", m_spotMarketManager->currentProviderId());
return createReply(returns);
}
JsonReply *NymeaEnergyJsonHandler::SetSpotMarketConfiguration(const QVariantMap &params)
{
bool enable = params.value("enabled").toBool();
QUuid providerId = params.value("providerId").toUuid();
EnergyManager::EnergyError error = EnergyManager::EnergyErrorNoError;
if (enable && providerId.isNull()) {
qCWarning(dcNymeaEnergy()) << "Could not enable spot market because ther is no valid provider id given.";
error = EnergyManager::EnergyErrorInvalidParameter;
} else if (enable && !providerId.isNull()) {
if (!m_spotMarketManager->changeProvider(providerId)) {
error = EnergyManager::EnergyErrorInvalidParameter;
}
}
if (error == EnergyManager::EnergyErrorNoError) {
m_spotMarketManager->setEnabled(enable);
}
QVariantMap returns;
returns.insert("energyError", enumValueName(error));
return createReply(returns);
}
JsonReply *NymeaEnergyJsonHandler::GetSpotMarketScoreEntries(const QVariantMap &params)
{
Q_UNUSED(params)
QVariantMap returns;
if (m_spotMarketManager->currentProvider() && m_spotMarketManager->enabled()) {
QVariantList entries;
if (m_spotMarketManager->currentProvider() && m_spotMarketManager->enabled()) {
ScoreEntries weightedEntries = SpotMarketManager::weightScoreEntries(m_spotMarketManager->currentProvider()->scoreEntries());
foreach (const ScoreEntry &entry, weightedEntries) {
entries << pack<ScoreEntry>(entry);
}
}
returns.insert("spotMarketScoreEntries", entries);
} else {
returns.insert("spotMarketScoreEntries", QVariantList());
}
return createReply(returns);
}
JsonReply *NymeaEnergyJsonHandler::GetChargingSchedules(const QVariantMap &params)
{
Q_UNUSED(params)
QVariantMap returns;
QVariantList schedules;
foreach (const ChargingSchedule &schedule, m_smartChargingManager->chargingSchedules()) {
schedules << pack<ChargingSchedule>(schedule);
}
returns.insert("chargingSchedules", schedules);
returns.insert("degradedMode", m_smartChargingManager->degradedMode()); // [ETM] L2, cf. supra
return createReply(returns);
}
bool NymeaEnergyJsonHandler::progressMesurable(const LoadConfig &c, QString &cause) const
{
cause.clear();
ThingManager *tm = m_smartChargingManager ? m_smartChargingManager->thingManager() : nullptr;
if (!tm)
return false;
// 1. Une BORNE se mesure par sa session. Sans `sessionEnergy`, rien à faire : c'est le
// matériel, et aucune manipulation ne le changera. Le dire évite qu'un installateur
// cherche un réglage qui n'existe pas.
if (c.adapter() == QLatin1String("evcharger")) {
Thing *t = tm->findConfiguredThing(ThingId(c.id()));
if (t && t->thingClass().hasStateType("sessionEnergy"))
return true;
cause = QStringLiteral("noSessionEnergy");
return false;
}
// 2. Une charge en watts se mesure par un compteur DÉSIGNÉ. Aucun : réparable en une
// manipulation, et c'est la cause la plus fréquente à la saisie.
if (c.meterThingId().isEmpty()) {
cause = QStringLiteral("noMeter");
return false;
}
// 3. Le compteur existe-t-il, et compte-t-il l'ÉNERGIE ? Un compteur peut publier
// `currentPower` sans `totalEnergyConsumed` — mesuré le 2026-09-01 sur l'ECS du banc.
// C'est précisément pourquoi la présence de `meterThingId` ne permet pas de déduire la
// mesurabilité : la puissance ne suffit pas, il faut un cumul.
Thing *compteur = tm->findConfiguredThing(ThingId(c.meterThingId()));
if (!compteur) {
cause = QStringLiteral("noMeter");
return false;
}
if (!compteur->thingClass().hasStateType("totalEnergyConsumed")) {
cause = QStringLiteral("meterWithoutEnergy");
return false;
}
return true;
}
JsonReply *NymeaEnergyJsonHandler::GetLoadConfig(const QVariantMap &params)
{
Q_UNUSED(params)
QVariantMap returns;
QVariantList list;
if (m_loadConfigStore) {
foreach (const LoadConfig &c, m_loadConfigStore->configs()) {
QVariantMap m = pack<LoadConfig>(c).toMap(); // inclut enabled==false (éditables par l'app)
// R6 — la MESURABILITÉ, calculée ici parce que c'est ici que l'app en a besoin :
// au moment de la SAISIE, avant que la charge existe dans loads[].
QString cause;
m.insert(QStringLiteral("progressMeasurable"), progressMesurable(c, cause));
if (!cause.isEmpty())
m.insert(QStringLiteral("progressUnmeasurableCause"), cause);
list << m;
}
}
returns.insert("loadConfigs", list);
return createReply(returns);
}
JsonReply *NymeaEnergyJsonHandler::SetLoadConfig(const QVariantMap &params)
{
if (!m_loadConfigStore) {
qCWarning(dcNymeaEnergy()) << "[NymeaEnergy] SetLoadConfig sans store configuré.";
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
}
LoadConfigs configs;
foreach (const QVariant &item, params.value("loadConfigs").toList())
configs.append(LoadConfig::fromMap(item.toMap()));
QString why;
// LM-1209-c — la marque de rang se réconcilie ICI, à la frontière CLIENTE, et nulle part
// ailleurs : c'est la seule couche qui sache qu'une écriture vient du dehors. Le moteur,
// lui, pose légitimement "auto" en créant une entrée d'office.
LoadConfigs reconciliees;
if (!m_loadConfigStore->reconcileRankOrigin(configs, &reconciliees, &why)) {
qCWarning(dcNymeaEnergy()) << "[NymeaEnergy] SetLoadConfig rejetée:" << why;
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
}
configs = reconciliees;
if (!m_loadConfigStore->setConfigs(configs, &why)) {
qCWarning(dcNymeaEnergy()) << "[NymeaEnergy] SetLoadConfig rejetée:" << why;
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
}
// store.setConfigs() a déjà persisté + émis changed() → l'arbitre reconstruit, la notif part.
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorNoError)}});
}
// [Phase 2] Construit le map de ratios courant depuis les cumuls de l'energymanager.
// Champ omis (jamais null) quand n/a. Met à jour l'état caché (m_lastRatios).
QVariantMap NymeaEnergyJsonHandler::energyRatiosSchema()
{
QVariantMap champs;
champs.insert("o:selfConsumptionRate", enumValueName(Double));
champs.insert("o:autonomyRate", enumValueName(Double));
// La FRONTIÈRE de la période, publiée plutôt que recalculée par chaque client. Trois
// clients la recalculeraient de trois façons, et la seule qui compte est celle qui a servi
// au calcul. `System.GetTime` donne un nom IANA : un décalage ne s'en déduit pas sans base
// de fuseaux, et le recalcul resterait faux les jours de changement d'heure, où la journée
// fait 23 ou 25 heures. Même règle que `counts`.
champs.insert("periodStartedAt", enumValueName(Uint));
// DEPUIS QUAND on mesure réellement — pas toujours la frontière. La baseline se repose à
// chaque redémarrage de nymead et à chaque recul de compteur ; un jour où nymead a
// redémarré à 14 h, ces ratios ne couvrent que l'après-midi. Sans ce champ, l'écran écrit
// « aujourd'hui » sur des chiffres qui ne parlent pas de la journée. Même paire, même
// raison que `measuredSince` / `targetWh` de l'avancement éco.
champs.insert("measuredSince", enumValueName(Uint));
// Le fuseau dans lequel la frontière a été construite. Il rend la période VÉRIFIABLE : sans
// lui, un bilan journalier découpé au mauvais endroit se lit comme un bilan normal.
champs.insert("timeZone", enumValueName(String));
return champs;
}
QVariantMap NymeaEnergyJsonHandler::currentEnergyRatios()
{
QVariantMap result;
if (!m_energyManager)
return result;
const EnergyRatiosCalculator::Ratios r = m_ratiosCalculator.compute(
m_energyManager->totalProduction(),
m_energyManager->totalReturn(),
m_energyManager->totalConsumption(),
m_energyManager->totalAcquisition(),
QDateTime::currentDateTime());
m_lastRatios = r;
m_lastRatiosValid = true;
if (r.autoconsommationValid)
result.insert("selfConsumptionRate", r.autoconsommation);
if (r.autonomieValid)
result.insert("autonomyRate", r.autonomie);
// Ces trois-là sont TOUJOURS présents, même quand les deux taux sont omis : « la période a
// commencé à minuit et rien n'est encore mesurable » est une phrase vraie et utile, alors
// qu'une réponse vide ne se distingue pas d'une panne.
result.insert("periodStartedAt", static_cast<qint64>(r.debutPeriode.toSecsSinceEpoch()));
result.insert("measuredSince",
static_cast<qint64>((r.mesureDepuis.isValid() ? r.mesureDepuis : r.debutPeriode)
.toSecsSinceEpoch()));
result.insert("timeZone", QString::fromUtf8(QTimeZone::systemTimeZoneId()));
return result;
}
JsonReply *NymeaEnergyJsonHandler::ClearLoadFault(const QVariantMap &params)
{
#ifdef ETM_ARBITRATOR
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager);
if (arb && arb->clearLoadFault(params.value("loadId").toString())) {
QVariantMap ret;
ret.insert("energyError", enumValueName(EnergyManager::EnergyErrorNoError));
return createReply(ret);
}
#else
Q_UNUSED(params)
#endif
QVariantMap ret;
ret.insert("energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter));
return createReply(ret);
}
QVariantMap NymeaEnergyJsonHandler::loadTelemetrySchema()
{
// Verrou : le TEMPS RESTANT, pas des watts. lockMinPowerW/lockMaxPowerW sont un artefact du
// mécanisme d'allocation, illisible pour un utilisateur. 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.
QVariantMap lockItem;
lockItem.insert("kind", enumValueName(String)); // "minOn" | "minOff" | "minStateHold"
lockItem.insert("remainingS", enumValueName(Int));
// UNION DISCRIMINÉE par mécanisme, comme LoadConfig : « kind » discrimine, chaque variante
// porte SA charge utile. LM-302 s'applique tel quel — TOUTES les clés internes sont
// optionnelles, sinon la charge utile d'un mécanisme contraindrait celle des autres.
QVariantMap mechItem;
mechItem.insert("kind", enumValueName(String)); // "relay" | "variable" | "sgReady"
mechItem.insert("o:stageW", enumValueName(Double)); // relay
mechItem.insert("o:maxStageW", enumValueName(Double)); // relay
// Paliers atteignables, dérivés des relais. LECTURE SEULE : aucun équivalent en
// écriture, et SetLoadConfig ne l'accepte pas — powerLevels reste vide sur ce
// mécanisme, la table se dérive et ne se déclare pas.
mechItem.insert("o:stagesW", QVariantList() << enumValueName(Double)); // relay
mechItem.insert("o:setpointW", enumValueName(Double)); // variable
mechItem.insert("o:percent", enumValueName(Double)); // variable
mechItem.insert("o:maxPowerW", enumValueName(Double)); // variable
mechItem.insert("o:minPowerW", enumValueName(Double)); // variable — plancher (§12)
mechItem.insert("o:state", enumValueName(Int)); // sgReady
mechItem.insert("o:estimatedPowerW", enumValueName(Double)); // sgReady
mechItem.insert("o:chargingEnabled", enumValueName(Bool)); // evcharger (réservé 3g)
mechItem.insert("o:pluggedIn", enumValueName(Bool)); // evcharger (réservé 3g)
mechItem.insert("o:currentA", enumValueName(Double)); // evcharger (réservé 3g)
mechItem.insert("o:phaseCount", enumValueName(Uint)); // evcharger (réservé 3g)
// Motif : un CODE et ses paramètres. « params » est typé Variant parce que ses clés
// DÉPENDENT du code — les figer au schéma reviendrait à publier N objets au lieu d'un, et à
// casser le contrat à chaque motif ajouté. Le catalogue des codes et de leurs paramètres
// fait autorité dans INTERFACE.md et dans etm/types/decisionreason.h.
QVariantMap decisionItem;
decisionItem.insert("code", enumValueName(String));
decisionItem.insert("params", enumValueName(Variant));
// R7 — DE QUEL NIVEAU ce motif parle. Sans lui, chaque client réinvente sa règle de fusion
// (« le motif du dernier niveau servi ? du premier refusé ? ») et deux écrans affichent deux
// motifs pour le même cycle. PRÉSENT exactement quand levels[] l'est : en mode dégradé il
// n'y a pas de passe, donc pas de niveau dont le motif parlerait.
decisionItem.insert("o:level", enumValueName(String));
// §11 / LM-1106 — la mesure et SA SOURCE. « source » discrimine, « powerW » n'existe que
// quand quelque chose mesure réellement (jamais sous « none »).
QVariantMap measurementItem;
measurementItem.insert("source", enumValueName(String)); // meter | device | none
measurementItem.insert("o:powerW", enumValueName(Double));
// §10 / R1-R2-R4 — un élément par passe RÉELLEMENT parcourue. Aujourd'hui il y en a un
// seul, « comfort » : la passe unique d'aujourd'hui est celle qui servira le confort depuis
// le surplus. Une charge sans obligation éco n'aura jamais d'entrée « eco » à zéro —
// l'absence est l'information. « targetW » est ce que le niveau a DÉCIDÉ (R4), à ne pas
// confondre avec la charge utile de mécanisme, qui dit ce qui a été APPLIQUÉ.
QVariantMap levelItem;
levelItem.insert("level", enumValueName(String)); // eco | comfort
levelItem.insert("targetW", enumValueName(Double));
levelItem.insert("funding", enumValueName(String)); // surplus | grid
// R3 — ventilation { destination → watts }. Clés OPAQUES, jamais recyclées pour un sens
// nouveau : une clé inconnue du client s'affiche « destination inconnue de cette version »
// et laisse Σ counts == targetW vérifiable. Typée Variant parce que ses CLÉS sont le
// contenu — les figer au schéma reviendrait à republier le catalogue à chaque destination
// ajoutée. Le catalogue fait autorité dans INTERFACE.md et dans etm/types/loadaction.h.
levelItem.insert("counts", enumValueName(Variant));
QVariantMap loadItem;
loadItem.insert("loadId", enumValueName(String));
// ALLOCATION du waterfall (ce qui a décrémenté le budget), pas la mesure : la mesure vit
// dans la charge utile de mécanisme. La somme des allocatedW des charges financées au
// SURPLUS retombe sur budget.allocatedW — voir « funding » juste dessous.
loadItem.insert("allocatedW", enumValueName(Double));
// FINANCEMENT — « surplus » ou « grid ». Depuis 3g-1 les bornes figurent dans loads[], et
// deux d'entre elles peuvent y être pour des raisons différentes : la cascade (surplus,
// comptée dans budget.allocatedW) ou le proxy — échéance et tarif dynamique — qui SOUTIRE
// au réseau et dont la puissance vit dans budget.evReservedW. Sans ce champ, un client qui
// somme allocatedW trouve un écart sans pouvoir dire s'il regarde un défaut ou une borne
// servie au réseau. Omis en mode dégradé : sans plan, il n'y a pas de financement.
// R2 — le financement descend au NIVEAU (levels[]). Il n'est PAS conservé en résumé sur la
// charge : 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.
// Deux endroits pour un fait divergent.
loadItem.insert("levels", QVariantList() << levelItem);
loadItem.insert("available", enumValueName(Bool));
loadItem.insert("o:faultCode", enumValueName(String)); // absent = aucun défaut
// §11 (LM-1101/1103) — mesures de la charge, publiées à côté de l'allocation et JAMAIS
// fondues dedans : la somme des allocations doit rester réconciliable avec
// budget.allocatedW, et une mesure en retard sur la commande la rendrait fausse.
//
// LM-1106 — « measurement » est un COUPLE {valeur, source}, et non le « measuredW » nu
// qu'il remplace. Le nombre seul ne pouvait pas dire d'où il venait, alors que l'écart
// commandé↔mesuré ne conclut pas la même chose selon la source ; et « source: none » est
// un état POSITIF — cette charge n'est pas mesurable — que l'absence d'une clé ne savait
// pas distinguer d'un compteur en panne. « powerW » est omis sous « none » : la puissance
// nominale reste lisible dans mechanism.*, elle ne se fait pas passer pour une mesure.
loadItem.insert("o:measurement", measurementItem);
loadItem.insert("o:temperatureC", enumValueName(Double));
loadItem.insert("o:lock", lockItem); // absent = aucun verrou actif
loadItem.insert("o:mechanism", mechItem);
loadItem.insert("decision", decisionItem);
QVariantMap budgetItem;
budgetItem.insert("surplusW", enumValueName(Double));
budgetItem.insert("evReservedW", enumValueName(Double));
budgetItem.insert("allocatedW", enumValueName(Double));
// Sans « recreditedW » l'identité surplus − alloué == restant serait fausse dès qu'une
// charge consomme déjà, et le client chercherait l'écart. Cf. PlanBudget.
budgetItem.insert("recreditedW", enumValueName(Double));
budgetItem.insert("remainingW", enumValueName(Double));
QVariantMap schema;
// Omis, jamais nul (règle maison, cf. GetEnergyRatios) :
// - timestamp absent = aucun cycle d'arbitrage depuis le démarrage. En forger un se
// lirait « cycle exécuté », soit exactement le mensonge que ce champ existe pour
// empêcher. Présent, c'est la FIN du dernier cycle — 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 « rien à allouer ».
schema.insert("o:timestamp", enumValueName(String)); // ISO 8601 UTC
schema.insert("degradedMode", enumValueName(Bool));
schema.insert("o:budget", budgetItem);
// R3 — registre des watts ACHETÉS, dans la même trame que « budget ». Un seul champ
// aujourd'hui : les quatre autres du §14a sont ABSENTS, jamais à 0 (voir buildTelemetry).
// Omis en mode dégradé, comme « budget » : sans plan, il n'y a pas de soutirage décidé.
QVariantMap drawItem;
drawItem.insert("committedW", enumValueName(Double));
schema.insert("o:draw", drawItem);
// Fin de dérogation — ÉVÉNEMENT de cycle, absent hors du cycle où elle se produit. Publié
// au niveau de la trame et non dans loads[] : au débranchement, la charge n'y est déjà
// plus. « restoredMode » est le mode mémorisé auquel on revient — le client ne peut pas le
// déduire, il n'a jamais vu celui d'avant la pose.
QVariantMap derogEndItem;
derogEndItem.insert("loadId", enumValueName(String));
derogEndItem.insert("cause", enumValueName(String)); // targetReached | unplugged |
// measurementLost | durationElapsed |
// cancelled
derogEndItem.insert("o:restoredMode", enumValueName(Int));
schema.insert("o:derogationEnded", QVariantList() << derogEndItem);
schema.insert("loads", QVariantList() << loadItem);
return schema;
}
QVariantMap NymeaEnergyJsonHandler::currentLoadTelemetry() const
{
QVariantMap t;
#ifdef ETM_ARBITRATOR
if (EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager))
t = arb->loadTelemetry();
#endif
// Carte minimale tant qu'aucun cycle n'a eu lieu : les clés REQUISES du schéma sont posées,
// « timestamp » ne l'est pas. Le client lit donc « pas encore de cycle », et non un cycle
// fictif à budget nul.
if (!t.contains(QStringLiteral("degradedMode")))
t.insert(QStringLiteral("degradedMode"),
m_smartChargingManager && m_smartChargingManager->degradedMode());
if (!t.contains(QStringLiteral("loads")))
t.insert(QStringLiteral("loads"), QVariantList());
return t;
}
JsonReply *NymeaEnergyJsonHandler::GetLoadTelemetry(const QVariantMap &params)
{
Q_UNUSED(params)
return createReply(currentLoadTelemetry());
}
JsonReply *NymeaEnergyJsonHandler::GetPvForecast(const QVariantMap &params)
{
#ifdef ETM_ARBITRATOR
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager);
ForecastStore *magasin = arb ? arb->forecastStore() : nullptr;
if (!magasin || !magasin->isValid())
return createReply({{"forecastPoints", QVariantList()}});
const QDateTime de = QDateTime::fromString(params.value("from").toString(), Qt::ISODate);
const QDateTime a = QDateTime::fromString(params.value("to").toString(), Qt::ISODate);
QDateTime connuA;
if (params.contains("knownAt"))
connuA = QDateTime::fromString(params.value("knownAt").toString(), Qt::ISODate);
QVariantList points;
for (const ForecastPoint &p : magasin->courbe(de, a, connuA)) {
// Le nom porte la protection : `forecastWh`, jamais `wh` ni `productionWh`. Un nombre
// qui ne dit pas d'où il vient finit par être lu comme une mesure, et une prévision lue
// comme une mesure est un mensonge que personne n'a écrit.
points.append(QVariantMap{
{"targetAt", p.targetAt.toUTC().toString(Qt::ISODate)},
{"forecastWh", p.wh},
{"firstSeenAt", p.firstSeenAt.toUTC().toString(Qt::ISODate)},
{"lastSeenAt", p.lastSeenAt.toUTC().toString(Qt::ISODate)}});
}
return createReply({{"forecastPoints", points}});
#else
Q_UNUSED(params)
return createReply({{"forecastPoints", QVariantList()}});
#endif
}
JsonReply *NymeaEnergyJsonHandler::GetForecastSite(const QVariantMap &params)
{
Q_UNUSED(params)
QVariantMap ret;
#ifdef ETM_ARBITRATOR
if (EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager)) {
const QString id = arb->forecastSiteId();
// OMIS, jamais une chaîne vide : « aucun site désigné » est un état, et une chaîne vide
// se lirait comme un identifiant qu'on n'a pas su rendre.
if (!id.isEmpty())
ret.insert(QStringLiteral("forecastSiteThingId"), id);
// LES CANDIDATS, toujours rendus — même vides. C'est ce qui permet à un écran de poser
// la question quand il y a un choix, sans un second appel ni une heuristique côté client.
const QStringList candidats = arb->forecastSiteCandidates();
ret.insert(QStringLiteral("candidateSiteThingIds"),
QVariant::fromValue(candidats).toList());
// Le motif ne sort que s'il y a un GESTE POSSIBLE : des plans publient, et personne n'a
// désigné. Sans candidat, l'absence de désignation n'est pas un manque — c'est une
// installation qui n'a pas de prévision, et c'est normal.
if (id.isEmpty() && !candidats.isEmpty())
ret.insert(QStringLiteral("fault"),
QLatin1String(DecisionCode::MissingForecastSite));
}
#endif
return createReply(ret);
}
JsonReply *NymeaEnergyJsonHandler::SetForecastSite(const QVariantMap &params)
{
#ifdef ETM_ARBITRATOR
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager);
if (!arb)
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
const bool ok = arb->setForecastSiteId(params.value("forecastSiteThingId").toString());
// L'énumération de l'amont n'a pas de ThingNotFound — on ne l'INVENTE pas : un client qui
// recevrait une valeur hors catalogue ne saurait pas la rendre, et l'ajouter ici la ferait
// diverger du contrat publié par nymea-energy.
return createReply(QVariantMap{{QStringLiteral("energyError"),
enumValueName(ok ? EnergyManager::EnergyErrorNoError
: EnergyManager::EnergyErrorInvalidParameter)}});
#else
Q_UNUSED(params)
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
#endif
}
JsonReply *NymeaEnergyJsonHandler::SetLoadDerogation(const QVariantMap &params)
{
#ifdef ETM_ARBITRATOR
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager);
if (!arb)
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
const QString loadId = params.value("loadId").toString();
const int durationS = params.value("durationS").toInt();
const double targetWh = params.value("targetWh", 0).toDouble();
// La validation vit dans poseDerogation() : bornes de durée, existence de la charge,
// mémorisation du mode. La refaire ici en donnerait DEUX versions, et c'est celle du moteur
// qui compte — le RPC n'est pas le seul chemin de pose.
const bool ok = arb->poseDerogation(loadId, durationS, targetWh);
return createReply({{"energyError",
enumValueName(ok ? EnergyManager::EnergyErrorNoError
: EnergyManager::EnergyErrorInvalidParameter)}});
#else
Q_UNUSED(params)
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
#endif
}
JsonReply *NymeaEnergyJsonHandler::ClearLoadDerogation(const QVariantMap &params)
{
#ifdef ETM_ARBITRATOR
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager);
if (!arb)
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
const bool ok = arb->leveDerogation(params.value("loadId").toString());
return createReply({{"energyError",
enumValueName(ok ? EnergyManager::EnergyErrorNoError
: EnergyManager::EnergyErrorInvalidParameter)}});
#else
Q_UNUSED(params)
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
#endif
}
JsonReply *NymeaEnergyJsonHandler::GetEnergyRatios(const QVariantMap &params)
{
Q_UNUSED(params)
return createReply(currentEnergyRatios());
}
void NymeaEnergyJsonHandler::onPowerBalanceChanged()
{
const EnergyRatiosCalculator::Ratios prev = m_lastRatios;
const bool hadPrev = m_lastRatiosValid;
const QVariantMap ratios = currentEnergyRatios();
const EnergyRatiosCalculator::Ratios &now = m_lastRatios;
// N'émet que si une valeur (ou sa disponibilité) a réellement bougé.
auto changed = [](bool prevValid, double prevVal, bool nowValid, double nowVal) {
if (prevValid != nowValid)
return true;
return nowValid && qAbs(prevVal - nowVal) > 1e-6;
};
const bool moved = !hadPrev
|| changed(prev.autoconsommationValid, prev.autoconsommation, now.autoconsommationValid, now.autoconsommation)
|| changed(prev.autonomieValid, prev.autonomie, now.autonomieValid, now.autonomie)
// La bascule de période et la repose de baseline sont des changements de la charge
// utile : ne pas les notifier laisserait un client connecté sur une frontière périmée
// — le silence ambigu que l'invariant 11 interdit.
|| prev.debutPeriode != now.debutPeriode
|| prev.mesureDepuis != now.mesureDepuis;
if (moved)
emit EnergyRatiosChanged(ratios);
}
void NymeaEnergyJsonHandler::sendSpotMarketConfigurationChangedNotification()
{
QVariantMap params;
params.insert("enabled", m_spotMarketManager->enabled());
params.insert("available", m_spotMarketManager->available());
if (m_spotMarketManager->enabled())
params.insert("providerId", m_spotMarketManager->currentProviderId());
emit SpotMarketConfigurationChanged(params);
}