Add SMLIGHT sensor entities for second radio (#137403)

* Add sensors for second radio

* Add test for zigbee2 sensor

* Update homeassistant/components/smlight/sensor.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* drop useless replace

* Fix test failure

* Fix code coverage in config flow

* Update homeassistant/components/smlight/sensor.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* fix conversion of iterator to list

* Remove assert on radios

* simplify handling of radios further

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
This commit is contained in:
TimL 2025-03-26 21:34:44 +11:00 committed by GitHub
parent e10801af80
commit 043603c9be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 91 additions and 32 deletions

View File

@ -51,14 +51,14 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
self.client = Api2(self._host, session=async_get_clientsession(self.hass)) self.client = Api2(self._host, session=async_get_clientsession(self.hass))
try: try:
info = await self.client.get_info()
self._host = str(info.device_ip)
self._device_name = str(info.hostname)
if info.model not in Devices:
return self.async_abort(reason="unsupported_device")
if not await self._async_check_auth_required(user_input): if not await self._async_check_auth_required(user_input):
info = await self.client.get_info()
self._host = str(info.device_ip)
self._device_name = str(info.hostname)
if info.model not in Devices:
return self.async_abort(reason="unsupported_device")
return await self._async_complete_entry(user_input) return await self._async_complete_entry(user_input)
except SmlightConnectionError: except SmlightConnectionError:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
@ -128,13 +128,13 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None: if user_input is not None:
try: try:
info = await self.client.get_info()
if info.model not in Devices:
return self.async_abort(reason="unsupported_device")
if not await self._async_check_auth_required(user_input): if not await self._async_check_auth_required(user_input):
return await self._async_complete_entry(user_input) info = await self.client.get_info()
if info.model not in Devices:
return self.async_abort(reason="unsupported_device")
return await self._async_complete_entry(user_input)
except SmlightConnectionError: except SmlightConnectionError:
return self.async_abort(reason="cannot_connect") return self.async_abort(reason="cannot_connect")

View File

@ -37,7 +37,7 @@ class SmSensorEntityDescription(SensorEntityDescription):
class SmInfoEntityDescription(SensorEntityDescription): class SmInfoEntityDescription(SensorEntityDescription):
"""Class describing SMLIGHT information entities.""" """Class describing SMLIGHT information entities."""
value_fn: Callable[[Info], StateType] value_fn: Callable[[Info, int], StateType]
INFO: list[SmInfoEntityDescription] = [ INFO: list[SmInfoEntityDescription] = [
@ -46,24 +46,25 @@ INFO: list[SmInfoEntityDescription] = [
translation_key="device_mode", translation_key="device_mode",
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=["eth", "wifi", "usb"], options=["eth", "wifi", "usb"],
value_fn=lambda x: x.coord_mode, value_fn=lambda x, idx: x.coord_mode,
), ),
SmInfoEntityDescription( SmInfoEntityDescription(
key="firmware_channel", key="firmware_channel",
translation_key="firmware_channel", translation_key="firmware_channel",
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=["dev", "release"], options=["dev", "release"],
value_fn=lambda x: x.fw_channel, value_fn=lambda x, idx: x.fw_channel,
),
SmInfoEntityDescription(
key="zigbee_type",
translation_key="zigbee_type",
device_class=SensorDeviceClass.ENUM,
options=["coordinator", "router", "thread"],
value_fn=lambda x: x.zb_type,
), ),
] ]
RADIO_INFO = SmInfoEntityDescription(
key="zigbee_type",
translation_key="zigbee_type",
device_class=SensorDeviceClass.ENUM,
options=["coordinator", "router", "thread"],
value_fn=lambda x, idx: x.radios[idx].zb_type,
)
SENSORS: list[SmSensorEntityDescription] = [ SENSORS: list[SmSensorEntityDescription] = [
SmSensorEntityDescription( SmSensorEntityDescription(
@ -102,6 +103,16 @@ SENSORS: list[SmSensorEntityDescription] = [
), ),
] ]
EXTRA_SENSOR = SmSensorEntityDescription(
key="zigbee_temperature_2",
translation_key="zigbee_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda x: x.zb_temp2,
)
UPTIME: list[SmSensorEntityDescription] = [ UPTIME: list[SmSensorEntityDescription] = [
SmSensorEntityDescription( SmSensorEntityDescription(
key="core_uptime", key="core_uptime",
@ -127,8 +138,7 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up SMLIGHT sensor based on a config entry.""" """Set up SMLIGHT sensor based on a config entry."""
coordinator = entry.runtime_data.data coordinator = entry.runtime_data.data
entities: list[SmEntity] = list(
async_add_entities(
chain( chain(
(SmInfoSensorEntity(coordinator, description) for description in INFO), (SmInfoSensorEntity(coordinator, description) for description in INFO),
(SmSensorEntity(coordinator, description) for description in SENSORS), (SmSensorEntity(coordinator, description) for description in SENSORS),
@ -136,6 +146,16 @@ async def async_setup_entry(
) )
) )
entities.extend(
SmInfoSensorEntity(coordinator, RADIO_INFO, idx)
for idx, _ in enumerate(coordinator.data.info.radios)
)
if coordinator.data.sensors.zb_temp2 is not None:
entities.append(SmSensorEntity(coordinator, EXTRA_SENSOR))
async_add_entities(entities)
class SmSensorEntity(SmEntity, SensorEntity): class SmSensorEntity(SmEntity, SensorEntity):
"""Representation of a slzb sensor.""" """Representation of a slzb sensor."""
@ -172,17 +192,20 @@ class SmInfoSensorEntity(SmEntity, SensorEntity):
self, self,
coordinator: SmDataUpdateCoordinator, coordinator: SmDataUpdateCoordinator,
description: SmInfoEntityDescription, description: SmInfoEntityDescription,
idx: int = 0,
) -> None: ) -> None:
"""Initiate slzb sensor.""" """Initiate slzb sensor."""
super().__init__(coordinator) super().__init__(coordinator)
self.entity_description = description self.entity_description = description
self._attr_unique_id = f"{coordinator.unique_id}_{description.key}" self.idx = idx
sensor = f"_{idx}" if idx else ""
self._attr_unique_id = f"{coordinator.unique_id}_{description.key}{sensor}"
@property @property
def native_value(self) -> StateType: def native_value(self) -> StateType:
"""Return the sensor value.""" """Return the sensor value."""
value = self.entity_description.value_fn(self.coordinator.data.info) value = self.entity_description.value_fn(self.coordinator.data.info, self.idx)
options = self.entity_description.options options = self.entity_description.options
if isinstance(value, int) and options is not None: if isinstance(value, int) and options is not None:

View File

@ -193,7 +193,7 @@ async def test_zeroconf_flow_auth(
} }
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
assert len(mock_smlight_client.get_info.mock_calls) == 3 assert len(mock_smlight_client.get_info.mock_calls) == 2
async def test_zeroconf_unsupported_abort( async def test_zeroconf_unsupported_abort(
@ -406,7 +406,7 @@ async def test_user_invalid_auth(
} }
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
assert len(mock_smlight_client.get_info.mock_calls) == 3 assert len(mock_smlight_client.get_info.mock_calls) == 2
async def test_user_cannot_connect( async def test_user_cannot_connect(
@ -443,7 +443,7 @@ async def test_user_cannot_connect(
assert result2["title"] == "SLZB-06p7" assert result2["title"] == "SLZB-06p7"
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
assert len(mock_smlight_client.get_info.mock_calls) == 3 assert len(mock_smlight_client.get_info.mock_calls) == 2
async def test_auth_cannot_connect( async def test_auth_cannot_connect(

View File

@ -2,17 +2,18 @@
from unittest.mock import MagicMock from unittest.mock import MagicMock
from pysmlight import Sensors from pysmlight import Info, Sensors
import pytest import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from homeassistant.components.smlight.const import DOMAIN
from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.const import STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers import device_registry as dr, entity_registry as er
from .conftest import setup_integration from .conftest import setup_integration
from tests.common import MockConfigEntry, snapshot_platform from tests.common import MockConfigEntry, load_json_object_fixture, snapshot_platform
pytestmark = [ pytestmark = [
pytest.mark.usefixtures( pytest.mark.usefixtures(
@ -73,3 +74,38 @@ async def test_zigbee_uptime_disconnected(
state = hass.states.get("sensor.mock_title_zigbee_uptime") state = hass.states.get("sensor.mock_title_zigbee_uptime")
assert state.state == STATE_UNKNOWN assert state.state == STATE_UNKNOWN
async def test_zigbee2_temp_sensor(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_smlight_client: MagicMock,
) -> None:
"""Test for zb_temp2 if device has second radio."""
mock_smlight_client.get_sensors.return_value = Sensors(zb_temp2=20.45)
await setup_integration(hass, mock_config_entry)
state = hass.states.get("sensor.mock_title_zigbee_chip_temp_2")
assert state
assert state.state == "20.45"
async def test_zigbee_type_sensors(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_smlight_client: MagicMock,
) -> None:
"""Test for zigbee type sensor with second radio."""
mock_smlight_client.get_info.side_effect = None
mock_smlight_client.get_info.return_value = Info.from_dict(
load_json_object_fixture("info-MR1.json", DOMAIN)
)
await setup_integration(hass, mock_config_entry)
state = hass.states.get("sensor.mock_title_zigbee_type")
assert state
assert state.state == "coordinator"
state = hass.states.get("sensor.mock_title_zigbee_type_2")
assert state
assert state.state == "router"