2026-06-22 13:03:50 +02:00
|
|
|
"""Modbus CLI — read-only manual test tool for Modbus TCP devices.
|
|
|
|
|
|
|
|
|
|
Entry point::
|
|
|
|
|
|
|
|
|
|
python -m scripts.modbus_cli <subcommand> [options]
|
|
|
|
|
|
|
|
|
|
Subcommands
|
|
|
|
|
-----------
|
|
|
|
|
read --host H --port P --unit U --profile NAME
|
|
|
|
|
Read all metrics defined in the named profile from the device and print
|
|
|
|
|
decoded engineering values as a formatted table. Use this to verify the
|
|
|
|
|
full decode chain end-to-end.
|
|
|
|
|
|
|
|
|
|
probe --host H --port P --unit U --fc {3,4} --address ADDR --count N
|
|
|
|
|
[--decode float32]
|
|
|
|
|
Send a raw FC03 (holding) or FC04 (input) read request for exactly N
|
|
|
|
|
registers starting at ADDR, and print the raw 16-bit register values in
|
|
|
|
|
hex. Optionally decode consecutive register pairs as big-endian float32.
|
|
|
|
|
Use this for first-contact verification of gateway reachability, framer
|
|
|
|
|
mode, and unit_id before setting up a full profile.
|
|
|
|
|
|
|
|
|
|
Design
|
|
|
|
|
------
|
|
|
|
|
- **Read-only**: only FC03 and FC04 are exposed. There is no write path.
|
|
|
|
|
This prevents accidental modification of device configuration registers
|
|
|
|
|
(Meter ID, baud rate, etc.) which could cause communication loss.
|
|
|
|
|
- **No DB dependency**: the tool connects directly to the gateway without
|
|
|
|
|
reading from the application database. It is safe to use before any
|
|
|
|
|
device has been configured in the application.
|
|
|
|
|
- **Non-zero exit on connection failure** so shell scripts can detect errors.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
# Ensure the project root is on sys.path when invoked as ``python -m scripts.modbus_cli``
|
|
|
|
|
# or directly.
|
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
if str(PROJECT_ROOT) not in sys.path:
|
|
|
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
|
|
|
|
|
|
from app.integrations.modbus.driver import (
|
|
|
|
|
ModbusConnectionError,
|
|
|
|
|
ModbusDriverError,
|
|
|
|
|
ModbusResponseError,
|
|
|
|
|
registers_to_float,
|
|
|
|
|
)
|
|
|
|
|
from app.integrations.modbus.profiles import ProfileNotFoundError, ProfileValidationError, decode, load_profile
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Helper — raw FC03/FC04 read (used by ``probe``)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _raw_read(
|
|
|
|
|
host: str,
|
|
|
|
|
port: int,
|
|
|
|
|
unit_id: int,
|
|
|
|
|
fc: int,
|
|
|
|
|
address: int,
|
|
|
|
|
count: int,
|
|
|
|
|
timeout: float = 5.0,
|
|
|
|
|
) -> list[int]:
|
|
|
|
|
"""Perform a raw FC03 or FC04 read and return the register list.
|
|
|
|
|
|
|
|
|
|
Raises ``ModbusConnectionError`` or ``ModbusResponseError`` on failure.
|
|
|
|
|
"""
|
|
|
|
|
from pymodbus.client import ModbusTcpClient
|
|
|
|
|
from pymodbus.exceptions import ConnectionException
|
|
|
|
|
|
|
|
|
|
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}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if fc == 4:
|
|
|
|
|
response = client.read_input_registers(address, count=count, device_id=unit_id)
|
|
|
|
|
elif fc == 3:
|
|
|
|
|
response = client.read_holding_registers(address, count=count, device_id=unit_id)
|
|
|
|
|
else:
|
|
|
|
|
raise ValueError(f"Unsupported function code FC{fc:02d} (only FC03/FC04 are allowed)")
|
|
|
|
|
|
|
|
|
|
if response.isError():
|
|
|
|
|
raise ModbusResponseError(
|
|
|
|
|
f"Modbus exception reading FC{fc:02d} registers 0x{address:04X}+{count}: "
|
|
|
|
|
f"{response}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
regs = response.registers
|
|
|
|
|
if len(regs) != count:
|
|
|
|
|
raise ModbusResponseError(
|
|
|
|
|
f"Expected {count} registers, got {len(regs)}"
|
|
|
|
|
)
|
|
|
|
|
return list(regs)
|
|
|
|
|
|
|
|
|
|
except ConnectionException as exc:
|
|
|
|
|
raise ModbusConnectionError(
|
|
|
|
|
f"Connection to {host}:{port} failed: {exc}"
|
|
|
|
|
) from exc
|
|
|
|
|
finally:
|
|
|
|
|
client.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Sub-command: read
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_read(args: argparse.Namespace) -> None:
|
|
|
|
|
"""Read all metrics from a named profile and print decoded values."""
|
|
|
|
|
profile_name: str = args.profile
|
|
|
|
|
host: str = args.host
|
|
|
|
|
port: int = args.port
|
|
|
|
|
unit_id: int = args.unit
|
|
|
|
|
|
|
|
|
|
# Load and validate the profile.
|
|
|
|
|
try:
|
|
|
|
|
profile = load_profile(profile_name)
|
|
|
|
|
except ProfileNotFoundError as exc:
|
|
|
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
except ProfileValidationError as exc:
|
|
|
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
print(f"Profile : {profile.name} — {profile.description}")
|
|
|
|
|
print(f"Gateway : {host}:{port} unit_id={unit_id}")
|
2026-06-29 18:20:35 +02:00
|
|
|
print(f"Function : FC{profile.function_code:02d}")
|
2026-06-22 13:03:50 +02:00
|
|
|
print(f"Blocks : {[(b.start, b.count) for b in profile.blocks]}")
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
# Convert profile blocks to the dict format expected by read_blocks.
|
|
|
|
|
blocks = [{"start": b.start, "count": b.count} for b in profile.blocks]
|
|
|
|
|
|
|
|
|
|
# Perform the read.
|
|
|
|
|
from app.integrations.modbus.driver import read_blocks
|
|
|
|
|
|
|
|
|
|
try:
|
2026-06-29 18:20:35 +02:00
|
|
|
registers = read_blocks(host, port, unit_id, blocks, function_code=profile.function_code)
|
2026-06-22 13:03:50 +02:00
|
|
|
except ModbusConnectionError as exc:
|
|
|
|
|
print(f"Connection error: {exc}", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
except ModbusResponseError as exc:
|
|
|
|
|
print(f"Modbus response error: {exc}", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
except ModbusDriverError as exc:
|
|
|
|
|
print(f"Modbus driver error: {exc}", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
# Decode.
|
|
|
|
|
values = decode(profile, registers)
|
|
|
|
|
|
|
|
|
|
# Print as table.
|
|
|
|
|
print(f"{'Metric':<20} {'Value':>14} Unit")
|
|
|
|
|
print("-" * 45)
|
|
|
|
|
for metric in profile.metrics:
|
|
|
|
|
key = metric.key
|
|
|
|
|
if key in values:
|
|
|
|
|
unit = metric.unit if metric.unit else "—"
|
|
|
|
|
print(f"{key:<20} {values[key]:>14.4f} {unit}")
|
|
|
|
|
else:
|
|
|
|
|
print(f"{key:<20} {'(not available)':>14}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Sub-command: probe
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_probe(args: argparse.Namespace) -> None:
|
|
|
|
|
"""Perform a raw FC03/FC04 read and print raw register values."""
|
|
|
|
|
host: str = args.host
|
|
|
|
|
port: int = args.port
|
|
|
|
|
unit_id: int = args.unit
|
|
|
|
|
fc: int = args.fc
|
|
|
|
|
address: int = args.address
|
|
|
|
|
count: int = args.count
|
|
|
|
|
do_decode: bool = args.decode == "float32"
|
|
|
|
|
|
|
|
|
|
if fc not in (3, 4):
|
|
|
|
|
print(f"Error: --fc must be 3 or 4 (got {fc})", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
print(f"Gateway : {host}:{port} unit_id={unit_id}")
|
|
|
|
|
print(f"Request : FC{fc:02d} start=0x{address:04X} count={count}")
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
regs = _raw_read(host, port, unit_id, fc, address, count)
|
|
|
|
|
except ModbusConnectionError as exc:
|
|
|
|
|
print(f"Connection error: {exc}", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
except ModbusResponseError as exc:
|
|
|
|
|
print(f"Modbus response error: {exc}", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
except ModbusDriverError as exc:
|
|
|
|
|
print(f"Modbus driver error: {exc}", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
# Print raw register values.
|
|
|
|
|
print(f"{'Addr':>8} {'Hex':>6} {'Dec':>7}")
|
|
|
|
|
print("-" * 28)
|
|
|
|
|
for i, val in enumerate(regs):
|
|
|
|
|
print(f"0x{address + i:04X} 0x{val:04X} {val:>7d}")
|
|
|
|
|
|
|
|
|
|
# Optional float32 decoding — consecutive register pairs.
|
|
|
|
|
if do_decode and len(regs) >= 2:
|
|
|
|
|
print()
|
|
|
|
|
print("float32 decode (big-endian, high register first):")
|
|
|
|
|
print(f"{'Addr pair':>14} {'Float value':>16}")
|
|
|
|
|
print("-" * 35)
|
|
|
|
|
for i in range(0, len(regs) - 1, 2):
|
|
|
|
|
hi = regs[i]
|
|
|
|
|
lo = regs[i + 1]
|
|
|
|
|
fval = registers_to_float(hi, lo)
|
|
|
|
|
print(f"0x{address + i:04X}+0x{address + i + 1:04X} {fval:>16.6f}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Argument parser
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _auto_int(value: str) -> int:
|
|
|
|
|
"""Parse an integer that may be hex (``0x...``) or decimal."""
|
|
|
|
|
return int(value, 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
|
prog="python -m scripts.modbus_cli",
|
|
|
|
|
description=(
|
|
|
|
|
"Modbus CLI — read-only manual test tool for Modbus TCP devices. "
|
|
|
|
|
"Use 'read' to decode all profile metrics; use 'probe' for raw "
|
|
|
|
|
"first-contact register inspection."
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
subparsers = parser.add_subparsers(dest="command", metavar="<command>")
|
|
|
|
|
subparsers.required = True
|
|
|
|
|
|
|
|
|
|
# -- read --
|
|
|
|
|
rp = subparsers.add_parser(
|
|
|
|
|
"read",
|
|
|
|
|
help=(
|
|
|
|
|
"Read and decode all metrics defined in a profile. "
|
|
|
|
|
"Prints a table of engineering values."
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
rp.add_argument("--host", required=True, metavar="HOST", help="Gateway IP/hostname.")
|
|
|
|
|
rp.add_argument(
|
|
|
|
|
"--port", type=int, default=502, metavar="PORT", help="Gateway TCP port (default 502)."
|
|
|
|
|
)
|
|
|
|
|
rp.add_argument(
|
|
|
|
|
"--unit",
|
|
|
|
|
type=int,
|
|
|
|
|
required=True,
|
|
|
|
|
metavar="UNIT_ID",
|
|
|
|
|
help="Modbus slave / unit ID (Meter ID, typically 1).",
|
|
|
|
|
)
|
|
|
|
|
rp.add_argument(
|
|
|
|
|
"--profile",
|
|
|
|
|
required=True,
|
|
|
|
|
metavar="NAME",
|
|
|
|
|
help="Profile name (e.g. 'sdm120').",
|
|
|
|
|
)
|
|
|
|
|
rp.set_defaults(func=cmd_read)
|
|
|
|
|
|
|
|
|
|
# -- probe --
|
|
|
|
|
pp = subparsers.add_parser(
|
|
|
|
|
"probe",
|
|
|
|
|
help=(
|
|
|
|
|
"Raw read of N registers starting at ADDR via FC03 or FC04. "
|
|
|
|
|
"Prints raw hex values; optionally decodes as big-endian float32."
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
pp.add_argument("--host", required=True, metavar="HOST", help="Gateway IP/hostname.")
|
|
|
|
|
pp.add_argument(
|
|
|
|
|
"--port", type=int, default=502, metavar="PORT", help="Gateway TCP port (default 502)."
|
|
|
|
|
)
|
|
|
|
|
pp.add_argument(
|
|
|
|
|
"--unit",
|
|
|
|
|
type=int,
|
|
|
|
|
required=True,
|
|
|
|
|
metavar="UNIT_ID",
|
|
|
|
|
help="Modbus slave / unit ID.",
|
|
|
|
|
)
|
|
|
|
|
pp.add_argument(
|
|
|
|
|
"--fc",
|
|
|
|
|
type=int,
|
|
|
|
|
choices=[3, 4],
|
|
|
|
|
default=4,
|
|
|
|
|
metavar="FC",
|
|
|
|
|
help="Function code: 3 (holding) or 4 (input registers, default).",
|
|
|
|
|
)
|
|
|
|
|
pp.add_argument(
|
|
|
|
|
"--address",
|
|
|
|
|
type=_auto_int,
|
|
|
|
|
required=True,
|
|
|
|
|
metavar="ADDR",
|
|
|
|
|
help="Start register address (hex e.g. 0x0000 or decimal).",
|
|
|
|
|
)
|
|
|
|
|
pp.add_argument(
|
|
|
|
|
"--count",
|
|
|
|
|
type=int,
|
|
|
|
|
required=True,
|
|
|
|
|
metavar="N",
|
|
|
|
|
help="Number of registers to read.",
|
|
|
|
|
)
|
|
|
|
|
pp.add_argument(
|
|
|
|
|
"--decode",
|
|
|
|
|
choices=["float32"],
|
|
|
|
|
default=None,
|
|
|
|
|
metavar="TYPE",
|
|
|
|
|
help="Optional: decode consecutive register pairs as 'float32'.",
|
|
|
|
|
)
|
|
|
|
|
pp.set_defaults(func=cmd_probe)
|
|
|
|
|
|
|
|
|
|
return parser
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Entry point
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> None:
|
|
|
|
|
parser = build_parser()
|
|
|
|
|
args = parser.parse_args(argv)
|
|
|
|
|
args.func(args)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|