UmberHubManager/hubmgr/acroname.py

905 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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()