From a9dd5394cb89fa11e801629cca7470c7bb120070 Mon Sep 17 00:00:00 2001 From: trip-g Date: Tue, 21 Jul 2026 12:38:43 -0400 Subject: [PATCH 1/8] Fix Lyric room sensor/priority data being silently skipped for T9/T10 thermostats The "LCC" device ID prefix check added in #116876 to avoid a 400 GetPriorityFailed error was gating on the wrong signal: the prefix reflects which Honeywell cloud backend a device is registered under, not whether it supports the room priority endpoint. This silently skipped room sensor/priority data for supported T9/T10 thermostats whose device IDs don't start with "LCC". Instead, attempt the room/priority fetch for every thermostat and handle the expected per-device 400 response individually, so an unsupported device no longer fails the whole coordinator update (the original #116665/#116668 issue) without excluding devices that do support it. Also drops the same broken prefix check from the select platform, which already gates correctly on rooms_dict. Co-Authored-By: Claude Sonnet 5 --- homeassistant/components/lyric/coordinator.py | 20 +++++- homeassistant/components/lyric/select.py | 1 - tests/components/lyric/test_coordinator.py | 64 +++++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 tests/components/lyric/test_coordinator.py diff --git a/homeassistant/components/lyric/coordinator.py b/homeassistant/components/lyric/coordinator.py index 9b3c76e4f1ce3..af34e8df8ea5f 100644 --- a/homeassistant/components/lyric/coordinator.py +++ b/homeassistant/components/lyric/coordinator.py @@ -67,13 +67,12 @@ async def _run_update(self, force_refresh_token: bool) -> Lyric: await self.lyric.get_locations() await asyncio.gather( *( - self.lyric.get_thermostat_rooms( + self._get_thermostat_rooms( location.location_id, device.device_id ) for location in self.lyric.locations for device in location.devices if device.device_class == "Thermostat" - and device.device_id.startswith("LCC") ) ) @@ -87,3 +86,20 @@ async def _run_update(self, force_refresh_token: bool) -> Lyric: except (LyricException, ClientResponseError) as exception: raise UpdateFailed(exception) from exception return self.lyric + + async def _get_thermostat_rooms(self, location_id: str, device_id: str) -> None: + """Fetch room/priority data for a single thermostat. + + Not all thermostat models support this endpoint; Honeywell returns + a 400 (GetPriorityFailed) for those. That's expected and shouldn't + fail the whole coordinator update, so it's handled per-device + instead of relying on device ID heuristics to predict support. + """ + try: + await self.lyric.get_thermostat_rooms(location_id, device_id) + except ClientResponseError as exception: + if exception.status != HTTPStatus.BAD_REQUEST: + raise + _LOGGER.debug( + "Device %s does not support room priority data", device_id + ) diff --git a/homeassistant/components/lyric/select.py b/homeassistant/components/lyric/select.py index aeb60b0b0c0a0..4842c0cd63d89 100644 --- a/homeassistant/components/lyric/select.py +++ b/homeassistant/components/lyric/select.py @@ -41,7 +41,6 @@ async def async_setup_entry( for location in coordinator.data.locations for device in location.devices if device.device_class == "Thermostat" - and device.device_id.startswith("LCC") and coordinator.data.rooms_dict.get(device.mac_id) ) diff --git a/tests/components/lyric/test_coordinator.py b/tests/components/lyric/test_coordinator.py new file mode 100644 index 0000000000000..39d8dc34ca0e3 --- /dev/null +++ b/tests/components/lyric/test_coordinator.py @@ -0,0 +1,64 @@ +"""Tests for the Honeywell Lyric coordinator.""" + +from unittest.mock import AsyncMock, MagicMock + +from aiohttp.client_exceptions import ClientResponseError +import pytest + +from homeassistant.components.lyric.coordinator import LyricDataUpdateCoordinator +from homeassistant.core import HomeAssistant + + +@pytest.fixture +def coordinator(hass: HomeAssistant) -> LyricDataUpdateCoordinator: + """Return a coordinator with a mocked Lyric client.""" + return LyricDataUpdateCoordinator( + hass, + config_entry=MagicMock(), + oauth_session=MagicMock(), + lyric=AsyncMock(), + ) + + +async def test_get_thermostat_rooms_ignores_unsupported_device( + coordinator: LyricDataUpdateCoordinator, +) -> None: + """A 400 GetPriorityFailed for one device shouldn't fail the update. + + Devices that don't support the room priority endpoint (e.g. older + thermostats) return a 400 here; that's expected and must not be + conflated with devices that do support it but got skipped by a + device ID heuristic. + """ + coordinator.lyric.get_thermostat_rooms.side_effect = ClientResponseError( + request_info=MagicMock(), history=(), status=400 + ) + + await coordinator._get_thermostat_rooms("location1", "device1") + + coordinator.lyric.get_thermostat_rooms.assert_called_once_with( + "location1", "device1" + ) + + +async def test_get_thermostat_rooms_reraises_other_errors( + coordinator: LyricDataUpdateCoordinator, +) -> None: + """Non-400 errors should still propagate to fail the update.""" + coordinator.lyric.get_thermostat_rooms.side_effect = ClientResponseError( + request_info=MagicMock(), history=(), status=500 + ) + + with pytest.raises(ClientResponseError): + await coordinator._get_thermostat_rooms("location1", "device1") + + +async def test_get_thermostat_rooms_success( + coordinator: LyricDataUpdateCoordinator, +) -> None: + """A supported device should have its room data fetched normally.""" + await coordinator._get_thermostat_rooms("location1", "device1") + + coordinator.lyric.get_thermostat_rooms.assert_called_once_with( + "location1", "device1" + ) From 90b9dc8e4cd453b5e0aaa5e22b9071fb875c2caf Mon Sep 17 00:00:00 2001 From: trip-g Date: Tue, 21 Jul 2026 13:50:48 -0400 Subject: [PATCH 2/8] Apply ruff formatting to lyric coordinator Co-Authored-By: Claude Sonnet 5 --- homeassistant/components/lyric/coordinator.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/lyric/coordinator.py b/homeassistant/components/lyric/coordinator.py index af34e8df8ea5f..acc60228d37bf 100644 --- a/homeassistant/components/lyric/coordinator.py +++ b/homeassistant/components/lyric/coordinator.py @@ -100,6 +100,4 @@ async def _get_thermostat_rooms(self, location_id: str, device_id: str) -> None: except ClientResponseError as exception: if exception.status != HTTPStatus.BAD_REQUEST: raise - _LOGGER.debug( - "Device %s does not support room priority data", device_id - ) + _LOGGER.debug("Device %s does not support room priority data", device_id) From dea99043d42eafa541e89d7ef46a7127dd15fe9b Mon Sep 17 00:00:00 2001 From: trip-g Date: Tue, 21 Jul 2026 19:41:38 -0400 Subject: [PATCH 3/8] Fix _get_thermostat_rooms catching the wrong exception type aiolyric's LyricClient.request() never raises aiohttp's ClientResponseError - for any non-200 response it raises its own LyricException (or LyricAuthenticationException for 401), with the HTTP status embedded in exception.args[0]["status"] rather than a .status attribute. The previous except ClientResponseError clause in _get_thermostat_rooms never matched anything real, so a 400 GetPriorityFailed from an unsupported device fell through to the outer except (LyricException, ClientResponseError) handler and failed the whole coordinator update - reintroducing the exact #116665/#116668 regression this fix was meant to prevent. Also: LyricAuthenticationException is a LyricException subclass, so it must be caught and re-raised before the generic LyricException handler, or auth failures would be silently swallowed as "unsupported device" instead of triggering the token-refresh retry in _run_update. Rewrote the coordinator tests to construct LyricException with aiolyric's real payload shape instead of a fabricated ClientResponseError, added a test asserting authentication errors still propagate, and added an aggregate-level test that exercises _run_update itself (not just the private helper) with a mix of a supported and unsupported device, verifying one unsupported thermostat no longer fails the whole refresh. Co-Authored-By: Claude Sonnet 5 --- homeassistant/components/lyric/coordinator.py | 13 +++- tests/components/lyric/test_coordinator.py | 71 ++++++++++++++++--- 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/lyric/coordinator.py b/homeassistant/components/lyric/coordinator.py index acc60228d37bf..d0d80772b98b9 100644 --- a/homeassistant/components/lyric/coordinator.py +++ b/homeassistant/components/lyric/coordinator.py @@ -94,10 +94,19 @@ async def _get_thermostat_rooms(self, location_id: str, device_id: str) -> None: a 400 (GetPriorityFailed) for those. That's expected and shouldn't fail the whole coordinator update, so it's handled per-device instead of relying on device ID heuristics to predict support. + + aiolyric raises a plain LyricException (not ClientResponseError) for + non-200 responses, carrying the HTTP status in + exception.args[0]["status"]. LyricAuthenticationException is a + LyricException subclass and must still propagate so the caller's + token-refresh retry logic runs. """ try: await self.lyric.get_thermostat_rooms(location_id, device_id) - except ClientResponseError as exception: - if exception.status != HTTPStatus.BAD_REQUEST: + except LyricAuthenticationException: + raise + except LyricException as exception: + status = exception.args[0]["status"] if exception.args else None + if status != HTTPStatus.BAD_REQUEST: raise _LOGGER.debug("Device %s does not support room priority data", device_id) diff --git a/tests/components/lyric/test_coordinator.py b/tests/components/lyric/test_coordinator.py index 39d8dc34ca0e3..9c7420f7c8887 100644 --- a/tests/components/lyric/test_coordinator.py +++ b/tests/components/lyric/test_coordinator.py @@ -2,20 +2,31 @@ from unittest.mock import AsyncMock, MagicMock -from aiohttp.client_exceptions import ClientResponseError +from aiolyric.exceptions import LyricAuthenticationException, LyricException import pytest from homeassistant.components.lyric.coordinator import LyricDataUpdateCoordinator from homeassistant.core import HomeAssistant +def _lyric_exception(status: int) -> LyricException: + """Build a LyricException matching aiolyric's actual payload shape.""" + return LyricException( + { + "request": {"method": "GET", "url": "https://example.com"}, + "response": {"message": "GetPriorityFailed"}, + "status": status, + } + ) + + @pytest.fixture def coordinator(hass: HomeAssistant) -> LyricDataUpdateCoordinator: """Return a coordinator with a mocked Lyric client.""" return LyricDataUpdateCoordinator( hass, config_entry=MagicMock(), - oauth_session=MagicMock(), + oauth_session=AsyncMock(), lyric=AsyncMock(), ) @@ -28,11 +39,11 @@ async def test_get_thermostat_rooms_ignores_unsupported_device( Devices that don't support the room priority endpoint (e.g. older thermostats) return a 400 here; that's expected and must not be conflated with devices that do support it but got skipped by a - device ID heuristic. + device ID heuristic. aiolyric raises this as a plain LyricException, + not ClientResponseError, with the HTTP status embedded in the + exception's payload rather than a status attribute. """ - coordinator.lyric.get_thermostat_rooms.side_effect = ClientResponseError( - request_info=MagicMock(), history=(), status=400 - ) + coordinator.lyric.get_thermostat_rooms.side_effect = _lyric_exception(400) await coordinator._get_thermostat_rooms("location1", "device1") @@ -45,11 +56,25 @@ async def test_get_thermostat_rooms_reraises_other_errors( coordinator: LyricDataUpdateCoordinator, ) -> None: """Non-400 errors should still propagate to fail the update.""" - coordinator.lyric.get_thermostat_rooms.side_effect = ClientResponseError( - request_info=MagicMock(), history=(), status=500 + coordinator.lyric.get_thermostat_rooms.side_effect = _lyric_exception(500) + + with pytest.raises(LyricException): + await coordinator._get_thermostat_rooms("location1", "device1") + + +async def test_get_thermostat_rooms_reraises_authentication_errors( + coordinator: LyricDataUpdateCoordinator, +) -> None: + """Authentication errors must propagate for the caller's retry logic. + + LyricAuthenticationException is a LyricException subclass, so it must + not be caught by the same handler that swallows the 400 case. + """ + coordinator.lyric.get_thermostat_rooms.side_effect = LyricAuthenticationException( + {"request": {}, "response": {}, "status": 401} ) - with pytest.raises(ClientResponseError): + with pytest.raises(LyricAuthenticationException): await coordinator._get_thermostat_rooms("location1", "device1") @@ -62,3 +87,31 @@ async def test_get_thermostat_rooms_success( coordinator.lyric.get_thermostat_rooms.assert_called_once_with( "location1", "device1" ) + + +async def test_run_update_skips_unsupported_device( + coordinator: LyricDataUpdateCoordinator, +) -> None: + """One unsupported thermostat shouldn't fail the whole coordinator update. + + Regression test for the original bug: previously, any thermostat + that didn't support the priority endpoint made the entire refresh + fail with UpdateFailed, since aiolyric's LyricException (not + ClientResponseError) went uncaught by _get_thermostat_rooms and + propagated to the outer handler. + """ + supported = MagicMock(device_class="Thermostat", device_id="device-ok") + unsupported = MagicMock(device_class="Thermostat", device_id="device-400") + location = MagicMock(location_id="location1", devices=[supported, unsupported]) + coordinator.lyric.locations = [location] + + async def get_thermostat_rooms(location_id: str, device_id: str) -> None: + if device_id == "device-400": + raise _lyric_exception(400) + + coordinator.lyric.get_thermostat_rooms.side_effect = get_thermostat_rooms + + result = await coordinator._run_update(False) + + assert result is coordinator.lyric + assert coordinator.lyric.get_thermostat_rooms.call_count == 2 From a015648792df3224171646c14b01443ef8d647b7 Mon Sep 17 00:00:00 2001 From: trip-g Date: Tue, 21 Jul 2026 19:53:47 -0400 Subject: [PATCH 4/8] Restrict 400 suppression to GetPriorityFailed specifically Checking HTTP status alone meant any 400 from the priority endpoint - not just "device doesn't support this" - was silently treated as an unsupported device, which could mask an unrelated bad-request error (a bug in our own request, or a new/different failure from Honeywell) with no visibility. Now also requires the response payload's "code" field to equal "GetPriorityFailed" before suppressing; any other 400 re-raises and fails loudly via UpdateFailed, same as before this fix existed. Also condensed a docstring that restated the exception-routing history already covered in commit messages. --- homeassistant/components/lyric/coordinator.py | 21 ++++------ tests/components/lyric/test_coordinator.py | 42 ++++++++++--------- 2 files changed, 32 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/lyric/coordinator.py b/homeassistant/components/lyric/coordinator.py index d0d80772b98b9..1109d1ee4a306 100644 --- a/homeassistant/components/lyric/coordinator.py +++ b/homeassistant/components/lyric/coordinator.py @@ -90,23 +90,20 @@ async def _run_update(self, force_refresh_token: bool) -> Lyric: async def _get_thermostat_rooms(self, location_id: str, device_id: str) -> None: """Fetch room/priority data for a single thermostat. - Not all thermostat models support this endpoint; Honeywell returns - a 400 (GetPriorityFailed) for those. That's expected and shouldn't - fail the whole coordinator update, so it's handled per-device - instead of relying on device ID heuristics to predict support. - - aiolyric raises a plain LyricException (not ClientResponseError) for - non-200 responses, carrying the HTTP status in - exception.args[0]["status"]. LyricAuthenticationException is a - LyricException subclass and must still propagate so the caller's - token-refresh retry logic runs. + Devices that don't support this endpoint return a GetPriorityFailed + 400, which is expected and shouldn't fail the whole coordinator + update. Any other error is re-raised. """ try: await self.lyric.get_thermostat_rooms(location_id, device_id) except LyricAuthenticationException: raise except LyricException as exception: - status = exception.args[0]["status"] if exception.args else None - if status != HTTPStatus.BAD_REQUEST: + payload = exception.args[0] if exception.args else {} + response = payload.get("response") or {} + if ( + payload.get("status") != HTTPStatus.BAD_REQUEST + or response.get("code") != "GetPriorityFailed" + ): raise _LOGGER.debug("Device %s does not support room priority data", device_id) diff --git a/tests/components/lyric/test_coordinator.py b/tests/components/lyric/test_coordinator.py index 9c7420f7c8887..426ff0c933417 100644 --- a/tests/components/lyric/test_coordinator.py +++ b/tests/components/lyric/test_coordinator.py @@ -9,12 +9,14 @@ from homeassistant.core import HomeAssistant -def _lyric_exception(status: int) -> LyricException: +def _lyric_exception( + status: int, code: str | None = "GetPriorityFailed" +) -> LyricException: """Build a LyricException matching aiolyric's actual payload shape.""" return LyricException( { "request": {"method": "GET", "url": "https://example.com"}, - "response": {"message": "GetPriorityFailed"}, + "response": {"code": code} if code else {}, "status": status, } ) @@ -34,15 +36,7 @@ def coordinator(hass: HomeAssistant) -> LyricDataUpdateCoordinator: async def test_get_thermostat_rooms_ignores_unsupported_device( coordinator: LyricDataUpdateCoordinator, ) -> None: - """A 400 GetPriorityFailed for one device shouldn't fail the update. - - Devices that don't support the room priority endpoint (e.g. older - thermostats) return a 400 here; that's expected and must not be - conflated with devices that do support it but got skipped by a - device ID heuristic. aiolyric raises this as a plain LyricException, - not ClientResponseError, with the HTTP status embedded in the - exception's payload rather than a status attribute. - """ + """A GetPriorityFailed 400 for one device shouldn't fail the update.""" coordinator.lyric.get_thermostat_rooms.side_effect = _lyric_exception(400) await coordinator._get_thermostat_rooms("location1", "device1") @@ -62,6 +56,23 @@ async def test_get_thermostat_rooms_reraises_other_errors( await coordinator._get_thermostat_rooms("location1", "device1") +async def test_get_thermostat_rooms_reraises_different_400_reason( + coordinator: LyricDataUpdateCoordinator, +) -> None: + """A 400 for a reason other than GetPriorityFailed should propagate. + + Guards against broadly suppressing every 400 as "unsupported device", + which would also hide unrelated bad-request errors (e.g. a bug in our + own request, or a new/different failure from Honeywell). + """ + coordinator.lyric.get_thermostat_rooms.side_effect = _lyric_exception( + 400, code="SomeOtherError" + ) + + with pytest.raises(LyricException): + await coordinator._get_thermostat_rooms("location1", "device1") + + async def test_get_thermostat_rooms_reraises_authentication_errors( coordinator: LyricDataUpdateCoordinator, ) -> None: @@ -92,14 +103,7 @@ async def test_get_thermostat_rooms_success( async def test_run_update_skips_unsupported_device( coordinator: LyricDataUpdateCoordinator, ) -> None: - """One unsupported thermostat shouldn't fail the whole coordinator update. - - Regression test for the original bug: previously, any thermostat - that didn't support the priority endpoint made the entire refresh - fail with UpdateFailed, since aiolyric's LyricException (not - ClientResponseError) went uncaught by _get_thermostat_rooms and - propagated to the outer handler. - """ + """One unsupported thermostat shouldn't fail the whole coordinator update.""" supported = MagicMock(device_class="Thermostat", device_id="device-ok") unsupported = MagicMock(device_class="Thermostat", device_id="device-400") location = MagicMock(location_id="location1", devices=[supported, unsupported]) From a60d49ce66f7b97963ba1a4fac624a37ea9a9ee3 Mon Sep 17 00:00:00 2001 From: clutch2sft Date: Mon, 27 Jul 2026 06:45:51 -0400 Subject: [PATCH 5/8] Rewrite Lyric coordinator tests to mock the library and exercise real setup Replace direct coordinator unit tests with integration-level tests that mock aiolyric and drive setup through hass.config_entries.async_setup(), asserting on entity states and config entry state instead of coordinator internals. Fold the coverage into test_init.py (setup failure modes) and test_sensor.py (entity creation), and drop test_coordinator.py. --- tests/components/lyric/__init__.py | 12 ++ tests/components/lyric/conftest.py | 102 +++++++++++++++++ tests/components/lyric/test_coordinator.py | 121 --------------------- tests/components/lyric/test_init.py | 81 +++++++++++++- tests/components/lyric/test_sensor.py | 90 +++++++++++++++ 5 files changed, 283 insertions(+), 123 deletions(-) create mode 100644 tests/components/lyric/conftest.py delete mode 100644 tests/components/lyric/test_coordinator.py diff --git a/tests/components/lyric/__init__.py b/tests/components/lyric/__init__.py index 794c6bf1ba095..4b4ee70008487 100644 --- a/tests/components/lyric/__init__.py +++ b/tests/components/lyric/__init__.py @@ -1 +1,13 @@ """Tests for the Honeywell Lyric integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the Lyric integration for tests.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/lyric/conftest.py b/tests/components/lyric/conftest.py new file mode 100644 index 0000000000000..7a0b4dea483b4 --- /dev/null +++ b/tests/components/lyric/conftest.py @@ -0,0 +1,102 @@ +"""Fixtures for the Honeywell Lyric integration tests.""" + +import time +from typing import Any +from unittest.mock import MagicMock + +from aiolyric import Lyric +from aiolyric.exceptions import LyricException +from aiolyric.objects.location import LyricLocation +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.lyric.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Set up the application credentials required for the Lyric OAuth2 flow.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), DOMAIN + ) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a Lyric config entry with a valid OAuth2 token.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": time.time() + 3600, + "token_type": "Bearer", + }, + }, + ) + + +def thermostat_json( + mac_id: str, device_id: str, name: str = "Thermostat" +) -> dict[str, Any]: + """Return a raw aiolyric device payload for a single thermostat.""" + return { + "deviceClass": "Thermostat", + "deviceID": device_id, + "macID": mac_id, + "name": name, + "units": "Celsius", + "indoorTemperature": 21.5, + "indoorHumidity": 45, + "outdoorTemperature": 10, + "displayedOutdoorHumidity": 60, + "deviceModel": "T9", + "changeableValues": { + "mode": "Heat", + "heatSetpoint": 20, + "coolSetpoint": 24, + "thermostatSetpointStatus": "NoHold", + }, + } + + +def location_json(location_id: int, devices: list[dict[str, Any]]) -> dict[str, Any]: + """Return a raw aiolyric location payload containing the given devices.""" + return {"locationID": location_id, "name": "Home", "devices": devices} + + +def build_mock_lyric( + locations: list[LyricLocation], rooms_dict: dict | None = None +) -> MagicMock: + """Build a mocked aiolyric client backed by real aiolyric location/device objects.""" + lyric = MagicMock(spec=Lyric) + lyric.locations = locations + lyric.locations_dict = {location.location_id: location for location in locations} + lyric.rooms_dict = rooms_dict or {} + return lyric + + +def lyric_exception( + status: int, code: str | None = "GetPriorityFailed" +) -> LyricException: + """Build a LyricException matching aiolyric's actual payload shape.""" + return LyricException( + { + "request": {"method": "GET", "url": "https://example.com"}, + "response": {"code": code} if code else {}, + "status": status, + } + ) diff --git a/tests/components/lyric/test_coordinator.py b/tests/components/lyric/test_coordinator.py deleted file mode 100644 index 426ff0c933417..0000000000000 --- a/tests/components/lyric/test_coordinator.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Tests for the Honeywell Lyric coordinator.""" - -from unittest.mock import AsyncMock, MagicMock - -from aiolyric.exceptions import LyricAuthenticationException, LyricException -import pytest - -from homeassistant.components.lyric.coordinator import LyricDataUpdateCoordinator -from homeassistant.core import HomeAssistant - - -def _lyric_exception( - status: int, code: str | None = "GetPriorityFailed" -) -> LyricException: - """Build a LyricException matching aiolyric's actual payload shape.""" - return LyricException( - { - "request": {"method": "GET", "url": "https://example.com"}, - "response": {"code": code} if code else {}, - "status": status, - } - ) - - -@pytest.fixture -def coordinator(hass: HomeAssistant) -> LyricDataUpdateCoordinator: - """Return a coordinator with a mocked Lyric client.""" - return LyricDataUpdateCoordinator( - hass, - config_entry=MagicMock(), - oauth_session=AsyncMock(), - lyric=AsyncMock(), - ) - - -async def test_get_thermostat_rooms_ignores_unsupported_device( - coordinator: LyricDataUpdateCoordinator, -) -> None: - """A GetPriorityFailed 400 for one device shouldn't fail the update.""" - coordinator.lyric.get_thermostat_rooms.side_effect = _lyric_exception(400) - - await coordinator._get_thermostat_rooms("location1", "device1") - - coordinator.lyric.get_thermostat_rooms.assert_called_once_with( - "location1", "device1" - ) - - -async def test_get_thermostat_rooms_reraises_other_errors( - coordinator: LyricDataUpdateCoordinator, -) -> None: - """Non-400 errors should still propagate to fail the update.""" - coordinator.lyric.get_thermostat_rooms.side_effect = _lyric_exception(500) - - with pytest.raises(LyricException): - await coordinator._get_thermostat_rooms("location1", "device1") - - -async def test_get_thermostat_rooms_reraises_different_400_reason( - coordinator: LyricDataUpdateCoordinator, -) -> None: - """A 400 for a reason other than GetPriorityFailed should propagate. - - Guards against broadly suppressing every 400 as "unsupported device", - which would also hide unrelated bad-request errors (e.g. a bug in our - own request, or a new/different failure from Honeywell). - """ - coordinator.lyric.get_thermostat_rooms.side_effect = _lyric_exception( - 400, code="SomeOtherError" - ) - - with pytest.raises(LyricException): - await coordinator._get_thermostat_rooms("location1", "device1") - - -async def test_get_thermostat_rooms_reraises_authentication_errors( - coordinator: LyricDataUpdateCoordinator, -) -> None: - """Authentication errors must propagate for the caller's retry logic. - - LyricAuthenticationException is a LyricException subclass, so it must - not be caught by the same handler that swallows the 400 case. - """ - coordinator.lyric.get_thermostat_rooms.side_effect = LyricAuthenticationException( - {"request": {}, "response": {}, "status": 401} - ) - - with pytest.raises(LyricAuthenticationException): - await coordinator._get_thermostat_rooms("location1", "device1") - - -async def test_get_thermostat_rooms_success( - coordinator: LyricDataUpdateCoordinator, -) -> None: - """A supported device should have its room data fetched normally.""" - await coordinator._get_thermostat_rooms("location1", "device1") - - coordinator.lyric.get_thermostat_rooms.assert_called_once_with( - "location1", "device1" - ) - - -async def test_run_update_skips_unsupported_device( - coordinator: LyricDataUpdateCoordinator, -) -> None: - """One unsupported thermostat shouldn't fail the whole coordinator update.""" - supported = MagicMock(device_class="Thermostat", device_id="device-ok") - unsupported = MagicMock(device_class="Thermostat", device_id="device-400") - location = MagicMock(location_id="location1", devices=[supported, unsupported]) - coordinator.lyric.locations = [location] - - async def get_thermostat_rooms(location_id: str, device_id: str) -> None: - if device_id == "device-400": - raise _lyric_exception(400) - - coordinator.lyric.get_thermostat_rooms.side_effect = get_thermostat_rooms - - result = await coordinator._run_update(False) - - assert result is coordinator.lyric - assert coordinator.lyric.get_thermostat_rooms.call_count == 2 diff --git a/tests/components/lyric/test_init.py b/tests/components/lyric/test_init.py index 43316079fe114..985356a34c3cf 100644 --- a/tests/components/lyric/test_init.py +++ b/tests/components/lyric/test_init.py @@ -1,14 +1,21 @@ """Tests for the Honeywell Lyric integration.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch +from aiolyric.exceptions import LyricAuthenticationException +from aiolyric.objects.location import LyricLocation +import pytest + +from homeassistant.components.lyric.api import OAuth2SessionLyric from homeassistant.components.lyric.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, ) +from .conftest import build_mock_lyric, location_json, lyric_exception, thermostat_json + from tests.common import MockConfigEntry @@ -38,3 +45,73 @@ async def test_oauth_implementation_not_available( await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("setup_credentials") +async def test_setup_retry_on_room_priority_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """A non-400 error fetching room priority data should mark the entry as not ready.""" + location = LyricLocation( + MagicMock(), + location_json(1, [thermostat_json("AABBCC000001", "LCC-AABBCC000001")]), + ) + lyric = build_mock_lyric([location]) + lyric.get_thermostat_rooms.side_effect = lyric_exception(500) + + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.lyric.Lyric", return_value=lyric): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("setup_credentials") +async def test_setup_retry_on_unexpected_bad_request_reason( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """A 400 for a reason other than GetPriorityFailed should mark the entry as not ready.""" + location = LyricLocation( + MagicMock(), + location_json(1, [thermostat_json("AABBCC000001", "LCC-AABBCC000001")]), + ) + lyric = build_mock_lyric([location]) + lyric.get_thermostat_rooms.side_effect = lyric_exception(400, code="SomeOtherError") + + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.lyric.Lyric", return_value=lyric): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("setup_credentials") +async def test_setup_error_starts_reauth_on_authentication_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Authentication errors should fail setup and start a reauth flow.""" + location = LyricLocation( + MagicMock(), + location_json(1, [thermostat_json("AABBCC000001", "LCC-AABBCC000001")]), + ) + lyric = build_mock_lyric([location]) + lyric.get_thermostat_rooms.side_effect = LyricAuthenticationException( + {"request": {}, "response": {}, "status": 401} + ) + + mock_config_entry.add_to_hass(hass) + with ( + patch("homeassistant.components.lyric.Lyric", return_value=lyric), + patch.object(OAuth2SessionLyric, "force_refresh_token", new=AsyncMock()), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + flows = hass.config_entries.flow.async_progress() + assert any(flow["context"]["source"] == SOURCE_REAUTH for flow in flows) diff --git a/tests/components/lyric/test_sensor.py b/tests/components/lyric/test_sensor.py index 7f74b5e8d63ea..14f746f481913 100644 --- a/tests/components/lyric/test_sensor.py +++ b/tests/components/lyric/test_sensor.py @@ -1,8 +1,22 @@ """Tests for the Honeywell Lyric sensor platform.""" from datetime import datetime +from unittest.mock import MagicMock, patch +from aiolyric.objects.location import LyricLocation +from aiolyric.objects.priority import LyricRoom +import pytest + +from homeassistant.components.lyric.const import DOMAIN from homeassistant.components.lyric.sensor import get_datetime_from_future_time +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .conftest import build_mock_lyric, location_json, lyric_exception, thermostat_json + +from tests.common import MockConfigEntry def test_get_datetime_from_future_time_none() -> None: @@ -19,3 +33,79 @@ def test_get_datetime_from_future_time_valid() -> None: """Test that a valid time string returns a datetime.""" result = get_datetime_from_future_time("13:30:00") assert isinstance(result, datetime) + + +ROOM_JSON = { + "id": 0, + "roomName": "Hallway", + "roomAvgTemp": 22, + "roomAvgHumidity": 50, + "accessories": [ + { + "id": 0, + "type": "IndoorAirSensor", + "temperature": 22.5, + "status": "Ok", + } + ], +} + + +@pytest.mark.usefixtures("setup_credentials") +async def test_room_sensors_skip_device_without_priority_support( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """A device that doesn't support room priority data shouldn't block setup or other devices' sensors.""" + location = LyricLocation( + MagicMock(), + location_json( + 1, + [ + thermostat_json("AABBCC000001", "LCC-AABBCC000001", "Living Room"), + thermostat_json("AABBCC000002", "LCC-AABBCC000002", "Bedroom"), + ], + ), + ) + lyric = build_mock_lyric( + [location], rooms_dict={"AABBCC000001": {0: LyricRoom(ROOM_JSON)}} + ) + + async def get_thermostat_rooms(location_id: int, device_id: str) -> None: + if device_id == "LCC-AABBCC000002": + raise lyric_exception(400) + + lyric.get_thermostat_rooms.side_effect = get_thermostat_rooms + + with ( + patch("homeassistant.components.lyric.Lyric", return_value=lyric), + patch("homeassistant.components.lyric.PLATFORMS", [Platform.SENSOR]), + ): + await setup_integration(hass, mock_config_entry) + + living_room_temperature = hass.states.get( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, "AABBCC000001_indoor_temperature" + ) + ) + assert living_room_temperature.state == "21.5" + + bedroom_temperature = hass.states.get( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, "AABBCC000002_indoor_temperature" + ) + ) + assert bedroom_temperature.state == "21.5" + + room_sensor_entity_id = entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, "AABBCC000001_room0_acc0_room_temperature" + ) + assert hass.states.get(room_sensor_entity_id).state == "22.5" + + assert ( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, "AABBCC000002_room0_acc0_room_temperature" + ) + is None + ) From b9bfd69cf41f1724e64abc43d43bd4045119b363 Mon Sep 17 00:00:00 2001 From: clutch2sft Date: Mon, 27 Jul 2026 06:46:03 -0400 Subject: [PATCH 6/8] Cover the non-LCC device ID regression in room priority tests The sensor test used two LCC-prefixed device IDs and only loaded the sensor platform, so it wouldn't fail if the removed device ID prefix gates were reintroduced in coordinator.py or select.py. Add a non-LCC supported device to the shared mock fixture and a new select platform test asserting its room priority entity. --- tests/components/lyric/conftest.py | 77 ++++++++++++++++++++++++++- tests/components/lyric/test_select.py | 51 ++++++++++++++++++ tests/components/lyric/test_sensor.py | 77 ++++++++------------------- 3 files changed, 148 insertions(+), 57 deletions(-) create mode 100644 tests/components/lyric/test_select.py diff --git a/tests/components/lyric/conftest.py b/tests/components/lyric/conftest.py index 7a0b4dea483b4..c66fef3d76add 100644 --- a/tests/components/lyric/conftest.py +++ b/tests/components/lyric/conftest.py @@ -1,12 +1,14 @@ """Fixtures for the Honeywell Lyric integration tests.""" +from collections.abc import AsyncGenerator import time from typing import Any -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from aiolyric import Lyric from aiolyric.exceptions import LyricException from aiolyric.objects.location import LyricLocation +from aiolyric.objects.priority import LyricRoom import pytest from homeassistant.components.application_credentials import ( @@ -100,3 +102,76 @@ def lyric_exception( "status": status, } ) + + +LCC_SUPPORTED_MAC = "AABBCC000001" +NON_LCC_SUPPORTED_MAC = "AABBCC000002" +UNSUPPORTED_MAC = "AABBCC000003" +UNSUPPORTED_DEVICE_ID = "LCC-AABBCC000003" + +LIVING_ROOM_JSON = { + "id": 0, + "roomName": "Living Room", + "roomAvgTemp": 22, + "roomAvgHumidity": 50, + "accessories": [ + {"id": 0, "type": "IndoorAirSensor", "temperature": 22.5, "status": "Ok"} + ], +} + +OFFICE_ROOM_JSON = { + "id": 0, + "roomName": "Office", + "roomAvgTemp": 24, + "roomAvgHumidity": 40, + "accessories": [ + {"id": 0, "type": "IndoorAirSensor", "temperature": 24.5, "status": "Ok"} + ], +} + + +@pytest.fixture +async def mock_lyric_mixed_devices() -> AsyncGenerator[MagicMock]: + """Yield a mocked Lyric client with an LCC device, a non-LCC device, and an unsupported device. + + Room priority support depends on the thermostat model, not on whether + its device ID happens to start with "LCC" (see the T9/T10 regression + this integration guards against), so this fixture exercises a + supported device on each prefix. ``get_thermostat_rooms`` populates + ``rooms_dict`` as a side effect, mirroring the real library, so a + device that the coordinator skips calling never gets room data. + """ + location = LyricLocation( + MagicMock(), + location_json( + 1, + [ + thermostat_json(LCC_SUPPORTED_MAC, "LCC-AABBCC000001", "Living Room"), + thermostat_json(NON_LCC_SUPPORTED_MAC, "TCC-AABBCC000002", "Office"), + thermostat_json(UNSUPPORTED_MAC, UNSUPPORTED_DEVICE_ID, "Bedroom"), + ], + ), + ) + rooms = { + LCC_SUPPORTED_MAC: LIVING_ROOM_JSON, + NON_LCC_SUPPORTED_MAC: OFFICE_ROOM_JSON, + } + mac_by_device_id = {device.device_id: device.mac_id for device in location.devices} + + lyric = MagicMock(spec=Lyric) + lyric.locations = [location] + lyric.locations_dict = {location.location_id: location} + lyric.rooms_dict = {} + lyric.priorities_dict = {} + + async def get_thermostat_rooms(location_id: int, device_id: str) -> None: + if device_id == UNSUPPORTED_DEVICE_ID: + raise lyric_exception(400) + mac_id = mac_by_device_id[device_id] + room_json = rooms[mac_id] + lyric.rooms_dict[mac_id] = {room_json["id"]: LyricRoom(room_json)} + + lyric.get_thermostat_rooms = AsyncMock(side_effect=get_thermostat_rooms) + + with patch("homeassistant.components.lyric.Lyric", return_value=lyric): + yield lyric diff --git a/tests/components/lyric/test_select.py b/tests/components/lyric/test_select.py new file mode 100644 index 0000000000000..6db1eb9f95528 --- /dev/null +++ b/tests/components/lyric/test_select.py @@ -0,0 +1,51 @@ +"""Tests for the Honeywell Lyric select platform.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.lyric.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("setup_credentials") +async def test_room_priority_select_created_regardless_of_device_id_prefix( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_lyric_mixed_devices: MagicMock, +) -> None: + """The room priority select is created for supported devices whether or not their ID starts with LCC. + + An unsupported device (GetPriorityFailed 400) shouldn't get a room + priority select, since it has no room data to choose between. + """ + with patch("homeassistant.components.lyric.PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, mock_config_entry) + + lcc_select = hass.states.get( + entity_registry.async_get_entity_id( + Platform.SELECT, DOMAIN, "AABBCC000001_room_priority" + ) + ) + assert lcc_select.attributes["options"] == ["follow_me", "Living Room"] + + non_lcc_select = hass.states.get( + entity_registry.async_get_entity_id( + Platform.SELECT, DOMAIN, "AABBCC000002_room_priority" + ) + ) + assert non_lcc_select.attributes["options"] == ["follow_me", "Office"] + + assert ( + entity_registry.async_get_entity_id( + Platform.SELECT, DOMAIN, "AABBCC000003_room_priority" + ) + is None + ) diff --git a/tests/components/lyric/test_sensor.py b/tests/components/lyric/test_sensor.py index 14f746f481913..699d1bdedfe7b 100644 --- a/tests/components/lyric/test_sensor.py +++ b/tests/components/lyric/test_sensor.py @@ -3,8 +3,6 @@ from datetime import datetime from unittest.mock import MagicMock, patch -from aiolyric.objects.location import LyricLocation -from aiolyric.objects.priority import LyricRoom import pytest from homeassistant.components.lyric.const import DOMAIN @@ -14,7 +12,6 @@ from homeassistant.helpers import entity_registry as er from . import setup_integration -from .conftest import build_mock_lyric, location_json, lyric_exception, thermostat_json from tests.common import MockConfigEntry @@ -35,77 +32,45 @@ def test_get_datetime_from_future_time_valid() -> None: assert isinstance(result, datetime) -ROOM_JSON = { - "id": 0, - "roomName": "Hallway", - "roomAvgTemp": 22, - "roomAvgHumidity": 50, - "accessories": [ - { - "id": 0, - "type": "IndoorAirSensor", - "temperature": 22.5, - "status": "Ok", - } - ], -} - - @pytest.mark.usefixtures("setup_credentials") -async def test_room_sensors_skip_device_without_priority_support( +async def test_room_sensors_created_regardless_of_device_id_prefix( hass: HomeAssistant, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, + mock_lyric_mixed_devices: MagicMock, ) -> None: - """A device that doesn't support room priority data shouldn't block setup or other devices' sensors.""" - location = LyricLocation( - MagicMock(), - location_json( - 1, - [ - thermostat_json("AABBCC000001", "LCC-AABBCC000001", "Living Room"), - thermostat_json("AABBCC000002", "LCC-AABBCC000002", "Bedroom"), - ], - ), - ) - lyric = build_mock_lyric( - [location], rooms_dict={"AABBCC000001": {0: LyricRoom(ROOM_JSON)}} - ) + """Room/accessory sensors are created for supported devices whether or not their ID starts with LCC. - async def get_thermostat_rooms(location_id: int, device_id: str) -> None: - if device_id == "LCC-AABBCC000002": - raise lyric_exception(400) - - lyric.get_thermostat_rooms.side_effect = get_thermostat_rooms - - with ( - patch("homeassistant.components.lyric.Lyric", return_value=lyric), - patch("homeassistant.components.lyric.PLATFORMS", [Platform.SENSOR]), - ): + An unsupported device (GetPriorityFailed 400) shouldn't get room + sensors, but must not block setup or the other devices' sensors either. + """ + with patch("homeassistant.components.lyric.PLATFORMS", [Platform.SENSOR]): await setup_integration(hass, mock_config_entry) - living_room_temperature = hass.states.get( + lcc_room_sensor = hass.states.get( entity_registry.async_get_entity_id( - Platform.SENSOR, DOMAIN, "AABBCC000001_indoor_temperature" + Platform.SENSOR, DOMAIN, "AABBCC000001_room0_acc0_room_temperature" ) ) - assert living_room_temperature.state == "21.5" + assert lcc_room_sensor.state == "22.5" - bedroom_temperature = hass.states.get( + non_lcc_room_sensor = hass.states.get( entity_registry.async_get_entity_id( - Platform.SENSOR, DOMAIN, "AABBCC000002_indoor_temperature" + Platform.SENSOR, DOMAIN, "AABBCC000002_room0_acc0_room_temperature" ) ) - assert bedroom_temperature.state == "21.5" - - room_sensor_entity_id = entity_registry.async_get_entity_id( - Platform.SENSOR, DOMAIN, "AABBCC000001_room0_acc0_room_temperature" - ) - assert hass.states.get(room_sensor_entity_id).state == "22.5" + assert non_lcc_room_sensor.state == "24.5" assert ( entity_registry.async_get_entity_id( - Platform.SENSOR, DOMAIN, "AABBCC000002_room0_acc0_room_temperature" + Platform.SENSOR, DOMAIN, "AABBCC000003_room0_acc0_room_temperature" ) is None ) + + unsupported_device_sensor = hass.states.get( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, "AABBCC000003_indoor_temperature" + ) + ) + assert unsupported_device_sensor.state == "21.5" From 8cc6a0fae8ed12cdd306711b0528eb9e7e4159d6 Mon Sep 17 00:00:00 2001 From: clutch2sft Date: Tue, 28 Jul 2026 15:57:07 -0400 Subject: [PATCH 7/8] Load the static parts of the thermostat test payload from a fixture thermostat_json() rebuilt its whole payload from scratch in Python on every call. Move the boilerplate (deviceClass, units, changeableValues, etc.) into fixtures/thermostat.json and just override the per-call fields (deviceID, macID, name) in Python, matching the "JSON fixtures over inline dicts" convention - while keeping the parameterization this fixture actually needs (it's called with different IDs across several tests, so a single static JSON file alone wouldn't fit). --- tests/components/lyric/conftest.py | 16 ++-------------- tests/components/lyric/fixtures/thermostat.json | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 14 deletions(-) create mode 100644 tests/components/lyric/fixtures/thermostat.json diff --git a/tests/components/lyric/conftest.py b/tests/components/lyric/conftest.py index c66fef3d76add..5a29886f69d2d 100644 --- a/tests/components/lyric/conftest.py +++ b/tests/components/lyric/conftest.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -56,22 +56,10 @@ def thermostat_json( ) -> dict[str, Any]: """Return a raw aiolyric device payload for a single thermostat.""" return { - "deviceClass": "Thermostat", + **load_json_object_fixture("thermostat.json", DOMAIN), "deviceID": device_id, "macID": mac_id, "name": name, - "units": "Celsius", - "indoorTemperature": 21.5, - "indoorHumidity": 45, - "outdoorTemperature": 10, - "displayedOutdoorHumidity": 60, - "deviceModel": "T9", - "changeableValues": { - "mode": "Heat", - "heatSetpoint": 20, - "coolSetpoint": 24, - "thermostatSetpointStatus": "NoHold", - }, } diff --git a/tests/components/lyric/fixtures/thermostat.json b/tests/components/lyric/fixtures/thermostat.json new file mode 100644 index 0000000000000..e79e516126f12 --- /dev/null +++ b/tests/components/lyric/fixtures/thermostat.json @@ -0,0 +1,15 @@ +{ + "deviceClass": "Thermostat", + "units": "Celsius", + "indoorTemperature": 21.5, + "indoorHumidity": 45, + "outdoorTemperature": 10, + "displayedOutdoorHumidity": 60, + "deviceModel": "T9", + "changeableValues": { + "mode": "Heat", + "heatSetpoint": 20, + "coolSetpoint": 24, + "thermostatSetpointStatus": "NoHold" + } +} From b3870c49f136fbe24146aee8e7238709efdb04b4 Mon Sep 17 00:00:00 2001 From: clutch2sft Date: Tue, 28 Jul 2026 16:53:03 -0400 Subject: [PATCH 8/8] Address Copilot's low-confidence review comments - _get_thermostat_rooms's location_id was typed str, but Resideo's own API docs document locationID as an Integer, and aiolyric's get_thermostat_rooms already types it int - our test fixtures had been quoting it as a string since early in this branch's history, which is what made the str annotation look consistent. Fixed the annotation to match the documented contract. - Apply the unused mock_lyric_mixed_devices fixture via usefixtures in test_sensor.py/test_select.py instead of injecting it as an unused parameter, per our own "don't inject unused fixture arguments" rule. --- homeassistant/components/lyric/coordinator.py | 2 +- tests/components/lyric/test_select.py | 5 ++--- tests/components/lyric/test_sensor.py | 5 ++--- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/lyric/coordinator.py b/homeassistant/components/lyric/coordinator.py index 1109d1ee4a306..f8e23067e08d9 100644 --- a/homeassistant/components/lyric/coordinator.py +++ b/homeassistant/components/lyric/coordinator.py @@ -87,7 +87,7 @@ async def _run_update(self, force_refresh_token: bool) -> Lyric: raise UpdateFailed(exception) from exception return self.lyric - async def _get_thermostat_rooms(self, location_id: str, device_id: str) -> None: + async def _get_thermostat_rooms(self, location_id: int, device_id: str) -> None: """Fetch room/priority data for a single thermostat. Devices that don't support this endpoint return a GetPriorityFailed diff --git a/tests/components/lyric/test_select.py b/tests/components/lyric/test_select.py index 6db1eb9f95528..88e5a2f63e8c0 100644 --- a/tests/components/lyric/test_select.py +++ b/tests/components/lyric/test_select.py @@ -1,6 +1,6 @@ """Tests for the Honeywell Lyric select platform.""" -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest @@ -14,12 +14,11 @@ from tests.common import MockConfigEntry -@pytest.mark.usefixtures("setup_credentials") +@pytest.mark.usefixtures("setup_credentials", "mock_lyric_mixed_devices") async def test_room_priority_select_created_regardless_of_device_id_prefix( hass: HomeAssistant, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - mock_lyric_mixed_devices: MagicMock, ) -> None: """The room priority select is created for supported devices whether or not their ID starts with LCC. diff --git a/tests/components/lyric/test_sensor.py b/tests/components/lyric/test_sensor.py index 699d1bdedfe7b..590ac096181b5 100644 --- a/tests/components/lyric/test_sensor.py +++ b/tests/components/lyric/test_sensor.py @@ -1,7 +1,7 @@ """Tests for the Honeywell Lyric sensor platform.""" from datetime import datetime -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest @@ -32,12 +32,11 @@ def test_get_datetime_from_future_time_valid() -> None: assert isinstance(result, datetime) -@pytest.mark.usefixtures("setup_credentials") +@pytest.mark.usefixtures("setup_credentials", "mock_lyric_mixed_devices") async def test_room_sensors_created_regardless_of_device_id_prefix( hass: HomeAssistant, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - mock_lyric_mixed_devices: MagicMock, ) -> None: """Room/accessory sensors are created for supported devices whether or not their ID starts with LCC.