roborock.data.containers

  1import dataclasses
  2import datetime
  3import inspect
  4import json
  5import logging
  6import re
  7import types
  8from dataclasses import asdict, dataclass, field
  9from enum import Enum
 10from functools import cached_property
 11from typing import Any, ClassVar, NamedTuple, get_args, get_origin
 12
 13from .code_mappings import (
 14    SHORT_MODEL_TO_ENUM,
 15    RoborockCategory,
 16    RoborockModeEnum,
 17    RoborockProductNickname,
 18)
 19
 20_LOGGER = logging.getLogger(__name__)
 21
 22
 23def _camelize(s: str):
 24    first, *others = s.split("_")
 25    if len(others) == 0:
 26        return s
 27    return "".join([first.lower(), *map(str.title, others)])
 28
 29
 30def _decamelize(s: str):
 31    # Split before uppercase letters not at the start, and before numbers
 32    s = re.sub(r"(?<=[a-z0-9])([A-Z])", r"_\1", s)
 33    s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s)  # Split acronyms followed by normal camelCase
 34    s = re.sub(r"([a-zA-Z])([0-9]+)", r"\1_\2", s)
 35    s = s.lower()
 36    # Temporary fix to avoid breaking any serialization.
 37    s = s.replace("base_64", "base64")
 38    return s
 39
 40
 41def _attr_repr(obj: Any) -> str:
 42    """Return a string representation of the object including specified attributes.
 43
 44    This reproduces the default repr behavior of dataclasses, but also includes
 45    properties. This must be called by the child class's __repr__ method since
 46    the parent RoborockBase class does not know about the child class's attributes.
 47    """
 48    # Reproduce default repr behavior
 49    parts = []
 50    for k in dir(obj):
 51        if k.startswith("_"):
 52            continue
 53        try:
 54            v = getattr(obj, k)
 55        except (RuntimeError, Exception):
 56            continue
 57        if callable(v):
 58            continue
 59        parts.append(f"{k}={v!r}")
 60    return f"{type(obj).__name__}({', '.join(parts)})"
 61
 62
 63def field_metadata(**kwargs):
 64    """Decorator to attach capability check metadata to a property.
 65
 66    This attaches a `_field_metadata` dictionary to the underlying getter function,
 67    which is then preserved when decorated with `@property`.
 68
 69    Supported metadata keys:
 70    - `feature` (str): Name of a capability property on `DeviceFeaturesTrait`.
 71    - `dock_feature` (str): Name of a capability property on `RoborockDockFeatures`.
 72    - `dps` (str/int): RoborockDataProtocol ID to check against supported schema IDs.
 73    """
 74
 75    def decorator(func):
 76        func._field_metadata = kwargs
 77        return func
 78
 79    return decorator
 80
 81
 82@dataclass(repr=False)
 83class RoborockBase:
 84    """Base class for all Roborock data classes."""
 85
 86    _missing_logged: ClassVar[set[str]] = set()
 87
 88    @staticmethod
 89    def _convert_to_class_obj(class_type: type, value):
 90        if get_origin(class_type) is list:
 91            sub_type = get_args(class_type)[0]
 92            return [RoborockBase._convert_to_class_obj(sub_type, obj) for obj in value]
 93        if get_origin(class_type) is dict:
 94            key_type, value_type = get_args(class_type)
 95            if key_type is not None:
 96                return {key_type(k): RoborockBase._convert_to_class_obj(value_type, v) for k, v in value.items()}
 97            return {k: RoborockBase._convert_to_class_obj(value_type, v) for k, v in value.items()}
 98        if inspect.isclass(class_type):
 99            if issubclass(class_type, RoborockBase):
100                return class_type.from_dict(value)
101            if issubclass(class_type, RoborockModeEnum):
102                return class_type.from_code(value)
103        if class_type is Any or type(class_type) is str:
104            return value
105        return class_type(value)  # type: ignore[call-arg]
106
107    @classmethod
108    def from_dict(cls, data: dict[str, Any]):
109        """Create an instance of the class from a dictionary."""
110        if not isinstance(data, dict):
111            return None
112        field_types = {field.name: field.type for field in dataclasses.fields(cls)}
113        normalized_data: dict[str, Any] = {}
114        for orig_key, value in data.items():
115            key = _decamelize(orig_key)
116            if field_types.get(key) is None:
117                if (log_key := f"{cls.__name__}.{key}") not in RoborockBase._missing_logged:
118                    _LOGGER.debug(
119                        "Key '%s' (decamelized: '%s') not found in %s fields, skipping",
120                        orig_key,
121                        key,
122                        cls.__name__,
123                    )
124                    RoborockBase._missing_logged.add(log_key)
125                continue
126            normalized_data[key] = value
127
128        result = RoborockBase.convert_dict(field_types, normalized_data)
129        return cls(**result)
130
131    @staticmethod
132    def convert_dict(types_map: dict[Any, type], data: dict[Any, Any]) -> dict[Any, Any]:
133        """Generic helper to convert a dictionary of values based on a schema map of types.
134
135        This is meant to be used by traits that use dataclass reflection similar to
136        `Roborock.from_dict` to merge in new data updates.
137        """
138        result: dict[Any, Any] = {}
139        for key, value in data.items():
140            if key not in types_map:
141                continue
142            field_type = types_map[key]
143            if value == "None" or value is None:
144                result[key] = None
145                continue
146            if isinstance(field_type, types.UnionType):
147                for subtype in get_args(field_type):
148                    if subtype is types.NoneType:
149                        continue
150                    try:
151                        result[key] = RoborockBase._convert_to_class_obj(subtype, value)
152                        break
153                    except Exception:
154                        _LOGGER.exception(f"Failed to convert {key} with value {value} to type {subtype}")
155                        continue
156            else:
157                try:
158                    result[key] = RoborockBase._convert_to_class_obj(field_type, value)
159                except Exception:
160                    _LOGGER.exception(f"Failed to convert {key} with value {value} to type {field_type}")
161                    continue
162
163        return result
164
165    def as_dict(self) -> dict:
166        return asdict(
167            self,
168            dict_factory=lambda _fields: {
169                _camelize(key): value.value if isinstance(value, Enum) else value
170                for (key, value) in _fields
171                if value is not None
172            },
173        )
174
175
176@dataclass
177class RoborockBaseTimer(RoborockBase):
178    start_hour: int | None = None
179    start_minute: int | None = None
180    end_hour: int | None = None
181    end_minute: int | None = None
182    enabled: int | None = None
183
184    @property
185    def start_time(self) -> datetime.time | None:
186        return (
187            datetime.time(hour=self.start_hour, minute=self.start_minute)
188            if self.start_hour is not None and self.start_minute is not None
189            else None
190        )
191
192    @property
193    def end_time(self) -> datetime.time | None:
194        return (
195            datetime.time(hour=self.end_hour, minute=self.end_minute)
196            if self.end_hour is not None and self.end_minute is not None
197            else None
198        )
199
200    def as_list(self) -> list:
201        return [self.start_hour, self.start_minute, self.end_hour, self.end_minute]
202
203    def __repr__(self) -> str:
204        return _attr_repr(self)
205
206
207@dataclass
208class Reference(RoborockBase):
209    r: str | None = None
210    a: str | None = None
211    m: str | None = None
212    l: str | None = None
213
214
215@dataclass
216class RRiot(RoborockBase):
217    u: str
218    s: str
219    h: str
220    k: str
221    r: Reference
222
223
224@dataclass
225class UserData(RoborockBase):
226    rriot: RRiot
227    uid: int | None = None
228    tokentype: str | None = None
229    token: str | None = None
230    rruid: str | None = None
231    region: str | None = None
232    countrycode: str | None = None
233    country: str | None = None
234    nickname: str | None = None
235    tuya_device_state: int | None = None
236    avatarurl: str | None = None
237
238
239@dataclass
240class HomeDataProductSchema(RoborockBase):
241    id: Any | None = None
242    name: Any | None = None
243    code: Any | None = None
244    mode: Any | None = None
245    type: Any | None = None
246    product_property: Any | None = None
247    property: Any | None = None
248    desc: Any | None = None
249
250
251@dataclass
252class HomeDataProduct(RoborockBase):
253    id: str
254    name: str
255    model: str
256    category: RoborockCategory
257    code: str | None = None
258    icon_url: str | None = None
259    attribute: Any | None = None
260    capability: int | None = None
261    schema: list[HomeDataProductSchema] | None = None
262
263    @property
264    def product_nickname(self) -> RoborockProductNickname:
265        return SHORT_MODEL_TO_ENUM.get(self.model.split(".")[-1], RoborockProductNickname.PEARLPLUS)
266
267    def summary_info(self) -> str:
268        """Return a string with key product information for logging purposes."""
269        return f"{self.name} (model={self.model}, category={self.category})"
270
271    @cached_property
272    def supported_schema_codes(self) -> set[str]:
273        """Return a set of schema codes that are supported by the device.
274
275        These correspond with string field names like "state" or "error_code" that
276        correspond to RoborockDataProtocol or RoborockB01Protocol code values.
277        """
278        if self.schema is None:
279            return set()
280        return {schema.code for schema in self.schema if schema.code is not None}
281
282    @cached_property
283    def supported_schema_ids(self) -> set[int]:
284        """Return a set of schema IDs (DPS integers) that are supported by the device.
285
286        These correspond to RoborockMessageProtocol and RoborockDataProtocol or
287        RoborockB01Protocol enum number values (depends on the device protocol versions).
288        """
289        if self.schema is None:
290            return set()
291        return {int(schema.id) for schema in self.schema if schema.id is not None}
292
293
294@dataclass
295class HomeDataDevice(RoborockBase):
296    duid: str
297    name: str
298    local_key: str
299    product_id: str
300    fv: str | None = None
301    attribute: Any | None = None
302    active_time: int | None = None
303    runtime_env: Any | None = None
304    time_zone_id: str | None = None
305    icon_url: str | None = None
306    lon: Any | None = None
307    lat: Any | None = None
308    share: Any | None = None
309    share_time: Any | None = None
310    online: bool | None = None
311    pv: str | None = None
312    room_id: Any | None = None
313    tuya_uuid: Any | None = None
314    tuya_migrated: bool | None = None
315    extra: Any | None = None
316    sn: str | None = None
317    feature_set: str | None = None
318    new_feature_set: str | None = None
319    device_status: dict | None = None
320    silent_ota_switch: bool | None = None
321    setting: Any | None = None
322    f: bool | None = None
323    create_time: int | None = None
324    cid: str | None = None
325    share_type: Any | None = None
326    share_expired_time: int | None = None
327
328    def summary_info(self) -> str:
329        """Return a string with key device information for logging purposes."""
330        return f"{self.name} (pv={self.pv}, fv={self.fv}, online={self.online})"
331
332
333@dataclass
334class HomeDataRoom(RoborockBase):
335    id: int
336    name: str
337
338    @property
339    def iot_id(self) -> str:
340        """Return the room's ID as a string IOT ID."""
341        return str(self.id)
342
343
344@dataclass
345class HomeDataScene(RoborockBase):
346    id: int
347    name: str
348
349
350@dataclass
351class HomeDataSchedule(RoborockBase):
352    id: int
353    cron: str
354    repeated: bool
355    enabled: bool
356    param: dict | None = None
357
358
359@dataclass
360class HomeData(RoborockBase):
361    id: int
362    name: str
363    products: list[HomeDataProduct] = field(default_factory=lambda: [])
364    devices: list[HomeDataDevice] = field(default_factory=lambda: [])
365    received_devices: list[HomeDataDevice] = field(default_factory=lambda: [])
366    lon: Any | None = None
367    lat: Any | None = None
368    geo_name: Any | None = None
369    rooms: list[HomeDataRoom] = field(default_factory=list)
370
371    def get_all_devices(self) -> list[HomeDataDevice]:
372        devices = []
373        if self.devices is not None:
374            devices += self.devices
375        if self.received_devices is not None:
376            devices += self.received_devices
377        return devices
378
379    @cached_property
380    def product_map(self) -> dict[str, HomeDataProduct]:
381        """Returns a dictionary of product IDs to HomeDataProduct objects."""
382        return {product.id: product for product in self.products}
383
384    @cached_property
385    def device_products(self) -> dict[str, tuple[HomeDataDevice, HomeDataProduct]]:
386        """Returns a dictionary of device DUIDs to HomeDataDeviceProduct objects."""
387        product_map = self.product_map
388        return {
389            device.duid: (device, product)
390            for device in self.get_all_devices()
391            if (product := product_map.get(device.product_id)) is not None
392        }
393
394    @property
395    def rooms_map(self) -> dict[str, HomeDataRoom]:
396        """Returns a dictionary of Room iot_id to rooms"""
397        return {room.iot_id: room for room in self.rooms}
398
399    @property
400    def rooms_name_map(self) -> dict[str, str]:
401        """Returns a dictionary of Room iot_id to room names."""
402        return {room.iot_id: room.name for room in self.rooms}
403
404
405@dataclass
406class LoginData(RoborockBase):
407    user_data: UserData
408    email: str
409    home_data: HomeData | None = None
410
411
412@dataclass
413class DeviceData(RoborockBase):
414    device: HomeDataDevice
415    model: str
416    host: str | None = None
417
418    @property
419    def product_nickname(self) -> RoborockProductNickname:
420        return SHORT_MODEL_TO_ENUM.get(self.model.split(".")[-1], RoborockProductNickname.PEARLPLUS)
421
422    def __repr__(self) -> str:
423        return _attr_repr(self)
424
425
426@dataclass
427class RoomMapping(RoborockBase):
428    segment_id: int
429    iot_id: str
430
431
432@dataclass
433class NamedRoomMapping(RoomMapping):
434    """Dataclass representing a mapping of a room segment to a name.
435
436    The name information is not provided by the device directly, but is provided
437    from the HomeData based on the iot_id from the room.
438    """
439
440    @property
441    def name(self) -> str:
442        """The human-readable name of the room, or a default name if not available."""
443        return self.raw_name or f"Room {self.segment_id}"
444
445    raw_name: str | None = None
446    """The raw name of the room, as provided by the device."""
447
448
449@dataclass
450class CombinedMapInfo(RoborockBase):
451    """Data structure for caching home information.
452
453    This is not provided directly by the API, but is a combination of map data
454    and room data to provide a more useful structure.
455    """
456
457    map_flag: int
458    """The map identifier."""
459
460    name: str
461    """The name of the map from MultiMapsListMapInfo."""
462
463    rooms: list[NamedRoomMapping]
464    """The list of rooms in the map."""
465
466    @property
467    def rooms_map(self) -> dict[int, NamedRoomMapping]:
468        """Returns a mapping of segment_id to NamedRoomMapping."""
469        return {room.segment_id: room for room in self.rooms}
470
471
472@dataclass
473class BroadcastMessage(RoborockBase):
474    duid: str
475    ip: str
476    version: bytes
477
478
479class ServerTimer(NamedTuple):
480    id: str
481    status: str
482    dontknow: int
483
484
485@dataclass
486class RoborockProductStateValue(RoborockBase):
487    value: list
488    desc: dict
489
490
491@dataclass
492class RoborockProductState(RoborockBase):
493    dps: int
494    desc: dict
495    value: list[RoborockProductStateValue]
496
497
498@dataclass
499class RoborockProductSpec(RoborockBase):
500    state: RoborockProductState
501    battery: dict | None = None
502    dry_countdown: dict | None = None
503    extra: dict | None = None
504    offpeak: dict | None = None
505    countdown: dict | None = None
506    mode: dict | None = None
507    ota_nfo: dict | None = None
508    pause: dict | None = None
509    program: dict | None = None
510    shutdown: dict | None = None
511    washing_left: dict | None = None
512
513
514@dataclass
515class RoborockProduct(RoborockBase):
516    id: int | None = None
517    name: str | None = None
518    model: str | None = None
519    packagename: str | None = None
520    ssid: str | None = None
521    picurl: str | None = None
522    cardpicurl: str | None = None
523    mediumCardpicurl: str | None = None
524    resetwifipicurl: str | None = None
525    configPicUrl: str | None = None
526    pluginPicUrl: str | None = None
527    resetwifitext: dict | None = None
528    tuyaid: str | None = None
529    status: int | None = None
530    rriotid: str | None = None
531    pictures: list | None = None
532    ncMode: str | None = None
533    scope: str | None = None
534    product_tags: list | None = None
535    agreements: list | None = None
536    cardspec: str | None = None
537    plugin_pic_url: str | None = None
538
539    @property
540    def product_nickname(self) -> RoborockProductNickname | None:
541        if self.cardspec:
542            return RoborockProductSpec.from_dict(json.loads(self.cardspec).get("data"))
543        return None
544
545    def __repr__(self) -> str:
546        return _attr_repr(self)
547
548
549@dataclass
550class RoborockProductCategory(RoborockBase):
551    id: int
552    display_name: str
553    icon_url: str
554
555
556@dataclass
557class RoborockCategoryDetail(RoborockBase):
558    category: RoborockProductCategory
559    product_list: list[RoborockProduct]
560
561
562@dataclass
563class ProductResponse(RoborockBase):
564    category_detail_list: list[RoborockCategoryDetail]
def field_metadata(**kwargs):
64def field_metadata(**kwargs):
65    """Decorator to attach capability check metadata to a property.
66
67    This attaches a `_field_metadata` dictionary to the underlying getter function,
68    which is then preserved when decorated with `@property`.
69
70    Supported metadata keys:
71    - `feature` (str): Name of a capability property on `DeviceFeaturesTrait`.
72    - `dock_feature` (str): Name of a capability property on `RoborockDockFeatures`.
73    - `dps` (str/int): RoborockDataProtocol ID to check against supported schema IDs.
74    """
75
76    def decorator(func):
77        func._field_metadata = kwargs
78        return func
79
80    return decorator

Decorator to attach capability check metadata to a property.

This attaches a _field_metadata dictionary to the underlying getter function, which is then preserved when decorated with @property.

Supported metadata keys:

  • feature (str): Name of a capability property on DeviceFeaturesTrait.
  • dock_feature (str): Name of a capability property on RoborockDockFeatures.
  • dps (str/int): RoborockDataProtocol ID to check against supported schema IDs.
@dataclass(repr=False)
class RoborockBase:
 83@dataclass(repr=False)
 84class RoborockBase:
 85    """Base class for all Roborock data classes."""
 86
 87    _missing_logged: ClassVar[set[str]] = set()
 88
 89    @staticmethod
 90    def _convert_to_class_obj(class_type: type, value):
 91        if get_origin(class_type) is list:
 92            sub_type = get_args(class_type)[0]
 93            return [RoborockBase._convert_to_class_obj(sub_type, obj) for obj in value]
 94        if get_origin(class_type) is dict:
 95            key_type, value_type = get_args(class_type)
 96            if key_type is not None:
 97                return {key_type(k): RoborockBase._convert_to_class_obj(value_type, v) for k, v in value.items()}
 98            return {k: RoborockBase._convert_to_class_obj(value_type, v) for k, v in value.items()}
 99        if inspect.isclass(class_type):
100            if issubclass(class_type, RoborockBase):
101                return class_type.from_dict(value)
102            if issubclass(class_type, RoborockModeEnum):
103                return class_type.from_code(value)
104        if class_type is Any or type(class_type) is str:
105            return value
106        return class_type(value)  # type: ignore[call-arg]
107
108    @classmethod
109    def from_dict(cls, data: dict[str, Any]):
110        """Create an instance of the class from a dictionary."""
111        if not isinstance(data, dict):
112            return None
113        field_types = {field.name: field.type for field in dataclasses.fields(cls)}
114        normalized_data: dict[str, Any] = {}
115        for orig_key, value in data.items():
116            key = _decamelize(orig_key)
117            if field_types.get(key) is None:
118                if (log_key := f"{cls.__name__}.{key}") not in RoborockBase._missing_logged:
119                    _LOGGER.debug(
120                        "Key '%s' (decamelized: '%s') not found in %s fields, skipping",
121                        orig_key,
122                        key,
123                        cls.__name__,
124                    )
125                    RoborockBase._missing_logged.add(log_key)
126                continue
127            normalized_data[key] = value
128
129        result = RoborockBase.convert_dict(field_types, normalized_data)
130        return cls(**result)
131
132    @staticmethod
133    def convert_dict(types_map: dict[Any, type], data: dict[Any, Any]) -> dict[Any, Any]:
134        """Generic helper to convert a dictionary of values based on a schema map of types.
135
136        This is meant to be used by traits that use dataclass reflection similar to
137        `Roborock.from_dict` to merge in new data updates.
138        """
139        result: dict[Any, Any] = {}
140        for key, value in data.items():
141            if key not in types_map:
142                continue
143            field_type = types_map[key]
144            if value == "None" or value is None:
145                result[key] = None
146                continue
147            if isinstance(field_type, types.UnionType):
148                for subtype in get_args(field_type):
149                    if subtype is types.NoneType:
150                        continue
151                    try:
152                        result[key] = RoborockBase._convert_to_class_obj(subtype, value)
153                        break
154                    except Exception:
155                        _LOGGER.exception(f"Failed to convert {key} with value {value} to type {subtype}")
156                        continue
157            else:
158                try:
159                    result[key] = RoborockBase._convert_to_class_obj(field_type, value)
160                except Exception:
161                    _LOGGER.exception(f"Failed to convert {key} with value {value} to type {field_type}")
162                    continue
163
164        return result
165
166    def as_dict(self) -> dict:
167        return asdict(
168            self,
169            dict_factory=lambda _fields: {
170                _camelize(key): value.value if isinstance(value, Enum) else value
171                for (key, value) in _fields
172                if value is not None
173            },
174        )

Base class for all Roborock data classes.

@classmethod
def from_dict(cls, data: dict[str, typing.Any]):
108    @classmethod
109    def from_dict(cls, data: dict[str, Any]):
110        """Create an instance of the class from a dictionary."""
111        if not isinstance(data, dict):
112            return None
113        field_types = {field.name: field.type for field in dataclasses.fields(cls)}
114        normalized_data: dict[str, Any] = {}
115        for orig_key, value in data.items():
116            key = _decamelize(orig_key)
117            if field_types.get(key) is None:
118                if (log_key := f"{cls.__name__}.{key}") not in RoborockBase._missing_logged:
119                    _LOGGER.debug(
120                        "Key '%s' (decamelized: '%s') not found in %s fields, skipping",
121                        orig_key,
122                        key,
123                        cls.__name__,
124                    )
125                    RoborockBase._missing_logged.add(log_key)
126                continue
127            normalized_data[key] = value
128
129        result = RoborockBase.convert_dict(field_types, normalized_data)
130        return cls(**result)

Create an instance of the class from a dictionary.

@staticmethod
def convert_dict( types_map: dict[typing.Any, type], data: dict[typing.Any, typing.Any]) -> dict[typing.Any, typing.Any]:
132    @staticmethod
133    def convert_dict(types_map: dict[Any, type], data: dict[Any, Any]) -> dict[Any, Any]:
134        """Generic helper to convert a dictionary of values based on a schema map of types.
135
136        This is meant to be used by traits that use dataclass reflection similar to
137        `Roborock.from_dict` to merge in new data updates.
138        """
139        result: dict[Any, Any] = {}
140        for key, value in data.items():
141            if key not in types_map:
142                continue
143            field_type = types_map[key]
144            if value == "None" or value is None:
145                result[key] = None
146                continue
147            if isinstance(field_type, types.UnionType):
148                for subtype in get_args(field_type):
149                    if subtype is types.NoneType:
150                        continue
151                    try:
152                        result[key] = RoborockBase._convert_to_class_obj(subtype, value)
153                        break
154                    except Exception:
155                        _LOGGER.exception(f"Failed to convert {key} with value {value} to type {subtype}")
156                        continue
157            else:
158                try:
159                    result[key] = RoborockBase._convert_to_class_obj(field_type, value)
160                except Exception:
161                    _LOGGER.exception(f"Failed to convert {key} with value {value} to type {field_type}")
162                    continue
163
164        return result

Generic helper to convert a dictionary of values based on a schema map of types.

This is meant to be used by traits that use dataclass reflection similar to Roborock.from_dict to merge in new data updates.

def as_dict(self) -> dict:
166    def as_dict(self) -> dict:
167        return asdict(
168            self,
169            dict_factory=lambda _fields: {
170                _camelize(key): value.value if isinstance(value, Enum) else value
171                for (key, value) in _fields
172                if value is not None
173            },
174        )
@dataclass
class RoborockBaseTimer(RoborockBase):
177@dataclass
178class RoborockBaseTimer(RoborockBase):
179    start_hour: int | None = None
180    start_minute: int | None = None
181    end_hour: int | None = None
182    end_minute: int | None = None
183    enabled: int | None = None
184
185    @property
186    def start_time(self) -> datetime.time | None:
187        return (
188            datetime.time(hour=self.start_hour, minute=self.start_minute)
189            if self.start_hour is not None and self.start_minute is not None
190            else None
191        )
192
193    @property
194    def end_time(self) -> datetime.time | None:
195        return (
196            datetime.time(hour=self.end_hour, minute=self.end_minute)
197            if self.end_hour is not None and self.end_minute is not None
198            else None
199        )
200
201    def as_list(self) -> list:
202        return [self.start_hour, self.start_minute, self.end_hour, self.end_minute]
203
204    def __repr__(self) -> str:
205        return _attr_repr(self)
RoborockBaseTimer( start_hour: int | None = None, start_minute: int | None = None, end_hour: int | None = None, end_minute: int | None = None, enabled: int | None = None)
start_hour: int | None = None
start_minute: int | None = None
end_hour: int | None = None
end_minute: int | None = None
enabled: int | None = None
start_time: datetime.time | None
185    @property
186    def start_time(self) -> datetime.time | None:
187        return (
188            datetime.time(hour=self.start_hour, minute=self.start_minute)
189            if self.start_hour is not None and self.start_minute is not None
190            else None
191        )
end_time: datetime.time | None
193    @property
194    def end_time(self) -> datetime.time | None:
195        return (
196            datetime.time(hour=self.end_hour, minute=self.end_minute)
197            if self.end_hour is not None and self.end_minute is not None
198            else None
199        )
def as_list(self) -> list:
201    def as_list(self) -> list:
202        return [self.start_hour, self.start_minute, self.end_hour, self.end_minute]
@dataclass
class Reference(RoborockBase):
208@dataclass
209class Reference(RoborockBase):
210    r: str | None = None
211    a: str | None = None
212    m: str | None = None
213    l: str | None = None
Reference( r: str | None = None, a: str | None = None, m: str | None = None, l: str | None = None)
r: str | None = None
a: str | None = None
m: str | None = None
l: str | None = None
@dataclass
class RRiot(RoborockBase):
216@dataclass
217class RRiot(RoborockBase):
218    u: str
219    s: str
220    h: str
221    k: str
222    r: Reference
RRiot( u: str, s: str, h: str, k: str, r: Reference)
u: str
s: str
h: str
k: str
@dataclass
class UserData(RoborockBase):
225@dataclass
226class UserData(RoborockBase):
227    rriot: RRiot
228    uid: int | None = None
229    tokentype: str | None = None
230    token: str | None = None
231    rruid: str | None = None
232    region: str | None = None
233    countrycode: str | None = None
234    country: str | None = None
235    nickname: str | None = None
236    tuya_device_state: int | None = None
237    avatarurl: str | None = None
UserData( rriot: RRiot, uid: int | None = None, tokentype: str | None = None, token: str | None = None, rruid: str | None = None, region: str | None = None, countrycode: str | None = None, country: str | None = None, nickname: str | None = None, tuya_device_state: int | None = None, avatarurl: str | None = None)
rriot: RRiot
uid: int | None = None
tokentype: str | None = None
token: str | None = None
rruid: str | None = None
region: str | None = None
countrycode: str | None = None
country: str | None = None
nickname: str | None = None
tuya_device_state: int | None = None
avatarurl: str | None = None
@dataclass
class HomeDataProductSchema(RoborockBase):
240@dataclass
241class HomeDataProductSchema(RoborockBase):
242    id: Any | None = None
243    name: Any | None = None
244    code: Any | None = None
245    mode: Any | None = None
246    type: Any | None = None
247    product_property: Any | None = None
248    property: Any | None = None
249    desc: Any | None = None
HomeDataProductSchema( id: typing.Any | None = None, name: typing.Any | None = None, code: typing.Any | None = None, mode: typing.Any | None = None, type: typing.Any | None = None, product_property: typing.Any | None = None, property: typing.Any | None = None, desc: typing.Any | None = None)
id: typing.Any | None = None
name: typing.Any | None = None
code: typing.Any | None = None
mode: typing.Any | None = None
type: typing.Any | None = None
product_property: typing.Any | None = None
property: typing.Any | None = None
desc: typing.Any | None = None
@dataclass
class HomeDataProduct(RoborockBase):
252@dataclass
253class HomeDataProduct(RoborockBase):
254    id: str
255    name: str
256    model: str
257    category: RoborockCategory
258    code: str | None = None
259    icon_url: str | None = None
260    attribute: Any | None = None
261    capability: int | None = None
262    schema: list[HomeDataProductSchema] | None = None
263
264    @property
265    def product_nickname(self) -> RoborockProductNickname:
266        return SHORT_MODEL_TO_ENUM.get(self.model.split(".")[-1], RoborockProductNickname.PEARLPLUS)
267
268    def summary_info(self) -> str:
269        """Return a string with key product information for logging purposes."""
270        return f"{self.name} (model={self.model}, category={self.category})"
271
272    @cached_property
273    def supported_schema_codes(self) -> set[str]:
274        """Return a set of schema codes that are supported by the device.
275
276        These correspond with string field names like "state" or "error_code" that
277        correspond to RoborockDataProtocol or RoborockB01Protocol code values.
278        """
279        if self.schema is None:
280            return set()
281        return {schema.code for schema in self.schema if schema.code is not None}
282
283    @cached_property
284    def supported_schema_ids(self) -> set[int]:
285        """Return a set of schema IDs (DPS integers) that are supported by the device.
286
287        These correspond to RoborockMessageProtocol and RoborockDataProtocol or
288        RoborockB01Protocol enum number values (depends on the device protocol versions).
289        """
290        if self.schema is None:
291            return set()
292        return {int(schema.id) for schema in self.schema if schema.id is not None}
HomeDataProduct( id: str, name: str, model: str, category: roborock.data.code_mappings.RoborockCategory, code: str | None = None, icon_url: str | None = None, attribute: typing.Any | None = None, capability: int | None = None, schema: list[HomeDataProductSchema] | None = None)
id: str
name: str
model: str
code: str | None = None
icon_url: str | None = None
attribute: typing.Any | None = None
capability: int | None = None
schema: list[HomeDataProductSchema] | None = None
264    @property
265    def product_nickname(self) -> RoborockProductNickname:
266        return SHORT_MODEL_TO_ENUM.get(self.model.split(".")[-1], RoborockProductNickname.PEARLPLUS)
def summary_info(self) -> str:
268    def summary_info(self) -> str:
269        """Return a string with key product information for logging purposes."""
270        return f"{self.name} (model={self.model}, category={self.category})"

Return a string with key product information for logging purposes.

supported_schema_codes: set[str]
272    @cached_property
273    def supported_schema_codes(self) -> set[str]:
274        """Return a set of schema codes that are supported by the device.
275
276        These correspond with string field names like "state" or "error_code" that
277        correspond to RoborockDataProtocol or RoborockB01Protocol code values.
278        """
279        if self.schema is None:
280            return set()
281        return {schema.code for schema in self.schema if schema.code is not None}

Return a set of schema codes that are supported by the device.

These correspond with string field names like "state" or "error_code" that correspond to RoborockDataProtocol or RoborockB01Protocol code values.

supported_schema_ids: set[int]
283    @cached_property
284    def supported_schema_ids(self) -> set[int]:
285        """Return a set of schema IDs (DPS integers) that are supported by the device.
286
287        These correspond to RoborockMessageProtocol and RoborockDataProtocol or
288        RoborockB01Protocol enum number values (depends on the device protocol versions).
289        """
290        if self.schema is None:
291            return set()
292        return {int(schema.id) for schema in self.schema if schema.id is not None}

Return a set of schema IDs (DPS integers) that are supported by the device.

These correspond to RoborockMessageProtocol and RoborockDataProtocol or RoborockB01Protocol enum number values (depends on the device protocol versions).

@dataclass
class HomeDataDevice(RoborockBase):
295@dataclass
296class HomeDataDevice(RoborockBase):
297    duid: str
298    name: str
299    local_key: str
300    product_id: str
301    fv: str | None = None
302    attribute: Any | None = None
303    active_time: int | None = None
304    runtime_env: Any | None = None
305    time_zone_id: str | None = None
306    icon_url: str | None = None
307    lon: Any | None = None
308    lat: Any | None = None
309    share: Any | None = None
310    share_time: Any | None = None
311    online: bool | None = None
312    pv: str | None = None
313    room_id: Any | None = None
314    tuya_uuid: Any | None = None
315    tuya_migrated: bool | None = None
316    extra: Any | None = None
317    sn: str | None = None
318    feature_set: str | None = None
319    new_feature_set: str | None = None
320    device_status: dict | None = None
321    silent_ota_switch: bool | None = None
322    setting: Any | None = None
323    f: bool | None = None
324    create_time: int | None = None
325    cid: str | None = None
326    share_type: Any | None = None
327    share_expired_time: int | None = None
328
329    def summary_info(self) -> str:
330        """Return a string with key device information for logging purposes."""
331        return f"{self.name} (pv={self.pv}, fv={self.fv}, online={self.online})"
HomeDataDevice( duid: str, name: str, local_key: str, product_id: str, fv: str | None = None, attribute: typing.Any | None = None, active_time: int | None = None, runtime_env: typing.Any | None = None, time_zone_id: str | None = None, icon_url: str | None = None, lon: typing.Any | None = None, lat: typing.Any | None = None, share: typing.Any | None = None, share_time: typing.Any | None = None, online: bool | None = None, pv: str | None = None, room_id: typing.Any | None = None, tuya_uuid: typing.Any | None = None, tuya_migrated: bool | None = None, extra: typing.Any | None = None, sn: str | None = None, feature_set: str | None = None, new_feature_set: str | None = None, device_status: dict | None = None, silent_ota_switch: bool | None = None, setting: typing.Any | None = None, f: bool | None = None, create_time: int | None = None, cid: str | None = None, share_type: typing.Any | None = None, share_expired_time: int | None = None)
duid: str
name: str
local_key: str
product_id: str
fv: str | None = None
attribute: typing.Any | None = None
active_time: int | None = None
runtime_env: typing.Any | None = None
time_zone_id: str | None = None
icon_url: str | None = None
lon: typing.Any | None = None
lat: typing.Any | None = None
share: typing.Any | None = None
share_time: typing.Any | None = None
online: bool | None = None
pv: str | None = None
room_id: typing.Any | None = None
tuya_uuid: typing.Any | None = None
tuya_migrated: bool | None = None
extra: typing.Any | None = None
sn: str | None = None
feature_set: str | None = None
new_feature_set: str | None = None
device_status: dict | None = None
silent_ota_switch: bool | None = None
setting: typing.Any | None = None
f: bool | None = None
create_time: int | None = None
cid: str | None = None
share_type: typing.Any | None = None
share_expired_time: int | None = None
def summary_info(self) -> str:
329    def summary_info(self) -> str:
330        """Return a string with key device information for logging purposes."""
331        return f"{self.name} (pv={self.pv}, fv={self.fv}, online={self.online})"

Return a string with key device information for logging purposes.

@dataclass
class HomeDataRoom(RoborockBase):
334@dataclass
335class HomeDataRoom(RoborockBase):
336    id: int
337    name: str
338
339    @property
340    def iot_id(self) -> str:
341        """Return the room's ID as a string IOT ID."""
342        return str(self.id)
HomeDataRoom(id: int, name: str)
id: int
name: str
iot_id: str
339    @property
340    def iot_id(self) -> str:
341        """Return the room's ID as a string IOT ID."""
342        return str(self.id)

Return the room's ID as a string IOT ID.

@dataclass
class HomeDataScene(RoborockBase):
345@dataclass
346class HomeDataScene(RoborockBase):
347    id: int
348    name: str
HomeDataScene(id: int, name: str)
id: int
name: str
@dataclass
class HomeDataSchedule(RoborockBase):
351@dataclass
352class HomeDataSchedule(RoborockBase):
353    id: int
354    cron: str
355    repeated: bool
356    enabled: bool
357    param: dict | None = None
HomeDataSchedule( id: int, cron: str, repeated: bool, enabled: bool, param: dict | None = None)
id: int
cron: str
repeated: bool
enabled: bool
param: dict | None = None
@dataclass
class HomeData(RoborockBase):
360@dataclass
361class HomeData(RoborockBase):
362    id: int
363    name: str
364    products: list[HomeDataProduct] = field(default_factory=lambda: [])
365    devices: list[HomeDataDevice] = field(default_factory=lambda: [])
366    received_devices: list[HomeDataDevice] = field(default_factory=lambda: [])
367    lon: Any | None = None
368    lat: Any | None = None
369    geo_name: Any | None = None
370    rooms: list[HomeDataRoom] = field(default_factory=list)
371
372    def get_all_devices(self) -> list[HomeDataDevice]:
373        devices = []
374        if self.devices is not None:
375            devices += self.devices
376        if self.received_devices is not None:
377            devices += self.received_devices
378        return devices
379
380    @cached_property
381    def product_map(self) -> dict[str, HomeDataProduct]:
382        """Returns a dictionary of product IDs to HomeDataProduct objects."""
383        return {product.id: product for product in self.products}
384
385    @cached_property
386    def device_products(self) -> dict[str, tuple[HomeDataDevice, HomeDataProduct]]:
387        """Returns a dictionary of device DUIDs to HomeDataDeviceProduct objects."""
388        product_map = self.product_map
389        return {
390            device.duid: (device, product)
391            for device in self.get_all_devices()
392            if (product := product_map.get(device.product_id)) is not None
393        }
394
395    @property
396    def rooms_map(self) -> dict[str, HomeDataRoom]:
397        """Returns a dictionary of Room iot_id to rooms"""
398        return {room.iot_id: room for room in self.rooms}
399
400    @property
401    def rooms_name_map(self) -> dict[str, str]:
402        """Returns a dictionary of Room iot_id to room names."""
403        return {room.iot_id: room.name for room in self.rooms}
HomeData( id: int, name: str, products: list[HomeDataProduct] = <factory>, devices: list[HomeDataDevice] = <factory>, received_devices: list[HomeDataDevice] = <factory>, lon: typing.Any | None = None, lat: typing.Any | None = None, geo_name: typing.Any | None = None, rooms: list[HomeDataRoom] = <factory>)
id: int
name: str
products: list[HomeDataProduct]
devices: list[HomeDataDevice]
received_devices: list[HomeDataDevice]
lon: typing.Any | None = None
lat: typing.Any | None = None
geo_name: typing.Any | None = None
rooms: list[HomeDataRoom]
def get_all_devices(self) -> list[HomeDataDevice]:
372    def get_all_devices(self) -> list[HomeDataDevice]:
373        devices = []
374        if self.devices is not None:
375            devices += self.devices
376        if self.received_devices is not None:
377            devices += self.received_devices
378        return devices
product_map: dict[str, HomeDataProduct]
380    @cached_property
381    def product_map(self) -> dict[str, HomeDataProduct]:
382        """Returns a dictionary of product IDs to HomeDataProduct objects."""
383        return {product.id: product for product in self.products}

Returns a dictionary of product IDs to HomeDataProduct objects.

device_products: dict[str, tuple[HomeDataDevice, HomeDataProduct]]
385    @cached_property
386    def device_products(self) -> dict[str, tuple[HomeDataDevice, HomeDataProduct]]:
387        """Returns a dictionary of device DUIDs to HomeDataDeviceProduct objects."""
388        product_map = self.product_map
389        return {
390            device.duid: (device, product)
391            for device in self.get_all_devices()
392            if (product := product_map.get(device.product_id)) is not None
393        }

Returns a dictionary of device DUIDs to HomeDataDeviceProduct objects.

rooms_map: dict[str, HomeDataRoom]
395    @property
396    def rooms_map(self) -> dict[str, HomeDataRoom]:
397        """Returns a dictionary of Room iot_id to rooms"""
398        return {room.iot_id: room for room in self.rooms}

Returns a dictionary of Room iot_id to rooms

rooms_name_map: dict[str, str]
400    @property
401    def rooms_name_map(self) -> dict[str, str]:
402        """Returns a dictionary of Room iot_id to room names."""
403        return {room.iot_id: room.name for room in self.rooms}

Returns a dictionary of Room iot_id to room names.

@dataclass
class LoginData(RoborockBase):
406@dataclass
407class LoginData(RoborockBase):
408    user_data: UserData
409    email: str
410    home_data: HomeData | None = None
LoginData( user_data: UserData, email: str, home_data: HomeData | None = None)
user_data: UserData
email: str
home_data: HomeData | None = None
@dataclass
class DeviceData(RoborockBase):
413@dataclass
414class DeviceData(RoborockBase):
415    device: HomeDataDevice
416    model: str
417    host: str | None = None
418
419    @property
420    def product_nickname(self) -> RoborockProductNickname:
421        return SHORT_MODEL_TO_ENUM.get(self.model.split(".")[-1], RoborockProductNickname.PEARLPLUS)
422
423    def __repr__(self) -> str:
424        return _attr_repr(self)
DeviceData( device: HomeDataDevice, model: str, host: str | None = None)
device: HomeDataDevice
model: str
host: str | None = None
419    @property
420    def product_nickname(self) -> RoborockProductNickname:
421        return SHORT_MODEL_TO_ENUM.get(self.model.split(".")[-1], RoborockProductNickname.PEARLPLUS)
@dataclass
class RoomMapping(RoborockBase):
427@dataclass
428class RoomMapping(RoborockBase):
429    segment_id: int
430    iot_id: str
RoomMapping(segment_id: int, iot_id: str)
segment_id: int
iot_id: str
@dataclass
class NamedRoomMapping(RoomMapping):
433@dataclass
434class NamedRoomMapping(RoomMapping):
435    """Dataclass representing a mapping of a room segment to a name.
436
437    The name information is not provided by the device directly, but is provided
438    from the HomeData based on the iot_id from the room.
439    """
440
441    @property
442    def name(self) -> str:
443        """The human-readable name of the room, or a default name if not available."""
444        return self.raw_name or f"Room {self.segment_id}"
445
446    raw_name: str | None = None
447    """The raw name of the room, as provided by the device."""

Dataclass representing a mapping of a room segment to a name.

The name information is not provided by the device directly, but is provided from the HomeData based on the iot_id from the room.

NamedRoomMapping(segment_id: int, iot_id: str, raw_name: str | None = None)
name: str
441    @property
442    def name(self) -> str:
443        """The human-readable name of the room, or a default name if not available."""
444        return self.raw_name or f"Room {self.segment_id}"

The human-readable name of the room, or a default name if not available.

raw_name: str | None = None

The raw name of the room, as provided by the device.

@dataclass
class CombinedMapInfo(RoborockBase):
450@dataclass
451class CombinedMapInfo(RoborockBase):
452    """Data structure for caching home information.
453
454    This is not provided directly by the API, but is a combination of map data
455    and room data to provide a more useful structure.
456    """
457
458    map_flag: int
459    """The map identifier."""
460
461    name: str
462    """The name of the map from MultiMapsListMapInfo."""
463
464    rooms: list[NamedRoomMapping]
465    """The list of rooms in the map."""
466
467    @property
468    def rooms_map(self) -> dict[int, NamedRoomMapping]:
469        """Returns a mapping of segment_id to NamedRoomMapping."""
470        return {room.segment_id: room for room in self.rooms}

Data structure for caching home information.

This is not provided directly by the API, but is a combination of map data and room data to provide a more useful structure.

CombinedMapInfo( map_flag: int, name: str, rooms: list[NamedRoomMapping])
map_flag: int

The map identifier.

name: str

The name of the map from MultiMapsListMapInfo.

rooms: list[NamedRoomMapping]

The list of rooms in the map.

rooms_map: dict[int, NamedRoomMapping]
467    @property
468    def rooms_map(self) -> dict[int, NamedRoomMapping]:
469        """Returns a mapping of segment_id to NamedRoomMapping."""
470        return {room.segment_id: room for room in self.rooms}

Returns a mapping of segment_id to NamedRoomMapping.

@dataclass
class BroadcastMessage(RoborockBase):
473@dataclass
474class BroadcastMessage(RoborockBase):
475    duid: str
476    ip: str
477    version: bytes
BroadcastMessage(duid: str, ip: str, version: bytes)
duid: str
ip: str
version: bytes
class ServerTimer(typing.NamedTuple):
480class ServerTimer(NamedTuple):
481    id: str
482    status: str
483    dontknow: int

ServerTimer(id, status, dontknow)

ServerTimer(id: str, status: str, dontknow: int)

Create new instance of ServerTimer(id, status, dontknow)

id: str

Alias for field number 0

status: str

Alias for field number 1

dontknow: int

Alias for field number 2

@dataclass
class RoborockProductStateValue(RoborockBase):
486@dataclass
487class RoborockProductStateValue(RoborockBase):
488    value: list
489    desc: dict
RoborockProductStateValue(value: list, desc: dict)
value: list
desc: dict
@dataclass
class RoborockProductState(RoborockBase):
492@dataclass
493class RoborockProductState(RoborockBase):
494    dps: int
495    desc: dict
496    value: list[RoborockProductStateValue]
RoborockProductState( dps: int, desc: dict, value: list[RoborockProductStateValue])
dps: int
desc: dict
@dataclass
class RoborockProductSpec(RoborockBase):
499@dataclass
500class RoborockProductSpec(RoborockBase):
501    state: RoborockProductState
502    battery: dict | None = None
503    dry_countdown: dict | None = None
504    extra: dict | None = None
505    offpeak: dict | None = None
506    countdown: dict | None = None
507    mode: dict | None = None
508    ota_nfo: dict | None = None
509    pause: dict | None = None
510    program: dict | None = None
511    shutdown: dict | None = None
512    washing_left: dict | None = None
RoborockProductSpec( state: RoborockProductState, battery: dict | None = None, dry_countdown: dict | None = None, extra: dict | None = None, offpeak: dict | None = None, countdown: dict | None = None, mode: dict | None = None, ota_nfo: dict | None = None, pause: dict | None = None, program: dict | None = None, shutdown: dict | None = None, washing_left: dict | None = None)
battery: dict | None = None
dry_countdown: dict | None = None
extra: dict | None = None
offpeak: dict | None = None
countdown: dict | None = None
mode: dict | None = None
ota_nfo: dict | None = None
pause: dict | None = None
program: dict | None = None
shutdown: dict | None = None
washing_left: dict | None = None
@dataclass
class RoborockProduct(RoborockBase):
515@dataclass
516class RoborockProduct(RoborockBase):
517    id: int | None = None
518    name: str | None = None
519    model: str | None = None
520    packagename: str | None = None
521    ssid: str | None = None
522    picurl: str | None = None
523    cardpicurl: str | None = None
524    mediumCardpicurl: str | None = None
525    resetwifipicurl: str | None = None
526    configPicUrl: str | None = None
527    pluginPicUrl: str | None = None
528    resetwifitext: dict | None = None
529    tuyaid: str | None = None
530    status: int | None = None
531    rriotid: str | None = None
532    pictures: list | None = None
533    ncMode: str | None = None
534    scope: str | None = None
535    product_tags: list | None = None
536    agreements: list | None = None
537    cardspec: str | None = None
538    plugin_pic_url: str | None = None
539
540    @property
541    def product_nickname(self) -> RoborockProductNickname | None:
542        if self.cardspec:
543            return RoborockProductSpec.from_dict(json.loads(self.cardspec).get("data"))
544        return None
545
546    def __repr__(self) -> str:
547        return _attr_repr(self)
RoborockProduct( id: int | None = None, name: str | None = None, model: str | None = None, packagename: str | None = None, ssid: str | None = None, picurl: str | None = None, cardpicurl: str | None = None, mediumCardpicurl: str | None = None, resetwifipicurl: str | None = None, configPicUrl: str | None = None, pluginPicUrl: str | None = None, resetwifitext: dict | None = None, tuyaid: str | None = None, status: int | None = None, rriotid: str | None = None, pictures: list | None = None, ncMode: str | None = None, scope: str | None = None, product_tags: list | None = None, agreements: list | None = None, cardspec: str | None = None, plugin_pic_url: str | None = None)
id: int | None = None
name: str | None = None
model: str | None = None
packagename: str | None = None
ssid: str | None = None
picurl: str | None = None
cardpicurl: str | None = None
mediumCardpicurl: str | None = None
resetwifipicurl: str | None = None
configPicUrl: str | None = None
pluginPicUrl: str | None = None
resetwifitext: dict | None = None
tuyaid: str | None = None
status: int | None = None
rriotid: str | None = None
pictures: list | None = None
ncMode: str | None = None
scope: str | None = None
product_tags: list | None = None
agreements: list | None = None
cardspec: str | None = None
plugin_pic_url: str | None = None
product_nickname: roborock.data.code_mappings.RoborockProductNickname | None
540    @property
541    def product_nickname(self) -> RoborockProductNickname | None:
542        if self.cardspec:
543            return RoborockProductSpec.from_dict(json.loads(self.cardspec).get("data"))
544        return None
@dataclass
class RoborockProductCategory(RoborockBase):
550@dataclass
551class RoborockProductCategory(RoborockBase):
552    id: int
553    display_name: str
554    icon_url: str
RoborockProductCategory(id: int, display_name: str, icon_url: str)
id: int
display_name: str
icon_url: str
@dataclass
class RoborockCategoryDetail(RoborockBase):
557@dataclass
558class RoborockCategoryDetail(RoborockBase):
559    category: RoborockProductCategory
560    product_list: list[RoborockProduct]
RoborockCategoryDetail( category: RoborockProductCategory, product_list: list[RoborockProduct])
product_list: list[RoborockProduct]
@dataclass
class ProductResponse(RoborockBase):
563@dataclass
564class ProductResponse(RoborockBase):
565    category_detail_list: list[RoborockCategoryDetail]
ProductResponse( category_detail_list: list[RoborockCategoryDetail])
category_detail_list: list[RoborockCategoryDetail]