Add Reflex CLI, inrush Reflex program, and hub stem discovery

- Wire fiwi.py reflex (help/doc/which/compile) through reflex_lang.py; discover arc and Reflex includes from BRAINSTEM2_DEV_ROOT and repo brainstem_sdk paths.
- Add reflex/inrush.reflex, compile script, reflex/.gitignore for generated .map files.
- Add tests/check_inrush.py; extend tests/check_concentrator.py.
- FiWiConcentrator: prefer defs.model_name(spec.model) stem class, then legacy MODEL_* order, then alternate 3p/3c/2x4 retries.
- Add fiwi_link/fiwi_relay package entrypoint stub.

Made-with: Cursor
This commit is contained in:
Robert McMahon 2026-04-03 15:54:26 -07:00
parent 0b4de5ba67
commit a488dc2ed0
10 changed files with 1002 additions and 38 deletions

View File

@ -112,6 +112,8 @@ def _print_cli_help() -> None:
" --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"
" reflex help|doc|which|compile … — on-device Reflex: compile .reflex→.map via SDK arc\n"
" (no Python brainstem import; set BRAINSTEM2_DEV_ROOT / FIWI_REFLEX_INCLUDE; load .map in HubTool)\n"
" 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"
@ -188,6 +190,11 @@ def main() -> int:
return 0 return 0
pos = ga.positional pos = ga.positional
if pos and pos[0].lower() == "reflex":
from fiwi.reflex_lang import cli_main as _reflex_cli_main
return _reflex_cli_main(pos[1:])
if ga.remote_host is not None: if ga.remote_host is not None:
if not pos: if not pos:
print( print(
@ -373,7 +380,7 @@ def main() -> int:
else: else:
print(f"Unknown command: {cmd!r}", file=sys.stderr, flush=True) print(f"Unknown command: {cmd!r}", file=sys.stderr, flush=True)
print( print(
"Try: --ssh user@host … | discover | show_hostcards | port-metrics-json | usb-hub-tty-json | show_radioheads | calibrate-ports-json | … | help", "Try: --ssh user@host … | reflex … | discover | show_hostcards | port-metrics-json | usb-hub-tty-json | show_radioheads | calibrate-ports-json | … | help",
file=sys.stderr, file=sys.stderr,
flush=True, flush=True,
) )

View File

@ -25,8 +25,10 @@ from fiwi.ieee80211_dev import discover_wireless_for_map, wlan_chip_and_interfac
class FiWiConcentrator: class FiWiConcentrator:
""" """
Main Fi-Wi object: BrainStem-driven USB power-control hub plane, ``FiberRadioPort`` / Main Fi-Wi object: BrainStem-driven USB power-control hub plane (host Python API). On-device
:class:`fiwi.ssh.SshNode` routing, and calibration. **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.
``FiberRadioPort`` / :class:`fiwi.ssh.SshNode` routing, and calibration.
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
@ -54,35 +56,75 @@ class FiWiConcentrator:
specs.sort(key=lambda x: x.serial_number) specs.sort(key=lambda x: x.serial_number)
return specs return specs
@staticmethod
def _unique_stem_class_order(order: list[type], cls: type | None) -> None:
if cls is not None and isinstance(cls, type) and cls not in order:
order.append(cls)
@staticmethod
def _stem_class_from_brainstem_model(spec) -> type | None:
"""
Map discovery ``spec.model`` ``brainstem.stem.<Name>`` via ``defs.model_name``.
When Acroname adds a new USB hub model, a matching ``MODEL_*`` + ``model_name`` in the
installed BrainStem wheel lets FiWi try the correct ``stem.<Name>`` class first.
"""
stem_pkg = stemmod.brainstem
defs = getattr(stem_pkg, "defs", None)
stem_ns = stem_pkg.stem
model = getattr(spec, "model", None)
if defs is None or model is None:
return None
try:
mn = defs.model_name(model)
except Exception:
return None
if not mn or mn == "Unknown":
return None
cls = getattr(stem_ns, mn, None)
return cls if isinstance(cls, type) else None
@staticmethod @staticmethod
def _hub_stem_classes_for_spec(spec, alternate=False): def _hub_stem_classes_for_spec(spec, alternate=False):
""" """
Pick stem class for hardware. USBHub2x4 vs USBHub3p must match the device or Pick stem class order for ``connectFromSpec``. Wrong class vs hardware fails the connect;
connectFromSpec fails (HubTool handles this; raw USBHub3p() does not). we try discovery-matched type first (via ``defs.model_name``), then legacy ``MODEL_USBHUB_*``
If alternate=True, try 3p 3c 2x4 (helps when model hints order wrong on some hosts). mapping, then USBHub2x4 / USBHub3p / USBHub3c.
If alternate=True, retry with model-matched stem first, then 3p 3c 2x4 (some hosts
mis-order model hints).
""" """
stemmod.load_brainstem() stemmod.load_brainstem()
if alternate: stem_ns = stemmod.brainstem.stem
return [
stemmod.brainstem.stem.USBHub3p,
stemmod.brainstem.stem.USBHub3c,
stemmod.brainstem.stem.USBHub2x4,
]
model = getattr(spec, "model", None)
defs = getattr(stemmod.brainstem, "defs", None) defs = getattr(stemmod.brainstem, "defs", None)
preferred = [] model = getattr(spec, "model", None)
if defs is not None and model is not None:
def append_legacy_model_constants(order: list[type]) -> None:
if defs is None or model is None:
return
for mid, cls in ( for mid, cls in (
(getattr(defs, "MODEL_USBHUB_2X4", None), stemmod.brainstem.stem.USBHub2x4), (getattr(defs, "MODEL_USBHUB_2X4", None), stem_ns.USBHub2x4),
(getattr(defs, "MODEL_USBHUB_3P", None), stemmod.brainstem.stem.USBHub3p), (getattr(defs, "MODEL_USBHUB_3P", None), stem_ns.USBHub3p),
(getattr(defs, "MODEL_USBHUB_3C", None), stemmod.brainstem.stem.USBHub3c), (getattr(defs, "MODEL_USBHUB_3C", None), stem_ns.USBHub3c),
): ):
if mid is not None and model == mid: if mid is not None and model == mid:
preferred.append(cls) FiWiConcentrator._unique_stem_class_order(order, cls)
for cls in (stemmod.brainstem.stem.USBHub2x4, stemmod.brainstem.stem.USBHub3p, stemmod.brainstem.stem.USBHub3c):
if cls not in preferred: mo_cls = FiWiConcentrator._stem_class_from_brainstem_model(spec)
preferred.append(cls)
return preferred if alternate:
order: list[type] = []
FiWiConcentrator._unique_stem_class_order(order, mo_cls)
for c in (stem_ns.USBHub3p, stem_ns.USBHub3c, stem_ns.USBHub2x4):
FiWiConcentrator._unique_stem_class_order(order, c)
return order
order = []
FiWiConcentrator._unique_stem_class_order(order, mo_cls)
append_legacy_model_constants(order)
for c in (stem_ns.USBHub2x4, stem_ns.USBHub3p, stem_ns.USBHub3c):
FiWiConcentrator._unique_stem_class_order(order, c)
return order
def _connect_from_spec(self, spec, alternate=False): def _connect_from_spec(self, spec, alternate=False):
for cls in self._hub_stem_classes_for_spec(spec, alternate=alternate): for cls in self._hub_stem_classes_for_spec(spec, alternate=alternate):
@ -114,7 +156,8 @@ class FiWiConcentrator:
if self.hubs and not first_pass_ok and not quiet: if self.hubs and not first_pass_ok and not quiet:
print( print(
"fiwi: local hub(s) opened after retry (alternate USBHub3p → USBHub3c → USBHub2x4 order).", "fiwi: local hub(s) opened after retry (alternate stem order: model-matched if any, "
"then USBHub3p → USBHub3c → USBHub2x4).",
flush=True, flush=True,
) )
@ -135,8 +178,8 @@ class FiWiConcentrator:
flush=True, flush=True,
) )
print( print(
" Use the correct stem type (USBHub2x4 for 4-port, USBHub3p for USBHub3+), " " Use the correct stem type for your hub, udev permissions (vendor 24ff), and a "
"udev permissions (vendor 24ff), and BrainStem library version.", "BrainStem Python/lib version that matches HubTool (upgrade for new hub models).",
flush=True, flush=True,
) )
else: else:

274
fiwi/reflex_lang.py Normal file
View File

@ -0,0 +1,274 @@
"""
Acroname **on-device Reflex** (embedded language on BrainStem modules).
This module does **not** run Reflex on the hub; it helps you **compile** ``.reflex`` sources to
``.map`` files using the BrainStem Development Kit **arc** compiler. Load maps with Acroname
**ReflexLoader** or **HubTool** (see their docs).
The pip ``brainstem`` package is the host Python API; **arc** ships with the separate dev kit.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from pathlib import Path
# Primary language reference (path may move; search Acroname “Reflex language” if stale).
REFLEX_DOC_URL = "https://acroname.com/reference/brainstem/reflex/"
# Full SDK tarball includes ``bin/arc``, Reflex API ``*.reflex`` headers, HubTool, etc.
BRAINSTEM_SDK_DOWNLOAD_URL = "https://acroname.com/software/brainstem-development-kit"
def _dedupe_paths(paths: list[Path]) -> list[Path]:
seen: set[str] = set()
out: list[Path] = []
for p in paths:
try:
key = str(p.resolve())
except OSError:
key = str(p)
if key not in seen:
seen.add(key)
out.append(p)
return out
def _env_path_list(key: str) -> list[Path]:
raw = os.environ.get(key, "").strip()
if not raw:
return []
return [Path(p).expanduser() for p in raw.split(os.pathsep) if p.strip()]
def _fiwi_repo_brainstem_sdk_roots() -> list[Path]:
"""
Optional checkout next to this repo: ``FiWiManager/brainstem_sdk`` (and nested ``BrainStem2``
after extracting the official ``.tgz``).
"""
root = Path(__file__).resolve().parent.parent
out: list[Path] = []
b = root / "brainstem_sdk"
if b.is_dir():
out.append(b)
inner = b / "BrainStem2"
if inner.is_dir():
out.append(inner)
return out
def brainstem_dev_roots() -> list[Path]:
"""Likely BrainStem2 checkout or SDK install roots (for ``bin/arc`` and reflex API includes)."""
roots: list[Path] = []
for key in (
"BRAINSTEM2_DEV_ROOT",
"BRAINSTEM_DEV_ROOT",
"ACRONAME_BRAINSTEM_ROOT",
"BRAINSTEM_ROOT",
"REFLEX_SDK_ROOT",
"FIWI_BRAINSTEM_SDK",
):
roots.extend(_env_path_list(key))
roots.extend(_fiwi_repo_brainstem_sdk_roots())
return _dedupe_paths(roots)
def _likely_sdk_roots_under_home() -> list[Path]:
"""Common extract locations when the user has not set env vars (Linux/macOS)."""
home = Path.home()
names = (
"BrainStem2",
"brainstem2",
"BrainStemDevelopment",
"brainstem-development-kit",
"acroname-brainstem",
)
out: list[Path] = []
for name in names:
p = home / name
if p.is_dir():
out.append(p)
# Nested layout: ~/Downloads/BrainStem2-2.12.2-Linux/BrainStem2
try:
for child in home.iterdir():
if not child.is_dir():
continue
if "BrainStem" in child.name or "brainstem" in child.name.lower():
nested = child / "BrainStem2"
if nested.is_dir():
out.append(nested)
out.append(child)
except OSError:
pass
return _dedupe_paths(out)
def default_reflex_include_dirs() -> list[Path]:
"""
``-I`` paths for ``#include <aUSBHub3p.reflex>``-style API headers.
Set ``FIWI_REFLEX_INCLUDE`` to ``os.pathsep``-separated directories (required if arc is not
configured elsewhere). Optionally set ``BRAINSTEM2_DEV_ROOT`` to a dev kit tree containing
``api/lib/BrainStem2`` (or similar) with ``*.reflex`` API files.
"""
inc: list[Path] = []
inc.extend(_env_path_list("FIWI_REFLEX_INCLUDE"))
for root in brainstem_dev_roots():
for sub in (
"api/lib/BrainStem2",
"lib/BrainStem2",
"BrainStem2/api/lib/BrainStem2",
"include/reflex",
):
p = root / sub
if p.is_dir():
inc.append(p)
return _dedupe_paths(inc)
def find_arc() -> str | None:
"""Return executable path for **arc**, or None."""
explicit = os.environ.get("FIWI_REFLEX_ARC", "").strip()
if explicit:
p = Path(explicit).expanduser()
if p.is_file() and os.access(p, os.X_OK):
return str(p)
return None
w = shutil.which("arc")
if w:
return w
roots = _dedupe_paths(brainstem_dev_roots() + _likely_sdk_roots_under_home())
for root in roots:
cand = root / "bin" / "arc"
if cand.is_file() and os.access(cand, os.X_OK):
return str(cand)
return None
def print_arc_missing_help() -> None:
print(
"The Reflex compiler **arc** is not on PATH and was not found under common install paths.\n"
"\n"
"It is **not** installed by ``pip install brainstem``. Get the BrainStem **Development Kit** "
"(Linux .tgz), extract it, then either:\n"
"\n"
f" • Add the kits ``bin`` to PATH (contains ``arc``, ``ReflexLoader``, ``HubTool``), or\n"
" • export BRAINSTEM2_DEV_ROOT=/path/to/extracted/BrainStem2\n"
" (FiWi searches $BRAINSTEM2_DEV_ROOT/bin/arc and api/lib/BrainStem2 for includes), or\n"
" • export FIWI_REFLEX_ARC=/full/path/to/arc\n"
"\n"
f"Download: {BRAINSTEM_SDK_DOWNLOAD_URL}\n"
"\n"
"If you keep the SDK under ``FiWiManager/brainstem_sdk``, extract the Linux ``.tgz`` there "
"so you get ``brainstem_sdk/BrainStem2/bin/arc`` (the ``api/`` tree alone does not ship "
"``arc``).\n"
"\n"
"Check: python3 fiwi.py reflex which\n",
file=sys.stderr,
flush=True,
)
def reflex_compile(
source: Path,
output_map: Path | None,
*,
extra_argv: list[str] | None = None,
verbose: bool = False,
) -> int:
"""
Run **arc** to compile ``source`` ``output_map`` (default: same basename, ``.map``).
``extra_argv`` are appended before the output/source (for unusual arc builds). Returns
subprocess exit code.
"""
arc = find_arc()
if not arc:
print("fiwi reflex: no 'arc' executable.", file=sys.stderr, flush=True)
print_arc_missing_help()
return 1
src = source.expanduser().resolve()
if not src.is_file():
print(f"fiwi reflex: source not found: {src}", file=sys.stderr, flush=True)
return 1
out = (
output_map.expanduser().resolve()
if output_map is not None
else src.with_suffix(".map")
)
includes = default_reflex_include_dirs()
argv: list[str] = [arc]
for d in includes:
argv.extend(["-I", str(d.resolve())])
if extra_argv:
argv.extend(extra_argv)
argv.extend(["-o", str(out), str(src)])
if verbose:
print(subprocess.list2cmdline(argv), flush=True)
return subprocess.run(argv, cwd=str(src.parent)).returncode
def print_reflex_cli_help() -> None:
print(
"On-device Reflex (Acroname) — compile only; load .map with ReflexLoader / HubTool.\n"
"\n"
"Usage:\n"
" fiwi.py reflex help | doc | which\n"
" fiwi.py reflex compile <source.reflex> [output.map] [-- <extra arc args...>]\n"
"\n"
"Environment:\n"
" FIWI_REFLEX_ARC Full path to arc (if not on PATH)\n"
" FIWI_REFLEX_INCLUDE Include dirs for aUSBHub*.reflex APIs (os.pathsep-separated)\n"
" BRAINSTEM2_DEV_ROOT BrainStem Development Kit root (finds bin/arc, api/lib/BrainStem2)\n"
" REFLEX_SDK_ROOT, FIWI_BRAINSTEM_SDK — same idea as BRAINSTEM2_DEV_ROOT\n"
"\n"
f"SDK (includes arc): {BRAINSTEM_SDK_DOWNLOAD_URL}\n"
"\n"
"Example:\n"
" export BRAINSTEM2_DEV_ROOT=~/BrainStem2\n"
" fiwi.py reflex compile reflex/inrush.reflex reflex/inrush.map\n"
" ./scripts/compile_inrush_reflex.sh\n"
f"\nLanguage reference: {REFLEX_DOC_URL}\n"
)
def cli_main(argv: list[str]) -> int:
"""Entry for ``fiwi.py reflex …`` (invoked before BrainStem Python load)."""
if not argv or argv[0].lower() in ("help", "-h", "--help"):
print_reflex_cli_help()
return 0
cmd = argv[0].lower()
if cmd == "doc":
print(REFLEX_DOC_URL, flush=True)
return 0
if cmd == "which":
arc = find_arc()
print(f"arc: {arc or '(not found)'}", flush=True)
incs = default_reflex_include_dirs()
if incs:
print("include (-I):", flush=True)
for d in incs:
print(f" {d}", flush=True)
else:
print("include (-I): (none — set FIWI_REFLEX_INCLUDE or BRAINSTEM2_DEV_ROOT)", flush=True)
return 0
if cmd != "compile":
print(f"fiwi reflex: unknown subcommand {argv[0]!r}", file=sys.stderr, flush=True)
print("Try: fiwi.py reflex help", file=sys.stderr, flush=True)
return 2
rest = argv[1:]
if not rest:
print("fiwi reflex: compile requires <source.reflex>", file=sys.stderr, flush=True)
return 2
extra: list[str] = []
for i, a in enumerate(rest):
if a == "--":
extra = rest[i + 1 :]
rest = rest[:i]
break
src_s = rest[0]
out_s = rest[1] if len(rest) >= 2 else None
verbose = os.environ.get("FIWI_REFLEX_VERBOSE", "").strip() in ("1", "yes", "true")
return reflex_compile(Path(src_s), Path(out_s) if out_s else None, extra_argv=extra or None, verbose=verbose)

View File

1
reflex/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.map

68
reflex/inrush.reflex Normal file
View File

@ -0,0 +1,68 @@
// Current inrush observer for USBHub3+ — runs on the hub (Reflex), not the host.
//
// Sampling uses Timer[0] in repeat mode (setExpiration in microseconds), not every_1ms(),
// so the hub schedules ticks on the RTOS timer — not the host sleep loop.
//
// Default sample grid: **100 µs** per tick (10 kHz). Time axis:
// elapsed_us ≈ ticks * sample_period_us
// time above threshold ≈ above_threshold_ticks * sample_period_us
//
// Compile: fiwi.py reflex compile reflex/inrush.reflex reflex/inrush.map
// or: ./scripts/compile_inrush_reflex.sh
// Load: ReflexLoader / HubTool (.map into module store).
// Readout: tests/check_inrush.py (default on-hub: re-arm slot, wait, scratchpad via pointer).
// Arm: map / mapEnable — zeros scratchpad, starts Timer[0] at 100 µs per tick.
// Stop: mapDisable — setExpiration(0) on Timer[0].
//
// Tune: port index in getPortCurrent(0) — 0..7 downstream on USBHub3+.
// Tune: 100 in mapEnable setExpiration + sample_period_us (keep them equal).
// Tune: threshold 50000 = 50 mA in µA.
//
// If arc rejects ``reflex hub.timer[0].expiration()``, try ``reflex timer[0].expiration()``
// (depends on your BrainStem / arc version).
#include <aUSBHub3p.reflex>
aUSBHub3p hub;
// Timer repeat period: 100 µs per sample (10 kHz). Change both setExpiration(...) and
// sample_period_us assignment in mapEnable if you retune. (BrainStem allows ~1 µss range;
// very fast rates increase CPU load; hub current readings may not update faster than hardware.)
// Scratchpad (host reads via pointer / HubTool)
// peak_ua — max port current since mapEnable (microamps)
// ticks — timer expiration count since mapEnable
// above_threshold_ticks — ticks where current > 50 mA
// sample_period_us — µs per tick (same as SAMPLE_PERIOD_US; for host scaling)
pad[0:4] signed int peak_ua;
pad[4:4] signed int ticks;
pad[8:4] signed int above_threshold_ticks;
pad[12:4] signed int sample_period_us;
reflex hub.timer[0].expiration() {
signed int current = hub.usb.getPortCurrent(0);
ticks++;
if (current > peak_ua) {
peak_ua = current;
}
if (current > 50000) {
above_threshold_ticks++;
}
}
reflex mapEnable() {
hub.timer[0].setExpiration(0);
peak_ua = 0;
ticks = 0;
above_threshold_ticks = 0;
sample_period_us = 100;
hub.timer[0].setMode(1);
hub.timer[0].setExpiration(100);
}
reflex mapDisable() {
hub.timer[0].setExpiration(0);
}

View File

@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Compile reflex/inrush.reflex → reflex/inrush.map using the BrainStem SDK arc compiler.
#
# Requires: **arc** from the BrainStem Development Kit (not ``pip install brainstem``).
# https://acroname.com/software/brainstem-development-kit
# Then: add .../BrainStem2/bin to PATH, or export BRAINSTEM2_DEV_ROOT=.../BrainStem2
# See: python3 fiwi.py reflex help
#
# Usage (from repo root):
# ./scripts/compile_inrush_reflex.sh
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
exec python3 fiwi.py reflex compile reflex/inrush.reflex reflex/inrush.map

95
tests/check_concentrator.py Normal file → Executable file
View File

@ -2,8 +2,9 @@
""" """
Smoke check: :class:`fiwi.concentrator.FiWiConcentrator` plus consolidated USB hub and per-port Smoke check: :class:`fiwi.concentrator.FiWiConcentrator` plus consolidated USB hub and per-port
metrics tables covering **this machine** and each configured **remote**. The consolidated hub table metrics tables covering **this machine** and each configured **remote**. The consolidated hub table
has **Location** (``local`` or remote IP/hostname, plus **tty** names when known) and **USB** has **Type** (BrainStem hub class), **Location** (``local`` or remote IP/hostname, plus **tty** when
(Bus/Device, VID:PID, product from sysfs). The per-port table starts with **Panel** (``fiber_ports`` known) and **USB** (Bus/Device, VID:PID, product from sysfs). The per-port table starts with **Panel**
(``fiber_ports``
key / rack position when ``hub``, ``port``, and optional ``ssh`` match this run), rows sorted by panel key / rack position when ``hub``, ``port``, and optional ``ssh`` match this run), rows sorted by panel
number; a **Power(N): Total W / mA (per port follows)** line heads the section (``N`` = ports with number; a **Power(N): Total W / mA (per port follows)** line heads the section (``N`` = ports with
``power`` ON), then columns **mA**, **V**, **W**, and **Location**. ``power`` ON), then columns **mA**, **V**, **W**, and **Location**.
@ -51,8 +52,10 @@ if _ROOT not in sys.path:
_WIDTH = 62 _WIDTH = 62
_paths_configured = False _paths_configured = False
_HUB_TYPE_COL_W = 14
_CONSOLIDATED_HUB_HDR = ( _CONSOLIDATED_HUB_HDR = (
f"{'#':<4} | {'Serial':<12} | {'Ports':<10} | {'Location':<28} | USB (Bus / ID / product)" f"{'#':<4} | {'Type':<{_HUB_TYPE_COL_W}} | {'Serial':<12} | {'Ports':<10} | "
f"{'Location':<28} | USB (Bus / ID / product)"
) )
_PER_PORT_PWR_HDR = ( _PER_PORT_PWR_HDR = (
f"{'Panel':<8} | {'Hub#':<4} | {'Serial':<12} | {'Pt':<3} | " f"{'Panel':<8} | {'Hub#':<4} | {'Serial':<12} | {'Pt':<3} | "
@ -78,6 +81,8 @@ class ConsolidatedHubRow:
usb_tty_hint: str = "" usb_tty_hint: str = ""
#: sysfs / ``lsusb``-style line: Bus/Dev, ID ``24ff:…``, product string. #: sysfs / ``lsusb``-style line: Bus/Dev, ID ``24ff:…``, product string.
usb_identity_hint: str = "" usb_identity_hint: str = ""
#: BrainStem stem class / ``defs.model_name`` (e.g. ``USBHub3p``); ``?`` if unknown.
hub_type: str = "?"
def _rule(char: str = "-") -> str: def _rule(char: str = "-") -> str:
@ -117,9 +122,64 @@ def _remote_location_label(ssh_target: str) -> str:
return host return host
def _collect_local_hub_rows(c: FiWiConcentrator) -> list[tuple[str, str]]: def _local_hub_type_for_spec(c: FiWiConcentrator, spec: object) -> str:
"""Stem class name when connected, else ``defs.model_name(spec.model)``, else ``?``."""
import fiwi.brainstem_loader as stemmod
stemmod.load_brainstem()
sn = getattr(spec, "serial_number", None)
if isinstance(sn, int):
for stem in c.hubs:
r = stem.system.getSerialNumber()
if r.error == c.SUCCESS and r.value == sn:
return type(stem).__name__
defs_mod = getattr(stemmod.brainstem, "defs", None)
model = getattr(spec, "model", None)
if defs_mod is not None and model is not None:
try:
mn = defs_mod.model_name(int(model))
except (TypeError, ValueError):
mn = "Unknown"
if mn != "Unknown":
return mn
return "?"
def _parse_serial_to_hub_type_from_hostcards_stdout(text: str) -> dict[str, str]:
""" """
``(serial_hex, ports_display)`` per local USB hub, same rules as From ``show_hostcards`` text, map normalized serial (``0x``) hub type name using
``serial=0x model=N`` discovery lines (same stdout as the hub table).
"""
import fiwi.brainstem_loader as stemmod
stemmod.load_brainstem()
defs_mod = getattr(stemmod.brainstem, "defs", None)
out: dict[str, str] = {}
for ln in text.splitlines():
m = re.search(r"serial=(0x[0-9A-Fa-f]+)\s+model=(\S+)", ln, re.I)
if not m:
continue
key = _norm_hub_serial(m.group(1))
mod_s = m.group(2).strip()
if mod_s == "?" or defs_mod is None:
out[key] = "?"
continue
try:
mid = int(mod_s, 10)
except ValueError:
out[key] = "?"
continue
try:
mn = defs_mod.model_name(mid)
except Exception:
mn = "Unknown"
out[key] = mn if mn != "Unknown" else "?"
return out
def _collect_local_hub_rows(c: FiWiConcentrator) -> list[tuple[str, str, str]]:
"""
``(serial_hex, ports_display, hub_type)`` per local USB hub, same rules as
:meth:`fiwi.concentrator.FiWiConcentrator._print_usb_hub_summary_table`. :meth:`fiwi.concentrator.FiWiConcentrator._print_usb_hub_summary_table`.
""" """
specs = c._enumerate_usb_specs() specs = c._enumerate_usb_specs()
@ -128,7 +188,7 @@ def _collect_local_hub_rows(c: FiWiConcentrator) -> list[tuple[str, str]]:
if not c.hubs: if not c.hubs:
c._connect_specs(specs) c._connect_specs(specs)
by_sn = c._serial_to_opened_port_count() by_sn = c._serial_to_opened_port_count()
out: list[tuple[str, str]] = [] out: list[tuple[str, str, str]] = []
for spec in specs: for spec in specs:
sn = spec.serial_number sn = spec.serial_number
sn_s = f"0x{sn:08X}" sn_s = f"0x{sn:08X}"
@ -138,7 +198,7 @@ def _collect_local_hub_rows(c: FiWiConcentrator) -> list[tuple[str, str]]:
inf = c._inferred_downstream_ports_from_spec(spec) inf = c._inferred_downstream_ports_from_spec(spec)
ports = str(inf) if inf is not None else "?" ports = str(inf) if inf is not None else "?"
ports += " *" ports += " *"
out.append((sn_s, ports)) out.append((sn_s, ports, _local_hub_type_for_spec(c, spec)))
return out return out
@ -304,7 +364,13 @@ def _build_consolidated_hub_rows(c: FiWiConcentrator) -> tuple[list[Consolidated
rc = 0 rc = 0
rows: list[ConsolidatedHubRow] = [] rows: list[ConsolidatedHubRow] = []
def _push(serial: str, ports_disp: str, loc: str, ssh_target: str | None) -> None: def _push(
serial: str,
ports_disp: str,
loc: str,
ssh_target: str | None,
hub_type: str = "?",
) -> None:
rows.append( rows.append(
ConsolidatedHubRow( ConsolidatedHubRow(
idx=len(rows) + 1, idx=len(rows) + 1,
@ -313,11 +379,12 @@ def _build_consolidated_hub_rows(c: FiWiConcentrator) -> tuple[list[Consolidated
n_ports=_parse_port_count(ports_disp), n_ports=_parse_port_count(ports_disp),
location=loc, location=loc,
ssh_target=ssh_target, ssh_target=ssh_target,
hub_type=hub_type,
) )
) )
for serial, ports in _collect_local_hub_rows(c): for serial, ports, ht in _collect_local_hub_rows(c):
_push(serial, ports, "local", None) _push(serial, ports, "local", None, ht)
hosts = _merged_hub_hosts() hosts = _merged_hub_hosts()
if not hosts: if not hosts:
@ -344,8 +411,10 @@ def _build_consolidated_hub_rows(c: FiWiConcentrator) -> tuple[list[Consolidated
) )
rc = 1 rc = 1
continue continue
remote_types = _parse_serial_to_hub_type_from_hostcards_stdout(out)
for serial, ports in parsed: for serial, ports in parsed:
_push(serial, ports, loc, host) ht = remote_types.get(_norm_hub_serial(serial), "?")
_push(serial, ports, loc, host, ht)
except Exception as exc: except Exception as exc:
print(f" ! Remote {host} ({loc}): {exc}", flush=True) print(f" ! Remote {host} ({loc}): {exc}", flush=True)
rc = 1 rc = 1
@ -838,8 +907,10 @@ def _print_usb_hub_tables(c: FiWiConcentrator, hub_rows: list[ConsolidatedHubRow
print(_CONSOLIDATED_HUB_HDR, flush=True) print(_CONSOLIDATED_HUB_HDR, flush=True)
print("-" * len(_CONSOLIDATED_HUB_HDR), flush=True) print("-" * len(_CONSOLIDATED_HUB_HDR), flush=True)
for row in hub_rows: for row in hub_rows:
ht = row.hub_type[:_HUB_TYPE_COL_W] if len(row.hub_type) > _HUB_TYPE_COL_W else row.hub_type
print( print(
f"{row.idx:<4} | {row.serial:<12} | {row.ports_display:<10} | {_location_cell(row):<28} | {_usb_cell(row)}", f"{row.idx:<4} | {ht:<{_HUB_TYPE_COL_W}} | {row.serial:<12} | {row.ports_display:<10} | "
f"{_location_cell(row):<28} | {_usb_cell(row)}",
flush=True, flush=True,
) )
print(flush=True) print(flush=True)

485
tests/check_inrush.py Normal file
View File

@ -0,0 +1,485 @@
#!/usr/bin/env python3
"""
Measure **USB inrush** on a local BrainStem hub port.
**Default on-hub sampling** (``reflex/inrush.reflex``):
- Load the compiled ``.map`` into a hub store (ReflexLoader / HubTool), then run this script.
- Re-arms the map (``slotDisable`` / ``slotEnable``) to run ``mapEnable`` so Timer[0] and scratchpad
reset, optionally power-cycles the downstream port, **waits** ``--sample-ms`` on the host while
the hub samples current at its timer rate (e.g. 100 µs), then reads **scratchpad** via
``pointer[N]`` (offsets match ``reflex/inrush.reflex``).
**Legacy host sampling** (``--host-sample``):
- Poll ``getPortCurrent`` on a deadline grid (default 1 ms). No Reflex map required.
**Standalone**::
python tests/check_inrush.py 1 0
python tests/check_inrush.py 1 0 --host-sample
python tests/check_inrush.py --config uax24 2 4 --map-slot 1 --json
``hub`` is **1-based**; ``port`` is **0-based**. On-hub mode: ``reflex/inrush.reflex`` is built for
**port 0** only unless you recompile with another index. ``--config`` sets ``FIWI_CONFIG``.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
import time
_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)
# Must match reflex/inrush.reflex scratchpad layout (byte offsets into pad).
_PAD_PEAK_UA = 0
_PAD_TICKS = 4
_PAD_ABOVE_THR_TICKS = 8
_PAD_SAMPLE_PERIOD_US = 12
# Reflex uses 50 mA threshold (50000 µA); host --threshold-ma does not change on-hub tallies.
_REFLEX_THRESHOLD_MA = 50.0
def _configure_paths() -> None:
import fiwi.paths as paths_mod
paths_mod.configure(_ROOT)
def _sleep_until(deadline: float) -> None:
while True:
now = time.perf_counter()
if now >= deadline:
return
time.sleep(deadline - now)
def _i32(v: int) -> int:
v &= 0xFFFFFFFF
if v >= 0x80000000:
return v - 0x100000000
return v
def _read_pad_i32(stem, pointer_index: int, byte_offset: int, ok) -> int | None:
"""Read signed 32-bit from reflex scratchpad at byte offset."""
try:
ptr = stem.pointer[pointer_index]
except (IndexError, AttributeError, TypeError):
return None
pm = getattr(ptr, "POINTER_MODE_STATIC", 0)
try:
ptr.setMode(pm)
except Exception:
pass
if ptr.setOffset(byte_offset) != ok:
return None
r = ptr.getInt()
if r.error != ok:
return None
return _i32(int(r.value))
def _rearm_map_slot(stem, store_index: int, slot: int, ok) -> bool:
"""Disable/enable store slot to re-trigger ``mapEnable`` on the loaded reflex map."""
try:
st = stem.store[store_index]
except (IndexError, AttributeError, TypeError):
print(
"check_inrush: hub has no store at this index (on-hub mode needs a loaded .map).",
file=sys.stderr,
flush=True,
)
return False
if st.slotDisable(slot) != ok:
return False
time.sleep(0.05)
if st.slotEnable(slot) != ok:
return False
time.sleep(0.02)
return True
def _measure_inrush_on_hub(
c,
hub_idx_0: int,
port: int,
*,
off_s: float,
sample_s: float,
power_cycle: bool,
store_index: int,
map_slot: int,
pointer_index: int,
rearm_map: bool,
) -> dict[str, object]:
stem = c.hubs[hub_idx_0]
ok = c.SUCCESS
if power_cycle:
stem.usb.setPortDisable(port)
time.sleep(off_s)
if rearm_map:
if not _rearm_map_slot(stem, store_index, map_slot, ok):
return {"error": "rearm_map_failed"}
if power_cycle:
stem.usb.setPortEnable(port)
time.sleep(sample_s)
peak_ua = _read_pad_i32(stem, pointer_index, _PAD_PEAK_UA, ok)
ticks = _read_pad_i32(stem, pointer_index, _PAD_TICKS, ok)
above_ticks = _read_pad_i32(stem, pointer_index, _PAD_ABOVE_THR_TICKS, ok)
period_us = _read_pad_i32(stem, pointer_index, _PAD_SAMPLE_PERIOD_US, ok)
sn_res = stem.system.getSerialNumber()
serial = f"0x{sn_res.value:08X}" if sn_res.error == ok else "?"
if peak_ua is None or ticks is None or above_ticks is None or period_us is None:
return {
"error": "scratchpad_read_failed",
"hub": hub_idx_0 + 1,
"port": port,
"serial": serial,
}
peak_ma = peak_ua / 1000.0
p_us = max(int(period_us), 0)
t = max(int(ticks), 0)
at = max(int(above_ticks), 0)
obs_us = t * p_us
above_us = at * p_us
return {
"mode": "on-hub",
"hub": hub_idx_0 + 1,
"port": port,
"serial": serial,
"peak_ma": round(peak_ma, 3),
"peak_ua": peak_ua,
"ticks": t,
"sample_period_us": p_us,
"observation_us": obs_us,
"above_threshold_ticks": at,
"above_threshold_us": above_us,
"reflex_threshold_ma": _REFLEX_THRESHOLD_MA,
"sample_window_ms": round(sample_s * 1000.0, 3),
"power_cycled": power_cycle,
"map_store_index": store_index,
"map_slot": map_slot,
"pointer_index": pointer_index,
}
def _measure_inrush_host(
c,
hub_idx_0: int,
port: int,
*,
off_s: float,
sample_s: float,
interval_s: float,
threshold_ma: float,
power_cycle: bool,
) -> dict[str, object]:
stem = c.hubs[hub_idx_0]
ok = c.SUCCESS
if power_cycle:
stem.usb.setPortDisable(port)
time.sleep(off_s)
stem.usb.setPortEnable(port)
interval_s = max(interval_s, 0.0005)
n_samples = max(1, int(round(sample_s / interval_s)))
samples: list[tuple[float, float]] = []
t0 = time.perf_counter()
peak_ma = float("-inf")
peak_first_s = 0.0
first_above_s: float | None = None
above_count = 0
for i in range(n_samples):
_sleep_until(t0 + i * interval_s)
rel_s = time.perf_counter() - t0
cr = stem.usb.getPortCurrent(port)
if cr.error != ok:
ma = float("nan")
else:
ma = cr.value / 1000.0
samples.append((rel_s, ma))
if not math.isnan(ma):
if ma > peak_ma:
peak_ma = ma
peak_first_s = rel_s
if ma > threshold_ma:
above_count += 1
if first_above_s is None:
first_above_s = rel_s
t_end = time.perf_counter()
if peak_ma == float("-inf") or math.isnan(peak_ma):
peak_ma = 0.0
above_s = above_count * interval_s
sn_res = stem.system.getSerialNumber()
serial = f"0x{sn_res.value:08X}" if sn_res.error == ok else "?"
return {
"mode": "host-sample",
"hub": hub_idx_0 + 1,
"port": port,
"serial": serial,
"peak_ma": round(peak_ma, 3),
"peak_time_ms": round(peak_first_s * 1000.0, 3),
"first_above_threshold_ms": (
None
if first_above_s is None
else round(first_above_s * 1000.0, 3)
),
"above_threshold_ma": threshold_ma,
"above_threshold_ms": round(above_s * 1000.0, 3),
"sample_count": len(samples),
"sample_window_ms": round(sample_s * 1000.0, 3),
"elapsed_ms": round((t_end - t0) * 1000.0, 3),
"interval_ms": round(interval_s * 1000.0, 3),
"power_cycled": power_cycle,
}
def main() -> int:
p = argparse.ArgumentParser(
description="USB inrush: default on-hub (reflex/inrush.reflex scratchpad); optional host polling.",
)
p.add_argument("hub", type=int, help="Hub index 1-based (first hub = 1)")
p.add_argument("port", type=int, help="Downstream port index 0-based")
p.add_argument(
"-c",
"--config",
metavar="PROFILE_OR_INI",
help="Set FIWI_CONFIG (profile name or absolute *.ini)",
)
p.add_argument(
"--host-sample",
action="store_true",
help="Poll getPortCurrent on the PC (legacy); default is on-hub reflex scratchpad.",
)
p.add_argument(
"--off-ms",
type=float,
default=250.0,
help="Milliseconds off before re-enable when power-cycling (default 250)",
)
p.add_argument(
"--sample-ms",
type=float,
default=500.0,
help="After re-arm (+ optional power-on), wait this long on host while hub samples (default 500)",
)
p.add_argument(
"--interval-ms",
type=float,
default=1.0,
help="(host-sample only) ms between getPortCurrent reads (default 1)",
)
p.add_argument(
"--threshold-ma",
type=float,
default=50.0,
help="(host-sample only) threshold for above-threshold tally",
)
p.add_argument(
"--no-power-cycle",
action="store_true",
help="Do not disable/enable the downstream port",
)
p.add_argument(
"--map-store-index",
type=int,
default=1,
help="stem.store index for slotDisable/Enable (USBHub3p: 0 internal, 1 RAM; default 1)",
)
p.add_argument(
"--map-slot",
type=int,
default=0,
help="Store slot number where inrush.map is loaded (default 0)",
)
p.add_argument(
"--pointer-index",
type=int,
default=0,
help="stem.pointer index for reflex scratchpad (default 0)",
)
p.add_argument(
"--no-rearm-map",
action="store_true",
help="Do not slotDisable/slotEnable before measure (counters/timer may be stale)",
)
p.add_argument("--json", action="store_true", help="Print one JSON object on stdout")
args = p.parse_args()
if args.config:
os.environ["FIWI_CONFIG"] = args.config.strip()
if args.hub < 1:
print("hub must be >= 1", file=sys.stderr, flush=True)
return 2
if args.port < 0:
print("port must be >= 0", file=sys.stderr, flush=True)
return 2
off_s = max(args.off_ms / 1000.0, 0.0)
sample_s = max(args.sample_ms / 1000.0, 0.01)
interval_s = max(args.interval_ms / 1000.0, 0.0005)
_configure_paths()
from fiwi.concentrator import FiWiConcentrator
c = FiWiConcentrator()
try:
if not c.connect():
print("No local USB power-control hubs connected.", file=sys.stderr, flush=True)
return 1
hi = args.hub - 1
if hi < 0 or hi >= len(c.hubs):
print(
f"Hub {args.hub} invalid (have {len(c.hubs)} hub(s)).",
file=sys.stderr,
flush=True,
)
return 1
stem = c.hubs[hi]
n = c._port_count(stem)
if args.port < 0 or args.port >= n:
print(
f"Port {args.port} out of range for hub {args.hub} (0..{n - 1}).",
file=sys.stderr,
flush=True,
)
return 1
if not args.host_sample and args.port != 0:
print(
"check_inrush: on-hub reflex/inrush.reflex monitors port 0 only; "
f"you asked for port {args.port}. Recompile Reflex or use --host-sample.",
file=sys.stderr,
flush=True,
)
if args.host_sample:
out = _measure_inrush_host(
c,
hi,
args.port,
off_s=off_s,
sample_s=sample_s,
interval_s=interval_s,
threshold_ma=args.threshold_ma,
power_cycle=not args.no_power_cycle,
)
else:
out = _measure_inrush_on_hub(
c,
hi,
args.port,
off_s=off_s,
sample_s=sample_s,
power_cycle=not args.no_power_cycle,
store_index=args.map_store_index,
map_slot=args.map_slot,
pointer_index=args.pointer_index,
rearm_map=not args.no_rearm_map,
)
if out.get("error") == "rearm_map_failed":
print(
"check_inrush: could not re-arm map (store slotDisable/Enable failed). "
"Load reflex/inrush.map into --map-store-index / --map-slot, or try --no-rearm-map.",
file=sys.stderr,
flush=True,
)
return 1
if out.get("error") == "scratchpad_read_failed":
print(
"check_inrush: scratchpad read failed (pointer offset / hub type). "
"Confirm inrush.map is loaded, mapEnable ran, and pointer index matches Reflex.",
file=sys.stderr,
flush=True,
)
return 1
if args.json:
print(json.dumps(out), flush=True)
else:
print(
f"Hub {out['hub']} port {out['port']} serial {out['serial']}",
flush=True,
)
if out.get("mode") == "on-hub":
print(f" mode: on-hub reflex (scratchpad via pointer {args.pointer_index})", flush=True)
print(
f" peak current: {out['peak_ma']} mA (peak_ua={out['peak_ua']})",
flush=True,
)
print(
f" hub ticks: {out['ticks']} @ {out['sample_period_us']} µs → "
f"~{out['observation_us']} µs on timer grid",
flush=True,
)
print(
f" > {_REFLEX_THRESHOLD_MA} mA (reflex): ~{out['above_threshold_us']} µs "
f"({out['above_threshold_ticks']} ticks)",
flush=True,
)
print(
f" host wait: {out['sample_window_ms']} ms power_cycled: {out['power_cycled']} "
f"store[{out['map_store_index']}] slot {out['map_slot']}",
flush=True,
)
if out["ticks"] == 0 and out.get("sample_period_us", 0) == 0:
print(
" ! ticks and sample_period_us are 0 — map likely not running or wrong pointer.",
file=sys.stderr,
flush=True,
)
else:
fat = out["first_above_threshold_ms"]
fat_s = f"{fat} ms" if fat is not None else "n/a"
print(f" mode: host-sample (getPortCurrent loop)", flush=True)
print(
f" peak current: {out['peak_ma']} mA (when max first rose: {out['peak_time_ms']} ms)",
flush=True,
)
print(
f" first > {out['above_threshold_ma']} mA: {fat_s}",
flush=True,
)
print(
f" > {out['above_threshold_ma']} mA for ~{out['above_threshold_ms']} ms "
f"({out['sample_count']} samples @ {out['interval_ms']} ms; wall {out['elapsed_ms']} ms)",
flush=True,
)
print(
f" nominal window: {out['sample_window_ms']} ms power_cycled: {out['power_cycled']}",
flush=True,
)
return 0
finally:
c.disconnect()
if __name__ == "__main__":
raise SystemExit(main())

0
tests/check_remote.py Normal file → Executable file
View File