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>
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>
Les deux correctifs V2C de la campagne du 2026-08-28 : sessionEnergy construit
par accumulation, et les registres 0x0BD9-0x0BDB republiés en ampères.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A41CTzMvir1Y5ABPXW8xpB
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
Constaté au banc à 14:41:58 : l'avertissement « has a model 160 but no
StCha/StDisCha block » a été émis une fois, au démarrage, sur une machine dont
les deux blocs existent et dont le currentPower a ensuite suivi la batterie
correctement en charge, en décharge et au repos.
Cause. libnymea-sunspec crée les blocs répétés d'un modèle depuis
`initFinished`, émis pendant la PREMIÈRE lecture, juste avant `blockUpdated`.
Sur ce premier signal les blocs existent mais n'ont jamais été remplis : tous
les `IDStr` sont vides, rien ne se classe, et `hasBatteryBlocks()` répond faux.
Cette réponse est exacte et hors sujet — la question n'était pas « pas de
batterie » mais « pas encore lu ».
Ce que le faux positif coûtait. Ce n'est pas du bruit : le README dit à
l'exploitation de chercher cette ligne EN PREMIER pour distinguer une batterie
cassée d'une batterie non mesurable. Une ligne qui sort à chaque démarrage sain
ne distingue plus rien, et le diagnostic que ce lot venait d'installer était
mort-né.
Les deux questions sont maintenant posées séparément : `blocksAreIdentified()`
avant `hasBatteryBlocks()`. Sans identification, on se tait et on attend le tour
suivant — `readBlockData()` passe à chaque rafraîchissement de toute façon.
Le garde en double au moment du binding disparaît aussi : les deux updaters se
gardent eux-mêmes, un seul endroit à tenir à jour plutôt que deux qui peuvent
diverger.
Deux tests figent la distinction, dont un sur le cas où l'avertissement DOIT
sortir — un GEN24 sans batterie, lu, identifié, sans bloc batterie.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y3gzsuwENJfJtvuNDT3xGn
sunspecStorage déclarait `battery` mais pas `energystorage`. Les trois
consommateurs qui filtrent par cette interface ne la trouvaient donc pas : le
bilan de puissance de nymea (`currentPowerStorage`, celui que le CLIENT lit sur
son tableau de bord), la réserve de stockage de la recharge VE, et celle du
waterfall. Vérifié sans authentification dans `energylogs.sqlite` de .75 : la
colonne `storage` de `powerBalance` est à 0.0 sur toutes les lignes, pendant
que la batterie affiche 50 % et « Holding ». Elle n'était pas à zéro, elle
était invisible.
Ce n'était pas une ligne de JSON. `energystorage` exige `currentPower` (W,
signé) et `capacity` (kWh), qu'aucun des quatorze états ne portait, et les
déclarer sans les câbler aurait donné une batterie éternellement inerte — pire
que son absence, puisque la réserve lirait « ni charge ni décharge » au lieu de
« je ne sais pas ».
D'où viennent les deux états. Le modèle 124 ne porte AUCUNE puissance : WChaMax
est un maximum, StorAval est en ampères-heures. La puissance instantanée vit
dans le modèle 160, réparti sur deux blocs `StCha` et `StDisCha` dont le `DCW`
est un uint16 toujours positif, l'inactif à zéro. D'où
`currentPower = DCW[StCha] - DCW[StDisCha]`, positif en charge, conformément à
libnymea/interfaces/energystorage.json (« positive is 'consumed' »). C'est
l'INVERSE de la convention constructeur du contrat §4, et
`FroniusRules::batteryPower()` est réécrite comme la négation stricte de la
nouvelle `storageCurrentPower()` pour qu'aucune des deux ne dérive seule.
`capacity` vient de WHRtg du modèle 120, et l'état est laissé tranquille plutôt
que mis à zéro quand le nameplate ne l'implémente pas.
Le signe est MESURÉ, pas déduit. Banc .211 unité 1, consigne batterie imposée
par MQTT, registres relus en lecture seule :
+2000 W -> StCha 2000,0 W StDisCha 0,0 W ChaSt Charging
-1500 W -> StCha 0,0 W StDisCha 1500,0 W ChaSt Discharging
Le même relevé a montré `DCW_SF` passant de -2 à -1 entre repos et charge : le
facteur d'échelle n'est jamais mis en cache d'un relevé à l'autre.
Écart assumé, D-9 dans DIVERGENCES.md : `sunspecStorage` est partagé avec le
vendor générique, et les interfaces se déclarent par thingClass. Un appareil
générique publiant le 124 sans le 160 porte désormais `energystorage` avec un
`currentPower` figé à zéro. Les trois alternatives sont pires — une thingClass
Fronius distincte détruirait le Thing existant et son historique. Le plugin
avertit donc UNE FOIS par thing quand il ne trouve pas de source de puissance,
en distinguant « pas de modèle 160 » de « modèle 160 sans bloc batterie ». Ça ne
remplace pas la mesure, mais ça retire le silence.
La cohérence des unités avec l'interface est vérifiée à la COMPILATION par
nymea-plugininfocompiler : mettre `KiloWattHour` sur `currentPower` fait échouer
le build. Onze tests `test_D9_*` figent le reste.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y3gzsuwENJfJtvuNDT3xGn
Records the new nymea-plugin-sunspec-etm01 binary package, the Fronius
vendor it carries, the storage setpoint corrections and the added
Build-Depends. The previous entry described an unrelated abbterra fix,
so the package would have shipped under a version that never mentioned
it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The plugin was added to PLUGIN_DIRS by the vendoring commit but never
packaged, so dh_missing found its .so in debian/tmp with nowhere to go
and aborted the whole source package: no .deb at all came out of
experimental-silo, not even for the four unrelated plugins.
Declares the binary package and adds libnymea-sunspec-dev to
Build-Depends. Upstream does not need that dependency because it builds
libnymea-sunspec from the same source tree; this repository vendors only
the plugin and links against the installed library.
Named with the -etm01 fork suffix rather than the upstream name, which
our own repository already publishes in stable and testing as a mirror
of 1.15.2. Same file path and same pluginId, hence Conflicts, Replaces
and Provides against nymea-plugin-sunspec so the two can never be
installed side by side.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to the etm1 fixes, driven by live debugging against a real ABB
Terra AC 7 kW behind a Waveshare RS485->TCP gateway (<ip-lan>, slave 2):
- etm3: disable TCP auto-discovery (createMethods = "user" only). Discovery
blind-scans the LAN and SIGSEGVs nymead as soon as any Modbus device answers
:502 with an exception response. The crash is in the generated reachability
handler (abbterramodbustcpconnection.cpp:2062 dereferences the check-reachable
reply after it is freed); it could not be fixed from the discovery code alone.
A gateway's slave id can't be discovered anyway, so manual add is required.
- etm2: still defer/guard AbbTerraTcpDiscovery::cleanupConnection() teardown
(kept for correctness even though discovery is now off by default).
- etm4: setupTcpThing() never called connectDevice(), so manual setup hung until
timeout. Start the connection when the network monitor reports the device.
- etm5: decode the serial-number and firmware register fields as BCD per ABB
spec §5.1 (0x46 = week 46, not 70; 0x22 = 22 kW). The binary reading rejected
real chargers ("does not match the ABB Terra AC Modbus register map").
Verified end to end: charger adds cleanly (TACW7-4-4624-S0034), states poll and
actions execute, no SIGSEGV. The underlying discovery/generated-code crash is
tracked separately.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The status block (12 registers × 2 words = 24 words) was read as a
single Modbus request, producing a 53-byte response that overflows the
RT38B01 433 MHz radio module's 31-byte frame limit.
Dissolve the status block into individual registers with
readSchedule:update so queuedRequests sends them one at a time
(max 9 bytes per response). The deviceInfo init block (8 words → 21
bytes) is left untouched.
Bump to 1.15.0+etm6.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
reachableChanged(true) never fires within the nymea setup timeout when
the RTU link is slow (e.g. 433 MHz radio relay). Add a fallback:
trigger initialize() immediately if master is already connected
(QTimer::singleShot 500 ms), and also on master::connectedChanged.
The existing reachableChanged path is kept for TCP-style RTU adapters.
Bump to 1.15.0+etm5.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 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>
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>