// 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 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 = true; 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); 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.state = m_currentState; ctx.telemetry.lastSwitch = m_lastSwitch; // 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; } // É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; } qCDebug(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label << "→ état" << newState << "(" << m_estimatedPowerW.value(newState, 0.0) << "W estimés)" << "|" << action.reason; applyStateRelays(m_currentState, 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; } // ---- 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; } void SgReadyAdapter::applyStateRelays(int fromState, int toState) { const QList targetList = m_stateRelays.value(toState); const QSet targetOn(targetList.begin(), targetList.end()); const QList fromList = m_stateRelays.value(fromState); const QSet currentOn(fromList.begin(), fromList.end()); // Relais dont l'état change lors de la transition. QStringList changed; for (const QString &relay : allRelays()) if (targetOn.contains(relay) != currentOn.contains(relay)) changed << relay; auto writeRelay = [&](const QString &thingId, bool on) { Thing *relay = m_thingManager->findConfiguredThing(ThingId(thingId)); if (!relay) { qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label << "— relais non trouvé:" << thingId; return; } StateType powerStateType = relay->thingClass().stateTypes().findByName("power"); if (!powerStateType.id().isNull()) { Action powerAction(powerStateType.id(), relay->id(), Action::TriggeredByRule); powerAction.setParams(ParamList() << Param(powerStateType.id(), on)); m_thingManager->executeAction(powerAction); } else { relay->setStateValue("power", on); // repli mock } }; // 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. 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)); }