Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
149 changes: 149 additions & 0 deletions tests/components/lyric/conftest.py
Original file line number Diff line number Diff line change
@@ -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 = {

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.

Put these in a json file in fixtures

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.

Done — LOCATIONS_RESPONSE (and the priority payload, before it moved out entirely, see below) now live in tests/components/lyric/fixtures/*.json, loaded via load_json_array_fixture.

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

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.

this is more a method for __init__.py as it's not a fixture

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.

Right — moved it to tests/components/lyric/init.py as setup_integration, matching what dev's newer tests already do there.

Loading
Loading