feat(modbus): add DDSU666 profile and select read function code per profile
- Add CHINT DDSU666 profile (ddsu666.yaml): FC03 holding registers, voltage/ current/active power (kW)/reactive power (kvar)/PF/frequency, import+export active energy. Word/byte order left at big-endian as a documented best guess (manual has no float example) — to be confirmed on-device. - Add DDSU666-Modbus-Protocol.md reference extracted from the official manual, plus the source PDF (parity with the SDM120 reference). - Generalize driver.read_blocks to dispatch FC03 (holding) or FC04 (input) based on a function_code argument (default 4, SDM120 behaviour unchanged); the code is validated before any connection is attempted. - Wire profile.function_code through the CLI read command, the background poller, and the device /test endpoint — previously the profile field was declared but never honoured (read path was hardcoded to FC04). - Tests: default -> FC04, function_code=3 -> FC03 holding, invalid FC rejected before connecting.
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
"""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.
|
||||
one or more block reads against a Modbus TCP gateway — using either FC04
|
||||
(Read Input Registers) or FC03 (Read Holding Registers), selected per call
|
||||
via the ``function_code`` argument — and returns a flat
|
||||
``dict[register_address -> 16-bit_value]`` map. The function code comes from
|
||||
the device profile (e.g. SDM120 uses FC04, DDSU666 uses FC03).
|
||||
|
||||
Design decisions
|
||||
----------------
|
||||
@@ -80,9 +83,10 @@ def read_blocks(
|
||||
unit_id: int,
|
||||
blocks: Sequence[Block],
|
||||
*,
|
||||
function_code: int = 4,
|
||||
timeout: float = 3.0,
|
||||
) -> dict[int, int]:
|
||||
"""Read one or more contiguous register blocks via FC04 (input registers).
|
||||
"""Read one or more contiguous register blocks via FC03 or FC04.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@@ -96,6 +100,11 @@ def read_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).
|
||||
function_code:
|
||||
Modbus read function code: ``4`` for FC04 (Read Input Registers,
|
||||
default — SDM120) or ``3`` for FC03 (Read Holding Registers —
|
||||
DDSU666 and other devices that expose measurements as holding
|
||||
registers). Comes from the device profile's ``function_code`` field.
|
||||
timeout:
|
||||
TCP connect/read timeout in seconds (default 3 s).
|
||||
|
||||
@@ -107,12 +116,21 @@ def read_blocks(
|
||||
|
||||
Raises
|
||||
------
|
||||
ModbusDriverError
|
||||
If ``function_code`` is neither 3 nor 4 (validated before any
|
||||
connection is attempted).
|
||||
ModbusConnectionError
|
||||
If the TCP connection to the gateway fails.
|
||||
ModbusResponseError
|
||||
If the gateway returns a Modbus exception frame or an unexpected
|
||||
number of registers.
|
||||
"""
|
||||
if function_code not in (3, 4):
|
||||
raise ModbusDriverError(
|
||||
f"Unsupported read function code FC{function_code:02d} "
|
||||
f"(only FC03 holding-register and FC04 input-register reads are supported)"
|
||||
)
|
||||
|
||||
client = ModbusTcpClient(host, port=port, timeout=timeout)
|
||||
try:
|
||||
connected = client.connect()
|
||||
@@ -125,7 +143,7 @@ def read_blocks(
|
||||
for block in blocks:
|
||||
start: int = block["start"]
|
||||
count: int = block["count"]
|
||||
_read_block(client, unit_id, start, count, registers)
|
||||
_read_block(client, unit_id, start, count, registers, function_code=function_code)
|
||||
|
||||
return registers
|
||||
|
||||
@@ -145,10 +163,19 @@ def _read_block(
|
||||
start: int,
|
||||
count: int,
|
||||
result: dict[int, int],
|
||||
*,
|
||||
function_code: int,
|
||||
) -> None:
|
||||
"""Read one block and merge into *result*. Raises on any error."""
|
||||
"""Read one block and merge into *result*. Raises on any error.
|
||||
|
||||
``function_code`` is assumed already validated to be 3 or 4 by the caller
|
||||
(``read_blocks``); 3 dispatches FC03 (holding) and 4 dispatches FC04 (input).
|
||||
"""
|
||||
try:
|
||||
response = client.read_input_registers(start, count=count, device_id=unit_id)
|
||||
if function_code == 3:
|
||||
response = client.read_holding_registers(start, count=count, device_id=unit_id)
|
||||
else: # function_code == 4 (input registers)
|
||||
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}"
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
name: ddsu666
|
||||
description: CHINT DDSU666 single-phase smart meter
|
||||
function_code: 3 # holding registers (FC03) — DDSU666 has NO input registers (no FC04)
|
||||
word_order: big # high register first — ASSUMED; verify with a known voltage reading
|
||||
byte_order: big # high byte first within each register (confirmed by manual CRC example)
|
||||
|
||||
# NOTE: the manual gives no worked float-decode example, so word_order is a best-guess
|
||||
# (standard big-endian, high register first, matching sdm120). After wiring the meter,
|
||||
# read 0x2000 (voltage) — it should decode to ~230 V. If it decodes to garbage, the
|
||||
# device uses the opposite word order and this profile (and the decoder) need adjusting.
|
||||
# Byte order is confirmed big-endian from the manual (Appendix A, Table A.4: 0x1388 -> 13 88).
|
||||
|
||||
blocks:
|
||||
# Instantaneous quantities 0x2000–0x200F: voltage, current, P, Q, (rsv), PF, (rsv), Freq.
|
||||
# 16 contiguous registers — single bulk read. (DDSU666 manual Table 9.)
|
||||
- { start: 0x2000, count: 0x0010 }
|
||||
# Active energy — import (0x4000) and export (0x400A) read as two small blocks rather
|
||||
# than one span, to avoid touching the undocumented/reserved 0x4002–0x4009 gap.
|
||||
- { start: 0x4000, count: 0x0002 }
|
||||
- { start: 0x400A, count: 0x0002 }
|
||||
|
||||
metrics:
|
||||
# Addresses are the raw Modbus protocol addresses (hex) from DDSU666 manual Table 9,
|
||||
# read via FC03. Each float32 occupies two consecutive 16-bit registers.
|
||||
|
||||
- key: voltage
|
||||
address: 0x2000 # U — Voltage (V)
|
||||
type: float32
|
||||
unit: "V"
|
||||
device_class: voltage
|
||||
ha_component: sensor
|
||||
|
||||
- key: current
|
||||
address: 0x2002 # I — Current (A)
|
||||
type: float32
|
||||
unit: "A"
|
||||
device_class: current
|
||||
ha_component: sensor
|
||||
|
||||
- key: active_power
|
||||
address: 0x2004 # P — Active power. Manual unit is kW (NOT W like sdm120).
|
||||
type: float32
|
||||
unit: "kW"
|
||||
device_class: power
|
||||
ha_component: sensor
|
||||
|
||||
- key: reactive_power
|
||||
address: 0x2006 # Q — Reactive power (kvar)
|
||||
type: float32
|
||||
unit: "kvar"
|
||||
device_class: reactive_power
|
||||
ha_component: sensor
|
||||
|
||||
- key: power_factor
|
||||
address: 0x200A # PF — Power factor (dimensionless)
|
||||
type: float32
|
||||
unit: ""
|
||||
device_class: power_factor
|
||||
ha_component: sensor
|
||||
|
||||
- key: frequency
|
||||
address: 0x200E # Freq — Frequency (Hz)
|
||||
type: float32
|
||||
unit: "Hz"
|
||||
device_class: frequency
|
||||
ha_component: sensor
|
||||
|
||||
- key: import_energy
|
||||
address: 0x4000 # Ep — positive/forward active energy (kWh)
|
||||
type: float32
|
||||
unit: "kWh"
|
||||
device_class: energy
|
||||
state_class: total_increasing
|
||||
ha_component: sensor
|
||||
|
||||
- key: export_energy
|
||||
address: 0x400A # -Ep — reverse active energy (kWh)
|
||||
type: float32
|
||||
unit: "kWh"
|
||||
device_class: energy
|
||||
state_class: total_increasing
|
||||
ha_component: sensor
|
||||
Reference in New Issue
Block a user