Fi-Wi: site setup, relay module, map/SSH consolidation

- Add site_setup entry (root shim + fiwi.site_setup) with SiteSetupApp and in-process
  relay/calibrate; FiWiRelayBootstrapApp in fiwi_relay/relay_app.py.
- CLI: run_panel_calibrate, parse_panel_calibrate_argv, shared BrainStem load helper;
  fiber_map_io.resolve_calibrate_ssh_targets for one merge of calibrate SSH hosts.
- Maps default under maps/; config and remote_ssh.env.example updates; concentrator
  and panel calibrate fixes; radiohead refactor; tests/docs refresh.
- Remove fiber_radio_port.py (unused example), empty fiwi_link stub, orphan peak_detect.reflex.

Made-with: Cursor
This commit is contained in:
Robert McMahon 2026-04-04 18:18:27 -07:00
parent 93cd346a07
commit 957078039d
26 changed files with 1918 additions and 397 deletions

2
.gitignore vendored
View File

@ -27,6 +27,8 @@ htmlcov/
# Operator maps; INI profiles live in config/ (see config/default.ini). SSH dotenv: remote_ssh.env.example # Operator maps; INI profiles live in config/ (see config/default.ini). SSH dotenv: remote_ssh.env.example
fiber_map.json fiber_map.json
fiber_map.json.bak* fiber_map.json.bak*
maps/fiber_map.json
maps/fiber_map.json.*
*.json.bak* *.json.bak*
panel_map.json panel_map.json
remote_ssh.env remote_ssh.env

View File

@ -7,10 +7,11 @@
description = Generic default profile description = Generic default profile
[paths] [paths]
fiber_map = fiber_map.json ; Under the Fi-Wi install root (directory containing fiwi.py). Backups: same folder, timestamp suffix.
fiber_map = maps/fiber_map.json
[patch_panel] [patch_panel]
; Front-panel port count when fiber_map.json has no patch_panel.slots yet ; Front-panel port count when the map has no patch_panel.slots yet
default_ports = 24 default_ports = 24
[remote_ssh] [remote_ssh]

View File

@ -4,7 +4,7 @@
description = UAX 24-port USB concentrator description = UAX 24-port USB concentrator
[paths] [paths]
fiber_map = fiber_map.json fiber_map = maps/fiber_map.json
[patch_panel] [patch_panel]
; Front-panel ports for this concentrator when map has no patch_panel yet ; Front-panel ports for this concentrator when map has no patch_panel yet

View File

@ -4,7 +4,7 @@
description = UAX 4-port USB concentrator description = UAX 4-port USB concentrator
[paths] [paths]
fiber_map = fiber_map.json fiber_map = maps/fiber_map.json
[patch_panel] [patch_panel]
; Front-panel ports for this concentrator when map has no patch_panel yet ; Front-panel ports for this concentrator when map has no patch_panel yet

View File

@ -12,14 +12,14 @@ The Fi-Wi stack ties together **USB power-control hubs** (BrainStem API), a **pa
|--------|------| |--------|------|
| `fiwi.py` | Entry point; configures `fiwi.paths` with the install directory, then runs `fiwi.cli.main`. | | `fiwi.py` | Entry point; configures `fiwi.paths` with the install directory, then runs `fiwi.cli.main`. |
| `fiwi/` | Library: concentrator, SSH transport, fiber map I/O, USB/wireless probes, diagnostics. | | `fiwi/` | Library: concentrator, SSH transport, fiber map I/O, USB/wireless probes, diagnostics. |
| `fiber_map.json` | Lives next to `fiwi.py` (see `fiwi.paths`). Operator-edited or calibrate-produced. | | `maps/fiber_map.json` | Default path under the install root (see `fiwi.paths`; override with `FIWI_FIBER_MAP`). Operator-edited or calibrate-produced; timestamped backups sit in `maps/` too. |
| `config/*.ini` | Optional profiles under `config/` (`default.ini` when `FIWI_CONFIG` unset). `[remote_hubs] hosts` sets `FIWI_REMOTE_HUBS` (merged with `FIWI_CALIBRATE_REMOTES` for panel calibrate). Merged after `remote_ssh.env` via `setdefault`. | | `config/*.ini` | Optional profiles under `config/` (`default.ini` when `FIWI_CONFIG` unset). `[remote_hubs] hosts` sets `FIWI_REMOTE_HUBS` (merged with `FIWI_CALIBRATE_REMOTES` for panel calibrate). Merged after `remote_ssh.env` via `setdefault`. |
| `remote_ssh.env` / `.fiwi_remote` | Optional dotenv next to the install; merged into process env for SSH defaults (see `SshNodeConfig`). | | `remote_ssh.env` / `.fiwi_remote` | Optional dotenv next to the install; merged into process env for SSH defaults (see `SshNodeConfig`). |
## Core components ## Core components
- **`FiWiConcentrator`** (`fiwi/concentrator.py`): Main Fi-Wi object — BrainStem discovery, power, reboot, panel + fiber workflows, and **panel calibrate** (interactive and hybrid local/remote ordering). - **`FiWiConcentrator`** (`fiwi/concentrator.py`): Main Fi-Wi object — BrainStem discovery, power, reboot, panel + fiber workflows, and **panel calibrate** (interactive and hybrid local/remote ordering).
- **`FiberRadioPort`** (`fiwi/fiber_radio_port.py`): View of one `fiber_ports[]` entry: power routing, SSH node resolution, previews for map-driven UIs. - **`RadioHeadEntry`** (`fiwi/radiohead.py`): View of one `fiber_ports[]` record (map key + data). **`RadioHead`** pairs that with a `FiWiConcentrator` for power/inrush (via `patch_panel().head` / `heads`).
- **`SshNode` / `SshNodeConfig`** (`fiwi/ssh.py`): Builds `ssh` argv (optional `FIWI_SSH_OPTS`), runs remote `FIWI_REMOTE_PYTHON` + `FIWI_REMOTE_SCRIPT` with forwarded subcommands, or **raw** `ssh target …` for helpers like `dmesg` / `lspci` in diagnostics. Supports **deferred** capture (`RemoteCallHandle`, `asyncio.Task`) when `defer=True` or `FIWI_REMOTE_DEFER` / `--async` so overlapping subprocesses do not require Python threads. - **`SshNode` / `SshNodeConfig`** (`fiwi/ssh.py`): Builds `ssh` argv (optional `FIWI_SSH_OPTS`), runs remote `FIWI_REMOTE_PYTHON` + `FIWI_REMOTE_SCRIPT` with forwarded subcommands, or **raw** `ssh target …` for helpers like `dmesg` / `lspci` in diagnostics. Supports **deferred** capture (`RemoteCallHandle`, `asyncio.Task`) when `defer=True` or `FIWI_REMOTE_DEFER` / `--async` so overlapping subprocesses do not require Python threads.
- **`ssh_dispatch`** (`fiwi/ssh_dispatch.py`): Early CLI path: if `fiber_map.json` routes a **power fiber-port** (or certain fiber flows) to another host, dispatch runs `SshNode.invoke` **without** importing BrainStem on the workstation. - **`ssh_dispatch`** (`fiwi/ssh_dispatch.py`): Early CLI path: if `fiber_map.json` routes a **power fiber-port** (or certain fiber flows) to another host, dispatch runs `SshNode.invoke` **without** importing BrainStem on the workstation.
- **`fiber_map_io`**: Load/save map, PCIe prompt helpers during calibrate, previews. - **`fiber_map_io`**: Load/save map, PCIe prompt helpers during calibrate, previews.
@ -40,7 +40,7 @@ A **central, append-only log** records ordered events (`NoteEvent`, `DmesgEvent`
## Extension points ## Extension points
- New **CLI subcommands**: `fiwi/cli.py` dispatch; heavy logic stays in `FiWiConcentrator` or focused modules. - New **CLI subcommands**: `fiwi/cli.py` dispatch; heavy logic stays in `FiWiConcentrator` or focused modules.
- New **map-driven behavior**: extend `fiber_ports` schema carefully; teach `FiberRadioPort` / `ssh_dispatch` if routing changes. - New **map-driven behavior**: extend `fiber_ports` schema carefully; teach `RadioHeadEntry` / `ssh_dispatch` if routing changes.
- New **remote machine-readable commands**: add branches in `fiwi/cli.py` (remote runs the same script). - New **remote machine-readable commands**: add branches in `fiwi/cli.py` (remote runs the same script).
- New **diagnostic event kinds**: add a frozen dataclass, widen `DiagEvent`, keep `dump_jsonl` as dict-per-line. - New **diagnostic event kinds**: add a frozen dataclass, widen `DiagEvent`, keep `dump_jsonl` as dict-per-line.

View File

@ -7,7 +7,7 @@ The repository does not yet ship a pytest suite for `fiwi/`; the patterns below
## Choose a control style ## Choose a control style
1. **Shell / subprocess** — Easiest to run exactly what operators run. Parse JSON from stdout for machine-readable commands (`calibrate-ports-json`, `lsusb-lines-json`, `wlan-info-json`). Stable for CI-style wrappers. 1. **Shell / subprocess** — Easiest to run exactly what operators run. Parse JSON from stdout for machine-readable commands (`calibrate-ports-json`, `lsusb-lines-json`, `wlan-info-json`). Stable for CI-style wrappers.
2. **Python imports** — Use `FiWiConcentrator`, `SshNode`, `FiberRadioPort`, and `fiwi.diag_log` for tighter integration, error handling, and async (`ainvoke_capture`, `alog_dmesg`, and so on). 2. **Python imports** — Use `FiWiConcentrator`, `SshNode`, `RadioHead` / `RadioHeadEntry`, and `fiwi.diag_log` for tighter integration, error handling, and async (`ainvoke_capture`, `alog_dmesg`, and so on).
Mix both: for example import `SshNode` to run remote JSON probes while keeping local steps in the shell. Mix both: for example import `SshNode` to run remote JSON probes while keeping local steps in the shell.

View File

@ -1,5 +1,13 @@
{ {
"patch_panel": { "slots": 24, "label": "Example rack" }, "fiwi_site": {
"concentrator_name": "example-concentrator",
"concentrator_location": "Lab / rack label"
},
"patch_panel": {
"slots": 24,
"label": "Example rack",
"location": "Lab bench A"
},
"calibrate_remotes": ["rjmcmahon@192.168.1.39"], "calibrate_remotes": ["rjmcmahon@192.168.1.39"],
"fiber_ports": { "fiber_ports": {
"1": { "1": {

30
fiwi.py
View File

@ -4,6 +4,36 @@
import os import os
import sys import sys
def _consume_early_config_argv(argv: list[str]) -> list[str]:
"""
Apply ``-c`` / ``--config`` (INI profile or ``*.ini`` path) as ``FIWI_CONFIG``, and drop those
tokens so :mod:`getopt` in :mod:`fiwi.cli` does not treat ``-c`` as unknown.
"""
out: list[str] = []
i = 0
while i < len(argv):
a = argv[i]
if a in ("-c", "--config"):
if i + 1 < len(argv):
os.environ["FIWI_CONFIG"] = argv[i + 1].strip()
i += 2
continue
out.append(a)
i += 1
continue
if a.startswith("--config="):
os.environ["FIWI_CONFIG"] = a.partition("=")[2].strip()
i += 1
continue
out.append(a)
i += 1
return out
if len(sys.argv) > 1:
sys.argv[1:] = _consume_early_config_argv(sys.argv[1:])
import fiwi.paths as _paths import fiwi.paths as _paths
_paths.configure(os.path.dirname(os.path.abspath(__file__))) _paths.configure(os.path.dirname(os.path.abspath(__file__)))

View File

@ -18,9 +18,10 @@ from fiwi.diag_log import (
kernel_dump_event, kernel_dump_event,
note_event, note_event,
) )
from fiwi.fiber_radio_port import FiberRadioPort from fiwi.radiohead import InrushSample, RadioHead, RadioHeadEntry, load_radio_head_entries
from fiwi.concentrator import FiWiConcentrator from fiwi.concentrator import BoundHubPlane, FiWiConcentrator, HubDownstreamPort
from fiwi.patch_panel import PatchPanel from fiwi.patch_panel import BoundPatchPanel, PatchPanel
from fiwi.site_setup import open_fiwi_stack
from fiwi.ssh import ( from fiwi.ssh import (
FetchCalibratePortsHandle, FetchCalibratePortsHandle,
RemoteCallHandle, RemoteCallHandle,
@ -36,9 +37,15 @@ __all__ = [
"DiagLog", "DiagLog",
"DmesgEvent", "DmesgEvent",
"KernelDumpEvent", "KernelDumpEvent",
"FiberRadioPort", "BoundHubPlane",
"BoundPatchPanel",
"FetchCalibratePortsHandle", "FetchCalibratePortsHandle",
"FiWiConcentrator", "FiWiConcentrator",
"HubDownstreamPort",
"InrushSample",
"RadioHead",
"RadioHeadEntry",
"load_radio_head_entries",
"NoteEvent", "NoteEvent",
"PatchPanel", "PatchPanel",
"PcieEvent", "PcieEvent",
@ -57,5 +64,6 @@ __all__ = [
"get_diag_log", "get_diag_log",
"kernel_dump_event", "kernel_dump_event",
"note_event", "note_event",
"open_fiwi_stack",
"resolve_remote_defer", "resolve_remote_defer",
] ]

View File

@ -108,7 +108,8 @@ def _parse_global_cli(argv: list[str]) -> _GlobalCli | None:
def _print_cli_help() -> None: def _print_cli_help() -> None:
print( print(
"Fi-Wi test framework — CLI\n" "Fi-Wi test framework — CLI\n"
"Usage: fiwi.py [--async] <command> [target]\n" "Usage: fiwi.py [-c PROFILE] [--async] <command> [target]\n"
" -c / --config PROFILE set FIWI_CONFIG before loading config/*.ini (same as site_setup.py)\n"
" --async set FIWI_REMOTE_DEFER: deferred calls spawn ssh child processes immediately;\n" " --async set FIWI_REMOTE_DEFER: deferred calls spawn ssh child processes immediately;\n"
" panel calibrate overlaps them (join via handle.result(); no Python threads).\n" " panel calibrate overlaps them (join via handle.result(); no Python threads).\n"
" Or set FIWI_REMOTE_DEFER=1 / remote_ssh.env / config/*.ini (FIWI_CONFIG=profile).\n" " Or set FIWI_REMOTE_DEFER=1 / remote_ssh.env / config/*.ini (FIWI_CONFIG=profile).\n"
@ -117,6 +118,7 @@ def _print_cli_help() -> None:
" discover — USB power-control hubs (serial, port count); no port I/O\n" " discover — USB power-control hubs (serial, port count); no port I/O\n"
" show_hostcards — same as discover, concentrator 'hostcards' label\n" " show_hostcards — same as discover, concentrator 'hostcards' label\n"
" port-metrics-json — JSON list: per-port power, current (mA), voltage (V) on connected hubs\n" " port-metrics-json — JSON list: per-port power, current (mA), voltage (V) on connected hubs\n"
" hub-inrush-json <hub.port> — JSON {peak_ma, duration_ms} short current capture (local hubs)\n"
" usb-hub-tty-json — JSON: Acroname (24ff) hubs: serial, tty[], bus, dev, id, manufacturer, product\n" " usb-hub-tty-json — JSON: Acroname (24ff) hubs: serial, tty[], bus, dev, id, manufacturer, product\n"
" show_radioheads — all fiber_ports rows + power (same table as fiber status)\n" " show_radioheads — all fiber_ports rows + power (same table as fiber status)\n"
" status [target] — default command; target like all, 1.3, all.2\n" " status [target] — default command; target like all, 1.3, all.2\n"
@ -149,10 +151,14 @@ def _print_cli_help() -> None:
) )
def _parse_panel_calibrate_argv(args): def parse_panel_calibrate_argv(args: list[str]) -> tuple[bool, int | None, list[str]]:
""" """
Parse argv tail after ``panel calibrate``:
``panel calibrate [merge] [N] [--ssh user@host] `` ``panel calibrate [merge] [N] [--ssh user@host] ``
Returns ``(merge, limit, calibrate_ssh_hosts)``.
Returns ``(merge, limit, calibrate_ssh_hosts)``. On bad tokens, prints to stderr and raises
:class:`SystemExit` (2) intended for CLI use; call :func:`run_panel_calibrate` with explicit
keyword args when embedding.
""" """
merge = False merge = False
limit = None limit = None
@ -181,6 +187,68 @@ def _parse_panel_calibrate_argv(args):
return merge, limit, hosts return merge, limit, hosts
def _try_load_brainstem_for_cli() -> int | None:
"""
Import BrainStem for local USB hub control.
Returns:
``None`` on success, or ``1`` after printing the usual install hints.
"""
try:
load_brainstem()
except Exception as exc:
print(f"fiwi: failed to import brainstem: {exc}", file=sys.stderr, flush=True)
if isinstance(exc, ImportError):
print(
" Install the BrainStem package for **this** Python (the one running fiwi.py), e.g.\n"
" ./env/bin/python3 -m pip install -r requirements.txt\n"
" or: python3 -m pip install --user -r requirements.txt\n"
" Check with: python3 -c \"import brainstem\" (same python you use for fiwi.py).\n"
" If you ran `fiwi.py --ssh` from another machine, set FIWI_REMOTE_PYTHON there to\n"
" a Python on **this** host where brainstem is installed (e.g. ~/…/env/bin/python3).",
file=sys.stderr,
flush=True,
)
return 1
return None
def run_panel_calibrate(
*,
merge: bool = False,
limit: int | None = None,
calibrate_ssh_hosts: list[str] | None = None,
emit_start_line: bool = False,
) -> int:
"""
Run :meth:`~fiwi.concentrator.FiWiConcentrator.panel_calibrate` in-process (same work as
``python fiwi.py panel calibrate `` for local hubs + SSH walk).
Expects :mod:`fiwi.paths` to be configured (e.g. ``paths.configure(install_root)``) and
``FIWI_CONFIG`` set if you use profiles. Does not re-parse ``sys.argv``.
Returns:
``0`` on normal completion. May raise :class:`SystemExit` for the same reasons as the CLI
(e.g. Ctrl-C during calibrate uses exit 130 inside ``panel_calibrate``).
"""
if emit_start_line and sys.stderr.isatty():
os.write(2, b"fiwi: start\n")
bs_rc = _try_load_brainstem_for_cli()
if bs_rc is not None:
return bs_rc
hosts = [s.strip() for s in (calibrate_ssh_hosts or []) if (s or "").strip()]
concentrator = FiWiConcentrator()
try:
concentrator.panel_calibrate(
merge=merge,
limit=limit,
calibrate_ssh_hosts=hosts,
)
finally:
concentrator.disconnect()
return 0
def main() -> int: def main() -> int:
ga = _parse_global_cli(sys.argv[1:]) ga = _parse_global_cli(sys.argv[1:])
if ga is None: if ga is None:
@ -217,22 +285,9 @@ def main() -> int:
# Skip when stderr is a pipe (SSH capture, subprocess): avoids interleaving with stdout consumers. # Skip when stderr is a pipe (SSH capture, subprocess): avoids interleaving with stdout consumers.
if sys.stderr.isatty(): if sys.stderr.isatty():
os.write(2, b"fiwi: start\n") os.write(2, b"fiwi: start\n")
try: bs_rc = _try_load_brainstem_for_cli()
load_brainstem() if bs_rc is not None:
except Exception as exc: return bs_rc
print(f"fiwi: failed to import brainstem: {exc}", file=sys.stderr, flush=True)
if isinstance(exc, ImportError):
print(
" Install the BrainStem package for **this** Python (the one running fiwi.py), e.g.\n"
" ./env/bin/python3 -m pip install -r requirements.txt\n"
" or: python3 -m pip install --user -r requirements.txt\n"
" Check with: python3 -c \"import brainstem\" (same python you use for fiwi.py).\n"
" If you ran `fiwi.py --ssh` from another machine, set FIWI_REMOTE_PYTHON there to\n"
" a Python on **this** host where brainstem is installed (e.g. ~/…/env/bin/python3).",
file=sys.stderr,
flush=True,
)
return 1
concentrator = FiWiConcentrator() concentrator = FiWiConcentrator()
try: try:
cmd = pos[0].lower() if pos else "status" cmd = pos[0].lower() if pos else "status"
@ -241,6 +296,21 @@ def main() -> int:
concentrator.status(target) concentrator.status(target)
elif cmd == "port-metrics-json": elif cmd == "port-metrics-json":
print(json.dumps(concentrator.port_metrics_snapshot()), flush=True) print(json.dumps(concentrator.port_metrics_snapshot()), flush=True)
elif cmd == "hub-inrush-json":
if len(pos) < 2:
print(
"Usage: fiwi.py hub-inrush-json <hub.port>\n"
" Example: fiwi.py hub-inrush-json 1.0 (hub 1, port 0; local BrainStem only)",
file=sys.stderr,
flush=True,
)
return 2
try:
sample = concentrator.sample_inrush_hub_port_str(pos[1])
except (ConnectionError, ValueError) as exc:
print(f"fiwi: {exc}", file=sys.stderr, flush=True)
return 1
print(json.dumps(sample.as_json_dict()), flush=True)
elif cmd == "calibrate-ports-json": elif cmd == "calibrate-ports-json":
if not concentrator.hubs and not concentrator.connect(): if not concentrator.hubs and not concentrator.connect():
print("[]", flush=True) print("[]", flush=True)
@ -335,7 +405,7 @@ def main() -> int:
concentrator.panel_status() concentrator.panel_status()
elif sub == "calibrate": elif sub == "calibrate":
cal_args = pos[2:] cal_args = pos[2:]
merge, limit, cal_hosts = _parse_panel_calibrate_argv(cal_args) merge, limit, cal_hosts = parse_panel_calibrate_argv(cal_args)
concentrator.panel_calibrate(merge=merge, limit=limit, calibrate_ssh_hosts=cal_hosts) concentrator.panel_calibrate(merge=merge, limit=limit, calibrate_ssh_hosts=cal_hosts)
elif sub in ("on", "off"): elif sub in ("on", "off"):
if len(pos) < 3: if len(pos) < 3:

View File

@ -1,5 +1,7 @@
"""Fi-Wi concentrator: BrainStem USB power, fiber/radio map, patch panel, remote nodes.""" """Fi-Wi concentrator: BrainStem USB power, fiber/radio map, patch panel, remote nodes."""
from __future__ import annotations
import asyncio import asyncio
import copy import copy
import json import json
@ -7,15 +9,15 @@ import os
import shutil import shutil
import sys import sys
import time import time
from dataclasses import dataclass
import fiwi.brainstem_loader as stemmod import fiwi.brainstem_loader as stemmod
from fiwi.patch_panel import PatchPanel, default_panel_ports, effective_panel_slots from fiwi.patch_panel import BoundPatchPanel, PatchPanel, default_panel_ports, effective_panel_slots
from fiwi.paths import fiber_map_path from fiwi.paths import fiber_map_path
from fiwi.fiber_radio_port import FiberRadioPort from fiwi.radiohead import InrushSample, RadioHeadEntry
from fiwi import fiber_map_io as fm from fiwi import fiber_map_io as fm
from fiwi.ssh import ( from fiwi.ssh import (
SshNode, SshNode,
SshNodeConfig,
parse_status_line_for_hub_port, parse_status_line_for_hub_port,
resolve_remote_defer, resolve_remote_defer,
) )
@ -28,7 +30,8 @@ class FiWiConcentrator:
Main Fi-Wi object: BrainStem-driven USB power-control hub plane (host Python API). On-device Main Fi-Wi object: BrainStem-driven USB power-control hub plane (host Python API). On-device
**Reflex** programs are separate compile with SDK **arc** (``fiwi.py reflex compile``), load **Reflex** programs are separate compile with SDK **arc** (``fiwi.py reflex compile``), load
with ReflexLoader / HubTool; FiWi still uses ``brainstem`` for port power and metrics. with ReflexLoader / HubTool; FiWi still uses ``brainstem`` for port power and metrics.
``FiberRadioPort`` / :class:`fiwi.ssh.SshNode` routing, and calibration. :class:`~fiwi.radiohead.RadioHeadEntry` (map) and :class:`~fiwi.radiohead.RadioHead` (via :meth:`patch_panel`),
:class:`HubDownstreamPort` (via :meth:`hub_plane`), and :class:`fiwi.ssh.SshNode` routing.
Local USB reboot staggering uses :mod:`asyncio`. Remote SSH work during ``panel calibrate`` 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 (fetching hub/port lists and baseline power-off) runs concurrent ``SshNode`` coroutines via
@ -380,6 +383,19 @@ class FiWiConcentrator:
print("Radioheads — fiber_map.json fiber_ports (per-strand attachments)", flush=True) print("Radioheads — fiber_map.json fiber_ports (per-strand attachments)", flush=True)
self.fiber_map_status() self.fiber_map_status()
def patch_panel(self, doc: dict | None = None) -> BoundPatchPanel:
"""Bound panel + this concentrator + map — use :meth:`~fiwi.patch_panel.BoundPatchPanel.head` / ``heads``."""
d = doc if doc is not None else fm.load_fiber_map_or_exit()
blob = d.get("patch_panel")
pp = PatchPanel.from_map_blob(blob)
if pp is None:
pp = PatchPanel(slots=effective_panel_slots(d), label="")
return pp.bound(self, d)
def hub_plane(self) -> BoundHubPlane:
"""Local USB hub downstream ports — use :meth:`~BoundHubPlane.port` / :meth:`~BoundHubPlane.port_at`."""
return BoundHubPlane(self)
def _sample_inrush(self, stem, port, sample_duration=0.3): def _sample_inrush(self, stem, port, sample_duration=0.3):
"""Captures peak current and duration for the reboot report.""" """Captures peak current and duration for the reboot report."""
samples = [] samples = []
@ -456,6 +472,13 @@ class FiWiConcentrator:
) )
return rows return rows
def hub_port_metrics(self, hub_1: int, port_0: int) -> dict[str, object] | None:
"""One row from :meth:`port_metrics_snapshot` for ``hub_1`` / ``port_0``, or ``None``."""
for row in self.port_metrics_snapshot():
if row.get("hub") == hub_1 and row.get("port") == port_0:
return row
return None
async def _async_reboot_port(self, h_idx, stem, port, delay, stagger, skip_empty): async def _async_reboot_port(self, h_idx, stem, port, delay, stagger, skip_empty):
"""Reboots a port and captures inrush vs steady state data.""" """Reboots a port and captures inrush vs steady state data."""
current_ma = stem.usb.getPortCurrent(port).value / 1000.0 current_ma = stem.usb.getPortCurrent(port).value / 1000.0
@ -551,9 +574,9 @@ class FiWiConcentrator:
"""Rack positions 1…N (N from fiber_map patch_panel.slots or default): mapping and power.""" """Rack positions 1…N (N from fiber_map patch_panel.slots or default): mapping and power."""
doc = fm.load_fiber_map_or_exit() doc = fm.load_fiber_map_or_exit()
n_slots = effective_panel_slots(doc) n_slots = effective_panel_slots(doc)
slot_frp = [FiberRadioPort.from_port_id(doc, n) for n in range(1, n_slots + 1)] slot_entries = [RadioHeadEntry.from_port_id(doc, n) for n in range(1, n_slots + 1)]
need_local = any( need_local = any(
x.hub_port() is not None and x.ssh_target() is None for x in slot_frp x.hub_port() is not None and x.ssh_target() is None for x in slot_entries
) )
if need_local and not self.hubs and not self.connect(): if need_local and not self.hubs and not self.connect():
return return
@ -563,12 +586,12 @@ class FiWiConcentrator:
flush=True, flush=True,
) )
print("-" * 120) print("-" * 120)
for idx, frp in enumerate(slot_frp): for idx, map_ent in enumerate(slot_entries):
panel_n = idx + 1 panel_n = idx + 1
tup = frp.hub_port() tup = map_ent.hub_port()
ssh = frp.ssh_target() ssh = map_ent.ssh_target()
chip_s = frp.chip_preview() chip_s = map_ent.chip_preview()
pcie_s = frp.pcie_preview() pcie_s = map_ent.pcie_preview()
if tup is None: if tup is None:
print( print(
f"{panel_n:<7} | {'':<10} | {'':<18} | {'':<5} | {'':<8} | {chip_s:<28} | {pcie_s:<22}", f"{panel_n:<7} | {'':<10} | {'':<18} | {'':<5} | {'':<8} | {chip_s:<28} | {pcie_s:<22}",
@ -627,15 +650,15 @@ class FiWiConcentrator:
if panel_1based < 1 or panel_1based > n_slots: 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) print(f"Panel port must be 1{n_slots}, got {panel_1based}", file=sys.stderr, flush=True)
sys.exit(1) sys.exit(1)
frp = FiberRadioPort.from_port_id(doc, panel_1based) map_ent = RadioHeadEntry.from_port_id(doc, panel_1based)
if not frp.is_mapped(): if not map_ent.is_mapped():
print( print(
f"Panel {panel_1based} is not mapped (no fiber_ports[{frp.map_key!r}] in fiber_map.json).", f"Panel {panel_1based} is not mapped (no fiber_ports[{map_ent.map_key!r}] in fiber_map.json).",
file=sys.stderr, file=sys.stderr,
flush=True, flush=True,
) )
sys.exit(1) sys.exit(1)
ssh = frp.ssh_target() ssh = map_ent.ssh_target()
if ssh: if ssh:
sys.exit( sys.exit(
SshNode.parse(ssh).invoke( SshNode.parse(ssh).invoke(
@ -643,7 +666,7 @@ class FiWiConcentrator:
defer=False, defer=False,
) )
) )
hub_1, port_0 = frp.hub_port() hub_1, port_0 = map_ent.hub_port()
assert hub_1 is not None and port_0 is not None assert hub_1 is not None and port_0 is not None
tgt = f"{hub_1}.{port_0}" tgt = f"{hub_1}.{port_0}"
print(f"Panel {panel_1based} → hub target {tgt} ({mode})", flush=True) print(f"Panel {panel_1based} → hub target {tgt} ({mode})", flush=True)
@ -653,23 +676,23 @@ class FiWiConcentrator:
def fiber_power(self, mode, fiber_port): def fiber_power(self, mode, fiber_port):
"""Power via fiber_map.json fiber_ports key (any positive integer id).""" """Power via fiber_map.json fiber_ports key (any positive integer id)."""
doc = fm.load_fiber_map_or_exit() doc = fm.load_fiber_map_or_exit()
frp = FiberRadioPort.from_port_id(doc, int(fiber_port)) map_ent = RadioHeadEntry.from_port_id(doc, int(fiber_port))
if not frp.is_mapped(): if not map_ent.is_mapped():
print( print(
f"Fiber port {fiber_port} is not mapped (missing fiber_ports[{frp.map_key!r}] in fiber_map.json).", f"Fiber port {fiber_port} is not mapped (missing fiber_ports[{map_ent.map_key!r}] in fiber_map.json).",
file=sys.stderr, file=sys.stderr,
flush=True, flush=True,
) )
sys.exit(1) sys.exit(1)
ssh = frp.ssh_target() ssh = map_ent.ssh_target()
if ssh: if ssh:
sys.exit( sys.exit(
SshNode.parse(ssh).invoke( SshNode.parse(ssh).invoke(
["power", "fiber-port", frp.map_key, mode.lower()], ["power", "fiber-port", map_ent.map_key, mode.lower()],
defer=False, defer=False,
) )
) )
hub_1, port_0 = frp.hub_port() hub_1, port_0 = map_ent.hub_port()
assert hub_1 is not None and port_0 is not None assert hub_1 is not None and port_0 is not None
tgt = f"{hub_1}.{port_0}" tgt = f"{hub_1}.{port_0}"
print(f"Fiber port {fiber_port} → hub target {tgt} ({mode})", flush=True) print(f"Fiber port {fiber_port} → hub target {tgt} ({mode})", flush=True)
@ -682,16 +705,16 @@ class FiWiConcentrator:
SSH-mapped fibers use PCIe metadata from calibrate / fiber_map.json not forwarded. SSH-mapped fibers use PCIe metadata from calibrate / fiber_map.json not forwarded.
""" """
doc = fm.load_fiber_map_or_exit() doc = fm.load_fiber_map_or_exit()
frp = FiberRadioPort.from_port_id(doc, int(fiber_port)) map_ent = RadioHeadEntry.from_port_id(doc, int(fiber_port))
key = frp.map_key key = map_ent.map_key
if not frp.is_mapped(): if not map_ent.is_mapped():
print( print(
f"Fiber port {fiber_port} is not mapped (missing fiber_ports[{key!r}] in fiber_map.json).", f"Fiber port {fiber_port} is not mapped (missing fiber_ports[{key!r}] in fiber_map.json).",
file=sys.stderr, file=sys.stderr,
flush=True, flush=True,
) )
sys.exit(1) sys.exit(1)
ssh = frp.ssh_target() ssh = map_ent.ssh_target()
if ssh: if ssh:
print( print(
"fiber chip: this fiber is SSH-mapped (PCIe/fiber path). Use panel calibrate PCIe prompts " "fiber chip: this fiber is SSH-mapped (PCIe/fiber path). Use panel calibrate PCIe prompts "
@ -709,7 +732,7 @@ class FiWiConcentrator:
sys.exit(1) sys.exit(1)
if not self.hubs and not self.connect(): if not self.hubs and not self.connect():
return return
hub_1, port_0 = frp.hub_port() hub_1, port_0 = map_ent.hub_port()
assert hub_1 is not None and port_0 is not None assert hub_1 is not None and port_0 is not None
h_idx = hub_1 - 1 h_idx = hub_1 - 1
if h_idx < 0 or h_idx >= len(self.hubs): if h_idx < 0 or h_idx >= len(self.hubs):
@ -773,7 +796,7 @@ class FiWiConcentrator:
def fiber_map_status(self): def fiber_map_status(self):
"""All fiber_ports entries with hub.port and live power (local BrainStem or ssh status).""" """All fiber_ports entries with hub.port and live power (local BrainStem or ssh status)."""
doc = fm.load_fiber_map_or_exit() doc = fm.load_fiber_map_or_exit()
all_frp = list(FiberRadioPort.each_from_document(doc)) all_entries = list(RadioHeadEntry.each_from_document(doc))
print( print(
f"{'Fiber':<8} | {'Hub.Port':<10} | {'Route':<18} | {'Pwr':<5} | {'mA':<8} | " f"{'Fiber':<8} | {'Hub.Port':<10} | {'Route':<18} | {'Pwr':<5} | {'mA':<8} | "
f"{'Chip (saved)':<28} | {'PCIe (saved)':<22}", f"{'Chip (saved)':<28} | {'PCIe (saved)':<22}",
@ -781,16 +804,16 @@ class FiWiConcentrator:
) )
print("-" * 120) print("-" * 120)
need_local = any( need_local = any(
frp.hub_port() is not None and frp.ssh_target() is None for frp in all_frp e.hub_port() is not None and e.ssh_target() is None for e in all_entries
) )
if need_local and not self.hubs and not self.connect(): if need_local and not self.hubs and not self.connect():
return return
for frp in all_frp: for map_ent in all_entries:
key = frp.map_key key = map_ent.map_key
tup = frp.hub_port() tup = map_ent.hub_port()
ssh = frp.ssh_target() ssh = map_ent.ssh_target()
chip_s = frp.chip_preview() chip_s = map_ent.chip_preview()
pcie_s = frp.pcie_preview() pcie_s = map_ent.pcie_preview()
if tup is None: if tup is None:
print( print(
f"{key!s:<8} | {'':<10} | {'':<18} | {'':<5} | {'':<8} | {chip_s:<28} | {pcie_s:<22}", f"{key!s:<8} | {'':<10} | {'':<18} | {'':<5} | {'':<8} | {chip_s:<28} | {pcie_s:<22}",
@ -849,15 +872,15 @@ class FiWiConcentrator:
if panel_1based < 1 or panel_1based > n_slots: 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) print(f"Panel port must be 1{n_slots}, got {panel_1based}", file=sys.stderr, flush=True)
sys.exit(1) sys.exit(1)
frp = FiberRadioPort.from_port_id(doc, panel_1based) map_ent = RadioHeadEntry.from_port_id(doc, panel_1based)
if not frp.is_mapped(): if not map_ent.is_mapped():
print( print(
f"Panel {panel_1based} is not mapped (no fiber_ports[{frp.map_key!r}] in fiber_map.json).", f"Panel {panel_1based} is not mapped (no fiber_ports[{map_ent.map_key!r}] in fiber_map.json).",
file=sys.stderr, file=sys.stderr,
flush=True, flush=True,
) )
sys.exit(1) sys.exit(1)
ssh = frp.ssh_target() ssh = map_ent.ssh_target()
sub = "reboot" if skip_empty else "reboot-force" sub = "reboot" if skip_empty else "reboot-force"
if ssh: if ssh:
sys.exit( sys.exit(
@ -866,7 +889,7 @@ class FiWiConcentrator:
defer=False, defer=False,
) )
) )
hub_1, port_0 = frp.hub_port() hub_1, port_0 = map_ent.hub_port()
assert hub_1 is not None and port_0 is not None assert hub_1 is not None and port_0 is not None
tgt = f"{hub_1}.{port_0}" tgt = f"{hub_1}.{port_0}"
print(f"Panel {panel_1based} → hub target {tgt} ({sub})", flush=True) print(f"Panel {panel_1based} → hub target {tgt} ({sub})", flush=True)
@ -889,6 +912,46 @@ class FiWiConcentrator:
else: else:
stem.usb.setPortDisable(port_0) stem.usb.setPortDisable(port_0)
def set_hub_port_power(self, hub_1: int, port_0: int, enable: bool) -> None:
"""
Enable or disable one local downstream USB port (1-based hub index, 0-based port index).
Connects to local hubs if needed. Used by :class:`~fiwi.radiohead.RadioHead` and tests.
"""
if not self.hubs and not self.connect():
raise ConnectionError("no USB power-control hubs connected")
h_idx = hub_1 - 1
if h_idx < 0 or h_idx >= len(self.hubs):
raise ValueError(f"hub {hub_1} is out of range (have {len(self.hubs)} hub(s))")
stem = self.hubs[h_idx]
n = self._port_count(stem)
if port_0 < 0 or port_0 >= n:
raise ValueError(f"hub {hub_1} has ports 0{n - 1}; got {port_0}")
self._set_hub_port_power(hub_1, port_0, enable)
def sample_inrush_hub_port(
self, hub_1: int, port_0: int, *, sample_duration: float = 0.3
) -> InrushSample:
"""
Sample downstream current for ``sample_duration`` seconds on a **local** hub port.
Remote hub hosts use CLI ``hub-inrush-json`` (same JSON shape).
"""
if not self.hubs and not self.connect():
raise ConnectionError("no USB power-control hubs connected")
h_idx = hub_1 - 1
if h_idx < 0 or h_idx >= len(self.hubs):
raise ValueError(f"hub {hub_1} is out of range (have {len(self.hubs)} hub(s))")
stem = self.hubs[h_idx]
n = self._port_count(stem)
if port_0 < 0 or port_0 >= n:
raise ValueError(f"hub {hub_1} has ports 0{n - 1}; got {port_0}")
raw = self._sample_inrush(stem, port_0, sample_duration)
return InrushSample(raw["peak"], raw["duration"])
def sample_inrush_hub_port_str(self, hub_dot_port: str, *, sample_duration: float = 0.3) -> InrushSample:
"""Parse ``hub.port`` (e.g. ``1.0``) and sample; for CLI ``hub-inrush-json``."""
hp = self.hub_plane().port_at(hub_dot_port)
return hp.measure_inrush(sample_duration=sample_duration)
def _calibrate_step_power_off(self, ssh_host, hub_1, 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).""" """Turn off downstream port at end of one calibrate step (after PCIe prompts if mapped)."""
if ssh_host is None: if ssh_host is None:
@ -906,6 +969,77 @@ class FiWiConcentrator:
flush=True, flush=True,
) )
@staticmethod
def _calibrate_interruptible_sleep(total_s: float, chunk_s: float = 0.25) -> None:
"""Sleep in small steps so Ctrl-C (SIGINT) is handled between BrainStem / blocking calls."""
if total_s <= 0:
return
end = time.monotonic() + total_s
while True:
remaining = end - time.monotonic()
if remaining <= 0:
return
time.sleep(min(chunk_s, remaining))
def _calibrate_power_down_all_ports(
self, remote_hosts: list[str], *, context: str = "Calibrate shutdown"
) -> None:
"""
Disable every local downstream port, then run ``fiwi off all`` on each SSH hub host.
Intended for calibrate cleanup / site-setup baseline so the rack is not left hot.
"""
if self.hubs:
print(
f"\n>>> {context}: all local downstream USB ports OFF…",
flush=True,
)
for stem in self.hubs:
n = self._port_count(stem)
for p in range(n):
try:
stem.usb.setPortDisable(p)
except Exception:
pass
self._calibrate_interruptible_sleep(0.15, 0.05)
seen: set[str] = set()
for host in remote_hosts:
h = str(host).strip()
if not h or h in seen:
continue
seen.add(h)
try:
print(f">>> {context}: `{h}` off all…", flush=True)
code, _out, err = SshNode.parse(h).invoke_capture(
["off", "all"], timeout=120, defer=False
)
if code != 0 and (err or "").strip():
print(err.strip()[:240], file=sys.stderr, flush=True)
except Exception as exc:
print(
f" ({context}: remote off all {h!r} failed: {exc})",
file=sys.stderr,
flush=True,
)
def power_down_all_downstream_ports(
self, remote_hosts: list[str] | None = None, *, context: str = "Site setup"
) -> None:
"""
Turn off every downstream port on **connected** local hubs, then run ``fiwi off all`` on
each ``user@host`` in *remote_hosts* (deduped). Opens local hubs via :meth:`connect` if none
are connected yet. Safe to call before site metadata edits or calibrate.
"""
if not self.hubs:
self.connect(quiet=True)
seen: list[str] = []
dup: set[str] = set()
for h in remote_hosts or []:
s = str(h).strip()
if s and "@" in s and s not in dup:
dup.add(s)
seen.append(s)
self._calibrate_power_down_all_ports(seen, context=context)
def _port_power_feedback(self, hub_1, port_0): def _port_power_feedback(self, hub_1, port_0):
"""Return short status string after a change (hub power state + optional current).""" """Return short status string after a change (hub power state + optional current)."""
h_idx = hub_1 - 1 h_idx = hub_1 - 1
@ -923,10 +1057,7 @@ class FiWiConcentrator:
def _write_fiber_map_document(self, doc): def _write_fiber_map_document(self, doc):
path = fiber_map_path() path = fiber_map_path()
out = fm.ensure_fiber_map_document(doc) fm.write_fiber_map_document(doc, path=path)
with open(path, "w", encoding="utf-8") as f:
json.dump(out, f, indent=2)
f.write("\n")
print(f"Wrote {path}", flush=True) print(f"Wrote {path}", flush=True)
def _prompt_patch_panel(self, doc: dict) -> PatchPanel: def _prompt_patch_panel(self, doc: dict) -> PatchPanel:
@ -989,11 +1120,23 @@ class FiWiConcentrator:
lab_in = "" lab_in = ""
if lab_in: if lab_in:
label = lab_in label = lab_in
panel = PatchPanel(slots=n, label=label) location = ""
if have and have.location:
location = have.location
try:
loc_in = input(
" Optional site / rack location [Enter to skip]: "
).strip()
except EOFError:
loc_in = ""
if loc_in:
location = loc_in
panel = PatchPanel(slots=n, label=label, location=location)
doc["patch_panel"] = panel.to_map_blob() doc["patch_panel"] = panel.to_map_blob()
print( print(
f" Patch panel set: {panel.slots} position(s)" f" Patch panel set: {panel.slots} position(s)"
+ (f" ({panel.label})" if panel.label else "") + (f" ({panel.label})" if panel.label else "")
+ (f" @ {panel.location}" if panel.location else "")
+ ".\n---\n", + ".\n---\n",
flush=True, flush=True,
) )
@ -1019,7 +1162,64 @@ class FiWiConcentrator:
doc = {"fiber_ports": {}} doc = {"fiber_ports": {}}
doc = fm.ensure_fiber_map_document(doc) doc = fm.ensure_fiber_map_document(doc)
mp = fiber_map_path()
print(f"fiwi: fiber map: {mp}", file=sys.stderr, flush=True)
_map_hosts = fm.calibrate_remotes_hosts(doc)
if _map_hosts:
print(
f"fiwi: calibrate_remotes from map: {', '.join(_map_hosts)}",
file=sys.stderr,
flush=True,
)
elif doc.get("calibrate_remotes") is not None:
print(
"fiwi: calibrate_remotes in map is empty or not a list/string of user@host — "
"no SSH hub hosts from JSON.",
file=sys.stderr,
flush=True,
)
try: try:
if merge:
have_pp = PatchPanel.from_map_blob(doc.get("patch_panel"))
if have_pp is not None:
bits = [f"{have_pp.slots} position(s)"]
if (have_pp.label or "").strip():
bits.append(f"label {have_pp.label.strip()!r}")
if (have_pp.location or "").strip():
bits.append(f"location {have_pp.location.strip()!r}")
summ = ", ".join(bits)
print(
"\n--- Patch panel (from map) ---\n"
f" {summ}\n"
" [Enter]=keep and continue to USB hub walk · e = edit panel fields",
flush=True,
)
if sys.stdin.isatty():
try:
ans = input(
" Your choice (then Enter): "
).strip().lower()
except EOFError:
ans = ""
else:
print(
" (stdin is not a TTY — keeping patch_panel from map; "
"run in a real terminal for prompts.)\n",
flush=True,
)
ans = ""
if ans != "e":
doc["patch_panel"] = have_pp.to_map_blob()
print(
f" Keeping patch_panel ({summ}).\n---\n",
flush=True,
)
else:
self._prompt_patch_panel(doc)
else:
self._prompt_patch_panel(doc)
else:
self._prompt_patch_panel(doc) self._prompt_patch_panel(doc)
except KeyboardInterrupt: except KeyboardInterrupt:
print( print(
@ -1031,34 +1231,14 @@ class FiWiConcentrator:
raise SystemExit(130) raise SystemExit(130)
self._write_fiber_map_document(doc) self._write_fiber_map_document(doc)
seen_h = set() plan = fm.resolve_calibrate_ssh_targets(
cli_hosts = [] doc, extra_cli_hosts=calibrate_ssh_hosts
for h in calibrate_ssh_hosts: )
s = str(h).strip() cli_hosts = list(plan.hosts)
if s and s not in seen_h: if plan.first_introduced_via_ssh_config:
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( print(
f"fiwi: calibrate remotes from FIWI_CALIBRATE_REMOTES / FIWI_REMOTE_HUBS: " "fiwi: calibrate remotes from FIWI_CALIBRATE_REMOTES / FIWI_REMOTE_HUBS / "
f"{', '.join(added_from_env)}", f"[remote_hubs]: {', '.join(plan.first_introduced_via_ssh_config)}",
file=sys.stderr, file=sys.stderr,
flush=True, flush=True,
) )
@ -1164,8 +1344,19 @@ class FiWiConcentrator:
if sh is not None and sh not in remote_hosts_ordered: if sh is not None and sh not in remote_hosts_ordered:
remote_hosts_ordered.append(sh) remote_hosts_ordered.append(sh)
if remote_hosts_ordered: if remote_hosts_ordered:
print(
"\nRemote USB baseline over SSH (turning off each hub.port on the Pi)…",
flush=True,
)
for host in remote_hosts_ordered:
print(f"{host}", flush=True)
print(
" Each hub.port line appears as that SSH session finishes (first connect can take up to the timeout).\n"
" Stuck with no new lines? Test `ssh <host> true` — password prompts do not work here; use keys or "
"ssh-agent. Fail fast: FIWI_SSH_OPTS='-o BatchMode=yes -o ConnectTimeout=15'.\n",
flush=True,
)
use_def = resolve_remote_defer(None) use_def = resolve_remote_defer(None)
by_host = {h: [] for h in remote_hosts_ordered}
if use_def: if use_def:
meta_off: list[tuple[str, int, int]] = [] meta_off: list[tuple[str, int, int]] = []
handles = [] handles = []
@ -1175,35 +1366,48 @@ class FiWiConcentrator:
for h, p in pairs: for h, p in pairs:
meta_off.append((host, h, p)) meta_off.append((host, h, p))
handles.append(node.remote_hub_port_power(h, p, False, defer=True)) handles.append(node.remote_hub_port_power(h, p, False, defer=True))
res_off = [h.result() for h in handles] n_tot = len(handles)
for (host, h, p), r in zip(meta_off, res_off): print(
by_host[host].append(((h, p), r)) f" {n_tot} SSH off command(s) started in parallel "
f"(FIWI_REMOTE_DEFER); collecting in start order…",
flush=True,
)
for i, (handle, (host, h, p)) in enumerate(zip(handles, meta_off), start=1):
print(f" [{i}/{n_tot}] awaiting {host} hub {h} port {p}", flush=True)
r = handle.result()
rc, rerr = r
if rc != 0:
print(
f" → exit {rc}: {(rerr or '').strip()[:120]}",
flush=True,
)
else:
print(" → ok", flush=True)
for host in remote_hosts_ordered:
time.sleep(0.6)
print(f" Remote baseline done for {host}.", flush=True)
else: else:
for host in remote_hosts_ordered: for host in remote_hosts_ordered:
pairs = sorted({(h, p) for s_h, h, p in steps if s_h == host}) pairs = sorted({(h, p) for s_h, h, p in steps if s_h == host})
for h, p in pairs: n_off = len(pairs)
print(
f"Turning OFF every downstream USB port on {host} "
f"(baseline, {n_off} SSH call(s), sequential)…",
flush=True,
)
for i, (h, p) in enumerate(pairs, start=1):
print(f" [{i}/{n_off}] off hub {h} port {p}", flush=True)
r = SshNode.parse(host).remote_hub_port_power( r = SshNode.parse(host).remote_hub_port_power(
h, p, False, defer=False h, p, False, defer=False
) )
by_host[host].append(((h, p), r)) rc, rerr = 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: if rc != 0:
print( print(
f" [{i}/{n_off}] off {h}.{p} → exit {rc}: {(rerr or '').strip()[:120]}", f" → exit {rc}: {(rerr or '').strip()[:120]}",
flush=True, flush=True,
) )
else: else:
print(f" [{i}/{n_off}] off {h}.{p} ok", flush=True) print(" → ok", flush=True)
time.sleep(0.6) time.sleep(0.6)
print(f" Remote baseline done for {host}.", flush=True) print(f" Remote baseline done for {host}.", flush=True)
@ -1476,3 +1680,49 @@ class FiWiConcentrator:
def disconnect(self): def disconnect(self):
for stem in self.hubs: stem.disconnect() for stem in self.hubs: stem.disconnect()
@dataclass
class HubDownstreamPort:
"""
One local BrainStem downstream USB port (1-based hub index, 0-based port index).
Obtain via :meth:`FiWiConcentrator.hub_plane` :meth:`BoundHubPlane.port` / :meth:`BoundHubPlane.port_at`.
For map-aware / SSH routing, use :meth:`FiWiConcentrator.patch_panel` :class:`~fiwi.radiohead.RadioHead`.
"""
concentrator: FiWiConcentrator
hub_1: int
port_0: int
def power_on(self) -> None:
self.concentrator.set_hub_port_power(self.hub_1, self.port_0, True)
def power_off(self) -> None:
self.concentrator.set_hub_port_power(self.hub_1, self.port_0, False)
def measure_inrush(self, *, sample_duration: float = 0.3) -> InrushSample:
return self.concentrator.sample_inrush_hub_port(
self.hub_1, self.port_0, sample_duration=sample_duration
)
@dataclass
class BoundHubPlane:
"""Bound view of this concentrators local hub/port space (symmetric to :class:`~fiwi.patch_panel.BoundPatchPanel`)."""
concentrator: FiWiConcentrator
def port(self, hub_1: int, port_0: int) -> HubDownstreamPort:
return HubDownstreamPort(self.concentrator, hub_1, port_0)
def port_at(self, hub_dot_port: str) -> HubDownstreamPort:
s = hub_dot_port.strip()
parts = s.split(".", 1)
if len(parts) != 2:
raise ValueError("expected hub.port, e.g. 1.0")
try:
h = int(parts[0].strip())
p = int(parts[1].strip())
except ValueError as exc:
raise ValueError("expected hub.port with integer hub and port, e.g. 1.0") from exc
return self.port(h, p)

View File

@ -1,10 +1,15 @@
"""Load/save fiber_map.json; parse hub/port bindings and chip metadata.""" """Load/save fiber_map.json; parse hub/port bindings and chip metadata."""
from __future__ import annotations
import json import json
import os import os
import re import re
import sys import sys
import time import time
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from typing import Any
from fiwi.adnacom_pcie_catalog import ( from fiwi.adnacom_pcie_catalog import (
ADNACOM_KNOWN_CARDS, ADNACOM_KNOWN_CARDS,
@ -13,7 +18,85 @@ from fiwi.adnacom_pcie_catalog import (
print_catalog_menu, print_catalog_menu,
short_bdf, short_bdf,
) )
from fiwi.paths import fiber_map_path from fiwi.paths import base_dir, fiber_map_path
def calibrate_remotes_hosts(doc) -> list[str]:
"""
Normalize ``doc['calibrate_remotes']`` to ``user@host`` strings.
Accepts a JSON **array** of strings or a single comma-separated **string**
(panel calibrate and site setup both use this).
"""
if not isinstance(doc, dict):
return []
cr = doc.get("calibrate_remotes")
if isinstance(cr, list):
return [str(x).strip() for x in cr if str(x).strip() and "@" in str(x)]
if isinstance(cr, str) and cr.strip():
return [p.strip() for p in cr.split(",") if p.strip() and "@" in p]
return []
@dataclass(frozen=True)
class CalibrateSshTargetPlan:
"""
Resolved ``user@host`` hub targets for hybrid Fi-Wi (panel calibrate, ``off all``, relay).
Order is stable: explicit CLI hosts, then ``doc['calibrate_remotes']``, then
:attr:`~fiwi.ssh.SshNodeConfig.calibrate_remotes` (INI + ``FIWI_CALIBRATE_REMOTES`` /
``FIWI_REMOTE_HUBS``), each phase deduped.
"""
hosts: tuple[str, ...]
first_introduced_via_ssh_config: tuple[str, ...]
"""Hosts whose first appearance was only from config/env, not map or ``extra_cli_hosts``."""
def resolve_calibrate_ssh_targets(
doc: Mapping[str, Any] | None,
*,
extra_cli_hosts: Iterable[str] | None = None,
) -> CalibrateSshTargetPlan:
"""
Single place that merges calibrate SSH targets (map + profile + optional ``--ssh``).
Call after :func:`fiwi.paths.configure` / INI load so :class:`~fiwi.ssh.SshNodeConfig` resolves.
"""
from fiwi.ssh import SshNodeConfig
hosts: list[str] = []
seen: set[str] = set()
introduced_cfg: list[str] = []
for raw in extra_cli_hosts or ():
t = str(raw).strip()
if not t or "@" not in t or t in seen:
continue
seen.add(t)
hosts.append(t)
if isinstance(doc, dict):
for t in calibrate_remotes_hosts(doc):
if t in seen:
continue
seen.add(t)
hosts.append(t)
cfg_line = (SshNodeConfig.load().calibrate_remotes or "").strip()
if cfg_line:
for part in cfg_line.split(","):
t = part.strip()
if not t or "@" not in t or t in seen:
continue
seen.add(t)
hosts.append(t)
introduced_cfg.append(t)
return CalibrateSshTargetPlan(
hosts=tuple(hosts),
first_introduced_via_ssh_config=tuple(introduced_cfg),
)
def ensure_fiber_map_document(doc): def ensure_fiber_map_document(doc):
@ -29,12 +112,38 @@ def ensure_fiber_map_document(doc):
def load_fiber_map_document(): def load_fiber_map_document():
"""Load ``fiber_map.json`` if present; return None if missing.""" """
Load the configured fiber map if present; return None if missing.
If the configured path (usually ``maps/fiber_map.json``) is absent but a legacy
``fiber_map.json`` exists in the install root, that file is loaded so older
layouts keep working until you save (which writes the configured path).
"""
fpath = fiber_map_path() fpath = fiber_map_path()
if not os.path.isfile(fpath): if os.path.isfile(fpath):
return None
with open(fpath, encoding="utf-8") as f: with open(fpath, encoding="utf-8") as f:
return ensure_fiber_map_document(json.load(f)) return ensure_fiber_map_document(json.load(f))
leg = os.path.join(base_dir(), "fiber_map.json")
if (
os.path.normpath(os.path.abspath(leg))
!= os.path.normpath(os.path.abspath(fpath))
and os.path.isfile(leg)
):
with open(leg, encoding="utf-8") as f:
return ensure_fiber_map_document(json.load(f))
return None
def write_fiber_map_document(doc, *, path=None):
"""Write the fiber map (default :func:`~fiwi.paths.fiber_map_path`) from a document dict."""
p = path if path is not None else fiber_map_path()
out = ensure_fiber_map_document(doc)
parent = os.path.dirname(os.path.abspath(p))
if parent:
os.makedirs(parent, exist_ok=True)
with open(p, "w", encoding="utf-8") as f:
json.dump(out, f, indent=2)
f.write("\n")
def load_fiber_map_or_exit(): def load_fiber_map_or_exit():
@ -42,7 +151,8 @@ def load_fiber_map_or_exit():
if doc is None: if doc is None:
print( print(
f"Missing {fiber_map_path()}.\n" f"Missing {fiber_map_path()}.\n"
" Copy fiber_map.example.json → fiber_map.json and set fiber_ports " " Copy fiber_map.example.json → maps/fiber_map.json (or set FIWI_FIBER_MAP) "
"and set fiber_ports "
'(e.g. "5": {"hub": 1, "port": 2, "ssh": "rjmcmahon@192.168.1.39"}). ' '(e.g. "5": {"hub": 1, "port": 2, "ssh": "rjmcmahon@192.168.1.39"}). '
"Use ssh / remote / host+user when USB hubs are reached via SSH.", "Use ssh / remote / host+user when USB hubs are reached via SSH.",
file=sys.stderr, file=sys.stderr,

View File

@ -1,94 +0,0 @@
"""
Fiber + radio port: central domain object for a row in fiber_map.json.
Power (USB hub downstream ports), SSH routing, PCIe / wlan / USB metadata all hang off this
aggregate. ``FiWiConcentrator`` supplies BrainStem power; not the conceptual center.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Iterator, List, Optional, Tuple
from fiwi import fiber_map_io as fm
from fiwi.ssh import SshNode
@dataclass
class FiberRadioPort:
"""
One logical fiber/radio attachment: ``fiber_ports[<map_key>]`` in the map document.
``entry`` is the live dict from the document (mutations persist when the doc is saved).
"""
map_key: str
entry: Optional[Dict[str, Any]]
@property
def port_id(self) -> Optional[int]:
"""Integer fiber id when ``map_key`` is decimal; else None."""
if self.map_key.isdigit():
return int(self.map_key)
return None
def hub_port(self) -> Optional[Tuple[int, int]]:
"""(hub_1based, port_0based) or None if unmapped / invalid."""
return fm.fiber_entry_hub_port(self.entry)
def ssh_target(self) -> Optional[str]:
"""user@host when this ports hubs are reached via SSH; else None."""
return fm.fiber_ssh_target(self.entry) if isinstance(self.entry, dict) else None
def ssh_node(self) -> Optional[SshNode]:
"""Remote Fi-Wi host (``SshNode``) for this port, or None when mapped locally."""
t = self.ssh_target()
if not t:
return None
try:
return SshNode.parse(t)
except ValueError:
return None
def is_mapped(self) -> bool:
"""True when hub.port is valid in the entry."""
return self.hub_port() is not None
def chip_preview(self, width: int = 26) -> str:
return fm.stored_chip_preview(self.entry) if isinstance(self.entry, dict) else ""
def pcie_preview(self, width: int = 22) -> str:
return fm.stored_pcie_preview(self.entry) if isinstance(self.entry, dict) else ""
@classmethod
def from_map_key(cls, doc: Dict[str, Any], map_key: str) -> FiberRadioPort:
ports = doc.get("fiber_ports") if isinstance(doc, dict) else None
if not isinstance(ports, dict):
return cls(str(map_key), None)
ent = ports.get(str(map_key))
return cls(
str(map_key),
ent if isinstance(ent, dict) else None,
)
@classmethod
def from_port_id(cls, doc: Dict[str, Any], port_id: int) -> FiberRadioPort:
return cls.from_map_key(doc, str(int(port_id)))
@staticmethod
def each_from_document(doc: Dict[str, Any]) -> Iterator[FiberRadioPort]:
"""All ``fiber_ports`` rows (sorted), including unmapped / empty values."""
ports = doc.get("fiber_ports") if isinstance(doc, dict) else None
if not isinstance(ports, dict):
return
for key in sorted(ports.keys(), key=fm.fiber_sort_key):
ent = ports[key]
yield FiberRadioPort(
str(key),
ent if isinstance(ent, dict) else None,
)
def load_fiber_radio_ports(doc: Dict[str, Any]) -> List[FiberRadioPort]:
"""Registry of all fiber map rows (sorted keys)."""
return list(FiberRadioPort.each_from_document(doc))

View File

@ -6,10 +6,13 @@ from .host import (
probe_remote_hub_readiness, probe_remote_hub_readiness,
suggested_remote_venv_python, suggested_remote_venv_python,
) )
from .relay_app import FiWiRelayBootstrapApp, main as run_fiwi_relay
__all__ = [ __all__ = [
"FiWiRelay", "FiWiRelay",
"FiWiRelayBootstrapApp",
"RemoteReadiness", "RemoteReadiness",
"probe_remote_hub_readiness", "probe_remote_hub_readiness",
"run_fiwi_relay",
"suggested_remote_venv_python", "suggested_remote_venv_python",
] ]

View File

@ -1,129 +1,8 @@
""" """``python -m fiwi.fiwi_relay`` — thin entry; see :mod:`fiwi.fiwi_relay.relay_app`."""
Run as ``python -m fiwi.fiwi_relay`` builds :class:`~.host.FiWiRelay` from config and calls :meth:`~.host.FiWiRelay.setup`.
"""
from __future__ import annotations from __future__ import annotations
import argparse from fiwi.fiwi_relay.relay_app import main
import os
import sys
_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."))
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
def main() -> int:
p = argparse.ArgumentParser(
description="Fi-Wi hub relay: FiWiRelay.setup() over SSH or locally (auto when user@host is this machine).",
)
p.add_argument(
"-c",
"--config",
metavar="PROFILE_OR_INI",
help="INI profile or path (sets FIWI_CONFIG before loading config)",
)
p.add_argument(
"--ssh",
metavar="USER@HOST",
help="Override SSH target (default: first host from merged [remote_hubs] / env); ignored with --local",
)
p.add_argument(
"-n",
"--name",
metavar="LABEL",
help="Override hub label (default: [remote_hubs] label or derived from host)",
)
p.add_argument(
"--all",
action="store_true",
help="Run setup() on every configured remote host (local vs ssh auto per host)",
)
p.add_argument(
"--timeout",
type=float,
default=600.0,
metavar="SEC",
help="SSH session timeout (default: 600)",
)
p.add_argument(
"--dry-run",
action="store_true",
help="Print what would run; do not SSH / do not run bash",
)
p.add_argument(
"--local",
action="store_true",
help="Force local execution (this machine); also used if from_config fails and no [remote_hubs]",
)
p.add_argument(
"--remote",
action="store_true",
help="Force SSH even if this machine matches the configured user@host",
)
args = p.parse_args()
if args.config:
os.environ["FIWI_CONFIG"] = args.config.strip()
if args.local and args.remote:
print("FAIL: use only one of --local and --remote.", file=sys.stderr)
return 2
try:
os.chdir(_ROOT)
except OSError as exc:
print(f"FAIL: cannot chdir to repo root {_ROOT!r}: {exc}", file=sys.stderr)
return 1
from fiwi.fiwi_relay.host import FiWiRelay
try:
if args.local and args.all:
print("FAIL: --local does not support --all (would run local bootstrap for every listed host).", file=sys.stderr)
return 2
if args.all:
hosts = FiWiRelay.each_from_config(_ROOT)
if not hosts:
print(
"No remote hosts in config. Set [remote_hubs] hosts or FIWI_REMOTE_HUBS.",
file=sys.stderr,
)
return 2
rc = 0
for h in hosts:
use_local = not args.remote and FiWiRelay.ssh_target_points_here(h.ssh_target)
mode = "local" if use_local else "ssh"
print(f"\n--- {h.name} ({h.ssh_target}) [{mode}] ---\n", flush=True)
code = h.setup(timeout=args.timeout, dry_run=args.dry_run, local=use_local)
if code != 0:
rc = code
return rc
if args.local:
try:
host = FiWiRelay.from_config(_ROOT, ssh_target=args.ssh, name=args.name)
except ValueError:
host = FiWiRelay.for_local_machine(_ROOT, name=args.name)
print(
f"FiWiRelay(name={host.name!r}, ssh_target={host.ssh_target!r}, local=True) [forced --local]",
flush=True,
)
return host.setup(timeout=args.timeout, dry_run=args.dry_run, local=True)
host = FiWiRelay.from_config(_ROOT, ssh_target=args.ssh, name=args.name)
use_local = not args.remote and FiWiRelay.ssh_target_points_here(host.ssh_target)
if use_local:
tag = " [auto local]"
elif args.remote:
tag = " [forced ssh]"
else:
tag = " [ssh]"
print(f"FiWiRelay(name={host.name!r}, ssh_target={host.ssh_target!r}){tag}", flush=True)
return host.setup(timeout=args.timeout, dry_run=args.dry_run, local=use_local)
except ValueError as exc:
print(f"FAIL: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())

View File

@ -0,0 +1,174 @@
"""
Hub relay bootstrap as an object: parse argv, chdir to install root, run :class:`~.host.FiWiRelay` setup.
Used by ``python -m fiwi.fiwi_relay`` and by :mod:`fiwi.site_setup` (embed with explicit ``argv``).
"""
from __future__ import annotations
import argparse
import os
import sys
from dataclasses import dataclass
from fiwi.fiwi_relay.host import FiWiRelay
def default_install_root() -> str:
"""Directory containing ``fiwi.py`` (repo / install root)."""
return os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."))
def build_relay_argument_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
description=(
"Fi-Wi hub relay: FiWiRelay.setup() over SSH or locally "
"(auto when user@host is this machine)."
),
)
p.add_argument(
"-c",
"--config",
metavar="PROFILE_OR_INI",
help="INI profile or path (sets FIWI_CONFIG before loading config)",
)
p.add_argument(
"--ssh",
metavar="USER@HOST",
help=(
"Override SSH target (default: first host from merged [remote_hubs] / env); "
"ignored with --local"
),
)
p.add_argument(
"-n",
"--name",
metavar="LABEL",
help="Override hub label (default: [remote_hubs] label or derived from host)",
)
p.add_argument(
"--all",
action="store_true",
help="Run setup() on every configured remote host (local vs ssh auto per host)",
)
p.add_argument(
"--timeout",
type=float,
default=600.0,
metavar="SEC",
help="SSH session timeout (default: 600)",
)
p.add_argument(
"--dry-run",
action="store_true",
help="Print what would run; do not SSH / do not run bash",
)
p.add_argument(
"--local",
action="store_true",
help=(
"Force local execution (this machine); also used if from_config fails "
"and no [remote_hubs]"
),
)
p.add_argument(
"--remote",
action="store_true",
help="Force SSH even if this machine matches the configured user@host",
)
return p
@dataclass
class FiWiRelayBootstrapApp:
"""
One relay bootstrap run: ``argv`` is either ``None`` (use ``sys.argv[1:]``) or an explicit
token list so a parent CLI is not misparsed.
"""
argv: list[str] | None = None
install_root: str | None = None
def _parse_args(self) -> argparse.Namespace:
p = build_relay_argument_parser()
if self.argv is None:
return p.parse_args()
return p.parse_args(self.argv)
def run(self) -> int:
args = self._parse_args()
if args.config:
os.environ["FIWI_CONFIG"] = args.config.strip()
if args.local and args.remote:
print("FAIL: use only one of --local and --remote.", file=sys.stderr)
return 2
root = self.install_root if self.install_root is not None else default_install_root()
if root not in sys.path:
sys.path.insert(0, root)
try:
os.chdir(root)
except OSError as exc:
print(f"FAIL: cannot chdir to repo root {root!r}: {exc}", file=sys.stderr)
return 1
try:
if args.local and args.all:
print(
"FAIL: --local does not support --all "
"(would run local bootstrap for every listed host).",
file=sys.stderr,
)
return 2
if args.all:
hosts = FiWiRelay.each_from_config(root)
if not hosts:
print(
"No remote hosts in config. Set [remote_hubs] hosts or FIWI_REMOTE_HUBS.",
file=sys.stderr,
)
return 2
rc = 0
for h in hosts:
use_local = not args.remote and FiWiRelay.ssh_target_points_here(h.ssh_target)
mode = "local" if use_local else "ssh"
print(f"\n--- {h.name} ({h.ssh_target}) [{mode}] ---\n", flush=True)
code = h.setup(timeout=args.timeout, dry_run=args.dry_run, local=use_local)
if code != 0:
rc = code
return rc
if args.local:
try:
host = FiWiRelay.from_config(root, ssh_target=args.ssh, name=args.name)
except ValueError:
host = FiWiRelay.for_local_machine(root, name=args.name)
print(
f"FiWiRelay(name={host.name!r}, ssh_target={host.ssh_target!r}, local=True) "
f"[forced --local]",
flush=True,
)
return host.setup(timeout=args.timeout, dry_run=args.dry_run, local=True)
host = FiWiRelay.from_config(root, ssh_target=args.ssh, name=args.name)
use_local = not args.remote and FiWiRelay.ssh_target_points_here(host.ssh_target)
if use_local:
tag = " [auto local]"
elif args.remote:
tag = " [forced ssh]"
else:
tag = " [ssh]"
print(
f"FiWiRelay(name={host.name!r}, ssh_target={host.ssh_target!r}){tag}",
flush=True,
)
return host.setup(timeout=args.timeout, dry_run=args.dry_run, local=use_local)
except ValueError as exc:
print(f"FAIL: {exc}", file=sys.stderr)
return 2
def main(argv: list[str] | None = None, *, install_root: str | None = None) -> int:
"""CLI / embed entry: same as ``FiWiRelayBootstrapApp(...).run()``."""
return FiWiRelayBootstrapApp(argv=argv, install_root=install_root).run()

View File

@ -1,7 +1,7 @@
""" """
Physical patch panel: front-panel position count for the rack (field workflow). Physical patch panel: front-panel position count for the rack (field workflow).
Stored in fiber_map.json as ``patch_panel``: ``{ "slots": N, "label": "" }``. Stored in fiber_map.json as ``patch_panel``: ``{ "slots": N, "label": "", "location": "" }``.
USB hub calibrate still walks hub ports; map keys 1N align with panel positions. USB hub calibrate still walks hub ports; map keys 1N align with panel positions.
""" """
@ -9,10 +9,15 @@ from __future__ import annotations
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Dict, Optional from typing import TYPE_CHECKING, Any, Dict, List, Optional
from fiwi.constants import PANEL_SLOTS from fiwi.constants import PANEL_SLOTS
if TYPE_CHECKING:
from fiwi.concentrator import FiWiConcentrator
from fiwi.radiohead import RadioHead, RadioHeadEntry
_MAX_SLOTS = 256 _MAX_SLOTS = 256
@ -35,6 +40,7 @@ class PatchPanel:
slots: int slots: int
label: str = "" label: str = ""
location: str = ""
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.slots < 1: if self.slots < 1:
@ -46,6 +52,8 @@ class PatchPanel:
d: Dict[str, Any] = {"slots": self.slots} d: Dict[str, Any] = {"slots": self.slots}
if self.label.strip(): if self.label.strip():
d["label"] = self.label.strip() d["label"] = self.label.strip()
if self.location.strip():
d["location"] = self.location.strip()
return d return d
@classmethod @classmethod
@ -61,7 +69,45 @@ class PatchPanel:
return None return None
lab = blob.get("label") lab = blob.get("label")
label = lab.strip() if isinstance(lab, str) else "" label = lab.strip() if isinstance(lab, str) else ""
return cls(slots=s, label=label) loc = blob.get("location")
location = loc.strip() if isinstance(loc, str) else ""
return cls(slots=s, label=label, location=location)
def bound(self, concentrator: FiWiConcentrator, doc: Dict[str, Any]) -> BoundPatchPanel:
"""Attach a :class:`FiWiConcentrator` and loaded fiber map for :class:`~fiwi.radiohead.RadioHead` access."""
return BoundPatchPanel(panel=self, concentrator=concentrator, doc=doc)
@dataclass
class BoundPatchPanel:
"""
Patch panel (slot count) plus fiber map document and concentrator the supported way to get a
:class:`~fiwi.radiohead.RadioHead` for a front-panel position.
"""
panel: PatchPanel
concentrator: FiWiConcentrator
doc: Dict[str, Any]
def head(self, position_1based: int) -> Optional[RadioHead]:
"""Radio head for panel position ``1…panel.slots``, or ``None`` if unmapped."""
if position_1based < 1 or position_1based > self.panel.slots:
raise ValueError(
f"panel position must be 1{self.panel.slots}, got {position_1based}"
)
map_ent = RadioHeadEntry.from_port_id(self.doc, position_1based)
if not map_ent.is_mapped():
return None
return RadioHead(map_ent, self.concentrator, patch_panel_port=position_1based)
def heads(self) -> List[RadioHead]:
"""Mapped positions only, in panel order (1…slots)."""
out: List[RadioHead] = []
for n in range(1, self.panel.slots + 1):
rh = self.head(n)
if rh is not None:
out.append(rh)
return out
def effective_panel_slots(doc: Optional[Dict[str, Any]]) -> int: def effective_panel_slots(doc: Optional[Dict[str, Any]]) -> int:

View File

@ -35,9 +35,14 @@ def config_dir() -> str:
def fiber_map_path() -> str: def fiber_map_path() -> str:
name = (os.environ.get("FIWI_FIBER_MAP") or "fiber_map.json").strip() """
Active map file path. Default: ``maps/fiber_map.json`` under :func:`base_dir`
(override with ``FIWI_FIBER_MAP`` or ``[paths] fiber_map`` in config).
Timestamped backups from site setup live beside this file in ``maps/``.
"""
name = (os.environ.get("FIWI_FIBER_MAP") or "maps/fiber_map.json").strip()
if not name: if not name:
name = "fiber_map.json" name = "maps/fiber_map.json"
if os.path.isabs(name): if os.path.isabs(name):
return name return name
return os.path.join(base_dir(), name) return os.path.join(base_dir(), name)

320
fiwi/radiohead.py Normal file
View File

@ -0,0 +1,320 @@
"""
Radio head control and map entries.
:class:`RadioHeadEntry` is one ``fiber_ports[]`` row (map key + JSON object). :class:`RadioHead`
pairs that with a :class:`FiWiConcentrator` for power and inrush obtain via
:meth:`FiWiConcentrator.patch_panel` :meth:`~fiwi.patch_panel.BoundPatchPanel.head` / ``heads``.
"""
from __future__ import annotations
import json
import time
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional, Tuple
from fiwi import fiber_map_io as fm
from fiwi.ssh import SshNode
if TYPE_CHECKING:
from fiwi.concentrator import FiWiConcentrator
@dataclass(frozen=True)
class InrushSample:
"""Peak downstream current (mA) and approximate settle time (ms) over a short capture window."""
peak_ma: float
duration_ms: float
def as_json_dict(self) -> dict[str, float]:
return {"peak_ma": self.peak_ma, "duration_ms": self.duration_ms}
@classmethod
def from_json_text(cls, text: str) -> InrushSample:
d = json.loads(text.strip())
if not isinstance(d, dict):
raise ValueError("inrush JSON must be an object")
return cls(float(d["peak_ma"]), float(d["duration_ms"]))
@dataclass
class RadioHeadEntry:
"""
One ``fiber_ports[<map_key>]`` record from ``fiber_map.json`` (no BrainStem / SSH capability).
``data`` is the live dict from the document (mutations persist when the doc is saved).
"""
map_key: str
data: Optional[Dict[str, Any]]
@property
def port_id(self) -> Optional[int]:
"""Integer fiber id when ``map_key`` is decimal; else None."""
if self.map_key.isdigit():
return int(self.map_key)
return None
def hub_port(self) -> Optional[Tuple[int, int]]:
"""(hub_1based, port_0based) or None if unmapped / invalid."""
return fm.fiber_entry_hub_port(self.data)
def ssh_target(self) -> Optional[str]:
"""user@host when this heads hubs are reached via SSH; else None."""
return fm.fiber_ssh_target(self.data) if isinstance(self.data, dict) else None
def ssh_node(self) -> Optional[SshNode]:
"""Remote Fi-Wi host (``SshNode``) for this head, or None when mapped locally."""
t = self.ssh_target()
if not t:
return None
try:
return SshNode.parse(t)
except ValueError:
return None
def is_mapped(self) -> bool:
"""True when hub.port is valid in the entry."""
return self.hub_port() is not None
def chip_preview(self, width: int = 26) -> str:
return fm.stored_chip_preview(self.data) if isinstance(self.data, dict) else ""
def pcie_preview(self, width: int = 22) -> str:
return fm.stored_pcie_preview(self.data) if isinstance(self.data, dict) else ""
@property
def chip_type(self) -> Optional[str]:
"""Saved radio / USB chip label from the map (``chip_type``, wlan, or ``usb_id``), if any."""
d = self.data
if not isinstance(d, dict):
return None
for k in ("chip_type", "chip_label", "usb_id"):
v = d.get(k)
if isinstance(v, str) and v.strip():
return v.strip()
wlan = d.get("wlan")
if isinstance(wlan, dict):
prim = wlan.get("primary")
if isinstance(prim, dict):
for k in ("chip_label", "product"):
v = prim.get(k)
if isinstance(v, str) and v.strip():
return v.strip()
v = d.get("usb_description")
if isinstance(v, str) and v.strip():
return v.strip()
return None
@property
def radio_name(self) -> Optional[str]:
"""Optional human label for this strand / RRH (``fiber_ports[].name``)."""
d = self.data
if not isinstance(d, dict):
return None
v = d.get("name")
return v.strip() if isinstance(v, str) and v.strip() else None
@property
def radio_location(self) -> Optional[str]:
"""Optional physical strand / antenna location (``fiber_ports[].location``)."""
d = self.data
if not isinstance(d, dict):
return None
v = d.get("location")
return v.strip() if isinstance(v, str) and v.strip() else None
@classmethod
def from_map_key(cls, doc: Dict[str, Any], map_key: str) -> RadioHeadEntry:
ports = doc.get("fiber_ports") if isinstance(doc, dict) else None
if not isinstance(ports, dict):
return cls(str(map_key), None)
ent = ports.get(str(map_key))
return cls(
str(map_key),
ent if isinstance(ent, dict) else None,
)
@classmethod
def from_port_id(cls, doc: Dict[str, Any], port_id: int) -> RadioHeadEntry:
return cls.from_map_key(doc, str(int(port_id)))
@staticmethod
def each_from_document(doc: Dict[str, Any]) -> Iterator[RadioHeadEntry]:
"""All ``fiber_ports`` rows (sorted), including unmapped / empty values."""
ports = doc.get("fiber_ports") if isinstance(doc, dict) else None
if not isinstance(ports, dict):
return
for key in sorted(ports.keys(), key=fm.fiber_sort_key):
ent = ports[key]
yield RadioHeadEntry(
str(key),
ent if isinstance(ent, dict) else None,
)
def load_radio_head_entries(doc: Dict[str, Any]) -> List[RadioHeadEntry]:
"""All ``fiber_ports`` rows (sorted keys)."""
return list(RadioHeadEntry.each_from_document(doc))
@dataclass
class RadioHead:
"""
Map entry plus concentrator: USB hub power, inrush sampling, and SSH routing from the map row.
Live ``power`` / ``voltage`` / ``current`` read BrainStem (or ``port-metrics-json`` over SSH).
Values are cached until :meth:`refresh_live`, or after :meth:`power_on` / :meth:`power_off` /
:meth:`power_cycle` / :meth:`measure_inrush`.
``chip_type`` and ``patch_panel_port`` come from the map / panel binding when known.
"""
map_entry: RadioHeadEntry
concentrator: FiWiConcentrator
patch_panel_port: Optional[int] = None
"""1-based front-panel position when this head was resolved via :meth:`~fiwi.patch_panel.BoundPatchPanel.head`."""
_electrical_cache: Optional[Tuple[Optional[bool], Optional[float], Optional[float]]] = field(
default=None, init=False, repr=False
)
def _invalidate_electrical_cache(self) -> None:
self._electrical_cache = None
def refresh_live(self) -> None:
"""Clear cached ``power`` / ``voltage`` / ``current`` so the next read hits the hub again."""
self._invalidate_electrical_cache()
def _hub_port(self) -> Tuple[int, int]:
tup = self.map_entry.hub_port()
if tup is None:
raise ValueError("radio head is not mapped to hub.port")
return tup
@property
def chip_type(self) -> Optional[str]:
"""Chip / NIC identity from ``fiber_map.json`` (not a live probe)."""
return self.map_entry.chip_type
@property
def power(self) -> Optional[bool]:
"""Downstream port power: ``True`` ON, ``False`` OFF, ``None`` if unknown (no hub / parse error)."""
p, _v, _c = self._read_live_electrical()
return p
@property
def voltage(self) -> Optional[float]:
"""Port voltage in volts, if the hub reports it."""
_p, v, _c = self._read_live_electrical()
return v
@property
def current(self) -> Optional[float]:
"""Port current in milliamps (BrainStem µA-based), if the hub reports it."""
_p, _v, c = self._read_live_electrical()
return c
def _read_live_electrical(self) -> Tuple[Optional[bool], Optional[float], Optional[float]]:
if self._electrical_cache is not None:
return self._electrical_cache
if not self.map_entry.is_mapped():
self._electrical_cache = (None, None, None)
return self._electrical_cache
hub_1, port_0 = self._hub_port()
if self.map_entry.ssh_target():
node = self.map_entry.ssh_node()
if node is None:
return None, None, None
code, out, err = node.invoke_capture(["port-metrics-json"], defer=False, timeout=90)
if code != 0 or not (out or "").strip():
self._electrical_cache = (None, None, None)
return self._electrical_cache
try:
rows = json.loads(out.strip())
except json.JSONDecodeError:
self._electrical_cache = (None, None, None)
return self._electrical_cache
if not isinstance(rows, list):
self._electrical_cache = (None, None, None)
return self._electrical_cache
for r in rows:
if not isinstance(r, dict):
continue
if r.get("hub") == hub_1 and r.get("port") == port_0:
pwr = r.get("power")
on = True if pwr == "ON" else False if pwr == "OFF" else None
v_raw = r.get("voltage_v")
v = float(v_raw) if isinstance(v_raw, (int, float)) else None
c_raw = r.get("current_ma")
c = float(c_raw) if isinstance(c_raw, (int, float)) else None
self._electrical_cache = (on, v, c)
return self._electrical_cache
self._electrical_cache = (None, None, None)
return self._electrical_cache
row = self.concentrator.hub_port_metrics(hub_1, port_0)
if row is None:
self._electrical_cache = (None, None, None)
return self._electrical_cache
pwr = row.get("power")
on = True if pwr == "ON" else False if pwr == "OFF" else None
v_raw = row.get("voltage_v")
v = float(v_raw) if isinstance(v_raw, (int, float)) else None
c_raw = row.get("current_ma")
c = float(c_raw) if isinstance(c_raw, (int, float)) else None
self._electrical_cache = (on, v, c)
return self._electrical_cache
def power_on(self) -> None:
hub_1, port_0 = self._hub_port()
if self.map_entry.ssh_target():
node = self.map_entry.ssh_node()
if node is None:
raise ValueError("invalid ssh target in fiber map")
code, msg = node.remote_hub_port_power(hub_1, port_0, True, defer=False)
if code != 0:
raise RuntimeError(msg.strip() if msg else f"remote power on failed (exit {code})")
else:
self.concentrator.set_hub_port_power(hub_1, port_0, True)
self._invalidate_electrical_cache()
def power_off(self) -> None:
hub_1, port_0 = self._hub_port()
if self.map_entry.ssh_target():
node = self.map_entry.ssh_node()
if node is None:
raise ValueError("invalid ssh target in fiber map")
code, msg = node.remote_hub_port_power(hub_1, port_0, False, defer=False)
if code != 0:
raise RuntimeError(msg.strip() if msg else f"remote power off failed (exit {code})")
else:
self.concentrator.set_hub_port_power(hub_1, port_0, False)
self._invalidate_electrical_cache()
def power_cycle(self, off_s: float = 0.25) -> None:
self.power_off()
time.sleep(off_s)
self.power_on()
def measure_inrush(self, *, sample_duration: float = 0.3) -> InrushSample:
hub_1, port_0 = self._hub_port()
if self.map_entry.ssh_target():
node = self.map_entry.ssh_node()
if node is None:
raise ValueError("invalid ssh target in fiber map")
code, out, err = node.invoke_capture(
["hub-inrush-json", f"{hub_1}.{port_0}"],
defer=False,
timeout=90,
)
blob = (out or "").strip()
if code != 0:
raise RuntimeError((err or blob or f"remote hub-inrush-json exit {code}").strip())
self._invalidate_electrical_cache()
return InrushSample.from_json_text(blob)
sample = self.concentrator.sample_inrush_hub_port(
hub_1, port_0, sample_duration=sample_duration
)
self._invalidate_electrical_cache()
return sample

620
fiwi/site_setup.py Normal file
View File

@ -0,0 +1,620 @@
"""
**Primary operator entry** for Fi-Wi on a rig: site metadata, fiber map layout, optional
USB power baseline, optional hub relay bootstrap, optional panel calibrate usually as::
site_setup.py -c uax24
Optional ``--relay`` / ``--calibrate`` only **trigger** those steps; relay **hosts** come from
``config/*.ini`` (``[remote_hubs] hosts``) and env. ``--relay`` runs
:class:`~fiwi.fiwi_relay.relay_app.FiWiRelayBootstrapApp` in-process (same as ``python -m fiwi.fiwi_relay``).
``--calibrate`` runs :func:`fiwi.cli.run_panel_calibrate` in-process.
``--ssh`` is only for **extra** calibrate targets beyond ``calibrate_remotes`` in the map / profile.
Writes the fiber map (default ``maps/fiber_map.json`` under the install root) keys:
* ``fiwi_site`` ``concentrator_name``, ``concentrator_location``
* ``patch_panel`` ``slots``, ``label`` (panel name), ``location``
* ``calibrate_remotes`` ``user@host`` list for hybrid panel calibrate
Interactive setup can optionally **power down** local downstream USB ports and run ``fiwi off all``
on configured SSH hub hosts **after** saving the map, only when you are **not** continuing to panel
calibrate (``panel calibrate`` repeats that baseline itself).
``calibrate_remotes`` defaults from the active INI profile (e.g. ``[remote_hubs] hosts`` in
``config/uax24.ini``) when you use ``site_setup.py -c uax24``.
Per-strand labels use optional ``fiber_ports[<id>].name`` and ``.location`` (see
:class:`~fiwi.radiohead.RadioHeadEntry`).
"""
from __future__ import annotations
import argparse
import os
import shutil
import socket
import sys
from datetime import datetime
from typing import TYPE_CHECKING, Any, Dict, List, Tuple
from fiwi import fiber_map_io as fm
from fiwi.paths import fiber_map_path
from fiwi.patch_panel import BoundPatchPanel, PatchPanel, default_panel_ports
if TYPE_CHECKING:
from fiwi.concentrator import FiWiConcentrator
from fiwi.radiohead import RadioHead
FIWI_SITE_KEY = "fiwi_site"
def _fiwi_repo_root() -> str:
"""Directory containing ``fiwi.py`` (parent of package ``fiwi``)."""
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def merge_calibrate_remotes(doc: Dict[str, Any], hosts: List[str]) -> Dict[str, Any]:
"""Set ``calibrate_remotes`` to deduped ``user@host`` list, or remove key if empty."""
doc = fm.ensure_fiber_map_document(doc)
seen: List[str] = []
for h in hosts:
s = (h or "").strip()
if s and "@" in s and s not in seen:
seen.append(s)
if seen:
doc["calibrate_remotes"] = seen
elif "calibrate_remotes" in doc:
del doc["calibrate_remotes"]
return doc
def read_fiwi_site(doc: Dict[str, Any]) -> Dict[str, str]:
"""Return ``fiwi_site`` object from a map document, or empty dict."""
raw = doc.get(FIWI_SITE_KEY)
if not isinstance(raw, dict):
return {}
out: Dict[str, str] = {}
for k in ("concentrator_name", "concentrator_location"):
v = raw.get(k)
if isinstance(v, str) and v.strip():
out[k] = v.strip()
return out
def merge_fiwi_site(
doc: Dict[str, Any],
*,
concentrator_name: str,
concentrator_location: str,
) -> Dict[str, Any]:
"""Attach ``fiwi_site``; empty strings omit that field (existing key removed if both empty)."""
doc = fm.ensure_fiber_map_document(doc)
site: Dict[str, str] = {}
if concentrator_name.strip():
site["concentrator_name"] = concentrator_name.strip()
if concentrator_location.strip():
site["concentrator_location"] = concentrator_location.strip()
if site:
doc[FIWI_SITE_KEY] = site
elif FIWI_SITE_KEY in doc:
del doc[FIWI_SITE_KEY]
return doc
def merge_patch_panel_meta(
doc: Dict[str, Any],
*,
slots: int,
label: str,
location: str,
) -> Dict[str, Any]:
"""Merge ``patch_panel`` slots / name / location; preserves unrelated keys in the blob."""
doc = fm.ensure_fiber_map_document(doc)
if slots < 1 or slots > 256:
raise ValueError("patch panel slots must be 1256")
pp = PatchPanel(slots=slots, label=label.strip(), location=location.strip())
blob: Dict[str, Any] = {}
prev = doc.get("patch_panel")
if isinstance(prev, dict):
blob.update(prev)
blob.update(pp.to_map_blob())
doc["patch_panel"] = blob
return doc
def open_fiwi_stack() -> Tuple["FiWiConcentrator", BoundPatchPanel, List["RadioHead"]]:
"""
Load ``fiber_map.json``, open a :class:`~fiwi.concentrator.FiWiConcentrator`, bound patch panel,
and all mapped :class:`~fiwi.radiohead.RadioHead` instances (panel order, then off-panel fiber ids).
Raises:
FileNotFoundError: if ``fiber_map.json`` is missing.
"""
from fiwi.concentrator import FiWiConcentrator
from fiwi.radiohead import RadioHead, RadioHeadEntry
doc = fm.load_fiber_map_document()
if doc is None:
raise FileNotFoundError(
f"Missing {fiber_map_path()}. Run site_setup.py or copy fiber_map.example.json."
)
c = FiWiConcentrator()
bound = c.patch_panel(doc)
heads: List[RadioHead] = list(bound.heads())
seen = {h.map_entry.map_key for h in heads}
for ent in RadioHeadEntry.each_from_document(doc):
if ent.is_mapped() and ent.map_key not in seen:
heads.append(RadioHead(ent, c))
seen.add(ent.map_key)
def _sort_key(h: RadioHead) -> tuple:
p = h.patch_panel_port
if p is not None:
return (0, p, h.map_entry.map_key)
mk = h.map_entry.map_key
if mk.isdigit():
return (1, int(mk), mk)
return (2, mk, mk)
heads.sort(key=_sort_key)
return c, bound, heads
def _prompt(default: str, label: str) -> str:
try:
raw = input(f" {label} [{default}]: ").strip()
except EOFError:
raw = ""
return raw if raw else default
def _prompt_usb_power_down(*, remotes: List[str], has_local_hubs: bool) -> bool:
bits: List[str] = []
if has_local_hubs:
bits.append("every downstream port on connected local USB hubs")
if remotes:
bits.append("`fiwi off all` on " + ", ".join(remotes))
print(
"\n--- USB power baseline ---\n\n"
"Turn OFF " + " and ".join(bits) + "\n"
" (You are not running panel calibrate this session; calibrate would do this baseline for you.)\n",
flush=True,
)
try:
raw = input(" Power down now? [Y/n]: ").strip().lower()
except EOFError:
return True
return raw not in ("n", "no")
def _run_early_usb_power_down_if_requested(doc: Dict[str, Any], args: argparse.Namespace) -> None:
if args.no_power_down:
return
from fiwi.concentrator import FiWiConcentrator
remotes = list(fm.resolve_calibrate_ssh_targets(doc, extra_cli_hosts=()).hosts)
c = FiWiConcentrator()
try:
if not c.hubs:
c.connect(quiet=True)
has_local = bool(c.hubs)
if not has_local and not remotes:
print(
"\n(USB power baseline skipped: no local hubs opened and no SSH hub hosts in profile/map.)\n",
flush=True,
)
return
if not _prompt_usb_power_down(remotes=remotes, has_local_hubs=has_local):
print(" Skipping USB power-down.\n", flush=True)
return
c.power_down_all_downstream_ports(remotes, context="Site setup")
print(" USB baseline done.\n", flush=True)
finally:
c.disconnect()
def _timestamped_fiber_map_backup_path(original: str) -> str:
"""``fiber_map.json`` → ``fiber_map.json.20260403T143022`` (suffix if collision)."""
d = os.path.dirname(os.path.abspath(original))
base = os.path.basename(original)
stamp = datetime.now().strftime("%Y%m%dT%H%M%S")
candidate = os.path.join(d, f"{base}.{stamp}")
if not os.path.exists(candidate):
return candidate
for i in range(2, 10_000):
alt = os.path.join(d, f"{base}.{stamp}.{i}")
if not os.path.exists(alt):
return alt
raise OSError("could not allocate a unique backup filename")
def _prompt_existing_map_action(path: str) -> str:
"""
Ask what to do when ``fiber_map.json`` already exists.
Returns:
``\"merge\"`` — load and update metadata only.
``\"delete\"`` — move file to a timestamped backup and start ``{\"fiber_ports\": {}}``.
``\"exit\"`` — caller should exit without writing.
"""
print(
f"\n{path} already exists.\n\n"
" [m] Merge — keep fiber_ports and everything else; update only\n"
" fiwi_site, patch_panel, and calibrate_remotes\n"
" [d] New map — move this file to a timestamped backup, then start fresh\n"
" [q] Quit — exit without changing anything\n",
flush=True,
)
while True:
try:
raw = input(" Continue? [m/d/q] (default: m): ").strip().lower()
except EOFError:
return "exit"
if not raw or raw in ("m", "merge"):
return "merge"
if raw in ("d", "delete", "fresh"):
return "delete"
if raw in ("q", "quit", "exit", "x"):
return "exit"
print(" Enter m, d, or q.", flush=True)
def _run_interactive(args: argparse.Namespace) -> Dict[str, Any]:
from fiwi.config import resolved_config_path
from fiwi import paths as paths_mod
from fiwi.ssh import SshNodeConfig
ini_path = resolved_config_path(paths_mod.base_dir())
prof = (os.environ.get("FIWI_CONFIG") or "").strip()
if ini_path:
print(
f"Active profile: {ini_path} (FIWI_CONFIG={prof or 'default'})\n",
flush=True,
)
elif prof:
print(
f"FIWI_CONFIG={prof!r} — no matching config/*.ini; using env / code defaults only.\n",
flush=True,
)
path = fiber_map_path()
exists = os.path.isfile(path)
if exists:
action = _prompt_existing_map_action(path)
if action == "exit":
raise SystemExit(0)
if action == "delete":
backup = _timestamped_fiber_map_backup_path(path)
try:
shutil.move(path, backup)
except OSError as exc:
print(f"Could not move {path}{backup}: {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
exists = False
print(f"Moved {path}{backup}\nStarting a new map.\n", flush=True)
doc = fm.load_fiber_map_document() if exists else {"fiber_ports": {}}
doc = fm.ensure_fiber_map_document(doc)
site = read_fiwi_site(doc)
pp = PatchPanel.from_map_blob(doc.get("patch_panel"))
def_cn = site.get("concentrator_name", args.concentrator_name or "") or socket.gethostname()
def_cl = site.get("concentrator_location", args.concentrator_location or "")
def_slots = pp.slots if pp is not None else default_panel_ports()
def_plab = (pp.label if pp else "") or args.panel_name or ""
def_ploc = (pp.location if pp else "") or args.panel_location or ""
print("\n--- Fi-Wi site (concentrator / this machine) ---\n", flush=True)
cn = _prompt(def_cn, "Concentrator name (e.g. rack or hostname label)")
cl = _prompt(def_cl, "Concentrator location (e.g. lab, datacenter row)")
print("\n--- Patch panel ---\n", flush=True)
slots_s = _prompt(str(def_slots), "Number of front-panel positions (slots)")
try:
slots = int(slots_s)
except ValueError:
print(" Invalid slot count.", file=sys.stderr, flush=True)
raise SystemExit(2) from None
plab = _prompt(def_plab, "Patch panel name / label")
ploc = _prompt(def_ploc, "Patch panel location (e.g. bay, room)")
print("\n--- Hub hosts (hybrid panel calibrate) ---\n", flush=True)
ssh_cfg = SshNodeConfig.load()
from_profile: List[str] = []
if ssh_cfg.calibrate_remotes:
from_profile = [
x.strip()
for x in ssh_cfg.calibrate_remotes.split(",")
if x.strip() and "@" in x
]
if from_profile and ini_path:
print(
f" Discovered from profile: {', '.join(from_profile)}\n",
flush=True,
)
elif from_profile:
print(
f" Discovered from environment: {', '.join(from_profile)}\n",
flush=True,
)
existing_cr = fm.calibrate_remotes_hosts(doc)
if existing_cr:
def_rel = ",".join(existing_cr)
elif from_profile:
def_rel = ",".join(from_profile)
else:
def_rel = ""
rel_raw = _prompt(
def_rel,
"Hub SSH targets (comma-separated user@host); Enter=default; -=omit",
)
if rel_raw.strip() == "-":
relay_hosts: List[str] = []
elif rel_raw.strip():
relay_hosts = [
x.strip()
for x in rel_raw.split(",")
if x.strip() and "@" in x
]
else:
relay_hosts = [
x.strip()
for x in def_rel.split(",")
if x.strip() and "@" in x
]
doc = merge_fiwi_site(doc, concentrator_name=cn, concentrator_location=cl)
doc = merge_patch_panel_meta(doc, slots=slots, label=plab, location=ploc)
doc = merge_calibrate_remotes(doc, relay_hosts)
return doc
def _run_batch(args: argparse.Namespace) -> Dict[str, Any]:
path = fiber_map_path()
exists = os.path.isfile(path)
if exists and not args.merge:
print(
f"{path} exists; use --merge for batch updates.",
file=sys.stderr,
flush=True,
)
raise SystemExit(2)
doc = fm.load_fiber_map_document() if exists else {"fiber_ports": {}}
doc = fm.ensure_fiber_map_document(doc)
if args.slots is None or args.slots < 1:
print("--batch requires --slots N (>= 1).", file=sys.stderr, flush=True)
raise SystemExit(2)
doc = merge_fiwi_site(
doc,
concentrator_name=args.concentrator_name or "",
concentrator_location=args.concentrator_location or "",
)
doc = merge_patch_panel_meta(
doc,
slots=int(args.slots),
label=args.panel_name or "",
location=args.panel_location or "",
)
return doc
def _profile_argv_prefix() -> List[str]:
prof = (os.environ.get("FIWI_CONFIG") or "").strip()
return ["-c", prof] if prof else []
def build_site_setup_argument_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
description=(
"Fi-Wi site setup — map metadata, optional chained relay + calibrate. "
"Relay SSH targets are read from the INI (e.g. [remote_hubs] hosts); "
"--relay/--calibrate only choose whether to run those steps after saving the map. "
"Run from the install directory next to fiwi.py; use -c PROFILE."
),
)
p.add_argument(
"-c",
"--config",
metavar="PROFILE",
help=(
"INI profile or path (sets FIWI_CONFIG before loading paths — e.g. uax24 → "
"patch_panel defaults, remote_hubs hosts for calibrate_remotes prompt)"
),
)
p.add_argument(
"--merge",
action="store_true",
help="Required with --batch when fiber_map.json already exists (interactive uses m/d/q prompt).",
)
p.add_argument(
"--relay",
action="store_true",
help=(
"After saving the map, run fiwi relay bootstrap in-process (FiWiRelayBootstrapApp; "
"same -c profile). Uses [remote_hubs] / env from config — this flag does not set hosts."
),
)
p.add_argument(
"--calibrate",
action="store_true",
help=(
"After saving the map (and after --relay if given), run panel calibrate merge in-process "
"(fiwi.cli.run_panel_calibrate) plus any --ssh. "
"Interactive runs also ask [Y/n] to calibrate unless --no-calibrate-prompt."
),
)
p.add_argument(
"--batch",
action="store_true",
help="Non-interactive: supply --slots and optional name/location strings",
)
p.add_argument("--concentrator-name", default="", metavar="STR")
p.add_argument("--concentrator-location", default="", metavar="STR")
p.add_argument("--panel-name", default="", metavar="STR")
p.add_argument("--panel-location", default="", metavar="STR")
p.add_argument("--slots", type=int, default=None, metavar="N")
p.add_argument(
"--ssh",
action="append",
default=[],
metavar="user@host",
help="Only with --calibrate: extra --ssh user@host for panel calibrate (repeatable)",
)
p.add_argument(
"--no-power-down",
action="store_true",
help=(
"Skip the optional USB power-down prompt when not calibrating (interactive only; "
"redundant with panel calibrates baseline if you use --calibrate)"
),
)
p.add_argument(
"--no-calibrate-prompt",
action="store_true",
help=(
"After saving the map, do not ask to start panel calibrate (interactive only); "
"use --calibrate to run it without a prompt."
),
)
return p
class SiteSetupApp:
"""Orchestrates fiber map metadata, optional USB power-down, relay bootstrap, panel calibrate."""
def __init__(self, args: argparse.Namespace) -> None:
self._args = args
def run(self) -> int:
import fiwi.paths as paths_mod
args = self._args
paths_mod.configure(_fiwi_repo_root())
if args.batch:
doc = _run_batch(args)
else:
if not sys.stdin.isatty():
print(
"site_setup: need a TTY for prompts, or use --batch with --slots.",
file=sys.stderr,
flush=True,
)
return 2
doc = _run_interactive(args)
out_path = fiber_map_path()
fm.write_fiber_map_document(doc)
print(f"\nWrote {out_path}", flush=True)
site = read_fiwi_site(doc)
pp = PatchPanel.from_map_blob(doc.get("patch_panel"))
if site:
print(
f" fiwi_site: {site.get('concentrator_name', '')!r} @ "
f"{site.get('concentrator_location', '')!r}",
flush=True,
)
if pp:
print(
f" patch_panel: {pp.slots} ports, name={pp.label or ''!r}, "
f"location={pp.location or ''!r}",
flush=True,
)
cr = fm.calibrate_remotes_hosts(doc)
if cr:
print(f" calibrate_remotes: {', '.join(cr)}", flush=True)
map_p = fiber_map_path()
prof = (os.environ.get("FIWI_CONFIG") or "").strip()
ex_prof = prof if prof else "uax24"
do_calibrate = bool(args.calibrate)
if (
not args.batch
and sys.stdin.isatty()
and not do_calibrate
and not args.no_calibrate_prompt
):
try:
ans = input(
"\nStart panel calibrate now (merge from map)? [Y/n]: "
).strip().lower()
except EOFError:
ans = "n"
if not ans or ans in ("y", "yes"):
do_calibrate = True
if not do_calibrate:
if not args.batch and sys.stdin.isatty():
_run_early_usb_power_down_if_requested(doc, args)
print(
"\nStopped after saving the map (no calibrate this run).\n"
"Optional chained steps — relay hosts come from config/ INI, not the CLI:\n"
f" site_setup.py -c {ex_prof} --relay\n"
f" site_setup.py -c {ex_prof} --calibrate\n"
f" site_setup.py -c {ex_prof} --relay --calibrate [--ssh user@host …]\n"
f"If calibrate skips the Pi: check FIWI_FIBER_MAP vs {map_p}; "
f"bootstrap with --relay if needed.\n"
"Per-strand labels: fiber_ports[\"<id>\"].name / .location\n",
flush=True,
)
else:
print(
"\nContinuing to panel calibrate…\n"
"(With `merge`, fiwi asks once to confirm patch panel from the map — Enter to keep, "
"or `e` to change slots/label/location.)\n",
flush=True,
)
root = _fiwi_repo_root()
if args.relay:
if not sys.stdin.isatty():
print("site_setup: --relay needs a TTY.", file=sys.stderr, flush=True)
return 2
from fiwi.fiwi_relay.relay_app import FiWiRelayBootstrapApp
relay_argv = _profile_argv_prefix()
print(
"\nRunning fiwi relay bootstrap (in-process"
+ (f"; {' '.join(relay_argv)}" if relay_argv else "")
+ ")…\n",
flush=True,
)
relay_rc = FiWiRelayBootstrapApp(argv=relay_argv, install_root=root).run()
if relay_rc != 0:
return relay_rc
if do_calibrate:
if not sys.stdin.isatty():
print("site_setup: panel calibrate needs a TTY.", file=sys.stderr, flush=True)
return 2
from fiwi.cli import run_panel_calibrate
extra_ssh = [str(h).strip() for h in (args.ssh or []) if str(h).strip()]
print(
"\nRunning panel calibrate (in-process, merge=True)"
+ (f"; extra --ssh: {', '.join(extra_ssh)}" if extra_ssh else "")
+ ".\n",
flush=True,
)
return run_panel_calibrate(
merge=True,
limit=None,
calibrate_ssh_hosts=extra_ssh,
emit_start_line=True,
)
return 0
def main(argv: List[str] | None = None) -> int:
args = build_site_setup_argument_parser().parse_args(argv)
if args.config:
os.environ["FIWI_CONFIG"] = args.config.strip()
return SiteSetupApp(args).run()
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -5,7 +5,7 @@ import sys
from fiwi.patch_panel import effective_panel_slots from fiwi.patch_panel import effective_panel_slots
from fiwi import fiber_map_io as fm from fiwi import fiber_map_io as fm
from fiwi.fiber_radio_port import FiberRadioPort from fiwi.radiohead import RadioHeadEntry
from fiwi.ssh import SshNode, apply_fiwi_ssh_env from fiwi.ssh import SshNode, apply_fiwi_ssh_env
@ -32,7 +32,7 @@ def dispatch_fiber_mapped_ssh_if_needed(argv):
fid = int(argv[3]) fid = int(argv[3])
except ValueError: except ValueError:
return None return None
node = FiberRadioPort.from_port_id(doc, fid).ssh_node() node = RadioHeadEntry.from_port_id(doc, fid).ssh_node()
if node: if node:
return node.invoke( return node.invoke(
["power", "fiber-port", str(fid), argv[4].lower()], ["power", "fiber-port", str(fid), argv[4].lower()],
@ -44,7 +44,7 @@ def dispatch_fiber_mapped_ssh_if_needed(argv):
fid = int(argv[2]) fid = int(argv[2])
except ValueError: except ValueError:
return None return None
node = FiberRadioPort.from_port_id(doc, fid).ssh_node() node = RadioHeadEntry.from_port_id(doc, fid).ssh_node()
if node: if node:
print( print(
"fiber chip: not forwarded over SSH — radios are PCIe/fiber; use panel calibrate PCIe prompts " "fiber chip: not forwarded over SSH — radios are PCIe/fiber; use panel calibrate PCIe prompts "
@ -64,7 +64,7 @@ def dispatch_fiber_mapped_ssh_if_needed(argv):
n_slots = effective_panel_slots(doc) n_slots = effective_panel_slots(doc)
if pn < 1 or pn > n_slots: if pn < 1 or pn > n_slots:
return None return None
node = FiberRadioPort.from_port_id(doc, pn).ssh_node() node = RadioHeadEntry.from_port_id(doc, pn).ssh_node()
if node: if node:
return node.invoke(["panel", sub, str(pn)], defer=False) return node.invoke(["panel", sub, str(pn)], defer=False)

View File

@ -1,28 +0,0 @@
#include <aUSBHub3p.reflex>
// Create a reference to the module
aUSBHub3p hub;
// Map scratchpad locations
// pad[offset : length]
pad[0:4] signed int peak;
pad[4:4] signed int duration;
reflex every_1ms() {
// Current in micro-amps
signed int current = hub.usb.getPortCurrent(4);
if (current > peak) {
peak = current;
}
// If drawing more than 50mA, increment duration counter
if (current > 50000) {
duration++;
}
}
reflex mapEnable() {
peak = 0;
duration = 0;
}

View File

@ -18,7 +18,8 @@ FIWI_REMOTE_SCRIPT=~/Code/FiWiManager/fiwi.py
# #
# Optional SSH client tweaks: # Optional SSH client tweaks:
# FIWI_SSH_BIN=ssh # FIWI_SSH_BIN=ssh
# FIWI_SSH_OPTS=-o BatchMode=yes # Non-interactive panel calibrate uses captured SSH (no password prompt). Use keys + optional:
# FIWI_SSH_OPTS=-o BatchMode=yes -o ConnectTimeout=15
# #
# Optional: deferred remote calls (spawn ssh children; panel calibrate overlaps SSH via Popen): # Optional: deferred remote calls (spawn ssh children; panel calibrate overlaps SSH via Popen):
# FIWI_REMOTE_DEFER=1 # FIWI_REMOTE_DEFER=1
@ -31,6 +32,6 @@ FIWI_REMOTE_SCRIPT=~/Code/FiWiManager/fiwi.py
# #
# Remote BrainStem hosts (comma-separated user@host); merged with FIWI_CALIBRATE_REMOTES for panel calibrate: # Remote BrainStem hosts (comma-separated user@host); merged with FIWI_CALIBRATE_REMOTES for panel calibrate:
# FIWI_REMOTE_HUBS=rjmcmahon@192.168.1.39 # FIWI_REMOTE_HUBS=rjmcmahon@192.168.1.39
# FIWI_FIBER_MAP=fiber_map.json # FIWI_FIBER_MAP=maps/fiber_map.json
# FIWI_DEFAULT_PANEL_PORTS=24 # FIWI_DEFAULT_PANEL_PORTS=24
# FIWI_DEFAULT_PANEL_SLOTS=24 ; legacy alias, same meaning # FIWI_DEFAULT_PANEL_SLOTS=24 ; legacy alias, same meaning

7
site_setup.py Normal file
View File

@ -0,0 +1,7 @@
#!/usr/bin/env python3
"""CLI entry: Fi-Wi site_setup — map + optional relay + optional calibrate (see fiwi.site_setup)."""
from fiwi.site_setup import main
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -29,6 +29,10 @@ and ``port-metrics-json``; the remote tree must include that command (same revis
``FIWI_CALIBRATE_REMOTES`` / merged hub hosts: all ports OFF (verify), then all ON (verify), ``FIWI_CALIBRATE_REMOTES`` / merged hub hosts: all ports OFF (verify), then all ON (verify),
then prints the port power table (snapshot after the test). then prints the port power table (snapshot after the test).
After the per-port power table, the script builds a :class:`fiwi.radiohead.RadioHead` for every
mapped ``fiber_ports`` row (panel slots first, then any other keys) and prints panel, saved chip
type, and live power / mA / V.
``--inrush HUB PORT`` (after the report, or after ``--powercycle``) runs the same USB inrush probe as ``--inrush HUB PORT`` (after the report, or after ``--powercycle``) runs the same USB inrush probe as
``tests/check_inrush.py`` on **local** hubs only (1-based hub index, 0-based port). Use ``tests/check_inrush.py`` on **local** hubs only (1-based hub index, 0-based port). Use
``--inrush-host-sample`` for host-side polling; default is on-hub Reflex scratchpad. ``--inrush-host-sample`` for host-side polling; default is on-hub Reflex scratchpad.
@ -908,6 +912,109 @@ def _print_per_port_power_table(
return rc return rc
_RADIO_HEADS_HDR = (
f"{'Panel':<8} | {'chip_type (map)':<28} | {'Pwr':<5} | {'mA':>10} | {'V':>8} | {'Hub.Pt':<8}"
)
def _print_radio_heads_section(c: FiWiConcentrator) -> None:
"""
Walk every mapped radio head: panel-bound via :meth:`~fiwi.concentrator.FiWiConcentrator.patch_panel`
``.heads()``, then any other mapped ``fiber_ports`` rows (off-panel fiber ids).
Uses :class:`fiwi.radiohead.RadioHead` ``power`` / ``current`` / ``voltage`` (BrainStem or SSH
``port-metrics-json`` per head).
"""
from fiwi import fiber_map_io as fm
from fiwi.radiohead import RadioHead, RadioHeadEntry
print("Radio heads (all mapped fiber_ports → RadioHead)", flush=True)
print("-" * len(_RADIO_HEADS_HDR), flush=True)
doc = fm.load_fiber_map_document()
if not doc:
print(" (no fiber_map.json — skip.)", flush=True)
print(flush=True)
return
try:
from fiwi.site_setup import read_fiwi_site
site = read_fiwi_site(doc)
if site:
cn = site.get("concentrator_name") or ""
cl = site.get("concentrator_location") or ""
print(
f" Fi-Wi concentrator: {cn} | Location: {cl}",
flush=True,
)
bound = c.patch_panel(doc)
pp = bound.panel
name_s = pp.label.strip() if pp.label.strip() else ""
loc_s = pp.location.strip() if pp.location.strip() else ""
print(
f" Patch panel name: {name_s} | Location: {loc_s} | Ports: {pp.slots}",
flush=True,
)
print(flush=True)
heads: list[RadioHead] = list(bound.heads())
seen_map_keys = {rh.map_entry.map_key for rh in heads}
for ent in RadioHeadEntry.each_from_document(doc):
if not ent.is_mapped() or ent.map_key in seen_map_keys:
continue
heads.append(RadioHead(ent, c))
seen_map_keys.add(ent.map_key)
def _rh_sort_key(rh: RadioHead) -> tuple:
p = rh.patch_panel_port
if p is not None:
return (0, p, rh.map_entry.map_key)
mk = rh.map_entry.map_key
if mk.isdigit():
return (1, int(mk), mk)
return (2, mk, mk)
heads.sort(key=_rh_sort_key)
except (OSError, ValueError) as exc:
print(f" ! Could not load patch panel / map: {exc}", flush=True)
print(flush=True)
return
if not heads:
print(" (no mapped fiber_ports entries.)", flush=True)
print(flush=True)
return
print(_RADIO_HEADS_HDR, flush=True)
print("-" * len(_RADIO_HEADS_HDR), flush=True)
for rh in heads:
panel_s = str(rh.patch_panel_port) if rh.patch_panel_port is not None else ""
chip = rh.chip_type or ""
if len(chip) > 28:
chip = chip[:25] + "..."
hp = rh.map_entry.hub_port()
hub_pt = f"{hp[0]}.{hp[1]}" if hp else ""
rh.refresh_live()
pwr_b = rh.power
if pwr_b is True:
pwr_s = "ON"
elif pwr_b is False:
pwr_s = "OFF"
else:
pwr_s = "?"
cur = rh.current
ma_s = f"{cur:.2f}" if isinstance(cur, (int, float)) else ""
vv = rh.voltage
v_s = f"{vv:.3f}" if isinstance(vv, (int, float)) else ""
print(
f"{panel_s:<8} | {chip:<28} | {pwr_s:<5} | {ma_s:>10} | {v_s:>8} | {hub_pt:<8}",
flush=True,
)
print(flush=True)
def _print_usb_hub_tables(c: FiWiConcentrator, hub_rows: list[ConsolidatedHubRow], base_rc: int) -> int: def _print_usb_hub_tables(c: FiWiConcentrator, hub_rows: list[ConsolidatedHubRow], base_rc: int) -> int:
"""Print consolidated hub table and port power / current / voltage table. Returns updated rc.""" """Print consolidated hub table and port power / current / voltage table. Returns updated rc."""
rc = base_rc rc = base_rc
@ -1125,6 +1232,7 @@ def _print_consolidated_report(c: FiWiConcentrator) -> int:
hub_rows, remote_rc = _build_consolidated_hub_rows(c) hub_rows, remote_rc = _build_consolidated_hub_rows(c)
hub_rows = _enrich_hub_rows_usb(hub_rows) hub_rows = _enrich_hub_rows_usb(hub_rows)
remote_rc = _print_usb_hub_tables(c, hub_rows, remote_rc) remote_rc = _print_usb_hub_tables(c, hub_rows, remote_rc)
_print_radio_heads_section(c)
_print_pcie_catalog_section() _print_pcie_catalog_section()
return remote_rc return remote_rc
@ -1376,6 +1484,7 @@ def main() -> int:
remote_fail = remote_fail or brc remote_fail = remote_fail or brc
hub_rows = _enrich_hub_rows_usb(hub_rows) hub_rows = _enrich_hub_rows_usb(hub_rows)
remote_fail = _print_per_port_power_table(c, hub_rows, remote_fail) remote_fail = _print_per_port_power_table(c, hub_rows, remote_fail)
_print_radio_heads_section(c)
else: else:
remote_fail = _print_consolidated_report(c) remote_fail = _print_consolidated_report(c)
if args.inrush is not None: if args.inrush is not None: