From a488dc2ed0ef45731307b48faf086ea45964d198 Mon Sep 17 00:00:00 2001 From: Robert McMahon Date: Fri, 3 Apr 2026 15:54:26 -0700 Subject: [PATCH] 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 --- fiwi/cli.py | 9 +- fiwi/concentrator.py | 93 ++++-- fiwi/reflex_lang.py | 274 +++++++++++++++++ fiwi_link/fiwi_relay/__main__.py | 0 reflex/.gitignore | 1 + reflex/inrush.reflex | 68 +++++ scripts/compile_inrush_reflex.sh | 15 + tests/check_concentrator.py | 95 +++++- tests/check_inrush.py | 485 +++++++++++++++++++++++++++++++ tests/check_remote.py | 0 10 files changed, 1002 insertions(+), 38 deletions(-) create mode 100644 fiwi/reflex_lang.py create mode 100644 fiwi_link/fiwi_relay/__main__.py create mode 100644 reflex/.gitignore create mode 100644 reflex/inrush.reflex create mode 100755 scripts/compile_inrush_reflex.sh mode change 100644 => 100755 tests/check_concentrator.py create mode 100644 tests/check_inrush.py mode change 100644 => 100755 tests/check_remote.py diff --git a/fiwi/cli.py b/fiwi/cli.py index c5422a4..3385890 100644 --- a/fiwi/cli.py +++ b/fiwi/cli.py @@ -112,6 +112,8 @@ def _print_cli_help() -> None: " --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" " 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" " 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" @@ -188,6 +190,11 @@ def main() -> int: return 0 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 not pos: print( @@ -373,7 +380,7 @@ def main() -> int: else: print(f"Unknown command: {cmd!r}", file=sys.stderr, flush=True) 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, flush=True, ) diff --git a/fiwi/concentrator.py b/fiwi/concentrator.py index 51def67..083479b 100644 --- a/fiwi/concentrator.py +++ b/fiwi/concentrator.py @@ -25,8 +25,10 @@ from fiwi.ieee80211_dev import discover_wireless_for_map, wlan_chip_and_interfac class FiWiConcentrator: """ - Main Fi-Wi object: BrainStem-driven USB power-control hub plane, ``FiberRadioPort`` / - :class:`fiwi.ssh.SshNode` routing, and calibration. + 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 + 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`` (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) 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.`` 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.`` 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 def _hub_stem_classes_for_spec(spec, alternate=False): """ - Pick stem class for hardware. USBHub2x4 vs USBHub3p must match the device or - connectFromSpec fails (HubTool handles this; raw USBHub3p() does not). - If alternate=True, try 3p → 3c → 2x4 (helps when model hints order wrong on some hosts). + Pick stem class order for ``connectFromSpec``. Wrong class vs hardware fails the connect; + we try discovery-matched type first (via ``defs.model_name``), then legacy ``MODEL_USBHUB_*`` + 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() - if alternate: - return [ - stemmod.brainstem.stem.USBHub3p, - stemmod.brainstem.stem.USBHub3c, - stemmod.brainstem.stem.USBHub2x4, - ] - model = getattr(spec, "model", None) + stem_ns = stemmod.brainstem.stem defs = getattr(stemmod.brainstem, "defs", None) - preferred = [] - if defs is not None and model is not None: + model = getattr(spec, "model", None) + + def append_legacy_model_constants(order: list[type]) -> None: + if defs is None or model is None: + return for mid, cls in ( - (getattr(defs, "MODEL_USBHUB_2X4", None), stemmod.brainstem.stem.USBHub2x4), - (getattr(defs, "MODEL_USBHUB_3P", None), stemmod.brainstem.stem.USBHub3p), - (getattr(defs, "MODEL_USBHUB_3C", None), stemmod.brainstem.stem.USBHub3c), + (getattr(defs, "MODEL_USBHUB_2X4", None), stem_ns.USBHub2x4), + (getattr(defs, "MODEL_USBHUB_3P", None), stem_ns.USBHub3p), + (getattr(defs, "MODEL_USBHUB_3C", None), stem_ns.USBHub3c), ): if mid is not None and model == mid: - preferred.append(cls) - for cls in (stemmod.brainstem.stem.USBHub2x4, stemmod.brainstem.stem.USBHub3p, stemmod.brainstem.stem.USBHub3c): - if cls not in preferred: - preferred.append(cls) - return preferred + FiWiConcentrator._unique_stem_class_order(order, cls) + + mo_cls = FiWiConcentrator._stem_class_from_brainstem_model(spec) + + 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): 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: 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, ) @@ -135,8 +178,8 @@ class FiWiConcentrator: flush=True, ) print( - " Use the correct stem type (USBHub2x4 for 4-port, USBHub3p for USBHub3+), " - "udev permissions (vendor 24ff), and BrainStem library version.", + " Use the correct stem type for your hub, udev permissions (vendor 24ff), and a " + "BrainStem Python/lib version that matches HubTool (upgrade for new hub models).", flush=True, ) else: diff --git a/fiwi/reflex_lang.py b/fiwi/reflex_lang.py new file mode 100644 index 0000000..60f153e --- /dev/null +++ b/fiwi/reflex_lang.py @@ -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 ``-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 kit’s ``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 [output.map] [-- ]\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 ", 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) diff --git a/fiwi_link/fiwi_relay/__main__.py b/fiwi_link/fiwi_relay/__main__.py new file mode 100644 index 0000000..e69de29 diff --git a/reflex/.gitignore b/reflex/.gitignore new file mode 100644 index 0000000..c7de605 --- /dev/null +++ b/reflex/.gitignore @@ -0,0 +1 @@ +*.map diff --git a/reflex/inrush.reflex b/reflex/inrush.reflex new file mode 100644 index 0000000..1d4e245 --- /dev/null +++ b/reflex/inrush.reflex @@ -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 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 µs–s 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); +} diff --git a/scripts/compile_inrush_reflex.sh b/scripts/compile_inrush_reflex.sh new file mode 100755 index 0000000..2818882 --- /dev/null +++ b/scripts/compile_inrush_reflex.sh @@ -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 diff --git a/tests/check_concentrator.py b/tests/check_concentrator.py old mode 100644 new mode 100755 index aef118d..8b6c9a7 --- a/tests/check_concentrator.py +++ b/tests/check_concentrator.py @@ -2,8 +2,9 @@ """ 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 -has **Location** (``local`` or remote IP/hostname, plus **tty** names when known) and **USB** -(Bus/Device, VID:PID, product from sysfs). The per-port table starts with **Panel** (``fiber_ports`` +has **Type** (BrainStem hub class), **Location** (``local`` or remote IP/hostname, plus **tty** when +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 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**. @@ -51,8 +52,10 @@ if _ROOT not in sys.path: _WIDTH = 62 _paths_configured = False +_HUB_TYPE_COL_W = 14 _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 = ( f"{'Panel':<8} | {'Hub#':<4} | {'Serial':<12} | {'Pt':<3} | " @@ -78,6 +81,8 @@ class ConsolidatedHubRow: usb_tty_hint: str = "" #: sysfs / ``lsusb``-style line: Bus/Dev, ID ``24ff:…``, product string. usb_identity_hint: str = "" + #: BrainStem stem class / ``defs.model_name`` (e.g. ``USBHub3p``); ``?`` if unknown. + hub_type: str = "?" def _rule(char: str = "-") -> str: @@ -117,9 +122,64 @@ def _remote_location_label(ssh_target: str) -> str: 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`. """ specs = c._enumerate_usb_specs() @@ -128,7 +188,7 @@ def _collect_local_hub_rows(c: FiWiConcentrator) -> list[tuple[str, str]]: if not c.hubs: c._connect_specs(specs) by_sn = c._serial_to_opened_port_count() - out: list[tuple[str, str]] = [] + out: list[tuple[str, str, str]] = [] for spec in specs: sn = spec.serial_number 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) ports = str(inf) if inf is not None else "?" ports += " *" - out.append((sn_s, ports)) + out.append((sn_s, ports, _local_hub_type_for_spec(c, spec))) return out @@ -304,7 +364,13 @@ def _build_consolidated_hub_rows(c: FiWiConcentrator) -> tuple[list[Consolidated rc = 0 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( ConsolidatedHubRow( 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), location=loc, ssh_target=ssh_target, + hub_type=hub_type, ) ) - for serial, ports in _collect_local_hub_rows(c): - _push(serial, ports, "local", None) + for serial, ports, ht in _collect_local_hub_rows(c): + _push(serial, ports, "local", None, ht) hosts = _merged_hub_hosts() if not hosts: @@ -344,8 +411,10 @@ def _build_consolidated_hub_rows(c: FiWiConcentrator) -> tuple[list[Consolidated ) rc = 1 continue + remote_types = _parse_serial_to_hub_type_from_hostcards_stdout(out) 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: print(f" ! Remote {host} ({loc}): {exc}", flush=True) rc = 1 @@ -838,8 +907,10 @@ def _print_usb_hub_tables(c: FiWiConcentrator, hub_rows: list[ConsolidatedHubRow print(_CONSOLIDATED_HUB_HDR, flush=True) print("-" * len(_CONSOLIDATED_HUB_HDR), flush=True) 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( - 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, ) print(flush=True) diff --git a/tests/check_inrush.py b/tests/check_inrush.py new file mode 100644 index 0000000..91083c6 --- /dev/null +++ b/tests/check_inrush.py @@ -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()) diff --git a/tests/check_remote.py b/tests/check_remote.py old mode 100644 new mode 100755