UmberHubManager/fiwi/harness.py

1286 lines
55 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.

"""Fi-Wi test harness: BrainStem USB power, fiber/radio map, patch panel, remote nodes."""
import asyncio
import copy
import json
import os
import shutil
import sys
import time
import fiwi.brainstem_loader as stemmod
from fiwi.constants import PANEL_SLOTS
from fiwi.patch_panel import PatchPanel, effective_panel_slots
from fiwi.paths import fiber_map_path
from fiwi.fiber_radio_port import FiberRadioPort
from fiwi import fiber_map_io as fm
from fiwi.ssh_node import (
SshNode,
SshNodeConfig,
parse_status_line_for_hub_port,
resolve_remote_defer,
)
from fiwi import usb_probe as usb
from fiwi.ieee80211_dev import discover_wireless_for_map, wlan_chip_and_interface
class FiWiHarness:
"""
Orchestrates Acroname/BrainStem power, ``FiberRadioPort`` / :class:`fiwi.ssh_node.SshNode` routing,
and calibration.
Local USB reboot staggering uses :mod:`asyncio`. Remote SSH work during ``panel calibrate``
(fetching hub/port lists and baseline power-off) runs concurrent ``SshNode`` coroutines via
:func:`asyncio.run` when multiple SSH hosts or ports are involved.
"""
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(stemmod.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(
"fiwi: local hub(s) opened after retry (alternate USBHub3p → USBHub3c → USBHub2x4 order).",
flush=True,
)
if self.hubs:
return True
if specs:
if quiet:
print(
"fiwi: 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(
"fiwi: 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():
print(
"Error: No Acroname hubs connected (cannot change port power).",
file=sys.stderr,
flush=True,
)
return False
h_target, p_target = self._parse_target(target_str)
for i, stem in enumerate(self.hubs):
if h_target != 'all' and i != h_target:
continue
ports = self._ports_for_hub(stem, i + 1, p_target)
if ports is None:
return False
for port in ports:
if mode.lower() == "on":
stem.usb.setPortEnable(port)
else:
stem.usb.setPortDisable(port)
self.status(target_str)
return True
def setup_udev(self):
if not self.hubs and not self.connect(): return
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 1…N (N from fiber_map patch_panel.slots or default): mapping and power."""
doc = fm.load_fiber_map_or_exit()
n_slots = effective_panel_slots(doc)
slot_frp = [FiberRadioPort.from_port_id(doc, n) for n in range(1, n_slots + 1)]
need_local = any(
x.hub_port() is not None and x.ssh_target() is None for x in slot_frp
)
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} | "
f"{'Chip (saved)':<28} | {'PCIe (saved)':<22}",
flush=True,
)
print("-" * 120)
for idx, frp in enumerate(slot_frp):
panel_n = idx + 1
tup = frp.hub_port()
ssh = frp.ssh_target()
chip_s = frp.chip_preview()
pcie_s = frp.pcie_preview()
if tup is None:
print(
f"{panel_n:<7} | {'':<10} | {'':<18} | {'':<5} | {'':<8} | {chip_s:<28} | {pcie_s:<22}",
flush=True,
)
continue
hub_1, port_0 = tup
route = (ssh if ssh else "local")[:18]
if ssh:
code, out, err = SshNode.parse(ssh).invoke_capture(
["status", f"{hub_1}.{port_0}"],
defer=False,
)
if code != 0:
pwr, cur = "?", "?"
if err.strip():
route = f"{ssh} (err)"[:18]
else:
pwr, cur = 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} | "
f"{chip_s:<28} | {pcie_s:<22}",
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} | "
f"{chip_s:<28} | {pcie_s:<22}",
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} | "
f"{chip_s:<28} | {pcie_s:<22}",
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} | "
f"{chip_s:<28} | {pcie_s:<22}",
flush=True,
)
def panel_power(self, mode, panel_1based):
doc = fm.load_fiber_map_or_exit()
n_slots = effective_panel_slots(doc)
if panel_1based < 1 or panel_1based > n_slots:
print(f"Panel port must be 1{n_slots}, got {panel_1based}", file=sys.stderr, flush=True)
sys.exit(1)
frp = FiberRadioPort.from_port_id(doc, panel_1based)
if not frp.is_mapped():
print(
f"Panel {panel_1based} is not mapped (no fiber_ports[{frp.map_key!r}] in fiber_map.json).",
file=sys.stderr,
flush=True,
)
sys.exit(1)
ssh = frp.ssh_target()
if ssh:
sys.exit(
SshNode.parse(ssh).invoke(
["panel", mode, str(panel_1based)],
defer=False,
)
)
hub_1, port_0 = frp.hub_port()
assert hub_1 is not None and port_0 is not None
tgt = f"{hub_1}.{port_0}"
print(f"Panel {panel_1based} → hub target {tgt} ({mode})", flush=True)
if not self.power(mode, tgt):
sys.exit(1)
def fiber_power(self, mode, fiber_port):
"""Power via fiber_map.json fiber_ports key (any positive integer id)."""
doc = fm.load_fiber_map_or_exit()
frp = FiberRadioPort.from_port_id(doc, int(fiber_port))
if not frp.is_mapped():
print(
f"Fiber port {fiber_port} is not mapped (missing fiber_ports[{frp.map_key!r}] in fiber_map.json).",
file=sys.stderr,
flush=True,
)
sys.exit(1)
ssh = frp.ssh_target()
if ssh:
sys.exit(
SshNode.parse(ssh).invoke(
["power", "fiber-port", frp.map_key, mode.lower()],
defer=False,
)
)
hub_1, port_0 = frp.hub_port()
assert hub_1 is not None and port_0 is not None
tgt = f"{hub_1}.{port_0}"
print(f"Fiber port {fiber_port} → hub target {tgt} ({mode})", flush=True)
if not self.power(mode, tgt):
sys.exit(1)
def fiber_chip(self, fiber_port, save=False):
"""
Local hubs only: lsusb diff on the mapped USB downstream port (usb_id / chip_type in map).
SSH-mapped fibers use PCIe metadata from calibrate / fiber_map.json — not forwarded.
"""
doc = fm.load_fiber_map_or_exit()
frp = FiberRadioPort.from_port_id(doc, int(fiber_port))
key = frp.map_key
if not frp.is_mapped():
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 = frp.ssh_target()
if ssh:
print(
"fiber chip: this fiber is SSH-mapped (PCIe/fiber path). Use panel calibrate PCIe prompts "
"or edit fiber_map.json; lsusb chip probe is not used for remote-mapped ports.",
file=sys.stderr,
flush=True,
)
sys.exit(2)
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 = frp.hub_port()
assert hub_1 is not None and port_0 is not None
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()
all_frp = list(FiberRadioPort.each_from_document(doc))
print(
f"{'Fiber':<8} | {'Hub.Port':<10} | {'Route':<18} | {'Pwr':<5} | {'mA':<8} | "
f"{'Chip (saved)':<28} | {'PCIe (saved)':<22}",
flush=True,
)
print("-" * 120)
need_local = any(
frp.hub_port() is not None and frp.ssh_target() is None for frp in all_frp
)
if need_local and not self.hubs and not self.connect():
return
for frp in all_frp:
key = frp.map_key
tup = frp.hub_port()
ssh = frp.ssh_target()
chip_s = frp.chip_preview()
pcie_s = frp.pcie_preview()
if tup is None:
print(
f"{key!s:<8} | {'':<10} | {'':<18} | {'':<5} | {'':<8} | {chip_s:<28} | {pcie_s:<22}",
flush=True,
)
continue
hub_1, port_0 = tup
route = (ssh if ssh else "local")[:18]
if ssh:
code, out, err = SshNode.parse(ssh).invoke_capture(
["status", f"{hub_1}.{port_0}"],
defer=False,
)
if code != 0:
pwr, cur = "?", "?"
if err.strip():
route = f"{ssh} (err)"[:18]
else:
pwr, cur = 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} | "
f"{chip_s:<28} | {pcie_s:<22}",
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} | "
f"{chip_s:<28} | {pcie_s:<22}",
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} | "
f"{chip_s:<28} | {pcie_s:<22}",
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} | "
f"{chip_s:<28} | {pcie_s:<22}",
flush=True,
)
def panel_reboot(self, panel_1based, skip_empty=True):
doc = fm.load_fiber_map_or_exit()
n_slots = effective_panel_slots(doc)
if panel_1based < 1 or panel_1based > n_slots:
print(f"Panel port must be 1{n_slots}, got {panel_1based}", file=sys.stderr, flush=True)
sys.exit(1)
frp = FiberRadioPort.from_port_id(doc, panel_1based)
if not frp.is_mapped():
print(
f"Panel {panel_1based} is not mapped (no fiber_ports[{frp.map_key!r}] in fiber_map.json).",
file=sys.stderr,
flush=True,
)
sys.exit(1)
ssh = frp.ssh_target()
sub = "reboot" if skip_empty else "reboot-force"
if ssh:
sys.exit(
SshNode.parse(ssh).invoke(
["panel", sub, str(panel_1based)],
defer=False,
)
)
hub_1, port_0 = frp.hub_port()
assert hub_1 is not None and port_0 is not None
tgt = f"{hub_1}.{port_0}"
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 _calibrate_step_power_off(self, ssh_host, hub_1, port_0):
"""Turn off downstream port at end of one calibrate step (after PCIe prompts if mapped)."""
if ssh_host is None:
self._set_hub_port_power(hub_1, port_0, False)
print(
f">>> OFF local hub {hub_1} USB port {port_0} ({self._port_power_feedback(hub_1, port_0)})",
flush=True,
)
else:
SshNode.parse(ssh_host).remote_hub_port_power(
hub_1, port_0, False, defer=False
)
print(
f">>> OFF {ssh_host} hub {hub_1} USB port {port_0}",
flush=True,
)
def _port_power_feedback(self, hub_1, port_0):
"""Return short status string after a change (hub power state + optional current)."""
h_idx = hub_1 - 1
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 _prompt_patch_panel(self, doc: dict) -> PatchPanel:
"""
Field workflow: define the physical patch panel before any USB hub walk.
Persists under doc['patch_panel']; map fiber ids 1…slots align with panel numbers.
"""
have = PatchPanel.from_map_blob(doc.get("patch_panel"))
print(
"\n--- Patch panel (front-panel positions) ---\n"
f"Fiber map keys 1…N refer to these panel positions (power/status: panel <N>).\n",
flush=True,
)
if have is not None:
print(f"Current map: {have.slots} position(s).", flush=True)
try:
line = input(" [Enter]=keep, or type new position count: ").strip()
except EOFError:
line = ""
if not line:
n = have.slots
else:
try:
n = int(line)
if n < 1 or n > 256:
print(f" Invalid; keeping {have.slots}.", flush=True)
n = have.slots
except ValueError:
print(f" Invalid; keeping {have.slots}.", flush=True)
n = have.slots
else:
print(f"No patch_panel in map yet; default is {PANEL_SLOTS}.", flush=True)
try:
line = input(
f" How many front-panel positions? [{PANEL_SLOTS}]: "
).strip()
except EOFError:
line = ""
if not line:
n = PANEL_SLOTS
else:
try:
n = int(line)
if n < 1 or n > 256:
print(f" Invalid; using {PANEL_SLOTS}.", flush=True)
n = PANEL_SLOTS
except ValueError:
print(f" Invalid; using {PANEL_SLOTS}.", flush=True)
n = PANEL_SLOTS
label = ""
if have and have.label:
label = have.label
try:
lab_in = input(
" Optional panel label [Enter to skip]: "
).strip()
except EOFError:
lab_in = ""
if lab_in:
label = lab_in
panel = PatchPanel(slots=n, label=label)
doc["patch_panel"] = panel.to_map_blob()
print(
f" Patch panel set: {panel.slots} position(s)"
+ (f" ({panel.label})" if panel.label else "")
+ ".\n---\n",
flush=True,
)
return panel
def panel_calibrate(self, merge=False, limit=None, calibrate_ssh_hosts=None):
"""
Prompts for patch panel size first (field workflow), writes fiber_map.json, then walks USB hub ports
(local then --ssh). You assign each step to a fiber id (panel position 1…N).
"""
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)
self._prompt_patch_panel(doc)
self._write_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)
env_rem = SshNodeConfig.load().calibrate_remotes
if env_rem:
added_from_env = []
for part in env_rem.split(","):
s = part.strip()
if s and s not in seen_h:
seen_h.add(s)
cli_hosts.append(s)
added_from_env.append(s)
if added_from_env:
print(
f"fiwi: calibrate remotes from FIWI_CALIBRATE_REMOTES: "
f"{', '.join(added_from_env)}",
file=sys.stderr,
flush=True,
)
# Always try hard to open local hubs for calibrate: full errors + stem-class retry (see _connect_specs).
saw_specs_connect_failed = False
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:
saw_specs_connect_failed = True
if not local_ok and cli_hosts:
print(
"fiwi: 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 fiwi.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))
if cli_hosts:
use_def = resolve_remote_defer(None)
if use_def:
handles = [
SshNode.parse(h).fetch_calibrate_ports_json(defer=True)
for h in cli_hosts
]
remote_results = [h.result() for h in handles]
else:
remote_results = [
SshNode.parse(h).fetch_calibrate_ports_json(defer=False)
for h in cli_hosts
]
else:
remote_results = []
for host, remote_pairs in zip(cli_hosts, remote_results):
if not remote_pairs:
print(
f"fiwi: WARNING: 0 remote calibrate steps from {host!r} — SSH fiwi returned "
"no port list (often: Pi used system python3 without brainstem). On this PC set "
"FIWI_REMOTE_PYTHON and FIWI_REMOTE_SCRIPT in remote_ssh.env to paths "
"that exist on the Pi (venv python3 + fiwi.py). See remote_ssh.env.example.",
file=sys.stderr,
flush=True,
)
for hub_1, port_0 in remote_pairs:
steps.append((host, hub_1, port_0))
if limit is not None:
steps = steps[: max(0, limit)]
if not steps:
if saw_specs_connect_failed and not cli_hosts:
print(
"fiwi: This PC sees Acroname USB module(s) in BrainStem discovery but connectFromSpec "
"failed, and no remote host was given for calibrate.\n"
" If your hubs are on a Raspberry Pi (or another machine), run from here:\n"
" python3 fiwi.py panel calibrate merge --ssh pi@<pi-address>\n"
" (repeat --ssh for each host). Or put in fiber_map.json next to fiwi.py:\n"
' "calibrate_remotes": ["pi@<pi-address>"]\n'
" Use remote_ssh.env on this PC if the Pi uses a venv path for python / script.\n"
" If the hubs are really plugged into *this* Fedora box, fix USB access (udev 24ff, plugdev, "
"unplug/replug) until `python3 fiwi.py discover` opens them.",
file=sys.stderr,
flush=True,
)
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)
if remote_hosts_ordered:
use_def = resolve_remote_defer(None)
by_host = {h: [] for h in remote_hosts_ordered}
if use_def:
meta_off: list[tuple[str, int, int]] = []
handles = []
for host in remote_hosts_ordered:
pairs = sorted({(h, p) for s_h, h, p in steps if s_h == host})
node = SshNode.parse(host)
for h, p in pairs:
meta_off.append((host, h, p))
handles.append(node.remote_hub_port_power(h, p, False, defer=True))
res_off = [h.result() for h in handles]
for (host, h, p), r in zip(meta_off, res_off):
by_host[host].append(((h, p), r))
else:
for host in remote_hosts_ordered:
pairs = sorted({(h, p) for s_h, h, p in steps if s_h == host})
for h, p in pairs:
r = SshNode.parse(host).remote_hub_port_power(
h, p, False, defer=False
)
by_host[host].append(((h, p), r))
for host in remote_hosts_ordered:
chunk = by_host.get(host, [])
n_off = len(chunk)
mode_s = "concurrent (deferred)" if use_def else "sequential"
print(
f"Turning OFF every downstream USB port on {host} "
f"(baseline, {n_off} SSH call(s), {mode_s})…",
flush=True,
)
for i, ((hp, rp), (rc, rerr)) in enumerate(chunk, start=1):
h, p = hp
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)
n_panel = effective_panel_slots(doc)
print(
f"Fiber map calibrate: patch panel {n_panel} position(s); "
f"{len(steps)} USB 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"
"Assign each powered device to a fiber id 1…"
f"{n_panel} (panel position); ids outside that range are allowed if you need extras.\n"
"After ON (~2s): fiwi snapshots wireless interfaces on that host (sysfs + lspci/iw) — "
"local and SSH — for chip/interface in the map (no external fiwi script).\n"
"Local steps: lsusb OFF→ON may also suggest a USB downstream device; USB lsusb is not used on SSH hosts.\n"
"Each step: ON → wlan snapshot → fiber id, s=skip, q=quit.\n"
"Port stays ON through optional PCIe prompts, then powers OFF. Ctrl-C anytime saves fiber_map.json and exits.\n"
"Remote rows: ssh + hub.port + wlan + pcie (usb_id/chip_type from lsusb are cleared; chip_type may come from wlan).\n"
"After each fiber id you can pick PCIe by number: 16 = known Adnacom H3 card, then SFP 14 "
"(no paste), or m=manual / c=clear / Enter=keep. Edit fiber_map.json anytime (see example).",
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
# Local: lsusb diff can hint USB downstream devices. Remote: PCIe/fiber — no lsusb (avoids misleading chip_type).
before_lsusb = usb.lsusb_lines() if ssh_host is None else []
chip_hint_lines = []
wlan_blob = None
step_powered = False
line = ""
print(f"\n>>> ON {route} hub {hub_1} USB port {port_0}", flush=True)
try:
if ssh_host is None:
self._set_hub_port_power(hub_1, port_0, True)
print(f" {self._port_power_feedback(hub_1, port_0)}", flush=True)
step_powered = True
else:
rc, rmsg = SshNode.parse(ssh_host).remote_hub_port_power(
hub_1, port_0, True, defer=False
)
if rc != 0:
snippet = (rmsg or "").strip()[:800]
print(
f" remote on failed (exit {rc})"
+ (f": {snippet}" if snippet else ""),
flush=True,
)
step_powered = False
else:
time.sleep(0.25)
print(
f" {SshNode.parse(ssh_host).remote_port_power_feedback(hub_1, port_0, defer=False)}",
flush=True,
)
step_powered = True
time.sleep(2.0)
if ssh_host is not None and not step_powered:
print(
" Skipping this calibrate step (remote ON failed; fix SSH / Pi fiwi exit code).",
flush=True,
)
continue
if step_powered:
if ssh_host is None:
wlan_blob = discover_wireless_for_map()
else:
wlan_blob = SshNode.parse(ssh_host).remote_wlan_info_json(
defer=False
)
if ssh_host is None:
after_lsusb = usb.lsusb_lines()
chip_hint_lines = usb.lsusb_new_devices(before_lsusb, after_lsusb)
if chip_hint_lines:
print(" USB (lsusb 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 host.)",
flush=True,
)
else:
print(
" Remote step: no USB lsusb; use PCIe prompts after fiber id. "
"Wlan snapshot runs on the SSH host (fiwi wlan-info-json).",
flush=True,
)
if step_powered:
if not wlan_blob and ssh_host:
print(
" (No wlan data from remote — deploy fiwi with wlan-info-json, "
"or SSH/json failed.)",
flush=True,
)
elif wlan_blob and isinstance(wlan_blob, dict):
prim = wlan_blob.get("primary")
ifaces = wlan_blob.get("interfaces") or {}
if isinstance(prim, dict) and prim.get("interface"):
bits = [
prim.get("iface_mode") or "",
prim.get("operstate") or "",
prim.get("connection_type") or "",
prim.get("pci_address") or "",
prim.get("driver") or "",
]
if prim.get("mac_address"):
bits.append(prim["mac_address"])
if prim.get("chanspec"):
bits.append(prim["chanspec"])
if prim.get("bands_ghz"):
bits.append(f"bands {prim.get('bands_ghz')}")
bl = (
prim.get("chip_label")
or prim.get("product")
or ""
)
extra = f" ({', '.join(x for x in bits if x)})" if any(bits) else ""
print(
f" Radio: {prim.get('interface')}{bl[:72]}{extra}",
flush=True,
)
if len(ifaces) > 1:
others = sorted(k for k in ifaces if k != prim.get("interface"))
if others:
print(f" Other wlan: {', '.join(others)}", flush=True)
elif ifaces:
print(
f" Radio: {len(ifaces)} wireless interface(s) on host (see wlan in map).",
flush=True,
)
elif ssh_host:
print(
" (Remote host: no wireless NIC seen in wlan snapshot.)",
flush=True,
)
else:
print(
" (No wireless interfaces found under /sys/class/net.)",
flush=True,
)
try:
line = input(
"Which fiber port id? [s=skip q=quit, Ctrl-C=save map & exit]: "
).strip()
except EOFError:
print(
"\n*** No input (stdin closed). Use a TTY, e.g. ssh -t … panel calibrate … ***\n",
flush=True,
)
line = "q"
except KeyboardInterrupt:
print(
"\n*** Calibrate interrupted (Ctrl-C); writing fiber_map.json and powering off this port. ***\n",
flush=True,
)
if step_powered:
self._calibrate_step_power_off(ssh_host, hub_1, port_0)
self._write_fiber_map_document(doc)
raise SystemExit(130)
low = line.lower()
if low == "q":
if step_powered:
self._calibrate_step_power_off(ssh_host, hub_1, port_0)
break
if low == "s" or not line:
if step_powered:
self._calibrate_step_power_off(ssh_host, hub_1, port_0)
continue
try:
pn = int(line)
except ValueError:
print(" Ignored (not an integer).", flush=True)
if step_powered:
self._calibrate_step_power_off(ssh_host, hub_1, port_0)
continue
if pn < 1:
print(" Ignored (fiber port id must be >= 1).", flush=True)
if step_powered:
self._calibrate_step_power_off(ssh_host, hub_1, port_0)
continue
key = str(pn)
prev = fm.fiber_entry_hub_port(ports.get(key))
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))
if ssh_host:
for k in (
"usb_lsusb_lines",
"usb_id",
"usb_ids",
"chip_type",
"chip_profiled_at",
):
entry.pop(k, None)
if (
step_powered
and wlan_blob
and isinstance(wlan_blob, dict)
and wlan_blob.get("interfaces")
):
entry["wlan"] = wlan_blob
chip_guess, if_guess = wlan_chip_and_interface(wlan_blob)
if if_guess:
entry["radio_interface"] = if_guess
elif "radio_interface" in entry:
entry.pop("radio_interface", None)
if chip_guess and (ssh_host or not chip_hint_lines):
entry["chip_type"] = chip_guess
try:
action, pdata = fm.prompt_pcie_metadata_for_calibrate(entry.get("pcie"))
except KeyboardInterrupt:
print(
"\n*** Interrupted during PCIe prompts; saving this ports map row and powering off. ***\n",
flush=True,
)
ports[key] = entry
if step_powered:
self._calibrate_step_power_off(ssh_host, hub_1, port_0)
self._write_fiber_map_document(doc)
raise SystemExit(130)
if action == "clear":
entry.pop("pcie", None)
elif action == "set" and pdata is not None:
if pdata:
entry["pcie"] = pdata
else:
entry.pop("pcie", None)
ports[key] = entry
if step_powered:
self._calibrate_step_power_off(ssh_host, hub_1, port_0)
self._write_fiber_map_document(doc)
def disconnect(self):
for stem in self.hubs: stem.disconnect()