Deux correctifs issus de la même campagne (borne .103, banc .75, phaseCount = 3, 2026-08-28). Ils touchent les mêmes fonctions et sortent des mêmes relevés. 1. sessionEnergy — CONSTRUIT, pas câblé ------------------------------------------------------------------------------ La Trydan ne publie pas d'énergie de session. Son compteur ChargeEnergy (0x0BC4) est exact — son intégration recoupe currentPower à mieux d'un pour cent, deux fois de suite — mais il n'est pas conservé : le firmware le remet à zéro à chaque ARRÊT DE CHARGE, câble encore branché. Cycle mesuré : arrêt à t+1 s, remise à zéro à t+8 s, débranchement seulement à t+127 s. Le déclencheur est l'arrêt, pas le débranchement. C'est notre problème et pas celui du firmware : en pilotage par surplus, c'est notre propre arbitrage qui coupe au passage d'un nuage. Le HEMS effacerait donc sa propre mesure plusieurs fois par jour, et une nuit de recharge relue à 7 h vaudrait 0 — soit « rien livré », ce que la condition C1 de LM-1009 interdit d'afficher. accumulerEnergieDeSession() accumule les incréments avant chaque remise à zéro. Dans le plugin et pas dans le moteur : le plugin scrute à 30 s, le moteur à la minute — un incrément effacé entre deux lectures du moteur lui serait invisible. En sortie, le moteur et l'app reçoivent le contrat evcharger standard, sans cas particulier V2C à porter en aval. Même principe que le câblage d'energystorage sur la batterie SunSpec. Trois décisions, détaillées dans AGENTS.md § SESSION ENERGY : - RAZ à l'OUVERTURE de session, jamais à la fermeture. Après le départ de la voiture, sessionEnergy garde le total de la session terminée : la question « combien la nuit a-t-elle livré ? » se pose au matin, voiture déjà partie. Remettre à zéro au débranchement effacerait la réponse au moment précis où on la cherche. - Anti-clignotement STRUCTUREL, pas temporisé. Le débranchement doit être confirmé sur 3 polls RÉUSSIS consécutifs. updateFinished() n'est émis qu'après une séquence de lecture complète : une coupure WiFi GÈLE le compteur d'absences au lieu de le faire avancer. C'est la protection qu'il fallait contre le lien de ce matin (latence 2-4 s, 40 % de perte avant stabilisation). - Persistance sur deux supports. sessionEnergy en state « cached » ; le repère de reprise est la dernière valeur brute de chargeEnergy, elle aussi passée en « cached » — retirer cette propriété ferait repartir de zéro une recharge qui traverse un redémarrage, SANS QUE RIEN NE LE SIGNALE, d'où l'annotation en travers du code. Le drapeau « session ouverte » va dans pluginStorage(), écrit sur transition seulement (flash de la passerelle). Deux limites assumées plutôt que contournées : - Si un débranchement PUIS un rebranchement tiennent entièrement dans une coupure, la nouvelle session hérite du total de la précédente. La borne ne numérote pas ses sessions, et une RAZ de ChargeEnergy ne lève pas l'ambiguïté : elle survient aussi au sein d'une même session. Le choix inverse — repartir de zéro à chaque redémarrage — casserait le cas que ce correctif existe pour couvrir. - L'énergie livrée entre le dernier poll et l'arrêt de charge est perdue, bornée par un intervalle de poll. NON estimée par currentPower × Δt : on n'invente pas une mesure pour combler un trou de mesure. chargeEnergy reste publié à côté, sous son nom exact — sessionEnergy est la grandeur construite, chargeEnergy la mesure brute. Si les deux divergent un jour, on saura laquelle a menti. Seul le displayName change, « Session energy (diagnostic) » étant devenu mensonger. Ceci lève l'interdiction précédente (« ne pas exposer sessionEnergy », cf. evcc issue #28047), mais pas de la façon qu'elle envisageait : elle attendait une validation de fiabilité, la campagne a séparé exactitude (bonne) et conservation (nulle). 2. Power_L1/L2/L3 portent des AMPÈRES ------------------------------------------------------------------------------ La feuille V2C intitule 0x0BD9-0x0BDB « Power_Lx ». Mesuré : consigne 6 A → 5,9-6,0 lus ; consigne 10 A → 9,x-10 lus. Sous 230 V une puissance de phase vaudrait ~1 380 W, pas 5,9. Publiés en Watt, l'app affichait « 5,9 W » là où la borne dit « 5,9 A » — une unité qui ment, même famille que carBatteryLevel publié sous un nom de mesure. States renommés currentL1/L2/L3, unité Ampere. Les UUID ne changent PAS : l'identifiant a toujours porté des ampères, c'est le nom qui mentait. Les noms d'enum RegPower_Lx gardent en revanche l'intitulé du constructeur — c'est ainsi qu'on retrouve l'adresse dans la feuille — avec l'avertissement en commentaire. Bénéfice de bord : le déséquilibre entre phases, que ne donne aucune autre borne du catalogue, utile à la protection de surcharge par phase (LM-1006-1). 3. Observations de campagne consignées ------------------------------------------------------------------------------ - Latence de consigne asymétrique, du bon côté pour la sécurité : montée 6 → 10 A en ~30 s, descente 10 → 6 A en ~7 s. - maxChargingCurrent (consigne, 0x177D) et intensity (effet, 0x0BCD) diffèrent pendant la transition — même couple commandé/réel qu'allocatedW vs mechanism. Ne pas « corriger » l'un avec l'autre. - optimizerSuspended et conflictDetected étaient à True : la borne a son propre optimiseur et le HEMS le suspend. Second commandeur potentiel, à lire en diagnostic quand un écart consigne/effet n'est pas expliqué par la latence. - La ThingClass ne déclare que deux actions, maxChargingCurrent et power : phaseCount est un réglage de câblage, jamais une commande. Compile sans avertissement sous -Werror -Wall -Wextra. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A41CTzMvir1Y5ABPXW8xpB
542 lines
26 KiB
C++
542 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.
|
|
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);
|
|
}
|