"""Modbus CLI — read-only manual test tool for Modbus TCP devices. Entry point:: python -m scripts.modbus_cli [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"Function : FC{profile.function_code:02d}") 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, function_code=profile.function_code) 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="") 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()