mirror of
https://github.com/home-assistant/core.git
synced 2026-09-24 15:31:52 -05:00
Add Wi-Fi signal strength diagnostic sensor to Duco (#168290)
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from duco import DucoClient
|
||||
@@ -18,7 +19,14 @@ from .const import DOMAIN, SCAN_INTERVAL
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type DucoConfigEntry = ConfigEntry[DucoCoordinator]
|
||||
type DucoData = dict[int, Node]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DucoData:
|
||||
"""Data returned by the Duco coordinator."""
|
||||
|
||||
nodes: dict[int, Node]
|
||||
rssi_wifi: int | None
|
||||
|
||||
|
||||
class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
@@ -72,4 +80,23 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
translation_key="api_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
return {node.node_id: node for node in nodes}
|
||||
|
||||
try:
|
||||
lan_info = await self.client.async_get_lan_info()
|
||||
except DucoConnectionError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except DucoError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
return DucoData(
|
||||
nodes={node.node_id: node for node in nodes},
|
||||
rssi_wifi=lan_info.rssi_wifi,
|
||||
)
|
||||
|
||||
@@ -44,7 +44,8 @@ async def async_get_config_entry_diagnostics(
|
||||
"board_info": board,
|
||||
"lan_info": asdict(lan_info),
|
||||
"nodes": {
|
||||
str(node_id): asdict(node) for node_id, node in coordinator.data.items()
|
||||
str(node_id): asdict(node)
|
||||
for node_id, node in coordinator.data.nodes.items()
|
||||
},
|
||||
"duco_diagnostics": [asdict(d) for d in duco_diags],
|
||||
"write_requests_remaining": write_remaining,
|
||||
|
||||
@@ -44,9 +44,9 @@ class DucoEntity(CoordinatorEntity[DucoCoordinator]):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return super().available and self._node_id in self.coordinator.data
|
||||
return super().available and self._node_id in self.coordinator.data.nodes
|
||||
|
||||
@property
|
||||
def _node(self) -> Node:
|
||||
"""Return the current node data from the coordinator."""
|
||||
return self.coordinator.data[self._node_id]
|
||||
return self.coordinator.data.nodes[self._node_id]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from duco.exceptions import DucoError
|
||||
from duco.models import Node, VentilationState
|
||||
from duco.models import Node, NodeType, VentilationState
|
||||
|
||||
from homeassistant.components.fan import FanEntity, FanEntityFeature
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -62,8 +62,8 @@ async def async_setup_entry(
|
||||
|
||||
async_add_entities(
|
||||
DucoVentilationFanEntity(coordinator, node)
|
||||
for node in coordinator.data.values()
|
||||
if node.general.node_type == "BOX"
|
||||
for node in coordinator.data.nodes.values()
|
||||
if node.general.node_type == NodeType.BOX
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ rules:
|
||||
comment: >-
|
||||
Users can pair new modules (CO2 sensors, humidity sensors, zone valves)
|
||||
to their Duco box. Dynamic device support to be added in a follow-up PR.
|
||||
entity-category: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
|
||||
@@ -13,7 +13,12 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
PERCENTAGE,
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
EntityCategory,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -31,6 +36,13 @@ class DucoSensorEntityDescription(SensorEntityDescription):
|
||||
node_types: tuple[NodeType, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class DucoBoxSensorEntityDescription(SensorEntityDescription):
|
||||
"""Duco sensor entity description for box-level diagnostic data."""
|
||||
|
||||
value_fn: Callable[[DucoCoordinator], int | float | None]
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
|
||||
DucoSensorEntityDescription(
|
||||
key="ventilation_state",
|
||||
@@ -78,6 +90,18 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
|
||||
),
|
||||
)
|
||||
|
||||
BOX_SENSOR_DESCRIPTIONS: tuple[DucoBoxSensorEntityDescription, ...] = (
|
||||
DucoBoxSensorEntityDescription(
|
||||
key="rssi_wifi",
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda coordinator: coordinator.data.rssi_wifi,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -88,10 +112,20 @@ async def async_setup_entry(
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
DucoSensorEntity(coordinator, node, description)
|
||||
for node in coordinator.data.values()
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
if node.general.node_type in description.node_types
|
||||
[
|
||||
*[
|
||||
DucoSensorEntity(coordinator, node, description)
|
||||
for node in coordinator.data.nodes.values()
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
if node.general.node_type in description.node_types
|
||||
],
|
||||
*[
|
||||
DucoBoxSensorEntity(coordinator, node, description)
|
||||
for node in coordinator.data.nodes.values()
|
||||
for description in BOX_SENSOR_DESCRIPTIONS
|
||||
if node.general.node_type == NodeType.BOX
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -117,3 +151,27 @@ class DucoSensorEntity(DucoEntity, SensorEntity):
|
||||
def native_value(self) -> int | float | str | None:
|
||||
"""Return the sensor value."""
|
||||
return self.entity_description.value_fn(self._node)
|
||||
|
||||
|
||||
class DucoBoxSensorEntity(DucoEntity, SensorEntity):
|
||||
"""Sensor entity for box-level diagnostic data."""
|
||||
|
||||
entity_description: DucoBoxSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DucoCoordinator,
|
||||
node: Node,
|
||||
description: DucoBoxSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the box sensor entity."""
|
||||
super().__init__(coordinator, node)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = (
|
||||
f"{coordinator.config_entry.unique_id}_{node.node_id}_{description.key}"
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | float | None:
|
||||
"""Return the sensor value."""
|
||||
return self.entity_description.value_fn(self.coordinator)
|
||||
|
||||
@@ -108,6 +108,61 @@
|
||||
'state': '85',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities_state[sensor.living_signal_strength-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.living_signal_strength',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Signal strength',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.SIGNAL_STRENGTH: 'signal_strength'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Signal strength',
|
||||
'platform': 'duco',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'aa:bb:cc:dd:ee:ff_1_rssi_wifi',
|
||||
'unit_of_measurement': 'dBm',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities_state[sensor.living_signal_strength-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'signal_strength',
|
||||
'friendly_name': 'Living Signal strength',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': 'dBm',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.living_signal_strength',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '-60',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities_state[sensor.living_ventilation_state-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from duco.exceptions import DucoConnectionError
|
||||
from duco.exceptions import DucoConnectionError, DucoError
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
@@ -57,6 +57,18 @@ async def test_iaq_sensor_entities_disabled_by_default(
|
||||
assert entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_diagnostic_sensor_entities_disabled_by_default(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test that diagnostic sensor entities are disabled by default."""
|
||||
for entity_id in ("sensor.living_signal_strength",):
|
||||
entry = entity_registry.async_get(entity_id)
|
||||
assert entry is not None
|
||||
assert entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_coordinator_update_marks_unavailable(
|
||||
hass: HomeAssistant,
|
||||
@@ -75,3 +87,41 @@ async def test_coordinator_update_marks_unavailable(
|
||||
state = hass.states.get("sensor.office_co2_carbon_dioxide")
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_coordinator_update_duco_error_marks_unavailable(
|
||||
hass: HomeAssistant,
|
||||
mock_duco_client: AsyncMock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test that sensor entities become unavailable when async_get_nodes raises DucoError."""
|
||||
mock_duco_client.async_get_nodes = AsyncMock(side_effect=DucoError("api error"))
|
||||
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
state = hass.states.get("sensor.office_co2_carbon_dioxide")
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
|
||||
async def test_lan_info_duco_error_marks_unavailable(
|
||||
hass: HomeAssistant,
|
||||
mock_duco_client: AsyncMock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test that entities become unavailable when async_get_lan_info raises DucoError."""
|
||||
mock_duco_client.async_get_lan_info = AsyncMock(
|
||||
side_effect=DucoError("lan info error")
|
||||
)
|
||||
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
state = hass.states.get("sensor.living_signal_strength")
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
Reference in New Issue
Block a user