15 Commits

Author SHA1 Message Date
Patrick Schurig
3d9748d3c1 feat(v2c): la borne déclare smartmeterconsumer — elle a enfin un passé
La classe trydan ne déclarait qu'evcharger + connectable. Ça la tenait hors de
EnergyManagerImpl::watchThing(), qui ne branche un Thing sur le journal
d'énergie que s'il déclare energymeter, smartmeterconsumer, smartmeterproducer
ou energystorage. Energy.GetThingPowerLogs ne REJETAIT pas la demande : il
rendait zéro ligne pour ce thingId, et l'app listait la borne en « sans
historique » alors que sa puissance instantanée était parfaitement lisible.
Deux bornes côte à côte, une seule avec un passé.

Au passage, le portier n'est pas `smartmeter` — cette interface est VIDE, aucun
state requis, la déclarer n'aurait rien produit. C'est `smartmeterconsumer` qui
compte, et elle exige totalEnergyConsumed.

Or la borne n'a pas de totalisateur : aucun registre de la carte Modbus V2C n'en
porte un, et ChargeEnergy (0x0BC4) est effacé à chaque arrêt de charge. Il est
donc CONSTRUIT, comme sessionEnergy, sur les mêmes incréments — et désormais par
la même règle unique, incrementEnergie(), partagée par les deux compteurs pour
qu'une correction ne puisse jamais s'appliquer à un seul. Contrairement à
sessionEnergy il ne se remet jamais à zéro et n'a aucune condition de session :
une recharge lancée à la main sur la borne, hors pilotage, y entre aussi.

Deux repères distincts (baseline / baselineTotale) et non un seul : la machine à
états de session sort par plusieurs chemins qui écrivent baseline sans
accumuler, un repère partagé ferait dépendre le total de l'ORDRE des deux
appels.

CE QUI A LEVÉ LE BLOCAGE « MONOTONE À VIE »

Le brief tenait ce totalisateur pour bloqué : la moindre perte se lirait comme
une consommation négative en aval. C'est trop strict, et vérifié : le moteur
tient son PROPRE compteur, se cale sur la première valeur sans l'ajouter à son
diff, et resynchronise explicitement quand le compteur d'un Thing recule
(« seems to have been reset »). Une perte côté plugin ne peut ni injecter de
négatif en aval, ni effacer le journal déjà écrit, ni produire une marche à
l'ajout du Thing. L'exigence réelle est « ne jamais reculer PENDANT une
exécution » — ce que le state "cached": true et le repère relu de chargeEnergy
au setup assurent.

Limite assumée, la même que sessionEnergy : l'énergie livrée entre le dernier
poll réussi et une RAZ de la borne est perdue, bornée par un intervalle de poll
(30 s). C'est pourquoi ce calcul vit dans le plugin et pas dans le moteur.

VÉRIFIÉ SUR LE BANC (.75, 1.15.2+etm14, 2026-09-08 10:57)

energylogs.sqlite, pour {4d1adafd-cafc-4813-93b1-ca8933ce71ab} :
  - thingCache : ligne absente avant, créée après — c'est watchThing() ;
  - thingPower : 0 ligne avant, puis une par minute comme les cinq autres Things.

À SAVOIR : sur un Thing déjà appairé, things.conf écrase le `logged: true` que
l'interface porte pour currentPower/totalEnergyConsumed (loggedStateTypeIds y
vaut déjà {connected}, {charging}, {power}). Le journal d'énergie du moteur ne
dépend PAS de ce réglage — la courbe marche sans y toucher.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 11:10:01 +02:00
Patrick Schurig
d526a1eaae fix(v2c): la borne basse du courant lit RegMinIntensity, comme la haute
executeAction écrêtait le haut avec master->maxIntensity() mais le bas avec un
littéral 6.0. Une borne déclarant un minimum de 8 A aurait donc reçu une consigne
de 7 A écrite telle quelle : au-dessus du littéral, en dessous de ce que la borne
honore. L'asymétrie datait de l'écriture initiale — la lecture du registre 0x0BD1
existait déjà, elle n'était simplement pas utilisée là.

Les deux extrémités passent maintenant par courantMinimalEffectif() /
courantMaximalEffectif(), qui retiennent la PLUS CONTRAIGNANTE des deux sources :
la borne peut être plus exigeante que l'IEC 61851, jamais moins — une lecture
aberrante à 0 ne doit pas ouvrir la voie à une consigne de 3 A. La borne haute est
refermée sur la basse pour la même raison : RegMaxIntensity dépend de la version de
firmware, et un intervalle inversé ferait échouer setStateMinMaxValues.

Le même couple sert aux DEUX usages, et c'est le point : ce qui est publié comme
min/max du state (lu par le curseur de l'app et par
EvCharger::maxChargingCurrentMinValue() côté moteur) et ce qui est écrêté à
l'écriture. Les faire diverger donnerait un curseur qui propose une valeur que
l'action transforme en pause — une commande sans effet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 11:09:36 +02:00
7863517bba docs(v2c): le compteur d'absences n'avance que sur les polls réussis — propriété load-bearing
Le commentaire disait déjà la propriété ; il ne disait pas qu'elle PORTE. Une
réécriture qui ferait avancer le compteur au timeout, ou le piloterait depuis un
minuteur, la casserait sans qu'aucun test ne le dise.

Ce qu'elle garantit : un faux débranchement est IMPOSSIBLE à fabriquer par une
coupure réseau. Ce n'est pas théorique — 70 % de polls perdus mesurés le
2026-08-28, 40 % encore le 2026-09-06. Un compteur piloté au timeout clôturerait
une session toutes les 90 secondes sur ce lien.

Et elle devient load-bearing au-delà de la session : les dérogations se terminent
au débranchement, et l'asymétrie que leur design exige — prompt à conclure la fin,
lent à conclure la poursuite — existe déjà ici. Quelqu'un y a pensé avant nous.
2026-09-08 06:21:14 +02:00
Patrick Schurig
fd94362e27 docs(v2c): k_errorLimit = 3, seuil à réévaluer — avec le protocole pour le trancher
Trois polls échoués d'affilée marquent la borne injoignable. Sur le lien
dégradé du 2026-08-28 (~70 % de perte), cela donne 12 bascules « unreachable »
en vingt minutes. Une borne qui disparaît toutes les deux minutes ne se pilote
pas, quel que soit le code : LM-1204-b, le plancher réel et EV_GRID_START
porteraient tous ce bruit.

Rien n'est changé dans le comportement — seulement documenté, parce que le
seuil ne PEUT PAS être tranché sur un lien malade. Le protocole tient en trois
étapes, à dérouler une fois le point d'accès rapproché : mesurer le taux de
polls avortés, compter les bascules sur la même fenêtre, puis conclure. Si les
bascules disparaissent, le seuil est bon et détecte vite une borne réellement
absente ; si elles persistent malgré un bon signal, c'est lui qui est trop
serré.

Le relever maintenant masquerait le vrai défaut au lieu de le corriger, et
retarderait d'autant la détection d'une borne vraiment partie.

Commentaire seul côté code : le .so n'est pas modifié, pas de redéploiement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A41CTzMvir1Y5ABPXW8xpB
2026-08-28 13:07:07 +02:00
Patrick Schurig
0798b2937d fix(v2c): sessionEnergy construit dans le plugin, et les « puissances » par phase sont des courants
Deux correctifs issus de la même campagne (borne .103, banc .75, phaseCount = 3,
2026-08-28). Ils touchent les mêmes fonctions et sortent des mêmes relevés.

1. sessionEnergy — CONSTRUIT, pas câblé
------------------------------------------------------------------------------
La Trydan ne publie pas d'énergie de session. Son compteur ChargeEnergy (0x0BC4)
est exact — son intégration recoupe currentPower à mieux d'un pour cent, deux
fois de suite — mais il n'est pas conservé : le firmware le remet à zéro à
chaque ARRÊT DE CHARGE, câble encore branché. Cycle mesuré : arrêt à t+1 s,
remise à zéro à t+8 s, débranchement seulement à t+127 s. Le déclencheur est
l'arrêt, pas le débranchement.

C'est notre problème et pas celui du firmware : en pilotage par surplus, c'est
notre propre arbitrage qui coupe au passage d'un nuage. Le HEMS effacerait donc
sa propre mesure plusieurs fois par jour, et une nuit de recharge relue à 7 h
vaudrait 0 — soit « rien livré », ce que la condition C1 de LM-1009 interdit
d'afficher.

accumulerEnergieDeSession() accumule les incréments avant chaque remise à zéro.
Dans le plugin et pas dans le moteur : le plugin scrute à 30 s, le moteur à la
minute — un incrément effacé entre deux lectures du moteur lui serait invisible.
En sortie, le moteur et l'app reçoivent le contrat evcharger standard, sans cas
particulier V2C à porter en aval. Même principe que le câblage d'energystorage
sur la batterie SunSpec.

Trois décisions, détaillées dans AGENTS.md § SESSION ENERGY :

- RAZ à l'OUVERTURE de session, jamais à la fermeture. Après le départ de la
  voiture, sessionEnergy garde le total de la session terminée : la question
  « combien la nuit a-t-elle livré ? » se pose au matin, voiture déjà partie.
  Remettre à zéro au débranchement effacerait la réponse au moment précis où on
  la cherche.
- Anti-clignotement STRUCTUREL, pas temporisé. Le débranchement doit être
  confirmé sur 3 polls RÉUSSIS consécutifs. updateFinished() n'est émis qu'après
  une séquence de lecture complète : une coupure WiFi GÈLE le compteur
  d'absences au lieu de le faire avancer. C'est la protection qu'il fallait
  contre le lien de ce matin (latence 2-4 s, 40 % de perte avant stabilisation).
- Persistance sur deux supports. sessionEnergy en state « cached » ; le repère
  de reprise est la dernière valeur brute de chargeEnergy, elle aussi passée en
  « cached » — retirer cette propriété ferait repartir de zéro une recharge qui
  traverse un redémarrage, SANS QUE RIEN NE LE SIGNALE, d'où l'annotation en
  travers du code. Le drapeau « session ouverte » va dans pluginStorage(), écrit
  sur transition seulement (flash de la passerelle).

Deux limites assumées plutôt que contournées :
- Si un débranchement PUIS un rebranchement tiennent entièrement dans une
  coupure, la nouvelle session hérite du total de la précédente. La borne ne
  numérote pas ses sessions, et une RAZ de ChargeEnergy ne lève pas
  l'ambiguïté : elle survient aussi au sein d'une même session. Le choix inverse
  — repartir de zéro à chaque redémarrage — casserait le cas que ce correctif
  existe pour couvrir.
- L'énergie livrée entre le dernier poll et l'arrêt de charge est perdue, bornée
  par un intervalle de poll. NON estimée par currentPower × Δt : on n'invente pas
  une mesure pour combler un trou de mesure.

chargeEnergy reste publié à côté, sous son nom exact — sessionEnergy est la
grandeur construite, chargeEnergy la mesure brute. Si les deux divergent un
jour, on saura laquelle a menti. Seul le displayName change, « Session energy
(diagnostic) » étant devenu mensonger.

Ceci lève l'interdiction précédente (« ne pas exposer sessionEnergy », cf. evcc
issue #28047), mais pas de la façon qu'elle envisageait : elle attendait une
validation de fiabilité, la campagne a séparé exactitude (bonne) et conservation
(nulle).

2. Power_L1/L2/L3 portent des AMPÈRES
------------------------------------------------------------------------------
La feuille V2C intitule 0x0BD9-0x0BDB « Power_Lx ». Mesuré : consigne 6 A →
5,9-6,0 lus ; consigne 10 A → 9,x-10 lus. Sous 230 V une puissance de phase
vaudrait ~1 380 W, pas 5,9. Publiés en Watt, l'app affichait « 5,9 W » là où la
borne dit « 5,9 A » — une unité qui ment, même famille que carBatteryLevel
publié sous un nom de mesure.

States renommés currentL1/L2/L3, unité Ampere. Les UUID ne changent PAS :
l'identifiant a toujours porté des ampères, c'est le nom qui mentait. Les noms
d'enum RegPower_Lx gardent en revanche l'intitulé du constructeur — c'est ainsi
qu'on retrouve l'adresse dans la feuille — avec l'avertissement en commentaire.

Bénéfice de bord : le déséquilibre entre phases, que ne donne aucune autre borne
du catalogue, utile à la protection de surcharge par phase (LM-1006-1).

3. Observations de campagne consignées
------------------------------------------------------------------------------
- Latence de consigne asymétrique, du bon côté pour la sécurité : montée
  6 → 10 A en ~30 s, descente 10 → 6 A en ~7 s.
- maxChargingCurrent (consigne, 0x177D) et intensity (effet, 0x0BCD) diffèrent
  pendant la transition — même couple commandé/réel qu'allocatedW vs mechanism.
  Ne pas « corriger » l'un avec l'autre.
- optimizerSuspended et conflictDetected étaient à True : la borne a son propre
  optimiseur et le HEMS le suspend. Second commandeur potentiel, à lire en
  diagnostic quand un écart consigne/effet n'est pas expliqué par la latence.
- La ThingClass ne déclare que deux actions, maxChargingCurrent et power :
  phaseCount est un réglage de câblage, jamais une commande.

Compile sans avertissement sous -Werror -Wall -Wextra.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A41CTzMvir1Y5ABPXW8xpB
2026-08-28 12:29:45 +02:00
1bf0b675c6 fix(v2c): la Trydan déclare phaseCount, et il est SAISI à l'appairage
Aucun registre de phases n'existe dans la carte Modbus V2C : la borne ne sait
pas dire si elle est câblée en mono ou en tri. La ThingClass ne le déclarait pas
non plus, et le repli du moteur pour un état absent est SILENCIEUX —
EvCharger::phaseCount() rend 1.

Sur une Trydan triphasée, cela publie un plancher de 1 380 W au lieu de 4 140
(6 A × 230 V × 1 au lieu de × 3) et convertit l'enveloppe allouée en un courant
trois fois trop élevé. La borne démarre, au mauvais courant, sans qu'aucun
journal ne le dise.

C'est la famille de défaut que le moteur vient de fermer en 3g-2 — un plancher
annoncé qui n'est pas celui qui gouverne le démarrage — rouverte côté plugin.
Trouvée la veille de la pose, en lisant la déclaration de classe plutôt qu'en
l'exécutant.

La valeur vient donc d'un RÉGLAGE (1 ou 3, défaut 1), recopié dans l'état que le
moteur lit, et réappliqué sur settingChanged pour qu'une borne recâblée se
corrige sans recréer le Thing. Le défaut 1 est celui du repli du moteur : une
installation qui ne renseigne rien se comporte exactement comme avant.

L'état porte minValue 1 / maxValue 3 — l'interface nymea evcharger définit
phaseCount avec ces bornes, et le validateur refuse la classe sans elles. S'y
conformer, c'est être lu par le moteur À TRAVERS l'interface, pas par une
convention propre au plugin.

Compile amd64 0 erreur / 0 warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
2026-08-27 15:15:31 +02:00
Patrick Schurig
ee3eaaa41e v2c: connexion TCP persistante — fin des rafales SYN
Diagnostic tcpdump : le plugin ouvrait/fermait la socket Modbus TCP à
chaque cycle de poll, générant 6-7 paquets SYN consécutifs qui saturaient
l'ESP32 WiFi de la borne (timeouts en boucle). mbpoll avec une connexion
persistante = 0 % d'échec.

Corrections :
- connectDevice() : guard "si déjà connecté → no-op" (élimine le warning
  "already in ConnectedState" et les FIN/RESET parasites).
- onConnectionStateChanged(true) : appelle initialize() directement au lieu
  de setReachable(true) — évite la ré-entrée initialize() via reachableChanged.
- initialize() : setReachable(true) uniquement au succès, après lecture
  MinIntensity/MaxIntensity ; guard || m_updating ajouté.
- update() : guard || m_initializing ajouté (pas de poll concurrent avec init).
- Timer : if (!master->update()) master->connectDevice() — le socket reste
  ouvert tant que la connexion TCP est active ; connectDevice() n'est appelé
  que quand le TCP est réellement coupé.
- initializationFinished(thing) : master->update() immédiat après reconnect.
- reachableChanged handler : suppression de l'appel à initialize() (déplacé).

Résultat attendu tcpdump : 1 seul SYN à l'établissement, puis uniquement
des échanges Modbus (P. length 12/13) toutes les 30 s, aucun FIN/RESET.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 10:37:09 +02:00
Patrick Schurig
87facfcc0a v2c: hw-validated improvements (Trydan fw2.4.6)
- Decode SlaveError (reg 0x0BC5) with full table (codes 0-10) into new
  slaveErrorMessage state; code 04 (WiFi reconnect) also surfaces in
  statusMessage for immediate SAV visibility.
- Add L1/L2/L3 phase powers (regs 0x0BD9-0x0BDB, confirmed hw) as
  diagnostic states polled every 30 s.
- Add RegChargeTime (0x0BC6) to enum for completeness (not yet exposed).
- Collapse debian/changelog to single 1.15.0+etm1 entry (new plugin).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 09:26:53 +02:00
Patrick Schurig
9f0743358a feat(v2c): remove NetworkDeviceMonitor, add poll-based connectivity + statusMessage
The Trydan ESP32 WiFi stack saturates under nymea's periodic ICMP pings
(observed: latency > 14 s, Modbus timeouts, charger drops off).

Changes:
- NetworkDeviceMonitor removed entirely from setupThing() and thingRemoved().
  The charger is expected to have a DHCP-reserved IP (documented in code).
  Address comes from trydanThingAddressParamTypeId param — already the correct
  path after the previous setup fix.
- Connectivity now derives from Modbus poll success/failure only (reachable()
  from TrydanModbusTcpMaster), NOT from network ping.  A ping response does
  not imply Modbus usability on this hardware.
- postSetupThing() timer (30 s): if not reachable, calls connectDevice() for
  reconnect; if reachable, calls update().  The 30 s period IS the backoff —
  no aggressive retry loop.
- Modbus timeout: 2 s → 5 s (tolerates residual WiFi latency spikes).
- Modbus retries per read: 1 → 0 (abort fast on first timeout; full poll
  sequence already aborts at first error via doNextRead).
- k_errorLimit: 5 → 3 (3 × 5 s = 15 s before marking unreachable).
- New state "statusMessage" (QString): set on disconnect with timestamp +
  cause ("timeout Modbus — vérifier signal WiFi de la borne"); cleared on
  successful poll.  Visible in nymea-app; helps SAV without SSH access.
- "networkdevice" removed from JSON interfaces (contract broken without monitor).

Invariants preserved: Big/Big float32 decode, no block read, PauseState+Lock
mirror, PauseDynamic conflict management, writeCompleted address filtering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 18:15:30 +02:00
Patrick Schurig
07dca8f1d2 fix(v2c): set host address before connectDevice() in setupThing()
Bug: ModbusTcpMaster: device ':502' ConnectionError when adding a trydan
by manual IP (createMethod "user") or after nymead restart before the
NetworkDeviceMonitor completes its ARP scan.

Root cause: TrydanModbusTcpMaster was constructed with
monitor->networkDeviceInfo().address(), which is null at first
registerMonitor() — the monitor only populates its cache after an
asynchronous ARP/mDNS resolution. connectDevice() then ran against
QHostAddress() → ":502".

Fix:
- Read the initial IP from the stored address param (always set at
  discovery or user-entry) and use it as the fallback when the monitor
  has not yet resolved the address.
- Guard the networkDeviceInfoChanged callback: only call setHostAddress()
  when the new address is non-null (the monitor emits transient null info
  during its internal refresh cycles).

Order is now: create master with valid address → wire callbacks →
connectDevice(). The monitor update path remains for DHCP renewals.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 17:13:37 +02:00
Patrick Schurig
596b9083db fix(v2c): correct logging category dcV2C + debian packaging + modbus.pri path
Build fixes found during first local compilation (qmake6 + g++ -Werror):

1. Logging category: nymea-plugininfocompiler generates 'dcV2C' (uppercase C)
   from the JSON vendor name "V2C". All three source files used 'dcV2c'
   (lowercase c) — corrected to 'dcV2C' in integrationpluginv2c.cpp,
   trydanmodbustcpmaster.cpp, v2ctcpdiscovery.cpp.

2. modbus.pri: hardcoded include(/usr/include/nymea-modbus/modbus-tool.pri)
   fails when libnymea-modbus-dev is not system-installed. Changed to a
   conditional: checks $$[QT_INSTALL_PREFIX] first, then NYMEA_MODBUS_PATH
   override, with a graceful fallback (warning + manual C++17 CONFIG flag).
   The v2c plugin has MODBUS_CONNECTIONS empty so the tool loop is a no-op
   either way; the fallback is safe.

3. debian/: add powersync-plugin-v2c package entry (control, .install,
   changelog entry 1.15.0+etm4).

Build verified: libnymea_integrationpluginv2c.so links against libnymea.so.1,
libnymea-modbus.so.1, Qt6Network/SerialBus/Core, 0 compiler warnings
(with -Werror -Wall -Wextra -std=c++17).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 15:20:11 +02:00
Patrick Schurig
671ab2bb85 fix(v2c): use qBound for min/maxIntensity clamp in initialize()
qMin(32, m_maxIntensity) gives 0 when firmware returns 0 for MaxIntensity
(aberrant value).  maxIntensity()==0 then causes every setMaxChargingCurrent
call to be clamped to 0, falling into the <6 A path which pauses charging
silently — the user sees the action succeed but the charger stops.

qBound(6, val, 32) is correct in all cases:
  - firmware reports 16 A (mono Trydan) → 16 (real installation limit kept)
  - firmware reports 0 (aberrant)        →  6 (safe floor, does not claim 32 A)
  - firmware reports 65535 (aberrant)    → 32 (IEC absolute ceiling)

The real installation limit still comes from MaxIntensity read on the device;
the bounds [6, 32] are only IEC guardrails, not hardcoded defaults.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 14:22:13 +02:00
Patrick Schurig
140860aa70 feat(v2c): add integration plugin, JSON metadata, and build plumbing
Étape 1 complete: V2C Trydan nymea plugin (Modbus TCP, status Partial).

Plugin (IntegrationPluginV2c):
  - discoverThings: delegates to V2cTcpDiscovery, maps results to
    ThingDescriptors; createMethod "user" bypasses discovery.
  - setupThing: registers a NetworkDeviceMonitor (IP tracking on DHCP
    renewal), creates TrydanModbusTcpMaster, wires reachableChanged →
    initialize() → state update on initializationFinished(), then starts
    periodic polls on a 30s PluginTimer.
  - updateThingStates: ChargeState 0/1/2 → pluggedIn/charging;
    power = (PauseState==0 AND Lock==0) per evcc trydan.go Enabled().
  - handleDynamicConflict: if Dynamic==1 (V2C internal PV optimizer),
    write PauseDynamic=1 to suppress it.  NEVER write Dynamic=0 (silences
    ChargePower telemetry).  Writes only on state transition to minimize
    flash wear.  cf. github.com/evcc-io/evcc/issues/28047.
  - Action "power": writes PauseState then Lock (always in mirror), then
    fire-and-forgets PauseDynamic if Dynamic==1.  Action handlers filter
    writeCompleted by register address to avoid reacting to concurrent
    background writes.
  - Action "maxChargingCurrent": clamps to [minIntensity, maxIntensity],
    writes Intensity.  Below 6A → pause instead of invalid setpoint.

JSON (integrationpluginv2c.json):
  - ThingClass "trydan" implementing evcharger + connectable + networkdevice.
  - settingsTypes: suspendInternalOptimizer (bool, default true) — allows
    the user to disable Dynamic conflict management if they want the V2C
    PID to remain active alongside the HEMS.
  - State "chargeEnergy" exposed as diagnostic only (NOT sessionEnergy) —
    firmware reliability on this register is unvalidated; cf. evcc #28047.

Build (v2c.pro):
  - MODBUS_CONNECTIONS intentionally empty: the modbus-tool code generator
    assumes non-overlapping block reads; leaving it empty skips generation
    while still linking nymea-modbus via the PKGCONFIG in modbus.pri.

VendorId 56c3e7bb… is new (no existing V2C vendor in the repo).
PluginId  f0692725… is new (generated for this plugin).

PORTING_STATUS_modbus.md documents register sources, the 4 items that
require validation on real hardware, and the "no beta matrix" constraint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 13:49:49 +02:00
Patrick Schurig
e774f8ee8a feat(v2c): add HTTP LAN discovery (V2cTcpDiscovery)
Architecture choice: Modbus TCP has no network discovery mechanism, so
discovery piggybacks on nymea's NetworkDeviceDiscovery (ARP/mDNS scan)
and then probes each host via GET /RealTimeData (V2C Trydan HTTP REST API).

The presence of a "Paused" field in the JSON response is used as a Trydan
fingerprint — it matches the charger model and is absent on generic devices.
cf. evcc charger/trydan.go realTimeData struct.

Probes run concurrently (one QNetworkReply per host) but the sequence
terminates via two independent paths that could both fire:
  1. The NetworkDeviceDiscoveryReply::finished callback arms a 3-second
     QTimer::singleShot to give outstanding probes time to complete.
  2. Each probe completion path checks (m_scanFinished && pendingProbes==0)
     and calls finishDiscovery() early if all probes are already done.
A m_finished bool guard prevents double-emission of discoveryFinished()
if both paths fire in quick succession (e.g. all probes complete exactly
as the timer fires).

Manual-entry (createMethod "user") skips discovery entirely; setupThing()
is called directly with the user-supplied IP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 13:49:22 +02:00
Patrick Schurig
06b75b1b06 feat(v2c): add Modbus transport abstraction + TCP implementation
Architecture choice: separate the register map / business logic from the
physical transport.  TrydanModbusMaster (abstract QObject) holds:
  - the complete ReadRegister / WriteRegister enums with addresses
  - float32 Big/Big decode helpers (memcpy pattern, matches pymodbus
    BinaryPayloadDecoder byteorder=Endian.Big wordorder=Endian.Big)
  - all last-polled cached values accessible via simple getters

TrydanModbusTcpMaster (concrete, Étape 1) wraps libnymea-modbus
ModbusTcpMaster.  Each register read is a distinct FC3(addr, 2)
transaction via a recursive runReadSequence/doNextRead pattern —
never a block read, because the V2C firmware's register windows
overlap (0x0BC2 and 0x0BC3 each span 2 registers, so a range read
starting at 0x0BC2 for ≥3 registers returns garbage for the second
value).  Writes use FC6 (single uint16, not float).

writeCompleted(quint16 address, bool success) carries the originating
register address so that concurrent background writes (e.g. PauseDynamic
from the conflict manager firing during an action) do not interfere with
action handlers waiting on a specific register's acknowledgement.

0x177E (Dynamic) is intentionally absent from WriteRegister: writing it
to 0 silences ChargePower telemetry even though the charger keeps running.
cf. evcc charger/trydan.go and github.com/evcc-io/evcc/issues/28047.

Étape 2 (RTU) will add TrydanModbusRtuMaster on the same interface;
no changes to plugin or register logic will be required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 13:49:11 +02:00