# 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/=` (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.