From 6ba33baccc9f6b4e2c621a3c05cfe1bddbe12d24 Mon Sep 17 00:00:00 2001 From: trip-g Date: Tue, 21 Jul 2026 16:59:33 -0400 Subject: [PATCH 01/13] Add Room Motion binary sensor to Lyric integration Resideo's priority endpoint reports per-accessory motion detection (detectMotion) for paired IndoorAirSensor room accessories, which the integration parsed but never surfaced as an entity. Adds a new binary_sensor platform following the same per-accessory pattern already used for Room Temperature/Humidity in sensor.py. --- homeassistant/components/lyric/__init__.py | 2 +- .../components/lyric/binary_sensor.py | 92 +++++++++++++++++++ homeassistant/components/lyric/strings.json | 5 + 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/lyric/binary_sensor.py diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index af610f1273729..9031de0442e34 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -21,7 +21,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -PLATFORMS = [Platform.CLIMATE, Platform.SELECT, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SELECT, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: LyricConfigEntry) -> bool: diff --git a/homeassistant/components/lyric/binary_sensor.py b/homeassistant/components/lyric/binary_sensor.py new file mode 100644 index 0000000000000..2a35c60d8741b --- /dev/null +++ b/homeassistant/components/lyric/binary_sensor.py @@ -0,0 +1,92 @@ +"""Support for Honeywell Lyric binary sensor platform.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from aiolyric.objects.device import LyricDevice +from aiolyric.objects.location import LyricLocation +from aiolyric.objects.priority import LyricAccessory, LyricRoom + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import LyricConfigEntry, LyricDataUpdateCoordinator +from .entity import LyricAccessoryEntity + + +@dataclass(frozen=True, kw_only=True) +class LyricBinarySensorAccessoryEntityDescription(BinarySensorEntityDescription): + """Class describing Honeywell Lyric room sensor binary sensor entities.""" + + value_fn: Callable[[LyricRoom, LyricAccessory], bool] + suitable_fn: Callable[[LyricRoom, LyricAccessory], bool] + + +ACCESSORY_BINARY_SENSORS: list[LyricBinarySensorAccessoryEntityDescription] = [ + LyricBinarySensorAccessoryEntityDescription( + key="room_motion", + translation_key="room_motion", + device_class=BinarySensorDeviceClass.MOTION, + value_fn=lambda _, accessory: accessory.detect_motion, + suitable_fn=lambda _, accessory: accessory.type == "IndoorAirSensor", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LyricConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Honeywell Lyric binary sensor platform based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + LyricAccessoryBinarySensor( + coordinator, binary_sensor, location, device, room, accessory + ) + for location in coordinator.data.locations + for device in location.devices + for room in coordinator.data.rooms_dict.get(device.mac_id, {}).values() + for accessory in room.accessories + for binary_sensor in ACCESSORY_BINARY_SENSORS + if binary_sensor.suitable_fn(room, accessory) + ) + + +class LyricAccessoryBinarySensor(LyricAccessoryEntity, BinarySensorEntity): + """Define a Honeywell Lyric room sensor binary sensor.""" + + entity_description: LyricBinarySensorAccessoryEntityDescription + + def __init__( + self, + coordinator: LyricDataUpdateCoordinator, + description: LyricBinarySensorAccessoryEntityDescription, + location: LyricLocation, + parentDevice: LyricDevice, + room: LyricRoom, + accessory: LyricAccessory, + ) -> None: + """Initialize.""" + super().__init__( + coordinator, + location, + parentDevice, + room, + accessory, + f"{parentDevice.mac_id}_room{room.id}_acc{accessory.id}_{description.key}", + ) + self.entity_description = description + + @property + @override + def is_on(self) -> bool: + """Return true if motion is detected.""" + return self.entity_description.value_fn(self.room, self.accessory) diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index b9547c8251475..034f3f87e1f23 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -37,6 +37,11 @@ } }, "entity": { + "binary_sensor": { + "room_motion": { + "name": "Room motion" + } + }, "select": { "room_priority": { "name": "Room priority", From 950804d20b6d012fc478034dae2afaf4cd2ff3b3 Mon Sep 17 00:00:00 2001 From: trip-g Date: Tue, 21 Jul 2026 17:04:21 -0400 Subject: [PATCH 02/13] Add Vacation Hold binary sensor to Lyric integration Resideo's device response includes vacationHold.enabled, which was parsed but never surfaced. Adds a device-level binary sensor alongside the existing per-accessory Room Motion sensor, introducing a DEVICE_BINARY_SENSORS list mirroring the DEVICE_SENSORS pattern already used in sensor.py. --- .../components/lyric/binary_sensor.py | 60 ++++++++++++++++++- homeassistant/components/lyric/strings.json | 3 + 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lyric/binary_sensor.py b/homeassistant/components/lyric/binary_sensor.py index 2a35c60d8741b..fb97fea392dcb 100644 --- a/homeassistant/components/lyric/binary_sensor.py +++ b/homeassistant/components/lyric/binary_sensor.py @@ -17,7 +17,15 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import LyricConfigEntry, LyricDataUpdateCoordinator -from .entity import LyricAccessoryEntity +from .entity import LyricAccessoryEntity, LyricDeviceEntity + + +@dataclass(frozen=True, kw_only=True) +class LyricBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class describing Honeywell Lyric binary sensor entities.""" + + value_fn: Callable[[LyricDevice], bool] + suitable_fn: Callable[[LyricDevice], bool] @dataclass(frozen=True, kw_only=True) @@ -28,6 +36,15 @@ class LyricBinarySensorAccessoryEntityDescription(BinarySensorEntityDescription) suitable_fn: Callable[[LyricRoom, LyricAccessory], bool] +DEVICE_BINARY_SENSORS: list[LyricBinarySensorEntityDescription] = [ + LyricBinarySensorEntityDescription( + key="vacation_hold", + translation_key="vacation_hold", + value_fn=lambda device: device.vacation_hold.enabled, + suitable_fn=lambda device: True, + ), +] + ACCESSORY_BINARY_SENSORS: list[LyricBinarySensorAccessoryEntityDescription] = [ LyricBinarySensorAccessoryEntityDescription( key="room_motion", @@ -47,6 +64,19 @@ async def async_setup_entry( """Set up the Honeywell Lyric binary sensor platform based on a config entry.""" coordinator = entry.runtime_data + async_add_entities( + LyricBinarySensor( + coordinator, + device_binary_sensor, + location, + device, + ) + for location in coordinator.data.locations + for device in location.devices + for device_binary_sensor in DEVICE_BINARY_SENSORS + if device_binary_sensor.suitable_fn(device) + ) + async_add_entities( LyricAccessoryBinarySensor( coordinator, binary_sensor, location, device, room, accessory @@ -60,6 +90,34 @@ async def async_setup_entry( ) +class LyricBinarySensor(LyricDeviceEntity, BinarySensorEntity): + """Define a Honeywell Lyric binary sensor.""" + + entity_description: LyricBinarySensorEntityDescription + + def __init__( + self, + coordinator: LyricDataUpdateCoordinator, + description: LyricBinarySensorEntityDescription, + location: LyricLocation, + device: LyricDevice, + ) -> None: + """Initialize.""" + super().__init__( + coordinator, + location, + device, + f"{device.mac_id}_{description.key}", + ) + self.entity_description = description + + @property + @override + def is_on(self) -> bool: + """Return true if the condition is met.""" + return self.entity_description.value_fn(self.device) + + class LyricAccessoryBinarySensor(LyricAccessoryEntity, BinarySensorEntity): """Define a Honeywell Lyric room sensor binary sensor.""" diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index 034f3f87e1f23..402cf5411e570 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -40,6 +40,9 @@ "binary_sensor": { "room_motion": { "name": "Room motion" + }, + "vacation_hold": { + "name": "Vacation hold" } }, "select": { From 87dad027f0a2bc39ff70aa2e5a094aeea9e0744d Mon Sep 17 00:00:00 2001 From: trip-g Date: Tue, 21 Jul 2026 17:05:50 -0400 Subject: [PATCH 03/13] Add Device Pairing Enabled binary sensor to Lyric integration Resideo's device response includes settings.devicePairingEnabled, which was parsed but never surfaced. Adds it as a diagnostic binary sensor alongside Vacation Hold, using the same DEVICE_BINARY_SENSORS list. --- homeassistant/components/lyric/binary_sensor.py | 8 ++++++++ homeassistant/components/lyric/strings.json | 3 +++ 2 files changed, 11 insertions(+) diff --git a/homeassistant/components/lyric/binary_sensor.py b/homeassistant/components/lyric/binary_sensor.py index fb97fea392dcb..1629794fe1edc 100644 --- a/homeassistant/components/lyric/binary_sensor.py +++ b/homeassistant/components/lyric/binary_sensor.py @@ -13,6 +13,7 @@ BinarySensorEntity, BinarySensorEntityDescription, ) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -43,6 +44,13 @@ class LyricBinarySensorAccessoryEntityDescription(BinarySensorEntityDescription) value_fn=lambda device: device.vacation_hold.enabled, suitable_fn=lambda device: True, ), + LyricBinarySensorEntityDescription( + key="device_pairing_enabled", + translation_key="device_pairing_enabled", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.settings.device_pairing_enabled, + suitable_fn=lambda device: True, + ), ] ACCESSORY_BINARY_SENSORS: list[LyricBinarySensorAccessoryEntityDescription] = [ diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index 402cf5411e570..c92c6ff2cac41 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -38,6 +38,9 @@ }, "entity": { "binary_sensor": { + "device_pairing_enabled": { + "name": "Device pairing enabled" + }, "room_motion": { "name": "Room motion" }, From 31dd000bd2f7e318f5c79adeb0cffed5287a9d86 Mon Sep 17 00:00:00 2001 From: trip-g Date: Thu, 23 Jul 2026 06:08:30 -0400 Subject: [PATCH 04/13] Add tests for the Lyric binary sensor platform Covers entity creation (async_setup_entry), the accessory-type filter that gates Room Motion to IndoorAirSensor accessories only, the Vacation Hold/Device Pairing Enabled value mappings, and diagnostic entity_category metadata. Co-Authored-By: Claude Sonnet 5 --- tests/components/lyric/test_binary_sensor.py | 119 +++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 tests/components/lyric/test_binary_sensor.py diff --git a/tests/components/lyric/test_binary_sensor.py b/tests/components/lyric/test_binary_sensor.py new file mode 100644 index 0000000000000..55b39feb32ff9 --- /dev/null +++ b/tests/components/lyric/test_binary_sensor.py @@ -0,0 +1,119 @@ +"""Tests for the Honeywell Lyric binary sensor platform.""" + +from unittest.mock import MagicMock + +from homeassistant.components.lyric.binary_sensor import ( + ACCESSORY_BINARY_SENSORS, + DEVICE_BINARY_SENSORS, + async_setup_entry, +) +from homeassistant.const import EntityCategory + + +def _mock_accessory( + accessory_id: int, accessory_type: str, detect_motion: bool = False +) -> MagicMock: + accessory = MagicMock() + accessory.id = accessory_id + accessory.type = accessory_type + accessory.detect_motion = detect_motion + return accessory + + +def _mock_room(room_id: int, accessories: list[MagicMock]) -> MagicMock: + room = MagicMock() + room.id = room_id + room.accessories = accessories + return room + + +def _mock_device( + mac_id: str = "AABBCC", + vacation_enabled: bool = False, + pairing_enabled: bool = True, +) -> MagicMock: + device = MagicMock() + device.mac_id = mac_id + device.vacation_hold.enabled = vacation_enabled + device.settings.device_pairing_enabled = pairing_enabled + return device + + +async def test_async_setup_entry_creates_expected_entities() -> None: + """Device-level and accessory-level binary sensors are created correctly. + + A room with two accessories (one Thermostat, one IndoorAirSensor) should + only produce a Room Motion entity for the IndoorAirSensor accessory. + """ + device = _mock_device() + location = MagicMock(location_id="location1", devices=[device]) + + thermostat_accessory = _mock_accessory(0, "Thermostat") + sensor_accessory = _mock_accessory(1, "IndoorAirSensor", detect_motion=True) + room = _mock_room(0, [thermostat_accessory, sensor_accessory]) + + coordinator = MagicMock() + coordinator.data.locations = [location] + coordinator.data.rooms_dict = {device.mac_id: {0: room}} + + entry = MagicMock() + entry.runtime_data = coordinator + + added: list[list] = [] + async_add_entities = MagicMock( + side_effect=lambda entities: added.append(list(entities)) + ) + + await async_setup_entry(MagicMock(), entry, async_add_entities) + + device_entities, accessory_entities = added + assert {e.entity_description.key for e in device_entities} == { + "vacation_hold", + "device_pairing_enabled", + } + assert len(accessory_entities) == 1 + assert accessory_entities[0].entity_description.key == "room_motion" + + +def test_vacation_hold_value_fn() -> None: + """Vacation Hold reflects device.vacation_hold.enabled.""" + description = next(d for d in DEVICE_BINARY_SENSORS if d.key == "vacation_hold") + assert description.value_fn(_mock_device(vacation_enabled=True)) is True + assert description.value_fn(_mock_device(vacation_enabled=False)) is False + + +def test_device_pairing_enabled_value_fn() -> None: + """Device Pairing Enabled reflects device.settings.device_pairing_enabled.""" + description = next( + d for d in DEVICE_BINARY_SENSORS if d.key == "device_pairing_enabled" + ) + assert description.value_fn(_mock_device(pairing_enabled=True)) is True + assert description.value_fn(_mock_device(pairing_enabled=False)) is False + + +def test_device_pairing_enabled_is_diagnostic() -> None: + """Device Pairing Enabled is diagnostic; Vacation Hold is not.""" + pairing = next( + d for d in DEVICE_BINARY_SENSORS if d.key == "device_pairing_enabled" + ) + vacation = next(d for d in DEVICE_BINARY_SENSORS if d.key == "vacation_hold") + assert pairing.entity_category is EntityCategory.DIAGNOSTIC + assert vacation.entity_category is None + + +def test_room_motion_suitable_fn_filters_by_accessory_type() -> None: + """Room Motion only applies to IndoorAirSensor accessories.""" + description = ACCESSORY_BINARY_SENSORS[0] + sensor_accessory = _mock_accessory(1, "IndoorAirSensor") + thermostat_accessory = _mock_accessory(0, "Thermostat") + assert description.suitable_fn(None, sensor_accessory) is True + assert description.suitable_fn(None, thermostat_accessory) is False + + +def test_room_motion_value_fn() -> None: + """Room Motion reflects accessory.detect_motion.""" + description = ACCESSORY_BINARY_SENSORS[0] + motion_detected = _mock_accessory(1, "IndoorAirSensor", detect_motion=True) + no_motion = _mock_accessory(1, "IndoorAirSensor", detect_motion=False) + assert description.value_fn(None, motion_detected) is True + assert description.value_fn(None, no_motion) is False From 7a25c55b254683d717a02554765235a2a6ee5df8 Mon Sep 17 00:00:00 2001 From: trip-g Date: Thu, 23 Jul 2026 06:23:23 -0400 Subject: [PATCH 05/13] Rework binary sensor tests to use real aiolyric objects and unique_ids Addresses two review comments on the previous version of this test: - Copilot: tests built entities from MagicMocks with pre-set already- parsed properties, so they'd pass even with the exact live payload/ key mismatches this session found (e.g. vacationHold.Enabled vs .enabled). Now builds real LyricDevice/LyricRoom/LyricAccessory objects from realistic payloads and asserts through entity.is_on, exercising the actual parsing boundary. - Maintainer (@Samielakkad): setup test only checked entity_description.key, not the generated unique_id - the part most likely to collide/drift as more room/accessory sensors are added. Added a dedicated test asserting exact unique_id values across multiple rooms/accessories. Since aiolyric 2.1.1 (the currently pinned release) still has the vacationHold.Enabled and accessory sensorType mismatches, two new tests use xfail(strict=True) against the real live payload shape: they currently fail for the same reason the real entities don't work yet, and will start passing (forcing marker removal, since strict=True turns an unexpected pass into a failure) once the companion aiolyric fixes are released and the manifest pin is bumped. This documents the known gap explicitly instead of hiding it behind mocks. device_pairing_enabled has no known aiolyric mismatch, so it gets a real end-to-end pass today, proving the pattern isn't blanket broken. Co-Authored-By: Claude Sonnet 5 --- tests/components/lyric/test_binary_sensor.py | 197 ++++++++++++++++--- 1 file changed, 173 insertions(+), 24 deletions(-) diff --git a/tests/components/lyric/test_binary_sensor.py b/tests/components/lyric/test_binary_sensor.py index 55b39feb32ff9..bc5894a0e6d01 100644 --- a/tests/components/lyric/test_binary_sensor.py +++ b/tests/components/lyric/test_binary_sensor.py @@ -2,6 +2,10 @@ from unittest.mock import MagicMock +from aiolyric.objects.device import LyricDevice +from aiolyric.objects.priority import LyricRoom +import pytest + from homeassistant.components.lyric.binary_sensor import ( ACCESSORY_BINARY_SENSORS, DEVICE_BINARY_SENSORS, @@ -9,6 +13,8 @@ ) from homeassistant.const import EntityCategory +MAC_ID = "5CFCE1B67035" + def _mock_accessory( accessory_id: int, accessory_type: str, detect_motion: bool = False @@ -20,13 +26,6 @@ def _mock_accessory( return accessory -def _mock_room(room_id: int, accessories: list[MagicMock]) -> MagicMock: - room = MagicMock() - room.id = room_id - room.accessories = accessories - return room - - def _mock_device( mac_id: str = "AABBCC", vacation_enabled: bool = False, @@ -39,23 +38,31 @@ def _mock_device( return device -async def test_async_setup_entry_creates_expected_entities() -> None: - """Device-level and accessory-level binary sensors are created correctly. +def _coordinator_for( + device: LyricDevice, rooms: list[LyricRoom] | None = None +) -> MagicMock: + """Build a coordinator mock that supports full entity property resolution. - A room with two accessories (one Thermostat, one IndoorAirSensor) should - only produce a Room Motion entity for the IndoorAirSensor accessory. + Wires up locations_dict/rooms_dict so LyricDeviceEntity.device and + LyricAccessoryEntity.room/.accessory resolve through the same lookups + the real entities use, not just a flat pre-set attribute. """ - device = _mock_device() - location = MagicMock(location_id="location1", devices=[device]) - - thermostat_accessory = _mock_accessory(0, "Thermostat") - sensor_accessory = _mock_accessory(1, "IndoorAirSensor", detect_motion=True) - room = _mock_room(0, [thermostat_accessory, sensor_accessory]) + location = MagicMock() + location.location_id = "location1" + location.devices = [device] + location.devices_dict = {device.mac_id: device} coordinator = MagicMock() coordinator.data.locations = [location] - coordinator.data.rooms_dict = {device.mac_id: {0: room}} + coordinator.data.locations_dict = {"location1": location} + coordinator.data.rooms_dict = { + device.mac_id: {room.id: room for room in rooms or []} + } + return coordinator + +async def _setup_and_collect(coordinator: MagicMock) -> tuple[list, list]: + """Run async_setup_entry and return (device_entities, accessory_entities).""" entry = MagicMock() entry.runtime_data = coordinator @@ -65,14 +72,156 @@ async def test_async_setup_entry_creates_expected_entities() -> None: ) await async_setup_entry(MagicMock(), entry, async_add_entities) + return added[0], added[1] + + +async def test_async_setup_entry_generates_correct_unique_ids() -> None: + """Entity unique_ids are correctly formed and don't collide across rooms. - device_entities, accessory_entities = added - assert {e.entity_description.key for e in device_entities} == { - "vacation_hold", - "device_pairing_enabled", + Uses real LyricDevice/LyricRoom/LyricAccessory objects (constructed with + field names aiolyric 2.1.1 already parses correctly) to verify the ID + formula itself: device-level IDs key off the device MAC, accessory-level + IDs additionally key off room id and accessory id - the parts most + likely to collide when more room/accessory sensors are added later. + """ + device = LyricDevice( + MagicMock(), + { + "macID": MAC_ID, + "vacationHold": {"enabled": False}, + "settings": {"devicePairingEnabled": True}, + }, + ) + room1 = LyricRoom( + { + "id": 1, + "accessories": [ + {"id": 1, "type": "IndoorAirSensor", "detectMotion": False}, + {"id": 0, "type": "Thermostat", "detectMotion": False}, + ], + } + ) + room2 = LyricRoom( + { + "id": 2, + "accessories": [ + {"id": 2, "type": "IndoorAirSensor", "detectMotion": True}, + ], + } + ) + + coordinator = _coordinator_for(device, [room1, room2]) + device_entities, accessory_entities = await _setup_and_collect(coordinator) + + assert {e.unique_id for e in device_entities} == { + f"{MAC_ID}_vacation_hold", + f"{MAC_ID}_device_pairing_enabled", + } + + # Only the IndoorAirSensor accessories produce Room Motion entities, one + # per room, and each unique_id is distinct despite sharing a device MAC. + assert {e.unique_id for e in accessory_entities} == { + f"{MAC_ID}_room1_acc1_room_motion", + f"{MAC_ID}_room2_acc2_room_motion", } - assert len(accessory_entities) == 1 - assert accessory_entities[0].entity_description.key == "room_motion" + + +async def test_device_pairing_enabled_end_to_end_with_real_payload() -> None: + """Device Pairing Enabled resolves correctly through real object parsing. + + Unlike vacation_hold and room_motion, this field isn't affected by any + known aiolyric field-name mismatch, so this exercises the full + integration boundary (real LyricDevice -> entity.is_on) end-to-end. + """ + device = LyricDevice( + MagicMock(), + { + "macID": MAC_ID, + "vacationHold": {"Enabled": False}, + "settings": {"devicePairingEnabled": True}, + }, + ) + coordinator = _coordinator_for(device) + device_entities, _ = await _setup_and_collect(coordinator) + + pairing_entity = next( + e + for e in device_entities + if e.entity_description.key == "device_pairing_enabled" + ) + assert pairing_entity.unique_id == f"{MAC_ID}_device_pairing_enabled" + assert pairing_entity.is_on is True + + +@pytest.mark.xfail( + strict=True, + reason=( + "aiolyric 2.1.1's VacationHold.enabled reads JSON key 'enabled', but " + "Resideo's live API returns 'Enabled' (capital E). Fixed upstream in " + "clutch2sft/aiolyric#fix-vacation-hold-key; once that's released and " + "the manifest pin is bumped, this will start passing for real and " + "this marker must be removed." + ), +) +async def test_vacation_hold_end_to_end_with_live_payload() -> None: + """Vacation Hold should resolve True given a live-shaped payload. + + Built from the actual API response shape captured from a live account, + not a synthetic/pre-parsed mock - currently fails because of the + pending key-name fix, by design. + """ + device = LyricDevice( + MagicMock(), + { + "macID": MAC_ID, + "vacationHold": {"Enabled": True}, + "settings": {"devicePairingEnabled": True}, + }, + ) + coordinator = _coordinator_for(device) + device_entities, _ = await _setup_and_collect(coordinator) + + vacation_entity = next( + e for e in device_entities if e.entity_description.key == "vacation_hold" + ) + assert vacation_entity.is_on is True + + +@pytest.mark.xfail( + strict=True, + reason=( + "aiolyric 2.1.1's LyricAccessory.type reads JSON key 'type', but " + "Resideo's live API returns 'sensorType'. Fixed upstream in " + "timmo001/aiolyric#165; once that's released and the manifest pin is " + "bumped, this will start passing for real and this marker must be " + "removed." + ), +) +async def test_room_motion_end_to_end_with_live_payload() -> None: + """Room Motion should be created and reflect real data from a live payload. + + Built from the actual /priority response shape captured from a live + T9-T10 account - currently fails because accessory.type never matches + "IndoorAirSensor" under the pending key-name fix, so no entity is + created at all. + """ + device = LyricDevice(MagicMock(), {"macID": MAC_ID}) + room = LyricRoom( + { + "id": 1, + "accessories": [ + {"id": 1, "sensorType": "IndoorAirSensor", "detectMotion": True}, + ], + } + ) + coordinator = _coordinator_for(device, [room]) + _, accessory_entities = await _setup_and_collect(coordinator) + + motion_entity = next( + e for e in accessory_entities if e.entity_description.key == "room_motion" + ) + assert motion_entity.unique_id == f"{MAC_ID}_room1_acc1_room_motion" + assert motion_entity.is_on is True def test_vacation_hold_value_fn() -> None: From d9569d43a26780e88f1ff4d6bd39beacbeb1aae6 Mon Sep 17 00:00:00 2001 From: trip-g Date: Thu, 23 Jul 2026 07:44:21 -0400 Subject: [PATCH 06/13] Rework binary sensor tests to use a real config entry setup CI enforces this as a hard rule (pylint plugin check home-assistant-tests-direct-platform-async-setup-entry, W7420): a platform's async_setup_entry must not be called directly in tests; use hass.config_entries.async_setup(entry.entry_id) instead. The previous version called binary_sensor.async_setup_entry directly with mocked hass/entry/async_add_entities, which this now-enforced rule flags. Adds tests/components/lyric/conftest.py with fixtures for a fully authenticated config entry (registered via application_credentials, matching test_config_flow.py's proven pattern) and HTTP-level mocks for the /locations and /priority endpoints using the actual live payload shape captured from a real T9-T10 account this session - including the real key names aiolyric 2.1.1 gets wrong (vacationHold .Enabled, accessories[].sensorType, currentPriority vs priority). Entities are looked up via entity_registry.async_get_entity_id() with their known unique_id format rather than guessing generated entity_id slugs, which is deterministic regardless of naming/translation quirks. device_pairing_enabled has no known field-name mismatch, so its test passes for real against the currently-pinned aiolyric release. vacation_hold and room_motion are xfail(strict=True) against the real live payload shape for the same reasons already disclosed in this PR's description; strict=True means they'll turn into hard failures (forcing marker removal) once the companion aiolyric fixes are released and the manifest pin is bumped, rather than silently staying green. Note: I could not run this against a real pytest+hass fixture harness locally (blocked by a Windows/fcntl limitation unrelated to this integration) - built carefully against the proven patterns in this repo's own test_config_flow.py and a structurally similar OAuth2 + DataUpdateCoordinator integration (iotty), but this needs a real pytest run to confirm before considering it fully verified. Co-Authored-By: Claude Sonnet 5 --- tests/components/lyric/conftest.py | 149 ++++++++++++ tests/components/lyric/test_binary_sensor.py | 232 ++++++------------- 2 files changed, 221 insertions(+), 160 deletions(-) create mode 100644 tests/components/lyric/conftest.py diff --git a/tests/components/lyric/conftest.py b/tests/components/lyric/conftest.py new file mode 100644 index 0000000000000..e1271011d8c53 --- /dev/null +++ b/tests/components/lyric/conftest.py @@ -0,0 +1,149 @@ +"""Fixtures for the Honeywell Lyric integration tests.""" + +from time import time + +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 +from tests.test_util.aiohttp import AiohttpClientMocker + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + +BASE_URL = "https://api.honeywellhome.com/v2" + +# Real payload shapes captured from a live T9-T10 account with paired +# RCHTSENSOR room accessories. Deliberately using the actual field names +# Resideo returns (e.g. vacationHold.Enabled, accessories[].sensorType), +# not the ones aiolyric happens to read, so tests exercise real parsing +# rather than hiding behind pre-parsed mocks. +LOCATION_ID = "35202000168931" +# Deliberately "LCC-"-prefixed: this branch is intentionally cut from a +# clean base without the coordinator fix from home-assistant/core#177022 +# (which removes a device-ID-prefix heuristic gating the /priority fetch). +# Using a non-"LCC-" ID here would make room-level entities fail to be +# created for that unrelated, separately-tracked reason, muddying what +# these tests are actually checking. +DEVICE_ID = "LCC-7f86b153-8480-f111-b78f-6045bdb25006" +MAC_ID = "5CFCE1B67035" + +LOCATIONS_RESPONSE = [ + { + "locationID": LOCATION_ID, + "name": "Ocala P01", + "devices": [ + { + "vacationHold": {"Enabled": True}, + "scheduleStatus": "Resume", + "settings": {"devicePairingEnabled": True}, + "deviceClass": "Thermostat", + "deviceType": "Thermostat", + "deviceID": DEVICE_ID, + "name": "Ocala", + "macID": MAC_ID, + "units": "Fahrenheit", + "indoorTemperature": 79, + "deviceModel": "T9-T10", + } + ], + "users": [], + } +] + +PRIORITY_RESPONSE = { + "deviceId": MAC_ID, + "priorityStatus": "NoHold", + "priority": { + "priorityType": "PickARoom", + "selectedRooms": [1], + "rooms": [ + { + "id": 1, + "name": "Primary Bedroom", + "avgTemperature": 79, + "avgHumidity": 54, + "overallMotion": False, + "accessories": [ + { + "id": 1, + "sensorType": "IndoorAirSensor", + "temperature": 79, + "status": "Ok", + "detectMotion": True, + } + ], + } + ], + }, +} + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Register lyric application credentials, matching test_config_flow.py.""" + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + await async_import_client_credential( + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "cred" + ) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return an already-authenticated Lyric config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + # Matches the credential name registered by setup_credentials via + # async_import_client_credential(..., "cred") - confirmed against + # test_config_flow.py's test_full_flow, which asserts a real + # completed flow ends up with auth_implementation == "cred", not + # DOMAIN. + "auth_implementation": "cred", + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": time() + 3600, + "token_type": "Bearer", + }, + }, + ) + + +@pytest.fixture +def mock_lyric_api(aioclient_mock: AiohttpClientMocker) -> AiohttpClientMocker: + """Mock the /locations and /priority HTTP endpoints with real-shaped data. + + Registered at the aiohttp transport level, so the real aiolyric client + and coordinator code runs unmodified - only the network call itself is + faked, using the actual field names Resideo returns. + """ + aioclient_mock.get( + f"{BASE_URL}/locations?apikey={CLIENT_ID}", + json=LOCATIONS_RESPONSE, + ) + aioclient_mock.get( + f"{BASE_URL}/devices/thermostats/{DEVICE_ID}/priority" + f"?apikey={CLIENT_ID}&locationId={LOCATION_ID}", + json=PRIORITY_RESPONSE, + ) + return aioclient_mock + + +async def async_setup_lyric_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Set up the mock config entry and wait for it to settle.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/lyric/test_binary_sensor.py b/tests/components/lyric/test_binary_sensor.py index bc5894a0e6d01..013790f1488c7 100644 --- a/tests/components/lyric/test_binary_sensor.py +++ b/tests/components/lyric/test_binary_sensor.py @@ -2,18 +2,20 @@ from unittest.mock import MagicMock -from aiolyric.objects.device import LyricDevice -from aiolyric.objects.priority import LyricRoom import pytest from homeassistant.components.lyric.binary_sensor import ( ACCESSORY_BINARY_SENSORS, DEVICE_BINARY_SENSORS, - async_setup_entry, ) from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -MAC_ID = "5CFCE1B67035" +from .conftest import MAC_ID, async_setup_lyric_entry + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker def _mock_accessory( @@ -38,119 +40,30 @@ def _mock_device( return device -def _coordinator_for( - device: LyricDevice, rooms: list[LyricRoom] | None = None -) -> MagicMock: - """Build a coordinator mock that supports full entity property resolution. - - Wires up locations_dict/rooms_dict so LyricDeviceEntity.device and - LyricAccessoryEntity.room/.accessory resolve through the same lookups - the real entities use, not just a flat pre-set attribute. - """ - location = MagicMock() - location.location_id = "location1" - location.devices = [device] - location.devices_dict = {device.mac_id: device} - - coordinator = MagicMock() - coordinator.data.locations = [location] - coordinator.data.locations_dict = {"location1": location} - coordinator.data.rooms_dict = { - device.mac_id: {room.id: room for room in rooms or []} - } - return coordinator - - -async def _setup_and_collect(coordinator: MagicMock) -> tuple[list, list]: - """Run async_setup_entry and return (device_entities, accessory_entities).""" - entry = MagicMock() - entry.runtime_data = coordinator - - added: list[list] = [] - async_add_entities = MagicMock( - side_effect=lambda entities: added.append(list(entities)) - ) - - await async_setup_entry(MagicMock(), entry, async_add_entities) - return added[0], added[1] - - -async def test_async_setup_entry_generates_correct_unique_ids() -> None: - """Entity unique_ids are correctly formed and don't collide across rooms. - - Uses real LyricDevice/LyricRoom/LyricAccessory objects (constructed with - field names aiolyric 2.1.1 already parses correctly) to verify the ID - formula itself: device-level IDs key off the device MAC, accessory-level - IDs additionally key off room id and accessory id - the parts most - likely to collide when more room/accessory sensors are added later. - """ - device = LyricDevice( - MagicMock(), - { - "macID": MAC_ID, - "vacationHold": {"enabled": False}, - "settings": {"devicePairingEnabled": True}, - }, - ) - room1 = LyricRoom( - { - "id": 1, - "accessories": [ - {"id": 1, "type": "IndoorAirSensor", "detectMotion": False}, - {"id": 0, "type": "Thermostat", "detectMotion": False}, - ], - } - ) - room2 = LyricRoom( - { - "id": 2, - "accessories": [ - {"id": 2, "type": "IndoorAirSensor", "detectMotion": True}, - ], - } - ) - - coordinator = _coordinator_for(device, [room1, room2]) - device_entities, accessory_entities = await _setup_and_collect(coordinator) - - assert {e.unique_id for e in device_entities} == { - f"{MAC_ID}_vacation_hold", - f"{MAC_ID}_device_pairing_enabled", - } - - # Only the IndoorAirSensor accessories produce Room Motion entities, one - # per room, and each unique_id is distinct despite sharing a device MAC. - assert {e.unique_id for e in accessory_entities} == { - f"{MAC_ID}_room1_acc1_room_motion", - f"{MAC_ID}_room2_acc2_room_motion", - } - - -async def test_device_pairing_enabled_end_to_end_with_real_payload() -> None: - """Device Pairing Enabled resolves correctly through real object parsing. - - Unlike vacation_hold and room_motion, this field isn't affected by any - known aiolyric field-name mismatch, so this exercises the full - integration boundary (real LyricDevice -> entity.is_on) end-to-end. +async def test_device_pairing_enabled_created( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + setup_credentials: None, + mock_lyric_api: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, +) -> None: + """Device Pairing Enabled is created via a real config entry setup. + + Exercises the full boundary: real HTTP responses (mocked at the aiohttp + transport level with the actual live payload shape) -> real aiolyric + parsing -> real coordinator -> real entity setup -> registered state. + devicePairingEnabled has no known aiolyric field-name mismatch, so this + passes against the currently-pinned release. """ - device = LyricDevice( - MagicMock(), - { - "macID": MAC_ID, - "vacationHold": {"Enabled": False}, - "settings": {"devicePairingEnabled": True}, - }, - ) - coordinator = _coordinator_for(device) - device_entities, _ = await _setup_and_collect(coordinator) + await async_setup_lyric_entry(hass, mock_config_entry) - pairing_entity = next( - e - for e in device_entities - if e.entity_description.key == "device_pairing_enabled" + entity_id = entity_registry.async_get_entity_id( + "binary_sensor", "lyric", f"{MAC_ID}_device_pairing_enabled" ) - assert pairing_entity.unique_id == f"{MAC_ID}_device_pairing_enabled" - assert pairing_entity.is_on is True + assert entity_id + state = hass.states.get(entity_id) + assert state + assert state.state == "on" @pytest.mark.xfail( @@ -163,65 +76,64 @@ async def test_device_pairing_enabled_end_to_end_with_real_payload() -> None: "this marker must be removed." ), ) -async def test_vacation_hold_end_to_end_with_live_payload() -> None: - """Vacation Hold should resolve True given a live-shaped payload. - - Built from the actual API response shape captured from a live account, - not a synthetic/pre-parsed mock - currently fails because of the - pending key-name fix, by design. +async def test_vacation_hold_created( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + setup_credentials: None, + mock_lyric_api: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, +) -> None: + """Vacation Hold should read "on" via a real config entry setup. + + The mocked /locations response has vacationHold.Enabled = True (the + real live shape), so this documents the currently-pinned aiolyric bug + rather than hiding it - it fails today for the same reason the real + entity does, and will start passing once the dependency is fixed. """ - device = LyricDevice( - MagicMock(), - { - "macID": MAC_ID, - "vacationHold": {"Enabled": True}, - "settings": {"devicePairingEnabled": True}, - }, - ) - coordinator = _coordinator_for(device) - device_entities, _ = await _setup_and_collect(coordinator) + await async_setup_lyric_entry(hass, mock_config_entry) - vacation_entity = next( - e for e in device_entities if e.entity_description.key == "vacation_hold" + entity_id = entity_registry.async_get_entity_id( + "binary_sensor", "lyric", f"{MAC_ID}_vacation_hold" ) - assert vacation_entity.is_on is True + assert entity_id + state = hass.states.get(entity_id) + assert state + assert state.state == "on" @pytest.mark.xfail( strict=True, reason=( - "aiolyric 2.1.1's LyricAccessory.type reads JSON key 'type', but " - "Resideo's live API returns 'sensorType'. Fixed upstream in " - "timmo001/aiolyric#165; once that's released and the manifest pin is " - "bumped, this will start passing for real and this marker must be " - "removed." + "aiolyric 2.1.1's LyricPriority.current_priority reads JSON key " + "'currentPriority' and LyricAccessory.type reads 'type', but " + "Resideo's live API returns 'priority' and 'sensorType'. Fixed " + "upstream in timmo001/aiolyric#165; once that's released and the " + "manifest pin is bumped, this will start passing for real and this " + "marker must be removed." ), ) -async def test_room_motion_end_to_end_with_live_payload() -> None: - """Room Motion should be created and reflect real data from a live payload. - - Built from the actual /priority response shape captured from a live - T9-T10 account - currently fails because accessory.type never matches - "IndoorAirSensor" under the pending key-name fix, so no entity is - created at all. +async def test_room_motion_created( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + setup_credentials: None, + mock_lyric_api: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, +) -> None: + """Room Motion should be created via a real config entry setup. + + The mocked /priority response uses the real live shape ("priority", + "sensorType"), so under the currently-pinned aiolyric this entity + doesn't get created at all - documents the gap instead of hiding it. """ - device = LyricDevice(MagicMock(), {"macID": MAC_ID}) - room = LyricRoom( - { - "id": 1, - "accessories": [ - {"id": 1, "sensorType": "IndoorAirSensor", "detectMotion": True}, - ], - } - ) - coordinator = _coordinator_for(device, [room]) - _, accessory_entities = await _setup_and_collect(coordinator) + await async_setup_lyric_entry(hass, mock_config_entry) - motion_entity = next( - e for e in accessory_entities if e.entity_description.key == "room_motion" + entity_id = entity_registry.async_get_entity_id( + "binary_sensor", "lyric", f"{MAC_ID}_room1_acc1_room_motion" ) - assert motion_entity.unique_id == f"{MAC_ID}_room1_acc1_room_motion" - assert motion_entity.is_on is True + assert entity_id + state = hass.states.get(entity_id) + assert state + assert state.state == "on" def test_vacation_hold_value_fn() -> None: From bd4198c9c7c9cbfd42b4ef07ae06f47b70aeb2c1 Mon Sep 17 00:00:00 2001 From: clutch2sft <148905f4@opayq.com> Date: Fri, 24 Jul 2026 06:45:52 -0400 Subject: [PATCH 07/13] Trim stale branch-history narrative from DEVICE_ID comment Only the LCC- prefix requirement matters to the fixture; the branch/PR context it referenced would rot as the codebase evolves. Co-Authored-By: Claude Sonnet 5 --- tests/components/lyric/conftest.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/components/lyric/conftest.py b/tests/components/lyric/conftest.py index e1271011d8c53..caae1c80b7d90 100644 --- a/tests/components/lyric/conftest.py +++ b/tests/components/lyric/conftest.py @@ -26,12 +26,7 @@ # not the ones aiolyric happens to read, so tests exercise real parsing # rather than hiding behind pre-parsed mocks. LOCATION_ID = "35202000168931" -# Deliberately "LCC-"-prefixed: this branch is intentionally cut from a -# clean base without the coordinator fix from home-assistant/core#177022 -# (which removes a device-ID-prefix heuristic gating the /priority fetch). -# Using a non-"LCC-" ID here would make room-level entities fail to be -# created for that unrelated, separately-tracked reason, muddying what -# these tests are actually checking. +# Use an LCC-prefixed ID so the current coordinator fetches room data. DEVICE_ID = "LCC-7f86b153-8480-f111-b78f-6045bdb25006" MAC_ID = "5CFCE1B67035" From fb2ffafab2c1846aef0be39af2767c91f156ff56 Mon Sep 17 00:00:00 2001 From: clutch2sft <148905f4@opayq.com> Date: Fri, 24 Jul 2026 08:28:10 -0400 Subject: [PATCH 08/13] Align test fixtures with sensor-schedule-status review feedback joostlek flagged the same conftest.py pattern on PR #177067 (this branch's conftest.py was cut from the same base): a bespoke "cred" credential name instead of the DOMAIN default, setting up the lyric domain directly instead of application_credentials, and mocking HTTP responses instead of the aiolyric client itself. Bringing this branch in line proactively rather than waiting for the same comments here. - Register the test credential under DOMAIN, matching every other OAuth2 integration's conftest. - Set up application_credentials directly instead of relying on it being pulled in as a side effect of lyric's manifest dependency. - Patch Lyric.get_locations/get_thermostat_rooms to build real LyricLocation/LyricPriority objects instead of mocking HTTP at the aiohttp transport level. Field-name parsing still runs for real (the properties read straight off the same attributes dicts). Co-Authored-By: Claude Sonnet 5 --- tests/components/lyric/conftest.py | 65 ++++++++++++-------- tests/components/lyric/test_binary_sensor.py | 26 ++++---- 2 files changed, 51 insertions(+), 40 deletions(-) diff --git a/tests/components/lyric/conftest.py b/tests/components/lyric/conftest.py index caae1c80b7d90..c43194f46ee7e 100644 --- a/tests/components/lyric/conftest.py +++ b/tests/components/lyric/conftest.py @@ -1,10 +1,16 @@ """Fixtures for the Honeywell Lyric integration tests.""" +from collections.abc import Generator from time import time +from unittest.mock import patch +from aiolyric import Lyric +from aiolyric.objects.location import LyricLocation +from aiolyric.objects.priority import LyricPriority import pytest from homeassistant.components.application_credentials import ( + DOMAIN as APPLICATION_CREDENTIALS_DOMAIN, ClientCredential, async_import_client_credential, ) @@ -13,7 +19,6 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -83,12 +88,11 @@ @pytest.fixture async def setup_credentials(hass: HomeAssistant) -> None: - """Register lyric application credentials, matching test_config_flow.py.""" - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + """Register lyric application credentials.""" + assert await async_setup_component(hass, APPLICATION_CREDENTIALS_DOMAIN, {}) await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "cred" + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) ) @@ -98,12 +102,7 @@ def mock_config_entry() -> MockConfigEntry: return MockConfigEntry( domain=DOMAIN, data={ - # Matches the credential name registered by setup_credentials via - # async_import_client_credential(..., "cred") - confirmed against - # test_config_flow.py's test_full_flow, which asserts a real - # completed flow ends up with auth_implementation == "cred", not - # DOMAIN. - "auth_implementation": "cred", + "auth_implementation": DOMAIN, "token": { "access_token": "mock-access-token", "refresh_token": "mock-refresh-token", @@ -115,23 +114,37 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_lyric_api(aioclient_mock: AiohttpClientMocker) -> AiohttpClientMocker: - """Mock the /locations and /priority HTTP endpoints with real-shaped data. +def mock_lyric_api() -> Generator[None]: + """Patch the aiolyric client to build real Location/Priority objects. - Registered at the aiohttp transport level, so the real aiolyric client - and coordinator code runs unmodified - only the network call itself is - faked, using the actual field names Resideo returns. + Patches Lyric.get_locations/get_thermostat_rooms directly rather than + mocking HTTP responses, so tests exercise real aiolyric parsing (the + same LyricLocation/LyricPriority code reading the actual field names + Resideo returns) without depending on network-mocking machinery. """ - aioclient_mock.get( - f"{BASE_URL}/locations?apikey={CLIENT_ID}", - json=LOCATIONS_RESPONSE, - ) - aioclient_mock.get( - f"{BASE_URL}/devices/thermostats/{DEVICE_ID}/priority" - f"?apikey={CLIENT_ID}&locationId={LOCATION_ID}", - json=PRIORITY_RESPONSE, - ) - return aioclient_mock + + async def get_locations(self: Lyric) -> None: + self._locations = [ + LyricLocation(self._client, location) for location in LOCATIONS_RESPONSE + ] + self._locations_dict = { + location.location_id: location for location in self._locations + } + + async def get_thermostat_rooms( + self: Lyric, location_id: str, device_id: str + ) -> None: + priority = LyricPriority(PRIORITY_RESPONSE) + self._priorities_dict[priority.device_id] = priority + self._rooms_dict[priority.device_id] = { + room.id: room for room in priority.current_priority.rooms + } + + with ( + patch.object(Lyric, "get_locations", get_locations), + patch.object(Lyric, "get_thermostat_rooms", get_thermostat_rooms), + ): + yield async def async_setup_lyric_entry( diff --git a/tests/components/lyric/test_binary_sensor.py b/tests/components/lyric/test_binary_sensor.py index 013790f1488c7..366ffe178e6cd 100644 --- a/tests/components/lyric/test_binary_sensor.py +++ b/tests/components/lyric/test_binary_sensor.py @@ -15,7 +15,6 @@ from .conftest import MAC_ID, async_setup_lyric_entry from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker def _mock_accessory( @@ -44,16 +43,15 @@ async def test_device_pairing_enabled_created( hass: HomeAssistant, entity_registry: er.EntityRegistry, setup_credentials: None, - mock_lyric_api: AiohttpClientMocker, + mock_lyric_api: None, mock_config_entry: MockConfigEntry, ) -> None: """Device Pairing Enabled is created via a real config entry setup. - Exercises the full boundary: real HTTP responses (mocked at the aiohttp - transport level with the actual live payload shape) -> real aiolyric - parsing -> real coordinator -> real entity setup -> registered state. - devicePairingEnabled has no known aiolyric field-name mismatch, so this - passes against the currently-pinned release. + Exercises the full boundary: real aiolyric parsing of the actual live + payload shape -> real coordinator -> real entity setup -> registered + state. devicePairingEnabled has no known aiolyric field-name mismatch, + so this passes against the currently-pinned release. """ await async_setup_lyric_entry(hass, mock_config_entry) @@ -80,15 +78,15 @@ async def test_vacation_hold_created( hass: HomeAssistant, entity_registry: er.EntityRegistry, setup_credentials: None, - mock_lyric_api: AiohttpClientMocker, + mock_lyric_api: None, mock_config_entry: MockConfigEntry, ) -> None: """Vacation Hold should read "on" via a real config entry setup. - The mocked /locations response has vacationHold.Enabled = True (the - real live shape), so this documents the currently-pinned aiolyric bug - rather than hiding it - it fails today for the same reason the real - entity does, and will start passing once the dependency is fixed. + The fixture location has vacationHold.Enabled = True (the real live + shape), so this documents the currently-pinned aiolyric bug rather + than hiding it - it fails today for the same reason the real entity + does, and will start passing once the dependency is fixed. """ await async_setup_lyric_entry(hass, mock_config_entry) @@ -116,12 +114,12 @@ async def test_room_motion_created( hass: HomeAssistant, entity_registry: er.EntityRegistry, setup_credentials: None, - mock_lyric_api: AiohttpClientMocker, + mock_lyric_api: None, mock_config_entry: MockConfigEntry, ) -> None: """Room Motion should be created via a real config entry setup. - The mocked /priority response uses the real live shape ("priority", + The fixture priority data uses the real live shape ("priority", "sensorType"), so under the currently-pinned aiolyric this entity doesn't get created at all - documents the gap instead of hiding it. """ From 5b11c9e7abc29b738bfe996f212dcfad2314ab6b Mon Sep 17 00:00:00 2001 From: clutch2sft <148905f4@opayq.com> Date: Sat, 25 Jul 2026 13:34:03 -0400 Subject: [PATCH 09/13] Trim implementation-narrative docstrings per Copilot re-review Same issue as the earlier DEVICE_ID comment fix: these docstrings restated the fixture/xfail-reason narrative rather than just the behavior under test, which would go stale as aiolyric changes. The xfail reason above each xfail test already documents the field-name mismatch, so the docstring doesn't need to repeat it. --- tests/components/lyric/test_binary_sensor.py | 23 +++----------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/tests/components/lyric/test_binary_sensor.py b/tests/components/lyric/test_binary_sensor.py index 366ffe178e6cd..02468adbdeba4 100644 --- a/tests/components/lyric/test_binary_sensor.py +++ b/tests/components/lyric/test_binary_sensor.py @@ -46,13 +46,7 @@ async def test_device_pairing_enabled_created( mock_lyric_api: None, mock_config_entry: MockConfigEntry, ) -> None: - """Device Pairing Enabled is created via a real config entry setup. - - Exercises the full boundary: real aiolyric parsing of the actual live - payload shape -> real coordinator -> real entity setup -> registered - state. devicePairingEnabled has no known aiolyric field-name mismatch, - so this passes against the currently-pinned release. - """ + """Device Pairing Enabled is created via a real config entry setup.""" await async_setup_lyric_entry(hass, mock_config_entry) entity_id = entity_registry.async_get_entity_id( @@ -81,13 +75,7 @@ async def test_vacation_hold_created( mock_lyric_api: None, mock_config_entry: MockConfigEntry, ) -> None: - """Vacation Hold should read "on" via a real config entry setup. - - The fixture location has vacationHold.Enabled = True (the real live - shape), so this documents the currently-pinned aiolyric bug rather - than hiding it - it fails today for the same reason the real entity - does, and will start passing once the dependency is fixed. - """ + """Vacation Hold should read "on" via a real config entry setup.""" await async_setup_lyric_entry(hass, mock_config_entry) entity_id = entity_registry.async_get_entity_id( @@ -117,12 +105,7 @@ async def test_room_motion_created( mock_lyric_api: None, mock_config_entry: MockConfigEntry, ) -> None: - """Room Motion should be created via a real config entry setup. - - The fixture priority data uses the real live shape ("priority", - "sensorType"), so under the currently-pinned aiolyric this entity - doesn't get created at all - documents the gap instead of hiding it. - """ + """Room Motion should be created via a real config entry setup.""" await async_setup_lyric_entry(hass, mock_config_entry) entity_id = entity_registry.async_get_entity_id( From 710edce5eca1c31853d595577da5d34dcb96b653 Mon Sep 17 00:00:00 2001 From: clutch2sft Date: Tue, 28 Jul 2026 15:34:36 -0400 Subject: [PATCH 10/13] Address review: JSON fixtures, mealie-style client mock, __init__.py helper - Move LOCATIONS_RESPONSE/PRIORITY_RESPONSE from inline dicts into fixtures/locations.json and fixtures/priority.json. - Patch Lyric where the integration imports it (autospec, matching mealie's client-mocking pattern) instead of patching individual methods on the aiolyric class directly. Real LyricLocation/LyricPriority objects are still built from the fixture JSON and assigned directly to the mock's locations/rooms_dict/priorities_dict, so field-name parsing is still exercised for real - this only changes how the client is substituted, not what gets tested. Bonus: no longer needs a valid OAuth token dance to avoid HTTP, since nothing touches the network at all now. - Move async_setup_lyric_entry out of conftest.py into tests/components/lyric/__init__.py as setup_integration, matching the convention already used by this integration's other test module (it's a helper, not a fixture). --- tests/components/lyric/__init__.py | 12 ++ tests/components/lyric/conftest.py | 122 ++++-------------- .../components/lyric/fixtures/locations.json | 22 ++++ tests/components/lyric/fixtures/priority.json | 26 ++++ tests/components/lyric/test_binary_sensor.py | 15 ++- 5 files changed, 96 insertions(+), 101 deletions(-) create mode 100644 tests/components/lyric/fixtures/locations.json create mode 100644 tests/components/lyric/fixtures/priority.json 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 index c43194f46ee7e..d4cea54ca1232 100644 --- a/tests/components/lyric/conftest.py +++ b/tests/components/lyric/conftest.py @@ -2,9 +2,8 @@ from collections.abc import Generator from time import time -from unittest.mock import patch +from unittest.mock import MagicMock, patch -from aiolyric import Lyric from aiolyric.objects.location import LyricLocation from aiolyric.objects.priority import LyricPriority import pytest @@ -18,73 +17,21 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) CLIENT_ID = "1234" CLIENT_SECRET = "5678" -BASE_URL = "https://api.honeywellhome.com/v2" - -# Real payload shapes captured from a live T9-T10 account with paired -# RCHTSENSOR room accessories. Deliberately using the actual field names -# Resideo returns (e.g. vacationHold.Enabled, accessories[].sensorType), -# not the ones aiolyric happens to read, so tests exercise real parsing -# rather than hiding behind pre-parsed mocks. +# Matches the values baked into fixtures/locations.json and fixtures/priority.json. LOCATION_ID = "35202000168931" -# Use an LCC-prefixed ID so the current coordinator fetches room data. +# LCC-prefixed so the current coordinator fetches room data. DEVICE_ID = "LCC-7f86b153-8480-f111-b78f-6045bdb25006" MAC_ID = "5CFCE1B67035" -LOCATIONS_RESPONSE = [ - { - "locationID": LOCATION_ID, - "name": "Ocala P01", - "devices": [ - { - "vacationHold": {"Enabled": True}, - "scheduleStatus": "Resume", - "settings": {"devicePairingEnabled": True}, - "deviceClass": "Thermostat", - "deviceType": "Thermostat", - "deviceID": DEVICE_ID, - "name": "Ocala", - "macID": MAC_ID, - "units": "Fahrenheit", - "indoorTemperature": 79, - "deviceModel": "T9-T10", - } - ], - "users": [], - } -] - -PRIORITY_RESPONSE = { - "deviceId": MAC_ID, - "priorityStatus": "NoHold", - "priority": { - "priorityType": "PickARoom", - "selectedRooms": [1], - "rooms": [ - { - "id": 1, - "name": "Primary Bedroom", - "avgTemperature": 79, - "avgHumidity": 54, - "overallMotion": False, - "accessories": [ - { - "id": 1, - "sensorType": "IndoorAirSensor", - "temperature": 79, - "status": "Ok", - "detectMotion": True, - } - ], - } - ], - }, -} - @pytest.fixture async def setup_credentials(hass: HomeAssistant) -> None: @@ -114,44 +61,31 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_lyric_api() -> Generator[None]: - """Patch the aiolyric client to build real Location/Priority objects. +def mock_lyric_api() -> Generator[MagicMock]: + """Mock the aiolyric client, backed by real objects parsed from live-shaped fixtures. - Patches Lyric.get_locations/get_thermostat_rooms directly rather than - mocking HTTP responses, so tests exercise real aiolyric parsing (the - same LyricLocation/LyricPriority code reading the actual field names - Resideo returns) without depending on network-mocking machinery. + Patches Lyric where the integration imports it (autospec, like the + mealie client mock) rather than mocking HTTP responses, so tests + exercise real aiolyric parsing - the same LyricLocation/LyricPriority + code reading the actual field names Resideo returns. """ + with patch("homeassistant.components.lyric.Lyric", autospec=True) as mock_lyric_cls: + lyric = mock_lyric_cls.return_value - async def get_locations(self: Lyric) -> None: - self._locations = [ - LyricLocation(self._client, location) for location in LOCATIONS_RESPONSE + locations_json = load_json_array_fixture("locations.json", DOMAIN) + lyric.locations = [ + LyricLocation(MagicMock(), location) for location in locations_json ] - self._locations_dict = { - location.location_id: location for location in self._locations + lyric.locations_dict = { + location.location_id: location for location in lyric.locations } - async def get_thermostat_rooms( - self: Lyric, location_id: str, device_id: str - ) -> None: - priority = LyricPriority(PRIORITY_RESPONSE) - self._priorities_dict[priority.device_id] = priority - self._rooms_dict[priority.device_id] = { - room.id: room for room in priority.current_priority.rooms + priority = LyricPriority(load_json_object_fixture("priority.json", DOMAIN)) + lyric.priorities_dict = {priority.device_id: priority} + lyric.rooms_dict = { + priority.device_id: { + room.id: room for room in priority.current_priority.rooms + } } - with ( - patch.object(Lyric, "get_locations", get_locations), - patch.object(Lyric, "get_thermostat_rooms", get_thermostat_rooms), - ): - yield - - -async def async_setup_lyric_entry( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, -) -> None: - """Set up the mock config entry and wait for it to settle.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + yield lyric diff --git a/tests/components/lyric/fixtures/locations.json b/tests/components/lyric/fixtures/locations.json new file mode 100644 index 0000000000000..516ace0010fee --- /dev/null +++ b/tests/components/lyric/fixtures/locations.json @@ -0,0 +1,22 @@ +[ + { + "locationID": "35202000168931", + "name": "Ocala P01", + "devices": [ + { + "vacationHold": { "Enabled": true }, + "scheduleStatus": "Resume", + "settings": { "devicePairingEnabled": true }, + "deviceClass": "Thermostat", + "deviceType": "Thermostat", + "deviceID": "LCC-7f86b153-8480-f111-b78f-6045bdb25006", + "name": "Ocala", + "macID": "5CFCE1B67035", + "units": "Fahrenheit", + "indoorTemperature": 79, + "deviceModel": "T9-T10" + } + ], + "users": [] + } +] diff --git a/tests/components/lyric/fixtures/priority.json b/tests/components/lyric/fixtures/priority.json new file mode 100644 index 0000000000000..9b127a79df9b1 --- /dev/null +++ b/tests/components/lyric/fixtures/priority.json @@ -0,0 +1,26 @@ +{ + "deviceId": "5CFCE1B67035", + "priorityStatus": "NoHold", + "priority": { + "priorityType": "PickARoom", + "selectedRooms": [1], + "rooms": [ + { + "id": 1, + "name": "Primary Bedroom", + "avgTemperature": 79, + "avgHumidity": 54, + "overallMotion": false, + "accessories": [ + { + "id": 1, + "sensorType": "IndoorAirSensor", + "temperature": 79, + "status": "Ok", + "detectMotion": true + } + ] + } + ] + } +} diff --git a/tests/components/lyric/test_binary_sensor.py b/tests/components/lyric/test_binary_sensor.py index 02468adbdeba4..09c82e6ac1b01 100644 --- a/tests/components/lyric/test_binary_sensor.py +++ b/tests/components/lyric/test_binary_sensor.py @@ -12,7 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import MAC_ID, async_setup_lyric_entry +from . import setup_integration +from .conftest import MAC_ID from tests.common import MockConfigEntry @@ -43,11 +44,11 @@ async def test_device_pairing_enabled_created( hass: HomeAssistant, entity_registry: er.EntityRegistry, setup_credentials: None, - mock_lyric_api: None, + mock_lyric_api: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Device Pairing Enabled is created via a real config entry setup.""" - await async_setup_lyric_entry(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry) entity_id = entity_registry.async_get_entity_id( "binary_sensor", "lyric", f"{MAC_ID}_device_pairing_enabled" @@ -72,11 +73,11 @@ async def test_vacation_hold_created( hass: HomeAssistant, entity_registry: er.EntityRegistry, setup_credentials: None, - mock_lyric_api: None, + mock_lyric_api: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Vacation Hold should read "on" via a real config entry setup.""" - await async_setup_lyric_entry(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry) entity_id = entity_registry.async_get_entity_id( "binary_sensor", "lyric", f"{MAC_ID}_vacation_hold" @@ -102,11 +103,11 @@ async def test_room_motion_created( hass: HomeAssistant, entity_registry: er.EntityRegistry, setup_credentials: None, - mock_lyric_api: None, + mock_lyric_api: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Room Motion should be created via a real config entry setup.""" - await async_setup_lyric_entry(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry) entity_id = entity_registry.async_get_entity_id( "binary_sensor", "lyric", f"{MAC_ID}_room1_acc1_room_motion" From 9325ce69a16736e0d9a40fd288e71db5ca650870 Mon Sep 17 00:00:00 2001 From: clutch2sft Date: Tue, 28 Jul 2026 15:41:04 -0400 Subject: [PATCH 11/13] Narrow this PR to Device Pairing Enabled only joostlek pushed back on shipping xfail-marked entities directly (vacation_hold, room_motion), consistent with abmantis's separate "keep as draft until the dependency lands" stance on the Priority Status sensor PR. Rather than argue two reviewers down independently, split scope: this PR now ships only device_pairing_enabled, which works today against the currently-pinned aiolyric. vacation_hold and room_motion move to a held-back branch (binary-sensor-vacation-hold-room-motion) to resume once timmo001/aiolyric#165 and the vacation-hold-key fix release. - Remove ACCESSORY_BINARY_SENSORS/LyricAccessoryBinarySensor and the vacation_hold DEVICE_BINARY_SENSORS entry from binary_sensor.py, and their strings.json entries - dead code with only one entity left. - Drop the now-unused priority.json fixture and the rooms_dict/ priorities_dict setup in mock_lyric_api; nothing left needs room data. - Replace the three hand-written entity tests (one xfail, two working) with a single snapshot_platform test, and drop the direct value_fn/ suitable_fn unit tests - joostlek's other point on this PR. With only one entity and a suitable_fn that's always True, there's nothing left for those to usefully cover beyond what the snapshot already asserts. --- .../components/lyric/binary_sensor.py | 72 +------- homeassistant/components/lyric/strings.json | 6 - tests/components/lyric/conftest.py | 24 +-- tests/components/lyric/fixtures/priority.json | 26 --- .../lyric/snapshots/test_binary_sensor.ambr | 51 ++++++ tests/components/lyric/test_binary_sensor.py | 154 ++---------------- 6 files changed, 67 insertions(+), 266 deletions(-) delete mode 100644 tests/components/lyric/fixtures/priority.json create mode 100644 tests/components/lyric/snapshots/test_binary_sensor.ambr diff --git a/homeassistant/components/lyric/binary_sensor.py b/homeassistant/components/lyric/binary_sensor.py index 1629794fe1edc..2e33abf8269e5 100644 --- a/homeassistant/components/lyric/binary_sensor.py +++ b/homeassistant/components/lyric/binary_sensor.py @@ -6,10 +6,8 @@ from aiolyric.objects.device import LyricDevice from aiolyric.objects.location import LyricLocation -from aiolyric.objects.priority import LyricAccessory, LyricRoom from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) @@ -18,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import LyricConfigEntry, LyricDataUpdateCoordinator -from .entity import LyricAccessoryEntity, LyricDeviceEntity +from .entity import LyricDeviceEntity @dataclass(frozen=True, kw_only=True) @@ -29,21 +27,7 @@ class LyricBinarySensorEntityDescription(BinarySensorEntityDescription): suitable_fn: Callable[[LyricDevice], bool] -@dataclass(frozen=True, kw_only=True) -class LyricBinarySensorAccessoryEntityDescription(BinarySensorEntityDescription): - """Class describing Honeywell Lyric room sensor binary sensor entities.""" - - value_fn: Callable[[LyricRoom, LyricAccessory], bool] - suitable_fn: Callable[[LyricRoom, LyricAccessory], bool] - - DEVICE_BINARY_SENSORS: list[LyricBinarySensorEntityDescription] = [ - LyricBinarySensorEntityDescription( - key="vacation_hold", - translation_key="vacation_hold", - value_fn=lambda device: device.vacation_hold.enabled, - suitable_fn=lambda device: True, - ), LyricBinarySensorEntityDescription( key="device_pairing_enabled", translation_key="device_pairing_enabled", @@ -53,16 +37,6 @@ class LyricBinarySensorAccessoryEntityDescription(BinarySensorEntityDescription) ), ] -ACCESSORY_BINARY_SENSORS: list[LyricBinarySensorAccessoryEntityDescription] = [ - LyricBinarySensorAccessoryEntityDescription( - key="room_motion", - translation_key="room_motion", - device_class=BinarySensorDeviceClass.MOTION, - value_fn=lambda _, accessory: accessory.detect_motion, - suitable_fn=lambda _, accessory: accessory.type == "IndoorAirSensor", - ), -] - async def async_setup_entry( hass: HomeAssistant, @@ -85,18 +59,6 @@ async def async_setup_entry( if device_binary_sensor.suitable_fn(device) ) - async_add_entities( - LyricAccessoryBinarySensor( - coordinator, binary_sensor, location, device, room, accessory - ) - for location in coordinator.data.locations - for device in location.devices - for room in coordinator.data.rooms_dict.get(device.mac_id, {}).values() - for accessory in room.accessories - for binary_sensor in ACCESSORY_BINARY_SENSORS - if binary_sensor.suitable_fn(room, accessory) - ) - class LyricBinarySensor(LyricDeviceEntity, BinarySensorEntity): """Define a Honeywell Lyric binary sensor.""" @@ -124,35 +86,3 @@ def __init__( def is_on(self) -> bool: """Return true if the condition is met.""" return self.entity_description.value_fn(self.device) - - -class LyricAccessoryBinarySensor(LyricAccessoryEntity, BinarySensorEntity): - """Define a Honeywell Lyric room sensor binary sensor.""" - - entity_description: LyricBinarySensorAccessoryEntityDescription - - def __init__( - self, - coordinator: LyricDataUpdateCoordinator, - description: LyricBinarySensorAccessoryEntityDescription, - location: LyricLocation, - parentDevice: LyricDevice, - room: LyricRoom, - accessory: LyricAccessory, - ) -> None: - """Initialize.""" - super().__init__( - coordinator, - location, - parentDevice, - room, - accessory, - f"{parentDevice.mac_id}_room{room.id}_acc{accessory.id}_{description.key}", - ) - self.entity_description = description - - @property - @override - def is_on(self) -> bool: - """Return true if motion is detected.""" - return self.entity_description.value_fn(self.room, self.accessory) diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index c92c6ff2cac41..a627e0971d967 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -40,12 +40,6 @@ "binary_sensor": { "device_pairing_enabled": { "name": "Device pairing enabled" - }, - "room_motion": { - "name": "Room motion" - }, - "vacation_hold": { - "name": "Vacation hold" } }, "select": { diff --git a/tests/components/lyric/conftest.py b/tests/components/lyric/conftest.py index d4cea54ca1232..67939d07c040d 100644 --- a/tests/components/lyric/conftest.py +++ b/tests/components/lyric/conftest.py @@ -5,7 +5,6 @@ from unittest.mock import MagicMock, patch from aiolyric.objects.location import LyricLocation -from aiolyric.objects.priority import LyricPriority import pytest from homeassistant.components.application_credentials import ( @@ -17,18 +16,13 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import ( - MockConfigEntry, - load_json_array_fixture, - load_json_object_fixture, -) +from tests.common import MockConfigEntry, load_json_array_fixture CLIENT_ID = "1234" CLIENT_SECRET = "5678" -# Matches the values baked into fixtures/locations.json and fixtures/priority.json. +# Matches the values baked into fixtures/locations.json. LOCATION_ID = "35202000168931" -# LCC-prefixed so the current coordinator fetches room data. DEVICE_ID = "LCC-7f86b153-8480-f111-b78f-6045bdb25006" MAC_ID = "5CFCE1B67035" @@ -62,12 +56,12 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture def mock_lyric_api() -> Generator[MagicMock]: - """Mock the aiolyric client, backed by real objects parsed from live-shaped fixtures. + """Mock the aiolyric client, backed by a real Location parsed from a live-shaped fixture. Patches Lyric where the integration imports it (autospec, like the mealie client mock) rather than mocking HTTP responses, so tests - exercise real aiolyric parsing - the same LyricLocation/LyricPriority - code reading the actual field names Resideo returns. + exercise real aiolyric parsing - the same LyricLocation code reading + the actual field names Resideo returns. """ with patch("homeassistant.components.lyric.Lyric", autospec=True) as mock_lyric_cls: lyric = mock_lyric_cls.return_value @@ -80,12 +74,4 @@ def mock_lyric_api() -> Generator[MagicMock]: location.location_id: location for location in lyric.locations } - priority = LyricPriority(load_json_object_fixture("priority.json", DOMAIN)) - lyric.priorities_dict = {priority.device_id: priority} - lyric.rooms_dict = { - priority.device_id: { - room.id: room for room in priority.current_priority.rooms - } - } - yield lyric diff --git a/tests/components/lyric/fixtures/priority.json b/tests/components/lyric/fixtures/priority.json deleted file mode 100644 index 9b127a79df9b1..0000000000000 --- a/tests/components/lyric/fixtures/priority.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "deviceId": "5CFCE1B67035", - "priorityStatus": "NoHold", - "priority": { - "priorityType": "PickARoom", - "selectedRooms": [1], - "rooms": [ - { - "id": 1, - "name": "Primary Bedroom", - "avgTemperature": 79, - "avgHumidity": 54, - "overallMotion": false, - "accessories": [ - { - "id": 1, - "sensorType": "IndoorAirSensor", - "temperature": 79, - "status": "Ok", - "detectMotion": true - } - ] - } - ] - } -} diff --git a/tests/components/lyric/snapshots/test_binary_sensor.ambr b/tests/components/lyric/snapshots/test_binary_sensor.ambr new file mode 100644 index 0000000000000..25051f05620e3 --- /dev/null +++ b/tests/components/lyric/snapshots/test_binary_sensor.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_binary_sensor[binary_sensor.ocala_thermostat_device_pairing_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.ocala_thermostat_device_pairing_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Device pairing enabled', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Device pairing enabled', + 'platform': 'lyric', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'device_pairing_enabled', + 'unique_id': '5CFCE1B67035_device_pairing_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.ocala_thermostat_device_pairing_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Ocala Thermostat Device pairing enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.ocala_thermostat_device_pairing_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/lyric/test_binary_sensor.py b/tests/components/lyric/test_binary_sensor.py index 09c82e6ac1b01..4695390462cf1 100644 --- a/tests/components/lyric/test_binary_sensor.py +++ b/tests/components/lyric/test_binary_sensor.py @@ -1,162 +1,28 @@ """Tests for the Honeywell Lyric binary sensor platform.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch -import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.lyric.binary_sensor import ( - ACCESSORY_BINARY_SENSORS, - DEVICE_BINARY_SENSORS, -) -from homeassistant.const import EntityCategory +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 MAC_ID -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform -def _mock_accessory( - accessory_id: int, accessory_type: str, detect_motion: bool = False -) -> MagicMock: - accessory = MagicMock() - accessory.id = accessory_id - accessory.type = accessory_type - accessory.detect_motion = detect_motion - return accessory - - -def _mock_device( - mac_id: str = "AABBCC", - vacation_enabled: bool = False, - pairing_enabled: bool = True, -) -> MagicMock: - device = MagicMock() - device.mac_id = mac_id - device.vacation_hold.enabled = vacation_enabled - device.settings.device_pairing_enabled = pairing_enabled - return device - - -async def test_device_pairing_enabled_created( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - setup_credentials: None, - mock_lyric_api: MagicMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Device Pairing Enabled is created via a real config entry setup.""" - await setup_integration(hass, mock_config_entry) - - entity_id = entity_registry.async_get_entity_id( - "binary_sensor", "lyric", f"{MAC_ID}_device_pairing_enabled" - ) - assert entity_id - state = hass.states.get(entity_id) - assert state - assert state.state == "on" - - -@pytest.mark.xfail( - strict=True, - reason=( - "aiolyric 2.1.1's VacationHold.enabled reads JSON key 'enabled', but " - "Resideo's live API returns 'Enabled' (capital E). Fixed upstream in " - "clutch2sft/aiolyric#fix-vacation-hold-key; once that's released and " - "the manifest pin is bumped, this will start passing for real and " - "this marker must be removed." - ), -) -async def test_vacation_hold_created( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - setup_credentials: None, - mock_lyric_api: MagicMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Vacation Hold should read "on" via a real config entry setup.""" - await setup_integration(hass, mock_config_entry) - - entity_id = entity_registry.async_get_entity_id( - "binary_sensor", "lyric", f"{MAC_ID}_vacation_hold" - ) - assert entity_id - state = hass.states.get(entity_id) - assert state - assert state.state == "on" - - -@pytest.mark.xfail( - strict=True, - reason=( - "aiolyric 2.1.1's LyricPriority.current_priority reads JSON key " - "'currentPriority' and LyricAccessory.type reads 'type', but " - "Resideo's live API returns 'priority' and 'sensorType'. Fixed " - "upstream in timmo001/aiolyric#165; once that's released and the " - "manifest pin is bumped, this will start passing for real and this " - "marker must be removed." - ), -) -async def test_room_motion_created( +async def test_binary_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, setup_credentials: None, mock_lyric_api: MagicMock, mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: - """Room Motion should be created via a real config entry setup.""" - await setup_integration(hass, mock_config_entry) - - entity_id = entity_registry.async_get_entity_id( - "binary_sensor", "lyric", f"{MAC_ID}_room1_acc1_room_motion" - ) - assert entity_id - state = hass.states.get(entity_id) - assert state - assert state.state == "on" - - -def test_vacation_hold_value_fn() -> None: - """Vacation Hold reflects device.vacation_hold.enabled.""" - description = next(d for d in DEVICE_BINARY_SENSORS if d.key == "vacation_hold") - assert description.value_fn(_mock_device(vacation_enabled=True)) is True - assert description.value_fn(_mock_device(vacation_enabled=False)) is False - - -def test_device_pairing_enabled_value_fn() -> None: - """Device Pairing Enabled reflects device.settings.device_pairing_enabled.""" - description = next( - d for d in DEVICE_BINARY_SENSORS if d.key == "device_pairing_enabled" - ) - assert description.value_fn(_mock_device(pairing_enabled=True)) is True - assert description.value_fn(_mock_device(pairing_enabled=False)) is False - - -def test_device_pairing_enabled_is_diagnostic() -> None: - """Device Pairing Enabled is diagnostic; Vacation Hold is not.""" - pairing = next( - d for d in DEVICE_BINARY_SENSORS if d.key == "device_pairing_enabled" - ) - vacation = next(d for d in DEVICE_BINARY_SENSORS if d.key == "vacation_hold") - assert pairing.entity_category is EntityCategory.DIAGNOSTIC - assert vacation.entity_category is None - - -def test_room_motion_suitable_fn_filters_by_accessory_type() -> None: - """Room Motion only applies to IndoorAirSensor accessories.""" - description = ACCESSORY_BINARY_SENSORS[0] - sensor_accessory = _mock_accessory(1, "IndoorAirSensor") - thermostat_accessory = _mock_accessory(0, "Thermostat") - assert description.suitable_fn(None, sensor_accessory) is True - assert description.suitable_fn(None, thermostat_accessory) is False - + """Test the Lyric binary sensor platform via a real config entry setup.""" + with patch("homeassistant.components.lyric.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) -def test_room_motion_value_fn() -> None: - """Room Motion reflects accessory.detect_motion.""" - description = ACCESSORY_BINARY_SENSORS[0] - motion_detected = _mock_accessory(1, "IndoorAirSensor", detect_motion=True) - no_motion = _mock_accessory(1, "IndoorAirSensor", detect_motion=False) - assert description.value_fn(None, motion_detected) is True - assert description.value_fn(None, no_motion) is False + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From d0c6e8b7731fa930556339825a7ac460cad6d41d Mon Sep 17 00:00:00 2001 From: clutch2sft Date: Tue, 28 Jul 2026 16:57:21 -0400 Subject: [PATCH 12/13] Fix locationID fixture/constant type to match the documented API contract Resideo's own API docs document locationID as an Integer, and aiolyric's get_thermostat_rooms already types it int, but this fixture quoted it as a string - inherited from early in this session before checking the real docs. No behavior change (the value is only ever interpolated into a URL), but the fixture now matches reality. --- tests/components/lyric/conftest.py | 2 +- tests/components/lyric/fixtures/locations.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/lyric/conftest.py b/tests/components/lyric/conftest.py index 67939d07c040d..05f07cb4070a7 100644 --- a/tests/components/lyric/conftest.py +++ b/tests/components/lyric/conftest.py @@ -22,7 +22,7 @@ CLIENT_SECRET = "5678" # Matches the values baked into fixtures/locations.json. -LOCATION_ID = "35202000168931" +LOCATION_ID = 35202000168931 DEVICE_ID = "LCC-7f86b153-8480-f111-b78f-6045bdb25006" MAC_ID = "5CFCE1B67035" diff --git a/tests/components/lyric/fixtures/locations.json b/tests/components/lyric/fixtures/locations.json index 516ace0010fee..0d3f11736e2ab 100644 --- a/tests/components/lyric/fixtures/locations.json +++ b/tests/components/lyric/fixtures/locations.json @@ -1,6 +1,6 @@ [ { - "locationID": "35202000168931", + "locationID": 35202000168931, "name": "Ocala P01", "devices": [ { From 2e1cda03de8806e6a1f357965e83d63bdb523093 Mon Sep 17 00:00:00 2001 From: clutch2sft Date: Tue, 28 Jul 2026 17:37:48 -0400 Subject: [PATCH 13/13] Address Copilot's low-confidence review comments - Condense mock_lyric_api's docstring to a single purpose-focused sentence; drop the patch-location/Mealie-comparison narration. - Apply setup_credentials/mock_lyric_api via usefixtures in test_binary_sensor instead of injecting them as unused parameters. --- tests/components/lyric/conftest.py | 8 +------- tests/components/lyric/test_binary_sensor.py | 6 +++--- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/tests/components/lyric/conftest.py b/tests/components/lyric/conftest.py index 05f07cb4070a7..014cf059b2e97 100644 --- a/tests/components/lyric/conftest.py +++ b/tests/components/lyric/conftest.py @@ -56,13 +56,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture def mock_lyric_api() -> Generator[MagicMock]: - """Mock the aiolyric client, backed by a real Location parsed from a live-shaped fixture. - - Patches Lyric where the integration imports it (autospec, like the - mealie client mock) rather than mocking HTTP responses, so tests - exercise real aiolyric parsing - the same LyricLocation code reading - the actual field names Resideo returns. - """ + """Mock the aiolyric client, backed by a real Location parsed from a live-shaped fixture.""" with patch("homeassistant.components.lyric.Lyric", autospec=True) as mock_lyric_cls: lyric = mock_lyric_cls.return_value diff --git a/tests/components/lyric/test_binary_sensor.py b/tests/components/lyric/test_binary_sensor.py index 4695390462cf1..974403b7d2556 100644 --- a/tests/components/lyric/test_binary_sensor.py +++ b/tests/components/lyric/test_binary_sensor.py @@ -1,7 +1,8 @@ """Tests for the Honeywell Lyric binary sensor platform.""" -from unittest.mock import MagicMock, patch +from unittest.mock import patch +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform @@ -13,11 +14,10 @@ from tests.common import MockConfigEntry, snapshot_platform +@pytest.mark.usefixtures("setup_credentials", "mock_lyric_api") async def test_binary_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, - setup_credentials: None, - mock_lyric_api: MagicMock, mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: