first
This commit is contained in:
281
probe_terminator.py
Normal file
281
probe_terminator.py
Normal file
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
probe_terminator.py — AutomationDirect Terminator I/O Modbus TCP prober
|
||||
Target: T1H-EBC100 Ethernet controller with T1H-08TDS digital input modules
|
||||
Protocol: Modbus TCP, FC02 (Read Discrete Inputs), port 502
|
||||
|
||||
Usage:
|
||||
python3 probe_terminator.py [--host HOST] [--port PORT] [--unit UNIT]
|
||||
[--watch] [--interval SEC] [--max-modules N]
|
||||
|
||||
--host IP address of the Terminator I/O controller (default: 192.168.3.202)
|
||||
--port Modbus TCP port (default: 502)
|
||||
--unit Modbus unit/slave ID (default: 1; 0 = auto-discover)
|
||||
--watch Continuously poll and display live state changes
|
||||
--interval Poll interval in seconds when using --watch (default: 0.5)
|
||||
--max-modules Maximum modules to probe during discovery (default: 3)
|
||||
|
||||
Note: The T1H-EBC100 returns zeros for unmapped addresses rather than a Modbus
|
||||
exception, so module count cannot be auto-detected from protocol errors alone.
|
||||
Use --max-modules to match the physically installed module count.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
import os
|
||||
|
||||
try:
|
||||
from pymodbus.client import ModbusTcpClient
|
||||
from pymodbus.exceptions import ModbusException
|
||||
from pymodbus.pdu import ExceptionResponse
|
||||
except ImportError:
|
||||
print("ERROR: pymodbus is not installed.")
|
||||
print(" Install with: pip3 install pymodbus --break-system-packages")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Constants
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
POINTS_PER_MODULE = 8 # T1H-08TDS has 8 input points
|
||||
MAX_UNIT_ID_SCAN = 10 # how many unit IDs to try during auto-discovery
|
||||
MODBUS_DI_START = 0 # pymodbus uses 0-based addressing; maps to 10001
|
||||
MODULE_NAMES = {8: "T1H-08TDS (8-pt DC input)"}
|
||||
|
||||
# ANSI colours (disabled automatically if not a TTY)
|
||||
_COLOUR = sys.stdout.isatty()
|
||||
C_RESET = "\033[0m" if _COLOUR else ""
|
||||
C_GREEN = "\033[32m" if _COLOUR else ""
|
||||
C_RED = "\033[31m" if _COLOUR else ""
|
||||
C_YELLOW = "\033[33m" if _COLOUR else ""
|
||||
C_CYAN = "\033[36m" if _COLOUR else ""
|
||||
C_BOLD = "\033[1m" if _COLOUR else ""
|
||||
C_DIM = "\033[2m" if _COLOUR else ""
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Helper: single FC02 read, returns list[bool] or None on error
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def read_discrete_inputs(client, address, count, unit):
|
||||
"""Read `count` discrete inputs starting at 0-based `address`. Returns list[bool] or None."""
|
||||
try:
|
||||
# pymodbus >= 3.x uses device_id=; older versions use slave=
|
||||
try:
|
||||
rr = client.read_discrete_inputs(address=address, count=count, device_id=unit)
|
||||
except TypeError:
|
||||
rr = client.read_discrete_inputs(address=address, count=count, slave=unit)
|
||||
if rr.isError() or isinstance(rr, ExceptionResponse):
|
||||
return None
|
||||
return rr.bits[:count]
|
||||
except ModbusException:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 1: Discover unit ID
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def discover_unit_id(client):
|
||||
"""
|
||||
Try unit IDs 1..MAX_UNIT_ID_SCAN and return the first one that responds to
|
||||
a FC02 read. The T1H-EBC100 usually responds to any unit ID over Modbus TCP
|
||||
but we still honour the configured ID.
|
||||
Returns the first responding unit ID, or 1 as a fallback.
|
||||
"""
|
||||
print(f"{C_CYAN}Scanning unit IDs 1–{MAX_UNIT_ID_SCAN}...{C_RESET}")
|
||||
for uid in range(1, MAX_UNIT_ID_SCAN + 1):
|
||||
result = read_discrete_inputs(client, MODBUS_DI_START, 1, uid)
|
||||
if result is not None:
|
||||
print(f" Unit ID {C_BOLD}{uid}{C_RESET} responded.")
|
||||
return uid
|
||||
else:
|
||||
print(f" Unit ID {uid} — no response")
|
||||
print(f"{C_YELLOW}No unit ID responded; defaulting to 1{C_RESET}")
|
||||
return 1
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 2: Discover how many 8-point modules are present
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def discover_modules(client, unit, max_modules):
|
||||
"""
|
||||
Read blocks of POINTS_PER_MODULE discrete inputs for each slot up to max_modules.
|
||||
The T1H-EBC100 returns zeros for unmapped slots rather than Modbus exceptions,
|
||||
so we rely on max_modules to define the installed hardware count.
|
||||
Returns the number of responsive slots (capped at max_modules).
|
||||
"""
|
||||
print(f"\n{C_CYAN}Probing {max_modules} slot(s) × {POINTS_PER_MODULE}-pt...{C_RESET}")
|
||||
print(f" {C_DIM}(T1H-EBC100 returns 0 for empty slots — set --max-modules to match physical count){C_RESET}")
|
||||
num_modules = 0
|
||||
for slot in range(max_modules):
|
||||
addr = slot * POINTS_PER_MODULE
|
||||
result = read_discrete_inputs(client, addr, POINTS_PER_MODULE, unit)
|
||||
if result is None:
|
||||
print(f" Slot {slot + 1}: no response — check connection")
|
||||
break
|
||||
print(f" Slot {slot + 1}: OK (Modbus DI {addr}–{addr + POINTS_PER_MODULE - 1})")
|
||||
num_modules += 1
|
||||
return num_modules
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 3: Read all discovered inputs and format output
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def read_all_inputs(client, unit, num_modules):
|
||||
"""Read all inputs for discovered modules. Returns list[bool] or None on error."""
|
||||
total = num_modules * POINTS_PER_MODULE
|
||||
return read_discrete_inputs(client, MODBUS_DI_START, total, unit)
|
||||
|
||||
|
||||
def format_inputs(bits, num_modules, prev_bits=None):
|
||||
"""
|
||||
Format input states as a module-by-module table.
|
||||
Highlights changed bits if prev_bits is provided.
|
||||
Returns a list of strings (lines).
|
||||
"""
|
||||
lines = []
|
||||
lines.append(f"{C_BOLD}{'Module':<10} {'Points':^72}{C_RESET}")
|
||||
lines.append(f"{'':10} " + " ".join(f"{'pt'+str(i+1):^5}" for i in range(POINTS_PER_MODULE)))
|
||||
lines.append("─" * 80)
|
||||
|
||||
for m in range(num_modules):
|
||||
pts = []
|
||||
for p in range(POINTS_PER_MODULE):
|
||||
idx = m * POINTS_PER_MODULE + p
|
||||
val = bits[idx]
|
||||
changed = (prev_bits is not None) and (val != prev_bits[idx])
|
||||
if val:
|
||||
label = f"{C_GREEN}{'ON':^5}{C_RESET}"
|
||||
else:
|
||||
label = f"{C_DIM}{'off':^5}{C_RESET}"
|
||||
if changed:
|
||||
label = f"{C_YELLOW}{'*':1}{C_RESET}" + label
|
||||
else:
|
||||
label = " " + label
|
||||
pts.append(label)
|
||||
addr_start = m * POINTS_PER_MODULE + 1 # 1-based Modbus reference
|
||||
addr_end = addr_start + POINTS_PER_MODULE - 1
|
||||
mod_label = f"Mod {m+1:>2} ({addr_start:05d}–{addr_end:05d})"
|
||||
lines.append(f"{mod_label:<30} " + " ".join(pts))
|
||||
|
||||
active = sum(1 for b in bits if b)
|
||||
lines.append("─" * 80)
|
||||
lines.append(f" {active} of {len(bits)} inputs active")
|
||||
return lines
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Main
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Probe AutomationDirect Terminator I/O via Modbus TCP"
|
||||
)
|
||||
parser.add_argument("--host", default="192.168.3.202",
|
||||
help="Controller IP address (default: 192.168.3.202)")
|
||||
parser.add_argument("--port", type=int, default=502,
|
||||
help="Modbus TCP port (default: 502)")
|
||||
parser.add_argument("--unit", type=int, default=0,
|
||||
help="Modbus unit/slave ID (default: 0 = auto-discover)")
|
||||
parser.add_argument("--watch", action="store_true",
|
||||
help="Continuously poll and display live state changes")
|
||||
parser.add_argument("--interval", type=float, default=0.5,
|
||||
help="Poll interval in seconds for --watch (default: 0.5)")
|
||||
parser.add_argument("--max-modules", type=int, default=3,
|
||||
help="Number of modules installed (default: 3 for 3x T1H-08TDS)")
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"\n{C_BOLD}=== Terminator I/O Modbus TCP Prober ==={C_RESET}")
|
||||
print(f" Host : {args.host}:{args.port}")
|
||||
print(f" Unit : {'auto-discover' if args.unit == 0 else args.unit}")
|
||||
print()
|
||||
|
||||
# ── Connect ──────────────────────────────────────────────────────────────
|
||||
client = ModbusTcpClient(host=args.host, port=args.port, timeout=2)
|
||||
if not client.connect():
|
||||
print(f"{C_RED}ERROR: Could not connect to {args.host}:{args.port}{C_RESET}")
|
||||
sys.exit(1)
|
||||
print(f"{C_GREEN}Connected to {args.host}:{args.port}{C_RESET}")
|
||||
|
||||
# ── Discover unit ID ─────────────────────────────────────────────────────
|
||||
unit = args.unit if args.unit != 0 else discover_unit_id(client)
|
||||
|
||||
# ── Discover modules ──────────────────────────────────────────────────────
|
||||
num_modules = discover_modules(client, unit, args.max_modules)
|
||||
if num_modules == 0:
|
||||
print(f"{C_RED}ERROR: No modules found. Check wiring and power.{C_RESET}")
|
||||
client.close()
|
||||
sys.exit(1)
|
||||
|
||||
total_pts = num_modules * POINTS_PER_MODULE
|
||||
print(f"\n{C_GREEN}Found {num_modules} module(s), {total_pts} input points total.{C_RESET}")
|
||||
print(f" Module type : {MODULE_NAMES.get(POINTS_PER_MODULE, f'{POINTS_PER_MODULE}-pt module')}")
|
||||
print(f" Modbus address : 10001 – {10000 + total_pts} (FC02)")
|
||||
print(f" Unit ID : {unit}")
|
||||
|
||||
# ── Single-shot read ──────────────────────────────────────────────────────
|
||||
print()
|
||||
bits = read_all_inputs(client, unit, num_modules)
|
||||
if bits is None:
|
||||
print(f"{C_RED}ERROR: Failed to read inputs.{C_RESET}")
|
||||
client.close()
|
||||
sys.exit(1)
|
||||
|
||||
for line in format_inputs(bits, num_modules):
|
||||
print(line)
|
||||
|
||||
if not args.watch:
|
||||
client.close()
|
||||
print(f"\n{C_DIM}Tip: run with --watch to monitor live state changes{C_RESET}")
|
||||
return
|
||||
|
||||
# ── Watch mode ────────────────────────────────────────────────────────────
|
||||
print(f"\n{C_CYAN}Watch mode: polling every {args.interval}s — press Ctrl+C to stop{C_RESET}\n")
|
||||
prev_bits = bits
|
||||
poll_count = 0
|
||||
try:
|
||||
while True:
|
||||
time.sleep(args.interval)
|
||||
new_bits = read_all_inputs(client, unit, num_modules)
|
||||
if new_bits is None:
|
||||
print(f"{C_RED}[{time.strftime('%H:%M:%S')}] Read error — retrying...{C_RESET}")
|
||||
# attempt reconnect
|
||||
client.close()
|
||||
time.sleep(1)
|
||||
client.connect()
|
||||
continue
|
||||
|
||||
poll_count += 1
|
||||
changed = any(a != b for a, b in zip(new_bits, prev_bits))
|
||||
|
||||
if changed or poll_count == 1:
|
||||
# Clear screen and redraw
|
||||
if _COLOUR:
|
||||
print("\033[H\033[J", end="") # clear terminal
|
||||
print(f"{C_BOLD}=== Terminator I/O Live Monitor ==={C_RESET} "
|
||||
f"{C_DIM}[{time.strftime('%H:%M:%S')}] poll #{poll_count}{C_RESET}")
|
||||
print(f" {args.host}:{args.port} unit={unit} "
|
||||
f"interval={args.interval}s Ctrl+C to stop\n")
|
||||
for line in format_inputs(new_bits, num_modules, prev_bits if changed else None):
|
||||
print(line)
|
||||
if changed:
|
||||
print(f"\n {C_YELLOW}* = changed since last poll{C_RESET}")
|
||||
|
||||
prev_bits = new_bits
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n{C_DIM}Stopped.{C_RESET}")
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user