diff --git a/docs/API.md b/docs/API.md index 48f2b697d..e23e7eac7 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/_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/_device_state_monitor/controller.py b/esphome_device_builder/controllers/_device_state_monitor/controller.py index 53e7a37e9..ccaf7de9b 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] +# 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) """ @@ -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_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, @@ -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_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 @@ -387,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/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 cba3bb8e1..d5c68b615 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 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(): + if device.name == name: + device.queued_update = is_queued + self._metadata_store.update(device.configuration, queued_update=is_queued) + self._fire_device_updated(device) + # ------------------------------------------------------------------ # API commands — listing # ------------------------------------------------------------------ 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 b496ab7b0..a28040138 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,43 @@ def __init__(self, device_builder: DeviceBuilder) -> None: # overwrite fresher state on disk). self._persist_lock = asyncio.Lock() + 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 _device_for_configuration(self, configuration: str) -> Any | None: + """Resolve a Device by its configuration filename.""" + if self._db.devices is None: + return None + + # Direct, typed call. Test stubs must implement get_devices(). + return next( + ( + d + for d in self._db.devices.get_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: + return + + config = event.data["configuration"] + 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) + if self._db.devices: + self._db.devices.set_queued_update(device.name, is_queued=False) + + create_eager_task(self.upload(configuration=config, port="OTA")) + # ------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------ @@ -166,6 +201,16 @@ 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) + + device = self._device_for_configuration(configuration) + 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) + @api_command("firmware/reset_build_env") async def reset_build_env(self, **kwargs: Any) -> FirmwareJob: """ @@ -213,6 +258,17 @@ async def install( """ _validate_port(port) await self._validate_configuration_boundary(configuration) + + if port == "OTA": + device = self._device_for_configuration(configuration) + # 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) # Install is a compile + a dependent local upload. The compile (local # or dispatched to a receiver) materialises the binary locally; the @@ -388,6 +444,15 @@ 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 + 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: + 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/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/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/_device_state_monitor/__init__.py b/tests/controllers/_device_state_monitor/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/controllers/_device_state_monitor/test_controller.py b/tests/controllers/_device_state_monitor/test_controller.py new file mode 100644 index 000000000..c0d44c523 --- /dev/null +++ b/tests/controllers/_device_state_monitor/test_controller.py @@ -0,0 +1,73 @@ +"""Tests for the DeviceStateMonitor controller.""" + +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.""" + # 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) + + +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_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/devices/test_update_device.py b/tests/controllers/devices/test_update_device.py index b6d16bfbc..9f1ef48e8 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, @@ -192,3 +193,31 @@ 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/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 new file mode 100644 index 000000000..cfc0ccfdc --- /dev/null +++ b/tests/controllers/firmware/test_offline_queue.py @@ -0,0 +1,276 @@ +"""Tests for the queued offline updates feature.""" + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from esphome_device_builder.controllers.firmware._state import FirmwareState +from esphome_device_builder.controllers.firmware.controller import FirmwareController +from esphome_device_builder.helpers.event_bus import Event +from esphome_device_builder.models import DeviceState, EventType, FirmwareJob, JobStatus, JobType + + +@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" + mock.configuration = "test_device.yaml" + return mock + + +@pytest.fixture +def firmware_controller(mock_device): + """Firmware controller for offline update tests.""" + controller = FirmwareController.__new__(FirmwareController) + controller._db = MagicMock() + + # 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() + return controller + + +@pytest.mark.asyncio +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 + + 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 getattr(called_job, "is_deferred_install", False) is True + + +@pytest.mark.asyncio +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) as mock_enqueue: + await firmware_controller.compile(configuration="test_device.yaml") + + 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 +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.set_queued_update = MagicMock() + + await firmware_controller.clear_queued_update(configuration="test_device.yaml") + + firmware_controller._db.devices.set_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 an invalid device configuration raises an error.""" + 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"): + 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 + + +# --- _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.set_queued_update = 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) + + 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") + 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.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", + new_callable=AsyncMock, + ): + await firmware_controller._execute_job(job, MagicMock()) + + 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.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", + new_callable=AsyncMock, + ): + await firmware_controller._execute_job(job, MagicMock()) + + firmware_controller._db.devices.set_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_unknown_stub(firmware_controller): + """Test the e2e StubDevices fallback that implements get_devices().""" + + class StubDevices: + def get_devices(self): + return [] # Real interface, empty result + + firmware_controller._db.devices = StubDevices() + assert firmware_controller._device_for_configuration("kitchen.yaml") is None