diff --git a/homeassistant/components/senz/__init__.py b/homeassistant/components/senz/__init__.py index c3238f7355f..4d54584bebd 100644 --- a/homeassistant/components/senz/__init__.py +++ b/homeassistant/components/senz/__init__.py @@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -PLATFORMS = [Platform.CLIMATE] +PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] type SENZDataUpdateCoordinator = DataUpdateCoordinator[dict[str, Thermostat]] diff --git a/homeassistant/components/senz/climate.py b/homeassistant/components/senz/climate.py index 48eeee54974..f303f4b147c 100644 --- a/homeassistant/components/senz/climate.py +++ b/homeassistant/components/senz/climate.py @@ -35,7 +35,7 @@ async def async_setup_entry( ) -class SENZClimate(CoordinatorEntity, ClimateEntity): +class SENZClimate(CoordinatorEntity[SENZDataUpdateCoordinator], ClimateEntity): """Representation of a SENZ climate entity.""" _attr_temperature_unit = UnitOfTemperature.CELSIUS diff --git a/homeassistant/components/senz/sensor.py b/homeassistant/components/senz/sensor.py new file mode 100644 index 00000000000..430c959b047 --- /dev/null +++ b/homeassistant/components/senz/sensor.py @@ -0,0 +1,93 @@ +"""nVent RAYCHEM SENZ sensor platform.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from aiosenz import Thermostat + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import SENZDataUpdateCoordinator +from .const import DOMAIN + + +@dataclass(kw_only=True, frozen=True) +class SenzSensorDescription(SensorEntityDescription): + """Describes SENZ sensor entity.""" + + value_fn: Callable[[Thermostat], str | int | float | None] + + +SENSORS: tuple[SenzSensorDescription, ...] = ( + SenzSensorDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + value_fn=lambda data: data.current_temperatue, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the SENZ sensor entities from a config entry.""" + coordinator: SENZDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + SENZSensor(thermostat, coordinator, description) + for description in SENSORS + for thermostat in coordinator.data.values() + ) + + +class SENZSensor(CoordinatorEntity[SENZDataUpdateCoordinator], SensorEntity): + """Representation of a SENZ sensor entity.""" + + entity_description: SenzSensorDescription + _attr_has_entity_name = True + + def __init__( + self, + thermostat: Thermostat, + coordinator: SENZDataUpdateCoordinator, + description: SenzSensorDescription, + ) -> None: + """Init SENZ sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._thermostat = thermostat + self._attr_unique_id = f"{thermostat.serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, thermostat.serial_number)}, + manufacturer="nVent Raychem", + model="SENZ WIFI", + name=thermostat.name, + serial_number=thermostat.serial_number, + ) + + @property + def available(self) -> bool: + """Return True if the thermostat is available.""" + return super().available and self._thermostat.online + + @property + def native_value(self) -> str | float | int | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self._thermostat) diff --git a/tests/components/senz/__init__.py b/tests/components/senz/__init__.py index 1428c18be94..66d5b71467b 100644 --- a/tests/components/senz/__init__.py +++ b/tests/components/senz/__init__.py @@ -1 +1,12 @@ """Tests for the SENZ integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Set up the Senz integration in Home Assistant.""" + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/senz/conftest.py b/tests/components/senz/conftest.py new file mode 100644 index 00000000000..60a1bfd0c47 --- /dev/null +++ b/tests/components/senz/conftest.py @@ -0,0 +1,111 @@ +"""Fixtures for Senz testing.""" + +from collections.abc import Generator +import time +from typing import Any +from unittest.mock import MagicMock, patch + +from aiosenz import Account, Thermostat +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.senz.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import CLIENT_ID, CLIENT_SECRET + +from tests.common import ( + MockConfigEntry, + async_load_json_array_fixture, + async_load_json_object_fixture, +) + + +@pytest.fixture(scope="package") +def load_device_file() -> str: + """Fixture for loading device file.""" + return "thermostats.json" + + +@pytest.fixture +async def device_fixture( + hass: HomeAssistant, load_device_file: str +) -> list[dict[str, Any]]: + """Fixture for device.""" + return await async_load_json_array_fixture(hass, load_device_file, DOMAIN) + + +@pytest.fixture(scope="package") +def load_account_file() -> str: + """Fixture for loading account file.""" + return "account.json" + + +@pytest.fixture +async def account_fixture( + hass: HomeAssistant, load_account_file: str +) -> dict[str, Any]: + """Fixture for device.""" + return await async_load_json_object_fixture(hass, load_account_file, DOMAIN) + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> float: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant, expires_at: float) -> MockConfigEntry: + """Return the default mocked config entry.""" + config_entry = MockConfigEntry( + minor_version=1, + domain=DOMAIN, + title="Senz test", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "Fake_token", + "expires_in": 86399, + "refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f", + "token_type": "Bearer", + "expires_at": expires_at, + }, + }, + entry_id="senz_test", + ) + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.fixture +def mock_senz_client(account_fixture, device_fixture) -> Generator[MagicMock]: + """Mock thermostat data.""" + with patch("homeassistant.components.senz.SENZAPI", autospec=True) as mock_senz: + client = mock_senz.return_value + + client.get_account.return_value = Account(account_fixture) + client.get_thermostats.return_value = [ + Thermostat(device, None) for device in device_fixture + ] + + yield client + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential( + CLIENT_ID, + CLIENT_SECRET, + ), + DOMAIN, + ) diff --git a/tests/components/senz/const.py b/tests/components/senz/const.py new file mode 100644 index 00000000000..19dfac6238b --- /dev/null +++ b/tests/components/senz/const.py @@ -0,0 +1,4 @@ +"""Constants for the senz component tests.""" + +CLIENT_ID = "test_client_id" +CLIENT_SECRET = "test_client_secret" diff --git a/tests/components/senz/fixtures/account.json b/tests/components/senz/fixtures/account.json new file mode 100644 index 00000000000..958017665fa --- /dev/null +++ b/tests/components/senz/fixtures/account.json @@ -0,0 +1,5 @@ +{ + "userName": "test_user", + "temperatureScale": "celsius", + "language": "en" +} diff --git a/tests/components/senz/fixtures/thermostats.json b/tests/components/senz/fixtures/thermostats.json new file mode 100644 index 00000000000..cd7ca94cb71 --- /dev/null +++ b/tests/components/senz/fixtures/thermostats.json @@ -0,0 +1,24 @@ +[ + { + "serialNumber": "1001", + "name": "Test room 1", + "currentTemperature": 1845, + "online": true, + "isHeating": true, + "setPointTemperature": 1900, + "holdUntil": null, + "mode": 5, + "errorState": null + }, + { + "serialNumber": "1002", + "name": "Test room 2", + "currentTemperature": 930, + "online": true, + "isHeating": false, + "setPointTemperature": 600, + "holdUntil": null, + "mode": 1, + "errorState": null + } +] diff --git a/tests/components/senz/snapshots/test_sensor.ambr b/tests/components/senz/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..8e35f51573c --- /dev/null +++ b/tests/components/senz/snapshots/test_sensor.ambr @@ -0,0 +1,113 @@ +# serializer version: 1 +# name: test_sensor_snapshot[sensor.test_room_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_room_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'senz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1001_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_room_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test room 1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_room_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.45', + }) +# --- +# name: test_sensor_snapshot[sensor.test_room_2_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_room_2_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'senz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1002_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_room_2_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test room 2 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_room_2_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.3', + }) +# --- diff --git a/tests/components/senz/test_config_flow.py b/tests/components/senz/test_config_flow.py index 4faf8775a62..b9e28115c46 100644 --- a/tests/components/senz/test_config_flow.py +++ b/tests/components/senz/test_config_flow.py @@ -15,12 +15,11 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.setup import async_setup_component +from .const import CLIENT_ID, CLIENT_SECRET + from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -CLIENT_ID = "1234" -CLIENT_SECRET = "5678" - @pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( diff --git a/tests/components/senz/test_init.py b/tests/components/senz/test_init.py new file mode 100644 index 00000000000..9ca28484aa9 --- /dev/null +++ b/tests/components/senz/test_init.py @@ -0,0 +1,27 @@ +"""Test init of senz integration.""" + +from unittest.mock import MagicMock + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_senz_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) + entry = mock_config_entry + + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/senz/test_sensor.py b/tests/components/senz/test_sensor.py new file mode 100644 index 00000000000..2a46e674ea8 --- /dev/null +++ b/tests/components/senz/test_sensor.py @@ -0,0 +1,29 @@ +"""Test Senz sensor platform.""" + +from unittest.mock import MagicMock, patch + +from syrupy.assertion import SnapshotAssertion + +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 + + +async def test_sensor_snapshot( + hass: HomeAssistant, + mock_senz_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test sensor setup for cloud connection.""" + with patch("homeassistant.components.senz.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + )