From a78f0eae39cbc20406bb7a83e1af70237ac9bf81 Mon Sep 17 00:00:00 2001 From: refinedcranberry <3584606+refinedcranberry@users.noreply.github.com> Date: Thu, 25 Nov 2021 12:34:04 +0100 Subject: [PATCH] Add "nextchange" sensors to AVM FRITZ!Smarthome devices (#58274) --- homeassistant/components/fritzbox/climate.py | 2 +- homeassistant/components/fritzbox/model.py | 2 +- homeassistant/components/fritzbox/sensor.py | 63 +++++++++++++++++++- tests/components/fritzbox/__init__.py | 5 ++ tests/components/fritzbox/test_climate.py | 60 +++++++++++++++++++ 5 files changed, 128 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index bf2d857e30e..ef1285f0ec2 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -185,7 +185,7 @@ class FritzboxThermostat(FritzBoxEntity, ClimateEntity): attrs[ATTR_STATE_HOLIDAY_MODE] = self.device.holiday_active if self.device.summer_active is not None: attrs[ATTR_STATE_SUMMER_MODE] = self.device.summer_active - if ATTR_STATE_WINDOW_OPEN is not None: + if self.device.window_open is not None: attrs[ATTR_STATE_WINDOW_OPEN] = self.device.window_open return attrs diff --git a/homeassistant/components/fritzbox/model.py b/homeassistant/components/fritzbox/model.py index fa6da56caeb..133638c1fe8 100644 --- a/homeassistant/components/fritzbox/model.py +++ b/homeassistant/components/fritzbox/model.py @@ -18,8 +18,8 @@ class FritzExtraAttributes(TypedDict): class ClimateExtraAttributes(FritzExtraAttributes, total=False): """TypedDict for climates extra attributes.""" - battery_low: bool battery_level: int + battery_low: bool holiday_mode: bool summer_mode: bool window_open: bool diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 6ffa8bc8560..0ee7c3e8563 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -3,10 +3,12 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime from typing import Final from pyfritzhome.fritzhomedevice import FritzhomeDevice +from homeassistant.components.climate.const import PRESET_COMFORT, PRESET_ECO from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, @@ -20,6 +22,7 @@ from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TIMESTAMP, ENERGY_KILO_WATT_HOUR, ENTITY_CATEGORY_DIAGNOSTIC, PERCENTAGE, @@ -28,6 +31,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util.dt import utc_from_timestamp from . import FritzBoxEntity from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN @@ -38,7 +43,7 @@ from .model import FritzEntityDescriptionMixinBase class FritzEntityDescriptionMixinSensor(FritzEntityDescriptionMixinBase): """Sensor description mixin for Fritz!Smarthome entities.""" - native_value: Callable[[FritzhomeDevice], float | int | None] + native_value: Callable[[FritzhomeDevice], StateType | datetime] @dataclass @@ -97,6 +102,60 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] native_value=lambda device: device.energy / 1000 if device.energy else 0.0, ), + # Thermostat Sensors + FritzSensorEntityDescription( + key="comfort_temperature", + name="Comfort Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + suitable=lambda device: device.has_thermostat + and device.comfort_temperature is not None, + native_value=lambda device: device.comfort_temperature, # type: ignore[no-any-return] + ), + FritzSensorEntityDescription( + key="eco_temperature", + name="Eco Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + suitable=lambda device: device.has_thermostat + and device.eco_temperature is not None, + native_value=lambda device: device.eco_temperature, # type: ignore[no-any-return] + ), + FritzSensorEntityDescription( + key="nextchange_temperature", + name="Next Scheduled Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + suitable=lambda device: device.has_thermostat + and device.nextchange_temperature is not None, + native_value=lambda device: device.nextchange_temperature, # type: ignore[no-any-return] + ), + FritzSensorEntityDescription( + key="nextchange_time", + name="Next Scheduled Change Time", + device_class=DEVICE_CLASS_TIMESTAMP, + suitable=lambda device: device.has_thermostat + and device.nextchange_endperiod is not None, + native_value=lambda device: utc_from_timestamp(device.nextchange_endperiod), + ), + FritzSensorEntityDescription( + key="nextchange_preset", + name="Next Scheduled Preset", + suitable=lambda device: device.has_thermostat + and device.nextchange_temperature is not None, + native_value=lambda device: PRESET_ECO + if device.nextchange_temperature == device.eco_temperature + else PRESET_COMFORT, + ), + FritzSensorEntityDescription( + key="scheduled_preset", + name="Current Scheduled Preset", + suitable=lambda device: device.has_thermostat + and device.nextchange_temperature is not None, + native_value=lambda device: PRESET_COMFORT + if device.nextchange_temperature == device.eco_temperature + else PRESET_ECO, + ), ) @@ -122,6 +181,6 @@ class FritzBoxSensor(FritzBoxEntity, SensorEntity): entity_description: FritzSensorEntityDescription @property - def native_value(self) -> float | int | None: + def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" return self.entity_description.native_value(self.device) diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index 27abd38f8cb..80052125f99 100644 --- a/tests/components/fritzbox/__init__.py +++ b/tests/components/fritzbox/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any from unittest.mock import Mock +from homeassistant.components.climate.const import PRESET_COMFORT, PRESET_ECO from homeassistant.components.fritzbox.const import DOMAIN from homeassistant.core import HomeAssistant @@ -86,6 +87,10 @@ class FritzDeviceClimateMock(FritzDeviceBaseMock): summer_active = "fake_summer" target_temperature = 19.5 window_open = "fake_window" + nextchange_temperature = 22.0 + nextchange_endperiod = 0 + nextchange_preset = PRESET_COMFORT + scheduled_preset = PRESET_ECO class FritzDeviceSensorMock(FritzDeviceBaseMock): diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 627c82f617a..175b67df858 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -39,6 +39,7 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_DEVICES, PERCENTAGE, + TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util @@ -85,6 +86,65 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE assert ATTR_STATE_CLASS not in state.attributes + state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_comfort_temperature") + assert state + assert state.state == "22.0" + assert ( + state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Comfort Temperature" + ) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS + assert ATTR_STATE_CLASS not in state.attributes + + state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_eco_temperature") + assert state + assert state.state == "16.0" + assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Eco Temperature" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS + assert ATTR_STATE_CLASS not in state.attributes + + state = hass.states.get( + f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_temperature" + ) + assert state + assert state.state == "22.0" + assert ( + state.attributes[ATTR_FRIENDLY_NAME] + == f"{CONF_FAKE_NAME} Next Scheduled Temperature" + ) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS + assert ATTR_STATE_CLASS not in state.attributes + + state = hass.states.get( + f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_change_time" + ) + assert state + assert state.state == "1970-01-01T00:00:00+00:00" + assert ( + state.attributes[ATTR_FRIENDLY_NAME] + == f"{CONF_FAKE_NAME} Next Scheduled Change Time" + ) + assert ATTR_STATE_CLASS not in state.attributes + + state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_preset") + assert state + assert state.state == PRESET_COMFORT + assert ( + state.attributes[ATTR_FRIENDLY_NAME] + == f"{CONF_FAKE_NAME} Next Scheduled Preset" + ) + assert ATTR_STATE_CLASS not in state.attributes + + state = hass.states.get( + f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_current_scheduled_preset" + ) + assert state + assert state.state == PRESET_ECO + assert ( + state.attributes[ATTR_FRIENDLY_NAME] + == f"{CONF_FAKE_NAME} Current Scheduled Preset" + ) + assert ATTR_STATE_CLASS not in state.attributes + async def test_target_temperature_on(hass: HomeAssistant, fritz: Mock): """Test turn device on."""