From 86e73bcff2ff5f6f61c047f43eeda777914e5334 Mon Sep 17 00:00:00 2001 From: Robert McMahon Date: Sun, 29 Mar 2026 18:15:13 -0700 Subject: [PATCH] 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 --- hubmgr/acroname.py | 117 +++++++++++++++++++++++++++++++++-------- hubmgr/cli.py | 3 +- hubmgr/remote_ssh.py | 13 +++-- remote_ssh.env.example | 5 +- 4 files changed, 112 insertions(+), 26 deletions(-) diff --git a/hubmgr/acroname.py b/hubmgr/acroname.py index e5465a5..5b680b8 100644 --- a/hubmgr/acroname.py +++ b/hubmgr/acroname.py @@ -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 host’s 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: 1–6 = known Adnacom H3 card, then SFP 1–4 " @@ -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 port’s 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) diff --git a/hubmgr/cli.py b/hubmgr/cli.py index 901243e..9924f40 100644 --- a/hubmgr/cli.py +++ b/hubmgr/cli.py @@ -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": diff --git a/hubmgr/remote_ssh.py b/hubmgr/remote_ssh.py index 6885ea0..7ec04a5 100644 --- a/hubmgr/remote_ssh.py +++ b/hubmgr/remote_ssh.py @@ -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): diff --git a/remote_ssh.env.example b/remote_ssh.env.example index 3081f3a..7feac97 100644 --- a/remote_ssh.env.example +++ b/remote_ssh.env.example @@ -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.