Constat de banc du 2026-08-09 : un SetLoadConfig posant enabled: false sur une
charge alors au palier 3500 W détruisait l'adaptateur en laissant les TROIS RELAIS
FERMÉS, juste avant une intervention de câblage. Plus personne ne les commandait ;
ils y seraient restés indéfiniment.
applySafeState(now) est ajouté à ILoadAdapter, PURE VIRTUELLE : l'état sûr est
propre à chaque adaptateur et une formulation « tout couper » serait fausse.
- RelayRouter : tous relais ouverts
- EtmVariableLoadAdapter : consigne 0 W
- SgReadyAdapter : ÉTAT 2 (normal, mains off) — JAMAIS 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).
- EvAdapter : sans effet, il n'est pas construit depuis LoadConfig.
L'application passe par le chemin d'action NORMAL avec force = true, celui du
mode dégradé L2 : le mécanisme de contournement des verrous existait déjà.
ORDRE, et c'est le point qui dépendait d'ECS-410 : l'état sûr est appliqué AVANT
la destruction, et la destruction passe par deleteLater(). Les écritures d'ECS-410
sont asynchrones avec `this` en contexte de connexion — détruire immédiatement
couperait les acquittements en vol, et on ne saurait pas si la mise en sécurité a
abouti, précisément dans le cas où elle échoue.
PÉRIMÈTRE BORNÉ. Rien de tout cela à l'arrêt du plugin ni au redémarrage de
nymead : l'état doit y être CONSERVÉ, c'est ce qu'ECS-411 relit, et couper l'eau
chaude à chaque redémarrage de service serait une régression. La désactivation est
un acte délibéré de l'opérateur ; un redémarrage n'en est pas un. Les charges
CONSERVÉES par le rebuild incrémental ne passent pas par ce chemin.
Test testEcsDisableLeavesSafeState, avec son CAS NÉGATIF en premier : un rebuild
qui ne change que le rang ne coupe rien — sans lui, ECS-412 serait annulé et
chaque changement de priorité couperait la charge. Puis le cas positif :
désactivation, relais ouvert, et il le reste même sous surplus au cycle suivant.
Build amd64 0 erreur. Simulation : 17/17.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
509 lines
21 KiB
C++
509 lines
21 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;
|
|
}
|
|
|
|
// 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.
|
|
QHash<QString, int> nominal;
|
|
for (int i = 0; i < m_relayMapping.size(); ++i)
|
|
for (const QString &id : m_relayMapping.at(i))
|
|
if (m_relayMapping.at(i).size() == 1)
|
|
nominal.insert(id, m_levels.at(i));
|
|
|
|
QSet<QString> on;
|
|
int sumW = 0;
|
|
bool unreadable = false;
|
|
for (const QString &thingId : allRelays) {
|
|
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 += nominal.value(thingId, 0);
|
|
continue;
|
|
}
|
|
if (relay->stateValue("power").toBool()) {
|
|
on.insert(thingId);
|
|
sumW += nominal.value(thingId, 0);
|
|
}
|
|
}
|
|
if (unreadable)
|
|
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label
|
|
<< "— relais introuvable(s) au démarrage : supposé(s) FERMÉ(s).";
|
|
|
|
// 1) Correspondance EXACTE de l'ensemble de relais avec un palier : cas nominal.
|
|
for (int i = 0; i < m_relayMapping.size(); ++i) {
|
|
const QList<QString> &l = m_relayMapping.at(i);
|
|
if (QSet<QString>(l.begin(), l.end()) == on) {
|
|
if (i > 0)
|
|
qCInfo(dcNymeaEnergy()) << "[RelayRouter]" << m_label
|
|
<< "— palier repris au démarrage:" << m_levels.at(i) << "W";
|
|
return i;
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
const int idx = m_levels.indexOf(sumW);
|
|
if (idx >= 0) {
|
|
qCInfo(dcNymeaEnergy()) << "[RelayRouter]" << m_label
|
|
<< "— encodage de démarrage hors table, repris par la puissance:"
|
|
<< sumW << "W (normalisé au premier cycle).";
|
|
return idx;
|
|
}
|
|
|
|
// 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é.
|
|
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label
|
|
<< "— puissance de démarrage" << sumW
|
|
<< "W hors table (relais tronqués ?) : palier maximal adopté.";
|
|
return m_levels.size() - 1;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
void RelayRouter::clearFault()
|
|
{
|
|
if (!m_faulted)
|
|
return;
|
|
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;
|
|
}
|
|
|
|
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);
|
|
}
|