Skip to content

Commit 8761c03

Browse files
committed
feat: Add volume trait
1 parent 5a2ca23 commit 8761c03

File tree

6 files changed

+156
-14
lines changed

6 files changed

+156
-14
lines changed

roborock/cli.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ async def execute_scene(ctx, scene_id):
382382
@click.pass_context
383383
@async_command
384384
async def status(ctx, device_id: str):
385-
"""Get device status - unified implementation for both modes."""
385+
"""Get device status."""
386386
context: RoborockContext = ctx.obj
387387

388388
device_manager = await context.get_device_manager()
@@ -398,6 +398,46 @@ async def status(ctx, device_id: str):
398398
click.echo(dump_json(status_result.as_dict()))
399399

400400

401+
@session.command()
402+
@click.option("--device_id", required=True)
403+
@click.pass_context
404+
@async_command
405+
async def volume(ctx, device_id: str):
406+
"""Get device volume."""
407+
context: RoborockContext = ctx.obj
408+
409+
device_manager = await context.get_device_manager()
410+
device = await device_manager.get_device(device_id)
411+
412+
if not (volume_trait := device.traits.get("sound_volume")):
413+
click.echo(f"Device {device.name} does not have a volume trait")
414+
return
415+
416+
volume_result = await volume_trait.get_volume()
417+
click.echo(f"Device {device_id} volume:")
418+
click.echo(volume_result)
419+
420+
421+
@session.command()
422+
@click.option("--device_id", required=True)
423+
@click.option("--volume", required=True, type=int)
424+
@click.pass_context
425+
@async_command
426+
async def set_volume(ctx, device_id: str, volume: int):
427+
"""Set the devicevolume."""
428+
context: RoborockContext = ctx.obj
429+
430+
device_manager = await context.get_device_manager()
431+
device = await device_manager.get_device(device_id)
432+
433+
if not (volume_trait := device.traits.get("sound_volume")):
434+
click.echo(f"Device {device.name} does not have a volume trait")
435+
return
436+
437+
await volume_trait.set_volume(volume)
438+
click.echo(f"Set Device {device_id} volume to {volume}")
439+
440+
401441
@click.command()
402442
@click.option("--device_id", required=True)
403443
@click.option("--cmd", required=True)
@@ -636,6 +676,8 @@ def write_markdown_table(product_features: dict[str, dict[str, any]], all_featur
636676
cli.add_command(session)
637677
cli.add_command(get_device_info)
638678
cli.add_command(update_docs)
679+
cli.add_command(volume)
680+
cli.add_command(set_volume)
639681

640682

641683
def main():

roborock/devices/device_manager.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from .traits.b01.props import B01PropsApi
2525
from .traits.dnd import DoNotDisturbTrait
2626
from .traits.dyad import DyadApi
27+
from .traits.sound_volume import SoundVolumeTrait
2728
from .traits.status import StatusTrait
2829
from .traits.trait import Trait
2930
from .traits.zeo import ZeoApi
@@ -154,6 +155,7 @@ def device_creator(device: HomeDataDevice, product: HomeDataProduct) -> Roborock
154155
channel = create_v1_channel(user_data, mqtt_params, mqtt_session, device, cache)
155156
traits.append(StatusTrait(product, channel.rpc_channel))
156157
traits.append(DoNotDisturbTrait(channel.rpc_channel))
158+
traits.append(SoundVolumeTrait(channel.rpc_channel))
157159
case DeviceVersion.A01:
158160
mqtt_channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
159161
match product.category:
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Module for controlling the sound volume of Roborock devices."""
2+
3+
from roborock.devices.traits.trait import Trait
4+
from roborock.devices.v1_rpc_channel import V1RpcChannel
5+
from roborock.roborock_typing import RoborockCommand
6+
7+
__all__ = [
8+
"SoundVolumeTrait",
9+
]
10+
11+
12+
class SoundVolumeTrait(Trait):
13+
"""Trait for controlling the sound volume of a Roborock device."""
14+
15+
name = "sound_volume"
16+
17+
def __init__(self, rpc_channel: V1RpcChannel) -> None:
18+
"""Initialize the SoundVolumeTrait."""
19+
self._rpc_channel = rpc_channel
20+
21+
async def get_volume(self) -> int:
22+
"""Get the current sound volume of the device."""
23+
response = await self._rpc_channel.send_command(RoborockCommand.GET_SOUND_VOLUME)
24+
if isinstance(response, list) and response:
25+
return int(response[0])
26+
return int(response)
27+
28+
async def set_volume(self, volume: int) -> None:
29+
"""Set the sound volume of the device."""
30+
await self._rpc_channel.send_command(RoborockCommand.CHANGE_SOUND_VOLUME, params=[volume])

roborock/devices/v1_rpc_channel.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,10 @@ def find_response(response_message: RoborockMessage) -> None:
148148
return
149149
_LOGGER.debug("Received response (request_id=%s): %s", self._name, decoded.request_id)
150150
if decoded.request_id == request_message.request_id:
151-
future.set_result(decoded.data)
151+
if decoded.api_error:
152+
future.set_exception(decoded.api_error)
153+
else:
154+
future.set_result(decoded.data)
152155

153156
unsub = await self._channel.subscribe(find_response)
154157
try:

roborock/protocols/v1_protocol.py

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,18 @@ class ResponseMessage:
105105
data: dict[str, Any]
106106
"""The data of the response."""
107107

108+
api_error: RoborockException | None = None
109+
"""The API error message of the response if any."""
110+
108111

109112
def decode_rpc_response(message: RoborockMessage) -> ResponseMessage:
110-
"""Decode a V1 RPC_RESPONSE message."""
113+
"""Decode a V1 RPC_RESPONSE message.
114+
115+
This will raise a RoborockException if the message cannot be parsed. A
116+
response object will be returned even if there is an error in the
117+
response, as long as we can extract the request ID. This is so we can
118+
associate an API response with a request even if there was an error.
119+
"""
111120
if not message.payload:
112121
return ResponseMessage(request_id=message.seq, data={})
113122
try:
@@ -133,19 +142,28 @@ def decode_rpc_response(message: RoborockMessage) -> ResponseMessage:
133142
) from e
134143

135144
request_id: int | None = data_point_response.get("id")
145+
exc: RoborockException | None = None
136146
if error := data_point_response.get("error"):
137-
raise RoborockException(f"Error in message: {error}")
138-
147+
exc = RoborockException(error)
139148
if not (result := data_point_response.get("result")):
140-
raise RoborockException(f"Invalid V1 message format: missing 'result' in data point for {message.payload!r}")
141-
_LOGGER.debug("Decoded V1 message result: %s", result)
142-
if isinstance(result, list) and result:
143-
result = result[0]
144-
if isinstance(result, str) and result == "ok":
145-
result = {}
146-
if not isinstance(result, dict):
147-
raise RoborockException(f"Invalid V1 message format: 'result' should be a dictionary for {message.payload!r}")
148-
return ResponseMessage(request_id=request_id, data=result)
149+
exc = RoborockException(f"Invalid V1 message format: missing 'result' in data point for {message.payload!r}")
150+
else:
151+
_LOGGER.debug("Decoded V1 message result: %s", result)
152+
if isinstance(result, list) and result:
153+
result = result[0]
154+
if isinstance(result, str):
155+
if result == "unknown_method":
156+
exc = RoborockException("The method called is not recognized by the device.")
157+
elif result != "ok":
158+
exc = RoborockException(f"Unexpected API Result: {result}")
159+
result = {}
160+
if not isinstance(result, (dict, list, int)):
161+
exc = RoborockException(
162+
f"Invalid V1 message format: 'result' should be a dictionary for {message.payload!r}"
163+
)
164+
if not request_id and exc:
165+
raise exc
166+
return ResponseMessage(request_id=request_id, data=result, api_error=exc)
149167

150168

151169
@dataclass

tests/protocols/test_v1_protocol.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,50 @@ def test_create_map_response_decoder_invalid_payload():
225225

226226
with pytest.raises(RoborockException, match="Invalid V1 map response format: missing payload"):
227227
decoder(message)
228+
229+
230+
@pytest.mark.parametrize(
231+
("payload", "expected_data", "expected_error"),
232+
[
233+
(
234+
b'{"t":1757883536,"dps":{"102":"{\\"id\\":20001,\\"result\\":\\"unknown_method\\"}"}}',
235+
{},
236+
"The method called is not recognized by the device.",
237+
),
238+
(
239+
b'{"t":1757883536,"dps":{"102":"{\\"id\\":20001,\\"result\\":\\"other\\"}"}}',
240+
{},
241+
"Unexpected API Result",
242+
),
243+
],
244+
)
245+
def test_decode_result_with_error(payload: bytes, expected_data: dict[str, str], expected_error: str) -> None:
246+
"""Test decoding a v1 RPC response protocol message."""
247+
# The values other than the payload are arbitrary
248+
message = RoborockMessage(
249+
protocol=RoborockMessageProtocol.GENERAL_RESPONSE,
250+
payload=payload,
251+
seq=12750,
252+
version=b"1.0",
253+
random=97431,
254+
timestamp=1652547161,
255+
)
256+
decoded_message = decode_rpc_response(message)
257+
assert decoded_message.request_id == 20001
258+
assert decoded_message.data == expected_data
259+
assert decoded_message.api_error
260+
assert expected_error in str(decoded_message.api_error)
261+
262+
263+
def test_decode_no_request_id():
264+
"""Test map response decoder without a request id is raised as an exception."""
265+
message = RoborockMessage(
266+
protocol=RoborockMessageProtocol.GENERAL_RESPONSE,
267+
payload=b'{"t":1757883536,"dps":{"102":"{\\"result\\":\\"unknown_method\\"}"}}',
268+
seq=12750,
269+
version=b"1.0",
270+
random=97431,
271+
timestamp=1652547161,
272+
)
273+
with pytest.raises(RoborockException, match="The method called is not recognized by the device"):
274+
decode_rpc_response(message)

0 commit comments

Comments
 (0)