Patrick Schurig 6298d5d42f [3c-3] waterfall ECS dans RuleBasedScheduler::getPlan() + tri priorité ASC
Corrections A (déduction EV unique) et B (anti-clignotement) intégrées.
Tri priorité ascendant (rang 1 = premier servi, OPTIMIZER_PROTOCOL §5/annexe C) —
corrige l'inversion du PLAN 3C et 3 doc-comments (plan.h, loaddescriptor.h,
ecsrelayadapter.h). Build 0 erreur / 0 warning.

telemetry() ECS : currentPowerW MESURÉE si au moins un relais expose "currentPower"
(thermostat coupé → 0, pas de fantôme), DÉCLARÉE en repli seulement sans comptage.
Dette evadapter.cpp priority=100 (ancienne convention) inscrite en 3g.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:20:08 +02:00

203 lines
7.1 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
{
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;
return ctx;
}
LoadAction EcsRelayAdapter::applyAction(const LoadAction &action)
{
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 — bypassés si force == true (L2 watchdog)
if (!action.force && lockActive(newStage)) {
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 = QDateTime::currentDateTime();
m_lastActionAt = m_lastSwitch;
LoadAction applied = action;
applied.stage = newStage;
applied.estimatedPowerW = m_stages.at(newStage);
return applied;
}
// ---- privé ---------------------------------------------------------------
bool EcsRelayAdapter::lockActive(int newStage) const
{
if (!m_lastSwitch.isValid())
return false;
const int elapsed = static_cast<int>(m_lastSwitch.secsTo(QDateTime::currentDateTime()));
if (newStage > m_currentStage) {
// Passage à un palier supérieur : minOffS si l'on quitte l'état OFF (stage 0→n)
// ou simplement le délai de stabilisation
if (m_currentStage == 0 && elapsed < m_minOffS)
return true;
} else {
// Réduction de palier : minOnS depuis le dernier ON
if (m_currentStage > 0 && elapsed < m_minOnS)
return true;
}
return false;
}
void EcsRelayAdapter::applyRelayStage(int stage)
{
// Ensemble des relais ON pour le nouveau stage
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;
}();
// Union de tous les relais connus
QSet<QString> allRelays;
for (const auto &list : m_relayMapping)
for (const QString &id : list)
allRelays.insert(id);
for (const QString &thingId : allRelays) {
Thing *relay = m_thingManager->findConfiguredThing(ThingId(thingId));
if (!relay) {
qCWarning(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label
<< "— relais non trouvé:" << thingId;
continue;
}
const bool targetOn = wantOn.contains(thingId);
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(), targetOn));
m_thingManager->executeAction(powerAction);
} else {
// Fallback mock : setStateValue direct (Things sans actionType "power")
relay->setStateValue("power", targetOn);
}
}
}