From dfa3df38947459003777a5b2088cc04df6f29c4d Mon Sep 17 00:00:00 2001 From: David Mulcahey Date: Wed, 6 Nov 2024 09:59:30 -0500 Subject: [PATCH] device offline and online events --- tests/test_climate.py | 9 +++++- tests/test_device.py | 60 ++++++++++++++++++++++++++++++-------- zha/application/gateway.py | 18 +++++++++++- zha/application/model.py | 4 +-- zha/zigbee/device.py | 22 +++++++++++--- 5 files changed, 93 insertions(+), 20 deletions(-) diff --git a/tests/test_climate.py b/tests/test_climate.py index 43bdfcb41..a7eff54cc 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -374,6 +374,9 @@ async def test_climate_hvac_action_running_state( assert entity.hvac_action == "off" assert sensor_entity.state["state"] == "off" + # the state isn't actually changing here... on the WS impl side we are getting + # the correct call count... we are getting the wrong call count on the normal impl + # TODO look into why this is the case... await send_attributes_report( zha_gateway, thrm_cluster, {0x001E: Thermostat.RunningMode.Off} ) @@ -417,7 +420,11 @@ async def test_climate_hvac_action_running_state( assert sensor_entity.state["state"] == "fan" # Both entities are updated! - assert len(subscriber.mock_calls) == 2 * 6 + assert ( + len(subscriber.mock_calls) == 2 * 6 + if not hasattr(zha_gateway, "ws_gateway") + else 2 * 5 + ) @pytest.mark.parametrize( diff --git a/tests/test_device.py b/tests/test_device.py index d0c42a079..5b94ac991 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -114,6 +114,14 @@ async def _send_time_changed(zha_gateway: Gateway, seconds: int): "zha.zigbee.cluster_handlers.general.BasicClusterHandler.async_initialize", new=mock.AsyncMock(), ) +@pytest.mark.parametrize( + "zha_gateway", + [ + "zha_gateway", + "ws_gateways", + ], + indirect=True, +) async def test_check_available_success( zha_gateway: Gateway, caplog: pytest.LogCaptureFixture, @@ -124,6 +132,12 @@ async def test_check_available_success( ) zha_device = await join_zigpy_device(zha_gateway, device_with_basic_cluster_handler) basic_ch = device_with_basic_cluster_handler.endpoints[3].basic + if hasattr(zha_gateway, "ws_gateway"): + server_device = zha_gateway.ws_gateway.devices[zha_device.ieee] + server_gateway = zha_gateway.ws_gateway + else: + server_device = zha_device + server_gateway = zha_gateway assert not zha_device.is_coordinator assert not zha_device.is_active_coordinator @@ -131,12 +145,15 @@ async def test_check_available_success( basic_ch.read_attributes.reset_mock() device_with_basic_cluster_handler.last_seen = None assert zha_device.available is True - await _send_time_changed(zha_gateway, zha_device.consider_unavailable_time + 2) + await _send_time_changed(zha_gateway, server_device.consider_unavailable_time + 2) assert zha_device.available is False assert basic_ch.read_attributes.await_count == 0 + for entity in server_device.platform_entities.values(): + assert not entity.available + device_with_basic_cluster_handler.last_seen = ( - time.time() - zha_device.consider_unavailable_time - 100 + time.time() - server_device.consider_unavailable_time - 100 ) _seens = [time.time(), device_with_basic_cluster_handler.last_seen] @@ -146,63 +163,82 @@ def _update_last_seen(*args, **kwargs): # pylint: disable=unused-argument basic_ch.read_attributes.side_effect = _update_last_seen - for entity in zha_device.platform_entities.values(): + for entity in server_device.platform_entities.values(): entity.emit = mock.MagicMock(wraps=entity.emit) # we want to test the device availability handling alone - zha_gateway.global_updater.stop() + server_gateway.global_updater.stop() # successfully ping zigpy device, but zha_device is not yet available await _send_time_changed( - zha_gateway, zha_gateway._device_availability_checker.__polling_interval + 1 + zha_gateway, server_gateway._device_availability_checker.__polling_interval + 1 ) assert basic_ch.read_attributes.await_count == 1 assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"] assert zha_device.available is False - for entity in zha_device.platform_entities.values(): + for entity in server_device.platform_entities.values(): entity.emit.assert_not_called() assert not entity.available + if server_device != zha_device: + assert not zha_device.platform_entities[ + (entity.PLATFORM, entity.unique_id) + ].available entity.emit.reset_mock() # There was traffic from the device: pings, but not yet available await _send_time_changed( - zha_gateway, zha_gateway._device_availability_checker.__polling_interval + 1 + zha_gateway, server_gateway._device_availability_checker.__polling_interval + 1 ) assert basic_ch.read_attributes.await_count == 2 assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"] assert zha_device.available is False - for entity in zha_device.platform_entities.values(): + for entity in server_device.platform_entities.values(): entity.emit.assert_not_called() assert not entity.available + if server_device != zha_device: + assert not zha_device.platform_entities[ + (entity.PLATFORM, entity.unique_id) + ].available entity.emit.reset_mock() # There was traffic from the device: don't try to ping, marked as available await _send_time_changed( - zha_gateway, zha_gateway._device_availability_checker.__polling_interval + 1 + zha_gateway, server_gateway._device_availability_checker.__polling_interval + 1 ) assert basic_ch.read_attributes.await_count == 2 assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"] assert zha_device.available is True assert zha_device.on_network is True - for entity in zha_device.platform_entities.values(): + for entity in server_device.platform_entities.values(): entity.emit.assert_called() + if server_device != zha_device: + assert zha_device.platform_entities[ + (entity.PLATFORM, entity.unique_id) + ].available assert entity.available entity.emit.reset_mock() assert "Device is not on the network, marking unavailable" not in caplog.text - zha_device.on_network = False + server_gateway._device_availability_checker.stop() + + server_device.on_network = False + await zha_gateway.async_block_till_done(wait_background_tasks=True) assert zha_device.available is False assert zha_device.on_network is False assert "Device is not on the network, marking unavailable" in caplog.text - for entity in zha_device.platform_entities.values(): + for entity in server_device.platform_entities.values(): entity.emit.assert_called() assert not entity.available + if server_device != zha_device: + assert not zha_device.platform_entities[ + (entity.PLATFORM, entity.unique_id) + ].available entity.emit.reset_mock() diff --git a/zha/application/gateway.py b/zha/application/gateway.py index a98178852..deefe6076 100644 --- a/zha/application/gateway.py +++ b/zha/application/gateway.py @@ -58,6 +58,8 @@ DeviceJoinedDeviceInfo, DeviceJoinedEvent, DeviceLeftEvent, + DeviceOfflineEvent, + DeviceOnlineEvent, DevicePairingStatus, DeviceRemovedEvent, ExtendedDeviceInfoWithPairingStatus, @@ -97,7 +99,7 @@ SwitchHelper, UpdateHelper, ) -from zha.websocket.const import ControllerEvents +from zha.websocket.const import ControllerEvents, DeviceEvents from zha.websocket.server.client import ClientManager, load_api as load_client_api from zha.zigbee.device import BaseDevice, Device, WebSocketClientDevice from zha.zigbee.endpoint import ATTR_IN_CLUSTERS, ATTR_OUT_CLUSTERS @@ -1149,6 +1151,20 @@ def handle_device_removed(self, event: DeviceRemovedEvent) -> None: self._devices.pop(device.ieee, None) self.emit(ZHA_GW_MSG_DEVICE_REMOVED, event) + def handle_device_online(self, event: DeviceOnlineEvent) -> None: + """Handle device online event.""" + if event.device_info.ieee in self.devices: + device = self.devices[event.device_info.ieee] + device.extended_device_info = event.device_info + device.emit(DeviceEvents.DEVICE_ONLINE, event) + + def handle_device_offline(self, event: DeviceOfflineEvent) -> None: + """Handle device offline event.""" + if event.device_info.ieee in self.devices: + device = self.devices[event.device_info.ieee] + device.extended_device_info = event.device_info + device.emit(DeviceEvents.DEVICE_OFFLINE, event) + def handle_group_member_removed(self, event: GroupMemberRemovedEvent) -> None: """Handle group member removed event.""" if event.group_info.group_id in self.groups: diff --git a/zha/application/model.py b/zha/application/model.py index 61320667e..6d7bbd020 100644 --- a/zha/application/model.py +++ b/zha/application/model.py @@ -133,7 +133,7 @@ class DeviceOfflineEvent(BaseEvent): event: Literal["device_offline"] = "device_offline" event_type: Literal["device_event"] = "device_event" - device: ExtendedDeviceInfo + device_info: ExtendedDeviceInfo class DeviceOnlineEvent(BaseEvent): @@ -141,4 +141,4 @@ class DeviceOnlineEvent(BaseEvent): event: Literal["device_online"] = "device_online" event_type: Literal["device_event"] = "device_event" - device: ExtendedDeviceInfo + device_info: ExtendedDeviceInfo diff --git a/zha/zigbee/device.py b/zha/zigbee/device.py index 598e89d85..b54edeb7b 100644 --- a/zha/zigbee/device.py +++ b/zha/zigbee/device.py @@ -57,6 +57,7 @@ ZHA_EVENT, ) from zha.application.helpers import convert_to_zcl_values +from zha.application.model import DeviceOfflineEvent, DeviceOnlineEvent from zha.application.platforms import PlatformEntity, T, WebSocketClientEntity from zha.event import EventBase from zha.exceptions import ZHAException @@ -637,16 +638,20 @@ def update_available( self.debug( ( "Update device availability - device available: %s - new availability:" - " %s - changed: %s" + " %s - changed: %s - on network: %s - new on network: %s - changed: %s" ), self.available, available, self.available ^ available, + self.on_network, + on_network, + self.on_network ^ on_network, ) availability_changed = self.available ^ available + on_network_changed = self.on_network ^ on_network self._available = available self._on_network = on_network - if availability_changed and available: + if (availability_changed or on_network_changed) and (available and on_network): # reinit cluster handlers then signal entities self.debug( "Device availability changed and device became available," @@ -658,8 +663,14 @@ def update_available( eager_start=True, ) return - if availability_changed and not available: + if (availability_changed or on_network_changed) and not ( + available and on_network + ): self.debug("Device availability changed and device became unavailable") + self.gateway.emit( + "device_offline", + DeviceOfflineEvent(device_info=self.extended_device_info), + ) for entity in self.platform_entities.values(): entity.maybe_emit_state_changed_event() self.emit_zha_event( @@ -681,6 +692,9 @@ def emit_zha_event(self, event_data: dict[str, str | int]) -> None: # pylint: d async def _async_became_available(self) -> None: """Update device availability and signal entities.""" + self.gateway.emit( + "device_online", DeviceOnlineEvent(device_info=self.extended_device_info) + ) await self.async_initialize(False) for platform_entity in self._platform_entities.values(): platform_entity.maybe_emit_state_changed_event() @@ -1299,7 +1313,7 @@ def _build_or_update_entities(self): for entity_info in self._extended_device_info.entities.values(): entity_key = (entity_info.platform, entity_info.unique_id) if entity_key in self._entities: - self._entities[entity_key].entity_info = entity_info + self._entities[entity_key].info_object = entity_info else: self._entities[entity_key] = ( discovery.ENTITY_INFO_CLASS_TO_WEBSOCKET_CLIENT_ENTITY_CLASS[