From 3eda687d30d3de84a62b3926846c50d976f88c94 Mon Sep 17 00:00:00 2001 From: Robin Lintermann Date: Mon, 11 Aug 2025 17:08:07 +0200 Subject: [PATCH] Smarla integration sensor platform (#145748) --- homeassistant/components/smarla/const.py | 2 +- homeassistant/components/smarla/icons.json | 14 ++ homeassistant/components/smarla/sensor.py | 107 +++++++++ homeassistant/components/smarla/strings.json | 15 ++ tests/components/smarla/conftest.py | 12 + .../smarla/snapshots/test_sensor.ambr | 208 ++++++++++++++++++ tests/components/smarla/test_sensor.py | 85 +++++++ 7 files changed, 442 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/smarla/sensor.py create mode 100644 tests/components/smarla/snapshots/test_sensor.ambr create mode 100644 tests/components/smarla/test_sensor.py diff --git a/homeassistant/components/smarla/const.py b/homeassistant/components/smarla/const.py index f81ccd328bc..fcb64f1e315 100644 --- a/homeassistant/components/smarla/const.py +++ b/homeassistant/components/smarla/const.py @@ -6,7 +6,7 @@ DOMAIN = "smarla" HOST = "https://devices.swing2sleep.de" -PLATFORMS = [Platform.NUMBER, Platform.SWITCH] +PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] DEVICE_MODEL_NAME = "Smarla" MANUFACTURER_NAME = "Swing2Sleep" diff --git a/homeassistant/components/smarla/icons.json b/homeassistant/components/smarla/icons.json index 2ba7404cc35..a72e7e7ea12 100644 --- a/homeassistant/components/smarla/icons.json +++ b/homeassistant/components/smarla/icons.json @@ -9,6 +9,20 @@ "intensity": { "default": "mdi:sine-wave" } + }, + "sensor": { + "amplitude": { + "default": "mdi:sine-wave" + }, + "period": { + "default": "mdi:sine-wave" + }, + "activity": { + "default": "mdi:baby-face" + }, + "swing_count": { + "default": "mdi:counter" + } } } } diff --git a/homeassistant/components/smarla/sensor.py b/homeassistant/components/smarla/sensor.py new file mode 100644 index 00000000000..18bef76e320 --- /dev/null +++ b/homeassistant/components/smarla/sensor.py @@ -0,0 +1,107 @@ +"""Support for the Swing2Sleep Smarla sensor entities.""" + +from dataclasses import dataclass + +from pysmarlaapi.federwiege.classes import Property + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfLength, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FederwiegeConfigEntry +from .entity import SmarlaBaseEntity, SmarlaEntityDescription + + +@dataclass(frozen=True, kw_only=True) +class SmarlaSensorEntityDescription(SmarlaEntityDescription, SensorEntityDescription): + """Class describing Swing2Sleep Smarla sensor entities.""" + + multiple: bool = False + value_pos: int = 0 + + +SENSORS: list[SmarlaSensorEntityDescription] = [ + SmarlaSensorEntityDescription( + key="amplitude", + translation_key="amplitude", + service="analyser", + property="oscillation", + multiple=True, + value_pos=0, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + state_class=SensorStateClass.MEASUREMENT, + ), + SmarlaSensorEntityDescription( + key="period", + translation_key="period", + service="analyser", + property="oscillation", + multiple=True, + value_pos=1, + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + state_class=SensorStateClass.MEASUREMENT, + ), + SmarlaSensorEntityDescription( + key="activity", + translation_key="activity", + service="analyser", + property="activity", + state_class=SensorStateClass.MEASUREMENT, + ), + SmarlaSensorEntityDescription( + key="swing_count", + translation_key="swing_count", + service="analyser", + property="swing_count", + state_class=SensorStateClass.TOTAL_INCREASING, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: FederwiegeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Smarla sensors from config entry.""" + federwiege = config_entry.runtime_data + async_add_entities( + ( + SmarlaSensor(federwiege, desc) + if not desc.multiple + else SmarlaSensorMultiple(federwiege, desc) + ) + for desc in SENSORS + ) + + +class SmarlaSensor(SmarlaBaseEntity, SensorEntity): + """Representation of Smarla sensor.""" + + entity_description: SmarlaSensorEntityDescription + + _property: Property[int] + + @property + def native_value(self) -> int | None: + """Return the entity value to represent the entity state.""" + return self._property.get() + + +class SmarlaSensorMultiple(SmarlaBaseEntity, SensorEntity): + """Representation of Smarla sensor with multiple values inside property.""" + + entity_description: SmarlaSensorEntityDescription + + _property: Property[list[int]] + + @property + def native_value(self) -> int | None: + """Return the entity value to represent the entity state.""" + v = self._property.get() + return v[self.entity_description.value_pos] if v is not None else None diff --git a/homeassistant/components/smarla/strings.json b/homeassistant/components/smarla/strings.json index fbe5df4c1d0..edf306b1183 100644 --- a/homeassistant/components/smarla/strings.json +++ b/homeassistant/components/smarla/strings.json @@ -28,6 +28,21 @@ "intensity": { "name": "Intensity" } + }, + "sensor": { + "amplitude": { + "name": "Amplitude" + }, + "period": { + "name": "Period" + }, + "activity": { + "name": "Activity" + }, + "swing_count": { + "name": "Swing count", + "unit_of_measurement": "swings" + } } } } diff --git a/tests/components/smarla/conftest.py b/tests/components/smarla/conftest.py index d472e929bcc..d25dab2446f 100644 --- a/tests/components/smarla/conftest.py +++ b/tests/components/smarla/conftest.py @@ -73,8 +73,20 @@ def mock_federwiege(mock_connection: MagicMock) -> Generator[MagicMock]: mock_babywiege_service.props["smart_mode"].get.return_value = False mock_babywiege_service.props["intensity"].get.return_value = 1 + mock_analyser_service = MagicMock(spec=Service) + mock_analyser_service.props = { + "oscillation": MagicMock(spec=Property), + "activity": MagicMock(spec=Property), + "swing_count": MagicMock(spec=Property), + } + + mock_analyser_service.props["oscillation"].get.return_value = [0, 0] + mock_analyser_service.props["activity"].get.return_value = 0 + mock_analyser_service.props["swing_count"].get.return_value = 0 + federwiege.services = { "babywiege": mock_babywiege_service, + "analyser": mock_analyser_service, } federwiege.get_property = MagicMock( diff --git a/tests/components/smarla/snapshots/test_sensor.ambr b/tests/components/smarla/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..88d6a6ecea6 --- /dev/null +++ b/tests/components/smarla/snapshots/test_sensor.ambr @@ -0,0 +1,208 @@ +# serializer version: 1 +# name: test_entities[sensor.smarla_activity-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.smarla_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Activity', + 'platform': 'smarla', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'activity', + 'unique_id': 'ABCD-activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.smarla_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smarla Activity', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.smarla_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_entities[sensor.smarla_amplitude-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.smarla_amplitude', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Amplitude', + 'platform': 'smarla', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'amplitude', + 'unique_id': 'ABCD-amplitude', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.smarla_amplitude-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smarla Amplitude', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smarla_amplitude', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_entities[sensor.smarla_period-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.smarla_period', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Period', + 'platform': 'smarla', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'period', + 'unique_id': 'ABCD-period', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.smarla_period-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smarla Period', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smarla_period', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_entities[sensor.smarla_swing_count-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.smarla_swing_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Swing count', + 'platform': 'smarla', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'swing_count', + 'unique_id': 'ABCD-swing_count', + 'unit_of_measurement': 'swings', + }) +# --- +# name: test_entities[sensor.smarla_swing_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smarla Swing count', + 'state_class': , + 'unit_of_measurement': 'swings', + }), + 'context': , + 'entity_id': 'sensor.smarla_swing_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/smarla/test_sensor.py b/tests/components/smarla/test_sensor.py new file mode 100644 index 00000000000..196e6d2a6f0 --- /dev/null +++ b/tests/components/smarla/test_sensor.py @@ -0,0 +1,85 @@ +"""Test sensor platform for Swing2Sleep Smarla integration.""" + +from unittest.mock import MagicMock, patch + +import pytest +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, update_property_listeners + +from tests.common import MockConfigEntry, snapshot_platform + +SENSOR_ENTITIES = [ + { + "entity_id": "sensor.smarla_amplitude", + "service": "analyser", + "property": "oscillation", + "test_value": [1, 0], + }, + { + "entity_id": "sensor.smarla_period", + "service": "analyser", + "property": "oscillation", + "test_value": [0, 1], + }, + { + "entity_id": "sensor.smarla_activity", + "service": "analyser", + "property": "activity", + "test_value": 1, + }, + { + "entity_id": "sensor.smarla_swing_count", + "service": "analyser", + "property": "swing_count", + "test_value": 1, + }, +] + + +@pytest.mark.usefixtures("mock_federwiege") +async def test_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Smarla entities.""" + with ( + patch("homeassistant.components.smarla.PLATFORMS", [Platform.SENSOR]), + ): + assert await setup_integration(hass, mock_config_entry) + + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +@pytest.mark.parametrize("entity_info", SENSOR_ENTITIES) +async def test_sensor_state_update( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_federwiege: MagicMock, + entity_info: dict[str, str], +) -> None: + """Test Smarla Sensor callback.""" + assert await setup_integration(hass, mock_config_entry) + + mock_sensor_property = mock_federwiege.get_property( + entity_info["service"], entity_info["property"] + ) + + entity_id = entity_info["entity_id"] + + assert hass.states.get(entity_id).state == "0" + + mock_sensor_property.get.return_value = entity_info["test_value"] + + await update_property_listeners(mock_sensor_property) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "1"