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.
556 lines
26 KiB
C++
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 ¶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,
|
|
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);
|
|
}
|