etm-powersync-plugins-modbus/v2c/trydanmodbustcpmaster.h
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

110 lines
4.1 KiB
C++

// 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;
// 3 consecutive failures → mark unreachable. With a 5 s timeout and no
// retry, 3 failed polls abort within 15 s — acceptable for HEMS decisions.
static constexpr int k_errorLimit = 3;
};
#endif // TRYDANMODBUSTCPMASTER_H