From e16aca4d1a6916d04fcdaa3ee4e2fc813f783965 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 28 Jun 2026 12:03:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(etm):=20RelayRouter=20=E2=80=94=20couche?= =?UTF-8?q?=20routeur=20watts=E2=86=92relais=20(contrat=20r=C3=A9v.=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restauration de l'ex-EcsRelayAdapter (b7bfd58) repositionné en ROUTEUR distinct, sous l'optimiseur watt-pur. ILoadAdapter consommant Setpoint(W) au lieu de Stage : - Paliers DÉRIVÉS : toutes les combinaisons (sommes de sous-ensembles) des relais, dédupliquées, triées, 0 inclus — source de vérité unique = les relais (descriptor().powerLevels/maxPowerW). - applyAction(Setpoint W) : mappe powerW → combinaison la plus haute ≤ powerW, applique les verrous minOn/minOff EN INTERNE (clamp, plus exposés au scheduler), commute via ThingManager::executeAction (interface "power"), off-before-on. force=true → bypass (repli L2). - currentPowerW : mesuré si relais expose "currentPower", sinon nominal commandé (GPIO bool nu). - LoadContext : watts uniquement (powerLevels/currentPowerW) — aucun relais ne franchit la frontière. Classe INERTE à ce commit (non câblée à l'arbitre) : aucune charge ne l'utilise, donc aucune charge sans repli L2. Câblage + repli L2 = étape 4 (atomiquement). Build prod 0/0. Co-Authored-By: Claude Opus 4.8 (1M context) --- energyplugin/etm/adapters/relayrouter.cpp | 245 ++++++++++++++++++++++ energyplugin/etm/adapters/relayrouter.h | 109 ++++++++++ energyplugin/etm/etm.pri | 2 + 3 files changed, 356 insertions(+) create mode 100644 energyplugin/etm/adapters/relayrouter.cpp create mode 100644 energyplugin/etm/adapters/relayrouter.h diff --git a/energyplugin/etm/adapters/relayrouter.cpp b/energyplugin/etm/adapters/relayrouter.cpp new file mode 100644 index 0000000..732dd08 --- /dev/null +++ b/energyplugin/etm/adapters/relayrouter.cpp @@ -0,0 +1,245 @@ +// 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 + +namespace { +//! Au-delà, 2^N combinaisons explose. 16 relais = 65536 combos — bien au-delà de tout ECS réel. +constexpr int MaxRelays = 16; +} + +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; + } + + // Paliers DÉRIVÉS : 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. + 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); + } + for (auto it = byPower.constBegin(); it != byPower.constEnd(); ++it) { + m_levels.append(it.key()); + m_relayMapping.append(it.value()); + } + // byPower contient toujours la clé 0 (masque vide) → m_levels[0] == 0. + Q_ASSERT(!m_levels.isEmpty() && m_levels.first() == 0); +} + +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(); + 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 = true; + 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). + double power = 0; + bool metered = false; + const QList activeRelays = m_currentStage < m_relayMapping.size() + ? m_relayMapping.at(m_currentStage) + : QList(); + for (const QString &thingId : 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é + + t.currentPowerW = power; + return t; +} + +LoadContext RelayRouter::toLoadContext(const QDateTime &now) const +{ + Q_UNUSED(now) // pas de fenêtre de verrou exposée : le verrou est INTERNE (frontière rév. 3). + + LoadContext ctx; + ctx.id = m_id; + ctx.adapter = QStringLiteral("relay-router"); + ctx.label = m_label; + ctx.priority = m_priority; + ctx.declared = descriptor().declared; // powerLevels/maxPowerW dérivés (watts) + ctx.needs = m_needs; + + ctx.telemetry.currentPowerW = telemetry().currentPowerW; + // Aucun stage/minStage/maxStage : ces champs n'existent plus (rév. 2) et le relais ne + // franchit pas la frontière. L'optimiseur ne voit que des watts. + 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."; + return action; + } + + 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); + + if (newStage == m_currentStage) + 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)" : "") + << "|" << action.reason; + + 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::lockWindow(const QDateTime &now, int &minStage, int &maxStage) const +{ + const int topStage = m_levels.size() - 1; + const bool valid = m_lastSwitch.isValid(); + const qint64 elapsed = valid ? m_lastSwitch.secsTo(now) : 0; + + // Plancher : si ON et minOn non écoulé → interdit de descendre (puissance engagée non-coupable). + minStage = (m_currentStage > 0 && valid && elapsed < m_minOnS) ? m_currentStage : 0; + // Plafond : si à l'arrêt et minOff non écoulé → interdit de redémarrer. + maxStage = (m_currentStage == 0 && valid && elapsed < m_minOffS) ? 0 : topStage; +} + +void RelayRouter::applyRelayStage(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 < 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); + + auto writeRelay = [&](const QString &thingId, bool on) { + Thing *relay = m_thingManager->findConfiguredThing(ThingId(thingId)); + if (!relay) { + qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << 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 + } + }; + + // 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); +} diff --git a/energyplugin/etm/adapters/relayrouter.h b/energyplugin/etm/adapters/relayrouter.h new file mode 100644 index 0000000..c6c1bbe --- /dev/null +++ b/energyplugin/etm/adapters/relayrouter.h @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#pragma once + +#include +#include +#include +#include +#include "iloadadapter.h" +#include "../types/loadconfig.h" // LoadConfigRelay + +class Thing; +class ThingManager; + +/*! + * \brief ROUTEUR de relais (contrat rév. 3) — traduit un \c Setpoint(W) de l'optimiseur en + * combinaison de things \c power, côté experience-plugin. + * + * \par Frontière rév. 3 + * L'optimiseur (scheduler) est **watt-pur** : il émet \c LoadAction{Setpoint, powerW}. Le + * `RelayRouter` est la couche **sous** l'optimiseur qui connaît les relais : il mappe + * \c powerW → la combinaison atteignable la plus haute ≤ \c powerW, applique l'anti-rebond + * (\c minOn/minOff) **ici** (plus dans le scheduler), commute les relais via + * \c ThingManager::executeAction (interface \c power), et agrège \c currentPowerW. **Aucun + * relais, aucun index de combinaison ne remonte dans le \c LoadContext** (seuls les watts + * dérivés `powerLevels`/`maxPowerW` y figurent). + * + * \par Paliers DÉRIVÉS + * Les \c powerLevels (et \c maxPowerW) sont **calculés** depuis la liste de relais : toutes + * les sommes de sous-ensembles atteignables, dédupliquées, triées, \c 0 inclus. **Source de + * vérité unique = les relais** (l'app affiche le même calcul, l'optimiseur les lit pour + * l'arrondi/résidu). + * + * \invariant \c supportedKinds == { Setpoint }. Autres kinds : retour sans effet. + * \invariant applyAction() rejette silencieusement toute action dont \c reason est vide. + * \invariant Verrous \c minOn/minOff appliqués en INTERNE (clamp), bypassés si \c force==true + * (repli L2). **Temps = paramètre** (cf. \c ILoadAdapter) : \c now reçu, jamais l'horloge. + * \invariant Transition relais en **off-before-on** : coupe d'abord les relais hors-cible. + */ +class RelayRouter : public QObject, public ILoadAdapter +{ + Q_OBJECT +public: + /*! + * \brief Constructeur. + * \param thingManager Résout les ThingIds des relais et exécute les actions \c power. + * \param id Identifiant LOGIQUE de la charge (rév. 3 : plus un thingId unique). + * \param label Nom lisible (logs, app). + * \param relays Liste \c {thingId, powerW} des relais \c power. Les paliers atteignables + * sont dérivés de toutes leurs combinaisons. + * \param minOnS/minOffS Verrous anti-rebond (protection relais/compresseur), appliqués ici. + * \param priority Rang dans le waterfall (1 = servi en premier). + * \param needs Besoins déclarés (exposés via descriptor().needs). + * \param parent Propriétaire Qt. + */ + explicit RelayRouter(ThingManager *thingManager, + const QString &id, + const QString &label, + const QList &relays, + int minOnS, + int minOffS, + int priority, + const LoadNeeds &needs = LoadNeeds(), + QObject *parent = nullptr); + + //! \return LoadDescriptor : adapter="relay-router", powerLevels/maxPowerW DÉRIVÉS, needs. + LoadDescriptor descriptor() const override; + + //! \return currentPowerW = somme des \c currentPower des relais ON (mesuré), sinon nominal commandé. + LoadTelemetry telemetry() const override; + + //! \return LoadContext §5 (watts uniquement : powerLevels, currentPowerW — aucun relais). + LoadContext toLoadContext(const QDateTime &now) const override; + + /*! + * \brief Applique un \c Setpoint(W) : mappe en combinaison de relais, clampe par les verrous, + * commute (off-before-on), publie l'état. + * \param action LoadAction kind \c Setpoint (\c powerW). Autres kinds : retour sans effet. + * \param now Temps de cycle (verrous + estampille). + * \return L'action après écrêtage (\c powerW = palier réellement appliqué). + */ + LoadAction applyAction(const LoadAction &action, const QDateTime &now) override; + + //! \brief Palier courant (0 = tout coupé). + int currentStage() const { return m_currentStage; } + //! \brief Puissance (W) du palier courant. + double currentSetpointW() const { return m_currentStage < m_levels.size() ? m_levels.at(m_currentStage) : 0.0; } + +private: + //! Palier le plus haut dont la puissance ≤ \p powerW (≥ 0). + int stageForPower(double powerW) const; + //! Fenêtre de paliers autorisée à \p now par minOn/minOff (verrou INTERNE, plus exposé). + void lockWindow(const QDateTime &now, int &minStage, int &maxStage) const; + void applyRelayStage(int stage); + + ThingManager *m_thingManager; + QString m_id; + QString m_label; + QList m_levels; //!< Paliers W dérivés, triés, [0]=0. + QList> m_relayMapping; //!< ThingIds ON par palier (dérivé des combinaisons). + int m_minOnS; + int m_minOffS; + int m_priority; + LoadNeeds m_needs; + + int m_currentStage = 0; + QDateTime m_lastSwitch; //!< Dernier changement (null = jamais). + QDateTime m_lastActionAt; +}; diff --git a/energyplugin/etm/etm.pri b/energyplugin/etm/etm.pri index 20e1589..78835e9 100644 --- a/energyplugin/etm/etm.pri +++ b/energyplugin/etm/etm.pri @@ -10,6 +10,7 @@ HEADERS += \ $$PWD/adapters/evadapter.h \ $$PWD/adapters/sgreadyadapter.h \ $$PWD/adapters/etmvariableloadadapter.h \ + $$PWD/adapters/relayrouter.h \ $$PWD/scheduler/rulebasedscheduler.h \ $$PWD/energyarbitrator.h \ @@ -19,5 +20,6 @@ SOURCES += \ $$PWD/adapters/evadapter.cpp \ $$PWD/adapters/sgreadyadapter.cpp \ $$PWD/adapters/etmvariableloadadapter.cpp \ + $$PWD/adapters/relayrouter.cpp \ $$PWD/scheduler/rulebasedscheduler.cpp \ $$PWD/energyarbitrator.cpp \