SSH remote: ~/ paths via $HOME; discover hub/port table; test_read_remote

- ssh_node: quote remote python/script so tilde expands (remote_path_bash_word)
- config: remote_python python3 by default; Pi ~/Code/FiWiManager script paths
- tests/test_read_remote.py: probe + discover; timeouts; vendor-neutral labels
- concentrator: always print Hub|Serial|Ports after discover/show_hostcards; infer
  port counts from stem class when connectFromSpec fails
- Docs/strings: USB power-control wording; requirements comment; usb_probe doc

Made-with: Cursor
This commit is contained in:
Robert McMahon 2026-04-03 12:05:34 -07:00
parent c361cbf17d
commit 179477702f
13 changed files with 371 additions and 56 deletions

View File

@ -14,8 +14,9 @@ fiber_map = fiber_map.json
default_ports = 24 default_ports = 24
[remote_ssh] [remote_ssh]
; Expanded on the *remote* hub host (e.g. rpi5-wif7). Match ``pwd`` there: ~/Code/FiWiManager ; On the *remote* hub. Use ``python3`` unless BrainStem only exists in a venv; then e.g.
remote_python = ~/Code/FiWiManager/env/bin/python3 ; ``~/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 remote_script = ~/Code/FiWiManager/fiwi.py
ssh_bin = ssh ssh_bin = ssh
ssh_opts = ssh_opts =

View File

@ -11,8 +11,8 @@ fiber_map = fiber_map.json
default_ports = 24 default_ports = 24
[remote_ssh] [remote_ssh]
; rpi5-wif7: ~/Code/FiWiManager + venv in ``env/`` (see ``(env)`` prompt). Git pull for fiwi.py (replaces hub_manager.py era). ; rpi5-wif7: ``remote_script`` under ~/Code/FiWiManager. Default ``python3``; set venv path if brainstem is only in ``env/``.
remote_python = ~/Code/FiWiManager/env/bin/python3 remote_python = python3
remote_script = ~/Code/FiWiManager/fiwi.py remote_script = ~/Code/FiWiManager/fiwi.py
ssh_bin = ssh ssh_bin = ssh
ssh_opts = ssh_opts =

View File

@ -11,7 +11,7 @@ fiber_map = fiber_map.json
default_ports = 4 default_ports = 4
[remote_ssh] [remote_ssh]
remote_python = ~/Code/FiWiManager/env/bin/python3 remote_python = python3
remote_script = ~/Code/FiWiManager/fiwi.py remote_script = ~/Code/FiWiManager/fiwi.py
ssh_bin = ssh ssh_bin = ssh
ssh_opts = ssh_opts =

View File

@ -4,7 +4,7 @@ This document is for engineers changing or extending the Fi-Wi code. For command
## Purpose ## 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 ## Repository layout

View File

@ -112,8 +112,8 @@ def _print_cli_help() -> None:
" --async set FIWI_REMOTE_DEFER: deferred calls spawn ssh child processes immediately;\n" " --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" " 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" " 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" " discover — USB power-control hubs (serial, port count); no port I/O\n"
" show_hostcards — same discovery path, labeled for concentrator USB hubs\n" " show_hostcards — same as discover, concentrator 'hostcards' label\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"

View File

@ -25,7 +25,7 @@ from fiwi.ieee80211_dev import discover_wireless_for_map, wlan_chip_and_interfac
class FiWiConcentrator: 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. :class:`fiwi.ssh_node.SshNode` routing, and calibration.
Local USB reboot staggering uses :mod:`asyncio`. Remote SSH work during ``panel calibrate`` Local USB reboot staggering uses :mod:`asyncio`. Remote SSH work during ``panel calibrate``
@ -124,7 +124,7 @@ class FiWiConcentrator:
if specs: if specs:
if quiet: if quiet:
print( 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.", "(udev 24ff / stem type / library); continuing without local hubs.",
file=sys.stderr, file=sys.stderr,
flush=True, flush=True,
@ -142,15 +142,21 @@ class FiWiConcentrator:
else: else:
if quiet: if quiet:
print( 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, file=sys.stderr,
flush=True, flush=True,
) )
else: 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() acr = usb.lsusb_acroname_lines()
if acr: 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]: for ln in acr[:8]:
print(f" {ln}", flush=True) print(f" {ln}", flush=True)
print( print(
@ -198,6 +204,60 @@ class FiWiConcentrator:
pass pass
return 8 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): 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.""" """Resolve port indices for this stem; return None if an explicit port is out of range."""
n = self._port_count(stem) n = self._port_count(stem)
@ -212,8 +272,8 @@ class FiWiConcentrator:
return [p_target] return [p_target]
def discover(self): def discover(self):
"""USB module discovery only: serial and cmdPORT entity count per hub. No port power reads or changes.""" """USB power-control hub discovery: serial and cmdPORT entity count per hub. No port power reads or changes."""
print("Scanning USB for Acroname modules (BrainStem discover)...", flush=True) print("Scanning USB for power-control hub modules (BrainStem discover)...", flush=True)
specs = self._enumerate_usb_specs() specs = self._enumerate_usb_specs()
if not specs: if not specs:
print( print(
@ -234,23 +294,16 @@ class FiWiConcentrator:
f"module={getattr(spec, 'module', '?')}", f"module={getattr(spec, 'module', '?')}",
flush=True, flush=True,
) )
if not self._connect_specs(specs): self._connect_specs(specs)
return self._print_usb_hub_summary_table(specs)
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}")
def show_hostcards(self): 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. 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() specs = self._enumerate_usb_specs()
if not specs: if not specs:
print( print(
@ -271,17 +324,9 @@ class FiWiConcentrator:
f"module={getattr(spec, 'module', '?')}", f"module={getattr(spec, 'module', '?')}",
flush=True, flush=True,
) )
if not self.hubs and not self._connect_specs(specs):
return
if not self.hubs: if not self.hubs:
return self._connect_specs(specs)
print(f"{'Hub':<6} | {'Serial':<12} | {'Ports'}", flush=True) self._print_usb_hub_summary_table(specs)
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)
def show_radioheads(self): def show_radioheads(self):
""" """
@ -376,7 +421,7 @@ class FiWiConcentrator:
def power(self, mode, target_str): def power(self, mode, target_str):
if not self.hubs and not self.connect(): if not self.hubs and not self.connect():
print( print(
"Error: No Acroname hubs connected (cannot change port power).", "Error: No USB power-control hubs connected (cannot change port power).",
file=sys.stderr, file=sys.stderr,
flush=True, flush=True,
) )
@ -399,7 +444,9 @@ class FiWiConcentrator:
def setup_udev(self): def setup_udev(self):
if not self.hubs and not self.connect(): return if not self.hubs and not self.connect(): return
rule_path = "/etc/udev/rules.d/99-acroname.rules" 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): for i, stem in enumerate(self.hubs):
res = stem.system.getSerialNumber() res = stem.system.getSerialNumber()
if res.error == self.SUCCESS: if res.error == self.SUCCESS:
@ -931,7 +978,7 @@ class FiWiConcentrator:
saw_specs_connect_failed = True saw_specs_connect_failed = True
if not local_ok and cli_hosts: if not local_ok and cli_hosts:
print( 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" "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" " 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" " 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 not steps:
if saw_specs_connect_failed and not cli_hosts: if saw_specs_connect_failed and not cli_hosts:
print( 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" "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" " If your hubs are on a Raspberry Pi (or another machine), run from here:\n"
" python3 fiwi.py panel calibrate merge --ssh pi@<pi-address>\n" " python3 fiwi.py panel calibrate merge --ssh pi@<pi-address>\n"
@ -996,7 +1043,7 @@ class FiWiConcentrator:
flush=True, flush=True,
) )
print( 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).", "(use --ssh user@host or calibrate_remotes in fiber_map.json).",
file=sys.stderr, file=sys.stderr,
flush=True, flush=True,

View File

@ -1,7 +1,7 @@
""" """
Fiber + radio port: central domain object for a row in fiber_map.json. 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. aggregate. ``FiWiConcentrator`` supplies BrainStem power; not the conceptual center.
""" """

View File

@ -17,6 +17,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import os import os
import re
import shlex import shlex
import subprocess import subprocess
import sys import sys
@ -522,13 +523,37 @@ class SshNode:
return self._invoke_capture_blocking(remote_args, timeout=timeout) return self._invoke_capture_blocking(remote_args, timeout=timeout)
@staticmethod @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 One bash word for a filesystem path run on the *remote* host via ``bash -lc``.
on the *remote* host (paths often differ from the workstation).
``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] if path.startswith("~/"):
return " ".join(shlex.quote(p) for p in parts) 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]: def _fiwi_cmd_argv(self, cfg: SshNodeConfig, remote_args: List[str]) -> List[str]:
inner = self._fiwi_remote_shell_command(cfg, remote_args) inner = self._fiwi_remote_shell_command(cfg, remote_args)

View File

@ -47,7 +47,7 @@ async def alsusb_lines() -> list[str]:
def lsusb_new_devices(before_lines, after_lines): 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) before = set(before_lines)
out = [] out = []
for ln in after_lines: for ln in after_lines:

View File

@ -8,8 +8,9 @@
# Example (Pi): FIWI_REMOTE_PYTHON=/home/pi/venv/bin/python3 # Example (Pi): FIWI_REMOTE_PYTHON=/home/pi/venv/bin/python3
# Example (same layout as this repo on the hub host): # 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). # Use remote ``python3`` unless brainstem is only in a venv, then e.g.:
FIWI_REMOTE_PYTHON=~/Code/FiWiManager/env/bin/python3 # FIWI_REMOTE_PYTHON=~/Code/FiWiManager/env/bin/python3
FIWI_REMOTE_PYTHON=python3
FIWI_REMOTE_SCRIPT=~/Code/FiWiManager/fiwi.py FIWI_REMOTE_SCRIPT=~/Code/FiWiManager/fiwi.py
# Optional: comma-separated hosts for hybrid panel calibrate (with local hubs in one run): # Optional: comma-separated hosts for hybrid panel calibrate (with local hubs in one run):

View File

@ -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) # In a virtualenv: pip install -r requirements.txt (do not use --user)
# System Python without a venv: pip install --user -r requirements.txt # System Python without a venv: pip install --user -r requirements.txt
brainstem brainstem

View File

@ -1,11 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Smoke test: construct :class:`fiwi.concentrator.FiWiConcentrator`, then print Adnacom host-card 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). ``[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 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. reach the Pi (or host) where ``fiwi.py`` and BrainStem are installed.
**Standalone** (any current working directory):: **Standalone** (any current working directory)::
@ -93,7 +93,7 @@ def _print_remote_hub_hostcards() -> None:
if not host or host in seen: if not host or host in seen:
continue continue
seen.append(host) 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: try:
node = SshNode.parse(host) node = SshNode.parse(host)
code, out, err = node.invoke_capture(["show_hostcards"], timeout=90, defer=False) 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: 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 from fiwi.adnacom_pcie_catalog import print_adnacom_host_card_table
print() print()
print_adnacom_host_card_table() print_adnacom_host_card_table()
print() print()
print("Acroname USB hubs (BrainStem — local hostcards)", flush=True) print("USB power-control hubs — local (hostcards)", flush=True)
c.show_hostcards() c.show_hostcards()
print() print()
_print_remote_hub_hostcards() _print_remote_hub_hostcards()

241
tests/test_read_remote.py Normal file
View File

@ -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())