Refactor hub manager into hubmgr package for maintainability.

Move AcronameManager, CLI, fiber map I/O, USB probe, remote SSH, and path
configuration into hubmgr/. hub_manager.py only configures map paths next to
the script and delegates to hubmgr.cli.main. Clarify remote_ssh.env.example
for Pi-side paths. Add fiber_map and panel_map example presets.

Made-with: Cursor
This commit is contained in:
Robert McMahon 2026-03-27 18:33:55 -07:00
parent 4ecf1c00b9
commit e7869dd9f2
15 changed files with 1879 additions and 1492 deletions

16
fiber_map.example.json Normal file
View File

@ -0,0 +1,16 @@
{
"calibrate_remotes": ["pi@192.168.1.39"],
"fiber_ports": {
"1": {
"hub": 1,
"port": 0,
"usb_id": "0bda:8812",
"chip_type": "Realtek RTL8812AU",
"chip_profiled_at": "2026-03-27T12:00:00Z"
},
"2": { "hub": 1, "port": 1 },
"5": { "hub": 1, "port": 4 },
"6": { "hub": 1, "port": 5, "ssh": "pi@192.168.1.39" },
"7": { "hub": 2, "port": 0, "host": "192.168.1.39", "user": "pi" }
}
}

24
fiber_map.rpi20.json Normal file
View File

@ -0,0 +1,24 @@
{
"fiber_ports": {
"1": { "hub": 1, "port": 0 },
"2": { "hub": 1, "port": 1 },
"3": { "hub": 1, "port": 2 },
"4": { "hub": 1, "port": 3 },
"5": { "hub": 1, "port": 4 },
"6": { "hub": 1, "port": 5 },
"7": { "hub": 1, "port": 6 },
"8": { "hub": 1, "port": 7 },
"9": { "hub": 2, "port": 0 },
"10": { "hub": 2, "port": 1 },
"11": { "hub": 2, "port": 2 },
"12": { "hub": 2, "port": 3 },
"13": { "hub": 2, "port": 4 },
"14": { "hub": 2, "port": 5 },
"15": { "hub": 2, "port": 6 },
"16": { "hub": 2, "port": 7 },
"17": { "hub": 3, "port": 0 },
"18": { "hub": 3, "port": 1 },
"19": { "hub": 3, "port": 2 },
"20": { "hub": 3, "port": 3 }
}
}

File diff suppressed because it is too large Load Diff

1
hubmgr/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Hub manager: Acroname USB hubs, fiber_map.json routing, optional SSH to remote hosts."""

904
hubmgr/acroname.py Normal file
View File

@ -0,0 +1,904 @@
"""Acroname BrainStem hub manager: connect, power, panel/fiber map, calibrate."""
import asyncio
import copy
import json
import os
import shutil
import sys
import time
import hubmgr.brainstem_loader as stemmod
from hubmgr.constants import PANEL_SLOTS
from hubmgr.paths import fiber_map_path
from hubmgr import fiber_map_io as fm
from hubmgr import remote_ssh as rs
from hubmgr import usb_probe as usb
class AcronameManager:
def __init__(self):
stemmod.load_brainstem()
self.hubs = []
self.SUCCESS = stemmod.brainstem.result.Result.NO_ERROR
def _enumerate_usb_specs(self):
"""Return sorted link specs from BrainStem USB discovery, or [] on failure."""
try:
specs = stemmod.brainstem.discover.findAllModules(stemmod.brainstem.link.Spec.USB)
except AttributeError:
from brainstem.discovery import Discovery
specs = Discovery.findAll(stemmod.brainstem.link.Spec.USB)
except Exception as exc:
print(f"BrainStem findAllModules(USB) raised: {exc}", file=sys.stderr, flush=True)
return []
if not specs:
return []
specs = list(specs)
specs.sort(key=lambda x: x.serial_number)
return specs
@staticmethod
def _hub_stem_classes_for_spec(spec, alternate=False):
"""
Pick stem class for hardware. USBHub2x4 vs USBHub3p must match the device or
connectFromSpec fails (HubTool handles this; raw USBHub3p() does not).
If alternate=True, try 3p 3c 2x4 (helps when model hints order wrong on some hosts).
"""
stemmod.load_brainstem()
if alternate:
return [
stemmod.brainstem.stem.USBHub3p,
stemmod.brainstem.stem.USBHub3c,
stemmod.brainstem.stem.USBHub2x4,
]
model = getattr(spec, "model", None)
defs = getattr(brainstem, "defs", None)
preferred = []
if defs is not None and model is not None:
for mid, cls in (
(getattr(defs, "MODEL_USBHUB_2X4", None), stemmod.brainstem.stem.USBHub2x4),
(getattr(defs, "MODEL_USBHUB_3P", None), stemmod.brainstem.stem.USBHub3p),
(getattr(defs, "MODEL_USBHUB_3C", None), stemmod.brainstem.stem.USBHub3c),
):
if mid is not None and model == mid:
preferred.append(cls)
for cls in (stemmod.brainstem.stem.USBHub2x4, stemmod.brainstem.stem.USBHub3p, stemmod.brainstem.stem.USBHub3c):
if cls not in preferred:
preferred.append(cls)
return preferred
def _connect_from_spec(self, spec, alternate=False):
for cls in self._hub_stem_classes_for_spec(spec, alternate=alternate):
stem = cls()
res = stem.connectFromSpec(spec)
if res == self.SUCCESS:
return stem
try:
stem.disconnect()
except Exception:
pass
return None
def _connect_specs(self, specs, quiet=False):
"""Append successfully opened stems to self.hubs; return False if none opened."""
first_pass_ok = False
for spec in specs:
stem = self._connect_from_spec(spec, alternate=False)
if stem is not None:
self.hubs.append(stem)
first_pass_ok = bool(self.hubs)
if not first_pass_ok and specs:
time.sleep(0.45)
for spec in specs:
stem = self._connect_from_spec(spec, alternate=True)
if stem is not None:
self.hubs.append(stem)
if self.hubs and not first_pass_ok and not quiet:
print(
"hub_manager: local hub(s) opened after retry (alternate USBHub3p → USBHub3c → USBHub2x4 order).",
flush=True,
)
if self.hubs:
return True
if specs:
if quiet:
print(
"hub_manager: local USB shows Acroname module(s) but BrainStem connectFromSpec failed "
"(udev 24ff / stem type / library); continuing without local hubs.",
file=sys.stderr,
flush=True,
)
else:
print(
"Error: findAllModules(USB) reported device(s) but connectFromSpec failed for all.",
flush=True,
)
print(
" Use the correct stem type (USBHub2x4 for 4-port, USBHub3p for USBHub3+), "
"udev permissions (vendor 24ff), and BrainStem library version.",
flush=True,
)
else:
if quiet:
print(
"hub_manager: no local Acroname hubs found; continuing (e.g. --ssh calibrate).",
file=sys.stderr,
flush=True,
)
else:
print("Error: No Acroname hubs found (findAllModules(USB) returned nothing).", flush=True)
acr = usb.lsusb_acroname_lines()
if acr:
print(" lsusb does see Acroname (kernel enumerates the device), e.g.:", flush=True)
for ln in acr[:8]:
print(f" {ln}", flush=True)
print(
" BrainStem still needs userland access: udev rules for vendor 24ff (MODE=0666 or "
"GROUP=plugdev + your user in that group), and the same libBrainStem version HubTool uses.",
flush=True,
)
return False
def connect(self, quiet=False):
"""Finds all hubs and sorts by serial number for consistent Hub 1/2 naming."""
return self._connect_specs(self._enumerate_usb_specs(), quiet=quiet)
def _parse_target(self, target_str):
if target_str.lower() == 'all':
return 'all', 'all'
try:
h_num, p_idx = target_str.split('.')
h_idx = 'all' if h_num.lower() == 'all' else int(h_num) - 1
p_idx = 'all' if p_idx.lower() == 'all' else int(p_idx)
return h_idx, p_idx
except ValueError:
print(f"Format Error: Use '1.4' for Hub 1 Port 4, or 'all'")
sys.exit(1)
def _port_count(self, stem):
"""
Count of downstream ports you power/cycle (Type-A sides on 2x4 / 3p).
Prefer stem-class NUMBER_OF_DOWNSTREAM_USB over cmdPORT quantity: the latter
matches NUMBER_OF_PORTS and includes extra PORT entities (e.g. 6 vs 4 on USBHub2x4).
"""
cls = type(stem)
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
if stemmod._BS_C is not None:
try:
res = stem.classQuantity(stemmod._BS_C.cmdPORT)
if res.error == self.SUCCESS and getattr(res, "value", 0) > 0:
return int(res.value)
except (AttributeError, TypeError, ValueError):
pass
return 8
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."""
n = self._port_count(stem)
if p_target == "all":
return list(range(n))
if p_target < 0 or p_target >= n:
print(
f"Error: Hub {hub_display_num} has {n} port(s) (indices 0{n - 1}); "
f"port {p_target} is invalid."
)
return None
return [p_target]
def discover(self):
"""USB module discovery only: serial and cmdPORT entity count per hub. No port power reads or changes."""
print("Scanning USB for Acroname modules (BrainStem discover)...", flush=True)
specs = self._enumerate_usb_specs()
if not specs:
print(
"findAllModules(USB): no link specs. If HubTool sees hubs, check udev (vendor 24ff), "
"group permissions, and that the BrainStem Python package matches HubTools library.",
flush=True,
)
acr = usb.lsusb_acroname_lines()
if acr:
print(" lsusb:", flush=True)
for ln in acr[:8]:
print(f" {ln}", flush=True)
return
print(f"findAllModules: {len(specs)} USB link spec(s)", flush=True)
for spec in specs:
print(
f" serial=0x{spec.serial_number:08X} model={getattr(spec, 'model', '?')} "
f"module={getattr(spec, 'module', '?')}",
flush=True,
)
if not self._connect_specs(specs):
return
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 _sample_inrush(self, stem, port, sample_duration=0.3):
"""Captures peak current and duration for the reboot report."""
samples = []
start_time = time.time()
while (time.time() - start_time) < sample_duration:
curr = stem.usb.getPortCurrent(port).value / 1000.0
samples.append((time.time() - start_time, curr))
peak_current = max(s[1] for s in samples) if samples else 0.0
duration = 0
if peak_current > 15.0:
for t, c in samples:
if c >= peak_current * 0.9:
duration = t
return {"peak": peak_current, "duration": duration * 1000}
def status(self, target_str="all"):
if not self.hubs and not self.connect(): return
h_target, p_target = self._parse_target(target_str)
print(f"{'Identity':<8} | {'Port':<5} | {'Power':<7} | {'Current (mA)':<12}")
print("-" * 55)
for i, stem in enumerate(self.hubs):
if h_target != 'all' and i != h_target: continue
ports = self._ports_for_hub(stem, i + 1, p_target)
if ports is None:
return
for port in ports:
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"Hub {i+1:<3} | {port:<5} | {pwr_str:<7} | {current:<12.2f}")
async def _async_reboot_port(self, h_idx, stem, port, delay, stagger, skip_empty):
"""Reboots a port and captures inrush vs steady state data."""
current_ma = stem.usb.getPortCurrent(port).value / 1000.0
if skip_empty and current_ma < 15.0:
return {"hub": h_idx+1, "port": port, "status": "Skipped", "peak": 0.0, "duration": 0.0, "steady": 0.0}
stem.usb.setPortDisable(port)
await asyncio.sleep(delay)
if stagger > 0:
await asyncio.sleep(stagger * port)
stem.usb.setPortEnable(port)
# Capture the immediate spike
inrush_stats = self._sample_inrush(stem, port)
# Increased to 2 seconds for radio stabilization
await asyncio.sleep(2.0)
steady_ma = stem.usb.getPortCurrent(port).value / 1000.0
return {
"hub": h_idx+1, "port": port, "status": "Rebooted",
"peak": inrush_stats['peak'], "duration": inrush_stats['duration'],
"steady": steady_ma if steady_ma > 15.0 else 0.0
}
def reboot(self, target_str, delay=2, stagger=0.2, skip_empty=True):
if not self.hubs and not self.connect(): return
h_target, p_target = self._parse_target(target_str)
tasks = []
for i, stem in enumerate(self.hubs):
if h_target != 'all' and i != h_target: continue
ports = self._ports_for_hub(stem, i + 1, p_target)
if ports is None:
return
for port in ports:
tasks.append(self._async_reboot_port(i, stem, port, delay, stagger, skip_empty))
if tasks:
print(f"{'Identity':<8} | {'Port':<5} | {'Action':<10} | {'Peak(mA)':<10} | {'Steady(mA)':<12} | {'Settle(ms)':<10}")
print("-" * 75)
results = asyncio.run(self._run_tasks(tasks))
for r in sorted(results, key=lambda x: (x['hub'], x['port'])):
print(f"Hub {r['hub']:<3} | {r['port']:<5} | {r['status']:<10} | {r['peak']:<10.2f} | {r['steady']:<12.2f} | {r['duration']:<10.2f}")
async def _run_tasks(self, tasks):
return await asyncio.gather(*tasks)
def power(self, mode, target_str):
if not self.hubs and not self.connect(): return
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
ports = self._ports_for_hub(stem, i + 1, p_target)
if ports is None:
return
for port in ports:
if mode.lower() == 'on': stem.usb.setPortEnable(port)
else: stem.usb.setPortDisable(port)
self.status(target_str)
def setup_udev(self):
if not self.hubs and not self.connect(): return
rule_path = "/etc/udev/rules.d/99-acroname.rules"
lines = ['# Acroname Hub Permissions\nSUBSYSTEM=="usb", ATTR{idVendor}=="24ff", MODE="0666", GROUP="plugdev"']
for i, stem in enumerate(self.hubs):
res = stem.system.getSerialNumber()
if res.error == self.SUCCESS:
sn = f"{res.value:08X}"
lines.append(f'SUBSYSTEM=="usb", ATTR{{idVendor}}=="24ff", ATTR{{serial}}=="{sn}", SYMLINK+="acroname_hub{i+1}"')
with open("99-acroname.rules", "w") as f: f.write("\n".join(lines))
print(f"udev rules generated. Install with: sudo mv 99-acroname.rules {rule_path}")
def verify(self):
for i in range(1, 3):
link = f"/dev/acroname_hub{i}"
if os.path.exists(link): print(f"[OK] Hub {i} -> {os.path.realpath(link)}")
else: print(f"[ERROR] {link} not found.")
def panel_status(self):
"""Rack positions 124: mapping and power via local hubs or per-entry ssh."""
doc = fm.load_fiber_map_or_exit()
ports = doc["fiber_ports"]
need_local = any(
fm.fiber_entry_hub_port(ports.get(str(n))) is not None
and fm.fiber_ssh_target(ports.get(str(n)) if isinstance(ports.get(str(n)), dict) else None)
is None
for n in range(1, PANEL_SLOTS + 1)
)
if need_local and not self.hubs and not self.connect():
return
print(
f"{'Panel':<7} | {'Hub.Port':<10} | {'Route':<18} | {'Pwr':<5} | {'mA':<8} | {'Chip (saved)':<28}",
flush=True,
)
print("-" * 95)
for idx in range(PANEL_SLOTS):
panel_n = idx + 1
key = str(panel_n)
entry = ports.get(key)
tup = fm.fiber_entry_hub_port(entry) if entry is not None else None
ssh = fm.fiber_ssh_target(entry) if isinstance(entry, dict) else None
chip_s = fm.stored_chip_preview(entry)
if tup is None:
print(f"{panel_n:<7} | {'':<10} | {'':<18} | {'':<5} | {'':<8} | {chip_s:<28}", flush=True)
continue
hub_1, port_0 = tup
route = (ssh if ssh else "local")[:18]
if ssh:
code, out, err = rs.ssh_forward_capture(ssh, ["status", f"{hub_1}.{port_0}"])
if code != 0:
pwr, cur = "?", "?"
if err.strip():
route = f"{ssh} (err)"[:18]
else:
pwr, cur = rs.parse_status_line_for_hub_port(out, hub_1, port_0)
print(
f"{panel_n:<7} | {hub_1}.{port_0:<9} | {route:<18} | {pwr:<5} | {cur:<8} | {chip_s:<28}",
flush=True,
)
continue
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} | {'local':<18} | {'?':<5} | {'no hub':<8} | {chip_s:<28}",
flush=True,
)
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} | {'local':<18} | {'?':<5} | {'bad map':<8} | {chip_s:<28}",
flush=True,
)
continue
pwr_val = stem.usb.getPortState(port_0).value
pwr_str = "ON" if (pwr_val & 1) else "OFF"
raw_curr = stem.usb.getPortCurrent(port_0).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} | {route:<18} | {pwr_str:<5} | {current:<8.2f} | {chip_s:<28}",
flush=True,
)
def panel_power(self, mode, panel_1based):
if panel_1based < 1 or panel_1based > PANEL_SLOTS:
print(f"Panel port must be 1{PANEL_SLOTS}, got {panel_1based}", file=sys.stderr, flush=True)
sys.exit(1)
doc = fm.load_fiber_map_or_exit()
key = str(panel_1based)
entry = doc["fiber_ports"].get(key)
tup = fm.fiber_entry_hub_port(entry) if entry is not None else None
if tup is None:
print(
f"Panel {panel_1based} is not mapped (no fiber_ports[{key!r}] in fiber_map.json).",
file=sys.stderr,
flush=True,
)
sys.exit(1)
ssh = fm.fiber_ssh_target(entry) if isinstance(entry, dict) else None
if ssh:
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)
def fiber_power(self, mode, fiber_port):
"""Power via fiber_map.json fiber_ports key (any positive integer id)."""
doc = fm.load_fiber_map_or_exit()
key = str(int(fiber_port))
entry = doc["fiber_ports"].get(key)
tup = fm.fiber_entry_hub_port(entry) if entry is not None else None
if tup is None:
print(
f"Fiber port {fiber_port} is not mapped (missing fiber_ports[{key!r}] in fiber_map.json).",
file=sys.stderr,
flush=True,
)
sys.exit(1)
ssh = fm.fiber_ssh_target(entry) if isinstance(entry, dict) else None
if ssh:
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)
def fiber_chip(self, fiber_port, save=False):
"""
Identify newly enumerated USB device(s) on this fibers hub port (lsusb diff).
If the port was off, turns it on briefly, snapshots, then restores previous power.
With save=True, merge usb_id / chip_type / usb_lsusb_lines into fiber_map.json when new lines appear.
"""
doc = fm.load_fiber_map_or_exit()
key = str(int(fiber_port))
entry = doc["fiber_ports"].get(key)
tup = fm.fiber_entry_hub_port(entry) if entry is not None else None
if tup is None:
print(
f"Fiber port {fiber_port} is not mapped (missing fiber_ports[{key!r}] in fiber_map.json).",
file=sys.stderr,
flush=True,
)
sys.exit(1)
ssh = fm.fiber_ssh_target(entry) if isinstance(entry, dict) else None
if ssh:
extra = ["save"] if save else []
sys.exit(rs.ssh_forward(ssh, ["fiber", "chip", key, *extra]))
if not shutil.which("lsusb"):
print(
"lsusb not found in PATH; install usbutils (e.g. usbutils package) on this host.",
file=sys.stderr,
flush=True,
)
sys.exit(1)
if not self.hubs and not self.connect():
return
hub_1, port_0 = tup
h_idx = hub_1 - 1
if h_idx < 0 or h_idx >= len(self.hubs):
print(f"No hub {hub_1} connected.", file=sys.stderr, flush=True)
sys.exit(1)
stem = self.hubs[h_idx]
nports = self._port_count(stem)
if port_0 < 0 or port_0 >= nports:
print(f"Hub {hub_1} has no USB port index {port_0}.", file=sys.stderr, flush=True)
sys.exit(1)
before = usb.lsusb_lines()
st = stem.usb.getPortState(port_0)
if st.error != self.SUCCESS:
print(f"getPortState error: {st.error}", file=sys.stderr, flush=True)
sys.exit(1)
was_on = (st.value & 1) != 0
if not was_on:
self._set_hub_port_power(hub_1, port_0, True)
time.sleep(2.0)
after = usb.lsusb_lines()
new_devs = usb.lsusb_new_devices(before, after)
print(
f"Fiber port {fiber_port} → hub {hub_1} USB port {port_0} — USB identity (lsusb, new vs baseline):",
flush=True,
)
if new_devs:
for ln in new_devs:
print(f" {ln}", flush=True)
else:
print(
" (No new lsusb lines vs snapshot before this step.) Device may already have been plugged in.",
flush=True,
)
if not was_on:
self._set_hub_port_power(hub_1, port_0, False)
print(" Restored hub port power: OFF.", flush=True)
if save:
if new_devs:
doc2 = fm.load_fiber_map_or_exit()
ent = doc2["fiber_ports"].get(key)
base = dict(ent) if isinstance(ent, dict) else {}
for k in (
"usb_lsusb_lines",
"usb_id",
"usb_ids",
"chip_type",
"chip_profiled_at",
):
base.pop(k, None)
base.update(fm.chip_fields_from_lsusb_lines(new_devs))
doc2["fiber_ports"][key] = base
self._write_fiber_map_document(doc2)
print(" Saved chip metadata to fiber_map.json (usb_id, chip_type, usb_lsusb_lines).", flush=True)
else:
print(
" save: no new lsusb lines — not updating fiber_map.json (avoids wiping a good profile).",
flush=True,
)
def fiber_map_status(self):
"""All fiber_ports entries with hub.port and live power (local BrainStem or ssh status)."""
doc = fm.load_fiber_map_or_exit()
ports = doc["fiber_ports"]
keys = sorted(ports.keys(), key=fm.fiber_sort_key)
print(
f"{'Fiber':<8} | {'Hub.Port':<10} | {'Route':<18} | {'Pwr':<5} | {'mA':<8} | {'Chip (saved)':<28}",
flush=True,
)
print("-" * 95)
need_local = any(
fm.fiber_entry_hub_port(ports[k]) is not None
and not fm.fiber_ssh_target(ports[k] if isinstance(ports[k], dict) else None)
for k in keys
)
if need_local and not self.hubs and not self.connect():
return
for key in keys:
entry = ports[key]
tup = fm.fiber_entry_hub_port(entry)
ssh = fm.fiber_ssh_target(entry) if isinstance(entry, dict) else None
chip_s = fm.stored_chip_preview(entry)
if tup is None:
print(f"{key!s:<8} | {'':<10} | {'':<18} | {'':<5} | {'':<8} | {chip_s:<28}", flush=True)
continue
hub_1, port_0 = tup
route = (ssh if ssh else "local")[:18]
if ssh:
code, out, err = rs.ssh_forward_capture(ssh, ["status", f"{hub_1}.{port_0}"])
if code != 0:
pwr, cur = "?", "?"
if err.strip():
route = f"{ssh} (err)"[:18]
else:
pwr, cur = rs.parse_status_line_for_hub_port(out, hub_1, port_0)
print(
f"{key!s:<8} | {hub_1}.{port_0:<9} | {route:<18} | {pwr:<5} | {cur:<8} | {chip_s:<28}",
flush=True,
)
continue
h_idx = hub_1 - 1
if h_idx < 0 or h_idx >= len(self.hubs):
print(
f"{key!s:<8} | {hub_1}.{port_0:<9} | {'local':<18} | {'?':<5} | {'no hub':<8} | {chip_s:<28}",
flush=True,
)
continue
stem = self.hubs[h_idx]
nports = self._port_count(stem)
if port_0 < 0 or port_0 >= nports:
print(
f"{key!s:<8} | {hub_1}.{port_0:<9} | {'local':<18} | {'?':<5} | {'bad map':<8} | {chip_s:<28}",
flush=True,
)
continue
pwr_val = stem.usb.getPortState(port_0).value
pwr_str = "ON" if (pwr_val & 1) else "OFF"
raw_curr = stem.usb.getPortCurrent(port_0).value / 1000.0
current = raw_curr if abs(raw_curr) > 15.0 else 0.0
print(
f"{key!s:<8} | {hub_1}.{port_0:<9} | {route:<18} | {pwr_str:<5} | {current:<8.2f} | {chip_s:<28}",
flush=True,
)
def panel_reboot(self, panel_1based, skip_empty=True):
if panel_1based < 1 or panel_1based > PANEL_SLOTS:
print(f"Panel port must be 1{PANEL_SLOTS}, got {panel_1based}", file=sys.stderr, flush=True)
sys.exit(1)
doc = fm.load_fiber_map_or_exit()
key = str(panel_1based)
entry = doc["fiber_ports"].get(key)
tup = fm.fiber_entry_hub_port(entry) if entry is not None else None
if tup is None:
print(
f"Panel {panel_1based} is not mapped (no fiber_ports[{key!r}] in fiber_map.json).",
file=sys.stderr,
flush=True,
)
sys.exit(1)
ssh = fm.fiber_ssh_target(entry) if isinstance(entry, dict) else None
sub = "reboot" if skip_empty else "reboot-force"
if ssh:
sys.exit(rs.ssh_forward(ssh, ["panel", sub, str(panel_1based)]))
tgt = f"{tup[0]}.{tup[1]}"
print(f"Panel {panel_1based} → hub target {tgt} ({sub})", flush=True)
self.reboot(tgt, skip_empty=skip_empty)
def _ordered_downstream_ports(self):
"""Hub 1 port 0, 1, … then hub 2 … (BrainStem hub order after connect)."""
out = []
for i, stem in enumerate(self.hubs):
for port in range(self._port_count(stem)):
out.append((i + 1, port))
return out
def _set_hub_port_power(self, hub_1, port_0, enable):
h_idx = hub_1 - 1
stem = self.hubs[h_idx]
if enable:
stem.usb.setPortEnable(port_0)
time.sleep(0.25)
else:
stem.usb.setPortDisable(port_0)
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
stem = self.hubs[h_idx]
st = stem.usb.getPortState(port_0)
if st.error != self.SUCCESS:
return f"getPortState error {st.error}"
on = (st.value & 1) != 0
cur = stem.usb.getPortCurrent(port_0)
ma = cur.value / 1000.0 if cur.error == self.SUCCESS else None
bits = f"hub reports {'ON' if on else 'OFF'}"
if ma is not None and abs(ma) > 15.0:
bits += f", ~{ma:.0f} mA"
return bits
def _write_fiber_map_document(self, doc):
path = fiber_map_path()
out = fm.ensure_fiber_map_document(doc)
with open(path, "w", encoding="utf-8") as f:
json.dump(out, f, indent=2)
f.write("\n")
print(f"Wrote {path}", flush=True)
def panel_calibrate(self, merge=False, limit=None, calibrate_ssh_hosts=None):
"""
Walk downstream USB ports in hub order: local hubs first, then each --ssh / calibrate_remotes host.
You type the fiber port id for each step; writes one fiber_map.json (adds ssh on remote steps).
"""
calibrate_ssh_hosts = list(calibrate_ssh_hosts or [])
try:
existing = fm.load_fiber_map_document()
except (OSError, json.JSONDecodeError, ValueError) as exc:
print(f"Could not load existing map: {exc}", file=sys.stderr, flush=True)
return
if merge and existing is not None:
doc = copy.deepcopy(existing)
elif existing is not None and isinstance(existing, dict):
doc = {k: copy.deepcopy(v) for k, v in existing.items() if k != "fiber_ports"}
doc["fiber_ports"] = {}
else:
doc = {"fiber_ports": {}}
doc = fm.ensure_fiber_map_document(doc)
seen_h = set()
cli_hosts = []
for h in calibrate_ssh_hosts:
s = str(h).strip()
if s and s not in seen_h:
seen_h.add(s)
cli_hosts.append(s)
cr = doc.get("calibrate_remotes")
if isinstance(cr, list):
for x in cr:
s = str(x).strip()
if s and s not in seen_h:
seen_h.add(s)
cli_hosts.append(s)
# Always try hard to open local hubs for calibrate: full errors + stem-class retry (see _connect_specs).
local_ok = bool(self.hubs)
if not local_ok:
specs_pre = self._enumerate_usb_specs()
if specs_pre:
local_ok = self._connect_specs(specs_pre, quiet=False)
if not local_ok and cli_hosts:
print(
"hub_manager: Local USB shows Acroname module(s) but no hub opened — "
"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"
" python3 hub_manager.py setup && sudo install -m 0644 99-acroname.rules /etc/udev/rules.d/\n"
" (setup needs a working connect; if it still fails, generic vendor rule:)\n"
' echo \'SUBSYSTEM=="usb", ATTR{idVendor}=="24ff", MODE="0666"\' | sudo tee /etc/udev/rules.d/99-acroname.rules\n'
" sudo udevadm control --reload-rules && sudo udevadm trigger\n"
" Unplug/replug the hub.\n",
file=sys.stderr,
flush=True,
)
else:
local_ok = self.connect(quiet=bool(cli_hosts))
local_ordered = self._ordered_downstream_ports() if local_ok else []
steps = []
for hub_1, port_0 in local_ordered:
steps.append((None, hub_1, port_0))
for host in cli_hosts:
remote_pairs = rs.fetch_calibrate_ports_json(host)
for hub_1, port_0 in remote_pairs:
steps.append((host, hub_1, port_0))
if limit is not None:
steps = steps[: max(0, limit)]
if not steps:
print(
"Nothing to calibrate: no local Acroname hubs and no remote ports "
"(use --ssh user@host or calibrate_remotes in fiber_map.json).",
file=sys.stderr,
flush=True,
)
return
n_loc = sum(1 for s in steps if s[0] is None)
n_rem = len(steps) - n_loc
# Baseline: only one downstream port should be powered per step.
if n_loc > 0 and self.hubs:
nlp = sum(self._port_count(s) for s in self.hubs)
print(f"Turning OFF every local downstream USB port (baseline, {nlp} port(s))…", flush=True)
for stem in self.hubs:
for p in range(self._port_count(stem)):
stem.usb.setPortDisable(p)
time.sleep(0.6)
print(" Local baseline done.", flush=True)
remote_hosts_ordered = []
for sh, _, _ in steps:
if sh is not None and sh not in remote_hosts_ordered:
remote_hosts_ordered.append(sh)
for host in remote_hosts_ordered:
pairs = sorted({(h, p) for s_h, h, p in steps if s_h == host})
n_off = len(pairs)
print(
f"Turning OFF every downstream USB port on {host} (baseline, {n_off} SSH round trip(s))…",
flush=True,
)
for i, (h, p) in enumerate(pairs, start=1):
rc, rerr = rs.remote_hub_port_power(host, h, p, False)
if rc != 0:
print(
f" [{i}/{n_off}] off {h}.{p} → exit {rc}: {(rerr or '').strip()[:120]}",
flush=True,
)
else:
print(f" [{i}/{n_off}] off {h}.{p} ok", flush=True)
time.sleep(0.6)
print(f" Remote baseline done for {host}.", flush=True)
print(
f"Fiber map calibrate: {len(steps)} step(s) — {n_loc} local, {n_rem} via ssh.\n"
"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"
"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.",
flush=True,
)
ports = doc["fiber_ports"]
for ssh_host, hub_1, port_0 in steps:
route = "local" if ssh_host is None else ssh_host
before_lsusb = (
usb.lsusb_lines()
if ssh_host is None
else rs.remote_lsusb_lines(ssh_host)
)
chip_hint_lines = []
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)
else:
rc, rerr = 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)
else:
time.sleep(0.25)
print(f" {rs.remote_port_power_feedback(ssh_host, hub_1, port_0)}", flush=True)
time.sleep(2.0)
after_lsusb = (
usb.lsusb_lines()
if ssh_host is None
else rs.remote_lsusb_lines(ssh_host)
)
chip_hint_lines = usb.lsusb_new_devices(before_lsusb, after_lsusb)
if chip_hint_lines:
print(" USB / chip (lsusb lines new vs port OFF):", flush=True)
for ln in chip_hint_lines:
print(f" {ln}", flush=True)
elif before_lsusb or after_lsusb:
print(
" (No new lsusb lines vs OFF snapshot — device may already be listed, or hub not downstream of host.)",
flush=True,
)
else:
print(
" (lsusb unavailable — install usbutils on this machine / Pi.)",
flush=True,
)
try:
line = input("Which fiber port id? [s=skip q=quit]: ").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,
)
low = line.lower()
if low == "q":
break
if low == "s" or not line:
continue
try:
pn = int(line)
except ValueError:
print(" Ignored (not an integer).", flush=True)
continue
if pn < 1:
print(" Ignored (fiber port id must be >= 1).", flush=True)
continue
key = str(pn)
prev = fm.fiber_entry_hub_port(ports.get(key))
prev_ssh = fm.fiber_ssh_target(ports.get(key)) if isinstance(ports.get(key), dict) else None
if prev is not None and (prev != (hub_1, port_0) or (ssh_host or None) != (prev_ssh or None)):
ps = f" ssh={prev_ssh!r}" if prev_ssh else ""
print(f" Note: fiber port {pn} was {prev[0]}.{prev[1]}{ps}; now {hub_1}.{port_0} @ {route}", flush=True)
base = ports.get(key)
if isinstance(base, dict):
entry = dict(base)
else:
entry = {}
entry["hub"] = hub_1
entry["port"] = port_0
if ssh_host:
entry["ssh"] = ssh_host
for k in ("remote", "host", "user"):
entry.pop(k, None)
else:
entry.pop("ssh", None)
entry.pop("remote", None)
entry.pop("host", None)
entry.pop("user", None)
if chip_hint_lines:
for k in (
"usb_lsusb_lines",
"usb_id",
"usb_ids",
"chip_type",
"chip_profiled_at",
):
entry.pop(k, None)
entry.update(fm.chip_fields_from_lsusb_lines(chip_hint_lines))
ports[key] = entry
self._write_fiber_map_document(doc)
def disconnect(self):
for stem in self.hubs: stem.disconnect()

View File

@ -0,0 +1,18 @@
# Loaded on first use so we can print diagnostics before touching native BrainStem code.
brainstem = None
_BS_C = None
def load_brainstem():
global brainstem, _BS_C
if brainstem is not None:
return brainstem
import brainstem as _bs
brainstem = _bs
try:
from brainstem import _BS_C as _c
_BS_C = _c
except ImportError:
_BS_C = None
return brainstem

204
hubmgr/cli.py Normal file
View File

@ -0,0 +1,204 @@
"""Command-line entry: argv dispatch, --ssh, fiber-map SSH forwarding."""
import json
import os
import sys
from hubmgr.acroname import AcronameManager
from hubmgr.brainstem_loader import load_brainstem
from hubmgr.ssh_dispatch import dispatch_fiber_mapped_ssh_if_needed
from hubmgr import remote_ssh as rs
from hubmgr import usb_probe as usb
def main() -> int:
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 <command> [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,
)
return 2
return rs.ssh_forward(remote_host, rest)
rc_ssh_map = dispatch_fiber_mapped_ssh_if_needed(argv)
if rc_ssh_map is not None:
return rc_ssh_map
os.write(2, b"hub_manager: start\n")
try:
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 venvs python and\n"
" HUB_MANAGER_REMOTE_SCRIPT to that hub_manager.py (absolute paths on the Pi).",
file=sys.stderr,
flush=True,
)
return 1
mgr = AcronameManager()
try:
cmd = sys.argv[1].lower() if len(sys.argv) > 1 else "status"
target = sys.argv[2] if len(sys.argv) > 2 else "all"
if cmd == "status":
mgr.status(target)
elif cmd == "calibrate-ports-json":
if not mgr.hubs and not mgr.connect():
print("[]", flush=True)
else:
pairs = mgr._ordered_downstream_ports()
print(json.dumps([[h, p] for h, p in pairs]), flush=True)
elif cmd == "lsusb-lines-json":
print(json.dumps(usb.lsusb_lines()), flush=True)
elif cmd == "discover":
mgr.discover()
elif cmd == "power":
if len(sys.argv) < 5 or sys.argv[2].lower() != "fiber-port":
print(
"Usage: hub_manager.py power fiber-port <fiber_port_id> on|off\n"
" Uses fiber_map.json; per-entry ssh / host+user forwards to that host (see help).",
file=sys.stderr,
flush=True,
)
return 2
try:
fp_n = int(sys.argv[3])
except ValueError:
print("fiber_port_id must be an integer.", file=sys.stderr, flush=True)
return 2
mode = sys.argv[4].lower()
if mode not in ("on", "off"):
print("Last argument must be on or off.", file=sys.stderr, flush=True)
return 2
mgr.fiber_power(mode, fp_n)
elif cmd == "fiber":
if len(sys.argv) < 3:
print(
"Usage: hub_manager.py fiber status\n"
" hub_manager.py fiber chip <fiber_port_id> [save]\n"
" status — hub.port, Route, power, and saved chip preview from fiber_map.json\n"
" chip — lsusb diff on that USB port; add save to store usb_id / chip_type in the map",
file=sys.stderr,
flush=True,
)
return 2
sub = sys.argv[2].lower()
if sub == "status":
mgr.fiber_map_status()
elif sub == "chip":
if len(sys.argv) < 4:
print(
"Usage: hub_manager.py fiber chip <fiber_port_id> [save]",
file=sys.stderr,
flush=True,
)
return 2
try:
chip_fp = int(sys.argv[3])
except ValueError:
print("fiber_port_id must be an integer.", file=sys.stderr, flush=True)
return 2
save_chip = len(sys.argv) >= 5 and sys.argv[4].lower() == "save"
mgr.fiber_chip(chip_fp, save=save_chip)
else:
print(f"Unknown fiber subcommand: {sub!r}", file=sys.stderr, flush=True)
return 2
elif cmd == "panel":
if len(sys.argv) < 3:
print(
"Usage: hub_manager.py panel status\n"
" hub_manager.py panel on|off <panel_port>\n"
" hub_manager.py panel reboot|reboot-force <panel_port>\n"
" hub_manager.py panel calibrate [merge] [<N>] [--ssh user@host] …\n"
" calibrate: local hub ports first, then each --ssh host (and calibrate_remotes in JSON).\n"
" merge / N as before; remote steps set \"ssh\" on new fiber_ports entries.\n"
" <panel_port> is 124; use power fiber-port for arbitrary ids.\n"
" Preset: fiber_map.rpi20.json → fiber_map.json for 8+8+4 → fiber ports 120.",
file=sys.stderr,
flush=True,
)
return 2
sub = sys.argv[2].lower()
if sub == "status":
mgr.panel_status()
elif sub == "calibrate":
args = sys.argv[3:]
merge, limit, cal_hosts = rs.parse_panel_calibrate_argv(args)
mgr.panel_calibrate(merge=merge, limit=limit, calibrate_ssh_hosts=cal_hosts)
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)
return 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)
return 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)
return 2
mgr.panel_reboot(int(sys.argv[3]), skip_empty=False)
else:
print(f"Unknown panel subcommand: {sub!r}", file=sys.stderr, flush=True)
return 2
elif cmd in ("on", "off"):
mgr.power(cmd, target)
elif cmd in ("reboot", "reboot-force"):
mgr.reboot(target, skip_empty=(cmd == "reboot"))
elif cmd == "setup":
mgr.setup_udev()
elif cmd == "verify":
mgr.verify()
elif cmd in ("help", "-h", "--help"):
print(
"Usage: hub_manager.py <command> [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"
" fiber status — fiber_ports + power (local or per-entry ssh / host+user)\n"
" fiber chip <id> [save] — lsusb probe; save stores usb_id / chip_type in fiber_map.json\n"
" power fiber-port <id> on|off — power by fiber key (ssh forward if map says so)\n"
" panel status — rack positions 124 (fiber ids 124 in fiber_map.json)\n"
" panel calibrate [merge] [N] [--ssh user@host]… — hybrid local + ssh hubs → one fiber_map.json\n"
" panel on|off|reboot|reboot-force <n>\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.\n"
"\n"
"fiber_map.json fiber_ports entries may set ssh routing (hubs on another machine):\n"
' "ssh": "user@host" or "remote": "" or "host": "ip", "user": "pi"\n'
" On the SSH destination, the same fiber id should be local (omit ssh) so commands are not re-forwarded.\n"
"\n"
"Hybrid calibrate: put {\"calibrate_remotes\": [\"pi@ip\"]} in fiber_map.json or pass --ssh per host;\n"
" order is all local downstream ports, then each remotes ports (see calibrate-ports-json on the Pi)."
)
else:
print(f"Unknown command: {cmd!r}", file=sys.stderr, flush=True)
print(
"Try: --ssh user@host … | discover | calibrate-ports-json | status | fiber | power | panel | … | help",
file=sys.stderr,
flush=True,
)
return 2
finally:
mgr.disconnect()
return 0

1
hubmgr/constants.py Normal file
View File

@ -0,0 +1 @@
PANEL_SLOTS = 24

163
hubmgr/fiber_map_io.py Normal file
View File

@ -0,0 +1,163 @@
"""Load/save fiber_map.json and legacy panel_map.json; parse entries and chip fields."""
import json
import os
import re
import sys
import time
from hubmgr.constants import PANEL_SLOTS
from hubmgr.paths import fiber_map_path, panel_map_path
def ensure_fiber_map_document(doc):
if not isinstance(doc, dict):
raise ValueError("map root must be a JSON object")
out = dict(doc)
fp = out.get("fiber_ports")
if fp is None:
out["fiber_ports"] = {}
elif not isinstance(fp, dict):
raise ValueError("fiber_ports must be a JSON object")
return out
def document_from_legacy_panel_array(slots):
fiber_ports = {}
for idx, slot in enumerate(slots):
if slot is not None:
fiber_ports[str(idx + 1)] = {"hub": slot[0], "port": slot[1]}
return {"fiber_ports": fiber_ports}
def load_fiber_map_document():
"""
Load routing map: prefer fiber_map.json (object keyed by fiber port).
If missing, migrate in-memory from legacy panel_map.json (24-slot array).
Returns None if neither file exists.
"""
fpath = fiber_map_path()
ppath = panel_map_path()
if os.path.isfile(fpath):
with open(fpath, encoding="utf-8") as f:
return ensure_fiber_map_document(json.load(f))
if os.path.isfile(ppath):
slots = read_panel_map_file(ppath)
return ensure_fiber_map_document(document_from_legacy_panel_array(slots))
return None
def load_fiber_map_or_exit():
doc = load_fiber_map_document()
if doc is None:
print(
f"Missing {fiber_map_path()} (or legacy {panel_map_path()}).\n"
" Copy fiber_map.example.json → fiber_map.json and set fiber_ports "
'(e.g. "5": {"hub": 1, "port": 2, "ssh": "pi@192.168.1.39"}). '
"Use ssh / remote / host+user when Acroname hubs are reached via SSH.",
file=sys.stderr,
flush=True,
)
sys.exit(1)
return doc
def fiber_sort_key(key):
s = str(key).strip()
if s.isdigit():
return (0, int(s))
return (1, s)
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 fiber_entry_hub_port(entry):
return parse_panel_map_entry(entry)
def fiber_ssh_target(entry):
"""
Optional SSH destination for a fiber_ports entry (hubs live on another host).
- "ssh": "user@host" or "user@ip"
- or "remote": same
- or "host": "ip" with optional "user" (default root)
"""
if not isinstance(entry, dict):
return None
s = entry.get("ssh") or entry.get("remote")
if isinstance(s, str) and s.strip():
return s.strip()
host = entry.get("host")
if isinstance(host, str) and host.strip():
user = entry.get("user")
u = user.strip() if isinstance(user, str) and user.strip() else "root"
return f"{u}@{host.strip()}"
return None
def chip_fields_from_lsusb_lines(lines):
"""Build fiber_map.json fields from full lsusb lines (see fiber chip … save)."""
if not lines:
return {}
ids = []
descs = []
for line in lines:
m = re.search(r"ID\s+([0-9a-f]{4}):([0-9a-f]{4})\s*(.*)", line, re.I)
if m:
ids.append(f"{m.group(1)}:{m.group(2)}".lower())
descs.append(m.group(3).strip())
out = {
"usb_lsusb_lines": list(lines),
"chip_profiled_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
if ids:
out["usb_id"] = ids[0]
out["chip_type"] = descs[0] if descs[0] else ids[0]
if len(ids) > 1:
out["usb_ids"] = ids
return out
def stored_chip_preview(entry, width=26):
"""Short label for status tables from saved probe metadata."""
if not isinstance(entry, dict):
return ""
for k in ("chip_type", "usb_description", "usb_id"):
v = entry.get(k)
if isinstance(v, str) and v.strip():
s = v.strip().replace("\n", " ")
if len(s) > width:
return s[: width - 2] + ".."
return s
return ""
def read_panel_map_file(path):
"""Load and normalize panel map JSON to a list of length PANEL_SLOTS."""
with open(path, encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, list):
raise ValueError("panel map must be a JSON array")
slots = [parse_panel_map_entry(x) for x in data]
while len(slots) < PANEL_SLOTS:
slots.append(None)
return slots[:PANEL_SLOTS]

26
hubmgr/paths.py Normal file
View File

@ -0,0 +1,26 @@
"""Runtime directory for JSON maps and remote_ssh.env (the hub_manager.py install location)."""
import os
from typing import Optional
_BASE: Optional[str] = None
def configure(app_dir: str) -> None:
"""Call once from hub_manager.py with dirname(abspath(__file__))."""
global _BASE
_BASE = os.path.abspath(app_dir)
def base_dir() -> str:
if _BASE is None:
raise RuntimeError("hubmgr.paths.configure() was not called (run via hub_manager.py)")
return _BASE
def panel_map_path() -> str:
return os.path.join(base_dir(), "panel_map.json")
def fiber_map_path() -> str:
return os.path.join(base_dir(), "fiber_map.json")

304
hubmgr/remote_ssh.py Normal file
View File

@ -0,0 +1,304 @@
"""SSH forwarding to remote hub_manager; remote lsusb/status helpers for calibrate."""
import json
import os
import shlex
import subprocess
import sys
from hubmgr.paths import base_dir
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 the hub_manager install directory.
Uses os.environ.setdefault so real environment variables still win.
"""
b = base_dir()
for fname in ("remote_ssh.env", ".hub_manager_remote"):
path = os.path.join(b, 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.
"""
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", ""))
if (
len(remote_args) >= 2
and remote_args[0].lower() == "panel"
and remote_args[1].lower() == "calibrate"
and not any(x in ("-t", "-tt") for x in extra)
):
extra = ["-t", *extra]
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, stdin=sys.stdin)
return proc.returncode if proc.returncode is not None else 1
def ssh_forward_capture(remote_host, remote_args, timeout=90):
"""Run hub_manager on remote; return (exit_code, stdout, stderr). No TTY."""
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]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
return 124, "", "ssh/hub_manager timed out"
return (
proc.returncode if proc.returncode is not None else 1,
proc.stdout or "",
proc.stderr or "",
)
def parse_panel_calibrate_argv(args):
"""
panel calibrate [merge] [N] [--ssh user@host] ...
Returns (merge, limit, calibrate_ssh_hosts).
"""
merge = False
limit = None
hosts = []
i = 0
while i < len(args):
a = args[i]
low = a.lower()
if low == "merge":
merge = True
i += 1
continue
if low == "--ssh":
if i + 1 >= len(args):
print("panel calibrate: --ssh requires user@host", file=sys.stderr, flush=True)
sys.exit(2)
hosts.append(args[i + 1].strip())
i += 2
continue
if a.isdigit():
limit = int(a)
i += 1
continue
print(f"panel calibrate: unknown argument {a!r}", file=sys.stderr, flush=True)
sys.exit(2)
return merge, limit, hosts
def parse_discover_stdout_for_calibrate_ports(stdout):
"""
Parse `discover` table lines: '1 | 0x........ | 8' (1,0)(1,7).
"""
pairs = []
for line in stdout.splitlines():
raw = line.rstrip()
if "|" not in raw:
continue
if set(raw.strip()) <= {"-", " "}:
continue
parts = [p.strip() for p in raw.split("|")]
if len(parts) < 3:
continue
hub_s, _, n_s = parts[0], parts[1], parts[2]
if not hub_s.isdigit():
continue
try:
hub = int(hub_s)
nports = int(n_s)
except ValueError:
continue
if hub < 1 or nports < 1:
continue
for p in range(nports):
pairs.append((hub, p))
return pairs
def maybe_hint_remote_ssh_python(exit_code, out, err):
"""Exit 126/127 usually means bad HUB_MANAGER_REMOTE_PYTHON path on the *remote* host."""
if exit_code not in (126, 127):
return
blob = f"{out or ''} {err or ''}".lower()
if "no such file" not in blob and "not found" not in blob:
return
py = os.environ.get("HUB_MANAGER_REMOTE_PYTHON", "python3")
script = os.environ.get("HUB_MANAGER_REMOTE_SCRIPT", "/usr/local/bin/hub_manager.py")
print(
" SSH ran this on the Pi (paths must exist *on the Pi*, not on Fedora):\n"
f" interpreter: {py}\n"
f" script: {script}\n"
" Fix: SSH to the Pi, activate the venv with brainstem, run:\n"
" which python3\n"
" realpath /path/to/hub_manager.py\n"
" Put those absolute paths in remote_ssh.env next to hub_manager.py on Fedora, or export:\n"
" HUB_MANAGER_REMOTE_PYTHON=… HUB_MANAGER_REMOTE_SCRIPT=…",
file=sys.stderr,
flush=True,
)
def fetch_calibrate_ports_json(ssh_host):
"""Ask remote hub_manager for [[hub,port], ...]; fall back to parsing `discover` if remote script is older."""
code, out, err = ssh_forward_capture(ssh_host, ["calibrate-ports-json"], timeout=90)
out = out or ""
err = err or ""
json_pairs = None
if code == 0 and out.strip():
try:
data = json.loads(out.strip())
except json.JSONDecodeError:
json_pairs = None
else:
if isinstance(data, list):
json_pairs = []
for item in data:
if isinstance(item, (list, tuple)) and len(item) == 2:
try:
json_pairs.append((int(item[0]), int(item[1])))
except (TypeError, ValueError):
continue
else:
json_pairs = None
if json_pairs is not None:
return json_pairs
if "Unknown command" in (out + err):
print(
f"Remote {ssh_host!r} has no calibrate-ports-json; using discover fallback "
"(scp this hub_manager.py to the Pi to skip the extra SSH round trip).",
file=sys.stderr,
flush=True,
)
code2, out2, err2 = ssh_forward_capture(ssh_host, ["discover"], timeout=120)
out2 = out2 or ""
if code2 != 0:
print(
f"discover on {ssh_host!r} failed (exit {code2}): {(err2 or out2).strip()[:400]}",
file=sys.stderr,
flush=True,
)
maybe_hint_remote_ssh_python(code2, out2, err2)
if code != 0 or (out + err).strip():
print(
f"calibrate-ports-json on {ssh_host!r} (exit {code}): {(err or out).strip()[:400]}",
file=sys.stderr,
flush=True,
)
maybe_hint_remote_ssh_python(code, out, err)
return []
pairs = parse_discover_stdout_for_calibrate_ports(out2)
if not pairs:
print(
f"Could not parse hub/port list from discover on {ssh_host!r}. "
"Ensure the Pi connects to its hubs (udev 24ff) and discover prints the Hub|Serial|Ports table.",
file=sys.stderr,
flush=True,
)
return []
print(
f"Using discover output for {ssh_host!r} ({len(pairs)} hub.port step(s)).",
file=sys.stderr,
flush=True,
)
return pairs
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
def remote_port_power_feedback(ssh_host, hub_1, port_0):
code, out, err = ssh_forward_capture(ssh_host, ["status", f"{hub_1}.{port_0}"])
if code != 0:
return f"remote status failed ({code}): {(err or out).strip()[:120]}"
pwr, cur = parse_status_line_for_hub_port(out, hub_1, port_0)
return f"remote hub reports {pwr}, {cur} mA"
def remote_lsusb_lines(ssh_host):
"""Full lsusb output lines on the SSH host (hub_manager lsusb-lines-json or plain `ssh … lsusb`)."""
code, out, err = ssh_forward_capture(ssh_host, ["lsusb-lines-json"], timeout=45)
if code == 0 and out.strip():
try:
data = json.loads(out.strip())
if isinstance(data, list) and all(isinstance(x, str) for x in data):
return data
except json.JSONDecodeError:
pass
apply_remote_ssh_env_file()
ssh_bin = os.environ.get("HUB_MANAGER_SSH_BIN", "ssh")
extra = shlex.split(os.environ.get("HUB_MANAGER_SSH_OPTS", ""))
try:
proc = subprocess.run(
[ssh_bin, *extra, ssh_host, "lsusb"],
capture_output=True,
text=True,
timeout=45,
)
except (OSError, subprocess.TimeoutExpired):
return []
if proc.returncode != 0 or not proc.stdout:
return []
return proc.stdout.splitlines()
def parse_status_line_for_hub_port(stdout, hub_1, port_0):
"""Parse `status hub.port` table output for one port; return (power, current_str) or (?, ?)."""
for line in stdout.splitlines():
ln = line.strip()
if not ln or ln.startswith("-") or "Identity" in ln or "Hub" not in ln:
continue
parts = [p.strip() for p in line.split("|")]
if len(parts) < 4:
continue
hub_part = parts[0].replace("Hub", "", 1).strip()
try:
h = int(hub_part)
p = int(parts[1])
except ValueError:
continue
if h == hub_1 and p == port_0:
return parts[2], parts[3]
return "?", "?"

64
hubmgr/ssh_dispatch.py Normal file
View File

@ -0,0 +1,64 @@
"""Forward CLI to SSH when fiber_map says the target lives on another host."""
import json
import sys
from hubmgr.constants import PANEL_SLOTS
from hubmgr import fiber_map_io as fm
from hubmgr import remote_ssh as rs
def dispatch_fiber_mapped_ssh_if_needed(argv):
"""
If the fiber map says this port is on another host (ssh / host+user), forward over SSH
without importing brainstem locally. Returns exit code, or None to continue normally.
"""
rs.apply_remote_ssh_env_file()
try:
doc = fm.load_fiber_map_document()
except (OSError, json.JSONDecodeError, ValueError):
return None
if doc is None:
return None
if (
len(argv) >= 5
and argv[0].lower() == "power"
and argv[2].lower() == "fiber-port"
and argv[4].lower() in ("on", "off")
):
try:
fid = int(argv[3])
except ValueError:
return None
entry = doc["fiber_ports"].get(str(fid))
ssh = fm.fiber_ssh_target(entry) if isinstance(entry, dict) else None
if ssh:
return rs.ssh_forward(ssh, ["power", "fiber-port", str(fid), argv[4].lower()])
if len(argv) >= 3 and argv[0].lower() == "fiber" and argv[1].lower() == "chip":
try:
fid = int(argv[2])
except ValueError:
return None
entry = doc["fiber_ports"].get(str(fid))
ssh = fm.fiber_ssh_target(entry) if isinstance(entry, dict) else None
if ssh:
extra = ["save"] if len(argv) >= 4 and argv[3].lower() == "save" else []
return rs.ssh_forward(ssh, ["fiber", "chip", str(fid), *extra])
if len(argv) >= 3 and argv[0].lower() == "panel":
sub = argv[1].lower()
if sub in ("on", "off", "reboot", "reboot-force"):
try:
pn = int(argv[2])
except ValueError:
return None
if pn < 1 or pn > PANEL_SLOTS:
return None
entry = doc["fiber_ports"].get(str(pn))
ssh = fm.fiber_ssh_target(entry) if isinstance(entry, dict) else None
if ssh:
return rs.ssh_forward(ssh, ["panel", sub, str(pn)])
return None

52
hubmgr/usb_probe.py Normal file
View File

@ -0,0 +1,52 @@
import shutil
import subprocess
def lsusb_lines():
lsusb_bin = shutil.which("lsusb")
if not lsusb_bin:
return []
try:
proc = subprocess.run(
[lsusb_bin], capture_output=True, text=True, timeout=15
)
except (OSError, subprocess.TimeoutExpired):
return []
if proc.returncode != 0 or not proc.stdout:
return []
return proc.stdout.splitlines()
def lsusb_new_devices(before_lines, after_lines):
"""Lines present in after but not before, excluding Acroname hub vendor lines."""
before = set(before_lines)
out = []
for ln in after_lines:
if ln in before:
continue
if "24ff:" in ln.lower():
continue
out.append(ln)
return out
def lsusb_acroname_lines():
try:
lsusb_bin = shutil.which("lsusb")
if not lsusb_bin:
return []
out = subprocess.run(
[lsusb_bin],
capture_output=True,
text=True,
timeout=5,
)
if out.returncode != 0 or not out.stdout:
return []
return [
ln
for ln in out.stdout.splitlines()
if "24ff:" in ln.lower() or " acroname" in ln.lower()
]
except (OSError, subprocess.TimeoutExpired):
return []

86
panel_map.rpi20.json Normal file
View File

@ -0,0 +1,86 @@
[
{
"hub": 1,
"port": 0
},
{
"hub": 1,
"port": 1
},
{
"hub": 1,
"port": 2
},
{
"hub": 1,
"port": 3
},
{
"hub": 1,
"port": 4
},
{
"hub": 1,
"port": 5
},
{
"hub": 1,
"port": 6
},
{
"hub": 1,
"port": 7
},
{
"hub": 2,
"port": 0
},
{
"hub": 2,
"port": 1
},
{
"hub": 2,
"port": 2
},
{
"hub": 2,
"port": 3
},
{
"hub": 2,
"port": 4
},
{
"hub": 2,
"port": 5
},
{
"hub": 2,
"port": 6
},
{
"hub": 2,
"port": 7
},
{
"hub": 3,
"port": 0
},
{
"hub": 3,
"port": 1
},
{
"hub": 3,
"port": 2
},
{
"hub": 3,
"port": 3
},
null,
null,
null,
null
]

View File

@ -1,5 +1,13 @@
# 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.
# Copy to remote_ssh.env (same folder as hub_manager.py on the machine where you RUN commands, e.g. Fedora).
#
# CRITICAL: HUB_MANAGER_REMOTE_PYTHON and HUB_MANAGER_REMOTE_SCRIPT must be real paths
# that exist ON THE RASPBERRY PI (after you ssh in), NOT on Fedora.
#
# On the Pi, in the venv where you `pip install brainstem`:
# which python3
# realpath /where/you/put/hub_manager.py
# Paste those outputs below. Do not use placeholder paths like "your-venv".
#
# Environment variables override these lines if both are set.
HUB_MANAGER_REMOTE_PYTHON=/home/rjmcmahon/Code/acroname/env/bin/python3