[3e-2] SgReadyAdapter : encodage 2 bits → 4 états + atomicité de transition
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) <noreply@anthropic.com>
This commit is contained in:
parent
83d5ad9ed7
commit
c6d7831df9
235
energyplugin/etm/adapters/sgreadyadapter.cpp
Normal file
235
energyplugin/etm/adapters/sgreadyadapter.cpp
Normal file
@ -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 <QDateTime>
|
||||
#include <QSet>
|
||||
#include <algorithm>
|
||||
#include <climits>
|
||||
#include <integrations/thingmanager.h>
|
||||
#include <integrations/thing.h>
|
||||
#include <types/action.h>
|
||||
#include <types/param.h>
|
||||
|
||||
SgReadyAdapter::SgReadyAdapter(ThingManager *thingManager,
|
||||
const QString &id,
|
||||
const QString &label,
|
||||
const QHash<int, QList<QString>> &stateRelays,
|
||||
const QHash<int, double> &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<QString> &onRelays) const
|
||||
{
|
||||
const QSet<QString> want(onRelays.begin(), onRelays.end());
|
||||
for (auto it = m_stateRelays.constBegin(); it != m_stateRelays.constEnd(); ++it) {
|
||||
const QSet<QString> s(it.value().begin(), it.value().end());
|
||||
if (s == want)
|
||||
return it.key();
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
QSet<QString> SgReadyAdapter::allRelays() const
|
||||
{
|
||||
QSet<QString> 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<QString> targetList = m_stateRelays.value(toState);
|
||||
const QSet<QString> targetOn(targetList.begin(), targetList.end());
|
||||
const QList<QString> fromList = m_stateRelays.value(fromState);
|
||||
const QSet<QString> 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<QString> 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));
|
||||
}
|
||||
125
energyplugin/etm/adapters/sgreadyadapter.h
Normal file
125
energyplugin/etm/adapters/sgreadyadapter.h
Normal file
@ -0,0 +1,125 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QDateTime>
|
||||
#include <QList>
|
||||
#include <QHash>
|
||||
#include <QString>
|
||||
#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<int, QList<QString>> &stateRelays,
|
||||
const QHash<int, double> &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<QString> &onRelays) const;
|
||||
|
||||
QSet<QString> allRelays() const;
|
||||
|
||||
ThingManager *m_thingManager;
|
||||
QString m_id;
|
||||
QString m_label;
|
||||
QHash<int, QList<QString>> m_stateRelays; //!< état → ThingIds ON.
|
||||
QHash<int, double> m_estimatedPowerW; //!< état → W estimés (déclaré).
|
||||
QList<int> 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;
|
||||
};
|
||||
@ -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 \
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user