Skip to content

Commit 25b68d1

Browse files
committed
feat: Simplify V1 trait handling
1 parent 7a61bfa commit 25b68d1

21 files changed

+273
-222
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.DefaultConverter(ChildLockStatus)
1213
requires_feature = "is_set_child_supported"
1314

1415
@property
Lines changed: 37 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,37 @@
11
import logging
2-
from typing import Self
32

4-
from roborock.data import CleanRecord, CleanSummaryWithDetail
3+
from roborock.data import CleanRecord, CleanSummaryWithDetail, RoborockBase
54
from roborock.devices.traits.v1 import common
65
from roborock.roborock_typing import RoborockCommand
76
from roborock.util import unpack_list
87

98
_LOGGER = logging.getLogger(__name__)
109

1110

12-
class CleanSummaryTrait(CleanSummaryWithDetail, common.V1TraitMixin):
13-
"""Trait for managing the clean summary of Roborock devices."""
14-
15-
command = RoborockCommand.GET_CLEAN_SUMMARY
16-
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)
11+
class CleanSummaryConverter(common.V1TraitDataConverter):
12+
"""Converter for CleanSummaryWithDetail objects."""
2913

30-
@classmethod
31-
def _parse_type_response(cls, response: common.V1ResponseData) -> Self:
14+
def convert(self, response: common.V1ResponseData) -> RoborockBase:
3215
"""Parse the response from the device into a CleanSummary."""
3316
if isinstance(response, dict):
34-
return cls.from_dict(response)
17+
return CleanSummaryWithDetail.from_dict(response)
3518
elif isinstance(response, list):
3619
clean_time, clean_area, clean_count, records = unpack_list(response, 4)
37-
return cls(
20+
return CleanSummaryWithDetail(
3821
clean_time=clean_time,
3922
clean_area=clean_area,
4023
clean_count=clean_count,
4124
records=records,
4225
)
4326
elif isinstance(response, int):
44-
return cls(clean_time=response)
27+
return CleanSummaryWithDetail(clean_time=response)
4528
raise ValueError(f"Unexpected clean summary format: {response!r}")
4629

47-
async def get_clean_record(self, record_id: int) -> CleanRecord:
48-
"""Load a specific clean record by ID."""
49-
response = await self.rpc_channel.send_command(RoborockCommand.GET_CLEAN_RECORD, params=[record_id])
50-
return self._parse_clean_record_response(response)
5130

52-
@classmethod
53-
def _parse_clean_record_response(cls, response: common.V1ResponseData) -> CleanRecord:
31+
class CleanRecordConverter(common.V1TraitDataConverter):
32+
"""Convert server responses to a CleanRecord."""
33+
34+
def convert(self, response: common.V1ResponseData) -> CleanRecord:
5435
"""Parse the response from the device into a CleanRecord."""
5536
if isinstance(response, list) and len(response) == 1:
5637
response = response[0]
@@ -81,3 +62,29 @@ def _parse_clean_record_response(cls, response: common.V1ResponseData) -> CleanR
8162
begin, end, duration, area = unpack_list(response, 4)
8263
return CleanRecord(begin=begin, end=end, duration=duration, area=area)
8364
raise ValueError(f"Unexpected clean record format: {response!r}")
65+
66+
67+
class CleanSummaryTrait(CleanSummaryWithDetail, common.V1TraitMixin):
68+
"""Trait for managing the clean summary of Roborock devices."""
69+
70+
command = RoborockCommand.GET_CLEAN_SUMMARY
71+
converter = CleanSummaryConverter()
72+
clean_record_converter = CleanRecordConverter()
73+
74+
async def refresh(self) -> None:
75+
"""Refresh the clean summary data and last clean record.
76+
77+
Assumes that the clean summary has already been fetched.
78+
"""
79+
await super().refresh()
80+
if not self.records:
81+
_LOGGER.debug("No clean records available in clean summary.")
82+
self.last_clean_record = None
83+
return
84+
last_record_id = self.records[0]
85+
self.last_clean_record = await self.get_clean_record(last_record_id)
86+
87+
async def get_clean_record(self, record_id: int) -> CleanRecord:
88+
"""Load a specific clean record by ID."""
89+
response = await self.rpc_channel.send_command(RoborockCommand.GET_CLEAN_RECORD, params=[record_id])
90+
return self.clean_record_converter.convert(response)

roborock/devices/traits/v1/common.py

Lines changed: 65 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,33 @@
55

66
import logging
77
from abc import ABC, abstractmethod
8-
from dataclasses import dataclass, fields
9-
from typing import ClassVar, Self
8+
from dataclasses import fields
9+
from typing import ClassVar
1010

1111
from roborock.data import RoborockBase
1212
from roborock.protocols.v1_protocol import V1RpcChannel
1313
from roborock.roborock_typing import RoborockCommand
1414

1515
_LOGGER = logging.getLogger(__name__)
1616

17+
1718
V1ResponseData = dict | list | int | str
1819

1920

20-
@dataclass
21+
class V1TraitDataConverter(ABC):
22+
"""Converts responses to RoborockBase objects.
23+
24+
This is an internal class and should not be used directly by consumers.
25+
"""
26+
27+
@abstractmethod
28+
def convert(self, response: V1ResponseData) -> RoborockBase:
29+
"""Convert the values to a dict that can be parsed as a RoborockBase."""
30+
31+
def __repr__(self) -> str:
32+
return self.__class__.__name__
33+
34+
2135
class V1TraitMixin(ABC):
2236
"""Base model that supports v1 traits.
2337
@@ -42,37 +56,13 @@ class V1TraitMixin(ABC):
4256
"""
4357

4458
command: ClassVar[RoborockCommand]
59+
"""The RoborockCommand used to fetch the trait data from the device (internal only)."""
4560

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.
61+
converter: V1TraitDataConverter
62+
"""The converter used to parse the response from the device (internal only)."""
7263

73-
This is called automatically after the dataclass is initialized by the
74-
device setup code.
75-
"""
64+
def __init__(self) -> None:
65+
"""Initialize the V1TraitMixin."""
7666
self._rpc_channel = None
7767

7868
@property
@@ -85,32 +75,42 @@ def rpc_channel(self) -> V1RpcChannel:
8575
async def refresh(self) -> None:
8676
"""Refresh the contents of this trait."""
8777
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)
93-
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)
99-
100-
101-
def _get_value_field(clazz: type[V1TraitMixin]) -> str:
102-
"""Get the name of the field marked as the main value of the RoborockValueBase."""
103-
value_fields = [field.name for field in fields(clazz) if field.metadata.get("roborock_value", False)]
104-
if len(value_fields) != 1:
105-
raise ValueError(
106-
f"RoborockValueBase subclass {clazz} must have exactly one field marked as roborock_value, "
107-
f" but found: {value_fields}"
108-
)
109-
return value_fields[0]
110-
111-
112-
@dataclass(init=False, kw_only=True)
113-
class RoborockValueBase(V1TraitMixin, RoborockBase):
78+
new_data = self.converter.convert(response)
79+
merge_trait_values(self, new_data) # type: ignore[arg-type]
80+
81+
82+
def merge_trait_values(target: RoborockBase, new_object: RoborockBase) -> bool:
83+
"""Update the target object with set fields in new_object."""
84+
updated = False
85+
for field in fields(new_object):
86+
old_value = getattr(target, field.name, None)
87+
new_value = getattr(new_object, field.name, None)
88+
if new_value != old_value:
89+
setattr(target, field.name, new_value)
90+
updated = True
91+
return updated
92+
93+
94+
class DefaultConverter(V1TraitDataConverter):
95+
"""Converts responses to RoborockBase objects."""
96+
97+
def __init__(self, dataclass_type: type[RoborockBase]) -> None:
98+
"""Initialize the converter."""
99+
self._dataclass_type = dataclass_type
100+
101+
def convert(self, response: V1ResponseData) -> RoborockBase:
102+
"""Convert the values to a dict that can be parsed as a RoborockBase.
103+
104+
Subclasses can override to implement custom parsing logic
105+
"""
106+
if isinstance(response, list):
107+
response = response[0]
108+
if not isinstance(response, dict):
109+
raise ValueError(f"Unexpected {self._dataclass_type.__name__} response format: {response!r}")
110+
return self._dataclass_type.from_dict(response)
111+
112+
113+
class SingleValueConverter(DefaultConverter):
114114
"""Base class for traits that represent a single value.
115115
116116
This class is intended to be subclassed by traits that represent a single
@@ -119,15 +119,18 @@ class RoborockValueBase(V1TraitMixin, RoborockBase):
119119
represents the main value of the trait.
120120
"""
121121

122-
@classmethod
123-
def _parse_response(cls, response: V1ResponseData) -> Self:
122+
def __init__(self, dataclass_type: type[RoborockBase], value_field: str) -> None:
123+
"""Initialize the converter."""
124+
super().__init__(dataclass_type)
125+
self._value_field = value_field
126+
127+
def convert(self, response: V1ResponseData) -> RoborockBase:
124128
"""Parse the response from the device into a RoborockValueBase."""
125129
if isinstance(response, list):
126130
response = response[0]
127131
if not isinstance(response, int):
128132
raise ValueError(f"Unexpected response format: {response!r}")
129-
value_field = _get_value_field(cls)
130-
return cls(**{value_field: response})
133+
return super().convert({self._value_field: response})
131134

132135

133136
class RoborockSwitchBase(ABC):

roborock/devices/traits/v1/consumeable.py

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

4343
command = RoborockCommand.GET_CONSUMABLE
44+
converter = common.DefaultConverter(Consumable)
4445

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

roborock/devices/traits/v1/device_features.py

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,37 @@
88
from roborock.roborock_typing import RoborockCommand
99

1010

11+
class DeviceTraitsConverter(common.V1TraitDataConverter):
12+
"""Converter for APP_GET_INIT_STATUS responses into DeviceFeatures."""
13+
14+
def __init__(self, product: HomeDataProduct) -> None:
15+
"""Initialize DeviceTraitsConverter."""
16+
self._product = product
17+
18+
def convert(self, response: common.V1ResponseData) -> DeviceFeatures:
19+
"""Parse an APP_GET_INIT_STATUS response into a DeviceFeatures instance."""
20+
if not isinstance(response, list):
21+
raise ValueError(f"Unexpected AppInitStatus response format: {type(response)}: {response!r}")
22+
app_status = AppInitStatus.from_dict(response[0])
23+
return DeviceFeatures.from_feature_flags(
24+
new_feature_info=app_status.new_feature_info,
25+
new_feature_info_str=app_status.new_feature_info_str,
26+
feature_info=app_status.feature_info,
27+
product_nickname=self._product.product_nickname,
28+
)
29+
30+
1131
class DeviceFeaturesTrait(DeviceFeatures, common.V1TraitMixin):
1232
"""Trait for managing supported features on Roborock devices."""
1333

1434
command = RoborockCommand.APP_GET_INIT_STATUS
35+
converter: DeviceTraitsConverter
1536

1637
def __init__(self, product: HomeDataProduct, device_cache: DeviceCache) -> None: # pylint: disable=super-init-not-called
1738
"""Initialize DeviceFeaturesTrait."""
39+
common.V1TraitMixin.__init__(self)
40+
self.converter = DeviceTraitsConverter(product)
1841
self._product = product
19-
self._nickname = product.product_nickname
2042
self._device_cache = device_cache
2143
# All fields of DeviceFeatures are required. Initialize them to False
2244
# so we have some known state.
@@ -54,21 +76,9 @@ async def refresh(self) -> None:
5476
"""
5577
cache_data = await self._device_cache.get()
5678
if cache_data.device_features is not None:
57-
self._update_trait_values(cache_data.device_features)
79+
common.merge_trait_values(self, cache_data.device_features)
5880
return
5981
# Save cached device features
6082
await super().refresh()
6183
cache_data.device_features = self
6284
await self._device_cache.set(cache_data)
63-
64-
def _parse_response(self, response: common.V1ResponseData) -> DeviceFeatures:
65-
"""Parse the response from the device into a MapContentTrait instance."""
66-
if not isinstance(response, list):
67-
raise ValueError(f"Unexpected AppInitStatus response format: {type(response)}")
68-
app_status = AppInitStatus.from_dict(response[0])
69-
return DeviceFeatures.from_feature_flags(
70-
new_feature_info=app_status.new_feature_info,
71-
new_feature_info_str=app_status.new_feature_info_str,
72-
feature_info=app_status.feature_info,
73-
product_nickname=self._nickname,
74-
)

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.DefaultConverter(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.DefaultConverter(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.DefaultConverter(FlowLedStatus)
1213
requires_feature = "is_flow_led_setting_supported"
1314

1415
@property

0 commit comments

Comments
 (0)