Patrick Schurig 74465375c4 feat(telemetry): lot B — frontière de télémétrie d'arbitrage + o:domain — 1.15.2+etm14
Constat d'origine, rejoué et daté au banc : 90 s d'écoute, arbitre actif, ~2 kW exportés
→ ZÉRO notification NymeaEnergy.*. L'état runtime de l'arbitre (allocation, budget, verrous,
paliers, disponibilité, motifs) ne franchissait aucune frontière RPC : il vivait exclusivement
dans qCInfo(dcNymeaEnergy). Expliquer les décisions au client n'avait donc aucun transport.
Après déploiement, même geste : 13 notifications en 480 s.

Aucune phrase composée par la box. La locale nymea est PAR CONNEXION (JSONRPC.Hello o:locale),
et NymeaEnergyJsonHandler est un JsonHandler sans pluginId — donc hors du catalogue de
traduction : rien ne rattraperait une phrase française émise là. La box transporte un CODE et
des PARAMÈTRES ; la phrase est fabriquée par le client.

LoadAction::reason devient DecisionReason{code, params}. La ligne française du journal est
RENDUE depuis ce couple par renderFr(), source unique — jamais maintenue en parallèle. Les 19
codes sont EXTRAITS des sites existants, pas inventés ; les motifs d'ECS-309 et ECS-309-b sont
repris mot pour mot. Prouvé par comparaison de traces (1726 lignes de journal identiques en
simulation), par une table d'or qui échoue au premier code sans phrase attendue, et au banc sur
7 motifs distincts. Le libellé de la charge n'entre pas dans la charge utile : il est substitué
au RENDU, par l'appelant qui le connaît.

Les verrous sortent en SECONDES RESTANTES, pas en watts. Ceci n'enfreint pas ECS-306 : cette
exigence interdit qu'un index de PALIER franchisse la frontière d'ALLOCATION ; une valeur
d'AFFICHAGE sortante est un autre chemin. Le mécanisme sort en union discriminée
(relay|variable|sgReady), toutes clés internes optionnelles au schéma (LM-302).

ILoadAdapter::runtimeView() est PURE VIRTUELLE à dessein — mais la pure virtuelle force à
RÉPONDRE, pas à répondre quelque chose. testRuntimeViewIsSubstantial() est le second verrou :
les quatre adaptateurs, amenés dans un état non trivial, doivent rendre les valeurs runtime
réelles, les verrous en secondes ET leur expiration, et les codes de défaut. Deux mutants
« vue vide » introduits puis retirés : le test échoue sur chacun.

budget publié : surplusW, evReservedW, recreditedW, allocatedW, remainingW, avec l'identité
surplus + recrédit − alloué == restant. recreditedW n'est pas décoratif : sans lui l'identité
serait fausse DÈS QU'UNE CHARGE CONSOMME, et le client chercherait un écart inexplicable. Le
banc l'a montré aussitôt — surplusW −250 W avec recreditedW 3000 W — rendant lisible l'artefact
de banc documenté dans AGENTS.md (relais sans mesure + rootmeter aveugle).

Champs omis, jamais nuls : timestamp absent = aucun cycle depuis le démarrage ; budget absent =
mode dégradé, planification suspendue. Publier un zéro se lirait « exécuté, rien à allouer ».

Cadence : changement qualitatif ou variation d'allocation > 50 W, plus un battement de 60 s.
C'est le battement qui rend un arbitre FIGÉ détectable — les notifications continuent tandis que
le timestamp cesse d'avancer. Observé 3 fois au banc. Volume mesuré : ≈ 1,7 Mo/jour, un client.

GetChargingSchedules rend enfin degradedMode : il était POUSSÉ en notification seule, donc
illisible par un client connecté après la bascule (famille ECS-411). Règles absolues 11 et 12
ajoutées à AGENTS.md.

LoadConfig : champ optionnel domain (ecs·ev·heating·hvac·battery), PURE métadonnée d'intention
que l'arbitre ne lit pas. Absente ou vide = non classée. Forme exacte découverte au banc et
corrigée dans la doc ET le test : GetLoadConfig sérialise via le méta-objet, donc la propriété
sort TOUJOURS, à "" — comme sgReady:{} ou powerLevels:[]. C'est le corollaire LM-302.

Règle 7-c, deux silences fermés : les retours idempotents du RelayRouter et du SgReadyAdapter
étaient MUETS — « consigne reçue, palier déjà bon » ne se distinguait pas de « l'action n'est
jamais parvenue à l'adaptateur », et c'est le cas ordinaire qu'on vient vérifier.

⚠️ Porté au contrat : loads[] n'est PAS en correspondance 1:1 avec GetLoadConfig. « pac-terrain »
est la PAC SG-Ready CODÉE EN DUR d'energypluginnymea.cpp (K1/K2 = Relay4/Relay5, {3:1500,
4:3000}) — arbitrée et publiée, mais inconfigurable : ni rang, ni domain, ni enabled:false.
INTERFACE.md porte la règle de détection pour l'app. Son sort reste À TRANCHER (RELEVE_LOTB §8-bis b).

Déploiement : la recette de TEST_TERRAIN.md §b portait un glob « dpkg -i /tmp/…_*_arm64.deb »,
de la même famille que celui qui a rétrogradé .75 de +etm12 à +etm9. Remplacée par
etm-powersync-deploy/deploy.sh (archivage avant dépôt, installation par nom, refus de
rétrogradation, contrôle de version après coup).

Vérifié sur .75 (1.15.2+etm13 puis +etm14) : critères 1, 2, 4, 5, 6, 7, 8 du brief. Critère 3
(épisode dégradé) vérifié en simulation seulement — le déclencher demande de faire taire un
simulateur SunSpec partagé, hors périmètre. Relevé complet, critère par critère, avec ce qui n'a
PAS été vérifié sur la machine et pourquoi : docs/RELEVE_LOTB.md.

Suites : simulation 38/38, charging 46/46, spotmarket 32/32, y compris en build DEBUG où les
Q_ASSERT_X du validateur JSON-RPC sont actifs (le schéma déclaré correspond donc à la charge
utile émise, retours ET notifications). Cross-arm64 : 0 avertissement. Doxygen : 10, inchangé.

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

197 lines
11 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QDateTime>
#include <QVariantMap>
#include "../types/loadaction.h"
#include "../types/loaddescriptor.h"
#include "../types/surpluscontext.h"
/*!
* \brief Vue runtime minimale exposée par un adaptateur à l'arbitre.
*/
struct LoadTelemetry {
double currentPowerW = 0; //!< Puissance mesurée (W).
bool available = true; //!< Faux si l'appareil nymea est absent ou en erreur.
QDateTime lastActionAt; //!< Dernier instant où applyAction() a produit un effet.
};
/*!
* \brief Vue runtime d'une charge destinée à la FRONTIÈRE DE TÉLÉMÉTRIE (RPC), et à elle seule.
*
* Distincte de \c LoadTelemetry (que l'arbitre lit pour DÉCIDER) et de \c LoadContext (ce que
* le scheduler reçoit). Elle porte ce qui n'a de sens que pour EXPLIQUER une décision à un
* humain : le défaut, le verrou en secondes restantes, et l'état du mécanisme.
*
* \par Des codes, jamais des phrases
* \c faultCode, \c lockKind et \c mechanismKind sont des CODES d'énumération fermée. La
* phrase est fabriquée par le client, dans la langue de son utilisateur — même raison que
* pour \c DecisionReason (cf. \c decisionreason.h).
*
* \par Des secondes, pas des watts
* \c lockMinPowerW / \c lockMaxPowerW (dans \c LoadContextTelemetry) sont un artefact du
* mécanisme d'allocation : illisibles 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
* \c stageW / \c state sont des watts et des états normés, pas des index.
*
* \par Union discriminée par mécanisme
* \c mechanismKind discrimine ; \c mechanism porte la charge utile de la variante, et
* d'elle seule (LM-300). Ajouter un mécanisme = une variante ici et son test — rien d'autre.
*/
struct LoadRuntimeView {
//! Code de défaut ; VIDE = aucun défaut. Énumération fermée, cf. \c INTERFACE.md.
QString faultCode;
//! Verrou ACTIF maintenant : "minOn" | "minOff" | "minStateHold" ; vide = aucun.
QString lockKind;
//! Secondes restantes du verrou nommé par \c lockKind ; sans objet si \c lockKind est vide.
int lockRemainingS = 0;
//! Mécanisme : "relay" | "variable" | "sgReady" | "evcharger".
QString mechanismKind;
//! Charge utile du mécanisme (clés propres à la variante) ; jamais celle d'un autre.
QVariantMap mechanism;
};
/*!
* \brief Interface pure des adaptateurs de charge.
*
* Les implémentations concrètes héritent de QObject + ILoadAdapter et déclarent leurs
* propres signaux (telemetryChanged, descriptorChanged).
*
* \invariant Les adaptateurs EXÉCUTENT, ils ne décident pas (AGENTS règle 2).
* \invariant applyAction() écrête les valeurs selon les limites matérielles réelles
* (second filet après l'écrêtage de l'arbitre).
* \invariant applyAction() avec un \c reason.code vide doit être rejetée silencieusement.
* \invariant Les méthodes non-applyAction() retournent immédiatement (pas de I/O bloquant).
* \invariant **Temps = paramètre, jamais l'horloge.** Toute logique temporelle d'un
* adaptateur (verrous minOn/minOff, fenêtres, fraîcheur…) utilise EXCLUSIVEMENT le
* \c now (= \c ctx.timestamp) reçu en paramètre de \c toLoadContext()/applyAction().
* JAMAIS \c QDateTime::currentDateTime(). C'est cette source unique, partagée avec le
* scheduler, qui rend impossible toute divergence décision/exécution et qui rend la
* logique injectable en simulation. Contrat pour tout futur adaptateur (SgReady, Battery).
*/
class ILoadAdapter {
public:
virtual ~ILoadAdapter() = default;
/*!
* \brief Description statique de la charge : capacités, limites, priorité, needs.
* \return LoadDescriptor construit depuis la configuration matérielle.
* \note Peut être rappelé à chaque cycle — l'implémentation doit être légère.
*/
virtual LoadDescriptor descriptor() const = 0;
/*!
* \brief Télémétrie runtime (puissance, disponibilité, dernière action).
* \return LoadTelemetry issue de l'état courant de l'appareil nymea.
*/
virtual LoadTelemetry telemetry() const = 0;
/*!
* \brief Construit l'entrée §5 loads[] pour le SurplusContext.
* \param now Temps de cycle (\c ctx.timestamp). Source unique pour l'évaluation des
* verrous (minStage/maxStage) — JAMAIS \c QDateTime::currentDateTime() côté adaptateur,
* afin que décision (scheduler) et exécution (applyAction) partagent le même temps.
* \return LoadContext incluant declared, limits, needs et télémétrie type-spécifique.
*/
virtual LoadContext toLoadContext(const QDateTime &now) const = 0;
/*!
* \brief Applique l'action et retourne ce qui a réellement été envoyé au matériel.
*
* L'arbitre a déjà écrêté selon les limites et le budget — ceci est le second filet.
*
* \param action Action à appliquer. Doit avoir \c reason.code non vide.
* \param now Temps de cycle (\c ctx.timestamp) — MÊME source que toLoadContext(),
* pour que l'évaluation des verrous coïncide avec celle vue par le scheduler.
* \return L'action après écrêtage matériel (peut différer de l'entrée).
* \note Retour silencieux sans effet si \c action.reason.code est vide.
*/
virtual LoadAction applyAction(const LoadAction &action, const QDateTime &now) = 0;
/*!
* \brief Met à jour les champs de configuration qui ne touchent PAS le matériel.
*
* Sert au rebuild INCRÉMENTAL (ECS-412) : quand seul le rang de service ou les besoins
* changent, l'arbitre met à jour l'adaptateur en place au lieu de le détruire. Détruire
* réarmerait les verrous — sur un ballon thermodynamique à \c minOn de 300 à 600 s, un
* client qui réordonne ses priorités depuis l'app pourrait faire court-cycler son
* compresseur — et provoquerait des réécritures de relais inutiles.
*
* \param priority Nouveau rang de service (ASC, 1 = premier servi).
* \param needs Nouveaux besoins déclarés.
* \warning NE DOIT jamais toucher au câblage, aux paliers ni aux verrous : ces
* changements-là exigent une vraie reconstruction.
* \note **Pure virtuelle à dessein**, sans implémentation par défaut. Un adaptateur qui
* n'est pas construit depuis \c LoadConfig n'a rien à mettre à jour, mais il doit le
* DÉCLARER : un défaut vide silencieux ferait qu'un futur adaptateur construit depuis
* la config ignorerait sans bruit les changements de rang.
*/
virtual void updateSoftConfig(int priority, const LoadNeeds &needs) = 0;
/*!
* \brief Lève le verrou de défaut de la charge (ECS-410).
*
* Le défaut est COLLANT par conception — « cesser toute commande jusqu'à intervention ».
* Sa levée est un acte délibéré de l'opérateur, jamais une expiration : sans quoi
* \c available oscillerait d'un cycle à l'autre et le waterfall réagirait à chaque
* battement.
*
* \note **Pure virtuelle à dessein.** Tous les adaptateurs ne savent pas encore tomber en
* défaut — seul \c RelayRouter implémente l'échelle ECS-410 — mais chacun doit le
* DÉCLARER. La généralisation est portée par **ECS-414**.
*
* \return \c true si un défaut a RÉELLEMENT été levé, \c false s'il n'y en avait pas.
*
* \note **Pourquoi un booléen et non \c void** (règle 7-c d'AGENTS.md). C'est le seul
* levier de reprise à distance : ECS-410 pose un verrou collant que seuls ce RPC ou une
* reconstruction lèvent. Sans valeur de retour, l'arbitre ne peut pas distinguer
* « défaut levé » de « aucun défaut à lever », et un opérateur à distance ne peut pas
* savoir si son geste a agi — il relancera, puis conclura que le système est cassé.
* Le type de retour force chaque implémentation, présente et future, à répondre à la
* question ; une ligne de journal dans chaque adaptateur se serait oubliée à la
* cinquième.
*/
virtual bool clearFault() = 0;
/*!
* \brief Vue runtime destinée à la frontière de télémétrie RPC (\c GetLoadTelemetry).
*
* \param now Temps de cycle — MÊME source que \c toLoadContext()/\c applyAction(), afin
* que le verrou annoncé au client soit celui qui a réellement borné la décision.
* \return Défaut, verrou en secondes restantes, et charge utile du mécanisme.
*
* \note **Pure virtuelle à dessein**, sans implémentation par défaut. Un mécanisme ajouté
* qui hériterait d'un défaut vide sortirait de la télémétrie SANS BRUIT : le client
* verrait une charge sans mécanisme et sans verrou, indistinguable d'une charge libre.
* C'est précisément le silence ambigu de la règle 7-c. Le compilateur pose donc la
* question à chaque nouvel adaptateur.
* \note Cette méthode n'entre JAMAIS dans une décision : l'arbitre ne la lit que pour
* publier. Un adaptateur qui s'en servirait pour décider violerait la règle 2.
*/
virtual LoadRuntimeView runtimeView(const QDateTime &now) const = 0;
/*!
* \brief Amène le matériel dans l'ÉTAT SÛR de cet adaptateur (ECS-413).
*
* Appelé quand la charge est désactivée (\c enabled: false) ou retirée de la
* configuration — un acte DÉLIBÉRÉ de l'opérateur. Sans cela, l'adaptateur est détruit et
* le matériel reste dans son DERNIER ÉTAT COMMANDÉ : constaté au banc le 2026-08-09, trois
* relais laissés fermés juste avant une intervention de câblage.
*
* \param now Temps de cycle. Passe par le chemin d'action normal avec \c force = true,
* comme le mode dégradé L2 : l'arrêt prime sur les verrous.
*
* \warning **NE PAS appeler à l'arrêt du plugin ni au redémarrage de nymead.** L'état doit
* y être CONSERVÉ — c'est ce qu'ECS-411 relit au démarrage, et couper l'eau chaude à
* chaque redémarrage de service serait une régression. La distinction est
* intentionnelle : la désactivation est délibérée, un redémarrage ne l'est pas.
*
* \note L'état sûr est PROPRE à chaque adaptateur — relais ouverts pour un routeur,
* consigne 0 W pour une charge continue, mais **état 2** pour une PAC SG-Ready, jamais
* le blocage (\c docs/SAFETY.md). Une formulation « tout couper » serait fausse.
*/
virtual void applySafeState(const QDateTime &now) = 0;
};