Le bloc d'enregistrement codé en dur d'energypluginnymea.cpp est supprimé, avec registerSgReadyAdapter(), la table m_sgReadyAdapters et claimedRelays(). Il n'existe plus aucun chemin d'enregistrement en dur : m_loadAdapters est la table UNIQUE des charges arbitrées, et toutes ses entrées viennent de LoadConfigStore. Contrat : loads[] de GetLoadTelemetry est désormais INCLUS dans GetLoadConfig, par CONSTRUCTION et non par discipline. La règle de détection publiée à +etm14 — « un loadId sans équivalent dans GetLoadConfig est une charge non configurable » — est retirée d'INTERFACE.md : elle n'avait qu'un porteur, pac-terrain, qui vient de disparaître. Aucun configurable:false n'est ajouté, il n'aurait plus rien à signaler. L'inclusion ne vaut que dans ce sens : une charge enabled:false est configurée sans être publiée. SÉCURITÉ — repli L2 d'une PAC de configuration, trou ouvert par LM-300 et refermé ici. Le repli envoyait un Setpoint(0) à toute la table et un State(2) à une table séparée réservée à la PAC en dur ; une PAC construite depuis la CONFIG vivant dans la première recevait donc une action que SgReadyAdapter rejette d'entrée, et rien d'autre — elle restait en état 4 sous compteur muet quand SAFETY.md §L2 exige l'état 2. Le motif DEGRADED_L2 s'affichait correctement : seule l'écriture manquait. Le repli lit maintenant supportedKinds (LM-301-b). ECS-411/-411-b au SG-Ready : l'état de départ est relu sur les contacts, avec relevé contact par contact et l'issue — y compris quand l'issue est l'état neutre. ECS-412 au minStateHold : armement paresseux au démarrage à froid, jamais permanent. Harnais de test : JournalCapture se chaînait sur elle-même (récursion infinie à croissance quadratique dès la deuxième capture vivante, donc OOM) ; tm/owner non initialisés sur trois sites de freshSetup(). Vérifié sur .75 avant publication — docs/RELEVE_LOTBbis.md. Suites : simulation 85, charging 74, spotmarket 44, zéro échec, un processus par test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
568 lines
25 KiB
C++
568 lines
25 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;
|
|
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;
|
|
}
|
|
|
|
// 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 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 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();
|
|
}
|