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.exceptions import RoborockParsingException
 13from roborock.protocols.v1_protocol import V1RpcChannel
 14from roborock.roborock_typing import RoborockCommand
 15
 16_LOGGER = logging.getLogger(__name__)
 17
 18
 19V1ResponseData = dict | list | int | str
 20
 21
 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__
 34
 35
 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: V1RpcChannel | None = 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        try:
 80            new_data = self.converter.convert(response)
 81        except (TypeError, ValueError) as err:
 82            raise RoborockParsingException(
 83                trait_name=type(self).__name__,
 84                command=self.command,
 85                payload=response,
 86                inner_error=err,
 87            ) from err
 88        merge_trait_values(self, new_data)  # type: ignore[arg-type]
 89
 90
 91def merge_trait_values(target: RoborockBase, new_object: RoborockBase) -> bool:
 92    """Update the target object with set fields in new_object."""
 93    updated = False
 94    for field in fields(new_object):
 95        old_value = getattr(target, field.name, None)
 96        new_value = getattr(new_object, field.name, None)
 97        if new_value != old_value:
 98            setattr(target, field.name, new_value)
 99            updated = True
100    return updated
101
102
103class DefaultConverter(V1TraitDataConverter):
104    """Converts responses to RoborockBase objects."""
105
106    def __init__(self, dataclass_type: type[RoborockBase]) -> None:
107        """Initialize the converter."""
108        self._dataclass_type = dataclass_type
109
110    def convert(self, response: V1ResponseData) -> RoborockBase:
111        """Convert the values to a dict that can be parsed as a RoborockBase.
112
113        Subclasses can override to implement custom parsing logic
114        """
115        if isinstance(response, list):
116            response = response[0]
117        if not isinstance(response, dict):
118            raise ValueError(f"Unexpected {self._dataclass_type.__name__} response format: {response!r}")
119        return self._dataclass_type.from_dict(response)
120
121
122class SingleValueConverter(DefaultConverter):
123    """Base class for traits that represent a single value.
124
125    This class is intended to be subclassed by traits that represent a single
126    value, such as volume or brightness. The subclass should define a single
127    field with the metadata `roborock_value=True` to indicate which field
128    represents the main value of the trait.
129    """
130
131    def __init__(self, dataclass_type: type[RoborockBase], value_field: str) -> None:
132        """Initialize the converter."""
133        super().__init__(dataclass_type)
134        self._value_field = value_field
135
136    def convert(self, response: V1ResponseData) -> RoborockBase:
137        """Parse the response from the device into a RoborockValueBase."""
138        if isinstance(response, list):
139            response = response[0]
140        if not isinstance(response, int):
141            raise ValueError(f"Unexpected response format: {response!r}")
142        return super().convert({self._value_field: response})
143
144
145class RoborockSwitchBase(ABC):
146    """Base class for traits that represent a boolean switch."""
147
148    @property
149    @abstractmethod
150    def is_on(self) -> bool:
151        """Return whether the switch is on."""
152
153    @abstractmethod
154    async def enable(self) -> None:
155        """Enable the switch."""
156
157    @abstractmethod
158    async def disable(self) -> None:
159        """Disable the switch."""
160
161
162def mqtt_rpc_channel(cls):
163    """Decorator to mark a function as cloud only.
164
165    Normally a trait uses an adaptive rpc channel that can use either local
166    or cloud communication depending on what is available. This will force
167    the trait to always use the cloud rpc channel.
168    """
169
170    def wrapper(*args, **kwargs):
171        return cls(*args, **kwargs)
172
173    cls.mqtt_rpc_channel = True  # type: ignore[attr-defined]
174    return wrapper
175
176
177def map_rpc_channel(cls):
178    """Decorator to mark a function as cloud only using the map rpc format."""
179
180    def wrapper(*args, **kwargs):
181        return cls(*args, **kwargs)
182
183    cls.map_rpc_channel = True  # type: ignore[attr-defined]
184    return wrapper
V1ResponseData = dict | list | int | str
class V1TraitDataConverter(abc.ABC):
23class V1TraitDataConverter(ABC):
24    """Converts responses to RoborockBase objects.
25
26    This is an internal class and should not be used directly by consumers.
27    """
28
29    @abstractmethod
30    def convert(self, response: V1ResponseData) -> RoborockBase:
31        """Convert the values to a dict that can be parsed as a RoborockBase."""
32
33    def __repr__(self) -> str:
34        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:
29    @abstractmethod
30    def convert(self, response: V1ResponseData) -> RoborockBase:
31        """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):
37class V1TraitMixin(ABC):
38    """Base model that supports v1 traits.
39
40    This class provides functioanlity for parsing responses from V1 devices
41    into dataclass instances. It also provides a reference to the V1RpcChannel
42    used to communicate with the device to execute commands.
43
44    Each trait subclass must define a class variable `command` that specifies
45    the RoborockCommand used to fetch the trait data from the device. The
46    `refresh()` method can be called to update the contents of the trait data
47    from the device.
48
49    A trait can also support additional commands for updating state associated
50    with the trait. It is expected that a trait will update its own internal
51    state either reflecting the change optimistically or by refreshing the
52    trait state from the device. In cases where one trait caches data that is
53    also represented in another trait, it is the responsibility of the caller
54    to ensure that both traits are refreshed as needed to keep them in sync.
55
56    The traits typically subclass RoborockBase to provide serialization
57    and deserialization functionality, but this is not strictly required.
58    """
59
60    command: ClassVar[RoborockCommand]
61    """The RoborockCommand used to fetch the trait data from the device (internal only)."""
62
63    converter: V1TraitDataConverter
64    """The converter used to parse the response from the device (internal only)."""
65
66    def __init__(self) -> None:
67        """Initialize the V1TraitMixin."""
68        self._rpc_channel: V1RpcChannel | None = None
69
70    @property
71    def rpc_channel(self) -> V1RpcChannel:
72        """Helper for executing commands, used internally by the trait"""
73        if not self._rpc_channel:
74            raise ValueError("Device trait in invalid state")
75        return self._rpc_channel
76
77    async def refresh(self) -> None:
78        """Refresh the contents of this trait."""
79        response = await self.rpc_channel.send_command(self.command)
80        try:
81            new_data = self.converter.convert(response)
82        except (TypeError, ValueError) as err:
83            raise RoborockParsingException(
84                trait_name=type(self).__name__,
85                command=self.command,
86                payload=response,
87                inner_error=err,
88            ) from err
89        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()
66    def __init__(self) -> None:
67        """Initialize the V1TraitMixin."""
68        self._rpc_channel: V1RpcChannel | None = 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
70    @property
71    def rpc_channel(self) -> V1RpcChannel:
72        """Helper for executing commands, used internally by the trait"""
73        if not self._rpc_channel:
74            raise ValueError("Device trait in invalid state")
75        return self._rpc_channel

Helper for executing commands, used internally by the trait

async def refresh(self) -> None:
77    async def refresh(self) -> None:
78        """Refresh the contents of this trait."""
79        response = await self.rpc_channel.send_command(self.command)
80        try:
81            new_data = self.converter.convert(response)
82        except (TypeError, ValueError) as err:
83            raise RoborockParsingException(
84                trait_name=type(self).__name__,
85                command=self.command,
86                payload=response,
87                inner_error=err,
88            ) from err
89        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:
 92def merge_trait_values(target: RoborockBase, new_object: RoborockBase) -> bool:
 93    """Update the target object with set fields in new_object."""
 94    updated = False
 95    for field in fields(new_object):
 96        old_value = getattr(target, field.name, None)
 97        new_value = getattr(new_object, field.name, None)
 98        if new_value != old_value:
 99            setattr(target, field.name, new_value)
100            updated = True
101    return updated

Update the target object with set fields in new_object.

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

Converts responses to RoborockBase objects.

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

Initialize the converter.

def convert( self, response: dict | list | int | str) -> roborock.data.containers.RoborockBase:
111    def convert(self, response: V1ResponseData) -> RoborockBase:
112        """Convert the values to a dict that can be parsed as a RoborockBase.
113
114        Subclasses can override to implement custom parsing logic
115        """
116        if isinstance(response, list):
117            response = response[0]
118        if not isinstance(response, dict):
119            raise ValueError(f"Unexpected {self._dataclass_type.__name__} response format: {response!r}")
120        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):
123class SingleValueConverter(DefaultConverter):
124    """Base class for traits that represent a single value.
125
126    This class is intended to be subclassed by traits that represent a single
127    value, such as volume or brightness. The subclass should define a single
128    field with the metadata `roborock_value=True` to indicate which field
129    represents the main value of the trait.
130    """
131
132    def __init__(self, dataclass_type: type[RoborockBase], value_field: str) -> None:
133        """Initialize the converter."""
134        super().__init__(dataclass_type)
135        self._value_field = value_field
136
137    def convert(self, response: V1ResponseData) -> RoborockBase:
138        """Parse the response from the device into a RoborockValueBase."""
139        if isinstance(response, list):
140            response = response[0]
141        if not isinstance(response, int):
142            raise ValueError(f"Unexpected response format: {response!r}")
143        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)
132    def __init__(self, dataclass_type: type[RoborockBase], value_field: str) -> None:
133        """Initialize the converter."""
134        super().__init__(dataclass_type)
135        self._value_field = value_field

Initialize the converter.

def convert( self, response: dict | list | int | str) -> roborock.data.containers.RoborockBase:
137    def convert(self, response: V1ResponseData) -> RoborockBase:
138        """Parse the response from the device into a RoborockValueBase."""
139        if isinstance(response, list):
140            response = response[0]
141        if not isinstance(response, int):
142            raise ValueError(f"Unexpected response format: {response!r}")
143        return super().convert({self._value_field: response})

Parse the response from the device into a RoborockValueBase.

class RoborockSwitchBase(abc.ABC):
146class RoborockSwitchBase(ABC):
147    """Base class for traits that represent a boolean switch."""
148
149    @property
150    @abstractmethod
151    def is_on(self) -> bool:
152        """Return whether the switch is on."""
153
154    @abstractmethod
155    async def enable(self) -> None:
156        """Enable the switch."""
157
158    @abstractmethod
159    async def disable(self) -> None:
160        """Disable the switch."""

Base class for traits that represent a boolean switch.

is_on: bool
149    @property
150    @abstractmethod
151    def is_on(self) -> bool:
152        """Return whether the switch is on."""

Return whether the switch is on.

@abstractmethod
async def enable(self) -> None:
154    @abstractmethod
155    async def enable(self) -> None:
156        """Enable the switch."""

Enable the switch.

@abstractmethod
async def disable(self) -> None:
158    @abstractmethod
159    async def disable(self) -> None:
160        """Disable the switch."""

Disable the switch.

def mqtt_rpc_channel(cls):
163def mqtt_rpc_channel(cls):
164    """Decorator to mark a function as cloud only.
165
166    Normally a trait uses an adaptive rpc channel that can use either local
167    or cloud communication depending on what is available. This will force
168    the trait to always use the cloud rpc channel.
169    """
170
171    def wrapper(*args, **kwargs):
172        return cls(*args, **kwargs)
173
174    cls.mqtt_rpc_channel = True  # type: ignore[attr-defined]
175    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):
178def map_rpc_channel(cls):
179    """Decorator to mark a function as cloud only using the map rpc format."""
180
181    def wrapper(*args, **kwargs):
182        return cls(*args, **kwargs)
183
184    cls.map_rpc_channel = True  # type: ignore[attr-defined]
185    return wrapper

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