mirror of
https://github.com/home-assistant/core.git
synced 2026-09-27 01:46:11 -04:00
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:
co-authored by
Claude Opus 5
parent
fe42af42c9
commit
c451e722d0
@@ -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"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -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
|
||||
|
||||
@@ -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**',
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user