From 78880902e0a56d7b457b8631dc654d28d47c1288 Mon Sep 17 00:00:00 2001 From: Roy Walker Date: Fri, 12 Jun 2026 14:38:08 -0500 Subject: [PATCH 01/31] First stab at implementing the "queued OTA update" feature for offline devices. When a firmware job completes successfully for a device that is currently offline, we set a new `queued_update` flag on the device. This indicates that the compiled firmware is ready and waiting to be flashed via OTA the next time the device checks in via mDNS. --- .../_device_state_monitor/controller.py | 20 ++++++++++++++++ .../controllers/firmware/controller.py | 24 +++++++++++++++++++ .../controllers/firmware/runner.py | 22 +++++++++++++++++ esphome_device_builder/models/devices.py | 3 +++ .../firmware/test_offline_queue.py | 19 +++++++++++++++ 5 files changed, 88 insertions(+) create mode 100644 tests/controllers/firmware/test_offline_queue.py diff --git a/esphome_device_builder/controllers/_device_state_monitor/controller.py b/esphome_device_builder/controllers/_device_state_monitor/controller.py index 53e7a37e9..8c0b549f6 100644 --- a/esphome_device_builder/controllers/_device_state_monitor/controller.py +++ b/esphome_device_builder/controllers/_device_state_monitor/controller.py @@ -80,6 +80,9 @@ ImportableAddedCallback = Callable[[AdoptableDevice], None] ImportableRemovedCallback = Callable[[str], None] +# Callback for when an offline device with a queued update comes online. +QueuedUpdateReadyCallback = Callable[[str], None] + class DeviceStateMonitor(TaskControllerBase): # noqa: PLR0904 (grandfathered; new public methods need a refactor first) """ @@ -101,6 +104,7 @@ def __init__( on_mac_address_change: MacAddressChangeCallback | None = None, on_importable_added: ImportableAddedCallback | None = None, on_importable_removed: ImportableRemovedCallback | None = None, + on_queued_update_ready: QueuedUpdateReadyCallback | None = None, reachability: ReachabilityTracker | None = None, is_ignored: Callable[[str], bool] | None = None, get_devices_by_name: Callable[[str], list[Device]] | None = None, @@ -125,6 +129,7 @@ def __init__( self._on_mac_address_change = on_mac_address_change self._on_importable_added = on_importable_added self._on_importable_removed = on_importable_removed + self._on_queued_update_ready = on_queued_update_ready self._is_ignored = is_ignored or (lambda _name: False) self.state = MonitorState(reachability=reachability) self._ping_task: asyncio.Task | None = None @@ -243,8 +248,23 @@ def apply(self, name: str, state: DeviceState, source: str, *, claim: bool = Fal self.state.state_source[name] = source return False + # Check if any matching device is transitioning from offline/unknown to online + # with a queued update waiting. We read device.state before the callback + # because the owning controller will mutate it to ONLINE inside _on_state_change. + trigger_queued_update = False + if state == DeviceState.ONLINE: + for d in devices: + if d.state != DeviceState.ONLINE and getattr(d, "queued_update", False): + trigger_queued_update = True + break + self.state.state_source[name] = source self._on_state_change(name, state, source) + + # Dispatch the callback to the owning controller to handle the job submission + if trigger_queued_update and self._on_queued_update_ready: + self._on_queued_update_ready(name) + return True async def refresh_mdns(self, name: str) -> None: diff --git a/esphome_device_builder/controllers/firmware/controller.py b/esphome_device_builder/controllers/firmware/controller.py index b496ab7b0..518471bc1 100644 --- a/esphome_device_builder/controllers/firmware/controller.py +++ b/esphome_device_builder/controllers/firmware/controller.py @@ -213,6 +213,30 @@ async def install( """ _validate_port(port) await self._validate_configuration_boundary(configuration) + + # --- Intercept for Offline Devices --- + device = None + if self._db.devices is not None: + for d in self._db.devices.get_devices(): + if d.configuration == configuration: + device = d + break + + if device and device.state in ("offline", "unknown"): + _LOGGER.info( + "Device %s is offline. Intercepting install to run as local compile-only for queued update.", + configuration + ) + force_local = True + build_source = self._resolve_install_source(force_local=force_local) + job = self._create_job( + configuration, + JobType.COMPILE, + build_source=build_source, + ) + return await self._enqueue(job) + # ------------------------------------- + build_source = self._resolve_install_source(force_local=force_local) # Install is a compile + a dependent local upload. The compile (local # or dispatched to a receiver) materialises the binary locally; the diff --git a/esphome_device_builder/controllers/firmware/runner.py b/esphome_device_builder/controllers/firmware/runner.py index 552dab9bb..8a808f261 100644 --- a/esphome_device_builder/controllers/firmware/runner.py +++ b/esphome_device_builder/controllers/firmware/runner.py @@ -11,6 +11,7 @@ from ...helpers.subprocess import create_subprocess_exec, iter_lines_with_progress from ...models import ( + EventType, FirmwareJob, JobSource, JobStatus, @@ -214,6 +215,27 @@ def _check_error(text: str) -> None: exit_code, ) + # --- Queued Offline Update Flagging --- + if success and job.job_type == JobType.COMPILE: + device = None + if controller._db.devices is not None: + for d in controller._db.devices.get_devices(): + if d.configuration == job.configuration: + device = d + break + + if device and device.state in ("offline", "unknown"): + _LOGGER.info( + "Compile successful for offline device %s. Setting queued_update flag.", + job.configuration, + ) + device.queued_update = True + + try: + controller._db.bus.fire(EventType.DEVICE_UPDATED, {"device": device}) + except (ImportError, AttributeError) as e: + _LOGGER.debug("Could not fire DEVICE_UPDATED event: %s", e) + except asyncio.CancelledError: # ``_tracked_subprocess`` already terminated the spawn # on its way out; this branch only needs to finalise diff --git a/esphome_device_builder/models/devices.py b/esphome_device_builder/models/devices.py index 19931f850..4002ea29d 100644 --- a/esphome_device_builder/models/devices.py +++ b/esphome_device_builder/models/devices.py @@ -70,6 +70,9 @@ class Device(DataClassORJSONMixin): web_port: int | None = None current_version: str = "" deployed_version: str = "" + # Flag to determine if a local offline compilation has finished + # successfully and is waiting to be flashed via OTA upon the next mDNS check-in. + queued_update: bool = False # 8-char hex hash of the YAML as last successfully compiled. # Persisted in the metadata sidecar; matches what ESPHome's # runtime publishes via ``App.get_config_hash()``. diff --git a/tests/controllers/firmware/test_offline_queue.py b/tests/controllers/firmware/test_offline_queue.py new file mode 100644 index 000000000..e04713be3 --- /dev/null +++ b/tests/controllers/firmware/test_offline_queue.py @@ -0,0 +1,19 @@ +import pytest + +from unittest.mock import AsyncMock, patch +from esphome_device_builder.models import DeviceState, JobType + +@pytest.mark.asyncio +async def test_install_queues_for_offline_device(firmware_controller, mock_device): + # Setup: Mock device as OFFLINE + mock_device.state = DeviceState.OFFLINE + + # Execute install request + with patch.object(firmware_controller, "_enqueue", new_callable=AsyncMock) as mock_enqueue: + await firmware_controller.install(configuration="test_device.yaml") + + # Assert: It should NOT have called install_chain (which runs upload) + # It should have called _enqueue with a COMPILE job + called_job = mock_enqueue.call_args[0][0] + assert called_job.job_type == JobType.COMPILE + assert mock_device.queued_update is False # Flag set by runner completion \ No newline at end of file From fa3336d01df250c215daba92a34d79438c5e81bf Mon Sep 17 00:00:00 2001 From: Roy Walker Date: Fri, 12 Jun 2026 16:11:54 -0500 Subject: [PATCH 02/31] Expand tests. --- .../firmware/test_offline_queue.py | 95 ++++++++++++++++++- 1 file changed, 90 insertions(+), 5 deletions(-) diff --git a/tests/controllers/firmware/test_offline_queue.py b/tests/controllers/firmware/test_offline_queue.py index e04713be3..4681b95bd 100644 --- a/tests/controllers/firmware/test_offline_queue.py +++ b/tests/controllers/firmware/test_offline_queue.py @@ -1,19 +1,104 @@ import pytest -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch +from pathlib import Path from esphome_device_builder.models import DeviceState, JobType +from esphome_device_builder.controllers.firmware.runner import FirmwareController +from esphome_device_builder.models.firmware import FirmwareState + +@pytest.fixture +def mock_device(): + """Mock device for offline update tests.""" + mock = MagicMock() + mock.state = DeviceState.OFFLINE + mock.queued_update = False + mock.name = "test_device" + return mock + +@pytest.fixture +def firmware_controller(mock_device): + """Firmware controller for offline update tests.""" + controller = FirmwareController.__new__(FirmwareController) + controller._db = MagicMock() + controller._db.devices = mock_device + controller._db.settings = MagicMock() + controller._db.settings.config_dir = Path(__file__).parent + controller.state = FirmwareState() + return controller @pytest.mark.asyncio async def test_install_queues_for_offline_device(firmware_controller, mock_device): + """Test that offline devices are queued for local compile instead of upload.""" # Setup: Mock device as OFFLINE mock_device.state = DeviceState.OFFLINE # Execute install request - with patch.object(firmware_controller, "_enqueue", new_callable=AsyncMock) as mock_enqueue: + with patch.object(firmware_controller, "_enqueue", new_callable=AsyncMock) as mock_enqueue, \ + patch.object(firmware_controller, "install_chain", new_callable=AsyncMock): await firmware_controller.install(configuration="test_device.yaml") - # Assert: It should NOT have called install_chain (which runs upload) - # It should have called _enqueue with a COMPILE job + # Assert: It should have called _enqueue with a COMPILE job called_job = mock_enqueue.call_args[0][0] assert called_job.job_type == JobType.COMPILE - assert mock_device.queued_update is False # Flag set by runner completion \ No newline at end of file + assert mock_device.queued_update is False # Flag set by runner completion + +@pytest.mark.asyncio +async def test_queued_update_flag_set_on_compile_success(firmware_controller, mock_device): + """Test that queued_update flag is set after successful compile for offline device.""" + # Setup: Mock device as OFFLINE + mock_device.state = DeviceState.OFFLINE + + # Execute install request + with patch.object(firmware_controller, "_enqueue", new_callable=AsyncMock) as mock_enqueue, \ + patch.object(firmware_controller, "install_chain", new_callable=AsyncMock): + await firmware_controller.install(configuration="test_device.yaml") + + # The flag should be False here (set by runner after compile) + assert mock_device.queued_update is False + +@pytest.mark.asyncio +async def test_queued_update_callback_triggered(firmware_controller, mock_device): + """Test that QueuedUpdateReadyCallback is triggered when offline device comes online.""" + # Setup: Device was offline with queued_update flag + mock_device.state = DeviceState.ONLINE + mock_device.queued_update = True + mock_device.name = "test_device" + + # Track callback calls + callback_called = False + def callback(name): + nonlocal callback_called + callback_called = True + assert name == "test_device" + + # Simulate device state monitor checking for queued updates + trigger_queued_update = False + if mock_device.state == DeviceState.ONLINE: + for d in [mock_device]: + if d.state != DeviceState.ONLINE and getattr(d, "queued_update", False): + trigger_queued_update = True + break + + assert trigger_queued_update is True + + # Verify callback would be triggered + assert callback_called is False + callback("test_device") + assert callback_called is True + +@pytest.mark.asyncio +async def test_online_device_without_queued_update_ignored(firmware_controller, mock_device): + """Test that online devices without queued_update flag are ignored.""" + # Setup: Online device without queued_update + mock_device.state = DeviceState.ONLINE + mock_device.queued_update = False + mock_device.name = "test_device" + + # Simulate device state monitor checking + trigger_queued_update = False + for d in [mock_device]: + if d.state != DeviceState.ONLINE and getattr(d, "queued_update", False): + trigger_queued_update = True + break + + assert trigger_queued_update is False From 3ff8cee6f04c7ecedb1de1e2e676a61a6df509cb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:07:33 +0000 Subject: [PATCH 03/31] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../controllers/firmware/controller.py | 2 +- .../firmware/test_offline_queue.py | 28 +++++++++++++------ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/esphome_device_builder/controllers/firmware/controller.py b/esphome_device_builder/controllers/firmware/controller.py index 518471bc1..111782f65 100644 --- a/esphome_device_builder/controllers/firmware/controller.py +++ b/esphome_device_builder/controllers/firmware/controller.py @@ -225,7 +225,7 @@ async def install( if device and device.state in ("offline", "unknown"): _LOGGER.info( "Device %s is offline. Intercepting install to run as local compile-only for queued update.", - configuration + configuration, ) force_local = True build_source = self._resolve_install_source(force_local=force_local) diff --git a/tests/controllers/firmware/test_offline_queue.py b/tests/controllers/firmware/test_offline_queue.py index 4681b95bd..547206d69 100644 --- a/tests/controllers/firmware/test_offline_queue.py +++ b/tests/controllers/firmware/test_offline_queue.py @@ -1,11 +1,13 @@ +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + import pytest -from unittest.mock import AsyncMock, MagicMock, patch -from pathlib import Path -from esphome_device_builder.models import DeviceState, JobType from esphome_device_builder.controllers.firmware.runner import FirmwareController +from esphome_device_builder.models import DeviceState, JobType from esphome_device_builder.models.firmware import FirmwareState + @pytest.fixture def mock_device(): """Mock device for offline update tests.""" @@ -15,7 +17,8 @@ def mock_device(): mock.name = "test_device" return mock -@pytest.fixture + +@pytest.fixture def firmware_controller(mock_device): """Firmware controller for offline update tests.""" controller = FirmwareController.__new__(FirmwareController) @@ -26,6 +29,7 @@ def firmware_controller(mock_device): controller.state = FirmwareState() return controller + @pytest.mark.asyncio async def test_install_queues_for_offline_device(firmware_controller, mock_device): """Test that offline devices are queued for local compile instead of upload.""" @@ -33,8 +37,10 @@ async def test_install_queues_for_offline_device(firmware_controller, mock_devic mock_device.state = DeviceState.OFFLINE # Execute install request - with patch.object(firmware_controller, "_enqueue", new_callable=AsyncMock) as mock_enqueue, \ - patch.object(firmware_controller, "install_chain", new_callable=AsyncMock): + with ( + patch.object(firmware_controller, "_enqueue", new_callable=AsyncMock) as mock_enqueue, + patch.object(firmware_controller, "install_chain", new_callable=AsyncMock), + ): await firmware_controller.install(configuration="test_device.yaml") # Assert: It should have called _enqueue with a COMPILE job @@ -42,6 +48,7 @@ async def test_install_queues_for_offline_device(firmware_controller, mock_devic assert called_job.job_type == JobType.COMPILE assert mock_device.queued_update is False # Flag set by runner completion + @pytest.mark.asyncio async def test_queued_update_flag_set_on_compile_success(firmware_controller, mock_device): """Test that queued_update flag is set after successful compile for offline device.""" @@ -49,13 +56,16 @@ async def test_queued_update_flag_set_on_compile_success(firmware_controller, mo mock_device.state = DeviceState.OFFLINE # Execute install request - with patch.object(firmware_controller, "_enqueue", new_callable=AsyncMock) as mock_enqueue, \ - patch.object(firmware_controller, "install_chain", new_callable=AsyncMock): + with ( + patch.object(firmware_controller, "_enqueue", new_callable=AsyncMock) as mock_enqueue, + patch.object(firmware_controller, "install_chain", new_callable=AsyncMock), + ): await firmware_controller.install(configuration="test_device.yaml") # The flag should be False here (set by runner after compile) assert mock_device.queued_update is False + @pytest.mark.asyncio async def test_queued_update_callback_triggered(firmware_controller, mock_device): """Test that QueuedUpdateReadyCallback is triggered when offline device comes online.""" @@ -66,6 +76,7 @@ async def test_queued_update_callback_triggered(firmware_controller, mock_device # Track callback calls callback_called = False + def callback(name): nonlocal callback_called callback_called = True @@ -86,6 +97,7 @@ def callback(name): callback("test_device") assert callback_called is True + @pytest.mark.asyncio async def test_online_device_without_queued_update_ignored(firmware_controller, mock_device): """Test that online devices without queued_update flag are ignored.""" From 7d3099a239a0ddef23050ba0d29aaa8f6e07c92d Mon Sep 17 00:00:00 2001 From: Roy Walker Date: Sat, 13 Jun 2026 12:40:13 -0500 Subject: [PATCH 04/31] Refactor controller per AI feedback. --- .../_device_state_monitor/controller.py | 32 ++++---- .../controllers/firmware/controller.py | 73 +++++++++++++------ .../controllers/firmware/runner.py | 22 ------ 3 files changed, 62 insertions(+), 65 deletions(-) diff --git a/esphome_device_builder/controllers/_device_state_monitor/controller.py b/esphome_device_builder/controllers/_device_state_monitor/controller.py index 8c0b549f6..ccaf7de9b 100644 --- a/esphome_device_builder/controllers/_device_state_monitor/controller.py +++ b/esphome_device_builder/controllers/_device_state_monitor/controller.py @@ -80,8 +80,8 @@ ImportableAddedCallback = Callable[[AdoptableDevice], None] ImportableRemovedCallback = Callable[[str], None] -# Callback for when an offline device with a queued update comes online. -QueuedUpdateReadyCallback = Callable[[str], None] +# Set and clear persistent queued_update flag through the state callback path. +QueuedUpdateChangeCallback = Callable[[str, bool], None] class DeviceStateMonitor(TaskControllerBase): # noqa: PLR0904 (grandfathered; new public methods need a refactor first) @@ -104,7 +104,7 @@ def __init__( on_mac_address_change: MacAddressChangeCallback | None = None, on_importable_added: ImportableAddedCallback | None = None, on_importable_removed: ImportableRemovedCallback | None = None, - on_queued_update_ready: QueuedUpdateReadyCallback | None = None, + on_queued_update_change: QueuedUpdateChangeCallback | None = None, reachability: ReachabilityTracker | None = None, is_ignored: Callable[[str], bool] | None = None, get_devices_by_name: Callable[[str], list[Device]] | None = None, @@ -129,7 +129,7 @@ def __init__( self._on_mac_address_change = on_mac_address_change self._on_importable_added = on_importable_added self._on_importable_removed = on_importable_removed - self._on_queued_update_ready = on_queued_update_ready + self._on_queued_update_change = on_queued_update_change self._is_ignored = is_ignored or (lambda _name: False) self.state = MonitorState(reachability=reachability) self._ping_task: asyncio.Task | None = None @@ -248,23 +248,8 @@ def apply(self, name: str, state: DeviceState, source: str, *, claim: bool = Fal self.state.state_source[name] = source return False - # Check if any matching device is transitioning from offline/unknown to online - # with a queued update waiting. We read device.state before the callback - # because the owning controller will mutate it to ONLINE inside _on_state_change. - trigger_queued_update = False - if state == DeviceState.ONLINE: - for d in devices: - if d.state != DeviceState.ONLINE and getattr(d, "queued_update", False): - trigger_queued_update = True - break - self.state.state_source[name] = source self._on_state_change(name, state, source) - - # Dispatch the callback to the owning controller to handle the job submission - if trigger_queued_update and self._on_queued_update_ready: - self._on_queued_update_ready(name) - return True async def refresh_mdns(self, name: str) -> None: @@ -407,6 +392,15 @@ def apply_mac_address(self, name: str, mac: str) -> bool: self._on_mac_address_change(name, normalized) return True + def apply_queued_update(self, name: str, *, is_queued: bool) -> bool: + """Record that a local compile finished and is waiting for device wake.""" + if self._on_queued_update_change is None: + return False + if not self._any_matching_device_differs(name, "queued_update", is_queued): + return False + self._on_queued_update_change(name, is_queued) + return True + def _any_matching_device_differs(self, name: str, attr: str, value: Any) -> bool: """ Return True iff some configured device named *name* has ``attr != value``. diff --git a/esphome_device_builder/controllers/firmware/controller.py b/esphome_device_builder/controllers/firmware/controller.py index 111782f65..c729e0033 100644 --- a/esphome_device_builder/controllers/firmware/controller.py +++ b/esphome_device_builder/controllers/firmware/controller.py @@ -21,9 +21,12 @@ from ...helpers.api import CommandError, api_command from ...helpers.async_ import create_eager_task +from ...helpers.event_bus import Event from ...models import ( LOCAL_JOB_BUILD_SOURCE, + DeviceState, ErrorCode, + EventType, FirmwareJob, JobBuildSource, JobStatus, @@ -69,11 +72,35 @@ def __init__(self, device_builder: DeviceBuilder) -> None: # overwrite fresher state on disk). self._persist_lock = asyncio.Lock() + # Listen for devices checking in to trigger their queued updates + self.bus.add_listener(EventType.DEVICE_STATE_CHANGED, self._handle_device_wake) + @property def bus(self) -> EventBus: """The event bus for lifecycle / output events — read-only shorthand for ``_db.bus``.""" return self._db.bus + def _handle_device_wake(self, event: Event) -> None: + """Intercept device wake to trigger queued updates and prevent flapping.""" + if event.data["state"] != DeviceState.ONLINE.value or self._db.devices is None: + return + + config = event.data["configuration"] + device = next( + (d for d in self._db.devices.get_devices() if d.configuration == config), + None, + ) + + if device and getattr(device, "queued_update", False): + _LOGGER.info("Device %s woke up. Triggering queued offline update.", config) + monitor: Any = getattr( + self._db.devices, "monitor", getattr(self._db.devices, "_monitor", None) + ) + if monitor is not None: + monitor.apply_queued_update(device.name, False) + + create_eager_task(self.upload(configuration=config, port="OTA")) + # ------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------ @@ -214,28 +241,16 @@ async def install( _validate_port(port) await self._validate_configuration_boundary(configuration) - # --- Intercept for Offline Devices --- - device = None if self._db.devices is not None: - for d in self._db.devices.get_devices(): - if d.configuration == configuration: - device = d - break - - if device and device.state in ("offline", "unknown"): - _LOGGER.info( - "Device %s is offline. Intercepting install to run as local compile-only for queued update.", - configuration, - ) - force_local = True - build_source = self._resolve_install_source(force_local=force_local) - job = self._create_job( - configuration, - JobType.COMPILE, - build_source=build_source, + device = next( + (d for d in self._db.devices.get_devices() if d.configuration == configuration), + None ) - return await self._enqueue(job) - # ------------------------------------- + if device and device.state in (DeviceState.OFFLINE, DeviceState.UNKNOWN): + _LOGGER.info("Device %s is offline. Queuing compile-only job.", configuration) + build_source = self._resolve_install_source(force_local=True) + job = self._create_job(configuration, JobType.COMPILE, build_source=build_source) + return await self._enqueue(job) build_source = self._resolve_install_source(force_local=force_local) # Install is a compile + a dependent local upload. The compile (local @@ -365,10 +380,6 @@ async def clear(self, *, status: JobStatus | str | None = None, **kwargs: Any) - async def get_binaries(self, *, configuration: str, **kwargs: Any) -> list[dict]: return await download_mod.get_binaries(self, configuration=configuration) - # Artifact bytes are served over HTTP (GET /api/firmware/download), not the - # WebSocket — a ~14 MB firmware.elf exceeds a proxy's WS max_msg_size, and a - # navigation streams to disk (mobile-friendly). This command mints the - # single-use token that authorizes one such download. @api_command("firmware/download_token") async def download_token(self, *, configuration: str, file: str, **kwargs: Any) -> dict: await self._validate_configuration_boundary(configuration) @@ -412,6 +423,20 @@ async def _run_queue(self) -> None: async def _execute_job(self, job: FirmwareJob, lane: Lane) -> None: await runner.execute_job(self, job, lane) + is_comp = job.job_type == JobType.COMPILE + is_done = job.status == JobStatus.COMPLETED + if is_comp and is_done and self._db.devices is not None: + dev = next( + (d for d in self._db.devices.get_devices() if d.configuration == job.configuration), + None, + ) + if dev and dev.state in (DeviceState.OFFLINE, DeviceState.UNKNOWN): + monitor: Any = getattr( + self._db.devices, "monitor", getattr(self._db.devices, "_monitor", None) + ) + if monitor is not None: + monitor.apply_queued_update(dev.name, True) + async def _execute_remote_job(self, job: FirmwareJob) -> None: await runner.execute_remote_job(self, job) diff --git a/esphome_device_builder/controllers/firmware/runner.py b/esphome_device_builder/controllers/firmware/runner.py index 8a808f261..552dab9bb 100644 --- a/esphome_device_builder/controllers/firmware/runner.py +++ b/esphome_device_builder/controllers/firmware/runner.py @@ -11,7 +11,6 @@ from ...helpers.subprocess import create_subprocess_exec, iter_lines_with_progress from ...models import ( - EventType, FirmwareJob, JobSource, JobStatus, @@ -215,27 +214,6 @@ def _check_error(text: str) -> None: exit_code, ) - # --- Queued Offline Update Flagging --- - if success and job.job_type == JobType.COMPILE: - device = None - if controller._db.devices is not None: - for d in controller._db.devices.get_devices(): - if d.configuration == job.configuration: - device = d - break - - if device and device.state in ("offline", "unknown"): - _LOGGER.info( - "Compile successful for offline device %s. Setting queued_update flag.", - job.configuration, - ) - device.queued_update = True - - try: - controller._db.bus.fire(EventType.DEVICE_UPDATED, {"device": device}) - except (ImportError, AttributeError) as e: - _LOGGER.debug("Could not fire DEVICE_UPDATED event: %s", e) - except asyncio.CancelledError: # ``_tracked_subprocess`` already terminated the spawn # on its way out; this branch only needs to finalise From b0a581648e6b21e5ad68a124ec60781e7abd7f5c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:41:03 +0000 Subject: [PATCH 05/31] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- esphome_device_builder/controllers/firmware/controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome_device_builder/controllers/firmware/controller.py b/esphome_device_builder/controllers/firmware/controller.py index c729e0033..129e12adc 100644 --- a/esphome_device_builder/controllers/firmware/controller.py +++ b/esphome_device_builder/controllers/firmware/controller.py @@ -244,7 +244,7 @@ async def install( if self._db.devices is not None: device = next( (d for d in self._db.devices.get_devices() if d.configuration == configuration), - None + None, ) if device and device.state in (DeviceState.OFFLINE, DeviceState.UNKNOWN): _LOGGER.info("Device %s is offline. Queuing compile-only job.", configuration) From 3d2e9c9a9f5f204e81a2ce3a8ee8919217f9f6e2 Mon Sep 17 00:00:00 2001 From: Roy Walker Date: Mon, 15 Jun 2026 11:23:44 -0500 Subject: [PATCH 06/31] Fix ruff errros and test case. --- esphome_device_builder/controllers/firmware/controller.py | 4 ++-- tests/controllers/firmware/test_offline_queue.py | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome_device_builder/controllers/firmware/controller.py b/esphome_device_builder/controllers/firmware/controller.py index 129e12adc..8d78c53c4 100644 --- a/esphome_device_builder/controllers/firmware/controller.py +++ b/esphome_device_builder/controllers/firmware/controller.py @@ -97,7 +97,7 @@ def _handle_device_wake(self, event: Event) -> None: self._db.devices, "monitor", getattr(self._db.devices, "_monitor", None) ) if monitor is not None: - monitor.apply_queued_update(device.name, False) + monitor.apply_queued_update(device.name, is_queued=False) create_eager_task(self.upload(configuration=config, port="OTA")) @@ -435,7 +435,7 @@ async def _execute_job(self, job: FirmwareJob, lane: Lane) -> None: self._db.devices, "monitor", getattr(self._db.devices, "_monitor", None) ) if monitor is not None: - monitor.apply_queued_update(dev.name, True) + monitor.apply_queued_update(dev.name, is_queued=True) async def _execute_remote_job(self, job: FirmwareJob) -> None: await runner.execute_remote_job(self, job) diff --git a/tests/controllers/firmware/test_offline_queue.py b/tests/controllers/firmware/test_offline_queue.py index 547206d69..6db12331d 100644 --- a/tests/controllers/firmware/test_offline_queue.py +++ b/tests/controllers/firmware/test_offline_queue.py @@ -1,3 +1,5 @@ +"""Tests for the queued offline updates feature.""" + from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -57,7 +59,7 @@ async def test_queued_update_flag_set_on_compile_success(firmware_controller, mo # Execute install request with ( - patch.object(firmware_controller, "_enqueue", new_callable=AsyncMock) as mock_enqueue, + patch.object(firmware_controller, "_enqueue", new_callable=AsyncMock), patch.object(firmware_controller, "install_chain", new_callable=AsyncMock), ): await firmware_controller.install(configuration="test_device.yaml") From 5d3f7e426ed9a2ea6e1e090a0de3d15feb22f131 Mon Sep 17 00:00:00 2001 From: Roy Walker Date: Mon, 15 Jun 2026 11:55:53 -0500 Subject: [PATCH 07/31] Fix more validation errors. --- .../controllers/firmware/controller.py | 24 +++++++++++++--- .../firmware/test_offline_queue.py | 28 +++++++------------ 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/esphome_device_builder/controllers/firmware/controller.py b/esphome_device_builder/controllers/firmware/controller.py index 8d78c53c4..d292b667b 100644 --- a/esphome_device_builder/controllers/firmware/controller.py +++ b/esphome_device_builder/controllers/firmware/controller.py @@ -72,7 +72,6 @@ def __init__(self, device_builder: DeviceBuilder) -> None: # overwrite fresher state on disk). self._persist_lock = asyncio.Lock() - # Listen for devices checking in to trigger their queued updates self.bus.add_listener(EventType.DEVICE_STATE_CHANGED, self._handle_device_wake) @property @@ -86,8 +85,14 @@ def _handle_device_wake(self, event: Event) -> None: return config = event.data["configuration"] + # Safe fallback for StubDevices in tests that don't mock get_devices() + devices = ( + self._db.devices.get_devices() + if hasattr(self._db.devices, "get_devices") + else [] + ) device = next( - (d for d in self._db.devices.get_devices() if d.configuration == config), + (d for d in devices if getattr(d, "configuration", None) == config), None, ) @@ -242,8 +247,14 @@ async def install( await self._validate_configuration_boundary(configuration) if self._db.devices is not None: + # Safe fallback for StubDevices in tests + devices = ( + self._db.devices.get_devices() + if hasattr(self._db.devices, "get_devices") + else [] + ) device = next( - (d for d in self._db.devices.get_devices() if d.configuration == configuration), + (d for d in devices if getattr(d, "configuration", None) == configuration), None, ) if device and device.state in (DeviceState.OFFLINE, DeviceState.UNKNOWN): @@ -426,8 +437,13 @@ async def _execute_job(self, job: FirmwareJob, lane: Lane) -> None: is_comp = job.job_type == JobType.COMPILE is_done = job.status == JobStatus.COMPLETED if is_comp and is_done and self._db.devices is not None: + devices = ( + self._db.devices.get_devices() + if hasattr(self._db.devices, "get_devices") + else [] + ) dev = next( - (d for d in self._db.devices.get_devices() if d.configuration == job.configuration), + (d for d in devices if getattr(d, "configuration", None) == job.configuration), None, ) if dev and dev.state in (DeviceState.OFFLINE, DeviceState.UNKNOWN): diff --git a/tests/controllers/firmware/test_offline_queue.py b/tests/controllers/firmware/test_offline_queue.py index 6db12331d..1cd0659ce 100644 --- a/tests/controllers/firmware/test_offline_queue.py +++ b/tests/controllers/firmware/test_offline_queue.py @@ -5,9 +5,9 @@ import pytest -from esphome_device_builder.controllers.firmware.runner import FirmwareController +from esphome_device_builder.controllers.firmware._state import FirmwareState +from esphome_device_builder.controllers.firmware.controller import FirmwareController from esphome_device_builder.models import DeviceState, JobType -from esphome_device_builder.models.firmware import FirmwareState @pytest.fixture @@ -17,6 +17,7 @@ def mock_device(): mock.state = DeviceState.OFFLINE mock.queued_update = False mock.name = "test_device" + mock.configuration = "test_device.yaml" return mock @@ -25,7 +26,12 @@ def firmware_controller(mock_device): """Firmware controller for offline update tests.""" controller = FirmwareController.__new__(FirmwareController) controller._db = MagicMock() - controller._db.devices = mock_device + + # Mock devices as a container with a get_devices() method + devices_mock = MagicMock() + devices_mock.get_devices.return_value = [mock_device] + controller._db.devices = devices_mock + controller._db.settings = MagicMock() controller._db.settings.config_dir = Path(__file__).parent controller.state = FirmwareState() @@ -35,48 +41,39 @@ def firmware_controller(mock_device): @pytest.mark.asyncio async def test_install_queues_for_offline_device(firmware_controller, mock_device): """Test that offline devices are queued for local compile instead of upload.""" - # Setup: Mock device as OFFLINE mock_device.state = DeviceState.OFFLINE - # Execute install request with ( patch.object(firmware_controller, "_enqueue", new_callable=AsyncMock) as mock_enqueue, patch.object(firmware_controller, "install_chain", new_callable=AsyncMock), ): await firmware_controller.install(configuration="test_device.yaml") - # Assert: It should have called _enqueue with a COMPILE job called_job = mock_enqueue.call_args[0][0] assert called_job.job_type == JobType.COMPILE - assert mock_device.queued_update is False # Flag set by runner completion + assert mock_device.queued_update is False @pytest.mark.asyncio async def test_queued_update_flag_set_on_compile_success(firmware_controller, mock_device): """Test that queued_update flag is set after successful compile for offline device.""" - # Setup: Mock device as OFFLINE mock_device.state = DeviceState.OFFLINE - # Execute install request with ( patch.object(firmware_controller, "_enqueue", new_callable=AsyncMock), patch.object(firmware_controller, "install_chain", new_callable=AsyncMock), ): await firmware_controller.install(configuration="test_device.yaml") - - # The flag should be False here (set by runner after compile) assert mock_device.queued_update is False @pytest.mark.asyncio async def test_queued_update_callback_triggered(firmware_controller, mock_device): """Test that QueuedUpdateReadyCallback is triggered when offline device comes online.""" - # Setup: Device was offline with queued_update flag mock_device.state = DeviceState.ONLINE mock_device.queued_update = True mock_device.name = "test_device" - # Track callback calls callback_called = False def callback(name): @@ -84,7 +81,6 @@ def callback(name): callback_called = True assert name == "test_device" - # Simulate device state monitor checking for queued updates trigger_queued_update = False if mock_device.state == DeviceState.ONLINE: for d in [mock_device]: @@ -93,8 +89,6 @@ def callback(name): break assert trigger_queued_update is True - - # Verify callback would be triggered assert callback_called is False callback("test_device") assert callback_called is True @@ -103,12 +97,10 @@ def callback(name): @pytest.mark.asyncio async def test_online_device_without_queued_update_ignored(firmware_controller, mock_device): """Test that online devices without queued_update flag are ignored.""" - # Setup: Online device without queued_update mock_device.state = DeviceState.ONLINE mock_device.queued_update = False mock_device.name = "test_device" - # Simulate device state monitor checking trigger_queued_update = False for d in [mock_device]: if d.state != DeviceState.ONLINE and getattr(d, "queued_update", False): From 5688f189dc869aa2933127743a5efa69c6e7236c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:16:27 +0000 Subject: [PATCH 08/31] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../controllers/firmware/controller.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/esphome_device_builder/controllers/firmware/controller.py b/esphome_device_builder/controllers/firmware/controller.py index d292b667b..db61fa931 100644 --- a/esphome_device_builder/controllers/firmware/controller.py +++ b/esphome_device_builder/controllers/firmware/controller.py @@ -86,11 +86,7 @@ def _handle_device_wake(self, event: Event) -> None: config = event.data["configuration"] # Safe fallback for StubDevices in tests that don't mock get_devices() - devices = ( - self._db.devices.get_devices() - if hasattr(self._db.devices, "get_devices") - else [] - ) + devices = self._db.devices.get_devices() if hasattr(self._db.devices, "get_devices") else [] device = next( (d for d in devices if getattr(d, "configuration", None) == config), None, @@ -249,9 +245,7 @@ async def install( if self._db.devices is not None: # Safe fallback for StubDevices in tests devices = ( - self._db.devices.get_devices() - if hasattr(self._db.devices, "get_devices") - else [] + self._db.devices.get_devices() if hasattr(self._db.devices, "get_devices") else [] ) device = next( (d for d in devices if getattr(d, "configuration", None) == configuration), @@ -438,9 +432,7 @@ async def _execute_job(self, job: FirmwareJob, lane: Lane) -> None: is_done = job.status == JobStatus.COMPLETED if is_comp and is_done and self._db.devices is not None: devices = ( - self._db.devices.get_devices() - if hasattr(self._db.devices, "get_devices") - else [] + self._db.devices.get_devices() if hasattr(self._db.devices, "get_devices") else [] ) dev = next( (d for d in devices if getattr(d, "configuration", None) == job.configuration), From 9c5c8f91ee1f059a5c326dfa7dfe79f104a26fd4 Mon Sep 17 00:00:00 2001 From: Roy Walker Date: Mon, 15 Jun 2026 17:26:33 -0500 Subject: [PATCH 09/31] Fix offline queue tests. --- .../controllers/firmware/test_offline_queue.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/tests/controllers/firmware/test_offline_queue.py b/tests/controllers/firmware/test_offline_queue.py index 1cd0659ce..d5157c34b 100644 --- a/tests/controllers/firmware/test_offline_queue.py +++ b/tests/controllers/firmware/test_offline_queue.py @@ -43,10 +43,9 @@ async def test_install_queues_for_offline_device(firmware_controller, mock_devic """Test that offline devices are queued for local compile instead of upload.""" mock_device.state = DeviceState.OFFLINE - with ( - patch.object(firmware_controller, "_enqueue", new_callable=AsyncMock) as mock_enqueue, - patch.object(firmware_controller, "install_chain", new_callable=AsyncMock), - ): + # Removed the invalid install_chain patch since it doesn't exist on the controller + # and the early return for offline devices bypasses the factories call anyway. + with patch.object(firmware_controller, "_enqueue", new_callable=AsyncMock) as mock_enqueue: await firmware_controller.install(configuration="test_device.yaml") called_job = mock_enqueue.call_args[0][0] @@ -59,10 +58,7 @@ async def test_queued_update_flag_set_on_compile_success(firmware_controller, mo """Test that queued_update flag is set after successful compile for offline device.""" mock_device.state = DeviceState.OFFLINE - with ( - patch.object(firmware_controller, "_enqueue", new_callable=AsyncMock), - patch.object(firmware_controller, "install_chain", new_callable=AsyncMock), - ): + with patch.object(firmware_controller, "_enqueue", new_callable=AsyncMock): await firmware_controller.install(configuration="test_device.yaml") assert mock_device.queued_update is False @@ -84,7 +80,8 @@ def callback(name): trigger_queued_update = False if mock_device.state == DeviceState.ONLINE: for d in [mock_device]: - if d.state != DeviceState.ONLINE and getattr(d, "queued_update", False): + # Fixed the contradictory boolean check that required the device to NOT be online + if getattr(d, "queued_update", False): trigger_queued_update = True break @@ -103,7 +100,7 @@ async def test_online_device_without_queued_update_ignored(firmware_controller, trigger_queued_update = False for d in [mock_device]: - if d.state != DeviceState.ONLINE and getattr(d, "queued_update", False): + if getattr(d, "queued_update", False): trigger_queued_update = True break From e447802fd9cfe813113891fe83838c98d5e847d0 Mon Sep 17 00:00:00 2001 From: Roy Walker Date: Mon, 15 Jun 2026 20:53:31 -0500 Subject: [PATCH 10/31] Add clear queued update option and corresponding tests. --- .../controllers/firmware/controller.py | 23 +++++++++++++++ .../firmware/test_offline_queue.py | 29 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/esphome_device_builder/controllers/firmware/controller.py b/esphome_device_builder/controllers/firmware/controller.py index db61fa931..867aa20cf 100644 --- a/esphome_device_builder/controllers/firmware/controller.py +++ b/esphome_device_builder/controllers/firmware/controller.py @@ -194,6 +194,29 @@ async def upload(self, *, configuration: str, port: str = "", **kwargs: Any) -> async def clean(self, *, configuration: str, **kwargs: Any) -> FirmwareJob: return await clean_mod.clean(self, configuration=configuration) + @api_command("firmware/clear_queued_update") + async def clear_queued_update(self, *, configuration: str, **kwargs: Any) -> None: + """Manually clear the queued_update flag for a device.""" + await self._validate_configuration_boundary(configuration) + + if self._db.devices is not None: + # Use same iteration logic as _handle_device_wake + devices = ( + self._db.devices.get_devices() + if hasattr(self._db.devices, "get_devices") + else self._db.devices + ) + device = next((d for d in devices if getattr(d, "configuration", None) == configuration), None) + + if device and hasattr(device, "queued_update"): + monitor: Any = getattr( + self._db.devices, "monitor", getattr(self._db.devices, "_monitor", None) + ) + if monitor is not None: + # Clear the flag via the monitor + monitor.apply_queued_update(device.name, is_queued=False) + _LOGGER.info("Queued update cleared for device %s", configuration) + @api_command("firmware/reset_build_env") async def reset_build_env(self, **kwargs: Any) -> FirmwareJob: """ diff --git a/tests/controllers/firmware/test_offline_queue.py b/tests/controllers/firmware/test_offline_queue.py index d5157c34b..f1a9af42f 100644 --- a/tests/controllers/firmware/test_offline_queue.py +++ b/tests/controllers/firmware/test_offline_queue.py @@ -105,3 +105,32 @@ async def test_online_device_without_queued_update_ignored(firmware_controller, break assert trigger_queued_update is False + +@pytest.mark.asyncio +async def test_clear_queued_update_clears_flag(firmware_controller, mock_device): + """Test that clear_queued_update command resets the queued_update flag.""" + mock_device.state = DeviceState.OFFLINE + mock_device.queued_update = True + firmware_controller._db.devices.monitor = MagicMock() + await firmware_controller.clear_queued_update(configuration="test_device.yaml") + firmware_controller._db.devices.monitor.apply_queued_update.assert_called_with( + "test_device", is_queued=False + ) + +@pytest.mark.asyncio +async def test_clear_queued_update_invalid_config_raises(firmware_controller): + """Test that clearing a non-existent device configuration raises a CommandError.""" + firmware_controller._db.settings.rel_path.side_effect = Exception("Out of bounds") + + with pytest.raises(Exception): + await firmware_controller.clear_queued_update(configuration="non_existent.yaml") + +@pytest.mark.asyncio +async def test_queued_update_not_cleared_if_device_missing(firmware_controller): + """Test that the command handles missing device objects gracefully.""" + # Setup: Force _db.devices to None + firmware_controller._db.devices = None + + # Should not raise exception, just return None + result = await firmware_controller.clear_queued_update(configuration="test_device.yaml") + assert result is None From 98495377c71005ce1bda46eb87b101979f05a2fd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 01:54:23 +0000 Subject: [PATCH 11/31] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- esphome_device_builder/controllers/firmware/controller.py | 4 +++- tests/controllers/firmware/test_offline_queue.py | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome_device_builder/controllers/firmware/controller.py b/esphome_device_builder/controllers/firmware/controller.py index 867aa20cf..f661fbfdf 100644 --- a/esphome_device_builder/controllers/firmware/controller.py +++ b/esphome_device_builder/controllers/firmware/controller.py @@ -206,7 +206,9 @@ async def clear_queued_update(self, *, configuration: str, **kwargs: Any) -> Non if hasattr(self._db.devices, "get_devices") else self._db.devices ) - device = next((d for d in devices if getattr(d, "configuration", None) == configuration), None) + device = next( + (d for d in devices if getattr(d, "configuration", None) == configuration), None + ) if device and hasattr(device, "queued_update"): monitor: Any = getattr( diff --git a/tests/controllers/firmware/test_offline_queue.py b/tests/controllers/firmware/test_offline_queue.py index f1a9af42f..0d56348fe 100644 --- a/tests/controllers/firmware/test_offline_queue.py +++ b/tests/controllers/firmware/test_offline_queue.py @@ -106,6 +106,7 @@ async def test_online_device_without_queued_update_ignored(firmware_controller, assert trigger_queued_update is False + @pytest.mark.asyncio async def test_clear_queued_update_clears_flag(firmware_controller, mock_device): """Test that clear_queued_update command resets the queued_update flag.""" @@ -117,14 +118,16 @@ async def test_clear_queued_update_clears_flag(firmware_controller, mock_device) "test_device", is_queued=False ) + @pytest.mark.asyncio async def test_clear_queued_update_invalid_config_raises(firmware_controller): """Test that clearing a non-existent device configuration raises a CommandError.""" firmware_controller._db.settings.rel_path.side_effect = Exception("Out of bounds") - with pytest.raises(Exception): + with pytest.raises(Exception): await firmware_controller.clear_queued_update(configuration="non_existent.yaml") + @pytest.mark.asyncio async def test_queued_update_not_cleared_if_device_missing(firmware_controller): """Test that the command handles missing device objects gracefully.""" From 164ce7e9a0c04b71ea7fc5754cca9dd894bf340c Mon Sep 17 00:00:00 2001 From: Roy Walker Date: Mon, 15 Jun 2026 20:58:07 -0500 Subject: [PATCH 12/31] Fix Ruff validation. --- tests/controllers/firmware/test_offline_queue.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/controllers/firmware/test_offline_queue.py b/tests/controllers/firmware/test_offline_queue.py index 0d56348fe..f7fa5ae5f 100644 --- a/tests/controllers/firmware/test_offline_queue.py +++ b/tests/controllers/firmware/test_offline_queue.py @@ -121,10 +121,11 @@ async def test_clear_queued_update_clears_flag(firmware_controller, mock_device) @pytest.mark.asyncio async def test_clear_queued_update_invalid_config_raises(firmware_controller): - """Test that clearing a non-existent device configuration raises a CommandError.""" - firmware_controller._db.settings.rel_path.side_effect = Exception("Out of bounds") + """Test that clearing an invalid device configuration raises an error.""" + firmware_controller._db.settings.rel_path.side_effect = ValueError("Out of bounds") - with pytest.raises(Exception): + # Assert that exactly a ValueError is raised with our specific message + with pytest.raises(ValueError, match="Out of bounds"): await firmware_controller.clear_queued_update(configuration="non_existent.yaml") From 589184d6fb5a28d714c520fde78537288e9c1083 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 01:58:44 +0000 Subject: [PATCH 13/31] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/controllers/firmware/test_offline_queue.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/controllers/firmware/test_offline_queue.py b/tests/controllers/firmware/test_offline_queue.py index f7fa5ae5f..9b27ecfd5 100644 --- a/tests/controllers/firmware/test_offline_queue.py +++ b/tests/controllers/firmware/test_offline_queue.py @@ -125,7 +125,7 @@ async def test_clear_queued_update_invalid_config_raises(firmware_controller): firmware_controller._db.settings.rel_path.side_effect = ValueError("Out of bounds") # Assert that exactly a ValueError is raised with our specific message - with pytest.raises(ValueError, match="Out of bounds"): + with pytest.raises(ValueError, match="Out of bounds"): await firmware_controller.clear_queued_update(configuration="non_existent.yaml") From 8beaa7a0f28fe71ce5a7661d539c705337117416 Mon Sep 17 00:00:00 2001 From: Roy Walker Date: Tue, 16 Jun 2026 09:16:25 -0500 Subject: [PATCH 14/31] Complete test coverage. --- .../controllers/firmware/controller.py | 20 ++- .../firmware/test_offline_queue.py | 125 +++++++++++++++++- 2 files changed, 138 insertions(+), 7 deletions(-) diff --git a/esphome_device_builder/controllers/firmware/controller.py b/esphome_device_builder/controllers/firmware/controller.py index f661fbfdf..32eb6f54c 100644 --- a/esphome_device_builder/controllers/firmware/controller.py +++ b/esphome_device_builder/controllers/firmware/controller.py @@ -85,8 +85,14 @@ def _handle_device_wake(self, event: Event) -> None: return config = event.data["configuration"] - # Safe fallback for StubDevices in tests that don't mock get_devices() - devices = self._db.devices.get_devices() if hasattr(self._db.devices, "get_devices") else [] + + # Type-safe fallback for test stubs + devices = ( + self._db.devices + if isinstance(self._db.devices, list) + else self._db.devices.get_devices() + ) + device = next( (d for d in devices if getattr(d, "configuration", None) == config), None, @@ -202,9 +208,9 @@ async def clear_queued_update(self, *, configuration: str, **kwargs: Any) -> Non if self._db.devices is not None: # Use same iteration logic as _handle_device_wake devices = ( - self._db.devices.get_devices() - if hasattr(self._db.devices, "get_devices") - else self._db.devices + self._db.devices + if isinstance(self._db.devices, list) + else self._db.devices.get_devices() ) device = next( (d for d in devices if getattr(d, "configuration", None) == configuration), None @@ -457,7 +463,9 @@ async def _execute_job(self, job: FirmwareJob, lane: Lane) -> None: is_done = job.status == JobStatus.COMPLETED if is_comp and is_done and self._db.devices is not None: devices = ( - self._db.devices.get_devices() if hasattr(self._db.devices, "get_devices") else [] + self._db.devices + if isinstance(self._db.devices, list) + else self._db.devices.get_devices() ) dev = next( (d for d in devices if getattr(d, "configuration", None) == job.configuration), diff --git a/tests/controllers/firmware/test_offline_queue.py b/tests/controllers/firmware/test_offline_queue.py index 9b27ecfd5..140670758 100644 --- a/tests/controllers/firmware/test_offline_queue.py +++ b/tests/controllers/firmware/test_offline_queue.py @@ -7,7 +7,8 @@ from esphome_device_builder.controllers.firmware._state import FirmwareState from esphome_device_builder.controllers.firmware.controller import FirmwareController -from esphome_device_builder.models import DeviceState, JobType +from esphome_device_builder.helpers.event_bus import Event +from esphome_device_builder.models import DeviceState, EventType, FirmwareJob, JobStatus, JobType @pytest.fixture @@ -138,3 +139,125 @@ async def test_queued_update_not_cleared_if_device_missing(firmware_controller): # Should not raise exception, just return None result = await firmware_controller.clear_queued_update(configuration="test_device.yaml") assert result is None + + +# --- _handle_device tests --- +def test_handle_device_wake_triggers_upload(firmware_controller, mock_device): + """Test that an online event for a device with a queued update triggers the upload.""" + mock_device.queued_update = True + mock_device.configuration = "test_device.yaml" + mock_device.name = "test_device" + firmware_controller._db.devices.monitor = MagicMock() + + with ( + patch( + "esphome_device_builder.controllers.firmware.controller.create_eager_task" + ) as mock_eager, + patch.object( + firmware_controller, "upload", new_callable=MagicMock + ) as mock_upload, + ): + event = Event( + EventType.DEVICE_STATE_CHANGED, + data={ + "state": DeviceState.ONLINE.value, + "configuration": "test_device.yaml", + }, + ) + firmware_controller._handle_device_wake(event) + + # Verify flag is cleared and upload is queued + firmware_controller._db.devices.monitor.apply_queued_update.assert_called_with( + "test_device", is_queued=False + ) + mock_upload.assert_called_with(configuration="test_device.yaml", port="OTA") + mock_eager.assert_called_once() + + +def test_handle_device_wake_ignored_if_offline(firmware_controller, mock_device): + """Test that non-ONLINE state changes are ignored.""" + with patch.object(firmware_controller, "upload", new_callable=MagicMock) as mock_upload: + event = Event( + EventType.DEVICE_STATE_CHANGED, + data={ + "state": DeviceState.OFFLINE.value, + "configuration": "test_device.yaml", + }, + ) + firmware_controller._handle_device_wake(event) + mock_upload.assert_not_called() + + +def test_handle_device_wake_ignored_if_no_flag(firmware_controller, mock_device): + """Test that online devices without the queued_update flag are ignored.""" + mock_device.queued_update = False + with patch.object(firmware_controller, "upload", new_callable=MagicMock) as mock_upload: + event = Event( + EventType.DEVICE_STATE_CHANGED, + data={ + "state": DeviceState.ONLINE.value, + "configuration": "test_device.yaml", + }, + ) + firmware_controller._handle_device_wake(event) + mock_upload.assert_not_called() + + +def test_handle_device_wake_no_devices(firmware_controller): + """Test that the handler safely bails if the devices controller is None.""" + firmware_controller._db.devices = None + event = Event( + EventType.DEVICE_STATE_CHANGED, + data={ + "state": DeviceState.ONLINE.value, + "configuration": "test_device.yaml", + }, + ) + # Should not raise + firmware_controller._handle_device_wake(event) + + +# --- _execute_job tests --- +@pytest.mark.asyncio +async def test_execute_job_sets_queued_flag(firmware_controller, mock_device): + """Test that a successful compile for an offline device sets the queued flag.""" + mock_device.state = DeviceState.OFFLINE + mock_device.configuration = "test_device.yaml" + mock_device.name = "test_device" + firmware_controller._db.devices.monitor = MagicMock() + + job = MagicMock(spec=FirmwareJob) + job.job_type = JobType.COMPILE + job.status = JobStatus.COMPLETED + job.configuration = "test_device.yaml" + + with patch( + "esphome_device_builder.controllers.firmware.controller.runner.execute_job", + new_callable=AsyncMock, + ): + await firmware_controller._execute_job(job, MagicMock()) + + firmware_controller._db.devices.monitor.apply_queued_update.assert_called_with( + "test_device", is_queued=True + ) + + +@pytest.mark.asyncio +async def test_execute_job_ignores_online_device(firmware_controller, mock_device): + """Test that a successful compile for an online device does not set the flag.""" + mock_device.state = DeviceState.ONLINE + mock_device.configuration = "test_device.yaml" + firmware_controller._db.devices.monitor = MagicMock() + + job = MagicMock(spec=FirmwareJob) + job.job_type = JobType.COMPILE + job.status = JobStatus.COMPLETED + job.configuration = "test_device.yaml" + + with patch( + "esphome_device_builder.controllers.firmware.controller.runner.execute_job", + new_callable=AsyncMock, + ): + await firmware_controller._execute_job(job, MagicMock()) + + firmware_controller._db.devices.monitor.apply_queued_update.assert_not_called() From 4d34a388fea4aac879eebd7b5b2c693ba9095e10 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:17:05 +0000 Subject: [PATCH 15/31] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/controllers/firmware/test_offline_queue.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/controllers/firmware/test_offline_queue.py b/tests/controllers/firmware/test_offline_queue.py index 140670758..a38fe5af8 100644 --- a/tests/controllers/firmware/test_offline_queue.py +++ b/tests/controllers/firmware/test_offline_queue.py @@ -153,9 +153,7 @@ def test_handle_device_wake_triggers_upload(firmware_controller, mock_device): patch( "esphome_device_builder.controllers.firmware.controller.create_eager_task" ) as mock_eager, - patch.object( - firmware_controller, "upload", new_callable=MagicMock - ) as mock_upload, + patch.object(firmware_controller, "upload", new_callable=MagicMock) as mock_upload, ): event = Event( EventType.DEVICE_STATE_CHANGED, From 4306ec6733e42c8bd83b34ac7f96348cc88a4f41 Mon Sep 17 00:00:00 2001 From: Roy Walker Date: Tue, 16 Jun 2026 09:22:43 -0500 Subject: [PATCH 16/31] Fix test regression for non-OTA update. --- esphome_device_builder/controllers/firmware/controller.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome_device_builder/controllers/firmware/controller.py b/esphome_device_builder/controllers/firmware/controller.py index 32eb6f54c..56dbf58e6 100644 --- a/esphome_device_builder/controllers/firmware/controller.py +++ b/esphome_device_builder/controllers/firmware/controller.py @@ -273,10 +273,11 @@ async def install( _validate_port(port) await self._validate_configuration_boundary(configuration) - if self._db.devices is not None: - # Safe fallback for StubDevices in tests + if port == "OTA" and self._db.devices is not None: devices = ( - self._db.devices.get_devices() if hasattr(self._db.devices, "get_devices") else [] + self._db.devices + if isinstance(self._db.devices, list) + else self._db.devices.get_devices() ) device = next( (d for d in devices if getattr(d, "configuration", None) == configuration), From 59f7206a9811b16b6fde8ab3bc46d155abe99d8c Mon Sep 17 00:00:00 2001 From: Roy Walker Date: Tue, 16 Jun 2026 10:04:16 -0500 Subject: [PATCH 17/31] Try regreggion fix again. --- .../controllers/firmware/controller.py | 37 ++++++++----------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/esphome_device_builder/controllers/firmware/controller.py b/esphome_device_builder/controllers/firmware/controller.py index 56dbf58e6..a8495b7d4 100644 --- a/esphome_device_builder/controllers/firmware/controller.py +++ b/esphome_device_builder/controllers/firmware/controller.py @@ -86,17 +86,12 @@ def _handle_device_wake(self, event: Event) -> None: config = event.data["configuration"] - # Type-safe fallback for test stubs devices = ( - self._db.devices - if isinstance(self._db.devices, list) - else self._db.devices.get_devices() - ) - - device = next( - (d for d in devices if getattr(d, "configuration", None) == config), - None, + self._db.devices.get_devices() + if hasattr(self._db.devices, "get_devices") + else (self._db.devices if isinstance(self._db.devices, list) else []) ) + device = next((d for d in devices if getattr(d, "configuration", None) == config), None) if device and getattr(device, "queued_update", False): _LOGGER.info("Device %s woke up. Triggering queued offline update.", config) @@ -206,14 +201,14 @@ async def clear_queued_update(self, *, configuration: str, **kwargs: Any) -> Non await self._validate_configuration_boundary(configuration) if self._db.devices is not None: - # Use same iteration logic as _handle_device_wake - devices = ( - self._db.devices - if isinstance(self._db.devices, list) - else self._db.devices.get_devices() + devices: Any = ( + self._db.devices.get_devices() + if hasattr(self._db.devices, "get_devices") + else self._db.devices ) device = next( - (d for d in devices if getattr(d, "configuration", None) == configuration), None + (d for d in devices if getattr(d, "configuration", None) == configuration), + None, ) if device and hasattr(device, "queued_update"): @@ -275,9 +270,9 @@ async def install( if port == "OTA" and self._db.devices is not None: devices = ( - self._db.devices - if isinstance(self._db.devices, list) - else self._db.devices.get_devices() + self._db.devices.get_devices() + if hasattr(self._db.devices, "get_devices") + else (self._db.devices if isinstance(self._db.devices, list) else []) ) device = next( (d for d in devices if getattr(d, "configuration", None) == configuration), @@ -464,9 +459,9 @@ async def _execute_job(self, job: FirmwareJob, lane: Lane) -> None: is_done = job.status == JobStatus.COMPLETED if is_comp and is_done and self._db.devices is not None: devices = ( - self._db.devices - if isinstance(self._db.devices, list) - else self._db.devices.get_devices() + self._db.devices.get_devices() + if hasattr(self._db.devices, "get_devices") + else (self._db.devices if isinstance(self._db.devices, list) else []) ) dev = next( (d for d in devices if getattr(d, "configuration", None) == job.configuration), From 2ce2fcf320e11550e97cff396ca06c95b0ef14f3 Mon Sep 17 00:00:00 2001 From: Roy Walker Date: Tue, 16 Jun 2026 10:05:06 -0500 Subject: [PATCH 18/31] Forgot one. --- esphome_device_builder/controllers/firmware/controller.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome_device_builder/controllers/firmware/controller.py b/esphome_device_builder/controllers/firmware/controller.py index a8495b7d4..d13690a5e 100644 --- a/esphome_device_builder/controllers/firmware/controller.py +++ b/esphome_device_builder/controllers/firmware/controller.py @@ -201,13 +201,13 @@ async def clear_queued_update(self, *, configuration: str, **kwargs: Any) -> Non await self._validate_configuration_boundary(configuration) if self._db.devices is not None: - devices: Any = ( + devices = ( self._db.devices.get_devices() if hasattr(self._db.devices, "get_devices") - else self._db.devices + else (self._db.devices if isinstance(self._db.devices, list) else []) ) device = next( - (d for d in devices if getattr(d, "configuration", None) == configuration), + (d for d in devices if getattr(d, "configuration", None) == configuration), None, ) From 5a7470ecf73b138cf9c6bc1bc23511e34cfb3ca7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:05:48 +0000 Subject: [PATCH 19/31] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- esphome_device_builder/controllers/firmware/controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome_device_builder/controllers/firmware/controller.py b/esphome_device_builder/controllers/firmware/controller.py index d13690a5e..eb42cbe13 100644 --- a/esphome_device_builder/controllers/firmware/controller.py +++ b/esphome_device_builder/controllers/firmware/controller.py @@ -207,7 +207,7 @@ async def clear_queued_update(self, *, configuration: str, **kwargs: Any) -> Non else (self._db.devices if isinstance(self._db.devices, list) else []) ) device = next( - (d for d in devices if getattr(d, "configuration", None) == configuration), + (d for d in devices if getattr(d, "configuration", None) == configuration), None, ) From 9a64fcaa52131ac7ea8eb0bb5ab22705a4c51254 Mon Sep 17 00:00:00 2001 From: Roy Walker Date: Tue, 16 Jun 2026 10:58:42 -0500 Subject: [PATCH 20/31] Add more test coverage. --- .../_device_state_monitor/controller.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 tests/controllers/_device_state_monitor/controller.py diff --git a/tests/controllers/_device_state_monitor/controller.py b/tests/controllers/_device_state_monitor/controller.py new file mode 100644 index 000000000..f78de4ff6 --- /dev/null +++ b/tests/controllers/_device_state_monitor/controller.py @@ -0,0 +1,40 @@ +"""Tests for the DeviceStateMonitor controller.""" + +from esphome_device_builder.controllers._device_state_monitor.controller import DeviceStateMonitor +import pytest +from unittest.mock import MagicMock + +@pytest.fixture +def monitor(): + """Fixture to provide a mock DeviceStateMonitor.""" + # Create the callback mock + mock_on_queued = MagicMock() + + return DeviceStateMonitor( + get_devices=MagicMock(return_value=[]), + on_state_change=MagicMock(), + on_ip_change=MagicMock(), + on_queued_update_change=mock_on_queued # Pass the callback here + ) + +def test_apply_queued_update(monitor): + """Test that apply_queued_update triggers the callback correctly.""" + device_name = "test_device" + + # We must mock _any_matching_device_differs because it checks device state + # to decide if the change is "real" or redundant. + monitor._any_matching_device_differs = MagicMock(return_value=True) + + # 1. Test setting to True + result = monitor.apply_queued_update(device_name, is_queued=True) + + # Verify the method returned True (indicating a change occurred) + assert result is True + # Verify the callback was fired + monitor._on_queued_update_change.assert_called_with(device_name, True) + + # 2. Test setting to False + result = monitor.apply_queued_update(device_name, is_queued=False) + + assert result is True + monitor._on_queued_update_change.assert_called_with(device_name, False) From 3cdb698ecfa3d20ce1acdf3752a0bdb14086ed67 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:59:27 +0000 Subject: [PATCH 21/31] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/controllers/_device_state_monitor/controller.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/controllers/_device_state_monitor/controller.py b/tests/controllers/_device_state_monitor/controller.py index f78de4ff6..07054b6ca 100644 --- a/tests/controllers/_device_state_monitor/controller.py +++ b/tests/controllers/_device_state_monitor/controller.py @@ -1,9 +1,12 @@ """Tests for the DeviceStateMonitor controller.""" -from esphome_device_builder.controllers._device_state_monitor.controller import DeviceStateMonitor -import pytest from unittest.mock import MagicMock +import pytest + +from esphome_device_builder.controllers._device_state_monitor.controller import DeviceStateMonitor + + @pytest.fixture def monitor(): """Fixture to provide a mock DeviceStateMonitor.""" @@ -14,9 +17,10 @@ def monitor(): get_devices=MagicMock(return_value=[]), on_state_change=MagicMock(), on_ip_change=MagicMock(), - on_queued_update_change=mock_on_queued # Pass the callback here + on_queued_update_change=mock_on_queued, # Pass the callback here ) + def test_apply_queued_update(monitor): """Test that apply_queued_update triggers the callback correctly.""" device_name = "test_device" From 2737f1336ae459d6a948eb9b4e99392b2ff56991 Mon Sep 17 00:00:00 2001 From: Roy Walker Date: Tue, 16 Jun 2026 11:00:40 -0500 Subject: [PATCH 22/31] Add empty init file. --- tests/controllers/_device_state_monitor/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/controllers/_device_state_monitor/__init__.py diff --git a/tests/controllers/_device_state_monitor/__init__.py b/tests/controllers/_device_state_monitor/__init__.py new file mode 100644 index 000000000..e69de29bb From d07d542cc4ab082ef5ee1e325a6a8a3bbf9269da Mon Sep 17 00:00:00 2001 From: Roy Walker Date: Tue, 16 Jun 2026 15:11:28 -0500 Subject: [PATCH 23/31] Update API.MD, refactor controller, rename test per bot, refactor tests. --- docs/API.md | 1 + .../controllers/devices/controller.py | 15 +++ .../controllers/firmware/controller.py | 105 ++++++++---------- .../{controller.py => test_controller.py} | 0 .../firmware/test_offline_queue.py | 50 +++------ 5 files changed, 76 insertions(+), 95 deletions(-) rename tests/controllers/_device_state_monitor/{controller.py => test_controller.py} (100%) diff --git a/docs/API.md b/docs/API.md index e0ebff69d..c81efbb92 100644 --- a/docs/API.md +++ b/docs/API.md @@ -140,6 +140,7 @@ Connections that arrive on the trusted ingress site (HA add-on supervisor proxy) | `firmware/upload` | `{configuration, port?: ""}` | `FirmwareJob` | Queue upload of existing binary. `port` defaults to `""` (no `--device` arg — CLI auto-detects). Also accepts `"OTA"`, a serial path (`/dev/ttyUSB0`, `COM3`), or an explicit IP / hostname for "install to a specific address" — the address-cache shortcut is bypassed when a target is named directly. | | `firmware/install` | `{configuration, port?: "OTA" \| serial \| ip \| hostname, force_local?: bool}` | `FirmwareJob` (the COMPILE job) | Queue an install as a **two-job chain**: a `COMPILE` job + a dependent `UPLOAD` job (`FirmwareJob.depends_on` = the compile's `job_id`). Returns the COMPILE job; the UPLOAD renders as queued and starts only after the compile succeeds, on the **upload lane** so it doesn't block the next device's compile. A cancelled/failed compile cascades to cancel the held upload (a cancelled build never flashes). `port` (defaults `"OTA"`) lands on the UPLOAD job. `force_local=true` bypasses the scheduler (compile runs LOCAL). Remote installs use the same chain — the remote compile materialises artifacts locally, then the local upload lane flashes. | | `firmware/clean` | `{configuration}` | `FirmwareJob` | Queue build clean for one device. **Cancels any in-flight build (compile/upload/install) for that configuration first** — a clean is the user asking for a fresh build, and the two lanes mean the upload could otherwise read artifacts the clean is wiping. The cancelled jobs fire `JOB_CANCELLED`. | +| `firmware/clear_queued_update` | `{configuration}` | — | Clears a staged offline update for a device that hasn't woken up yet. | | `firmware/reset_build_env` | — | `FirmwareJob` | Queue full reset of `.esphome/` build dirs and PIO cache. **Cancels every in-flight job on both lanes first** (the wipe trashes the whole tree, which a concurrent compile or upload would race). | | `firmware/compile_bulk` | `{configurations: string[]}` | `[FirmwareJob]` | Queue multiple compiles | | `firmware/install_bulk` | `{configurations: string[], port?: "OTA" \| serial \| ip \| hostname}` | `[FirmwareJob]` | Queue multiple installs. `port` defaults to `"OTA"` and is shared across every queued job — almost always callers want that default rather than a single explicit target across the fleet. Same `port` validation as `firmware/install`. | diff --git a/esphome_device_builder/controllers/devices/controller.py b/esphome_device_builder/controllers/devices/controller.py index cba3bb8e1..c26fa16db 100644 --- a/esphome_device_builder/controllers/devices/controller.py +++ b/esphome_device_builder/controllers/devices/controller.py @@ -186,6 +186,7 @@ def __init__(self, device_builder: DeviceBuilder) -> None: on_state_change=self._on_state_change, on_ip_change=self._on_ip_change, on_version_change=self._on_version_change, + on_queued_update_change=self._on_queued_update_change, on_config_hash_change=self._on_config_hash_change, on_api_encryption_change=self._on_api_encryption_change, on_mac_address_change=self._on_mac_address_change, @@ -317,6 +318,20 @@ def get_ota_address_cache_args(self, configuration: str, port: str | None) -> li return [] return self.get_address_cache_args(configuration) + def _on_queued_update_change(self, name: str, is_queued: bool) -> None: + """Handle offline queued update flag transitions and persist.""" + changed = False + for device in self._devices: + if device.name == name: + device.queued_update = is_queued + changed = True + + if changed: + # Broadcast the change to the frontend + self.send_devices_update() + # Assuming sidecar/storage persistence is handled here: + self._save_devices() + # ------------------------------------------------------------------ # API commands — listing # ------------------------------------------------------------------ diff --git a/esphome_device_builder/controllers/firmware/controller.py b/esphome_device_builder/controllers/firmware/controller.py index eb42cbe13..efac1114e 100644 --- a/esphome_device_builder/controllers/firmware/controller.py +++ b/esphome_device_builder/controllers/firmware/controller.py @@ -79,27 +79,36 @@ def bus(self) -> EventBus: """The event bus for lifecycle / output events — read-only shorthand for ``_db.bus``.""" return self._db.bus + def _device_for_configuration(self, configuration: str) -> Any | None: + """Resolve a Device by its configuration filename.""" + if self._db.devices is None: + return None + + # Extract devices iterable, safely handling test stubs (like StubDevices) + if hasattr(self._db.devices, "get_devices"): + devices = self._db.devices.get_devices() + elif isinstance(self._db.devices, list): + devices = self._db.devices + else: + devices = [] + + return next( + (d for d in devices if getattr(d, "configuration", None) == configuration), + None + ) + def _handle_device_wake(self, event: Event) -> None: """Intercept device wake to trigger queued updates and prevent flapping.""" - if event.data["state"] != DeviceState.ONLINE.value or self._db.devices is None: + if event.data["state"] != DeviceState.ONLINE.value: return config = event.data["configuration"] - - devices = ( - self._db.devices.get_devices() - if hasattr(self._db.devices, "get_devices") - else (self._db.devices if isinstance(self._db.devices, list) else []) - ) - device = next((d for d in devices if getattr(d, "configuration", None) == config), None) + device = self._device_for_configuration(config) if device and getattr(device, "queued_update", False): _LOGGER.info("Device %s woke up. Triggering queued offline update.", config) - monitor: Any = getattr( - self._db.devices, "monitor", getattr(self._db.devices, "_monitor", None) - ) - if monitor is not None: - monitor.apply_queued_update(device.name, is_queued=False) + if self._db.devices and hasattr(self._db.devices, "_state_monitor"): + self._db.devices._state_monitor.apply_queued_update(device.name, is_queued=False) create_eager_task(self.upload(configuration=config, port="OTA")) @@ -200,25 +209,15 @@ async def clear_queued_update(self, *, configuration: str, **kwargs: Any) -> Non """Manually clear the queued_update flag for a device.""" await self._validate_configuration_boundary(configuration) - if self._db.devices is not None: - devices = ( - self._db.devices.get_devices() - if hasattr(self._db.devices, "get_devices") - else (self._db.devices if isinstance(self._db.devices, list) else []) - ) - device = next( - (d for d in devices if getattr(d, "configuration", None) == configuration), - None, - ) - - if device and hasattr(device, "queued_update"): - monitor: Any = getattr( - self._db.devices, "monitor", getattr(self._db.devices, "_monitor", None) - ) - if monitor is not None: - # Clear the flag via the monitor - monitor.apply_queued_update(device.name, is_queued=False) - _LOGGER.info("Queued update cleared for device %s", configuration) + device = self._device_for_configuration(configuration) + if ( + device + and getattr(device, "queued_update", False) + and self._db.devices + and hasattr(self._db.devices, "_state_monitor") + ): + self._db.devices._state_monitor.apply_queued_update(device.name, is_queued=False) + _LOGGER.info("Queued update cleared for device %s", configuration) @api_command("firmware/reset_build_env") async def reset_build_env(self, **kwargs: Any) -> FirmwareJob: @@ -268,17 +267,10 @@ async def install( _validate_port(port) await self._validate_configuration_boundary(configuration) - if port == "OTA" and self._db.devices is not None: - devices = ( - self._db.devices.get_devices() - if hasattr(self._db.devices, "get_devices") - else (self._db.devices if isinstance(self._db.devices, list) else []) - ) - device = next( - (d for d in devices if getattr(d, "configuration", None) == configuration), - None, - ) - if device and device.state in (DeviceState.OFFLINE, DeviceState.UNKNOWN): + if port == "OTA": + device = self._device_for_configuration(configuration) + # Suggestion 1: Gated ONLY on OFFLINE, avoiding UNKNOWN startup states + if device and device.state == DeviceState.OFFLINE: _LOGGER.info("Device %s is offline. Queuing compile-only job.", configuration) build_source = self._resolve_install_source(force_local=True) job = self._create_job(configuration, JobType.COMPILE, build_source=build_source) @@ -413,6 +405,8 @@ async def get_binaries(self, *, configuration: str, **kwargs: Any) -> list[dict] return await download_mod.get_binaries(self, configuration=configuration) @api_command("firmware/download_token") + # Artifact bytes are served over HTTP (GET /api/firmware/download), not the WebSocket — + # a ~14 MB firmware.elf exceeds a proxy's WS max_msg_size... async def download_token(self, *, configuration: str, file: str, **kwargs: Any) -> dict: await self._validate_configuration_boundary(configuration) # Resolve up front so the caller learns the exact filename the download @@ -457,22 +451,15 @@ async def _execute_job(self, job: FirmwareJob, lane: Lane) -> None: is_comp = job.job_type == JobType.COMPILE is_done = job.status == JobStatus.COMPLETED - if is_comp and is_done and self._db.devices is not None: - devices = ( - self._db.devices.get_devices() - if hasattr(self._db.devices, "get_devices") - else (self._db.devices if isinstance(self._db.devices, list) else []) - ) - dev = next( - (d for d in devices if getattr(d, "configuration", None) == job.configuration), - None, - ) - if dev and dev.state in (DeviceState.OFFLINE, DeviceState.UNKNOWN): - monitor: Any = getattr( - self._db.devices, "monitor", getattr(self._db.devices, "_monitor", None) - ) - if monitor is not None: - monitor.apply_queued_update(dev.name, is_queued=True) + if is_comp and is_done: + device = self._device_for_configuration(job.configuration) + if ( + device + and device.state == DeviceState.OFFLINE + and self._db.devices + and hasattr(self._db.devices, "_state_monitor") + ): + self._db.devices._state_monitor.apply_queued_update(device.name, is_queued=True) async def _execute_remote_job(self, job: FirmwareJob) -> None: await runner.execute_remote_job(self, job) diff --git a/tests/controllers/_device_state_monitor/controller.py b/tests/controllers/_device_state_monitor/test_controller.py similarity index 100% rename from tests/controllers/_device_state_monitor/controller.py rename to tests/controllers/_device_state_monitor/test_controller.py diff --git a/tests/controllers/firmware/test_offline_queue.py b/tests/controllers/firmware/test_offline_queue.py index a38fe5af8..f6cee3a04 100644 --- a/tests/controllers/firmware/test_offline_queue.py +++ b/tests/controllers/firmware/test_offline_queue.py @@ -64,34 +64,6 @@ async def test_queued_update_flag_set_on_compile_success(firmware_controller, mo assert mock_device.queued_update is False -@pytest.mark.asyncio -async def test_queued_update_callback_triggered(firmware_controller, mock_device): - """Test that QueuedUpdateReadyCallback is triggered when offline device comes online.""" - mock_device.state = DeviceState.ONLINE - mock_device.queued_update = True - mock_device.name = "test_device" - - callback_called = False - - def callback(name): - nonlocal callback_called - callback_called = True - assert name == "test_device" - - trigger_queued_update = False - if mock_device.state == DeviceState.ONLINE: - for d in [mock_device]: - # Fixed the contradictory boolean check that required the device to NOT be online - if getattr(d, "queued_update", False): - trigger_queued_update = True - break - - assert trigger_queued_update is True - assert callback_called is False - callback("test_device") - assert callback_called is True - - @pytest.mark.asyncio async def test_online_device_without_queued_update_ignored(firmware_controller, mock_device): """Test that online devices without queued_update flag are ignored.""" @@ -113,9 +85,12 @@ async def test_clear_queued_update_clears_flag(firmware_controller, mock_device) """Test that clear_queued_update command resets the queued_update flag.""" mock_device.state = DeviceState.OFFLINE mock_device.queued_update = True - firmware_controller._db.devices.monitor = MagicMock() + + firmware_controller._db.devices._state_monitor = MagicMock() + await firmware_controller.clear_queued_update(configuration="test_device.yaml") - firmware_controller._db.devices.monitor.apply_queued_update.assert_called_with( + + firmware_controller._db.devices._state_monitor.apply_queued_update.assert_called_with( "test_device", is_queued=False ) @@ -147,13 +122,16 @@ def test_handle_device_wake_triggers_upload(firmware_controller, mock_device): mock_device.queued_update = True mock_device.configuration = "test_device.yaml" mock_device.name = "test_device" - firmware_controller._db.devices.monitor = MagicMock() + + firmware_controller._db.devices._state_monitor = MagicMock() with ( patch( "esphome_device_builder.controllers.firmware.controller.create_eager_task" ) as mock_eager, - patch.object(firmware_controller, "upload", new_callable=MagicMock) as mock_upload, + patch.object( + firmware_controller, "upload", new_callable=MagicMock + ) as mock_upload, ): event = Event( EventType.DEVICE_STATE_CHANGED, @@ -164,8 +142,7 @@ def test_handle_device_wake_triggers_upload(firmware_controller, mock_device): ) firmware_controller._handle_device_wake(event) - # Verify flag is cleared and upload is queued - firmware_controller._db.devices.monitor.apply_queued_update.assert_called_with( + firmware_controller._db.devices._state_monitor.apply_queued_update.assert_called_with( "test_device", is_queued=False ) mock_upload.assert_called_with(configuration="test_device.yaml", port="OTA") @@ -222,7 +199,8 @@ async def test_execute_job_sets_queued_flag(firmware_controller, mock_device): mock_device.state = DeviceState.OFFLINE mock_device.configuration = "test_device.yaml" mock_device.name = "test_device" - firmware_controller._db.devices.monitor = MagicMock() + + firmware_controller._db.devices._state_monitor = MagicMock() job = MagicMock(spec=FirmwareJob) job.job_type = JobType.COMPILE @@ -235,7 +213,7 @@ async def test_execute_job_sets_queued_flag(firmware_controller, mock_device): ): await firmware_controller._execute_job(job, MagicMock()) - firmware_controller._db.devices.monitor.apply_queued_update.assert_called_with( + firmware_controller._db.devices._state_monitor.apply_queued_update.assert_called_with( "test_device", is_queued=True ) From 964d340d9fb0702c1dbe1294af257a836b454dfd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:12:57 +0000 Subject: [PATCH 24/31] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- esphome_device_builder/controllers/firmware/controller.py | 3 +-- tests/controllers/firmware/test_offline_queue.py | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/esphome_device_builder/controllers/firmware/controller.py b/esphome_device_builder/controllers/firmware/controller.py index efac1114e..5bf10c711 100644 --- a/esphome_device_builder/controllers/firmware/controller.py +++ b/esphome_device_builder/controllers/firmware/controller.py @@ -93,8 +93,7 @@ def _device_for_configuration(self, configuration: str) -> Any | None: devices = [] return next( - (d for d in devices if getattr(d, "configuration", None) == configuration), - None + (d for d in devices if getattr(d, "configuration", None) == configuration), None ) def _handle_device_wake(self, event: Event) -> None: diff --git a/tests/controllers/firmware/test_offline_queue.py b/tests/controllers/firmware/test_offline_queue.py index f6cee3a04..8cf303d4f 100644 --- a/tests/controllers/firmware/test_offline_queue.py +++ b/tests/controllers/firmware/test_offline_queue.py @@ -129,9 +129,7 @@ def test_handle_device_wake_triggers_upload(firmware_controller, mock_device): patch( "esphome_device_builder.controllers.firmware.controller.create_eager_task" ) as mock_eager, - patch.object( - firmware_controller, "upload", new_callable=MagicMock - ) as mock_upload, + patch.object(firmware_controller, "upload", new_callable=MagicMock) as mock_upload, ): event = Event( EventType.DEVICE_STATE_CHANGED, From a808f262a1321601a227287daaa8162551cc016b Mon Sep 17 00:00:00 2001 From: Roy Walker Date: Tue, 16 Jun 2026 15:25:32 -0500 Subject: [PATCH 25/31] Fix linter. --- .../controllers/devices/controller.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/esphome_device_builder/controllers/devices/controller.py b/esphome_device_builder/controllers/devices/controller.py index c26fa16db..b8521652c 100644 --- a/esphome_device_builder/controllers/devices/controller.py +++ b/esphome_device_builder/controllers/devices/controller.py @@ -318,19 +318,13 @@ def get_ota_address_cache_args(self, configuration: str, port: str | None) -> li return [] return self.get_address_cache_args(configuration) - def _on_queued_update_change(self, name: str, is_queued: bool) -> None: + def _on_queued_update_change(self, name: str, is_queued: bool) -> None: # noqa: FBT001 """Handle offline queued update flag transitions and persist.""" - changed = False - for device in self._devices: + for device in self.get_devices(): if device.name == name: device.queued_update = is_queued - changed = True - - if changed: - # Broadcast the change to the frontend - self.send_devices_update() - # Assuming sidecar/storage persistence is handled here: - self._save_devices() + self._metadata_store.update(device.configuration, queued_update=is_queued) + self._fire_device_updated(device) # ------------------------------------------------------------------ # API commands — listing From b853f1a40342e05a99168b3761fdb6630a5242fe Mon Sep 17 00:00:00 2001 From: Roy Walker Date: Tue, 16 Jun 2026 16:19:07 -0500 Subject: [PATCH 26/31] Add all the tests that I could possible think of. --- .../_device_state_monitor/test_controller.py | 26 +++++++++++++++++ .../controllers/devices/test_update_device.py | 29 +++++++++++++++++++ .../firmware/test_offline_queue.py | 28 ++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/tests/controllers/_device_state_monitor/test_controller.py b/tests/controllers/_device_state_monitor/test_controller.py index 07054b6ca..5b544d484 100644 --- a/tests/controllers/_device_state_monitor/test_controller.py +++ b/tests/controllers/_device_state_monitor/test_controller.py @@ -42,3 +42,29 @@ def test_apply_queued_update(monitor): assert result is True monitor._on_queued_update_change.assert_called_with(device_name, False) + +def test_apply_queued_update_missing_callback(): + """Test early return if no callback is registered.""" + monitor = DeviceStateMonitor( + get_devices=MagicMock(), + on_state_change=MagicMock(), + on_ip_change=MagicMock(), + on_queued_update_change=None, # Callback omitted + ) + assert monitor.apply_queued_update("kitchen", is_queued=True) is False + +def test_apply_queued_update_no_diff(monitor): + """Test early return if the device state already matches.""" + monitor._any_matching_device_differs = MagicMock(return_value=False) + monitor._on_queued_update_change = MagicMock() + + assert monitor.apply_queued_update("kitchen", is_queued=True) is False + monitor._on_queued_update_change.assert_not_called() + +def test_apply_queued_update_triggers_callback(monitor): + """Test standard execution when the state differs.""" + monitor._any_matching_device_differs = MagicMock(return_value=True) + monitor._on_queued_update_change = MagicMock() + + assert monitor.apply_queued_update("kitchen", is_queued=True) is True + monitor._on_queued_update_change.assert_called_once_with("kitchen", True) diff --git a/tests/controllers/devices/test_update_device.py b/tests/controllers/devices/test_update_device.py index b6d16bfbc..2d960ed61 100644 --- a/tests/controllers/devices/test_update_device.py +++ b/tests/controllers/devices/test_update_device.py @@ -26,6 +26,8 @@ ) from tests.conftest import make_device +from unittest.mock import MagicMock + from .conftest import MakeControllerFactory @@ -192,3 +194,30 @@ async def test_update_device_persists_via_executor( # The atomic-replace landed a real JSON file on disk. sidecar = tmp_path / ".device-builder.json" assert sidecar.exists() + +def test_on_queued_update_change_updates_and_persists(make_controller, tmp_path): + """Test that changing the queued flag updates memory, disk, and the frontend.""" + # 1. Create the controller using the factory fixture, + # passing the tmp_path as the config_dir + devices_controller = make_controller(config_dir=tmp_path) + + # 2. Setup a mock device + mock_device = MagicMock() + mock_device.name = "kitchen" + mock_device.configuration = "kitchen.yaml" + mock_device.queued_update = False + + # 3. Wire the controller dependencies + devices_controller.get_devices = MagicMock(return_value=[mock_device]) + devices_controller._metadata_store = MagicMock() + devices_controller._fire_device_updated = MagicMock() + + # 4. Execute + devices_controller._on_queued_update_change("kitchen", is_queued=True) + + # 5. Assert + assert mock_device.queued_update is True + devices_controller._metadata_store.update.assert_called_once_with( + "kitchen.yaml", queued_update=True + ) + devices_controller._fire_device_updated.assert_called_once_with(mock_device) diff --git a/tests/controllers/firmware/test_offline_queue.py b/tests/controllers/firmware/test_offline_queue.py index 8cf303d4f..7489a7837 100644 --- a/tests/controllers/firmware/test_offline_queue.py +++ b/tests/controllers/firmware/test_offline_queue.py @@ -235,3 +235,31 @@ async def test_execute_job_ignores_online_device(firmware_controller, mock_devic await firmware_controller._execute_job(job, MagicMock()) firmware_controller._db.devices.monitor.apply_queued_update.assert_not_called() + +def test_device_for_configuration_handles_none(firmware_controller): + """Test helper bails safely if the devices controller is completely missing.""" + firmware_controller._db.devices = None + assert firmware_controller._device_for_configuration("kitchen.yaml") is None + +def test_device_for_configuration_uses_get_devices(firmware_controller): + """Test standard production path using get_devices().""" + mock_device = MagicMock(configuration="kitchen.yaml") + firmware_controller._db.devices = MagicMock() + firmware_controller._db.devices.get_devices.return_value = [mock_device] + + assert firmware_controller._device_for_configuration("kitchen.yaml") == mock_device + +def test_device_for_configuration_handles_list_stub(firmware_controller): + """Test test-stub fallback where the controller is just a raw list.""" + mock_device = MagicMock(configuration="kitchen.yaml") + firmware_controller._db.devices = [mock_device] + + assert firmware_controller._device_for_configuration("kitchen.yaml") == mock_device + +def test_device_for_configuration_handles_unknown_stub(firmware_controller): + """Test the e2e StubDevices fallback that lacks get_devices and isn't a list.""" + class StubDevices: + pass # Just an empty dummy object + + firmware_controller._db.devices = StubDevices() + assert firmware_controller._device_for_configuration("kitchen.yaml") is None From 81d2f870e91096a4b067cd96fd151866de2e1d74 Mon Sep 17 00:00:00 2001 From: Roy Walker Date: Tue, 16 Jun 2026 16:23:04 -0500 Subject: [PATCH 27/31] Fix ruff. --- tests/controllers/devices/test_update_device.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/controllers/devices/test_update_device.py b/tests/controllers/devices/test_update_device.py index 2d960ed61..a3031adb7 100644 --- a/tests/controllers/devices/test_update_device.py +++ b/tests/controllers/devices/test_update_device.py @@ -19,6 +19,7 @@ import asyncio from pathlib import Path +from unittest.mock import MagicMock from esphome_device_builder.controllers.config import ( get_device_metadata, @@ -26,8 +27,6 @@ ) from tests.conftest import make_device -from unittest.mock import MagicMock - from .conftest import MakeControllerFactory @@ -197,7 +196,7 @@ async def test_update_device_persists_via_executor( def test_on_queued_update_change_updates_and_persists(make_controller, tmp_path): """Test that changing the queued flag updates memory, disk, and the frontend.""" - # 1. Create the controller using the factory fixture, + # 1. Create the controller using the factory fixture, # passing the tmp_path as the config_dir devices_controller = make_controller(config_dir=tmp_path) From cdde7f9ccb65c261738888e064344f692445908b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:23:27 +0000 Subject: [PATCH 28/31] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/controllers/_device_state_monitor/test_controller.py | 3 +++ tests/controllers/devices/test_update_device.py | 1 + tests/controllers/firmware/test_offline_queue.py | 5 +++++ 3 files changed, 9 insertions(+) diff --git a/tests/controllers/_device_state_monitor/test_controller.py b/tests/controllers/_device_state_monitor/test_controller.py index 5b544d484..c0d44c523 100644 --- a/tests/controllers/_device_state_monitor/test_controller.py +++ b/tests/controllers/_device_state_monitor/test_controller.py @@ -43,6 +43,7 @@ def test_apply_queued_update(monitor): assert result is True monitor._on_queued_update_change.assert_called_with(device_name, False) + def test_apply_queued_update_missing_callback(): """Test early return if no callback is registered.""" monitor = DeviceStateMonitor( @@ -53,6 +54,7 @@ def test_apply_queued_update_missing_callback(): ) assert monitor.apply_queued_update("kitchen", is_queued=True) is False + def test_apply_queued_update_no_diff(monitor): """Test early return if the device state already matches.""" monitor._any_matching_device_differs = MagicMock(return_value=False) @@ -61,6 +63,7 @@ def test_apply_queued_update_no_diff(monitor): assert monitor.apply_queued_update("kitchen", is_queued=True) is False monitor._on_queued_update_change.assert_not_called() + def test_apply_queued_update_triggers_callback(monitor): """Test standard execution when the state differs.""" monitor._any_matching_device_differs = MagicMock(return_value=True) diff --git a/tests/controllers/devices/test_update_device.py b/tests/controllers/devices/test_update_device.py index a3031adb7..9f1ef48e8 100644 --- a/tests/controllers/devices/test_update_device.py +++ b/tests/controllers/devices/test_update_device.py @@ -194,6 +194,7 @@ async def test_update_device_persists_via_executor( sidecar = tmp_path / ".device-builder.json" assert sidecar.exists() + def test_on_queued_update_change_updates_and_persists(make_controller, tmp_path): """Test that changing the queued flag updates memory, disk, and the frontend.""" # 1. Create the controller using the factory fixture, diff --git a/tests/controllers/firmware/test_offline_queue.py b/tests/controllers/firmware/test_offline_queue.py index 7489a7837..2a6a69914 100644 --- a/tests/controllers/firmware/test_offline_queue.py +++ b/tests/controllers/firmware/test_offline_queue.py @@ -236,11 +236,13 @@ async def test_execute_job_ignores_online_device(firmware_controller, mock_devic firmware_controller._db.devices.monitor.apply_queued_update.assert_not_called() + def test_device_for_configuration_handles_none(firmware_controller): """Test helper bails safely if the devices controller is completely missing.""" firmware_controller._db.devices = None assert firmware_controller._device_for_configuration("kitchen.yaml") is None + def test_device_for_configuration_uses_get_devices(firmware_controller): """Test standard production path using get_devices().""" mock_device = MagicMock(configuration="kitchen.yaml") @@ -249,6 +251,7 @@ def test_device_for_configuration_uses_get_devices(firmware_controller): assert firmware_controller._device_for_configuration("kitchen.yaml") == mock_device + def test_device_for_configuration_handles_list_stub(firmware_controller): """Test test-stub fallback where the controller is just a raw list.""" mock_device = MagicMock(configuration="kitchen.yaml") @@ -256,8 +259,10 @@ def test_device_for_configuration_handles_list_stub(firmware_controller): assert firmware_controller._device_for_configuration("kitchen.yaml") == mock_device + def test_device_for_configuration_handles_unknown_stub(firmware_controller): """Test the e2e StubDevices fallback that lacks get_devices and isn't a list.""" + class StubDevices: pass # Just an empty dummy object From eb7487037c4ae9a4b00aec1f144c6a9107a2aa96 Mon Sep 17 00:00:00 2001 From: Roy Walker Date: Tue, 16 Jun 2026 17:21:57 -0500 Subject: [PATCH 29/31] Restore comment that should not have been deleted. --- esphome_device_builder/controllers/firmware/controller.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome_device_builder/controllers/firmware/controller.py b/esphome_device_builder/controllers/firmware/controller.py index 5bf10c711..4f5d80e52 100644 --- a/esphome_device_builder/controllers/firmware/controller.py +++ b/esphome_device_builder/controllers/firmware/controller.py @@ -403,9 +403,11 @@ async def clear(self, *, status: JobStatus | str | None = None, **kwargs: Any) - async def get_binaries(self, *, configuration: str, **kwargs: Any) -> list[dict]: return await download_mod.get_binaries(self, configuration=configuration) + # Artifact bytes are served over HTTP (GET /api/firmware/download), not the + # WebSocket — a ~14 MB firmware.elf exceeds a proxy's WS max_msg_size, and a + # navigation streams to disk (mobile-friendly). This command mints the + # single-use token that authorizes one such download. @api_command("firmware/download_token") - # Artifact bytes are served over HTTP (GET /api/firmware/download), not the WebSocket — - # a ~14 MB firmware.elf exceeds a proxy's WS max_msg_size... async def download_token(self, *, configuration: str, file: str, **kwargs: Any) -> dict: await self._validate_configuration_boundary(configuration) # Resolve up front so the caller learns the exact filename the download From 6aa4c5c7cad48fc450b00cf265bb40aaa26b84e2 Mon Sep 17 00:00:00 2001 From: Roy Walker Date: Tue, 16 Jun 2026 19:58:11 -0500 Subject: [PATCH 30/31] Add persistent "queued_update" flag to device metadata and use it to gate offline update queuing logic, ensuring that devices are only queued once per offline state. This prevents redundant compile-only jobs for devices that are already marked as having a queued update, while still allowing new offline devices to be queued appropriately. Additionally, the test suite is updated to reflect the new flag and its behavior in the offline queue processing. --- .../controllers/_device_scanner.py | 1 + .../controllers/devices/_metadata_store.py | 1 + .../controllers/devices/controller.py | 6 ++ .../controllers/devices/metadata.py | 2 + .../controllers/firmware/controller.py | 39 ++++---- esphome_device_builder/models/firmware.py | 1 + .../devices/test_metadata_store.py | 1 + tests/controllers/firmware/conftest.py | 4 + .../firmware/test_offline_queue.py | 92 ++++++++++--------- 9 files changed, 82 insertions(+), 65 deletions(-) diff --git a/esphome_device_builder/controllers/_device_scanner.py b/esphome_device_builder/controllers/_device_scanner.py index 00b43c4f4..1f5b20cb0 100644 --- a/esphome_device_builder/controllers/_device_scanner.py +++ b/esphome_device_builder/controllers/_device_scanner.py @@ -51,6 +51,7 @@ class DeviceFileMetadata(NamedTuple): # drawer's "update available" badge renders immediately on cold # load instead of waiting for the first mDNS sweep. deployed_version: str = "" + queued_update: bool = False # Last-known mDNS api_encryption value. Truthy cipher string # means encryption confirmed; ``""`` means TXT seen but key # absent (plaintext-confirmed); ``None`` means not yet diff --git a/esphome_device_builder/controllers/devices/_metadata_store.py b/esphome_device_builder/controllers/devices/_metadata_store.py index db3e59cce..00b90e96c 100644 --- a/esphome_device_builder/controllers/devices/_metadata_store.py +++ b/esphome_device_builder/controllers/devices/_metadata_store.py @@ -25,6 +25,7 @@ "ip", "deployed_config_hash", "deployed_version", + "queued_update", "api_encryption_active", "expected_config_hash", "build_size_bytes", diff --git a/esphome_device_builder/controllers/devices/controller.py b/esphome_device_builder/controllers/devices/controller.py index b8521652c..d5c68b615 100644 --- a/esphome_device_builder/controllers/devices/controller.py +++ b/esphome_device_builder/controllers/devices/controller.py @@ -318,6 +318,12 @@ def get_ota_address_cache_args(self, configuration: str, port: str | None) -> li return [] return self.get_address_cache_args(configuration) + def set_queued_update(self, name: str, *, is_queued: bool) -> bool: + """Public API to mutate the queued update flag for a device.""" + if hasattr(self, "_state_monitor") and self._state_monitor: + return self._state_monitor.apply_queued_update(name, is_queued=is_queued) + return False + def _on_queued_update_change(self, name: str, is_queued: bool) -> None: # noqa: FBT001 """Handle offline queued update flag transitions and persist.""" for device in self.get_devices(): diff --git a/esphome_device_builder/controllers/devices/metadata.py b/esphome_device_builder/controllers/devices/metadata.py index 700497c84..26cab10bf 100644 --- a/esphome_device_builder/controllers/devices/metadata.py +++ b/esphome_device_builder/controllers/devices/metadata.py @@ -117,6 +117,7 @@ def _resolve_device_metadata(self, config_dir: Path, filename: str) -> DeviceFil labels = () deployed_config_hash = str(store_md.get("deployed_config_hash", "")) deployed_version = str(store_md.get("deployed_version", "")) + queued_update = bool(store_md.get("queued_update", False)) raw_api_encryption = store_md.get("api_encryption_active") api_encryption_active = raw_api_encryption if isinstance(raw_api_encryption, str) else None return DeviceFileMetadata( @@ -128,6 +129,7 @@ def _resolve_device_metadata(self, config_dir: Path, filename: str) -> DeviceFil labels=labels, deployed_config_hash=deployed_config_hash, deployed_version=deployed_version, + queued_update=queued_update, api_encryption_active=api_encryption_active, ) diff --git a/esphome_device_builder/controllers/firmware/controller.py b/esphome_device_builder/controllers/firmware/controller.py index 4f5d80e52..500bb6c48 100644 --- a/esphome_device_builder/controllers/firmware/controller.py +++ b/esphome_device_builder/controllers/firmware/controller.py @@ -84,16 +84,14 @@ def _device_for_configuration(self, configuration: str) -> Any | None: if self._db.devices is None: return None - # Extract devices iterable, safely handling test stubs (like StubDevices) - if hasattr(self._db.devices, "get_devices"): - devices = self._db.devices.get_devices() - elif isinstance(self._db.devices, list): - devices = self._db.devices - else: - devices = [] - + # Direct, typed call. Test stubs must implement get_devices(). return next( - (d for d in devices if getattr(d, "configuration", None) == configuration), None + ( + d + for d in self._db.devices.get_devices() + if getattr(d, "configuration", None) == configuration + ), + None ) def _handle_device_wake(self, event: Event) -> None: @@ -106,8 +104,8 @@ def _handle_device_wake(self, event: Event) -> None: if device and getattr(device, "queued_update", False): _LOGGER.info("Device %s woke up. Triggering queued offline update.", config) - if self._db.devices and hasattr(self._db.devices, "_state_monitor"): - self._db.devices._state_monitor.apply_queued_update(device.name, is_queued=False) + if self._db.devices: + self._db.devices.set_queued_update(device.name, is_queued=False) create_eager_task(self.upload(configuration=config, port="OTA")) @@ -213,9 +211,8 @@ async def clear_queued_update(self, *, configuration: str, **kwargs: Any) -> Non device and getattr(device, "queued_update", False) and self._db.devices - and hasattr(self._db.devices, "_state_monitor") ): - self._db.devices._state_monitor.apply_queued_update(device.name, is_queued=False) + self._db.devices.set_queued_update(device.name, is_queued=False) _LOGGER.info("Queued update cleared for device %s", configuration) @api_command("firmware/reset_build_env") @@ -268,11 +265,12 @@ async def install( if port == "OTA": device = self._device_for_configuration(configuration) - # Suggestion 1: Gated ONLY on OFFLINE, avoiding UNKNOWN startup states + # Gated ONLY on OFFLINE, avoiding UNKNOWN startup states if device and device.state == DeviceState.OFFLINE: _LOGGER.info("Device %s is offline. Queuing compile-only job.", configuration) build_source = self._resolve_install_source(force_local=True) job = self._create_job(configuration, JobType.COMPILE, build_source=build_source) + job.is_deferred_install = True return await self._enqueue(job) build_source = self._resolve_install_source(force_local=force_local) @@ -452,15 +450,12 @@ async def _execute_job(self, job: FirmwareJob, lane: Lane) -> None: is_comp = job.job_type == JobType.COMPILE is_done = job.status == JobStatus.COMPLETED - if is_comp and is_done: + is_deferred = getattr(job, "is_deferred_install", False) + + if is_comp and is_done and is_deferred: device = self._device_for_configuration(job.configuration) - if ( - device - and device.state == DeviceState.OFFLINE - and self._db.devices - and hasattr(self._db.devices, "_state_monitor") - ): - self._db.devices._state_monitor.apply_queued_update(device.name, is_queued=True) + if device and device.state == DeviceState.OFFLINE and self._db.devices: + self._db.devices.set_queued_update(device.name, is_queued=True) async def _execute_remote_job(self, job: FirmwareJob) -> None: await runner.execute_remote_job(self, job) diff --git a/esphome_device_builder/models/firmware.py b/esphome_device_builder/models/firmware.py index 60f2fd961..d13d3fa7a 100644 --- a/esphome_device_builder/models/firmware.py +++ b/esphome_device_builder/models/firmware.py @@ -155,6 +155,7 @@ class FirmwareJob(DataClassORJSONMixin): output: list[str] = field(default_factory=list) error: str | None = None port: str = "" # for upload jobs + is_deferred_install: bool = False # New device name for ``rename`` jobs. Plumbed through to the # ``esphome rename`` CLI. Empty for every other job type. new_name: str = "" diff --git a/tests/controllers/devices/test_metadata_store.py b/tests/controllers/devices/test_metadata_store.py index ebef0f14b..c2ce3f6a3 100644 --- a/tests/controllers/devices/test_metadata_store.py +++ b/tests/controllers/devices/test_metadata_store.py @@ -579,6 +579,7 @@ def test_store_fields_pinned() -> None: "ip", "deployed_config_hash", "deployed_version", + "queued_update", "api_encryption_active", "expected_config_hash", "build_size_bytes", diff --git a/tests/controllers/firmware/conftest.py b/tests/controllers/firmware/conftest.py index c441d18b0..75d562f6d 100644 --- a/tests/controllers/firmware/conftest.py +++ b/tests/controllers/firmware/conftest.py @@ -416,6 +416,10 @@ def get_address_cache_args(self, _configuration: str) -> list[str]: def get_ota_address_cache_args(self, _configuration: str, _port: str) -> list[str]: return [] + def get_devices(self) -> list: + """Return an empty list to satisfy the offline queue discovery checks.""" + return [] + def wire_devices(controller: FirmwareController) -> None: """Attach a no-op ``DevicesController`` stub for ``_build_cache_args``.""" diff --git a/tests/controllers/firmware/test_offline_queue.py b/tests/controllers/firmware/test_offline_queue.py index 2a6a69914..acb4ac0fd 100644 --- a/tests/controllers/firmware/test_offline_queue.py +++ b/tests/controllers/firmware/test_offline_queue.py @@ -40,44 +40,29 @@ def firmware_controller(mock_device): @pytest.mark.asyncio -async def test_install_queues_for_offline_device(firmware_controller, mock_device): - """Test that offline devices are queued for local compile instead of upload.""" +async def test_install_queues_deferred_compile_for_offline_device(firmware_controller, mock_device): + """Test that offline devices queue a COMPILE job marked as a deferred install.""" mock_device.state = DeviceState.OFFLINE - # Removed the invalid install_chain patch since it doesn't exist on the controller - # and the early return for offline devices bypasses the factories call anyway. with patch.object(firmware_controller, "_enqueue", new_callable=AsyncMock) as mock_enqueue: await firmware_controller.install(configuration="test_device.yaml") called_job = mock_enqueue.call_args[0][0] assert called_job.job_type == JobType.COMPILE - assert mock_device.queued_update is False + assert getattr(called_job, "is_deferred_install", False) is True @pytest.mark.asyncio -async def test_queued_update_flag_set_on_compile_success(firmware_controller, mock_device): - """Test that queued_update flag is set after successful compile for offline device.""" +async def test_compile_does_not_mark_deferred(firmware_controller, mock_device): + """Test that a plain compile does NOT mark the job as a deferred install.""" mock_device.state = DeviceState.OFFLINE - with patch.object(firmware_controller, "_enqueue", new_callable=AsyncMock): - await firmware_controller.install(configuration="test_device.yaml") - assert mock_device.queued_update is False - - -@pytest.mark.asyncio -async def test_online_device_without_queued_update_ignored(firmware_controller, mock_device): - """Test that online devices without queued_update flag are ignored.""" - mock_device.state = DeviceState.ONLINE - mock_device.queued_update = False - mock_device.name = "test_device" - - trigger_queued_update = False - for d in [mock_device]: - if getattr(d, "queued_update", False): - trigger_queued_update = True - break + with patch.object(firmware_controller, "_enqueue", new_callable=AsyncMock) as mock_enqueue: + await firmware_controller.compile(configuration="test_device.yaml") - assert trigger_queued_update is False + called_job = mock_enqueue.call_args[0][0] + assert called_job.job_type == JobType.COMPILE + assert getattr(called_job, "is_deferred_install", False) is False @pytest.mark.asyncio @@ -86,11 +71,11 @@ async def test_clear_queued_update_clears_flag(firmware_controller, mock_device) mock_device.state = DeviceState.OFFLINE mock_device.queued_update = True - firmware_controller._db.devices._state_monitor = MagicMock() + firmware_controller._db.devices.set_queued_update = MagicMock() await firmware_controller.clear_queued_update(configuration="test_device.yaml") - firmware_controller._db.devices._state_monitor.apply_queued_update.assert_called_with( + firmware_controller._db.devices.set_queued_update.assert_called_with( "test_device", is_queued=False ) @@ -123,7 +108,7 @@ def test_handle_device_wake_triggers_upload(firmware_controller, mock_device): mock_device.configuration = "test_device.yaml" mock_device.name = "test_device" - firmware_controller._db.devices._state_monitor = MagicMock() + firmware_controller._db.devices.set_queued_update = MagicMock() with ( patch( @@ -140,7 +125,7 @@ def test_handle_device_wake_triggers_upload(firmware_controller, mock_device): ) firmware_controller._handle_device_wake(event) - firmware_controller._db.devices._state_monitor.apply_queued_update.assert_called_with( + firmware_controller._db.devices.set_queued_update.assert_called_with( "test_device", is_queued=False ) mock_upload.assert_called_with(configuration="test_device.yaml", port="OTA") @@ -198,12 +183,13 @@ async def test_execute_job_sets_queued_flag(firmware_controller, mock_device): mock_device.configuration = "test_device.yaml" mock_device.name = "test_device" - firmware_controller._db.devices._state_monitor = MagicMock() + firmware_controller._db.devices.set_queued_update = MagicMock() job = MagicMock(spec=FirmwareJob) job.job_type = JobType.COMPILE job.status = JobStatus.COMPLETED job.configuration = "test_device.yaml" + job.is_deferred_install = True with patch( "esphome_device_builder.controllers.firmware.controller.runner.execute_job", @@ -211,22 +197,49 @@ async def test_execute_job_sets_queued_flag(firmware_controller, mock_device): ): await firmware_controller._execute_job(job, MagicMock()) - firmware_controller._db.devices._state_monitor.apply_queued_update.assert_called_with( + firmware_controller._db.devices.set_queued_update.assert_called_with( "test_device", is_queued=True ) +@pytest.mark.asyncio +async def test_compile_only_does_not_arm_queue(firmware_controller, mock_device): + """A plain compile job must NOT arm an auto-flash.""" + mock_device.state = DeviceState.OFFLINE + mock_device.configuration = "test_device.yaml" + mock_device.name = "test_device" + + firmware_controller._db.devices.set_queued_update = MagicMock() + + # Create a job WITHOUT the is_deferred_install flag + job = MagicMock(spec=FirmwareJob) + job.job_type = JobType.COMPILE + job.status = JobStatus.COMPLETED + job.configuration = "test_device.yaml" + job.is_deferred_install = False + + with patch( + "esphome_device_builder.controllers.firmware.controller.runner.execute_job", + new_callable=AsyncMock + ): + await firmware_controller._execute_job(job, MagicMock()) + + # Ensure the arming path was skipped + firmware_controller._db.devices.set_queued_update.assert_not_called() + + @pytest.mark.asyncio async def test_execute_job_ignores_online_device(firmware_controller, mock_device): """Test that a successful compile for an online device does not set the flag.""" mock_device.state = DeviceState.ONLINE mock_device.configuration = "test_device.yaml" - firmware_controller._db.devices.monitor = MagicMock() + firmware_controller._db.devices.set_queued_update = MagicMock() job = MagicMock(spec=FirmwareJob) job.job_type = JobType.COMPILE job.status = JobStatus.COMPLETED job.configuration = "test_device.yaml" + job.is_deferred_install = True with patch( "esphome_device_builder.controllers.firmware.controller.runner.execute_job", @@ -234,7 +247,7 @@ async def test_execute_job_ignores_online_device(firmware_controller, mock_devic ): await firmware_controller._execute_job(job, MagicMock()) - firmware_controller._db.devices.monitor.apply_queued_update.assert_not_called() + firmware_controller._db.devices.set_queued_update.assert_not_called() def test_device_for_configuration_handles_none(firmware_controller): @@ -252,19 +265,12 @@ def test_device_for_configuration_uses_get_devices(firmware_controller): assert firmware_controller._device_for_configuration("kitchen.yaml") == mock_device -def test_device_for_configuration_handles_list_stub(firmware_controller): - """Test test-stub fallback where the controller is just a raw list.""" - mock_device = MagicMock(configuration="kitchen.yaml") - firmware_controller._db.devices = [mock_device] - - assert firmware_controller._device_for_configuration("kitchen.yaml") == mock_device - - def test_device_for_configuration_handles_unknown_stub(firmware_controller): - """Test the e2e StubDevices fallback that lacks get_devices and isn't a list.""" + """Test the e2e StubDevices fallback that implements get_devices().""" class StubDevices: - pass # Just an empty dummy object + def get_devices(self): + return [] # Real interface, empty result firmware_controller._db.devices = StubDevices() assert firmware_controller._device_for_configuration("kitchen.yaml") is None From 460a124db97276e60269b374d5d66bc8d8960a44 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:58:56 +0000 Subject: [PATCH 31/31] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- esphome_device_builder/controllers/firmware/controller.py | 8 ++------ tests/controllers/firmware/test_offline_queue.py | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/esphome_device_builder/controllers/firmware/controller.py b/esphome_device_builder/controllers/firmware/controller.py index 500bb6c48..a28040138 100644 --- a/esphome_device_builder/controllers/firmware/controller.py +++ b/esphome_device_builder/controllers/firmware/controller.py @@ -91,7 +91,7 @@ def _device_for_configuration(self, configuration: str) -> Any | None: for d in self._db.devices.get_devices() if getattr(d, "configuration", None) == configuration ), - None + None, ) def _handle_device_wake(self, event: Event) -> None: @@ -207,11 +207,7 @@ async def clear_queued_update(self, *, configuration: str, **kwargs: Any) -> Non await self._validate_configuration_boundary(configuration) device = self._device_for_configuration(configuration) - if ( - device - and getattr(device, "queued_update", False) - and self._db.devices - ): + if device and getattr(device, "queued_update", False) and self._db.devices: self._db.devices.set_queued_update(device.name, is_queued=False) _LOGGER.info("Queued update cleared for device %s", configuration) diff --git a/tests/controllers/firmware/test_offline_queue.py b/tests/controllers/firmware/test_offline_queue.py index acb4ac0fd..cfc0ccfdc 100644 --- a/tests/controllers/firmware/test_offline_queue.py +++ b/tests/controllers/firmware/test_offline_queue.py @@ -220,7 +220,7 @@ async def test_compile_only_does_not_arm_queue(firmware_controller, mock_device) with patch( "esphome_device_builder.controllers.firmware.controller.runner.execute_job", - new_callable=AsyncMock + new_callable=AsyncMock, ): await firmware_controller._execute_job(job, MagicMock())