SgReadyAdapter::applyAction() renvoyait, quand le verrou minStateHold refusait la transition, l'action DEMANDÉE telle quelle. Un appelant qui relisait `state` y retrouvait l'état qu'il venait de demander et concluait au succès — alors que rien n'avait été écrit et que les contacts n'avaient pas bougé. Ce n'est pas un défaut du délestage, c'est un défaut de contrat. N'importe quelle descente d'état pouvait échouer en silence, et `available` restait vrai : la charge avait l'air saine pendant que la PAC tirait 3 kW. Même famille qu'ECS-111 — une issue indiscernable de son contraire — transposée au retour d'une commande au lieu de la télémétrie. Toute sortie décrit désormais l'état RÉELLEMENT tenu, `estimatedPowerW` compris. L'idempotence n'y échappe pas : demander « état 4, 0 W » à une PAC qui en tire 3000 doit rendre 3000, pas le zéro de l'appelant — sinon il lit son enveloppe, pas une réponse. Même correction sur la branche « motif vide » du RelayRouter et de l'EtmVariableLoadAdapter. Le défaut était LATENT : aucun appelant ne lisait ce retour, le dispatch l'ignore. Le premier à s'y fier fut le délestage L4 en cours d'écriture, qui a publié 3 kW rendus par une PAC restée en état 4. Un contrat qu'aucun appelant n'exerce n'est pas un contrat tenu, c'est un contrat non testé. Deux pistes écartées, mesurées et non supposées. m_currentState n'était pas en cause : il est écrit synchroniquement, sans attendre d'acquittement. ECS-411-b non plus : la relecture des contacts répond à une divergence entre état interne et matériel, et il n'y en avait aucune — l'état interne était juste, c'est le retour qui mentait. Les trois pièges rencontrés à l'étape 4 sont consignés dans DESIGN_DELESTAGE §6bis, les deux placements écartés compris. L'étape 4 elle-même reste non livrée. Suite complète : simulation 53/53, charging 48/48, loadmodel 21/21, spotmarket 32/32, doxygen 0 avertissement. Contre-épreuve : sur l'ancien retour, refuse.state vaut 2 — l'état demandé — au lieu de 4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
589 lines
26 KiB
C++
589 lines
26 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 <QStringList>
|
|
#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.";
|
|
return; // rien à relire : aucun état ne sera jamais commandé.
|
|
}
|
|
|
|
// ECS-411 — REPRISE DE L'ÉTAT depuis les contacts RÉELS. Partir de l'état 2 par défaut,
|
|
// c'est le moteur qui croit la PAC au repos pendant qu'elle tourne en forcé : même famille
|
|
// de défaut que le palier ECS repris à 0 alors que 3 kW circulent.
|
|
//
|
|
// Le cas VRAIMENT méchant n'est pas l'annonce fausse, c'est son corollaire (LM-104) :
|
|
// applyAction(état 2) sur un adaptateur qui se CROIT déjà en 2 est idempotent, donc MUET,
|
|
// donc les contacts restent en 4. L'hypothèse de prudence deviendrait un masquage de panne.
|
|
// Relire supprime les deux d'un coup.
|
|
m_currentState = deduceStateFromRelays();
|
|
m_statePrev = m_stateTarget = m_currentState;
|
|
}
|
|
|
|
int SgReadyAdapter::deduceStateFromRelays() const
|
|
{
|
|
// ECS-411-b — journaliser ce qui a été LU, pas seulement ce qui en est déduit. « état repris
|
|
// 2 » ne dit pas si les contacts étaient ouverts ou si l'un d'eux était injoignable et
|
|
// supposé fermé : deux situations qui appellent des gestes opposés. On publie donc le relevé
|
|
// contact par contact AVEC l'issue — y compris quand l'issue est l'état neutre, qui est
|
|
// justement celui qu'on vient vérifier après un démarrage.
|
|
const QSet<QString> tous = allRelays();
|
|
QSet<QString> fermes;
|
|
bool injoignable = false;
|
|
QStringList lecture;
|
|
for (const QString &thingId : tous) {
|
|
const QString court = thingId.left(8);
|
|
Thing *relay = m_thingManager ? m_thingManager->findConfiguredThing(ThingId(thingId)) : nullptr;
|
|
if (!relay) {
|
|
// Contact INJOIGNABLE : état inconnu, supposé FERMÉ (LM-104, même choix
|
|
// conservateur que readActualOn() et qu'ECS-411 côté routeur).
|
|
injoignable = true;
|
|
fermes.insert(thingId);
|
|
lecture << QStringLiteral("%1=INJOIGNABLE→supposé fermé").arg(court);
|
|
continue;
|
|
}
|
|
const bool ferme = relay->stateValue("power").toBool();
|
|
if (ferme)
|
|
fermes.insert(thingId);
|
|
lecture << QStringLiteral("%1=%2").arg(court,
|
|
ferme ? QStringLiteral("fermé") : QStringLiteral("ouvert"));
|
|
}
|
|
lecture.sort(); // ordre stable : QSet n'en garantit aucun, et un relevé se compare
|
|
|
|
int etat = -1;
|
|
QString issue;
|
|
|
|
// 1) Correspondance EXACTE du motif de contacts avec un état déclaré : cas nominal. Avec
|
|
// un encodage 2 bits complet (00/01/10/11), c'est le seul cas atteignable.
|
|
for (auto it = m_stateRelays.constBegin(); it != m_stateRelays.constEnd() && etat < 0; ++it) {
|
|
const QSet<QString> s(it.value().begin(), it.value().end());
|
|
if (s == fermes) {
|
|
etat = it.key();
|
|
issue = QStringLiteral("correspondance exacte");
|
|
}
|
|
}
|
|
|
|
// 2) Motif NON DÉCLARÉ — possible seulement sur un encodage partiel (tous les états ne
|
|
// sont pas décrits). On ne sait pas dans quel état est la PAC. On retient l'état
|
|
// déclaré de plus forte puissance estimée : ne JAMAIS annoncer moins que ce qui peut
|
|
// être appliqué (règle d'ECS-411). Le premier applyAction normalisera les contacts.
|
|
if (etat < 0) {
|
|
double pire = -1.0;
|
|
for (int s : m_states) {
|
|
const double p = m_estimatedPowerW.value(s, 0.0);
|
|
if (p > pire) { pire = p; etat = s; }
|
|
}
|
|
issue = QStringLiteral("motif de contacts NON DÉCLARÉ — état de plus forte puissance "
|
|
"estimée retenu (normalisé au premier cycle)");
|
|
}
|
|
|
|
const QString releve = QStringLiteral("[SgReadyAdapter] %1 — reprise au démarrage : état %2 "
|
|
"(~%3 W estimés) [%4] ; contacts lus : %5")
|
|
.arg(m_label).arg(etat)
|
|
.arg(qRound(m_estimatedPowerW.value(etat, 0.0))).arg(issue)
|
|
.arg(lecture.isEmpty() ? QStringLiteral("(aucun contact déclaré)")
|
|
: lecture.join(QStringLiteral(", ")));
|
|
|
|
// Un contact injoignable rend la reprise incertaine : elle mérite un avertissement, pas une
|
|
// information. Le relevé est le même, seul le niveau change.
|
|
if (injoignable)
|
|
qCWarning(dcNymeaEnergy()).noquote() << releve << "— état INCERTAIN (contact injoignable).";
|
|
else
|
|
qCInfo(dcNymeaEnergy()).noquote() << releve;
|
|
|
|
return etat;
|
|
}
|
|
|
|
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;
|
|
// §12 / LM-1202 — plancher DÉRIVÉ : la plus petite estimation non nulle des états
|
|
// commandables. Le mapping d'états l'applique déjà implicitement (un état se prend
|
|
// entier ou pas du tout) ; le déclarer rend le modèle uniforme et lisible de l'extérieur.
|
|
double plancher = 0;
|
|
for (auto it = m_estimatedPowerW.constBegin(); it != m_estimatedPowerW.constEnd(); ++it)
|
|
if (it.value() > 0 && (plancher == 0 || it.value() < plancher))
|
|
plancher = it.value();
|
|
d.declared.minPowerW = plancher;
|
|
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;
|
|
}
|
|
|
|
/*!
|
|
* \brief Retour décrivant l'état RÉELLEMENT tenu — forme unique de tout refus (ECS-415).
|
|
*
|
|
* Un appelant doit pouvoir distinguer « appliqué » de « refusé » en lisant la valeur de retour,
|
|
* et par le MÊME test dans les deux cas. Renvoyer l'action demandée telle quelle rendait un
|
|
* refus indiscernable d'un succès : le lecteur y retrouvait l'état qu'il venait de demander.
|
|
*/
|
|
LoadAction SgReadyAdapter::inchange(const LoadAction &action) const
|
|
{
|
|
LoadAction refuse = action;
|
|
refuse.state = m_currentState;
|
|
refuse.estimatedPowerW = m_estimatedPowerW.value(m_currentState, 0.0);
|
|
return refuse;
|
|
}
|
|
|
|
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 inchange(action);
|
|
}
|
|
|
|
if (!m_usable)
|
|
return inchange(action); // 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)
|
|
return inchange(action);
|
|
|
|
// ECS-412 — l'armement paresseux se DÉSARME ici, dans le chemin NON-const qui reçoit le
|
|
// temps de cycle : au premier \c now vu, on estampille, et le verrou expire alors
|
|
// naturellement. Sans ça l'armement de \c lockWindow() serait permanent. L'invariant
|
|
// « temps = paramètre, jamais l'horloge » est préservé : aucune horloge n'est lue.
|
|
if (!m_lastSwitch.isValid())
|
|
m_lastSwitch = now;
|
|
|
|
// É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 inchange(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);
|
|
// ECS-415 — même pour l'idempotence : l'état est bien celui-là, mais la puissance
|
|
// estimée doit venir de l'adaptateur, pas de ce que l'appelant avait mis dans son
|
|
// enveloppe. Un appelant qui demande « état 4, 0 W » et se voit rendre « 0 W » sur une
|
|
// PAC qui en tire 3000 lit son propre chiffre, pas une réponse.
|
|
return inchange(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 inchange(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 et la MÊME convention
|
|
// d'armement que lockWindow() — y compris le démarrage à froid, où un m_lastSwitch nul
|
|
// vaut « commutation venant d'avoir lieu » (ECS-412). L'affichage ne peut donc pas
|
|
// contredire le comportement : le client lit le verrou qui a réellement borné la décision.
|
|
const qint64 elapsed = m_lastSwitch.isValid() ? m_lastSwitch.secsTo(now) : 0;
|
|
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();
|
|
|
|
// ECS-412 — ARMEMENT PARESSEUX au démarrage à froid, symétrique du RelayRouter.
|
|
// Un \c m_lastSwitch nul signifie « on ne sait pas quand la dernière commutation a eu
|
|
// lieu » (redémarrage de nymead, reconstruction depuis la config) — PAS « il n'y en a
|
|
// jamais eu ». Le défaut SÛR est donc d'ARMER : traiter la commutation comme venant
|
|
// d'avoir lieu, et laisser le verrou expirer.
|
|
//
|
|
// Ce que ça évite : la PAC tourne en forcé depuis deux minutes, nymead redémarre, le
|
|
// budget a chuté — sans armement le moteur la coupe immédiatement, et c'est du
|
|
// court-cycling compresseur provoqué par un redémarrage de service. Or le verrou
|
|
// minStateHold n'existe que pour ça.
|
|
//
|
|
// Cet armement est TRANSITOIRE : applyAction() estampille \c m_lastSwitch au premier
|
|
// \c now reçu, si bien que le verrou expire après sa durée configurée. Il ne doit JAMAIS
|
|
// devenir permanent — c'est le défaut corrigé côté routeur le 2026-08-09, où une charge
|
|
// démarrant contacts ouverts avec un verrou armé en permanence ne pouvait plus jamais
|
|
// s'enclencher (blocage circulaire : pas d'enclenchement → pas de commutation → pas
|
|
// d'estampille). Les replis de sécurité passent en \c force = true et le bypassent.
|
|
const qint64 elapsed = m_lastSwitch.isValid() ? m_lastSwitch.secsTo(now) : 0;
|
|
|
|
if (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> ¤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();
|
|
}
|