`sessionEnergy` est l'ACCUMULATEUR du plugin V2C, pas un compteur remis à zéro pour le moteur : l'écart avec `chargeEnergy` est constant au dix-millième (7,2044 kWh sur deux échantillons à deux heures d'écart), parce qu'il porte les incréments d'avant la dernière remise à zéro de la borne. Le lire directement ferait croire toute obligation éco tenue de 7,2 kWh dès le premier cycle — un nombre crédible et faux que rien ne signalerait. Contre-épreuve : lecture directe, le test annonce 7204. La base se prend à l'ouverture de l'OBLIGATION, jamais au démarrage de nymead : sinon un redémarrage en pleine session réinitialise l'avancement sur une valeur déjà accumulée, et la recharge de nuit relue au matin repart de zéro. Elle est RECAPTURÉE sur un recul franc de l'accumulateur — une nouvelle session — mais pas sur un recul infime, qui est du bruit de lecture et coûterait l'avancement acquis à chaque frémissement du compteur. Ce que le moteur ne refait PAS : la détection de remise à zéro de la BORNE vit dans le plugin, qui interroge à 30 s. Le moteur travaille à la minute et raterait des cycles entiers. Lire comment le plugin l'avait écrite a évité d'ajouter une seconde accumulation, moins fine, en concurrence de la première. Trois maillons manquants raccordés, tous déclarés et alimentés par personne : EvCharger n'exposait pas sessionEnergy ; LoadContextTelemetry::sessionWh n'était rempli nulle part ; et toLoadContext() ABANDONNAIT les besoins déclarés, si bien que LoadContext::needs n'était rempli par aucun adaptateur. L'obligation éco n'existait qu'en configuration. Mock : sessionEnergy ajouté à chargerPhaseSwitching UNIQUEMENT — `charger` reste sans, ce qui donne le cas unmeasurable nativement. La suite porte les deux régimes, comme le banc. Le régime est publié alors qu'aucune décision ne s'y appuie encore, et c'est délibéré : cela sépare publier le régime de décider avec, comme measurement l'a été de l'arbitrage, et rend R6 testable le jour où le motif naîtra au lieu d'un invariant posé sur du code que rien n'émet. Suite : simulation 56/56, charging 48/48 identique à la référence ligne à ligne, loadmodel 21/21, spotmarket 32/32, doxygen 0 avertissement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
332 lines
12 KiB
C++
332 lines
12 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
|
*
|
|
* Copyright (C) 2013 - 2024, nymea GmbH
|
|
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
|
|
*
|
|
* This file is part of nymea-energy-plugin-nymea.
|
|
*
|
|
* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with nymea-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
|
|
*
|
|
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
|
|
|
#include "evcharger.h"
|
|
|
|
#include "types/action.h"
|
|
#include "integrations/thingmanager.h"
|
|
|
|
#include <QLoggingCategory>
|
|
Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy)
|
|
|
|
EvCharger::EvCharger(ThingManager *thingManager, Thing *parent):
|
|
QObject{parent},
|
|
m_thingManager(thingManager),
|
|
m_thing(parent)
|
|
{
|
|
|
|
}
|
|
|
|
ThingId EvCharger::id() const
|
|
{
|
|
return m_thing->id();
|
|
}
|
|
|
|
Thing *EvCharger::thing() const
|
|
{
|
|
return m_thing;
|
|
}
|
|
|
|
QString EvCharger::name() const
|
|
{
|
|
return m_thing->name();
|
|
}
|
|
|
|
uint EvCharger::chargingEnabledLockDuration() const
|
|
{
|
|
return m_chargingEnabledLockDuration;
|
|
}
|
|
|
|
void EvCharger::setChargingEnabledLockDuration(uint chargingEnabledLockDuration)
|
|
{
|
|
m_chargingEnabledLockDuration = chargingEnabledLockDuration;
|
|
}
|
|
|
|
bool EvCharger::chargingEnabledLocked(const QDateTime ¤tDateTime) const
|
|
{
|
|
if (!m_lastChargingEnabledAction.isValid())
|
|
return false;
|
|
|
|
return m_lastChargingEnabledAction.secsTo(currentDateTime) < chargingEnabledLockDuration();
|
|
}
|
|
|
|
uint EvCharger::chargingCurrentLockDuration() const
|
|
{
|
|
return m_chargingCurrentLockDuration;
|
|
}
|
|
|
|
void EvCharger::setChargingCurrentLockDuration(uint chargingCurrentLockDuration)
|
|
{
|
|
m_chargingCurrentLockDuration = chargingCurrentLockDuration;
|
|
}
|
|
|
|
bool EvCharger::chargingCurrentLocked(const QDateTime ¤tDateTime) const
|
|
{
|
|
if (!m_lastChargingCurrentAction.isValid())
|
|
return false;
|
|
|
|
return m_lastChargingCurrentAction.secsTo(currentDateTime) < chargingCurrentLockDuration();
|
|
}
|
|
|
|
bool EvCharger::available() const
|
|
{
|
|
if (thing()->thingClass().interfaces().contains("connectable")) {
|
|
return thing()->stateValue("connected").toBool();
|
|
}
|
|
|
|
// If not implementing connectable, we assume yes,
|
|
// otherwise it would never start loading
|
|
return true;
|
|
}
|
|
|
|
uint EvCharger::maxChargingCurrentMaxValue() const
|
|
{
|
|
return m_thing->state("maxChargingCurrent").maxValue().toUInt();
|
|
}
|
|
|
|
uint EvCharger::maxChargingCurrentMinValue() const
|
|
{
|
|
return m_thing->state("maxChargingCurrent").minValue().toUInt();
|
|
}
|
|
|
|
uint EvCharger::maxChargingCurrent() const
|
|
{
|
|
return m_thing->stateValue("maxChargingCurrent").toUInt();
|
|
}
|
|
|
|
double EvCharger::currentPower() const
|
|
{
|
|
// If it has power metering, use that
|
|
if (m_thing->thingClass().hasStateType("currentPower")) {
|
|
return m_thing->stateValue("currentPower").toDouble();
|
|
}
|
|
|
|
// If the the charger does not support measuring current consumption, let's see if it supports "charging"
|
|
if (m_thing->thingClass().hasStateType("charging")) {
|
|
// If it is currently charging, return maxChargingCurrent
|
|
bool charging = m_thing->stateValue("charging").toBool();
|
|
if (charging) {
|
|
return m_thing->stateValue("maxChargingCurrent").toInt() * 230 * phaseCount();
|
|
} else {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
// No "charging" state either... Fall back to "power" which is mandatory
|
|
bool powered = m_thing->stateValue("power").toBool();
|
|
if (powered) {
|
|
return m_thing->stateValue("maxChargingCurrent").toDouble() * 230 * phaseCount();
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
uint EvCharger::phaseCount() const
|
|
{
|
|
if (m_thing->hasState("phaseCount")) {
|
|
uint phaseCount = m_thing->stateValue("phaseCount").toUInt();
|
|
// Adjusting to be not 0 so this doesn't need to be checked on every division.
|
|
// If a plugin reports 0 phases, something is probably not working somewhere in the communication with the charger
|
|
if (phaseCount == 0) {
|
|
qCWarning(dcNymeaEnergy()) << "EV charger reports 0 phases... That can't be right... Adjusting to 1.";
|
|
phaseCount = 1;
|
|
}
|
|
return phaseCount;
|
|
}
|
|
|
|
// Assuming 1 phases if we don't know...
|
|
return 1;
|
|
}
|
|
|
|
Electricity::Phases EvCharger::phases() const
|
|
{
|
|
if (m_thing->hasState("phaseCount")) {
|
|
uint phaseCount = m_thing->stateValue("phaseCount").toUInt();
|
|
if (phaseCount == 3) {
|
|
return Electricity::PhaseAll;
|
|
}
|
|
}
|
|
|
|
// For single-phase chargers, detect the actual connected phase from per-phase metering
|
|
Electricity::Phases metered = meteredPhases();
|
|
if (metered != Electricity::PhaseNone) {
|
|
return metered;
|
|
}
|
|
|
|
// Phase unknown — charger has no per-phase metering or is not currently charging
|
|
return Electricity::PhaseNone;
|
|
}
|
|
|
|
bool EvCharger::canSetPhaseCount() const
|
|
{
|
|
return m_thing->hasState("desiredPhaseCount");
|
|
}
|
|
|
|
ThingActionInfo *EvCharger::setDesiredPhaseCount(int desiredPhases)
|
|
{
|
|
StateType desiredPhaseCountStateType = m_thing->thingClass().stateTypes().findByName("desiredPhaseCount");
|
|
|
|
Action maxChargingAction(desiredPhaseCountStateType.id(), m_thing->id(), Action::TriggeredByRule);
|
|
maxChargingAction.setParams(ParamList() << Param(desiredPhaseCountStateType.id(), desiredPhases));
|
|
return m_thingManager->executeAction(maxChargingAction);
|
|
}
|
|
|
|
Electricity::Phases EvCharger::meteredPhases() const
|
|
{
|
|
Electricity::Phases phases = Electricity::PhaseNone;
|
|
if (m_thing->hasState("currentPower")) {
|
|
double currentPower = m_thing->stateValue("currentPower").toDouble();
|
|
if (currentPower != 0) {
|
|
|
|
/* Note: we prefere the current data per phase over power, since some wallboxes show a few watts on phases not beeing really used.
|
|
Also, if we use the Ampere, the smaller resolution mostly filters away some induction on non active phases. Seen so far on webasto NEXT.
|
|
*/
|
|
if (m_thing->hasState("currentPhaseA")) {
|
|
if (m_thing->stateValue("currentPhaseA").toDouble() != 0) {
|
|
phases.setFlag(Electricity::PhaseA);
|
|
}
|
|
} else if (m_thing->hasState("currentPowerPhaseA") && m_thing->stateValue("currentPowerPhaseA").toDouble() != 0) {
|
|
phases.setFlag(Electricity::PhaseA);
|
|
}
|
|
|
|
if (m_thing->hasState("currentPhaseB")) {
|
|
if (m_thing->stateValue("currentPhaseB").toDouble() != 0) {
|
|
phases.setFlag(Electricity::PhaseB);
|
|
}
|
|
} else if (m_thing->hasState("currentPowerPhaseB") && m_thing->stateValue("currentPowerPhaseB").toDouble() != 0) {
|
|
phases.setFlag(Electricity::PhaseB);
|
|
}
|
|
|
|
if (m_thing->hasState("currentPhaseC")) {
|
|
if (m_thing->stateValue("currentPhaseC").toDouble() != 0) {
|
|
phases.setFlag(Electricity::PhaseC);
|
|
}
|
|
} else if (m_thing->hasState("currentPowerPhaseC") && m_thing->stateValue("currentPowerPhaseC").toDouble() != 0) {
|
|
phases.setFlag(Electricity::PhaseC);
|
|
}
|
|
}
|
|
}
|
|
|
|
return phases;
|
|
}
|
|
|
|
ThingActionInfo* EvCharger::setMaxChargingCurrent(uint maxChargingCurrent, const QDateTime ¤tDateTime, bool force)
|
|
{
|
|
QDateTime now = currentDateTime;
|
|
if (!force && chargingCurrentLocked(now)) {
|
|
uint secondsLeft = m_lastChargingCurrentAction.secsTo(now);
|
|
qCDebug(dcNymeaEnergy()) << "Adjust the charging current is locked since" << secondsLeft << "seconds. Lock duration is"
|
|
<< chargingCurrentLockDuration() << "seconds."
|
|
<< "Last action:" << m_lastChargingCurrentAction.toString("dd.MM.yyyy hh:mm:ss")
|
|
<< "now:" << now.toString("dd.MM.yyyy hh:mm:ss");
|
|
return nullptr;
|
|
}
|
|
|
|
StateType maxChargingCurrentStateType = m_thing->thingClass().stateTypes().findByName("maxChargingCurrent");
|
|
|
|
uint paramValue = qMax(maxChargingCurrentStateType.minValue().toUInt(), maxChargingCurrent);
|
|
paramValue = qMin(maxChargingCurrentStateType.maxValue().toUInt(), paramValue);
|
|
bool chargingCurrentActionRequired = (this->maxChargingCurrent() != paramValue);
|
|
|
|
Action maxChargingAction(maxChargingCurrentStateType.id(), m_thing->id(), Action::TriggeredByRule);
|
|
maxChargingAction.setParams(ParamList() << Param(maxChargingCurrentStateType.id(), paramValue));
|
|
|
|
ThingActionInfo *info = m_thingManager->executeAction(maxChargingAction);
|
|
connect(info, &ThingActionInfo::finished, this, [this, info, now, chargingCurrentActionRequired](){
|
|
if (info->status() == Thing::ThingErrorNoError && chargingCurrentActionRequired) {
|
|
m_lastChargingCurrentAction = now;
|
|
}
|
|
});
|
|
|
|
return info;
|
|
}
|
|
|
|
bool EvCharger::hasPowerMeter() const
|
|
{
|
|
return m_thing->thingClass().interfaces().contains("smartmeterconsumer");
|
|
}
|
|
|
|
bool EvCharger::chargingEnabled() const
|
|
{
|
|
return m_thing->stateValue("power").toBool();
|
|
}
|
|
|
|
ThingActionInfo* EvCharger::setChargingEnabled(bool power, const QDateTime ¤tDateTime, bool force)
|
|
{
|
|
QDateTime now = currentDateTime;
|
|
if (!force && chargingEnabledLocked(now)) {
|
|
uint secondsLeft = m_lastChargingEnabledAction.secsTo(now);
|
|
qCDebug(dcNymeaEnergy()) << "Charging enabled is locked since" << secondsLeft << "seconds. Lock duration is"
|
|
<< chargingEnabledLockDuration() << "seconds."
|
|
<< "Last action:" << m_lastChargingEnabledAction.toString("dd.MM.yyyy hh:mm:ss")
|
|
<< "now:" << now.toString("dd.MM.yyyy hh:mm:ss");
|
|
return nullptr;
|
|
}
|
|
|
|
StateType powerStateType = m_thing->thingClass().stateTypes().findByName("power");
|
|
bool lockActionRequired = (chargingEnabled() != power);
|
|
|
|
Action powerAction(powerStateType.id(), m_thing->id(), Action::TriggeredByRule);
|
|
powerAction.setParams(ParamList() << Param(powerStateType.id(), power));
|
|
|
|
ThingActionInfo *info = m_thingManager->executeAction(powerAction);
|
|
connect(info, &ThingActionInfo::finished, this, [this, info, now, lockActionRequired](){
|
|
if (info->status() == Thing::ThingErrorNoError && lockActionRequired) {
|
|
m_lastChargingEnabledAction = now;
|
|
}
|
|
});
|
|
return info;
|
|
}
|
|
|
|
bool EvCharger::hasSessionEnergy() const
|
|
{
|
|
return thing()->thingClass().hasStateType("sessionEnergy");
|
|
}
|
|
|
|
double EvCharger::sessionEnergyWh() const
|
|
{
|
|
if (!hasSessionEnergy())
|
|
return 0;
|
|
// La borne publie des kWh (unit KiloWattHour) ; le modèle raisonne en Wh.
|
|
return thing()->stateValue("sessionEnergy").toDouble() * 1000.0;
|
|
}
|
|
|
|
bool EvCharger::pluggedIn() const
|
|
{
|
|
if (thing()->hasState("pluggedIn"))
|
|
return thing()->stateValue("pluggedIn").toBool();
|
|
|
|
// We assume yes if the state is not available (since optional),
|
|
// otherwise we will never start charging...
|
|
return true;
|
|
}
|
|
|
|
bool EvCharger::charging() const
|
|
{
|
|
if (m_thing->hasState("charging"))
|
|
return m_thing->stateValue("charging").toBool();
|
|
|
|
return chargingEnabled() && pluggedIn();
|
|
}
|