applyRelayStage faisait déjà du set-cible complet (delta correct, gère le non-cascadé) : durcissement off-before-on (anti sur-puissance transitoire quand monter de palier éteint des relais, ex. 3 résistances 500/1000/2000 : 1500→2000 commute 3 relais) + intention documentée (comme SG-Ready). testEcsRelayTopologies : ECS simple 1 relais (on/off) + ECS 3 relais non-cascadé (transition 1500→2000 → set final r2000 SEUL, r500/r1000 coupés). Couvre les 2 topologies du test terrain vendredi. Suite simulation 20/20, plugin prod 0/0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
218 lines
8.4 KiB
C++
218 lines
8.4 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
|
|
|
#include "ecsrelayadapter.h"
|
|
#include "plugininfo.h"
|
|
|
|
#include <QDateTime>
|
|
#include <integrations/thingmanager.h>
|
|
#include <integrations/thing.h>
|
|
#include <types/action.h>
|
|
#include <types/param.h>
|
|
|
|
EcsRelayAdapter::EcsRelayAdapter(ThingManager *thingManager,
|
|
const QString &id,
|
|
const QString &label,
|
|
const QList<int> &stages,
|
|
const QList<QList<QString>> &relayMapping,
|
|
int minOnS,
|
|
int minOffS,
|
|
int priority,
|
|
QObject *parent)
|
|
: QObject(parent)
|
|
, m_thingManager(thingManager)
|
|
, m_id(id)
|
|
, m_label(label)
|
|
, m_stages(stages)
|
|
, m_relayMapping(relayMapping)
|
|
, m_minOnS(minOnS)
|
|
, m_minOffS(minOffS)
|
|
, m_priority(priority)
|
|
{
|
|
Q_ASSERT(!m_stages.isEmpty() && m_stages.first() == 0);
|
|
Q_ASSERT(m_relayMapping.size() == m_stages.size());
|
|
}
|
|
|
|
LoadDescriptor EcsRelayAdapter::descriptor() const
|
|
{
|
|
LoadDescriptor d;
|
|
d.id = m_id;
|
|
d.label = m_label;
|
|
d.adapter = QStringLiteral("relay-stages");
|
|
d.priority = m_priority;
|
|
|
|
d.declared.stages = m_stages;
|
|
d.limits.minOnS = m_minOnS;
|
|
d.limits.minOffS = m_minOffS;
|
|
|
|
d.supportedKinds = { LoadAction::Stage };
|
|
return d;
|
|
}
|
|
|
|
LoadTelemetry EcsRelayAdapter::telemetry() const
|
|
{
|
|
LoadTelemetry t;
|
|
t.available = true;
|
|
t.lastActionAt = m_lastActionAt;
|
|
|
|
// Source de currentPowerW (consommée par le recrédit anti-clignotement du waterfall,
|
|
// RuleBasedScheduler::buildEcsStageAction — correction B) :
|
|
// - MESURÉE dès qu'au moins un relais du stage expose un state "currentPower" :
|
|
// somme des mesures. Un thermostat interne coupé (relais ON mais 0 W réel) renvoie
|
|
// alors 0 → recrédit 0, jamais de puissance fantôme.
|
|
// - DÉCLARÉE (repli) : stages[stage] UNIQUEMENT si aucun relais actif ne mesure
|
|
// (relais nus / mock sans powermetering). Approximation = palier à sa nominale.
|
|
double power = 0;
|
|
bool metered = false;
|
|
const QList<QString> &activeRelays = m_currentStage < m_relayMapping.size()
|
|
? m_relayMapping.at(m_currentStage)
|
|
: QList<QString>();
|
|
for (const QString &thingId : activeRelays) {
|
|
Thing *t2 = m_thingManager->findConfiguredThing(ThingId(thingId));
|
|
if (!t2)
|
|
continue;
|
|
if (!t2->thingClass().stateTypes().findByName("currentPower").id().isNull()) {
|
|
metered = true;
|
|
power += t2->stateValue("currentPower").toDouble();
|
|
}
|
|
}
|
|
// Repli déclaré : seulement si la mesure n'est pas disponible (pas si elle vaut 0).
|
|
if (!metered && m_currentStage > 0 && m_currentStage < m_stages.size())
|
|
power = m_stages.at(m_currentStage);
|
|
|
|
t.currentPowerW = power;
|
|
return t;
|
|
}
|
|
|
|
LoadContext EcsRelayAdapter::toLoadContext(const QDateTime &now) const
|
|
{
|
|
LoadContext ctx;
|
|
ctx.id = m_id;
|
|
ctx.adapter = QStringLiteral("relay-stages");
|
|
ctx.label = m_label;
|
|
ctx.priority = m_priority;
|
|
ctx.declared = descriptor().declared;
|
|
ctx.limits = descriptor().limits;
|
|
|
|
const LoadTelemetry tel = telemetry();
|
|
ctx.telemetry.currentPowerW = tel.currentPowerW;
|
|
ctx.telemetry.stage = m_currentStage;
|
|
ctx.telemetry.lastSwitch = m_lastSwitch;
|
|
// Fenêtre de verrou évaluée au temps de cycle : le scheduler y borne son choix
|
|
// (plancher = puissance engagée non-coupable sous minOn ; plafond = redémarrage minOff).
|
|
lockWindow(now, ctx.telemetry.minStage, ctx.telemetry.maxStage);
|
|
return ctx;
|
|
}
|
|
|
|
LoadAction EcsRelayAdapter::applyAction(const LoadAction &action, const QDateTime &now)
|
|
{
|
|
if (action.kind != LoadAction::Stage)
|
|
return action;
|
|
|
|
if (action.reason.isEmpty()) {
|
|
qCWarning(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label
|
|
<< "— LoadAction sans reason rejetée.";
|
|
return action;
|
|
}
|
|
|
|
const int newStage = qBound(0, action.stage, m_stages.size() - 1);
|
|
|
|
if (newStage == m_currentStage)
|
|
return action; // Aucun changement → idempotent
|
|
|
|
// Verrous anti-rebond évalués au temps de cycle (même fenêtre que le scheduler) —
|
|
// bypassés si force == true (L2 watchdog).
|
|
if (!action.force && lockActive(newStage, now)) {
|
|
qCDebug(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label
|
|
<< "— verrou anti-rebond actif, stage" << newStage << "ignoré.";
|
|
return action;
|
|
}
|
|
|
|
qCDebug(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label
|
|
<< "→ stage" << newStage
|
|
<< "(" << (m_currentStage < m_stages.size() ? m_stages.at(m_currentStage) : 0) << "W"
|
|
<< "→" << m_stages.at(newStage) << "W)"
|
|
<< "|" << action.reason;
|
|
|
|
applyRelayStage(newStage);
|
|
|
|
m_currentStage = newStage;
|
|
m_lastSwitch = now; // estampille au temps de cycle (cohérent avec la fenêtre de verrou)
|
|
m_lastActionAt = now;
|
|
|
|
LoadAction applied = action;
|
|
applied.stage = newStage;
|
|
applied.estimatedPowerW = m_stages.at(newStage);
|
|
return applied;
|
|
}
|
|
|
|
// ---- privé ---------------------------------------------------------------
|
|
|
|
void EcsRelayAdapter::lockWindow(const QDateTime &now, int &minStage, int &maxStage) const
|
|
{
|
|
const int topStage = m_stages.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 sous le palier courant
|
|
// (puissance engagée non-coupable, protection compresseur / anti court-cycling).
|
|
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;
|
|
}
|
|
|
|
bool EcsRelayAdapter::lockActive(int newStage, const QDateTime &now) const
|
|
{
|
|
// MÊME calcul que la fenêtre exposée au scheduler → décision et exécution coïncident.
|
|
int minStage, maxStage;
|
|
lockWindow(now, minStage, maxStage);
|
|
return newStage < minStage || newStage > maxStage;
|
|
}
|
|
|
|
void EcsRelayAdapter::applyRelayStage(int stage)
|
|
{
|
|
// Set de relais CIBLE du palier (delta complet : chaque relais connu est amené à son
|
|
// état cible on/off, pas d'ajout incrémental). Gère les mappings NON-CASCADÉS où monter
|
|
// d'un palier éteint des relais (ex. 3 résistances 500/1000/2000 W : 1500=[r500,r1000]
|
|
// → 2000=[r2000] commute 3 relais).
|
|
const QSet<QString> wantOn = [&]() {
|
|
QSet<QString> s;
|
|
if (stage < m_relayMapping.size())
|
|
for (const QString &id : m_relayMapping.at(stage))
|
|
s.insert(id);
|
|
return s;
|
|
}();
|
|
|
|
QSet<QString> 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()) << "[EcsRelayAdapter]" << 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
|
|
}
|
|
};
|
|
|
|
// Intention (résistif : transitoire inoffensif, mais portée comme SG-Ready) : COUPER
|
|
// d'abord les relais hors-cible, PUIS enclencher ceux de 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);
|
|
}
|