Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ repos:
hooks:
- id: mypy
exclude: cli.py
additional_dependencies: [ "types-paho-mqtt" ]
additional_dependencies: [ "types-paho-mqtt", "types-pyyaml" ]
- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook
rev: v9.23.0
hooks:
Expand Down
27 changes: 17 additions & 10 deletions roborock/data/v1/v1_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,10 @@ class StatusField(FieldNameBase):
to understand if a feature is supported by the device using `is_field_supported`.

The enum values are names of fields in the `Status` class. Each field is
annotated with `requires_schema_code` metadata to map the field to a schema
code in the product schema, which may have a different name than the field/attribute name.
annotated with one of the following:
- `requires_schema_code` metadata to map the field to a schema code in the
product schema, which may have a different name than the field/attribute name.
- `requires_supported_feature` metadata to map the field to a field in `DeviceFeatures`.
"""

STATE = "state"
Expand All @@ -113,20 +115,25 @@ class StatusField(FieldNameBase):
WATER_BOX_MODE = "water_box_mode"
CHARGE_STATUS = "charge_status"
DRY_STATUS = "dry_status"
CLEAN_PERCENT = "clean_percent"


def _requires_schema_code(requires_schema_code: str, default=None) -> Any:
def _requires_schema_code(requires_schema_code: str, default: Any = None) -> Any:
return field(metadata={"requires_schema_code": requires_schema_code}, default=default)


def _requires_supported_feature(requires_supported_feature: str, default: Any = None) -> Any:
return field(metadata={"requires_supported_feature": requires_supported_feature}, default=default)


@dataclass
class Status(RoborockBase):
"""This status will be deprecated in favor of StatusV2."""

msg_ver: int | None = None
msg_seq: int | None = None
state: RoborockStateCode | None = _requires_schema_code("state", default=None)
battery: int | None = _requires_schema_code("battery", default=None)
state: RoborockStateCode | None = _requires_schema_code("state")
battery: int | None = _requires_schema_code("battery")
clean_time: int | None = None
clean_area: int | None = None
error_code: RoborockErrorCode | None = None
Expand All @@ -139,12 +146,12 @@ class Status(RoborockBase):
back_type: int | None = None
wash_phase: int | None = None
wash_ready: int | None = None
fan_power: RoborockFanPowerCode | None = _requires_schema_code("fan_power", default=None)
fan_power: RoborockFanPowerCode | None = _requires_schema_code("fan_power")
dnd_enabled: int | None = None
map_status: int | None = None
is_locating: int | None = None
lock_status: int | None = None
water_box_mode: RoborockMopIntensityCode | None = _requires_schema_code("water_box_mode", default=None)
water_box_mode: RoborockMopIntensityCode | None = _requires_schema_code("water_box_mode")
water_box_carriage_status: int | None = None
mop_forbidden_enable: int | None = None
camera_status: int | None = None
Expand All @@ -162,15 +169,15 @@ class Status(RoborockBase):
collision_avoid_status: int | None = None
switch_map_mode: int | None = None
dock_error_status: RoborockDockErrorCode | None = None
charge_status: int | None = _requires_schema_code("charge_status", default=None)
charge_status: int | None = _requires_schema_code("charge_status")
unsave_map_reason: int | None = None
unsave_map_flag: int | None = None
wash_status: int | None = None
distance_off: int | None = None
in_warmup: int | None = None
dry_status: int | None = _requires_schema_code("drying_status", default=None)
dry_status: int | None = _requires_schema_code("drying_status")
rdt: int | None = None
clean_percent: int | None = None
clean_percent: int | None = _requires_supported_feature("is_support_clean_estimate")
rss: int | None = None
Comment on lines 176 to 181
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

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

requires_supported_feature metadata is added to the deprecated Status dataclass, but StatusTrait (used in tests and the V1 status implementation) inherits StatusV2, where clean_percent still has no metadata. As a result, DeviceFeaturesTrait.is_field_supported(StatusTrait, StatusField.CLEAN_PERCENT) will continue to return True unconditionally, so the new feature-based support check isn’t actually applied. The metadata (or an equivalent mechanism) likely needs to live on StatusV2 (or whatever dataclass is passed to is_field_supported) for this to work as intended.

Copilot uses AI. Check for mistakes.
dss: int | None = None
common_status: int | None = None
Expand Down
6 changes: 2 additions & 4 deletions roborock/device_features.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
from __future__ import annotations

from dataclasses import dataclass, field, fields
from enum import IntEnum, StrEnum
from typing import Any
from typing import Any, Self

from roborock.data.code_mappings import RoborockProductNickname
from roborock.data.containers import RoborockBase
Expand Down Expand Up @@ -566,7 +564,7 @@ def from_feature_flags(
new_feature_info_str: str,
feature_info: list[int],
product_nickname: RoborockProductNickname | None,
) -> DeviceFeatures:
) -> Self:
"""Creates a DeviceFeatures instance from raw feature flags.
:param new_feature_info: A int from get_init_status (sometimes can be found in homedata, but it is not always)
:param new_feature_info_str: A hex string from get_init_status or home_data.
Expand Down
16 changes: 9 additions & 7 deletions roborock/devices/traits/v1/device_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,11 @@ def __init__(self, product: HomeDataProduct, device_cache: DeviceCache) -> None:
self.converter = DeviceTraitsConverter(product)
self._product = product
self._device_cache = device_cache
# All fields of DeviceFeatures are required. Initialize them to False
# All boolean fields of DeviceFeatures are required. Initialize them to False
# so we have some known state.
for field in fields(self):
setattr(self, field.name, False)
if field.type is bool:
setattr(self, field.name, False)

def is_field_supported(self, cls: type[RoborockBase], field_name: FieldNameBase) -> bool:
"""Determines if the specified field is supported by this device.
Expand All @@ -61,11 +62,12 @@ def is_field_supported(self, cls: type[RoborockBase], field_name: FieldNameBase)
raise ValueError(f"Field {field_name} not found in {cls}")

requires_schema_code = dataclass_field.metadata.get("requires_schema_code", None)
if requires_schema_code is None:
# We assume the field is supported
return True
# If the field requires a protocol that is not supported, we return False
return requires_schema_code in self._product.supported_schema_codes
if requires_schema_code is not None:
return requires_schema_code in self._product.supported_schema_codes
requires_supported_feature = dataclass_field.metadata.get("requires_supported_feature", None)
if requires_supported_feature is not None:
return getattr(self, requires_supported_feature)
return True

async def refresh(self) -> None:
"""Refresh the contents of this trait.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
dict({
'battery': True,
'charge_status': True,
'clean_percent': True,
'dry_status': True,
'fan_power': True,
'state': True,
Expand All @@ -13,6 +14,7 @@
dict({
'battery': True,
'charge_status': True,
'clean_percent': True,
'dry_status': True,
'fan_power': True,
'state': True,
Expand All @@ -23,6 +25,7 @@
dict({
'battery': True,
'charge_status': True,
'clean_percent': True,
'dry_status': True,
'fan_power': True,
'state': True,
Expand Down
21 changes: 20 additions & 1 deletion tests/devices/traits/v1/fixtures.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Fixtures for V1 trait tests."""

from copy import deepcopy
from typing import Any
from unittest.mock import AsyncMock

import pytest
Expand Down Expand Up @@ -107,16 +108,34 @@ def dock_type_code_fixture(request: pytest.FixtureRequest) -> RoborockDockTypeCo
return RoborockDockTypeCode.s7_max_ultra_dock


@pytest.fixture(name="mock_app_get_init_status")
def mock_app_get_init_status_fixture(device_info: HomeDataDevice, products: list[HomeDataProduct]) -> dict[str, Any]:
"""Fixture to provide model-specific APP_GET_INIT_STATUS data.

Uses real device feature data from device_info.yaml when available for the
product model, falling back to the default mock data otherwise.
"""
product = next(filter(lambda product: product.id == device_info.product_id, products))
if not product:
Comment thread
allenporter marked this conversation as resolved.
Outdated
raise ValueError(f"Product {device_info.product_id} not found")
device_info_data = mock_data.DEVICE_INFO.get(product.model, {})
return {
**mock_data.APP_GET_INIT_STATUS,
**device_info_data,
}


@pytest.fixture(autouse=True)
async def discover_features_fixture(
device: RoborockDevice,
mock_rpc_channel: AsyncMock,
mock_app_get_init_status: dict[str, Any],
dock_type_code: RoborockDockTypeCode | None,
) -> None:
"""Fixture to handle device feature discovery."""
assert device.v1_properties
mock_rpc_channel.send_command.side_effect = [
[mock_data.APP_GET_INIT_STATUS],
[mock_app_get_init_status],
{
**mock_data.STATUS,
"dock_type": dock_type_code,
Expand Down
14 changes: 14 additions & 0 deletions tests/mock_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import pathlib
from typing import Any

import yaml

# All data is based on a U.S. customer with a Roborock S7 MaxV Ultra
USER_EMAIL = "user@domain.com"

Expand Down Expand Up @@ -141,6 +143,18 @@
ZEO_ONE_DEVICE_DATA = DEVICES["home_data_device_zeo_one.json"]
SAROS_10R_DEVICE_DATA = DEVICES["home_data_device_saros_10r.json"]

# Additional Device Features info from YAML keyed by product model.
# Each entry contains the fields needed for APP_GET_INIT_STATUS responses.
_DEVICE_INFO_DATA = yaml.safe_load(pathlib.Path("device_info.yaml").read_text())
DEVICE_INFO: dict[str, dict[str, Any]] = {
product_model: {
"new_feature_info": data.get("new_feature_info"),
"new_feature_info_str": data.get("new_feature_info_str"),
"feature_info": data.get("feature_info", []),
}
for product_model, data in _DEVICE_INFO_DATA.items()
}
Comment thread
allenporter marked this conversation as resolved.


HOME_DATA_RAW: dict[str, Any] = {
"id": 123456,
Expand Down
Loading