// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #include "etmvariableloadadapter.h" #include "plugininfo.h" #include #include #include #include #include #include #include EtmVariableLoadAdapter::EtmVariableLoadAdapter(ThingManager *thingManager, const QString &id, const QString &label, const QList &powerLevels, int maxPowerW, int priority, const LoadNeeds &needs, QObject *parent) : QObject(parent) , m_thingManager(thingManager) , m_id(id) , m_label(label) , m_powerLevels(powerLevels) , m_maxPowerW(maxPowerW) , m_priority(priority) , m_needs(needs) { // Re-tri par sécurité : l'UI émet trié, l'energymanager re-trie (contrat §6 cas limites). std::sort(m_powerLevels.begin(), m_powerLevels.end()); // Contrat §2 : les paliers incluent 0. En *fixed* (liste non vide), 0 doit être présent. Q_ASSERT(m_powerLevels.isEmpty() || m_powerLevels.first() == 0); } LoadDescriptor EtmVariableLoadAdapter::descriptor() const { LoadDescriptor d; d.id = m_id; d.label = m_label; d.adapter = QStringLiteral("etmvariableload"); d.priority = m_priority; d.declared.powerLevels = m_powerLevels; d.declared.maxPowerW = m_maxPowerW; d.needs = m_needs; d.supportedKinds = { LoadAction::Setpoint }; return d; } LoadTelemetry EtmVariableLoadAdapter::telemetry() const { LoadTelemetry t; Thing *thing = m_thingManager->findConfiguredThing(ThingId(m_id)); t.available = (thing != nullptr) && !m_faulted; t.currentPowerW = readCurrentPowerW(); // juge runtime (contrat §4) // ECS-414 — INDÉTERMINATION : écriture en vol, on annonce la plus HAUTE des deux // consignes possibles si le thing ne mesure pas. Jamais moins que ce qui peut couler. if (m_pending > 0 && t.currentPowerW <= 0) t.currentPowerW = qMax(m_setpointPrev, m_setpointTarget); t.lastActionAt = m_lastActionAt; return t; } LoadContext EtmVariableLoadAdapter::toLoadContext(const QDateTime &now) const { Q_UNUSED(now) // aucune fenêtre de verrou côté moteur : l'anti-rebond vit dans le thing. LoadContext ctx; ctx.id = m_id; ctx.adapter = QStringLiteral("etmvariableload"); ctx.label = m_label; ctx.priority = m_priority; ctx.declared = descriptor().declared; ctx.telemetry.currentPowerW = telemetry().currentPowerW; ctx.telemetry.available = telemetry().available; if (m_faulted) { // Charge FIGÉE : plancher == plafond == puissance crue engagée. ctx.telemetry.lockMinPowerW = ctx.telemetry.currentPowerW; ctx.telemetry.lockMaxPowerW = ctx.telemetry.currentPowerW; } return ctx; } LoadAction EtmVariableLoadAdapter::applyAction(const LoadAction &action, const QDateTime &now) { if (action.kind != LoadAction::Setpoint) return action; if (action.reason.isEmpty()) { qCWarning(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label << "— LoadAction sans reason rejetée."; return action; } // ECS-414 — en défaut, plus aucune consigne écrite, y compris forcée. if (m_faulted) { LoadAction refused = action; refused.powerW = refused.estimatedPowerW = m_currentSetpointW; return refused; } // Second filet (invariant ILoadAdapter) : borner la consigne au plafond physique. // L'arrondi au powerLevels (mode *fixed*) est fait par le scheduler (contrat §4, T3). const double setpointW = qBound(0.0, action.powerW, static_cast(m_maxPowerW)); qCInfo(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label << "→ setpoint" << qRound(setpointW) << "W" << (action.force ? "(force)" : "") << "|" << action.reason; m_setpointPrev = m_currentSetpointW; m_setpointTarget = setpointW; m_writeFailed = false; writeSetpoint(setpointW); m_currentSetpointW = setpointW; m_lastActionAt = now; LoadAction applied = action; applied.powerW = setpointW; applied.estimatedPowerW = setpointW; return applied; } void EtmVariableLoadAdapter::applySafeState(const QDateTime &now) { LoadAction sur; sur.loadId = m_id; sur.kind = LoadAction::Setpoint; sur.powerW = 0; sur.force = true; sur.reason = QStringLiteral("Charge désactivée — mise en état sûr (0 W) avant retrait (ECS-413)"); applyAction(sur, now); } // ---- privé --------------------------------------------------------------- double EtmVariableLoadAdapter::readCurrentPowerW() const { Thing *thing = m_thingManager->findConfiguredThing(ThingId(m_id)); if (!thing) return 0.0; if (thing->thingClass().stateTypes().findByName("currentPowerW").id().isNull()) return 0.0; return thing->stateValue("currentPowerW").toDouble(); } void EtmVariableLoadAdapter::writeSetpoint(double powerW) { Thing *thing = m_thingManager ? m_thingManager->findConfiguredThing(ThingId(m_id)) : nullptr; if (!thing) { qCWarning(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label << "— thing non trouvé:" << m_id; m_writeFailed = true; settleTransition(); return; } StateType setpointStateType = thing->thingClass().stateTypes().findByName("powerSetpoint"); if (setpointStateType.id().isNull()) { thing->setStateValue("powerSetpoint", powerW); // repli mock : synchrone return; } Action setpointAction(setpointStateType.id(), thing->id(), Action::TriggeredByRule); setpointAction.setParams(ParamList() << Param(setpointStateType.id(), powerW)); ThingActionInfo *info = m_thingManager->executeAction(setpointAction); if (!info) { m_writeFailed = true; settleTransition(); return; } // ECS-414 — même modèle qu'ECS-410 : aucune attente, verdict par signal, `this` en contexte. ++m_pending; connect(info, &ThingActionInfo::finished, this, [this, info]() { if (info->status() != Thing::ThingErrorNoError) { m_writeFailed = true; qCWarning(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label << "— écriture consigne en échec, status" << info->status(); } if (--m_pending == 0) settleTransition(); }); } void EtmVariableLoadAdapter::settleTransition() { if (!m_writeFailed) { m_setpointPrev = m_setpointTarget = m_currentSetpointW; return; } m_writeFailed = false; switch (m_phase) { case PhaseNominale: qCWarning(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label << "— échec d'écriture : retour à" << m_setpointPrev << "W."; m_phase = PhaseRepli; m_currentSetpointW = m_setpointPrev; writeSetpoint(m_setpointPrev); break; case PhaseRepli: qCWarning(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label << "— retour arrière en échec : consigne 0 W."; m_phase = PhasePlancher; m_currentSetpointW = 0; m_setpointTarget = 0; writeSetpoint(0); break; case PhasePlancher: case PhaseDefaut: qCCritical(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label << "— consigne 0 W en échec : charge EN DÉFAUT, plus aucune" << "commande. Levée par NymeaEnergy.ClearLoadFault."; m_phase = PhaseDefaut; m_faulted = true; break; } } void EtmVariableLoadAdapter::clearFault() { if (!m_faulted) return; qCInfo(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label << "— défaut levé par l'opérateur (ClearLoadFault)."; m_faulted = false; m_phase = PhaseNominale; m_writeFailed = false; m_currentSetpointW = readCurrentPowerW(); // relu, pas supposé m_setpointPrev = m_setpointTarget = m_currentSetpointW; }