// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #include "ecsrelayadapter.h" #include "plugininfo.h" #include #include #include #include #include EcsRelayAdapter::EcsRelayAdapter(ThingManager *thingManager, const QString &id, const QString &label, const QList &stages, const QList> &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 &activeRelays = m_currentStage < m_relayMapping.size() ? m_relayMapping.at(m_currentStage) : QList(); 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 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()) << "[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); }