From 7be902366790a5665f1768703664261da86cab90 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 21 Feb 2026 15:21:55 -0800 Subject: [PATCH 1/3] 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. --- roborock/devices/traits/b01/q10/common.py | 35 ++++++++++++++++++++- roborock/devices/traits/b01/q10/status.py | 19 +++-------- tests/devices/traits/b01/q10/test_status.py | 23 +++++++++++--- 3 files changed, 58 insertions(+), 19 deletions(-) diff --git a/roborock/devices/traits/b01/q10/common.py b/roborock/devices/traits/b01/q10/common.py index ad66e895..e6509cd2 100644 --- a/roborock/devices/traits/b01/q10/common.py +++ b/roborock/devices/traits/b01/q10/common.py @@ -36,12 +36,41 @@ class MyStatus(RoborockBase): """ import dataclasses +import logging +from collections.abc import Callable from typing import Any +from roborock.callbacks import CallbackList from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP from roborock.data.containers import RoborockBase +class TraitUpdateListener: + """Trait update listener. + + This is a base class for traits to support notifying listeners when they + have been updated. Clients may register callbacks to be notified when the + trait has been updated. When the listener callback is invoked, the client + should read the trait's properties to get the updated values. + """ + + def __init__(self, logger: logging.Logger) -> None: + """Initialize the trait update listener.""" + self._update_callbacks: CallbackList[None] = CallbackList(logger=logger) + + def add_update_listener(self, callback: Callable[[], None]) -> Callable[[], None]: + """Register a callback when the trait has been updated. + + Returns a callable to remove the listener. + """ + # We wrap the callback to ignore the value passed to it. + return self._update_callbacks.add_callback(lambda _: callback()) + + def _notify_update(self) -> None: + """Notify all update listeners.""" + self._update_callbacks(None) + + class DpsDataConverter: """Utility to handle the transformation and merging of DPS data into models. @@ -66,7 +95,7 @@ def from_dataclass(cls, dataclass_type: type[RoborockBase]): dps_field_map[dps_id] = field_obj.name return cls(dps_type_map, dps_field_map) - def update_from_dps(self, target: RoborockBase, decoded_dps: dict[B01_Q10_DP, Any]) -> None: + def update_from_dps(self, target: RoborockBase, decoded_dps: dict[B01_Q10_DP, Any]) -> bool: """Convert and merge raw DPS data into the target object. 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 Args: target: The target object to update. decoded_dps: The decoded DPS data to convert. + + Returns: + True if any values were updated, False otherwise. """ conversions = RoborockBase.convert_dict(self._dps_type_map, decoded_dps) for dps_id, value in conversions.items(): field_name = self._dps_field_map[dps_id] setattr(target, field_name, value) + return bool(conversions) diff --git a/roborock/devices/traits/b01/q10/status.py b/roborock/devices/traits/b01/q10/status.py index 329ff104..786dcfb0 100644 --- a/roborock/devices/traits/b01/q10/status.py +++ b/roborock/devices/traits/b01/q10/status.py @@ -1,21 +1,19 @@ """Status trait for Q10 B01 devices.""" import logging -from collections.abc import Callable from typing import Any -from roborock.callbacks import CallbackList from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP from roborock.data.b01_q10.b01_q10_containers import Q10Status -from .common import DpsDataConverter +from .common import DpsDataConverter, TraitUpdateListener _LOGGER = logging.getLogger(__name__) _CONVERTER = DpsDataConverter.from_dataclass(Q10Status) -class StatusTrait(Q10Status): +class StatusTrait(Q10Status, TraitUpdateListener): """Trait for managing the status of Q10 Roborock devices. This is a thin wrapper around Q10Status that provides the Trait interface. @@ -26,16 +24,9 @@ class StatusTrait(Q10Status): def __init__(self) -> None: """Initialize the status trait.""" super().__init__() - self._update_callbacks: CallbackList[dict[B01_Q10_DP, Any]] = CallbackList(logger=_LOGGER) - - def add_update_listener(self, callback: Callable[[dict[B01_Q10_DP, Any]], None]) -> Callable[[], None]: - """Register a callback for decoded DPS updates. - - Returns a callable to remove the listener. - """ - return self._update_callbacks.add_callback(callback) + TraitUpdateListener.__init__(self, logger=_LOGGER) def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None: """Update the trait from raw DPS data.""" - _CONVERTER.update_from_dps(self, decoded_dps) - self._update_callbacks(decoded_dps) + if _CONVERTER.update_from_dps(self, decoded_dps): + self._notify_update() diff --git a/tests/devices/traits/b01/q10/test_status.py b/tests/devices/traits/b01/q10/test_status.py index 06f5b196..e8dfc9b2 100644 --- a/tests/devices/traits/b01/q10/test_status.py +++ b/tests/devices/traits/b01/q10/test_status.py @@ -144,18 +144,33 @@ async def test_status_trait_refresh( def test_status_trait_update_listener(q10_api: Q10PropertiesApi) -> None: """Test that status listeners receive updates and can unsubscribe.""" - updates: list[dict[B01_Q10_DP, Any]] = [] + event = asyncio.Event() - unsubscribe = q10_api.status.add_update_listener(updates.append) + unsubscribe = q10_api.status.add_update_listener(event.set) first_update = {B01_Q10_DP.BATTERY: 88} q10_api.status.update_from_dps(first_update) - assert updates == [first_update] + assert event.is_set() + event.clear() unsubscribe() second_update = {B01_Q10_DP.BATTERY: 87} q10_api.status.update_from_dps(second_update) - assert updates == [first_update] + assert not event.is_set() + + +def test_status_trait_update_listener_ignores_value(q10_api: Q10PropertiesApi) -> None: + """Test that status listeners receive updates and can unsubscribe.""" + event = asyncio.Event() + + unsubscribe = q10_api.status.add_update_listener(event.set) + + first_update = {B01_Q10_DP.HEARTBEAT: 1} # Not a value in `Status` dataclass + q10_api.status.update_from_dps(first_update) + + assert not event.is_set() + + unsubscribe() From 0e1c838f31939aa4ecfa2877f1c3557b5df0c2e7 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 21 Feb 2026 15:26:04 -0800 Subject: [PATCH 2/3] chore: Update tests/devices/traits/b01/q10/test_status.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/devices/traits/b01/q10/test_status.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/devices/traits/b01/q10/test_status.py b/tests/devices/traits/b01/q10/test_status.py index e8dfc9b2..c6e809eb 100644 --- a/tests/devices/traits/b01/q10/test_status.py +++ b/tests/devices/traits/b01/q10/test_status.py @@ -163,7 +163,7 @@ def test_status_trait_update_listener(q10_api: Q10PropertiesApi) -> None: def test_status_trait_update_listener_ignores_value(q10_api: Q10PropertiesApi) -> None: - """Test that status listeners receive updates and can unsubscribe.""" + """Test that status listeners are not notified for updates with non-Status dataclass data points (e.g., HEARTBEAT).""" event = asyncio.Event() unsubscribe = q10_api.status.add_update_listener(event.set) From e9cdf5e27ae2d8f5bd097a07bb2a92567c727785 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 21 Feb 2026 15:27:17 -0800 Subject: [PATCH 3/3] chore: Fix test pydoc lint error --- tests/devices/traits/b01/q10/test_status.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/devices/traits/b01/q10/test_status.py b/tests/devices/traits/b01/q10/test_status.py index c6e809eb..e295e890 100644 --- a/tests/devices/traits/b01/q10/test_status.py +++ b/tests/devices/traits/b01/q10/test_status.py @@ -163,7 +163,7 @@ def test_status_trait_update_listener(q10_api: Q10PropertiesApi) -> None: def test_status_trait_update_listener_ignores_value(q10_api: Q10PropertiesApi) -> None: - """Test that status listeners are not notified for updates with non-Status dataclass data points (e.g., HEARTBEAT).""" + """Test that status listeners are not notified for unrelated updates.""" event = asyncio.Event() unsubscribe = q10_api.status.add_update_listener(event.set)