Compare commits

...

5 Commits

Author SHA1 Message Date
Patrick Schurig
2ac00e1330 fix(v2c): correct logging category dcV2C + debian packaging + modbus.pri path
Build fixes found during first local compilation (qmake6 + g++ -Werror):

1. Logging category: nymea-plugininfocompiler generates 'dcV2C' (uppercase C)
   from the JSON vendor name "V2C". All three source files used 'dcV2c'
   (lowercase c) — corrected to 'dcV2C' in integrationpluginv2c.cpp,
   trydanmodbustcpmaster.cpp, v2ctcpdiscovery.cpp.

2. modbus.pri: hardcoded include(/usr/include/nymea-modbus/modbus-tool.pri)
   fails when libnymea-modbus-dev is not system-installed. Changed to a
   conditional: checks $$[QT_INSTALL_PREFIX] first, then NYMEA_MODBUS_PATH
   override, with a graceful fallback (warning + manual C++17 CONFIG flag).
   The v2c plugin has MODBUS_CONNECTIONS empty so the tool loop is a no-op
   either way; the fallback is safe.

3. debian/: add powersync-plugin-v2c package entry (control, .install,
   changelog entry 1.15.0+etm4).

Build verified: libnymea_integrationpluginv2c.so links against libnymea.so.1,
libnymea-modbus.so.1, Qt6Network/SerialBus/Core, 0 compiler warnings
(with -Werror -Wall -Wextra -std=c++17).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 15:20:11 +02:00
Patrick Schurig
f0be2d00a2 fix(v2c): use qBound for min/maxIntensity clamp in initialize()
qMin(32, m_maxIntensity) gives 0 when firmware returns 0 for MaxIntensity
(aberrant value).  maxIntensity()==0 then causes every setMaxChargingCurrent
call to be clamped to 0, falling into the <6 A path which pauses charging
silently — the user sees the action succeed but the charger stops.

qBound(6, val, 32) is correct in all cases:
  - firmware reports 16 A (mono Trydan) → 16 (real installation limit kept)
  - firmware reports 0 (aberrant)        →  6 (safe floor, does not claim 32 A)
  - firmware reports 65535 (aberrant)    → 32 (IEC absolute ceiling)

The real installation limit still comes from MaxIntensity read on the device;
the bounds [6, 32] are only IEC guardrails, not hardcoded defaults.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 14:22:13 +02:00
Patrick Schurig
18655452d9 feat(v2c): add integration plugin, JSON metadata, and build plumbing
É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 <noreply@anthropic.com>
2026-06-12 13:49:49 +02:00
Patrick Schurig
947da7e1fc feat(v2c): add HTTP LAN discovery (V2cTcpDiscovery)
Architecture choice: Modbus TCP has no network discovery mechanism, so
discovery piggybacks on nymea's NetworkDeviceDiscovery (ARP/mDNS scan)
and then probes each host via GET /RealTimeData (V2C Trydan HTTP REST API).

The presence of a "Paused" field in the JSON response is used as a Trydan
fingerprint — it matches the charger model and is absent on generic devices.
cf. evcc charger/trydan.go realTimeData struct.

Probes run concurrently (one QNetworkReply per host) but the sequence
terminates via two independent paths that could both fire:
  1. The NetworkDeviceDiscoveryReply::finished callback arms a 3-second
     QTimer::singleShot to give outstanding probes time to complete.
  2. Each probe completion path checks (m_scanFinished && pendingProbes==0)
     and calls finishDiscovery() early if all probes are already done.
A m_finished bool guard prevents double-emission of discoveryFinished()
if both paths fire in quick succession (e.g. all probes complete exactly
as the timer fires).

Manual-entry (createMethod "user") skips discovery entirely; setupThing()
is called directly with the user-supplied IP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 13:49:22 +02:00
Patrick Schurig
14f07cc638 feat(v2c): add Modbus transport abstraction + TCP implementation
Architecture choice: separate the register map / business logic from the
physical transport.  TrydanModbusMaster (abstract QObject) holds:
  - the complete ReadRegister / WriteRegister enums with addresses
  - float32 Big/Big decode helpers (memcpy pattern, matches pymodbus
    BinaryPayloadDecoder byteorder=Endian.Big wordorder=Endian.Big)
  - all last-polled cached values accessible via simple getters

TrydanModbusTcpMaster (concrete, Étape 1) wraps libnymea-modbus
ModbusTcpMaster.  Each register read is a distinct FC3(addr, 2)
transaction via a recursive runReadSequence/doNextRead pattern —
never a block read, because the V2C firmware's register windows
overlap (0x0BC2 and 0x0BC3 each span 2 registers, so a range read
starting at 0x0BC2 for ≥3 registers returns garbage for the second
value).  Writes use FC6 (single uint16, not float).

writeCompleted(quint16 address, bool success) carries the originating
register address so that concurrent background writes (e.g. PauseDynamic
from the conflict manager firing during an action) do not interfere with
action handlers waiting on a specific register's acknowledgement.

0x177E (Dynamic) is intentionally absent from WriteRegister: writing it
to 0 silences ChargePower telemetry even though the charger keeps running.
cf. evcc charger/trydan.go and github.com/evcc-io/evcc/issues/28047.

Étape 2 (RTU) will add TrydanModbusRtuMaster on the same interface;
no changes to plugin or register logic will be required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 13:49:11 +02:00
20 changed files with 1713 additions and 85 deletions

16
AGENTS.md Normal file
View File

@ -0,0 +1,16 @@
# AGENTS.md — etm-powersync-plugins-modbus
Plugins Modbus ETM (fork GPL) : compteurs, bornes, relais.
Contient abbb2x, abbterra, eastron, waveshare-relay-d8.
- **Licence** : GPL-3.0 (+ LGPL pour libnymea-modbus / libnymea-sunspec) · **Miroir public** : OUI
- **Agent** : aucun en beta — plugins déjà publiés, repo de maintenance/référence.
## Invariants locaux
1. NE PAS renommer les paquets PUBLIÉS : `powersync-plugin-eastron`,
`powersync-plugin-abbb2x`, `nymea-plugin-abbterra`. Casse l'upgrade des box beta.
2. `abbterra` est volontairement sous nom amont (transition upstream propre) — ne pas « corriger ».
3. Matrice validée beta : SDM72 / SDM120 (eastron), Waveshare D8.
4. Aucun composant propriétaire ici.
Carte globale et frontières : voir `../AGENTS.md`.

50
PORTING_STATUS_modbus.md Normal file
View File

@ -0,0 +1,50 @@
# PORTING_STATUS — etm-powersync-plugins-modbus
État des plugins Modbus ETM pour bornes de recharge.
**Statuts :** `partiel` · `beta` · `supporté` · `todo`
| Charger | Protocole | Statut | Origin | Plugin / Notes |
|---|---|---|---|---|
| V2C Trydan | Modbus TCP | **partiel** | etm | `v2c` — Étape 1 TCP implémentée, **non testée sur borne réelle** (192.168.1.127). Voir ci-dessous. |
| ABB Terra AC | Modbus TCP + RTU | supporté | nymea | `abbterra` (upstream nymea) |
| Eastron SDM | Modbus RTU | supporté | etm (tested) | `eastron` |
---
## V2C Trydan — détail Étape 1 (Modbus TCP)
**Sources de la carte registres (juin 2026) :**
- Lib officielle : `github.com/V2Charge/Trydan_Modbus_TCP`, `src/v2ctrydan/modbus.py`
→ décodage float32 Big/Big, FC3 2 registres par valeur, FC6 écriture uint16
- Carte officielle TCP & RTU (Google Sheet) : adresses identiques pour les deux transports
- Implémentation evcc de référence : `charger/trydan.go`
→ sémantique ChargeState 0/1/2 (A/B/C), conflit Dynamic/PauseDynamic, Lock miroir
**Spécificité firmware — à valider sur borne réelle :**
1. **MaxIntensity (0x0BD2 / 0x1782)** : des versions firmware V2C antérieures avaient un
bug sur ce registre (corrections mentionnées dans les changelogs V2C). À vérifier :
- La valeur lue en 0x0BD2 correspond-elle bien à la limite physique configurée ?
- L'écriture en 0x1782 est-elle effectivement prise en compte ?
- Documenter la version firmware minimale validée (affichée dans l'app V2C).
2. **ChargeEnergy (0x0BC4)** : evcc a retiré son `ChargeRater` pour la Trydan
(issue #28047 : énergie session peu fiable). Ce plugin expose `chargeEnergy` en
**état diagnostique uniquement** — pas câblé en `sessionEnergy`.
À valider : la valeur reset-elle correctement à chaque nouvelle session ?
3. **PauseDynamic (0x1783)** : comportement non documenté officiellement.
À valider : l'écriture 1 suspend-elle bien le PID interne sans interrompre
la remontée de ChargePower ?
4. **Chevauchement registres** : les adresses 0x0BC2..0x0BD5 sont consécutives mais
chaque valeur occupe 2 registres (float32). La lecture individuelle (une transaction
par valeur) doit être préservée — tester qu'un block read corrompt effectivement
les valeurs (pour confirmer la contrainte firmware).
**UUID plugin (V2C vendor — nouveau, non présent dans les autres plugins du repo) :**
- vendorId : `56c3e7bb-2db8-4002-b799-9e21530e6fb9`
- pluginId : `f0692725-650a-47c3-a673-172f077768f1`
**Statut beta :** NON — plugin en statut Partiel jusqu'à validation terrain.
Ne pas inclure dans la matrice beta avant test sur borne réelle.

13
debian/changelog vendored
View File

@ -1,3 +1,16 @@
etm-powersync-plugins-modbus (1.15.0+etm4) trixie; urgency=medium
* Ajout plugin V2C Trydan (borne de charge EV, Modbus TCP) : interface
evcharger complète — charge enable/disable (PauseState+Lock miroir),
consigne courant (Intensity), telemetrie (ChargePower, HousePower,
PowerFV, ChargeState IEC 61851). Gestion conflit Dynamic/PauseDynamic
sans ecriture du registre Dynamic (evite la coupure de telemetrie,
cf. evcc issue #28047). Decouverte LAN via HTTP /RealTimeData.
Transport abstrait (TCP Etape 1, RTU Etape 2 prevu).
Statut : Partiel — non teste sur borne reelle.
-- ETM-Schurig SARL <contact@etm-schurig.eu> Thu, 12 Jun 2026 12:00:00 +0200
etm-powersync-plugins-modbus (1.15.0+etm3) trixie; urgency=medium
* Ajout plugin ABB Terra AC (borne de charge, Modbus TCP/RTU) : vendoring

12
debian/control vendored
View File

@ -41,3 +41,15 @@ Depends: ${shlibs:Depends},
Description: ABB Terra AC charging station (Modbus TCP/RTU) - vendored from upstream
nymea integration plugin for ABB Terra AC wallbox over Modbus TCP or RTU.
Vendored from nymea-plugins-modbus experimental-silo, pending upstream release.
Package: powersync-plugin-v2c
Architecture: any
Section: libs
Depends: ${shlibs:Depends},
${misc:Depends},
Description: PowerSync integration plugin for V2C Trydan EV charger (Modbus TCP)
nymea integration plugin for the V2C Trydan / Trydan Pro EV charger over
Modbus TCP (port 502), for use by the ETM PowerSync home energy management
system. Implements the evcharger interface: charge enable/disable, current
setpoint, plugged-in / charging state, house and solar power telemetry.
Status: Partial — not yet validated on real hardware.

1
debian/powersync-plugin-v2c.install vendored Normal file
View File

@ -0,0 +1 @@
usr/lib/*/nymea/plugins/libnymea_integrationpluginv2c.so

View File

@ -6,7 +6,8 @@ TEMPLATE = subdirs
PLUGIN_DIRS = \
eastron \
abbb2x \
abbterra
abbterra \
v2c
message(============================================)

View File

@ -1,11 +1,22 @@
QT += network serialport serialbus
# libnymea-modbus depuis le paquet systeme (libnymea-modbus-dev), plus les sources locales
# libnymea-modbus depuis le paquet systeme (libnymea-modbus-dev) ou via NYMEA_MODBUS_PATH.
CONFIG += link_pkgconfig
PKGCONFIG += nymea-modbus
OTHER_FILES += $${MODBUS_CONNECTIONS}
# Outil de generation des connexions modbus (sdm*-registers.json -> .cpp/.h),
# fourni par libnymea-modbus-dev
include(/usr/include/nymea-modbus/modbus-tool.pri)
# Locate modbus-tool.pri: system install first, then NYMEA_MODBUS_PATH override
# (for builds against a local libnymea-modbus source tree).
# When MODBUS_CONNECTIONS is empty (e.g. v2c plugin), the tool's for-loop is a
# no-op; we still need the file for the C++ standard CONFIG lines below.
MODBUS_TOOL_PRI = $$[QT_INSTALL_PREFIX]/include/nymea-modbus/modbus-tool.pri
!isEmpty(NYMEA_MODBUS_PATH): MODBUS_TOOL_PRI = $$NYMEA_MODBUS_PATH/modbus-tool.pri
exists($$MODBUS_TOOL_PRI) {
include($$MODBUS_TOOL_PRI)
} else {
warning("modbus-tool.pri not found at $$MODBUS_TOOL_PRI — MODBUS_CONNECTIONS code-gen disabled")
greaterThan(QT_MAJOR_VERSION, 5): CONFIG *= c++17
lessThan(QT_MAJOR_VERSION, 6): CONFIG *= c++11
}

View File

@ -1,135 +1,232 @@
# Brief agent — plugin V2C Trydan
> Repo : `etm-powersync-plugins-modbus` · Dossier : `v2c/` · Branche : `feature/v2c-trydan`
> Borne : V2C Trydan / Trydan Pro (monophasée·triphasée, ES). Comm locale : **Modbus TCP (port 502)** + **HTTP REST**.
> Borne : V2C Trydan / Trydan Pro (monophasée·triphasée, ES).
> Comm locale : **Modbus TCP (port 502)** [Étape 1] + **Modbus RTU / RS485** [Étape 2] + **HTTP REST** (discovery/diag).
> Statut cible : **Partiel** (comme Keba) — PAS dans la matrice beta validée. Ne pas promettre tant que non testé sur borne réelle.
> Borne de test : IP 192.168.1.127 (Modbus TCP / LAN).
>
> **Livraison en 2 étapes** :
> - **Étape 1 — Modbus TCP** : implémentation complète + testable maintenant sur la borne réelle. C'est le périmètre de la beta.
> - **Étape 2 — Modbus RTU (RS485)** : ajout d'un second transport réutilisant le cœur. Hors beta, non bloquant pour le lab field test.
>
> **Sources de vérité croisées (juin 2026)** :
> - Lib officielle V2C : `github.com/V2Charge/Trydan_Modbus_TCP`, fichier `src/v2ctrydan/modbus.py` (carte registres + décodage float + params série RTU).
> - Carte officielle RS485 (Google Sheet) : intitulée « V2C - Datamanager Modbus **TCP & RTU** » → **une seule carte de registres pour les deux transports**.
> - Implémentation de référence evcc : `charger/trydan.go` (sémantique ChargeState, conflit Dynamic, endpoints HTTP).
---
## RÔLE
Créer le plugin nymea `v2c` dans `etm-powersync-plugins-modbus`, sur le modèle des plugins
existants du repo (abbterra pour la structure evcharger Modbus TCP, eastron pour le pattern
existants du repo (`abbterra` pour la structure evcharger Modbus TCP, `eastron` pour le pattern
registres). ThingClass `trydan` implémentant l'interface **`evcharger`** — c'est elle que le
moteur energy-etm consomme (states `chargingEnabled`, `maxChargingCurrent`, `pluggedIn`,
`charging`, `currentPower` ; actions `setChargingEnabled`, `setMaxChargingCurrent`).
## TRANSPORT — décision d'architecture
## ARCHITECTURE TRANSPORT — séparation cœur / transport (règle d'or)
**Modbus TCP = transport principal** (port 502, unit id 1). C'est le bus du repo, l'outillage
(`nymea-modbus-cli`, libnymea-modbus) existe, et le polling périodique colle au cycle du moteur.
Le point central de ce plugin : **la carte de registres, le décodage des valeurs et la
gestion du conflit Dynamic sont STRICTEMENT IDENTIQUES en TCP et en RTU** (confirmé par la
feuille officielle « TCP & RTU » : mêmes adresses, même format). Seule la couche transport change.
**HTTP = complément, deux usages** :
1. **Découverte** : la Trydan est en WiFi/LAN, pas de discovery Modbus. `GET /RealTimeData`
(JSON) sert de probe d'identification pendant le setup (NetworkDeviceDiscovery + test HTTP).
2. **Fallback diagnostic** : si un champ manque en Modbus, le compléter par HTTP est permis,
mais le chemin de contrôle (pause, intensité) reste Modbus.
→ **Le code doit isoler la logique métier du transport.** Lectures/écritures de registres,
décodage float, logique evcharger, gestion conflit : tout doit passer par une abstraction de
transport (master Modbus) sans savoir si dessous c'est du TCP ou du RTU.
Ne PAS implémenter deux ThingClasses (une Modbus, une HTTP) — une seule, transport hybride interne.
- **Étape 1** : une seule implémentation concrète de transport = **Modbus TCP** (master
libnymea-modbus TCP). Tout le reste du plugin est écrit dessus mais sans le coupler en dur.
- **Étape 2** : ajouter une implémentation **Modbus RTU** (master libnymea-modbus RTU) qui se
branche sur la même abstraction. AUCUN registre, AUCUN décodage, AUCUNE logique conflit à
réécrire — sinon c'est que l'étape 1 a mal découpé.
## CARTE REGISTRES (source : lib officielle V2Charge/Trydan_Modbus_TCP)
🔴 **Interdiction étape 1** : ne PAS écrire de logique métier qui appelle directement le client
TCP. Si l'agent se retrouve à devoir réécrire le décodage pour l'étape 2, l'étape 1 est ratée.
Lecture : holding registers, **2 registres par valeur, décodage float 32 bits big-endian
(byte ET word order Big)**. Écriture : write_register simple (uint16).
## TRANSPORT — détails par couche
### Lecture (0x0BC2…)
| Reg | Nom | Note |
### HTTP (commun aux deux étapes — discovery + diag)
1. **Découverte** : la Trydan TCP est en WiFi/LAN, pas de discovery Modbus natif.
`GET /RealTimeData` (JSON) sert de probe d'identification pendant le setup
(NetworkDeviceDiscovery + test HTTP). ⚠️ Le JSON expose le champ **`Paused`** (pas `PauseState`).
2. **Fallback diagnostic** : compléter un champ manquant par HTTP est permis, mais le chemin de
contrôle (pause, intensité) reste Modbus.
### Étape 1 — Modbus TCP
- Port **502**, unit id **1**. Master libnymea-modbus TCP. Setup par IP manuelle + discovery réseau.
### Étape 2 — Modbus RTU (RS485)
- Couche physique RS485 série. Params confirmés par la lib officielle (`modbus.py`) :
**19200 baud, 8N1 (parité N), unit id 1**.
- Pas de discovery réseau : setup = sélection du port série + unit id (modèle des plugins RTU
existants si le repo en a, sinon paramRegisters série classiques nymea).
- Réutilise intégralement le cœur de l'étape 1. Le HTTP discovery n'a PAS de sens en RTU pur
(pas d'IP) → le setup RTU est manuel.
Ne PAS implémenter plusieurs ThingClasses pour les transports — **une seule ThingClass `trydan`**,
le transport est un paramètre/variante interne.
## ⚠️ DÉCODAGE LECTURE — chaque valeur = UNE transaction (NE PAS regrouper) [commun TCP+RTU]
Source confirmée (`modbus.py`, `_read_register` + `regenera_float`) :
- Lecture : `read_holding_registers(addr, count=2, unit=1)` → **2 registres par valeur**.
- Décodage : **float 32 bits, `byteorder=Endian.Big`, `wordorder=Endian.Big`** (Big/Big).
- Les valeurs entières (ChargeState, Intensity, Dynamic…) sont encodées en float32 puis
arrondies : décoder en float, puis caster int. NE PAS supposer du uint16 brut.
🔴 **PIÈGE FIRMWARE** : valeurs à adresses **consécutives** (0x0BC2, 0x0BC3…) alors que chacune
occupe 2 registres → fenêtres **qui se chevauchent**. Bizarrerie assumée du firmware V2C (la lib
officielle lit valeur par valeur). **INTERDICTION de regrouper en block read** dans le JSON ou
le code. libnymea-modbus regroupe volontiers les registres contigus — ici ça corrompt tout.
Chaque valeur = une transaction Modbus individuelle de 2 registres. Vrai en TCP comme en RTU.
## CARTE REGISTRES (source : `modbus.py`, vérifiée juin 2026 — identique TCP/RTU)
### Lecture — holding registers (READ, base 0x0BC2)
| Reg | Nom | Type/Note |
|---|---|---|
| 0x0BC2 | ChargeState | 0=déconnecté, 1=connecté sans charge, 2=en charge (à confirmer sur borne) |
| 0x0BC3 | ChargePower | W |
| 0x0BC4 | ChargeEnergy | kWh session |
| 0x0BC5 | SlaveError | code erreur |
| 0x0BC2 | ChargeState | **0=A déconnecté, 1=B connecté sans charge, 2=C en charge** ✅ confirmé (evcc `Status()`) |
| 0x0BC3 | ChargePower | W → `currentPower` |
| 0x0BC4 | ChargeEnergy | kWh session — **NE PAS exposer en `sessionEnergy`** (voir ChargeRater ci-dessous) |
| 0x0BC5 | SlaveError | code erreur → state diag |
| 0x0BC6 | ChargeTime | s |
| 0x0BC7 | ValuePWM | duty CP |
| 0x0BC8 | HousePower | W — pince CT maison (si installée) |
| 0x0BC9 | PowerFV | W — production PV vue par la borne (si configurée) |
| 0x0BCA | PauseState | |
| 0x0BCB | Lock | |
| 0x0BCC | Program(Promgram) | |
| 0x0BC8 | HousePower | W — pince CT maison (si installée) → state diag |
| 0x0BC9 | PowerFV | W — production PV vue par la borne (si configurée) → state diag |
| 0x0BCA | PauseState | 0=actif, 1=pausé |
| 0x0BCB | Lock | 0=déverrouillé, 1=verrouillé |
| 0x0BCC | Program (Promgram) | timer interne |
| 0x0BCD | Intensity | A courante |
| 0x0BCE | Dynamic | mode dynamique V2C on/off |
| 0x0BCF | Payment | |
| 0x0BD0 | OCPP | |
| 0x0BCE | Dynamic | **mode dynamique V2C on/off — pivot du conflit, lire à CHAQUE poll** |
| 0x0BCF | Payment | hors scope |
| 0x0BD0 | OCPP | hors scope |
| 0x0BD1 | MinIntensity | A |
| 0x0BD2 | MaxIntensity | A |
| 0x0BD3 | PauseDynamic | |
| 0x0BD3 | PauseDynamic | **levier de neutralisation propre — voir CONFLIT** |
| 0x0BD4 | DynamicPowerMode | |
| 0x0BD5 | ContractedPower | W |
### Écriture (0x177A…)
### Écriture — write single register FC6, uint16 (WRITE, base 0x177A)
| Reg | Nom | Usage HEMS |
|---|---|---|
| 0x177A | PauseState | **setChargingEnabled** (pause=1 → désactivé) |
| 0x177B | Lock | verrouillage |
| 0x177C | Program | timer interne — ne pas utiliser (conflit moteur) |
| 0x177A | PauseState | **setChargingEnabled** (1=pause → désactivé, 0=actif) |
| 0x177B | Lock | **à écrire en miroir de PauseState** (voir logique enable) |
| 0x177C | Program | timer interne — **ne pas utiliser** (conflit moteur) |
| 0x177D | Intensity | **setMaxChargingCurrent** (A) |
| 0x177E | Dynamic | voir CONFLIT ci-dessous |
| 0x177E | Dynamic | **NE JAMAIS écrire 0** (voir CONFLIT — casse la télémétrie) |
| 0x177F | Payment | hors scope |
| 0x1780 | OCPP | hors scope |
| 0x1781 | MinIntensity | borne basse (6 A) |
| 0x1782 | MaxIntensity | borne haute (bug historique V2C corrigé upstream — vérifier firmware) |
| 0x1783 | PauseDynamic | voir CONFLIT |
| 0x1784 | DynamicPowerMode | voir CONFLIT |
| 0x1785 | ContractedPower | protection abonnement interne borne |
| 0x1783 | PauseDynamic | **levier de neutralisation : 1=suspend PID interne, 0=relâche** |
| 0x1784 | DynamicPowerMode | ne pas toucher sans raison |
| 0x1785 | ContractedPower | protection abonnement interne borne — ne pas toucher |
Vérifier la carte contre la feuille officielle V2C (lien dans le README du repo
V2Charge/Trydan_Modbus_TCP, gid=0) avant d'écrire les paramRegisters JSON.
Écriture confirmée : `write_register(addr, value, unit=1)` — FC6 simple, valeur uint16
(`modbus.py`, `_write_register`). Pas de write float en écriture, contrairement aux lectures.
## ⚠️ CONFLIT D'OPTIMISEURS — le piège central de cette borne
## 🔴 CONFLIT D'OPTIMISEURS — le piège central (commun TCP+RTU)
La Trydan embarque SON PROPRE pilotage solaire (« Dynamic », pince CT + lecture onduleur,
PID interne). Deux cerveaux ne doivent pas piloter la même borne :
La Trydan embarque SON PROPRE pilotage solaire (« Dynamic » : pince CT + lecture onduleur,
PID interne). Deux cerveaux ne doivent pas piloter la même borne — MAIS la neutralisation
naïve (écrire Dynamic=0) est **un bug** :
- **Dynamic V2C ACTIF (0x0BCE=1)** → la borne s'autopilote au surplus ; toute écriture
Intensity du moteur sera combattue par le PID interne. Oscillations garanties.
- **Mode HEMS (le nôtre)** → exiger Dynamic=0 ; le moteur energy-etm décide, la borne obéit.
> **Leçon evcc (commentaire explicite dans `trydan.go`)** : si on désactive `Dynamic`,
> **la borne arrête de remonter les lectures de puissance**. On perd `currentPower`.
> Donc on NE touche JAMAIS au registre Dynamic (0x177E).
Comportement du plugin :
1. À l'init, LIRE 0x0BCE. Si Dynamic=1, exposer un state `conflictDetected` (ou équivalent)
et logger un avertissement explicite — ne PAS désactiver silencieusement le mode du client.
2. Setting de Thing `disableInternalOptimizer` (défaut: demander) : si l'utilisateur accepte,
écrire Dynamic=0 + PauseDynamic/DynamicPowerMode cohérents.
3. `decisionReason` côté moteur doit pouvoir refléter « borne en autopilotage V2C — pilotage
HEMS suspendu » si Dynamic revient à 1 (l'app V2C du client peut le réactiver à tout moment
→ re-lire à chaque poll, pas seulement à l'init).
**Stratégie correcte — suspendre via PauseDynamic, pas désactiver Dynamic** :
C'est le même problème que la PV-Edition Keba : une borne « intelligente » à neutraliser
proprement pour que l'arbitrage central reste l'unique décideur.
1. À chaque poll, LIRE `Dynamic` (0x0BCE). État que l'app V2C du client peut changer à tout
moment → re-lecture systématique, pas seulement à l'init.
2. Si `Dynamic == 1` (optimiseur interne présent) :
- HEMS **prend** le contrôle (charge activée) → écrire `PauseDynamic = 1` (0x1783).
Suspend le PID interne SANS couper la télémétrie.
- HEMS **relâche** (charge désactivée) → écrire `PauseDynamic = 0`.
- N'écrire `PauseDynamic` **que si `Dynamic == 1`** a été lu (sinon sans objet).
3. Si `Dynamic == 0` : pas de PID interne, rien à suspendre. Pilotage HEMS direct.
4. Exposer un state `conflictDetected` / `internalOptimizerActive` reflétant `Dynamic == 1`,
et logger un avertissement clair. NE PAS modifier silencieusement le mode du client.
5. Setting de Thing `suspendInternalOptimizer` (défaut: oui, demander à la première détection) :
contrôle si le plugin a le droit d'écrire PauseDynamic. Si refus, avertir que l'arbitrage
sera combattu par le PID (oscillations).
6. `decisionReason` côté moteur doit pouvoir refléter « borne en autopilotage V2C — pilotage
HEMS suspendu » si Dynamic==1 et suspension refusée/échouée.
## SÉCURITÉ / ROBUSTESSE
**Logique `chargingEnabled` (state + action) — Lock inclus** (source evcc `Enabled()`/`Enable()`) :
- `chargingEnabled = (PauseState == 0) ET (Lock == 0)`.
- `setChargingEnabled(false)` → écrire `PauseState=1` ET `Lock=1`.
- `setChargingEnabled(true)` → écrire `PauseState=0` ET `Lock=0`, puis gérer PauseDynamic
selon Dynamic comme ci-dessus.
- Anti-flapping : respecter les verrous du moteur (chargingEnabledLockDuration etc.) — le
plugin n'introduit PAS sa propre cadence d'écriture ; il applique ce que le moteur envoie.
- Connexion Modbus perdue → states `connected=false`, pas de retry agressif (backoff).
- Écritures idempotentes : ne réécrire Intensity que si la valeur cible change réellement
(la borne journalise chaque write ; éviter l'usure flash et le spam).
- Min 6 A (IEC 61851). Clamper toute consigne < 6 A à pause plutôt qu'à une valeur illégale.
- Float decode : 2 registres, Big/Big. NE PAS supposer du uint16 sur les lectures.
Même problème que la PV-Edition Keba : borne « intelligente » à neutraliser proprement — mais
ici via PauseDynamic, pas une coupure brutale.
## INTERDIT
## ⚠️ SESSION ENERGY — ne pas l'exposer (pour l'instant)
- Toucher aux plugins publiés du repo (eastron, abbb2x, abbterra, waveshare-relay-d8).
evcc a **retiré** son interface ChargeRater pour la Trydan (cf. `github.com/evcc-io/evcc/issues/28047`)
— remontée d'énergie session jugée peu fiable côté firmware. Donc `ChargeEnergy` (0x0BC4) reste
un **state diag uniquement**, ne PAS le câbler sur `sessionEnergy` tant que non validé sur borne
réelle avec compteur de référence.
## SÉCURITÉ / ROBUSTESSE [commun]
- Anti-flapping : respecter les verrous du moteur (`chargingEnabledLockDuration` etc.) — le
plugin n'introduit PAS sa propre cadence d'écriture.
- Connexion perdue (TCP timeout OU RTU pas de réponse) → state `connected=false`, backoff,
pas de retry agressif.
- Écritures idempotentes : ne réécrire Intensity / PauseDynamic que sur transition réelle
(la borne journalise chaque write ; éviter usure flash et spam).
- Min 6 A (IEC 61851). Clamper toute consigne < 6 A à pause (PauseState=1).
- Float decode : 2 registres, Big/Big, transactions individuelles. NE PAS regrouper.
## INTERDIT [commun]
- Écrire le registre `Dynamic` (0x177E) — casse la télémétrie. Utiliser PauseDynamic.
- Regrouper les lectures en block read (chevauchement firmware).
- **Coupler la logique métier au transport TCP** (casse l'étape 2).
- Toucher aux plugins publiés du repo (`eastron`, `abbb2x`, `abbterra`, `waveshare-relay-d8`).
- Promettre la borne dans la doc/matrice beta (statut Partiel, non testé matériel).
- Dépendance à un service privé ETM — plugin 100% GPL, parle à la borne en direct.
- Implémenter OCPP/Payment/RFID — hors scope HEMS.
- Exposer `sessionEnergy` depuis ChargeEnergy sans validation matérielle.
## DEFINITION OF DONE
1. Plugin compile (amd64 + cross arm64) et s'intègre au .pro du repo.
2. ThingClass `trydan` expose l'interface `evcharger` complète + states diag
(HousePower, PowerFV, SlaveError, Dynamic/conflit).
3. Discovery réseau + setup par IP manuelle fonctionnels.
4. Gestion du conflit Dynamic implémentée (lecture à chaque poll, state, setting).
5. Testé contre simulateur Modbus (registres mockés) — le test sur borne réelle est
un jalon séparé, AVANT tout passage en « Supporté ».
6. Entrée PORTING_STATUS_modbus.md : statut Partiel, source de la carte registres notée.
### Étape 1 — Modbus TCP (périmètre beta)
1. Plugin compile (amd64 + cross arm64) et s'intègre au `.pro` du repo.
2. **Cœur métier découplé du transport** : registres/décodage/conflit passent par une
abstraction de master Modbus, implémentation concrète = TCP uniquement.
3. ThingClass `trydan` expose l'interface `evcharger` complète + states diag
(HousePower, PowerFV, SlaveError, Dynamic/conflit). PAS de sessionEnergy.
4. Discovery réseau (probe HTTP `/RealTimeData`) + setup par IP manuelle fonctionnels.
5. Gestion du conflit Dynamic **via PauseDynamic** (lecture Dynamic à chaque poll, state
`conflictDetected`, setting `suspendInternalOptimizer`). JAMAIS d'écriture Dynamic=0.
6. Logique `chargingEnabled` = PauseState==0 ET Lock==0 ; écritures en miroir.
7. Lectures en transactions individuelles de 2 registres (float Big/Big), pas de block read.
8. Testé contre simulateur Modbus TCP (registres mockés). Test sur borne réelle (192.168.1.127)
= jalon séparé, AVANT tout passage en « Supporté ».
9. Entrée `PORTING_STATUS_modbus.md` : statut Partiel, sources notées, firmware à documenter.
### Étape 2 — Modbus RTU (RS485, hors beta)
1. Ajout d'une implémentation de transport **RTU** sur la même abstraction — **zéro** réécriture
du cœur (registres, décodage, conflit, logique evcharger).
2. Setup RTU : sélection port série + unit id ; params **19200 8N1, unit 1** (confirmés lib V2C).
3. Pas de discovery HTTP en RTU (setup manuel).
4. Testé contre simulateur Modbus RTU (mocké). Test sur borne réelle via RS485 = jalon séparé.
5. `PORTING_STATUS_modbus.md` mis à jour : RTU ajouté, statut Partiel, params série notés.
## RÉFÉRENCES
- Lib officielle : github.com/V2Charge/Trydan_Modbus_TCP (carte registres + décodage float)
- API HTTP : feuille V2C (gid=1147522182) + github.com/V2Charge/API-doc-v2c — à vérifier,
endpoints probables /RealTimeData (lecture JSON) et /write/<param>=<val>
- Dans le repo : abbterra (structure evcharger Modbus TCP), PORTING_STATUS_modbus.md
- Firmware : les updates V2C mentionnent des fixes Modbus (Max/MinIntensity) — documenter
la version firmware minimale constatée lors du test réel.
- Lib officielle : `github.com/V2Charge/Trydan_Modbus_TCP` → `src/v2ctrydan/modbus.py`
(carte registres + décodage float Big/Big + FC6 écriture + params série RTU 19200 8N1).
- Carte officielle RS485/TCP (Google Sheet) : gid=0 (Modbus, « TCP & RTU ») et gid=1147522182 (HTTP).
- Implémentation evcc de référence : `charger/trydan.go` (ChargeState 0/1/2 = A/B/C,
conflit Dynamic via PauseDynamic, Lock en miroir, ChargeRater retiré issue #28047).
- API HTTP : endpoints `/RealTimeData` (lecture JSON, champ `Paused`) et `/write/<Param>=<val>`
(réponse texte `"OK"`) — confirmés via evcc.
- Dans le repo : `abbterra` (structure evcharger Modbus TCP), `PORTING_STATUS_modbus.md`,
+ tout plugin RTU existant comme modèle de couche série pour l'étape 2.
- Firmware : updates V2C mentionnent des fixes Modbus (Max/MinIntensity) — documenter la
version firmware minimale constatée lors du test réel sur 192.168.1.127.

View File

@ -0,0 +1,330 @@
// 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>
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<quint16>(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<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 ---
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<double>(master->chargePower()));
thing->setStateValue(trydanHousePowerStateTypeId, static_cast<double>(master->housePower()));
thing->setStateValue(trydanSolarPowerStateTypeId, static_cast<double>(master->powerFV()));
thing->setStateValue(trydanSlaveErrorStateTypeId, static_cast<uint>(master->slaveError()));
thing->setStateValue(trydanChargeEnergyStateTypeId, static_cast<double>(master->chargeEnergy()));
thing->setStateValue(trydanIntensityStateTypeId, static_cast<double>(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<quint16>(desired));
}
}
void IntegrationPluginV2c::setDisconnectedState(Thing *thing)
{
thing->setStateValue(trydanConnectedStateTypeId, false);
thing->setStateValue(trydanChargingStateTypeId, false);
thing->setStateValue(trydanPluggedInStateTypeId, false);
thing->setStateValue(trydanCurrentPowerStateTypeId, 0.0);
}

View File

@ -0,0 +1,68 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#ifndef INTEGRATIONPLUGINV2C_H
#define INTEGRATIONPLUGINV2C_H
#include <plugintimer.h>
#include <integrations/integrationplugin.h>
#include <network/networkdevicemonitor.h>
#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<Thing *, TrydanModbusTcpMaster *> m_tcpMasters;
QHash<Thing *, NetworkDeviceMonitor *> m_monitors;
};
#endif // INTEGRATIONPLUGINV2C_H

View File

@ -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": []
}
]
}
]
}

12
v2c/meta.json Normal file
View File

@ -0,0 +1,12 @@
{
"title": "V2C Trydan",
"tagline": "Connect V2C Trydan EV chargers over Modbus TCP.",
"stability": "consumer",
"offline": true,
"technologies": [
"network"
],
"categories": [
"energy"
]
}

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE TS>
<TS version="2.1" language="en_US">
<context>
<name>IntegrationPluginV2c</name>
<message>
<source>Could not register network monitor for this charger.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Could not communicate with the V2C Trydan charger.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>The V2C Trydan charger is not reachable.</source>
<translation type="unfinished"></translation>
</message>
</context>
</TS>

View File

@ -0,0 +1,32 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "trydanmodbusmaster.h"
#include <cstring>
TrydanModbusMaster::TrydanModbusMaster(QObject *parent)
: QObject(parent)
{}
float TrydanModbusMaster::decodeFloat32BB(quint16 high, quint16 low)
{
// High word is the most-significant 16 bits (Big word order).
// Within each word bytes are already in big-endian order on the wire.
// This mirrors pymodbus BinaryPayloadDecoder(byteorder=Endian.Big, wordorder=Endian.Big).
quint32 raw = (static_cast<quint32>(high) << 16) | static_cast<quint32>(low);
float result;
std::memcpy(&result, &raw, sizeof(float));
return result;
}
int TrydanModbusMaster::decodeIntFromFloat32(quint16 high, quint16 low)
{
return static_cast<int>(qRound(decodeFloat32BB(high, low)));
}
void TrydanModbusMaster::setReachable(bool reachable)
{
if (m_reachable == reachable)
return;
m_reachable = reachable;
emit reachableChanged(reachable);
}

211
v2c/trydanmodbusmaster.h Normal file
View File

@ -0,0 +1,211 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#ifndef TRYDANMODBUSMASTER_H
#define TRYDANMODBUSMASTER_H
#include <QObject>
#include <cstring>
/*!
* \brief Abstract transport interface for the V2C Trydan Modbus connection.
*
* Carries all register addresses, float-decode helpers, and the last-polled
* values. Concrete subclasses provide the physical transport (Modbus TCP for
* Étape 1, Modbus RTU for Étape 2) without duplicating any of this logic.
*
* \b Register-map rules (cf. V2C Trydan_Modbus_TCP, modbus.py):
* - Every value occupies \b two holding registers encoded as IEEE-754 float32
* with big-endian byte order and big-endian word order (high word first).
* - Integer values (ChargeState, Intensity, Dynamic …) are still sent as
* float32; they must be decoded then rounded — never read as raw uint16.
* - \b No block read: consecutive addresses (0x0BC2, 0x0BC3 …) overlap their
* 2-register windows. Each value must be fetched in its own FC3 transaction.
* - Writes use FC6 (single register, uint16 direct value — NOT float).
*/
class TrydanModbusMaster : public QObject
{
Q_OBJECT
public:
/*!
* \brief Holding-register read addresses (FC3, 2 registers each, float32 Big/Big).
*
* Source: modbus.py lines _read_register + regenera_float (V2C lib).
* The intentional overlap (0x0BC2 covers regs 0x0BC2..0x0BC3, 0x0BC3
* covers 0x0BC3..0x0BC4 …) is why block reads are forbidden.
*/
enum ReadRegister : quint16 {
RegChargeState = 0x0BC2, ///< 0=A(disconnected) 1=B(connected) 2=C(charging)
RegChargePower = 0x0BC3, ///< W
RegChargeEnergy = 0x0BC4, ///< kWh session — diagnostic only, NOT sessionEnergy
RegSlaveError = 0x0BC5, ///< firmware error code
RegHousePower = 0x0BC8, ///< W, CT clamp (if installed)
RegPowerFV = 0x0BC9, ///< W, PV production seen by charger (if configured)
RegPauseState = 0x0BCA, ///< 0=active 1=paused
RegLock = 0x0BCB, ///< 0=unlocked 1=locked
RegIntensity = 0x0BCD, ///< A, active charge current
RegDynamic = 0x0BCE, ///< 0=off 1=internal optimizer running — re-read every poll
RegMinIntensity = 0x0BD1, ///< A, lower bound
RegMaxIntensity = 0x0BD2, ///< A, upper bound (firmware-version–dependent, verify on hardware)
RegPauseDynamic = 0x0BD3, ///< 0=optimizer runs 1=optimizer suspended by HEMS
};
/*!
* \brief Write-register addresses (FC6, uint16 raw value — not float).
*
* \warning 0x177E (Dynamic) is \b intentionally absent: writing it to 0
* disables power telemetry (ChargePower goes silent) even though the charger
* keeps operating. Use PauseDynamic (0x1783) to suppress the internal PID
* instead. cf. evcc charger/trydan.go and github.com/evcc-io/evcc/issues/28047.
*/
enum WriteRegister : quint16 {
WRegPauseState = 0x177A, ///< setChargingEnabled: 1=pause, 0=active
WRegLock = 0x177B, ///< mirror of PauseState (always written together)
WRegIntensity = 0x177D, ///< setMaxChargingCurrent (integer amperes)
WRegPauseDynamic = 0x1783, ///< 1=suspend internal PID, 0=release
};
// --- Last values from the most recent update() cycle ---
/*! \brief ChargeState: 0=A, 1=B, 2=C (IEC 61851) */
int chargeState() const { return m_chargeState; }
/*! \brief ChargePower in watts */
float chargePower() const { return m_chargePower; }
/*! \brief Session energy in kWh (diagnostic only — firmware reliability unvalidated) */
float chargeEnergy() const { return m_chargeEnergy; }
/*! \brief Firmware error code */
int slaveError() const { return m_slaveError; }
/*! \brief House CT-clamp power in watts */
float housePower() const { return m_housePower; }
/*! \brief PV production seen by the charger, in watts */
float powerFV() const { return m_powerFV; }
/*! \brief PauseState register: 0=active, 1=paused */
int pauseState() const { return m_pauseState; }
/*! \brief Lock register: 0=unlocked, 1=locked */
int lock() const { return m_lock; }
/*! \brief Current charge current in amperes */
int intensity() const { return m_intensity; }
/*! \brief Dynamic register: 0=no internal optimizer, 1=optimizer active */
int dynamicMode() const { return m_dynamic; }
/*! \brief Configured minimum intensity (init-time, usually 6 A) */
int minIntensity() const { return m_minIntensity; }
/*! \brief Configured maximum intensity (init-time, firmware-version–dependent) */
int maxIntensity() const { return m_maxIntensity; }
/*! \brief PauseDynamic: 0=optimizer runs, 1=HEMS has suspended it */
int pauseDynamic() const { return m_pauseDynamic; }
bool reachable() const { return m_reachable; }
// --- Transport operations (pure virtual) ---
/*! \brief Establish the physical connection to the charger. */
virtual void connectDevice() = 0;
/*! \brief Drop the physical connection. */
virtual void disconnectDevice() = 0;
/*!
* \brief Read init-time registers (MinIntensity, MaxIntensity).
* \return false if a read is already in progress.
*/
virtual bool initialize() = 0;
/*!
* \brief Read all poll-cycle registers sequentially.
* Emits updateFinished() on completion (success or error).
* \return false if a poll is already in progress.
*/
virtual bool update() = 0;
/*!
* \brief Write PauseState register (FC6).
* Always pair with writeLock(); the two must stay consistent.
* cf. evcc trydan.go Enable().
* \param value 0=active 1=paused
*/
virtual void writePauseState(quint16 value) = 0;
/*!
* \brief Write Lock register (FC6).
* \param value 0=unlocked 1=locked
*/
virtual void writeLock(quint16 value) = 0;
/*!
* \brief Write Intensity register (FC6, integer amperes).
* Caller must clamp to [minIntensity(), maxIntensity()] before calling.
*/
virtual void writeIntensity(quint16 amps) = 0;
/*!
* \brief Write PauseDynamic register (FC6).
* \param value 1=suspend internal optimizer PID, 0=release
*/
virtual void writePauseDynamic(quint16 value) = 0;
signals:
/*! \brief Emitted when the transport-level connection state changes. */
void reachableChanged(bool reachable);
/*!
* \brief Emitted once initialize() finishes (success or failure).
* On success the init-time registers (MinIntensity, MaxIntensity) are valid.
*/
void initializationFinished(bool success);
/*!
* \brief Emitted once each update() cycle completes.
* All accessor methods reflect the newly read values when this fires.
*/
void updateFinished();
/*!
* \brief Emitted after each write operation completes.
* \param address The write register address (WRegPauseState, WRegLock, …).
* Action handlers must filter by address to avoid processing
* background writes (e.g. PauseDynamic from conflict management)
* as if they were the writes triggered by the action itself.
*/
void writeCompleted(quint16 address, bool success);
protected:
explicit TrydanModbusMaster(QObject *parent = nullptr);
/*!
* \brief Decode two holding-register words into a float32 (Big/Big).
*
* The V2C Trydan uses big-endian byte order AND big-endian word order:
* the high 16-bit word arrives first on the wire, forming the most-significant
* half of the 32-bit pattern. This matches pymodbus BinaryPayloadDecoder
* with byteorder=Endian.Big, wordorder=Endian.Big.
* cf. modbus.py regenera_float()
*
* \param high First register received (most-significant 16 bits of float32)
* \param low Second register received (least-significant 16 bits)
*/
static float decodeFloat32BB(quint16 high, quint16 low);
/*!
* \brief Decode an integer value stored as float32 Big/Big.
*
* All "integer" registers (ChargeState, Intensity, Dynamic …) are still
* encoded as float32. Round to nearest int after decode.
* cf. modbus.py: int(round(regenera_float(regs)))
*/
static int decodeIntFromFloat32(quint16 high, quint16 low);
/*!
* \brief Update reachability and emit reachableChanged() on transition.
*/
void setReachable(bool reachable);
// Cached register values (written by concrete subclasses during polls)
int m_chargeState = 0;
float m_chargePower = 0.0f;
float m_chargeEnergy = 0.0f;
int m_slaveError = 0;
float m_housePower = 0.0f;
float m_powerFV = 0.0f;
int m_pauseState = 0;
int m_lock = 0;
int m_intensity = 0;
int m_dynamic = 0;
int m_minIntensity = 6;
int m_maxIntensity = 32;
int m_pauseDynamic = 0;
bool m_reachable = false;
};
#endif // TRYDANMODBUSMASTER_H

View File

@ -0,0 +1,267 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "trydanmodbustcpmaster.h"
#include <modbustcpmaster.h>
#include <QtSerialBus>
#include "extern-plugininfo.h"
TrydanModbusTcpMaster::TrydanModbusTcpMaster(const QHostAddress &address,
quint16 port,
quint16 slaveId,
QObject *parent)
: TrydanModbusMaster(parent),
m_slaveId(slaveId)
{
m_modbusTcpMaster = new ModbusTcpMaster(address, port, this);
m_modbusTcpMaster->setNumberOfRetries(1);
m_modbusTcpMaster->setTimeout(2000);
connect(m_modbusTcpMaster, &ModbusTcpMaster::connectionStateChanged,
this, &TrydanModbusTcpMaster::onConnectionStateChanged);
}
TrydanModbusTcpMaster::~TrydanModbusTcpMaster()
{
// m_modbusTcpMaster is a child QObject and will be deleted automatically.
}
ModbusTcpMaster *TrydanModbusTcpMaster::modbusTcpMaster() const
{
return m_modbusTcpMaster;
}
void TrydanModbusTcpMaster::setHostAddress(const QHostAddress &address)
{
m_modbusTcpMaster->setHostAddress(address);
}
void TrydanModbusTcpMaster::connectDevice()
{
m_modbusTcpMaster->connectDevice();
}
void TrydanModbusTcpMaster::disconnectDevice()
{
m_modbusTcpMaster->disconnectDevice();
setReachable(false);
}
bool TrydanModbusTcpMaster::initialize()
{
if (!m_modbusTcpMaster->connected() || m_initializing)
return false;
m_initializing = true;
runReadSequence(buildInitSteps(), [this](bool ok) {
m_initializing = false;
if (ok) {
// qBound to IEC 61851 absolute limits [6, 32]. The real installation
// ceiling comes from MaxIntensity read above (a 16 A mono Trydan reports
// 16, not 32). The lower guard (qMax 6 was wrong for aberrant 0 returns:
// qMin(32, 0)=0 → maxIntensity()==0 → every setpoint gets clamped to 0
// and then the <6 A path pauses charging silently). qBound floors to 6
// on a spurious 0 and caps to 32 on a spurious 65535.
m_minIntensity = qBound(6, m_minIntensity, 32);
m_maxIntensity = qBound(6, m_maxIntensity, 32);
}
emit initializationFinished(ok);
});
return true;
}
bool TrydanModbusTcpMaster::update()
{
if (!m_modbusTcpMaster->connected() || m_updating)
return false;
m_updating = true;
runReadSequence(buildPollSteps(), [this](bool ok) {
m_updating = false;
if (ok) {
m_errorCount = 0;
setReachable(true);
emit updateFinished();
} else {
handleError();
}
});
return true;
}
void TrydanModbusTcpMaster::writePauseState(quint16 value)
{
writeFC6(WRegPauseState, value,
[this](quint16 addr, bool ok) { emit writeCompleted(addr, ok); });
}
void TrydanModbusTcpMaster::writeLock(quint16 value)
{
writeFC6(WRegLock, value,
[this](quint16 addr, bool ok) { emit writeCompleted(addr, ok); });
}
void TrydanModbusTcpMaster::writeIntensity(quint16 amps)
{
writeFC6(WRegIntensity, amps,
[this](quint16 addr, bool ok) { emit writeCompleted(addr, ok); });
}
void TrydanModbusTcpMaster::writePauseDynamic(quint16 value)
{
// PauseDynamic is also emitted via writeCompleted so the plugin can update
// the optimizerSuspended state if needed; action handlers must filter by address.
writeFC6(WRegPauseDynamic, value,
[this](quint16 addr, bool ok) { emit writeCompleted(addr, ok); });
}
// --- Private implementation ---
QList<TrydanModbusTcpMaster::ReadStep> TrydanModbusTcpMaster::buildInitSteps()
{
return {
{ RegMinIntensity, [this](bool ok, quint16 h, quint16 l) {
if (ok) m_minIntensity = decodeIntFromFloat32(h, l);
}},
{ RegMaxIntensity, [this](bool ok, quint16 h, quint16 l) {
if (ok) m_maxIntensity = decodeIntFromFloat32(h, l);
}},
};
}
QList<TrydanModbusTcpMaster::ReadStep> TrydanModbusTcpMaster::buildPollSteps()
{
// Each register pair is a separate FC3 transaction (2 registers, float32 Big/Big).
// Order matters: Dynamic must always be read (last) to reflect the most current
// state before the plugin applies the conflict-management logic.
return {
{ RegChargeState, [this](bool ok, quint16 h, quint16 l) {
if (ok) m_chargeState = decodeIntFromFloat32(h, l);
}},
{ RegChargePower, [this](bool ok, quint16 h, quint16 l) {
if (ok) m_chargePower = decodeFloat32BB(h, l);
}},
{ RegChargeEnergy, [this](bool ok, quint16 h, quint16 l) {
if (ok) m_chargeEnergy = decodeFloat32BB(h, l);
}},
{ RegSlaveError, [this](bool ok, quint16 h, quint16 l) {
if (ok) m_slaveError = decodeIntFromFloat32(h, l);
}},
{ RegHousePower, [this](bool ok, quint16 h, quint16 l) {
if (ok) m_housePower = decodeFloat32BB(h, l);
}},
{ RegPowerFV, [this](bool ok, quint16 h, quint16 l) {
if (ok) m_powerFV = decodeFloat32BB(h, l);
}},
{ RegPauseState, [this](bool ok, quint16 h, quint16 l) {
if (ok) m_pauseState = decodeIntFromFloat32(h, l);
}},
{ RegLock, [this](bool ok, quint16 h, quint16 l) {
if (ok) m_lock = decodeIntFromFloat32(h, l);
}},
{ RegIntensity, [this](bool ok, quint16 h, quint16 l) {
if (ok) m_intensity = decodeIntFromFloat32(h, l);
}},
{ RegDynamic, [this](bool ok, quint16 h, quint16 l) {
// Critical: must be read every poll — the V2C app can toggle this at any time.
if (ok) m_dynamic = decodeIntFromFloat32(h, l);
}},
{ RegPauseDynamic, [this](bool ok, quint16 h, quint16 l) {
if (ok) m_pauseDynamic = decodeIntFromFloat32(h, l);
}},
};
}
void TrydanModbusTcpMaster::runReadSequence(QList<ReadStep> steps,
std::function<void(bool)> onDone)
{
doNextRead(std::move(steps), 0, std::move(onDone));
}
void TrydanModbusTcpMaster::doNextRead(QList<ReadStep> steps,
int index,
std::function<void(bool)> onDone)
{
if (index >= steps.count()) {
onDone(true);
return;
}
const quint16 address = steps.at(index).first;
const ReadCallback callback = steps.at(index).second;
// Individual FC3 read of exactly 2 registers (one float32 value).
// Never read a range spanning multiple values — overlapping windows would
// return garbage for addresses beyond the first. cf. modbus.py _read_register.
QModbusReply *reply = m_modbusTcpMaster->sendReadRequest(
QModbusDataUnit(QModbusDataUnit::HoldingRegisters, address, 2),
static_cast<int>(m_slaveId));
if (!reply) {
qCWarning(dcV2C()) << "TrydanModbusTcpMaster: sendReadRequest returned null for reg" << Qt::hex << address;
onDone(false);
return;
}
connect(reply, &QModbusReply::finished, this,
[this, reply, steps, index, callback, onDone]() mutable {
reply->deleteLater();
if (reply->error() != QModbusDevice::NoError) {
qCWarning(dcV2C()) << "TrydanModbusTcpMaster: read error for reg"
<< Qt::hex << steps.at(index).first
<< ":" << reply->errorString();
callback(false, 0, 0);
onDone(false);
return;
}
const auto values = reply->result().values();
callback(true, values.value(0), values.value(1));
doNextRead(std::move(steps), index + 1, std::move(onDone));
});
}
void TrydanModbusTcpMaster::writeFC6(quint16 address, quint16 value,
std::function<void(quint16, bool)> callback)
{
// FC6 = Write Single Register; QModbusTcpClient uses FC6 for single-word writes.
QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address, 1);
unit.setValue(0, value);
QModbusReply *reply = m_modbusTcpMaster->sendWriteRequest(unit, static_cast<int>(m_slaveId));
if (!reply) {
qCWarning(dcV2C()) << "TrydanModbusTcpMaster: sendWriteRequest returned null for reg" << Qt::hex << address;
callback(address, false);
return;
}
connect(reply, &QModbusReply::finished, this, [reply, address, callback]() {
reply->deleteLater();
callback(address, reply->error() == QModbusDevice::NoError);
});
}
void TrydanModbusTcpMaster::onConnectionStateChanged(bool connected)
{
if (connected) {
m_errorCount = 0;
// Signal reachability so the plugin calls initialize().
setReachable(true);
} else {
m_updating = false;
m_initializing = false;
setReachable(false);
}
}
void TrydanModbusTcpMaster::handleError()
{
m_errorCount++;
if (m_errorCount >= k_errorLimit) {
qCWarning(dcV2C()) << "TrydanModbusTcpMaster: error limit reached, marking unreachable";
setReachable(false);
}
}

107
v2c/trydanmodbustcpmaster.h Normal file
View File

@ -0,0 +1,107 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#ifndef TRYDANMODBUSTCPMASTER_H
#define TRYDANMODBUSTCPMASTER_H
#include "trydanmodbusmaster.h"
#include <QHostAddress>
#include <QList>
#include <QPair>
#include <functional>
class ModbusTcpMaster;
class QModbusReply;
/*!
* \brief Modbus TCP transport for the V2C Trydan (Étape 1).
*
* Wraps a libnymea-modbus ModbusTcpMaster. All register reads are individual
* FC3 transactions of exactly 2 registers — never batched — to avoid the
* overlapping-window firmware bug of the V2C Trydan.
* cf. modbus.py _read_register and AGENTS.md § Décodage lecture.
*
* Writes use FC6 (single-register, uint16, via ModbusTcpMaster::sendWriteRequest).
*
* Étape 2 (RTU) will add TrydanModbusRtuMaster on the same TrydanModbusMaster
* interface without touching this file.
*/
class TrydanModbusTcpMaster : public TrydanModbusMaster
{
Q_OBJECT
public:
/*!
* \param address IP address of the charger
* \param port Modbus TCP port (default 502)
* \param slaveId Modbus unit ID (always 1 for Trydan)
*/
explicit TrydanModbusTcpMaster(const QHostAddress &address,
quint16 port,
quint16 slaveId,
QObject *parent = nullptr);
~TrydanModbusTcpMaster() override;
/*! \brief Underlying TCP master — exposed so the plugin can update the host address. */
ModbusTcpMaster *modbusTcpMaster() const;
/*! \brief Update the target IP without reconnecting (call reconnectDevice() separately). */
void setHostAddress(const QHostAddress &address);
void connectDevice() override;
void disconnectDevice() override;
bool initialize() override;
bool update() override;
void writePauseState(quint16 value) override;
void writeLock(quint16 value) override;
void writeIntensity(quint16 amps) override;
void writePauseDynamic(quint16 value) override;
private:
// Callback type for a single 2-register read result
using ReadCallback = std::function<void(bool ok, quint16 high, quint16 low)>;
using ReadStep = QPair<quint16, ReadCallback>;
/*!
* \brief Execute \a steps sequentially, one FC3 read per register pair.
*
* The sequence aborts on the first failed read; \a onDone receives false in
* that case and the error counter is incremented. On full success \a onDone
* receives true and the error counter is reset.
*
* Sequential execution is required because concurrent reads from the same
* slave on a Modbus TCP connection would not be incorrect per protocol, but
* the firmware's overlapping register windows make ordering critical.
*/
void runReadSequence(QList<ReadStep> steps, std::function<void(bool)> onDone);
void doNextRead(QList<ReadStep> steps, int index, std::function<void(bool)> onDone);
/*!
* \brief Send an FC6 (Write Single Register) request and invoke \a callback.
* FC6 is used for all writes per the V2C spec (modbus.py _write_register).
* \param callback Receives (address, success) so callers can emit writeCompleted
* with the originating register address, enabling action handlers
* to distinguish expected writes from background writes.
*/
void writeFC6(quint16 address, quint16 value,
std::function<void(quint16 addr, bool ok)> callback);
void onConnectionStateChanged(bool connected);
/*! \brief Increment the error counter and mark unreachable once it hits the limit. */
void handleError();
QList<ReadStep> buildInitSteps();
QList<ReadStep> buildPollSteps();
ModbusTcpMaster *m_modbusTcpMaster = nullptr;
quint16 m_slaveId = 1;
int m_errorCount = 0;
bool m_updating = false;
bool m_initializing = false;
static constexpr int k_errorLimit = 5;
};
#endif // TRYDANMODBUSTCPMASTER_H

23
v2c/v2c.pro Normal file
View File

@ -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

110
v2c/v2ctcpdiscovery.cpp Normal file
View File

@ -0,0 +1,110 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "v2ctcpdiscovery.h"
#include "extern-plugininfo.h"
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QJsonDocument>
#include <QJsonObject>
#include <QTimer>
#include <QTime>
#include <QUrl>
V2cTcpDiscovery::V2cTcpDiscovery(NetworkDeviceDiscovery *networkDeviceDiscovery,
QObject *parent)
: QObject(parent),
m_networkDeviceDiscovery(networkDeviceDiscovery)
{}
void V2cTcpDiscovery::startDiscovery()
{
qCInfo(dcV2C()) << "Discovery: searching for V2C Trydan chargers on the network...";
m_startTime = QDateTime::currentDateTime();
m_pendingProbes = 0;
m_scanFinished = false;
m_results.clear();
m_networkDeviceInfos.clear();
NetworkDeviceDiscoveryReply *reply = m_networkDeviceDiscovery->discover();
connect(reply, &NetworkDeviceDiscoveryReply::hostAddressDiscovered,
this, &V2cTcpDiscovery::probeAddress);
connect(reply, &NetworkDeviceDiscoveryReply::finished,
reply, &NetworkDeviceDiscoveryReply::deleteLater);
connect(reply, &NetworkDeviceDiscoveryReply::finished, this, [this, reply]() {
m_networkDeviceInfos = reply->networkDeviceInfos();
m_scanFinished = true;
// Give outstanding HTTP probes 3 extra seconds to complete.
QTimer::singleShot(3000, this, &V2cTcpDiscovery::finishDiscovery);
});
}
QList<V2cTcpDiscovery::Result> V2cTcpDiscovery::results() const
{
return m_results;
}
void V2cTcpDiscovery::probeAddress(const QHostAddress &address)
{
// The V2C Trydan HTTP API: GET /RealTimeData returns JSON with a "Paused"
// field (and ChargeState, ChargePower, etc.). The presence of "Paused"
// is a reliable Trydan fingerprint; it does not appear on generic devices.
// cf. evcc charger/trydan.go realTimeData struct and AGENTS.md § HTTP.
QUrl url;
url.setScheme(QStringLiteral("http"));
url.setHost(address.toString());
url.setPath(QStringLiteral("/RealTimeData"));
QNetworkRequest request(url);
request.setTransferTimeout(3000);
m_pendingProbes++;
QNetworkReply *reply = m_nam.get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply, address]() {
reply->deleteLater();
m_pendingProbes--;
if (reply->error() == QNetworkReply::NoError) {
const QByteArray data = reply->readAll();
const QJsonObject json = QJsonDocument::fromJson(data).object();
// "Paused" is the HTTP-API name for PauseState — note it differs from
// the Modbus register name (cf. AGENTS.md § HTTP).
if (json.contains(QStringLiteral("Paused"))) {
Result result;
result.ipAddress = address.toString();
result.networkDeviceInfo = m_networkDeviceInfos.get(address);
if (result.networkDeviceInfo.address().isNull()) {
NetworkDeviceInfo info;
info.setAddress(address);
result.networkDeviceInfo = info;
}
qCDebug(dcV2C()) << "Discovery: found V2C Trydan at" << address.toString();
m_results.append(result);
}
}
// Finish early if the scan is already done and this was the last probe.
if (m_scanFinished && m_pendingProbes == 0)
finishDiscovery();
});
}
void V2cTcpDiscovery::finishDiscovery()
{
// Guard against double-emission (can be called by the timer and the last probe).
if (m_finished)
return;
m_finished = true;
const qint64 ms = QDateTime::currentMSecsSinceEpoch() - m_startTime.toMSecsSinceEpoch();
qCInfo(dcV2C()) << "Discovery: finished in"
<< QTime::fromMSecsSinceStartOfDay(static_cast<int>(ms)).toString("mm:ss.zzz")
<< "with" << m_results.count() << "V2C Trydan(s) found.";
emit discoveryFinished();
}

61
v2c/v2ctcpdiscovery.h Normal file
View File

@ -0,0 +1,61 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#ifndef V2CTCPDISCOVERY_H
#define V2CTCPDISCOVERY_H
#include <QObject>
#include <QDateTime>
#include <QNetworkAccessManager>
#include <QHash>
#include <network/networkdevicediscovery.h>
/*!
* \brief Network discovery for V2C Trydan chargers (Modbus TCP).
*
* Uses nymea's NetworkDeviceDiscovery to enumerate LAN hosts, then probes
* each one with \c GET /RealTimeData (the V2C Trydan HTTP REST API) to
* confirm it is a Trydan. The probe is HTTP — not Modbus — because the
* Trydan has no network-accessible Modbus discovery mechanism.
* cf. AGENTS.md § HTTP and evcc charger/trydan.go.
*
* Manual-entry (createMethod "user") bypasses discovery entirely;
* the plugin calls setupThing() directly with the user-supplied IP.
*/
class V2cTcpDiscovery : public QObject
{
Q_OBJECT
public:
struct Result {
NetworkDeviceInfo networkDeviceInfo;
QString ipAddress;
};
explicit V2cTcpDiscovery(NetworkDeviceDiscovery *networkDeviceDiscovery,
QObject *parent = nullptr);
void startDiscovery();
/*! \brief Results available after discoveryFinished() is emitted. */
QList<Result> results() const;
signals:
void discoveryFinished();
private:
void probeAddress(const QHostAddress &address);
void finishDiscovery();
NetworkDeviceDiscovery *m_networkDeviceDiscovery = nullptr;
QNetworkAccessManager m_nam;
QDateTime m_startTime;
NetworkDeviceInfos m_networkDeviceInfos;
int m_pendingProbes = 0;
QList<Result> m_results;
bool m_scanFinished = false;
bool m_finished = false;
};
#endif // V2CTCPDISCOVERY_H