diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..f902e52
--- /dev/null
+++ b/AGENTS.md
@@ -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`.
diff --git a/PORTING_STATUS_modbus.md b/PORTING_STATUS_modbus.md
new file mode 100644
index 0000000..6acbe72
--- /dev/null
+++ b/PORTING_STATUS_modbus.md
@@ -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.
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/AGENTS.md b/v2c/AGENTS.md
index a7ebdf0..ebac5ac 100644
--- a/v2c/AGENTS.md
+++ b/v2c/AGENTS.md
@@ -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/=
-- 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/=`
+ (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.
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