diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index 4cdcbe5cfdd005..0766d0a6b0cb54 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -15,7 +15,7 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -189,6 +189,14 @@ async def async_setup_entry( if accessory_sensor.suitable_fn(room, accessory) ) + async_add_entities( + LyricPriorityStatusSensor(coordinator, location, device) + for location in coordinator.data.locations + for device in location.devices + if device.device_class == "Thermostat" + and coordinator.data.rooms_dict.get(device.mac_id) + ) + class LyricSensor(LyricDeviceEntity, SensorEntity): """Define a Honeywell Lyric sensor.""" @@ -258,3 +266,33 @@ def __init__( def native_value(self) -> StateType | datetime: """Return the state.""" return self.entity_description.value_fn(self.room, self.accessory) + + +class LyricPriorityStatusSensor(LyricDeviceEntity, SensorEntity): + """Define a Honeywell Lyric room priority hold status sensor.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_translation_key = "priority_status" + + def __init__( + self, + coordinator: LyricDataUpdateCoordinator, + location: LyricLocation, + device: LyricDevice, + ) -> None: + """Initialize.""" + super().__init__( + coordinator, + location, + device, + f"{device.mac_id}_priority_status", + ) + + @property + @override + def native_value(self) -> StateType: + """Return the state.""" + priority = self.coordinator.data.priorities_dict.get(self._mac_id) + if priority is None: + return None + return priority.status diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index b9547c8251475c..3c4f80671dd527 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -61,6 +61,9 @@ "outdoor_temperature": { "name": "Outdoor temperature" }, + "priority_status": { + "name": "Priority status" + }, "room_humidity": { "name": "Room humidity" }, diff --git a/tests/components/lyric/__init__.py b/tests/components/lyric/__init__.py index 794c6bf1ba095b..4b4ee70008487a 100644 --- a/tests/components/lyric/__init__.py +++ b/tests/components/lyric/__init__.py @@ -1 +1,13 @@ """Tests for the Honeywell Lyric integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the Lyric integration for tests.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/lyric/conftest.py b/tests/components/lyric/conftest.py new file mode 100644 index 00000000000000..8598c8cd2e4509 --- /dev/null +++ b/tests/components/lyric/conftest.py @@ -0,0 +1,88 @@ +"""Fixtures for the Honeywell Lyric integration tests.""" + +from collections.abc import Generator +from time import time +from unittest.mock import MagicMock, patch + +from aiolyric.objects.location import LyricLocation +import pytest + +from homeassistant.components.application_credentials import ( + DOMAIN as APPLICATION_CREDENTIALS_DOMAIN, + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.lyric.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_json_array_fixture + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + +# Matches the values baked into fixtures/locations.json. +LOCATION_ID = 35202000168931 +DEVICE_ID = "LCC-7f86b153-8480-f111-b78f-6045bdb25006" +MAC_ID = "5CFCE1B67035" +# Second device: has room data but no priority data yet, exercising the +# defensive "no priority entry" branch of LyricPriorityStatusSensor. +NO_PRIORITY_DATA_MAC_ID = "5CFCE1B67036" + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """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) + ) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return an already-authenticated Lyric config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": time() + 3600, + "token_type": "Bearer", + }, + }, + ) + + +@pytest.fixture +def mock_lyric_api() -> Generator[MagicMock]: + """Mock the aiolyric client, backed by a real Location and directly-set priority data. + + rooms_dict/priorities_dict are set directly rather than parsed from + real LyricPriority objects: LyricPriority.status/current_priority + currently read the wrong JSON keys (aiolyric#165), and that parsing + bug is aiolyric's own test suite's concern, not this integration's. + The second device has room data but no priority entry, exercising + both branches of LyricPriorityStatusSensor.native_value. + """ + with patch("homeassistant.components.lyric.Lyric", autospec=True) as mock_lyric_cls: + lyric = mock_lyric_cls.return_value + + locations_json = load_json_array_fixture("locations.json", DOMAIN) + lyric.locations = [ + LyricLocation(MagicMock(), location) for location in locations_json + ] + lyric.locations_dict = { + location.location_id: location for location in lyric.locations + } + + lyric.priorities_dict = {MAC_ID: MagicMock(status="NoHold")} + lyric.rooms_dict = { + MAC_ID: {1: MagicMock()}, + NO_PRIORITY_DATA_MAC_ID: {1: MagicMock()}, + } + + yield lyric diff --git a/tests/components/lyric/fixtures/locations.json b/tests/components/lyric/fixtures/locations.json new file mode 100644 index 00000000000000..efc5cdfea5e1ca --- /dev/null +++ b/tests/components/lyric/fixtures/locations.json @@ -0,0 +1,35 @@ +[ + { + "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" + }, + { + "vacationHold": { "Enabled": false }, + "scheduleStatus": "Resume", + "settings": { "devicePairingEnabled": true }, + "deviceClass": "Thermostat", + "deviceType": "Thermostat", + "deviceID": "LCC-8f86b153-8480-f111-b78f-6045bdb25007", + "name": "Bedroom", + "macID": "5CFCE1B67036", + "units": "Fahrenheit", + "indoorTemperature": 72, + "deviceModel": "T9-T10" + } + ], + "users": [] + } +] diff --git a/tests/components/lyric/snapshots/test_sensor.ambr b/tests/components/lyric/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..6f73213d5be2c6 --- /dev/null +++ b/tests/components/lyric/snapshots/test_sensor.ambr @@ -0,0 +1,217 @@ +# serializer version: 1 +# name: test_sensor[sensor.bedroom_thermostat_indoor_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_thermostat_indoor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Indoor temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Indoor temperature', + 'platform': 'lyric', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indoor_temperature', + 'unique_id': '5CFCE1B67036_indoor_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.bedroom_thermostat_indoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Bedroom Thermostat Indoor temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bedroom_thermostat_indoor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.2222222222222', + }) +# --- +# name: test_sensor[sensor.bedroom_thermostat_priority_status-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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bedroom_thermostat_priority_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Priority status', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Priority status', + 'platform': 'lyric', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'priority_status', + 'unique_id': '5CFCE1B67036_priority_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.bedroom_thermostat_priority_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom Thermostat Priority status', + }), + 'context': , + 'entity_id': 'sensor.bedroom_thermostat_priority_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.ocala_thermostat_indoor_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ocala_thermostat_indoor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Indoor temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Indoor temperature', + 'platform': 'lyric', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indoor_temperature', + 'unique_id': '5CFCE1B67035_indoor_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.ocala_thermostat_indoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Ocala Thermostat Indoor temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.ocala_thermostat_indoor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.1111111111111', + }) +# --- +# name: test_sensor[sensor.ocala_thermostat_priority_status-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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ocala_thermostat_priority_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Priority status', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Priority status', + 'platform': 'lyric', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'priority_status', + 'unique_id': '5CFCE1B67035_priority_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.ocala_thermostat_priority_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Ocala Thermostat Priority status', + }), + 'context': , + 'entity_id': 'sensor.ocala_thermostat_priority_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'NoHold', + }) +# --- diff --git a/tests/components/lyric/test_sensor.py b/tests/components/lyric/test_sensor.py index 7f74b5e8d63ea7..8243eb09be5d9e 100644 --- a/tests/components/lyric/test_sensor.py +++ b/tests/components/lyric/test_sensor.py @@ -1,8 +1,19 @@ """Tests for the Honeywell Lyric sensor platform.""" from datetime import datetime +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.lyric.sensor import get_datetime_from_future_time +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform def test_get_datetime_from_future_time_none() -> None: @@ -19,3 +30,17 @@ def test_get_datetime_from_future_time_valid() -> None: """Test that a valid time string returns a datetime.""" result = get_datetime_from_future_time("13:30:00") assert isinstance(result, datetime) + + +@pytest.mark.usefixtures("setup_credentials", "mock_lyric_api") +async def test_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Lyric sensor platform via a real config entry setup.""" + with patch("homeassistant.components.lyric.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)