Skip to content

Commit e72d5ca

Browse files
allenporterCopilot
andauthored
feat: Update the status listener API (#771)
* feat: Update the status listener API This removes the datapoints argument from the update listener since the caller is supposed to review the contents of the trait rather than the raw data. * chore: Update tests/devices/traits/b01/q10/test_status.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore: Fix test pydoc lint error --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 216b644 commit e72d5ca

File tree

3 files changed

+58
-19
lines changed

3 files changed

+58
-19
lines changed

roborock/devices/traits/b01/q10/common.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,41 @@ class MyStatus(RoborockBase):
3636
"""
3737

3838
import dataclasses
39+
import logging
40+
from collections.abc import Callable
3941
from typing import Any
4042

43+
from roborock.callbacks import CallbackList
4144
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
4245
from roborock.data.containers import RoborockBase
4346

4447

48+
class TraitUpdateListener:
49+
"""Trait update listener.
50+
51+
This is a base class for traits to support notifying listeners when they
52+
have been updated. Clients may register callbacks to be notified when the
53+
trait has been updated. When the listener callback is invoked, the client
54+
should read the trait's properties to get the updated values.
55+
"""
56+
57+
def __init__(self, logger: logging.Logger) -> None:
58+
"""Initialize the trait update listener."""
59+
self._update_callbacks: CallbackList[None] = CallbackList(logger=logger)
60+
61+
def add_update_listener(self, callback: Callable[[], None]) -> Callable[[], None]:
62+
"""Register a callback when the trait has been updated.
63+
64+
Returns a callable to remove the listener.
65+
"""
66+
# We wrap the callback to ignore the value passed to it.
67+
return self._update_callbacks.add_callback(lambda _: callback())
68+
69+
def _notify_update(self) -> None:
70+
"""Notify all update listeners."""
71+
self._update_callbacks(None)
72+
73+
4574
class DpsDataConverter:
4675
"""Utility to handle the transformation and merging of DPS data into models.
4776
@@ -66,7 +95,7 @@ def from_dataclass(cls, dataclass_type: type[RoborockBase]):
6695
dps_field_map[dps_id] = field_obj.name
6796
return cls(dps_type_map, dps_field_map)
6897

69-
def update_from_dps(self, target: RoborockBase, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
98+
def update_from_dps(self, target: RoborockBase, decoded_dps: dict[B01_Q10_DP, Any]) -> bool:
7099
"""Convert and merge raw DPS data into the target object.
71100
72101
Uses the pre-calculated type mapping to ensure values are converted to the
@@ -75,8 +104,12 @@ def update_from_dps(self, target: RoborockBase, decoded_dps: dict[B01_Q10_DP, An
75104
Args:
76105
target: The target object to update.
77106
decoded_dps: The decoded DPS data to convert.
107+
108+
Returns:
109+
True if any values were updated, False otherwise.
78110
"""
79111
conversions = RoborockBase.convert_dict(self._dps_type_map, decoded_dps)
80112
for dps_id, value in conversions.items():
81113
field_name = self._dps_field_map[dps_id]
82114
setattr(target, field_name, value)
115+
return bool(conversions)
Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,19 @@
11
"""Status trait for Q10 B01 devices."""
22

33
import logging
4-
from collections.abc import Callable
54
from typing import Any
65

7-
from roborock.callbacks import CallbackList
86
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
97
from roborock.data.b01_q10.b01_q10_containers import Q10Status
108

11-
from .common import DpsDataConverter
9+
from .common import DpsDataConverter, TraitUpdateListener
1210

1311
_LOGGER = logging.getLogger(__name__)
1412

1513
_CONVERTER = DpsDataConverter.from_dataclass(Q10Status)
1614

1715

18-
class StatusTrait(Q10Status):
16+
class StatusTrait(Q10Status, TraitUpdateListener):
1917
"""Trait for managing the status of Q10 Roborock devices.
2018
2119
This is a thin wrapper around Q10Status that provides the Trait interface.
@@ -26,16 +24,9 @@ class StatusTrait(Q10Status):
2624
def __init__(self) -> None:
2725
"""Initialize the status trait."""
2826
super().__init__()
29-
self._update_callbacks: CallbackList[dict[B01_Q10_DP, Any]] = CallbackList(logger=_LOGGER)
30-
31-
def add_update_listener(self, callback: Callable[[dict[B01_Q10_DP, Any]], None]) -> Callable[[], None]:
32-
"""Register a callback for decoded DPS updates.
33-
34-
Returns a callable to remove the listener.
35-
"""
36-
return self._update_callbacks.add_callback(callback)
27+
TraitUpdateListener.__init__(self, logger=_LOGGER)
3728

3829
def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
3930
"""Update the trait from raw DPS data."""
40-
_CONVERTER.update_from_dps(self, decoded_dps)
41-
self._update_callbacks(decoded_dps)
31+
if _CONVERTER.update_from_dps(self, decoded_dps):
32+
self._notify_update()

tests/devices/traits/b01/q10/test_status.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,18 +144,33 @@ async def test_status_trait_refresh(
144144

145145
def test_status_trait_update_listener(q10_api: Q10PropertiesApi) -> None:
146146
"""Test that status listeners receive updates and can unsubscribe."""
147-
updates: list[dict[B01_Q10_DP, Any]] = []
147+
event = asyncio.Event()
148148

149-
unsubscribe = q10_api.status.add_update_listener(updates.append)
149+
unsubscribe = q10_api.status.add_update_listener(event.set)
150150

151151
first_update = {B01_Q10_DP.BATTERY: 88}
152152
q10_api.status.update_from_dps(first_update)
153153

154-
assert updates == [first_update]
154+
assert event.is_set()
155+
event.clear()
155156

156157
unsubscribe()
157158

158159
second_update = {B01_Q10_DP.BATTERY: 87}
159160
q10_api.status.update_from_dps(second_update)
160161

161-
assert updates == [first_update]
162+
assert not event.is_set()
163+
164+
165+
def test_status_trait_update_listener_ignores_value(q10_api: Q10PropertiesApi) -> None:
166+
"""Test that status listeners are not notified for unrelated updates."""
167+
event = asyncio.Event()
168+
169+
unsubscribe = q10_api.status.add_update_listener(event.set)
170+
171+
first_update = {B01_Q10_DP.HEARTBEAT: 1} # Not a value in `Status` dataclass
172+
q10_api.status.update_from_dps(first_update)
173+
174+
assert not event.is_set()
175+
176+
unsubscribe()

0 commit comments

Comments
 (0)