É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>
14 KiB
Brief agent — plugin V2C Trydan
Repo :
etm-powersync-plugins-modbus· Dossier :v2c/· Branche :feature/v2c-trydanBorne : 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, fichiersrc/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)
- 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 champPaused(pasPauseState). - 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ésactiveDynamic, la borne arrête de remonter les lectures de puissance. On perdcurrentPower. Donc on NE touche JAMAIS au registre Dynamic (0x177E).
Stratégie correcte — suspendre via PauseDynamic, pas désactiver Dynamic :
- À chaque poll, LIRE
Dynamic(0x0BCE). État que l'app V2C du client peut changer à tout moment → re-lecture systématique, pas seulement à l'init. - 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
PauseDynamicque siDynamic == 1a été lu (sinon sans objet).
- HEMS prend le contrôle (charge activée) → écrire
- Si
Dynamic == 0: pas de PID interne, rien à suspendre. Pilotage HEMS direct. - Exposer un state
conflictDetected/internalOptimizerActivereflétantDynamic == 1, et logger un avertissement clair. NE PAS modifier silencieusement le mode du client. - 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). decisionReasoncô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)→ écrirePauseState=1ETLock=1.setChargingEnabled(true)→ écrirePauseState=0ETLock=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 (
chargingEnabledLockDurationetc.) — 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
sessionEnergydepuis ChargeEnergy sans validation matérielle.
DEFINITION OF DONE
Étape 1 — Modbus TCP (périmètre beta)
- Plugin compile (amd64 + cross arm64) et s'intègre au
.produ repo. - 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.
- ThingClass
trydanexpose l'interfaceevchargercomplète + states diag (HousePower, PowerFV, SlaveError, Dynamic/conflit). PAS de sessionEnergy. - Discovery réseau (probe HTTP
/RealTimeData) + setup par IP manuelle fonctionnels. - Gestion du conflit Dynamic via PauseDynamic (lecture Dynamic à chaque poll, state
conflictDetected, settingsuspendInternalOptimizer). JAMAIS d'écriture Dynamic=0. - Logique
chargingEnabled= PauseState==0 ET Lock==0 ; écritures en miroir. - Lectures en transactions individuelles de 2 registres (float Big/Big), pas de block read.
- 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é ».
- Entrée
PORTING_STATUS_modbus.md: statut Partiel, sources notées, firmware à documenter.
Étape 2 — Modbus RTU (RS485, hors beta)
- 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).
- Setup RTU : sélection port série + unit id ; params 19200 8N1, unit 1 (confirmés lib V2C).
- Pas de discovery HTTP en RTU (setup manuel).
- Testé contre simulateur Modbus RTU (mocké). Test sur borne réelle via RS485 = jalon séparé.
PORTING_STATUS_modbus.mdmis à 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, champPaused) 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.