etm-powersync-plugins-modbus/v2c/integrationpluginv2c.cpp
Patrick Schurig 7863517bba docs(v2c): le compteur d'absences n'avance que sur les polls réussis — propriété load-bearing
Le commentaire disait déjà la propriété ; il ne disait pas qu'elle PORTE. Une
réécriture qui ferait avancer le compteur au timeout, ou le piloterait depuis un
minuteur, la casserait sans qu'aucun test ne le dise.

Ce qu'elle garantit : un faux débranchement est IMPOSSIBLE à fabriquer par une
coupure réseau. Ce n'est pas théorique — 70 % de polls perdus mesurés le
2026-08-28, 40 % encore le 2026-09-06. Un compteur piloté au timeout clôturerait
une session toutes les 90 secondes sur ce lien.

Et elle devient load-bearing au-delà de la session : les dérogations se terminent
au débranchement, et l'asymétrie que leur design exige — prompt à conclure la fin,
lent à conclure la poursuite — existe déjà ici. Quelqu'un y a pensé avant nous.
2026-09-08 06:21:14 +02:00

556 lines
26 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>
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 &paramTypeId, 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,
master->minIntensity(),
master->maxIntensity());
// 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();
// Clamp to IEC 61851 minimum. Below 6 A → pause rather than send invalid setpoint.
if (current < 6.0) {
master->writePauseState(1);
master->writeLock(1);
thing->setStateValue(trydanPowerStateTypeId, false);
info->finish(Thing::ThingErrorNoError);
return;
}
current = qMin(current, static_cast<double>(master->maxIntensity()));
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()));
// sessionEnergy AVANT chargeEnergy : l'accumulateur travaille sur la valeur
// fraîche du master, mais l'ordre garde les deux 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, sessionEnergy la grandeur construite. Si les deux
// divergent un jour, on saura laquelle a menti.
accumulerEnergieDeSession(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'écart depuis le dernier poll réussi.
double session = thing->stateValue(trydanSessionEnergyStateTypeId).toDouble();
const double ecart = brut - suivi.baseline;
if (ecart >= 0.0) {
// Cas courant : le compteur de la borne monte, on le suit.
session += ecart;
} else if (ecart > -k_bruitEnergieKWh) {
// Recul infime : bruit de lecture, pas une remise à zéro. On n'ajoute rien ;
// la baseline suit tout de même la valeur lue, donc la remontée qui suivra
// ne sera pas comptée deux fois.
} else {
// Remise à zéro par la borne (arrêt de charge, câble encore branché). Les
// incréments d'avant la RAZ sont déjà dans session ; 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.
session += brut;
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);
}
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();
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";
}
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);
}