Sensibo Add timer (#73072)

This commit is contained in:
G Johansson 2022-06-13 21:17:08 +02:00 committed by GitHub
parent dca4d3cd61
commit c660fae8d8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 373 additions and 15 deletions

View file

@ -1,9 +1,9 @@
"""Binary Sensor platform for Sensibo integration.""" """Binary Sensor platform for Sensibo integration."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable, Mapping
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any
from pysensibo.model import MotionSensor, SensiboDevice from pysensibo.model import MotionSensor, SensiboDevice
@ -36,6 +36,7 @@ class DeviceBaseEntityDescriptionMixin:
"""Mixin for required Sensibo base description keys.""" """Mixin for required Sensibo base description keys."""
value_fn: Callable[[SensiboDevice], bool | None] value_fn: Callable[[SensiboDevice], bool | None]
extra_fn: Callable[[SensiboDevice], dict[str, str | bool | None] | None] | None
@dataclass @dataclass
@ -77,13 +78,25 @@ MOTION_SENSOR_TYPES: tuple[SensiboMotionBinarySensorEntityDescription, ...] = (
), ),
) )
DEVICE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( MOTION_DEVICE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = (
SensiboDeviceBinarySensorEntityDescription( SensiboDeviceBinarySensorEntityDescription(
key="room_occupied", key="room_occupied",
device_class=BinarySensorDeviceClass.MOTION, device_class=BinarySensorDeviceClass.MOTION,
name="Room Occupied", name="Room Occupied",
icon="mdi:motion-sensor", icon="mdi:motion-sensor",
value_fn=lambda data: data.room_occupied, 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", name="Pure Boost Enabled",
icon="mdi:wind-power-outline", icon="mdi:wind-power-outline",
value_fn=lambda data: data.pure_boost_enabled, value_fn=lambda data: data.pure_boost_enabled,
extra_fn=None,
), ),
SensiboDeviceBinarySensorEntityDescription( SensiboDeviceBinarySensorEntityDescription(
key="pure_ac_integration", key="pure_ac_integration",
@ -102,6 +116,7 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = (
name="Pure Boost linked with AC", name="Pure Boost linked with AC",
icon="mdi:connection", icon="mdi:connection",
value_fn=lambda data: data.pure_ac_integration, value_fn=lambda data: data.pure_ac_integration,
extra_fn=None,
), ),
SensiboDeviceBinarySensorEntityDescription( SensiboDeviceBinarySensorEntityDescription(
key="pure_geo_integration", key="pure_geo_integration",
@ -110,6 +125,7 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = (
name="Pure Boost linked with Presence", name="Pure Boost linked with Presence",
icon="mdi:connection", icon="mdi:connection",
value_fn=lambda data: data.pure_geo_integration, value_fn=lambda data: data.pure_geo_integration,
extra_fn=None,
), ),
SensiboDeviceBinarySensorEntityDescription( SensiboDeviceBinarySensorEntityDescription(
key="pure_measure_integration", key="pure_measure_integration",
@ -118,6 +134,7 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = (
name="Pure Boost linked with Indoor Air Quality", name="Pure Boost linked with Indoor Air Quality",
icon="mdi:connection", icon="mdi:connection",
value_fn=lambda data: data.pure_measure_integration, value_fn=lambda data: data.pure_measure_integration,
extra_fn=None,
), ),
SensiboDeviceBinarySensorEntityDescription( SensiboDeviceBinarySensorEntityDescription(
key="pure_prime_integration", key="pure_prime_integration",
@ -126,6 +143,7 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = (
name="Pure Boost linked with Outdoor Air Quality", name="Pure Boost linked with Outdoor Air Quality",
icon="mdi:connection", icon="mdi:connection",
value_fn=lambda data: data.pure_prime_integration, 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 sensor_id, sensor_data in device_data.motion_sensors.items()
for description in MOTION_SENSOR_TYPES 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( entities.extend(
SensiboDeviceSensor(coordinator, device_id, description) SensiboDeviceSensor(coordinator, device_id, description)
for description in DEVICE_SENSOR_TYPES for description in DEVICE_SENSOR_TYPES
for device_id, device_data in coordinator.data.parsed.items() 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( entities.extend(
SensiboDeviceSensor(coordinator, device_id, description) SensiboDeviceSensor(coordinator, device_id, description)
@ -223,3 +247,10 @@ class SensiboDeviceSensor(SensiboDeviceBaseEntity, BinarySensorEntity):
def is_on(self) -> bool | None: def is_on(self) -> bool | None:
"""Return true if the binary sensor is on.""" """Return true if the binary sensor is on."""
return self.entity_description.value_fn(self.device_data) 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

View file

@ -18,7 +18,7 @@ from homeassistant.const import (
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError 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.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.temperature import convert as convert_temperature from homeassistant.util.temperature import convert as convert_temperature
@ -27,6 +27,8 @@ from .coordinator import SensiboDataUpdateCoordinator
from .entity import SensiboDeviceBaseEntity from .entity import SensiboDeviceBaseEntity
SERVICE_ASSUME_STATE = "assume_state" SERVICE_ASSUME_STATE = "assume_state"
SERVICE_TIMER = "timer"
ATTR_MINUTES = "minutes"
PARALLEL_UPDATES = 0 PARALLEL_UPDATES = 0
FIELD_TO_FLAG = { FIELD_TO_FLAG = {
@ -85,6 +87,14 @@ async def async_setup_entry(
}, },
"async_assume_state", "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): class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity):
@ -276,3 +286,25 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity):
"""Sync state with api.""" """Sync state with api."""
await self._async_set_ac_state_property("on", state != HVACMode.OFF, True) await self._async_set_ac_state_property("on", state != HVACMode.OFF, True)
await self.coordinator.async_refresh() 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}")

View file

@ -57,7 +57,7 @@ class SensiboDeviceBaseEntity(SensiboBaseEntity):
) )
async def async_send_command( async def async_send_command(
self, command: str, params: dict[str, Any] self, command: str, params: dict[str, Any] | None = None
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Send command to Sensibo api.""" """Send command to Sensibo api."""
try: try:
@ -72,16 +72,20 @@ class SensiboDeviceBaseEntity(SensiboBaseEntity):
return result return result
async def async_send_api_call( 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]: ) -> dict[str, Any]:
"""Send api call.""" """Send api call."""
result: dict[str, Any] = {"status": None} result: dict[str, Any] = {"status": None}
if command == "set_calibration": if command == "set_calibration":
if TYPE_CHECKING:
assert params is not None
result = await self._client.async_set_calibration( result = await self._client.async_set_calibration(
self._device_id, self._device_id,
params["data"], params["data"],
) )
if command == "set_ac_state": if command == "set_ac_state":
if TYPE_CHECKING:
assert params is not None
result = await self._client.async_set_ac_state_property( result = await self._client.async_set_ac_state_property(
self._device_id, self._device_id,
params["name"], params["name"],
@ -89,6 +93,12 @@ class SensiboDeviceBaseEntity(SensiboBaseEntity):
params["ac_states"], params["ac_states"],
params["assumed_state"], 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 return result

View file

@ -1,9 +1,10 @@
"""Sensor platform for Sensibo integration.""" """Sensor platform for Sensibo integration."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable, Mapping
from dataclasses import dataclass 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 from pysensibo.model import MotionSensor, SensiboDevice
@ -44,7 +45,8 @@ class MotionBaseEntityDescriptionMixin:
class DeviceBaseEntityDescriptionMixin: class DeviceBaseEntityDescriptionMixin:
"""Mixin for required Sensibo base description keys.""" """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 @dataclass
@ -111,13 +113,25 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = (
name="PM2.5", name="PM2.5",
icon="mdi:air-filter", icon="mdi:air-filter",
value_fn=lambda data: data.pm25, value_fn=lambda data: data.pm25,
extra_fn=None,
), ),
SensiboDeviceSensorEntityDescription( SensiboDeviceSensorEntityDescription(
key="pure_sensitivity", key="pure_sensitivity",
name="Pure Sensitivity", name="Pure Sensitivity",
icon="mdi:air-filter", icon="mdi:air-filter",
value_fn=lambda data: str(data.pure_sensitivity).lower(), value_fn=lambda data: data.pure_sensitivity,
device_class="sensibo__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 for description in PURE_SENSOR_TYPES
if device_data.model == "pure" 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) async_add_entities(entities)
@ -205,6 +225,13 @@ class SensiboDeviceSensor(SensiboDeviceBaseEntity, SensorEntity):
self._attr_name = f"{self.device_data.name} {entity_description.name}" self._attr_name = f"{self.device_data.name} {entity_description.name}"
@property @property
def native_value(self) -> StateType: def native_value(self) -> StateType | datetime:
"""Return value of sensor.""" """Return value of sensor."""
return self.entity_description.value_fn(self.device_data) 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

View file

@ -16,3 +16,31 @@ assume_state:
options: options:
- "on" - "on"
- "off" - "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

View file

@ -1,8 +1,8 @@
"""The test for the sensibo binary sensor platform.""" """The test for the sensibo binary sensor platform."""
from __future__ import annotations from __future__ import annotations
from datetime import timedelta from datetime import datetime, timedelta
from unittest.mock import patch from unittest.mock import AsyncMock, patch
from pysensibo.model import SensiboData from pysensibo.model import SensiboData
import pytest import pytest
@ -21,7 +21,9 @@ from homeassistant.components.climate.const import (
SERVICE_SET_TEMPERATURE, SERVICE_SET_TEMPERATURE,
) )
from homeassistant.components.sensibo.climate import ( from homeassistant.components.sensibo.climate import (
ATTR_MINUTES,
SERVICE_ASSUME_STATE, SERVICE_ASSUME_STATE,
SERVICE_TIMER,
_find_valid_target_temp, _find_valid_target_temp,
) )
from homeassistant.components.sensibo.const import DOMAIN from homeassistant.components.sensibo.const import DOMAIN
@ -32,6 +34,7 @@ from homeassistant.const import (
ATTR_TEMPERATURE, ATTR_TEMPERATURE,
SERVICE_TURN_OFF, SERVICE_TURN_OFF,
SERVICE_TURN_ON, SERVICE_TURN_ON,
STATE_UNKNOWN,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError 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["swing_mode"] is None
assert state.attributes["fan_modes"] is None assert state.attributes["fan_modes"] is None
assert state.attributes["swing_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()