diff --git a/homeassistant/components/lyric/coordinator.py b/homeassistant/components/lyric/coordinator.py index 9b3c76e4f1ce3..f8e23067e08d9 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,24 @@ 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: int, device_id: str) -> None: + """Fetch room/priority data for a single thermostat. + + 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: + 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/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/__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..5a29886f69d2d --- /dev/null +++ b/tests/components/lyric/conftest.py @@ -0,0 +1,165 @@ +"""Fixtures for the Honeywell Lyric integration tests.""" + +from collections.abc import AsyncGenerator +import time +from typing import Any +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 ( + 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, load_json_object_fixture + +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 { + **load_json_object_fixture("thermostat.json", DOMAIN), + "deviceID": device_id, + "macID": mac_id, + "name": name, + } + + +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, + } + ) + + +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/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" + } +} 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_select.py b/tests/components/lyric/test_select.py new file mode 100644 index 0000000000000..88e5a2f63e8c0 --- /dev/null +++ b/tests/components/lyric/test_select.py @@ -0,0 +1,50 @@ +"""Tests for the Honeywell Lyric select platform.""" + +from unittest.mock import 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", "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, +) -> 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 7f74b5e8d63ea..590ac096181b5 100644 --- a/tests/components/lyric/test_sensor.py +++ b/tests/components/lyric/test_sensor.py @@ -1,8 +1,19 @@ """Tests for the Honeywell Lyric sensor platform.""" from datetime import datetime +from unittest.mock import patch +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 tests.common import MockConfigEntry def test_get_datetime_from_future_time_none() -> None: @@ -19,3 +30,46 @@ 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) + + +@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, +) -> None: + """Room/accessory sensors are created for supported devices whether or not their ID starts with LCC. + + 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) + + lcc_room_sensor = hass.states.get( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, "AABBCC000001_room0_acc0_room_temperature" + ) + ) + assert lcc_room_sensor.state == "22.5" + + non_lcc_room_sensor = hass.states.get( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, "AABBCC000002_room0_acc0_room_temperature" + ) + ) + assert non_lcc_room_sensor.state == "24.5" + + assert ( + entity_registry.async_get_entity_id( + 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"