diff --git a/config/default.ini b/config/default.ini index 26d5ec6..3f83673 100644 --- a/config/default.ini +++ b/config/default.ini @@ -14,8 +14,9 @@ fiber_map = fiber_map.json default_ports = 24 [remote_ssh] -; Expanded on the *remote* hub host (e.g. rpi5-wif7). Match ``pwd`` there: ~/Code/FiWiManager -remote_python = ~/Code/FiWiManager/env/bin/python3 +; On the *remote* hub. Use ``python3`` unless BrainStem only exists in a venv; then e.g. +; ``~/Code/FiWiManager/env/bin/python3`` after: cd ~/Code/FiWiManager && python3 -m venv env && env/bin/pip install -r requirements.txt +remote_python = python3 remote_script = ~/Code/FiWiManager/fiwi.py ssh_bin = ssh ssh_opts = diff --git a/config/uax24.ini b/config/uax24.ini index bc49757..95b50a6 100644 --- a/config/uax24.ini +++ b/config/uax24.ini @@ -11,8 +11,8 @@ fiber_map = fiber_map.json default_ports = 24 [remote_ssh] -; rpi5-wif7: ~/Code/FiWiManager + venv in ``env/`` (see ``(env)`` prompt). Git pull for fiwi.py (replaces hub_manager.py era). -remote_python = ~/Code/FiWiManager/env/bin/python3 +; rpi5-wif7: ``remote_script`` under ~/Code/FiWiManager. Default ``python3``; set venv path if brainstem is only in ``env/``. +remote_python = python3 remote_script = ~/Code/FiWiManager/fiwi.py ssh_bin = ssh ssh_opts = diff --git a/config/uax4.ini b/config/uax4.ini index 0b411d2..3c8f63d 100644 --- a/config/uax4.ini +++ b/config/uax4.ini @@ -11,7 +11,7 @@ fiber_map = fiber_map.json default_ports = 4 [remote_ssh] -remote_python = ~/Code/FiWiManager/env/bin/python3 +remote_python = python3 remote_script = ~/Code/FiWiManager/fiwi.py ssh_bin = ssh ssh_opts = diff --git a/docs/fiwi-design.md b/docs/fiwi-design.md index a9fd0c4..114ac19 100644 --- a/docs/fiwi-design.md +++ b/docs/fiwi-design.md @@ -4,7 +4,7 @@ This document is for engineers changing or extending the Fi-Wi code. For command ## Purpose -The Fi-Wi stack ties together **Acroname / BrainStem USB hubs**, a **patch panel** model, and a **fiber map** (`fiber_map.json`) so operators can discover hubs, power downstream ports, walk calibration, and record per-fiber metadata (SSH routing, wireless, optional PCIe hints). Commands may run **locally** (Python loads BrainStem) or **on a remote host** over SSH when the map or CLI says the hardware lives elsewhere. +The Fi-Wi stack ties together **USB power-control hubs** (BrainStem API), a **patch panel** model, and a **fiber map** (`fiber_map.json`) so operators can discover hubs, power downstream ports, walk calibration, and record per-fiber metadata (SSH routing, wireless, optional PCIe hints). Commands may run **locally** (Python loads BrainStem) or **on a remote host** over SSH when the map or CLI says the hardware lives elsewhere. ## Repository layout diff --git a/fiwi/cli.py b/fiwi/cli.py index 4c2a19c..8569433 100644 --- a/fiwi/cli.py +++ b/fiwi/cli.py @@ -112,8 +112,8 @@ def _print_cli_help() -> None: " --async set FIWI_REMOTE_DEFER: deferred calls spawn ssh child processes immediately;\n" " panel calibrate overlaps them (join via handle.result(); no Python threads).\n" " Or set FIWI_REMOTE_DEFER=1 / remote_ssh.env / config/*.ini (FIWI_CONFIG=profile).\n" - " discover — list hubs (serial, port count); no port I/O\n" - " show_hostcards — same discovery path, labeled for concentrator USB hubs\n" + " discover — USB power-control hubs (serial, port count); no port I/O\n" + " show_hostcards — same as discover, concentrator 'hostcards' label\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" " fiber status — fiber_ports + power (local or per-entry ssh / host+user)\n" diff --git a/fiwi/concentrator.py b/fiwi/concentrator.py index 3edd71f..26c3a19 100644 --- a/fiwi/concentrator.py +++ b/fiwi/concentrator.py @@ -25,7 +25,7 @@ from fiwi.ieee80211_dev import discover_wireless_for_map, wlan_chip_and_interfac class FiWiConcentrator: """ - Main Fi-Wi object: Acroname/BrainStem hub plane, ``FiberRadioPort`` / + Main Fi-Wi object: BrainStem-driven USB power-control hub plane, ``FiberRadioPort`` / :class:`fiwi.ssh_node.SshNode` routing, and calibration. Local USB reboot staggering uses :mod:`asyncio`. Remote SSH work during ``panel calibrate`` @@ -124,7 +124,7 @@ class FiWiConcentrator: if specs: if quiet: print( - "fiwi: local USB shows Acroname module(s) but BrainStem connectFromSpec failed " + "fiwi: local USB shows hub module(s) but BrainStem connectFromSpec failed " "(udev 24ff / stem type / library); continuing without local hubs.", file=sys.stderr, flush=True, @@ -142,15 +142,21 @@ class FiWiConcentrator: else: if quiet: print( - "fiwi: no local Acroname hubs found; continuing (e.g. --ssh calibrate).", + "fiwi: no local USB power-control hubs found; continuing (e.g. --ssh calibrate).", file=sys.stderr, flush=True, ) else: - print("Error: No Acroname hubs found (findAllModules(USB) returned nothing).", flush=True) + print( + "Error: No USB power-control hubs found (findAllModules(USB) returned nothing).", + flush=True, + ) acr = usb.lsusb_acroname_lines() if acr: - print(" lsusb does see Acroname (kernel enumerates the device), e.g.:", flush=True) + print( + " lsusb lists matching USB devices (kernel enumerates them), e.g.:", + flush=True, + ) for ln in acr[:8]: print(f" {ln}", flush=True) print( @@ -198,6 +204,60 @@ class FiWiConcentrator: pass return 8 + def _inferred_downstream_ports_from_spec(self, spec) -> int | None: + """Downstream port count from stem *class* matching ``spec`` (no ``connectFromSpec``).""" + seen: set[type] = set() + for use_alt in (False, True): + for cls in self._hub_stem_classes_for_spec(spec, alternate=use_alt): + if cls in seen: + continue + seen.add(cls) + nd = getattr(cls, "NUMBER_OF_DOWNSTREAM_USB", None) + if isinstance(nd, int) and nd > 0: + return nd + nu = getattr(cls, "NUMBER_OF_USB_PORTS", None) + if isinstance(nu, int) and nu > 0: + return nu + return None + + def _serial_to_opened_port_count(self) -> dict[int, int]: + """Serial number → downstream port count for stems in ``self.hubs``.""" + out: dict[int, int] = {} + for stem in self.hubs: + sn_res = stem.system.getSerialNumber() + if sn_res.error != self.SUCCESS: + continue + out[sn_res.value] = self._port_count(stem) + return out + + def _print_usb_hub_summary_table(self, specs) -> None: + """Print ``Hub | Serial | Ports``; use live counts when connected, else model-inferred (*).""" + if not specs: + return + by_sn = self._serial_to_opened_port_count() + need_note = False + print(flush=True) + print(f"{'Hub':<6} | {'Serial':<12} | {'Ports'}", flush=True) + print("-" * 30, flush=True) + for idx, spec in enumerate(specs): + sn = spec.serial_number + sn_s = f"0x{sn:08X}" + if sn in by_sn: + n = by_sn[sn] + suffix = "" + else: + need_note = True + inf = self._inferred_downstream_ports_from_spec(spec) + n = inf if inf is not None else "?" + suffix = " *" + print(f"{idx + 1:<6} | {sn_s:<12} | {n}{suffix}", flush=True) + if need_note: + print(flush=True) + print( + "* Ports from hub model; device not opened (udev 24ff / stem type — power control unavailable).", + flush=True, + ) + def _ports_for_hub(self, stem, hub_display_num, p_target): """Resolve port indices for this stem; return None if an explicit port is out of range.""" n = self._port_count(stem) @@ -212,8 +272,8 @@ class FiWiConcentrator: return [p_target] def discover(self): - """USB module discovery only: serial and cmdPORT entity count per hub. No port power reads or changes.""" - print("Scanning USB for Acroname modules (BrainStem discover)...", flush=True) + """USB power-control hub discovery: serial and cmdPORT entity count per hub. No port power reads or changes.""" + print("Scanning USB for power-control hub modules (BrainStem discover)...", flush=True) specs = self._enumerate_usb_specs() if not specs: print( @@ -234,23 +294,16 @@ class FiWiConcentrator: f"module={getattr(spec, 'module', '?')}", flush=True, ) - if not self._connect_specs(specs): - return - print(f"{'Hub':<6} | {'Serial':<12} | {'Ports'}") - print("-" * 28) - for i, stem in enumerate(self.hubs): - sn_res = stem.system.getSerialNumber() - sn = f"0x{sn_res.value:08X}" if sn_res.error == self.SUCCESS else "?" - n = self._port_count(stem) - print(f"{i + 1:<6} | {sn:<12} | {n}") + self._connect_specs(specs) + self._print_usb_hub_summary_table(specs) def show_hostcards(self): """ - Local **hostcards**: Acroname USB hubs on this machine (BrainStem discovery + hub table). + Local **hostcards**: USB power-control hubs on this machine (BrainStem discovery + hub table). Same discovery path as ``discover``, framed for concentrator-side hardware. """ - print("Hostcards — local Acroname USB hubs (BrainStem)", flush=True) + print("Hostcards — local USB power-control hubs (BrainStem)", flush=True) specs = self._enumerate_usb_specs() if not specs: print( @@ -271,17 +324,9 @@ class FiWiConcentrator: f"module={getattr(spec, 'module', '?')}", flush=True, ) - if not self.hubs and not self._connect_specs(specs): - return if not self.hubs: - return - print(f"{'Hub':<6} | {'Serial':<12} | {'Ports'}", flush=True) - print("-" * 28, flush=True) - for i, stem in enumerate(self.hubs): - sn_res = stem.system.getSerialNumber() - sn = f"0x{sn_res.value:08X}" if sn_res.error == self.SUCCESS else "?" - n = self._port_count(stem) - print(f"{i + 1:<6} | {sn:<12} | {n}", flush=True) + self._connect_specs(specs) + self._print_usb_hub_summary_table(specs) def show_radioheads(self): """ @@ -376,7 +421,7 @@ class FiWiConcentrator: def power(self, mode, target_str): if not self.hubs and not self.connect(): print( - "Error: No Acroname hubs connected (cannot change port power).", + "Error: No USB power-control hubs connected (cannot change port power).", file=sys.stderr, flush=True, ) @@ -399,7 +444,9 @@ class FiWiConcentrator: def setup_udev(self): if not self.hubs and not self.connect(): return rule_path = "/etc/udev/rules.d/99-acroname.rules" - lines = ['# Acroname Hub Permissions\nSUBSYSTEM=="usb", ATTR{idVendor}=="24ff", MODE="0666", GROUP="plugdev"'] + lines = [ + '# USB power-control hub (vendor 24ff)\nSUBSYSTEM=="usb", ATTR{idVendor}=="24ff", MODE="0666", GROUP="plugdev"' + ] for i, stem in enumerate(self.hubs): res = stem.system.getSerialNumber() if res.error == self.SUCCESS: @@ -931,7 +978,7 @@ class FiWiConcentrator: saw_specs_connect_failed = True if not local_ok and cli_hosts: print( - "fiwi: Local USB shows Acroname module(s) but no hub opened — " + "fiwi: Local USB shows hub module(s) but no hub opened — " "this calibrate run will skip local ports and use only --ssh.\n" " Fix Fedora access, then re-run to include local + Pi in one pass:\n" " python3 fiwi.py setup && sudo install -m 0644 99-acroname.rules /etc/udev/rules.d/\n" @@ -983,7 +1030,7 @@ class FiWiConcentrator: if not steps: if saw_specs_connect_failed and not cli_hosts: print( - "fiwi: This PC sees Acroname USB module(s) in BrainStem discovery but connectFromSpec " + "fiwi: This PC sees USB hub module(s) in BrainStem discovery but connectFromSpec " "failed, and no remote host was given for calibrate.\n" " If your hubs are on a Raspberry Pi (or another machine), run from here:\n" " python3 fiwi.py panel calibrate merge --ssh pi@\n" @@ -996,7 +1043,7 @@ class FiWiConcentrator: flush=True, ) print( - "Nothing to calibrate: no local Acroname hubs and no remote ports " + "Nothing to calibrate: no local USB power-control hubs and no remote ports " "(use --ssh user@host or calibrate_remotes in fiber_map.json).", file=sys.stderr, flush=True, diff --git a/fiwi/fiber_radio_port.py b/fiwi/fiber_radio_port.py index fee9040..a2dd0ca 100644 --- a/fiwi/fiber_radio_port.py +++ b/fiwi/fiber_radio_port.py @@ -1,7 +1,7 @@ """ Fiber + radio port: central domain object for a row in fiber_map.json. -Power (Acroname hub downstream), SSH routing, PCIe / wlan / USB metadata all hang off this +Power (USB hub downstream ports), SSH routing, PCIe / wlan / USB metadata all hang off this aggregate. ``FiWiConcentrator`` supplies BrainStem power; not the conceptual center. """ diff --git a/fiwi/ssh_node.py b/fiwi/ssh_node.py index dac4a38..0ba2a7c 100644 --- a/fiwi/ssh_node.py +++ b/fiwi/ssh_node.py @@ -17,6 +17,7 @@ from __future__ import annotations import asyncio import json import os +import re import shlex import subprocess import sys @@ -522,13 +523,37 @@ class SshNode: return self._invoke_capture_blocking(remote_args, timeout=timeout) @staticmethod - def _fiwi_remote_shell_command(cfg: SshNodeConfig, remote_args: List[str]) -> str: + def _bash_escape_double(s: str) -> str: + """Escape for inclusion inside bash double quotes (``$ ` \\ "``).""" + return "".join("\\" + c if c in '\\$"`' else c for c in s) + + _REMOTE_HOME_TAIL_SAFE = re.compile(r"^[a-zA-Z0-9._+/@-]+$") + + @classmethod + def remote_path_bash_word(cls, path: str) -> str: """ - Single remote command string for ``bash -lc`` so ``~`` in ``FIWI_REMOTE_SCRIPT`` expands - on the *remote* host (paths often differ from the workstation). + One bash word for a filesystem path run on the *remote* host via ``bash -lc``. + + ``shlex.quote('~/foo')`` yields a single-quoted literal with a tilde, which bash does not + expand, so ``~/venv/bin/python3`` would not run. Use ``$HOME/…`` for ``~/`` prefixes. """ - parts = [cfg.python, cfg.script, *remote_args] - return " ".join(shlex.quote(p) for p in parts) + if path.startswith("~/"): + rest = path[2:] + if cls._REMOTE_HOME_TAIL_SAFE.fullmatch(rest): + return "$HOME/" + rest + return '"$HOME/' + cls._bash_escape_double(rest) + '"' + return shlex.quote(path) + + @classmethod + def _fiwi_remote_shell_command(cls, cfg: SshNodeConfig, remote_args: List[str]) -> str: + """ + Single remote command string for ``bash -lc`` so ``~/`` in ``FIWI_REMOTE_*`` paths expands + on the *remote* host (quoted tilde literals would not). + """ + py = cls.remote_path_bash_word(cfg.python) + sc = cls.remote_path_bash_word(cfg.script) + tail = " ".join(shlex.quote(p) for p in remote_args) + return f"{py} {sc}" + (f" {tail}" if tail else "") def _fiwi_cmd_argv(self, cfg: SshNodeConfig, remote_args: List[str]) -> List[str]: inner = self._fiwi_remote_shell_command(cfg, remote_args) diff --git a/fiwi/usb_probe.py b/fiwi/usb_probe.py index a47a18b..5138b70 100644 --- a/fiwi/usb_probe.py +++ b/fiwi/usb_probe.py @@ -47,7 +47,7 @@ async def alsusb_lines() -> list[str]: def lsusb_new_devices(before_lines, after_lines): - """Lines present in after but not before, excluding Acroname hub vendor lines.""" + """Lines present in after but not before, excluding known USB hub vendor (24ff) lines.""" before = set(before_lines) out = [] for ln in after_lines: diff --git a/remote_ssh.env.example b/remote_ssh.env.example index ba215cd..a48f928 100644 --- a/remote_ssh.env.example +++ b/remote_ssh.env.example @@ -8,8 +8,9 @@ # Example (Pi): FIWI_REMOTE_PYTHON=/home/pi/venv/bin/python3 # Example (same layout as this repo on the hub host): -# On Pi (rpi5-wif7): repo ~/Code/FiWiManager, venv often ./env — tilde expands on remote (bash -lc). -FIWI_REMOTE_PYTHON=~/Code/FiWiManager/env/bin/python3 +# Use remote ``python3`` unless brainstem is only in a venv, then e.g.: +# FIWI_REMOTE_PYTHON=~/Code/FiWiManager/env/bin/python3 +FIWI_REMOTE_PYTHON=python3 FIWI_REMOTE_SCRIPT=~/Code/FiWiManager/fiwi.py # Optional: comma-separated hosts for hybrid panel calibrate (with local hubs in one run): diff --git a/requirements.txt b/requirements.txt index b0ce8a6..5cf0640 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -# Acroname BrainStem Python API (install on the machine that has USB hubs attached). +# BrainStem Python API — USB power-control / monitoring hubs (install where USB hubs attach). # In a virtualenv: pip install -r requirements.txt (do not use --user) # System Python without a venv: pip install --user -r requirements.txt brainstem diff --git a/tests/test_concentrator_instantiate.py b/tests/test_concentrator_instantiate.py index edc01d1..b8069b3 100644 --- a/tests/test_concentrator_instantiate.py +++ b/tests/test_concentrator_instantiate.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 """ Smoke test: construct :class:`fiwi.concentrator.FiWiConcentrator`, then print Adnacom host-card -catalog, local Acroname USB hubs (``show_hostcards``), and **remote** hub hosts from +catalog, local USB power-control hubs (``show_hostcards``), and **remote** hub hosts from ``[remote_hubs]`` / ``FIWI_REMOTE_HUBS`` (each probed with ``ssh … show_hostcards`` when possible). Requires the BrainStem Python package (``brainstem`` in requirements.txt). USB discovery may print -“no link specs” if no Acroname hubs are attached — that is normal off the bench. Remote SSH must +“no link specs” if no compatible hubs are attached — that is normal off the bench. Remote SSH must reach the Pi (or host) where ``fiwi.py`` and BrainStem are installed. **Standalone** (any current working directory):: @@ -93,7 +93,7 @@ def _print_remote_hub_hostcards() -> None: if not host or host in seen: continue seen.append(host) - print(f"\nRemote Acroname USB hubs via SSH → {host}\n", flush=True) + print(f"\nRemote USB power-control hubs via SSH → {host}\n", flush=True) try: node = SshNode.parse(host) code, out, err = node.invoke_capture(["show_hostcards"], timeout=90, defer=False) @@ -109,13 +109,13 @@ def _print_remote_hub_hostcards() -> None: def _print_inventory(c: FiWiConcentrator) -> None: - """Adnacom PCIe catalog + local and remote Acroname USB hub views.""" + """Adnacom PCIe catalog + local and remote USB power-control hub views.""" from fiwi.adnacom_pcie_catalog import print_adnacom_host_card_table print() print_adnacom_host_card_table() print() - print("Acroname USB hubs (BrainStem — local hostcards)", flush=True) + print("USB power-control hubs — local (hostcards)", flush=True) c.show_hostcards() print() _print_remote_hub_hostcards() diff --git a/tests/test_read_remote.py b/tests/test_read_remote.py new file mode 100644 index 0000000..feec30e --- /dev/null +++ b/tests/test_read_remote.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +""" +Probe configured remote hub hosts and list **USB power-control hubs** (per-port switching and +monitoring) on each via remote ``fiwi.py discover`` — serial, model, hub index / port count. +Uses the generic ``discover`` view, not the ``show_hostcards`` concentrator label (same scan). + +Does **not** load BrainStem locally. Default: light SSH probe, then remote ``discover``. Use +``--probe-only`` to skip discovery; ``--invoke`` to run another Fi-Wi subcommand instead. + +**Standalone**:: + + python tests/test_read_remote.py -c uax24 + python tests/test_read_remote.py -c uax24 --probe-only + python tests/test_read_remote.py -c uax24 --invoke show_hostcards + +``--config`` matches ``FIWI_CONFIG`` (profile name or absolute ``*.ini`` path). +""" + +from __future__ import annotations + +import argparse +import os +import shlex +import subprocess +import sys + +_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +if _ROOT not in sys.path: + sys.path.insert(0, _ROOT) + + +def _configure_paths() -> None: + import fiwi.paths as paths_mod + + paths_mod.configure(_ROOT) + + +def _print_config_summary() -> None: + from fiwi.ssh_node import SshNodeConfig + + cfg = SshNodeConfig.load() + raw = (cfg.calibrate_remotes or "").strip() + print("Resolved remote SSH (after paths + INI)", flush=True) + print("-" * 60, flush=True) + print(f" FIWI_REMOTE_PYTHON → {cfg.python}", flush=True) + print(f" FIWI_REMOTE_SCRIPT → {cfg.script}", flush=True) + print(f" FIWI_SSH_BIN {cfg.ssh_bin}", flush=True) + if cfg.ssh_extra_argv: + print(f" FIWI_SSH_OPTS {' '.join(cfg.ssh_extra_argv)}", flush=True) + print(f" merged hub hosts {raw or '(none)'}", flush=True) + print("-" * 60, flush=True) + print(flush=True) + + +def _ssh_bash_lc(cfg, target: str, inner: str, *, timeout: float) -> tuple[int, str, str]: + cmd = [cfg.ssh_bin, *cfg.ssh_extra_argv, target, "bash", "-lc", inner] + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + stdin=subprocess.DEVNULL, + ) + except subprocess.TimeoutExpired: + return 124, "", "ssh timed out" + except OSError as exc: + return 1, "", str(exc) + return ( + proc.returncode if proc.returncode is not None else 1, + proc.stdout or "", + proc.stderr or "", + ) + + +def _light_probe_host(cfg, target: str, *, timeout: float) -> int: + """Hostname, script path present, remote ``python -c`` (no fiwi import).""" + from fiwi.ssh_node import SshNode + script = (cfg.script or "").strip() + # ``test -f`` path: keep unquoted so ``~`` expands on the remote (same as fiwi SSH transport). + file_check = f"if test -f {script}; then echo OK; else echo MISSING; fi" if script else "echo '(no FIWI_REMOTE_SCRIPT)'" + inner_parts = [ + "echo '== hostname =='", + "hostname || true", + "echo '== uname =='", + "uname -sm 2>/dev/null || true", + "echo '== fiwi script file =='", + file_check, + "echo '== remote python executable =='", + f"{SshNode.remote_path_bash_word(cfg.python)} -c {shlex.quote('import sys; print(sys.executable)')}", + ] + inner = "; ".join(inner_parts) + code, out, err = _ssh_bash_lc(cfg, target, inner, timeout=timeout) + if out.strip(): + print(out.rstrip(), flush=True) + if err.strip(): + print(err.rstrip(), file=sys.stderr, flush=True) + if code != 0: + print(f" (ssh probe exit {code})", flush=True) + if code == 127 and "No such file" in (err or ""): + print( + " Hint: FIWI_REMOTE_PYTHON path missing on the hub. Repo defaults use ``python3``; " + "if you need a venv: ssh … 'cd ~/Code/FiWiManager && python3 -m venv env && " + "env/bin/pip install -r requirements.txt' then set remote_python to that env/bin/python3.", + flush=True, + ) + return code + + +def _merged_hosts() -> list[str]: + from fiwi.ssh_node import SshNodeConfig + + raw = (SshNodeConfig.load().calibrate_remotes or "").strip() + seen: list[str] = [] + for part in raw.split(","): + h = part.strip() + if h and h not in seen: + seen.append(h) + return seen + + +def main() -> int: + p = argparse.ArgumentParser( + description="SSH probe for remote USB power-control hub hosts from config.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p.add_argument( + "-c", + "--config", + metavar="PROFILE_OR_INI", + help="INI profile or path (sets FIWI_CONFIG for this run)", + ) + p.add_argument( + "--timeout", + type=float, + default=45.0, + metavar="SEC", + help="SSH timeout for the light probe (default: 45)", + ) + p.add_argument( + "--discover-timeout", + "--hostcards-timeout", + type=float, + default=90.0, + dest="discover_timeout", + metavar="SEC", + help="Timeout for remote discover (default: 90; only after successful probe)", + ) + p.add_argument( + "--probe-only", + action="store_true", + help="Only run SSH/python probe; do not run remote discover", + ) + p.add_argument( + "--invoke", + nargs=argparse.REMAINDER, + metavar="ARGS", + help="Run only this Fi-Wi subcommand on each host (skips probe and default discover)", + ) + args = p.parse_args() + if args.config: + os.environ["FIWI_CONFIG"] = args.config.strip() + + try: + os.chdir(_ROOT) + except OSError as exc: + print(f"FAIL: cannot chdir to repo root {_ROOT!r}: {exc}", file=sys.stderr) + return 1 + + _configure_paths() + _print_config_summary() + + hosts = _merged_hosts() + if not hosts: + print( + "No remote hosts configured. Set [remote_hubs] hosts or FIWI_REMOTE_HUBS / " + "FIWI_CALIBRATE_REMOTES.", + flush=True, + ) + return 2 + + invoke_args = args.invoke + if invoke_args is not None and len(invoke_args) >= 1 and invoke_args[0] == "--": + invoke_args = invoke_args[1:] + + from fiwi.ssh_node import SshNode, SshNodeConfig + + cfg = SshNodeConfig.load() + rc = 0 + for host in hosts: + print(f"\n>>> {host}\n", flush=True) + if invoke_args: + try: + node = SshNode.parse(host) + code, out, err = node.invoke_capture(list(invoke_args), timeout=args.timeout, 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" (fiwi invoke exit {code})", flush=True) + rc = 1 + except Exception as exc: + print(f" invoke_capture failed: {exc}", flush=True) + rc = 1 + continue + + prc = _light_probe_host(cfg, host, timeout=args.timeout) + if prc != 0: + rc = 1 + continue + + if args.probe_only: + continue + + print("\n== remote USB power-control hubs (fiwi discover) ==\n", flush=True) + try: + node = SshNode.parse(host) + code, out, err = node.invoke_capture( + ["discover"], + timeout=args.discover_timeout, + 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" (discover exit {code})", flush=True) + rc = 1 + except Exception as exc: + print(f" discover failed: {exc}", flush=True) + rc = 1 + + label = os.environ.get("FIWI_CONFIG", "default") + print(f"\nDone [config: {label}]") + return rc + + +if __name__ == "__main__": + raise SystemExit(main())