diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index ce5e74cfeec..d9226f36c87 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -6,12 +6,8 @@ from datetime import timedelta from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError import requests -from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_NAME, CONF_HOST, CONF_PASSWORD, CONF_USERNAME, @@ -20,15 +16,23 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) -from .const import CONF_CONNECTIONS, CONF_COORDINATOR, DOMAIN, LOGGER, PLATFORMS -from .model import EntityInfo +from .const import ( + ATTR_STATE_DEVICE_LOCKED, + ATTR_STATE_LOCKED, + CONF_CONNECTIONS, + CONF_COORDINATOR, + DOMAIN, + LOGGER, + PLATFORMS, +) +from .model import FritzExtraAttributes async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -128,18 +132,21 @@ class FritzBoxEntity(CoordinatorEntity): def __init__( self, - entity_info: EntityInfo, coordinator: DataUpdateCoordinator[dict[str, FritzhomeDevice]], ain: str, + entity_description: EntityDescription | None = None, ) -> None: """Initialize the FritzBox entity.""" super().__init__(coordinator) self.ain = ain - self._name = entity_info[ATTR_NAME] - self._unique_id = entity_info[ATTR_ENTITY_ID] - self._device_class = entity_info[ATTR_DEVICE_CLASS] - self._attr_state_class = entity_info[ATTR_STATE_CLASS] + if entity_description is not None: + self.entity_description = entity_description + self._attr_name = f"{self.device.name} {entity_description.name}" + self._attr_unique_id = f"{ain}_{entity_description.key}" + else: + self._attr_name = self.device.name + self._attr_unique_id = ain @property def available(self) -> bool: @@ -163,16 +170,9 @@ class FritzBoxEntity(CoordinatorEntity): } @property - def unique_id(self) -> str: - """Return the unique ID of the device.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name of the device.""" - return self._name - - @property - def device_class(self) -> str | None: - """Return the device class.""" - return self._device_class + def extra_state_attributes(self) -> FritzExtraAttributes: + """Return the state attributes of the device.""" + return { + ATTR_STATE_DEVICE_LOCKED: self.device.device_lock, + ATTR_STATE_LOCKED: self.device.lock, + } diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index 5514408cb3c..b7e78ceaf47 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -1,57 +1,52 @@ """Support for Fritzbox binary sensors.""" from __future__ import annotations -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_WINDOW, - BinarySensorEntity, -) -from homeassistant.components.sensor import ATTR_STATE_CLASS +from pyfritzhome.fritzhomedevice import FritzhomeDevice + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.fritzbox.model import FritzBinarySensorEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, -) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import FritzBoxEntity -from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN +from .const import BINARY_SENSOR_TYPES, CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome binary sensor from ConfigEntry.""" - entities: list[FritzboxBinarySensor] = [] coordinator = hass.data[FRITZBOX_DOMAIN][entry.entry_id][CONF_COORDINATOR] - for ain, device in coordinator.data.items(): - if not device.has_alarm: - continue - - entities.append( - FritzboxBinarySensor( - { - ATTR_NAME: f"{device.name}", - ATTR_ENTITY_ID: f"{device.ain}", - ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_DEVICE_CLASS: DEVICE_CLASS_WINDOW, - ATTR_STATE_CLASS: None, - }, - coordinator, - ain, - ) - ) - - async_add_entities(entities) + async_add_entities( + [ + FritzboxBinarySensor(coordinator, ain, description) + for ain, device in coordinator.data.items() + for description in BINARY_SENSOR_TYPES + if description.suitable(device) + ] + ) class FritzboxBinarySensor(FritzBoxEntity, BinarySensorEntity): """Representation of a binary FRITZ!SmartHome device.""" + entity_description: FritzBinarySensorEntityDescription + + def __init__( + self, + coordinator: DataUpdateCoordinator[dict[str, FritzhomeDevice]], + ain: str, + entity_description: FritzBinarySensorEntityDescription, + ) -> None: + """Initialize the FritzBox entity.""" + super().__init__(coordinator, ain, entity_description) + self._attr_name = self.device.name + self._attr_unique_id = ain + @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return true if sensor is on.""" - return self.device.alert_state # type: ignore [no-any-return] + return self.entity_description.is_on(self.device) diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 4baa1b3b81a..f8e394e1ef0 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -13,15 +13,10 @@ from homeassistant.components.climate.const import ( SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, ) -from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_NAME, ATTR_TEMPERATURE, - ATTR_UNIT_OF_MEASUREMENT, PRECISION_HALVES, TEMP_CELSIUS, ) @@ -61,28 +56,15 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome thermostat from ConfigEntry.""" - entities: list[FritzboxThermostat] = [] coordinator = hass.data[FRITZBOX_DOMAIN][entry.entry_id][CONF_COORDINATOR] - for ain, device in coordinator.data.items(): - if not device.has_thermostat: - continue - - entities.append( - FritzboxThermostat( - { - ATTR_NAME: f"{device.name}", - ATTR_ENTITY_ID: f"{device.ain}", - ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_DEVICE_CLASS: None, - ATTR_STATE_CLASS: None, - }, - coordinator, - ain, - ) - ) - - async_add_entities(entities) + async_add_entities( + [ + FritzboxThermostat(coordinator, ain) + for ain, device in coordinator.data.items() + if device.has_thermostat + ] + ) class FritzboxThermostat(FritzBoxEntity, ClimateEntity): diff --git a/homeassistant/components/fritzbox/const.py b/homeassistant/components/fritzbox/const.py index 6af75449a29..79123abbda7 100644 --- a/homeassistant/components/fritzbox/const.py +++ b/homeassistant/components/fritzbox/const.py @@ -4,6 +4,26 @@ from __future__ import annotations import logging from typing import Final +from homeassistant.components.binary_sensor import DEVICE_CLASS_WINDOW +from homeassistant.components.fritzbox.model import ( + FritzBinarySensorEntityDescription, + FritzSensorEntityDescription, +) +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + ENERGY_KILO_WATT_HOUR, + PERCENTAGE, + POWER_WATT, + TEMP_CELSIUS, +) + ATTR_STATE_BATTERY_LOW: Final = "battery_low" ATTR_STATE_DEVICE_LOCKED: Final = "device_locked" ATTR_STATE_HOLIDAY_MODE: Final = "holiday_mode" @@ -24,3 +44,53 @@ DOMAIN: Final = "fritzbox" LOGGER: Final[logging.Logger] = logging.getLogger(__package__) PLATFORMS: Final[list[str]] = ["binary_sensor", "climate", "switch", "sensor"] + +BINARY_SENSOR_TYPES: Final[tuple[FritzBinarySensorEntityDescription, ...]] = ( + FritzBinarySensorEntityDescription( + key="alarm", + name="Alarm", + device_class=DEVICE_CLASS_WINDOW, + suitable=lambda device: device.has_alarm, # type: ignore[no-any-return] + is_on=lambda device: device.alert_state, # type: ignore[no-any-return] + ), +) + +SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( + FritzSensorEntityDescription( + key="temperature", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + suitable=lambda device: ( + device.has_temperature_sensor and not device.has_thermostat + ), + native_value=lambda device: device.temperature, # type: ignore[no-any-return] + ), + FritzSensorEntityDescription( + key="battery", + name="Battery", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_BATTERY, + suitable=lambda device: device.battery_level is not None, + native_value=lambda device: device.battery_level, # type: ignore[no-any-return] + ), + FritzSensorEntityDescription( + key="power_consumption", + name="Power Consumption", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] + native_value=lambda device: device.power / 1000 if device.power else 0.0, + ), + FritzSensorEntityDescription( + key="total_energy", + name="Total Energy", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] + native_value=lambda device: device.energy / 1000 if device.energy else 0.0, + ), +) diff --git a/homeassistant/components/fritzbox/model.py b/homeassistant/components/fritzbox/model.py index 0e401a75be3..fb694a97012 100644 --- a/homeassistant/components/fritzbox/model.py +++ b/homeassistant/components/fritzbox/model.py @@ -1,7 +1,13 @@ """Models for the AVM FRITZ!SmartHome integration.""" from __future__ import annotations -from typing import TypedDict +from dataclasses import dataclass +from typing import Callable, TypedDict + +from pyfritzhome import FritzhomeDevice + +from homeassistant.components.binary_sensor import BinarySensorEntityDescription +from homeassistant.components.sensor import SensorEntityDescription class EntityInfo(TypedDict): @@ -14,25 +20,23 @@ class EntityInfo(TypedDict): state_class: str | None -class ClimateExtraAttributes(TypedDict, total=False): - """TypedDict for climates extra attributes.""" - - battery_low: bool - device_locked: bool - locked: bool - battery_level: int - holiday_mode: bool - summer_mode: bool - window_open: bool - - -class SensorExtraAttributes(TypedDict): +class FritzExtraAttributes(TypedDict): """TypedDict for sensors extra attributes.""" device_locked: bool locked: bool +class ClimateExtraAttributes(FritzExtraAttributes, total=False): + """TypedDict for climates extra attributes.""" + + battery_low: bool + battery_level: int + holiday_mode: bool + summer_mode: bool + window_open: bool + + class SwitchExtraAttributes(TypedDict, total=False): """TypedDict for sensors extra attributes.""" @@ -42,3 +46,38 @@ class SwitchExtraAttributes(TypedDict, total=False): total_consumption_unit: str temperature: str temperature_unit: str + + +@dataclass +class FritzEntityDescriptionMixinBase: + """Bases description mixin for Fritz!Smarthome entities.""" + + suitable: Callable[[FritzhomeDevice], bool] + + +@dataclass +class FritzEntityDescriptionMixinSensor(FritzEntityDescriptionMixinBase): + """Sensor description mixin for Fritz!Smarthome entities.""" + + native_value: Callable[[FritzhomeDevice], float | int | None] + + +@dataclass +class FritzEntityDescriptionMixinBinarySensor(FritzEntityDescriptionMixinBase): + """BinarySensor description mixin for Fritz!Smarthome entities.""" + + is_on: Callable[[FritzhomeDevice], bool | None] + + +@dataclass +class FritzSensorEntityDescription( + SensorEntityDescription, FritzEntityDescriptionMixinSensor +): + """Description for Fritz!Smarthome sensor entities.""" + + +@dataclass +class FritzBinarySensorEntityDescription( + BinarySensorEntityDescription, FritzEntityDescriptionMixinBinarySensor +): + """Description for Fritz!Smarthome binary sensor entities.""" diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 09a652d64ad..2150c2359b3 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -1,170 +1,38 @@ """Support for AVM FRITZ!SmartHome temperature sensor only devices.""" from __future__ import annotations -from pyfritzhome import FritzhomeDevice - -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, - SensorEntity, -) +from homeassistant.components.fritzbox.model import FritzSensorEntityDescription +from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_TEMPERATURE, - ENERGY_KILO_WATT_HOUR, - PERCENTAGE, - POWER_WATT, - TEMP_CELSIUS, -) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import FritzBoxEntity -from .const import ( - ATTR_STATE_DEVICE_LOCKED, - ATTR_STATE_LOCKED, - CONF_COORDINATOR, - DOMAIN as FRITZBOX_DOMAIN, -) -from .model import EntityInfo, SensorExtraAttributes +from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN, SENSOR_TYPES async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome sensor from ConfigEntry.""" - entities: list[FritzBoxEntity] = [] coordinator = hass.data[FRITZBOX_DOMAIN][entry.entry_id][CONF_COORDINATOR] - for ain, device in coordinator.data.items(): - if device.has_temperature_sensor and not device.has_thermostat: - entities.append( - FritzBoxTempSensor( - { - ATTR_NAME: f"{device.name} Temperature", - ATTR_ENTITY_ID: f"{device.ain}_temperature", - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - coordinator, - ain, - ) - ) - - if device.battery_level is not None: - entities.append( - FritzBoxBatterySensor( - { - ATTR_NAME: f"{device.name} Battery", - ATTR_ENTITY_ID: f"{device.ain}_battery", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, - ATTR_STATE_CLASS: None, - }, - coordinator, - ain, - ) - ) - - if device.has_powermeter: - entities.append( - FritzBoxPowerSensor( - { - ATTR_NAME: f"{device.name} Power Consumption", - ATTR_ENTITY_ID: f"{device.ain}_power_consumption", - ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, - ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - coordinator, - ain, - ) - ) - entities.append( - FritzBoxEnergySensor( - { - ATTR_NAME: f"{device.name} Total Energy", - ATTR_ENTITY_ID: f"{device.ain}_total_energy", - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, - }, - coordinator, - ain, - ) - ) - - async_add_entities(entities) + async_add_entities( + [ + FritzBoxSensor(coordinator, ain, description) + for ain, device in coordinator.data.items() + for description in SENSOR_TYPES + if description.suitable(device) + ] + ) class FritzBoxSensor(FritzBoxEntity, SensorEntity): """The entity class for FRITZ!SmartHome sensors.""" - def __init__( - self, - entity_info: EntityInfo, - coordinator: DataUpdateCoordinator[dict[str, FritzhomeDevice]], - ain: str, - ) -> None: - """Initialize the FritzBox entity.""" - FritzBoxEntity.__init__(self, entity_info, coordinator, ain) - self._attr_native_unit_of_measurement = entity_info[ATTR_UNIT_OF_MEASUREMENT] - - -class FritzBoxBatterySensor(FritzBoxSensor): - """The entity class for FRITZ!SmartHome battery sensors.""" + entity_description: FritzSensorEntityDescription @property - def native_value(self) -> int | None: + def native_value(self) -> float | int | None: """Return the state of the sensor.""" - return self.device.battery_level # type: ignore [no-any-return] - - -class FritzBoxPowerSensor(FritzBoxSensor): - """The entity class for FRITZ!SmartHome power consumption sensors.""" - - @property - def native_value(self) -> float | None: - """Return the state of the sensor.""" - if power := self.device.power: - return power / 1000 # type: ignore [no-any-return] - return 0.0 - - -class FritzBoxEnergySensor(FritzBoxSensor): - """The entity class for FRITZ!SmartHome total energy sensors.""" - - @property - def native_value(self) -> float | None: - """Return the state of the sensor.""" - if energy := self.device.energy: - return energy / 1000 # type: ignore [no-any-return] - return 0.0 - - -class FritzBoxTempSensor(FritzBoxSensor): - """The entity class for FRITZ!SmartHome temperature sensors.""" - - @property - def native_value(self) -> float | None: - """Return the state of the sensor.""" - return self.device.temperature # type: ignore [no-any-return] - - @property - def extra_state_attributes(self) -> SensorExtraAttributes: - """Return the state attributes of the device.""" - attrs: SensorExtraAttributes = { - ATTR_STATE_DEVICE_LOCKED: self.device.device_lock, - ATTR_STATE_LOCKED: self.device.lock, - } - return attrs + return self.entity_description.native_value(self.device) diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py index 133db92feda..79f256bded0 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -3,54 +3,28 @@ from __future__ import annotations from typing import Any -from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, -) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import FritzBoxEntity -from .const import ( - ATTR_STATE_DEVICE_LOCKED, - ATTR_STATE_LOCKED, - CONF_COORDINATOR, - DOMAIN as FRITZBOX_DOMAIN, -) -from .model import SwitchExtraAttributes +from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the FRITZ!SmartHome switch from ConfigEntry.""" - entities: list[FritzboxSwitch] = [] coordinator = hass.data[FRITZBOX_DOMAIN][entry.entry_id][CONF_COORDINATOR] - for ain, device in coordinator.data.items(): - if not device.has_switch: - continue - - entities.append( - FritzboxSwitch( - { - ATTR_NAME: f"{device.name}", - ATTR_ENTITY_ID: f"{device.ain}", - ATTR_UNIT_OF_MEASUREMENT: None, - ATTR_DEVICE_CLASS: None, - ATTR_STATE_CLASS: None, - }, - coordinator, - ain, - ) - ) - - async_add_entities(entities) + async_add_entities( + [ + FritzboxSwitch(coordinator, ain) + for ain, device in coordinator.data.items() + if device.has_switch + ] + ) class FritzboxSwitch(FritzBoxEntity, SwitchEntity): @@ -70,12 +44,3 @@ class FritzboxSwitch(FritzBoxEntity, SwitchEntity): """Turn the switch off.""" await self.hass.async_add_executor_job(self.device.set_switch_state_off) await self.coordinator.async_refresh() - - @property - def extra_state_attributes(self) -> SwitchExtraAttributes: - """Return the state attributes of the device.""" - attrs: SwitchExtraAttributes = { - ATTR_STATE_DEVICE_LOCKED: self.device.device_lock, - ATTR_STATE_LOCKED: self.device.lock, - } - return attrs