AVM Fritz!Box SmartHome: Integrate Templates (#81885)

This commit is contained in:
Ayk Borstelmann 2022-11-18 17:37:56 +01:00 committed by GitHub
parent f7badfe441
commit 3b783a85c3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 235 additions and 95 deletions

View file

@ -1,7 +1,10 @@
"""Support for AVM FRITZ!SmartHome devices.""" """Support for AVM FRITZ!SmartHome devices."""
from __future__ import annotations from __future__ import annotations
from abc import ABC, abstractmethod
from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError
from pyfritzhome.devicetypes.fritzhomeentitybase import FritzhomeEntityBase
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -93,7 +96,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return unload_ok return unload_ok
class FritzBoxEntity(CoordinatorEntity[FritzboxDataUpdateCoordinator]): class FritzBoxEntity(CoordinatorEntity[FritzboxDataUpdateCoordinator], ABC):
"""Basis FritzBox entity.""" """Basis FritzBox entity."""
def __init__( def __init__(
@ -108,30 +111,39 @@ class FritzBoxEntity(CoordinatorEntity[FritzboxDataUpdateCoordinator]):
self.ain = ain self.ain = ain
if entity_description is not None: if entity_description is not None:
self.entity_description = entity_description self.entity_description = entity_description
self._attr_name = f"{self.device.name} {entity_description.name}" self._attr_name = f"{self.entity.name} {entity_description.name}"
self._attr_unique_id = f"{ain}_{entity_description.key}" self._attr_unique_id = f"{ain}_{entity_description.key}"
else: else:
self._attr_name = self.device.name self._attr_name = self.entity.name
self._attr_unique_id = ain self._attr_unique_id = ain
@property
@abstractmethod
def entity(self) -> FritzhomeEntityBase:
"""Return entity object from coordinator."""
class FritzBoxDeviceEntity(FritzBoxEntity):
"""Reflects FritzhomeDevice and uses its attributes to construct FritzBoxDeviceEntity."""
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return if entity is available.""" """Return if entity is available."""
return super().available and self.device.present return super().available and self.entity.present
@property @property
def device(self) -> FritzhomeDevice: def entity(self) -> FritzhomeDevice:
"""Return device object from coordinator.""" """Return device object from coordinator."""
return self.coordinator.data[self.ain] return self.coordinator.data.devices[self.ain]
@property @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
"""Return device specific attributes.""" """Return device specific attributes."""
return DeviceInfo( return DeviceInfo(
name=self.device.name, name=self.entity.name,
identifiers={(DOMAIN, self.ain)}, identifiers={(DOMAIN, self.ain)},
manufacturer=self.device.manufacturer, manufacturer=self.entity.manufacturer,
model=self.device.productname, model=self.entity.productname,
sw_version=self.device.fw_version, sw_version=self.entity.fw_version,
configuration_url=self.coordinator.configuration_url, configuration_url=self.coordinator.configuration_url,
) )

View file

@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import FritzBoxEntity from . import FritzBoxDeviceEntity
from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN
from .coordinator import FritzboxDataUpdateCoordinator from .coordinator import FritzboxDataUpdateCoordinator
from .model import FritzEntityDescriptionMixinBase from .model import FritzEntityDescriptionMixinBase
@ -73,14 +73,14 @@ async def async_setup_entry(
async_add_entities( async_add_entities(
[ [
FritzboxBinarySensor(coordinator, ain, description) FritzboxBinarySensor(coordinator, ain, description)
for ain, device in coordinator.data.items() for ain, device in coordinator.data.devices.items()
for description in BINARY_SENSOR_TYPES for description in BINARY_SENSOR_TYPES
if description.suitable(device) if description.suitable(device)
] ]
) )
class FritzboxBinarySensor(FritzBoxEntity, BinarySensorEntity): class FritzboxBinarySensor(FritzBoxDeviceEntity, BinarySensorEntity):
"""Representation of a binary FRITZ!SmartHome device.""" """Representation of a binary FRITZ!SmartHome device."""
entity_description: FritzBinarySensorEntityDescription entity_description: FritzBinarySensorEntityDescription
@ -93,10 +93,10 @@ class FritzboxBinarySensor(FritzBoxEntity, BinarySensorEntity):
) -> None: ) -> None:
"""Initialize the FritzBox entity.""" """Initialize the FritzBox entity."""
super().__init__(coordinator, ain, entity_description) super().__init__(coordinator, ain, entity_description)
self._attr_name = f"{self.device.name} {entity_description.name}" self._attr_name = f"{self.entity.name} {entity_description.name}"
self._attr_unique_id = f"{ain}_{entity_description.key}" self._attr_unique_id = f"{ain}_{entity_description.key}"
@property @property
def is_on(self) -> bool | None: def is_on(self) -> bool | None:
"""Return true if sensor is on.""" """Return true if sensor is on."""
return self.entity_description.is_on(self.device) return self.entity_description.is_on(self.entity)

View file

@ -0,0 +1,56 @@
"""Support for AVM FRITZ!SmartHome templates."""
from pyfritzhome.devicetypes import FritzhomeTemplate
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import FritzboxDataUpdateCoordinator, FritzBoxEntity
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 template from ConfigEntry."""
coordinator: FritzboxDataUpdateCoordinator = hass.data[FRITZBOX_DOMAIN][
entry.entry_id
][CONF_COORDINATOR]
async_add_entities(
[
FritzBoxTemplate(coordinator, ain)
for ain in coordinator.data.templates.keys()
]
)
class FritzBoxTemplate(FritzBoxEntity, ButtonEntity):
"""Interface between FritzhomeTemplate and hass."""
@property
def entity(self) -> FritzhomeTemplate:
"""Return the template entity."""
return self.coordinator.data.templates[self.ain]
@property
def device_info(self) -> DeviceInfo:
"""Return device specific attributes."""
return DeviceInfo(
name=self.entity.name,
identifiers={(FRITZBOX_DOMAIN, self.ain)},
configuration_url=self.coordinator.configuration_url,
manufacturer="AVM",
model="SmartHome Template",
)
async def async_press(self) -> None:
"""Apply template and refresh."""
await self.hass.async_add_executor_job(self.apply_template)
await self.coordinator.async_refresh()
def apply_template(self) -> None:
"""Use Fritzhome to apply the template via ain."""
self.coordinator.fritz.apply_template(self.ain)

View file

@ -21,7 +21,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import FritzBoxEntity from . import FritzBoxDeviceEntity
from .const import ( from .const import (
ATTR_STATE_BATTERY_LOW, ATTR_STATE_BATTERY_LOW,
ATTR_STATE_HOLIDAY_MODE, ATTR_STATE_HOLIDAY_MODE,
@ -55,13 +55,13 @@ async def async_setup_entry(
async_add_entities( async_add_entities(
[ [
FritzboxThermostat(coordinator, ain) FritzboxThermostat(coordinator, ain)
for ain, device in coordinator.data.items() for ain, device in coordinator.data.devices.items()
if device.has_thermostat if device.has_thermostat
] ]
) )
class FritzboxThermostat(FritzBoxEntity, ClimateEntity): class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
"""The thermostat class for FRITZ!SmartHome thermostats.""" """The thermostat class for FRITZ!SmartHome thermostats."""
_attr_precision = PRECISION_HALVES _attr_precision = PRECISION_HALVES
@ -73,18 +73,18 @@ class FritzboxThermostat(FritzBoxEntity, ClimateEntity):
@property @property
def current_temperature(self) -> float: def current_temperature(self) -> float:
"""Return the current temperature.""" """Return the current temperature."""
if self.device.has_temperature_sensor and self.device.temperature is not None: if self.entity.has_temperature_sensor and self.entity.temperature is not None:
return self.device.temperature # type: ignore [no-any-return] return self.entity.temperature # type: ignore [no-any-return]
return self.device.actual_temperature # type: ignore [no-any-return] return self.entity.actual_temperature # type: ignore [no-any-return]
@property @property
def target_temperature(self) -> float: def target_temperature(self) -> float:
"""Return the temperature we try to reach.""" """Return the temperature we try to reach."""
if self.device.target_temperature == ON_API_TEMPERATURE: if self.entity.target_temperature == ON_API_TEMPERATURE:
return ON_REPORT_SET_TEMPERATURE return ON_REPORT_SET_TEMPERATURE
if self.device.target_temperature == OFF_API_TEMPERATURE: if self.entity.target_temperature == OFF_API_TEMPERATURE:
return OFF_REPORT_SET_TEMPERATURE return OFF_REPORT_SET_TEMPERATURE
return self.device.target_temperature # type: ignore [no-any-return] return self.entity.target_temperature # type: ignore [no-any-return]
async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature.""" """Set new target temperature."""
@ -94,14 +94,14 @@ class FritzboxThermostat(FritzBoxEntity, ClimateEntity):
elif kwargs.get(ATTR_TEMPERATURE) is not None: elif kwargs.get(ATTR_TEMPERATURE) is not None:
temperature = kwargs[ATTR_TEMPERATURE] temperature = kwargs[ATTR_TEMPERATURE]
await self.hass.async_add_executor_job( await self.hass.async_add_executor_job(
self.device.set_target_temperature, temperature self.entity.set_target_temperature, temperature
) )
await self.coordinator.async_refresh() await self.coordinator.async_refresh()
@property @property
def hvac_mode(self) -> str: def hvac_mode(self) -> str:
"""Return the current operation mode.""" """Return the current operation mode."""
if self.device.target_temperature in ( if self.entity.target_temperature in (
OFF_REPORT_SET_TEMPERATURE, OFF_REPORT_SET_TEMPERATURE,
OFF_API_TEMPERATURE, OFF_API_TEMPERATURE,
): ):
@ -120,15 +120,15 @@ class FritzboxThermostat(FritzBoxEntity, ClimateEntity):
await self.async_set_temperature(temperature=OFF_REPORT_SET_TEMPERATURE) await self.async_set_temperature(temperature=OFF_REPORT_SET_TEMPERATURE)
else: else:
await self.async_set_temperature( await self.async_set_temperature(
temperature=self.device.comfort_temperature temperature=self.entity.comfort_temperature
) )
@property @property
def preset_mode(self) -> str | None: def preset_mode(self) -> str | None:
"""Return current preset mode.""" """Return current preset mode."""
if self.device.target_temperature == self.device.comfort_temperature: if self.entity.target_temperature == self.entity.comfort_temperature:
return PRESET_COMFORT return PRESET_COMFORT
if self.device.target_temperature == self.device.eco_temperature: if self.entity.target_temperature == self.entity.eco_temperature:
return PRESET_ECO return PRESET_ECO
return None return None
@ -141,10 +141,10 @@ class FritzboxThermostat(FritzBoxEntity, ClimateEntity):
"""Set preset mode.""" """Set preset mode."""
if preset_mode == PRESET_COMFORT: if preset_mode == PRESET_COMFORT:
await self.async_set_temperature( await self.async_set_temperature(
temperature=self.device.comfort_temperature temperature=self.entity.comfort_temperature
) )
elif preset_mode == PRESET_ECO: elif preset_mode == PRESET_ECO:
await self.async_set_temperature(temperature=self.device.eco_temperature) await self.async_set_temperature(temperature=self.entity.eco_temperature)
@property @property
def min_temp(self) -> int: def min_temp(self) -> int:
@ -160,17 +160,17 @@ class FritzboxThermostat(FritzBoxEntity, ClimateEntity):
def extra_state_attributes(self) -> ClimateExtraAttributes: def extra_state_attributes(self) -> ClimateExtraAttributes:
"""Return the device specific state attributes.""" """Return the device specific state attributes."""
attrs: ClimateExtraAttributes = { attrs: ClimateExtraAttributes = {
ATTR_STATE_BATTERY_LOW: self.device.battery_low, ATTR_STATE_BATTERY_LOW: self.entity.battery_low,
} }
# the following attributes are available since fritzos 7 # the following attributes are available since fritzos 7
if self.device.battery_level is not None: if self.entity.battery_level is not None:
attrs[ATTR_BATTERY_LEVEL] = self.device.battery_level attrs[ATTR_BATTERY_LEVEL] = self.entity.battery_level
if self.device.holiday_active is not None: if self.entity.holiday_active is not None:
attrs[ATTR_STATE_HOLIDAY_MODE] = self.device.holiday_active attrs[ATTR_STATE_HOLIDAY_MODE] = self.entity.holiday_active
if self.device.summer_active is not None: if self.entity.summer_active is not None:
attrs[ATTR_STATE_SUMMER_MODE] = self.device.summer_active attrs[ATTR_STATE_SUMMER_MODE] = self.entity.summer_active
if self.device.window_open is not None: if self.entity.window_open is not None:
attrs[ATTR_STATE_WINDOW_OPEN] = self.device.window_open attrs[ATTR_STATE_WINDOW_OPEN] = self.entity.window_open
return attrs return attrs

View file

@ -26,6 +26,7 @@ LOGGER: Final[logging.Logger] = logging.getLogger(__package__)
PLATFORMS: Final[list[Platform]] = [ PLATFORMS: Final[list[Platform]] = [
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CLIMATE, Platform.CLIMATE,
Platform.COVER, Platform.COVER,
Platform.LIGHT, Platform.LIGHT,

View file

@ -1,9 +1,11 @@
"""Data update coordinator for AVM FRITZ!SmartHome devices.""" """Data update coordinator for AVM FRITZ!SmartHome devices."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError
from pyfritzhome.devicetypes import FritzhomeTemplate
import requests import requests
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -14,7 +16,15 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import CONF_CONNECTIONS, DOMAIN, LOGGER from .const import CONF_CONNECTIONS, DOMAIN, LOGGER
class FritzboxDataUpdateCoordinator(DataUpdateCoordinator): @dataclass
class FritzboxCoordinatorData:
"""Data Type of FritzboxDataUpdateCoordinator's data."""
devices: dict[str, FritzhomeDevice]
templates: dict[str, FritzhomeTemplate]
class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorData]):
"""Fritzbox Smarthome device data update coordinator.""" """Fritzbox Smarthome device data update coordinator."""
configuration_url: str configuration_url: str
@ -31,10 +41,11 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator):
update_interval=timedelta(seconds=30), update_interval=timedelta(seconds=30),
) )
def _update_fritz_devices(self) -> dict[str, FritzhomeDevice]: def _update_fritz_devices(self) -> FritzboxCoordinatorData:
"""Update all fritzbox device data.""" """Update all fritzbox device data."""
try: try:
self.fritz.update_devices() self.fritz.update_devices()
self.fritz.update_templates()
except requests.exceptions.ConnectionError as ex: except requests.exceptions.ConnectionError as ex:
raise UpdateFailed from ex raise UpdateFailed from ex
except requests.exceptions.HTTPError: except requests.exceptions.HTTPError:
@ -44,9 +55,10 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator):
except LoginError as ex: except LoginError as ex:
raise ConfigEntryAuthFailed from ex raise ConfigEntryAuthFailed from ex
self.fritz.update_devices() self.fritz.update_devices()
self.fritz.update_templates()
devices = self.fritz.get_devices() devices = self.fritz.get_devices()
data = {} device_data = {}
for device in devices: for device in devices:
# assume device as unavailable, see #55799 # assume device as unavailable, see #55799
if ( if (
@ -61,9 +73,15 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator):
LOGGER.debug("Assume device %s as unavailable", device.name) LOGGER.debug("Assume device %s as unavailable", device.name)
device.present = False device.present = False
data[device.ain] = device device_data[device.ain] = device
return data
async def _async_update_data(self) -> dict[str, FritzhomeDevice]: templates = self.fritz.get_templates()
template_data = {}
for template in templates:
template_data[template.ain] = template
return FritzboxCoordinatorData(devices=device_data, templates=template_data)
async def _async_update_data(self) -> FritzboxCoordinatorData:
"""Fetch all device data.""" """Fetch all device data."""
return await self.hass.async_add_executor_job(self._update_fritz_devices) return await self.hass.async_add_executor_job(self._update_fritz_devices)

View file

@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import FritzBoxEntity from . import FritzBoxDeviceEntity
from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN
@ -25,12 +25,12 @@ async def async_setup_entry(
async_add_entities( async_add_entities(
FritzboxCover(coordinator, ain) FritzboxCover(coordinator, ain)
for ain, device in coordinator.data.items() for ain, device in coordinator.data.devices.items()
if device.has_blind if device.has_blind
) )
class FritzboxCover(FritzBoxEntity, CoverEntity): class FritzboxCover(FritzBoxDeviceEntity, CoverEntity):
"""The cover class for FRITZ!SmartHome covers.""" """The cover class for FRITZ!SmartHome covers."""
_attr_device_class = CoverDeviceClass.BLIND _attr_device_class = CoverDeviceClass.BLIND
@ -45,34 +45,34 @@ class FritzboxCover(FritzBoxEntity, CoverEntity):
def current_cover_position(self) -> int | None: def current_cover_position(self) -> int | None:
"""Return the current position.""" """Return the current position."""
position = None position = None
if self.device.levelpercentage is not None: if self.entity.levelpercentage is not None:
position = 100 - self.device.levelpercentage position = 100 - self.entity.levelpercentage
return position return position
@property @property
def is_closed(self) -> bool | None: def is_closed(self) -> bool | None:
"""Return if the cover is closed.""" """Return if the cover is closed."""
if self.device.levelpercentage is None: if self.entity.levelpercentage is None:
return None return None
return self.device.levelpercentage == 100 # type: ignore [no-any-return] return self.entity.levelpercentage == 100 # type: ignore [no-any-return]
async def async_open_cover(self, **kwargs: Any) -> None: async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover.""" """Open the cover."""
await self.hass.async_add_executor_job(self.device.set_blind_open) await self.hass.async_add_executor_job(self.entity.set_blind_open)
await self.coordinator.async_refresh() await self.coordinator.async_refresh()
async def async_close_cover(self, **kwargs: Any) -> None: async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover.""" """Close the cover."""
await self.hass.async_add_executor_job(self.device.set_blind_close) await self.hass.async_add_executor_job(self.entity.set_blind_close)
await self.coordinator.async_refresh() await self.coordinator.async_refresh()
async def async_set_cover_position(self, **kwargs: Any) -> None: async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position.""" """Move the cover to a specific position."""
await self.hass.async_add_executor_job( await self.hass.async_add_executor_job(
self.device.set_level_percentage, 100 - kwargs[ATTR_POSITION] self.entity.set_level_percentage, 100 - kwargs[ATTR_POSITION]
) )
async def async_stop_cover(self, **kwargs: Any) -> None: async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover.""" """Stop the cover."""
await self.hass.async_add_executor_job(self.device.set_blind_stop) await self.hass.async_add_executor_job(self.entity.set_blind_stop)
await self.coordinator.async_refresh() await self.coordinator.async_refresh()

View file

@ -23,11 +23,13 @@ async def async_get_config_entry_diagnostics(
"entry": async_redact_data(entry.as_dict(), TO_REDACT), "entry": async_redact_data(entry.as_dict(), TO_REDACT),
"data": {}, "data": {},
} }
if not isinstance(coordinator.data, dict):
return diag_data
entities: dict[str, dict] = {
**coordinator.data.devices,
**coordinator.data.templates,
}
diag_data["data"] = { diag_data["data"] = {
ain: {k: v for k, v in vars(dev).items() if not k.startswith("_")} ain: {k: v for k, v in vars(entity).items() if not k.startswith("_")}
for ain, dev in coordinator.data.items() for ain, entity in entities.items()
} }
return diag_data return diag_data

View file

@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import FritzBoxEntity from . import FritzBoxDeviceEntity
from .const import ( from .const import (
COLOR_MODE, COLOR_MODE,
COLOR_TEMP_MODE, COLOR_TEMP_MODE,
@ -36,7 +36,7 @@ async def async_setup_entry(
entities: list[FritzboxLight] = [] entities: list[FritzboxLight] = []
coordinator = hass.data[FRITZBOX_DOMAIN][entry.entry_id][CONF_COORDINATOR] coordinator = hass.data[FRITZBOX_DOMAIN][entry.entry_id][CONF_COORDINATOR]
for ain, device in coordinator.data.items(): for ain, device in coordinator.data.devices.items():
if not device.has_lightbulb: if not device.has_lightbulb:
continue continue
@ -58,7 +58,7 @@ async def async_setup_entry(
async_add_entities(entities) async_add_entities(entities)
class FritzboxLight(FritzBoxEntity, LightEntity): class FritzboxLight(FritzBoxDeviceEntity, LightEntity):
"""The light class for FRITZ!SmartHome lightbulbs.""" """The light class for FRITZ!SmartHome lightbulbs."""
def __init__( def __init__(
@ -88,36 +88,36 @@ class FritzboxLight(FritzBoxEntity, LightEntity):
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""If the light is currently on or off.""" """If the light is currently on or off."""
return self.device.state # type: ignore [no-any-return] return self.entity.state # type: ignore [no-any-return]
@property @property
def brightness(self) -> int: def brightness(self) -> int:
"""Return the current Brightness.""" """Return the current Brightness."""
return self.device.level # type: ignore [no-any-return] return self.entity.level # type: ignore [no-any-return]
@property @property
def hs_color(self) -> tuple[float, float] | None: def hs_color(self) -> tuple[float, float] | None:
"""Return the hs color value.""" """Return the hs color value."""
if self.device.color_mode != COLOR_MODE: if self.entity.color_mode != COLOR_MODE:
return None return None
hue = self.device.hue hue = self.entity.hue
saturation = self.device.saturation saturation = self.entity.saturation
return (hue, float(saturation) * 100.0 / 255.0) return (hue, float(saturation) * 100.0 / 255.0)
@property @property
def color_temp_kelvin(self) -> int | None: def color_temp_kelvin(self) -> int | None:
"""Return the CT color value.""" """Return the CT color value."""
if self.device.color_mode != COLOR_TEMP_MODE: if self.entity.color_mode != COLOR_TEMP_MODE:
return None return None
return self.device.color_temp # type: ignore [no-any-return] return self.entity.color_temp # type: ignore [no-any-return]
@property @property
def color_mode(self) -> ColorMode: def color_mode(self) -> ColorMode:
"""Return the color mode of the light.""" """Return the color mode of the light."""
if self.device.color_mode == COLOR_MODE: if self.entity.color_mode == COLOR_MODE:
return ColorMode.HS return ColorMode.HS
return ColorMode.COLOR_TEMP return ColorMode.COLOR_TEMP
@ -130,7 +130,7 @@ class FritzboxLight(FritzBoxEntity, LightEntity):
"""Turn the light on.""" """Turn the light on."""
if kwargs.get(ATTR_BRIGHTNESS) is not None: if kwargs.get(ATTR_BRIGHTNESS) is not None:
level = kwargs[ATTR_BRIGHTNESS] level = kwargs[ATTR_BRIGHTNESS]
await self.hass.async_add_executor_job(self.device.set_level, level) await self.hass.async_add_executor_job(self.entity.set_level, level)
if kwargs.get(ATTR_HS_COLOR) is not None: if kwargs.get(ATTR_HS_COLOR) is not None:
# Try setunmappedcolor first. This allows free color selection, # Try setunmappedcolor first. This allows free color selection,
# but we don't know if its supported by all devices. # but we don't know if its supported by all devices.
@ -139,7 +139,7 @@ class FritzboxLight(FritzBoxEntity, LightEntity):
unmapped_hue = int(kwargs[ATTR_HS_COLOR][0] % 360) unmapped_hue = int(kwargs[ATTR_HS_COLOR][0] % 360)
unmapped_saturation = round(kwargs[ATTR_HS_COLOR][1] * 255.0 / 100.0) unmapped_saturation = round(kwargs[ATTR_HS_COLOR][1] * 255.0 / 100.0)
await self.hass.async_add_executor_job( await self.hass.async_add_executor_job(
self.device.set_unmapped_color, (unmapped_hue, unmapped_saturation) self.entity.set_unmapped_color, (unmapped_hue, unmapped_saturation)
) )
# This will raise 400 BAD REQUEST if the setunmappedcolor is not available # This will raise 400 BAD REQUEST if the setunmappedcolor is not available
except HTTPError as err: except HTTPError as err:
@ -157,18 +157,18 @@ class FritzboxLight(FritzBoxEntity, LightEntity):
key=lambda x: abs(x - unmapped_saturation), key=lambda x: abs(x - unmapped_saturation),
) )
await self.hass.async_add_executor_job( await self.hass.async_add_executor_job(
self.device.set_color, (hue, saturation) self.entity.set_color, (hue, saturation)
) )
if kwargs.get(ATTR_COLOR_TEMP_KELVIN) is not None: if kwargs.get(ATTR_COLOR_TEMP_KELVIN) is not None:
await self.hass.async_add_executor_job( await self.hass.async_add_executor_job(
self.device.set_color_temp, kwargs[ATTR_COLOR_TEMP_KELVIN] self.entity.set_color_temp, kwargs[ATTR_COLOR_TEMP_KELVIN]
) )
await self.hass.async_add_executor_job(self.device.set_state_on) await self.hass.async_add_executor_job(self.entity.set_state_on)
await self.coordinator.async_refresh() await self.coordinator.async_refresh()
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off.""" """Turn the light off."""
await self.hass.async_add_executor_job(self.device.set_state_off) await self.hass.async_add_executor_job(self.entity.set_state_off)
await self.coordinator.async_refresh() await self.coordinator.async_refresh()

View file

@ -30,7 +30,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utc_from_timestamp from homeassistant.util.dt import utc_from_timestamp
from . import FritzBoxEntity from . import FritzBoxDeviceEntity
from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN
from .model import FritzEntityDescriptionMixinBase from .model import FritzEntityDescriptionMixinBase
@ -220,14 +220,14 @@ async def async_setup_entry(
async_add_entities( async_add_entities(
[ [
FritzBoxSensor(coordinator, ain, description) FritzBoxSensor(coordinator, ain, description)
for ain, device in coordinator.data.items() for ain, device in coordinator.data.devices.items()
for description in SENSOR_TYPES for description in SENSOR_TYPES
if description.suitable(device) if description.suitable(device)
] ]
) )
class FritzBoxSensor(FritzBoxEntity, SensorEntity): class FritzBoxSensor(FritzBoxDeviceEntity, SensorEntity):
"""The entity class for FRITZ!SmartHome sensors.""" """The entity class for FRITZ!SmartHome sensors."""
entity_description: FritzSensorEntityDescription entity_description: FritzSensorEntityDescription
@ -235,4 +235,4 @@ class FritzBoxSensor(FritzBoxEntity, SensorEntity):
@property @property
def native_value(self) -> StateType | datetime: def native_value(self) -> StateType | datetime:
"""Return the state of the sensor.""" """Return the state of the sensor."""
return self.entity_description.native_value(self.device) return self.entity_description.native_value(self.entity)

View file

@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import FritzBoxEntity from . import FritzBoxDeviceEntity
from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN from .const import CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN
@ -21,26 +21,26 @@ async def async_setup_entry(
async_add_entities( async_add_entities(
[ [
FritzboxSwitch(coordinator, ain) FritzboxSwitch(coordinator, ain)
for ain, device in coordinator.data.items() for ain, device in coordinator.data.devices.items()
if device.has_switch if device.has_switch
] ]
) )
class FritzboxSwitch(FritzBoxEntity, SwitchEntity): class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity):
"""The switch class for FRITZ!SmartHome switches.""" """The switch class for FRITZ!SmartHome switches."""
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return true if the switch is on.""" """Return true if the switch is on."""
return self.device.switch_state # type: ignore [no-any-return] return self.entity.switch_state # type: ignore [no-any-return]
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on.""" """Turn the switch on."""
await self.hass.async_add_executor_job(self.device.set_switch_state_on) await self.hass.async_add_executor_job(self.entity.set_switch_state_on)
await self.coordinator.async_refresh() await self.coordinator.async_refresh()
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off.""" """Turn the switch off."""
await self.hass.async_add_executor_job(self.device.set_switch_state_off) await self.hass.async_add_executor_job(self.entity.set_switch_state_off)
await self.coordinator.async_refresh() await self.coordinator.async_refresh()

View file

@ -24,6 +24,7 @@ async def setup_config_entry(
unique_id: str = "any", unique_id: str = "any",
device: Mock = None, device: Mock = None,
fritz: Mock = None, fritz: Mock = None,
template: Mock = None,
) -> bool: ) -> bool:
"""Do setup of a MockConfigEntry.""" """Do setup of a MockConfigEntry."""
entry = MockConfigEntry( entry = MockConfigEntry(
@ -34,13 +35,17 @@ async def setup_config_entry(
entry.add_to_hass(hass) entry.add_to_hass(hass)
if device is not None and fritz is not None: if device is not None and fritz is not None:
fritz().get_devices.return_value = [device] fritz().get_devices.return_value = [device]
if template is not None and fritz is not None:
fritz().get_templates.return_value = [template]
result = await hass.config_entries.async_setup(entry.entry_id) result = await hass.config_entries.async_setup(entry.entry_id)
if device is not None: if device is not None:
await hass.async_block_till_done() await hass.async_block_till_done()
return result return result
class FritzDeviceBaseMock(Mock): class FritzEntityBaseMock(Mock):
"""base mock of a AVM Fritz!Box binary sensor device.""" """base mock of a AVM Fritz!Box binary sensor device."""
ain = CONF_FAKE_AIN ain = CONF_FAKE_AIN
@ -49,7 +54,7 @@ class FritzDeviceBaseMock(Mock):
productname = CONF_FAKE_PRODUCTNAME productname = CONF_FAKE_PRODUCTNAME
class FritzDeviceBinarySensorMock(FritzDeviceBaseMock): class FritzDeviceBinarySensorMock(FritzEntityBaseMock):
"""Mock of a AVM Fritz!Box binary sensor device.""" """Mock of a AVM Fritz!Box binary sensor device."""
alert_state = "fake_state" alert_state = "fake_state"
@ -65,7 +70,7 @@ class FritzDeviceBinarySensorMock(FritzDeviceBaseMock):
present = True present = True
class FritzDeviceClimateMock(FritzDeviceBaseMock): class FritzDeviceClimateMock(FritzEntityBaseMock):
"""Mock of a AVM Fritz!Box climate device.""" """Mock of a AVM Fritz!Box climate device."""
actual_temperature = 18.0 actual_temperature = 18.0
@ -96,7 +101,7 @@ class FritzDeviceClimateMock(FritzDeviceBaseMock):
scheduled_preset = PRESET_ECO scheduled_preset = PRESET_ECO
class FritzDeviceSensorMock(FritzDeviceBaseMock): class FritzDeviceSensorMock(FritzEntityBaseMock):
"""Mock of a AVM Fritz!Box sensor device.""" """Mock of a AVM Fritz!Box sensor device."""
battery_level = 23 battery_level = 23
@ -115,7 +120,7 @@ class FritzDeviceSensorMock(FritzDeviceBaseMock):
rel_humidity = 42 rel_humidity = 42
class FritzDeviceSwitchMock(FritzDeviceBaseMock): class FritzDeviceSwitchMock(FritzEntityBaseMock):
"""Mock of a AVM Fritz!Box switch device.""" """Mock of a AVM Fritz!Box switch device."""
battery_level = None battery_level = None
@ -137,7 +142,7 @@ class FritzDeviceSwitchMock(FritzDeviceBaseMock):
temperature = 1.23 temperature = 1.23
class FritzDeviceLightMock(FritzDeviceBaseMock): class FritzDeviceLightMock(FritzEntityBaseMock):
"""Mock of a AVM Fritz!Box light device.""" """Mock of a AVM Fritz!Box light device."""
fw_version = "1.2.3" fw_version = "1.2.3"
@ -153,7 +158,7 @@ class FritzDeviceLightMock(FritzDeviceBaseMock):
state = True state = True
class FritzDeviceCoverMock(FritzDeviceBaseMock): class FritzDeviceCoverMock(FritzEntityBaseMock):
"""Mock of a AVM Fritz!Box cover device.""" """Mock of a AVM Fritz!Box cover device."""
fw_version = "1.2.3" fw_version = "1.2.3"

View file

@ -0,0 +1,43 @@
"""Tests for AVM Fritz!Box templates."""
from unittest.mock import Mock
from homeassistant.components.button import DOMAIN, SERVICE_PRESS
from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_FRIENDLY_NAME,
CONF_DEVICES,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
from . import FritzEntityBaseMock, setup_config_entry
from .const import CONF_FAKE_NAME, MOCK_CONFIG
ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}"
async def test_setup(hass: HomeAssistant, fritz: Mock):
"""Test if is initialized correctly."""
template = FritzEntityBaseMock()
assert await setup_config_entry(
hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], fritz=fritz, template=template
)
state = hass.states.get(ENTITY_ID)
assert state
assert state.attributes[ATTR_FRIENDLY_NAME] == CONF_FAKE_NAME
assert state.state == STATE_UNKNOWN
async def test_apply_template(hass: HomeAssistant, fritz: Mock):
"""Test if applies works."""
template = FritzEntityBaseMock()
assert await setup_config_entry(
hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], fritz=fritz, template=template
)
assert await hass.services.async_call(
DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: ENTITY_ID}, True
)
assert fritz().apply_template.call_count == 1

View file

@ -167,7 +167,9 @@ async def test_coordinator_update_after_reboot(hass: HomeAssistant, fritz: Mock)
assert await hass.config_entries.async_setup(entry.entry_id) assert await hass.config_entries.async_setup(entry.entry_id)
assert fritz().update_devices.call_count == 2 assert fritz().update_devices.call_count == 2
assert fritz().update_templates.call_count == 1
assert fritz().get_devices.call_count == 1 assert fritz().get_devices.call_count == 1
assert fritz().get_templates.call_count == 1
assert fritz().login.call_count == 2 assert fritz().login.call_count == 2
@ -187,6 +189,7 @@ async def test_coordinator_update_after_password_change(
assert not await hass.config_entries.async_setup(entry.entry_id) assert not await hass.config_entries.async_setup(entry.entry_id)
assert fritz().update_devices.call_count == 1 assert fritz().update_devices.call_count == 1
assert fritz().get_devices.call_count == 0 assert fritz().get_devices.call_count == 0
assert fritz().get_templates.call_count == 0
assert fritz().login.call_count == 2 assert fritz().login.call_count == 2