From 0d90434ee4a63c629c9da091947eb2c872f3902a Mon Sep 17 00:00:00 2001 From: Robert McMahon Date: Wed, 25 Mar 2026 22:44:56 -0700 Subject: [PATCH] Add hub discovery, panel map, SSH remote helper, and Pi deps. Support USBHub2x4 and USBHub3p discovery with correct downstream port counts, patch-panel mapping via panel_map.json, lazy brainstem import and diagnostics, SSH forwarding with optional remote_ssh.env defaults, and requirements.txt for brainstem. Ignore local panel_map.json, remote_ssh.env, and __pycache__. Made-with: Cursor --- .gitignore | 4 + hub_manager.py | 247 ++++++++++++++++++++++++++++++++++++++++- panel_map.example.json | 26 +++++ remote_ssh.env.example | 6 + requirements.txt | 4 + 5 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 panel_map.example.json create mode 100644 remote_ssh.env.example create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore index 5bc44d2..390ea72 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ env/ +__pycache__/ hub_manager.py~ arc.txt +panel_map.json +remote_ssh.env +.hub_manager_remote diff --git a/hub_manager.py b/hub_manager.py index cf8fa97..03d1c56 100755 --- a/hub_manager.py +++ b/hub_manager.py @@ -3,9 +3,13 @@ import sys import time import asyncio import os +import json +import shlex import subprocess import shutil +PANEL_SLOTS = 24 + # Loaded on first use so we can print diagnostics before touching native BrainStem code. brainstem = None _BS_C = None @@ -26,6 +30,97 @@ def _load_brainstem(): return brainstem +def _panel_map_path(): + return os.path.join(os.path.dirname(os.path.abspath(__file__)), "panel_map.json") + + +_REMOTE_SSH_ENV_KEYS = frozenset( + { + "HUB_MANAGER_REMOTE_PYTHON", + "HUB_MANAGER_REMOTE_SCRIPT", + "HUB_MANAGER_SSH_BIN", + "HUB_MANAGER_SSH_OPTS", + } +) + + +def _apply_remote_ssh_env_file(): + """ + Load remote_ssh.env (or .hub_manager_remote) from this script’s directory. + Uses os.environ.setdefault so real environment variables still win. + Put this file on the machine where you run `hub_manager.py --ssh` (e.g. Fedora), not on the Pi. + Values are paths as seen on the SSH *destination* (the Pi). + """ + base = os.path.dirname(os.path.abspath(__file__)) + for fname in ("remote_ssh.env", ".hub_manager_remote"): + path = os.path.join(base, fname) + if not os.path.isfile(path): + continue + try: + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + key, _, val = line.partition("=") + key, val = key.strip(), val.strip() + if len(val) >= 2 and val[0] == val[-1] and val[0] in "'\"": + val = val[1:-1] + if key in _REMOTE_SSH_ENV_KEYS: + os.environ.setdefault(key, val) + except OSError: + continue + break + + +def _ssh_forward(remote_host, remote_args): + """ + Run hub_manager on a remote machine (e.g. Raspberry Pi with USB hubs attached). + Does not import brainstem locally — only needs OpenSSH client. + + Optional file next to this script (on the PC where you run --ssh): remote_ssh.env + See remote_ssh.env.example. Env vars still override the file. + + Env (paths are as seen *on the remote* after SSH login): + HUB_MANAGER_REMOTE_PYTHON default: python3 (use …/env/bin/python3 if brainstem is in a venv) + HUB_MANAGER_REMOTE_SCRIPT default: /usr/local/bin/hub_manager.py + HUB_MANAGER_SSH_BIN default: ssh + HUB_MANAGER_SSH_OPTS extra ssh args, shell-quoted string, e.g. -o BatchMode=yes + """ + _apply_remote_ssh_env_file() + py = os.environ.get("HUB_MANAGER_REMOTE_PYTHON", "python3") + script = os.environ.get("HUB_MANAGER_REMOTE_SCRIPT", "/usr/local/bin/hub_manager.py") + ssh_bin = os.environ.get("HUB_MANAGER_SSH_BIN", "ssh") + extra = shlex.split(os.environ.get("HUB_MANAGER_SSH_OPTS", "")) + cmd = [ssh_bin, *extra, remote_host, py, script, *remote_args] + print(f"hub_manager: ssh {remote_host} → {py} {script} {' '.join(remote_args)}", file=sys.stderr, flush=True) + proc = subprocess.run(cmd) + return proc.returncode if proc.returncode is not None else 1 + + +def _parse_panel_map_entry(entry): + """Return (hub_1based, port_0based) or None if unmapped / invalid.""" + if entry is None: + return None + if isinstance(entry, str): + entry = entry.strip() + if not entry or entry.lower() == "null": + return None + try: + h_s, p_s = entry.split(".", 1) + return int(h_s), int(p_s) + except ValueError: + return None + if isinstance(entry, dict): + try: + return int(entry["hub"]), int(entry["port"]) + except (KeyError, TypeError, ValueError): + return None + return None + + def _lsusb_acroname_lines(): try: lsusb_bin = shutil.which("lsusb") @@ -331,10 +426,108 @@ class AcronameManager: if os.path.exists(link): print(f"[OK] Hub {i} -> {os.path.realpath(link)}") else: print(f"[ERROR] {link} not found.") + def _load_panel_map(self): + """24 slots: index 0 = patch panel port 1. Each entry: (hub_1based, port_0based) or None.""" + path = _panel_map_path() + if not os.path.isfile(path): + print( + f"Missing {path}. Copy panel_map.example.json to panel_map.json and set each slot to " + f'{{"hub": 1, "port": 0}} or "1.0" (hub 1-based, port 0-based), or null if unused.', + file=sys.stderr, + flush=True, + ) + sys.exit(1) + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError) as exc: + print(f"Cannot read panel map {path}: {exc}", file=sys.stderr, flush=True) + sys.exit(1) + if not isinstance(data, list): + print(f"panel_map.json must be a JSON array of {PANEL_SLOTS} entries.", file=sys.stderr, flush=True) + sys.exit(1) + slots = [_parse_panel_map_entry(x) for x in data] + while len(slots) < PANEL_SLOTS: + slots.append(None) + if len(slots) > PANEL_SLOTS: + slots = slots[:PANEL_SLOTS] + return slots + + def _panel_target_for(self, panel_1based): + if panel_1based < 1 or panel_1based > PANEL_SLOTS: + print(f"Patch panel port must be 1–{PANEL_SLOTS}, got {panel_1based}", file=sys.stderr, flush=True) + sys.exit(1) + slots = self._load_panel_map() + mapped = slots[panel_1based - 1] + if mapped is None: + print(f"Patch panel port {panel_1based} is not mapped (null in panel_map.json).", file=sys.stderr, flush=True) + sys.exit(1) + hub_1, port_0 = mapped + return f"{hub_1}.{port_0}" + + def panel_status(self): + """Show all 24 patch panel positions, mapping, and power state when connected.""" + slots = self._load_panel_map() + if not self.hubs and not self.connect(): + return + print(f"{'Panel':<7} | {'Hub.Port':<10} | {'Power':<7} | {'Current (mA)':<12}") + print("-" * 50) + for idx in range(PANEL_SLOTS): + panel_n = idx + 1 + mapped = slots[idx] + if mapped is None: + print(f"{panel_n:<7} | {'—':<10} | {'—':<7} | {'—':<12}") + continue + hub_1, port_0 = mapped + h_idx = hub_1 - 1 + if h_idx < 0 or h_idx >= len(self.hubs): + print(f"{panel_n:<7} | {hub_1}.{port_0:<9} | {'?':<7} | {'no hub':<12}") + continue + stem = self.hubs[h_idx] + nports = self._port_count(stem) + if port_0 < 0 or port_0 >= nports: + print(f"{panel_n:<7} | {hub_1}.{port_0:<9} | {'?':<7} | {'bad map':<12}") + continue + port = port_0 + pwr_val = stem.usb.getPortState(port).value + pwr_str = "ON" if (pwr_val & 1) else "OFF" + raw_curr = stem.usb.getPortCurrent(port).value / 1000.0 + current = raw_curr if abs(raw_curr) > 15.0 else 0.0 + print(f"{panel_n:<7} | {hub_1}.{port_0:<9} | {pwr_str:<7} | {current:<12.2f}") + + def panel_power(self, mode, panel_1based): + tgt = self._panel_target_for(panel_1based) + print(f"Patch panel {panel_1based} → hub target {tgt} ({mode})", flush=True) + self.power(mode, tgt) + + def panel_reboot(self, panel_1based, skip_empty=True): + tgt = self._panel_target_for(panel_1based) + cmd = "reboot" if skip_empty else "reboot-force" + print(f"Patch panel {panel_1based} → hub target {tgt} ({cmd})", flush=True) + self.reboot(tgt, skip_empty=skip_empty) + def disconnect(self): for stem in self.hubs: stem.disconnect() if __name__ == "__main__": + argv = sys.argv[1:] + if len(argv) >= 2 and argv[0] in ("--ssh", "--remote"): + remote_host = argv[1] + rest = argv[2:] + if not rest: + print( + "Usage: hub_manager.py --ssh user@host [args...]\n" + " Example: hub_manager.py --ssh pi@192.168.1.39 discover\n" + " If brainstem is in a Pi venv: copy remote_ssh.env.example → remote_ssh.env next to\n" + " this script on the PC where you run --ssh (paths in the file are on the Pi).\n" + " Or export HUB_MANAGER_REMOTE_PYTHON / HUB_MANAGER_REMOTE_SCRIPT.\n" + " On the Pi: pip install -r requirements.txt in that venv; udev 24ff.", + file=sys.stderr, + flush=True, + ) + sys.exit(2) + sys.exit(_ssh_forward(remote_host, rest)) + # If this never appears but the prompt returns, you are not executing this file. # If stderr shows this but nothing else, `import brainstem` may be aborting the process. os.write(2, b"hub_manager: start\n") @@ -342,6 +535,14 @@ if __name__ == "__main__": _load_brainstem() except Exception as exc: print(f"hub_manager: failed to import brainstem: {exc}", file=sys.stderr, flush=True) + if isinstance(exc, ImportError): + print( + " If this text came from `hub_manager.py --ssh …`: the remote used system python3 by default.\n" + " On your PC export HUB_MANAGER_REMOTE_PYTHON to the Pi venv’s python and\n" + " HUB_MANAGER_REMOTE_SCRIPT to that hub_manager.py (absolute paths on the Pi).", + file=sys.stderr, + flush=True, + ) sys.exit(1) mgr = AcronameManager() try: @@ -349,6 +550,38 @@ if __name__ == "__main__": target = sys.argv[2] if len(sys.argv) > 2 else "all" if cmd == "status": mgr.status(target) elif cmd == "discover": mgr.discover() + elif cmd == "panel": + if len(sys.argv) < 3: + print( + "Usage: hub_manager.py panel status\n" + " hub_manager.py panel on|off \n" + " hub_manager.py panel reboot|reboot-force \n" + " is 1–24; map in panel_map.json (copy from panel_map.example.json).", + file=sys.stderr, + flush=True, + ) + sys.exit(2) + sub = sys.argv[2].lower() + if sub == "status": + mgr.panel_status() + elif sub in ("on", "off"): + if len(sys.argv) < 4: + print(f"Usage: hub_manager.py panel {sub} <1-24>", file=sys.stderr, flush=True) + sys.exit(2) + mgr.panel_power(sub, int(sys.argv[3])) + elif sub == "reboot": + if len(sys.argv) < 4: + print("Usage: hub_manager.py panel reboot <1-24>", file=sys.stderr, flush=True) + sys.exit(2) + mgr.panel_reboot(int(sys.argv[3]), skip_empty=True) + elif sub == "reboot-force": + if len(sys.argv) < 4: + print("Usage: hub_manager.py panel reboot-force <1-24>", file=sys.stderr, flush=True) + sys.exit(2) + mgr.panel_reboot(int(sys.argv[3]), skip_empty=False) + else: + print(f"Unknown panel subcommand: {sub!r}", file=sys.stderr, flush=True) + sys.exit(2) elif cmd in ["on", "off"]: mgr.power(cmd, target) elif cmd in ["reboot", "reboot-force"]: mgr.reboot(target, skip_empty=(cmd == "reboot")) @@ -359,12 +592,22 @@ if __name__ == "__main__": "Usage: hub_manager.py [target]\n" " discover — list hubs (serial, port count); no port I/O\n" " status [target] — default command; target like all, 1.3, all.2\n" - " on|off [target] reboot|reboot-force [target] setup verify" + " panel status — patch panel 1–24 (see panel_map.json)\n" + " panel on|off|reboot|reboot-force \n" + " on|off [target] reboot|reboot-force [target] setup verify\n" + "\n" + "Remote (hubs on another host — no local brainstem needed):\n" + " hub_manager.py --ssh user@host discover\n" + " remote_ssh.env next to hub_manager.py (see remote_ssh.env.example) or env vars:\n" + " HUB_MANAGER_REMOTE_PYTHON remote interpreter (default python3)\n" + " HUB_MANAGER_REMOTE_SCRIPT remote script path (default /usr/local/bin/hub_manager.py)\n" + " HUB_MANAGER_SSH_OPTS e.g. '-o BatchMode=yes'\n" + " Pi: pip install -r requirements.txt in the venv you point REMOTE_PYTHON at; udev 24ff." ) else: print(f"Unknown command: {cmd!r}", file=sys.stderr, flush=True) print( - "Try: discover | status | on | off | reboot | reboot-force | setup | verify | help", + "Try: --ssh user@host … | discover | status | panel | on | off | reboot | … | help", file=sys.stderr, flush=True, ) diff --git a/panel_map.example.json b/panel_map.example.json new file mode 100644 index 0000000..104c82d --- /dev/null +++ b/panel_map.example.json @@ -0,0 +1,26 @@ +[ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null +] diff --git a/remote_ssh.env.example b/remote_ssh.env.example new file mode 100644 index 0000000..36ed4aa --- /dev/null +++ b/remote_ssh.env.example @@ -0,0 +1,6 @@ +# Copy to remote_ssh.env (same folder as hub_manager.py on the machine where you run --ssh, e.g. Fedora). +# Paths below are on the SSH *destination* (Raspberry Pi), not your laptop. +# Environment variables override these lines if both are set. + +HUB_MANAGER_REMOTE_PYTHON=/home/rjmcmahon/Code/acroname/env/bin/python3 +HUB_MANAGER_REMOTE_SCRIPT=/home/rjmcmahon/Code/acroname/hub_manager.py diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b0ce8a6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +# Acroname BrainStem Python API (install on the machine that has USB hubs attached). +# In a virtualenv: pip install -r requirements.txt (do not use --user) +# System Python without a venv: pip install --user -r requirements.txt +brainstem