// SPDX-License-Identifier: GPL-3.0-or-later #include "integrationpluginv2c.h" #include "v2ctcpdiscovery.h" #include "plugininfo.h" #include #include #include #include #include #include #include #include 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(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); // 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(master->maxIntensity())); const quint16 amps = static_cast(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(master->chargePower())); thing->setStateValue(trydanHousePowerStateTypeId, static_cast(master->housePower())); thing->setStateValue(trydanSolarPowerStateTypeId, static_cast(master->powerFV())); thing->setStateValue(trydanChargeEnergyStateTypeId, static_cast(master->chargeEnergy())); thing->setStateValue(trydanIntensityStateTypeId, static_cast(master->intensity())); thing->setStateValue(trydanPowerL1StateTypeId, static_cast(master->powerL1())); thing->setStateValue(trydanPowerL2StateTypeId, static_cast(master->powerL2())); thing->setStateValue(trydanPowerL3StateTypeId, static_cast(master->powerL3())); const int slaveErr = master->slaveError(); thing->setStateValue(trydanSlaveErrorStateTypeId, static_cast(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(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); }