Compare commits
5 Commits
8f017dbb91
...
2ac00e1330
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ac00e1330 | ||
|
|
f0be2d00a2 | ||
|
|
18655452d9 | ||
|
|
947da7e1fc | ||
|
|
14f07cc638 |
16
AGENTS.md
Normal file
16
AGENTS.md
Normal file
@ -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`.
|
||||
50
PORTING_STATUS_modbus.md
Normal file
50
PORTING_STATUS_modbus.md
Normal file
@ -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.
|
||||
13
debian/changelog
vendored
13
debian/changelog
vendored
@ -1,3 +1,16 @@
|
||||
etm-powersync-plugins-modbus (1.15.0+etm4) trixie; urgency=medium
|
||||
|
||||
* Ajout plugin V2C Trydan (borne de charge EV, Modbus TCP) : interface
|
||||
evcharger complète — charge enable/disable (PauseState+Lock miroir),
|
||||
consigne courant (Intensity), telemetrie (ChargePower, HousePower,
|
||||
PowerFV, ChargeState IEC 61851). Gestion conflit Dynamic/PauseDynamic
|
||||
sans ecriture du registre Dynamic (evite la coupure de telemetrie,
|
||||
cf. evcc issue #28047). Decouverte LAN via HTTP /RealTimeData.
|
||||
Transport abstrait (TCP Etape 1, RTU Etape 2 prevu).
|
||||
Statut : Partiel — non teste sur borne reelle.
|
||||
|
||||
-- ETM-Schurig SARL <contact@etm-schurig.eu> Thu, 12 Jun 2026 12:00:00 +0200
|
||||
|
||||
etm-powersync-plugins-modbus (1.15.0+etm3) trixie; urgency=medium
|
||||
|
||||
* Ajout plugin ABB Terra AC (borne de charge, Modbus TCP/RTU) : vendoring
|
||||
|
||||
12
debian/control
vendored
12
debian/control
vendored
@ -41,3 +41,15 @@ Depends: ${shlibs:Depends},
|
||||
Description: ABB Terra AC charging station (Modbus TCP/RTU) - vendored from upstream
|
||||
nymea integration plugin for ABB Terra AC wallbox over Modbus TCP or RTU.
|
||||
Vendored from nymea-plugins-modbus experimental-silo, pending upstream release.
|
||||
|
||||
Package: powersync-plugin-v2c
|
||||
Architecture: any
|
||||
Section: libs
|
||||
Depends: ${shlibs:Depends},
|
||||
${misc:Depends},
|
||||
Description: PowerSync integration plugin for V2C Trydan EV charger (Modbus TCP)
|
||||
nymea integration plugin for the V2C Trydan / Trydan Pro EV charger over
|
||||
Modbus TCP (port 502), for use by the ETM PowerSync home energy management
|
||||
system. Implements the evcharger interface: charge enable/disable, current
|
||||
setpoint, plugged-in / charging state, house and solar power telemetry.
|
||||
Status: Partial — not yet validated on real hardware.
|
||||
|
||||
1
debian/powersync-plugin-v2c.install
vendored
Normal file
1
debian/powersync-plugin-v2c.install
vendored
Normal file
@ -0,0 +1 @@
|
||||
usr/lib/*/nymea/plugins/libnymea_integrationpluginv2c.so
|
||||
@ -6,7 +6,8 @@ TEMPLATE = subdirs
|
||||
PLUGIN_DIRS = \
|
||||
eastron \
|
||||
abbb2x \
|
||||
abbterra
|
||||
abbterra \
|
||||
v2c
|
||||
|
||||
|
||||
message(============================================)
|
||||
|
||||
19
modbus.pri
19
modbus.pri
@ -1,11 +1,22 @@
|
||||
QT += network serialport serialbus
|
||||
|
||||
# libnymea-modbus depuis le paquet systeme (libnymea-modbus-dev), plus les sources locales
|
||||
# libnymea-modbus depuis le paquet systeme (libnymea-modbus-dev) ou via NYMEA_MODBUS_PATH.
|
||||
CONFIG += link_pkgconfig
|
||||
PKGCONFIG += nymea-modbus
|
||||
|
||||
OTHER_FILES += $${MODBUS_CONNECTIONS}
|
||||
|
||||
# Outil de generation des connexions modbus (sdm*-registers.json -> .cpp/.h),
|
||||
# fourni par libnymea-modbus-dev
|
||||
include(/usr/include/nymea-modbus/modbus-tool.pri)
|
||||
# Locate modbus-tool.pri: system install first, then NYMEA_MODBUS_PATH override
|
||||
# (for builds against a local libnymea-modbus source tree).
|
||||
# When MODBUS_CONNECTIONS is empty (e.g. v2c plugin), the tool's for-loop is a
|
||||
# no-op; we still need the file for the C++ standard CONFIG lines below.
|
||||
MODBUS_TOOL_PRI = $$[QT_INSTALL_PREFIX]/include/nymea-modbus/modbus-tool.pri
|
||||
!isEmpty(NYMEA_MODBUS_PATH): MODBUS_TOOL_PRI = $$NYMEA_MODBUS_PATH/modbus-tool.pri
|
||||
|
||||
exists($$MODBUS_TOOL_PRI) {
|
||||
include($$MODBUS_TOOL_PRI)
|
||||
} else {
|
||||
warning("modbus-tool.pri not found at $$MODBUS_TOOL_PRI — MODBUS_CONNECTIONS code-gen disabled")
|
||||
greaterThan(QT_MAJOR_VERSION, 5): CONFIG *= c++17
|
||||
lessThan(QT_MAJOR_VERSION, 6): CONFIG *= c++11
|
||||
}
|
||||
|
||||
257
v2c/AGENTS.md
257
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/<param>=<val>
|
||||
- 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/<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.
|
||||
|
||||
330
v2c/integrationpluginv2c.cpp
Normal file
330
v2c/integrationpluginv2c.cpp
Normal file
@ -0,0 +1,330 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "integrationpluginv2c.h"
|
||||
#include "v2ctcpdiscovery.h"
|
||||
#include "plugininfo.h"
|
||||
|
||||
#include <integrations/thing.h>
|
||||
#include <integrations/thingactioninfo.h>
|
||||
#include <integrations/thingdescriptor.h>
|
||||
#include <integrations/thingdiscoveryinfo.h>
|
||||
#include <integrations/thingsetupinfo.h>
|
||||
#include <hardwaremanager.h>
|
||||
#include <network/networkdevicediscovery.h>
|
||||
|
||||
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<quint16>(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<double>(master->maxIntensity()));
|
||||
|
||||
const quint16 amps = static_cast<quint16>(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<double>(master->chargePower()));
|
||||
thing->setStateValue(trydanHousePowerStateTypeId, static_cast<double>(master->housePower()));
|
||||
thing->setStateValue(trydanSolarPowerStateTypeId, static_cast<double>(master->powerFV()));
|
||||
thing->setStateValue(trydanSlaveErrorStateTypeId, static_cast<uint>(master->slaveError()));
|
||||
thing->setStateValue(trydanChargeEnergyStateTypeId, static_cast<double>(master->chargeEnergy()));
|
||||
thing->setStateValue(trydanIntensityStateTypeId, static_cast<double>(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<quint16>(desired));
|
||||
}
|
||||
}
|
||||
|
||||
void IntegrationPluginV2c::setDisconnectedState(Thing *thing)
|
||||
{
|
||||
thing->setStateValue(trydanConnectedStateTypeId, false);
|
||||
thing->setStateValue(trydanChargingStateTypeId, false);
|
||||
thing->setStateValue(trydanPluggedInStateTypeId, false);
|
||||
thing->setStateValue(trydanCurrentPowerStateTypeId, 0.0);
|
||||
}
|
||||
68
v2c/integrationpluginv2c.h
Normal file
68
v2c/integrationpluginv2c.h
Normal file
@ -0,0 +1,68 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#ifndef INTEGRATIONPLUGINV2C_H
|
||||
#define INTEGRATIONPLUGINV2C_H
|
||||
|
||||
#include <plugintimer.h>
|
||||
#include <integrations/integrationplugin.h>
|
||||
#include <network/networkdevicemonitor.h>
|
||||
|
||||
#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<Thing *, TrydanModbusTcpMaster *> m_tcpMasters;
|
||||
QHash<Thing *, NetworkDeviceMonitor *> m_monitors;
|
||||
};
|
||||
|
||||
#endif // INTEGRATIONPLUGINV2C_H
|
||||
187
v2c/integrationpluginv2c.json
Normal file
187
v2c/integrationpluginv2c.json
Normal file
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
12
v2c/meta.json
Normal file
12
v2c/meta.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"title": "V2C Trydan",
|
||||
"tagline": "Connect V2C Trydan EV chargers over Modbus TCP.",
|
||||
"stability": "consumer",
|
||||
"offline": true,
|
||||
"technologies": [
|
||||
"network"
|
||||
],
|
||||
"categories": [
|
||||
"energy"
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE TS>
|
||||
<TS version="2.1" language="en_US">
|
||||
<context>
|
||||
<name>IntegrationPluginV2c</name>
|
||||
<message>
|
||||
<source>Could not register network monitor for this charger.</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Could not communicate with the V2C Trydan charger.</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The V2C Trydan charger is not reachable.</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
</context>
|
||||
</TS>
|
||||
32
v2c/trydanmodbusmaster.cpp
Normal file
32
v2c/trydanmodbusmaster.cpp
Normal file
@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "trydanmodbusmaster.h"
|
||||
#include <cstring>
|
||||
|
||||
TrydanModbusMaster::TrydanModbusMaster(QObject *parent)
|
||||
: QObject(parent)
|
||||
{}
|
||||
|
||||
float TrydanModbusMaster::decodeFloat32BB(quint16 high, quint16 low)
|
||||
{
|
||||
// High word is the most-significant 16 bits (Big word order).
|
||||
// Within each word bytes are already in big-endian order on the wire.
|
||||
// This mirrors pymodbus BinaryPayloadDecoder(byteorder=Endian.Big, wordorder=Endian.Big).
|
||||
quint32 raw = (static_cast<quint32>(high) << 16) | static_cast<quint32>(low);
|
||||
float result;
|
||||
std::memcpy(&result, &raw, sizeof(float));
|
||||
return result;
|
||||
}
|
||||
|
||||
int TrydanModbusMaster::decodeIntFromFloat32(quint16 high, quint16 low)
|
||||
{
|
||||
return static_cast<int>(qRound(decodeFloat32BB(high, low)));
|
||||
}
|
||||
|
||||
void TrydanModbusMaster::setReachable(bool reachable)
|
||||
{
|
||||
if (m_reachable == reachable)
|
||||
return;
|
||||
m_reachable = reachable;
|
||||
emit reachableChanged(reachable);
|
||||
}
|
||||
211
v2c/trydanmodbusmaster.h
Normal file
211
v2c/trydanmodbusmaster.h
Normal file
@ -0,0 +1,211 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#ifndef TRYDANMODBUSMASTER_H
|
||||
#define TRYDANMODBUSMASTER_H
|
||||
|
||||
#include <QObject>
|
||||
#include <cstring>
|
||||
|
||||
/*!
|
||||
* \brief Abstract transport interface for the V2C Trydan Modbus connection.
|
||||
*
|
||||
* Carries all register addresses, float-decode helpers, and the last-polled
|
||||
* values. Concrete subclasses provide the physical transport (Modbus TCP for
|
||||
* Étape 1, Modbus RTU for Étape 2) without duplicating any of this logic.
|
||||
*
|
||||
* \b Register-map rules (cf. V2C Trydan_Modbus_TCP, modbus.py):
|
||||
* - Every value occupies \b two holding registers encoded as IEEE-754 float32
|
||||
* with big-endian byte order and big-endian word order (high word first).
|
||||
* - Integer values (ChargeState, Intensity, Dynamic …) are still sent as
|
||||
* float32; they must be decoded then rounded — never read as raw uint16.
|
||||
* - \b No block read: consecutive addresses (0x0BC2, 0x0BC3 …) overlap their
|
||||
* 2-register windows. Each value must be fetched in its own FC3 transaction.
|
||||
* - Writes use FC6 (single register, uint16 direct value — NOT float).
|
||||
*/
|
||||
class TrydanModbusMaster : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
/*!
|
||||
* \brief Holding-register read addresses (FC3, 2 registers each, float32 Big/Big).
|
||||
*
|
||||
* Source: modbus.py lines _read_register + regenera_float (V2C lib).
|
||||
* The intentional overlap (0x0BC2 covers regs 0x0BC2..0x0BC3, 0x0BC3
|
||||
* covers 0x0BC3..0x0BC4 …) is why block reads are forbidden.
|
||||
*/
|
||||
enum ReadRegister : quint16 {
|
||||
RegChargeState = 0x0BC2, ///< 0=A(disconnected) 1=B(connected) 2=C(charging)
|
||||
RegChargePower = 0x0BC3, ///< W
|
||||
RegChargeEnergy = 0x0BC4, ///< kWh session — diagnostic only, NOT sessionEnergy
|
||||
RegSlaveError = 0x0BC5, ///< firmware error code
|
||||
RegHousePower = 0x0BC8, ///< W, CT clamp (if installed)
|
||||
RegPowerFV = 0x0BC9, ///< W, PV production seen by charger (if configured)
|
||||
RegPauseState = 0x0BCA, ///< 0=active 1=paused
|
||||
RegLock = 0x0BCB, ///< 0=unlocked 1=locked
|
||||
RegIntensity = 0x0BCD, ///< A, active charge current
|
||||
RegDynamic = 0x0BCE, ///< 0=off 1=internal optimizer running — re-read every poll
|
||||
RegMinIntensity = 0x0BD1, ///< A, lower bound
|
||||
RegMaxIntensity = 0x0BD2, ///< A, upper bound (firmware-version–dependent, verify on hardware)
|
||||
RegPauseDynamic = 0x0BD3, ///< 0=optimizer runs 1=optimizer suspended by HEMS
|
||||
};
|
||||
|
||||
/*!
|
||||
* \brief Write-register addresses (FC6, uint16 raw value — not float).
|
||||
*
|
||||
* \warning 0x177E (Dynamic) is \b intentionally absent: writing it to 0
|
||||
* disables power telemetry (ChargePower goes silent) even though the charger
|
||||
* keeps operating. Use PauseDynamic (0x1783) to suppress the internal PID
|
||||
* instead. cf. evcc charger/trydan.go and github.com/evcc-io/evcc/issues/28047.
|
||||
*/
|
||||
enum WriteRegister : quint16 {
|
||||
WRegPauseState = 0x177A, ///< setChargingEnabled: 1=pause, 0=active
|
||||
WRegLock = 0x177B, ///< mirror of PauseState (always written together)
|
||||
WRegIntensity = 0x177D, ///< setMaxChargingCurrent (integer amperes)
|
||||
WRegPauseDynamic = 0x1783, ///< 1=suspend internal PID, 0=release
|
||||
};
|
||||
|
||||
// --- Last values from the most recent update() cycle ---
|
||||
|
||||
/*! \brief ChargeState: 0=A, 1=B, 2=C (IEC 61851) */
|
||||
int chargeState() const { return m_chargeState; }
|
||||
/*! \brief ChargePower in watts */
|
||||
float chargePower() const { return m_chargePower; }
|
||||
/*! \brief Session energy in kWh (diagnostic only — firmware reliability unvalidated) */
|
||||
float chargeEnergy() const { return m_chargeEnergy; }
|
||||
/*! \brief Firmware error code */
|
||||
int slaveError() const { return m_slaveError; }
|
||||
/*! \brief House CT-clamp power in watts */
|
||||
float housePower() const { return m_housePower; }
|
||||
/*! \brief PV production seen by the charger, in watts */
|
||||
float powerFV() const { return m_powerFV; }
|
||||
/*! \brief PauseState register: 0=active, 1=paused */
|
||||
int pauseState() const { return m_pauseState; }
|
||||
/*! \brief Lock register: 0=unlocked, 1=locked */
|
||||
int lock() const { return m_lock; }
|
||||
/*! \brief Current charge current in amperes */
|
||||
int intensity() const { return m_intensity; }
|
||||
/*! \brief Dynamic register: 0=no internal optimizer, 1=optimizer active */
|
||||
int dynamicMode() const { return m_dynamic; }
|
||||
/*! \brief Configured minimum intensity (init-time, usually 6 A) */
|
||||
int minIntensity() const { return m_minIntensity; }
|
||||
/*! \brief Configured maximum intensity (init-time, firmware-version–dependent) */
|
||||
int maxIntensity() const { return m_maxIntensity; }
|
||||
/*! \brief PauseDynamic: 0=optimizer runs, 1=HEMS has suspended it */
|
||||
int pauseDynamic() const { return m_pauseDynamic; }
|
||||
|
||||
bool reachable() const { return m_reachable; }
|
||||
|
||||
// --- Transport operations (pure virtual) ---
|
||||
|
||||
/*! \brief Establish the physical connection to the charger. */
|
||||
virtual void connectDevice() = 0;
|
||||
/*! \brief Drop the physical connection. */
|
||||
virtual void disconnectDevice() = 0;
|
||||
/*!
|
||||
* \brief Read init-time registers (MinIntensity, MaxIntensity).
|
||||
* \return false if a read is already in progress.
|
||||
*/
|
||||
virtual bool initialize() = 0;
|
||||
/*!
|
||||
* \brief Read all poll-cycle registers sequentially.
|
||||
* Emits updateFinished() on completion (success or error).
|
||||
* \return false if a poll is already in progress.
|
||||
*/
|
||||
virtual bool update() = 0;
|
||||
|
||||
/*!
|
||||
* \brief Write PauseState register (FC6).
|
||||
* Always pair with writeLock(); the two must stay consistent.
|
||||
* cf. evcc trydan.go Enable().
|
||||
* \param value 0=active 1=paused
|
||||
*/
|
||||
virtual void writePauseState(quint16 value) = 0;
|
||||
/*!
|
||||
* \brief Write Lock register (FC6).
|
||||
* \param value 0=unlocked 1=locked
|
||||
*/
|
||||
virtual void writeLock(quint16 value) = 0;
|
||||
/*!
|
||||
* \brief Write Intensity register (FC6, integer amperes).
|
||||
* Caller must clamp to [minIntensity(), maxIntensity()] before calling.
|
||||
*/
|
||||
virtual void writeIntensity(quint16 amps) = 0;
|
||||
/*!
|
||||
* \brief Write PauseDynamic register (FC6).
|
||||
* \param value 1=suspend internal optimizer PID, 0=release
|
||||
*/
|
||||
virtual void writePauseDynamic(quint16 value) = 0;
|
||||
|
||||
signals:
|
||||
/*! \brief Emitted when the transport-level connection state changes. */
|
||||
void reachableChanged(bool reachable);
|
||||
/*!
|
||||
* \brief Emitted once initialize() finishes (success or failure).
|
||||
* On success the init-time registers (MinIntensity, MaxIntensity) are valid.
|
||||
*/
|
||||
void initializationFinished(bool success);
|
||||
/*!
|
||||
* \brief Emitted once each update() cycle completes.
|
||||
* All accessor methods reflect the newly read values when this fires.
|
||||
*/
|
||||
void updateFinished();
|
||||
/*!
|
||||
* \brief Emitted after each write operation completes.
|
||||
* \param address The write register address (WRegPauseState, WRegLock, …).
|
||||
* Action handlers must filter by address to avoid processing
|
||||
* background writes (e.g. PauseDynamic from conflict management)
|
||||
* as if they were the writes triggered by the action itself.
|
||||
*/
|
||||
void writeCompleted(quint16 address, bool success);
|
||||
|
||||
protected:
|
||||
explicit TrydanModbusMaster(QObject *parent = nullptr);
|
||||
|
||||
/*!
|
||||
* \brief Decode two holding-register words into a float32 (Big/Big).
|
||||
*
|
||||
* The V2C Trydan uses big-endian byte order AND big-endian word order:
|
||||
* the high 16-bit word arrives first on the wire, forming the most-significant
|
||||
* half of the 32-bit pattern. This matches pymodbus BinaryPayloadDecoder
|
||||
* with byteorder=Endian.Big, wordorder=Endian.Big.
|
||||
* cf. modbus.py regenera_float()
|
||||
*
|
||||
* \param high First register received (most-significant 16 bits of float32)
|
||||
* \param low Second register received (least-significant 16 bits)
|
||||
*/
|
||||
static float decodeFloat32BB(quint16 high, quint16 low);
|
||||
|
||||
/*!
|
||||
* \brief Decode an integer value stored as float32 Big/Big.
|
||||
*
|
||||
* All "integer" registers (ChargeState, Intensity, Dynamic …) are still
|
||||
* encoded as float32. Round to nearest int after decode.
|
||||
* cf. modbus.py: int(round(regenera_float(regs)))
|
||||
*/
|
||||
static int decodeIntFromFloat32(quint16 high, quint16 low);
|
||||
|
||||
/*!
|
||||
* \brief Update reachability and emit reachableChanged() on transition.
|
||||
*/
|
||||
void setReachable(bool reachable);
|
||||
|
||||
// Cached register values (written by concrete subclasses during polls)
|
||||
int m_chargeState = 0;
|
||||
float m_chargePower = 0.0f;
|
||||
float m_chargeEnergy = 0.0f;
|
||||
int m_slaveError = 0;
|
||||
float m_housePower = 0.0f;
|
||||
float m_powerFV = 0.0f;
|
||||
int m_pauseState = 0;
|
||||
int m_lock = 0;
|
||||
int m_intensity = 0;
|
||||
int m_dynamic = 0;
|
||||
int m_minIntensity = 6;
|
||||
int m_maxIntensity = 32;
|
||||
int m_pauseDynamic = 0;
|
||||
|
||||
bool m_reachable = false;
|
||||
};
|
||||
|
||||
#endif // TRYDANMODBUSMASTER_H
|
||||
267
v2c/trydanmodbustcpmaster.cpp
Normal file
267
v2c/trydanmodbustcpmaster.cpp
Normal file
@ -0,0 +1,267 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "trydanmodbustcpmaster.h"
|
||||
|
||||
#include <modbustcpmaster.h>
|
||||
|
||||
#include <QtSerialBus>
|
||||
|
||||
#include "extern-plugininfo.h"
|
||||
|
||||
TrydanModbusTcpMaster::TrydanModbusTcpMaster(const QHostAddress &address,
|
||||
quint16 port,
|
||||
quint16 slaveId,
|
||||
QObject *parent)
|
||||
: TrydanModbusMaster(parent),
|
||||
m_slaveId(slaveId)
|
||||
{
|
||||
m_modbusTcpMaster = new ModbusTcpMaster(address, port, this);
|
||||
m_modbusTcpMaster->setNumberOfRetries(1);
|
||||
m_modbusTcpMaster->setTimeout(2000);
|
||||
|
||||
connect(m_modbusTcpMaster, &ModbusTcpMaster::connectionStateChanged,
|
||||
this, &TrydanModbusTcpMaster::onConnectionStateChanged);
|
||||
}
|
||||
|
||||
TrydanModbusTcpMaster::~TrydanModbusTcpMaster()
|
||||
{
|
||||
// m_modbusTcpMaster is a child QObject and will be deleted automatically.
|
||||
}
|
||||
|
||||
ModbusTcpMaster *TrydanModbusTcpMaster::modbusTcpMaster() const
|
||||
{
|
||||
return m_modbusTcpMaster;
|
||||
}
|
||||
|
||||
void TrydanModbusTcpMaster::setHostAddress(const QHostAddress &address)
|
||||
{
|
||||
m_modbusTcpMaster->setHostAddress(address);
|
||||
}
|
||||
|
||||
void TrydanModbusTcpMaster::connectDevice()
|
||||
{
|
||||
m_modbusTcpMaster->connectDevice();
|
||||
}
|
||||
|
||||
void TrydanModbusTcpMaster::disconnectDevice()
|
||||
{
|
||||
m_modbusTcpMaster->disconnectDevice();
|
||||
setReachable(false);
|
||||
}
|
||||
|
||||
bool TrydanModbusTcpMaster::initialize()
|
||||
{
|
||||
if (!m_modbusTcpMaster->connected() || m_initializing)
|
||||
return false;
|
||||
|
||||
m_initializing = true;
|
||||
runReadSequence(buildInitSteps(), [this](bool ok) {
|
||||
m_initializing = false;
|
||||
if (ok) {
|
||||
// qBound to IEC 61851 absolute limits [6, 32]. The real installation
|
||||
// ceiling comes from MaxIntensity read above (a 16 A mono Trydan reports
|
||||
// 16, not 32). The lower guard (qMax 6 was wrong for aberrant 0 returns:
|
||||
// qMin(32, 0)=0 → maxIntensity()==0 → every setpoint gets clamped to 0
|
||||
// and then the <6 A path pauses charging silently). qBound floors to 6
|
||||
// on a spurious 0 and caps to 32 on a spurious 65535.
|
||||
m_minIntensity = qBound(6, m_minIntensity, 32);
|
||||
m_maxIntensity = qBound(6, m_maxIntensity, 32);
|
||||
}
|
||||
emit initializationFinished(ok);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TrydanModbusTcpMaster::update()
|
||||
{
|
||||
if (!m_modbusTcpMaster->connected() || m_updating)
|
||||
return false;
|
||||
|
||||
m_updating = true;
|
||||
runReadSequence(buildPollSteps(), [this](bool ok) {
|
||||
m_updating = false;
|
||||
if (ok) {
|
||||
m_errorCount = 0;
|
||||
setReachable(true);
|
||||
emit updateFinished();
|
||||
} else {
|
||||
handleError();
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
void TrydanModbusTcpMaster::writePauseState(quint16 value)
|
||||
{
|
||||
writeFC6(WRegPauseState, value,
|
||||
[this](quint16 addr, bool ok) { emit writeCompleted(addr, ok); });
|
||||
}
|
||||
|
||||
void TrydanModbusTcpMaster::writeLock(quint16 value)
|
||||
{
|
||||
writeFC6(WRegLock, value,
|
||||
[this](quint16 addr, bool ok) { emit writeCompleted(addr, ok); });
|
||||
}
|
||||
|
||||
void TrydanModbusTcpMaster::writeIntensity(quint16 amps)
|
||||
{
|
||||
writeFC6(WRegIntensity, amps,
|
||||
[this](quint16 addr, bool ok) { emit writeCompleted(addr, ok); });
|
||||
}
|
||||
|
||||
void TrydanModbusTcpMaster::writePauseDynamic(quint16 value)
|
||||
{
|
||||
// PauseDynamic is also emitted via writeCompleted so the plugin can update
|
||||
// the optimizerSuspended state if needed; action handlers must filter by address.
|
||||
writeFC6(WRegPauseDynamic, value,
|
||||
[this](quint16 addr, bool ok) { emit writeCompleted(addr, ok); });
|
||||
}
|
||||
|
||||
// --- Private implementation ---
|
||||
|
||||
QList<TrydanModbusTcpMaster::ReadStep> TrydanModbusTcpMaster::buildInitSteps()
|
||||
{
|
||||
return {
|
||||
{ RegMinIntensity, [this](bool ok, quint16 h, quint16 l) {
|
||||
if (ok) m_minIntensity = decodeIntFromFloat32(h, l);
|
||||
}},
|
||||
{ RegMaxIntensity, [this](bool ok, quint16 h, quint16 l) {
|
||||
if (ok) m_maxIntensity = decodeIntFromFloat32(h, l);
|
||||
}},
|
||||
};
|
||||
}
|
||||
|
||||
QList<TrydanModbusTcpMaster::ReadStep> TrydanModbusTcpMaster::buildPollSteps()
|
||||
{
|
||||
// Each register pair is a separate FC3 transaction (2 registers, float32 Big/Big).
|
||||
// Order matters: Dynamic must always be read (last) to reflect the most current
|
||||
// state before the plugin applies the conflict-management logic.
|
||||
return {
|
||||
{ RegChargeState, [this](bool ok, quint16 h, quint16 l) {
|
||||
if (ok) m_chargeState = decodeIntFromFloat32(h, l);
|
||||
}},
|
||||
{ RegChargePower, [this](bool ok, quint16 h, quint16 l) {
|
||||
if (ok) m_chargePower = decodeFloat32BB(h, l);
|
||||
}},
|
||||
{ RegChargeEnergy, [this](bool ok, quint16 h, quint16 l) {
|
||||
if (ok) m_chargeEnergy = decodeFloat32BB(h, l);
|
||||
}},
|
||||
{ RegSlaveError, [this](bool ok, quint16 h, quint16 l) {
|
||||
if (ok) m_slaveError = decodeIntFromFloat32(h, l);
|
||||
}},
|
||||
{ RegHousePower, [this](bool ok, quint16 h, quint16 l) {
|
||||
if (ok) m_housePower = decodeFloat32BB(h, l);
|
||||
}},
|
||||
{ RegPowerFV, [this](bool ok, quint16 h, quint16 l) {
|
||||
if (ok) m_powerFV = decodeFloat32BB(h, l);
|
||||
}},
|
||||
{ RegPauseState, [this](bool ok, quint16 h, quint16 l) {
|
||||
if (ok) m_pauseState = decodeIntFromFloat32(h, l);
|
||||
}},
|
||||
{ RegLock, [this](bool ok, quint16 h, quint16 l) {
|
||||
if (ok) m_lock = decodeIntFromFloat32(h, l);
|
||||
}},
|
||||
{ RegIntensity, [this](bool ok, quint16 h, quint16 l) {
|
||||
if (ok) m_intensity = decodeIntFromFloat32(h, l);
|
||||
}},
|
||||
{ RegDynamic, [this](bool ok, quint16 h, quint16 l) {
|
||||
// Critical: must be read every poll — the V2C app can toggle this at any time.
|
||||
if (ok) m_dynamic = decodeIntFromFloat32(h, l);
|
||||
}},
|
||||
{ RegPauseDynamic, [this](bool ok, quint16 h, quint16 l) {
|
||||
if (ok) m_pauseDynamic = decodeIntFromFloat32(h, l);
|
||||
}},
|
||||
};
|
||||
}
|
||||
|
||||
void TrydanModbusTcpMaster::runReadSequence(QList<ReadStep> steps,
|
||||
std::function<void(bool)> onDone)
|
||||
{
|
||||
doNextRead(std::move(steps), 0, std::move(onDone));
|
||||
}
|
||||
|
||||
void TrydanModbusTcpMaster::doNextRead(QList<ReadStep> steps,
|
||||
int index,
|
||||
std::function<void(bool)> onDone)
|
||||
{
|
||||
if (index >= steps.count()) {
|
||||
onDone(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const quint16 address = steps.at(index).first;
|
||||
const ReadCallback callback = steps.at(index).second;
|
||||
|
||||
// Individual FC3 read of exactly 2 registers (one float32 value).
|
||||
// Never read a range spanning multiple values — overlapping windows would
|
||||
// return garbage for addresses beyond the first. cf. modbus.py _read_register.
|
||||
QModbusReply *reply = m_modbusTcpMaster->sendReadRequest(
|
||||
QModbusDataUnit(QModbusDataUnit::HoldingRegisters, address, 2),
|
||||
static_cast<int>(m_slaveId));
|
||||
|
||||
if (!reply) {
|
||||
qCWarning(dcV2C()) << "TrydanModbusTcpMaster: sendReadRequest returned null for reg" << Qt::hex << address;
|
||||
onDone(false);
|
||||
return;
|
||||
}
|
||||
|
||||
connect(reply, &QModbusReply::finished, this,
|
||||
[this, reply, steps, index, callback, onDone]() mutable {
|
||||
reply->deleteLater();
|
||||
|
||||
if (reply->error() != QModbusDevice::NoError) {
|
||||
qCWarning(dcV2C()) << "TrydanModbusTcpMaster: read error for reg"
|
||||
<< Qt::hex << steps.at(index).first
|
||||
<< ":" << reply->errorString();
|
||||
callback(false, 0, 0);
|
||||
onDone(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const auto values = reply->result().values();
|
||||
callback(true, values.value(0), values.value(1));
|
||||
doNextRead(std::move(steps), index + 1, std::move(onDone));
|
||||
});
|
||||
}
|
||||
|
||||
void TrydanModbusTcpMaster::writeFC6(quint16 address, quint16 value,
|
||||
std::function<void(quint16, bool)> callback)
|
||||
{
|
||||
// FC6 = Write Single Register; QModbusTcpClient uses FC6 for single-word writes.
|
||||
QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address, 1);
|
||||
unit.setValue(0, value);
|
||||
|
||||
QModbusReply *reply = m_modbusTcpMaster->sendWriteRequest(unit, static_cast<int>(m_slaveId));
|
||||
if (!reply) {
|
||||
qCWarning(dcV2C()) << "TrydanModbusTcpMaster: sendWriteRequest returned null for reg" << Qt::hex << address;
|
||||
callback(address, false);
|
||||
return;
|
||||
}
|
||||
|
||||
connect(reply, &QModbusReply::finished, this, [reply, address, callback]() {
|
||||
reply->deleteLater();
|
||||
callback(address, reply->error() == QModbusDevice::NoError);
|
||||
});
|
||||
}
|
||||
|
||||
void TrydanModbusTcpMaster::onConnectionStateChanged(bool connected)
|
||||
{
|
||||
if (connected) {
|
||||
m_errorCount = 0;
|
||||
// Signal reachability so the plugin calls initialize().
|
||||
setReachable(true);
|
||||
} else {
|
||||
m_updating = false;
|
||||
m_initializing = false;
|
||||
setReachable(false);
|
||||
}
|
||||
}
|
||||
|
||||
void TrydanModbusTcpMaster::handleError()
|
||||
{
|
||||
m_errorCount++;
|
||||
if (m_errorCount >= k_errorLimit) {
|
||||
qCWarning(dcV2C()) << "TrydanModbusTcpMaster: error limit reached, marking unreachable";
|
||||
setReachable(false);
|
||||
}
|
||||
}
|
||||
107
v2c/trydanmodbustcpmaster.h
Normal file
107
v2c/trydanmodbustcpmaster.h
Normal file
@ -0,0 +1,107 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#ifndef TRYDANMODBUSTCPMASTER_H
|
||||
#define TRYDANMODBUSTCPMASTER_H
|
||||
|
||||
#include "trydanmodbusmaster.h"
|
||||
|
||||
#include <QHostAddress>
|
||||
#include <QList>
|
||||
#include <QPair>
|
||||
#include <functional>
|
||||
|
||||
class ModbusTcpMaster;
|
||||
class QModbusReply;
|
||||
|
||||
/*!
|
||||
* \brief Modbus TCP transport for the V2C Trydan (Étape 1).
|
||||
*
|
||||
* Wraps a libnymea-modbus ModbusTcpMaster. All register reads are individual
|
||||
* FC3 transactions of exactly 2 registers — never batched — to avoid the
|
||||
* overlapping-window firmware bug of the V2C Trydan.
|
||||
* cf. modbus.py _read_register and AGENTS.md § Décodage lecture.
|
||||
*
|
||||
* Writes use FC6 (single-register, uint16, via ModbusTcpMaster::sendWriteRequest).
|
||||
*
|
||||
* Étape 2 (RTU) will add TrydanModbusRtuMaster on the same TrydanModbusMaster
|
||||
* interface without touching this file.
|
||||
*/
|
||||
class TrydanModbusTcpMaster : public TrydanModbusMaster
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
/*!
|
||||
* \param address IP address of the charger
|
||||
* \param port Modbus TCP port (default 502)
|
||||
* \param slaveId Modbus unit ID (always 1 for Trydan)
|
||||
*/
|
||||
explicit TrydanModbusTcpMaster(const QHostAddress &address,
|
||||
quint16 port,
|
||||
quint16 slaveId,
|
||||
QObject *parent = nullptr);
|
||||
~TrydanModbusTcpMaster() override;
|
||||
|
||||
/*! \brief Underlying TCP master — exposed so the plugin can update the host address. */
|
||||
ModbusTcpMaster *modbusTcpMaster() const;
|
||||
|
||||
/*! \brief Update the target IP without reconnecting (call reconnectDevice() separately). */
|
||||
void setHostAddress(const QHostAddress &address);
|
||||
|
||||
void connectDevice() override;
|
||||
void disconnectDevice() override;
|
||||
bool initialize() override;
|
||||
bool update() override;
|
||||
|
||||
void writePauseState(quint16 value) override;
|
||||
void writeLock(quint16 value) override;
|
||||
void writeIntensity(quint16 amps) override;
|
||||
void writePauseDynamic(quint16 value) override;
|
||||
|
||||
private:
|
||||
// Callback type for a single 2-register read result
|
||||
using ReadCallback = std::function<void(bool ok, quint16 high, quint16 low)>;
|
||||
using ReadStep = QPair<quint16, ReadCallback>;
|
||||
|
||||
/*!
|
||||
* \brief Execute \a steps sequentially, one FC3 read per register pair.
|
||||
*
|
||||
* The sequence aborts on the first failed read; \a onDone receives false in
|
||||
* that case and the error counter is incremented. On full success \a onDone
|
||||
* receives true and the error counter is reset.
|
||||
*
|
||||
* Sequential execution is required because concurrent reads from the same
|
||||
* slave on a Modbus TCP connection would not be incorrect per protocol, but
|
||||
* the firmware's overlapping register windows make ordering critical.
|
||||
*/
|
||||
void runReadSequence(QList<ReadStep> steps, std::function<void(bool)> onDone);
|
||||
void doNextRead(QList<ReadStep> steps, int index, std::function<void(bool)> onDone);
|
||||
|
||||
/*!
|
||||
* \brief Send an FC6 (Write Single Register) request and invoke \a callback.
|
||||
* FC6 is used for all writes per the V2C spec (modbus.py _write_register).
|
||||
* \param callback Receives (address, success) so callers can emit writeCompleted
|
||||
* with the originating register address, enabling action handlers
|
||||
* to distinguish expected writes from background writes.
|
||||
*/
|
||||
void writeFC6(quint16 address, quint16 value,
|
||||
std::function<void(quint16 addr, bool ok)> callback);
|
||||
|
||||
void onConnectionStateChanged(bool connected);
|
||||
|
||||
/*! \brief Increment the error counter and mark unreachable once it hits the limit. */
|
||||
void handleError();
|
||||
|
||||
QList<ReadStep> buildInitSteps();
|
||||
QList<ReadStep> buildPollSteps();
|
||||
|
||||
ModbusTcpMaster *m_modbusTcpMaster = nullptr;
|
||||
quint16 m_slaveId = 1;
|
||||
int m_errorCount = 0;
|
||||
bool m_updating = false;
|
||||
bool m_initializing = false;
|
||||
|
||||
static constexpr int k_errorLimit = 5;
|
||||
};
|
||||
|
||||
#endif // TRYDANMODBUSTCPMASTER_H
|
||||
23
v2c/v2c.pro
Normal file
23
v2c/v2c.pro
Normal file
@ -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
|
||||
110
v2c/v2ctcpdiscovery.cpp
Normal file
110
v2c/v2ctcpdiscovery.cpp
Normal file
@ -0,0 +1,110 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "v2ctcpdiscovery.h"
|
||||
#include "extern-plugininfo.h"
|
||||
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QTimer>
|
||||
#include <QTime>
|
||||
#include <QUrl>
|
||||
|
||||
V2cTcpDiscovery::V2cTcpDiscovery(NetworkDeviceDiscovery *networkDeviceDiscovery,
|
||||
QObject *parent)
|
||||
: QObject(parent),
|
||||
m_networkDeviceDiscovery(networkDeviceDiscovery)
|
||||
{}
|
||||
|
||||
void V2cTcpDiscovery::startDiscovery()
|
||||
{
|
||||
qCInfo(dcV2C()) << "Discovery: searching for V2C Trydan chargers on the network...";
|
||||
m_startTime = QDateTime::currentDateTime();
|
||||
m_pendingProbes = 0;
|
||||
m_scanFinished = false;
|
||||
m_results.clear();
|
||||
m_networkDeviceInfos.clear();
|
||||
|
||||
NetworkDeviceDiscoveryReply *reply = m_networkDeviceDiscovery->discover();
|
||||
|
||||
connect(reply, &NetworkDeviceDiscoveryReply::hostAddressDiscovered,
|
||||
this, &V2cTcpDiscovery::probeAddress);
|
||||
|
||||
connect(reply, &NetworkDeviceDiscoveryReply::finished,
|
||||
reply, &NetworkDeviceDiscoveryReply::deleteLater);
|
||||
|
||||
connect(reply, &NetworkDeviceDiscoveryReply::finished, this, [this, reply]() {
|
||||
m_networkDeviceInfos = reply->networkDeviceInfos();
|
||||
m_scanFinished = true;
|
||||
|
||||
// Give outstanding HTTP probes 3 extra seconds to complete.
|
||||
QTimer::singleShot(3000, this, &V2cTcpDiscovery::finishDiscovery);
|
||||
});
|
||||
}
|
||||
|
||||
QList<V2cTcpDiscovery::Result> V2cTcpDiscovery::results() const
|
||||
{
|
||||
return m_results;
|
||||
}
|
||||
|
||||
void V2cTcpDiscovery::probeAddress(const QHostAddress &address)
|
||||
{
|
||||
// The V2C Trydan HTTP API: GET /RealTimeData returns JSON with a "Paused"
|
||||
// field (and ChargeState, ChargePower, etc.). The presence of "Paused"
|
||||
// is a reliable Trydan fingerprint; it does not appear on generic devices.
|
||||
// cf. evcc charger/trydan.go realTimeData struct and AGENTS.md § HTTP.
|
||||
QUrl url;
|
||||
url.setScheme(QStringLiteral("http"));
|
||||
url.setHost(address.toString());
|
||||
url.setPath(QStringLiteral("/RealTimeData"));
|
||||
|
||||
QNetworkRequest request(url);
|
||||
request.setTransferTimeout(3000);
|
||||
|
||||
m_pendingProbes++;
|
||||
QNetworkReply *reply = m_nam.get(request);
|
||||
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply, address]() {
|
||||
reply->deleteLater();
|
||||
m_pendingProbes--;
|
||||
|
||||
if (reply->error() == QNetworkReply::NoError) {
|
||||
const QByteArray data = reply->readAll();
|
||||
const QJsonObject json = QJsonDocument::fromJson(data).object();
|
||||
|
||||
// "Paused" is the HTTP-API name for PauseState — note it differs from
|
||||
// the Modbus register name (cf. AGENTS.md § HTTP).
|
||||
if (json.contains(QStringLiteral("Paused"))) {
|
||||
Result result;
|
||||
result.ipAddress = address.toString();
|
||||
result.networkDeviceInfo = m_networkDeviceInfos.get(address);
|
||||
if (result.networkDeviceInfo.address().isNull()) {
|
||||
NetworkDeviceInfo info;
|
||||
info.setAddress(address);
|
||||
result.networkDeviceInfo = info;
|
||||
}
|
||||
qCDebug(dcV2C()) << "Discovery: found V2C Trydan at" << address.toString();
|
||||
m_results.append(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Finish early if the scan is already done and this was the last probe.
|
||||
if (m_scanFinished && m_pendingProbes == 0)
|
||||
finishDiscovery();
|
||||
});
|
||||
}
|
||||
|
||||
void V2cTcpDiscovery::finishDiscovery()
|
||||
{
|
||||
// Guard against double-emission (can be called by the timer and the last probe).
|
||||
if (m_finished)
|
||||
return;
|
||||
m_finished = true;
|
||||
|
||||
const qint64 ms = QDateTime::currentMSecsSinceEpoch() - m_startTime.toMSecsSinceEpoch();
|
||||
qCInfo(dcV2C()) << "Discovery: finished in"
|
||||
<< QTime::fromMSecsSinceStartOfDay(static_cast<int>(ms)).toString("mm:ss.zzz")
|
||||
<< "with" << m_results.count() << "V2C Trydan(s) found.";
|
||||
emit discoveryFinished();
|
||||
}
|
||||
61
v2c/v2ctcpdiscovery.h
Normal file
61
v2c/v2ctcpdiscovery.h
Normal file
@ -0,0 +1,61 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#ifndef V2CTCPDISCOVERY_H
|
||||
#define V2CTCPDISCOVERY_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QDateTime>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QHash>
|
||||
|
||||
#include <network/networkdevicediscovery.h>
|
||||
|
||||
/*!
|
||||
* \brief Network discovery for V2C Trydan chargers (Modbus TCP).
|
||||
*
|
||||
* Uses nymea's NetworkDeviceDiscovery to enumerate LAN hosts, then probes
|
||||
* each one with \c GET /RealTimeData (the V2C Trydan HTTP REST API) to
|
||||
* confirm it is a Trydan. The probe is HTTP — not Modbus — because the
|
||||
* Trydan has no network-accessible Modbus discovery mechanism.
|
||||
* cf. AGENTS.md § HTTP and evcc charger/trydan.go.
|
||||
*
|
||||
* Manual-entry (createMethod "user") bypasses discovery entirely;
|
||||
* the plugin calls setupThing() directly with the user-supplied IP.
|
||||
*/
|
||||
class V2cTcpDiscovery : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
struct Result {
|
||||
NetworkDeviceInfo networkDeviceInfo;
|
||||
QString ipAddress;
|
||||
};
|
||||
|
||||
explicit V2cTcpDiscovery(NetworkDeviceDiscovery *networkDeviceDiscovery,
|
||||
QObject *parent = nullptr);
|
||||
|
||||
void startDiscovery();
|
||||
|
||||
/*! \brief Results available after discoveryFinished() is emitted. */
|
||||
QList<Result> results() const;
|
||||
|
||||
signals:
|
||||
void discoveryFinished();
|
||||
|
||||
private:
|
||||
void probeAddress(const QHostAddress &address);
|
||||
void finishDiscovery();
|
||||
|
||||
NetworkDeviceDiscovery *m_networkDeviceDiscovery = nullptr;
|
||||
QNetworkAccessManager m_nam;
|
||||
|
||||
QDateTime m_startTime;
|
||||
NetworkDeviceInfos m_networkDeviceInfos;
|
||||
int m_pendingProbes = 0;
|
||||
QList<Result> m_results;
|
||||
bool m_scanFinished = false;
|
||||
bool m_finished = false;
|
||||
};
|
||||
|
||||
#endif // V2CTCPDISCOVERY_H
|
||||
Loading…
x
Reference in New Issue
Block a user