Patrick Schurig cd1a0c83a5 fix: ECS-410-b — l'issue de ClearLoadFault, et l'asymétrie refus/succès (règles 7-c, 7-d)
ClearLoadFault est le SEUL levier de reprise à distance : ECS-410 pose un verrou collant que
seuls ce RPC ou une reconstruction lèvent. L'arbitre journalisait la demande de l'opérateur,
jamais son issue — si bien qu'appelé depuis l'étranger, on ne distinguait pas « le défaut a
été levé » de « il n'y en avait pas » de « l'appel n'a pas atteint l'adaptateur ». L'opérateur
relance trois fois et conclut que le système est cassé.

L'issue est écrite dans l'ARBITRE, pas dans chaque adaptateur : une ligne là couvre les quatre
d'un coup et ne peut pas être oubliée par un adaptateur futur ; répétée dans chacun, elle
manquerait le cinquième. Les trois cas sont distincts à la lecture.

ILoadAdapter::clearFault() rend un bool au lieu de void. Le type de retour force chaque
implémentation, présente et future, à répondre à la question « un défaut a-t-il réellement été
levé ». EvAdapter, qui n'a pas de mécanisme de défaut, le dit désormais explicitement plutôt
que par un corps vide.

Règle 7-d — un refus doit être au moins aussi visible que l'application correspondante. Le
refus par verrou minStateHold de SgReadyAdapter était en qCDebug quand l'application, dix
lignes plus bas, était en qCInfo. L'asymétrie est le défaut, pas le niveau : un resserrement
de la journalisation — et on vient d'en faire un pour tenir deux semaines — aurait fait
disparaître le refus AVANT le succès, ne laissant au journal que les décisions abouties. 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. Vérification faite sur les autres adaptateurs : c'était le seul cas. Les
qCDebug restants sont symétriques, et le marqueur L2 par cycle reste en debug à dessein, son
entrée étant en qCWarning et sa sortie en qCInfo.

Le retour idempotent du routeur reste DIFFÉRÉ, et ira en qCDebug : c'est le cas le plus
fréquent — plusieurs milliers de lignes par jour sur un budget ramené à 6,5 Mo — et son
ambiguïté est partiellement couverte, le scheduler journalisant sa décision et l'armement à
froid s'exécutant avant ce retour. Faible gain, coût réel : l'inverse des deux cas corrigés.

Le test porte sur le TEXTE des trois issues et vérifie qu'elles sont deux à deux distinctes.
Suite complète : 112 tests, 0 échec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 20:27:53 +02:00

546 lines
23 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "relayrouter.h"
#include "plugininfo.h"
#include <QDateTime>
#include <QMap>
#include <QSet>
#include <integrations/thingmanager.h>
#include <integrations/thing.h>
#include <types/action.h>
#include <types/param.h>
#include <integrations/thingactioninfo.h>
namespace {
//! Au-delà, 2^N combinaisons explose. 16 relais = 65536 combos — bien au-delà de tout ECS réel.
constexpr int MaxRelays = 16;
}
RelayRouter::RelayRouter(ThingManager *thingManager,
const QString &id,
const QString &label,
const QList<LoadConfigRelay> &relays,
int minOnS,
int minOffS,
int priority,
const LoadNeeds &needs,
QObject *parent)
: QObject(parent)
, m_thingManager(thingManager)
, m_id(id)
, m_label(label)
, m_minOnS(minOnS)
, m_minOffS(minOffS)
, m_priority(priority)
, m_needs(needs)
{
int n = relays.size();
if (n > MaxRelays) {
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label << "" << n
<< "relais > max" << MaxRelays << ": tronqué.";
n = MaxRelays;
}
// Nominale par relais, prise à la DÉCLARATION. Voir m_relayNominalW dans l'en-tête :
// la redéduire des encodages solo la perd pour tout relais dont la puissance est
// aussi atteignable par une combinaison.
for (int i = 0; i < n; ++i)
m_relayNominalW.insert(relays.at(i).thingId, relays.at(i).powerW);
// Paliers DÉRIVÉS : toutes les sommes de sous-ensembles, dédupliquées par puissance, triées
// (QMap = clés croissantes), 0 inclus (sous-ensemble vide = masque 0). Pour des puissances
// identiques (ex. deux relais 1000 W), on garde la première combinaison rencontrée.
QMap<int, QList<QString>> byPower;
for (int mask = 0; mask < (1 << n); ++mask) {
int sum = 0;
QList<QString> set;
for (int i = 0; i < n; ++i) {
if (mask & (1 << i)) {
sum += relays.at(i).powerW;
set.append(relays.at(i).thingId);
}
}
if (!byPower.contains(sum))
byPower.insert(sum, set);
}
for (auto it = byPower.constBegin(); it != byPower.constEnd(); ++it) {
m_levels.append(it.key());
m_relayMapping.append(it.value());
}
// byPower contient toujours la clé 0 (masque vide) → m_levels[0] == 0.
Q_ASSERT(!m_levels.isEmpty() && m_levels.first() == 0);
// ECS-411 — reprise du palier depuis l'état RÉEL des relais. Partir de 0 alors que des
// contacts sont fermés, c'est le moteur qui croit 0 W pendant que le ballon tire 3 kW :
// même famille de défaut qu'ECS-410 (annoncer une puissance non appliquée).
m_currentStage = deduceStageFromThings();
}
LoadDescriptor RelayRouter::descriptor() const
{
LoadDescriptor d;
d.id = m_id;
d.label = m_label;
d.adapter = QStringLiteral("relay-router");
d.priority = m_priority;
// Paliers DÉRIVÉS exposés à l'optimiseur (watts) — JAMAIS les relais.
d.declared.powerLevels = m_levels;
d.declared.maxPowerW = m_levels.isEmpty() ? 0 : m_levels.last();
d.limits.minOnS = m_minOnS;
d.limits.minOffS = m_minOffS;
d.needs = m_needs;
d.supportedKinds = { LoadAction::Setpoint };
return d;
}
LoadTelemetry RelayRouter::telemetry() const
{
LoadTelemetry t;
t.available = !m_faulted; // ECS-410 : en défaut, la charge sort de l'ARBITRAGE…
t.lastActionAt = m_lastActionAt;
// currentPowerW :
// - MESURÉ dès qu'au moins un relais du palier expose un state "currentPower" (somme).
// Un thermostat coupé (relais ON mais 0 W) → 0, jamais de puissance fantôme.
// - NOMINAL commandé (repli) si aucun relais actif ne mesure (GPIO bool nu). Dans ce cas
// currentPowerW reflète le COMMANDÉ, pas le mesuré (contrat rév. 3 §3.a).
double power = 0;
bool metered = false;
const QList<QString> activeRelays = m_currentStage < m_relayMapping.size()
? m_relayMapping.at(m_currentStage)
: QList<QString>();
for (const QString &thingId : activeRelays) {
Thing *relay = m_thingManager->findConfiguredThing(ThingId(thingId));
if (!relay)
continue;
if (!relay->thingClass().stateTypes().findByName("currentPower").id().isNull()) {
metered = true;
power += relay->stateValue("currentPower").toDouble();
}
}
if (!metered && m_currentStage > 0 && m_currentStage < m_levels.size())
power = m_levels.at(m_currentStage); // nominal commandé
// ECS-410 — INDÉTERMINATION. Tant que des écritures sont en vol, on ne sait pas ce qui est
// réellement fermé : on annonce la PLUS HAUTE des deux puissances possibles. Même direction
// qu'ECS-411 (relais injoignable supposé fermé) : ne JAMAIS annoncer moins que ce qui peut
// être appliqué. Sous-estimer fait sur-allouer les charges suivantes et soutirer au réseau ;
// surestimer ne fait que retarder une montée.
if (!metered && m_pending > 0) {
const int hi = qMax(m_stagePrev, m_stageTarget);
if (hi >= 0 && hi < m_levels.size())
power = qMax(power, static_cast<double>(m_levels.at(hi)));
}
// …mais PAS de la COMPTABILITÉ : une puissance qu'on ne sait plus couper reste consommée.
// La retirer du bilan offrirait aux charges suivantes un budget qui n'existe pas.
t.currentPowerW = power;
return t;
}
LoadContext RelayRouter::toLoadContext(const QDateTime &now) const
{
LoadContext ctx;
ctx.id = m_id;
ctx.adapter = QStringLiteral("relay-router");
ctx.label = m_label;
ctx.priority = m_priority;
ctx.declared = descriptor().declared; // powerLevels/maxPowerW dérivés (watts)
ctx.needs = m_needs;
ctx.telemetry.currentPowerW = telemetry().currentPowerW;
// ECS-306 — fenêtre de verrou exposée au scheduler, EN WATTS. Aucun index de palier ni
// identifiant de relais ne franchit la frontière (rév. 3) : on convertit les bornes
// internes en puissances via la table de paliers. Sans ce canal, le scheduler décrémente
// le budget d'une valeur que l'adaptateur va écrêter, et les charges suivantes reçoivent
// un résidu surestimé pendant toute la fenêtre minOn (AGENTS règle 4).
ctx.telemetry.available = !m_faulted;
if (m_faulted) {
// ECS-410 — charge FIGÉE, ni montée ni descente : plancher ET plafond à la puissance
// crue engagée. Un plafond nul avec un plancher non nul serait contradictoire et
// ferait dérailler le qBound du scheduler.
ctx.telemetry.lockMinPowerW = ctx.telemetry.currentPowerW;
ctx.telemetry.lockMaxPowerW = ctx.telemetry.currentPowerW;
return ctx;
}
int minStage, maxStage;
lockWindow(now, minStage, maxStage);
ctx.telemetry.lockMinPowerW = m_levels.at(minStage);
ctx.telemetry.lockMaxPowerW = m_levels.at(maxStage);
return ctx;
}
LoadAction RelayRouter::applyAction(const LoadAction &action, const QDateTime &now)
{
if (action.kind != LoadAction::Setpoint)
return action;
if (action.reason.isEmpty()) {
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label
<< "— LoadAction sans reason rejetée.";
return action;
}
// ECS-410 — en défaut, on CESSE TOUTE COMMANDE, y compris les replis forcés : le matériel
// n'a pas répondu à trois tentatives, en réémettre n'apporterait rien et masquerait l'état.
if (m_faulted) {
LoadAction refused = action;
refused.powerW = refused.estimatedPowerW = currentSetpointW();
return refused;
}
// ECS-412 — ARMEMENT PARESSEUX du verrou (démarrage à froid). Au premier \c now reçu,
// on estampille la « dernière commutation » : le verrou est donc armé pour sa durée
// configurée, puis EXPIRE naturellement.
//
// Sans cela, un \c m_lastSwitch nul faisait office de sentinelle « elapsed = 0 » à chaque
// cycle, donc un verrou PERMANENT : une charge démarrant au palier 0 avec minOffS > 0 ne
// pouvait jamais s'enclencher, donc jamais commuter, donc jamais valider m_lastSwitch —
// blocage circulaire. Constaté au banc le 2026-08-09 (charge sonde figée à 0 W sous
// 4 kW de surplus disponible).
//
// L'armement vit ICI, dans le chemin NON-const qui reçoit le temps de cycle, et non dans
// lockWindow() qui est const et ne fait que calculer. L'invariant « temps = paramètre,
// jamais l'horloge » (iloadadapter.h) est préservé : aucune horloge n'est lue.
if (!m_lastSwitch.isValid())
m_lastSwitch = now;
int newStage = stageForPower(action.powerW);
// Verrou anti-rebond INTERNE (clamp), au temps de cycle. Bypass si force==true (repli L2).
if (!action.force) {
int minStage, maxStage;
lockWindow(now, minStage, maxStage);
newStage = qBound(minStage, newStage, maxStage);
}
LoadAction applied = action;
applied.powerW = m_levels.at(newStage);
applied.estimatedPowerW = m_levels.at(newStage);
if (newStage == m_currentStage)
return applied; // idempotent — pas de re-commutation
qCInfo(dcNymeaEnergy()) << "[RelayRouter]" << m_label
<< "→ consigne" << qRound(action.powerW) << "W → palier"
<< m_levels.at(newStage) << "W"
<< (action.force ? "(force)" : "")
<< "|" << action.reason;
applyRelayStage(newStage);
m_currentStage = newStage;
m_lastSwitch = now;
m_lastActionAt = now;
return applied;
}
// ---- privé ---------------------------------------------------------------
int RelayRouter::stageForPower(double powerW) const
{
int stage = 0; // m_levels[0] == 0 garanti
for (int i = 0; i < m_levels.size(); ++i) {
if (m_levels.at(i) <= powerW)
stage = i;
else
break; // m_levels triés croissants
}
return stage;
}
void RelayRouter::updateSoftConfig(int priority, const LoadNeeds &needs)
{
// ECS-412 — mise à jour EN PLACE : ni m_currentStage ni m_lastSwitch ne bougent,
// aucun relais n'est réécrit.
m_priority = priority;
m_needs = needs;
}
void RelayRouter::lockWindow(const QDateTime &now, int &minStage, int &maxStage) const
{
const int topStage = m_levels.size() - 1;
// ECS-412 — DÉMARRAGE À FROID. m_lastSwitch nul signifie « on ne sait pas quand la
// dernière commutation a eu lieu » (redémarrage de nymead, reconstruction). Le défaut
// SÛR est d'ARMER le verrou — traiter la commutation comme venant d'avoir lieu, donc
// elapsed = 0 — et non de le purger. L'écriture naturelle (`valid && elapsed < minOnS`)
// fait l'inverse et laisserait une boucle de redémarrage court-circuiter la protection
// compresseur exactement quand elle est la plus nécessaire.
//
// Cet armement est TRANSITOIRE : applyAction() estampille m_lastSwitch au premier \c now
// reçu, si bien que le verrou expire après sa durée configurée. Le repli ci-dessous ne
// couvre donc que les cycles précédant la première action — typiquement le premier
// toLoadContext(). Il ne doit JAMAIS devenir un état permanent : c'était le défaut
// corrigé le 2026-08-09 (cf. testEcsColdStartLockExpires).
//
// Armer ne signifie PAS verrouiller inconditionnellement : avec une durée nulle,
// `0 < 0` est faux et le verrou reste inactif.
const qint64 elapsed = m_lastSwitch.isValid() ? m_lastSwitch.secsTo(now) : 0;
const bool onHeld = elapsed < m_minOnS;
const bool offHeld = elapsed < m_minOffS;
// Plancher : si ON et minOn non écoulé → interdit de descendre (puissance engagée non-coupable).
minStage = (m_currentStage > 0 && onHeld) ? m_currentStage : 0;
// Plafond : si à l'arrêt et minOff non écoulé → interdit de redémarrer.
maxStage = (m_currentStage == 0 && offHeld) ? 0 : topStage;
}
int RelayRouter::deduceStageFromThings() const
{
// Ensemble des relais réellement FERMÉS, lu une seule fois.
QSet<QString> allRelays;
for (const auto &list : m_relayMapping)
for (const QString &id : list)
allRelays.insert(id);
// Puissance nominale par relais, pour sommer l'état lu. Prise à la DÉCLARATION
// (m_relayNominalW) et non redéduite des encodages solo : avec 500/1000/1500, le
// palier 1500 est encodé {500,1000} et le relais 1500 n'apparaît jamais seul. Sa
// nominale valait alors 0 — donc un démarrage sur ce seul relais donnait sumW = 0,
// aucune correspondance exacte, et un palier 0 annoncé pendant que 1500 W circulaient.
// C'est précisément l'annonce en sous-estimation qu'ECS-411 interdit. Trouvé par le
// relevé d'ECS-411-b, qui affichait « (0W) » pour ce relais.
const QHash<QString, int> &nominal = m_relayNominalW;
// ECS-411-b — journaliser ce qui est LU, pas seulement ce qui en est déduit.
// « palier repris : 0 » ne dit pas si les relais étaient ouverts ou si l'un d'eux
// était injoignable et supposé fermé : deux situations qui appellent des gestes
// opposés. On garde donc la lecture relais par relais, et on la publie avec l'issue.
QSet<QString> on;
int sumW = 0;
bool unreadable = false;
QStringList lecture;
for (const QString &thingId : allRelays) {
const int w = nominal.value(thingId, 0);
const QString court = thingId.left(8); // assez pour identifier, assez court pour tenir
Thing *relay = m_thingManager ? m_thingManager->findConfiguredThing(ThingId(thingId)) : nullptr;
if (!relay) {
// Thing absent : son état est INCONNU. On le suppose FERMÉ — sous-estimer la
// puissance appliquée est le défaut qu'ECS-411 corrige, le surestimer ne fait
// que retarder une montée en puissance.
unreadable = true;
on.insert(thingId);
sumW += w;
lecture << QStringLiteral("%1(%2W)=INJOIGNABLE→supposé fermé").arg(court).arg(w);
continue;
}
const bool ferme = relay->stateValue("power").toBool();
if (ferme) {
on.insert(thingId);
sumW += w;
}
lecture << QStringLiteral("%1(%2W)=%3").arg(court).arg(w)
.arg(ferme ? QStringLiteral("fermé") : QStringLiteral("ouvert"));
}
lecture.sort(); // ordre stable : QSet n'en garantit aucun, et un relevé se compare
// Sortie UNIQUE. Les trois issues étaient auparavant journalisées séparément, et la
// première se taisait au palier 0 — un silence indistinguable d'une reprise non
// exécutée. Toute issue est désormais annoncée, la nominale comme les remarquables.
int idx = -1;
QString issue;
// 1) Correspondance EXACTE de l'ensemble de relais avec un palier : cas nominal.
for (int i = 0; i < m_relayMapping.size() && idx < 0; ++i) {
const QList<QString> &l = m_relayMapping.at(i);
if (QSet<QString>(l.begin(), l.end()) == on) {
idx = i;
issue = QStringLiteral("correspondance exacte");
}
}
// 2) Ensemble non retenu dans la table — cas des encodages DÉDUPLIQUÉS (deux
// combinaisons de même puissance, une seule conservée). On se rabat sur la
// PUISSANCE : toute somme de sous-ensemble figure nécessairement dans m_levels,
// puisque m_levels est construit de toutes ces sommes. On annonce donc la bonne
// puissance, et le premier applyAction() normalisera l'encodage des relais.
if (idx < 0) {
const int parPuissance = m_levels.indexOf(sumW);
if (parPuissance >= 0) {
idx = parPuissance;
issue = QStringLiteral("encodage hors table, repris par la puissance "
"(normalisé au premier cycle)");
}
}
// 3) Inatteignable sauf troncature à MaxRelays : la somme lue dépasse la table. On prend
// le palier le plus haut plutôt que 0 — ne JAMAIS annoncer moins que ce qui est appliqué.
if (idx < 0) {
idx = m_levels.size() - 1;
issue = QStringLiteral("puissance hors table (relais tronqués ?) : palier maximal adopté");
}
const QString releve = QStringLiteral("[RelayRouter] %1 — reprise au démarrage : "
"palier %2 W [%3] ; relais lus : %4 ; somme lue %5 W")
.arg(m_label).arg(m_levels.at(idx)).arg(issue)
.arg(lecture.join(QStringLiteral(", "))).arg(sumW);
// Un relais injoignable rend la reprise incertaine : elle mérite un avertissement,
// pas une information. Le relevé est le même, seul le niveau change.
if (unreadable)
qCWarning(dcNymeaEnergy()).noquote() << releve << "— état INCERTAIN (relais injoignable).";
else
qCInfo(dcNymeaEnergy()).noquote() << releve;
return idx;
}
void RelayRouter::writeRelay(const QString &thingId, bool on)
{
Thing *relay = m_thingManager ? m_thingManager->findConfiguredThing(ThingId(thingId)) : nullptr;
if (!relay) {
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label << "— relais non trouvé:" << thingId;
m_writeFailed = true; // compte comme un échec : ECS-410 §1
return;
}
StateType powerStateType = relay->thingClass().stateTypes().findByName("power");
if (powerStateType.id().isNull()) {
relay->setStateValue("power", on); // repli mock : synchrone, toujours réussi
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-410 — on n'attend PAS : le cycle rend la main immédiatement (AGENTS règle 5).
// Le verdict arrive par signal, et la conséquence est traitée au cycle suivant.
// `this` en contexte de connexion : si l'adaptateur meurt, le callback est coupé
// proprement — motif repris d'EvCharger::setChargingEnabled (evcharger.cpp:293-299).
++m_pending;
connect(info, &ThingActionInfo::finished, this, [this, info]() {
if (info->status() != Thing::ThingErrorNoError) {
m_writeFailed = true;
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label
<< "— écriture relais en échec, status" << info->status();
}
if (--m_pending == 0)
settleTransition();
});
}
void RelayRouter::settleTransition()
{
// Appelé quand TOUTES les écritures d'une étape sont acquittées. Fait avancer l'échelle
// d'ECS-410 d'un cran, une tentative par cran, sans jamais boucler.
if (!m_writeFailed) {
m_stagePrev = m_currentStage; // transition confirmée : l'indétermination cesse
m_stageTarget = m_currentStage;
return;
}
m_writeFailed = false;
switch (m_phase) {
case PhaseNominale:
// Barreau 2 — retour à l'état précédent.
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label
<< "— échec d'écriture : retour au palier" << m_levels.at(m_stagePrev) << "W.";
m_phase = PhaseRepli;
m_currentStage = m_stagePrev;
emitRelayWrites(m_stagePrev);
break;
case PhaseRepli:
// Barreau 3 — arrêt total. Dernière tentative.
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label
<< "— retour arrière en échec : arrêt total.";
m_phase = PhaseArretTotal;
m_currentStage = 0;
m_stageTarget = 0;
emitRelayWrites(0);
break;
case PhaseArretTotal:
case PhaseDefaut:
// Fin de l'échelle : verdict. Verrou COLLANT — aucune commande jusqu'à
// ClearLoadFault (ECS-410 §4). Il ne se lève pas tout seul : pas de clignotement.
qCCritical(dcNymeaEnergy()) << "[RelayRouter]" << m_label
<< "— arrêt total en échec : charge EN DÉFAUT, plus aucune"
<< "commande émise. Levée par NymeaEnergy.ClearLoadFault.";
m_phase = PhaseDefaut;
m_faulted = true;
break;
}
}
void RelayRouter::applySafeState(const QDateTime &now)
{
// ECS-413 — état sûr du routeur : tous relais ouverts. Passe par le chemin d'action
// NORMAL avec force = true, exactement comme le repli L2 : c'est le mécanisme qui existe
// déjà pour contourner les verrous quand l'arrêt prime. Rien à inventer.
LoadAction sur;
sur.loadId = m_id;
sur.kind = LoadAction::Setpoint;
sur.powerW = 0;
sur.force = true;
sur.reason = QStringLiteral("Charge désactivée — mise en état sûr avant retrait (ECS-413)");
applyAction(sur, now);
}
bool RelayRouter::clearFault()
{
if (!m_faulted)
return false; // rien à lever — l'arbitre le dira (règle 7-c)
qCInfo(dcNymeaEnergy()) << "[RelayRouter]" << m_label
<< "— défaut levé par l'opérateur (ClearLoadFault).";
m_faulted = false;
m_phase = PhaseNominale;
m_writeFailed = false;
// L'état matériel réel est inconnu après un défaut : on le relit plutôt que de le supposer
// (même principe qu'ECS-411, et jamais moins que ce qui peut être appliqué).
m_currentStage = deduceStageFromThings();
m_stagePrev = m_stageTarget = m_currentStage;
return true;
}
void RelayRouter::emitRelayWrites(int stage)
{
// Set CIBLE du palier (delta complet : chaque relais connu amené à son état on/off cible).
// Gère les mappings NON-CASCADÉS (monter d'un palier peut éteindre des relais).
const QSet<QString> wantOn = [&]() {
QSet<QString> s;
if (stage >= 0 && stage < m_relayMapping.size())
for (const QString &id : m_relayMapping.at(stage))
s.insert(id);
return s;
}();
QSet<QString> allRelays;
for (const auto &list : m_relayMapping)
for (const QString &id : list)
allRelays.insert(id);
// off-before-on : couper d'abord les relais hors-cible, puis enclencher la cible → pas de
// sur-puissance transitoire (somme des deux paliers) sur une transition non-cascadée.
for (const QString &id : allRelays)
if (!wantOn.contains(id))
writeRelay(id, false);
for (const QString &id : wantOn)
writeRelay(id, true);
// Chemin entièrement synchrone (mock, ou aucun relais) : aucun acquittement n'arrivera,
// le verdict se prend donc tout de suite.
if (m_pending == 0)
settleTransition();
}
void RelayRouter::applyRelayStage(int stage)
{
m_stagePrev = m_currentStage;
m_stageTarget = stage;
m_writeFailed = false;
emitRelayWrites(stage);
}