Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion homeassistant/components/lyric/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
158 changes: 158 additions & 0 deletions homeassistant/components/lyric/binary_sensor.py
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,

Copy link
Copy Markdown
Contributor Author

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).

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",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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)
11 changes: 11 additions & 0 deletions homeassistant/components/lyric/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@
}
},
"entity": {
"binary_sensor": {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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",
Expand Down
119 changes: 119 additions & 0 deletions tests/components/lyric/test_binary_sensor.py
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

View workflow job for this annotation

GitHub Actions / Check pylint on tests

W7420: Do not call a platform's `async_setup_entry` directly from tests; use `await hass.config_entries.async_setup(entry.entry_id)` instead (home-assistant-tests-direct-platform-async-setup-entry)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't test all these things directly. Instead use snapshot_platform to fixate all the entities at least once, and for things like suitable_fn, you can change the source data and see that it's not created

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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
Loading