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

457 lines
19 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "sgreadyadapter.h"
#include "plugininfo.h"
#include <QDateTime>
#include <QSet>
#include <algorithm>
#include <climits>
#include <integrations/thingmanager.h>
#include <integrations/thing.h>
#include <types/action.h>
#include <types/param.h>
#include <integrations/thingactioninfo.h>
SgReadyAdapter::SgReadyAdapter(ThingManager *thingManager,
const QString &id,
const QString &label,
const QHash<int, QList<QString>> &stateRelays,
const QHash<int, double> &estimatedPowerW,
int minStateHoldS,
int priority,
QObject *parent)
: QObject(parent)
, m_thingManager(thingManager)
, m_id(id)
, m_label(label)
, m_stateRelays(stateRelays)
, m_estimatedPowerW(estimatedPowerW)
, m_minStateHoldS(minStateHoldS)
, m_priority(priority)
{
m_states = m_stateRelays.keys();
std::sort(m_states.begin(), m_states.end());
// ECS-110 — la présence de l'état 2 NE DOIT PAS reposer sur un Q_ASSERT : QT_NO_DEBUG est
// défini en release, l'assertion disparaît du binaire livré. Tant que les états venaient
// d'une construction codée en dur elle ne pouvait pas échouer ; dès qu'ils viennent de la
// configuration, elle serait le SEUL garde-fou sur le repli sûr du mode dégradé L2.
//
// La vérification vit donc dans LoadConfig::isValid() (refus explicite, message FR) et le
// contrôle ci-dessous n'est qu'un dernier filet pour les constructions directes (tests,
// enregistrement codé en dur) : il PARLE au lieu de disparaître.
m_usable = !m_states.isEmpty() && m_stateRelays.contains(2);
if (!m_usable)
qCCritical(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
<< "— encodage INUTILISABLE : l'état 2 (normal) n'est pas"
<< "exprimable. C'est le repli sûr du mode dégradé L2 ;"
<< "l'adaptateur refusera toute commande.";
}
LoadDescriptor SgReadyAdapter::descriptor() const
{
LoadDescriptor d;
d.id = m_id;
d.label = m_label;
d.adapter = QStringLiteral("sg-ready");
d.priority = m_priority;
d.declared.states = m_states;
d.declared.estimatedPowerW = m_estimatedPowerW;
d.limits.minStateHoldS = m_minStateHoldS;
d.supportedKinds = { LoadAction::State };
return d;
}
LoadTelemetry SgReadyAdapter::telemetry() const
{
LoadTelemetry t;
t.available = m_usable && !m_faulted; // ECS-414 : sort de l'ARBITRAGE, pas de la COMPTABILITÉ.
t.lastActionAt = m_lastActionAt;
// Base du recrédit budget = puissance ALLOUÉE de l'état (déclaré), pas la conso mesurée
// (états 1/2 → 0 ; états 3/4 → P3/P4). Cf. invariant 8.
t.currentPowerW = m_estimatedPowerW.value(m_currentState, 0.0);
// ECS-414 — INDÉTERMINATION : tant que des écritures sont en vol, on annonce la plus
// HAUTE des deux puissances allouées possibles. Ne jamais annoncer moins que ce qui peut
// être appliqué (même direction qu'ECS-410/411).
if (m_pending > 0)
t.currentPowerW = qMax(m_estimatedPowerW.value(m_statePrev, 0.0),
m_estimatedPowerW.value(m_stateTarget, 0.0));
return t;
}
LoadContext SgReadyAdapter::toLoadContext(const QDateTime &now) const
{
LoadContext ctx;
ctx.id = m_id;
ctx.adapter = QStringLiteral("sg-ready");
ctx.label = m_label;
ctx.priority = m_priority;
ctx.declared = descriptor().declared;
ctx.limits = descriptor().limits;
ctx.telemetry.currentPowerW = telemetry().currentPowerW;
ctx.telemetry.available = m_usable && !m_faulted;
ctx.telemetry.state = m_currentState;
ctx.telemetry.lastSwitch = m_lastSwitch;
if (m_faulted) {
// Charge FIGÉE : ni montée ni descente. Plancher == plafond, comme pour le routeur.
ctx.telemetry.minState = ctx.telemetry.maxState = m_currentState;
return ctx;
}
// Fenêtre de verrou évaluée au temps de cycle (protection court-cycling PAC).
lockWindow(now, ctx.telemetry.minState, ctx.telemetry.maxState);
return ctx;
}
LoadAction SgReadyAdapter::applyAction(const LoadAction &action, const QDateTime &now)
{
if (action.kind != LoadAction::State)
return action;
if (action.reason.isEmpty()) {
qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
<< "— LoadAction sans reason rejetée.";
return action;
}
if (!m_usable) {
LoadAction refused = action;
refused.state = m_currentState;
return refused; // encodage sans état 2 : aucune commande, cf. constructeur
}
// ECS-414 — en défaut, plus aucune commande, y compris forcée : trois tentatives ont
// déjà échoué, en réémettre masquerait l'état sans rien réparer.
if (m_faulted) {
LoadAction refused = action;
refused.state = m_currentState;
return refused;
}
// Écrêtage à un état déclaré (borne puis exigence d'appartenance).
int newState = qBound(m_states.first(), action.state, m_states.last());
if (!m_stateRelays.contains(newState)) {
qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
<< "— état non déclaré:" << action.state << "→ ignoré.";
return action;
}
// Règle 7-c — même défaut que celui corrigé au RelayRouter : ce retour était MUET, si bien
// que « état déjà bon » ne se distinguait pas de « action jamais reçue ». Une PAC qui ne
// bouge pas est exactement le cas où l'on vient lire le journal.
if (newState == m_currentState) {
qCInfo(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
<< "→ état" << newState << "INCHANGÉ (aucune commutation)"
<< (action.force ? "(force)" : "")
<< "|" << renderFr(action.reason, m_label);
return action; // Idempotent
}
// Verrou minStateHold évalué au temps de cycle (même fenêtre que le scheduler) —
// bypassé si force == true (L2 watchdog → état 2).
if (!action.force && lockActive(newState, now)) {
// Règle 7-c — un REFUS doit être au moins aussi visible que l'application
// correspondante. Ce refus était en qCDebug quand l'application juste en dessous est
// en qCInfo : un resserrement de la journalisation faisait disparaître le refus AVANT
// le succès. Or un refus est plus informatif qu'une application réussie — il dit
// qu'une décision a été prise et n'a PAS été exécutée.
qCInfo(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
<< "— verrou minStateHold actif, état" << newState
<< "REFUSÉ (état" << m_currentState << "maintenu).";
return action;
}
qCInfo(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
<< "→ état" << newState
<< "(" << m_estimatedPowerW.value(newState, 0.0) << "W estimés)"
<< "|" << renderFr(action.reason, m_label);
m_statePrev = m_currentState;
m_stateTarget = newState;
m_writeFailed = false;
applyStateRelays(readActualOn(), newState);
m_currentState = newState;
m_lastSwitch = now;
m_lastActionAt = now;
LoadAction applied = action;
applied.state = newState;
applied.estimatedPowerW = m_estimatedPowerW.value(newState, 0.0);
return applied;
}
LoadRuntimeView SgReadyAdapter::runtimeView(const QDateTime &now) const
{
LoadRuntimeView v;
v.mechanismKind = QStringLiteral("sgReady");
// L'état normé EST la grandeur commandée d'une PAC SG-Ready : 4 états constructeur, non
// exprimables en watts (c'est la raison d'être du kind State). La puissance qui l'accompagne
// est une ESTIMATION déclarée, jamais un engagement — le nom de la clé le dit.
v.mechanism.insert(QStringLiteral("state"), m_currentState);
v.mechanism.insert(QStringLiteral("estimatedPowerW"),
m_estimatedPowerW.value(m_currentState, 0.0));
// ECS-414 et ECS-110 — deux indisponibilités de natures opposées, deux codes. Un encodage
// sans état 2 refuse TOUTE commande depuis le constructeur : le taire ferait passer une
// PAC jamais pilotable pour une PAC au repos.
if (m_faulted)
v.faultCode = QStringLiteral("WRITE_FAILED");
else if (!m_usable)
v.faultCode = QStringLiteral("UNUSABLE_ENCODING");
// Verrou en secondes restantes, calculé sur le MÊME m_lastSwitch que lockWindow(). Un
// m_lastSwitch nul vaut « jamais commuté » et ne verrouille rien — convention identique
// à lockWindow(), pour que l'affichage ne contredise pas le comportement.
if (m_lastSwitch.isValid()) {
const qint64 elapsed = m_lastSwitch.secsTo(now);
if (elapsed < m_minStateHoldS) {
v.lockKind = QStringLiteral("minStateHold");
v.lockRemainingS = static_cast<int>(m_minStateHoldS - elapsed);
}
}
return v;
}
void SgReadyAdapter::applySafeState(const QDateTime &now)
{
// ECS-413 — état sûr d'une PAC = état 2 (normal, mains off). PAS l'état 1 : bloquer une
// PAC n'est pas la mettre en sécurité, c'est arrêter le chauffage sans raison visible
// (SAFETY.md, et même choix que le repli L2).
LoadAction sur;
sur.loadId = m_id;
sur.kind = LoadAction::State;
sur.state = 2;
sur.force = true;
sur.reason = { DecisionCode::SafeStateSgReady };
applyAction(sur, now); // force = true : minStateHoldS vaut 900 s, attendre est indéfendable
}
// ---- privé ---------------------------------------------------------------
void SgReadyAdapter::lockWindow(const QDateTime &now, int &minState, int &maxState) const
{
const int lo = m_states.first();
const int hi = m_states.last();
const bool valid = m_lastSwitch.isValid();
const qint64 elapsed = valid ? m_lastSwitch.secsTo(now) : 0;
if (valid && elapsed < m_minStateHoldS) {
// Gel total : la PAC doit tenir son état (protection court-cycling compresseur).
minState = maxState = m_currentState;
} else {
minState = lo;
maxState = hi;
}
}
bool SgReadyAdapter::lockActive(int newState, const QDateTime &now) const
{
// MÊME calcul que la fenêtre exposée au scheduler → décision et exécution coïncident.
int minState, maxState;
lockWindow(now, minState, maxState);
return newState < minState || newState > maxState;
}
int SgReadyAdapter::transientHarm(int state)
{
// Transitoire le plus doux d'abord : neutre (2) < recommandation (3) < blocage (1) < forcé (4).
switch (state) {
case 2: return 0; // neutre
case 3: return 1; // recommandation (run doux)
case 1: return 2; // blocage (coupe le chauffage)
case 4: return 3; // forcé (démarrage franc compresseur)
default: return 2; // combinaison hors-norme : prudence
}
}
int SgReadyAdapter::stateForRelays(const QList<QString> &onRelays) const
{
const QSet<QString> want(onRelays.begin(), onRelays.end());
for (auto it = m_stateRelays.constBegin(); it != m_stateRelays.constEnd(); ++it) {
const QSet<QString> s(it.value().begin(), it.value().end());
if (s == want)
return it.key();
}
return -1;
}
QSet<QString> SgReadyAdapter::allRelays() const
{
QSet<QString> all;
for (const auto &list : m_stateRelays)
for (const QString &id : list)
all.insert(id);
return all;
}
QSet<QString> SgReadyAdapter::readActualOn() const
{
QSet<QString> on;
for (const QString &id : allRelays()) {
Thing *relay = m_thingManager ? m_thingManager->findConfiguredThing(ThingId(id)) : nullptr;
if (!relay) {
// Contact INJOIGNABLE : son état est inconnu, on le suppose FERMÉ. Même choix
// conservateur qu'ECS-411 (relayrouter.cpp) — ne jamais supposer moins de
// puissance appliquée qu'il n'y en a peut-être. Corollaire utile : un repli vers
// un état ne demandant aucune écriture ne peut plus « réussir » à vide alors
// qu'on ne sait rien du matériel.
on.insert(id);
continue;
}
if (relay->stateValue("power").toBool())
on.insert(id);
}
return on;
}
void SgReadyAdapter::writeRelay(const QString &thingId, bool on)
{
Thing *relay = m_thingManager ? m_thingManager->findConfiguredThing(ThingId(thingId)) : nullptr;
if (!relay) {
qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label << "— relais non trouvé:" << thingId;
m_writeFailed = true;
return;
}
StateType powerStateType = relay->thingClass().stateTypes().findByName("power");
if (powerStateType.id().isNull()) {
relay->setStateValue("power", on); // repli mock : synchrone
return;
}
Action powerAction(powerStateType.id(), relay->id(), Action::TriggeredByRule);
powerAction.setParams(ParamList() << Param(powerStateType.id(), on));
ThingActionInfo *info = m_thingManager->executeAction(powerAction);
if (!info) {
m_writeFailed = true;
return;
}
// ECS-414 — même modèle qu'ECS-410 : on n'attend pas, le verdict arrive par signal, et
// `this` en contexte coupe proprement les callbacks si l'adaptateur meurt.
++m_pending;
connect(info, &ThingActionInfo::finished, this, [this, info]() {
if (info->status() != Thing::ThingErrorNoError) {
m_writeFailed = true;
qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
<< "— écriture contact en échec, status" << info->status();
}
if (--m_pending == 0)
settleTransition();
});
}
void SgReadyAdapter::settleTransition()
{
if (!m_writeFailed) {
m_statePrev = m_currentState;
m_stateTarget = m_currentState;
return;
}
m_writeFailed = false;
// Le repli part TOUJOURS du motif RÉELLEMENT lu : après un échec partiel, les deux bits
// peuvent former un état valide mais non voulu, voire un motif hors table.
//
// Il écrit DIRECTEMENT les contacts, sans repasser par applyAction() : le verrou
// minStateHoldS n'est donc jamais consulté — équivalent d'un force = true, comme le
// repli L2. Sur une PAC, minStateHoldS vaut 900 s : attendre un quart d'heure pour
// sortir d'un état non voulu n'est pas défendable.
const QSet<QString> reel = readActualOn();
switch (m_phase) {
case PhaseNominale:
qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
<< "— échec d'écriture : retour à l'état" << m_statePrev << ".";
m_phase = PhaseRepli;
m_currentState = m_statePrev;
applyStateRelays(reel, m_statePrev);
break;
case PhaseRepli:
// PLANCHER d'une PAC : l'ÉTAT 2, pas « contacts ouverts ». Ouvrir les deux contacts
// est une COMMANDE, et selon l'encodage câblé ce peut être le BLOCAGE (SAFETY.md).
qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
<< "— retour arrière en échec : repli sur l'état 2 (normal).";
m_phase = PhasePlancher;
m_currentState = 2;
m_stateTarget = 2;
applyStateRelays(reel, 2);
break;
case PhasePlancher:
case PhaseDefaut:
qCCritical(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
<< "— repli état 2 en échec : PAC EN DÉFAUT, plus aucune"
<< "commande. Levée par NymeaEnergy.ClearLoadFault.";
m_phase = PhaseDefaut;
m_faulted = true;
break;
}
}
bool SgReadyAdapter::clearFault()
{
if (!m_faulted)
return false; // rien à lever — l'arbitre le dira (règle 7-c)
qCInfo(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
<< "— défaut levé par l'opérateur (ClearLoadFault).";
m_faulted = false;
m_phase = PhaseNominale;
m_writeFailed = false;
// État RELU, jamais supposé : le motif 2 bits peut être valide mais non voulu.
const int lu = stateForRelays(readActualOn().values());
if (lu > 0)
m_currentState = lu;
m_statePrev = m_stateTarget = m_currentState;
return true;
}
void SgReadyAdapter::applyStateRelays(const QSet<QString> &currentOn, int toState)
{
const QList<QString> targetList = m_stateRelays.value(toState);
const QSet<QString> targetOn(targetList.begin(), targetList.end());
// Relais dont l'état change lors de la transition.
//
// Un contact INJOIGNABLE est toujours inclus, même si l'état supposé coïncide avec la
// cible : on ne peut pas le vérifier, donc on doit le COMMANDER. Sans cela, l'hypothèse
// conservatrice « injoignable = fermé » masquerait l'échec — la transition paraîtrait
// réussie sans qu'aucune écriture n'ait été tentée sur le contact en panne.
QStringList changed;
for (const QString &relay : allRelays()) {
const bool verifiable = m_thingManager && m_thingManager->findConfiguredThing(ThingId(relay));
if (!verifiable || targetOn.contains(relay) != currentOn.contains(relay))
changed << relay;
}
// Contrat d'atomicité : si 2 relais (ou +) changent, commuter d'abord celui dont le
// TRANSITOIRE est le plus doux (neutre/reco plutôt que blocage/forcé), puis les autres.
// Vaut pour le chemin ALLER **comme** pour le REPLI (ECS-414) : une récupération qui
// traverserait le blocage serait plus dangereuse que la panne qu'elle corrige.
if (changed.size() >= 2) {
QString best;
int bestHarm = INT_MAX;
for (const QString &r : changed) {
QSet<QString> transient = currentOn;
if (targetOn.contains(r)) transient.insert(r); else transient.remove(r);
const int h = transientHarm(stateForRelays(transient.values()));
if (h < bestHarm) { bestHarm = h; best = r; }
}
writeRelay(best, targetOn.contains(best));
changed.removeAll(best);
}
// Relais restants amenés à leur valeur cible.
for (const QString &r : changed)
writeRelay(r, targetOn.contains(r));
// Chemin entièrement synchrone (mock) : aucun acquittement n'arrivera, verdict immédiat.
if (m_pending == 0)
settleTransition();
}