// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #include "sgreadyadapter.h" #include "plugininfo.h" #include #include #include #include #include #include #include #include #include #include SgReadyAdapter::SgReadyAdapter(ThingManager *thingManager, const QString &id, const QString &label, const QHash> &stateRelays, const QHash &estimatedPowerW, int minStateHoldS, int priority, QObject *parent) : QObject(parent) , m_thingManager(thingManager) , m_id(id) , m_label(label) , m_stateRelays(stateRelays) , m_estimatedPowerW(estimatedPowerW) , m_minStateHoldS(minStateHoldS) , m_priority(priority) { m_states = m_stateRelays.keys(); std::sort(m_states.begin(), m_states.end()); // ECS-110 — la présence de l'état 2 NE DOIT PAS reposer sur un Q_ASSERT : QT_NO_DEBUG est // défini en release, l'assertion disparaît du binaire livré. Tant que les états venaient // d'une construction codée en dur elle ne pouvait pas échouer ; dès qu'ils viennent de la // configuration, elle serait le SEUL garde-fou sur le repli sûr du mode dégradé L2. // // La vérification vit donc dans LoadConfig::isValid() (refus explicite, message FR) et le // contrôle ci-dessous n'est qu'un dernier filet pour les constructions directes (tests, // enregistrement codé en dur) : il PARLE au lieu de disparaître. m_usable = !m_states.isEmpty() && m_stateRelays.contains(2); if (!m_usable) { qCCritical(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label << "— encodage INUTILISABLE : l'état 2 (normal) n'est pas" << "exprimable. C'est le repli sûr du mode dégradé L2 ;" << "l'adaptateur refusera toute commande."; return; // rien à relire : aucun état ne sera jamais commandé. } // ECS-411 — REPRISE DE L'ÉTAT depuis les contacts RÉELS. Partir de l'état 2 par défaut, // c'est le moteur qui croit la PAC au repos pendant qu'elle tourne en forcé : même famille // de défaut que le palier ECS repris à 0 alors que 3 kW circulent. // // Le cas VRAIMENT méchant n'est pas l'annonce fausse, c'est son corollaire (LM-104) : // applyAction(état 2) sur un adaptateur qui se CROIT déjà en 2 est idempotent, donc MUET, // donc les contacts restent en 4. L'hypothèse de prudence deviendrait un masquage de panne. // Relire supprime les deux d'un coup. m_currentState = deduceStateFromRelays(); m_statePrev = m_stateTarget = m_currentState; } int SgReadyAdapter::deduceStateFromRelays() const { // ECS-411-b — journaliser ce qui a été LU, pas seulement ce qui en est déduit. « état repris // 2 » ne dit pas si les contacts étaient ouverts ou si l'un d'eux était injoignable et // supposé fermé : deux situations qui appellent des gestes opposés. On publie donc le relevé // contact par contact AVEC l'issue — y compris quand l'issue est l'état neutre, qui est // justement celui qu'on vient vérifier après un démarrage. const QSet tous = allRelays(); QSet fermes; bool injoignable = false; QStringList lecture; for (const QString &thingId : tous) { const QString court = thingId.left(8); Thing *relay = m_thingManager ? m_thingManager->findConfiguredThing(ThingId(thingId)) : nullptr; if (!relay) { // Contact INJOIGNABLE : état inconnu, supposé FERMÉ (LM-104, même choix // conservateur que readActualOn() et qu'ECS-411 côté routeur). injoignable = true; fermes.insert(thingId); lecture << QStringLiteral("%1=INJOIGNABLE→supposé fermé").arg(court); continue; } const bool ferme = relay->stateValue("power").toBool(); if (ferme) fermes.insert(thingId); lecture << QStringLiteral("%1=%2").arg(court, ferme ? QStringLiteral("fermé") : QStringLiteral("ouvert")); } lecture.sort(); // ordre stable : QSet n'en garantit aucun, et un relevé se compare int etat = -1; QString issue; // 1) Correspondance EXACTE du motif de contacts avec un état déclaré : cas nominal. Avec // un encodage 2 bits complet (00/01/10/11), c'est le seul cas atteignable. for (auto it = m_stateRelays.constBegin(); it != m_stateRelays.constEnd() && etat < 0; ++it) { const QSet s(it.value().begin(), it.value().end()); if (s == fermes) { etat = it.key(); issue = QStringLiteral("correspondance exacte"); } } // 2) Motif NON DÉCLARÉ — possible seulement sur un encodage partiel (tous les états ne // sont pas décrits). On ne sait pas dans quel état est la PAC. On retient l'état // déclaré de plus forte puissance estimée : ne JAMAIS annoncer moins que ce qui peut // être appliqué (règle d'ECS-411). Le premier applyAction normalisera les contacts. if (etat < 0) { double pire = -1.0; for (int s : m_states) { const double p = m_estimatedPowerW.value(s, 0.0); if (p > pire) { pire = p; etat = s; } } issue = QStringLiteral("motif de contacts NON DÉCLARÉ — état de plus forte puissance " "estimée retenu (normalisé au premier cycle)"); } const QString releve = QStringLiteral("[SgReadyAdapter] %1 — reprise au démarrage : état %2 " "(~%3 W estimés) [%4] ; contacts lus : %5") .arg(m_label).arg(etat) .arg(qRound(m_estimatedPowerW.value(etat, 0.0))).arg(issue) .arg(lecture.isEmpty() ? QStringLiteral("(aucun contact déclaré)") : lecture.join(QStringLiteral(", "))); // Un contact injoignable rend la reprise incertaine : elle mérite un avertissement, pas une // information. Le relevé est le même, seul le niveau change. if (injoignable) qCWarning(dcNymeaEnergy()).noquote() << releve << "— état INCERTAIN (contact injoignable)."; else qCInfo(dcNymeaEnergy()).noquote() << releve; return etat; } LoadDescriptor SgReadyAdapter::descriptor() const { LoadDescriptor d; d.id = m_id; d.label = m_label; d.adapter = QStringLiteral("sg-ready"); d.priority = m_priority; d.declared.states = m_states; d.declared.estimatedPowerW = m_estimatedPowerW; // §12 / LM-1202 — plancher DÉRIVÉ : la plus petite estimation non nulle des états // commandables. Le mapping d'états l'applique déjà implicitement (un état se prend // entier ou pas du tout) ; le déclarer rend le modèle uniforme et lisible de l'extérieur. double plancher = 0; for (auto it = m_estimatedPowerW.constBegin(); it != m_estimatedPowerW.constEnd(); ++it) if (it.value() > 0 && (plancher == 0 || it.value() < plancher)) plancher = it.value(); d.declared.minPowerW = plancher; d.limits.minStateHoldS = m_minStateHoldS; d.supportedKinds = { LoadAction::State }; return d; } LoadTelemetry SgReadyAdapter::telemetry() const { LoadTelemetry t; t.available = m_usable && !m_faulted; // ECS-414 : sort de l'ARBITRAGE, pas de la COMPTABILITÉ. t.lastActionAt = m_lastActionAt; // Base du recrédit budget = puissance ALLOUÉE de l'état (déclaré), pas la conso mesurée // (états 1/2 → 0 ; états 3/4 → P3/P4). Cf. invariant 8. t.currentPowerW = m_estimatedPowerW.value(m_currentState, 0.0); // ECS-414 — INDÉTERMINATION : tant que des écritures sont en vol, on annonce la plus // HAUTE des deux puissances allouées possibles. Ne jamais annoncer moins que ce qui peut // être appliqué (même direction qu'ECS-410/411). if (m_pending > 0) t.currentPowerW = qMax(m_estimatedPowerW.value(m_statePrev, 0.0), m_estimatedPowerW.value(m_stateTarget, 0.0)); return t; } LoadContext SgReadyAdapter::toLoadContext(const QDateTime &now) const { LoadContext ctx; ctx.id = m_id; ctx.adapter = QStringLiteral("sg-ready"); ctx.label = m_label; ctx.priority = m_priority; ctx.declared = descriptor().declared; ctx.limits = descriptor().limits; ctx.telemetry.currentPowerW = telemetry().currentPowerW; ctx.telemetry.available = m_usable && !m_faulted; ctx.telemetry.state = m_currentState; ctx.telemetry.lastSwitch = m_lastSwitch; if (m_faulted) { // Charge FIGÉE : ni montée ni descente. Plancher == plafond, comme pour le routeur. ctx.telemetry.minState = ctx.telemetry.maxState = m_currentState; return ctx; } // Fenêtre de verrou évaluée au temps de cycle (protection court-cycling PAC). lockWindow(now, ctx.telemetry.minState, ctx.telemetry.maxState); return ctx; } /*! * \brief Retour décrivant l'état RÉELLEMENT tenu — forme unique de tout refus (ECS-415). * * Un appelant doit pouvoir distinguer « appliqué » de « refusé » en lisant la valeur de retour, * et par le MÊME test dans les deux cas. Renvoyer l'action demandée telle quelle rendait un * refus indiscernable d'un succès : le lecteur y retrouvait l'état qu'il venait de demander. */ LoadAction SgReadyAdapter::inchange(const LoadAction &action) const { LoadAction refuse = action; refuse.state = m_currentState; refuse.estimatedPowerW = m_estimatedPowerW.value(m_currentState, 0.0); return refuse; } LoadAction SgReadyAdapter::applyAction(const LoadAction &action, const QDateTime &now) { if (action.kind != LoadAction::State) return action; if (action.reason.isEmpty()) { qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label << "— LoadAction sans reason rejetée."; return inchange(action); } if (!m_usable) return inchange(action); // encodage sans état 2 : aucune commande, cf. constructeur // ECS-414 — en défaut, plus aucune commande, y compris forcée : trois tentatives ont // déjà échoué, en réémettre masquerait l'état sans rien réparer. if (m_faulted) return inchange(action); // ECS-412 — l'armement paresseux se DÉSARME ici, dans le chemin NON-const qui reçoit le // temps de cycle : au premier \c now vu, on estampille, et le verrou expire alors // naturellement. Sans ça l'armement de \c lockWindow() serait permanent. L'invariant // « temps = paramètre, jamais l'horloge » est préservé : aucune horloge n'est lue. if (!m_lastSwitch.isValid()) m_lastSwitch = now; // Écrêtage à un état déclaré (borne puis exigence d'appartenance). int newState = qBound(m_states.first(), action.state, m_states.last()); if (!m_stateRelays.contains(newState)) { qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label << "— état non déclaré:" << action.state << "→ ignoré."; return inchange(action); } // Règle 7-c — même défaut que celui corrigé au RelayRouter : ce retour était MUET, si bien // que « état déjà bon » ne se distinguait pas de « action jamais reçue ». Une PAC qui ne // bouge pas est exactement le cas où l'on vient lire le journal. if (newState == m_currentState) { qCInfo(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label << "→ état" << newState << "INCHANGÉ (aucune commutation)" << (action.force ? "(force)" : "") << "|" << renderFr(action.reason, m_label); // ECS-415 — même pour l'idempotence : l'état est bien celui-là, mais la puissance // estimée doit venir de l'adaptateur, pas de ce que l'appelant avait mis dans son // enveloppe. Un appelant qui demande « état 4, 0 W » et se voit rendre « 0 W » sur une // PAC qui en tire 3000 lit son propre chiffre, pas une réponse. return inchange(action); // Idempotent } // Verrou minStateHold évalué au temps de cycle (même fenêtre que le scheduler) — // bypassé si force == true (L2 watchdog → état 2). if (!action.force && lockActive(newState, now)) { // Règle 7-c — un REFUS doit être au moins aussi visible que l'application // correspondante. Ce refus était en qCDebug quand l'application juste en dessous est // en qCInfo : un resserrement de la journalisation faisait disparaître le refus AVANT // le succès. Or un refus est plus informatif qu'une application réussie — il dit // qu'une décision a été prise et n'a PAS été exécutée. qCInfo(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label << "— verrou minStateHold actif, état" << newState << "REFUSÉ (état" << m_currentState << "maintenu)."; return inchange(action); } qCInfo(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label << "→ état" << newState << "(" << m_estimatedPowerW.value(newState, 0.0) << "W estimés)" << "|" << renderFr(action.reason, m_label); m_statePrev = m_currentState; m_stateTarget = newState; m_writeFailed = false; applyStateRelays(readActualOn(), newState); m_currentState = newState; m_lastSwitch = now; m_lastActionAt = now; LoadAction applied = action; applied.state = newState; applied.estimatedPowerW = m_estimatedPowerW.value(newState, 0.0); return applied; } LoadRuntimeView SgReadyAdapter::runtimeView(const QDateTime &now) const { LoadRuntimeView v; v.mechanismKind = QStringLiteral("sgReady"); // L'état normé EST la grandeur commandée d'une PAC SG-Ready : 4 états constructeur, non // exprimables en watts (c'est la raison d'être du kind State). La puissance qui l'accompagne // est une ESTIMATION déclarée, jamais un engagement — le nom de la clé le dit. v.mechanism.insert(QStringLiteral("state"), m_currentState); v.mechanism.insert(QStringLiteral("estimatedPowerW"), m_estimatedPowerW.value(m_currentState, 0.0)); // ECS-414 et ECS-110 — deux indisponibilités de natures opposées, deux codes. Un encodage // sans état 2 refuse TOUTE commande depuis le constructeur : le taire ferait passer une // PAC jamais pilotable pour une PAC au repos. if (m_faulted) v.faultCode = QStringLiteral("WRITE_FAILED"); else if (!m_usable) v.faultCode = QStringLiteral("UNUSABLE_ENCODING"); // Verrou en secondes restantes, calculé sur le MÊME m_lastSwitch et la MÊME convention // d'armement que lockWindow() — y compris le démarrage à froid, où un m_lastSwitch nul // vaut « commutation venant d'avoir lieu » (ECS-412). L'affichage ne peut donc pas // contredire le comportement : le client lit le verrou qui a réellement borné la décision. const qint64 elapsed = m_lastSwitch.isValid() ? m_lastSwitch.secsTo(now) : 0; if (elapsed < m_minStateHoldS) { v.lockKind = QStringLiteral("minStateHold"); v.lockRemainingS = static_cast(m_minStateHoldS - elapsed); } return v; } void SgReadyAdapter::applySafeState(const QDateTime &now) { // ECS-413 — état sûr d'une PAC = état 2 (normal, mains off). PAS l'état 1 : bloquer une // PAC n'est pas la mettre en sécurité, c'est arrêter le chauffage sans raison visible // (SAFETY.md, et même choix que le repli L2). LoadAction sur; sur.loadId = m_id; sur.kind = LoadAction::State; sur.state = 2; sur.force = true; sur.reason = { DecisionCode::SafeStateSgReady }; applyAction(sur, now); // force = true : minStateHoldS vaut 900 s, attendre est indéfendable } // ---- privé --------------------------------------------------------------- void SgReadyAdapter::lockWindow(const QDateTime &now, int &minState, int &maxState) const { const int lo = m_states.first(); const int hi = m_states.last(); // ECS-412 — ARMEMENT PARESSEUX au démarrage à froid, symétrique du RelayRouter. // Un \c m_lastSwitch nul signifie « on ne sait pas quand la dernière commutation a eu // lieu » (redémarrage de nymead, reconstruction depuis la config) — PAS « il n'y en a // jamais eu ». Le défaut SÛR est donc d'ARMER : traiter la commutation comme venant // d'avoir lieu, et laisser le verrou expirer. // // Ce que ça évite : la PAC tourne en forcé depuis deux minutes, nymead redémarre, le // budget a chuté — sans armement le moteur la coupe immédiatement, et c'est du // court-cycling compresseur provoqué par un redémarrage de service. Or le verrou // minStateHold n'existe que pour ça. // // Cet armement est TRANSITOIRE : applyAction() estampille \c m_lastSwitch au premier // \c now reçu, si bien que le verrou expire après sa durée configurée. Il ne doit JAMAIS // devenir permanent — c'est le défaut corrigé côté routeur le 2026-08-09, où une charge // démarrant contacts ouverts avec un verrou armé en permanence ne pouvait plus jamais // s'enclencher (blocage circulaire : pas d'enclenchement → pas de commutation → pas // d'estampille). Les replis de sécurité passent en \c force = true et le bypassent. const qint64 elapsed = m_lastSwitch.isValid() ? m_lastSwitch.secsTo(now) : 0; if (elapsed < m_minStateHoldS) { // Gel total : la PAC doit tenir son état (protection court-cycling compresseur). minState = maxState = m_currentState; } else { minState = lo; maxState = hi; } } bool SgReadyAdapter::lockActive(int newState, const QDateTime &now) const { // MÊME calcul que la fenêtre exposée au scheduler → décision et exécution coïncident. int minState, maxState; lockWindow(now, minState, maxState); return newState < minState || newState > maxState; } int SgReadyAdapter::transientHarm(int state) { // Transitoire le plus doux d'abord : neutre (2) < recommandation (3) < blocage (1) < forcé (4). switch (state) { case 2: return 0; // neutre case 3: return 1; // recommandation (run doux) case 1: return 2; // blocage (coupe le chauffage) case 4: return 3; // forcé (démarrage franc compresseur) default: return 2; // combinaison hors-norme : prudence } } int SgReadyAdapter::stateForRelays(const QList &onRelays) const { const QSet want(onRelays.begin(), onRelays.end()); for (auto it = m_stateRelays.constBegin(); it != m_stateRelays.constEnd(); ++it) { const QSet s(it.value().begin(), it.value().end()); if (s == want) return it.key(); } return -1; } QSet SgReadyAdapter::allRelays() const { QSet all; for (const auto &list : m_stateRelays) for (const QString &id : list) all.insert(id); return all; } QSet SgReadyAdapter::readActualOn() const { QSet on; for (const QString &id : allRelays()) { Thing *relay = m_thingManager ? m_thingManager->findConfiguredThing(ThingId(id)) : nullptr; if (!relay) { // Contact INJOIGNABLE : son état est inconnu, on le suppose FERMÉ. Même choix // conservateur qu'ECS-411 (relayrouter.cpp) — ne jamais supposer moins de // puissance appliquée qu'il n'y en a peut-être. Corollaire utile : un repli vers // un état ne demandant aucune écriture ne peut plus « réussir » à vide alors // qu'on ne sait rien du matériel. on.insert(id); continue; } if (relay->stateValue("power").toBool()) on.insert(id); } return on; } void SgReadyAdapter::writeRelay(const QString &thingId, bool on) { Thing *relay = m_thingManager ? m_thingManager->findConfiguredThing(ThingId(thingId)) : nullptr; if (!relay) { qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label << "— relais non trouvé:" << thingId; m_writeFailed = true; return; } StateType powerStateType = relay->thingClass().stateTypes().findByName("power"); if (powerStateType.id().isNull()) { relay->setStateValue("power", on); // repli mock : synchrone return; } Action powerAction(powerStateType.id(), relay->id(), Action::TriggeredByRule); powerAction.setParams(ParamList() << Param(powerStateType.id(), on)); ThingActionInfo *info = m_thingManager->executeAction(powerAction); if (!info) { m_writeFailed = true; return; } // ECS-414 — même modèle qu'ECS-410 : on n'attend pas, le verdict arrive par signal, et // `this` en contexte coupe proprement les callbacks si l'adaptateur meurt. ++m_pending; connect(info, &ThingActionInfo::finished, this, [this, info]() { if (info->status() != Thing::ThingErrorNoError) { m_writeFailed = true; qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label << "— écriture contact en échec, status" << info->status(); } if (--m_pending == 0) settleTransition(); }); } void SgReadyAdapter::settleTransition() { if (!m_writeFailed) { m_statePrev = m_currentState; m_stateTarget = m_currentState; return; } m_writeFailed = false; // Le repli part TOUJOURS du motif RÉELLEMENT lu : après un échec partiel, les deux bits // peuvent former un état valide mais non voulu, voire un motif hors table. // // Il écrit DIRECTEMENT les contacts, sans repasser par applyAction() : le verrou // minStateHoldS n'est donc jamais consulté — équivalent d'un force = true, comme le // repli L2. Sur une PAC, minStateHoldS vaut 900 s : attendre un quart d'heure pour // sortir d'un état non voulu n'est pas défendable. const QSet reel = readActualOn(); switch (m_phase) { case PhaseNominale: qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label << "— échec d'écriture : retour à l'état" << m_statePrev << "."; m_phase = PhaseRepli; m_currentState = m_statePrev; applyStateRelays(reel, m_statePrev); break; case PhaseRepli: // PLANCHER d'une PAC : l'ÉTAT 2, pas « contacts ouverts ». Ouvrir les deux contacts // est une COMMANDE, et selon l'encodage câblé ce peut être le BLOCAGE (SAFETY.md). qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label << "— retour arrière en échec : repli sur l'état 2 (normal)."; m_phase = PhasePlancher; m_currentState = 2; m_stateTarget = 2; applyStateRelays(reel, 2); break; case PhasePlancher: case PhaseDefaut: qCCritical(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label << "— repli état 2 en échec : PAC EN DÉFAUT, plus aucune" << "commande. Levée par NymeaEnergy.ClearLoadFault."; m_phase = PhaseDefaut; m_faulted = true; break; } } bool SgReadyAdapter::clearFault() { if (!m_faulted) return false; // rien à lever — l'arbitre le dira (règle 7-c) qCInfo(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label << "— défaut levé par l'opérateur (ClearLoadFault)."; m_faulted = false; m_phase = PhaseNominale; m_writeFailed = false; // État RELU, jamais supposé : le motif 2 bits peut être valide mais non voulu. const int lu = stateForRelays(readActualOn().values()); if (lu > 0) m_currentState = lu; m_statePrev = m_stateTarget = m_currentState; return true; } void SgReadyAdapter::applyStateRelays(const QSet ¤tOn, int toState) { const QList targetList = m_stateRelays.value(toState); const QSet targetOn(targetList.begin(), targetList.end()); // Relais dont l'état change lors de la transition. // // Un contact INJOIGNABLE est toujours inclus, même si l'état supposé coïncide avec la // cible : on ne peut pas le vérifier, donc on doit le COMMANDER. Sans cela, l'hypothèse // conservatrice « injoignable = fermé » masquerait l'échec — la transition paraîtrait // réussie sans qu'aucune écriture n'ait été tentée sur le contact en panne. QStringList changed; for (const QString &relay : allRelays()) { const bool verifiable = m_thingManager && m_thingManager->findConfiguredThing(ThingId(relay)); if (!verifiable || targetOn.contains(relay) != currentOn.contains(relay)) changed << relay; } // Contrat d'atomicité : si 2 relais (ou +) changent, commuter d'abord celui dont le // TRANSITOIRE est le plus doux (neutre/reco plutôt que blocage/forcé), puis les autres. // Vaut pour le chemin ALLER **comme** pour le REPLI (ECS-414) : une récupération qui // traverserait le blocage serait plus dangereuse que la panne qu'elle corrige. if (changed.size() >= 2) { QString best; int bestHarm = INT_MAX; for (const QString &r : changed) { QSet transient = currentOn; if (targetOn.contains(r)) transient.insert(r); else transient.remove(r); const int h = transientHarm(stateForRelays(transient.values())); if (h < bestHarm) { bestHarm = h; best = r; } } writeRelay(best, targetOn.contains(best)); changed.removeAll(best); } // Relais restants amenés à leur valeur cible. for (const QString &r : changed) writeRelay(r, targetOn.contains(r)); // Chemin entièrement synchrone (mock) : aucun acquittement n'arrivera, verdict immédiat. if (m_pending == 0) settleTransition(); }