// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #include "evadapter.h" #include "../energyarbitrator.h" #include "../../evcharger.h" #include "../../types/chargingaction.h" #include "plugininfo.h" EvAdapter::EvAdapter(EvCharger *evCharger, EnergyArbitrator *parent) : QObject(parent) , m_charger(evCharger) , m_parent(parent) { } LoadDescriptor EvAdapter::descriptor() const { LoadDescriptor d; d.id = m_charger->thing()->id().toString(); d.label = m_charger->name(); d.adapter = QStringLiteral("evcharger"); d.priority = 100; d.declared.minA = m_charger->maxChargingCurrentMinValue(); d.declared.maxA = m_charger->maxChargingCurrentMaxValue(); d.declared.phases = static_cast(m_charger->phaseCount()); d.limits.chargingEnabledLockS = static_cast(m_charger->chargingEnabledLockDuration()); d.limits.currentChangeLockS = static_cast(m_charger->chargingCurrentLockDuration()); d.supportedKinds = { LoadAction::Setpoint }; return d; } LoadTelemetry EvAdapter::telemetry() const { LoadTelemetry t; t.currentPowerW = m_charger->currentPower(); t.available = m_charger->available(); t.lastActionAt = m_lastActionAt; return t; } LoadContext EvAdapter::toLoadContext(const QDateTime &now) const { Q_UNUSED(now) // Les verrous EV (enclenchement, changement de courant) vivent en aval. LoadContext ctx; ctx.id = m_charger->thing()->id().toString(); ctx.adapter = QStringLiteral("evcharger"); ctx.label = m_charger->name(); ctx.declared = descriptor().declared; ctx.limits = descriptor().limits; ctx.priority = descriptor().priority; // --- 3g-1 : la borne entre dans le waterfall, donc elle parle en WATTS --------------- // // Le nombre de phases VISÉ est décidé ici, avant l'allocation, et non après : c'est lui // qui fixe le plancher. Publier celui du nombre COURANT alors que l'exécution basculera // vers un autre produirait un défaut silencieux — le waterfall allouerait 2 kW en croyant // démarrer une borne qui en exige 4,1, et se croirait servi. // // Le courant de référence pour ce choix est ce que la borne tire DÉJÀ : c'est la seule // grandeur connue avant l'allocation, et c'est celle que le proxy utilisait aussi. const double courantActuelA = m_charger->currentPower() / 230.0; m_targetPhases = m_charger->canSetPhaseCount() ? m_parent->evTargetPhases(m_charger, courantActuelA) : m_charger->phaseCount(); if (m_targetPhases == 0) m_targetPhases = 1; ctx.declared.minPowerW = m_parent->evFloorW(m_charger, m_targetPhases); ctx.declared.maxPowerW = m_charger->maxChargingCurrentMaxValue() * 230.0 * m_targetPhases; ctx.declared.phases = static_cast(m_targetPhases); ctx.telemetry.currentPowerW = m_charger->currentPower(); ctx.telemetry.available = m_charger->available(); ctx.telemetry.pluggedIn = m_charger->pluggedIn(); ctx.telemetry.charging = m_charger->charging(); // Pas de fenêtre de verrou de palier côté EV : lockMaxPowerW négatif = aucun plafond // (convention du contexte), et lockMinPowerW nul = rien d'engagé de non-coupable. ctx.telemetry.lockMaxPowerW = -1; return ctx; } LoadRuntimeView EvAdapter::runtimeView(const QDateTime &now) const { Q_UNUSED(now) // les verrous EV vivent dans SmartChargingManager amont, pas dans cet adaptateur. LoadRuntimeView v; v.mechanismKind = QStringLiteral("evcharger"); v.mechanism.insert(QStringLiteral("chargingEnabled"), m_charger->charging()); v.mechanism.insert(QStringLiteral("pluggedIn"), m_charger->pluggedIn()); v.mechanism.insert(QStringLiteral("currentA"), m_charger->maxChargingCurrent()); v.mechanism.insert(QStringLiteral("phaseCount"), m_charger->phaseCount()); if (!m_charger->available()) v.faultCode = QStringLiteral("THING_MISSING"); return v; } LoadAction EvAdapter::applyAction(const LoadAction &action, const QDateTime &now) { if (action.kind != LoadAction::Setpoint) return action; if (action.reason.isEmpty()) { qCWarning(dcNymeaEnergy()) << "[EvAdapter]" << m_charger->name() << "— LoadAction sans reason rejetée."; return action; } const uint minA = m_charger->maxChargingCurrentMinValue(); const uint maxA = m_charger->maxChargingCurrentMaxValue(); // --- 3g-1 : deux origines d'action, deux unités --------------------------------------- // // Le PROXY (échéance, spot market) décide encore en ampères et remplit currentA. Le // WATERFALL, lui, alloue des WATTS : c'est ici, au dernier moment, que la conversion se // fait — conformément à la règle 4 (les bornes de l'adaptateur écrêtent toute sortie de // stratégie, et l'adaptateur seul connaît son matériel). // // Le discriminant est `currentA` : non nul, l'action vient du proxy et s'exécute telle // quelle ; nul avec une puissance, elle vient de la cascade et se convertit. const uint phases = (m_charger->canSetPhaseCount() && action.phaseCount > 0) ? qBound(1u, action.phaseCount, m_charger->phaseCount()) : (m_targetPhases > 0 ? m_targetPhases : m_charger->phaseCount()); double demandeA = action.currentA; if (demandeA <= 0 && action.powerW > 0) demandeA = action.powerW / (230.0 * phases); const uint clampedA = static_cast( qBound(static_cast(minA), demandeA, static_cast(maxA))); // Sous le plancher, la cascade a déjà alloué 0 W et publié son motif : on n'enclenche pas. // Le test porte sur la DEMANDE, pas sur la valeur écrêtée — qui, elle, est remontée à minA // par le qBound et ferait croire à un courant suffisant. const bool enclencher = action.chargingEnabled && demandeA > 0 && demandeA >= static_cast(minA); const auto issuer = (action.funding == LoadAction::Surplus) ? ChargingAction::ChargingActionIssuerSurplusCharging : ChargingAction::ChargingActionIssuerTimeRequirement; ChargingAction ca(enclencher, clampedA, phases, issuer, false); m_parent->doExecuteChargingAction(m_charger, ca, now); // now = temps de cycle (injectable) m_lastActionAt = now; LoadAction applied = action; applied.currentA = clampedA; applied.phaseCount = phases; applied.chargingEnabled = enclencher; // Ce qui a RÉELLEMENT été engagé, en watts — c'est ce chiffre que la télémétrie publie et // que la comptabilité du budget doit voir, pas la demande d'origine. applied.estimatedPowerW = enclencher ? clampedA * 230.0 * phases : 0.0; return applied; }