diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py index 3a7deadc405..72003e0a418 100644 --- a/homeassistant/components/sensibo/binary_sensor.py +++ b/homeassistant/components/sensibo/binary_sensor.py @@ -1,9 +1,9 @@ """Binary Sensor platform for Sensibo integration.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from pysensibo.model import MotionSensor, SensiboDevice @@ -36,6 +36,7 @@ class DeviceBaseEntityDescriptionMixin: """Mixin for required Sensibo base description keys.""" value_fn: Callable[[SensiboDevice], bool | None] + extra_fn: Callable[[SensiboDevice], dict[str, str | bool | None] | None] | None @dataclass @@ -77,13 +78,25 @@ MOTION_SENSOR_TYPES: tuple[SensiboMotionBinarySensorEntityDescription, ...] = ( ), ) -DEVICE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( +MOTION_DEVICE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( SensiboDeviceBinarySensorEntityDescription( key="room_occupied", device_class=BinarySensorDeviceClass.MOTION, name="Room Occupied", icon="mdi:motion-sensor", value_fn=lambda data: data.room_occupied, + extra_fn=None, + ), +) + +DEVICE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( + SensiboDeviceBinarySensorEntityDescription( + key="timer_on", + device_class=BinarySensorDeviceClass.RUNNING, + name="Timer Running", + icon="mdi:timer", + value_fn=lambda data: data.timer_on, + extra_fn=lambda data: {"id": data.timer_id, "turn_on": data.timer_state_on}, ), ) @@ -94,6 +107,7 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( name="Pure Boost Enabled", icon="mdi:wind-power-outline", value_fn=lambda data: data.pure_boost_enabled, + extra_fn=None, ), SensiboDeviceBinarySensorEntityDescription( key="pure_ac_integration", @@ -102,6 +116,7 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( name="Pure Boost linked with AC", icon="mdi:connection", value_fn=lambda data: data.pure_ac_integration, + extra_fn=None, ), SensiboDeviceBinarySensorEntityDescription( key="pure_geo_integration", @@ -110,6 +125,7 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( name="Pure Boost linked with Presence", icon="mdi:connection", value_fn=lambda data: data.pure_geo_integration, + extra_fn=None, ), SensiboDeviceBinarySensorEntityDescription( key="pure_measure_integration", @@ -118,6 +134,7 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( name="Pure Boost linked with Indoor Air Quality", icon="mdi:connection", value_fn=lambda data: data.pure_measure_integration, + extra_fn=None, ), SensiboDeviceBinarySensorEntityDescription( key="pure_prime_integration", @@ -126,6 +143,7 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( name="Pure Boost linked with Outdoor Air Quality", icon="mdi:connection", value_fn=lambda data: data.pure_prime_integration, + extra_fn=None, ), ) @@ -148,11 +166,17 @@ async def async_setup_entry( for sensor_id, sensor_data in device_data.motion_sensors.items() for description in MOTION_SENSOR_TYPES ) + entities.extend( + SensiboDeviceSensor(coordinator, device_id, description) + for description in MOTION_DEVICE_SENSOR_TYPES + for device_id, device_data in coordinator.data.parsed.items() + if device_data.motion_sensors is not None + ) entities.extend( SensiboDeviceSensor(coordinator, device_id, description) for description in DEVICE_SENSOR_TYPES for device_id, device_data in coordinator.data.parsed.items() - if getattr(device_data, description.key) is not None + if device_data.model != "pure" ) entities.extend( SensiboDeviceSensor(coordinator, device_id, description) @@ -223,3 +247,10 @@ class SensiboDeviceSensor(SensiboDeviceBaseEntity, BinarySensorEntity): def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" return self.entity_description.value_fn(self.device_data) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return additional attributes.""" + if self.entity_description.extra_fn is not None: + return self.entity_description.extra_fn(self.device_data) + return None diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index c7967d05be0..f0ce7b74c01 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_platform +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.temperature import convert as convert_temperature @@ -27,6 +27,8 @@ from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity SERVICE_ASSUME_STATE = "assume_state" +SERVICE_TIMER = "timer" +ATTR_MINUTES = "minutes" PARALLEL_UPDATES = 0 FIELD_TO_FLAG = { @@ -85,6 +87,14 @@ async def async_setup_entry( }, "async_assume_state", ) + platform.async_register_entity_service( + SERVICE_TIMER, + { + vol.Required(ATTR_STATE): vol.In(["on", "off"]), + vol.Optional(ATTR_MINUTES): cv.positive_int, + }, + "async_set_timer", + ) class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): @@ -276,3 +286,25 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): """Sync state with api.""" await self._async_set_ac_state_property("on", state != HVACMode.OFF, True) await self.coordinator.async_refresh() + + async def async_set_timer(self, state: str, minutes: int | None = None) -> None: + """Set or delete timer.""" + if state == "off" and self.device_data.timer_id is None: + raise HomeAssistantError("No timer to delete") + + if state == "on" and minutes is None: + raise ValueError("No value provided for timer") + + if state == "off": + result = await self.async_send_command("del_timer") + else: + new_state = bool(self.device_data.ac_states["on"] is False) + params = { + "minutesFromNow": minutes, + "acState": {**self.device_data.ac_states, "on": new_state}, + } + result = await self.async_send_command("set_timer", params) + + if result["status"] == "success": + return await self.coordinator.async_request_refresh() + raise HomeAssistantError(f"Could not set timer for device {self.name}") diff --git a/homeassistant/components/sensibo/entity.py b/homeassistant/components/sensibo/entity.py index c2f4869a4e6..430d7ac61ac 100644 --- a/homeassistant/components/sensibo/entity.py +++ b/homeassistant/components/sensibo/entity.py @@ -57,7 +57,7 @@ class SensiboDeviceBaseEntity(SensiboBaseEntity): ) async def async_send_command( - self, command: str, params: dict[str, Any] + self, command: str, params: dict[str, Any] | None = None ) -> dict[str, Any]: """Send command to Sensibo api.""" try: @@ -72,16 +72,20 @@ class SensiboDeviceBaseEntity(SensiboBaseEntity): return result async def async_send_api_call( - self, command: str, params: dict[str, Any] + self, command: str, params: dict[str, Any] | None = None ) -> dict[str, Any]: """Send api call.""" result: dict[str, Any] = {"status": None} if command == "set_calibration": + if TYPE_CHECKING: + assert params is not None result = await self._client.async_set_calibration( self._device_id, params["data"], ) if command == "set_ac_state": + if TYPE_CHECKING: + assert params is not None result = await self._client.async_set_ac_state_property( self._device_id, params["name"], @@ -89,6 +93,12 @@ class SensiboDeviceBaseEntity(SensiboBaseEntity): params["ac_states"], params["assumed_state"], ) + if command == "set_timer": + if TYPE_CHECKING: + assert params is not None + result = await self._client.async_set_timer(self._device_id, params) + if command == "del_timer": + result = await self._client.async_del_timer(self._device_id) return result diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index f37a054b606..259a24ab876 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -1,9 +1,10 @@ """Sensor platform for Sensibo integration.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass -from typing import TYPE_CHECKING +from datetime import datetime +from typing import TYPE_CHECKING, Any from pysensibo.model import MotionSensor, SensiboDevice @@ -44,7 +45,8 @@ class MotionBaseEntityDescriptionMixin: class DeviceBaseEntityDescriptionMixin: """Mixin for required Sensibo base description keys.""" - value_fn: Callable[[SensiboDevice], StateType] + value_fn: Callable[[SensiboDevice], StateType | datetime] + extra_fn: Callable[[SensiboDevice], dict[str, str | bool | None] | None] | None @dataclass @@ -111,13 +113,25 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( name="PM2.5", icon="mdi:air-filter", value_fn=lambda data: data.pm25, + extra_fn=None, ), SensiboDeviceSensorEntityDescription( key="pure_sensitivity", name="Pure Sensitivity", icon="mdi:air-filter", - value_fn=lambda data: str(data.pure_sensitivity).lower(), - device_class="sensibo__sensitivity", + value_fn=lambda data: data.pure_sensitivity, + extra_fn=None, + ), +) + +DEVICE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( + SensiboDeviceSensorEntityDescription( + key="timer_time", + device_class=SensorDeviceClass.TIMESTAMP, + name="Timer End Time", + icon="mdi:timer", + value_fn=lambda data: data.timer_time, + extra_fn=lambda data: {"id": data.timer_id, "turn_on": data.timer_state_on}, ), ) @@ -146,6 +160,12 @@ async def async_setup_entry( for description in PURE_SENSOR_TYPES if device_data.model == "pure" ) + entities.extend( + SensiboDeviceSensor(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + for description in DEVICE_SENSOR_TYPES + if device_data.model != "pure" + ) async_add_entities(entities) @@ -205,6 +225,13 @@ class SensiboDeviceSensor(SensiboDeviceBaseEntity, SensorEntity): self._attr_name = f"{self.device_data.name} {entity_description.name}" @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return value of sensor.""" return self.entity_description.value_fn(self.device_data) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return additional attributes.""" + if self.entity_description.extra_fn is not None: + return self.entity_description.extra_fn(self.device_data) + return None diff --git a/homeassistant/components/sensibo/services.yaml b/homeassistant/components/sensibo/services.yaml index bbbdb8611e8..1a64f8703b4 100644 --- a/homeassistant/components/sensibo/services.yaml +++ b/homeassistant/components/sensibo/services.yaml @@ -16,3 +16,31 @@ assume_state: options: - "on" - "off" +timer: + name: Timer + description: Set or delete timer for device. + target: + entity: + integration: sensibo + domain: climate + fields: + state: + name: State + description: Timer on or off. + required: true + example: "on" + selector: + select: + options: + - "on" + - "off" + minutes: + name: Minutes + description: Countdown for timer (for timer state on) + required: false + example: 30 + selector: + number: + min: 0 + step: 1 + mode: box diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index ead4fb02d88..16e83162600 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -1,8 +1,8 @@ """The test for the sensibo binary sensor platform.""" from __future__ import annotations -from datetime import timedelta -from unittest.mock import patch +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, patch from pysensibo.model import SensiboData import pytest @@ -21,7 +21,9 @@ from homeassistant.components.climate.const import ( SERVICE_SET_TEMPERATURE, ) from homeassistant.components.sensibo.climate import ( + ATTR_MINUTES, SERVICE_ASSUME_STATE, + SERVICE_TIMER, _find_valid_target_temp, ) from homeassistant.components.sensibo.const import DOMAIN @@ -32,6 +34,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -675,3 +678,230 @@ async def test_climate_no_fan_no_swing( assert state.attributes["swing_mode"] is None assert state.attributes["fan_modes"] is None assert state.attributes["swing_modes"] is None + + +async def test_climate_set_timer( + hass: HomeAssistant, + entity_registry_enabled_by_default: AsyncMock, + load_int: ConfigEntry, + monkeypatch: pytest.MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo climate Set Timer service.""" + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state1 = hass.states.get("climate.hallway") + assert hass.states.get("sensor.hallway_timer_end_time").state == STATE_UNKNOWN + assert hass.states.get("binary_sensor.hallway_timer_running").state == "off" + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", + return_value={"status": "success", "result": {"id": "SzTGE4oZ4D"}}, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_TIMER, + { + ATTR_ENTITY_ID: state1.entity_id, + ATTR_STATE: "on", + ATTR_MINUTES: 30, + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_on", True) + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_id", "SzTGE4oZ4D") + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_state_on", False) + monkeypatch.setattr( + get_data.parsed["ABC999111"], + "timer_time", + datetime(2022, 6, 6, 12, 00, 00, tzinfo=dt.UTC), + ) + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.hallway_timer_end_time").state + == "2022-06-06T12:00:00+00:00" + ) + assert hass.states.get("binary_sensor.hallway_timer_running").state == "on" + assert hass.states.get("binary_sensor.hallway_timer_running").attributes == { + "device_class": "running", + "friendly_name": "Hallway Timer Running", + "icon": "mdi:timer", + "id": "SzTGE4oZ4D", + "turn_on": False, + } + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_del_timer", + return_value={"status": "success"}, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_TIMER, + { + ATTR_ENTITY_ID: state1.entity_id, + ATTR_STATE: "off", + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_on", False) + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_id", None) + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_state_on", None) + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_time", None) + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + assert hass.states.get("sensor.hallway_timer_end_time").state == STATE_UNKNOWN + assert hass.states.get("binary_sensor.hallway_timer_running").state == "off" + + +async def test_climate_set_timer_failures( + hass: HomeAssistant, + entity_registry_enabled_by_default: AsyncMock, + load_int: ConfigEntry, + monkeypatch: pytest.MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo climate Set Timer service failures.""" + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state1 = hass.states.get("climate.hallway") + assert hass.states.get("sensor.hallway_timer_end_time").state == STATE_UNKNOWN + assert hass.states.get("binary_sensor.hallway_timer_running").state == "off" + + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_TIMER, + { + ATTR_ENTITY_ID: state1.entity_id, + ATTR_STATE: "on", + }, + blocking=True, + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", + return_value={"status": "success", "result": {"id": ""}}, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_TIMER, + { + ATTR_ENTITY_ID: state1.entity_id, + ATTR_STATE: "on", + ATTR_MINUTES: 30, + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_on", True) + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_id", None) + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_state_on", False) + monkeypatch.setattr( + get_data.parsed["ABC999111"], + "timer_time", + datetime(2022, 6, 6, 12, 00, 00, tzinfo=dt.UTC), + ) + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_TIMER, + { + ATTR_ENTITY_ID: state1.entity_id, + ATTR_STATE: "off", + }, + blocking=True, + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", + return_value={"status": "failure"}, + ): + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_TIMER, + { + ATTR_ENTITY_ID: state1.entity_id, + ATTR_STATE: "on", + ATTR_MINUTES: 30, + }, + blocking=True, + ) + await hass.async_block_till_done()