Bump pytradfri to 14.0.0 (#182250)

This commit is contained in:
Jamie Magee
2026-09-15 19:57:09 +01:00
committed by GitHub
parent ec2a00de1e
commit 6f23c02bb5
15 changed files with 160 additions and 116 deletions
+3 -3
View File
@@ -3,7 +3,7 @@
from datetime import datetime, timedelta
from pytradfri import Gateway, RequestError
from pytradfri.api.aiocoap_api import APIFactory
from pytradfri.api.aiocoap_api import APIFactory, APIRequestProtocol
from pytradfri.command import Command
from pytradfri.device import Device
@@ -56,12 +56,12 @@ async def async_setup_entry(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
)
api = factory.request
api: APIRequestProtocol = factory.request
gateway = Gateway()
try:
gateway_info = await api(gateway.get_gateway_info(), timeout=TIMEOUT_API)
devices_commands: Command = await api(
devices_commands: list[Command[Device]] = await api(
gateway.get_devices(), timeout=TIMEOUT_API
)
devices: list[Device] = await api(devices_commands, timeout=TIMEOUT_API)
@@ -1,15 +1,14 @@
"""Tradfri DataUpdateCoordinator."""
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import timedelta
from typing import Any, override
from typing import cast, override
from pytradfri import Gateway
from pytradfri.api.aiocoap_api import APIFactory
from pytradfri.command import Command
from pytradfri.api.aiocoap_api import APIFactory, 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
@@ -28,7 +27,7 @@ class TradfriData:
factory: APIFactory
gateway: Gateway
api: Callable[[Command | list[Command]], Any]
api: APIRequestProtocol
coordinator_list: list[TradfriDeviceDataUpdateCoordinator] = field(
default_factory=list
)
@@ -43,7 +42,7 @@ class TradfriDeviceDataUpdateCoordinator(DataUpdateCoordinator[Device]):
self,
hass: HomeAssistant,
config_entry: TradfriConfigEntry,
api: Callable[[Command | list[Command]], Any],
api: APIRequestProtocol,
device: Device,
) -> None:
"""Initialize device coordinator."""
@@ -67,9 +66,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."""
self.async_set_updated_data(data=device)
self.async_set_updated_data(data=cast(Device, device))
@callback
def _exception_callback(self, exc: Exception) -> None:
+7 -6
View File
@@ -1,9 +1,8 @@
"""Support for IKEA Tradfri covers."""
from collections.abc import Callable
from typing import Any, cast, override
from typing import TYPE_CHECKING, Any, override
from pytradfri.command import Command
from pytradfri.api.aiocoap_api import APIRequestProtocol
from homeassistant.components.cover import ATTR_POSITION, CoverEntity
from homeassistant.core import HomeAssistant
@@ -42,7 +41,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."""
@@ -52,13 +51,15 @@ class TradfriCover(TradfriBaseEntity, CoverEntity):
gateway_id=gateway_id,
)
if TYPE_CHECKING:
assert self._device.blind_control is not None
self._device_control = self._device.blind_control
self._device_data = self._device_control.blinds[0]
@override
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
@override
@@ -75,7 +76,7 @@ class TradfriCover(TradfriBaseEntity, CoverEntity):
"""
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
@override
async def async_set_cover_position(self, **kwargs: Any) -> None:
+6 -6
View File
@@ -3,10 +3,10 @@
from abc import abstractmethod
from collections.abc import Callable, Coroutine
from functools import wraps
from typing import Any, cast, override
from typing import Any, override
from pytradfri.api.aiocoap_api import APIRequestProtocol
from pytradfri.command import Command
from pytradfri.const import ATTR_DEVICE_FIRMWARE_VERSION
from pytradfri.device import Device
from pytradfri.error import RequestError
@@ -20,7 +20,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 +44,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)
@@ -62,7 +62,7 @@ class TradfriBaseEntity(CoordinatorEntity[TradfriDeviceDataUpdateCoordinator]):
manufacturer=info.manufacturer,
model=info.model_number,
name=self._device.name,
sw_version=info.raw.get(ATTR_DEVICE_FIRMWARE_VERSION),
sw_version=info.firmware_version,
via_device_id=dr.async_get_device_id_by_identifier(
device_coordinator.hass,
(DOMAIN, gateway_id),
@@ -90,4 +90,4 @@ class TradfriBaseEntity(CoordinatorEntity[TradfriDeviceDataUpdateCoordinator]):
@override
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
+7 -6
View File
@@ -1,9 +1,8 @@
"""Represent an air purifier."""
from collections.abc import Callable
from typing import Any, cast, override
from typing import TYPE_CHECKING, Any, override
from pytradfri.command import Command
from pytradfri.api.aiocoap_api import APIRequestProtocol
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.core import HomeAssistant
@@ -69,7 +68,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."""
@@ -79,13 +78,15 @@ class TradfriAirPurifierFan(TradfriBaseEntity, FanEntity):
gateway_id=gateway_id,
)
if TYPE_CHECKING:
assert self._device.air_purifier_control is not None
self._device_control = self._device.air_purifier_control
self._device_data = self._device_control.air_purifiers[0]
@override
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
@override
@@ -93,7 +94,7 @@ class TradfriAirPurifierFan(TradfriBaseEntity, FanEntity):
"""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
@override
+44 -59
View File
@@ -1,9 +1,8 @@
"""Support for IKEA Tradfri lights."""
from collections.abc import Callable
from typing import Any, cast, override
from typing import TYPE_CHECKING, Any, cast, override
from pytradfri.command import Command
from pytradfri.api.aiocoap_api import APIRequestProtocol
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
@@ -54,7 +53,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."""
@@ -64,6 +63,8 @@ class TradfriLight(TradfriBaseEntity, LightEntity):
gateway_id=gateway_id,
)
if TYPE_CHECKING:
assert self._device.light_control is not None
self._device_control = self._device.light_control
self._device_data = self._device_control.lights[0]
@@ -72,32 +73,27 @@ class TradfriLight(TradfriBaseEntity, LightEntity):
# Calculate supported color modes
modes: set[ColorMode] = {ColorMode.ONOFF}
if self._device.light_control.can_set_color:
if self._device_data.supports_hsb_xy_color:
modes.add(ColorMode.HS)
if self._device.light_control.can_set_temp:
if self._device_data.supports_color_temp:
modes.add(ColorMode.COLOR_TEMP)
if self._device.light_control.can_set_dimmer:
if self._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(
self._device_control.min_mireds
)
self._attr_min_color_temp_kelvin = color_util.color_temperature_mired_to_kelvin(
self._device_control.max_mireds
)
@override
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
@override
@@ -105,7 +101,7 @@ class TradfriLight(TradfriBaseEntity, LightEntity):
"""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
@override
@@ -137,23 +133,18 @@ class TradfriLight(TradfriBaseEntity, LightEntity):
@override
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
@override
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
@@ -169,8 +160,6 @@ class TradfriLight(TradfriBaseEntity, LightEntity):
@override
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
@@ -189,59 +178,55 @@ class TradfriLight(TradfriBaseEntity, LightEntity):
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)
@@ -10,5 +10,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pytradfri"],
"requirements": ["pytradfri[async]==9.0.1"]
"requirements": ["pytradfri[async]==14.0.0"]
}
+4 -7
View File
@@ -4,7 +4,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, cast, override
from pytradfri.command import Command
from pytradfri.api.aiocoap_api import APIRequestProtocol
from pytradfri.device import Device
from homeassistant.components.sensor import (
@@ -38,17 +38,14 @@ def _get_air_quality(device: Device) -> int | None:
): # 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.air_purifier_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.air_purifier_control.air_purifiers[0].filter_lifetime_remaining / 60
)
@@ -163,7 +160,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:
+7 -6
View File
@@ -1,9 +1,8 @@
"""Support for IKEA Tradfri switches."""
from collections.abc import Callable
from typing import Any, cast, override
from typing import TYPE_CHECKING, Any, override
from pytradfri.command import Command
from pytradfri.api.aiocoap_api import APIRequestProtocol
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
@@ -42,7 +41,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."""
@@ -52,13 +51,15 @@ class TradfriSwitch(TradfriBaseEntity, SwitchEntity):
gateway_id=gateway_id,
)
if TYPE_CHECKING:
assert self._device.socket_control is not None
self._device_control = self._device.socket_control
self._device_data = self._device_control.sockets[0]
@override
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
@override
@@ -66,7 +67,7 @@ class TradfriSwitch(TradfriBaseEntity, SwitchEntity):
"""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
@override
async def async_turn_off(self, **kwargs: Any) -> None:
+1 -1
View File
@@ -2872,7 +2872,7 @@ pytouchlinesl==0.6.0
pytraccar==3.0.0
# homeassistant.components.tradfri
pytradfri[async]==9.0.1
pytradfri[async]==14.0.0
# homeassistant.components.trafikverket_camera
# homeassistant.components.trafikverket_ferry
+1 -2
View File
@@ -1,6 +1,5 @@
"""Common tools used for the Tradfri test suite."""
from copy import deepcopy
from dataclasses import dataclass
from typing import Any
@@ -62,7 +61,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:
+10 -5
View File
@@ -7,13 +7,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
@@ -28,12 +27,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(),
@@ -90,10 +89,16 @@ def device(
"""Return a device."""
device_response: dict[str, Any] = json.loads(request.getfixturevalue(request.param))
device = Device(device_response)
command_store.register_device(mock_gateway, device.raw)
command_store.register_device(mock_gateway, device_response)
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."""
@@ -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
}
+10 -4
View File
@@ -1,8 +1,9 @@
"""Tests for Tradfri setup."""
import json
from unittest.mock import MagicMock
from pytradfri.const import ATTR_FIRMWARE_VERSION, ATTR_GATEWAY_ID
from pytradfri.const import ATTR_GATEWAY_ID
from pytradfri.gateway import Gateway
from homeassistant.components import tradfri
@@ -104,6 +105,7 @@ async def test_migrate_config_entry_and_identifiers(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
command_store: CommandStore,
gateway_response: str,
) -> None:
"""Test migration of device registry identifiers to the unique format.
@@ -120,7 +122,7 @@ async def test_migrate_config_entry_and_identifiers(
},
)
gateway1 = mock_gateway_fixture(command_store, GATEWAY_ID1)
gateway1 = mock_gateway_fixture(command_store, GATEWAY_ID1, gateway_response)
command_store.register_device(
gateway1, await async_load_json_object_fixture(hass, "bulb_w.json", DOMAIN)
)
@@ -226,12 +228,16 @@ async def test_migrate_config_entry_and_identifiers(
assert config_entry3.version == 1
def mock_gateway_fixture(command_store: CommandStore, gateway_id: str) -> Gateway:
def mock_gateway_fixture(
command_store: CommandStore, gateway_id: str, gateway_response: str
) -> Gateway:
"""Mock a Tradfri gateway."""
gateway = Gateway()
gateway_info_response = json.loads(gateway_response)
gateway_info_response[ATTR_GATEWAY_ID] = gateway_id
command_store.register_response(
gateway.get_gateway_info(),
{ATTR_GATEWAY_ID: gateway_id, ATTR_FIRMWARE_VERSION: "1.2.1234"},
gateway_info_response,
)
command_store.register_response(
gateway.get_devices(),
+8 -2
View File
@@ -245,10 +245,16 @@ async def test_turn_on(
state_attributes: dict[str, Any],
) -> None:
"""Test turning on a light."""
# Make sure the light is off.
device.raw[ATTR_LIGHT_CONTROL][0][ATTR_DEVICE_STATE] = 0
await setup_integration(hass)
await command_store.trigger_observe_callback(
hass, device, {ATTR_LIGHT_CONTROL: [{ATTR_DEVICE_STATE: 0}]}
)
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_OFF
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,