Skip to content

Commit 8a03863

Browse files
committed
fix: Address unresolved code review comments on PR #759
- Add type annotations to Q10 command parameters (ctx: click.Context, device_id: str) - Add return type annotations (-> None, -> VacuumTrait) for all Q10 commands - Change Q10 commands from @click.command() to @session.command() for session mode availability - Add VacuumTrait import for proper type hints - Create comprehensive StatusTrait tests (5 tests covering refresh, properties, enums) - Create comprehensive send_decoded_command tests (6 tests covering success, filtering, timeout, errors) Resolves all 6 unresolved comments from code review
1 parent 5e82e72 commit 8a03863

File tree

3 files changed

+352
-11
lines changed

3 files changed

+352
-11
lines changed

roborock/cli.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
from roborock.devices.device import RoborockDevice
5151
from roborock.devices.device_manager import DeviceManager, UserParams, create_device_manager
5252
from roborock.devices.traits import Trait
53+
from roborock.devices.traits.b01.q10.vacuum import VacuumTrait
5354
from roborock.devices.traits.v1 import V1TraitMixin
5455
from roborock.devices.traits.v1.consumeable import ConsumableAttribute
5556
from roborock.devices.traits.v1.map_content import MapContentTrait
@@ -438,7 +439,7 @@ async def _display_v1_trait(context: RoborockContext, device_id: str, display_fu
438439
click.echo(dump_json(trait.as_dict()))
439440

440441

441-
async def _q10_vacuum_trait(context: RoborockContext, device_id: str):
442+
async def _q10_vacuum_trait(context: RoborockContext, device_id: str) -> VacuumTrait:
442443
"""Get VacuumTrait from Q10 device."""
443444
device_manager = await context.get_device_manager()
444445
device = await device_manager.get_device(device_id)
@@ -1150,11 +1151,11 @@ def write_markdown_table(product_features: dict[str, dict[str, any]], all_featur
11501151
click.echo("Done.")
11511152

11521153

1153-
@click.command()
1154+
@session.command()
11541155
@click.option("--device_id", required=True, help="Device ID")
11551156
@click.pass_context
11561157
@async_command
1157-
async def q10_vacuum_start(ctx, device_id):
1158+
async def q10_vacuum_start(ctx: click.Context, device_id: str) -> None:
11581159
"""Start vacuum cleaning on Q10 device."""
11591160
context: RoborockContext = ctx.obj
11601161
try:
@@ -1167,11 +1168,11 @@ async def q10_vacuum_start(ctx, device_id):
11671168
click.echo(f"Error: {e}")
11681169

11691170

1170-
@click.command()
1171+
@session.command()
11711172
@click.option("--device_id", required=True, help="Device ID")
11721173
@click.pass_context
11731174
@async_command
1174-
async def q10_vacuum_pause(ctx, device_id):
1175+
async def q10_vacuum_pause(ctx: click.Context, device_id: str) -> None:
11751176
"""Pause vacuum cleaning on Q10 device."""
11761177
context: RoborockContext = ctx.obj
11771178
try:
@@ -1184,11 +1185,11 @@ async def q10_vacuum_pause(ctx, device_id):
11841185
click.echo(f"Error: {e}")
11851186

11861187

1187-
@click.command()
1188+
@session.command()
11881189
@click.option("--device_id", required=True, help="Device ID")
11891190
@click.pass_context
11901191
@async_command
1191-
async def q10_vacuum_resume(ctx, device_id):
1192+
async def q10_vacuum_resume(ctx: click.Context, device_id: str) -> None:
11921193
"""Resume vacuum cleaning on Q10 device."""
11931194
context: RoborockContext = ctx.obj
11941195
try:
@@ -1201,11 +1202,11 @@ async def q10_vacuum_resume(ctx, device_id):
12011202
click.echo(f"Error: {e}")
12021203

12031204

1204-
@click.command()
1205+
@session.command()
12051206
@click.option("--device_id", required=True, help="Device ID")
12061207
@click.pass_context
12071208
@async_command
1208-
async def q10_vacuum_stop(ctx, device_id):
1209+
async def q10_vacuum_stop(ctx: click.Context, device_id: str) -> None:
12091210
"""Stop vacuum cleaning on Q10 device."""
12101211
context: RoborockContext = ctx.obj
12111212
try:
@@ -1218,11 +1219,11 @@ async def q10_vacuum_stop(ctx, device_id):
12181219
click.echo(f"Error: {e}")
12191220

12201221

1221-
@click.command()
1222+
@session.command()
12221223
@click.option("--device_id", required=True, help="Device ID")
12231224
@click.pass_context
12241225
@async_command
1225-
async def q10_vacuum_dock(ctx, device_id):
1226+
async def q10_vacuum_dock(ctx: click.Context, device_id: str) -> None:
12261227
"""Return vacuum to dock on Q10 device."""
12271228
context: RoborockContext = ctx.obj
12281229
try:
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
"""Tests for the b01_q10_channel."""
2+
3+
import asyncio
4+
from typing import Any
5+
from unittest.mock import AsyncMock, patch
6+
7+
import pytest
8+
9+
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
10+
from roborock.devices.rpc.b01_q10_channel import send_decoded_command
11+
from roborock.exceptions import RoborockException
12+
from roborock.protocols.b01_q10_protocol import encode_mqtt_payload
13+
from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol
14+
from tests.fixtures.channel_fixtures import FakeChannel
15+
16+
17+
@pytest.fixture
18+
def mock_mqtt_channel() -> FakeChannel:
19+
"""Fixture for a fake MQTT channel."""
20+
return FakeChannel()
21+
22+
23+
async def test_send_decoded_command_success(mock_mqtt_channel: FakeChannel):
24+
"""Test successful command sending and response decoding."""
25+
# Prepare response data
26+
response_data = {
27+
B01_Q10_DP.STATUS: 1, # sleepstate
28+
B01_Q10_DP.BATTERY: 91,
29+
}
30+
31+
# Encode response message
32+
encoded = encode_mqtt_payload(B01_Q10_DP.STATUS, {})
33+
response_message = RoborockMessage(
34+
protocol=RoborockMessageProtocol.RPC_RESPONSE,
35+
payload=encoded.payload,
36+
version=encoded.version,
37+
)
38+
39+
# Mock the decode_rpc_response to return test data
40+
with patch("roborock.devices.rpc.b01_q10_channel.decode_rpc_response") as mock_decode:
41+
mock_decode.return_value = response_data
42+
mock_mqtt_channel.response_queue.append(response_message)
43+
44+
# Call the function
45+
result = await send_decoded_command(
46+
mock_mqtt_channel,
47+
B01_Q10_DP.REQUETDPS,
48+
{},
49+
expected_dps={B01_Q10_DP.STATUS, B01_Q10_DP.BATTERY},
50+
)
51+
52+
# Assertions
53+
assert result == response_data
54+
mock_mqtt_channel.publish.assert_awaited_once()
55+
mock_mqtt_channel.subscribe.assert_awaited_once()
56+
57+
58+
async def test_send_decoded_command_filters_by_expected_dps(mock_mqtt_channel: FakeChannel):
59+
"""Test that responses are filtered by expected_dps."""
60+
# First response doesn't match expected_dps
61+
non_matching_data = {B01_Q10_DP.CLEANING_PROGRESS: 50}
62+
63+
# Second response matches
64+
matching_data = {B01_Q10_DP.STATUS: 1, B01_Q10_DP.BATTERY: 91}
65+
66+
encoded1 = encode_mqtt_payload(B01_Q10_DP.CLEANING_PROGRESS, {})
67+
response1 = RoborockMessage(
68+
protocol=RoborockMessageProtocol.RPC_RESPONSE,
69+
payload=encoded1.payload,
70+
version=encoded1.version,
71+
)
72+
73+
encoded2 = encode_mqtt_payload(B01_Q10_DP.STATUS, {})
74+
response2 = RoborockMessage(
75+
protocol=RoborockMessageProtocol.RPC_RESPONSE,
76+
payload=encoded2.payload,
77+
version=encoded2.version,
78+
)
79+
80+
with patch("roborock.devices.rpc.b01_q10_channel.decode_rpc_response") as mock_decode:
81+
mock_decode.side_effect = [non_matching_data, matching_data]
82+
83+
# Add both responses to queue
84+
mock_mqtt_channel.response_queue.extend([response1, response2])
85+
86+
# Call the function with expected_dps
87+
result = await send_decoded_command(
88+
mock_mqtt_channel,
89+
B01_Q10_DP.REQUETDPS,
90+
{},
91+
expected_dps={B01_Q10_DP.STATUS, B01_Q10_DP.BATTERY},
92+
)
93+
94+
# Should get the matching response, not the first one
95+
assert result == matching_data
96+
97+
98+
async def test_send_decoded_command_timeout():
99+
"""Test that command times out if no matching response."""
100+
mock_mqtt_channel = FakeChannel()
101+
102+
with patch("roborock.devices.rpc.b01_q10_channel.decode_rpc_response") as mock_decode:
103+
mock_decode.return_value = {B01_Q10_DP.CLEANING_PROGRESS: 50}
104+
105+
# Don't add any responses to queue
106+
with pytest.raises(RoborockException, match="timed out"):
107+
await send_decoded_command(
108+
mock_mqtt_channel,
109+
B01_Q10_DP.REQUETDPS,
110+
{},
111+
expected_dps={B01_Q10_DP.STATUS}, # Won't match CLEANING_PROGRESS
112+
)
113+
114+
115+
async def test_send_decoded_command_ignores_decode_errors(mock_mqtt_channel: FakeChannel):
116+
"""Test that decode errors are logged but don't fail the command."""
117+
# First response has decode error, second is valid
118+
valid_data = {B01_Q10_DP.STATUS: 1}
119+
120+
encoded1 = encode_mqtt_payload(B01_Q10_DP.STATUS, {})
121+
response1 = RoborockMessage(
122+
protocol=RoborockMessageProtocol.RPC_RESPONSE,
123+
payload=encoded1.payload,
124+
version=encoded1.version,
125+
)
126+
127+
encoded2 = encode_mqtt_payload(B01_Q10_DP.STATUS, {})
128+
response2 = RoborockMessage(
129+
protocol=RoborockMessageProtocol.RPC_RESPONSE,
130+
payload=encoded2.payload,
131+
version=encoded2.version,
132+
)
133+
134+
with patch("roborock.devices.rpc.b01_q10_channel.decode_rpc_response") as mock_decode:
135+
# First call raises, second returns valid data
136+
mock_decode.side_effect = [
137+
RoborockException("Decode error"),
138+
valid_data,
139+
]
140+
141+
mock_mqtt_channel.response_queue.extend([response1, response2])
142+
143+
# Command should still succeed with second response
144+
result = await send_decoded_command(
145+
mock_mqtt_channel,
146+
B01_Q10_DP.REQUETDPS,
147+
{},
148+
expected_dps={B01_Q10_DP.STATUS},
149+
)
150+
151+
assert result == valid_data
152+
153+
154+
async def test_send_decoded_command_no_expected_dps_filter():
155+
"""Test that without expected_dps, any decoded response is accepted."""
156+
mock_mqtt_channel = FakeChannel()
157+
158+
response_data = {B01_Q10_DP.CLEANING_PROGRESS: 50}
159+
160+
encoded = encode_mqtt_payload(B01_Q10_DP.CLEANING_PROGRESS, {})
161+
response = RoborockMessage(
162+
protocol=RoborockMessageProtocol.RPC_RESPONSE,
163+
payload=encoded.payload,
164+
version=encoded.version,
165+
)
166+
167+
with patch("roborock.devices.rpc.b01_q10_channel.decode_rpc_response") as mock_decode:
168+
mock_decode.return_value = response_data
169+
mock_mqtt_channel.response_queue.append(response)
170+
171+
# Call without expected_dps
172+
result = await send_decoded_command(
173+
mock_mqtt_channel,
174+
B01_Q10_DP.REQUETDPS,
175+
{},
176+
)
177+
178+
assert result == response_data
179+
180+
181+
async def test_send_decoded_command_publishes_message(mock_mqtt_channel: FakeChannel):
182+
"""Test that the command is properly published."""
183+
response_data = {B01_Q10_DP.STATUS: 1}
184+
185+
encoded = encode_mqtt_payload(B01_Q10_DP.STATUS, {})
186+
response = RoborockMessage(
187+
protocol=RoborockMessageProtocol.RPC_RESPONSE,
188+
payload=encoded.payload,
189+
version=encoded.version,
190+
)
191+
192+
with patch("roborock.devices.rpc.b01_q10_channel.decode_rpc_response") as mock_decode:
193+
mock_decode.return_value = response_data
194+
mock_mqtt_channel.response_queue.append(response)
195+
196+
await send_decoded_command(
197+
mock_mqtt_channel,
198+
B01_Q10_DP.REQUETDPS,
199+
{},
200+
)
201+
202+
# Verify message was published
203+
assert len(mock_mqtt_channel.published_messages) == 1

0 commit comments

Comments
 (0)