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
This commit is contained in:
Robert McMahon 2026-04-10 18:22:33 -07:00
parent c3df0145ea
commit 3244f68d67
8 changed files with 351 additions and 18 deletions

View File

@ -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):

View File

@ -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 workstations **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@<ipaddr>`** (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”**.
---

View File

@ -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.<id>]`**, and every **`[inventory.host.<name>]`**. 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.<id>]` — SSH targets for `mode = remote`
`<id>` is the section suffix (e.g. **`raspberry_pi5`** in **`[node.raspberry_pi5]`**). Remote inventory rows reference this id with **`node = <id>`**.
| Key | Required | Meaning |
|-----|----------|---------|
| **`ipaddr`** | yes | Address passed to **`ssh_node`** (passwordless **`root@<ipaddr>`**). 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 = <id>`** where **`<id>`** matches a **`[node.<id>]`** section.
### `[inventory.host.<name>]` — expected USB lab devices
`<name>` 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.<id>]`**. |
| **`node`** | required if **`mode = remote`** | Must match **`[node.<id>]`** 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 rows **`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@<RIG_IP> true` — exit **0**, no password prompt.
4. **Remote Python + fiwicontrol (rig)**
`ssh -o BatchMode=yes root@<RIG_IP> '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=<RIG_IP>` if not using the default, then **`pytest -q tests/test_remote_power_dependencies.py`**.
7. **SSH command path (workstation)**
`FIWI_REMOTE_IP=<RIG_IP> pytest -q tests/test_node_control.py` — exercises **`ssh_node`** / **`rexec`** against **`root@<ip>`**.
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.<name>]`** with **`mode = local`** — discovery runs on the machine where you invoke **`python -m fiwicontrol.power --verify-inventory ...`**.
- **`mode = remote`** with **`node = <id>`** — resolves **`[node.<id>]`** (e.g. **`ipaddr`**) and runs **`python3 -m fiwicontrol.power --discovery-json`** over SSH on that host.
- **`[inventory.host.<name>]`** 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 = <id>`** — resolves **`[node.<id>]`** (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.
---

View File

@ -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:

View File

@ -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/<bus>/<dev>`` 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:

View File

@ -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:

View File

@ -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

View File

@ -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(