Skip to content

Commit

Permalink
fix: ensure ping back messages are called back and empty updates excl…
Browse files Browse the repository at this point in the history
…uded (#62)
  • Loading branch information
bdraco authored Jun 16, 2024
1 parent 3a39554 commit b319dba
Show file tree
Hide file tree
Showing 4 changed files with 51 additions and 12 deletions.
9 changes: 7 additions & 2 deletions src/uiprotect/data/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@
EVENT_PING_INTERVAL = timedelta(seconds=3)
EVENT_PING_INTERVAL_SECONDS = EVENT_PING_INTERVAL.total_seconds()

_EMPTY_EVENT_PING_BACK: dict[Any, Any] = {}


_LOGGER = logging.getLogger(__name__)


Expand Down Expand Up @@ -788,7 +791,7 @@ async def emit_message(self, updated: dict[str, Any]) -> None:

def _emit_message(self, updated: dict[str, Any]) -> None:
"""Emits fake WS message for ProtectApiClient to process."""
if updated == {}:
if _is_ping_back := updated is _EMPTY_EVENT_PING_BACK:
_LOGGER.debug("Event ping callback started for %s", self.id)

if self.model is None:
Expand Down Expand Up @@ -817,7 +820,9 @@ def _emit_message(self, updated: dict[str, Any]) -> None:

message = self._api.bootstrap.process_ws_packet(
WSPacket(action_frame.packed + data_frame.packed),
is_ping_back=_is_ping_back,
)

if message is not None:
self._api.emit_message(message)

Expand Down Expand Up @@ -876,7 +881,7 @@ def _event_callback_ping(self) -> None:
self._callback_ping = loop.call_later(
EVENT_PING_INTERVAL_SECONDS,
self._emit_message,
{},
_EMPTY_EVENT_PING_BACK,
)

async def set_name(self, name: str | None) -> None:
Expand Down
30 changes: 20 additions & 10 deletions src/uiprotect/data/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,15 @@ def _process_device_update(
action: dict[str, Any],
data: dict[str, Any],
ignore_stats: bool,
is_ping_back: bool,
) -> WSSubscriptionMessage | None:
"""
Process a device update packet.
If is_ping_back is True, the packet is an empty packet
that was generated internally as a result of an event
that will expire and result in a state change.
"""
remove_keys = (
STATS_AND_IGNORE_DEVICE_KEYS if ignore_stats else IGNORE_DEVICE_KEYS
)
Expand All @@ -462,7 +470,7 @@ def _process_device_update(
if model_type is ModelType.CAMERA and "lastMotion" in data:
del data["lastMotion"]
# nothing left to process
if not data:
if not data and not is_ping_back:
self._create_stat(packet, None, True)
return None

Expand All @@ -476,6 +484,12 @@ def _process_device_update(

obj = devices[action_id]
data = obj.unifi_dict_to_dict(data)

if not data and not is_ping_back:
# nothing left to process
self._create_stat(packet, None, True)
return None

old_obj = obj.copy()
obj = obj.update_from_dict(deepcopy(data))

Expand Down Expand Up @@ -513,6 +527,7 @@ def process_ws_packet(
packet: WSPacket,
models: set[ModelType] | None = None,
ignore_stats: bool = False,
is_ping_back: bool = False,
) -> WSSubscriptionMessage | None:
"""Process a WS packet."""
action = packet.action_frame.data
Expand All @@ -521,17 +536,16 @@ def process_ws_packet(
action = deepcopy(action)
data = deepcopy(data)

new_update_id: str = action["newUpdateId"]
new_update_id: str | None = action["newUpdateId"]
if new_update_id is not None:
self.last_update_id = new_update_id

model_key: str = action["modelKey"]
if model_key not in ModelType.values_set():
if (model_type := ModelType.from_string(model_key)) is ModelType.UNKNOWN:
_LOGGER.debug("Unknown model type: %s", model_key)
self._create_stat(packet, None, True)
return None

model_type = ModelType.from_string(model_key)
if models and model_type not in models:
self._create_stat(packet, None, True)
return None
Expand All @@ -540,7 +554,7 @@ def process_ws_packet(
if action_action == "remove":
return self._process_remove_packet(model_type, packet)

if not data:
if not data and not is_ping_back:
self._create_stat(packet, None, True)
return None

Expand All @@ -552,11 +566,7 @@ def process_ws_packet(
return self._process_nvr_update(packet, data, ignore_stats)
if model_type in ModelType.bootstrap_models_types_and_event_set:
return self._process_device_update(
model_type,
packet,
action,
data,
ignore_stats,
model_type, packet, action, data, ignore_stats, is_ping_back
)
except (ValidationError, ValueError) as err:
self._handle_ws_error(action, err)
Expand Down
11 changes: 11 additions & 0 deletions tests/data/test_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from __future__ import annotations

import pytest

from uiprotect.data.types import ModelType


@pytest.mark.asyncio()
async def test_model_type_from_string():
assert ModelType.from_string("camera") is ModelType.CAMERA
assert ModelType.from_string("invalid") is ModelType.UNKNOWN
13 changes: 13 additions & 0 deletions tests/test_api_ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ def get_camera() -> Camera:

@pytest.mark.skipif(not TEST_CAMERA_EXISTS, reason="Missing testdata")
@patch("uiprotect.data.devices.utc_now")
@patch("uiprotect.data.base.EVENT_PING_INTERVAL_SECONDS", 0)
@pytest.mark.asyncio()
async def test_ws_emit_ring_callback(
mock_now,
Expand Down Expand Up @@ -449,6 +450,18 @@ async def test_ws_emit_ring_callback(
mock_now.return_value = utc_now() + EVENT_PING_INTERVAL
assert not obj.is_ringing

# The event message should be emitted
assert protect_client.emit_message.call_count == 1

await asyncio.sleep(0)
await asyncio.sleep(0)

# An empty messages should be emitted
assert protect_client.emit_message.call_count == 2

message: WSSubscriptionMessage = protect_client.emit_message.call_args[0][0]
assert message.changed_data == {}


@pytest.mark.skipif(not TEST_SENSOR_EXISTS, reason="Missing testdata")
@patch("uiprotect.data.devices.utc_now")
Expand Down

0 comments on commit b319dba

Please sign in to comment.