From 91818781dfc186236259066e20af1a1baa03f180 Mon Sep 17 00:00:00 2001 From: Robert McMahon Date: Fri, 3 Apr 2026 13:23:55 -0700 Subject: [PATCH] check_concentrator: single hub table with Location (local vs remote IP). Collect local rows via concentrator discovery; parse remote show_hostcards tables; renumber globally. Location is 'local' or resolved IPv4 (fallback host string). Made-with: Cursor --- tests/check_concentrator.py | 163 +++++++++++++++++++++++++++--------- 1 file changed, 122 insertions(+), 41 deletions(-) diff --git a/tests/check_concentrator.py b/tests/check_concentrator.py index df61d8a..6f98a27 100644 --- a/tests/check_concentrator.py +++ b/tests/check_concentrator.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 """ -Smoke check: :class:`fiwi.concentrator.FiWiConcentrator` plus **consolidated** USB hub inventory -for **this machine** and every configured **remote** hub host (``show_hostcards`` / same discovery -path as ``discover``). Optional Adnacom PCIe host-card catalog at the end. +Smoke check: :class:`fiwi.concentrator.FiWiConcentrator` plus a **single** USB hub table covering +**this machine** and each configured **remote** (serial, ports, and Location = ``local`` or remote +IPv4 when resolvable). Adnacom PCIe catalog last. -Requires BrainStem (``brainstem``) for local ``show_hostcards`` when this host has hubs attached. -Remote hosts need ``fiwi.py`` + venv on the Pi as for ``check_remote``. +Requires BrainStem locally for hub enumeration on this host. Remotes use SSH ``show_hostcards`` +(captured and parsed for the summary table). **Standalone**:: @@ -23,6 +23,7 @@ from __future__ import annotations import argparse import os +import re import socket import sys from typing import TYPE_CHECKING @@ -37,6 +38,10 @@ if _ROOT not in sys.path: _WIDTH = 62 _paths_configured = False +_HUB_TABLE_ROW_RE = re.compile( + r"^\s*\d+\s+\|\s+(0x[0-9A-Fa-f]+)\s+\|\s+(.+)\s*$" +) + def _rule(char: str = "-") -> str: return char * _WIDTH @@ -61,6 +66,70 @@ def _instantiate_concentrator() -> FiWiConcentrator: return c +def _remote_location_label(ssh_target: str) -> str: + """IPv4 for ``user@host`` when possible; otherwise the host part.""" + if "@" not in ssh_target: + host = ssh_target.strip() + else: + host = ssh_target.split("@", 1)[1].strip() + if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", host): + return host + try: + return socket.gethostbyname(host) + except OSError: + return host + + +def _collect_local_hub_rows(c: FiWiConcentrator) -> list[tuple[str, str]]: + """ + ``(serial_hex, ports_display)`` per local USB hub, same rules as + :meth:`fiwi.concentrator.FiWiConcentrator._print_usb_hub_summary_table`. + """ + specs = c._enumerate_usb_specs() + if not specs: + return [] + if not c.hubs: + c._connect_specs(specs) + by_sn = c._serial_to_opened_port_count() + out: list[tuple[str, str]] = [] + for spec in specs: + sn = spec.serial_number + sn_s = f"0x{sn:08X}" + if sn in by_sn: + ports = str(by_sn[sn]) + else: + inf = c._inferred_downstream_ports_from_spec(spec) + ports = str(inf) if inf is not None else "?" + ports += " *" + out.append((sn_s, ports)) + return out + + +def _parse_hub_table_from_show_hostcards_stdout(text: str) -> list[tuple[str, str]]: + """Extract ``(serial, ports)`` from ``show_hostcards`` printed table.""" + lines = text.splitlines() + in_table = False + rows: list[tuple[str, str]] = [] + for ln in lines: + if "Hub" in ln and "Serial" in ln and "Ports" in ln and "|" in ln: + in_table = True + continue + if not in_table: + continue + if re.match(r"^\s*-+\s*$", ln): + continue + if not ln.strip(): + break + if ln.strip().startswith("*"): + break + m = _HUB_TABLE_ROW_RE.match(ln) + if m: + rows.append((m.group(1), m.group(2).strip())) + elif rows: + break + return rows + + def _print_banner(config_label: str) -> None: print(_rule("="), flush=True) print(f"Fi-Wi concentrator check [config: {config_label}]", flush=True) @@ -97,49 +166,69 @@ def _merged_hub_hosts() -> list[str]: return seen -def _print_local_usb_section(c: FiWiConcentrator) -> None: - hn = socket.gethostname() - print(f"--- This machine — {hn} (local USB) ---", flush=True) - print(flush=True) - c.show_hostcards() - print(flush=True) - - -def _print_remote_usb_sections() -> int: +def _print_consolidated_usb_table(c: FiWiConcentrator) -> int: """ - Run ``show_hostcards`` over SSH for each merged hub host. Returns 0 if all ok, else 1. + One table: #, Serial, Ports, Location (``local`` or remote IP / host). + Returns 0 if all remote SSH runs succeeded, else 1. """ - from fiwi.ssh import SshNode + rc = 0 + rows: list[tuple[str, str, str]] = [] + + for serial, ports in _collect_local_hub_rows(c): + rows.append((serial, ports, "local")) hosts = _merged_hub_hosts() if not hosts: - print("--- Remote USB hubs ---", flush=True) - print( - " (no remote hosts) — set [remote_hubs] hosts or FIWI_REMOTE_HUBS / " - "FIWI_CALIBRATE_REMOTES.", - flush=True, - ) + print(" (no remote hosts in config — only local hubs listed.)", flush=True) print(flush=True) - return 0 - rc = 0 + from fiwi.ssh import SshNode + for host in hosts: - print(f"--- Remote USB hubs — SSH → {host} ---", flush=True) - print(flush=True) + loc = _remote_location_label(host) try: node = SshNode.parse(host) code, out, err = node.invoke_capture(["show_hostcards"], timeout=90, defer=False) - if out.strip(): - print(out.rstrip(), flush=True) if err.strip(): print(err.rstrip(), file=sys.stderr, flush=True) if code != 0: - print(f" (show_hostcards exit {code})", flush=True) + print(f" ! Remote {host} ({loc}): show_hostcards exit {code}", flush=True) rc = 1 + continue + parsed = _parse_hub_table_from_show_hostcards_stdout(out) + if not parsed: + print( + f" ! Remote {host} ({loc}): no hub table in output " + f"(empty discover / parse miss).", + flush=True, + ) + rc = 1 + continue + for serial, ports in parsed: + rows.append((serial, ports, loc)) except Exception as exc: - print(f" SSH / remote run failed: {exc}", flush=True) + print(f" ! Remote {host} ({loc}): {exc}", flush=True) rc = 1 + + print("USB power-control hubs (consolidated)", flush=True) + print(_rule(), flush=True) + hn = socket.gethostname() + print(f" Local host: {hn} | Remote resolution: IPv4 when possible, else hostname", flush=True) + print(flush=True) + + if not rows: + print( + " (no hubs) — none on this machine and none parsed from remotes.", + flush=True, + ) print(flush=True) + return rc + + print(f"{'#':<4} | {'Serial':<12} | {'Ports':<10} | Location", flush=True) + print("-" * 52, flush=True) + for i, (serial, ports, loc) in enumerate(rows, start=1): + print(f"{i:<4} | {serial:<12} | {ports:<10} | {loc}", flush=True) + print(flush=True) return rc @@ -153,16 +242,8 @@ def _print_pcie_catalog_section() -> None: def _print_consolidated_report(c: FiWiConcentrator) -> int: - """ - One flow: config summary → USB hubs (local + all remotes) → PCIe catalog. - Returns 0 if remote SSH sections all succeeded, else 1 (local errors still raise). - """ _print_ssh_and_hosts_summary() - print("USB power-control hubs (BrainStem hostcards)", flush=True) - print(_rule(), flush=True) - print(flush=True) - _print_local_usb_section(c) - remote_rc = _print_remote_usb_sections() + remote_rc = _print_consolidated_usb_table(c) _print_pcie_catalog_section() return remote_rc @@ -174,7 +255,7 @@ def test_concentrator() -> None: def _parse_args() -> argparse.Namespace: p = argparse.ArgumentParser( - description="Check FiWiConcentrator: consolidated local + remote USB hub inventory.", + description="Check FiWiConcentrator: consolidated local + remote USB hub table.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=( "PROFILE selects /config/.ini (e.g. uax24, uax4, default).\n" @@ -224,7 +305,7 @@ def main() -> int: print(_rule("="), flush=True) print(f"FiWiConcentrator() OK [config: {label}]", flush=True) if remote_fail != 0: - print(" (one or more remote show_hostcards runs failed — see above)", flush=True) + print(" (one or more remote hub queries had errors — see above)", flush=True) print(_rule("="), flush=True) return remote_fail