mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 11:17:21 +00:00
Reset UniFi bandwidth sensor when client misses heartbeat (#104522)
* Reset UniFi bandwidth sensor when client misses heartbeat * Fix initialization sequence * Code simplification: remove heartbeat_timedelta, unique_id and tracker logic * Add unit tests * Remove unused _is_connected attribute * Remove redundant async_initiate_state * Make is_connected_fn optional, heartbeat detection will only happen if not None * Add checks on is_connected_fn
This commit is contained in:
parent
1cdfb06d77
commit
d94421e1a4
@ -32,7 +32,8 @@ from homeassistant.components.sensor import (
|
|||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import EntityCategory, UnitOfDataRate, UnitOfPower
|
from homeassistant.const import EntityCategory, UnitOfDataRate, UnitOfPower
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import Event as core_Event, HomeAssistant, callback
|
||||||
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
import homeassistant.util.dt as dt_util
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
@ -132,6 +133,20 @@ def async_device_outlet_supported_fn(controller: UniFiController, obj_id: str) -
|
|||||||
return controller.api.devices[obj_id].outlet_ac_power_budget is not None
|
return controller.api.devices[obj_id].outlet_ac_power_budget is not None
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_client_is_connected_fn(controller: UniFiController, obj_id: str) -> bool:
|
||||||
|
"""Check if client was last seen recently."""
|
||||||
|
client = controller.api.clients[obj_id]
|
||||||
|
|
||||||
|
if (
|
||||||
|
dt_util.utcnow() - dt_util.utc_from_timestamp(client.last_seen or 0)
|
||||||
|
> controller.option_detection_time
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class UnifiSensorEntityDescriptionMixin(Generic[HandlerT, ApiItemT]):
|
class UnifiSensorEntityDescriptionMixin(Generic[HandlerT, ApiItemT]):
|
||||||
"""Validate and load entities from different UniFi handlers."""
|
"""Validate and load entities from different UniFi handlers."""
|
||||||
@ -153,6 +168,8 @@ class UnifiSensorEntityDescription(
|
|||||||
):
|
):
|
||||||
"""Class describing UniFi sensor entity."""
|
"""Class describing UniFi sensor entity."""
|
||||||
|
|
||||||
|
is_connected_fn: Callable[[UniFiController, str], bool] | None = None
|
||||||
|
|
||||||
|
|
||||||
ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = (
|
ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = (
|
||||||
UnifiSensorEntityDescription[Clients, Client](
|
UnifiSensorEntityDescription[Clients, Client](
|
||||||
@ -169,6 +186,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = (
|
|||||||
device_info_fn=async_client_device_info_fn,
|
device_info_fn=async_client_device_info_fn,
|
||||||
event_is_on=None,
|
event_is_on=None,
|
||||||
event_to_subscribe=None,
|
event_to_subscribe=None,
|
||||||
|
is_connected_fn=async_client_is_connected_fn,
|
||||||
name_fn=lambda _: "RX",
|
name_fn=lambda _: "RX",
|
||||||
object_fn=lambda api, obj_id: api.clients[obj_id],
|
object_fn=lambda api, obj_id: api.clients[obj_id],
|
||||||
should_poll=False,
|
should_poll=False,
|
||||||
@ -190,6 +208,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = (
|
|||||||
device_info_fn=async_client_device_info_fn,
|
device_info_fn=async_client_device_info_fn,
|
||||||
event_is_on=None,
|
event_is_on=None,
|
||||||
event_to_subscribe=None,
|
event_to_subscribe=None,
|
||||||
|
is_connected_fn=async_client_is_connected_fn,
|
||||||
name_fn=lambda _: "TX",
|
name_fn=lambda _: "TX",
|
||||||
object_fn=lambda api, obj_id: api.clients[obj_id],
|
object_fn=lambda api, obj_id: api.clients[obj_id],
|
||||||
should_poll=False,
|
should_poll=False,
|
||||||
@ -388,6 +407,16 @@ class UnifiSensorEntity(UnifiEntity[HandlerT, ApiItemT], SensorEntity):
|
|||||||
|
|
||||||
entity_description: UnifiSensorEntityDescription[HandlerT, ApiItemT]
|
entity_description: UnifiSensorEntityDescription[HandlerT, ApiItemT]
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _make_disconnected(self, *_: core_Event) -> None:
|
||||||
|
"""No heart beat by device.
|
||||||
|
|
||||||
|
Reset sensor value to 0 when client device is disconnected
|
||||||
|
"""
|
||||||
|
if self._attr_native_value != 0:
|
||||||
|
self._attr_native_value = 0
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_update_state(self, event: ItemEvent, obj_id: str) -> None:
|
def async_update_state(self, event: ItemEvent, obj_id: str) -> None:
|
||||||
"""Update entity state.
|
"""Update entity state.
|
||||||
@ -398,3 +427,33 @@ class UnifiSensorEntity(UnifiEntity[HandlerT, ApiItemT], SensorEntity):
|
|||||||
obj = description.object_fn(self.controller.api, self._obj_id)
|
obj = description.object_fn(self.controller.api, self._obj_id)
|
||||||
if (value := description.value_fn(self.controller, obj)) != self.native_value:
|
if (value := description.value_fn(self.controller, obj)) != self.native_value:
|
||||||
self._attr_native_value = value
|
self._attr_native_value = value
|
||||||
|
|
||||||
|
if description.is_connected_fn is not None:
|
||||||
|
# Send heartbeat if client is connected
|
||||||
|
if description.is_connected_fn(self.controller, self._obj_id):
|
||||||
|
self.controller.async_heartbeat(
|
||||||
|
self._attr_unique_id,
|
||||||
|
dt_util.utcnow() + self.controller.option_detection_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
"""Register callbacks."""
|
||||||
|
await super().async_added_to_hass()
|
||||||
|
|
||||||
|
if self.entity_description.is_connected_fn is not None:
|
||||||
|
# Register callback for missed heartbeat
|
||||||
|
self.async_on_remove(
|
||||||
|
async_dispatcher_connect(
|
||||||
|
self.hass,
|
||||||
|
f"{self.controller.signal_heartbeat_missed}_{self.unique_id}",
|
||||||
|
self._make_disconnected,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_will_remove_from_hass(self) -> None:
|
||||||
|
"""Disconnect object when removed."""
|
||||||
|
await super().async_will_remove_from_hass()
|
||||||
|
|
||||||
|
if self.entity_description.is_connected_fn is not None:
|
||||||
|
# Remove heartbeat registration
|
||||||
|
self.controller.async_heartbeat(self._attr_unique_id)
|
||||||
|
@ -5,7 +5,7 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from aiounifi.models.device import DeviceState
|
from aiounifi.models.device import DeviceState
|
||||||
from aiounifi.models.message import MessageKey
|
from aiounifi.models.message import MessageKey
|
||||||
from freezegun.api import FrozenDateTimeFactory
|
from freezegun.api import FrozenDateTimeFactory, freeze_time
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN
|
from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN
|
||||||
@ -22,6 +22,7 @@ from homeassistant.components.unifi.const import (
|
|||||||
CONF_TRACK_CLIENTS,
|
CONF_TRACK_CLIENTS,
|
||||||
CONF_TRACK_DEVICES,
|
CONF_TRACK_DEVICES,
|
||||||
DEVICE_STATES,
|
DEVICE_STATES,
|
||||||
|
DOMAIN as UNIFI_DOMAIN,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY
|
from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY
|
||||||
from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, EntityCategory
|
from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, EntityCategory
|
||||||
@ -393,6 +394,31 @@ async def test_bandwidth_sensors(
|
|||||||
assert hass.states.get("sensor.wireless_client_rx").state == "3456.0"
|
assert hass.states.get("sensor.wireless_client_rx").state == "3456.0"
|
||||||
assert hass.states.get("sensor.wireless_client_tx").state == "7891.0"
|
assert hass.states.get("sensor.wireless_client_tx").state == "7891.0"
|
||||||
|
|
||||||
|
# Verify reset sensor after heartbeat expires
|
||||||
|
|
||||||
|
controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id]
|
||||||
|
new_time = dt_util.utcnow()
|
||||||
|
wireless_client["last_seen"] = dt_util.as_timestamp(new_time)
|
||||||
|
|
||||||
|
mock_unifi_websocket(message=MessageKey.CLIENT, data=wireless_client)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
with freeze_time(new_time):
|
||||||
|
async_fire_time_changed(hass, new_time)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert hass.states.get("sensor.wireless_client_rx").state == "3456.0"
|
||||||
|
assert hass.states.get("sensor.wireless_client_tx").state == "7891.0"
|
||||||
|
|
||||||
|
new_time = new_time + controller.option_detection_time + timedelta(seconds=1)
|
||||||
|
|
||||||
|
with freeze_time(new_time):
|
||||||
|
async_fire_time_changed(hass, new_time)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert hass.states.get("sensor.wireless_client_rx").state == "0"
|
||||||
|
assert hass.states.get("sensor.wireless_client_tx").state == "0"
|
||||||
|
|
||||||
# Disable option
|
# Disable option
|
||||||
|
|
||||||
options[CONF_ALLOW_BANDWIDTH_SENSORS] = False
|
options[CONF_ALLOW_BANDWIDTH_SENSORS] = False
|
||||||
|
Loading…
x
Reference in New Issue
Block a user