// SPDX-License-Identifier: GPL-3.0-or-later #include "integrationpluginv2c.h" #include "v2ctcpdiscovery.h" #include "plugininfo.h" #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(); } // Register or reuse the network monitor (tracks IP changes when DHCP renews). NetworkDeviceMonitor *monitor = m_monitors.value(thing); if (!monitor) { monitor = hardwareManager()->networkDeviceDiscovery()->registerMonitor(thing); if (!monitor) { info->finish(Thing::ThingErrorInvalidParameter, QT_TR_NOOP("Could not register network monitor for this charger.")); return; } m_monitors.insert(thing, monitor); } const quint16 port = static_cast(thing->paramValue(trydanThingPortParamTypeId).toUInt()); const quint16 slaveId = 1; // V2C Trydan always uses unit ID 1 TrydanModbusTcpMaster *master = new TrydanModbusTcpMaster( monitor->networkDeviceInfo().address(), port, slaveId, thing); // Update target IP if the device gets a new DHCP lease. connect(monitor, &NetworkDeviceMonitor::networkDeviceInfoChanged, master, [master](const NetworkDeviceInfo &info) { master->setHostAddress(info.address()); }); // Abort the setup if the info object is destroyed before we finish. connect(info, &ThingSetupInfo::aborted, master, &TrydanModbusTcpMaster::deleteLater); connect(info, &ThingSetupInfo::aborted, monitor, [this, thing]() { if (m_monitors.contains(thing)) { hardwareManager()->networkDeviceDiscovery()->unregisterMonitor(m_monitors.take(thing)); } }); // Reachable → initialize; lost → disconnected state. connect(master, &TrydanModbusMaster::reachableChanged, thing, [this, thing, master](bool reachable) { if (reachable) { master->initialize(); } else { setDisconnectedState(thing); } }); // After init, set the current-range and report setup done. connect(master, &TrydanModbusMaster::initializationFinished, thing, [this, thing, master](bool success) { if (!success) { return; } thing->setStateValue(trydanConnectedStateTypeId, true); thing->setStateMinMaxValues(trydanMaxChargingCurrentStateTypeId, master->minIntensity(), master->maxIntensity()); }); 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) { master->update(); } }); m_pluginTimer->start(); } void IntegrationPluginV2c::thingRemoved(Thing *thing) { delete m_tcpMasters.take(thing); if (m_monitors.contains(thing)) { hardwareManager()->networkDeviceDiscovery()->unregisterMonitor(m_monitors.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 --- void IntegrationPluginV2c::updateThingStates(Thing *thing, TrydanModbusMaster *master) { thing->setStateValue(trydanConnectedStateTypeId, master->reachable()); // 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(trydanSlaveErrorStateTypeId, static_cast(master->slaveError())); thing->setStateValue(trydanChargeEnergyStateTypeId, static_cast(master->chargeEnergy())); thing->setStateValue(trydanIntensityStateTypeId, static_cast(master->intensity())); // 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) { thing->setStateValue(trydanConnectedStateTypeId, false); thing->setStateValue(trydanChargingStateTypeId, false); thing->setStateValue(trydanPluggedInStateTypeId, false); thing->setStateValue(trydanCurrentPowerStateTypeId, 0.0); }