// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #include "relayrouter.h" #include "plugininfo.h" #include #include #include #include #include #include #include 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 &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> byPower; for (int mask = 0; mask < (1 << n); ++mask) { int sum = 0; QList 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 activeRelays = m_currentStage < m_relayMapping.size() ? m_relayMapping.at(m_currentStage) : QList(); 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::lockWindow(const QDateTime &now, int &minStage, int &maxStage) const { const int topStage = m_levels.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 (puissance engagée non-coupable). 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; } 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 wantOn = [&]() { QSet s; if (stage < m_relayMapping.size()) for (const QString &id : m_relayMapping.at(stage)) s.insert(id); return s; }(); QSet 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); }