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
302 lines
11 KiB
C++
302 lines
11 KiB
C++
// 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);
|
|
// No retry per individual read: abort fast on first timeout so the 30s poll
|
|
// cycle is not blocked for minutes on a dropped connection.
|
|
m_modbusTcpMaster->setNumberOfRetries(0);
|
|
// 5 s tolerates the WiFi latency spikes observed on the Trydan ESP32
|
|
// without waiting forever when the charger is genuinely unreachable.
|
|
m_modbusTcpMaster->setTimeout(5000);
|
|
|
|
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()
|
|
{
|
|
// Guard: calling connectDevice() on an already-connected QModbusTcpClient
|
|
// logs a warning AND closes+reopens the TCP socket, generating a SYN burst
|
|
// that saturates the Trydan ESP32 WiFi stack. Skip if already connected.
|
|
if (m_modbusTcpMaster->connected())
|
|
return;
|
|
m_modbusTcpMaster->connectDevice();
|
|
}
|
|
|
|
void TrydanModbusTcpMaster::disconnectDevice()
|
|
{
|
|
m_modbusTcpMaster->disconnectDevice();
|
|
setReachable(false);
|
|
}
|
|
|
|
bool TrydanModbusTcpMaster::initialize()
|
|
{
|
|
// Also guard against an ongoing poll: init and update share the same sequential
|
|
// read pipeline; running both concurrently would interleave FC3 transactions
|
|
// and corrupt both sequences.
|
|
if (!m_modbusTcpMaster->connected() || m_initializing || m_updating)
|
|
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);
|
|
// TCP is live and init succeeded — mark reachable so the plugin can
|
|
// update states. This is the ONLY place setReachable(true) is called
|
|
// from the connection path; onConnectionStateChanged(true) no longer
|
|
// does it directly, avoiding a re-entrant initialize() call via the
|
|
// reachableChanged signal.
|
|
setReachable(true);
|
|
}
|
|
emit initializationFinished(ok);
|
|
});
|
|
return true;
|
|
}
|
|
|
|
bool TrydanModbusTcpMaster::update()
|
|
{
|
|
// Guard against concurrent init: both use the same sequential FC3 pipeline.
|
|
if (!m_modbusTcpMaster->connected() || m_updating || m_initializing)
|
|
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);
|
|
}},
|
|
{ RegPower_L1, [this](bool ok, quint16 h, quint16 l) {
|
|
if (ok) m_currentL1 = decodeFloat32BB(h, l);
|
|
}},
|
|
{ RegPower_L2, [this](bool ok, quint16 h, quint16 l) {
|
|
if (ok) m_currentL2 = decodeFloat32BB(h, l);
|
|
}},
|
|
{ RegPower_L3, [this](bool ok, quint16 h, quint16 l) {
|
|
if (ok) m_currentL3 = decodeFloat32BB(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;
|
|
// Start init directly — do NOT call setReachable(true) here.
|
|
// setReachable(true) is deferred to initialize()'s success callback so the
|
|
// plugin's reachableChanged handler can no longer trigger a second initialize()
|
|
// call via signal re-entry. The connection sequence is:
|
|
// TCP up → initialize() → setReachable(true) → reachableChanged(true) → UI update
|
|
initialize();
|
|
} else {
|
|
// TCP actually dropped: abort any in-flight sequence and go offline.
|
|
// The 30s poll timer will call connectDevice() → new TCP handshake.
|
|
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);
|
|
}
|
|
}
|