Skip to content

Commit d11ee2a

Browse files
committed
Draft to set trait values
1 parent 7a61bfa commit d11ee2a

21 files changed

+177
-70
lines changed

roborock/devices/traits/v1/child_lock.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class ChildLockTrait(ChildLockStatus, common.V1TraitMixin, common.RoborockSwitch
99
"""Trait for controlling the child lock of a Roborock device."""
1010

1111
command = RoborockCommand.GET_CHILD_LOCK_STATUS
12+
converter = common.V1TraitDataConverter(ChildLockStatus)
1213
requires_feature = "is_set_child_supported"
1314

1415
@property

roborock/devices/traits/v1/clean_summary.py

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,26 +9,12 @@
99
_LOGGER = logging.getLogger(__name__)
1010

1111

12-
class CleanSummaryTrait(CleanSummaryWithDetail, common.V1TraitMixin):
13-
"""Trait for managing the clean summary of Roborock devices."""
12+
class CleanSummaryConverter(common.V1TraitDataConverter):
1413

15-
command = RoborockCommand.GET_CLEAN_SUMMARY
1614

17-
async def refresh(self) -> None:
18-
"""Refresh the clean summary data and last clean record.
19-
20-
Assumes that the clean summary has already been fetched.
21-
"""
22-
await super().refresh()
23-
if not self.records:
24-
_LOGGER.debug("No clean records available in clean summary.")
25-
self.last_clean_record = None
26-
return
27-
last_record_id = self.records[0]
28-
self.last_clean_record = await self.get_clean_record(last_record_id)
2915

3016
@classmethod
31-
def _parse_type_response(cls, response: common.V1ResponseData) -> Self:
17+
def _convert_to_dict(cls, response: common.V1ResponseData) -> Self:
3218
"""Parse the response from the device into a CleanSummary."""
3319
if isinstance(response, dict):
3420
return cls.from_dict(response)
@@ -44,6 +30,26 @@ def _parse_type_response(cls, response: common.V1ResponseData) -> Self:
4430
return cls(clean_time=response)
4531
raise ValueError(f"Unexpected clean summary format: {response!r}")
4632

33+
34+
class CleanSummaryTrait(CleanSummaryWithDetail, common.V1TraitMixin):
35+
"""Trait for managing the clean summary of Roborock devices."""
36+
37+
command = RoborockCommand.GET_CLEAN_SUMMARY
38+
converter = common.V1TraitDataConverter(CleanSummaryWithDetail)
39+
40+
async def refresh(self) -> None:
41+
"""Refresh the clean summary data and last clean record.
42+
43+
Assumes that the clean summary has already been fetched.
44+
"""
45+
await super().refresh()
46+
if not self.records:
47+
_LOGGER.debug("No clean records available in clean summary.")
48+
self.last_clean_record = None
49+
return
50+
last_record_id = self.records[0]
51+
self.last_clean_record = await self.get_clean_record(last_record_id)
52+
4753
async def get_clean_record(self, record_id: int) -> CleanRecord:
4854
"""Load a specific clean record by ID."""
4955
response = await self.rpc_channel.send_command(RoborockCommand.GET_CLEAN_RECORD, params=[record_id])

roborock/devices/traits/v1/common.py

Lines changed: 62 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,50 @@
66
import logging
77
from abc import ABC, abstractmethod
88
from dataclasses import dataclass, fields
9-
from typing import ClassVar, Self
9+
from typing import Any, ClassVar, Self
1010

11+
from roborock.callbacks import CallbackList
1112
from roborock.data import RoborockBase
1213
from roborock.protocols.v1_protocol import V1RpcChannel
1314
from roborock.roborock_typing import RoborockCommand
15+
from roborock.devices.traits.common import TraitDataConverter, TraitUpdateListener
1416

1517
_LOGGER = logging.getLogger(__name__)
1618

1719
V1ResponseData = dict | list | int | str
1820

21+
class V1TraitDataConverter:
22+
"""Utility to handle the transformation and merging of data into models.
23+
24+
This has some special handling for v1 responses, then uses the common data
25+
converters for updating the target objects.
26+
"""
27+
28+
def __init__(self, dataclass_type: type[RoborockBase]):
29+
"""Initialize the converter for a specific RoborockBase-derived class."""
30+
self._dataclass_type = dataclass_type
31+
self._converter = TraitDataConverter(dataclass_type)
32+
33+
def update_from_data(self, target: RoborockBase, data: V1ResponseData) -> None:
34+
"""Update the target object from a dictionary of raw values.
35+
36+
Returns True if any values were updated.
37+
"""
38+
response = self._convert_to_dict(data)
39+
return self._converter.update_from_dict(target, response)
40+
41+
def _convert_to_dict(self, response: V1ResponseData) -> None:
42+
"""Convert the values to a dict that can be parsed as a RoborockBase.
43+
44+
Subclasses can override to implement custom parsing logic
45+
"""
46+
if isinstance(response, list):
47+
response = response[0]
48+
if not isinstance(response, dict):
49+
raise ValueError(f"Unexpected {self._dataclass_type.__name__} response format: {response!r}")
50+
return response
51+
1952

20-
@dataclass
2153
class V1TraitMixin(ABC):
2254
"""Base model that supports v1 traits.
2355
@@ -42,38 +74,13 @@ class V1TraitMixin(ABC):
4274
"""
4375

4476
command: ClassVar[RoborockCommand]
77+
converter: ClassVar[V1TraitDataConverter]
4578

46-
@classmethod
47-
def _parse_type_response(cls, response: V1ResponseData) -> RoborockBase:
48-
"""Parse the response from the device into a a RoborockBase.
49-
50-
Subclasses should override this method to implement custom parsing
51-
logic as needed.
52-
"""
53-
if not issubclass(cls, RoborockBase):
54-
raise NotImplementedError(f"Trait {cls} does not implement RoborockBase")
55-
# Subclasses can override to implement custom parsing logic
56-
if isinstance(response, list):
57-
response = response[0]
58-
if not isinstance(response, dict):
59-
raise ValueError(f"Unexpected {cls} response format: {response!r}")
60-
return cls.from_dict(response)
61-
62-
def _parse_response(self, response: V1ResponseData) -> RoborockBase:
63-
"""Parse the response from the device into a a RoborockBase.
64-
65-
This is used by subclasses that want to override the class
66-
behavior with instance-specific data.
67-
"""
68-
return self._parse_type_response(response)
69-
70-
def __post_init__(self) -> None:
71-
"""Post-initialization to set up the RPC channel.
72-
73-
This is called automatically after the dataclass is initialized by the
74-
device setup code.
75-
"""
79+
def __init__(self) -> None:
80+
"""Initialize the V1TraitMixin."""
7681
self._rpc_channel = None
82+
self._update_listener = TraitUpdateListener(_LOGGER)
83+
7784

7885
@property
7986
def rpc_channel(self) -> V1RpcChannel:
@@ -85,17 +92,23 @@ def rpc_channel(self) -> V1RpcChannel:
8592
async def refresh(self) -> None:
8693
"""Refresh the contents of this trait."""
8794
response = await self.rpc_channel.send_command(self.command)
88-
new_data = self._parse_response(response)
89-
if not isinstance(new_data, RoborockBase):
90-
raise ValueError(f"Internal error, unexpected response type: {new_data!r}")
91-
_LOGGER.debug("Refreshed %s: %s", self.__class__.__name__, new_data)
92-
self._update_trait_values(new_data)
95+
self._update_data(response)
9396

94-
def _update_trait_values(self, new_data: RoborockBase) -> None:
95-
"""Update the values of this trait from another instance."""
96-
for field in fields(new_data):
97-
new_value = getattr(new_data, field.name, None)
98-
setattr(self, field.name, new_value)
97+
def _update_data(self, response: Any) -> None:
98+
"""Applies the new data to the object and notifies listeners.
99+
100+
This can be overridden by subclasses to change the behavior, but it is
101+
preferred to just use the `converter`.
102+
"""
103+
if self.converter.update_from_data(self, response):
104+
self._update_listener._notify_update()
105+
106+
def add_update_listener(self, callback: Callable[[], None]) -> Callable[[], None]:
107+
"""Register a callback when the trait has been updated.
108+
109+
Returns a callable to remove the listener.
110+
"""
111+
self._update_listener.add_update_listener(callback)
99112

100113

101114
def _get_value_field(clazz: type[V1TraitMixin]) -> str:
@@ -108,9 +121,7 @@ def _get_value_field(clazz: type[V1TraitMixin]) -> str:
108121
)
109122
return value_fields[0]
110123

111-
112-
@dataclass(init=False, kw_only=True)
113-
class RoborockValueBase(V1TraitMixin, RoborockBase):
124+
class SingleValueConverter(V1TraitDataConverter):
114125
"""Base class for traits that represent a single value.
115126
116127
This class is intended to be subclassed by traits that represent a single
@@ -119,6 +130,11 @@ class RoborockValueBase(V1TraitMixin, RoborockBase):
119130
represents the main value of the trait.
120131
"""
121132

133+
def __init__(self, dataclass_type: type[RoborockBase], value_field: str) -> None:
134+
"""Initialize the converter."""
135+
super().__init__(dataclass_type)
136+
self._value_field = value_field
137+
122138
@classmethod
123139
def _parse_response(cls, response: V1ResponseData) -> Self:
124140
"""Parse the response from the device into a RoborockValueBase."""
@@ -127,8 +143,7 @@ def _parse_response(cls, response: V1ResponseData) -> Self:
127143
if not isinstance(response, int):
128144
raise ValueError(f"Unexpected response format: {response!r}")
129145
value_field = _get_value_field(cls)
130-
return cls(**{value_field: response})
131-
146+
return cls(**{self.__value_field: response})
132147

133148
class RoborockSwitchBase(ABC):
134149
"""Base class for traits that represent a boolean switch."""

roborock/devices/traits/v1/consumeable.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ class ConsumableTrait(Consumable, common.V1TraitMixin):
4141
"""
4242

4343
command = RoborockCommand.GET_CONSUMABLE
44+
converter = common.V1TraitDataConverter(Consumable)
45+
46+
def __init__(self) -> None:
47+
common.V1TraitMixin.__init__(self)
4448

4549
async def reset_consumable(self, consumable: ConsumableAttribute) -> None:
4650
"""Reset a specific consumable attribute on the device."""

roborock/devices/traits/v1/device_features.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ class DeviceFeaturesTrait(DeviceFeatures, common.V1TraitMixin):
1212
"""Trait for managing supported features on Roborock devices."""
1313

1414
command = RoborockCommand.APP_GET_INIT_STATUS
15+
converter = common.V1TraitDataConverter(DeviceFeatures)
1516

1617
def __init__(self, product: HomeDataProduct, device_cache: DeviceCache) -> None: # pylint: disable=super-init-not-called
1718
"""Initialize DeviceFeaturesTrait."""
19+
common.V1TraitMixin.__init__(self)
1820
self._product = product
1921
self._nickname = product.product_nickname
2022
self._device_cache = device_cache

roborock/devices/traits/v1/do_not_disturb.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class DoNotDisturbTrait(DnDTimer, common.V1TraitMixin, common.RoborockSwitchBase
99
"""Trait for managing Do Not Disturb (DND) settings on Roborock devices."""
1010

1111
command = RoborockCommand.GET_DND_TIMER
12+
converter = common.V1TraitDataConverter(DnDTimer)
1213

1314
@property
1415
def is_on(self) -> bool:

roborock/devices/traits/v1/dust_collection_mode.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ class DustCollectionModeTrait(DustCollectionMode, common.V1TraitMixin):
1010
"""Trait for dust collection mode."""
1111

1212
command = RoborockCommand.GET_DUST_COLLECTION_MODE
13+
converter = common.V1TraitDataConverter(DustCollectionMode)
1314
requires_dock_type = is_valid_dock

roborock/devices/traits/v1/flow_led_status.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class FlowLedStatusTrait(FlowLedStatus, common.V1TraitMixin, common.RoborockSwit
99
"""Trait for controlling the Flow LED status of a Roborock device."""
1010

1111
command = RoborockCommand.GET_FLOW_LED_STATUS
12+
converter = common.V1TraitDataConverter(FlowLedStatus)
1213
requires_feature = "is_flow_led_setting_supported"
1314

1415
@property

roborock/devices/traits/v1/home.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class HomeTrait(RoborockBase, common.V1TraitMixin):
4141
"""Trait that represents a full view of the home layout."""
4242

4343
command = RoborockCommand.GET_MAP_V1 # This is not used
44+
converter = common.V1TraitDataConverter(RoborockBase)
4445

4546
def __init__(
4647
self,

roborock/devices/traits/v1/led_status.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class LedStatusTrait(LedStatus, common.V1TraitMixin, common.RoborockSwitchBase):
99
"""Trait for controlling the LED status of a Roborock device."""
1010

1111
command = RoborockCommand.GET_LED_STATUS
12+
converter = common.V1TraitDataConverter(LedStatus)
1213
requires_feature = "is_led_status_switch_supported"
1314

1415
@property

0 commit comments

Comments
 (0)