diff --git a/app/api/routes/api/modbus.py b/app/api/routes/api/modbus.py index 6b56146..15a6143 100644 --- a/app/api/routes/api/modbus.py +++ b/app/api/routes/api/modbus.py @@ -488,6 +488,7 @@ def test_read( device.port, device.unit_id, [{"start": b.start, "count": b.count} for b in profile.blocks], + function_code=profile.function_code, ) payload: dict[str, Any] = decode_profile(profile, registers) return ModbusTestReadResponse(ok=True, payload=payload) diff --git a/app/integrations/modbus/driver.py b/app/integrations/modbus/driver.py index 64d9ddf..3d62206 100644 --- a/app/integrations/modbus/driver.py +++ b/app/integrations/modbus/driver.py @@ -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}" diff --git a/app/integrations/modbus/profiles/ddsu666.yaml b/app/integrations/modbus/profiles/ddsu666.yaml new file mode 100644 index 0000000..bd7b3a0 --- /dev/null +++ b/app/integrations/modbus/profiles/ddsu666.yaml @@ -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 diff --git a/app/services/modbus_poll.py b/app/services/modbus_poll.py index 1046531..359ba40 100644 --- a/app/services/modbus_poll.py +++ b/app/services/modbus_poll.py @@ -64,6 +64,7 @@ def poll_device(session: Session, device: ModbusDevice) -> ModbusReading | None: device.port, device.unit_id, [{"start": b.start, "count": b.count} for b in profile.blocks], + function_code=profile.function_code, ) payload = profiles.decode(profile, registers) diff --git a/docs/references/DDSU666 Single phase Smart Meter.pdf b/docs/references/DDSU666 Single phase Smart Meter.pdf new file mode 100644 index 0000000..bc173f5 Binary files /dev/null and b/docs/references/DDSU666 Single phase Smart Meter.pdf differ diff --git a/docs/references/DDSU666-Modbus-Protocol.md b/docs/references/DDSU666-Modbus-Protocol.md new file mode 100644 index 0000000..5479ca4 --- /dev/null +++ b/docs/references/DDSU666-Modbus-Protocol.md @@ -0,0 +1,198 @@ +# DDSU666 Modbus 协议(从官方 PDF 提取) + +> 来源:`docs/references/DDSU666 Single phase Smart Meter.pdf` +> (CHINT / 正泰仪表 **DDSU666 Single phase Smart Meter — Operation Manual**,文档号 `ZTY0.464.1224`,版本 **V2**,2020 年 8 月;厂商 Zhejiang Chint Instrument & Meter Co., Ltd.) +> 本文件是 PDF 的可读化提取,供本项目的 Modbus 采集驱动设计参考。**以官方 PDF 为准**,本文件如有出入以 PDF 为准。 +> 同类文档见 SDM120 的 `SDM120-Modbus-Protocol.md`;两表差异较大,见下方 §6「与 SDM120 的关键差异」。 + +## 设备速览(来自手册 Table 1 / Table 5) + +| 项 | DDSU666(直接接入) | DDSU666-CT(经互感器) | +| --- | --- | --- | +| 精度等级 | Active Class B | Active Class C | +| 参考电压 | 230 V | 230 V | +| 电流规格 | 0.25–5(80) A | 0.015–1.5(6) A | +| 表常数 | 800 imp/kWh | 6400 imp/kWh | +| 接入方式 | 直接接入 | 经电流互感器 | + +- 单相电子式电能表,DIN35mm 导轨安装;测量电压、电流、有功/无功功率、频率、功率因数、正/反向有功电能。 +- 电能测量范围 `0~999999.99 kWh`(LCD 只显示 6 位,自动移动小数点)。 +- 通信:RS485,**Modbus-RTU**(也支持 DL/T 645-2007,可切换,见 §5 `0005H ChangeProtocol`)。 +- 手册 Table 1 标注 Frequency Reference = 60Hz,但 LCD 示例又写 `F=50.00Hz`(手册自身不一致);**实际频率以寄存器 `200EH` 读数为准**,不要把 50/60 写死。 + +## 0. 本项目的接入方式(重要) + +DDSU666 物理层是 **Modbus RTU(RS-485 串口)**,半双工。和 SDM120 一样,本项目通过一个 **Modbus-TCP 网关**接入: + +- 后端用 **Modbus TCP**(`IP:port`)连到网关,网关在串口侧转成 RTU 与电表通信。 +- TCP 帧用 MBAP header、**无 CRC**(CRC 由网关在 RTU 侧处理)。本文档里 RTU 帧的 `CRC (Lo/Hi)` 字段在 TCP 模式下不需要我们关心。 +- **Slave Address / Unit ID = 电表的通信地址 Addr**(范围 1–247;面板按键只能设 1–99;见 §5 `0006H`),在 TCP 请求里作为 unit id 传入。 +- 若以后直连串口(RTU),才需要管波特率 / 数据格式 / CRC:**默认串口格式是 8 数据位、无校验、2 停止位(8N2)**,与 SDM120 的 8N1 不同——直连时务必对齐。 +- 电表必须处于 **Modbus 协议模式**(而非 DL/T 645)才能用本协议;可经面板长按切换,或写 `0005H = 2`(见 §5)。 + +## 1. 协议帧格式(Appendix A) + +异步传输,按字节为单位。一帧 10 位字符 = **1 起始位(0) + 8 数据位(无校验) + 2 停止位(1)**(其它格式可定制)。 + +### 信息帧结构(Table A.1) + +| 字段 | 长度 | 说明 | +| --- | --- | --- | +| Start(起始) | >3.5 字符静默 | 帧间至少 3.5 字符空闲时间作为分隔 | +| Address code(地址码) | 1 字节 | 目标从机地址 1–247;每个从机在总线上地址唯一 | +| Function code(功能码) | 1 字节 | 仅支持 **03H / 10H**(见 §2) | +| Data(数据域) | n 字节 | 随功能码不同而不同(起始地址、寄存器数、寄存器数据等) | +| CRC check code(CRC 校验) | 2 字节 | 16-bit CRC(**低字节在前、高字节在后**;多项式 `A001`) | +| End(结束) | >3.5 字符静默 | 帧间静默 | + +> TCP 网关模式下 Start/End 静默与 CRC 由网关处理,本项目不关心。 + +### 功能码 03H 示例(读寄存器,Table A.3/A.4) + +读从机 `01H`、起始地址 `0CH`、2 个寄存器: + +- 主机发送:`01 03 00 0C 00 02 04 08`(最后 `04 08` 是 CRC,低字节 `04` 在前)。 +- 从机返回(设 `0CH/0DH` 内容为 `0000H`、`1388H`):`01 03 04 00 00 13 88 F7 65` + - `04` = 字节数;`00 00` = `0CH` 数据;`13 88` = `0DH` 数据;`F7 65` = CRC(低字节 `F7` 在前)。 + +> **注意**:单个 16-bit 寄存器内是「高字节在前、低字节在后」(Table A.4 里 `0DH` 数据返回 `13 88` = `0x1388`)。这一点对解码浮点的字节序很关键,见 §3。 + +### 功能码 10H 示例(写多个寄存器,Table A.5/A.6) + +向从机 `01H`、起始地址 `00H` 连续写 3 个寄存器 `0002H,1388H,000AH`: + +- 主机发送:`01 10 00 00 00 03 06 00 02 13 88 00 0A 9B E9` + - `06` = 写入字节数(3 寄存器 × 2 字节);随后是 3 个寄存器的数据;末尾 `9B E9` CRC。 +- 从机返回:`01 10 00 00 00 03 80 08`(回显起始地址 + 寄存器数 + CRC `80 08`)。 + +### 异常响应(Table A.7/A.8) + +- 异常时返回的 Function Code = **原功能码 + 128**(即最高位置 1,`03H→83H`、`10H→90H`)。 +- 数据为单字节 Error Code: + +| Error Code | 含义 | 说明 | +| --- | --- | --- | +| `01H` | Illegal function code | 收到的功能码本表不支持 | +| `02H` | Illegal register address | 寄存器地址超出有效范围 | +| `03H` | Illegal data value | 数据值超出对应地址的取值范围 | + +## 2. 功能码 + +DDSU666 **只支持两个功能码**(Table A.2): + +| 功能码 | 作用 | 说明 | +| --- | --- | --- | +| **03H** | Read register(读寄存器) | 读一个或多个寄存器——**测量值、电量、配置全部走它** | +| **10H** | Write multiple registers(写多个寄存器) | 向 n 个连续寄存器写 n 个 16-bit 数据(改配置 / 清电量) | + +> ⚠️ **DDSU666 没有「输入寄存器 / FC04」概念**——所有数据(包括电压电流功率)都用 **FC 03H** 读保持寄存器。这是它和 SDM120(测量值走 FC04)最大的踩坑差异,见 §6。 + +## 3. 数据编码 + +DDSU666 有两类数据: + +1. **配置 / 参数寄存器(§5,`0000H`–`0010H`)**:每个 1 个寄存器、`16-bit with symbols`(**16 位有符号整数**)。 +2. **测量 / 电量寄存器(§4,`2000H`+ / `4000H`+)**:每个参数 = **32-bit IEEE-754 单精度浮点**(手册写 “single precision floating decimal”),占 **2 个相邻寄存器**(Length = 2 Word)。 + +**浮点字节序 / 字序** + +- **字节序(byte order)= 大端:寄存器内高字节在前** —— 由 Table A.4 的 `0x1388` 返回为 `13 88` 确认。 +- **字序(word order,两个寄存器谁是高 16 位)**:手册**没有给出浮点解码的实例**,未明确标注。按标准 Modbus 浮点惯例应为**大端字序(高寄存器在前,`ABCD`)**,与本项目 SDM120 驱动一致(`registers_to_float` 用 `>f`,高寄存器在前)。 +- ⚠️ **需上机实测确认**:读 `2000H`(电压)应解出 ~230V 这样的合理值;若解出乱数,多半是字序相反,改成「低寄存器在前」再试。CHINT 同系列(DTSU/DDSU666)现场固件偶有字序差异,**首次接入务必用一个已知量(电压)校验**,不要凭手册想当然。 + +> Python 解码(大端、高寄存器在前):`struct.unpack('>f', struct.pack('>HH', hi_reg, lo_reg))[0]`。 +> pymodbus:`BinaryPayloadDecoder.fromRegisters(regs, byteorder=Endian.BIG, wordorder=Endian.BIG)`。 + +## 4. 测量 / 电量寄存器表(FC 03H 读) + +全部为只读、`Float`(32-bit),每项占 **2 个寄存器**。地址为 Modbus 协议原始地址(即帧里的 Start Register Address Hi/Lo),手册用十六进制。 + +### 4.1 瞬时量(“Electric quantity of the secondary side”,`2000H` 段) + +| 地址(hex) | 参数 | 代号 | 单位 | 备注 | +| --- | --- | --- | --- | --- | +| `2000H` | 电压 Voltage | U | V | | +| `2002H` | 电流 Current | I | A | | +| `2004H` | 有功功率 Active power | P | **kW** | 手册标注 “the unit is KW”——**不是 W** | +| `2006H` | 无功功率 Reactive power | Q | **kvar** | | +| `2008H` | (保留 RESERVED) | — | — | 占 2 寄存器,跳过 | +| `200AH` | 功率因数 Power factor | PF | — | 无量纲 | +| `200CH` | (保留 RESERVED) | — | — | 占 2 寄存器,跳过 | +| `200EH` | 频率 Frequency | Freq | Hz | | + +### 4.2 电量(“Electrical data of the secondary side”,`4000H` 段) + +| 地址(hex) | 参数 | 代号 | 单位 | 备注 | +| --- | --- | --- | --- | --- | +| `4000H` | 正向(导入)有功电能 Active in electricity | Ep | kWh | 正向 / forward active energy | +| `400AH` | 反向(导出)有功电能 Reverse in electricity | -Ep | kWh | 反向 / reverse active energy | + +> 手册里 `4000H` 与 `400AH` 之间(`4002H`–`4009H`)未列出,视为保留/未文档化。 + +**读取分块建议** + +- 瞬时量:`2000H`–`200FH` 共 **16 个寄存器连续**,一次块读即可覆盖 U…Freq(含两段 RESERVED,解码时跳过)。 +- 电量:`4000H`(2 寄存器)与 `400AH`(2 寄存器)相距较远,分两小块读,或读 `4000H`–`400BH`(12 寄存器)一次取出后挑用——以网关 / 电表是否允许跨保留地址块读为准,谨慎起见分开读更稳。 + +### 常用核心子集(日常监控够用) + +电压 `2000H`、电流 `2002H`、有功功率 `2004H`(kW)、功率因数 `200AH`、频率 `200EH`、正向有功电能 `4000H`、反向有功电能 `400AH`。 + +## 5. 配置 / 参数寄存器表(FC 03H 读 / FC 10H 写)(Table 9) + +每个 1 个寄存器、**16-bit 有符号整数**。`R/W` 列来自手册。 + +| 地址(hex) | 代号 | 含义 | R/W | 取值 / 说明 | +| --- | --- | --- | --- | --- | +| `0000H` | UCode | 编程密码 Programming password code | R/W | 写配置前的密码字 | +| `0001H` | REV. | 保留;**实际读出的是版本号** | R | | +| `0002H` | ClrE | 电能清零 CLr.E | R/W | **写 `1` 清除总电量**(不可逆,慎用) | +| `0003H`–`0004H` | RESERVED | 保留 | — | | +| `0005H` | ChangeProtocol | 协议切换 | R/W | **`2` = Modbus-RTU**,`1` = DL/T 645-2007 | +| `0006H` | Addr | 通信地址 | R/W | 1–247(面板按键仅 1–99) | +| `0007H`–`000AH` | RESERVED | 保留 | — | | +| `000BH` | Meter type | 表型 Meter type | R | 只读设备类型标识 | +| `000CH` | BAud | 通信波特率 | R/W | **`1`=2400bps,`2`=4800bps,`3`=9600bps**(手册寄存器仅列这三档;通信章另提到也支持 1200bps) | +| `000DH`–`0010H` | RESERVED | 保留 | — | | + +> ⚠️ 写 `0002H`(清电量)、`0006H`(改地址)、`000CH`(改波特率)、`0005H`(切协议)都会改变电表状态或通信参数,配错可能**清空累计电量**或**导致通信中断**。本项目默认**只读采集**,不在自动化链路里写电表配置寄存器。 +> 写配置通常需先经 `0000H UCode` 密码字校验;具体密码值手册正文未给出,需向厂商确认或经面板操作。 + +## 6. 与 SDM120 的关键差异(迁移 / 复用驱动时必看) + +本项目已有 SDM120 profile(`app/integrations/modbus/profiles/sdm120.yaml`)。DDSU666 **不能照搬**,主要差异: + +| 维度 | SDM120 (Eastron) | DDSU666 (CHINT) | +| --- | --- | --- | +| 读测量值功能码 | **FC 04**(输入寄存器 3X) | **FC 03**(保持寄存器,无 FC04) | +| 测量值起始地址 | `0x0000` 起(30001) | 瞬时量 `0x2000` 起;电量 `0x4000`/`0x400A` | +| 有功功率单位 | **W**(瓦) | **kW(千瓦)** —— 入库前注意换算 / 单位标注 | +| 无功功率单位 | VAr | kvar | +| 配置寄存器格式 | Float(FC03/16) | **16-bit 有符号整数**(FC03/10) | +| 写功能码 | 16 / 0x10 | 10H(同 0x10) | +| 串口默认格式 | 8N1(1 停止位) | **8N2(2 停止位)** | +| 多协议 | 仅 Modbus | Modbus **与 DL/T 645-2007 可切换**(需确保在 Modbus 模式) | +| 浮点字序 | 大端、高寄存器在前(手册有实例佐证) | 字节序大端已确认;**字序手册无实例,需上机实测** | + +## 7. 给本项目采集驱动的要点小结 + +1. 走 **Modbus TCP 网关**:`ModbusTcpClient(host, port)`,`slave=`;电表须在 **Modbus 协议模式**。 +2. 所有读取(测量 + 电量 + 配置)都用 **FC 03H**——**没有 FC04**。 +3. 测量值在 `0x2000` 段、电量在 `0x4000`/`0x400A`,均为 **float32**;解码大端字节序,**字序默认高寄存器在前但务必用电压实测校验**。 +4. **有功功率单位是 kW、无功是 kvar**——与 SDM120 的 W/VAr 不同,新建 profile / 入库映射时单位别抄错。 +5. 配置寄存器(`0x0000`–`0x0010`)是 **16-bit 有符号整数**,不是 float。 +6. 默认**只读**;`0002H` 写 1 会**清空累计电量**、`0006H/000CH/0005H` 会改通信参数,自动化链路里一律不写。 +7. 新建 profile 时这是 `ddsu666` 这一个 register profile 的定义;建议 `function_code: 3`、`word_order: big`(先按大端字序,接入后用电压读数验证)、瞬时量与电量分块读。 + +## 8. 实测记录(真机验证,2026-06-30) + +首次接入一台 **DDSU666 直接接入版(5(80)A)** 实测,确认以下几点: + +- **字序大端,确认无误**:电压 / 电流 / 频率 / 电能用「大端、高寄存器在前」解码全部得到合理值(如 233.9 V / 0.055 A / 49.99 Hz),与 §3 的假设一致。`ddsu666.yaml` 的 `word_order: big` / `byte_order: big` **无需修改**,§3 里「字序需上机实测」一项可视为已关闭。 +- **FC03 读通**:所有量走 FC03,profile `ddsu666` 在采集链路(CLI `read` / 设备 `/test` / 后台轮询)中工作正常。 +- **低电流下「瞬时功率读 0、但电能照常累加」**:测试负载仅为一台 PoE 交换机(≈230 V / 0.05 A,真实有功仅几瓦),**远低于本表测量量程下限 Imin≈0.25 A**。此工况下: + - 瞬时 `active_power`(`0x2004`)与 `power_factor`(`0x200A`)寄存器返回**全零**(原始 hex `0x0000 0x0000`);电表 LCD 上功率在 0~3.3 W、PF 在 0~0.6 之间抖动。 + - 抖动成因 = **低电流测量噪声 + 开关电源(SMPS,无 PFC)畸变电流**(电流为电压峰值附近的窄脉冲、谐波重 → 畸变功率因数天然偏低)。**不是**「采样率与开关频率拍频」:计量芯片 SH79F7019 采样在 kHz 量级,远低于开关电源 50–200 kHz 的开关频率,两者不在一个频段。 + - 但累计电能 `import_energy`(`0x4000`)**正常累加**(实测 0 → 0.01 kWh)——电表内部积分器在防潜动起始电流(`0.004·Ib`≈0.02 A)之上照常计量。 + - **结论**:低于量程下限时**瞬时功率 / PF 不可信,但电能计量不丢**;电流进入量程(正常负载)后瞬时量即稳定可信。这是 5(80)A 大量程表对极小负载的固有特性,**非缺陷、非解码问题**。 +- **排错提示**:若日后看到 DDSU666「功率一直 0」,先确认负载电流是否在 Imin 以上——多半是负载太轻而非链路故障;可用 `scripts/modbus_cli probe --fc 3 --address 0x2000 --count 16` 看 `0x2004/0x2005` 原始寄存器是否真为全零佐证。 diff --git a/scripts/modbus_cli.py b/scripts/modbus_cli.py index 39f917d..b38538e 100644 --- a/scripts/modbus_cli.py +++ b/scripts/modbus_cli.py @@ -131,6 +131,7 @@ def cmd_read(args: argparse.Namespace) -> None: print(f"Profile : {profile.name} — {profile.description}") print(f"Gateway : {host}:{port} unit_id={unit_id}") + print(f"Function : FC{profile.function_code:02d}") print(f"Blocks : {[(b.start, b.count) for b in profile.blocks]}") print() @@ -141,7 +142,7 @@ def cmd_read(args: argparse.Namespace) -> None: from app.integrations.modbus.driver import read_blocks try: - registers = read_blocks(host, port, unit_id, blocks) + registers = read_blocks(host, port, unit_id, blocks, function_code=profile.function_code) except ModbusConnectionError as exc: print(f"Connection error: {exc}", file=sys.stderr) sys.exit(1) diff --git a/tests/test_modbus_driver.py b/tests/test_modbus_driver.py index 7412914..e084c55 100644 --- a/tests/test_modbus_driver.py +++ b/tests/test_modbus_driver.py @@ -217,6 +217,57 @@ class TestReadBlocks: mock_client.close.assert_called_once() + @patch("app.integrations.modbus.driver.ModbusTcpClient") + def test_default_function_code_uses_fc04_input_registers( + self, mock_client_cls: MagicMock + ) -> None: + """With no function_code given, read_blocks uses FC04 (read_input_registers).""" + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_client.connect.return_value = True + mock_client.read_input_registers.return_value = _make_ok_response([0x4366, 0x3334]) + + read_blocks("127.0.0.1", 502, 1, [{"start": 0x0000, "count": 2}]) + + mock_client.read_input_registers.assert_called_once_with(0x0000, count=2, device_id=1) + mock_client.read_holding_registers.assert_not_called() + + @patch("app.integrations.modbus.driver.ModbusTcpClient") + def test_function_code_3_uses_fc03_holding_registers( + self, mock_client_cls: MagicMock + ) -> None: + """function_code=3 dispatches FC03 (read_holding_registers), e.g. DDSU666.""" + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_client.connect.return_value = True + mock_client.read_holding_registers.return_value = _make_ok_response( + [0x4366, 0x3334, 0x3F80, 0x0000] + ) + + blocks = [{"start": 0x2000, "count": 4}] + result = read_blocks("127.0.0.1", 502, 1, blocks, function_code=3) + + assert result == {0x2000: 0x4366, 0x2001: 0x3334, 0x2002: 0x3F80, 0x2003: 0x0000} + mock_client.read_holding_registers.assert_called_once_with( + 0x2000, count=4, device_id=1 + ) + mock_client.read_input_registers.assert_not_called() + + @patch("app.integrations.modbus.driver.ModbusTcpClient") + def test_invalid_function_code_raises_before_connecting( + self, mock_client_cls: MagicMock + ) -> None: + """An unsupported function code is rejected without opening a connection.""" + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + + with pytest.raises(ModbusDriverError, match="Unsupported read function code"): + read_blocks("127.0.0.1", 502, 1, [{"start": 0, "count": 2}], function_code=16) + + # No client should have been constructed or connected for a bad FC. + mock_client_cls.assert_not_called() + mock_client.connect.assert_not_called() + @patch("app.integrations.modbus.driver.ModbusTcpClient") def test_unit_id_passed_as_device_id(self, mock_client_cls: MagicMock) -> None: """unit_id is forwarded as device_id= keyword argument (pymodbus 3.13.x)."""