Calibrate: PCIe while port ON, Ctrl-C save; fix remote ON tracking.

- Keep hub port powered through fiber id + PCIe prompts; power off after.
- Ctrl-C saves fiber_map.json, turns off current port, exits 130; handle during PCIe.
- Remote calibrate: set step_powered only when SSH on succeeds; skip step if remote
  ON failed (was incorrectly treating failed ON as powered).
- power() returns False when hubs cannot connect; CLI on/off exits 1 so SSH sees failures.
- remote_hub_port_power: merge stdout+stderr for diagnostics; ssh_forward_capture uses stdin=DEVNULL.
- Warn when calibrate_remotes yields zero remote steps; clarify remote_ssh.env.example.

Made-with: Cursor
This commit is contained in:
Robert McMahon 2026-03-29 18:15:13 -07:00
parent 6d2fb4b957
commit 86e73bcff2
4 changed files with 112 additions and 26 deletions

View File

@ -310,17 +310,27 @@ class AcronameManager:
return await asyncio.gather(*tasks)
def power(self, mode, target_str):
if not self.hubs and not self.connect(): return
if not self.hubs and not self.connect():
print(
"Error: No Acroname hubs connected (cannot change port power).",
file=sys.stderr,
flush=True,
)
return False
h_target, p_target = self._parse_target(target_str)
for i, stem in enumerate(self.hubs):
if h_target != 'all' and i != h_target: continue
if h_target != 'all' and i != h_target:
continue
ports = self._ports_for_hub(stem, i + 1, p_target)
if ports is None:
return
return False
for port in ports:
if mode.lower() == 'on': stem.usb.setPortEnable(port)
else: stem.usb.setPortDisable(port)
if mode.lower() == "on":
stem.usb.setPortEnable(port)
else:
stem.usb.setPortDisable(port)
self.status(target_str)
return True
def setup_udev(self):
if not self.hubs and not self.connect(): return
@ -435,7 +445,8 @@ class AcronameManager:
sys.exit(rs.ssh_forward(ssh, ["panel", mode, str(panel_1based)]))
tgt = f"{tup[0]}.{tup[1]}"
print(f"Panel {panel_1based} → hub target {tgt} ({mode})", flush=True)
self.power(mode, tgt)
if not self.power(mode, tgt):
sys.exit(1)
def fiber_power(self, mode, fiber_port):
"""Power via fiber_map.json fiber_ports key (any positive integer id)."""
@ -455,7 +466,8 @@ class AcronameManager:
sys.exit(rs.ssh_forward(ssh, ["power", "fiber-port", key, mode.lower()]))
tgt = f"{tup[0]}.{tup[1]}"
print(f"Fiber port {fiber_port} → hub target {tgt} ({mode})", flush=True)
self.power(mode, tgt)
if not self.power(mode, tgt):
sys.exit(1)
def fiber_chip(self, fiber_port, save=False):
"""
@ -660,6 +672,21 @@ class AcronameManager:
else:
stem.usb.setPortDisable(port_0)
def _calibrate_step_power_off(self, ssh_host, hub_1, port_0):
"""Turn off downstream port at end of one calibrate step (after PCIe prompts if mapped)."""
if ssh_host is None:
self._set_hub_port_power(hub_1, port_0, False)
print(
f">>> OFF local hub {hub_1} USB port {port_0} ({self._port_power_feedback(hub_1, port_0)})",
flush=True,
)
else:
rs.remote_hub_port_power(ssh_host, hub_1, port_0, False)
print(
f">>> OFF {ssh_host} hub {hub_1} USB port {port_0}",
flush=True,
)
def _port_power_feedback(self, hub_1, port_0):
"""Return short status string after a change (hub power state + optional current)."""
h_idx = hub_1 - 1
@ -767,6 +794,15 @@ class AcronameManager:
steps.append((None, hub_1, port_0))
for host in cli_hosts:
remote_pairs = rs.fetch_calibrate_ports_json(host)
if not remote_pairs:
print(
f"hub_manager: WARNING: 0 remote calibrate steps from {host!r} — SSH hub_manager returned "
"no port list (often: Pi used system python3 without brainstem). On this PC set "
"HUB_MANAGER_REMOTE_PYTHON and HUB_MANAGER_REMOTE_SCRIPT in remote_ssh.env to paths "
"that exist on the Pi (venv python3 + hub_manager.py). See remote_ssh.env.example.",
file=sys.stderr,
flush=True,
)
for hub_1, port_0 in remote_pairs:
steps.append((host, hub_1, port_0))
@ -837,6 +873,7 @@ class AcronameManager:
"All downstream ports were turned OFF first so only one port is ON per step.\n"
"Order: all local hub ports (hub 1 port 0 first), then each --ssh hosts ports in order.\n"
"Each step: lsusb snapshot (port OFF) → ON (~2s) → new lsusb lines (chip hint) → fiber id, s=skip, q=quit.\n"
"Port stays ON through optional PCIe prompts, then powers OFF. Ctrl-C anytime saves fiber_map.json and exits.\n"
"When you map a fiber, usb_id / chip_type are saved if new lsusb lines appeared.\n"
"Remote steps store ssh in fiber_map.json automatically.\n"
"After each fiber id you can pick PCIe by number: 16 = known Adnacom H3 card, then SFP 14 "
@ -853,19 +890,35 @@ class AcronameManager:
else rs.remote_lsusb_lines(ssh_host)
)
chip_hint_lines = []
step_powered = False
line = ""
print(f"\n>>> ON {route} hub {hub_1} USB port {port_0}", flush=True)
try:
if ssh_host is None:
self._set_hub_port_power(hub_1, port_0, True)
print(f" {self._port_power_feedback(hub_1, port_0)}", flush=True)
step_powered = True
else:
rc, rerr = rs.remote_hub_port_power(ssh_host, hub_1, port_0, True)
rc, rmsg = rs.remote_hub_port_power(ssh_host, hub_1, port_0, True)
if rc != 0:
print(f" remote on failed ({rc}): {rerr.strip()[:200]}", flush=True)
snippet = (rmsg or "").strip()[:800]
print(
f" remote on failed (exit {rc})"
+ (f": {snippet}" if snippet else ""),
flush=True,
)
step_powered = False
else:
time.sleep(0.25)
print(f" {rs.remote_port_power_feedback(ssh_host, hub_1, port_0)}", flush=True)
step_powered = True
time.sleep(2.0)
if ssh_host is not None and not step_powered:
print(
" Skipping this calibrate step (remote ON failed; fix SSH / Pi hub_manager exit code).",
flush=True,
)
continue
after_lsusb = (
usb.lsusb_lines()
if ssh_host is None
@ -887,36 +940,45 @@ class AcronameManager:
flush=True,
)
try:
line = input("Which fiber port id? [s=skip q=quit]: ").strip()
line = input(
"Which fiber port id? [s=skip q=quit, Ctrl-C=save map & exit]: "
).strip()
except EOFError:
print(
"\n*** No input (stdin closed). Use a TTY, e.g. ssh -t … panel calibrate … ***\n",
flush=True,
)
line = "q"
finally:
if ssh_host is None:
self._set_hub_port_power(hub_1, port_0, False)
print(f">>> OFF local hub {hub_1} USB port {port_0} ({self._port_power_feedback(hub_1, port_0)})", flush=True)
else:
rs.remote_hub_port_power(ssh_host, hub_1, port_0, False)
print(
f">>> OFF {ssh_host} hub {hub_1} USB port {port_0}",
flush=True,
)
except KeyboardInterrupt:
print(
"\n*** Calibrate interrupted (Ctrl-C); writing fiber_map.json and powering off this port. ***\n",
flush=True,
)
if step_powered:
self._calibrate_step_power_off(ssh_host, hub_1, port_0)
self._write_fiber_map_document(doc)
raise SystemExit(130)
low = line.lower()
if low == "q":
if step_powered:
self._calibrate_step_power_off(ssh_host, hub_1, port_0)
break
if low == "s" or not line:
if step_powered:
self._calibrate_step_power_off(ssh_host, hub_1, port_0)
continue
try:
pn = int(line)
except ValueError:
print(" Ignored (not an integer).", flush=True)
if step_powered:
self._calibrate_step_power_off(ssh_host, hub_1, port_0)
continue
if pn < 1:
print(" Ignored (fiber port id must be >= 1).", flush=True)
if step_powered:
self._calibrate_step_power_off(ssh_host, hub_1, port_0)
continue
key = str(pn)
prev = fm.fiber_entry_hub_port(ports.get(key))
@ -950,7 +1012,18 @@ class AcronameManager:
):
entry.pop(k, None)
entry.update(fm.chip_fields_from_lsusb_lines(chip_hint_lines))
action, pdata = fm.prompt_pcie_metadata_for_calibrate(entry.get("pcie"))
try:
action, pdata = fm.prompt_pcie_metadata_for_calibrate(entry.get("pcie"))
except KeyboardInterrupt:
print(
"\n*** Interrupted during PCIe prompts; saving this ports map row and powering off. ***\n",
flush=True,
)
ports[key] = entry
if step_powered:
self._calibrate_step_power_off(ssh_host, hub_1, port_0)
self._write_fiber_map_document(doc)
raise SystemExit(130)
if action == "clear":
entry.pop("pcie", None)
elif action == "set" and pdata is not None:
@ -959,6 +1032,8 @@ class AcronameManager:
else:
entry.pop("pcie", None)
ports[key] = entry
if step_powered:
self._calibrate_step_power_off(ssh_host, hub_1, port_0)
self._write_fiber_map_document(doc)

View File

@ -157,7 +157,8 @@ def main() -> int:
print(f"Unknown panel subcommand: {sub!r}", file=sys.stderr, flush=True)
return 2
elif cmd in ("on", "off"):
mgr.power(cmd, target)
if not mgr.power(cmd, target):
return 1
elif cmd in ("reboot", "reboot-force"):
mgr.reboot(target, skip_empty=(cmd == "reboot"))
elif cmd == "setup":

View File

@ -81,7 +81,13 @@ def ssh_forward_capture(remote_host, remote_args, timeout=90):
extra = shlex.split(os.environ.get("HUB_MANAGER_SSH_OPTS", ""))
cmd = [ssh_bin, *extra, remote_host, py, script, *remote_args]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired:
return 124, "", "ssh/hub_manager timed out"
return (
@ -247,8 +253,9 @@ def fetch_calibrate_ports_json(ssh_host):
def remote_hub_port_power(ssh_host, hub_1, port_0, enable):
sub = "on" if enable else "off"
code, _, err = ssh_forward_capture(ssh_host, [sub, f"{hub_1}.{port_0}"])
return code, err
code, out, err = ssh_forward_capture(ssh_host, [sub, f"{hub_1}.{port_0}"])
blob = "\n".join(x.strip() for x in (out or "", err or "") if x and x.strip()).strip()
return code, blob
def remote_port_power_feedback(ssh_host, hub_1, port_0):

View File

@ -13,6 +13,9 @@
HUB_MANAGER_REMOTE_PYTHON=/home/rjmcmahon/Code/acroname/env/bin/python3
HUB_MANAGER_REMOTE_SCRIPT=/home/rjmcmahon/Code/acroname/hub_manager.py
# Optional: comma-separated Pi (or other) hosts. Then one command does local + remote calibrate:
# Optional: comma-separated Pi (or other) hosts for hybrid calibrate (local + remote in one run):
# python3 hub_manager.py panel calibrate
# HUB_MANAGER_CALIBRATE_REMOTES=pi@192.168.1.50,pi@192.168.1.51
#
# Remotes still need the two lines above: without them, SSH uses `python3` on the Pi and you get
# "No module named 'brainstem'" and 0 remote calibrate steps.