Patrick Schurig e6ee513dcc fix(moteur): le motif publié décrit l'action fusionnée (correctif a, etm70)
Quand la passe éco et la passe confort se cumulent sur une charge en
consigne de puissance (relay-router, etmvariableload, borne « éco +
minimum »), la fusion additionnait les watts sans toucher au motif : une
consigne achetée au réseau se publiait « surplus insuffisant ».

Règle décidée par Patrick le 2026-10-16 : le motif publié est celui de la
part MAJORITAIRE de la consigne, égalité → éco, part éco nulle jamais en
tête ; LOAD_UNAVAILABLE garde la tête (préséance L4 > indisponible /
dégradé > éco | confort). Les deux parts restent dans levels[].

- decision.level = le niveau dont le motif est publié (publishedLevel),
  au lieu du « dernier parcouru » ; règle écrite dans INTERFACE.md R7.
- Journaux [Arbitre], [RelayRouter], [EtmVariableLoadAdapter] : un helper
  unique renderLevelsFr() rend la ventilation quand il y a deux niveaux ;
  ligne inchangée pour une action à un seul niveau.
- Aucune consigne, aucun powerW, aucun budget/soutirage ne change.
- Test testTheDecisionDescribesTheMergedAction : 3 charges × (éco seule,
  égalité, confort majoritaire, sans éco, ECO_FLOOR_MET), journal et RPC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:11:51 +02:00

723 lines
34 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.
//! \rule{ECS-308} — le plafond TRONQUE au lieu de refuser : au-delà, les relais
//! surnuméraires sont ignorés en silence. Connu, non corrigé, et c'est pour cela que la
//! règle existe — 2^16 sous-ensembles est déjà une table qu'aucune installation n'atteint.
constexpr int MaxRelays = 16;
}
RelayStageTable RelayRouter::deriveStages(const QList<LoadConfigRelay> &relays)
{
int n = relays.size();
if (n > MaxRelays)
n = MaxRelays; // l'avertissement est émis à la construction, pas ici
// 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 — d'où le fait
// que l'ordre de déclaration soit significatif dans ce cas, et lui seul.
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);
}
RelayStageTable t;
for (auto it = byPower.constBegin(); it != byPower.constEnd(); ++it) {
t.levels.append(it.key());
t.mapping.append(it.value());
}
return t;
}
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);
// La combinatoire vit dans deriveStages() — une seule implémentation pour les trois
// usages (construction, comparaison matérielle, publication en télémétrie).
const RelayStageTable table = deriveStages(relays);
m_levels = table.levels;
m_relayMapping = table.mapping;
// 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();
// §12 / LM-1202 — plancher DÉRIVÉ : le plus petit palier non nul. m_levels[0] vaut
// toujours 0 (sous-ensemble vide), donc le plancher est le suivant s'il existe.
d.declared.minPowerW = m_levels.size() > 1 ? m_levels.at(1) : 0;
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).
// \rule{ECS-111} — UN RELAIS DÉCLARÉ QUI MANQUE REND LA CHARGE INDISPONIBLE.
//
// Le comportement était « non défini », et son effet dépassait l'affichage. Le relais
// disparu était SAUTÉ : ou bien plus aucun relais actif ne mesurait, et l'on retombait sur
// le nominal du PALIER ENTIER — la part du relais fantôme comprise ; ou bien des survivants
// mesuraient, et l'on publiait une somme SOUS-ÉVALUÉE. Dans les deux cas le chiffre était
// faux, et il n'était pas seulement affiché : \c currentPowerW alimente le recrédit
// anti-clignotement, donc le BUDGET. L'arbitre se trompait sur le surplus disponible
// pendant que \c available restait vrai — un mensonge qui décide, pas un mensonge qui
// s'affiche.
//
// Un relais absent du registre nymea est un Thing supprimé, ou un plugin qui ne le voit
// plus : **on ne sait pas dans quel état est le contact**. Compter 0 W et servir le reste
// serait supposer. ECS-410 dit quoi faire d'une charge dont on ne sait plus rien — sortir
// de l'ARBITRAGE, rester dans la COMPTABILITÉ, figer à la puissance crue engagée — et
// c'est le traitement appliqué ici, comme \c EtmVariableLoadAdapter et \c EvAdapter le
// font déjà pour leur Thing. \c deviceMeasurement() le faisait déjà : ceci finit le
// mouvement au lieu d'inventer une politique.
const bool relaisManquant = aUnRelaisManquant();
t.available = t.available && !relaisManquant;
double power = 0;
bool metered = false;
const QList<QString> activeRelays = m_currentStage < m_relayMapping.size()
? m_relayMapping.at(m_currentStage)
: QList<QString>();
// Câblage incomplet : aucune somme partielle. On annonce le NOMINAL du palier courant —
// la plus haute puissance qui puisse couler, jamais moins (même direction qu'ECS-410 et
// ECS-411). Sous-estimer ferait offrir aux charges suivantes un budget qui n'existe pas.
for (const QString &thingId : relaisManquant ? QList<QString>() : 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;
// §10-C — les BESOINS déclarés atteignent enfin le cycle. Ils étaient posés dans le
// descripteur et abandonnés ici : `LoadContext::needs` n'était rempli par personne, donc
// l'obligation éco n'existait qu'en configuration. Transmis tels quels, sans être lus par
// aucune décision à ce stade — la passe éco est l'étape suivante.
ctx.needs = descriptor().needs; // 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).
// \rule{ECS-111} — la disponibilité se lit sur telemetry(), PAS sur m_faulted seul : un
// relais déclaré qui manque au registre rend la charge indisponible au même titre qu'un
// défaut d'écriture. Recopier « !m_faulted » ici ferait qu'une charge annoncée
// indisponible en télémétrie continuerait d'être ALLOUÉE par le waterfall — les deux
// frontières diraient deux choses de la même charge.
ctx.telemetry.available = telemetry().available;
if (!ctx.telemetry.available) {
// 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.
//
// C'est ce qui rend le traitement d'ECS-111 juste POUR LE BUDGET : la puissance crue
// engagée est RETIRÉE du budget des charges suivantes au lieu de leur être recréditée.
// Avant, le fantôme gonflait le recrédit — il offrait du surplus qui n'existait pas.
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.";
// ECS-415 — un refus se lit comme un refus : le retour décrit le palier TENU, jamais
// celui qu'on demandait. Rendre l'enveloppe telle quelle ferait conclure au succès.
LoadAction refuse = action;
refuse.powerW = refuse.estimatedPowerW = currentSetpointW();
return refuse;
}
// 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);
// Règle 7-c — journaliser L'ISSUE, pas seulement l'issue remarquable. Ce retour était MUET :
// « consigne reçue, palier déjà bon » ne se distinguait pas de « l'action n'est jamais
// parvenue au routeur », et c'est précisément le cas ordinaire qu'on vient vérifier quand
// une charge ne bouge pas. Même niveau que la branche qui commute (7-d, symétrie) — et le
// volume reste borné : une ligne par charge et par cycle, comme l'adaptateur à consigne
// continue en produit déjà une inconditionnellement.
if (newStage == m_currentStage) {
qCInfo(dcNymeaEnergy()) << "[RelayRouter]" << m_label
<< "→ consigne" << qRound(action.powerW) << "W → palier"
<< m_levels.at(newStage) << "W INCHANGÉ (aucune commutation)"
<< (action.force ? "(force)" : "")
<< "|" << renderLevelsFr(action, m_label);
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)" : "")
<< "|" << renderLevelsFr(action, m_label);
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(const QString &label, 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.
//
// Le libellé en fait partie. Il ne change RIEN au routage — d'où son absence de
// sameHardware() — mais il nomme la charge dans chaque ligne de journal. Sans cette
// affectation, renommer « chauffe-eau » en « Ballon 200 L » laissait le journal
// imprimer l'ancien nom jusqu'à la prochaine reconstruction, alors que GetLoadConfig
// rendait déjà le nouveau. Pour qui suit journalctl pendant une mise en service, les
// deux sources se contredisaient.
m_label = label;
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;
}
}
bool RelayRouter::aUnRelaisManquant() const
{
for (auto it = m_relayNominalW.constBegin(); it != m_relayNominalW.constEnd(); ++it)
if (!m_thingManager->findConfiguredThing(ThingId(it.key())))
return true;
return false;
}
LoadMeasurement RelayRouter::deviceMeasurement() const
{
LoadMeasurement m;
if (m_relayNominalW.isEmpty())
return m;
// CAPACITÉ — la question porte sur la ThingClass de TOUS les relais déclarés, jamais sur
// ceux qui sont fermés maintenant : au palier 0 il n'y en a aucun, et juger là-dessus
// ferait basculer la source publiée à chaque extinction (LM-1106).
//
// TOUS, et pas « au moins un » : une topologie mixte donnerait une somme sous-évaluée
// présentée comme la mesure de la charge — un écart commandé↔mesuré qui envoie chercher
// une panne inexistante (LM-1102). Une mesure partielle n'est pas une mesure.
for (auto it = m_relayNominalW.constBegin(); it != m_relayNominalW.constEnd(); ++it) {
Thing *relay = m_thingManager->findConfiguredThing(ThingId(it.key()));
if (!relay || relay->thingClass().stateTypes().findByName("currentPower").id().isNull())
return m; // un seul relais nu, et la charge n'est pas mesurable
}
// VALEUR — sur les relais FERMÉS. Un thermostat qui a ouvert donne 0 W relais fermé, et
// c'est une mesure juste : la charge ne consomme réellement rien.
m.available = true;
const QList<QString> fermes = m_currentStage < m_relayMapping.size()
? m_relayMapping.at(m_currentStage)
: QList<QString>();
for (const QString &thingId : fermes)
if (Thing *relay = m_thingManager->findConfiguredThing(ThingId(thingId)))
m.powerW += relay->stateValue("currentPower").toDouble();
return m;
}
LoadRuntimeView RelayRouter::runtimeView(const QDateTime &now) const
{
LoadRuntimeView v;
v.mechanismKind = QStringLiteral("relay");
// Charge utile du mécanisme : des WATTS, jamais un index de palier ni un ThingId de relais.
// La frontière rév. 3 tient aussi dans le sens sortant.
v.mechanism.insert(QStringLiteral("stageW"), currentSetpointW());
v.mechanism.insert(QStringLiteral("maxStageW"),
m_levels.isEmpty() ? 0.0 : static_cast<double>(m_levels.last()));
// Paliers atteignables, publiés en LECTURE SEULE (lot D §4).
//
// Ils ne sont déclarés nulle part : ils se dérivent des relais. Sans cette publication,
// un client qui veut les montrer doit réimplémenter la combinatoire — seconde
// implémentation, divergence certaine à la première correction d'un seul côté, et c'est
// le client qui aurait tort sans le savoir.
//
// Ici et pas dans GetLoadConfig, à dessein : la télémétrie n'a AUCUN chemin d'écriture,
// donc « lecture seule » y est vrai par construction. Les publier dans GetLoadConfig
// aurait cassé l'aller-retour neutre dont l'app dépend pour écrire — elle renvoie
// verbatim ce qu'elle a lu, le champ serait revenu dans SetLoadConfig, et un refus en
// bloc aurait emporté toutes les charges.
QVariantList stagesW;
for (int w : m_levels)
stagesW.append(static_cast<double>(w));
v.mechanism.insert(QStringLiteral("stagesW"), stagesW);
// ECS-410 — le défaut sort en CODE. Il n'a aujourd'hui qu'une cause : l'échelle d'écriture
// épuisée. Une cause nouvelle vaudra un code nouveau, jamais une phrase.
if (m_faulted)
v.faultCode = QStringLiteral("WRITE_FAILED");
// \rule{ECS-111} — THING_MISSING PRIME sur WRITE_FAILED, et ce n'est pas un choix de
// présentation : un relais absent EXPLIQUE les écritures qui échouent. Annoncer
// WRITE_FAILED enverrait l'opérateur au bus alors que la cause est dans la configuration,
// et \c ClearLoadFault — le remède de WRITE_FAILED — n'y peut rien.
if (aUnRelaisManquant())
v.faultCode = QStringLiteral("THING_MISSING");
// Verrou en SECONDES RESTANTES. Le calcul dérive du MÊME m_lastSwitch et du même \c now que
// lockWindow() : ce que le client lit est ce qui a borné la décision, pas une seconde vue.
// m_lastSwitch nul = « commutation venant d'avoir lieu » (ECS-412, armement à froid) —
// même convention que lockWindow(), sans quoi l'affichage contredirait le comportement.
const qint64 elapsed = m_lastSwitch.isValid() ? m_lastSwitch.secsTo(now) : 0;
if (m_currentStage > 0 && elapsed < m_minOnS) {
v.lockKind = QStringLiteral("minOn");
v.lockRemainingS = static_cast<int>(m_minOnS - elapsed);
} else if (m_currentStage == 0 && elapsed < m_minOffS) {
v.lockKind = QStringLiteral("minOff");
v.lockRemainingS = static_cast<int>(m_minOffS - elapsed);
}
return v;
}
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 = { DecisionCode::SafeStateRelay };
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);
}