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:
parent
c3df0145ea
commit
3244f68d67
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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@<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”**.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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@<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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue