383 lines
11 KiB
Python
Executable File
383 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Modbus Probe Tool for ICS-SimLab Diagnostics.
|
|
|
|
Probes all monitor targets (HMI→PLC, PLC→Sensor) to diagnose:
|
|
- TCP connectivity
|
|
- Modbus exceptions (illegal address/function)
|
|
- Register type/address mismatches
|
|
|
|
Usage:
|
|
python3 tools/probe_modbus.py [--config path/to/configuration.json]
|
|
python3 tools/probe_modbus.py --docker # Run from host via docker exec
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import socket
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
# Optional pymodbus import (may not be available on host)
|
|
try:
|
|
from pymodbus.client import ModbusTcpClient
|
|
from pymodbus.exceptions import ModbusException
|
|
PYMODBUS_AVAILABLE = True
|
|
except ImportError:
|
|
PYMODBUS_AVAILABLE = False
|
|
|
|
|
|
@dataclass
|
|
class ProbeTarget:
|
|
"""A Modbus read target to probe."""
|
|
source: str # e.g., "operator_hmi", "plc1"
|
|
monitor_id: str # e.g., "water_tank_level_reg"
|
|
target_ip: str
|
|
target_port: int
|
|
value_type: str # coil, discrete_input, holding_register, input_register
|
|
slave_id: int
|
|
address: int
|
|
count: int
|
|
|
|
|
|
@dataclass
|
|
class ProbeResult:
|
|
"""Result of probing a target."""
|
|
target: ProbeTarget
|
|
tcp_ok: bool
|
|
modbus_ok: bool
|
|
value: Optional[Any] = None
|
|
error: Optional[str] = None
|
|
|
|
|
|
def parse_config(config_path: Path) -> Dict[str, Any]:
|
|
"""Load and parse configuration.json."""
|
|
with open(config_path) as f:
|
|
return json.load(f)
|
|
|
|
|
|
def extract_probe_targets(config: Dict[str, Any]) -> List[ProbeTarget]:
|
|
"""Extract all monitor targets from config (HMIs and PLCs)."""
|
|
targets = []
|
|
|
|
# HMI monitors -> PLCs
|
|
for hmi in config.get("hmis", []):
|
|
hmi_name = hmi.get("name", "unknown_hmi")
|
|
outbound_map = {
|
|
conn["id"]: (conn["ip"], conn["port"])
|
|
for conn in hmi.get("outbound_connections", [])
|
|
}
|
|
|
|
for mon in hmi.get("monitors", []):
|
|
conn_id = mon.get("outbound_connection_id")
|
|
if conn_id not in outbound_map:
|
|
continue
|
|
ip, port = outbound_map[conn_id]
|
|
targets.append(ProbeTarget(
|
|
source=hmi_name,
|
|
monitor_id=mon["id"],
|
|
target_ip=ip,
|
|
target_port=port,
|
|
value_type=mon["value_type"],
|
|
slave_id=mon.get("slave_id", 1),
|
|
address=mon["address"],
|
|
count=mon.get("count", 1),
|
|
))
|
|
|
|
# PLC monitors -> Sensors/other PLCs
|
|
for plc in config.get("plcs", []):
|
|
plc_name = plc.get("name", "unknown_plc")
|
|
outbound_map = {
|
|
conn["id"]: (conn["ip"], conn["port"])
|
|
for conn in plc.get("outbound_connections", [])
|
|
}
|
|
|
|
for mon in plc.get("monitors", []):
|
|
conn_id = mon.get("outbound_connection_id")
|
|
if conn_id not in outbound_map:
|
|
continue
|
|
ip, port = outbound_map[conn_id]
|
|
targets.append(ProbeTarget(
|
|
source=plc_name,
|
|
monitor_id=mon["id"],
|
|
target_ip=ip,
|
|
target_port=port,
|
|
value_type=mon["value_type"],
|
|
slave_id=mon.get("slave_id", 1),
|
|
address=mon["address"],
|
|
count=mon.get("count", 1),
|
|
))
|
|
|
|
return targets
|
|
|
|
|
|
def check_tcp(ip: str, port: int, timeout: float = 2.0) -> bool:
|
|
"""Check if TCP port is reachable."""
|
|
try:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
sock.settimeout(timeout)
|
|
sock.connect((ip, port))
|
|
return True
|
|
except (socket.timeout, ConnectionRefusedError, OSError):
|
|
return False
|
|
|
|
|
|
def probe_modbus(target: ProbeTarget, timeout: float = 3.0) -> ProbeResult:
|
|
"""Probe a single Modbus target."""
|
|
# First check TCP connectivity
|
|
tcp_ok = check_tcp(target.target_ip, target.target_port, timeout)
|
|
if not tcp_ok:
|
|
return ProbeResult(
|
|
target=target,
|
|
tcp_ok=False,
|
|
modbus_ok=False,
|
|
error=f"TCP connection refused: {target.target_ip}:{target.target_port}"
|
|
)
|
|
|
|
if not PYMODBUS_AVAILABLE:
|
|
return ProbeResult(
|
|
target=target,
|
|
tcp_ok=True,
|
|
modbus_ok=False,
|
|
error="pymodbus not installed (TCP OK)"
|
|
)
|
|
|
|
# Try Modbus read
|
|
client = ModbusTcpClient(
|
|
host=target.target_ip,
|
|
port=target.target_port,
|
|
timeout=timeout
|
|
)
|
|
|
|
try:
|
|
if not client.connect():
|
|
return ProbeResult(
|
|
target=target,
|
|
tcp_ok=True,
|
|
modbus_ok=False,
|
|
error="Modbus connect() failed"
|
|
)
|
|
|
|
# Select appropriate read function
|
|
read_funcs = {
|
|
"coil": client.read_coils,
|
|
"discrete_input": client.read_discrete_inputs,
|
|
"holding_register": client.read_holding_registers,
|
|
"input_register": client.read_input_registers,
|
|
}
|
|
|
|
func = read_funcs.get(target.value_type)
|
|
if not func:
|
|
return ProbeResult(
|
|
target=target,
|
|
tcp_ok=True,
|
|
modbus_ok=False,
|
|
error=f"Unknown value_type: {target.value_type}"
|
|
)
|
|
|
|
# Perform Modbus read
|
|
# ICS-SimLab containers use pymodbus that accepts positional args
|
|
addr = target.address
|
|
cnt = target.count
|
|
|
|
# Simple call - positional args work with ICS-SimLab's pymodbus
|
|
result = func(addr, cnt)
|
|
|
|
if result.isError():
|
|
return ProbeResult(
|
|
target=target,
|
|
tcp_ok=True,
|
|
modbus_ok=False,
|
|
error=f"Modbus error: {result}"
|
|
)
|
|
|
|
# Extract value
|
|
if target.value_type in ("coil", "discrete_input"):
|
|
value = result.bits[:target.count]
|
|
else:
|
|
value = result.registers[:target.count]
|
|
|
|
return ProbeResult(
|
|
target=target,
|
|
tcp_ok=True,
|
|
modbus_ok=True,
|
|
value=value
|
|
)
|
|
|
|
except ModbusException as e:
|
|
return ProbeResult(
|
|
target=target,
|
|
tcp_ok=True,
|
|
modbus_ok=False,
|
|
error=f"ModbusException: {e}"
|
|
)
|
|
except Exception as e:
|
|
return ProbeResult(
|
|
target=target,
|
|
tcp_ok=True,
|
|
modbus_ok=False,
|
|
error=f"Exception: {type(e).__name__}: {e}"
|
|
)
|
|
finally:
|
|
client.close()
|
|
|
|
|
|
def format_result(r: ProbeResult) -> str:
|
|
"""Format a probe result as a single line."""
|
|
t = r.target
|
|
status = "OK" if r.modbus_ok else "FAIL"
|
|
tcp_status = "TCP_OK" if r.tcp_ok else "TCP_FAIL"
|
|
|
|
line = f"[{status}] {t.source} -> {t.target_ip}:{t.target_port}"
|
|
line += f" {t.value_type}@{t.address} (id={t.monitor_id})"
|
|
|
|
if r.modbus_ok:
|
|
line += f" value={r.value}"
|
|
else:
|
|
line += f" ({tcp_status}) {r.error}"
|
|
|
|
return line
|
|
|
|
|
|
def run_probe(config_path: Path, verbose: bool = False) -> List[ProbeResult]:
|
|
"""Run probe on all targets."""
|
|
config = parse_config(config_path)
|
|
targets = extract_probe_targets(config)
|
|
|
|
if verbose:
|
|
print(f"Found {len(targets)} probe targets")
|
|
print("=" * 70)
|
|
|
|
results = []
|
|
for target in targets:
|
|
result = probe_modbus(target)
|
|
results.append(result)
|
|
if verbose:
|
|
print(format_result(result))
|
|
|
|
return results
|
|
|
|
|
|
def generate_report(results: List[ProbeResult]) -> str:
|
|
"""Generate a full probe report."""
|
|
lines = []
|
|
lines.append("=" * 70)
|
|
lines.append("MODBUS PROBE REPORT")
|
|
lines.append("=" * 70)
|
|
lines.append("")
|
|
|
|
# Summary
|
|
total = len(results)
|
|
tcp_ok = sum(1 for r in results if r.tcp_ok)
|
|
modbus_ok = sum(1 for r in results if r.modbus_ok)
|
|
|
|
lines.append(f"Total targets: {total}")
|
|
lines.append(f"TCP reachable: {tcp_ok}/{total}")
|
|
lines.append(f"Modbus OK: {modbus_ok}/{total}")
|
|
lines.append("")
|
|
|
|
# Group by source
|
|
by_source: Dict[str, List[ProbeResult]] = {}
|
|
for r in results:
|
|
src = r.target.source
|
|
by_source.setdefault(src, []).append(r)
|
|
|
|
for source, source_results in sorted(by_source.items()):
|
|
lines.append(f"--- {source} monitors ---")
|
|
for r in source_results:
|
|
lines.append(format_result(r))
|
|
lines.append("")
|
|
|
|
# Diagnosis
|
|
lines.append("=" * 70)
|
|
lines.append("DIAGNOSIS")
|
|
lines.append("=" * 70)
|
|
|
|
tcp_fails = [r for r in results if not r.tcp_ok]
|
|
modbus_fails = [r for r in results if r.tcp_ok and not r.modbus_ok]
|
|
|
|
if tcp_fails:
|
|
lines.append("")
|
|
lines.append("TCP FAILURES (connection refused/timeout):")
|
|
for r in tcp_fails:
|
|
lines.append(f" - {r.target.source} -> {r.target.target_ip}:{r.target.target_port}")
|
|
lines.append(" Likely causes: container not running, network isolation, firewall")
|
|
|
|
if modbus_fails:
|
|
lines.append("")
|
|
lines.append("MODBUS FAILURES (TCP OK but read failed):")
|
|
for r in modbus_fails:
|
|
lines.append(f" - {r.target.source}.{r.target.monitor_id}: {r.error}")
|
|
lines.append(" Likely causes: wrong address, wrong register type, device not serving")
|
|
|
|
if modbus_ok == total:
|
|
lines.append("")
|
|
lines.append("All Modbus reads successful. Data flow issue is likely in:")
|
|
lines.append(" - HIL not updating physical_values")
|
|
lines.append(" - Sensors not receiving from HIL")
|
|
lines.append(" - Value is legitimately 0")
|
|
|
|
# Values that are 0 (potential issue)
|
|
zero_values = [r for r in results if r.modbus_ok and r.value in ([0], [False], 0)]
|
|
if zero_values:
|
|
lines.append("")
|
|
lines.append("REGISTERS WITH VALUE 0 (may indicate HIL not producing data):")
|
|
for r in zero_values:
|
|
lines.append(f" - {r.target.source}.{r.target.monitor_id} = {r.value}")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Modbus probe for ICS-SimLab diagnostics")
|
|
parser.add_argument(
|
|
"--config",
|
|
default="outputs/scenario_run/configuration.json",
|
|
help="Path to configuration.json"
|
|
)
|
|
parser.add_argument(
|
|
"--docker",
|
|
action="store_true",
|
|
help="Run probes via docker exec (from host)"
|
|
)
|
|
parser.add_argument(
|
|
"--verbose", "-v",
|
|
action="store_true",
|
|
help="Print results as they are collected"
|
|
)
|
|
parser.add_argument(
|
|
"--output", "-o",
|
|
help="Write report to file instead of stdout"
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
config_path = Path(args.config)
|
|
|
|
if not config_path.exists():
|
|
print(f"ERROR: Config not found: {config_path}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if args.docker:
|
|
# TODO: Implement docker exec wrapper
|
|
print("--docker mode not yet implemented", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
results = run_probe(config_path, verbose=args.verbose)
|
|
report = generate_report(results)
|
|
|
|
if args.output:
|
|
Path(args.output).write_text(report)
|
|
print(f"Report written to {args.output}")
|
|
else:
|
|
print(report)
|
|
|
|
# Exit with error if any failures
|
|
if not all(r.modbus_ok for r in results):
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|