Add USB camera and uhubctl support for new test suite. Also included some bug fixes (#10204)
* Add USB camera and uhubctl support for new test suite. Also added some bug fixes * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Refactor test messages for clarity and consistency in regex tests --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
GitHub
Copilot Autofix powered by AI
parent
6b15571e14
commit
de23e5199d
@@ -0,0 +1,6 @@
|
||||
"""Recovery tier — exercises `uhubctl` power control end-to-end.
|
||||
|
||||
Requires `uhubctl` installed AND at least one connected device on a
|
||||
PPPS-capable hub. The whole tier skips cleanly via
|
||||
`tests/recovery/conftest.py::_recovery_tier_guard` when either is missing.
|
||||
"""
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Recovery-tier gating + shared helpers.
|
||||
|
||||
Session-scoped guard skips the whole tier when uhubctl isn't installed.
|
||||
Tests under this directory assume uhubctl is callable AND that at least
|
||||
one hub role is detected on a PPPS-capable port.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _recovery_tier_guard() -> None:
|
||||
"""Skip the tier when uhubctl is unavailable OR no device is on a
|
||||
PPPS-capable hub. Prints the specific reason so operators know what
|
||||
to fix."""
|
||||
from tests import _power
|
||||
|
||||
if not _power.is_uhubctl_available():
|
||||
pytest.skip(
|
||||
"uhubctl not installed; recovery tier needs it. "
|
||||
"Install via `brew install uhubctl` or `apt install uhubctl`.",
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
# Probe: can we even list hubs? (A macOS user without sudo gets a
|
||||
# permission error here — we'd rather find out once at tier-start than
|
||||
# 6 tests later.)
|
||||
from meshtastic_mcp import uhubctl
|
||||
|
||||
try:
|
||||
hubs = uhubctl.list_hubs()
|
||||
except uhubctl.UhubctlError as exc:
|
||||
pytest.skip(
|
||||
f"uhubctl list failed: {exc}. Try the udev rules or `sudo` as a fallback.",
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
if not any(h["ppps"] for h in hubs):
|
||||
pytest.skip(
|
||||
"no PPPS-capable hubs detected — recovery tier has nothing to exercise.",
|
||||
allow_module_level=True,
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Smoke test: `uhubctl_list` returns a well-formed structure.
|
||||
|
||||
No destructive action. Runs first in the tier as a sanity check that the
|
||||
tier's dependencies (uhubctl binary + permissions) are actually satisfied.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp import uhubctl
|
||||
|
||||
|
||||
@pytest.mark.timeout(30)
|
||||
def test_list_hubs_returns_at_least_one_ppps_hub() -> None:
|
||||
hubs = uhubctl.list_hubs()
|
||||
assert hubs, "uhubctl found no hubs at all — is a USB hub connected?"
|
||||
assert any(h["ppps"] for h in hubs), (
|
||||
"no PPPS-capable hubs detected; power control won't work. "
|
||||
"Check that the hub supports Per-Port Power Switching."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.timeout(30)
|
||||
def test_list_hubs_structure(hub_devices: dict[str, str]) -> None:
|
||||
hubs = uhubctl.list_hubs()
|
||||
for hub in hubs:
|
||||
assert "location" in hub and hub["location"]
|
||||
assert "ports" in hub and isinstance(hub["ports"], list)
|
||||
for port in hub["ports"]:
|
||||
assert "port" in port and isinstance(port["port"], int)
|
||||
assert "status" in port
|
||||
|
||||
# At least one of the detected Meshtastic roles should show up in some
|
||||
# port's device_vid — otherwise the recovery tier can't drive them.
|
||||
seen_vids = {
|
||||
p["device_vid"] for h in hubs for p in h["ports"] if p["device_vid"] is not None
|
||||
}
|
||||
expected_any = {0x239A, 0x303A, 0x10C4} & seen_vids
|
||||
assert expected_any or not hub_devices, (
|
||||
f"hub_devices detected roles {list(hub_devices)} but uhubctl sees "
|
||||
f"VIDs {sorted(hex(v) for v in seen_vids)} — the devices may be on "
|
||||
"a hub that uhubctl can't see (e.g. built-in laptop ports)."
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Hard reset via uhubctl must NOT wipe NVS. Verify the test profile's
|
||||
region + channel survive a power-cycle.
|
||||
|
||||
Guards against a regression where a firmware change treats unexpected
|
||||
power loss as a factory-reset trigger (e.g. bad EEPROM wear-leveling,
|
||||
erase-on-boot-for-safety). Such a regression would be catastrophic for
|
||||
field deployments.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp import admin, info
|
||||
from tests import _power
|
||||
from tests._port_discovery import resolve_port_by_role
|
||||
|
||||
|
||||
@pytest.mark.timeout(180)
|
||||
def test_lora_config_survives_power_cycle(
|
||||
baked_single: dict[str, object],
|
||||
test_profile: dict[str, object],
|
||||
) -> None:
|
||||
role = baked_single["role"]
|
||||
pre_port = baked_single["port"]
|
||||
|
||||
pre_config = admin.get_config(section="lora", port=pre_port)["config"]["lora"]
|
||||
pre_region = pre_config.get("region")
|
||||
pre_preset = pre_config.get("modem_preset")
|
||||
assert pre_region, f"lora.region not set pre-cycle on {role}"
|
||||
|
||||
# Hard power-cycle.
|
||||
_power.power_cycle(role, delay_s=2)
|
||||
time.sleep(0.5)
|
||||
new_port = resolve_port_by_role(role, timeout_s=30.0)
|
||||
# Let the firmware complete boot before admin reads.
|
||||
time.sleep(2.0)
|
||||
# Quick readiness probe.
|
||||
probe = info.device_info(port=new_port, timeout_s=10.0)
|
||||
assert (
|
||||
probe.get("my_node_num") is not None
|
||||
), f"device {role!r} didn't respond after power-cycle"
|
||||
|
||||
post_config = admin.get_config(section="lora", port=new_port)["config"]["lora"]
|
||||
post_region = post_config.get("region")
|
||||
post_preset = post_config.get("modem_preset")
|
||||
|
||||
assert post_region == pre_region, (
|
||||
f"lora.region wiped by power-cycle on {role}: "
|
||||
f"pre={pre_region!r} post={post_region!r}"
|
||||
)
|
||||
assert post_preset == pre_preset, (
|
||||
f"lora.modem_preset wiped by power-cycle on {role}: "
|
||||
f"pre={pre_preset!r} post={post_preset!r}"
|
||||
)
|
||||
|
||||
# Channel-0 name should also match the test profile.
|
||||
pri_ch = admin.get_channel_url(port=new_port)
|
||||
assert pri_ch.get("url"), f"channel URL empty after power-cycle on {role}"
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Full power-cycle round-trip: off → verify gone → on → verify identity
|
||||
preserved.
|
||||
|
||||
Parametrized over every connected role. Validates both the uhubctl
|
||||
plumbing AND that the device survives a hard reset with the same
|
||||
`my_node_num` (no firmware-level identity regeneration).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp import info
|
||||
from tests import _power
|
||||
from tests._port_discovery import resolve_port_by_role
|
||||
|
||||
|
||||
@pytest.mark.timeout(180)
|
||||
def test_power_cycle_preserves_node_identity(
|
||||
baked_single: dict[str, object],
|
||||
) -> None:
|
||||
role = baked_single["role"]
|
||||
pre_port = baked_single["port"]
|
||||
pre_node_num = baked_single["my_node_num"]
|
||||
pre_fw = baked_single.get("firmware_version")
|
||||
|
||||
# Record pre-cycle state.
|
||||
pre_info = info.device_info(port=pre_port, timeout_s=5.0)
|
||||
assert pre_info.get("my_node_num") == pre_node_num
|
||||
|
||||
# Power off; confirm the device actually disappears from list_devices.
|
||||
_power.power_off(role)
|
||||
try:
|
||||
_power.wait_for_absence(role, timeout_s=10.0)
|
||||
except TimeoutError:
|
||||
# If it didn't disappear, power it back on so we don't leave the
|
||||
# hub in a weird state for the next test.
|
||||
_power.power_on(role)
|
||||
resolve_port_by_role(role, timeout_s=30.0)
|
||||
pytest.fail(f"device {role!r} stayed visible after power_off")
|
||||
|
||||
# Power back on + re-discover port.
|
||||
_power.power_on(role)
|
||||
time.sleep(0.5) # head-start before polling
|
||||
new_port = resolve_port_by_role(role, timeout_s=30.0)
|
||||
|
||||
# Give the firmware a moment to finish boot before we hit it with admin.
|
||||
time.sleep(2.0)
|
||||
|
||||
post_info = info.device_info(port=new_port, timeout_s=10.0)
|
||||
assert post_info.get("my_node_num") == pre_node_num, (
|
||||
f"my_node_num changed across power-cycle: pre={pre_node_num:#x} "
|
||||
f"post={post_info.get('my_node_num'):#x}"
|
||||
)
|
||||
# Firmware version must match (same bake, not a re-flash).
|
||||
if pre_fw:
|
||||
assert post_info.get("firmware_version") == pre_fw, (
|
||||
f"firmware changed across cycle: pre={pre_fw} "
|
||||
f"post={post_info.get('firmware_version')}"
|
||||
)
|
||||
Reference in New Issue
Block a user