Patrick Schurig 42cb2f080b fix(sg-ready): ECS-110 sans Q_ASSERT — refus explicite au lieu d'une assertion muette
Les deux Q_ASSERT de SgReadyAdapter (states non vide, état 2 présent) gardaient un
invariant de configuration. QT_NO_DEBUG les retire du binaire livré : le jour où la
PAC passe à la configuration, ils ne gardent plus rien chez le client.

Ils sont donc retirés AVANT ce basculement, remplacés par un drapeau m_usable calculé
à la construction : journalisation critique, refus de toute commande, available faux.
L'état 2 est le plancher de repli du mode dégradé L2 et de la désactivation (ECS-413,
ECS-414) — une PAC qui ne peut pas l'exprimer ne doit pas piloter.

Ajoute aussi claimedRelays(), accesseur des contacts revendiqués, consommé par le
commit qui branche la fabrique depuis la configuration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:34:39 +02:00

410 lines
16 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;
}
if (newState == m_currentState)
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)) {
qCDebug(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
<< "— verrou minStateHold actif, état" << newState << "ignoré.";
return action;
}
qCInfo(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
<< "→ état" << newState
<< "(" << m_estimatedPowerW.value(newState, 0.0) << "W estimés)"
<< "|" << action.reason;
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;
}
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 = QStringLiteral("Charge désactivée — mise en état sûr (état 2) avant retrait (ECS-413)");
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;
}
}
void SgReadyAdapter::clearFault()
{
if (!m_faulted)
return;
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;
}
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();
}