From 61b249a98a7589e53207bd37da1e16d4a9fdc225 Mon Sep 17 00:00:00 2001 From: mills Date: Wed, 10 Jun 2026 00:52:58 -0700 Subject: [PATCH 1/3] test(firmware): gated USB flash round-trip integration test Drives the real flipperzero_firmware_install tool over USB to flash the device to the opposite firmware flavor and back, asserting after_confirmed and the observed flavor each direction. Restores the device to its starting flavor so it leaves no net change. Guarded behind FLIPPER_RUN_FLASH_TEST so it never runs in the ordinary integration/usb sweep (each direction reboots the device and takes minutes). Part of #99 Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/integration/test_firmware_flash.py | 74 ++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/integration/test_firmware_flash.py diff --git a/tests/integration/test_firmware_flash.py b/tests/integration/test_firmware_flash.py new file mode 100644 index 0000000..3d5d559 --- /dev/null +++ b/tests/integration/test_firmware_flash.py @@ -0,0 +1,74 @@ +"""End-to-end USB firmware-flash round-trip against a real Flipper. + +Destructive and slow: each flash pushes a full update bundle, reboots the +device into the on-device updater, and waits for it to come back (minutes per +direction). Guarded behind ``FLIPPER_RUN_FLASH_TEST`` so it never runs as part +of the ordinary ``integration``/``usb`` sweep. The test restores the device to +the firmware flavor it started on, so it leaves no net change. +""" + +from __future__ import annotations + +import os + +import pytest +from fastmcp import Client + +from flipperzero_mcp.config import FlipperConfig +from flipperzero_mcp.firmware.flavor import FirmwareFlavor, classify +from flipperzero_mcp.rpc.client import FlipperClient +from flipperzero_mcp.server import create_server +from flipperzero_mcp.transport import get_transport + +_FLASH_GATE = "FLIPPER_RUN_FLASH_TEST" +_OPPOSITE = {FirmwareFlavor.OFFICIAL: "momentum", FirmwareFlavor.MOMENTUM: "official"} + + +async def _read_identity() -> tuple[str, FirmwareFlavor]: + """Read the device name and firmware flavor over a throwaway connection.""" + cfg = FlipperConfig(_env_file=None, transport="usb") # ty: ignore[unknown-argument] + client = FlipperClient(get_transport("usb", cfg.as_transport_config())) + if not await client.connect() or client.rpc is None: + pytest.skip("No USB Flipper connected") + try: + info = await client.rpc.get_device_info() + finally: + await client.disconnect() + name = info.get("hardware_name") + flavor = classify(info).flavor + if not name or flavor not in _OPPOSITE: + pytest.skip(f"device identity unusable for a flash: name={name!r} flavor={flavor}") + return name, flavor + + +async def _flash(client: Client, flavor: str, confirm: str) -> dict: + source = {"flavor": flavor, "channel": "release", "version": "latest"} + result = await client.call_tool( + "flipperzero_firmware_install", {"source": source, "confirm": confirm} + ) + return result.data + + +@pytest.mark.integration +@pytest.mark.usb +async def test_usb_firmware_flash_roundtrip(): + if not os.environ.get(_FLASH_GATE): + pytest.skip(f"set {_FLASH_GATE}=1 to run the destructive flash round-trip") + + name, origin = await _read_identity() + other = _OPPOSITE[origin] + + cfg = FlipperConfig( + _env_file=None, # ty: ignore[unknown-argument] + transport="usb", + enable_write_tools=True, + enable_firmware_flash=True, + ) + async with Client(create_server(cfg)) as client: + forward = await _flash(client, other, name) + assert forward["after_confirmed"] is True + assert forward["after"]["flavor"] == other + + back = await _flash(client, origin.value, name) + assert back["after_confirmed"] is True + assert back["after"]["flavor"] == origin.value From 25bf45c4b8340556b33941388a0bd077fb27d9ff Mon Sep 17 00:00:00 2001 From: mills Date: Wed, 10 Jun 2026 01:14:37 -0700 Subject: [PATCH 2/3] fix(firmware): raise reconnect budget to cover slow Momentum apply End-to-end USB validation flashed Official -> Momentum successfully, but the tool raised "device did not reconnect after the update" because the Momentum bundle's larger resources made the on-device updater re-enumerate just past the 300 s budget - a false negative on a flash that had applied. The device returned to mntm-012 moments after the budget expired. Raise the budget to 600 s so the slow apply is awaited instead of misreported. Part of #99 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/flipperzero_mcp/tools/firmware.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/flipperzero_mcp/tools/firmware.py b/src/flipperzero_mcp/tools/firmware.py index 8d0413f..4656f8d 100644 --- a/src/flipperzero_mcp/tools/firmware.py +++ b/src/flipperzero_mcp/tools/firmware.py @@ -16,7 +16,11 @@ from flipperzero_mcp.tools._common import ensure_connected, get_rpc, require_firmware_flash _PKG_NAME = "mcp-update" -_RECONNECT_BUDGET_S = 300.0 +# The device is off the bus while the on-device updater applies the bundle and +# reboots. A larger firmware (Momentum's resources) was observed to re-enumerate +# just past 300 s, so the old budget raised a false "did not reconnect" on an +# update that had in fact succeeded; 600 s covers the slow apply. +_RECONNECT_BUDGET_S = 600.0 async def _resolve(source: dict[str, Any], target: str) -> Any: From 2ced6b576c3064c8696f3402bafb1ce93e0d447a Mon Sep 17 00:00:00 2001 From: mills Date: Wed, 10 Jun 2026 01:30:31 -0700 Subject: [PATCH 3/3] test(firmware): always attempt restore flash in round-trip A forward-flash assertion failure previously skipped the flash back, leaving the device on the opposite firmware despite the docstring's no-net-change promise. Run the restore in a finally block and type the helper return. Co-Authored-By: Claude Fable 5 --- tests/integration/test_firmware_flash.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/integration/test_firmware_flash.py b/tests/integration/test_firmware_flash.py index 3d5d559..ad388ed 100644 --- a/tests/integration/test_firmware_flash.py +++ b/tests/integration/test_firmware_flash.py @@ -3,13 +3,16 @@ Destructive and slow: each flash pushes a full update bundle, reboots the device into the on-device updater, and waits for it to come back (minutes per direction). Guarded behind ``FLIPPER_RUN_FLASH_TEST`` so it never runs as part -of the ordinary ``integration``/``usb`` sweep. The test restores the device to -the firmware flavor it started on, so it leaves no net change. +of the ordinary ``integration``/``usb`` sweep. The test always attempts to +flash back to the starting flavor, even when the forward flash fails, so a +green or assertion-failed run leaves no net change; only a wedged device can +strand the swap. """ from __future__ import annotations import os +from typing import Any import pytest from fastmcp import Client @@ -41,7 +44,7 @@ async def _read_identity() -> tuple[str, FirmwareFlavor]: return name, flavor -async def _flash(client: Client, flavor: str, confirm: str) -> dict: +async def _flash(client: Client, flavor: str, confirm: str) -> dict[str, Any]: source = {"flavor": flavor, "channel": "release", "version": "latest"} result = await client.call_tool( "flipperzero_firmware_install", {"source": source, "confirm": confirm} @@ -65,10 +68,11 @@ async def test_usb_firmware_flash_roundtrip(): enable_firmware_flash=True, ) async with Client(create_server(cfg)) as client: - forward = await _flash(client, other, name) - assert forward["after_confirmed"] is True - assert forward["after"]["flavor"] == other - - back = await _flash(client, origin.value, name) + try: + forward = await _flash(client, other, name) + assert forward["after_confirmed"] is True + assert forward["after"]["flavor"] == other + finally: + back = await _flash(client, origin.value, name) assert back["after_confirmed"] is True assert back["after"]["flavor"] == origin.value