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>
236 lines
8.4 KiB
C++
236 lines
8.4 KiB
C++
// 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));
|
|
}
|