diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py index c0d1afe1fa50..e0ab3b922d5e 100644 --- a/homeassistant/components/lifx/const.py +++ b/homeassistant/components/lifx/const.py @@ -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" diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index 8f7d9817d60d..408fd2d7fa6b 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -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: diff --git a/homeassistant/components/lifx/diagnostics.py b/homeassistant/components/lifx/diagnostics.py index 21c0a30c25ec..e1cc89b4da2e 100644 --- a/homeassistant/components/lifx/diagnostics.py +++ b/homeassistant/components/lifx/diagnostics.py @@ -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, ] diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 0e070e5e3b36..c1b71bbb4f25 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -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."] } diff --git a/requirements_all.txt b/requirements_all.txt index 2e0c9e4d6153..101a4aa9448d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/tests/components/lifx/helpers.py b/tests/components/lifx/helpers.py index 2ab6b2a340e4..1abcf7105b8b 100644 --- a/tests/components/lifx/helpers.py +++ b/tests/components/lifx/helpers.py @@ -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 diff --git a/tests/components/lifx/snapshots/test_diagnostics.ambr b/tests/components/lifx/snapshots/test_diagnostics.ambr index 2120aaeceea7..519551b5b73d 100644 --- a/tests/components/lifx/snapshots/test_diagnostics.ambr +++ b/tests/components/lifx/snapshots/test_diagnostics.ambr @@ -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**', + }), + }) +# --- diff --git a/tests/components/lifx/test_config_flow.py b/tests/components/lifx/test_config_flow.py index c749f8ca2195..f654197913da 100644 --- a/tests/components/lifx/test_config_flow.py +++ b/tests/components/lifx/test_config_flow.py @@ -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() diff --git a/tests/components/lifx/test_diagnostics.py b/tests/components/lifx/test_diagnostics.py index f9677fcb75b0..86623904259e 100644 --- a/tests/components/lifx/test_diagnostics.py +++ b/tests/components/lifx/test_diagnostics.py @@ -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( diff --git a/tests/components/lifx/test_sensor.py b/tests/components/lifx/test_sensor.py index d4611ff34943..0c965b04b74c 100644 --- a/tests/components/lifx/test_sensor.py +++ b/tests/components/lifx/test_sensor.py @@ -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