From c6d7831df92f25bb1a58746757af8ceea51d863c Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Tue, 9 Jun 2026 22:53:07 +0200 Subject: [PATCH] =?UTF-8?q?[3e-2]=20SgReadyAdapter=20:=20encodage=202=20bi?= =?UTF-8?q?ts=20=E2=86=92=204=20=C3=A9tats=20+=20atomicit=C3=A9=20de=20tra?= =?UTF-8?q?nsition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adaptateur sg-ready (kind:State) : pilote N relais signal (stateRelays par état), lockWindow symétrique (minStateHold, gel total — protection court-cycling), seam de temps unifié (toLoadContext(now)/applyAction(now)). currentPowerW = puissance allouée déclarée (pas mesurée → recrédit correct, anti double-comptage état 2). Atomicité 2 bits : applyStateRelays commute d'abord le relais au transitoire le plus doux (neutre/reco) puis les autres → jamais de blocage/forcé parasite. Contrat documenté (transport déporté Shelly/Modbus). État initial = 2 (mains off). Build 0/0. Co-Authored-By: Claude Opus 4.8 (1M context) --- energyplugin/etm/adapters/sgreadyadapter.cpp | 235 +++++++++++++++++++ energyplugin/etm/adapters/sgreadyadapter.h | 125 ++++++++++ energyplugin/etm/etm.pri | 2 + 3 files changed, 362 insertions(+) create mode 100644 energyplugin/etm/adapters/sgreadyadapter.cpp create mode 100644 energyplugin/etm/adapters/sgreadyadapter.h diff --git a/energyplugin/etm/adapters/sgreadyadapter.cpp b/energyplugin/etm/adapters/sgreadyadapter.cpp new file mode 100644 index 0000000..20357a0 --- /dev/null +++ b/energyplugin/etm/adapters/sgreadyadapter.cpp @@ -0,0 +1,235 @@ +// 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)); +} diff --git a/energyplugin/etm/adapters/sgreadyadapter.h b/energyplugin/etm/adapters/sgreadyadapter.h new file mode 100644 index 0000000..57dcb5b --- /dev/null +++ b/energyplugin/etm/adapters/sgreadyadapter.h @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#pragma once + +#include +#include +#include +#include +#include +#include "iloadadapter.h" + +class Thing; +class ThingManager; + +/*! + * \brief Adaptateur SG-Ready (PAC) — interface "sg-ready", action \c kind:State. + * + * Pilote une pompe à chaleur via 2 contacts SG-Ready (encodage 2 bits → 4 états NORMÉS) : + * 1 = blocage (EVU-Sperre) · 2 = normal (mains off : la PAC décide) + * 3 = recommandation (surplus) · 4 = forcé (boost) + * + * Les 4 états ne sont PAS des paliers de puissance : ils sont qualitatifs, la PAC les + * interprète selon SA logique. \c m_stateRelays[état] = ThingIds powerswitch à mettre ON + * pour cet état (encodage câblé par l'installateur ; les autres relais sont OFF). + * + * \invariant applyAction() rejette silencieusement toute action dont \c reason est vide. + * \invariant applyAction() applique le verrou \c minStateHoldS (protection court-cycling + * compresseur) SAUF si \c action.force == true (réservé L2 watchdog → état 2). + * \invariant L'état est écrêté à l'ensemble \c m_states avant envoi matériel. + * \invariant Seul le kind State est traité ; les autres kinds retournent sans effet. + * + * \par Contrat d'atomicité (transport déporté Shelly/Modbus à venir) + * Une transition d'état commute parfois 2 relais (ex. 2→4 : 00→11). Les contacts + * doivent être écrits **aussi atomiquement que possible**, et l'ORDRE de commutation + * doit éviter tout **état actif parasite** : on passe par le transitoire le plus DOUX + * (neutre = état 2, sinon recommandation = état 3) plutôt que par blocage (1) ou forcé + * (4). \c applyStateRelays() choisit cet ordre. En GPIO local le transitoire dure des µs, + * mais l'intention est portée par l'adaptateur pour rester correcte sur un bus lent. + */ +class SgReadyAdapter : public QObject, public ILoadAdapter +{ + Q_OBJECT +public: + /*! + * \brief Constructeur. + * \param thingManager Gestionnaire nymea pour résoudre les ThingIds. + * \param id Identifiant logique de la charge. + * \param label Nom lisible (logs, app). + * \param stateRelays état → liste de ThingIds powerswitch ON (encodage 2 bits SG-Ready). + * \param estimatedPowerW Puissance estimée (W) par état (déclaré installateur, approx.). + * \param minStateHoldS Durée minimale de maintien d'état (s) — protection court-cycling. + * \param priority Rang dans le waterfall (protocole §5 : valeur plus BASSE = servi en premier). + * \param parent Propriétaire Qt. + */ + explicit SgReadyAdapter(ThingManager *thingManager, + const QString &id, + const QString &label, + const QHash> &stateRelays, + const QHash &estimatedPowerW, + int minStateHoldS, + int priority, + QObject *parent = nullptr); + + LoadDescriptor descriptor() const override; + + /*! + * \brief Télémétrie runtime. \c currentPowerW = puissance ALLOUÉE de l'état courant + * (\c declared.estimatedPowerW : 0 pour états 1/2, P3/P4 pour 3/4). C'est la base du + * recrédit budget — PAS la conso mesurée de la PAC (l'état 2 autonome est déjà au + * compteur, invariant 8 : la recréditer double-compterait). + */ + LoadTelemetry telemetry() const override; + + /*! + * \brief Construit l'entrée loads[] §5 (adapter="sg-ready"). + * \param now Temps de cycle (\c ctx.timestamp) — source unique de la fenêtre minState/maxState. + */ + LoadContext toLoadContext(const QDateTime &now) const override; + + /*! + * \brief Applique un changement d'état SG-Ready (2 relais, transition atomique-douce). + * \param action LoadAction de kind State. Autres kinds : retour sans effet. + * \param now Temps de cycle — MÊME source que toLoadContext() (verrou + lastSwitch). + * \return L'action après écrêtage (state borné à l'ensemble déclaré). + */ + LoadAction applyAction(const LoadAction &action, const QDateTime &now) override; + + /*! \brief État SG-Ready courant (1-4). */ + int currentState() const { return m_currentState; } + +private: + /*! + * \brief Fenêtre d'états autorisée à \p now par le verrou minStateHold (symétrique). + * \param[out] minState / maxState Gel total (== \c m_currentState) si \c minStateHold + * non écoulé ; sinon [min, max] des états déclarés. + */ + void lockWindow(const QDateTime &now, int &minState, int &maxState) const; + + bool lockActive(int newState, const QDateTime &now) const; + + //! Applique l'ensemble de relais de \p toState en passant par le transitoire le plus + //! doux (cf. contrat d'atomicité). \p fromState = état courant (pour l'ordre). + void applyStateRelays(int fromState, int toState); + + //! Rang de nocivité d'un état comme TRANSITOIRE (2 neutre < 3 reco < 1 blocage < 4 forcé). + static int transientHarm(int state); + + //! État correspondant à un ensemble de relais ON (-1 si aucun état ne correspond). + int stateForRelays(const QList &onRelays) const; + + QSet allRelays() const; + + ThingManager *m_thingManager; + QString m_id; + QString m_label; + QHash> m_stateRelays; //!< état → ThingIds ON. + QHash m_estimatedPowerW; //!< état → W estimés (déclaré). + QList m_states; //!< États déclarés triés croissants. + int m_minStateHoldS; + int m_priority; + + int m_currentState = 2; //!< Démarrage en NORMAL (mains off). + QDateTime m_lastSwitch; //!< Dernier changement d'état (null = jamais). + QDateTime m_lastActionAt; +}; diff --git a/energyplugin/etm/etm.pri b/energyplugin/etm/etm.pri index 62b60c4..37a0792 100644 --- a/energyplugin/etm/etm.pri +++ b/energyplugin/etm/etm.pri @@ -7,11 +7,13 @@ HEADERS += \ $$PWD/scheduler/ischeduler.h \ $$PWD/adapters/evadapter.h \ $$PWD/adapters/ecsrelayadapter.h \ + $$PWD/adapters/sgreadyadapter.h \ $$PWD/scheduler/rulebasedscheduler.h \ $$PWD/energyarbitrator.h \ SOURCES += \ $$PWD/adapters/evadapter.cpp \ $$PWD/adapters/ecsrelayadapter.cpp \ + $$PWD/adapters/sgreadyadapter.cpp \ $$PWD/scheduler/rulebasedscheduler.cpp \ $$PWD/energyarbitrator.cpp \