É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>
233 lines
14 KiB
Markdown
233 lines
14 KiB
Markdown
# 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)** [É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
|
|
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`).
|
|
|
|
## ARCHITECTURE TRANSPORT — séparation cœur / transport (règle d'or)
|
|
|
|
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.
|
|
|
|
→ **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.
|
|
|
|
- **É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é.
|
|
|
|
🔴 **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.
|
|
|
|
## TRANSPORT — détails par couche
|
|
|
|
### 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=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) → 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 — pivot du conflit, lire à CHAQUE poll** |
|
|
| 0x0BCF | Payment | hors scope |
|
|
| 0x0BD0 | OCPP | hors scope |
|
|
| 0x0BD1 | MinIntensity | A |
|
|
| 0x0BD2 | MaxIntensity | A |
|
|
| 0x0BD3 | PauseDynamic | **levier de neutralisation propre — voir CONFLIT** |
|
|
| 0x0BD4 | DynamicPowerMode | |
|
|
| 0x0BD5 | ContractedPower | W |
|
|
|
|
### Écriture — write single register FC6, uint16 (WRITE, base 0x177A)
|
|
| Reg | Nom | Usage HEMS |
|
|
|---|---|---|
|
|
| 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 | **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 | **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 |
|
|
|
|
É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 (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 — MAIS la neutralisation
|
|
naïve (écrire Dynamic=0) est **un bug** :
|
|
|
|
> **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).
|
|
|
|
**Stratégie correcte — suspendre via PauseDynamic, pas désactiver Dynamic** :
|
|
|
|
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.
|
|
|
|
**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.
|
|
|
|
Même problème que la PV-Edition Keba : borne « intelligente » à neutraliser proprement — mais
|
|
ici via PauseDynamic, pas une coupure brutale.
|
|
|
|
## ⚠️ SESSION ENERGY — ne pas l'exposer (pour l'instant)
|
|
|
|
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
|
|
|
|
### É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` → `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.
|