From 379400f47745c5fca57249bc7cde99f4b2eab201 Mon Sep 17 00:00:00 2001 From: Leszek Swirski Date: Mon, 28 Sep 2026 14:35:58 +0200 Subject: [PATCH 1/2] Add support for DEEBOT T90 OMNI (7fpaed) --- deebot_client/hardware/7fpaed.py | 1 + 1 file changed, 1 insertion(+) create mode 120000 deebot_client/hardware/7fpaed.py diff --git a/deebot_client/hardware/7fpaed.py b/deebot_client/hardware/7fpaed.py new file mode 120000 index 000000000..538c49215 --- /dev/null +++ b/deebot_client/hardware/7fpaed.py @@ -0,0 +1 @@ +twunby.py \ No newline at end of file From c491eb6d97099999a59f4f051d0ad0866e5f9a4b Mon Sep 17 00:00:00 2001 From: Leszek Swirski Date: Mon, 28 Sep 2026 18:37:25 +0200 Subject: [PATCH 2/2] Add T90 OMNI clean action support --- deebot_client/hardware/7fpaed.py | 56 +++++++++++++++++++++++++++++++- tests/hardware/test_init.py | 38 +++++++++++++++++++++- 2 files changed, 92 insertions(+), 2 deletions(-) mode change 120000 => 100644 deebot_client/hardware/7fpaed.py diff --git a/deebot_client/hardware/7fpaed.py b/deebot_client/hardware/7fpaed.py deleted file mode 120000 index 538c49215..000000000 --- a/deebot_client/hardware/7fpaed.py +++ /dev/null @@ -1 +0,0 @@ -twunby.py \ No newline at end of file diff --git a/deebot_client/hardware/7fpaed.py b/deebot_client/hardware/7fpaed.py new file mode 100644 index 000000000..9eb8eb789 --- /dev/null +++ b/deebot_client/hardware/7fpaed.py @@ -0,0 +1,55 @@ +"""DEEBOT T90 OMNI (7fpaed) capabilities.""" + +from __future__ import annotations + +from dataclasses import replace +import time +from typing import Any + +from deebot_client.commands.json.clean import CleanV2 +from deebot_client.hardware.twunby import get_device_info as get_twunby_device_info +from deebot_client.models import CleanAction, CleanMode, StaticDeviceInfo + + +class _CleanV2(CleanV2): + """Serialize clean actions as expected by the T90 OMNI.""" + + def _get_args(self, action: CleanAction) -> dict[str, Any]: + args: dict[str, Any] = {"act": action.value} + if action != CleanAction.STOP: + args.update( + { + "content": {"type": CleanMode.AUTO.value}, + "noVoiceResp": 0, + } + ) + return args + + def _get_payload(self) -> dict[str, Any] | list[Any]: + if ( + isinstance(self._args, dict) + and self._args.get("act") == CleanAction.STOP.value + ): + return super()._get_payload() + + return { + "header": { + "ver": 0.1, + "priority": 1, + "ts": int(time.time() * 1000), + "channel": "ROP", + }, + "body": {"data": self._args}, + } + + +def get_device_info() -> StaticDeviceInfo: + """Get device info for the DEEBOT T90 OMNI.""" + device_info = get_twunby_device_info() + capabilities = device_info.capabilities + clean = capabilities.clean + action = replace(clean.action, command=_CleanV2) + return replace( + device_info, + capabilities=replace(capabilities, clean=replace(clean, action=action)), + ) diff --git a/tests/hardware/test_init.py b/tests/hardware/test_init.py index 06e956b3b..a55833932 100644 --- a/tests/hardware/test_init.py +++ b/tests/hardware/test_init.py @@ -2,6 +2,7 @@ from __future__ import annotations +import importlib from pathlib import Path from typing import TYPE_CHECKING from unittest import mock @@ -87,7 +88,7 @@ from deebot_client.events.water_info import MopAttachedEvent, WaterAmountEvent from deebot_client.hardware.r8ead0 import get_device_info as get_r8ead0_info from deebot_client.hardware.yna5xi import get_device_info as get_yna5xi_info -from deebot_client.models import StaticDeviceInfo +from deebot_client.models import CleanAction, StaticDeviceInfo if TYPE_CHECKING: from deebot_client.command import Command @@ -279,3 +280,38 @@ async def test_all_models_loaded() -> None: assert isinstance(device_info, StaticDeviceInfo), ( f"Failed to load device info for {module_name}" ) + + +def test_7fpaed_clean_actions() -> None: + """Test DEEBOT T90 OMNI clean action serialization.""" + module = importlib.import_module("deebot_client.hardware.7fpaed") + info = module.get_device_info() + + command = info.capabilities.clean.action.command + start = command(CleanAction.START) + assert start._args == { + "act": "start", + "content": {"type": "auto"}, + "noVoiceResp": 0, + } + start_payload = start._get_payload() + assert isinstance(start_payload, dict) + assert start_payload["header"]["channel"] == "ROP" + assert start_payload["header"]["ver"] == 0.1 + assert start_payload["header"]["priority"] == 1 + assert start_payload["body"] == {"data": start._args} + assert command(CleanAction.PAUSE)._args == { + "act": "pause", + "content": {"type": "auto"}, + "noVoiceResp": 0, + } + assert command(CleanAction.RESUME)._args == { + "act": "resume", + "content": {"type": "auto"}, + "noVoiceResp": 0, + } + stop = command(CleanAction.STOP) + assert stop._args == {"act": "stop"} + stop_payload = stop._get_payload() + assert isinstance(stop_payload, dict) + assert stop_payload["header"]["ver"] == "0.0.50"