check_concentrator: USB identity column, TTY in Location, powercycle test.

- usb_probe: sysfs scan for 24ff hubs (bus/dev, VID:PID, strings) plus ttyUSB/ttyACM;
  usb_acroname_hub_identity_list(); usb-hub-tty-json returns full records.
- check_concentrator: Location stays host+TTY; new USB column (lsusb-style line);
  enrich via local sysfs and remote usb-hub-tty-json per SSH host.
- Add --powercycle: all ports OFF/verify then ON/verify (local + remotes), restore on failure.

Made-with: Cursor
This commit is contained in:
Robert McMahon 2026-04-03 14:11:35 -07:00
parent 64a3e5a604
commit 63bb205383
3 changed files with 478 additions and 13 deletions

View File

@ -115,6 +115,7 @@ def _print_cli_help() -> None:
" discover — USB power-control hubs (serial, port count); no port I/O\n" " discover — USB power-control hubs (serial, port count); no port I/O\n"
" show_hostcards — same as discover, concentrator 'hostcards' label\n" " show_hostcards — same as discover, concentrator 'hostcards' label\n"
" port-metrics-json — JSON list: per-port power, current (mA), voltage (V) on connected hubs\n" " port-metrics-json — JSON list: per-port power, current (mA), voltage (V) on connected hubs\n"
" usb-hub-tty-json — JSON: Acroname (24ff) hubs: serial, tty[], bus, dev, id, manufacturer, product\n"
" show_radioheads — all fiber_ports rows + power (same table as fiber status)\n" " show_radioheads — all fiber_ports rows + power (same table as fiber status)\n"
" status [target] — default command; target like all, 1.3, all.2\n" " status [target] — default command; target like all, 1.3, all.2\n"
" fiber status — fiber_ports + power (local or per-entry ssh / host+user)\n" " fiber status — fiber_ports + power (local or per-entry ssh / host+user)\n"
@ -241,6 +242,8 @@ def main() -> int:
print(json.dumps([[h, p] for h, p in pairs]), flush=True) print(json.dumps([[h, p] for h, p in pairs]), flush=True)
elif cmd == "lsusb-lines-json": elif cmd == "lsusb-lines-json":
print(json.dumps(usb.lsusb_lines()), flush=True) print(json.dumps(usb.lsusb_lines()), flush=True)
elif cmd == "usb-hub-tty-json":
print(json.dumps(usb.usb_acroname_hub_identity_list()), flush=True)
elif cmd == "wlan-info-json": elif cmd == "wlan-info-json":
from fiwi.ieee80211_dev import discover_wireless_for_map from fiwi.ieee80211_dev import discover_wireless_for_map
@ -370,7 +373,7 @@ def main() -> int:
else: else:
print(f"Unknown command: {cmd!r}", file=sys.stderr, flush=True) print(f"Unknown command: {cmd!r}", file=sys.stderr, flush=True)
print( print(
"Try: --ssh user@host … | discover | show_hostcards | port-metrics-json | show_radioheads | calibrate-ports-json | … | help", "Try: --ssh user@host … | discover | show_hostcards | port-metrics-json | usb-hub-tty-json | show_radioheads | calibrate-ports-json | … | help",
file=sys.stderr, file=sys.stderr,
flush=True, flush=True,
) )

View File

@ -3,8 +3,14 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import glob
import os
import shutil import shutil
import subprocess import subprocess
from pathlib import Path
# Acroname / BrainStem USB power-control hubs (see udev examples in concentrator).
_ACRONAME_USB_VID = 0x24FF
def lsusb_lines(): def lsusb_lines():
@ -84,3 +90,152 @@ def lsusb_acroname_lines():
async def alsusb_acroname_lines() -> list[str]: async def alsusb_acroname_lines() -> list[str]:
lines = await alsusb_lines() lines = await alsusb_lines()
return [ln for ln in lines if "24ff:" in ln.lower() or " acroname" in ln.lower()] return [ln for ln in lines if "24ff:" in ln.lower() or " acroname" in ln.lower()]
def _parse_sysfs_usb_serial(text: str) -> int | None:
t = text.strip()
if not t:
return None
try:
return int(t, 0)
except ValueError:
try:
return int(t, 16)
except ValueError:
return None
def _read_sysfs_int(path: Path) -> int | None:
try:
return int(path.read_text().strip(), 10)
except (OSError, ValueError):
return None
def _read_sysfs_hex16(path: Path) -> int | None:
try:
return int(path.read_text().strip(), 16)
except (OSError, ValueError):
return None
def _tty_nodes_by_acroname_serial() -> dict[int, list[str]]:
"""Map hub serial (int) → ``ttyUSB*`` / ``ttyACM*`` names for 24ff devices."""
by_serial: dict[int, set[str]] = {}
for pattern in ("/sys/class/tty/ttyUSB*", "/sys/class/tty/ttyACM*"):
for tty_class in sorted(glob.glob(pattern)):
dev_link = Path(tty_class) / "device"
if not dev_link.exists():
continue
usb_dev = _usb_device_dir_for_tty_class_device(dev_link)
if usb_dev is None:
continue
try:
vid = int((usb_dev / "idVendor").read_text().strip(), 16)
except (ValueError, OSError):
continue
if vid != _ACRONAME_USB_VID:
continue
try:
sn_raw = (usb_dev / "serial").read_text()
except OSError:
continue
sn = _parse_sysfs_usb_serial(sn_raw)
if sn is None:
continue
by_serial.setdefault(sn, set()).add(os.path.basename(tty_class))
return {k: sorted(v) for k, v in by_serial.items()}
def _usb_device_dir_for_tty_class_device(device_link: Path) -> Path | None:
"""
From ``/sys/class/tty/ttyUSBn/device``, walk up to the USB *device* directory
(the one that has ``idVendor`` / ``serial``, not the ``*:1.0`` interface).
"""
try:
p = device_link.resolve()
except OSError:
return None
for _ in range(20):
vendor = p / "idVendor"
serial = p / "serial"
if vendor.is_file() and serial.is_file():
return p
if p == p.parent:
break
p = p.parent
return None
def usb_acroname_hub_identity_list() -> list[dict[str, object]]:
"""
One record per Acroname (24ff) USB device with a ``serial`` sysfs file same facts as a short
``lsusb`` line (bus, device, ID, strings), plus matching ``ttyUSB*`` / ``ttyACM*`` names when
the hub exposes a serial TTY.
Keys: ``serial``, ``tty`` (list), ``bus``, ``dev``, ``id`` (``24ff:xxxx``), ``manufacturer``,
``product``.
"""
tty_by_sn = _tty_nodes_by_acroname_serial()
out_map: dict[int, dict[str, object]] = {}
base = Path("/sys/bus/usb/devices")
try:
entries = sorted(base.iterdir(), key=lambda p: p.name)
except OSError:
return []
for p in entries:
if not p.is_dir():
continue
if ":" in p.name:
continue
vid_f, ser_f = p / "idVendor", p / "serial"
if not vid_f.is_file() or not ser_f.is_file():
continue
vid = _read_sysfs_hex16(vid_f)
if vid is None or vid != _ACRONAME_USB_VID:
continue
sn = _parse_sysfs_usb_serial(ser_f.read_text())
if sn is None:
continue
pid = _read_sysfs_hex16(p / "idProduct")
bus = _read_sysfs_int(p / "busnum")
dev = _read_sysfs_int(p / "devnum")
man = ""
prod = ""
try:
mf = p / "manufacturer"
if mf.is_file():
man = mf.read_text().strip()
except OSError:
pass
try:
pf = p / "product"
if pf.is_file():
prod = pf.read_text().strip()
except OSError:
pass
vp = f"{vid:04x}:{pid:04x}" if pid is not None else f"{vid:04x}:????"
out_map[sn] = {
"serial": f"0x{sn:08X}",
"tty": list(tty_by_sn.get(sn, [])),
"bus": bus,
"dev": dev,
"id": vp,
"manufacturer": man,
"product": prod,
}
return [out_map[k] for k in sorted(out_map.keys())]
def usb_acroname_hub_tty_map() -> list[dict[str, object]]:
"""
Subset of :func:`usb_acroname_hub_identity_list` only ``serial`` and ``tty`` keys per hub.
``tty`` may be an empty list when the device has no ``ttyUSB`` / ``ttyACM`` node.
"""
return [
{"serial": x["serial"], "tty": list(x.get("tty") or [])}
for x in usb_acroname_hub_identity_list()
]

View File

@ -2,7 +2,8 @@
""" """
Smoke check: :class:`fiwi.concentrator.FiWiConcentrator` plus consolidated USB hub and per-port Smoke check: :class:`fiwi.concentrator.FiWiConcentrator` plus consolidated USB hub and per-port
metrics tables covering **this machine** and each configured **remote** (serial, ports, Location = metrics tables covering **this machine** and each configured **remote** (serial, ports, Location =
``local`` or remote IPv4 when resolvable). Adnacom PCIe catalog last. host plus **TTY** when known, and a **USB** column with Bus/Device, VID:PID, and product string from
sysfs the same facts as a short ``lsusb`` line). Adnacom PCIe catalog last.
Requires BrainStem locally for hub enumeration on this host. Remotes use SSH ``show_hostcards`` Requires BrainStem locally for hub enumeration on this host. Remotes use SSH ``show_hostcards``
and ``port-metrics-json``; the remote tree must include that command (same revision as here). and ``port-metrics-json``; the remote tree must include that command (same revision as here).
@ -14,6 +15,9 @@ and ``port-metrics-json``; the remote tree must include that command (same revis
``--config`` matches ``FIWI_CONFIG`` (profile or absolute ``*.ini``). ``--config`` matches ``FIWI_CONFIG`` (profile or absolute ``*.ini``).
``--powercycle`` runs a destructive self-test on **local** USB hubs and each host in
``FIWI_CALIBRATE_REMOTES`` / merged hub hosts: all ports OFF (verify), then all ON (verify).
With pytest:: With pytest::
FIWI_CONFIG=uax24 pytest tests/check_concentrator.py FIWI_CONFIG=uax24 pytest tests/check_concentrator.py
@ -27,7 +31,8 @@ import os
import re import re
import socket import socket
import sys import sys
from dataclasses import dataclass import time
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
@ -55,6 +60,10 @@ class ConsolidatedHubRow:
n_ports: int n_ports: int
location: str location: str
ssh_target: str | None ssh_target: str | None
#: e.g. ``ttyUSB0`` or ``ttyUSB0+ttyACM0`` from sysfs (24ff hubs); empty if unknown.
usb_tty_hint: str = ""
#: sysfs / ``lsusb``-style line: Bus/Dev, ID ``24ff:…``, product string.
usb_identity_hint: str = ""
def _rule(char: str = "-") -> str: def _rule(char: str = "-") -> str:
@ -254,6 +263,106 @@ def _build_consolidated_hub_rows(c: FiWiConcentrator) -> tuple[list[Consolidated
return rows, rc return rows, rc
def _local_usb_ent_by_serial() -> dict[str, dict[str, object]]:
"""Normalized hub serial → identity dict from :func:`fiwi.usb_probe.usb_acroname_hub_identity_list`."""
from fiwi import usb_probe as usb_mod
out: dict[str, dict[str, object]] = {}
for ent in usb_mod.usb_acroname_hub_identity_list():
if not isinstance(ent, dict):
continue
sn = _norm_hub_serial(str(ent.get("serial", "")))
if sn:
out[sn] = ent
return out
def _fetch_remote_usb_ent_by_serial(host: str) -> dict[str, dict[str, object]]:
from fiwi.ssh import SshNode
node = SshNode.parse(host)
code, out, err = node.invoke_capture(["usb-hub-tty-json"], timeout=45, defer=False)
if err.strip():
print(err.rstrip(), file=sys.stderr, flush=True)
if code != 0:
return {}
try:
data = json.loads(out.strip() or "[]")
except json.JSONDecodeError:
return {}
if not isinstance(data, list):
return {}
out_map: dict[str, dict[str, object]] = {}
for raw in data:
if not isinstance(raw, dict):
continue
sn = _norm_hub_serial(str(raw.get("serial", "")))
if sn:
out_map[sn] = raw
return out_map
def _usb_identity_display(ent: dict[str, object] | None, *, max_product: int = 42) -> str:
"""Human-readable USB row (matches short ``lsusb`` intent; values come from sysfs)."""
if not ent:
return ""
bus = ent.get("bus")
dev = ent.get("dev")
vp = str(ent.get("id") or "").strip()
prod = str(ent.get("product") or "").replace("\n", " ").strip()
if not prod:
prod = str(ent.get("manufacturer") or "").replace("\n", " ").strip()
if len(prod) > max_product:
prod = prod[: max_product - 3] + "..."
bits: list[str] = []
if isinstance(bus, int) and isinstance(dev, int):
bits.append(f"Bus {bus:03d} Dev {dev:03d}")
if vp:
bits.append(f"ID {vp}")
if prod:
bits.append(prod)
return " · ".join(bits)
def _enrich_hub_rows_usb(rows: list[ConsolidatedHubRow]) -> list[ConsolidatedHubRow]:
"""Fill ``usb_tty_hint`` and ``usb_identity_hint`` from local sysfs or ``usb-hub-tty-json`` over SSH."""
local = _local_usb_ent_by_serial()
remote_maps: dict[str, dict[str, dict[str, object]]] = {}
out: list[ConsolidatedHubRow] = []
for row in rows:
ent: dict[str, object] | None = None
if row.ssh_target is None:
ent = local.get(_norm_hub_serial(row.serial))
else:
if row.ssh_target not in remote_maps:
remote_maps[row.ssh_target] = _fetch_remote_usb_ent_by_serial(row.ssh_target)
ent = remote_maps[row.ssh_target].get(_norm_hub_serial(row.serial))
tty_hint = ""
if ent:
ttys = ent.get("tty")
if isinstance(ttys, list):
tty_hint = "+".join(str(x) for x in ttys if isinstance(x, str))
usb_line = _usb_identity_display(ent)
out.append(
replace(
row,
usb_tty_hint=tty_hint,
usb_identity_hint=usb_line,
)
)
return out
def _location_cell(row: ConsolidatedHubRow) -> str:
if row.usb_tty_hint:
return f"{row.location} · {row.usb_tty_hint}"
return row.location
def _usb_cell(row: ConsolidatedHubRow) -> str:
return row.usb_identity_hint if row.usb_identity_hint else ""
def _index_metrics_by_serial_port(items: list[dict[str, object]]) -> dict[str, dict[int, dict[str, object]]]: def _index_metrics_by_serial_port(items: list[dict[str, object]]) -> dict[str, dict[int, dict[str, object]]]:
out: dict[str, dict[int, dict[str, object]]] = {} out: dict[str, dict[int, dict[str, object]]] = {}
for raw in items: for raw in items:
@ -314,6 +423,180 @@ def _fetch_remote_port_metrics(host: str) -> tuple[int, list[dict[str, object]],
return 0, typed, out, err return 0, typed, out, err
_POWERCYCLE_SETTLE_SEC = 0.35
def _set_all_local_ports(c: FiWiConcentrator, on: bool) -> None:
"""Enable or disable every downstream port on every connected local hub."""
for stem in c.hubs:
n = c._port_count(stem)
for port in range(n):
if on:
stem.usb.setPortEnable(port)
else:
stem.usb.setPortDisable(port)
def _power_mismatches(
rows: list[dict[str, object]], want_on: bool, where: str
) -> list[str]:
"""Each row must have ``power`` of ON or OFF matching ``want_on``."""
want = "ON" if want_on else "OFF"
bad: list[str] = []
for row in rows:
pwr = row.get("power")
if pwr != want:
hub = row.get("hub", "?")
port = row.get("port", "?")
sn = row.get("serial", "?")
bad.append(
f"{where} hub {hub} port {port} serial {sn!r}: power={pwr!r} (expected {want})"
)
return bad
def _restore_all_ports_on(c: FiWiConcentrator, hosts: list[str]) -> None:
"""Best-effort: turn every local and configured-remote port on (cleanup after failed test)."""
from fiwi.ssh import SshNode
print(" (cleanup) Restoring all ports ON…", flush=True)
try:
if not c.hubs:
c.connect()
if c.hubs:
_set_all_local_ports(c, True)
except Exception as exc:
print(f" ! Local restore failed: {exc}", flush=True)
for host in hosts:
try:
code, _o, err = SshNode.parse(host).invoke_capture(
["on", "all"], timeout=120, defer=False
)
if err.strip():
print(err.rstrip(), file=sys.stderr, flush=True)
if code != 0:
print(f" ! Remote {host}: on all restore exit {code}", flush=True)
except Exception as exc:
print(f" ! Remote {host}: on all restore: {exc}", flush=True)
def _run_powercycle_test(c: FiWiConcentrator) -> int:
"""
Turn all ports off, confirm via metrics; then on, confirm.
Covers local hubs (BrainStem) and each merged SSH hub host (``off``/``on all``).
"""
from fiwi.ssh import SshNode
hosts = _merged_hub_hosts()
if not c.hubs:
c.connect()
has_local = bool(c.hubs)
if not has_local and not hosts:
print("FAIL: no local USB hubs and no remote hub hosts configured.", flush=True)
return 1
print("Power-cycle self-test (disrupts USB power on local + remote hub hosts)", flush=True)
print(_rule(), flush=True)
if has_local:
print(f" Local: {len(c.hubs)} hub(s) on {socket.gethostname()}", flush=True)
else:
print(" Local: (no USB power hubs connected here)", flush=True)
if hosts:
print(f" Remote hub hosts: {', '.join(hosts)}", flush=True)
print(flush=True)
completed = False
settle = _POWERCYCLE_SETTLE_SEC
try:
if has_local:
print(" Step 1a — local: OFF all downstream ports…", flush=True)
_set_all_local_ports(c, False)
time.sleep(settle)
snap = c.port_metrics_snapshot()
if not snap:
print(" ! Local: no port-metrics after OFF (empty snapshot).", flush=True)
return 1
bad = _power_mismatches(snap, False, "local")
if bad:
for line in bad:
print(f" ! {line}", flush=True)
return 1
print(" Step 1a — OK (all local ports report OFF).", flush=True)
for host in hosts:
loc = _remote_location_label(host)
print(f" Step 1b — remote {host} ({loc}): OFF all…", flush=True)
node = SshNode.parse(host)
code, _out, err = node.invoke_capture(["off", "all"], timeout=120, defer=False)
if err.strip():
print(err.rstrip(), file=sys.stderr, flush=True)
if code != 0:
print(f" ! Remote off all exit {code}", flush=True)
return 1
time.sleep(settle)
code_m, rows, _o2, _e2 = _fetch_remote_port_metrics(host)
if code_m != 0:
print(f" ! Remote port-metrics-json exit {code_m} after OFF", flush=True)
return 1
if not rows:
print(f" ! Remote {host}: empty port-metrics after OFF.", flush=True)
return 1
bad = _power_mismatches(rows, False, f"remote {host}")
if bad:
for line in bad:
print(f" ! {line}", flush=True)
return 1
print(f" Step 1b — OK ({host}: all OFF).", flush=True)
if has_local:
print(" Step 2a — local: ON all downstream ports…", flush=True)
_set_all_local_ports(c, True)
time.sleep(settle)
snap = c.port_metrics_snapshot()
if not snap:
print(" ! Local: no port-metrics after ON.", flush=True)
return 1
bad = _power_mismatches(snap, True, "local")
if bad:
for line in bad:
print(f" ! {line}", flush=True)
return 1
print(" Step 2a — OK (all local ports report ON).", flush=True)
for host in hosts:
loc = _remote_location_label(host)
print(f" Step 2b — remote {host} ({loc}): ON all…", flush=True)
node = SshNode.parse(host)
code, _out, err = node.invoke_capture(["on", "all"], timeout=120, defer=False)
if err.strip():
print(err.rstrip(), file=sys.stderr, flush=True)
if code != 0:
print(f" ! Remote on all exit {code}", flush=True)
return 1
time.sleep(settle)
code_m, rows, _o2, _e2 = _fetch_remote_port_metrics(host)
if code_m != 0:
print(f" ! Remote port-metrics-json exit {code_m} after ON", flush=True)
return 1
if not rows:
print(f" ! Remote {host}: empty port-metrics after ON.", flush=True)
return 1
bad = _power_mismatches(rows, True, f"remote {host}")
if bad:
for line in bad:
print(f" ! {line}", flush=True)
return 1
print(f" Step 2b — OK ({host}: all ON).", flush=True)
completed = True
print(flush=True)
print("Power-cycle self-test passed.", flush=True)
return 0
finally:
if not completed:
_restore_all_ports_on(c, hosts)
def _print_usb_hub_tables(c: FiWiConcentrator, hub_rows: list[ConsolidatedHubRow], base_rc: int) -> int: def _print_usb_hub_tables(c: FiWiConcentrator, hub_rows: list[ConsolidatedHubRow], base_rc: int) -> int:
"""Print consolidated hub table and per-port power / current / voltage table. Returns updated rc.""" """Print consolidated hub table and per-port power / current / voltage table. Returns updated rc."""
rc = base_rc rc = base_rc
@ -331,10 +614,16 @@ def _print_usb_hub_tables(c: FiWiConcentrator, hub_rows: list[ConsolidatedHubRow
print(flush=True) print(flush=True)
return rc return rc
print(f"{'#':<4} | {'Serial':<12} | {'Ports':<10} | Location", flush=True) print(
print("-" * 52, flush=True) f"{'#':<4} | {'Serial':<12} | {'Ports':<10} | {'Location (host / TTY)':<28} | USB (Bus / ID / product)",
flush=True,
)
print("-" * 100, flush=True)
for row in hub_rows: for row in hub_rows:
print(f"{row.idx:<4} | {row.serial:<12} | {row.ports_display:<10} | {row.location}", flush=True) print(
f"{row.idx:<4} | {row.serial:<12} | {row.ports_display:<10} | {_location_cell(row):<28} | {_usb_cell(row)}",
flush=True,
)
print(flush=True) print(flush=True)
# Per-port metrics: same hub order and port counts as consolidated table. # Per-port metrics: same hub order and port counts as consolidated table.
@ -360,10 +649,11 @@ def _print_usb_hub_tables(c: FiWiConcentrator, hub_rows: list[ConsolidatedHubRow
print("Per-port power (consolidated hub order)", flush=True) print("Per-port power (consolidated hub order)", flush=True)
print(_rule(), flush=True) print(_rule(), flush=True)
print( print(
f"{'Hub#':<4} | {'Serial':<12} | {'Pt':<3} | {'Pwr':<5} | {'mA':>8} | {'V':>8} | Location", f"{'Hub#':<4} | {'Serial':<12} | {'Pt':<3} | {'Pwr':<5} | {'mA':>8} | {'V':>8} | "
f"{'Loc (host/TTY)':<22} | USB (Bus / ID / product)",
flush=True, flush=True,
) )
print("-" * 62, flush=True) print("-" * 110, flush=True)
for row in hub_rows: for row in hub_rows:
sn_key = _norm_hub_serial(row.serial) sn_key = _norm_hub_serial(row.serial)
@ -395,7 +685,8 @@ def _print_usb_hub_tables(c: FiWiConcentrator, hub_rows: list[ConsolidatedHubRow
except (TypeError, ValueError): except (TypeError, ValueError):
v_s = "" v_s = ""
print( print(
f"{row.idx:<4} | {row.serial:<12} | {port:<3} | {pwr:<5} | {ma_s:>8} | {v_s:>8} | {row.location}", f"{row.idx:<4} | {row.serial:<12} | {port:<3} | {pwr:<5} | {ma_s:>8} | {v_s:>8} | "
f"{_location_cell(row):<22} | {_usb_cell(row)}",
flush=True, flush=True,
) )
@ -428,6 +719,7 @@ def _print_pcie_catalog_section() -> None:
def _print_consolidated_report(c: FiWiConcentrator) -> int: def _print_consolidated_report(c: FiWiConcentrator) -> int:
_print_ssh_and_hosts_summary() _print_ssh_and_hosts_summary()
hub_rows, remote_rc = _build_consolidated_hub_rows(c) hub_rows, remote_rc = _build_consolidated_hub_rows(c)
hub_rows = _enrich_hub_rows_usb(hub_rows)
remote_rc = _print_usb_hub_tables(c, hub_rows, remote_rc) remote_rc = _print_usb_hub_tables(c, hub_rows, remote_rc)
_print_pcie_catalog_section() _print_pcie_catalog_section()
return remote_rc return remote_rc
@ -453,6 +745,14 @@ def _parse_args() -> argparse.Namespace:
metavar="PROFILE_OR_INI", metavar="PROFILE_OR_INI",
help="INI profile or absolute path (sets FIWI_CONFIG for this run)", help="INI profile or absolute path (sets FIWI_CONFIG for this run)",
) )
p.add_argument(
"--powercycle",
action="store_true",
help=(
"Self-test only: all downstream ports OFF then ON on local hubs and each "
"merged remote hub host; verifies via port metrics (disrupts USB power)."
),
)
return p.parse_args() return p.parse_args()
@ -473,7 +773,10 @@ def main() -> int:
remote_fail = 0 remote_fail = 0
try: try:
c = _instantiate_concentrator() c = _instantiate_concentrator()
remote_fail = _print_consolidated_report(c) if args.powercycle:
remote_fail = _run_powercycle_test(c)
else:
remote_fail = _print_consolidated_report(c)
except AssertionError as exc: except AssertionError as exc:
print(f"FAIL: {exc}", file=sys.stderr) print(f"FAIL: {exc}", file=sys.stderr)
return 1 return 1
@ -488,9 +791,13 @@ def main() -> int:
pass pass
print(_rule("="), flush=True) print(_rule("="), flush=True)
print(f"FiWiConcentrator() OK [config: {label}]", flush=True) if args.powercycle:
if remote_fail != 0: tag = "Power-cycle self-test OK" if remote_fail == 0 else "Power-cycle self-test FAILED"
print(" (one or more remote hub queries had errors — see above)", flush=True) print(f"{tag} [config: {label}]", flush=True)
else:
print(f"FiWiConcentrator() OK [config: {label}]", flush=True)
if remote_fail != 0:
print(" (one or more remote hub queries had errors — see above)", flush=True)
print(_rule("="), flush=True) print(_rule("="), flush=True)
return remote_fail return remote_fail