-
-
Notifications
You must be signed in to change notification settings - Fork 38.4k
Add Device Pairing Enabled binary sensor to Lyric integration #177062
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
6ba33ba
950804d
87dad02
31dd000
7a25c55
d9569d4
bd4198c
fb2ffaf
5b11c9e
710edce
9325ce6
d0c6e8b
2e1cda0
26e0d7a
7b5b5b8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,158 @@ | ||
| """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.const import EntityCategory | ||
| from homeassistant.core import HomeAssistant | ||
| from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback | ||
|
|
||
| from .coordinator import LyricConfigEntry, LyricDataUpdateCoordinator | ||
| 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) | ||
| 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", | ||
| entity_category=EntityCategory.DIAGNOSTIC, | ||
| value_fn=lambda device: device.settings.device_pairing_enabled, | ||
| suitable_fn=lambda device: True, | ||
| ), | ||
| ] | ||
|
|
||
| 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", | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct, and already tracked — this needs timmo001/aiolyric#165 (the currentPriority/priority and type/sensorType field fix) merged and released before Room Motion populates for real accounts. Filing it as a dependency rather than blocking on it here, since #165 is a separate project on its own release cycle, and this PR is still correct/complete for what core can control. Once #165 releases, the follow-up is a one-line manifest.json version bump — tracked separately. |
||
| ), | ||
| ] | ||
|
|
||
|
|
||
| async def async_setup_entry( | ||
| hass: HomeAssistant, | ||
| entry: LyricConfigEntry, | ||
| async_add_entities: AddConfigEntryEntitiesCallback, | ||
| ) -> None: | ||
|
Comment on lines
+41
to
+45
|
||
| """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 | ||
| ) | ||
| 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.""" | ||
|
|
||
| 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.""" | ||
|
|
||
| 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) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,6 +37,17 @@ | |
| } | ||
| }, | ||
| "entity": { | ||
| "binary_sensor": { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Link to documentation pull request: home-assistant/home-assistant.io#46986 |
||
| "device_pairing_enabled": { | ||
| "name": "Device pairing enabled" | ||
| }, | ||
| "room_motion": { | ||
| "name": "Room motion" | ||
| }, | ||
| "vacation_hold": { | ||
| "name": "Vacation hold" | ||
| } | ||
| }, | ||
| "select": { | ||
| "room_priority": { | ||
| "name": "Room priority", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
Check warning on line 67 in tests/components/lyric/test_binary_sensor.py
|
||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fair — the previous version built entities from MagicMocks with already-parsed properties set directly, so it would pass regardless of real payload/key mismatches. Reworked to construct real LyricDevice/LyricRoom/LyricAccessory objects from realistic payloads and assert through entity.is_on, exercising the actual aiolyric parsing boundary. For vacation_hold and room_motion specifically, the currently-pinned aiolyric==2.1.1 still has the exact key mismatches this PR's description already discloses (vacationHold.Enabled vs .enabled, sensorType vs type) — so those two are now xfail(strict=True) against the real live payload shape: they fail today for the same reason the real entities don't populate correctly yet, and strict=True means they'll turn into hard failures (forcing the marker's removal) the moment the companion aiolyric fixes are released and the manifest pin is bumped, rather than silently staying green. device_pairing_enabled has no known mismatch, so it gets a real end-to-end passing test today. |
||
|
|
||
| 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") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We don't test all these things directly. Instead use
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See above |
||
| 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 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed — good catch. vacationHold.Enabled (capital E) is what Resideo actually returns; I have a live captured payload showing it. Fixed in timmo001/aiolyric here: clutch2sft/aiolyric#170 (branch fix-vacation-hold-key).