-
Notifications
You must be signed in to change notification settings - Fork 75
Expand file tree
/
Copy pathcommon.py
More file actions
249 lines (185 loc) · 9.24 KB
/
common.py
File metadata and controls
249 lines (185 loc) · 9.24 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
"""Module for Roborock V1 devices common trait commands.
This is an internal library and should not be used directly by consumers.
"""
import logging
from abc import ABC, abstractmethod
from collections.abc import Callable
from dataclasses import fields
from typing import Any, ClassVar
from roborock.callbacks import CallbackList
from roborock.data import RoborockBase
from roborock.protocols.v1_protocol import V1RpcChannel
from roborock.roborock_message import RoborockDataProtocol
from roborock.roborock_typing import RoborockCommand
_LOGGER = logging.getLogger(__name__)
V1ResponseData = dict | list | int | str
class V1TraitDataConverter(ABC):
"""Converts responses to RoborockBase objects.
This is an internal class and should not be used directly by consumers.
"""
@abstractmethod
def convert(self, response: V1ResponseData) -> RoborockBase:
"""Convert the values to a dict that can be parsed as a RoborockBase."""
def __repr__(self) -> str:
return self.__class__.__name__
class V1TraitMixin(ABC):
"""Base model that supports v1 traits.
This class provides functioanlity for parsing responses from V1 devices
into dataclass instances. It also provides a reference to the V1RpcChannel
used to communicate with the device to execute commands.
Each trait subclass must define a class variable `command` that specifies
the RoborockCommand used to fetch the trait data from the device. The
`refresh()` method can be called to update the contents of the trait data
from the device.
A trait can also support additional commands for updating state associated
with the trait. It is expected that a trait will update its own internal
state either reflecting the change optimistically or by refreshing the
trait state from the device. In cases where one trait caches data that is
also represented in another trait, it is the responsibility of the caller
to ensure that both traits are refreshed as needed to keep them in sync.
The traits typically subclass RoborockBase to provide serialization
and deserialization functionality, but this is not strictly required.
"""
command: ClassVar[RoborockCommand]
"""The RoborockCommand used to fetch the trait data from the device (internal only)."""
converter: V1TraitDataConverter
"""The converter used to parse the response from the device (internal only)."""
def __init__(self) -> None:
"""Initialize the V1TraitMixin."""
self._rpc_channel = None
@property
def rpc_channel(self) -> V1RpcChannel:
"""Helper for executing commands, used internally by the trait"""
if not self._rpc_channel:
raise ValueError("Device trait in invalid state")
return self._rpc_channel
async def refresh(self) -> None:
"""Refresh the contents of this trait."""
response = await self.rpc_channel.send_command(self.command)
new_data = self.converter.convert(response)
merge_trait_values(self, new_data) # type: ignore[arg-type]
def merge_trait_values(target: RoborockBase, new_object: RoborockBase) -> bool:
"""Update the target object with set fields in new_object."""
updated = False
for field in fields(new_object):
old_value = getattr(target, field.name, None)
new_value = getattr(new_object, field.name, None)
if new_value != old_value:
setattr(target, field.name, new_value)
updated = True
return updated
class DefaultConverter(V1TraitDataConverter):
"""Converts responses to RoborockBase objects."""
def __init__(self, dataclass_type: type[RoborockBase]) -> None:
"""Initialize the converter."""
self._dataclass_type = dataclass_type
def convert(self, response: V1ResponseData) -> RoborockBase:
"""Convert the values to a dict that can be parsed as a RoborockBase.
Subclasses can override to implement custom parsing logic
"""
if isinstance(response, list):
response = response[0]
if not isinstance(response, dict):
raise ValueError(f"Unexpected {self._dataclass_type.__name__} response format: {response!r}")
return self._dataclass_type.from_dict(response)
class SingleValueConverter(DefaultConverter):
"""Base class for traits that represent a single value.
This class is intended to be subclassed by traits that represent a single
value, such as volume or brightness. The subclass should define a single
field with the metadata `roborock_value=True` to indicate which field
represents the main value of the trait.
"""
def __init__(self, dataclass_type: type[RoborockBase], value_field: str) -> None:
"""Initialize the converter."""
super().__init__(dataclass_type)
self._value_field = value_field
def convert(self, response: V1ResponseData) -> RoborockBase:
"""Parse the response from the device into a RoborockValueBase."""
if isinstance(response, list):
response = response[0]
if not isinstance(response, int):
raise ValueError(f"Unexpected response format: {response!r}")
return super().convert({self._value_field: response})
class RoborockSwitchBase(ABC):
"""Base class for traits that represent a boolean switch."""
@property
@abstractmethod
def is_on(self) -> bool:
"""Return whether the switch is on."""
@abstractmethod
async def enable(self) -> None:
"""Enable the switch."""
@abstractmethod
async def disable(self) -> None:
"""Disable the switch."""
def mqtt_rpc_channel(cls):
"""Decorator to mark a function as cloud only.
Normally a trait uses an adaptive rpc channel that can use either local
or cloud communication depending on what is available. This will force
the trait to always use the cloud rpc channel.
"""
def wrapper(*args, **kwargs):
return cls(*args, **kwargs)
cls.mqtt_rpc_channel = True # type: ignore[attr-defined]
return wrapper
def map_rpc_channel(cls):
"""Decorator to mark a function as cloud only using the map rpc format."""
def wrapper(*args, **kwargs):
return cls(*args, **kwargs)
cls.map_rpc_channel = True # type: ignore[attr-defined]
return wrapper
# TODO(allenporter): Merge with roborock.devices.traits.b01.q10.common.TraitUpdateListener
class TraitUpdateListener(ABC):
"""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.
This class pre-calculates the mapping between Data Point IDs and dataclass fields
to optimize repeated updates from device streams.
"""
def __init__(self, dps_type_map: dict[RoborockDataProtocol, type], dps_field_map: dict[RoborockDataProtocol, str]):
"""Initialize the converter for a specific RoborockBase-derived class."""
self._dps_type_map = dps_type_map
self._dps_field_map = dps_field_map
@classmethod
def from_dataclass(cls, dataclass_type: type[RoborockBase]):
"""Initialize the converter for a specific RoborockBase-derived class."""
dps_type_map: dict[RoborockDataProtocol, type] = {}
dps_field_map: dict[RoborockDataProtocol, str] = {}
for field_obj in fields(dataclass_type):
if field_obj.metadata and "dps" in field_obj.metadata:
dps_id = field_obj.metadata["dps"]
dps_type_map[dps_id] = field_obj.type
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[RoborockDataProtocol, 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
correct Python types before being updated on the target.
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)