Files
home-automation/scripts/modbus_cli.py
T

339 lines
11 KiB
Python
Raw Normal View History

"""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}")
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:
registers = read_blocks(host, port, unit_id, blocks)
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()