Add Thread support to LIFX integration via lifx-async bump (#183009)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Avi Miller <me@dje.li>
This commit is contained in:
Avi Miller
2026-09-24 16:40:02 +01:00
committed by GitHub
co-authored by Claude Opus 5
parent fe42af42c9
commit c451e722d0
10 changed files with 191 additions and 20 deletions
+1
View File
@@ -14,6 +14,7 @@ DATA_LIFX_MANAGER: HassKey[LIFXManager] = HassKey(DOMAIN)
CONF_GROUP = "group"
CONF_LABEL = "label"
CONF_MAC_ADDRESS = "mac_address"
CONF_NETWORK_NAME = "network_name"
CONF_SERIAL = "serial"
CONF_TITLE = "title"
+10 -7
View File
@@ -9,6 +9,7 @@ from lifx import (
STATE_REFRESH_DEBOUNCE_MS,
CeilingLightState,
Colors,
Connectivity,
Device,
HevLight,
HevLightState,
@@ -26,7 +27,7 @@ from lifx import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.const import SIGNAL_STRENGTH_DECIBELS_MILLIWATT, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.debounce import Debouncer
@@ -111,17 +112,19 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[LIFXState]):
@property
def rssi(self) -> int | None:
"""Return the library-calculated signal strength."""
if self.device.connectivity is Connectivity.THREAD:
return self.data.thread_info.rssi if self.data.thread_info else None
return self.data.wifi_info.rssi
@property
def rssi_uom(self) -> str | None:
"""Return the library-classified signal-strength unit.
The library resolves the unit from the firmware version, so it is on
the state from the first refresh even while the signal itself is not
being read: a sensor registered without a unit and given one later
breaks its long term statistics.
Thread reports dBm; the library resolves Wi-Fi units from firmware.
Set the unit before the first reading to preserve long term statistics.
"""
if self.device.connectivity is Connectivity.THREAD:
return SIGNAL_STRENGTH_DECIBELS_MILLIWATT
return self.data.wifi_info.rssi_unit
def async_get_entity_id(self, platform: Platform, key: str) -> str | None:
@@ -171,9 +174,9 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[LIFXState]):
@callback
def _async_disable_rssi_updates() -> None:
self.device.fetch_wifi_info = False
self.device.fetch_radio_info = False
self.device.fetch_wifi_info = True
self.device.fetch_radio_info = True
return _async_disable_rssi_updates
async def async_set_infrared_brightness(self, option: str) -> None:
+9 -1
View File
@@ -6,7 +6,14 @@ from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST, CONF_IP_ADDRESS, CONF_LOCATION
from homeassistant.core import HomeAssistant
from .const import CONF_GROUP, CONF_LABEL, CONF_MAC_ADDRESS, CONF_SERIAL, CONF_TITLE
from .const import (
CONF_GROUP,
CONF_LABEL,
CONF_MAC_ADDRESS,
CONF_NETWORK_NAME,
CONF_SERIAL,
CONF_TITLE,
)
from .coordinator import LIFXConfigEntry
TO_REDACT = [
@@ -18,6 +25,7 @@ TO_REDACT = [
CONF_MAC_ADDRESS,
CONF_GROUP,
CONF_LOCATION,
CONF_NETWORK_NAME,
]
+1 -1
View File
@@ -52,6 +52,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["lifx"],
"requirements": ["lifx-async==6.5.1"],
"requirements": ["lifx-async==7.4.0"],
"zeroconf": ["_lifx._udp.local."]
}
+1 -1
View File
@@ -1529,7 +1529,7 @@ librouteros==4.2.2
libsoundtouch==0.8
# homeassistant.components.lifx
lifx-async==6.5.1
lifx-async==7.4.0
# homeassistant.components.osramlightify
lightify==1.0.7.3
+21 -1
View File
@@ -8,6 +8,7 @@ from lifx import (
CeilingLight,
CeilingLightState,
CollectionInfo,
Connectivity,
DeviceCapabilities,
FirmwareEffect,
FirmwareInfo,
@@ -25,6 +26,8 @@ from lifx import (
MatrixLightState,
MultiZoneLight,
MultiZoneLightState,
ThreadInfo,
ThreadRoutingRole,
TileInfo,
WifiInfo,
)
@@ -142,7 +145,8 @@ def _create_mock_device(
device.set_reboot = AsyncMock()
device.apply_theme = AsyncMock()
# The library only reads the signal strength once it is switched on
device.fetch_wifi_info = False
device.fetch_radio_info = False
device.connectivity = Connectivity.WIFI
return cast(_DeviceT, device)
@@ -467,3 +471,19 @@ def create_reference_ceiling_128_light() -> CeilingLight:
return _create_reference_ceiling_light(
model="LIFX Ceiling 13x26", product=201, width=16, zone_count=127
)
def create_reference_thread_light() -> Light:
"""Create the normalized released-emulator color light on Thread."""
device = create_reference_color_light()
device.connectivity = Connectivity.THREAD
device.state.thread_info = ThreadInfo(
rloc=1024,
network_name="Private mesh",
role=ThreadRoutingRole.ROUTER,
next_hop=2048,
link_quality_in=3,
link_quality_out=3,
link_margin_db=40,
)
return device
@@ -33,6 +33,7 @@
'model': 'LIFX Mini W',
'power': 65535,
'serial': '**REDACTED**',
'thread_info': None,
'wifi_firmware': dict({
'version_major': 3,
'version_minor': 70,
@@ -1264,6 +1265,7 @@
'kelvin': 3500,
'saturation': 0.0,
}),
'thread_info': None,
'tile_colors': list([
dict({
'brightness': 0.5,
@@ -4045,6 +4047,7 @@
'kelvin': 3500,
'saturation': 0.0,
}),
'thread_info': None,
'tile_colors': list([
dict({
'brightness': 0.5,
@@ -4880,6 +4883,7 @@
'model': 'Original',
'power': 65535,
'serial': '**REDACTED**',
'thread_info': None,
'wifi_firmware': dict({
'version_major': 3,
'version_minor': 70,
@@ -4933,6 +4937,7 @@
'model': 'LIFX White 900 (BR30)',
'power': 65535,
'serial': '**REDACTED**',
'thread_info': None,
'wifi_firmware': dict({
'version_major': 3,
'version_minor': 70,
@@ -4987,6 +4992,7 @@
'model': 'LIFX Beam',
'power': 65535,
'serial': '**REDACTED**',
'thread_info': None,
'wifi_firmware': dict({
'version_major': 3,
'version_minor': 70,
@@ -5533,6 +5539,7 @@
'model': 'LIFX Clean A19 1100lm',
'power': 65535,
'serial': '**REDACTED**',
'thread_info': None,
'wifi_firmware': dict({
'version_major': 3,
'version_minor': 70,
@@ -5587,6 +5594,7 @@
'model': 'LIFX+ (A19)',
'power': 65535,
'serial': '**REDACTED**',
'thread_info': None,
'wifi_firmware': dict({
'version_major': 3,
'version_minor': 70,
@@ -5641,6 +5649,7 @@
'model': 'LIFX Z',
'power': 65535,
'serial': '**REDACTED**',
'thread_info': None,
'wifi_firmware': dict({
'version_major': 3,
'version_minor': 70,
@@ -5881,6 +5890,7 @@
'model': 'LIFX Tile',
'power': 65535,
'serial': '**REDACTED**',
'thread_info': None,
'tile_colors': list([
dict({
'brightness': 0.5,
@@ -7830,3 +7840,67 @@
}),
})
# ---
# name: test_diagnostics[thread]
dict({
'data': dict({
'ambient_light': None,
'capabilities': dict({
'has_chain': False,
'has_color': True,
'has_extended_multizone': False,
'has_hev': False,
'has_infrared': False,
'has_matrix': False,
'has_multizone': False,
'has_variable_color_temp': True,
'kelvin_max': 9000,
'kelvin_min': 2500,
}),
'color': dict({
'brightness': 0.5,
'hue': 119.9981689453125,
'kelvin': 3500,
'saturation': 1.0,
}),
'group': '**REDACTED**',
'host_firmware': dict({
'version_major': 3,
'version_minor': 70,
}),
'label': '**REDACTED**',
'last_updated': 0.0,
'location': '**REDACTED**',
'mac_address': '**REDACTED**',
'model': 'Original',
'power': 65535,
'serial': '**REDACTED**',
'thread_info': dict({
'link_margin_db': 40,
'link_quality_in': 3,
'link_quality_out': 3,
'network_name': '**REDACTED**',
'next_hop': 2048,
'rloc': 1024,
'role': 'ROUTER',
'rssi': -60,
'rssi_unit': 'dBm',
}),
'wifi_firmware': dict({
'version_major': 3,
'version_minor': 70,
}),
'wifi_info': dict({
'rssi': None,
'rssi_unit': 'dBm',
'signal': None,
}),
}),
'entry': dict({
'data': dict({
'host': '**REDACTED**',
'serial': '**REDACTED**',
}),
'title': '**REDACTED**',
}),
})
# ---
+12 -5
View File
@@ -3,7 +3,7 @@
from collections.abc import AsyncGenerator, Callable, Generator
from contextlib import contextmanager
from dataclasses import replace
from ipaddress import IPv4Address
from ipaddress import IPv4Address, IPv6Address
from unittest.mock import AsyncMock, patch
from lifx import (
@@ -124,20 +124,27 @@ def _mock_broadcast_discovery(
yield
@pytest.mark.parametrize(
"address",
[IPv4Address(IP_ADDRESS), IPv6Address("fd00::1234")],
ids=["wifi", "thread"],
)
async def test_zeroconf_discovery_creates_version_2_entry(
hass: HomeAssistant, mock_light: Light
hass: HomeAssistant, mock_light: Light, address: IPv4Address | IPv6Address
) -> None:
"""Test mDNS discovery creates a per-device version 2 entry."""
mock_light.ip = str(address)
with patch(
"homeassistant.components.lifx.config_flow.Device.connect",
return_value=mock_light,
):
) as mock_connect:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=_zeroconf_info(),
data=replace(_zeroconf_info(), ip_address=address, ip_addresses=[address]),
)
mock_connect.assert_awaited_once_with(ip=str(address), serial=SERIAL)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
@@ -145,7 +152,7 @@ async def test_zeroconf_discovery_creates_version_2_entry(
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == LABEL
assert result["data"] == {CONF_HOST: IP_ADDRESS, CONF_SERIAL: SERIAL}
assert result["data"] == {CONF_HOST: str(address), CONF_SERIAL: SERIAL}
assert result["result"].unique_id == SERIAL
assert result["result"].version == 2
mock_light.close.assert_awaited_once_with()
@@ -20,6 +20,7 @@ from .helpers import (
create_reference_infrared_light,
create_reference_legacy_multizone_light,
create_reference_matrix_light,
create_reference_thread_light,
)
from tests.components.diagnostics import get_diagnostics_for_config_entry
@@ -41,6 +42,7 @@ from tests.typing import ClientSessionGenerator
pytest.param(create_reference_matrix_light, id="matrix"),
pytest.param(create_reference_ceiling_light, id="ceiling"),
pytest.param(create_reference_ceiling_128_light, id="ceiling_128"),
pytest.param(create_reference_thread_light, id="thread"),
],
)
async def test_diagnostics(
+60 -4
View File
@@ -1,6 +1,6 @@
"""Test the LIFX sensor platform."""
from lifx import FirmwareInfo, WifiInfo
from lifx import Connectivity, FirmwareInfo, ThreadInfo, ThreadRoutingRole, WifiInfo
import pytest
from homeassistant.components import lifx
@@ -42,12 +42,12 @@ async def test_rssi_sensor(
device = create_mock_light()
device.state.wifi_info = WifiInfo(0.000001, FirmwareInfo(0, *firmware))
await async_setup_lifx_entry(hass, device)
entry = await async_setup_lifx_entry(hass, device)
await async_trigger_update(hass)
# The signal is only requested while the sensor is enabled
assert device.fetch_wifi_info is True
assert device.fetch_radio_info is True
state = hass.states.get("sensor.my_group_my_bulb_rssi")
assert state
@@ -56,15 +56,71 @@ async def test_rssi_sensor(
assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.SIGNAL_STRENGTH
assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT
assert await hass.config_entries.async_unload(entry.entry_id)
assert device.fetch_radio_info is False
@pytest.mark.parametrize("connectivity", [Connectivity.WIFI, Connectivity.THREAD])
async def test_rssi_signal_is_not_read_while_the_sensor_is_disabled(
hass: HomeAssistant,
connectivity: Connectivity,
) -> None:
"""Test the signal is left out of the poll until the sensor is enabled."""
device = create_mock_light()
device.connectivity = connectivity
await async_setup_lifx_entry(hass, device)
await async_trigger_update(hass)
assert device.fetch_wifi_info is False
assert device.fetch_radio_info is False
async def test_thread_rssi_sensor(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""Test Thread RSSI has a stable unit before and after the first reading."""
entity_registry.async_get_or_create(
"sensor",
lifx.DOMAIN,
f"{SERIAL}_rssi",
disabled_by=None,
suggested_object_id="my_group_my_bulb_rssi",
)
device = create_mock_light()
device.connectivity = Connectivity.THREAD
device.state.wifi_info = WifiInfo(0.00001, FirmwareInfo(0, 2, 77))
entry = await async_setup_lifx_entry(hass, device)
state = hass.states.get("sensor.my_group_my_bulb_rssi")
assert state
assert state.state == "unknown"
assert (
state.attributes[ATTR_UNIT_OF_MEASUREMENT] == SIGNAL_STRENGTH_DECIBELS_MILLIWATT
)
assert device.fetch_radio_info is True
device.state.thread_info = ThreadInfo(
rloc=1024,
network_name="Test mesh",
role=ThreadRoutingRole.ROUTER,
next_hop=2048,
link_quality_in=3,
link_quality_out=3,
link_margin_db=40,
)
await async_trigger_update(hass)
state = hass.states.get("sensor.my_group_my_bulb_rssi")
assert state
assert state.state == "-60"
assert (
state.attributes[ATTR_UNIT_OF_MEASUREMENT] == SIGNAL_STRENGTH_DECIBELS_MILLIWATT
)
assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.SIGNAL_STRENGTH
assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT
assert await hass.config_entries.async_unload(entry.entry_id)
assert device.fetch_radio_info is False