From 3244f68d67ee5f663b7e49edd49b60d6b8eb5eef Mon Sep 17 00:00:00 2001 From: Robert McMahon Date: Fri, 10 Apr 2026 18:22:33 -0700 Subject: [PATCH] Monsoon discovery via Linux sysfs; lab INI docs and --list-power-devices - discovery: count Monsoon HVPM from /sys/bus/usb/devices when pyserial has no TTY (matches lsusb 2ab9:0001; official stack uses libusb/HID not ttyACM) - power CLI: --list-power-devices INI; inventory_verify dump helpers - docs: lab INI reference, verification checklist, Monsoon note; README/install pointers - tests: sysfs monsoon unit test, dump_inventory local-only test Made-with: Cursor --- README.md | 2 + docs/install.md | 10 +- docs/power-control-and-inventory.md | 118 +++++++++++++++++++++- src/fiwicontrol/power/__main__.py | 20 +++- src/fiwicontrol/power/discovery.py | 97 ++++++++++++++++-- src/fiwicontrol/power/inventory_verify.py | 77 +++++++++++++- tests/test_inventory_config.py | 23 ++++- tests/test_power_discovery.py | 22 ++++ 8 files changed, 351 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 51d23fc..373583a 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ from fiwicontrol.commands import ssh_node, Command, CommandManager ## Tests +After install, use the ordered **verification checklist** and **INI reference** in **`docs/power-control-and-inventory.md`** (sections **“Verification checklist (after install)”** and **“Lab INI file reference”**). + Commands, example **`pytest`** output, and **`unittest`** entry points for **`node_control`**: **`docs/node-control-asyncio-design.md`** → section **“Running tests”** (top-level `##` heading near the top of the file). Layout / import smoke (no network): diff --git a/docs/install.md b/docs/install.md index d69fcc1..f49122c 100644 --- a/docs/install.md +++ b/docs/install.md @@ -18,7 +18,7 @@ Use this order the first time you (or someone else) joins the project or replace | 3 | Workstation → rig | **Passwordless SSH as root:** **`ssh -o BatchMode=yes root@192.168.1.39 true`** must exit **0** with no password prompt. On the rig, install your workstation’s **public key** in **`/root/.ssh/authorized_keys`** and ensure **`sshd`** allows **`root`** with pubkey auth. | | 4 | Rig (e.g. Pi) | **`git clone`** the same repo where you will run power/discovery (often **`/root/Code/FiWiControl`**). Your **`--remote-repo`** path must match. | | 5 | Workstation | From your clone, run **`--setup-remote`** (PEP 668 / Raspberry Pi OS needs **`--break-system-packages`** or **`FIWI_REMOTE_PIP_FLAGS`**). See **Remote host setup** §3–§4 below. | -| 6 | Workstation | **Verify**, in order: **`pytest -q tests/test_package_layout.py`**, then **`FIWI_REMOTE_IP=… pytest -q tests/test_node_control.py`**, then **`pytest -q tests/test_remote_power_dependencies.py`**. Default **`FIWI_REMOTE_IP`** is **`192.168.1.39`** if unset. | +| 6 | Workstation | **Verify** (minimal): **`pytest -q tests/test_package_layout.py`**, then **`FIWI_REMOTE_IP=… pytest -q tests/test_node_control.py`**, then **`pytest -q tests/test_remote_power_dependencies.py`**. Default **`FIWI_REMOTE_IP`** is **`192.168.1.39`** if unset. For a full post-install checklist (INI, **`--verify-inventory`**, …), see **`docs/power-control-and-inventory.md`** → **“Verification checklist (after install)”**. | **SSH user:** **`ssh_node`** ( **`sshtype="ssh"`** ) always uses an explicit **`root@`** (or whatever **`ssh_session`** **`user`** is set to). It does **not** open **`ssh 192.168.1.39`** with your local login name, so your manual tests should use **`root@…`** as well. @@ -133,15 +133,17 @@ export FIWI_REMOTE_PYTHON=python3.11 # or full path, e.g. /usr/bin/python3.11 That variable is read by **`discover_devices_remote_async`**, **`tests/test_remote_power_dependencies.py`**, and **`python3 -m fiwicontrol.power --setup-remote --remote-python …`**. -### 5. Verify +### 5. Verify after install -From Fedora (needs the same SSH target as **`FIWI_REMOTE_IP`**): +Quick remote sanity (imports + **`python -m fiwicontrol.power --discovery-json`** over SSH; needs **`FIWI_REMOTE_IP`** if not **`192.168.1.39`**): ```bash pytest -q tests/test_remote_power_dependencies.py ``` -Power discovery, INI inventory, and **`--verify-inventory`**: **`docs/power-control-and-inventory.md`**. +**Full checklist for a new machine** (local discovery, **`ssh root@…`**, remote **`fiwicontrol`**, INI editing, **`--list-power-devices`**, **`--verify-inventory`**, optional pytest): **`docs/power-control-and-inventory.md`** → **“Verification checklist (after install)”**. + +**INI layout** (**`[site]`**, **`[node.*]`**, **`[inventory.host.*]`**, **`acroname`** / **`monsoon_count`**): same doc → **“Lab INI file reference”**. --- diff --git a/docs/power-control-and-inventory.md b/docs/power-control-and-inventory.md index f9d590b..cc4b21a 100644 --- a/docs/power-control-and-inventory.md +++ b/docs/power-control-and-inventory.md @@ -3,7 +3,72 @@ **Package:** **`fiwicontrol.power`** **Code:** `src/fiwicontrol/power/` (discovery, **`AcronamePower`**, **`MonsoonPower`**, **`Power`** facade, INI inventory helpers). -This document describes **what must be installed on remote rigs** (e.g. a Raspberry Pi 5) when you use **remote discovery** or **`--verify-inventory`** with `mode = remote` in your INI. +This document describes **what must be installed on remote rigs** (e.g. a Raspberry Pi 5) when you use **remote discovery** or **`--verify-inventory`** with `mode = remote` in your INI. It also explains **how to edit the lab INI** and **how to verify** a new install end-to-end. + +--- + +## Lab INI file reference (`configs/*.ini`) + +Power inventory files are normal INI files (Python **`configparser`**). FiWiControl reads **`[site]`**, every **`[node.]`**, and every **`[inventory.host.]`**. Other sections or keys are ignored unless you add tooling that reads them. + +Copy **`configs/clubhouse.ini`** as a template, or start from the minimal skeleton at the end of this section. + +### `[site]` (optional) + +| Key | Meaning | +|-----|---------| +| **`name`** | Human-readable site label (printed by **`--verify-inventory`**). | + +### `[node.]` — SSH targets for `mode = remote` + +`` is the section suffix (e.g. **`raspberry_pi5`** in **`[node.raspberry_pi5]`**). Remote inventory rows reference this id with **`node = `**. + +| Key | Required | Meaning | +|-----|----------|---------| +| **`ipaddr`** | yes | Address passed to **`ssh_node`** (passwordless **`root@`**). Use **`127.0.0.1`** only for documentation symmetry; **`mode = local`** does not use SSH. | +| **`sshtype`** | no (default **`ssh`**) | Must be **`ssh`** for standard OpenSSH automation. | +| **`label`**, **`description`** | no | Comments for humans; not used by discovery. | + +Every remote **`[inventory.host.*]`** must set **`node = `** where **``** matches a **`[node.]`** section. + +### `[inventory.host.]` — expected USB lab devices + +`` is a short label (e.g. **`localhost`**, **`pi5`**). Each section describes **one machine** whose live discovery is compared to your expectations. + +| Key | Required | Meaning | +|-----|----------|---------| +| **`mode`** | no (default **`local`**) | **`local`** — run **`python3 -m fiwicontrol.power --discovery-json`** on the workstation that runs the command. **`remote`** — run the same over SSH using **`[node.]`**. | +| **`node`** | required if **`mode = remote`** | Must match **`[node.]`** section id (suffix after **`node.`**). | +| **`acroname`** | no (default empty) | Comma-separated list of **`StemClass:downstream_usb_ports`**. Each entry is one expected Acroname / BrainStem module (by **stem class** and **downstream USB port count**). Order does not matter; duplicates are allowed (**multiset** match vs discovery). Example: **`USBHub2x4:4, USBHub3p:8`**. | +| **`monsoon_count`** | no (default **`0`**) | Exact number of Monsoon-class USB serial devices discovery must find on that host. | + +**Finding stem names and port counts:** run **`python3 -m fiwicontrol.power --discovery-json`** on the host (or **`--list-power-devices your.ini`** once you have a draft INI) and read each Acroname row’s **`stem_class`** and **`downstream_usb_ports`**. Those strings and integers must match your **`acroname`** entries. + +**Empty hardware:** **`acroname`** may be left blank and **`monsoon_count = 0`** if that host has no power/USB inventory to track. + +### Minimal example + +```ini +[site] +name = MyLab + +[node.pi] +ipaddr = 192.168.1.50 +sshtype = ssh + +[inventory.host.workstation] +mode = local +acroname = +monsoon_count = 0 + +[inventory.host.pi] +mode = remote +node = pi +acroname = USBHub2x4:4 +monsoon_count = 0 +``` + +At least one **`[inventory.host.*]`** section is required; otherwise loading the INI fails. --- @@ -31,6 +96,12 @@ If **`fiwicontrol`** is only on `PYTHONPATH` (no install), ensure the remote she **First-time provisioning** (clone on the Pi, **`pip install -e '.[power]'`** from your workstation over SSH, PEP 668, smoke tests): see **`docs/install.md`** → **“Bring up systems”** and **“Remote host setup”**. +**List attached power/USB devices on every inventory host** (this machine + each `mode=remote` SSH target; addresses in each section header): + +```bash +python3 -m fiwicontrol.power --list-power-devices configs/clubhouse.ini +``` + **Sanity on the Pi** (after install): ```bash @@ -40,14 +111,53 @@ python3 -m fiwicontrol.power --discovery-json The second command should print JSON with top-level keys **`acroname`** and **`monsoon`** (possibly empty lists if nothing is plugged in). Errors such as **`acroname_error`** / **`monsoon_error`** in that JSON usually mean a missing dependency or no access to USB devices. +**Monsoon note:** the HVPM often shows up in **`lsusb`** as **`2ab9:0001`** but **does not create a `/dev/ttyACM*`** (upstream tools use **libusb**, not a serial TTY). FiWiControl counts Monsoon for discovery/inventory using **pyserial when a TTY exists**, and on **Linux** also matches the same VID/PID under **`/sys/bus/usb/devices`**, so **`monsoon`** can still reflect **`lsusb`** even when **`list_ports`** is empty. + +--- + +## Verification checklist (after install) + +Run these **in order** on your **workstation** (repo root, **`pip install -e ".[power]"`** for anything that touches USB discovery). Fix each step before moving on. + +1. **Package imports (workstation)** + `python3 -c "import fiwicontrol.commands, fiwicontrol.power; print('ok')"` + +2. **Local USB discovery (workstation)** + `python3 -m fiwicontrol.power --discovery-json` — expect JSON with **`acroname`** / **`monsoon`** (or **`*_error`** keys if dependencies/USB access are wrong). + +3. **SSH to the rig (non-interactive)** + `ssh -o BatchMode=yes root@ true` — exit **0**, no password prompt. + +4. **Remote Python + fiwicontrol (rig)** + `ssh -o BatchMode=yes root@ 'python3 -c "import fiwicontrol.power; print(\"ok\")"'` + +5. **Remote discovery (rig)** + Same SSH, run **`python3 -m fiwicontrol.power --discovery-json`** and confirm JSON shape (as in step 2). + +6. **Automated remote dependency test (workstation)** + `export FIWI_REMOTE_IP=` if not using the default, then **`pytest -q tests/test_remote_power_dependencies.py`**. + +7. **SSH command path (workstation)** + `FIWI_REMOTE_IP= pytest -q tests/test_node_control.py` — exercises **`ssh_node`** / **`rexec`** against **`root@`**. + +8. **INI matches your lab (workstation)** + Edit **`configs/*.ini`** (see **Lab INI file reference** above). Then: + **`python3 -m fiwicontrol.power --list-power-devices configs/your.ini`** — confirms discovery per host with headers showing addresses. + **`python3 -m fiwicontrol.power --verify-inventory configs/your.ini`** — must print **`OK: discovery matches INI…`** (exit **0**). If you see **`MISMATCH`**, adjust **`acroname`** / **`monsoon_count`** or fix hardware/cabling. + +9. **Optional strict pytest (workstation)** + **`FIWI_VERIFY_POWER_INI=1 pytest -q tests/test_inventory_verify_live.py`** — opt-in live test that compares discovery to **`configs/clubhouse.ini`** (edit the test or your INI if your lab uses a different file). + +**Environment variables** (see **`docs/install.md`**): **`FIWI_REMOTE_IP`**, **`FIWI_REMOTE_PYTHON`** (if **`python3`** on the rig is not the interpreter **`pip`** used), **`FIWI_REMOTE_PIP_FLAGS`** (PEP 668 on the Pi). + --- ## Local vs remote in `configs/*.ini` -- **`[inventory.host.]`** with **`mode = local`** — discovery runs on the machine where you invoke **`python -m fiwicontrol.power --verify-inventory ...`**. -- **`mode = remote`** with **`node = `** — resolves **`[node.]`** (e.g. **`ipaddr`**) and runs **`python3 -m fiwicontrol.power --discovery-json`** over SSH on that host. +- **`[inventory.host.]`** with **`mode = local`** — discovery runs on the machine where you invoke **`python -m fiwicontrol.power --verify-inventory ...`** (or **`--list-power-devices`**). +- **`mode = remote`** with **`node = `** — resolves **`[node.]`** (e.g. **`ipaddr`**) and runs **`python3 -m fiwicontrol.power --discovery-json`** over SSH on that host as **`root`**. -See comments in **`configs/clubhouse.ini`** for the expected **`acroname = Stem:ports, ...`** and **`monsoon_count`** format. +See **`configs/clubhouse.ini`** for a full example and inline comments. --- diff --git a/src/fiwicontrol/power/__main__.py b/src/fiwicontrol/power/__main__.py index 7baa7a2..57adfa5 100644 --- a/src/fiwicontrol/power/__main__.py +++ b/src/fiwicontrol/power/__main__.py @@ -2,7 +2,7 @@ # # Licensed under the Apache License, Version 2.0; see LICENSE. # -"""Run ``python -m fiwicontrol.power --discovery-json`` on a lab host (local or over SSH).""" +"""CLI: ``--discovery-json``, ``--list-power-devices``, ``--verify-inventory``, ``--setup-remote``.""" from __future__ import annotations @@ -10,7 +10,7 @@ import argparse import sys from fiwicontrol.power.discovery import discovery_json -from fiwicontrol.power.inventory_verify import main_verify +from fiwicontrol.power.inventory_verify import main_dump_discovery, main_verify from fiwicontrol.power.remote_setup import main_setup @@ -27,6 +27,17 @@ def main(argv: list[str] | None = None) -> int: action="store_true", help="Print Acroname + Monsoon discovery as JSON (stdout).", ) + parser.add_argument( + "--list-power-devices", + dest="list_power_devices_ini", + metavar="INI", + type=str, + default=None, + help=( + "Load INI and print BrainStem/Monsoon discovery for each [inventory.host.*] " + "(local: workstation addresses; remote: SSH target IP from [node.*])." + ), + ) parser.add_argument( "--verify-inventory", dest="verify_ini", @@ -84,6 +95,11 @@ def main(argv: list[str] | None = None) -> int: help="With --setup-remote: host or user@host (default: $FIWI_REMOTE_IP or 192.168.1.39).", ) args = parser.parse_args(argv) + if args.list_power_devices_ini: + sub = [args.list_power_devices_ini] + if args.ssh_controlmaster: + sub.append("--ssh-controlmaster") + return main_dump_discovery(sub) if args.setup_remote: sargv: list[str] = [] if args.setup_host: diff --git a/src/fiwicontrol/power/discovery.py b/src/fiwicontrol/power/discovery.py index 18ee07c..8a6df53 100644 --- a/src/fiwicontrol/power/discovery.py +++ b/src/fiwicontrol/power/discovery.py @@ -2,7 +2,7 @@ # # Licensed under the Apache License, Version 2.0; see LICENSE. # -"""Local discovery for Acroname (BrainStem) and Monsoon (USB-serial) lab devices.""" +"""Local discovery for Acroname (BrainStem) and Monsoon (USB) lab devices.""" from __future__ import annotations @@ -10,7 +10,9 @@ import asyncio import json import logging import os +import sys from dataclasses import asdict, dataclass +from pathlib import Path from typing import Any, Type logger = logging.getLogger(__name__) @@ -146,16 +148,82 @@ def discover_acroname_modules() -> list[AcronameModuleInfo]: return out +def _discover_monsoon_sysfs_usb( + *, + vid: int, + pids: tuple[int, ...] | None, + sysfs_usb_devices: Path, +) -> list[MonsoonPortInfo]: + """ + Monsoon HVPM often binds as a USB HID/bulk device (see upstream PyMonsoon / libusb), + so there is no ``/dev/ttyACM*`` for :mod:`serial.tools.list_ports` to see. On Linux, + walk ``sysfs`` for matching ``idVendor`` / ``idProduct`` and return one row per device + using ``/dev/bus/usb//`` when available (suitable for inventory counts). + """ + if not sysfs_usb_devices.is_dir(): + return [] + found: list[MonsoonPortInfo] = [] + for devpath in sorted(sysfs_usb_devices.iterdir()): + if not devpath.is_dir(): + continue + vid_f, pid_f = devpath / "idVendor", devpath / "idProduct" + if not vid_f.is_file() or not pid_f.is_file(): + continue + try: + v = int(vid_f.read_text(encoding="utf-8").strip(), 16) + prod = int(pid_f.read_text(encoding="utf-8").strip(), 16) + except ValueError: + continue + if v != vid: + continue + if pids is not None and prod not in pids: + continue + bus_f, num_f = devpath / "busnum", devpath / "devnum" + device_str: str + if bus_f.is_file() and num_f.is_file(): + try: + b = bus_f.read_text(encoding="utf-8").strip() + d = num_f.read_text(encoding="utf-8").strip() + device_str = "/dev/bus/usb/{}/{}".format(b, d) + except OSError: + device_str = "sysfs:" + devpath.name + else: + device_str = "sysfs:" + devpath.name + sn: str | None = None + ser_f = devpath / "serial" + if ser_f.is_file(): + try: + sn = ser_f.read_text(encoding="utf-8").strip() or None + except OSError: + sn = None + found.append( + MonsoonPortInfo( + device=device_str, + serial_number=sn, + vid=v, + pid=prod, + manufacturer=None, + product=None, + hwid="sysfs:{}".format(devpath.name), + ) + ) + return found + + +def _monsoon_row_key(m: MonsoonPortInfo) -> tuple[str, str | None, int | None, int | None]: + return (m.device, m.serial_number, m.vid, m.pid) + + def discover_monsoon_serial_ports( *, vid: int = MONSOON_USB_VID, pids: tuple[int, ...] | None = MONSOON_USB_PIDS, ) -> list[MonsoonPortInfo]: """ - List serial ports whose USB identity matches a Monsoon power monitor. - - On Linux (e.g. Raspberry Pi with Monsoon on USB), ports are usually ``/dev/ttyUSB*`` - or ``/dev/ttyACM*``. Requires ``pyserial`` (``pip install 'fiwicontrol[power]'``). + List Monsoon-class devices: **pyserial** ports (``ttyACM`` / ``ttyUSB`` when the + kernel exposes them) plus, on **Linux**, matching USB devices under + ``/sys/bus/usb/devices`` (HVPM often appears only here — ``lsusb`` shows it but there + is no TTY). Requires ``pyserial`` for the serial path (``pip install 'fiwicontrol[power]'``). """ from serial.tools import list_ports @@ -176,6 +244,16 @@ def discover_monsoon_serial_ports( hwid=p.hwid, ) ) + seen = {_monsoon_row_key(m) for m in found} + serial_numbers = {m.serial_number for m in found if m.serial_number} + if sys.platform.startswith("linux"): + for m in _discover_monsoon_sysfs_usb(vid=vid, pids=pids, sysfs_usb_devices=Path("/sys/bus/usb/devices")): + if m.serial_number and m.serial_number in serial_numbers: + continue + k = _monsoon_row_key(m) + if k not in seen: + seen.add(k) + found.append(m) return found @@ -246,7 +324,14 @@ async def discover_devices_remote_async( text = session.results.decode("utf-8", errors="replace").strip() if not text: raise RuntimeError("empty discovery output from remote host") - return json.loads(text) + try: + return json.loads(text) + except json.JSONDecodeError as exc: + preview = text if len(text) <= 4000 else text[:4000] + "\n… (truncated)" + raise RuntimeError( + "remote host did not return JSON for {!r} (SSH failure, wrong interpreter, or " + "non-discovery output on stdout). Raw output:\n{}".format(cmd, preview) + ) from exc def monsoon_port_info_from_mapping(row: dict[str, Any]) -> MonsoonPortInfo: diff --git a/src/fiwicontrol/power/inventory_verify.py b/src/fiwicontrol/power/inventory_verify.py index 4769141..923d056 100644 --- a/src/fiwicontrol/power/inventory_verify.py +++ b/src/fiwicontrol/power/inventory_verify.py @@ -7,9 +7,11 @@ from __future__ import annotations import asyncio +import json +import subprocess import sys from pathlib import Path -from typing import Any +from typing import Any, TextIO from fiwicontrol.power.discovery import ( AcronameModuleInfo, @@ -20,6 +22,79 @@ from fiwicontrol.power.discovery import ( from fiwicontrol.power.inventory_config import HostInventorySpec, InventoryDocument, load_inventory_ini +def _workstation_ipv4_addrs() -> str: + """Space-separated IPv4 addresses from ``hostname -I``, or ``?`` if unavailable.""" + try: + out = subprocess.check_output(["hostname", "-I"], text=True, timeout=3).strip() + return out or "?" + except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired): + return "?" + + +async def dump_inventory_discovery_ini( + path: str | Path, + *, + ssh_controlmaster: bool = False, + file: TextIO | None = None, +) -> None: + """ + For each ``[inventory.host.*]``, print a header (host name + local addresses or remote IP) + and the same discovery JSON structure as :func:`discovery_payload_dict` / remote ``rexec``. + """ + from fiwicontrol.commands.node_control import ssh_node + + doc = load_inventory_ini(path) + out = file or sys.stdout + for spec in doc.hosts: + if spec.mode == "local": + print( + "=== {} (mode=local; addresses: {}) ===".format(spec.name, _workstation_ipv4_addrs()), + file=out, + ) + payload = discovery_payload_dict() + else: + assert spec.ipaddr is not None + print("=== {} (mode=remote; {}) ===".format(spec.name, spec.ipaddr), file=out) + node = ssh_node( + name=spec.name, + ipaddr=spec.ipaddr, + ssh_controlmaster=ssh_controlmaster, + sshtype=spec.sshtype, + silent_mode=True, + ) + payload = await discover_devices_remote_async(node) + print(json.dumps(payload, indent=2), file=out) + print(file=out) + + +def dump_inventory_discovery_ini_sync( + path: str | Path, + *, + ssh_controlmaster: bool = False, + file: TextIO | None = None, +) -> None: + asyncio.run(dump_inventory_discovery_ini(path, ssh_controlmaster=ssh_controlmaster, file=file)) + + +def main_dump_discovery(argv: list[str] | None) -> int: + import argparse + + p = argparse.ArgumentParser(description="Print BrainStem/Monsoon discovery per [inventory.host.*] entry.") + p.add_argument("ini", type=Path, help="INI path (e.g. configs/clubhouse.ini)") + p.add_argument( + "--ssh-controlmaster", + action="store_true", + help="Use ssh_node ControlMaster mode for remote hosts.", + ) + args = p.parse_args(argv) + try: + dump_inventory_discovery_ini_sync(args.ini, ssh_controlmaster=args.ssh_controlmaster) + except Exception as exc: + print("Failed: {}".format(exc), file=sys.stderr) + return 1 + return 0 + + def _acroname_multiset(infos: list[AcronameModuleInfo]) -> list[tuple[str, int]]: rows: list[tuple[str, int]] = [] for i in infos: diff --git a/tests/test_inventory_config.py b/tests/test_inventory_config.py index 6dc6b12..ffc976f 100644 --- a/tests/test_inventory_config.py +++ b/tests/test_inventory_config.py @@ -5,12 +5,13 @@ from __future__ import annotations import textwrap +from io import StringIO from pathlib import Path import pytest from fiwicontrol.power.inventory_config import load_inventory_ini -from fiwicontrol.power.inventory_verify import compare_host_inventory +from fiwicontrol.power.inventory_verify import compare_host_inventory, dump_inventory_discovery_ini_sync def test_load_clubhouse_ini(tmp_path: Path) -> None: @@ -120,3 +121,23 @@ def test_parse_acroname_invalid(tmp_path: Path) -> None: ) with pytest.raises(ValueError, match="Stem:ports"): load_inventory_ini(p) + + +def test_dump_inventory_discovery_local_only(tmp_path: Path) -> None: + ini = tmp_path / "solo.ini" + ini.write_text( + textwrap.dedent( + """ + [inventory.host.solo] + mode = local + acroname = + monsoon_count = 0 + """ + ).strip(), + encoding="utf-8", + ) + buf = StringIO() + dump_inventory_discovery_ini_sync(ini, file=buf) + text = buf.getvalue() + assert "=== solo (mode=local;" in text + assert '"acroname"' in text and '"monsoon"' in text diff --git a/tests/test_power_discovery.py b/tests/test_power_discovery.py index 3da67ec..ea9931e 100644 --- a/tests/test_power_discovery.py +++ b/tests/test_power_discovery.py @@ -12,6 +12,8 @@ import pytest from fiwicontrol.power.discovery import ( MONSOON_USB_VID, + MONSOON_USB_PIDS, + _discover_monsoon_sysfs_usb, acroname_module_info_from_spec, discover_monsoon_serial_ports, discovery_json, @@ -42,6 +44,26 @@ def test_acroname_module_info_from_spec_layout(model_id, stem, downstream, entit assert stem in info.model_name or info.model_name.startswith("USB") +def test_discover_monsoon_sysfs_usb(tmp_path): + dev = tmp_path / "5-2" + dev.mkdir() + (dev / "idVendor").write_text("2ab9\n", encoding="utf-8") + (dev / "idProduct").write_text("0001\n", encoding="utf-8") + (dev / "busnum").write_text("5\n", encoding="utf-8") + (dev / "devnum").write_text("2\n", encoding="utf-8") + (dev / "serial").write_text("HVPM-TEST\n", encoding="utf-8") + rows = _discover_monsoon_sysfs_usb( + vid=MONSOON_USB_VID, + pids=MONSOON_USB_PIDS, + sysfs_usb_devices=tmp_path, + ) + assert len(rows) == 1 + assert rows[0].device == "/dev/bus/usb/5/2" + assert rows[0].vid == MONSOON_USB_VID + assert rows[0].pid == 0x0001 + assert rows[0].serial_number == "HVPM-TEST" + + def test_discover_monsoon_serial_ports_filters_vid_pid(): pytest.importorskip("serial") fake = SimpleNamespace(