La classe trydan ne déclarait qu'evcharger + connectable. Ça la tenait hors de
EnergyManagerImpl::watchThing(), qui ne branche un Thing sur le journal
d'énergie que s'il déclare energymeter, smartmeterconsumer, smartmeterproducer
ou energystorage. Energy.GetThingPowerLogs ne REJETAIT pas la demande : il
rendait zéro ligne pour ce thingId, et l'app listait la borne en « sans
historique » alors que sa puissance instantanée était parfaitement lisible.
Deux bornes côte à côte, une seule avec un passé.
Au passage, le portier n'est pas `smartmeter` — cette interface est VIDE, aucun
state requis, la déclarer n'aurait rien produit. C'est `smartmeterconsumer` qui
compte, et elle exige totalEnergyConsumed.
Or la borne n'a pas de totalisateur : aucun registre de la carte Modbus V2C n'en
porte un, et ChargeEnergy (0x0BC4) est effacé à chaque arrêt de charge. Il est
donc CONSTRUIT, comme sessionEnergy, sur les mêmes incréments — et désormais par
la même règle unique, incrementEnergie(), partagée par les deux compteurs pour
qu'une correction ne puisse jamais s'appliquer à un seul. Contrairement à
sessionEnergy il ne se remet jamais à zéro et n'a aucune condition de session :
une recharge lancée à la main sur la borne, hors pilotage, y entre aussi.
Deux repères distincts (baseline / baselineTotale) et non un seul : la machine à
états de session sort par plusieurs chemins qui écrivent baseline sans
accumuler, un repère partagé ferait dépendre le total de l'ORDRE des deux
appels.
CE QUI A LEVÉ LE BLOCAGE « MONOTONE À VIE »
Le brief tenait ce totalisateur pour bloqué : la moindre perte se lirait comme
une consommation négative en aval. C'est trop strict, et vérifié : le moteur
tient son PROPRE compteur, se cale sur la première valeur sans l'ajouter à son
diff, et resynchronise explicitement quand le compteur d'un Thing recule
(« seems to have been reset »). Une perte côté plugin ne peut ni injecter de
négatif en aval, ni effacer le journal déjà écrit, ni produire une marche à
l'ajout du Thing. L'exigence réelle est « ne jamais reculer PENDANT une
exécution » — ce que le state "cached": true et le repère relu de chargeEnergy
au setup assurent.
Limite assumée, la même que sessionEnergy : l'énergie livrée entre le dernier
poll réussi et une RAZ de la borne est perdue, bornée par un intervalle de poll
(30 s). C'est pourquoi ce calcul vit dans le plugin et pas dans le moteur.
VÉRIFIÉ SUR LE BANC (.75, 1.15.2+etm14, 2026-09-08 10:57)
energylogs.sqlite, pour {4d1adafd-cafc-4813-93b1-ca8933ce71ab} :
- thingCache : ligne absente avant, créée après — c'est watchThing() ;
- thingPower : 0 ligne avant, puis une par minute comme les cinq autres Things.
À SAVOIR : sur un Thing déjà appairé, things.conf écrase le `logged: true` que
l'interface porte pour currentPower/totalEnergyConsumed (loggedStateTypeIds y
vaut déjà {connected}, {charging}, {power}). Le journal d'énergie du moteur ne
dépend PAS de ce réglage — la courbe marche sans y toucher.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
648 lines
31 KiB
C++
648 lines
31 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
#include "integrationpluginv2c.h"
|
|
#include "v2ctcpdiscovery.h"
|
|
#include "plugininfo.h"
|
|
|
|
#include <integrations/thing.h>
|
|
#include <integrations/thingactioninfo.h>
|
|
#include <integrations/thingdescriptor.h>
|
|
#include <integrations/thingdiscoveryinfo.h>
|
|
#include <integrations/thingsetupinfo.h>
|
|
#include <hardwaremanager.h>
|
|
#include <network/networkdevicediscovery.h>
|
|
|
|
#include <QDateTime>
|
|
#include <QSettings>
|
|
|
|
namespace {
|
|
|
|
/*!
|
|
* \brief Courant minimal admissible sur cette borne, en ampères.
|
|
*
|
|
* Le plus contraignant des deux : le minimum IEC 61851 (6 A, sous lequel un véhicule ne
|
|
* doit pas se voir proposer de consigne) et celui que la borne DÉCLARE dans RegMinIntensity
|
|
* (0x0BD1). La borne gagne quand elle est plus exigeante ; elle ne peut pas descendre sous
|
|
* la norme — une lecture aberrante à 0 ne doit pas ouvrir la voie à une consigne de 3 A.
|
|
*
|
|
* Le même plancher sert à l'écriture (executeAction) et à ce qui est PUBLIÉ comme minimum
|
|
* du state (setStateMinMaxValues). Les deux doivent coïncider : un curseur d'app qui
|
|
* propose une valeur que l'action transforme en pause serait une commande sans effet.
|
|
*/
|
|
double courantMinimalEffectif(TrydanModbusMaster *master)
|
|
{
|
|
return qMax(6.0, static_cast<double>(master->minIntensity()));
|
|
}
|
|
|
|
/*!
|
|
* \brief Courant maximal admissible, jamais inférieur au minimal.
|
|
*
|
|
* RegMaxIntensity dépend de la version de firmware et pourrait, mal lu, passer sous le
|
|
* plancher. Un intervalle inversé ferait échouer setStateMinMaxValues et rendrait
|
|
* l'écrêtage incohérent ; on le referme plutôt sur un point.
|
|
*/
|
|
double courantMaximalEffectif(TrydanModbusMaster *master)
|
|
{
|
|
return qMax(courantMinimalEffectif(master), static_cast<double>(master->maxIntensity()));
|
|
}
|
|
|
|
} // namespace
|
|
|
|
IntegrationPluginV2c::IntegrationPluginV2c()
|
|
{}
|
|
|
|
void IntegrationPluginV2c::discoverThings(ThingDiscoveryInfo *info)
|
|
{
|
|
V2cTcpDiscovery *discovery = new V2cTcpDiscovery(hardwareManager()->networkDeviceDiscovery(), info);
|
|
|
|
connect(discovery, &V2cTcpDiscovery::discoveryFinished, info, [this, info, discovery]() {
|
|
for (const V2cTcpDiscovery::Result &result : discovery->results()) {
|
|
ThingDescriptor descriptor(trydanThingClassId,
|
|
QStringLiteral("V2C Trydan"),
|
|
result.ipAddress);
|
|
ParamList params;
|
|
params.append(Param(trydanThingMacAddressParamTypeId,
|
|
result.networkDeviceInfo.thingParamValueMacAddress()));
|
|
params.append(Param(trydanThingHostNameParamTypeId,
|
|
result.networkDeviceInfo.thingParamValueHostName()));
|
|
params.append(Param(trydanThingAddressParamTypeId,
|
|
result.networkDeviceInfo.thingParamValueAddress()));
|
|
params.append(Param(trydanThingPortParamTypeId, 502));
|
|
descriptor.setParams(params);
|
|
|
|
if (Thing *existing = myThings().findByParams(params)) {
|
|
descriptor.setThingId(existing->id());
|
|
}
|
|
info->addThingDescriptor(descriptor);
|
|
}
|
|
info->finish(Thing::ThingErrorNoError);
|
|
});
|
|
|
|
discovery->startDiscovery();
|
|
}
|
|
|
|
void IntegrationPluginV2c::setupThing(ThingSetupInfo *info)
|
|
{
|
|
Thing *thing = info->thing();
|
|
|
|
if (m_tcpMasters.contains(thing)) {
|
|
m_tcpMasters.take(thing)->deleteLater();
|
|
}
|
|
|
|
// Pre-req: the charger must have a static (DHCP-reserved) IP.
|
|
// NetworkDeviceMonitor is intentionally absent: its periodic ICMP pings
|
|
// saturate the Trydan ESP32 WiFi stack (observed latency > 14 s, causing
|
|
// Modbus timeouts). Connectivity is derived from Modbus poll results only.
|
|
const QHostAddress address(thing->paramValue(trydanThingAddressParamTypeId).toString());
|
|
const quint16 port = static_cast<quint16>(thing->paramValue(trydanThingPortParamTypeId).toUInt());
|
|
const quint16 slaveId = 1; // V2C Trydan always uses unit ID 1
|
|
|
|
TrydanModbusTcpMaster *master = new TrydanModbusTcpMaster(address, port, slaveId, thing);
|
|
|
|
connect(info, &ThingSetupInfo::aborted, master, &TrydanModbusTcpMaster::deleteLater);
|
|
|
|
// --- Nombre de phases : SAISI, parce que la borne ne sait pas le dire -------------------
|
|
//
|
|
// Il n'existe aucun registre de phases dans la carte Modbus V2C (0x0BC2-0x0BD5,
|
|
// 0x177A-0x1783) : le câblage mono/tri n'est pas lisible sur la borne. Or le moteur
|
|
// energy-etm en a besoin, et son repli est SILENCIEUX — EvCharger::phaseCount() retourne 1
|
|
// quand l'état est absent. Sur une Trydan triphasée, cela publierait un plancher de
|
|
// 6 A x 230 V x 1 = 1 380 W au lieu de 4 140, et convertirait les watts alloués en un
|
|
// courant trois fois trop élevé. La borne démarrerait, au mauvais courant, sans erreur.
|
|
//
|
|
// Le nombre de phases vient donc d'un RÉGLAGE, modifiable si la borne est recâblée, et il
|
|
// est recopié dans l'état que le moteur lit. Défaut 1 : le même que le repli du moteur,
|
|
// pour qu'une installation non renseignée se comporte comme avant ce correctif.
|
|
auto appliquerNombreDePhases = [](Thing *t) {
|
|
const uint phases = t->setting(trydanSettingsPhaseCountParamTypeId).toUInt();
|
|
t->setStateValue(trydanPhaseCountStateTypeId, (phases == 3 ? 3u : 1u));
|
|
};
|
|
appliquerNombreDePhases(thing);
|
|
connect(thing, &Thing::settingChanged, thing, [thing, appliquerNombreDePhases](
|
|
const ParamTypeId ¶mTypeId, const QVariant &/*value*/) {
|
|
if (paramTypeId == trydanSettingsPhaseCountParamTypeId) {
|
|
appliquerNombreDePhases(thing);
|
|
}
|
|
});
|
|
|
|
// Reprendre le suivi de session AVANT que le premier poll n'arrive : c'est ici,
|
|
// et pas au premier poll, que le cache d'états de nymea est encore intact
|
|
// (initThing() restaure les états cachés avant setupThing()).
|
|
restaurerSuiviDeSession(thing);
|
|
|
|
// Setup raté : le Thing est détruit par le moteur, et m_sessions garderait une
|
|
// clé pendante. Elle n'est jamais déréférencée, mais un Thing réalloué à la même
|
|
// adresse hériterait d'un repère qui n'est pas le sien — et d'un sessionEnergy
|
|
// qui saute. Notre handler est connecté avant celui du moteur, donc il passe
|
|
// avant la destruction.
|
|
connect(info, &ThingSetupInfo::finished, this, [this, info]() {
|
|
if (info->status() != Thing::ThingErrorNoError) {
|
|
m_sessions.remove(info->thing());
|
|
}
|
|
});
|
|
|
|
// Reachability is driven by Modbus poll results (not TCP state directly).
|
|
// initialize() is now called from TrydanModbusTcpMaster::onConnectionStateChanged(true)
|
|
// so we no longer need to call it here — this handler only updates the UI.
|
|
connect(master, &TrydanModbusMaster::reachableChanged, thing,
|
|
[this, thing](bool reachable) {
|
|
if (!reachable) {
|
|
setDisconnectedState(thing, QStringLiteral("timeout Modbus — vérifier signal WiFi de la borne"));
|
|
}
|
|
// reachable=true: UI cleared by next successful poll (updateThingStates).
|
|
});
|
|
|
|
// After init (first time or reconnect): expose current range and kick an immediate poll
|
|
// so states are refreshed without waiting up to 30 s for the next timer tick.
|
|
connect(master, &TrydanModbusMaster::initializationFinished, thing,
|
|
[this, thing, master](bool success) {
|
|
if (!success) {
|
|
return;
|
|
}
|
|
thing->setStateValue(trydanConnectedStateTypeId, true);
|
|
thing->setStateValue(trydanStatusMessageStateTypeId, QString());
|
|
thing->setStateMinMaxValues(trydanMaxChargingCurrentStateTypeId,
|
|
courantMinimalEffectif(master),
|
|
courantMaximalEffectif(master));
|
|
// Immediate first poll. For setup the info handler below also calls update();
|
|
// the m_updating guard makes the second call a no-op.
|
|
master->update();
|
|
});
|
|
|
|
connect(master, &TrydanModbusMaster::initializationFinished, info,
|
|
[this, info, thing, master](bool success) {
|
|
if (!success) {
|
|
master->deleteLater();
|
|
info->finish(Thing::ThingErrorHardwareFailure,
|
|
QT_TR_NOOP("Could not communicate with the V2C Trydan charger."));
|
|
return;
|
|
}
|
|
m_tcpMasters.insert(thing, master);
|
|
master->update();
|
|
info->finish(Thing::ThingErrorNoError);
|
|
});
|
|
|
|
// Each completed poll triggers a full state refresh + conflict handling.
|
|
connect(master, &TrydanModbusMaster::updateFinished, thing,
|
|
[this, thing, master]() {
|
|
updateThingStates(thing, master);
|
|
handleDynamicConflict(thing, master);
|
|
});
|
|
|
|
master->connectDevice();
|
|
}
|
|
|
|
void IntegrationPluginV2c::postSetupThing(Thing *thing)
|
|
{
|
|
Q_UNUSED(thing)
|
|
|
|
if (m_pluginTimer)
|
|
return;
|
|
|
|
m_pluginTimer = hardwareManager()->pluginTimerManager()->registerTimer(30);
|
|
connect(m_pluginTimer, &PluginTimer::timeout, this, [this]() {
|
|
for (TrydanModbusTcpMaster *master : m_tcpMasters) {
|
|
// update() returns false only when the TCP socket is not connected
|
|
// (or a poll/init is already running). connectDevice() is a no-op
|
|
// if the socket is already connected — it will NOT close and reopen
|
|
// the connection, avoiding the SYN bursts that saturate the ESP32 WiFi.
|
|
// Normal path: TCP stays open, only Modbus frames exchanged every 30 s.
|
|
if (!master->update()) {
|
|
master->connectDevice();
|
|
}
|
|
}
|
|
});
|
|
m_pluginTimer->start();
|
|
}
|
|
|
|
void IntegrationPluginV2c::thingRemoved(Thing *thing)
|
|
{
|
|
delete m_tcpMasters.take(thing);
|
|
m_sessions.remove(thing);
|
|
|
|
// Le drapeau de session persisté n'a plus de propriétaire : le laisser traîner
|
|
// ferait repartir une borne réappairée sur une session qui n'existe plus.
|
|
QSettings *stockage = pluginStorage();
|
|
stockage->remove(thing->id().toString());
|
|
|
|
if (myThings().isEmpty() && m_pluginTimer) {
|
|
hardwareManager()->pluginTimerManager()->unregisterTimer(m_pluginTimer);
|
|
m_pluginTimer = nullptr;
|
|
}
|
|
}
|
|
|
|
void IntegrationPluginV2c::executeAction(ThingActionInfo *info)
|
|
{
|
|
Thing *thing = info->thing();
|
|
TrydanModbusTcpMaster *master = m_tcpMasters.value(thing);
|
|
|
|
if (!master || !master->reachable()) {
|
|
info->finish(Thing::ThingErrorHardwareNotAvailable,
|
|
QT_TR_NOOP("The V2C Trydan charger is not reachable."));
|
|
return;
|
|
}
|
|
|
|
if (info->action().actionTypeId() == trydanPowerActionTypeId) {
|
|
const bool enable = info->action().paramValue(trydanPowerActionPowerParamTypeId).toBool();
|
|
|
|
// setChargingEnabled — always mirror PauseState and Lock together.
|
|
// cf. evcc trydan.go Enable(): both registers must be written consistently.
|
|
// PauseState: 1=paused(disabled) / 0=active(enabled)
|
|
// Lock: 1=locked(disabled) / 0=unlocked(enabled)
|
|
const quint16 pauseVal = enable ? 0 : 1;
|
|
const quint16 lockVal = enable ? 0 : 1;
|
|
|
|
// writeCompleted carries the originating register address so we can ignore
|
|
// concurrent background writes (e.g. PauseDynamic from conflict management).
|
|
master->writePauseState(pauseVal);
|
|
connect(master, &TrydanModbusMaster::writeCompleted, info,
|
|
[this, info, thing, master, lockVal, enable]
|
|
(quint16 addr, bool ok) {
|
|
if (addr != TrydanModbusMaster::WRegPauseState) return; // ignore others
|
|
disconnect(master, &TrydanModbusMaster::writeCompleted, info, nullptr);
|
|
if (!ok) { info->finish(Thing::ThingErrorHardwareFailure); return; }
|
|
|
|
master->writeLock(lockVal);
|
|
connect(master, &TrydanModbusMaster::writeCompleted, info,
|
|
[this, info, thing, master, enable](quint16 addr2, bool ok2) {
|
|
if (addr2 != TrydanModbusMaster::WRegLock) return; // ignore others
|
|
disconnect(master, &TrydanModbusMaster::writeCompleted, info, nullptr);
|
|
if (ok2) {
|
|
thing->setStateValue(trydanPowerStateTypeId, enable);
|
|
// Fire-and-forget: apply PauseDynamic conflict suppression.
|
|
// Does not block the action — next poll will reflect the result.
|
|
if (master->dynamicMode() == 1) {
|
|
const bool suspend = thing->setting(
|
|
trydanSettingsSuspendInternalOptimizerParamTypeId).toBool();
|
|
if (suspend)
|
|
master->writePauseDynamic(enable ? 1 : 0);
|
|
}
|
|
master->update();
|
|
info->finish(Thing::ThingErrorNoError);
|
|
} else {
|
|
info->finish(Thing::ThingErrorHardwareFailure);
|
|
}
|
|
});
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (info->action().actionTypeId() == trydanMaxChargingCurrentActionTypeId) {
|
|
double current = info->action().paramValue(
|
|
trydanMaxChargingCurrentActionMaxChargingCurrentParamTypeId).toDouble();
|
|
|
|
// Plancher : le minimum que la BORNE déclare (RegMinIntensity), jamais sous les 6 A
|
|
// de l'IEC 61851 — cf. courantMinimalEffectif(). Sous ce plancher → pause, plutôt
|
|
// qu'une consigne que la borne n'honorerait pas. La borne haute lisait déjà le
|
|
// registre ; la borne basse était un littéral 6.0, donc une borne qui exigerait
|
|
// 8 A aurait reçu une consigne de 7 A écrite telle quelle.
|
|
const double minA = courantMinimalEffectif(master);
|
|
if (current < minA) {
|
|
master->writePauseState(1);
|
|
master->writeLock(1);
|
|
thing->setStateValue(trydanPowerStateTypeId, false);
|
|
info->finish(Thing::ThingErrorNoError);
|
|
return;
|
|
}
|
|
current = qMin(current, courantMaximalEffectif(master));
|
|
|
|
const quint16 amps = static_cast<quint16>(qRound(current));
|
|
master->writeIntensity(amps);
|
|
connect(master, &TrydanModbusMaster::writeCompleted, info,
|
|
[this, info, thing, master, current](quint16 addr, bool ok) {
|
|
if (addr != TrydanModbusMaster::WRegIntensity) return;
|
|
disconnect(master, &TrydanModbusMaster::writeCompleted, info, nullptr);
|
|
if (ok) {
|
|
thing->setStateValue(trydanMaxChargingCurrentStateTypeId, current);
|
|
master->update();
|
|
info->finish(Thing::ThingErrorNoError);
|
|
} else {
|
|
info->finish(Thing::ThingErrorHardwareFailure);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
info->finish(Thing::ThingErrorUnsupportedFeature);
|
|
}
|
|
|
|
// --- Private ---
|
|
|
|
/*!
|
|
* SlaveError register (0x0BC5) firmware table, confirmed on Trydan fw2.4.6.
|
|
* Returns empty string for code 0 (no error).
|
|
* Code 4 also triggers a statusMessage update in updateThingStates().
|
|
*/
|
|
static QString decodeSlaveError(int code)
|
|
{
|
|
switch (code) {
|
|
case 0: return QString();
|
|
case 1: return QStringLiteral("Erreur communication Modbus");
|
|
case 2: return QStringLiteral("Erreur lecture registre");
|
|
case 3: return QStringLiteral("Erreur esclave");
|
|
case 4: return QStringLiteral("Borne en reconnexion WiFi");
|
|
case 5: return QStringLiteral("Attente communication");
|
|
case 6: return QStringLiteral("Mauvaise IP");
|
|
case 7: return QStringLiteral("Esclave introuvable");
|
|
case 8: return QStringLiteral("Mauvais esclave");
|
|
case 9: return QStringLiteral("Pas de réponse");
|
|
case 10: return QStringLiteral("Pince non connectée");
|
|
default: return QStringLiteral("Code erreur inconnu (%1)").arg(code);
|
|
}
|
|
}
|
|
|
|
void IntegrationPluginV2c::updateThingStates(Thing *thing, TrydanModbusMaster *master)
|
|
{
|
|
thing->setStateValue(trydanConnectedStateTypeId, master->reachable());
|
|
|
|
// Poll succeeded → clear any previous offline message.
|
|
thing->setStateValue(trydanStatusMessageStateTypeId, QString());
|
|
|
|
// ChargeState: 0=A(disconnected), 1=B(connected, not charging), 2=C(charging).
|
|
// cf. IEC 61851 and evcc trydan.go Status().
|
|
const int cs = master->chargeState();
|
|
thing->setStateValue(trydanPluggedInStateTypeId, cs >= 1);
|
|
thing->setStateValue(trydanChargingStateTypeId, cs == 2);
|
|
|
|
// chargingEnabled = PauseState==0 AND Lock==0 (cf. evcc trydan.go Enabled()).
|
|
thing->setStateValue(trydanPowerStateTypeId,
|
|
master->pauseState() == 0 && master->lock() == 0);
|
|
|
|
thing->setStateValue(trydanCurrentPowerStateTypeId, static_cast<double>(master->chargePower()));
|
|
thing->setStateValue(trydanHousePowerStateTypeId, static_cast<double>(master->housePower()));
|
|
thing->setStateValue(trydanSolarPowerStateTypeId, static_cast<double>(master->powerFV()));
|
|
thing->setStateValue(trydanIntensityStateTypeId, static_cast<double>(master->intensity()));
|
|
|
|
// currentL1/L2/L3 : ces registres sont intitulés « Power_Lx » par V2C mais portent
|
|
// des ampères (cf. TrydanModbusMaster::RegPower_L1). Publiés en A, sous un nom de
|
|
// courant : une unité qui ment coûte plus cher qu'un state manquant.
|
|
thing->setStateValue(trydanCurrentL1StateTypeId, static_cast<double>(master->currentL1()));
|
|
thing->setStateValue(trydanCurrentL2StateTypeId, static_cast<double>(master->currentL2()));
|
|
thing->setStateValue(trydanCurrentL3StateTypeId, static_cast<double>(master->currentL3()));
|
|
|
|
// Les deux grandeurs construites AVANT chargeEnergy : les accumulateurs travaillent
|
|
// sur la valeur fraîche du master, mais l'ordre garde les states cohérents pour qui
|
|
// les lit à la volée. chargeEnergy reste publié sous son nom exact, à côté : c'est la
|
|
// mesure brute de la borne, les deux autres sont construites. Si elles divergent un
|
|
// jour, on saura laquelle a menti.
|
|
//
|
|
// Chacune tient son propre repère (cf. SuiviDeSession::baselineTotale) : l'ordre de
|
|
// ces deux appels n'a donc aucune conséquence, et ce n'est pas un hasard.
|
|
accumulerEnergieDeSession(thing, master);
|
|
accumulerEnergieTotale(thing, master);
|
|
thing->setStateValue(trydanChargeEnergyStateTypeId, static_cast<double>(master->chargeEnergy()));
|
|
|
|
const int slaveErr = master->slaveError();
|
|
thing->setStateValue(trydanSlaveErrorStateTypeId, static_cast<uint>(slaveErr));
|
|
const QString errMsg = decodeSlaveError(slaveErr);
|
|
thing->setStateValue(trydanSlaveErrorMessageStateTypeId, errMsg);
|
|
// Code 4: borne drops WiFi mid-session and reconnects — surface it prominently.
|
|
if (slaveErr == 4) {
|
|
thing->setStateValue(trydanStatusMessageStateTypeId,
|
|
QStringLiteral("Borne en reconnexion WiFi"));
|
|
}
|
|
|
|
// Dynamic==1 means the V2C internal PV optimizer is configured and running.
|
|
const bool conflict = (master->dynamicMode() == 1);
|
|
thing->setStateValue(trydanConflictDetectedStateTypeId, conflict);
|
|
thing->setStateValue(trydanOptimizerSuspendedStateTypeId, master->pauseDynamic() == 1);
|
|
|
|
if (conflict) {
|
|
qCDebug(dcV2C()) << thing->name()
|
|
<< ": V2C internal optimizer is active (Dynamic==1). "
|
|
"HEMS will suppress it via PauseDynamic if suspendInternalOptimizer==true.";
|
|
}
|
|
}
|
|
|
|
void IntegrationPluginV2c::handleDynamicConflict(Thing *thing, TrydanModbusMaster *master)
|
|
{
|
|
// The V2C Trydan embeds its own solar-tracking PID ("Dynamic mode").
|
|
// Two controllers must not drive the same charger simultaneously.
|
|
//
|
|
// Correct suppression: write PauseDynamic=1 to freeze the internal PID.
|
|
// WRONG approach: write Dynamic=0 — this silences ChargePower telemetry.
|
|
// cf. evcc charger/trydan.go and github.com/evcc-io/evcc/issues/28047.
|
|
//
|
|
// PauseDynamic is only relevant when Dynamic==1 (optimizer configured on device).
|
|
// We re-evaluate every poll because the V2C app can toggle Dynamic at any time.
|
|
|
|
if (master->dynamicMode() != 1)
|
|
return; // No internal optimizer — nothing to manage.
|
|
|
|
const bool suspendAllowed = thing->setting(trydanSettingsSuspendInternalOptimizerParamTypeId).toBool();
|
|
if (!suspendAllowed) {
|
|
qCWarning(dcV2C()) << thing->name()
|
|
<< ": V2C internal optimizer active but suspendInternalOptimizer==false. "
|
|
"HEMS and V2C PID may fight — expect oscillations.";
|
|
return;
|
|
}
|
|
|
|
// HEMS is in control when chargingEnabled is true.
|
|
const bool hemsInControl = (master->pauseState() == 0 && master->lock() == 0);
|
|
const int desired = hemsInControl ? 1 : 0;
|
|
|
|
// Only write on transition to avoid unnecessary flash wear.
|
|
if (master->pauseDynamic() != desired) {
|
|
qCDebug(dcV2C()) << thing->name()
|
|
<< ": writing PauseDynamic =" << desired
|
|
<< (hemsInControl ? "(HEMS taking control)" : "(releasing to V2C PID)");
|
|
master->writePauseDynamic(static_cast<quint16>(desired));
|
|
}
|
|
}
|
|
|
|
void IntegrationPluginV2c::accumulerEnergieDeSession(Thing *thing, TrydanModbusMaster *master)
|
|
{
|
|
SuiviDeSession &suivi = m_sessions[thing];
|
|
|
|
const double brut = static_cast<double>(master->chargeEnergy());
|
|
const bool branche = (master->chargeState() >= 1);
|
|
|
|
// 1. Confirmation du débranchement. Seuls les polls RÉUSSIS parviennent ici :
|
|
// updateFinished() n'est émis qu'après une séquence de lecture complète. Une
|
|
// coupure réseau gèle donc le compteur d'absences, elle ne le fait pas avancer.
|
|
//
|
|
// ┌─ CETTE PROPRIÉTÉ PORTE. Ne pas la « corriger » en avançant le compteur sur les
|
|
// │ polls échoués, ni en le pilotant depuis un minuteur.
|
|
// │
|
|
// │ Elle rend un faux débranchement IMPOSSIBLE à fabriquer par un lien mort — ce qui
|
|
// │ n'est pas théorique ici : la campagne du 2026-08-28 a mesuré jusqu'à 70 % de polls
|
|
// │ perdus, et le 2026-09-06 encore 40 % sur l'heure. Un compteur qui avancerait au
|
|
// │ timeout clôturerait une session toutes les 90 s sur ce lien.
|
|
// │
|
|
// │ Et elle devient LOAD-BEARING au-delà de la session : les dérogations (« boost »)
|
|
// │ se terminent au débranchement, et une dérogation terminée à tort par un lien qui
|
|
// │ bat serait une charge interrompue sans raison. Cf. docs/DESIGN_DEROGATION.md du
|
|
// │ dépôt moteur — l'asymétrie qu'il demande existait déjà ici.
|
|
// └─
|
|
suivi.absences = branche ? 0 : suivi.absences + 1;
|
|
|
|
// 2. Clôture, une fois le débranchement confirmé sur k_absencesPourClore polls.
|
|
// On ne remet PAS sessionEnergy à zéro ici : la question « combien la nuit
|
|
// a-t-elle livré ? » se pose au matin, voiture déjà partie. La valeur reste
|
|
// affichée jusqu'au branchement suivant, qui seul ouvre une nouvelle session.
|
|
if (suivi.ouverte && suivi.absences >= k_absencesPourClore) {
|
|
suivi.ouverte = false;
|
|
ecrireSessionOuverte(thing, false);
|
|
qCDebug(dcV2C()) << thing->name() << ": session close a"
|
|
<< thing->stateValue(trydanSessionEnergyStateTypeId).toDouble()
|
|
<< "kWh (debranchement confirme sur" << k_absencesPourClore << "polls)";
|
|
}
|
|
|
|
// 3. Ouverture : un véhicule vient d'être branché. On part de la valeur brute et
|
|
// non de zéro — si le branchement a eu lieu pendant une coupure réseau, la
|
|
// borne a déjà compté, et cette énergie appartient à la session qui s'ouvre.
|
|
if (!suivi.ouverte && branche) {
|
|
suivi.ouverte = true;
|
|
suivi.baseline = brut;
|
|
thing->setStateValue(trydanSessionEnergyStateTypeId, brut);
|
|
ecrireSessionOuverte(thing, true);
|
|
qCDebug(dcV2C()) << thing->name() << ": nouvelle session, depart a" << brut << "kWh";
|
|
return;
|
|
}
|
|
|
|
// 4. Session close et véhicule absent : rien à accumuler. On suit quand même la
|
|
// valeur brute pour que la prochaine ouverture parte du bon repère.
|
|
if (!suivi.ouverte) {
|
|
suivi.baseline = brut;
|
|
return;
|
|
}
|
|
|
|
// 5. Session ouverte : accumuler l'incrément depuis le dernier poll réussi. La règle
|
|
// des trois cas (montée / bruit / remise à zéro) vit dans incrementEnergie() —
|
|
// partagée avec le totalisateur, pour qu'une correction ne s'applique jamais à
|
|
// une seule des deux grandeurs.
|
|
const double session = thing->stateValue(trydanSessionEnergyStateTypeId).toDouble()
|
|
+ incrementEnergie(brut, suivi.baseline);
|
|
|
|
if (brut <= suivi.baseline - k_bruitEnergieKWh) {
|
|
qCDebug(dcV2C()) << thing->name() << ": chargeEnergy remis a zero par la borne ("
|
|
<< suivi.baseline << "->" << brut << "kWh), session cumulee"
|
|
<< session << "kWh";
|
|
}
|
|
|
|
suivi.baseline = brut;
|
|
thing->setStateValue(trydanSessionEnergyStateTypeId, session);
|
|
}
|
|
|
|
double IntegrationPluginV2c::incrementEnergie(double brut, double repere)
|
|
{
|
|
const double ecart = brut - repere;
|
|
|
|
// Cas courant : le compteur de la borne monte, on le suit.
|
|
if (ecart >= 0.0)
|
|
return ecart;
|
|
|
|
// Recul infime : bruit de lecture, pas une remise à zéro. On n'ajoute rien ; l'appelant
|
|
// avance tout de même son repère sur la valeur lue, donc la remontée qui suivra ne sera
|
|
// pas comptée deux fois. Conséquence assumée : le creux lui-même est recompté à la
|
|
// remontée, soit un sur-comptage borné par k_bruitEnergieKWh (1 Wh) et par creux. Le
|
|
// choix inverse — figer le repère — sous-compterait de la même quantité ; à ce niveau
|
|
// de bruit, la simplicité du repère qui suit toujours la dernière lecture vaut mieux.
|
|
if (ecart > -k_bruitEnergieKWh)
|
|
return 0.0;
|
|
|
|
// Remise à zéro par la borne (arrêt de charge, câble encore branché). Les incréments
|
|
// d'avant la RAZ sont déjà cumulés chez l'appelant ; ce qui reste à ajouter est ce que
|
|
// la borne a compté DEPUIS. Perte résiduelle : l'énergie livrée entre le dernier poll
|
|
// et l'arrêt, bornée par un intervalle de poll — c'est exactement pourquoi ce calcul
|
|
// vit dans le plugin (30 s) et pas dans le moteur (à la minute), qui raterait des
|
|
// cycles entiers.
|
|
return brut;
|
|
}
|
|
|
|
void IntegrationPluginV2c::accumulerEnergieTotale(Thing *thing, TrydanModbusMaster *master)
|
|
{
|
|
SuiviDeSession &suivi = m_sessions[thing];
|
|
|
|
const double brut = static_cast<double>(master->chargeEnergy());
|
|
|
|
// Aucune condition de session ici, et c'est la différence de fond avec sessionEnergy :
|
|
// ce compteur suit l'énergie que la BORNE a livrée, qu'un véhicule ait été vu branché
|
|
// ou non. Une recharge lancée à la main sur la borne, hors pilotage — celle qu'un
|
|
// journal de sessions raterait — y entre comme les autres.
|
|
const double increment = incrementEnergie(brut, suivi.baselineTotale);
|
|
|
|
// Premier poll après appairage : le repère vaut 0 et la borne peut déjà compter une
|
|
// recharge en cours — le total démarre alors à cette valeur, pas à zéro. Ce n'est pas
|
|
// une consommation inventée pour autant : le moteur se CALE sur la première valeur
|
|
// sans l'ajouter à son propre diff (« Don't have a consumption counter for … Synching
|
|
// internal counters to initial value », EnergyManagerImpl::updateThingPower). La
|
|
// courbe ne montre donc pas de marche à l'ajout du Thing.
|
|
if (increment > 0.0) {
|
|
const double total = thing->stateValue(trydanTotalEnergyConsumedStateTypeId).toDouble()
|
|
+ increment;
|
|
thing->setStateValue(trydanTotalEnergyConsumedStateTypeId, total);
|
|
}
|
|
|
|
suivi.baselineTotale = brut;
|
|
}
|
|
|
|
void IntegrationPluginV2c::restaurerSuiviDeSession(Thing *thing)
|
|
{
|
|
SuiviDeSession suivi;
|
|
|
|
// Repère de reprise = la dernière valeur BRUTE publiée avant l'arrêt de nymead.
|
|
// Elle est relue du cache d'états : d'où "cached": true sur chargeEnergy dans
|
|
// integrationpluginv2c.json. NE PAS retirer cette propriété — l'accumulateur en
|
|
// dépend, et sans elle une recharge qui traverse un redémarrage repartirait de
|
|
// zéro sans que rien ne le signale.
|
|
suivi.baseline = thing->stateValue(trydanChargeEnergyStateTypeId).toDouble();
|
|
|
|
// Le totalisateur repart du même repère brut. Sa valeur cumulée, elle, est relue du
|
|
// cache d'états — d'où "cached": true sur totalEnergyConsumed. Si la borne a remis son
|
|
// compteur à zéro pendant la coupure, le premier poll verra brut < repère et ajoutera
|
|
// brut : ce que la borne a compté depuis sa RAZ, pas zéro.
|
|
suivi.baselineTotale = suivi.baseline;
|
|
|
|
QSettings *stockage = pluginStorage();
|
|
stockage->beginGroup(thing->id().toString());
|
|
suivi.ouverte = stockage->value("sessionOuverte", false).toBool();
|
|
stockage->endGroup();
|
|
|
|
m_sessions.insert(thing, suivi);
|
|
|
|
// Ce que fera le premier poll, selon l'état retrouvé :
|
|
// - session ouverte + véhicule toujours branché → l'écart accumulé pendant la
|
|
// coupure est repris (cas du redémarrage de nymead en pleine recharge) ;
|
|
// - session ouverte + véhicule absent → clôture après confirmation, la valeur
|
|
// restaurée reste lisible ;
|
|
// - session close + véhicule branché → nouvelle session, remise à zéro.
|
|
// LIMITE ASSUMÉE : si la voiture a été débranchée PUIS rebranchée pendant la
|
|
// coupure, on ne peut pas le savoir — la borne ne numérote pas ses sessions — et
|
|
// la nouvelle session héritera du total de la précédente. Le choix inverse
|
|
// (repartir de zéro à chaque redémarrage) casserait justement le cas que ce
|
|
// correctif existe pour couvrir : la recharge de nuit relue au matin.
|
|
qCDebug(dcV2C()) << thing->name() << ": reprise du suivi de session — ouverte ="
|
|
<< suivi.ouverte << ", repere chargeEnergy =" << suivi.baseline
|
|
<< "kWh, sessionEnergy restauree ="
|
|
<< thing->stateValue(trydanSessionEnergyStateTypeId).toDouble()
|
|
<< "kWh, totalEnergyConsumed restaure ="
|
|
<< thing->stateValue(trydanTotalEnergyConsumedStateTypeId).toDouble() << "kWh";
|
|
}
|
|
|
|
void IntegrationPluginV2c::ecrireSessionOuverte(Thing *thing, bool ouverte)
|
|
{
|
|
// Écrit sur transition seulement (ouverture / clôture), jamais à chaque poll :
|
|
// le stockage est sur la mémoire flash de la passerelle.
|
|
QSettings *stockage = pluginStorage();
|
|
stockage->beginGroup(thing->id().toString());
|
|
stockage->setValue("sessionOuverte", ouverte);
|
|
stockage->endGroup();
|
|
}
|
|
|
|
void IntegrationPluginV2c::setDisconnectedState(Thing *thing, const QString &cause)
|
|
{
|
|
thing->setStateValue(trydanConnectedStateTypeId, false);
|
|
thing->setStateValue(trydanChargingStateTypeId, false);
|
|
thing->setStateValue(trydanPluggedInStateTypeId, false);
|
|
thing->setStateValue(trydanCurrentPowerStateTypeId, 0.0);
|
|
|
|
// Inform the user with a timestamp so they know when the outage started,
|
|
// and a hint pointing to the most likely cause (WiFi signal on this charger).
|
|
const QString ts = QDateTime::currentDateTime().toString(QStringLiteral("dd/MM HH:mm"));
|
|
const QString msg = cause.isEmpty()
|
|
? QStringLiteral("Hors ligne depuis ") + ts
|
|
: QStringLiteral("Hors ligne depuis ") + ts + QStringLiteral(" — ") + cause;
|
|
thing->setStateValue(trydanStatusMessageStateTypeId, msg);
|
|
}
|