Skip to content

feat(q10): add decoded command helper and status trait - #768

Closed
lboue wants to merge 9 commits into
Python-roborock:mainfrom
lboue:feat/q10-status-trait
Closed

feat(q10): add decoded command helper and status trait#768
lboue wants to merge 9 commits into
Python-roborock:mainfrom
lboue:feat/q10-status-trait

Conversation

@lboue

@lbouelboue commented Feb 14, 2026

Copy link
Copy Markdown
Contributor

Summary

This pull request adds status monitoring capabilities to Q10 S5+ devices by introducing:

  • New send_decoded_command() helper function in b01_q10_channel.py that sends MQTT commands and awaits decoded responses with optional datapoint filtering
  • New StatusTrait class in status.py that provides properties to read device status including:
    • state - Device state (charging, cleaning, paused, etc.)
    • battery - Battery percentage
    • fan_level - Current suction power level
    • clean_mode - Current work mode (vacuum, mop, or both)
    • clean_task - Type of cleaning task
    • cleaning_progress - Cleaning progress percentage
    • refresh() - Method to fetch latest status from device

Test plan

  • ruff check passes with no errors
  • All 352 existing tests pass
  • Q10 vacuum trait tests pass (8/8)
  • Manual verification of imports and syntax

Changes

  • Modified: b01_q10_channel.py - Added send_decoded_command() function
  • Created: status.py - New status trait for Q10 devices

#767

@lboue
lboueforce-pushed the feat/q10-status-trait branch from e061ee5 to afd5ce0CompareFebruary 14, 2026 13:05
@lboue
lboue marked this pull request as ready for review February 14, 2026 13:10
CopilotAI review requested due to automatic review settings February 14, 2026 13:10

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds status monitoring capabilities for Q10 S5+ devices by introducing a new helper function for decoded MQTT communication and a trait class for managing device status. The implementation follows established patterns from other B01 device types (Q7) and provides comprehensive test coverage.

Changes:

  • Added send_decoded_command() helper function that sends MQTT commands and awaits decoded responses with optional datapoint filtering
  • Created StatusTrait class that provides properties to read device status (state, battery, fan level, clean mode, clean task, and cleaning progress)
  • Added comprehensive test coverage for both the channel helper and status trait

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

FileDescription
roborock/devices/rpc/b01_q10_channel.pyAdded send_decoded_command() function that handles MQTT request/response flow with timeout and error handling
roborock/devices/traits/b01/q10/status.pyCreated StatusTrait class with properties for accessing device status information and a refresh() method to update from device
tests/devices/rpc/test_b01_q10_channel.pyAdded 9 comprehensive tests for channel functions covering basic operation, filtering, timeouts, and edge cases
tests/devices/traits/b01/q10/test_status.pyAdded 8 tests for StatusTrait covering all properties, empty data, unknown states, and multiple refreshes

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment threadroborock/devices/traits/b01/q10/status.py Outdated
Comment threadroborock/devices/traits/b01/q10/status.py Outdated
Comment threadroborock/devices/traits/b01/q10/status.py Outdated
Comment threadroborock/devices/traits/b01/q10/status.py Outdated
Comment threadroborock/devices/traits/b01/q10/status.py Outdated
@lboue
lboueforce-pushed the feat/q10-status-trait branch from afd5ce0 to 9a989c5CompareFebruary 14, 2026 13:23
Adds the StatusTrait to Q10PropertiesApi to enable querying device status.
The trait was implemented but not exposed in the API class.
This enables Home Assistant integration to call api.status.refresh() to
retrieve Q10 device data (battery, status, fan level, etc.)

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment threadtests/devices/traits/b01/q10/test_status.py Outdated
Add all data points needed by Home Assistant sensors:
- Brush/filter life indicators
- Cleaning statistics (time, area, count, progress)
- Fault status

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +77 to +86
async def subscribe_stream(self) -> AsyncGenerator[RoborockMessage, None]:
"""Subscribe to the device's message stream."""
message_queue: asyncio.Queue[RoborockMessage] = asyncio.Queue()
unsub = await self.subscribe(message_queue.put_nowait)
try:
while True:
message = await message_queue.get()
yield message
finally:
unsub()

CopilotAIFeb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

subscribe_stream() buffers all inbound messages in an unbounded asyncio.Queue(). If the consumer is slower than the producer (or stops iterating without closing), this can grow without bound and increase memory usage. Consider adding a reasonable maxsize plus an overflow strategy (e.g., drop oldest/newest with a debug log), or implement backpressure by awaiting put() in an async callback path.

Copilot uses AI. Check for mistakes.
self._subscribe_task: asyncio.Task[None] | None = None

async def start(self) -> None:
"""Start any necessary subscriptions for the trait."""

CopilotAIFeb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

start() overwrites _subscribe_task unconditionally. If start() is called twice, this will leak the first task and create multiple concurrent subscription loops updating the same status object. Add a guard (no-op if already started) or cancel/await the existing task before creating a new one.

Suggested change
"""Start any necessary subscriptions for the trait."""
"""Start any necessary subscriptions for the trait."""
ifself._subscribe_taskisnotNoneandnotself._subscribe_task.done():
# Subscription loop already running; avoid starting another one.
return

Copilot uses AI. Check for mistakes.
Comment on lines +48 to +55
if self._subscribe_task is not None:
self._subscribe_task.cancel()
try:
await self._subscribe_task
except asyncio.CancelledError:
pass
self._subscribe_task = None

CopilotAIFeb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

close() only suppresses CancelledError. If _subscribe_task has already exited with an exception (e.g., subscribe_stream() raises during disconnect), await self._subscribe_task will re-raise and make Device.close() fail. Consider checking task.done()/task.exception() and suppressing/logging non-cancellation exceptions during shutdown.

Suggested change
ifself._subscribe_taskisnotNone:
self._subscribe_task.cancel()
try:
awaitself._subscribe_task
exceptasyncio.CancelledError:
pass
self._subscribe_task=None
ifself._subscribe_taskisNone:
return
# Take a local reference and clear the attribute early to avoid
# reusing a task that is in the process of shutting down.
task=self._subscribe_task
self._subscribe_task=None
# If the task is still running, request cancellation.
ifnottask.done():
task.cancel()
try:
awaittask
exceptasyncio.CancelledError:
# Expected during normal shutdown when the task is cancelled.
pass
exceptException:
# Suppress unexpected exceptions from the background task during
# shutdown, but log them for debugging purposes.
_LOGGER.exception(
"Error while waiting for subscribe task to finish during close()"
)

Copilot uses AI. Check for mistakes.
Comment on lines 199 to +205
unsub = await self._channel.subscribe(self._on_message)
if self.v1_properties is not None:
try:
try:
if self.v1_properties is not None:
await self.v1_properties.discover_features()
except RoborockException:
unsub()
raise
elif self.b01_q10_properties is not None:
await self.b01_q10_properties.start()
except RoborockException:

CopilotAIFeb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For B01/Q10 devices, connect() subscribes to _on_message (which currently only logs) and then Q10PropertiesApi.start() creates a second subscription via subscribe_stream(). This means every MQTT message will be decoded/dispatched twice, increasing CPU/log noise and complicating future message routing. Consider skipping the _on_message subscription for B01/Q10 (or making _on_message forward into the Q10 stream/decoder so only one subscription exists).

Copilot uses AI. Check for mistakes.
Comment on lines +17 to 39
async def stream_decoded_responses(
mqtt_channel: MqttChannel,
) -> AsyncGenerator[dict[B01_Q10_DP, Any], None]:
"""Stream decoded DPS messages received via MQTT."""

async for response_message in mqtt_channel.subscribe_stream():
try:
decoded_dps = decode_rpc_response(response_message)
except RoborockException as ex:
_LOGGER.debug(
"Failed to decode B01 RPC response: %s: %s",
response_message,
ex,
)
continue
yield decoded_dps


async def send_command(
mqtt_channel: MqttChannel,
command: B01_Q10_DP,
params: ParamsType,
) -> None:

CopilotAIFeb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description mentions adding a send_decoded_command() helper in b01_q10_channel.py that awaits decoded responses with optional DPS filtering, but this file currently only adds stream_decoded_responses() plus send_command(). Either implement the described helper (similar to roborock/devices/rpc/b01_q7_channel.py) or update the PR description/API to match what’s actually being introduced.

Copilot uses AI. Check for mistakes.
@Lash-L

Copy link
Copy Markdown
Collaborator

Now that the other PR is merged, can you make sure this one is up to date so that it's easier to just see the new changes?

@lboue

lboue commented Feb 15, 2026

Copy link
Copy Markdown
ContributorAuthor

Now that the other PR is merged, can you make sure this one is up to date so that it's easier to just see the new changes?

I used the new version 4.14.0 (#769) directly without using my PR to do my tests with Home Assistant today.
I'm not sure if it's still useful.

@allenporter
allenporter marked this pull request as draft February 28, 2026 14:32
@lbouelboue closed this Apr 6, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@lboue@Lash-L