Même modèle qu'ECS-410, rien de nouveau conçu : m_pending, verdict à zéro,
échelle bornée à trois barreaux, `this` en contexte de connexion.
PLANCHER PROPRE À CHAQUE ADAPTATEUR. Pour EtmVariableLoadAdapter, consigne 0 W.
Pour SgReadyAdapter, le barreau 3 est l'ÉTAT 2 — pas « tous contacts ouverts ».
Ouvrir les deux contacts d'une PAC est une COMMANDE, et selon l'encodage câblé
c'est potentiellement le BLOCAGE, donc l'inverse d'une mise en sécurité.
ATOMICITÉ DU REPLI — le point qui n'existait pas sur le routeur relais. Un état
SG-Ready est porté par deux bits ; si une écriture échoue, on est dans un motif
valide mais NON VOULU, et le retour depuis ce motif peut exiger de traverser le
blocage. applyStateRelays() prend désormais un ENSEMBLE de relais réellement
fermés au lieu d'un index d'état, de sorte que transientHarm ordonne le repli
exactement comme il ordonne l'aller. Sans cela, la récupération serait plus
dangereuse que la panne qu'elle corrige.
Le repli écrit les contacts directement, sans repasser par applyAction : le verrou
minStateHoldS n'est donc jamais consulté — équivalent d'un force = true, comme le
repli L2. À 900 s sur une PAC, attendre un quart d'heure pour sortir d'un état non
voulu ne serait pas défendable.
DEUX INCOHÉRENCES TROUVÉES PAR LE TEST, et corrigées :
1. readActualOn() traitait un contact injoignable comme OUVERT, alors qu'ECS-411
le suppose FERMÉ. Conséquence : un repli ne demandant aucune écriture
« réussissait » à vide alors qu'on ne savait rien du matériel.
2. Corollaire inverse : une fois supposé fermé, un contact injoignable n'était
plus jamais écrit quand la cible le voulait fermé — l'échec était masqué et
la transition paraissait réussie sans qu'aucune écriture n'ait été tentée.
Un contact non vérifiable est désormais TOUJOURS commandé.
Test testSgReadyPartialFailure. Ce qu'il verrouille n'est pas le défaut mais le
PLANCHER : le repli ramène K1 à l'ouverture, soit l'état 2 sur cet encodage. Un
plancher « contacts ouverts par principe » aurait pu, sur un autre câblage, valoir
l'état 1 — le blocage. Vérifie aussi que minStateHoldS = 900 s ne retarde pas le
repli, que la charge figée a plancher == plafond, et que la levée relit l'état.
Une correction de mon propre scénario au passage : j'avais écrit un cas
« récupération au barreau 2 » qui n'est pas constructible avec un contact
définitivement injoignable — rien ne peut alors être confirmé, et le défaut est
l'issue honnête.
Build amd64 0 erreur. Simulation : 18/18.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
391 lines
15 KiB
C++
391 lines
15 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());
|
|
Q_ASSERT(!m_states.isEmpty());
|
|
Q_ASSERT(m_stateRelays.contains(2)); // état 2 (normal) = repli sûr obligatoire
|
|
}
|
|
|
|
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_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_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;
|
|
}
|
|
|
|
// 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> ¤tOn, 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();
|
|
}
|