diff --git a/roborock/testing/__init__.py b/roborock/testing/__init__.py index 12e86e56..e7a3bcdb 100644 --- a/roborock/testing/__init__.py +++ b/roborock/testing/__init__.py @@ -63,6 +63,17 @@ async def test_start_vacuum_service(): ``` """ +from roborock.testing.a01_simulator import ( + DEFAULT_DYAD_DEVICE_INFO, + DEFAULT_DYAD_PRODUCT, + DEFAULT_DYAD_STATUS, + DEFAULT_ZEO_DEVICE_INFO, + DEFAULT_ZEO_PRODUCT, + DEFAULT_ZEO_STATUS, + A01DeviceSimulator, + DyadSimulator, + ZeoSimulator, +) from roborock.testing.b01_q10_simulator import ( DEFAULT_Q10_STATUS, Q10VacuumSimulator, @@ -91,6 +102,9 @@ async def test_start_vacuum_service(): "DEFAULT_CLEAN_SUMMARY", "DEFAULT_CONSUMABLE", "DEFAULT_DND_TIMER", + "DEFAULT_DYAD_DEVICE_INFO", + "DEFAULT_DYAD_PRODUCT", + "DEFAULT_DYAD_STATUS", "DEFAULT_KEY_T", "DEFAULT_LAST_CLEAN_RECORD", "DEFAULT_LOCAL_KEY", @@ -98,10 +112,16 @@ async def test_start_vacuum_service(): "DEFAULT_PRODUCT_ID", "DEFAULT_Q10_STATUS", "DEFAULT_STATUS", + "DEFAULT_ZEO_DEVICE_INFO", + "DEFAULT_ZEO_PRODUCT", + "DEFAULT_ZEO_STATUS", + "A01DeviceSimulator", + "DyadSimulator", "FakeChannel", "FakeRoborockCloud", "FakeWebApiClient", "Q10VacuumSimulator", "RoborockDeviceSimulator", "V1VacuumSimulator", + "ZeoSimulator", ] diff --git a/roborock/testing/a01_simulator.py b/roborock/testing/a01_simulator.py new file mode 100644 index 00000000..472d440c --- /dev/null +++ b/roborock/testing/a01_simulator.py @@ -0,0 +1,287 @@ +"""Stateful simulator for Roborock A01 (Dyad and Zeo) devices.""" + +import copy +import enum +import json +import logging +from dataclasses import replace +from typing import Any + +from roborock.data import HomeDataDevice, HomeDataProduct, RoborockCategory +from roborock.data.dyad.dyad_code_mappings import ( + DyadCleanMode, + DyadError, + DyadSuction, + DyadWaterLevel, + RoborockDyadStateCode, +) +from roborock.data.zeo.zeo_code_mappings import ( + ZeoDetergentType, + ZeoDryingMode, + ZeoError, + ZeoMode, + ZeoProgram, + ZeoRinse, + ZeoSoftenerType, + ZeoSpin, + ZeoState, + ZeoTemperature, +) +from roborock.exceptions import RoborockException +from roborock.protocols.a01_protocol import ( + decode_rpc_response, + encode_mqtt_payload, +) +from roborock.roborock_message import ( + RoborockDyadDataProtocol, + RoborockMessage, + RoborockMessageProtocol, + RoborockZeoProtocol, +) +from roborock.testing.simulator import DEFAULT_LOCAL_KEY, RoborockDeviceSimulator + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_DYAD_PRODUCT_ID = "product-id-dyad" +DEFAULT_ZEO_PRODUCT_ID = "product-id-zeo" + +DEFAULT_DYAD_PRODUCT = HomeDataProduct( + id=DEFAULT_DYAD_PRODUCT_ID, + name="Roborock Dyad", + model="roborock.wetdryvac.a01", + category=RoborockCategory.WET_DRY_VAC, +) + +DEFAULT_DYAD_DEVICE_INFO = HomeDataDevice( + duid="fake_dyad_duid", + name="Roborock Dyad", + local_key=DEFAULT_LOCAL_KEY, + product_id=DEFAULT_DYAD_PRODUCT_ID, + sn="fake_dyad_sn", + pv="A01", +) + +DEFAULT_ZEO_PRODUCT = HomeDataProduct( + id=DEFAULT_ZEO_PRODUCT_ID, + name="Roborock Zeo One", + model="roborock.washer.a01", + category=RoborockCategory.WASHING_MACHINE, +) + +DEFAULT_ZEO_DEVICE_INFO = HomeDataDevice( + duid="fake_zeo_duid", + name="Roborock Zeo One", + local_key=DEFAULT_LOCAL_KEY, + product_id=DEFAULT_ZEO_PRODUCT_ID, + sn="fake_zeo_sn", + pv="A01", +) + +# Dyad protocol default values +DEFAULT_DYAD_STATUS: dict[enum.Enum, Any] = { + RoborockDyadDataProtocol.STATUS: RoborockDyadStateCode.charging, + RoborockDyadDataProtocol.POWER: 100, + RoborockDyadDataProtocol.SUCTION: DyadSuction.l1, + RoborockDyadDataProtocol.WATER_LEVEL: DyadWaterLevel.l1, + RoborockDyadDataProtocol.CLEAN_MODE: DyadCleanMode.auto, + RoborockDyadDataProtocol.AUTO_DRY: 1, + RoborockDyadDataProtocol.VOLUME_SET: 80, + RoborockDyadDataProtocol.ERROR: DyadError.none, + RoborockDyadDataProtocol.TOTAL_RUN_TIME: 120, + RoborockDyadDataProtocol.SILENT_MODE: 0, +} + +# Zeo protocol default values +DEFAULT_ZEO_STATUS: dict[enum.Enum, Any] = { + RoborockZeoProtocol.STATE: ZeoState.standby, + RoborockZeoProtocol.MODE: ZeoMode.wash_and_dry, + RoborockZeoProtocol.PROGRAM: ZeoProgram.standard, + RoborockZeoProtocol.TEMP: ZeoTemperature.medium, + RoborockZeoProtocol.RINSE_TIMES: ZeoRinse.low, + RoborockZeoProtocol.SPIN_LEVEL: ZeoSpin.high, + RoborockZeoProtocol.DRYING_MODE: ZeoDryingMode.store, + RoborockZeoProtocol.DETERGENT_EMPTY: False, + RoborockZeoProtocol.SOFTENER_EMPTY: False, + RoborockZeoProtocol.SOUND_SET: True, + RoborockZeoProtocol.ERROR: ZeoError.none, + RoborockZeoProtocol.WASHING_LEFT: 0, + RoborockZeoProtocol.COUNTDOWN: 0, + RoborockZeoProtocol.TIMES_AFTER_CLEAN: 0, + RoborockZeoProtocol.DETERGENT_TYPE: ZeoDetergentType.low, + RoborockZeoProtocol.SOFTENER_TYPE: ZeoSoftenerType.low, +} + + +def _extract_int_value(val: Any) -> Any: + """Helper to extract raw value from Enum members or return primitive values directly.""" + if isinstance(val, enum.Enum): + return val.value + return val + + +class A01DeviceSimulator(RoborockDeviceSimulator): + """Base stateful firmware simulator for Roborock A01 devices.""" + + def __init__( + self, + duid: str, + device_info: HomeDataDevice, + product: HomeDataProduct, + status: dict[enum.Enum, Any] | None = None, + ): + super().__init__(duid, device_info, product, has_local_channel=False) + raw_status = status or {} + self.status: dict[int, Any] = {int(_extract_int_value(k)): _extract_int_value(v) for k, v in raw_status.items()} + + def set_protocol_value(self, protocol: enum.Enum | int, value: Any, push: bool = False) -> None: + """Set a protocol value in the simulator status. + + Accepts protocol enums (e.g. RoborockDyadDataProtocol, RoborockZeoProtocol) + or raw integer IDs, and value enums or raw primitive values. + """ + protocol_key = int(_extract_int_value(protocol)) + raw_value = _extract_int_value(value) + self.status[protocol_key] = raw_value + + if push: + self.push_dps({protocol_key: raw_value}) + + async def _handle_publish(self, message: RoborockMessage, channel: Any) -> None: + """Process incoming A01 encrypted payload and respond.""" + if not message.payload: + return + + try: + decoded_dps = decode_rpc_response(message) + except RoborockException as ex: + _LOGGER.warning("Simulator failed to decode A01 payload: %s", ex) + return + + updated_dps: dict[int, Any] = {} + + id_query_key = int(RoborockDyadDataProtocol.ID_QUERY) + if query_raw := decoded_dps.get(id_query_key): + query_list: list[Any] = [] + if isinstance(query_raw, str): + try: + parsed = json.loads(query_raw) + if isinstance(parsed, list): + query_list = parsed + except (json.JSONDecodeError, TypeError): + pass + elif isinstance(query_raw, list): + query_list = query_raw + + for key_val in query_list: + key_int = int(_extract_int_value(key_val)) + if key_int in self.status: + updated_dps[key_int] = self.status[key_int] + + if updated_dps: + self.push_dps(updated_dps) + return + + # Direct setters: update internal state for received DPS + for code, val in decoded_dps.items(): + if code != id_query_key: + self.status[code] = val + updated_dps[code] = val + + if updated_dps: + self.push_dps(updated_dps) + + def push_dps(self, dps_updates: dict[int, Any]) -> None: + """Push encrypted A01 status datapoint updates to subscribers.""" + msg = encode_mqtt_payload(dps_updates) # type: ignore[arg-type] + msg.protocol = RoborockMessageProtocol.RPC_RESPONSE + self.mqtt_channel.notify_subscribers(msg) + + def trigger_push_update(self) -> None: + """Send the full status dump to subscribers.""" + self.push_dps(self.status) + + +class DyadSimulator(A01DeviceSimulator): + """Stateful firmware simulator for Roborock Dyad (WET_DRY_VAC) A01 devices.""" + + def __init__( + self, + duid: str = "fake_dyad_duid", + status: dict[enum.Enum, Any] | None = None, + device_info: HomeDataDevice | None = None, + product: HomeDataProduct | None = None, + ): + product = product or DEFAULT_DYAD_PRODUCT + if device_info is None: + device_info = replace(DEFAULT_DYAD_DEVICE_INFO, duid=duid, name=f"Dyad {duid}") + merged_status: dict[enum.Enum, Any] = copy.deepcopy(DEFAULT_DYAD_STATUS) + if status: + for k, v in status.items(): + merged_status[k] = v + + super().__init__(duid, device_info, product, merged_status) + + def set_state(self, state: RoborockDyadStateCode | int, push: bool = False) -> None: + """Set the Dyad state code.""" + self.set_protocol_value(RoborockDyadDataProtocol.STATUS, state, push=push) + + def set_suction(self, suction: DyadSuction | int, push: bool = False) -> None: + """Set the Dyad suction level.""" + self.set_protocol_value(RoborockDyadDataProtocol.SUCTION, suction, push=push) + + def set_water_level(self, level: DyadWaterLevel | int, push: bool = False) -> None: + """Set the Dyad water level.""" + self.set_protocol_value(RoborockDyadDataProtocol.WATER_LEVEL, level, push=push) + + def set_error(self, error: DyadError | int, push: bool = False) -> None: + """Set the Dyad error code.""" + self.set_protocol_value(RoborockDyadDataProtocol.ERROR, error, push=push) + + def set_volume(self, volume: int, push: bool = False) -> None: + """Set the Dyad speaker volume.""" + self.set_protocol_value(RoborockDyadDataProtocol.VOLUME_SET, volume, push=push) + + +class ZeoSimulator(A01DeviceSimulator): + """Stateful firmware simulator for Roborock Zeo (WASHING_MACHINE) A01 devices.""" + + def __init__( + self, + duid: str = "fake_zeo_duid", + status: dict[enum.Enum, Any] | None = None, + device_info: HomeDataDevice | None = None, + product: HomeDataProduct | None = None, + ): + product = product or DEFAULT_ZEO_PRODUCT + if device_info is None: + device_info = replace(DEFAULT_ZEO_DEVICE_INFO, duid=duid, name=f"Zeo {duid}") + merged_status: dict[enum.Enum, Any] = copy.deepcopy(DEFAULT_ZEO_STATUS) + if status: + for k, v in status.items(): + merged_status[k] = v + + super().__init__(duid, device_info, product, merged_status) + + def set_state(self, state: ZeoState | int, push: bool = False) -> None: + """Set the Zeo washing state.""" + self.set_protocol_value(RoborockZeoProtocol.STATE, state, push=push) + + def set_mode(self, mode: ZeoMode | int, push: bool = False) -> None: + """Set the Zeo wash mode.""" + self.set_protocol_value(RoborockZeoProtocol.MODE, mode, push=push) + + def set_program(self, program: ZeoProgram | int, push: bool = False) -> None: + """Set the Zeo wash program.""" + self.set_protocol_value(RoborockZeoProtocol.PROGRAM, program, push=push) + + def set_temperature(self, temp: ZeoTemperature | int, push: bool = False) -> None: + """Set the Zeo water temperature.""" + self.set_protocol_value(RoborockZeoProtocol.TEMP, temp, push=push) + + def set_error(self, error: ZeoError | int, push: bool = False) -> None: + """Set the Zeo error code.""" + self.set_protocol_value(RoborockZeoProtocol.ERROR, error, push=push) + + def set_detergent_empty(self, empty: bool, push: bool = False) -> None: + """Set detergent empty indicator.""" + self.set_protocol_value(RoborockZeoProtocol.DETERGENT_EMPTY, empty, push=push) diff --git a/roborock/testing/cloud.py b/roborock/testing/cloud.py index 61a0c543..eb2b0165 100644 --- a/roborock/testing/cloud.py +++ b/roborock/testing/cloud.py @@ -16,6 +16,7 @@ from roborock.data import HomeData, Reference, RRiot, UserData from roborock.devices.rpc.v1_channel import create_v1_channel as original_create_v1_channel from roborock.devices.transport.mqtt_channel import create_mqtt_channel as original_create_mqtt_channel +from roborock.testing.a01_simulator import A01DeviceSimulator from roborock.testing.b01_q10_simulator import Q10VacuumSimulator from roborock.testing.simulator import RoborockDeviceSimulator from roborock.testing.v1_simulator import V1VacuumSimulator @@ -267,12 +268,17 @@ def mock_create_mqtt_channel(user_data, mqtt_params, mqtt_session, device): f"Simulating protocol {device.pv} is not yet supported. " "Only Q10VacuumSimulator is supported for B01 protocols currently." ) + if device.pv == "A01" and not isinstance(server, A01DeviceSimulator): + raise NotImplementedError( + f"Device '{device.duid}' is registered with a {type(server).__name__} " + "simulator, but protocol A01 requires an A01DeviceSimulator." + ) server.mqtt_channel._is_connected = True return server.mqtt_channel if device.pv in ("A01", "B01"): raise NotImplementedError( f"Simulating protocol {device.pv} is not yet supported. " - "TODO: Implement stateful simulators for B01 (Q7) and A01 (Zeo/Dyad) devices." + "TODO: Implement stateful simulators for B01 (Q7) devices." ) return original_create_mqtt_channel(user_data, mqtt_params, mqtt_session, device) diff --git a/tests/testing/test_a01_simulator.py b/tests/testing/test_a01_simulator.py new file mode 100644 index 00000000..188885cc --- /dev/null +++ b/tests/testing/test_a01_simulator.py @@ -0,0 +1,206 @@ +"""Unit and integration tests for DyadSimulator and ZeoSimulator (A01 protocol).""" + +import pytest + +from roborock.data.containers import UserData +from roborock.data.dyad.dyad_code_mappings import ( + DyadCleanMode, + DyadError, + DyadSuction, + DyadWaterLevel, + RoborockDyadStateCode, +) +from roborock.data.zeo.zeo_code_mappings import ( + ZeoDryingMode, + ZeoError, + ZeoMode, + ZeoProgram, + ZeoRinse, + ZeoSpin, + ZeoState, + ZeoTemperature, +) +from roborock.devices.device_manager import UserParams, create_device_manager +from roborock.devices.traits.a01 import DyadApi, ZeoApi +from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol +from roborock.testing import ( + DEFAULT_DYAD_STATUS, + DEFAULT_ZEO_STATUS, + DyadSimulator, + FakeRoborockCloud, + ZeoSimulator, +) +from tests.mock_data import USER_DATA + + +@pytest.fixture(name="dyad_sim") +def dyad_sim_fixture() -> DyadSimulator: + """Fixture for DyadSimulator.""" + return DyadSimulator(duid="test_dyad_duid") + + +@pytest.fixture(name="zeo_sim") +def zeo_sim_fixture() -> ZeoSimulator: + """Fixture for ZeoSimulator.""" + return ZeoSimulator(duid="test_zeo_duid") + + +def test_dyad_default_status(dyad_sim: DyadSimulator) -> None: + """Test default initial status dictionary for Dyad simulator.""" + assert dyad_sim.status == DEFAULT_DYAD_STATUS + assert dyad_sim.status[int(RoborockDyadDataProtocol.STATUS)] == RoborockDyadStateCode.charging.value + assert dyad_sim.status[int(RoborockDyadDataProtocol.SUCTION)] == DyadSuction.l1.value + + +def test_zeo_default_status(zeo_sim: ZeoSimulator) -> None: + """Test default initial status dictionary for Zeo simulator.""" + assert zeo_sim.status == DEFAULT_ZEO_STATUS + assert zeo_sim.status[int(RoborockZeoProtocol.STATE)] == ZeoState.standby.value + assert zeo_sim.status[int(RoborockZeoProtocol.MODE)] == ZeoMode.wash_and_dry.value + + +def test_dyad_set_protocol_value_and_named_setters(dyad_sim: DyadSimulator) -> None: + """Test setting Dyad values via enum-aware set_protocol_value and explicit helper methods.""" + dyad_sim.set_suction(DyadSuction.l2) + assert dyad_sim.status[int(RoborockDyadDataProtocol.SUCTION)] == DyadSuction.l2.value + + dyad_sim.set_water_level(DyadWaterLevel.l3) + assert dyad_sim.status[int(RoborockDyadDataProtocol.WATER_LEVEL)] == DyadWaterLevel.l3.value + + dyad_sim.set_state(RoborockDyadStateCode.washing) + assert dyad_sim.status[int(RoborockDyadDataProtocol.STATUS)] == RoborockDyadStateCode.washing.value + + dyad_sim.set_error(DyadError.dirty_tank_full) + assert dyad_sim.status[int(RoborockDyadDataProtocol.ERROR)] == DyadError.dirty_tank_full.value + + +def test_zeo_set_protocol_value_and_named_setters(zeo_sim: ZeoSimulator) -> None: + """Test setting Zeo values via enum-aware set_protocol_value and explicit helper methods.""" + zeo_sim.set_state(ZeoState.washing) + assert zeo_sim.status[int(RoborockZeoProtocol.STATE)] == ZeoState.washing.value + + zeo_sim.set_mode(ZeoMode.wash) + assert zeo_sim.status[int(RoborockZeoProtocol.MODE)] == ZeoMode.wash.value + + zeo_sim.set_program(ZeoProgram.wool) + assert zeo_sim.status[int(RoborockZeoProtocol.PROGRAM)] == ZeoProgram.wool.value + + zeo_sim.set_temperature(ZeoTemperature.high) + assert zeo_sim.status[int(RoborockZeoProtocol.TEMP)] == ZeoTemperature.high.value + + zeo_sim.set_detergent_empty(True) + assert zeo_sim.status[int(RoborockZeoProtocol.DETERGENT_EMPTY)] is True + + +async def test_dyad_query_values(dyad_sim: DyadSimulator) -> None: + """Test querying Dyad protocols via DyadApi with a simulated A01 channel.""" + api = DyadApi(dyad_sim.mqtt_channel) # type: ignore[arg-type] + + query_protocols = [ + RoborockDyadDataProtocol.STATUS, + RoborockDyadDataProtocol.POWER, + RoborockDyadDataProtocol.SUCTION, + RoborockDyadDataProtocol.WATER_LEVEL, + RoborockDyadDataProtocol.CLEAN_MODE, + RoborockDyadDataProtocol.AUTO_DRY, + RoborockDyadDataProtocol.VOLUME_SET, + RoborockDyadDataProtocol.ERROR, + RoborockDyadDataProtocol.TOTAL_RUN_TIME, + ] + + res = await api.query_values(query_protocols) + + assert res[RoborockDyadDataProtocol.STATUS] == RoborockDyadStateCode.charging.name + assert res[RoborockDyadDataProtocol.POWER] == 100 + assert res[RoborockDyadDataProtocol.SUCTION] == DyadSuction.l1.name + assert res[RoborockDyadDataProtocol.WATER_LEVEL] == DyadWaterLevel.l1.name + assert res[RoborockDyadDataProtocol.CLEAN_MODE] == DyadCleanMode.auto.name + assert res[RoborockDyadDataProtocol.AUTO_DRY] is True + assert res[RoborockDyadDataProtocol.VOLUME_SET] == 80 + assert res[RoborockDyadDataProtocol.ERROR] == DyadError.none.name + assert res[RoborockDyadDataProtocol.TOTAL_RUN_TIME] == 120 + + +async def test_zeo_query_values(zeo_sim: ZeoSimulator) -> None: + """Test querying Zeo protocols via ZeoApi with a simulated A01 channel.""" + api = ZeoApi(zeo_sim.mqtt_channel) # type: ignore[arg-type] + + query_protocols = [ + RoborockZeoProtocol.STATE, + RoborockZeoProtocol.MODE, + RoborockZeoProtocol.PROGRAM, + RoborockZeoProtocol.TEMP, + RoborockZeoProtocol.RINSE_TIMES, + RoborockZeoProtocol.SPIN_LEVEL, + RoborockZeoProtocol.DRYING_MODE, + RoborockZeoProtocol.DETERGENT_EMPTY, + RoborockZeoProtocol.SOFTENER_EMPTY, + RoborockZeoProtocol.SOUND_SET, + RoborockZeoProtocol.ERROR, + ] + + res = await api.query_values(query_protocols) + + assert res[RoborockZeoProtocol.STATE] == ZeoState.standby.name + assert res[RoborockZeoProtocol.MODE] == ZeoMode.wash_and_dry.name + assert res[RoborockZeoProtocol.PROGRAM] == ZeoProgram.standard.name + assert res[RoborockZeoProtocol.TEMP] == ZeoTemperature.medium.name + assert res[RoborockZeoProtocol.RINSE_TIMES] == ZeoRinse.low.name + assert res[RoborockZeoProtocol.SPIN_LEVEL] == ZeoSpin.high.name + assert res[RoborockZeoProtocol.DRYING_MODE] == ZeoDryingMode.store.name + assert res[RoborockZeoProtocol.DETERGENT_EMPTY] is False + assert res[RoborockZeoProtocol.SOFTENER_EMPTY] is False + assert res[RoborockZeoProtocol.SOUND_SET] is True + assert res[RoborockZeoProtocol.ERROR] == ZeoError.none.name + + +async def test_dyad_set_value(dyad_sim: DyadSimulator) -> None: + """Test sending set_value commands through DyadApi to update simulator state.""" + dyad_api = DyadApi(dyad_sim.mqtt_channel) # type: ignore[arg-type] + await dyad_api.set_value(RoborockDyadDataProtocol.VOLUME_SET, 45) + assert dyad_sim.status[int(RoborockDyadDataProtocol.VOLUME_SET)] == 45 + + +async def test_zeo_set_value(zeo_sim: ZeoSimulator) -> None: + """Test sending set_value commands through ZeoApi to update simulator state.""" + zeo_api = ZeoApi(zeo_sim.mqtt_channel) # type: ignore[arg-type] + await zeo_api.set_value(RoborockZeoProtocol.PROGRAM, ZeoProgram.wool.value) + assert zeo_sim.status[int(RoborockZeoProtocol.PROGRAM)] == ZeoProgram.wool.value + + +async def test_a01_device_manager_integration() -> None: + """Test full DeviceManager discovery and connection to simulated Dyad and Zeo devices.""" + cloud = FakeRoborockCloud() + dyad_sim = DyadSimulator(duid="dyad_e2e") + zeo_sim = ZeoSimulator(duid="zeo_e2e") + + cloud.add_device(dyad_sim) + cloud.add_device(zeo_sim) + + with cloud.patch_device_manager(): + manager = await create_device_manager( + user_params=UserParams(username="test_user", user_data=UserData.from_dict(USER_DATA)), + ) + devices = await manager.get_devices() + + assert len(devices) == 2 + devices.sort(key=lambda d: d.duid) + + dyad_device = devices[0] + zeo_device = devices[1] + + assert dyad_device.duid == "dyad_e2e" + assert dyad_device.dyad is not None + + assert zeo_device.duid == "zeo_e2e" + assert zeo_device.zeo is not None + + # Query values on discovered Dyad device + dyad_status = await dyad_device.dyad.query_values([RoborockDyadDataProtocol.STATUS]) + assert dyad_status[RoborockDyadDataProtocol.STATUS] == RoborockDyadStateCode.charging.name + + # Query values on discovered Zeo device + zeo_status = await zeo_device.zeo.query_values([RoborockZeoProtocol.STATE]) + assert zeo_status[RoborockZeoProtocol.STATE] == ZeoState.standby.name + + await manager.close()