From 140860aa70b2f935e3e94ff35866c24bc9ab4b90 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 12 Jun 2026 13:49:49 +0200 Subject: [PATCH] feat(v2c): add integration plugin, JSON metadata, and build plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Étape 1 complete: V2C Trydan nymea plugin (Modbus TCP, status Partial). Plugin (IntegrationPluginV2c): - discoverThings: delegates to V2cTcpDiscovery, maps results to ThingDescriptors; createMethod "user" bypasses discovery. - setupThing: registers a NetworkDeviceMonitor (IP tracking on DHCP renewal), creates TrydanModbusTcpMaster, wires reachableChanged → initialize() → state update on initializationFinished(), then starts periodic polls on a 30s PluginTimer. - updateThingStates: ChargeState 0/1/2 → pluggedIn/charging; power = (PauseState==0 AND Lock==0) per evcc trydan.go Enabled(). - handleDynamicConflict: if Dynamic==1 (V2C internal PV optimizer), write PauseDynamic=1 to suppress it. NEVER write Dynamic=0 (silences ChargePower telemetry). Writes only on state transition to minimize flash wear. cf. github.com/evcc-io/evcc/issues/28047. - Action "power": writes PauseState then Lock (always in mirror), then fire-and-forgets PauseDynamic if Dynamic==1. Action handlers filter writeCompleted by register address to avoid reacting to concurrent background writes. - Action "maxChargingCurrent": clamps to [minIntensity, maxIntensity], writes Intensity. Below 6A → pause instead of invalid setpoint. JSON (integrationpluginv2c.json): - ThingClass "trydan" implementing evcharger + connectable + networkdevice. - settingsTypes: suspendInternalOptimizer (bool, default true) — allows the user to disable Dynamic conflict management if they want the V2C PID to remain active alongside the HEMS. - State "chargeEnergy" exposed as diagnostic only (NOT sessionEnergy) — firmware reliability on this register is unvalidated; cf. evcc #28047. Build (v2c.pro): - MODBUS_CONNECTIONS intentionally empty: the modbus-tool code generator assumes non-overlapping block reads; leaving it empty skips generation while still linking nymea-modbus via the PKGCONFIG in modbus.pri. VendorId 56c3e7bb… is new (no existing V2C vendor in the repo). PluginId f0692725… is new (generated for this plugin). PORTING_STATUS_modbus.md documents register sources, the 4 items that require validation on real hardware, and the "no beta matrix" constraint. Co-Authored-By: Claude Sonnet 4.6 --- etm-powersync-plugins-modbus.pro | 3 +- v2c/integrationpluginv2c.cpp | 330 ++++++++++++++++++ v2c/integrationpluginv2c.h | 68 ++++ v2c/integrationpluginv2c.json | 187 ++++++++++ v2c/meta.json | 12 + ...92725-650a-47c3-a673-172f077768f1-en_US.ts | 19 + v2c/v2c.pro | 23 ++ 7 files changed, 641 insertions(+), 1 deletion(-) create mode 100644 v2c/integrationpluginv2c.cpp create mode 100644 v2c/integrationpluginv2c.h create mode 100644 v2c/integrationpluginv2c.json create mode 100644 v2c/meta.json create mode 100644 v2c/translations/f0692725-650a-47c3-a673-172f077768f1-en_US.ts create mode 100644 v2c/v2c.pro diff --git a/etm-powersync-plugins-modbus.pro b/etm-powersync-plugins-modbus.pro index 267edd0..5c90608 100644 --- a/etm-powersync-plugins-modbus.pro +++ b/etm-powersync-plugins-modbus.pro @@ -6,7 +6,8 @@ TEMPLATE = subdirs PLUGIN_DIRS = \ eastron \ abbb2x \ - abbterra + abbterra \ + v2c message(============================================) diff --git a/v2c/integrationpluginv2c.cpp b/v2c/integrationpluginv2c.cpp new file mode 100644 index 0000000..32f7d2e --- /dev/null +++ b/v2c/integrationpluginv2c.cpp @@ -0,0 +1,330 @@ +// 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); +} diff --git a/v2c/integrationpluginv2c.h b/v2c/integrationpluginv2c.h new file mode 100644 index 0000000..5ae19f9 --- /dev/null +++ b/v2c/integrationpluginv2c.h @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef INTEGRATIONPLUGINV2C_H +#define INTEGRATIONPLUGINV2C_H + +#include +#include +#include + +#include "extern-plugininfo.h" +#include "trydanmodbusmaster.h" +#include "trydanmodbustcpmaster.h" + +/*! + * \brief nymea integration plugin for the V2C Trydan EV charger. + * + * ThingClass \c trydan implementing the \c evcharger interface via Modbus TCP + * (Étape 1). All register-level I/O is delegated to TrydanModbusTcpMaster; + * this class owns the evcharger business logic: + * - ChargeState → pluggedIn / charging mapping + * - chargingEnabled = (PauseState==0 AND Lock==0) + * - Dynamic optimizer conflict management via PauseDynamic + * + * Étape 2 (RTU) will add TrydanModbusRtuMaster on the same TrydanModbusMaster + * interface; the logic in this file requires no changes. + */ +class IntegrationPluginV2c : public IntegrationPlugin +{ + Q_OBJECT + + Q_PLUGIN_METADATA(IID "io.nymea.IntegrationPlugin" FILE "integrationpluginv2c.json") + Q_INTERFACES(IntegrationPlugin) + +public: + explicit IntegrationPluginV2c(); + + void discoverThings(ThingDiscoveryInfo *info) override; + void setupThing(ThingSetupInfo *info) override; + void postSetupThing(Thing *thing) override; + void thingRemoved(Thing *thing) override; + +public slots: + void executeAction(ThingActionInfo *info) override; + +private: + /*! \brief Apply all evcharger states from the last completed poll. */ + void updateThingStates(Thing *thing, TrydanModbusMaster *master); + + /*! + * \brief Manage the Dynamic/PauseDynamic conflict on each poll. + * + * If Dynamic==1 (V2C internal optimizer is running) and HEMS is in control + * (chargingEnabled==true), write PauseDynamic=1 to suppress the internal PID + * without disabling the telemetry. Release PauseDynamic=0 when HEMS + * relinquishes control. Dynamic is NEVER written (would silence ChargePower). + * cf. AGENTS.md § CONFLIT and evcc charger/trydan.go. + */ + void handleDynamicConflict(Thing *thing, TrydanModbusMaster *master); + + void setDisconnectedState(Thing *thing); + + PluginTimer *m_pluginTimer = nullptr; + + QHash m_tcpMasters; + QHash m_monitors; +}; + +#endif // INTEGRATIONPLUGINV2C_H diff --git a/v2c/integrationpluginv2c.json b/v2c/integrationpluginv2c.json new file mode 100644 index 0000000..31b510e --- /dev/null +++ b/v2c/integrationpluginv2c.json @@ -0,0 +1,187 @@ +{ + "name": "V2C", + "displayName": "V2C Trydan", + "id": "f0692725-650a-47c3-a673-172f077768f1", + "paramTypes": [], + "vendors": [ + { + "id": "56c3e7bb-2db8-4002-b799-9e21530e6fb9", + "name": "v2c", + "displayName": "V2C", + "thingClasses": [ + { + "id": "b2a12873-4d11-444e-a4ad-914907491eb4", + "name": "trydan", + "displayName": "Trydan", + "interfaces": [ + "evcharger", + "connectable", + "networkdevice" + ], + "createMethods": [ + "discovery", + "user" + ], + "discoveryType": "weak", + "paramTypes": [ + { + "id": "2fe17a54-b24b-4868-ae99-36a4627e6827", + "name": "macAddress", + "displayName": "MAC address", + "type": "QString", + "inputType": "MacAddress", + "defaultValue": "", + "readOnly": true + }, + { + "id": "c0cdfa64-7054-4486-809e-3b7f257a3aab", + "name": "address", + "displayName": "Host address", + "type": "QString", + "inputType": "IPv4Address", + "defaultValue": "" + }, + { + "id": "bd9987a4-4c45-49c8-ab67-8c80705ec109", + "name": "hostName", + "displayName": "Host name", + "type": "QString", + "inputType": "TextLine", + "defaultValue": "" + }, + { + "id": "d096bfe1-5861-456f-915c-f1f2c65b03b3", + "name": "port", + "displayName": "Modbus TCP port", + "type": "uint", + "defaultValue": 502 + } + ], + "settingsTypes": [ + { + "id": "a47f20bd-dfdc-4827-ab7d-05d7819f316e", + "name": "suspendInternalOptimizer", + "displayName": "Suspend internal V2C optimizer when HEMS takes control", + "type": "bool", + "defaultValue": true + } + ], + "stateTypes": [ + { + "id": "47054399-82fb-4e12-9bb6-9ae3e4fd4f78", + "name": "connected", + "displayName": "Connected", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "f38c313c-47cc-4791-8a8f-64c1af852af6", + "name": "pluggedIn", + "displayName": "Plugged in", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "6d003e09-d30d-4a1c-9e2d-9e838b61dc5d", + "name": "charging", + "displayName": "Charging", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "eb3253a2-7518-4e76-9c06-1bb133d5037d", + "name": "power", + "displayName": "Charging enabled", + "displayNameAction": "Set charging enabled", + "type": "bool", + "defaultValue": true, + "writable": true + }, + { + "id": "deb8826d-4f91-42b8-89cf-16feb6425e0e", + "name": "maxChargingCurrent", + "displayName": "Maximum charging current", + "displayNameAction": "Set maximum charging current", + "type": "double", + "unit": "Ampere", + "minValue": 6, + "maxValue": 32, + "stepSize": 1, + "defaultValue": 16, + "writable": true + }, + { + "id": "2570c0d7-f9a5-4e24-9242-6d79253004ec", + "name": "currentPower", + "displayName": "Charging power", + "type": "double", + "unit": "Watt", + "defaultValue": 0, + "cached": false + }, + { + "id": "d11a392f-387d-4f10-9050-e25198d2c584", + "name": "housePower", + "displayName": "House power (CT clamp)", + "type": "double", + "unit": "Watt", + "defaultValue": 0, + "cached": false + }, + { + "id": "37e706fb-fa3c-484a-b776-b50aa5de5c61", + "name": "solarPower", + "displayName": "Solar power (PV seen by charger)", + "type": "double", + "unit": "Watt", + "defaultValue": 0, + "cached": false + }, + { + "id": "9a9e6afb-a641-409f-9705-c16776db3a29", + "name": "slaveError", + "displayName": "Charger error code", + "type": "uint", + "defaultValue": 0 + }, + { + "id": "fe616e45-39cb-4be3-8426-9d27c7a64f06", + "name": "chargeEnergy", + "displayName": "Session energy (diagnostic)", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0 + }, + { + "id": "71773194-ebd3-4ded-893c-14bfef9b1854", + "name": "intensity", + "displayName": "Current charging current", + "type": "double", + "unit": "Ampere", + "defaultValue": 0, + "cached": false + }, + { + "id": "3c86495b-74da-440e-98a5-825678e35c1a", + "name": "conflictDetected", + "displayName": "Internal V2C optimizer active", + "type": "bool", + "defaultValue": false + }, + { + "id": "006dd018-9c6a-4a4e-a7b2-eb965d27ca92", + "name": "optimizerSuspended", + "displayName": "Internal optimizer suspended by HEMS", + "type": "bool", + "defaultValue": false + } + ], + "actionTypes": [] + } + ] + } + ] +} diff --git a/v2c/meta.json b/v2c/meta.json new file mode 100644 index 0000000..bdce0e2 --- /dev/null +++ b/v2c/meta.json @@ -0,0 +1,12 @@ +{ + "title": "V2C Trydan", + "tagline": "Connect V2C Trydan EV chargers over Modbus TCP.", + "stability": "consumer", + "offline": true, + "technologies": [ + "network" + ], + "categories": [ + "energy" + ] +} diff --git a/v2c/translations/f0692725-650a-47c3-a673-172f077768f1-en_US.ts b/v2c/translations/f0692725-650a-47c3-a673-172f077768f1-en_US.ts new file mode 100644 index 0000000..074ebaa --- /dev/null +++ b/v2c/translations/f0692725-650a-47c3-a673-172f077768f1-en_US.ts @@ -0,0 +1,19 @@ + + + + + IntegrationPluginV2c + + Could not register network monitor for this charger. + + + + Could not communicate with the V2C Trydan charger. + + + + The V2C Trydan charger is not reachable. + + + + diff --git a/v2c/v2c.pro b/v2c/v2c.pro new file mode 100644 index 0000000..463c02e --- /dev/null +++ b/v2c/v2c.pro @@ -0,0 +1,23 @@ +include(../plugins.pri) + +# nymea-modbus provides ModbusTcpMaster and float-decode utils. +# MODBUS_CONNECTIONS is intentionally empty: V2C Trydan register windows +# overlap (see AGENTS.md), so the auto-generator's block-read optimisation +# would corrupt the readings. All I/O is hand-coded in TrydanModbusTcpMaster. +include(../modbus.pri) + +QT += network + +TARGET = $$qtLibraryTarget(nymea_integrationpluginv2c) + +SOURCES += \ + integrationpluginv2c.cpp \ + trydanmodbusmaster.cpp \ + trydanmodbustcpmaster.cpp \ + v2ctcpdiscovery.cpp + +HEADERS += \ + integrationpluginv2c.h \ + trydanmodbusmaster.h \ + trydanmodbustcpmaster.h \ + v2ctcpdiscovery.h