# 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.
