etm-powersync-plugins-modbus/v2c/integrationpluginv2c.cpp
Patrick Schurig 1bf0b675c6 fix(v2c): la Trydan déclare phaseCount, et il est SAISI à l'appairage
Aucun registre de phases n'existe dans la carte Modbus V2C : la borne ne sait
pas dire si elle est câblée en mono ou en tri. La ThingClass ne le déclarait pas
non plus, et le repli du moteur pour un état absent est SILENCIEUX —
EvCharger::phaseCount() rend 1.

Sur une Trydan triphasée, cela publie un plancher de 1 380 W au lieu de 4 140
(6 A × 230 V × 1 au lieu de × 3) et convertit l'enveloppe allouée en un courant
trois fois trop élevé. La borne démarre, au mauvais courant, sans qu'aucun
journal ne le dise.

C'est la famille de défaut que le moteur vient de fermer en 3g-2 — un plancher
annoncé qui n'est pas celui qui gouverne le démarrage — rouverte côté plugin.
Trouvée la veille de la pose, en lisant la déclaration de classe plutôt qu'en
l'exécutant.

La valeur vient donc d'un RÉGLAGE (1 ou 3, défaut 1), recopié dans l'état que le
moteur lit, et réappliqué sur settingChanged pour qu'une borne recâblée se
corrige sans recréer le Thing. Le défaut 1 est celui du repli du moteur : une
installation qui ne renseigne rien se comporte exactement comme avant.

L'état porte minValue 1 / maxValue 3 — l'interface nymea evcharger définit
phaseCount avec ces bornes, et le validateur refuse la classe sans elles. S'y
conformer, c'est être lu par le moteur À TRAVERS l'interface, pas par une
convention propre au plugin.

Compile amd64 0 erreur / 0 warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
2026-08-27 15:15:31 +02:00

392 lines
18 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>
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);
}
});
// 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);
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(trydanChargeEnergyStateTypeId, static_cast<double>(master->chargeEnergy()));
thing->setStateValue(trydanIntensityStateTypeId, static_cast<double>(master->intensity()));
thing->setStateValue(trydanPowerL1StateTypeId, static_cast<double>(master->powerL1()));
thing->setStateValue(trydanPowerL2StateTypeId, static_cast<double>(master->powerL2()));
thing->setStateValue(trydanPowerL3StateTypeId, static_cast<double>(master->powerL3()));
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::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);
}