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
This commit is contained in:
Robert McMahon 2026-04-03 13:23:55 -07:00
parent f3e514ffdf
commit 91818781df
1 changed files with 122 additions and 41 deletions

View File

@ -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,48 +166,68 @@ 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 <repo>/config/<PROFILE>.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