j0h

rio_debug_scan.py

j0h
Mar 26th, 2026
128
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 22.81 KB | None | 0 0
  1. #!/usr/bin/env python3
  2. '''
  3. Debug/health scanner for groov RIO devices over OptoMMP/TCP.
  4.  
  5. Usage:
  6.  
  7. rio_debug_scan.py 192.168.1.50 --mode once
  8. rio_debug_scan.py 192.168.1.50 --mode watch --interval 5
  9. rio_debug_scan.py --host 192.168.1.50 --mode once
  10. rio_debug_scan.py 192.168.1.50 --mode watch --heal-timeout 75
  11. python3 /mnt/data/rio_debug_scan.py 192.168.1.50 --mode once --json
  12.  
  13. About Features:
  14. - once mode: single scan and report
  15. - watch mode: continuous scan and report
  16. - optional host as positional arg or --host
  17. - optional ICMP ping check before each scan
  18. - "heal" attempt: reads the module-build area when ping fails or a read stalls
  19.  because the user observed that reading module information can restore network response
  20. - uses the generated CSV files when available, with safe hardcoded fallbacks
  21. - json file output
  22. This is intentionally conservative: it only reads addresses and never writes anything
  23. except an optional Powerup Clear if you explicitly add that later.
  24. '''
  25.  
  26. from __future__ import annotations
  27.  
  28. import argparse
  29. import csv
  30. import datetime as dt
  31. import ipaddress
  32. import json
  33. import os
  34. import platform
  35. import socket
  36. import struct
  37. import subprocess
  38. import sys
  39. import time
  40. from dataclasses import dataclass
  41. from typing import Dict, Iterable, List, Optional, Tuple
  42.  
  43. DEFAULT_PORT = 2001
  44. DEFAULT_TIMEOUT = 5.0
  45. DEFAULT_INTERVAL = 5.0
  46. DEFAULT_HEAL_TIMEOUT = 65.0
  47. DEFAULT_MODULE_BUILD_LENGTH = 0x800  # From module-build area last valid address pattern.
  48.  
  49. # Fallback addresses from the OptoMMP guide / generated CSV.
  50. FALLBACK_REGS: Dict[str, Tuple[int, int, str]] = {
  51.     # name: (address, length, type)
  52.     "mem_map_version": (0xFFFFF0300000, 4, "UI"),
  53.     "powerup_clear_flag": (0xFFFFF0300004, 4, "UI"),
  54.     "busy_flag": (0xFFFFF0300008, 4, "UI"),
  55.     "last_error_code": (0xFFFFF030000C, 4, "I"),
  56.     "txn_label": (0xFFFFF0300010, 2, "UI"),
  57.     "source_address": (0xFFFFF0300012, 2, "UI"),
  58.     "error_address": (0xFFFFF0300014, 4, "UI"),
  59.     "loader_revision": (0xFFFFF0300018, 4, "UI"),
  60.     "firmware_revision": (0xFFFFF030001C, 4, "IP"),
  61.     "unit_type": (0xFFFFF0300020, 4, "UI"),
  62.     "hw_month": (0xFFFFF0300024, 1, "UI"),
  63.     "hw_day": (0xFFFFF0300025, 1, "UI"),
  64.     "hw_year": (0xFFFFF0300026, 2, "UI"),
  65.     "installed_ram": (0xFFFFF0300028, 4, "UI"),
  66.     "mac_enet1": (0xFFFFF030002E, 6, "MAC"),
  67.     "ip_enet1": (0xFFFFF0300034, 4, "IP"),
  68.     "subnet_enet1": (0xFFFFF0300038, 4, "IP"),
  69.     "gateway_enet1": (0xFFFFF030003C, 4, "IP"),
  70.     "dns_enet1": (0xFFFFF0300040, 4, "IP"),
  71.     "bootp_dhcp_status": (0xFFFFF0300058, 1, "UI"),
  72.     "uptime_ms": (0xFFFFF030010C, 4, "UI"),
  73.     "quality_mask": (0xFFFFF1002000, 8, "M"),
  74.     "quality_code_0_0": (0xFFFFF1002080, 4, "UI"),
  75.     "module0_type": (0xFFFFF8110000, 4, "UI"),
  76.     "module0_firmware_mode": (0xFFFFF8110008, 4, "UI"),
  77.     "module0_serial": (0xFFFFF811000C, 4, "UI"),
  78. }
  79.  
  80. UNIT_TYPES = {
  81.     0x00000103: "GRV-R7-MM1001-10",
  82.     0x00000108: "GRV-R7-MM2001-10",
  83.     0x00000101: "GRV-EPIC-PR1",
  84.     0x00000107: "GRV-EPIC-PR2",
  85. }
  86.  
  87. MODULE_TYPES = {
  88.     0xF0000022: "groov RIO base module",
  89. }
  90.  
  91. QUALITY_CODES = {
  92.     0: "good/none",
  93.     1: "bad value",
  94.     2: "clamped high",
  95.     3: "clamped low",
  96.     30: "no channel present",
  97. }
  98.  
  99.  
  100. @dataclass
  101. class RegisterDef:
  102.     name: str
  103.     address: int
  104.     length: int
  105.     typ: str
  106.     description: str = ""
  107.  
  108.  
  109. class OptoMMPClient:
  110.     def __init__(self, host: str, port: int = DEFAULT_PORT, timeout: float = DEFAULT_TIMEOUT):
  111.         self.host = host
  112.         self.port = port
  113.         self.timeout = timeout
  114.         self.sock: Optional[socket.socket] = None
  115.  
  116.     def connect(self) -> None:
  117.         self.close()
  118.         self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  119.         self.sock.settimeout(self.timeout)
  120.         self.sock.connect((self.host, self.port))
  121.  
  122.     def close(self) -> None:
  123.         if self.sock is not None:
  124.             try:
  125.                 self.sock.close()
  126.             except OSError:
  127.                 pass
  128.             self.sock = None
  129.  
  130.     def _ensure(self) -> socket.socket:
  131.         if self.sock is None:
  132.             self.connect()
  133.         assert self.sock is not None
  134.         return self.sock
  135.  
  136.     def _recv_exact(self, n: int) -> bytes:
  137.         sock = self._ensure()
  138.         chunks = bytearray()
  139.         while len(chunks) < n:
  140.             chunk = sock.recv(n - len(chunks))
  141.             if not chunk:
  142.                 raise ConnectionError("socket closed while receiving data")
  143.             chunks.extend(chunk)
  144.         return bytes(chunks)
  145.  
  146.     def read_block(self, address: int, length: int) -> bytes:
  147.         if not (0 < length <= 0xFFFF):
  148.             raise ValueError(f"invalid read length: {length}")
  149.         sock = self._ensure()
  150.         # Read Block Request packet per OptoMMP examples.
  151.         request = bytearray(16)
  152.         request[0:4] = bytes([0x00, 0x00, 0x04, 0x50])
  153.         request[4:8] = bytes([0x00, 0x00, 0xFF, 0xFF])
  154.         request[8:12] = address.to_bytes(4, "big")
  155.         request[12:14] = length.to_bytes(2, "big")
  156.         request[14:16] = bytes([0x00, 0x00])
  157.         sock.sendall(request)
  158.  
  159.         # Read response header first.
  160.         header = self._recv_exact(16)
  161.         # rcode lives in low nibble of byte 3 in IEEE-1394 style responses.
  162.         rcode = header[3] & 0x0F
  163.         if rcode != 0:
  164.             raise RuntimeError(f"read response returned rcode={rcode}")
  165.  
  166.         # Most devices return the requested data immediately after the 16-byte header.
  167.         data = self._recv_exact(length)
  168.         return data
  169.  
  170.     def write_quadlet(self, address: int, value: int) -> None:
  171.         sock = self._ensure()
  172.         request = bytearray(20)
  173.         request[0:4] = bytes([0x00, 0x00, 0x04, 0x10])
  174.         request[4:8] = bytes([0x00, 0x00, 0xFF, 0xFF])
  175.         request[8:12] = address.to_bytes(4, "big")
  176.         request[12:16] = (0).to_bytes(4, "big")
  177.         request[16:20] = value.to_bytes(4, "big", signed=False)
  178.         sock.sendall(request)
  179.         resp = self._recv_exact(12)
  180.         rcode = resp[3] & 0x0F
  181.         if rcode != 0:
  182.             raise RuntimeError(f"write response returned rcode={rcode}")
  183.  
  184.  
  185. def parse_args() -> argparse.Namespace:
  186.     p = argparse.ArgumentParser(description="groov RIO OptoMMP debug scanner")
  187.     p.add_argument("host", nargs="?", default=None, help="Target RIO IP address (or use --host)")
  188.     p.add_argument("--host", dest="host_kw", default=None, help="Target RIO IP address")
  189.     p.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"OptoMMP TCP port (default {DEFAULT_PORT})")
  190.     p.add_argument("--mode", choices=["once", "watch"], default="once", help="Scan once or continuously")
  191.     p.add_argument("--interval", type=float, default=DEFAULT_INTERVAL, help="Seconds between scans in watch mode")
  192.     p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, help="Socket timeout in seconds")
  193.     p.add_argument("--heal-timeout", type=float, default=DEFAULT_HEAL_TIMEOUT, help="How long to keep retrying the heal read")
  194.     p.add_argument("--no-ping", action="store_true", help="Skip ICMP ping check")
  195.     p.add_argument("--csv", default=None, help="Path to groov_rio_memory_map_filtered.csv")
  196.     p.add_argument("--patterns", default=None, help="Path to groov_rio_address_patterns.csv")
  197.     p.add_argument("--json", action="store_true", help="Emit JSON per scan instead of text")
  198.     p.add_argument("--quiet-heal", action="store_true", help="Suppress heal progress messages")
  199.     args = p.parse_args()
  200.     args.host = args.host_kw or args.host
  201.     if not args.host:
  202.         p.error("provide a target IP either as arg[1] or with --host")
  203.     try:
  204.         ipaddress.ip_address(args.host)
  205.     except ValueError as exc:
  206.         p.error(f"invalid IP address: {exc}")
  207.     return args
  208.  
  209.  
  210. def default_csv_path(filename: str) -> str:
  211.     return os.path.join(os.path.dirname(os.path.abspath(__file__)), filename)
  212.  
  213.  
  214. def load_register_defs(csv_path: Optional[str]) -> Dict[str, RegisterDef]:
  215.     path = csv_path or default_csv_path("groov_rio_memory_map_filtered.csv")
  216.     regs: Dict[str, RegisterDef] = {}
  217.     if os.path.exists(path):
  218.         try:
  219.             with open(path, newline="", encoding="utf-8") as fh:
  220.                 reader = csv.DictReader(fh)
  221.                 for row in reader:
  222.                     area = (row.get("area") or "").strip().lower()
  223.                     desc = (row.get("description") or "").strip()
  224.                     address_hex = (row.get("address_hex") or "").strip()
  225.                     length_hex = (row.get("length_hex") or "").strip()
  226.                     typ = (row.get("type") or "").strip()
  227.                     if not address_hex or not length_hex:
  228.                         continue
  229.                     key = infer_register_name(area, desc, address_hex)
  230.                     if not key:
  231.                         continue
  232.                     try:
  233.                         address = int(address_hex.replace(" ", ""), 16)
  234.                         length = int(length_hex.replace(" ", ""), 16)
  235.                     except ValueError:
  236.                         continue
  237.                     regs[key] = RegisterDef(key, address, length, typ, desc)
  238.         except Exception:
  239.             pass
  240.     for key, (address, length, typ) in FALLBACK_REGS.items():
  241.         regs.setdefault(key, RegisterDef(key, address, length, typ, key.replace("_", " ")))
  242.     return regs
  243.  
  244.  
  245. def infer_register_name(area: str, desc: str, address_hex: str) -> Optional[str]:
  246.     d = desc.lower()
  247.     if area == "status_read":
  248.         if "memory map revision number" in d:
  249.             return "mem_map_version"
  250.         if "powerup clear flag" in d:
  251.             return "powerup_clear_flag"
  252.         if "busy flag" in d:
  253.             return "busy_flag"
  254.         if "last error code" in d:
  255.             return "last_error_code"
  256.         if "transaction label" in d:
  257.             return "txn_label"
  258.         if "source address of the unit" in d:
  259.             return "source_address"
  260.         if "error address for last error" in d:
  261.             return "error_address"
  262.         if d.startswith("loader revision"):
  263.             return "loader_revision"
  264.         if d.startswith("firmware (kernel) revision"):
  265.             return "firmware_revision"
  266.         if d.startswith("unit type"):
  267.             return "unit_type"
  268.         if "hardware revision (month)" in d:
  269.             return "hw_month"
  270.         if "hardware revision (day)" in d:
  271.             return "hw_day"
  272.         if "hardware revision (year)" in d:
  273.             return "hw_year"
  274.         if "installed ram" in d:
  275.             return "installed_ram"
  276.         if "mac address" in d or "enet1 mac address" in d:
  277.             return "mac_enet1"
  278.         if "enet1 tcp/ip address" in d:
  279.             return "ip_enet1"
  280.         if "enet1 tcp/ip subnet mask" in d:
  281.             return "subnet_enet1"
  282.         if "enet1 tcp/ip default gateway" in d:
  283.             return "gateway_enet1"
  284.         if "enet1 dns server address" in d:
  285.             return "dns_enet1"
  286.         if "bootp" in d or "dhcp" in d:
  287.             return "bootp_dhcp_status"
  288.         if "uptime" in d:
  289.             return "uptime_ms"
  290.     if area == "status_write" and "operation code" in d:
  291.         return "operation_code"
  292.     if area == "quality":
  293.         if "quality indicators present" in d:
  294.             return "quality_mask"
  295.         if "(0,0) quality indicator code" in d:
  296.             return "quality_code_0_0"
  297.     if area == "module_build":
  298.         if "module 0. module type" in d:
  299.             return "module0_type"
  300.         if "firmware mode" in d:
  301.             return "module0_firmware_mode"
  302.         if "manufacturer’s serial number" in d or "manufacturer's serial number" in d:
  303.             return "module0_serial"
  304.     return None
  305.  
  306.  
  307. def ping(host: str, timeout_s: float = 1.0) -> bool:
  308.     system = platform.system().lower()
  309.     if system == "windows":
  310.         cmd = ["ping", "-n", "1", "-w", str(int(timeout_s * 1000)), host]
  311.     else:
  312.         cmd = ["ping", "-c", "1", "-W", str(max(1, int(timeout_s))), host]
  313.     try:
  314.         proc = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  315.         return proc.returncode == 0
  316.     except Exception:
  317.         return False
  318.  
  319.  
  320. def decode_value(data: bytes, typ: str):
  321.     typ = (typ or "").upper()
  322.     n = len(data)
  323.     if typ == "IP" and n == 4:
  324.         return ".".join(str(b) for b in data)
  325.     if typ == "MAC" and n == 6:
  326.         return ":".join(f"{b:02X}" for b in data)
  327.     if typ == "I" and n == 4:
  328.         return struct.unpack(">i", data)[0]
  329.     if typ == "F" and n == 4:
  330.         return struct.unpack(">f", data)[0]
  331.     if typ in {"UI", "M", "B"}:
  332.         if n == 1:
  333.             return data[0]
  334.         if n == 2:
  335.             return int.from_bytes(data, "big", signed=False)
  336.         if n == 4:
  337.             return int.from_bytes(data, "big", signed=False)
  338.         if n == 8:
  339.             return int.from_bytes(data, "big", signed=False)
  340.     return data.hex()
  341.  
  342.  
  343. def decode_firmware_word_as_text(value: int) -> str:
  344.     b = value.to_bytes(4, "big", signed=False)
  345.     return f"{b[0]}.{b[1]} rev={b[2]} letter={b[3]}"
  346.  
  347.  
  348. def decode_hw_date(month: int, day: int, year: int) -> str:
  349.     try:
  350.         return f"{year:04d}-{month:02d}-{day:02d}"
  351.     except Exception:
  352.         return f"{month}/{day}/{year}"
  353.  
  354.  
  355. def module_type_name(code: int) -> str:
  356.     return MODULE_TYPES.get(code, f"0x{code:08X}")
  357.  
  358.  
  359. def unit_type_name(code: int) -> str:
  360.     return UNIT_TYPES.get(code, f"0x{code:08X}")
  361.  
  362.  
  363. def quality_channels_from_mask(mask_value: int, max_channels: int = 64) -> List[int]:
  364.     channels: List[int] = []
  365.     for ch in range(max_channels):
  366.         if (mask_value >> ch) & 1:
  367.             channels.append(ch)
  368.     return channels
  369.  
  370.  
  371. def read_quality_codes(client: OptoMMPClient, base_addr: int, count: int = 64) -> List[int]:
  372.     # For groov RIO all channels are effectively module position 0 with channel stride 4.
  373.     block = client.read_block(base_addr, count * 4)
  374.     return [int.from_bytes(block[i:i + 4], "big", signed=False) for i in range(0, len(block), 4)]
  375.  
  376.  
  377. def heal_read_all_modules(host: str, port: int, timeout: float, heal_timeout: float, quiet: bool = False) -> bool:
  378.     start = time.time()
  379.     attempts = 0
  380.     while time.time() - start < heal_timeout:
  381.         attempts += 1
  382.         try:
  383.             client = OptoMMPClient(host, port=port, timeout=timeout)
  384.             client.connect()
  385.             _ = client.read_block(0xFFFFF8110000, DEFAULT_MODULE_BUILD_LENGTH)
  386.             client.close()
  387.             if not quiet:
  388.                 print(f"[heal] module-build read succeeded after {attempts} attempt(s)", file=sys.stderr)
  389.             return True
  390.         except Exception as exc:
  391.             if not quiet:
  392.                 print(f"[heal] attempt {attempts}: {exc}", file=sys.stderr)
  393.             time.sleep(1.0)
  394.     return False
  395.  
  396.  
  397. def read_named(client: OptoMMPClient, reg: RegisterDef):
  398.     raw = client.read_block(reg.address, reg.length)
  399.     return decode_value(raw, reg.typ)
  400.  
  401.  
  402. def perform_scan(host: str, port: int, timeout: float, regs: Dict[str, RegisterDef], do_ping: bool,
  403.                  heal_timeout: float, quiet_heal: bool) -> Dict[str, object]:
  404.     result: Dict[str, object] = {
  405.         "timestamp": dt.datetime.now().isoformat(timespec="seconds"),
  406.         "host": host,
  407.         "port": port,
  408.     }
  409.  
  410.     ping_ok = True
  411.     if do_ping:
  412.         ping_ok = ping(host)
  413.         result["ping_ok"] = ping_ok
  414.  
  415.     if do_ping and not ping_ok:
  416.         healed = heal_read_all_modules(host, port, timeout, heal_timeout, quiet=quiet_heal)
  417.         result["heal_attempted"] = True
  418.         result["heal_succeeded"] = healed
  419.         result["ping_ok_after_heal"] = ping(host)
  420.     else:
  421.         result["heal_attempted"] = False
  422.  
  423.     client = OptoMMPClient(host, port=port, timeout=timeout)
  424.     try:
  425.         client.connect()
  426.         status: Dict[str, object] = {}
  427.         for key in [
  428.             "mem_map_version", "powerup_clear_flag", "busy_flag", "last_error_code", "txn_label",
  429.             "source_address", "error_address", "loader_revision", "firmware_revision", "unit_type",
  430.             "hw_month", "hw_day", "hw_year", "installed_ram", "mac_enet1", "ip_enet1",
  431.             "subnet_enet1", "gateway_enet1", "dns_enet1", "bootp_dhcp_status", "uptime_ms",
  432.         ]:
  433.             reg = regs.get(key)
  434.             if reg is None:
  435.                 continue
  436.             try:
  437.                 status[key] = read_named(client, reg)
  438.             except Exception as exc:
  439.                 status[key] = f"ERROR: {exc}"
  440.  
  441.         # Derived fields.
  442.         unit_type = status.get("unit_type")
  443.         if isinstance(unit_type, int):
  444.             status["unit_type_name"] = unit_type_name(unit_type)
  445.         if isinstance(status.get("loader_revision"), int):
  446.             status["loader_revision_text"] = decode_firmware_word_as_text(status["loader_revision"])  # type: ignore[index]
  447.         if isinstance(status.get("firmware_revision"), str):
  448.             status["firmware_revision_text"] = status["firmware_revision"]
  449.         if all(isinstance(status.get(k), int) for k in ("hw_month", "hw_day", "hw_year")):
  450.             status["hardware_revision_date"] = decode_hw_date(
  451.                 status["hw_month"], status["hw_day"], status["hw_year"]
  452.             )
  453.         if isinstance(status.get("uptime_ms"), int):
  454.             status["uptime_s"] = round(status["uptime_ms"] / 1000.0, 3)
  455.  
  456.         result["status"] = status
  457.  
  458.         # Module info / the healing read.
  459.         module_info: Dict[str, object] = {}
  460.         try:
  461.             module_block = client.read_block(0xFFFFF8110000, DEFAULT_MODULE_BUILD_LENGTH)
  462.             module0_type = int.from_bytes(module_block[0:4], "big", signed=False)
  463.             module0_mode = int.from_bytes(module_block[8:12], "big", signed=False)
  464.             module0_serial = int.from_bytes(module_block[12:16], "big", signed=False)
  465.             module_info = {
  466.                 "module0_type": module0_type,
  467.                 "module0_type_name": module_type_name(module0_type),
  468.                 "module0_firmware_mode": module0_mode,
  469.                 "module0_firmware_mode_text": chr(module0_mode) if 0 <= module0_mode <= 255 else str(module0_mode),
  470.                 "module0_serial": module0_serial,
  471.                 "module_build_read_ok": True,
  472.             }
  473.         except Exception as exc:
  474.             module_info = {"module_build_read_ok": False, "error": str(exc)}
  475.         result["module_build"] = module_info
  476.  
  477.         # Quality mask and per-channel quality codes.
  478.         quality: Dict[str, object] = {}
  479.         try:
  480.             mask_reg = regs["quality_mask"]
  481.             qmask = read_named(client, mask_reg)
  482.             assert isinstance(qmask, int)
  483.             channels = quality_channels_from_mask(qmask)
  484.             quality["mask"] = f"0x{qmask:016X}"
  485.             quality["channels_with_quality_bits"] = channels
  486.             codes = read_quality_codes(client, regs["quality_code_0_0"].address, 64)
  487.             nonzero = [{
  488.                 "channel": idx,
  489.                 "code": code,
  490.                 "meaning": QUALITY_CODES.get(code, "see guide page 213"),
  491.             } for idx, code in enumerate(codes) if code != 0]
  492.             quality["nonzero_codes"] = nonzero
  493.         except Exception as exc:
  494.             quality["error"] = str(exc)
  495.         result["quality"] = quality
  496.  
  497.     finally:
  498.         client.close()
  499.  
  500.     return result
  501.  
  502.  
  503. def format_text_report(scan: Dict[str, object]) -> str:
  504.     lines: List[str] = []
  505.     lines.append(f"[{scan.get('timestamp')}] host={scan.get('host')} port={scan.get('port')}")
  506.     if "ping_ok" in scan:
  507.         lines.append(f"ping_ok={scan.get('ping_ok')}")
  508.     if scan.get("heal_attempted"):
  509.         lines.append(f"heal_attempted=True heal_succeeded={scan.get('heal_succeeded')}")
  510.         if "ping_ok_after_heal" in scan:
  511.             lines.append(f"ping_ok_after_heal={scan.get('ping_ok_after_heal')}")
  512.  
  513.     status = scan.get("status", {})
  514.     if isinstance(status, dict):
  515.         lines.append("status:")
  516.         for key in [
  517.             "unit_type_name", "unit_type", "mem_map_version", "powerup_clear_flag", "busy_flag",
  518.             "last_error_code", "error_address", "loader_revision_text", "firmware_revision_text",
  519.             "hardware_revision_date", "installed_ram", "mac_enet1", "ip_enet1", "subnet_enet1",
  520.             "gateway_enet1", "dns_enet1", "bootp_dhcp_status", "uptime_ms", "uptime_s",
  521.         ]:
  522.             if key in status:
  523.                 lines.append(f"  {key}: {status[key]}")
  524.  
  525.     module_build = scan.get("module_build", {})
  526.     if isinstance(module_build, dict):
  527.         lines.append("module_build:")
  528.         for key in [
  529.             "module_build_read_ok", "module0_type_name", "module0_type",
  530.             "module0_firmware_mode_text", "module0_firmware_mode", "module0_serial", "error",
  531.         ]:
  532.             if key in module_build:
  533.                 lines.append(f"  {key}: {module_build[key]}")
  534.  
  535.     quality = scan.get("quality", {})
  536.     if isinstance(quality, dict):
  537.         lines.append("quality:")
  538.         if "mask" in quality:
  539.             lines.append(f"  mask: {quality['mask']}")
  540.         channels = quality.get("channels_with_quality_bits", [])
  541.         if isinstance(channels, list):
  542.             lines.append(f"  channels_with_quality_bits: {channels}")
  543.         nonzero = quality.get("nonzero_codes", [])
  544.         if isinstance(nonzero, list) and nonzero:
  545.             lines.append("  nonzero_codes:")
  546.             for item in nonzero:
  547.                 lines.append(
  548.                     f"    ch {item['channel']:02d}: code={item['code']} meaning={item['meaning']}"
  549.                 )
  550.         elif "error" in quality:
  551.             lines.append(f"  error: {quality['error']}")
  552.         else:
  553.             lines.append("  nonzero_codes: []")
  554.  
  555.     return "\n".join(lines)
  556.  
  557.  
  558. def main() -> int:
  559.     args = parse_args()
  560.     regs = load_register_defs(args.csv)
  561.  
  562.     while True:
  563.         try:
  564.             scan = perform_scan(
  565.                 host=args.host,
  566.                 port=args.port,
  567.                 timeout=args.timeout,
  568.                 regs=regs,
  569.                 do_ping=not args.no_ping,
  570.                 heal_timeout=args.heal_timeout,
  571.                 quiet_heal=args.quiet_heal,
  572.             )
  573.             if args.json:
  574.                 print(json.dumps(scan, indent=2, sort_keys=False))
  575.             else:
  576.                 print(format_text_report(scan))
  577.         except KeyboardInterrupt:
  578.             return 130
  579.         except Exception as exc:
  580.             stamp = dt.datetime.now().isoformat(timespec="seconds")
  581.             msg = {"timestamp": stamp, "host": args.host, "error": str(exc)}
  582.             if args.json:
  583.                 print(json.dumps(msg, indent=2))
  584.             else:
  585.                 print(f"[{stamp}] ERROR host={args.host}: {exc}", file=sys.stderr)
  586.  
  587.         if args.mode == "once":
  588.             return 0
  589.         time.sleep(max(0.1, args.interval))
  590.  
  591.  
  592. if __name__ == "__main__":
  593.     raise SystemExit(main())
  594.  
Advertisement
Add Comment
Please, Sign In to add comment