diff --git a/homeassistant/components/touchline_sl/__init__.py b/homeassistant/components/touchline_sl/__init__.py index ba1da06ed5a..6f81b5b26e0 100644 --- a/homeassistant/components/touchline_sl/__init__.py +++ b/homeassistant/components/touchline_sl/__init__.py @@ -13,7 +13,7 @@ from homeassistant.helpers import device_registry as dr from .const import DOMAIN from .coordinator import TouchlineSLConfigEntry, TouchlineSLModuleCoordinator -PLATFORMS: list[Platform] = [Platform.CLIMATE] +PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: TouchlineSLConfigEntry) -> bool: diff --git a/homeassistant/components/touchline_sl/sensor.py b/homeassistant/components/touchline_sl/sensor.py new file mode 100644 index 00000000000..7d520ff51ce --- /dev/null +++ b/homeassistant/components/touchline_sl/sensor.py @@ -0,0 +1,87 @@ +"""Roth Touchline SL sensor platform.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from pytouchlinesl import Zone + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import TouchlineSLConfigEntry, TouchlineSLModuleCoordinator +from .entity import TouchlineSLZoneEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class TouchlineSLSensorEntityDescription(SensorEntityDescription): + """Describes a Touchline SL sensor entity.""" + + value_fn: Callable[[Zone], int | None] + exists_fn: Callable[[Zone], bool] = lambda _: True + + +SENSORS: tuple[TouchlineSLSensorEntityDescription, ...] = ( + TouchlineSLSensorEntityDescription( + key="battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + value_fn=lambda zone: zone.battery_level, + exists_fn=lambda zone: zone.battery_level is not None, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TouchlineSLConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Touchline SL sensors.""" + coordinators = entry.runtime_data + async_add_entities( + TouchlineSLSensor( + coordinator=coordinator, zone_id=zone_id, description=description + ) + for coordinator in coordinators + for zone_id in coordinator.data.zones + for description in SENSORS + if description.exists_fn(coordinator.data.zones[zone_id]) + ) + + +class TouchlineSLSensor(TouchlineSLZoneEntity, SensorEntity): + """A sensor entity for a Roth Touchline SL zone.""" + + entity_description: TouchlineSLSensorEntityDescription + + def __init__( + self, + coordinator: TouchlineSLModuleCoordinator, + zone_id: int, + description: TouchlineSLSensorEntityDescription, + ) -> None: + """Initialise a Touchline SL sensor.""" + super().__init__(coordinator, zone_id) + self.entity_description = description + self._attr_unique_id = ( + f"module-{coordinator.data.module.id}-zone-{zone_id}-{description.key}" + ) + + @property + def native_value(self) -> int | None: + """Return the sensor value.""" + return self.entity_description.value_fn(self.zone) diff --git a/tests/components/touchline_sl/test_sensor.py b/tests/components/touchline_sl/test_sensor.py new file mode 100644 index 00000000000..046b5171d1d --- /dev/null +++ b/tests/components/touchline_sl/test_sensor.py @@ -0,0 +1,96 @@ +"""Tests for the Roth Touchline SL sensor platform.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from .conftest import make_mock_module, make_mock_zone + +from tests.common import MockConfigEntry + +BATTERY_ENTITY_ID = "sensor.zone_1_battery" + + +async def test_battery_sensor_with_battery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_touchlinesl_client: MagicMock, +) -> None: + """Test that the battery sensor reports the correct level.""" + zone = make_mock_zone() + zone.battery_level = 85 + module = make_mock_module([zone]) + mock_touchlinesl_client.modules = AsyncMock(return_value=[module]) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(BATTERY_ENTITY_ID) + assert state is not None + assert state.state == "85" + + +async def test_battery_sensor_no_battery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_touchlinesl_client: MagicMock, +) -> None: + """Test that no battery sensor is created for wired zones without a battery.""" + zone = make_mock_zone() + zone.battery_level = None + module = make_mock_module([zone]) + mock_touchlinesl_client.modules = AsyncMock(return_value=[module]) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(BATTERY_ENTITY_ID) is None + + +async def test_battery_sensor_only_created_for_zones_with_battery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_touchlinesl_client: MagicMock, +) -> None: + """Test that battery sensors are only created for wireless zones.""" + wired_zone = make_mock_zone(zone_id=1, name="Wired Zone") + wired_zone.battery_level = None + wireless_zone = make_mock_zone(zone_id=2, name="Wireless Zone") + wireless_zone.battery_level = 75 + + module = make_mock_module([wired_zone, wireless_zone]) + mock_touchlinesl_client.modules = AsyncMock(return_value=[module]) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.wired_zone_battery") is None + assert hass.states.get("sensor.wireless_zone_battery") is not None + + +@pytest.mark.parametrize("alarm", ["no_communication", "sensor_damaged"]) +async def test_battery_sensor_unavailable_on_alarm( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_touchlinesl_client: MagicMock, + alarm: str, +) -> None: + """Test that the battery sensor is unavailable when the zone has an alarm.""" + zone = make_mock_zone(alarm=alarm) + zone.battery_level = 50 + module = make_mock_module([zone]) + mock_touchlinesl_client.modules = AsyncMock(return_value=[module]) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(BATTERY_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE