etm-powersync-plugins-modbus/v2c/trydanmodbustcpmaster.cpp
Patrick Schurig 076a0dcae9 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

272 lines
9.4 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()
{
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);
}
}