// 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 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()); Q_ASSERT(!m_states.isEmpty()); Q_ASSERT(m_stateRelays.contains(2)); // état 2 (normal) = repli sûr obligatoire } 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; d.limits.minStateHoldS = m_minStateHoldS; d.supportedKinds = { LoadAction::State }; return d; } LoadTelemetry SgReadyAdapter::telemetry() const { LoadTelemetry t; t.available = !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_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; } 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 action; } // 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) { LoadAction refused = action; refused.state = m_currentState; return refused; } // É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 action; } if (newState == m_currentState) return 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)) { qCDebug(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label << "— verrou minStateHold actif, état" << newState << "ignoré."; return action; } qCInfo(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label << "→ état" << newState << "(" << m_estimatedPowerW.value(newState, 0.0) << "W estimés)" << "|" << action.reason; 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; } 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 = QStringLiteral("Charge désactivée — mise en état sûr (état 2) avant retrait (ECS-413)"); 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(); const bool valid = m_lastSwitch.isValid(); const qint64 elapsed = valid ? m_lastSwitch.secsTo(now) : 0; if (valid && 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; } } void SgReadyAdapter::clearFault() { if (!m_faulted) return; 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; } 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(); }