// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #include "relayrouter.h" #include "plugininfo.h" #include #include #include #include #include #include #include #include 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 &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> byPower; for (int mask = 0; mask < (1 << n); ++mask) { int sum = 0; QList 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 &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 activeRelays = m_currentStage < m_relayMapping.size() ? m_relayMapping.at(m_currentStage) : QList(); // 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() : 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(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)" : "") << "|" << renderFr(action.reason, 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)" : "") << "|" << renderFr(action.reason, 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 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 &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 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 &l = m_relayMapping.at(i); if (QSet(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 fermes = m_currentStage < m_relayMapping.size() ? m_relayMapping.at(m_currentStage) : QList(); 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(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(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(m_minOnS - elapsed); } else if (m_currentStage == 0 && elapsed < m_minOffS) { v.lockKind = QStringLiteral("minOff"); v.lockRemainingS = static_cast(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 wantOn = [&]() { QSet s; if (stage >= 0 && stage < m_relayMapping.size()) for (const QString &id : m_relayMapping.at(stage)) s.insert(id); return s; }(); QSet 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); }