Skip to content

Commit a391c17

Browse files
authored
feat: add a sound volume trait (#477)
* feat: Add volume trait * feat: Add volume trait * chore: remove duplicate api_error from bad merge * chore: fix imports
1 parent 772a829 commit a391c17

File tree

8 files changed

+166
-14
lines changed

8 files changed

+166
-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()
@@ -414,6 +414,46 @@ async def clean_summary(ctx, device_id: str):
414414
click.echo(dump_json(clean_summary_result.as_dict()))
415415

416416

417+
@session.command()
418+
@click.option("--device_id", required=True)
419+
@click.pass_context
420+
@async_command
421+
async def volume(ctx, device_id: str):
422+
"""Get device volume."""
423+
context: RoborockContext = ctx.obj
424+
425+
device_manager = await context.get_device_manager()
426+
device = await device_manager.get_device(device_id)
427+
428+
if not (volume_trait := device.traits.get("sound_volume")):
429+
click.echo(f"Device {device.name} does not have a volume trait")
430+
return
431+
432+
volume_result = await volume_trait.get_volume()
433+
click.echo(f"Device {device_id} volume:")
434+
click.echo(volume_result)
435+
436+
437+
@session.command()
438+
@click.option("--device_id", required=True)
439+
@click.option("--volume", required=True, type=int)
440+
@click.pass_context
441+
@async_command
442+
async def set_volume(ctx, device_id: str, volume: int):
443+
"""Set the devicevolume."""
444+
context: RoborockContext = ctx.obj
445+
446+
device_manager = await context.get_device_manager()
447+
device = await device_manager.get_device(device_id)
448+
449+
if not (volume_trait := device.traits.get("sound_volume")):
450+
click.echo(f"Device {device.name} does not have a volume trait")
451+
return
452+
453+
await volume_trait.set_volume(volume)
454+
click.echo(f"Set Device {device_id} volume to {volume}")
455+
456+
417457
@click.command()
418458
@click.option("--device_id", required=True)
419459
@click.option("--cmd", required=True)
@@ -653,6 +693,8 @@ def write_markdown_table(product_features: dict[str, dict[str, any]], all_featur
653693
cli.add_command(get_device_info)
654694
cli.add_command(update_docs)
655695
cli.add_command(clean_summary)
696+
cli.add_command(volume)
697+
cli.add_command(set_volume)
656698

657699

658700
def main():

roborock/devices/device_manager.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from .traits.clean_summary import CleanSummaryTrait
2626
from .traits.dnd import DoNotDisturbTrait
2727
from .traits.dyad import DyadApi
28+
from .traits.sound_volume import SoundVolumeTrait
2829
from .traits.status import StatusTrait
2930
from .traits.trait import Trait
3031
from .traits.zeo import ZeoApi
@@ -156,6 +157,7 @@ def device_creator(device: HomeDataDevice, product: HomeDataProduct) -> Roborock
156157
traits.append(StatusTrait(product, channel.rpc_channel))
157158
traits.append(DoNotDisturbTrait(channel.rpc_channel))
158159
traits.append(CleanSummaryTrait(channel.rpc_channel))
160+
traits.append(SoundVolumeTrait(channel.rpc_channel))
159161
case DeviceVersion.A01:
160162
mqtt_channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
161163
match product.category:
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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.exceptions import RoborockException
6+
from roborock.roborock_typing import RoborockCommand
7+
8+
__all__ = [
9+
"SoundVolumeTrait",
10+
]
11+
12+
13+
class SoundVolumeTrait(Trait):
14+
"""Trait for controlling the sound volume of a Roborock device."""
15+
16+
name = "sound_volume"
17+
18+
def __init__(self, rpc_channel: V1RpcChannel) -> None:
19+
"""Initialize the SoundVolumeTrait."""
20+
self._rpc_channel = rpc_channel
21+
22+
async def get_volume(self) -> int:
23+
"""Get the current sound volume of the device."""
24+
response = await self._rpc_channel.send_command(RoborockCommand.GET_SOUND_VOLUME)
25+
if not isinstance(response, list) or not response:
26+
raise RoborockException(f"Unexpected volume format: {response!r}")
27+
return int(response[0])
28+
29+
async def set_volume(self, volume: int) -> None:
30+
"""Set the sound volume of the device."""
31+
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
@@ -149,7 +149,10 @@ def find_response(response_message: RoborockMessage) -> None:
149149
return
150150
_LOGGER.debug("Received response (request_id=%s): %s", self._name, decoded.request_id)
151151
if decoded.request_id == request_message.request_id:
152-
future.set_result(decoded.data)
152+
if decoded.api_error:
153+
future.set_exception(decoded.api_error)
154+
else:
155+
future.set_result(decoded.data)
153156

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

roborock/protocols/v1_protocol.py

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,18 @@ class ResponseMessage:
108108
data: ResponseData
109109
"""The data of the response, where the type depends on the command."""
110110

111+
api_error: RoborockException | None = None
112+
"""The API error message of the response if any."""
113+
111114

112115
def decode_rpc_response(message: RoborockMessage) -> ResponseMessage:
113-
"""Decode a V1 RPC_RESPONSE message."""
116+
"""Decode a V1 RPC_RESPONSE message.
117+
118+
This will raise a RoborockException if the message cannot be parsed. A
119+
response object will be returned even if there is an error in the
120+
response, as long as we can extract the request ID. This is so we can
121+
associate an API response with a request even if there was an error.
122+
"""
114123
if not message.payload:
115124
return ResponseMessage(request_id=message.seq, data={})
116125
try:
@@ -136,19 +145,26 @@ def decode_rpc_response(message: RoborockMessage) -> ResponseMessage:
136145
) from e
137146

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

153169

154170
@dataclass

tests/protocols/__snapshots__/test_v1_protocol.ambr

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,13 @@
8484
]
8585
'''
8686
# ---
87+
# name: test_decode_rpc_payload[get_volume]
88+
20001
89+
# ---
90+
# name: test_decode_rpc_payload[get_volume].1
91+
'''
92+
[
93+
90
94+
]
95+
'''
96+
# ---

tests/protocols/test_v1_protocol.py

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

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

0 commit comments

Comments
 (0)