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:
parent
c361cbf17d
commit
179477702f
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
Loading…
Reference in New Issue