"""Modbus TCP driver — thin wrapper around pymodbus. This module provides a single public function ``read_blocks`` that performs one or more FC04 (Read Input Registers) block reads against a Modbus TCP gateway and returns a flat ``dict[register_address -> 16-bit_value]`` map. Design decisions ---------------- - **Only read function codes** (FC03/FC04) are exposed. There is no write path. - float32 decoding uses ``struct.unpack('>f', ...)`` directly rather than the pymodbus ``BinaryPayloadDecoder`` helper, which has had API churn across 3.x releases. Big-endian word order + big-endian byte order means the 4 raw bytes are already in standard network (big-endian) order. - ``ModbusTcpClient`` is used in synchronous mode (``connect()`` / ``close()``). The client is created fresh per call; this keeps the driver stateless and avoids threading concerns at the cost of one TCP handshake per read cycle. APScheduler jobs call this from a thread pool, so stateless is safer. - pymodbus 3.13.x uses ``device_id=`` as the slave-address keyword argument (renamed from ``slave=`` in earlier 3.x releases). Exceptions ---------- ``ModbusDriverError`` Base exception for all driver-level errors. ``ModbusConnectionError`` Raised when the TCP connection to the gateway cannot be established or is lost during the read. ``ModbusResponseError`` Raised when the gateway responds with a Modbus exception frame or when the returned register count does not match the requested count. """ from __future__ import annotations import logging import struct from typing import TYPE_CHECKING from pymodbus.client import ModbusTcpClient from pymodbus.exceptions import ConnectionException, ModbusException if TYPE_CHECKING: from collections.abc import Sequence logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Custom exceptions # --------------------------------------------------------------------------- class ModbusDriverError(RuntimeError): """Base class for all Modbus driver errors.""" class ModbusConnectionError(ModbusDriverError): """Cannot connect to (or lost connection with) the Modbus TCP gateway.""" class ModbusResponseError(ModbusDriverError): """Modbus gateway returned an exception frame or an unexpected response.""" # --------------------------------------------------------------------------- # Block definition type alias # --------------------------------------------------------------------------- #: A single read block: ``{"start": int, "count": int}``. Block = dict[str, int] # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def read_blocks( host: str, port: int, unit_id: int, blocks: Sequence[Block], *, timeout: float = 3.0, ) -> dict[int, int]: """Read one or more contiguous register blocks via FC04 (input registers). Parameters ---------- host: Hostname or IP address of the Modbus TCP gateway. port: TCP port (typically 502). unit_id: Modbus slave / unit address (the device's Meter ID for SDM120). blocks: Sequence of ``{"start": int, "count": int}`` dicts describing the contiguous register ranges to read. ``count`` is the number of 16-bit registers (not bytes). timeout: TCP connect/read timeout in seconds (default 3 s). Returns ------- dict[int, int] Flat mapping of ``{register_address: 16-bit_register_value}`` for every register returned across all blocks. Raises ------ ModbusConnectionError If the TCP connection to the gateway fails. ModbusResponseError If the gateway returns a Modbus exception frame or an unexpected number of registers. """ client = ModbusTcpClient(host, port=port, timeout=timeout) try: connected = client.connect() if not connected: raise ModbusConnectionError( f"Could not connect to Modbus gateway at {host}:{port}" ) registers: dict[int, int] = {} for block in blocks: start: int = block["start"] count: int = block["count"] _read_block(client, unit_id, start, count, registers) return registers except ConnectionException as exc: raise ModbusConnectionError( f"Connection to Modbus gateway {host}:{port} failed: {exc}" ) from exc except ModbusException as exc: raise ModbusResponseError(f"Modbus protocol error: {exc}") from exc finally: client.close() def _read_block( client: ModbusTcpClient, unit_id: int, start: int, count: int, result: dict[int, int], ) -> None: """Read one block and merge into *result*. Raises on any error.""" try: response = client.read_input_registers(start, count=count, device_id=unit_id) except ConnectionException as exc: raise ModbusConnectionError( f"Lost connection while reading registers 0x{start:04X}+{count}: {exc}" ) from exc if response.isError(): raise ModbusResponseError( f"Modbus exception reading registers 0x{start:04X}+{count}: {response}" ) regs = response.registers if len(regs) != count: raise ModbusResponseError( f"Expected {count} registers from 0x{start:04X}, got {len(regs)}" ) for offset, value in enumerate(regs): result[start + offset] = value # --------------------------------------------------------------------------- # Decoding helpers # --------------------------------------------------------------------------- def registers_to_float(hi_reg: int, lo_reg: int) -> float: """Decode two 16-bit registers to a big-endian IEEE-754 float32. The SDM120 (and most Modbus float devices) use big-endian word order: the high 16-bit register comes first, then the low register. Combined with big-endian byte order within each register, the raw 4-byte sequence is standard network-byte-order (big-endian) float32. Parameters ---------- hi_reg: The first (higher-address-range, most-significant) 16-bit register. lo_reg: The second (lower-address-range, least-significant) 16-bit register. Returns ------- float The decoded IEEE-754 single-precision floating-point value. Example ------- >>> registers_to_float(0x4366, 0x3334) 230.20001220703125 """ raw = struct.pack(">HH", hi_reg, lo_reg) return struct.unpack(">f", raw)[0]