Compare commits

...

22 Commits

Author SHA1 Message Date
Martin Hjelmare
20211eb225 Update gateway response 2025-02-12 20:16:52 +01:00
Martin Hjelmare
8ab627cd44 Fix test typing 2025-02-12 11:53:35 +01:00
Martin Hjelmare
4870ea907d Bump pytradfri to 14.0.0 2025-02-12 10:13:47 +01:00
Martin Hjelmare
68888100f6 Fix last test 2025-02-10 14:13:28 +01:00
Martin Hjelmare
4e81f3ac15 Fix more tests 2025-02-10 14:02:02 +01:00
Martin Hjelmare
dcefdc7bf2 Fix light after rebase 2025-02-10 14:01:04 +01:00
Martin Hjelmare
99e2ac2d2f Fix some tests but not all 2025-02-10 13:12:56 +01:00
Martin Hjelmare
f65693b6e8 Bump pytradfri to 12.0.0 2025-02-10 12:37:12 +01:00
Martin Hjelmare
380f0c4588 Use combines commands in tradfri light 2025-02-10 12:36:17 +01:00
Martin Hjelmare
136ffc898b Adjust tradfri light supported features 2025-02-10 12:36:17 +01:00
Martin Hjelmare
5474fd77b2 Fix tradfri light typing except command addition 2025-02-10 12:31:13 +01:00
Martin Hjelmare
7b43714adf Fix tradfri sensor typing 2025-02-10 12:21:03 +01:00
Martin Hjelmare
ab903f7fea Fix tradfri switch typing 2025-02-10 12:19:17 +01:00
Martin Hjelmare
8774d4ae75 Fix tradfri fan typing 2025-02-10 12:18:52 +01:00
Martin Hjelmare
e7cc87be4c Fix tradfri cover typing 2025-02-10 12:17:01 +01:00
Martin Hjelmare
951ae92668 Migrate tradfri device identifier 2025-02-10 12:17:00 +01:00
Martin Hjelmare
f323289d2a Fix tradfri reachable not needed cast 2025-02-10 12:15:09 +01:00
Martin Hjelmare
b66bdd444e Use dataclass for tradfri hass.data 2025-02-10 12:15:09 +01:00
Martin Hjelmare
e04bb5932d Copy device identifer to string 2025-02-10 12:10:25 +01:00
Martin Hjelmare
b21f69ab7b Use APIRequestProtocol 2025-02-10 12:10:25 +01:00
Martin Hjelmare
00ab475d0f Fix observe update callback 2025-02-10 12:08:01 +01:00
Martin Hjelmare
81114f4f82 Improve tradfri typing 2025-02-10 12:08:01 +01:00
21 changed files with 329 additions and 248 deletions

View File

@@ -3,11 +3,9 @@
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
from pytradfri import Gateway, RequestError
from pytradfri.api.aiocoap_api import APIFactory
from pytradfri.command import Command
from pytradfri.device import Device
from homeassistant.config_entries import ConfigEntry
@@ -21,18 +19,10 @@ from homeassistant.helpers.dispatcher import (
)
from homeassistant.helpers.event import async_track_time_interval
from .const import (
CONF_GATEWAY_ID,
CONF_IDENTITY,
CONF_KEY,
COORDINATOR,
COORDINATOR_LIST,
DOMAIN,
FACTORY,
KEY_API,
LOGGER,
)
from .const import CONF_GATEWAY_ID, CONF_IDENTITY, CONF_KEY, DOMAIN, LOGGER
from .coordinator import TradfriDeviceDataUpdateCoordinator
from .migration import migrate_device_identifier
from .models import TradfriData
PLATFORMS = [
Platform.COVER,
@@ -50,15 +40,14 @@ async def async_setup_entry(
entry: ConfigEntry,
) -> bool:
"""Create a gateway."""
tradfri_data: dict[str, Any] = {}
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = tradfri_data
# Migrate old integer device identifier to string, added in core-2022.7.0.
migrate_device_identifier(hass, entry)
factory = await APIFactory.init(
entry.data[CONF_HOST],
psk_id=entry.data[CONF_IDENTITY],
psk=entry.data[CONF_KEY],
)
tradfri_data[FACTORY] = factory # Used for async_unload_entry
async def on_hass_stop(event: Event) -> None:
"""Close connection when hass stops."""
@@ -74,10 +63,8 @@ async def async_setup_entry(
try:
gateway_info = await api(gateway.get_gateway_info(), timeout=TIMEOUT_API)
devices_commands: Command = await api(
gateway.get_devices(), timeout=TIMEOUT_API
)
devices: list[Device] = await api(devices_commands, timeout=TIMEOUT_API)
devices_commands = await api(gateway.get_devices(), timeout=TIMEOUT_API)
devices = await api(devices_commands, timeout=TIMEOUT_API)
except RequestError as exc:
await factory.shutdown()
@@ -98,11 +85,7 @@ async def async_setup_entry(
remove_stale_devices(hass, entry, devices)
# Setup the device coordinators
coordinator_data = {
CONF_GATEWAY_ID: gateway,
KEY_API: api,
COORDINATOR_LIST: [],
}
coordinators: list[TradfriDeviceDataUpdateCoordinator] = []
for device in devices:
coordinator = TradfriDeviceDataUpdateCoordinator(
@@ -113,9 +96,12 @@ async def async_setup_entry(
entry.async_on_unload(
async_dispatcher_connect(hass, SIGNAL_GW, coordinator.set_hub_available)
)
coordinator_data[COORDINATOR_LIST].append(coordinator)
coordinators.append(coordinator)
tradfri_data[COORDINATOR] = coordinator_data
tradfri_data = TradfriData(
api=api, coordinators=coordinators, factory=factory, gateway=gateway
)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = tradfri_data
async def async_keep_alive(now: datetime) -> None:
if hass.is_stopping:
@@ -143,8 +129,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
tradfri_data = hass.data[DOMAIN].pop(entry.entry_id)
factory = tradfri_data[FACTORY]
tradfri_data: TradfriData = hass.data[DOMAIN].pop(entry.entry_id)
factory = tradfri_data.factory
await factory.shutdown()
return unload_ok
@@ -159,7 +145,7 @@ def remove_stale_devices(
device_entries = dr.async_entries_for_config_entry(
device_registry, config_entry.entry_id
)
all_device_ids = {device.id for device in devices}
all_device_ids = {str(device.id) for device in devices}
for device_entry in device_entries:
device_id: str | None = None
@@ -169,7 +155,9 @@ def remove_stale_devices(
if identifier[0] != DOMAIN:
continue
_id = identifier[1]
# The device id in the identifier was not copied from integer to string
# when setting entity device info. Copy here to make sure it's a string.
_id = str(identifier[1])
# Identify gateway device.
if _id == config_entry.data[CONF_GATEWAY_ID]:

View File

@@ -7,8 +7,4 @@ LOGGER = logging.getLogger(__package__)
CONF_GATEWAY_ID = "gateway_id"
CONF_IDENTITY = "identity"
CONF_KEY = "key"
COORDINATOR = "coordinator"
COORDINATOR_LIST = "coordinator_list"
DOMAIN = "tradfri"
FACTORY = "tradfri_factory"
KEY_API = "tradfri_api"

View File

@@ -2,13 +2,13 @@
from __future__ import annotations
from collections.abc import Callable
from datetime import timedelta
from typing import Any
from typing import cast
from pytradfri.command import Command
from pytradfri.api.aiocoap_api import APIRequestProtocol
from pytradfri.device import Device
from pytradfri.error import RequestError
from pytradfri.resource import ApiResource
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
@@ -27,8 +27,9 @@ class TradfriDeviceDataUpdateCoordinator(DataUpdateCoordinator[Device]):
def __init__(
self,
hass: HomeAssistant,
*,
config_entry: ConfigEntry,
api: Callable[[Command | list[Command]], Any],
api: APIRequestProtocol,
device: Device,
) -> None:
"""Initialize device coordinator."""
@@ -52,8 +53,9 @@ class TradfriDeviceDataUpdateCoordinator(DataUpdateCoordinator[Device]):
await self.async_request_refresh()
@callback
def _observe_update(self, device: Device) -> None:
def _observe_update(self, device: ApiResource) -> None:
"""Update the coordinator for a device when a change is detected."""
device = cast(Device, device)
self.async_set_updated_data(data=device)
@callback

View File

@@ -2,19 +2,19 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any, cast
from typing import Any
from pytradfri.command import Command
from pytradfri.api.aiocoap_api import APIRequestProtocol
from homeassistant.components.cover import ATTR_POSITION, CoverEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API
from .const import CONF_GATEWAY_ID, DOMAIN
from .coordinator import TradfriDeviceDataUpdateCoordinator
from .entity import TradfriBaseEntity
from .models import TradfriData
async def async_setup_entry(
@@ -24,8 +24,8 @@ async def async_setup_entry(
) -> None:
"""Load Tradfri covers based on a config entry."""
gateway_id = config_entry.data[CONF_GATEWAY_ID]
coordinator_data = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR]
api = coordinator_data[KEY_API]
tradfri_data: TradfriData = hass.data[DOMAIN][config_entry.entry_id]
api = tradfri_data.api
async_add_entities(
TradfriCover(
@@ -33,7 +33,7 @@ async def async_setup_entry(
api,
gateway_id,
)
for device_coordinator in coordinator_data[COORDINATOR_LIST]
for device_coordinator in tradfri_data.coordinators
if device_coordinator.device.has_blind_control
)
@@ -46,7 +46,7 @@ class TradfriCover(TradfriBaseEntity, CoverEntity):
def __init__(
self,
device_coordinator: TradfriDeviceDataUpdateCoordinator,
api: Callable[[Command | list[Command]], Any],
api: APIRequestProtocol,
gateway_id: str,
) -> None:
"""Initialize a switch."""
@@ -56,12 +56,14 @@ class TradfriCover(TradfriBaseEntity, CoverEntity):
gateway_id=gateway_id,
)
self._device_control = self._device.blind_control
self._device_data = self._device_control.blinds[0]
device_control = self._device.blind_control
assert device_control # blind_control is ensured when creating the entity
self._device_control = device_control
self._device_data = device_control.blinds[0]
def _refresh(self) -> None:
"""Refresh the device."""
self._device_data = self.coordinator.data.blind_control.blinds[0]
self._device_data = self._device_control.blinds[0]
@property
def extra_state_attributes(self) -> dict[str, str] | None:
@@ -74,32 +76,22 @@ class TradfriCover(TradfriBaseEntity, CoverEntity):
None is unknown, 0 is closed, 100 is fully open.
"""
if not self._device_data:
return None
return 100 - cast(int, self._device_data.current_cover_position)
return 100 - self._device_data.current_cover_position
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
if not self._device_control:
return
await self._api(self._device_control.set_state(100 - kwargs[ATTR_POSITION]))
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
if not self._device_control:
return
await self._api(self._device_control.set_state(0))
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close cover."""
if not self._device_control:
return
await self._api(self._device_control.set_state(100))
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Close cover."""
if not self._device_control:
return
await self._api(self._device_control.trigger_blind())
@property

View File

@@ -8,15 +8,15 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN
from .const import CONF_GATEWAY_ID, DOMAIN
from .models import TradfriData
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics the Tradfri platform."""
entry_data = hass.data[DOMAIN][entry.entry_id]
coordinator_data = entry_data[COORDINATOR]
tradfri_data: TradfriData = hass.data[DOMAIN][entry.entry_id]
device_registry = dr.async_get(hass)
device = cast(
@@ -28,7 +28,7 @@ async def async_get_config_entry_diagnostics(
device_data: list = [
coordinator.device.device_info.model_number
for coordinator in coordinator_data[COORDINATOR_LIST]
for coordinator in tradfri_data.coordinators
]
return {

View File

@@ -5,8 +5,9 @@ from __future__ import annotations
from abc import abstractmethod
from collections.abc import Callable, Coroutine
from functools import wraps
from typing import Any, cast
from typing import Any
from pytradfri.api.aiocoap_api import APIRequestProtocol
from pytradfri.command import Command
from pytradfri.device import Device
from pytradfri.error import RequestError
@@ -20,7 +21,7 @@ from .coordinator import TradfriDeviceDataUpdateCoordinator
def handle_error(
func: Callable[[Command | list[Command]], Any],
func: APIRequestProtocol,
) -> Callable[[Command | list[Command]], Coroutine[Any, Any, None]]:
"""Handle tradfri api call error."""
@@ -44,7 +45,7 @@ class TradfriBaseEntity(CoordinatorEntity[TradfriDeviceDataUpdateCoordinator]):
self,
device_coordinator: TradfriDeviceDataUpdateCoordinator,
gateway_id: str,
api: Callable[[Command | list[Command]], Any],
api: APIRequestProtocol,
) -> None:
"""Initialize a device."""
super().__init__(device_coordinator)
@@ -58,7 +59,7 @@ class TradfriBaseEntity(CoordinatorEntity[TradfriDeviceDataUpdateCoordinator]):
info = self._device.device_info
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._device_id)},
identifiers={(DOMAIN, str(self._device.id))},
manufacturer=info.manufacturer,
model=info.model_number,
name=self._device.name,
@@ -84,4 +85,4 @@ class TradfriBaseEntity(CoordinatorEntity[TradfriDeviceDataUpdateCoordinator]):
@property
def available(self) -> bool:
"""Return if entity is available."""
return cast(bool, self._device.reachable) and super().available
return self._device.reachable and super().available

View File

@@ -2,19 +2,19 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any, cast
from typing import Any
from pytradfri.command import Command
from pytradfri.api.aiocoap_api import APIRequestProtocol
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API
from .const import CONF_GATEWAY_ID, DOMAIN
from .coordinator import TradfriDeviceDataUpdateCoordinator
from .entity import TradfriBaseEntity
from .models import TradfriData
ATTR_AUTO = "Auto"
ATTR_MAX_FAN_STEPS = 49
@@ -37,8 +37,8 @@ async def async_setup_entry(
) -> None:
"""Load Tradfri switches based on a config entry."""
gateway_id = config_entry.data[CONF_GATEWAY_ID]
coordinator_data = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR]
api = coordinator_data[KEY_API]
tradfri_data: TradfriData = hass.data[DOMAIN][config_entry.entry_id]
api = tradfri_data.api
async_add_entities(
TradfriAirPurifierFan(
@@ -46,7 +46,7 @@ async def async_setup_entry(
api,
gateway_id,
)
for device_coordinator in coordinator_data[COORDINATOR_LIST]
for device_coordinator in tradfri_data.coordinators
if device_coordinator.device.has_air_purifier_control
)
@@ -73,7 +73,7 @@ class TradfriAirPurifierFan(TradfriBaseEntity, FanEntity):
def __init__(
self,
device_coordinator: TradfriDeviceDataUpdateCoordinator,
api: Callable[[Command | list[Command]], Any],
api: APIRequestProtocol,
gateway_id: str,
) -> None:
"""Initialize a switch."""
@@ -83,37 +83,30 @@ class TradfriAirPurifierFan(TradfriBaseEntity, FanEntity):
gateway_id=gateway_id,
)
self._device_control = self._device.air_purifier_control
self._device_data = self._device_control.air_purifiers[0]
device_control = self._device.air_purifier_control
assert (
device_control # air_purifier_control is ensured when creating the entity
)
self._device_control = device_control
self._device_data = device_control.air_purifiers[0]
def _refresh(self) -> None:
"""Refresh the device."""
self._device_data = self.coordinator.data.air_purifier_control.air_purifiers[0]
self._device_data = self._device_control.air_purifiers[0]
@property
def is_on(self) -> bool:
"""Return true if switch is on."""
if not self._device_data:
return False
return cast(bool, self._device_data.state)
return self._device_data.state
@property
def percentage(self) -> int | None:
def percentage(self) -> int:
"""Return the current speed percentage."""
if not self._device_data:
return None
if self._device_data.fan_speed:
return _from_fan_speed(self._device_data.fan_speed)
return None
return _from_fan_speed(self._device_data.fan_speed)
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
if not self._device_data:
return None
if self._device_data.is_auto_mode:
return ATTR_AUTO
@@ -121,10 +114,8 @@ class TradfriAirPurifierFan(TradfriBaseEntity, FanEntity):
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the fan."""
if not self._device_control:
return
# Preset must be 'Auto'
if not preset_mode == ATTR_AUTO:
raise ValueError("Preset must be 'Auto'.")
await self._api(self._device_control.turn_on_auto_mode())
@@ -135,9 +126,6 @@ class TradfriAirPurifierFan(TradfriBaseEntity, FanEntity):
**kwargs: Any,
) -> None:
"""Turn on the fan. Auto-mode if no argument is given."""
if not self._device_control:
return
if percentage is not None:
await self.async_set_percentage(percentage)
return
@@ -147,9 +135,6 @@ class TradfriAirPurifierFan(TradfriBaseEntity, FanEntity):
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed percentage of the fan."""
if not self._device_control:
return
if percentage == 0:
await self.async_turn_off()
return
@@ -160,6 +145,4 @@ class TradfriAirPurifierFan(TradfriBaseEntity, FanEntity):
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the fan."""
if not self._device_control:
return
await self._api(self._device_control.turn_off())

View File

@@ -2,10 +2,9 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any, cast
from typing import Any
from pytradfri.command import Command
from pytradfri.api.aiocoap_api import APIRequestProtocol
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
@@ -22,9 +21,10 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import color as color_util
from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API
from .const import CONF_GATEWAY_ID, DOMAIN
from .coordinator import TradfriDeviceDataUpdateCoordinator
from .entity import TradfriBaseEntity
from .models import TradfriData
async def async_setup_entry(
@@ -34,8 +34,8 @@ async def async_setup_entry(
) -> None:
"""Load Tradfri lights based on a config entry."""
gateway_id = config_entry.data[CONF_GATEWAY_ID]
coordinator_data = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR]
api = coordinator_data[KEY_API]
tradfri_data: TradfriData = hass.data[DOMAIN][config_entry.entry_id]
api = tradfri_data.api
async_add_entities(
TradfriLight(
@@ -43,7 +43,7 @@ async def async_setup_entry(
api,
gateway_id,
)
for device_coordinator in coordinator_data[COORDINATOR_LIST]
for device_coordinator in tradfri_data.coordinators
if device_coordinator.device.has_light_control
)
@@ -58,7 +58,7 @@ class TradfriLight(TradfriBaseEntity, LightEntity):
def __init__(
self,
device_coordinator: TradfriDeviceDataUpdateCoordinator,
api: Callable[[Command | list[Command]], Any],
api: APIRequestProtocol,
gateway_id: str,
) -> None:
"""Initialize a Light."""
@@ -68,46 +68,42 @@ class TradfriLight(TradfriBaseEntity, LightEntity):
gateway_id=gateway_id,
)
self._device_control = self._device.light_control
self._device_data = self._device_control.lights[0]
device_control = self._device.light_control
assert device_control # light_control is ensured when creating the entity
self._device_control = device_control
device_data = device_control.lights[0]
self._device_data = device_data
self._attr_unique_id = f"light-{gateway_id}-{self._device_id}"
self._hs_color = None
# Calculate supported color modes
modes: set[ColorMode] = {ColorMode.ONOFF}
if self._device.light_control.can_set_color:
if device_data.supports_hsb_xy_color:
modes.add(ColorMode.HS)
if self._device.light_control.can_set_temp:
if device_data.supports_color_temp:
modes.add(ColorMode.COLOR_TEMP)
if self._device.light_control.can_set_dimmer:
if device_data.supports_dimmer:
modes.add(ColorMode.BRIGHTNESS)
self._attr_supported_color_modes = filter_supported_color_modes(modes)
if len(self._attr_supported_color_modes) == 1:
self._fixed_color_mode = next(iter(self._attr_supported_color_modes))
if self._device_control:
self._attr_max_color_temp_kelvin = (
color_util.color_temperature_mired_to_kelvin(
self._device_control.min_mireds
)
)
self._attr_min_color_temp_kelvin = (
color_util.color_temperature_mired_to_kelvin(
self._device_control.max_mireds
)
)
self._attr_max_color_temp_kelvin = color_util.color_temperature_mired_to_kelvin(
device_control.min_mireds
)
self._attr_min_color_temp_kelvin = color_util.color_temperature_mired_to_kelvin(
device_control.max_mireds
)
def _refresh(self) -> None:
"""Refresh the device."""
self._device_data = self.coordinator.data.light_control.lights[0]
self._device_data = self._device_control.lights[0]
@property
def is_on(self) -> bool:
"""Return true if light is on."""
if not self._device_data:
return False
return cast(bool, self._device_data.state)
return self._device_data.state
@property
def color_mode(self) -> ColorMode | None:
@@ -121,36 +117,29 @@ class TradfriLight(TradfriBaseEntity, LightEntity):
@property
def brightness(self) -> int | None:
"""Return the brightness of the light."""
if not self._device_data:
return None
return cast(int, self._device_data.dimmer)
return self._device_data.dimmer
@property
def color_temp_kelvin(self) -> int | None:
"""Return the color temperature value in Kelvin."""
if not self._device_data or not (color_temp := self._device_data.color_temp):
if (color_temp := self._device_data.color_temp) is None:
return None
return color_util.color_temperature_mired_to_kelvin(color_temp)
@property
def hs_color(self) -> tuple[float, float] | None:
"""HS color of the light."""
if not self._device_control or not self._device_data:
hsbxy = self._device_data.hsb_xy_color
if hsbxy is None:
return None
if self._device_control.can_set_color:
hsbxy = self._device_data.hsb_xy_color
hue = hsbxy[0] / (self._device_control.max_hue / 360)
sat = hsbxy[1] / (self._device_control.max_saturation / 100)
if hue is not None and sat is not None:
return hue, sat
return None
hue = hsbxy[0] / (self._device_control.max_hue / 360)
sat = hsbxy[1] / (self._device_control.max_saturation / 100)
return hue, sat
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
# This allows transitioning to off, but resets the brightness
# to 1 for the next set_state(True) command
if not self._device_control:
return
transition_time = None
if ATTR_TRANSITION in kwargs:
transition_time = int(kwargs[ATTR_TRANSITION]) * 10
@@ -165,81 +154,75 @@ class TradfriLight(TradfriBaseEntity, LightEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
if not self._device_control:
return
transition_time = None
if ATTR_TRANSITION in kwargs:
transition_time = int(kwargs[ATTR_TRANSITION]) * 10
dimmer_command = None
if ATTR_BRIGHTNESS in kwargs:
brightness = kwargs[ATTR_BRIGHTNESS]
brightness = min(brightness, 254)
dimmer_data = {
"dimmer": brightness,
"transition_time": transition_time,
}
dimmer_command = self._device_control.set_dimmer(**dimmer_data)
dimmer_command = self._device_control.set_dimmer(
dimmer=brightness, transition_time=transition_time
)
transition_time = None
else:
dimmer_command = self._device_control.set_state(True)
color_command = None
if ATTR_HS_COLOR in kwargs and self._device_control.can_set_color:
if ATTR_HS_COLOR in kwargs and self._device_data.supports_hsb_xy_color:
hue = int(kwargs[ATTR_HS_COLOR][0] * (self._device_control.max_hue / 360))
sat = int(
kwargs[ATTR_HS_COLOR][1] * (self._device_control.max_saturation / 100)
)
color_data = {
"hue": hue,
"saturation": sat,
"transition_time": transition_time,
}
color_command = self._device_control.set_hsb(**color_data)
color_command = self._device_control.set_hsb(
hue=hue, saturation=sat, transition_time=transition_time
)
transition_time = None
temp_command = None
if ATTR_COLOR_TEMP_KELVIN in kwargs and (
self._device_control.can_set_temp or self._device_control.can_set_color
self._device_data.supports_color_temp
or self._device_data.supports_hsb_xy_color
):
temp_k = kwargs[ATTR_COLOR_TEMP_KELVIN]
# White Spectrum bulb
if self._device_control.can_set_temp:
if self._device_data.supports_color_temp:
temp = color_util.color_temperature_kelvin_to_mired(temp_k)
if temp < (min_mireds := self._device_control.min_mireds):
temp = min_mireds
elif temp > (max_mireds := self._device_control.max_mireds):
temp = max_mireds
temp_data = {
"color_temp": temp,
"transition_time": transition_time,
}
temp_command = self._device_control.set_color_temp(**temp_data)
temp_command = self._device_control.set_color_temp(
color_temp=temp, transition_time=transition_time
)
transition_time = None
# Color bulb (CWS)
# color_temp needs to be set with hue/saturation
elif self._device_control.can_set_color:
elif self._device_data.supports_hsb_xy_color:
hs_color = color_util.color_temperature_to_hs(temp_k)
hue = int(hs_color[0] * (self._device_control.max_hue / 360))
sat = int(hs_color[1] * (self._device_control.max_saturation / 100))
color_data = {
"hue": hue,
"saturation": sat,
"transition_time": transition_time,
}
color_command = self._device_control.set_hsb(**color_data)
color_command = self._device_control.set_hsb(
hue=hue, saturation=sat, transition_time=transition_time
)
transition_time = None
# HSB can always be set, but color temp + brightness is bulb dependent
if (command := dimmer_command) is not None:
command += color_command
else:
command = color_command
command = dimmer_command
if color_command is not None:
command = self._device_control.combine_commands(
[dimmer_command, color_command]
)
if self._device_control.can_combine_commands:
await self._api(command + temp_command)
if self._device_control.can_combine_commands and temp_command is not None:
await self._api(
self._device_control.combine_commands([command, temp_command])
)
else:
if temp_command is not None:
await self._api(temp_command)
if command is not None:
await self._api(command)
await self._api(command)

View File

@@ -9,5 +9,5 @@
},
"iot_class": "local_polling",
"loggers": ["pytradfri"],
"requirements": ["pytradfri[async]==9.0.1"]
"requirements": ["pytradfri[async]==14.0.0"]
}

View File

@@ -0,0 +1,32 @@
"""Provide migration tools for the Tradfri integration."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
import homeassistant.helpers.device_registry as dr
from .const import DOMAIN
@callback
def migrate_device_identifier(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Migrate device identifier to new format."""
device_registry = dr.async_get(hass)
device_entries = dr.async_entries_for_config_entry(
device_registry, config_entry.entry_id
)
for device_entry in device_entries:
device_identifiers = set(device_entry.identifiers)
for identifier in device_entry.identifiers:
if identifier[0] == DOMAIN and isinstance(
identifier[1], int # type: ignore[unreachable]
):
device_identifiers.remove(identifier) # type: ignore[unreachable]
# Copy pytradfri device id to string.
device_identifiers.add((DOMAIN, str(identifier[1])))
break
if device_identifiers != device_entry.identifiers:
device_registry.async_update_device(
device_entry.id, new_identifiers=device_identifiers
)

View File

@@ -0,0 +1,19 @@
"""Provide a model for the Tradfri integration."""
from __future__ import annotations
from dataclasses import dataclass
from pytradfri import Gateway
from pytradfri.api.aiocoap_api import APIFactory, APIRequestProtocol
from .coordinator import TradfriDeviceDataUpdateCoordinator
@dataclass
class TradfriData:
"""Data for the Tradfri integration."""
api: APIRequestProtocol
coordinators: list[TradfriDeviceDataUpdateCoordinator]
factory: APIFactory
gateway: Gateway

View File

@@ -4,9 +4,9 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, cast
from typing import Any
from pytradfri.command import Command
from pytradfri.api.aiocoap_api import APIRequestProtocol
from pytradfri.device import Device
from homeassistant.components.sensor import (
@@ -26,16 +26,10 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
CONF_GATEWAY_ID,
COORDINATOR,
COORDINATOR_LIST,
DOMAIN,
KEY_API,
LOGGER,
)
from .const import CONF_GATEWAY_ID, DOMAIN, LOGGER
from .coordinator import TradfriDeviceDataUpdateCoordinator
from .entity import TradfriBaseEntity
from .models import TradfriData
@dataclass(frozen=True, kw_only=True)
@@ -49,22 +43,20 @@ def _get_air_quality(device: Device) -> int | None:
"""Fetch the air quality value."""
assert device.air_purifier_control is not None
if (
device.air_purifier_control.air_purifiers[0].air_quality == 65535
): # The sensor returns 65535 if the fan is turned off
device_control := device.air_purifier_control
) is None or device_control.air_purifiers[
0
].air_quality == 65535: # The sensor returns 65535 if the fan is turned off
return None
return cast(int, device.air_purifier_control.air_purifiers[0].air_quality)
return device_control.air_purifiers[0].air_quality
def _get_filter_time_left(device: Device) -> int:
"""Fetch the filter's remaining lifetime (in hours)."""
assert device.air_purifier_control is not None
return round(
cast(
int, device.air_purifier_control.air_purifiers[0].filter_lifetime_remaining
)
/ 60
)
device_control = device.air_purifier_control
assert device_control # air_purifier_control is ensured when creating the entity
return round(device_control.air_purifiers[0].filter_lifetime_remaining / 60)
SENSOR_DESCRIPTIONS_BATTERY: tuple[TradfriSensorEntityDescription, ...] = (
@@ -73,7 +65,7 @@ SENSOR_DESCRIPTIONS_BATTERY: tuple[TradfriSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value=lambda device: cast(int, device.device_info.battery_level),
value=lambda device: device.device_info.battery_level,
),
)
@@ -132,12 +124,12 @@ async def async_setup_entry(
) -> None:
"""Set up a Tradfri config entry."""
gateway_id = config_entry.data[CONF_GATEWAY_ID]
coordinator_data = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR]
api = coordinator_data[KEY_API]
tradfri_data: TradfriData = hass.data[DOMAIN][config_entry.entry_id]
api = tradfri_data.api
entities: list[TradfriSensor] = []
for device_coordinator in coordinator_data[COORDINATOR_LIST]:
for device_coordinator in tradfri_data.coordinators:
if (
not device_coordinator.device.has_light_control
and not device_coordinator.device.has_socket_control
@@ -178,7 +170,7 @@ class TradfriSensor(TradfriBaseEntity, SensorEntity):
def __init__(
self,
device_coordinator: TradfriDeviceDataUpdateCoordinator,
api: Callable[[Command | list[Command]], Any],
api: APIRequestProtocol,
gateway_id: str,
description: TradfriSensorEntityDescription,
) -> None:

View File

@@ -2,19 +2,19 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any, cast
from typing import Any
from pytradfri.command import Command
from pytradfri.api.aiocoap_api import APIRequestProtocol
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API
from .const import CONF_GATEWAY_ID, DOMAIN
from .coordinator import TradfriDeviceDataUpdateCoordinator
from .entity import TradfriBaseEntity
from .models import TradfriData
async def async_setup_entry(
@@ -24,8 +24,8 @@ async def async_setup_entry(
) -> None:
"""Load Tradfri switches based on a config entry."""
gateway_id = config_entry.data[CONF_GATEWAY_ID]
coordinator_data = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR]
api = coordinator_data[KEY_API]
tradfri_data: TradfriData = hass.data[DOMAIN][config_entry.entry_id]
api = tradfri_data.api
async_add_entities(
TradfriSwitch(
@@ -33,7 +33,7 @@ async def async_setup_entry(
api,
gateway_id,
)
for device_coordinator in coordinator_data[COORDINATOR_LIST]
for device_coordinator in tradfri_data.coordinators
if device_coordinator.device.has_socket_control
)
@@ -46,7 +46,7 @@ class TradfriSwitch(TradfriBaseEntity, SwitchEntity):
def __init__(
self,
device_coordinator: TradfriDeviceDataUpdateCoordinator,
api: Callable[[Command | list[Command]], Any],
api: APIRequestProtocol,
gateway_id: str,
) -> None:
"""Initialize a switch."""
@@ -56,28 +56,24 @@ class TradfriSwitch(TradfriBaseEntity, SwitchEntity):
gateway_id=gateway_id,
)
self._device_control = self._device.socket_control
self._device_data = self._device_control.sockets[0]
device_control = self._device.socket_control
assert device_control # socket_control is ensured when creating the entity
self._device_control = device_control
self._device_data = device_control.sockets[0]
def _refresh(self) -> None:
"""Refresh the device."""
self._device_data = self.coordinator.data.socket_control.sockets[0]
self._device_data = self._device_control.sockets[0]
@property
def is_on(self) -> bool:
"""Return true if switch is on."""
if not self._device_data:
return False
return cast(bool, self._device_data.state)
return self._device_data.state
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the switch to turn off."""
if not self._device_control:
return
await self._api(self._device_control.set_state(False))
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the switch to turn on."""
if not self._device_control:
return
await self._api(self._device_control.set_state(True))

2
requirements_all.txt generated
View File

@@ -2498,7 +2498,7 @@ pytouchlinesl==0.3.0
pytraccar==2.1.1
# homeassistant.components.tradfri
pytradfri[async]==9.0.1
pytradfri[async]==14.0.0
# homeassistant.components.trafikverket_camera
# homeassistant.components.trafikverket_ferry

View File

@@ -2022,7 +2022,7 @@ pytouchlinesl==0.3.0
pytraccar==2.1.1
# homeassistant.components.tradfri
pytradfri[async]==9.0.1
pytradfri[async]==14.0.0
# homeassistant.components.trafikverket_camera
# homeassistant.components.trafikverket_ferry

View File

@@ -1,12 +1,10 @@
"""Common tools used for the Tradfri test suite."""
from copy import deepcopy
from dataclasses import dataclass
from typing import Any
from pytradfri.command import Command
from pytradfri.const import ATTR_ID
from pytradfri.device import Device
from pytradfri.device import Device, DeviceResponse
from pytradfri.gateway import Gateway
from homeassistant.components import tradfri
@@ -25,13 +23,13 @@ class CommandStore:
mock_responses: dict[str, Any]
def register_device(
self, gateway: Gateway, device_response: dict[str, Any]
self, gateway: Gateway, device_response: DeviceResponse
) -> None:
"""Register device response."""
get_devices_command = gateway.get_devices()
self.register_response(get_devices_command, [device_response[ATTR_ID]])
get_device_command = gateway.get_device(device_response[ATTR_ID])
self.register_response(get_device_command, device_response)
self.register_response(get_devices_command, [device_response.id])
get_device_command = gateway.get_device(str(device_response.id))
self.register_response(get_device_command, device_response.dict(by_alias=True))
def register_response(self, command: Command, response: Any) -> None:
"""Register command response."""
@@ -62,7 +60,7 @@ class CommandStore:
assert observe_command
device_path = "/".join(str(v) for v in device.path)
device_state = deepcopy(device.raw)
device_state = device.raw.dict(by_alias=True)
# Create a default observed state based on the sent commands.
for command in self.sent_commands:

View File

@@ -9,13 +9,12 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pytradfri.command import Command
from pytradfri.const import ATTR_FIRMWARE_VERSION, ATTR_GATEWAY_ID
from pytradfri.device import Device
from pytradfri.gateway import Gateway
from homeassistant.components.tradfri.const import DOMAIN
from . import GATEWAY_ID, TRADFRI_PATH
from . import TRADFRI_PATH
from .common import CommandStore
from tests.common import load_fixture
@@ -30,12 +29,12 @@ def mock_entry_setup() -> Generator[AsyncMock]:
@pytest.fixture(name="mock_gateway", autouse=True)
def mock_gateway_fixture(command_store: CommandStore) -> Gateway:
def mock_gateway_fixture(command_store: CommandStore, gateway_response: str) -> Gateway:
"""Mock a Tradfri gateway."""
gateway = Gateway()
command_store.register_response(
gateway.get_gateway_info(),
{ATTR_GATEWAY_ID: GATEWAY_ID, ATTR_FIRMWARE_VERSION: "1.2.1234"},
json.loads(gateway_response),
)
command_store.register_response(
gateway.get_devices(),
@@ -96,6 +95,12 @@ def device(
return device
@pytest.fixture(scope="package")
def gateway_response() -> str:
"""Return a gateway response."""
return load_fixture("gateway.json", DOMAIN)
@pytest.fixture(scope="package")
def air_purifier() -> str:
"""Return an air purifier response."""

View File

@@ -0,0 +1,44 @@
{
"9023": "xyz.pool.ntp.pool",
"9029": "1.2.1234",
"9054": 0,
"9055": 0,
"9059": 1509788799,
"9060": "2017-11-04T09:46:39.046784Z",
"9061": 0,
"9062": 0,
"9066": 5,
"9069": 1509474847,
"9071": 1,
"9072": 0,
"9073": 0,
"9074": 0,
"9075": 0,
"9076": 0,
"9077": 0,
"9078": 0,
"9079": 0,
"9080": 0,
"9081": "mock-gateway-id",
"9082": true,
"9083": "123-45-67",
"9092": 0,
"9093": 0,
"9103": "blablablabla12.iot.eu-central-1.amazonaws.com",
"9105": 0,
"9106": 0,
"9107": 0,
"9118": 0,
"9200": "abc12345-a123-b345-c567-123abc123456",
"9201": 1,
"9202": 1234567890,
"9204": 1,
"9208": 1234567890,
"9209": 1234567890,
"9211": 0,
"9232": 1234567,
"9234": 1,
"9235": "SE",
"9236": 3600,
"9266": 2
}

View File

@@ -93,7 +93,7 @@ async def test_fan_available(
ATTR_AIR_PURIFIER_MODE: 0,
},
STATE_OFF,
None,
0,
None,
),
(
@@ -145,7 +145,7 @@ async def test_fan_available(
ATTR_AIR_PURIFIER_MODE: 0,
},
STATE_OFF,
None,
0,
None,
),
],

View File

@@ -246,7 +246,9 @@ async def test_turn_on(
) -> None:
"""Test turning on a light."""
# Make sure the light is off.
device.raw[ATTR_LIGHT_CONTROL][0][ATTR_DEVICE_STATE] = 0
light_control = device.raw.light_control
assert light_control
light_control[0].state = 0
await setup_integration(hass)
await hass.services.async_call(

View File

@@ -0,0 +1,48 @@
"""Test the tradfri migration tools."""
from unittest.mock import MagicMock
import pytest
from pytradfri.device import Device
from homeassistant.components.tradfri.const import DOMAIN
from homeassistant.core import HomeAssistant
import homeassistant.helpers.device_registry as dr
from . import GATEWAY_ID
from tests.common import MockConfigEntry
@pytest.mark.parametrize("device", ["air_purifier"], indirect=True)
async def test_migrate_device_identifier(
hass: HomeAssistant,
mock_api_factory: MagicMock,
device: Device,
) -> None:
"""Test migrate device identifier."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
"host": "mock-host",
"identity": "mock-identity",
"key": "mock-key",
"gateway_id": GATEWAY_ID,
},
)
entry.add_to_hass(hass)
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, 65551)}, # type: ignore[arg-type]
)
assert device_entry.identifiers == {(DOMAIN, 65551)} # type: ignore[comparison-overlap]
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
migrated_device_entry = device_registry.async_get(device_entry.id)
assert migrated_device_entry
assert migrated_device_entry.identifiers == {(DOMAIN, "65551")}