roborock.devices.traits.v1.common

Module for Roborock V1 devices common trait commands.

This is an internal library and should not be used directly by consumers.

  1"""Module for Roborock V1 devices common trait commands.
  2
  3This is an internal library and should not be used directly by consumers.
  4"""
  5
  6import logging
  7from abc import ABC, abstractmethod
  8from dataclasses import fields
  9from typing import ClassVar
 10
 11from roborock.data import RoborockBase
 12from roborock.protocols.v1_protocol import V1RpcChannel
 13from roborock.roborock_typing import RoborockCommand
 14
 15_LOGGER = logging.getLogger(__name__)
 16
 17
 18V1ResponseData = dict | list | int | str
 19
 20
 21class 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
 35class V1TraitMixin(ABC):
 36    """Base model that supports v1 traits.
 37
 38    This class provides functioanlity for parsing responses from V1 devices
 39    into dataclass instances. It also provides a reference to the V1RpcChannel
 40    used to communicate with the device to execute commands.
 41
 42    Each trait subclass must define a class variable `command` that specifies
 43    the RoborockCommand used to fetch the trait data from the device. The
 44    `refresh()` method can be called to update the contents of the trait data
 45    from the device.
 46
 47    A trait can also support additional commands for updating state associated
 48    with the trait. It is expected that a trait will update its own internal
 49    state either reflecting the change optimistically or by refreshing the
 50    trait state from the device. In cases where one trait caches data that is
 51    also represented in another trait, it is the responsibility of the caller
 52    to ensure that both traits are refreshed as needed to keep them in sync.
 53
 54    The traits typically subclass RoborockBase to provide serialization
 55    and deserialization functionality, but this is not strictly required.
 56    """
 57
 58    command: ClassVar[RoborockCommand]
 59    """The RoborockCommand used to fetch the trait data from the device (internal only)."""
 60
 61    converter: V1TraitDataConverter
 62    """The converter used to parse the response from the device (internal only)."""
 63
 64    def __init__(self) -> None:
 65        """Initialize the V1TraitMixin."""
 66        self._rpc_channel = None
 67
 68    @property
 69    def rpc_channel(self) -> V1RpcChannel:
 70        """Helper for executing commands, used internally by the trait"""
 71        if not self._rpc_channel:
 72            raise ValueError("Device trait in invalid state")
 73        return self._rpc_channel
 74
 75    async def refresh(self) -> None:
 76        """Refresh the contents of this trait."""
 77        response = await self.rpc_channel.send_command(self.command)
 78        new_data = self.converter.convert(response)
 79        merge_trait_values(self, new_data)  # type: ignore[arg-type]
 80
 81
 82def 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
 94class 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
113class SingleValueConverter(DefaultConverter):
114    """Base class for traits that represent a single value.
115
116    This class is intended to be subclassed by traits that represent a single
117    value, such as volume or brightness. The subclass should define a single
118    field with the metadata `roborock_value=True` to indicate which field
119    represents the main value of the trait.
120    """
121
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:
128        """Parse the response from the device into a RoborockValueBase."""
129        if isinstance(response, list):
130            response = response[0]
131        if not isinstance(response, int):
132            raise ValueError(f"Unexpected response format: {response!r}")
133        return super().convert({self._value_field: response})
134
135
136class RoborockSwitchBase(ABC):
137    """Base class for traits that represent a boolean switch."""
138
139    @property
140    @abstractmethod
141    def is_on(self) -> bool:
142        """Return whether the switch is on."""
143
144    @abstractmethod
145    async def enable(self) -> None:
146        """Enable the switch."""
147
148    @abstractmethod
149    async def disable(self) -> None:
150        """Disable the switch."""
151
152
153def mqtt_rpc_channel(cls):
154    """Decorator to mark a function as cloud only.
155
156    Normally a trait uses an adaptive rpc channel that can use either local
157    or cloud communication depending on what is available. This will force
158    the trait to always use the cloud rpc channel.
159    """
160
161    def wrapper(*args, **kwargs):
162        return cls(*args, **kwargs)
163
164    cls.mqtt_rpc_channel = True  # type: ignore[attr-defined]
165    return wrapper
166
167
168def map_rpc_channel(cls):
169    """Decorator to mark a function as cloud only using the map rpc format."""
170
171    def wrapper(*args, **kwargs):
172        return cls(*args, **kwargs)
173
174    cls.map_rpc_channel = True  # type: ignore[attr-defined]
175    return wrapper
V1ResponseData = dict | list | int | str
class V1TraitDataConverter(abc.ABC):
22class V1TraitDataConverter(ABC):
23    """Converts responses to RoborockBase objects.
24
25    This is an internal class and should not be used directly by consumers.
26    """
27
28    @abstractmethod
29    def convert(self, response: V1ResponseData) -> RoborockBase:
30        """Convert the values to a dict that can be parsed as a RoborockBase."""
31
32    def __repr__(self) -> str:
33        return self.__class__.__name__

Converts responses to RoborockBase objects.

This is an internal class and should not be used directly by consumers.

@abstractmethod
def convert( self, response: dict | list | int | str) -> roborock.data.containers.RoborockBase:
28    @abstractmethod
29    def convert(self, response: V1ResponseData) -> RoborockBase:
30        """Convert the values to a dict that can be parsed as a RoborockBase."""

Convert the values to a dict that can be parsed as a RoborockBase.

class V1TraitMixin(abc.ABC):
36class V1TraitMixin(ABC):
37    """Base model that supports v1 traits.
38
39    This class provides functioanlity for parsing responses from V1 devices
40    into dataclass instances. It also provides a reference to the V1RpcChannel
41    used to communicate with the device to execute commands.
42
43    Each trait subclass must define a class variable `command` that specifies
44    the RoborockCommand used to fetch the trait data from the device. The
45    `refresh()` method can be called to update the contents of the trait data
46    from the device.
47
48    A trait can also support additional commands for updating state associated
49    with the trait. It is expected that a trait will update its own internal
50    state either reflecting the change optimistically or by refreshing the
51    trait state from the device. In cases where one trait caches data that is
52    also represented in another trait, it is the responsibility of the caller
53    to ensure that both traits are refreshed as needed to keep them in sync.
54
55    The traits typically subclass RoborockBase to provide serialization
56    and deserialization functionality, but this is not strictly required.
57    """
58
59    command: ClassVar[RoborockCommand]
60    """The RoborockCommand used to fetch the trait data from the device (internal only)."""
61
62    converter: V1TraitDataConverter
63    """The converter used to parse the response from the device (internal only)."""
64
65    def __init__(self) -> None:
66        """Initialize the V1TraitMixin."""
67        self._rpc_channel = None
68
69    @property
70    def rpc_channel(self) -> V1RpcChannel:
71        """Helper for executing commands, used internally by the trait"""
72        if not self._rpc_channel:
73            raise ValueError("Device trait in invalid state")
74        return self._rpc_channel
75
76    async def refresh(self) -> None:
77        """Refresh the contents of this trait."""
78        response = await self.rpc_channel.send_command(self.command)
79        new_data = self.converter.convert(response)
80        merge_trait_values(self, new_data)  # type: ignore[arg-type]

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.

V1TraitMixin()
65    def __init__(self) -> None:
66        """Initialize the V1TraitMixin."""
67        self._rpc_channel = None

Initialize the V1TraitMixin.

The RoborockCommand used to fetch the trait data from the device (internal only).

The converter used to parse the response from the device (internal only).

rpc_channel: roborock.protocols.v1_protocol.V1RpcChannel
69    @property
70    def rpc_channel(self) -> V1RpcChannel:
71        """Helper for executing commands, used internally by the trait"""
72        if not self._rpc_channel:
73            raise ValueError("Device trait in invalid state")
74        return self._rpc_channel

Helper for executing commands, used internally by the trait

async def refresh(self) -> None:
76    async def refresh(self) -> None:
77        """Refresh the contents of this trait."""
78        response = await self.rpc_channel.send_command(self.command)
79        new_data = self.converter.convert(response)
80        merge_trait_values(self, new_data)  # type: ignore[arg-type]

Refresh the contents of this trait.

def merge_trait_values( target: roborock.data.containers.RoborockBase, new_object: roborock.data.containers.RoborockBase) -> bool:
83def merge_trait_values(target: RoborockBase, new_object: RoborockBase) -> bool:
84    """Update the target object with set fields in new_object."""
85    updated = False
86    for field in fields(new_object):
87        old_value = getattr(target, field.name, None)
88        new_value = getattr(new_object, field.name, None)
89        if new_value != old_value:
90            setattr(target, field.name, new_value)
91            updated = True
92    return updated

Update the target object with set fields in new_object.

class DefaultConverter(V1TraitDataConverter):
 95class DefaultConverter(V1TraitDataConverter):
 96    """Converts responses to RoborockBase objects."""
 97
 98    def __init__(self, dataclass_type: type[RoborockBase]) -> None:
 99        """Initialize the converter."""
100        self._dataclass_type = dataclass_type
101
102    def convert(self, response: V1ResponseData) -> RoborockBase:
103        """Convert the values to a dict that can be parsed as a RoborockBase.
104
105        Subclasses can override to implement custom parsing logic
106        """
107        if isinstance(response, list):
108            response = response[0]
109        if not isinstance(response, dict):
110            raise ValueError(f"Unexpected {self._dataclass_type.__name__} response format: {response!r}")
111        return self._dataclass_type.from_dict(response)

Converts responses to RoborockBase objects.

DefaultConverter(dataclass_type: type[roborock.data.containers.RoborockBase])
 98    def __init__(self, dataclass_type: type[RoborockBase]) -> None:
 99        """Initialize the converter."""
100        self._dataclass_type = dataclass_type

Initialize the converter.

def convert( self, response: dict | list | int | str) -> roborock.data.containers.RoborockBase:
102    def convert(self, response: V1ResponseData) -> RoborockBase:
103        """Convert the values to a dict that can be parsed as a RoborockBase.
104
105        Subclasses can override to implement custom parsing logic
106        """
107        if isinstance(response, list):
108            response = response[0]
109        if not isinstance(response, dict):
110            raise ValueError(f"Unexpected {self._dataclass_type.__name__} response format: {response!r}")
111        return self._dataclass_type.from_dict(response)

Convert the values to a dict that can be parsed as a RoborockBase.

Subclasses can override to implement custom parsing logic

class SingleValueConverter(DefaultConverter):
114class SingleValueConverter(DefaultConverter):
115    """Base class for traits that represent a single value.
116
117    This class is intended to be subclassed by traits that represent a single
118    value, such as volume or brightness. The subclass should define a single
119    field with the metadata `roborock_value=True` to indicate which field
120    represents the main value of the trait.
121    """
122
123    def __init__(self, dataclass_type: type[RoborockBase], value_field: str) -> None:
124        """Initialize the converter."""
125        super().__init__(dataclass_type)
126        self._value_field = value_field
127
128    def convert(self, response: V1ResponseData) -> RoborockBase:
129        """Parse the response from the device into a RoborockValueBase."""
130        if isinstance(response, list):
131            response = response[0]
132        if not isinstance(response, int):
133            raise ValueError(f"Unexpected response format: {response!r}")
134        return super().convert({self._value_field: response})

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.

SingleValueConverter( dataclass_type: type[roborock.data.containers.RoborockBase], value_field: str)
123    def __init__(self, dataclass_type: type[RoborockBase], value_field: str) -> None:
124        """Initialize the converter."""
125        super().__init__(dataclass_type)
126        self._value_field = value_field

Initialize the converter.

def convert( self, response: dict | list | int | str) -> roborock.data.containers.RoborockBase:
128    def convert(self, response: V1ResponseData) -> RoborockBase:
129        """Parse the response from the device into a RoborockValueBase."""
130        if isinstance(response, list):
131            response = response[0]
132        if not isinstance(response, int):
133            raise ValueError(f"Unexpected response format: {response!r}")
134        return super().convert({self._value_field: response})

Parse the response from the device into a RoborockValueBase.

class RoborockSwitchBase(abc.ABC):
137class RoborockSwitchBase(ABC):
138    """Base class for traits that represent a boolean switch."""
139
140    @property
141    @abstractmethod
142    def is_on(self) -> bool:
143        """Return whether the switch is on."""
144
145    @abstractmethod
146    async def enable(self) -> None:
147        """Enable the switch."""
148
149    @abstractmethod
150    async def disable(self) -> None:
151        """Disable the switch."""

Base class for traits that represent a boolean switch.

is_on: bool
140    @property
141    @abstractmethod
142    def is_on(self) -> bool:
143        """Return whether the switch is on."""

Return whether the switch is on.

@abstractmethod
async def enable(self) -> None:
145    @abstractmethod
146    async def enable(self) -> None:
147        """Enable the switch."""

Enable the switch.

@abstractmethod
async def disable(self) -> None:
149    @abstractmethod
150    async def disable(self) -> None:
151        """Disable the switch."""

Disable the switch.

def mqtt_rpc_channel(cls):
154def mqtt_rpc_channel(cls):
155    """Decorator to mark a function as cloud only.
156
157    Normally a trait uses an adaptive rpc channel that can use either local
158    or cloud communication depending on what is available. This will force
159    the trait to always use the cloud rpc channel.
160    """
161
162    def wrapper(*args, **kwargs):
163        return cls(*args, **kwargs)
164
165    cls.mqtt_rpc_channel = True  # type: ignore[attr-defined]
166    return wrapper

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 map_rpc_channel(cls):
169def map_rpc_channel(cls):
170    """Decorator to mark a function as cloud only using the map rpc format."""
171
172    def wrapper(*args, **kwargs):
173        return cls(*args, **kwargs)
174
175    cls.map_rpc_channel = True  # type: ignore[attr-defined]
176    return wrapper

Decorator to mark a function as cloud only using the map rpc format.