Patrick Schurig c9b8e63f89 fix(etm): ECS-412 — rebuild incrémental et armement du verrou à froid
Cause racine, pas symptôme. rebuildLoadAdapters() détruisait TOUS les adaptateurs
à chaque SetLoadConfig, réarmant leurs verrous. Sur un ballon thermodynamique à
minOn de 300-600 s, un client qui réordonne ses priorités depuis l'app pouvait
faire court-cycler son compresseur. C'est de la protection matérielle.

L'arbitre mémorise désormais la config ayant servi à construire chaque adaptateur
(m_builtFrom) et ne reconstruit que si le MATÉRIEL a changé — type, câblage,
paliers, plafond, verrous (sameHardware()). Un changement de rang ou de besoins
passe par updateSoftConfig(), en place : ni m_currentStage ni m_lastSwitch ne
bougent, aucun relais n'est réécrit. Le log distingue créées / mises à jour /
inchangées / retirées.

updateSoftConfig est PURE VIRTUELLE, sans implémentation par défaut. Un défaut
vide silencieux ferait qu'un futur adaptateur construit depuis LoadConfig
ignorerait sans bruit les changements de rang ; là, le compilateur force la
décision. EvAdapter et SgReadyAdapter la déclarent sans effet, avec le motif.

Démarrage à froid : après un redémarrage de nymead, m_lastSwitch est
irrécupérable. lockWindow() traite désormais un horodatage nul comme
« commutation venant d'avoir lieu » (elapsed = 0), donc verrou ARMÉ pour sa durée
configurée. L'écriture naturelle (`valid && elapsed < minOnS`) fait l'inverse et
laisserait une boucle de redémarrage court-circuiter la protection compresseur
quand elle est la plus nécessaire. Armer n'est pas verrouiller
inconditionnellement : avec une durée nulle, `0 < 0` est faux et le verrou reste
inactif — un premier jet qui forçait le verrou a fait tomber trois tests
existants, qui avaient raison.

Test : testEcsRebuildPreservesLock — SetLoadConfig pendant une fenêtre de verrou
active, le relais reste fermé ; le délestage reprend une fois minOn écoulé.

Build amd64 0 erreur. Simulation : 12/12.

Réf. specs/spec_ecs.md §3 ECS-412 (0.5.1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:42:56 +02:00

264 lines
9.8 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "relayrouter.h"
#include "plugininfo.h"
#include <QDateTime>
#include <QMap>
#include <QSet>
#include <integrations/thingmanager.h>
#include <integrations/thing.h>
#include <types/action.h>
#include <types/param.h>
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<LoadConfigRelay> &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<int, QList<QString>> byPower;
for (int mask = 0; mask < (1 << n); ++mask) {
int sum = 0;
QList<QString> 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<QString> activeRelays = m_currentStage < m_relayMapping.size()
? m_relayMapping.at(m_currentStage)
: QList<QString>();
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::updateSoftConfig(int priority, const LoadNeeds &needs)
{
// ECS-412 — mise à jour EN PLACE : ni m_currentStage ni m_lastSwitch ne bougent,
// aucun relais n'est réécrit.
m_priority = priority;
m_needs = needs;
}
void RelayRouter::lockWindow(const QDateTime &now, int &minStage, int &maxStage) const
{
const int topStage = m_levels.size() - 1;
// ECS-412 — DÉMARRAGE À FROID. m_lastSwitch nul signifie « on ne sait pas quand la
// dernière commutation a eu lieu » (redémarrage de nymead, reconstruction). Le défaut
// SÛR est d'ARMER le verrou — traiter la commutation comme venant d'avoir lieu, donc
// elapsed = 0 — et non de le purger. L'écriture naturelle (`valid && elapsed < minOnS`)
// fait l'inverse et laisserait une boucle de redémarrage court-circuiter la protection
// compresseur exactement quand elle est la plus nécessaire.
// Armer ne signifie PAS verrouiller inconditionnellement : avec une durée nulle,
// `0 < 0` est faux et le verrou reste inactif.
const qint64 elapsed = m_lastSwitch.isValid() ? m_lastSwitch.secsTo(now) : 0;
const bool onHeld = elapsed < m_minOnS;
const bool offHeld = elapsed < m_minOffS;
// Plancher : si ON et minOn non écoulé → interdit de descendre (puissance engagée non-coupable).
minStage = (m_currentStage > 0 && onHeld) ? m_currentStage : 0;
// Plafond : si à l'arrêt et minOff non écoulé → interdit de redémarrer.
maxStage = (m_currentStage == 0 && offHeld) ? 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<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()) << "[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);
}