1381 lines
59 KiB
Python
1381 lines
59 KiB
Python
"""Fi-Wi concentrator: 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.patch_panel import PatchPanel, default_panel_ports, 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 FiWiConcentrator:
|
||
"""
|
||
Main Fi-Wi object: BrainStem-driven USB power-control hub plane, ``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 hub 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 USB power-control hubs found; continuing (e.g. --ssh calibrate).",
|
||
file=sys.stderr,
|
||
flush=True,
|
||
)
|
||
else:
|
||
print(
|
||
"Error: No USB power-control hubs found (findAllModules(USB) returned nothing).",
|
||
flush=True,
|
||
)
|
||
acr = usb.lsusb_acroname_lines()
|
||
if acr:
|
||
print(
|
||
" lsusb lists matching USB devices (kernel enumerates them), 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 _inferred_downstream_ports_from_spec(self, spec) -> int | None:
|
||
"""Downstream port count from stem *class* matching ``spec`` (no ``connectFromSpec``)."""
|
||
seen: set[type] = set()
|
||
for use_alt in (False, True):
|
||
for cls in self._hub_stem_classes_for_spec(spec, alternate=use_alt):
|
||
if cls in seen:
|
||
continue
|
||
seen.add(cls)
|
||
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
|
||
return None
|
||
|
||
def _serial_to_opened_port_count(self) -> dict[int, int]:
|
||
"""Serial number → downstream port count for stems in ``self.hubs``."""
|
||
out: dict[int, int] = {}
|
||
for stem in self.hubs:
|
||
sn_res = stem.system.getSerialNumber()
|
||
if sn_res.error != self.SUCCESS:
|
||
continue
|
||
out[sn_res.value] = self._port_count(stem)
|
||
return out
|
||
|
||
def _print_usb_hub_summary_table(self, specs) -> None:
|
||
"""Print ``Hub | Serial | Ports``; use live counts when connected, else model-inferred (*)."""
|
||
if not specs:
|
||
return
|
||
by_sn = self._serial_to_opened_port_count()
|
||
need_note = False
|
||
print(flush=True)
|
||
print(f"{'Hub':<6} | {'Serial':<12} | {'Ports'}", flush=True)
|
||
print("-" * 30, flush=True)
|
||
for idx, spec in enumerate(specs):
|
||
sn = spec.serial_number
|
||
sn_s = f"0x{sn:08X}"
|
||
if sn in by_sn:
|
||
n = by_sn[sn]
|
||
suffix = ""
|
||
else:
|
||
need_note = True
|
||
inf = self._inferred_downstream_ports_from_spec(spec)
|
||
n = inf if inf is not None else "?"
|
||
suffix = " *"
|
||
print(f"{idx + 1:<6} | {sn_s:<12} | {n}{suffix}", flush=True)
|
||
if need_note:
|
||
print(flush=True)
|
||
print(
|
||
"* Ports from hub model; device not opened (udev 24ff / stem type — power control unavailable).",
|
||
flush=True,
|
||
)
|
||
|
||
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 power-control hub discovery: serial and cmdPORT entity count per hub. No port power reads or changes."""
|
||
print("Scanning USB for power-control hub 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 HubTool’s 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,
|
||
)
|
||
self._connect_specs(specs)
|
||
self._print_usb_hub_summary_table(specs)
|
||
|
||
def show_hostcards(self):
|
||
"""
|
||
Local **hostcards**: USB power-control hubs on this machine (BrainStem discovery + hub table).
|
||
|
||
Same discovery path as ``discover``, framed for concentrator-side hardware.
|
||
"""
|
||
print("Hostcards — local USB power-control hubs (BrainStem)", 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 HubTool’s 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.hubs:
|
||
self._connect_specs(specs)
|
||
self._print_usb_hub_summary_table(specs)
|
||
|
||
def show_radioheads(self):
|
||
"""
|
||
**Radioheads**: every ``fiber_ports`` row in ``fiber_map.json`` with route and live power.
|
||
|
||
Same table as ``fiber status`` / :meth:`fiber_map_status`, under the radiohead label.
|
||
"""
|
||
print("Radioheads — fiber_map.json fiber_ports (per-strand attachments)", flush=True)
|
||
self.fiber_map_status()
|
||
|
||
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 USB power-control 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 = [
|
||
'# USB power-control hub (vendor 24ff)\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:
|
||
ports_default = default_panel_ports()
|
||
print(f"No patch_panel in map yet; default is {ports_default} ports.", flush=True)
|
||
try:
|
||
line = input(
|
||
f" How many front-panel positions? [{ports_default}]: "
|
||
).strip()
|
||
except EOFError:
|
||
line = ""
|
||
if not line:
|
||
n = ports_default
|
||
else:
|
||
try:
|
||
n = int(line)
|
||
if n < 1 or n > 256:
|
||
print(f" Invalid; using {ports_default}.", flush=True)
|
||
n = ports_default
|
||
except ValueError:
|
||
print(f" Invalid; using {ports_default}.", flush=True)
|
||
n = ports_default
|
||
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 / FIWI_REMOTE_HUBS: "
|
||
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 hub 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 USB hub 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 USB power-control 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 host’s 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: 1–6 = known Adnacom H3 card, then SFP 1–4 "
|
||
"(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 port’s map row and powering off. ***\n",
|
||
flush=True,
|
||
)
|
||
ports[key] = entry
|
||
if step_powered:
|
||
self._calibrate_step_power_off(ssh_host, hub_1, port_0)
|
||
self._write_fiber_map_document(doc)
|
||
raise SystemExit(130)
|
||
if action == "clear":
|
||
entry.pop("pcie", None)
|
||
elif action == "set" and pdata is not None:
|
||
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()
|
||
|