From d8af4f4fcf161f31e430e13b5a96985d11492f48 Mon Sep 17 00:00:00 2001 From: JNickson Date: Fri, 23 May 2025 12:50:36 +0100 Subject: [PATCH 01/42] added inital api endpoint execute request change --- deebot_client/command.py | 53 +++++++++++++++---------- deebot_client/commands/json/__init__.py | 6 ++- deebot_client/commands/json/clean.py | 39 ++++++++++++++++++ deebot_client/const.py | 1 + 4 files changed, 77 insertions(+), 22 deletions(-) diff --git a/deebot_client/command.py b/deebot_client/command.py index 207d6da0e..f6b77beb7 100644 --- a/deebot_client/command.py +++ b/deebot_client/command.py @@ -14,7 +14,7 @@ ) from deebot_client.util import verify_required_class_variables_exists -from .const import PATH_API_IOT_DEVMANAGER, REQUEST_HEADERS, DataType +from .const import PATH_API_IOT_DEVMANAGER, PATH_API_IOT_CONTROL, REQUEST_HEADERS, DataType from .logging_filter import get_logger from .message import HandlingResult, HandlingState, Message @@ -76,6 +76,7 @@ def __init__(self, args: dict[str, Any] | list[Any] | None = None) -> None: if args is None: args = {} self._args = args + self._api_path = PATH_API_IOT_DEVMANAGER @abstractmethod def _get_payload(self) -> dict[str, Any] | list[Any] | str: @@ -151,7 +152,8 @@ async def _execute( async def _execute_api_request( self, authenticator: Authenticator, device_info: ApiDeviceInfo ) -> dict[str, Any]: - payload = { + if self._api_path == PATH_API_IOT_DEVMANAGER: + payload = { "cmdName": self.NAME, "payload": self._get_payload(), "payloadType": self.DATA_TYPE.value, @@ -159,25 +161,34 @@ async def _execute_api_request( "toId": device_info["did"], "toRes": device_info["resource"], "toType": device_info["class"], - } - - credentials = await authenticator.authenticate() - query_params = { - "mid": payload["toType"], - "did": payload["toId"], - "td": payload["td"], - "u": credentials.user_id, - "cv": "1.67.3", - "t": "a", - "av": "1.3.1", - } - - return await authenticator.post_authenticated( - PATH_API_IOT_DEVMANAGER, - payload, - query_params=query_params, - headers=REQUEST_HEADERS, - ) + } + + credentials = await authenticator.authenticate() + query_params = { + "mid": payload["toType"], + "did": payload["toId"], + "td": payload["td"], + "u": credentials.user_id, + "cv": "1.67.3", + "t": "a", + "av": "1.3.1", + } + return await authenticator.post_authenticated( + self._api_path, + payload, + query_params=query_params, + headers=REQUEST_HEADERS, + ) + + elif self._api_path == PATH_API_IOT_CONTROL: + body = ... + query_params = ... + return await authenticator.post_authenticated( + self._api_path, + body, + query_params=query_params, + headers=REQUEST_HEADERS, + ) def __handle_response( self, event_bus: EventBus, response: dict[str, Any] diff --git a/deebot_client/commands/json/__init__.py b/deebot_client/commands/json/__init__.py index 6f1db25f1..e5c52cc47 100644 --- a/deebot_client/commands/json/__init__.py +++ b/deebot_client/commands/json/__init__.py @@ -12,7 +12,7 @@ from .charge import Charge from .charge_state import GetChargeState from .child_lock import GetChildLock, SetChildLock -from .clean import Clean, CleanArea, CleanV2, GetCleanInfo, GetCleanInfoV2 +from .clean import Clean, CleanArea, CleanV2, CleanV3, GetCleanInfo, GetCleanInfoV2, GetCleanInfoV3 from .clean_count import GetCleanCount, SetCleanCount from .clean_logs import GetCleanLogs from .clean_preference import GetCleanPreference, SetCleanPreference @@ -59,6 +59,7 @@ "Clean", "CleanArea", "CleanV2", + "CleanV3", "ClearMap", "GetAdvancedMode", "GetBattery", @@ -70,6 +71,7 @@ "GetCleanCount", "GetCleanInfo", "GetCleanInfoV2", + "GetCleanInfoV3", "GetCleanLogs", "GetCleanPreference", "GetContinuousCleaning", @@ -160,9 +162,11 @@ Clean, CleanV2, + CleanV3, CleanArea, GetCleanInfo, GetCleanInfoV2, + GetCleanInfoV3, GetCleanLogs, diff --git a/deebot_client/commands/json/clean.py b/deebot_client/commands/json/clean.py index ae0e51c12..c3aadea01 100644 --- a/deebot_client/commands/json/clean.py +++ b/deebot_client/commands/json/clean.py @@ -8,6 +8,7 @@ from deebot_client.logging_filter import get_logger from deebot_client.message import HandlingResult, MessageBodyDataDict from deebot_client.models import ApiDeviceInfo, CleanAction, CleanMode, State +from deebot_client.const import PATH_API_IOT_CONTROL from .common import ExecuteCommand, JsonCommandWithMessageHandling @@ -103,6 +104,39 @@ def _get_args(self, action: CleanAction) -> dict[str, Any]: args["content"].update(self._additional_content) return args +class CleanV3(Clean): + """Clean V3 command.""" + + NAME = "clean_V3" + + def __init__(self, action: CleanAction) -> None: + super().__init__(action) + self._api_path = PATH_API_IOT_CONTROL + + def _get_args(self, action: CleanAction) -> dict[str, Any]: + content: dict[str, str] = {} + args = {"act": action.value, "content": content} + match action: + case CleanAction.START: + content["type"] = CleanMode.AUTO.value + case CleanAction.STOP | CleanAction.PAUSE: + content["type"] = "" + return args + + +class CleanAreaV3(CleanV3): + """Clean area command.""" + + def __init__(self, mode: CleanMode, area: str, _: int = 1) -> None: + self._additional_content = {"type": mode.value, "value": area} + super().__init__(CleanAction.START) + + def _get_args(self, action: CleanAction) -> dict[str, Any]: + args = super()._get_args(action) + if action == CleanAction.START: + args["content"].update(self._additional_content) + return args + class GetCleanInfo(JsonCommandWithMessageHandling, MessageBodyDataDict): """Get clean info command.""" @@ -159,3 +193,8 @@ class GetCleanInfoV2(GetCleanInfo): """Get clean info v2 command.""" NAME = "getCleanInfo_V2" + +class GetCleanInfoV3(GetCleanInfo): + """Get clean info v3 command.""" + + NAME = "getCleanInfo_V3" diff --git a/deebot_client/const.py b/deebot_client/const.py index 87ddcd824..482e90aca 100644 --- a/deebot_client/const.py +++ b/deebot_client/const.py @@ -9,6 +9,7 @@ PATH_API_APPSVR_APP = "appsvr/app.do" PATH_API_PIM_PRODUCT_IOT_MAP = "pim/product/getProductIotMap" PATH_API_IOT_DEVMANAGER = "iot/devmanager.do" +PATH_API_IOT_CONTROL = "iot/endpoint/control.do" PATH_API_LG_LOG = "lg/log.do" PATH_API_USERS_USER = "users/user.do" REQUEST_HEADERS = { From 069fa9bf07571e74b618ec472499b70acc0a0f1e Mon Sep 17 00:00:00 2001 From: Alec <30310787+alec-pinson@users.noreply.github.com> Date: Fri, 23 May 2025 13:27:19 +0100 Subject: [PATCH 02/42] rm symlink + use new body / header --- deebot_client/command.py | 33 ++++++++++++++++--------- deebot_client/hardware/deebot/2px96q.py | 1 - 2 files changed, 21 insertions(+), 13 deletions(-) delete mode 120000 deebot_client/hardware/deebot/2px96q.py diff --git a/deebot_client/command.py b/deebot_client/command.py index f6b77beb7..515ae9c37 100644 --- a/deebot_client/command.py +++ b/deebot_client/command.py @@ -152,17 +152,18 @@ async def _execute( async def _execute_api_request( self, authenticator: Authenticator, device_info: ApiDeviceInfo ) -> dict[str, Any]: - if self._api_path == PATH_API_IOT_DEVMANAGER: - payload = { - "cmdName": self.NAME, - "payload": self._get_payload(), - "payloadType": self.DATA_TYPE.value, - "td": "q", - "toId": device_info["did"], - "toRes": device_info["resource"], - "toType": device_info["class"], - } + payload = { + "cmdName": self.NAME, + "payload": self._get_payload(), + "payloadType": self.DATA_TYPE.value, + "td": "q", + "toId": device_info["did"], + "toRes": device_info["resource"], + "toType": device_info["class"], + } + + if self._api_path == PATH_API_IOT_DEVMANAGER: credentials = await authenticator.authenticate() query_params = { "mid": payload["toType"], @@ -181,8 +182,16 @@ async def _execute_api_request( ) elif self._api_path == PATH_API_IOT_CONTROL: - body = ... - query_params = ... + body = payload["payload"] + query_params = { + "fmt": self.DATA_TYPE.value, + "ct": "q", + "eid": device_info["did"], + "er": device_info["resource"], + "et": device_info["class"], + "apn": self.NAME, # (clean|charge) + "si": device_info["resource"], # new http param si (some random id which matches request header X-ECO-REQUEST-ID) + } return await authenticator.post_authenticated( self._api_path, body, diff --git a/deebot_client/hardware/deebot/2px96q.py b/deebot_client/hardware/deebot/2px96q.py deleted file mode 120000 index 28514679f..000000000 --- a/deebot_client/hardware/deebot/2px96q.py +++ /dev/null @@ -1 +0,0 @@ -5xu9h3.py \ No newline at end of file From 585dfac77ea5a951dee7b45e6826ec60c8cc92ce Mon Sep 17 00:00:00 2001 From: Alec <30310787+alec-pinson@users.noreply.github.com> Date: Fri, 23 May 2025 13:28:26 +0100 Subject: [PATCH 03/42] use 'cleanv3' --- deebot_client/hardware/deebot/2px96q.py | 141 ++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 deebot_client/hardware/deebot/2px96q.py diff --git a/deebot_client/hardware/deebot/2px96q.py b/deebot_client/hardware/deebot/2px96q.py new file mode 100644 index 000000000..607df042e --- /dev/null +++ b/deebot_client/hardware/deebot/2px96q.py @@ -0,0 +1,141 @@ +"""DEEBOT GOAT O800 Capabilities.""" + +from __future__ import annotations + +from deebot_client.capabilities import ( + Capabilities, + CapabilityClean, + CapabilityCleanAction, + CapabilityCustomCommand, + CapabilityEvent, + CapabilityExecute, + CapabilityLifeSpan, + CapabilitySet, + CapabilitySetEnable, + CapabilitySettings, + CapabilityStats, + DeviceType, +) +from deebot_client.commands.json import ( + GetBorderSwitch, + GetChildLock, + GetCrossMapBorderWarning, + GetCutDirection, + GetMoveUpWarning, + GetSafeProtect, + SetBorderSwitch, + SetChildLock, + SetCrossMapBorderWarning, + SetCutDirection, + SetMoveUpWarning, + SetSafeProtect, +) +from deebot_client.commands.json.advanced_mode import GetAdvancedMode, SetAdvancedMode +from deebot_client.commands.json.battery import GetBattery +from deebot_client.commands.json.charge import Charge +from deebot_client.commands.json.charge_state import GetChargeState +from deebot_client.commands.json.clean import CleanV3, GetCleanInfoV3 +from deebot_client.commands.json.custom import CustomCommand +from deebot_client.commands.json.error import GetError +from deebot_client.commands.json.life_span import GetLifeSpan, ResetLifeSpan +from deebot_client.commands.json.network import GetNetInfo +from deebot_client.commands.json.play_sound import PlaySound +from deebot_client.commands.json.stats import GetStats, GetTotalStats +from deebot_client.commands.json.true_detect import GetTrueDetect, SetTrueDetect +from deebot_client.commands.json.volume import GetVolume, SetVolume +from deebot_client.const import DataType +from deebot_client.events import ( + AdvancedModeEvent, + AvailabilityEvent, + BatteryEvent, + BorderSwitchEvent, + ChildLockEvent, + CrossMapBorderWarningEvent, + CustomCommandEvent, + CutDirectionEvent, + ErrorEvent, + LifeSpan, + LifeSpanEvent, + MoveUpWarningEvent, + NetworkInfoEvent, + ReportStatsEvent, + SafeProtectEvent, + StateEvent, + StatsEvent, + TotalStatsEvent, + TrueDetectEvent, + VolumeEvent, +) +from deebot_client.models import StaticDeviceInfo +from deebot_client.util import short_name + +from . import DEVICES + +DEVICES[short_name(__name__)] = StaticDeviceInfo( + DataType.JSON, + Capabilities( + device_type=DeviceType.MOWER, + availability=CapabilityEvent( + AvailabilityEvent, [GetBattery(is_available_check=True)] + ), + battery=CapabilityEvent(BatteryEvent, [GetBattery()]), + charge=CapabilityExecute(Charge), + clean=CapabilityClean( + action=CapabilityCleanAction(command=CleanV2), + ), + custom=CapabilityCustomCommand( + event=CustomCommandEvent, get=[], set=CustomCommand + ), + error=CapabilityEvent(ErrorEvent, [GetError()]), + life_span=CapabilityLifeSpan( + types=(LifeSpan.BLADE, LifeSpan.LENS_BRUSH), + event=LifeSpanEvent, + get=[ + GetLifeSpan( + [ + LifeSpan.BLADE, + LifeSpan.LENS_BRUSH, + ] + ) + ], + reset=ResetLifeSpan, + ), + network=CapabilityEvent(NetworkInfoEvent, [GetNetInfo()]), + play_sound=CapabilityExecute(PlaySound), + settings=CapabilitySettings( + advanced_mode=CapabilitySetEnable( + AdvancedModeEvent, [GetAdvancedMode()], SetAdvancedMode + ), + border_switch=CapabilitySetEnable( + BorderSwitchEvent, [GetBorderSwitch()], SetBorderSwitch + ), + cut_direction=CapabilitySet( + CutDirectionEvent, [GetCutDirection()], SetCutDirection + ), + child_lock=CapabilitySetEnable( + ChildLockEvent, [GetChildLock()], SetChildLock + ), + moveup_warning=CapabilitySetEnable( + MoveUpWarningEvent, [GetMoveUpWarning()], SetMoveUpWarning + ), + cross_map_border_warning=CapabilitySetEnable( + CrossMapBorderWarningEvent, + [GetCrossMapBorderWarning()], + SetCrossMapBorderWarning, + ), + safe_protect=CapabilitySetEnable( + SafeProtectEvent, [GetSafeProtect()], SetSafeProtect + ), + true_detect=CapabilitySetEnable( + TrueDetectEvent, [GetTrueDetect()], SetTrueDetect + ), + volume=CapabilitySet(VolumeEvent, [GetVolume()], SetVolume), + ), + state=CapabilityEvent(StateEvent, [GetChargeState(), GetCleanInfoV2()]), + stats=CapabilityStats( + clean=CapabilityEvent(StatsEvent, [GetStats()]), + report=CapabilityEvent(ReportStatsEvent, []), + total=CapabilityEvent(TotalStatsEvent, [GetTotalStats()]), + ), + ), +) From b4e3226e0f6c6a551796f80fa2ed9cb2e68327d1 Mon Sep 17 00:00:00 2001 From: Alec <30310787+alec-pinson@users.noreply.github.com> Date: Fri, 23 May 2025 13:32:07 +0100 Subject: [PATCH 04/42] missed some --- deebot_client/hardware/deebot/2px96q.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deebot_client/hardware/deebot/2px96q.py b/deebot_client/hardware/deebot/2px96q.py index 607df042e..6c1cb9280 100644 --- a/deebot_client/hardware/deebot/2px96q.py +++ b/deebot_client/hardware/deebot/2px96q.py @@ -81,7 +81,7 @@ battery=CapabilityEvent(BatteryEvent, [GetBattery()]), charge=CapabilityExecute(Charge), clean=CapabilityClean( - action=CapabilityCleanAction(command=CleanV2), + action=CapabilityCleanAction(command=CleanV3), ), custom=CapabilityCustomCommand( event=CustomCommandEvent, get=[], set=CustomCommand @@ -131,7 +131,7 @@ ), volume=CapabilitySet(VolumeEvent, [GetVolume()], SetVolume), ), - state=CapabilityEvent(StateEvent, [GetChargeState(), GetCleanInfoV2()]), + state=CapabilityEvent(StateEvent, [GetChargeState(), GetCleanInfoV3()]), stats=CapabilityStats( clean=CapabilityEvent(StatsEvent, [GetStats()]), report=CapabilityEvent(ReportStatsEvent, []), From 53c485cf86671e0e70003b6057faaeae976bcedd Mon Sep 17 00:00:00 2001 From: Alec <30310787+alec-pinson@users.noreply.github.com> Date: Fri, 23 May 2025 13:47:27 +0100 Subject: [PATCH 05/42] correct path + command --- deebot_client/commands/json/clean.py | 2 +- deebot_client/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deebot_client/commands/json/clean.py b/deebot_client/commands/json/clean.py index c3aadea01..1d279b6a9 100644 --- a/deebot_client/commands/json/clean.py +++ b/deebot_client/commands/json/clean.py @@ -107,7 +107,7 @@ def _get_args(self, action: CleanAction) -> dict[str, Any]: class CleanV3(Clean): """Clean V3 command.""" - NAME = "clean_V3" + NAME = "clean" def __init__(self, action: CleanAction) -> None: super().__init__(action) diff --git a/deebot_client/const.py b/deebot_client/const.py index 482e90aca..e3d961ad1 100644 --- a/deebot_client/const.py +++ b/deebot_client/const.py @@ -9,7 +9,7 @@ PATH_API_APPSVR_APP = "appsvr/app.do" PATH_API_PIM_PRODUCT_IOT_MAP = "pim/product/getProductIotMap" PATH_API_IOT_DEVMANAGER = "iot/devmanager.do" -PATH_API_IOT_CONTROL = "iot/endpoint/control.do" +PATH_API_IOT_CONTROL = "iot/endpoint/control" PATH_API_LG_LOG = "lg/log.do" PATH_API_USERS_USER = "users/user.do" REQUEST_HEADERS = { From 41329b8b56ae53765647370b14770eddd91bb558 Mon Sep 17 00:00:00 2001 From: Alec <30310787+alec-pinson@users.noreply.github.com> Date: Fri, 23 May 2025 13:55:40 +0100 Subject: [PATCH 06/42] added other headers --- deebot_client/command.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/deebot_client/command.py b/deebot_client/command.py index 515ae9c37..3985cdf48 100644 --- a/deebot_client/command.py +++ b/deebot_client/command.py @@ -183,6 +183,14 @@ async def _execute_api_request( elif self._api_path == PATH_API_IOT_CONTROL: body = payload["payload"] + body["header"].update({ + "channel": "Android", + "m": "request", + "pri": 2, + "ver": "0.0.22", + "tzm": 60, + "tzc": "Europe/London" + }) query_params = { "fmt": self.DATA_TYPE.value, "ct": "q", @@ -190,7 +198,7 @@ async def _execute_api_request( "er": device_info["resource"], "et": device_info["class"], "apn": self.NAME, # (clean|charge) - "si": device_info["resource"], # new http param si (some random id which matches request header X-ECO-REQUEST-ID) + "si": device_info["resource"] # new http param si (some random id which matches request header X-ECO-REQUEST-ID) } return await authenticator.post_authenticated( self._api_path, From 19360c009cce5af6b69bd2eff53169858f7d1f07 Mon Sep 17 00:00:00 2001 From: JNickson Date: Fri, 23 May 2025 14:12:27 +0100 Subject: [PATCH 07/42] debug auth json --- deebot_client/authentication.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/deebot_client/authentication.py b/deebot_client/authentication.py index a300568e5..202635d71 100644 --- a/deebot_client/authentication.py +++ b/deebot_client/authentication.py @@ -298,6 +298,14 @@ async def post( headers=headers, timeout=_TIMEOUT, ) as res: + raw_content = await res.read() + _LOGGER.debug( + "Response info: status=%s, content_type=%s, headers=%s, raw=%s", + res.status, + res.content_type, + res.headers, + raw_content, + ) if res.status == HTTPStatus.OK: response_data: dict[str, Any] = await res.json() _LOGGER.debug( From 52cc9cd8b7efe231180e5a7f379bf7275134f91e Mon Sep 17 00:00:00 2001 From: Alec <30310787+alec-pinson@users.noreply.github.com> Date: Fri, 23 May 2025 15:53:57 +0100 Subject: [PATCH 08/42] another api required to get new auth token for control api --- deebot_client/command.py | 3 ++- deebot_client/const.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/deebot_client/command.py b/deebot_client/command.py index 3985cdf48..74c674719 100644 --- a/deebot_client/command.py +++ b/deebot_client/command.py @@ -163,8 +163,9 @@ async def _execute_api_request( "toType": device_info["class"], } + credentials = await authenticator.authenticate() + if self._api_path == PATH_API_IOT_DEVMANAGER: - credentials = await authenticator.authenticate() query_params = { "mid": payload["toType"], "did": payload["toId"], diff --git a/deebot_client/const.py b/deebot_client/const.py index e3d961ad1..592d6e9b0 100644 --- a/deebot_client/const.py +++ b/deebot_client/const.py @@ -10,6 +10,7 @@ PATH_API_PIM_PRODUCT_IOT_MAP = "pim/product/getProductIotMap" PATH_API_IOT_DEVMANAGER = "iot/devmanager.do" PATH_API_IOT_CONTROL = "iot/endpoint/control" +PATH_API_ISSUE_NEW_PERMISSION = "new-perm/token/sst/issue" PATH_API_LG_LOG = "lg/log.do" PATH_API_USERS_USER = "users/user.do" REQUEST_HEADERS = { From ebb08edc1fa2db401489755ebfdc7cae1af59526 Mon Sep 17 00:00:00 2001 From: Alec <30310787+alec-pinson@users.noreply.github.com> Date: Fri, 23 May 2025 16:18:36 +0100 Subject: [PATCH 09/42] begin adding new perms api --- deebot_client/authentication.py | 38 ++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/deebot_client/authentication.py b/deebot_client/authentication.py index 202635d71..b71e5d768 100644 --- a/deebot_client/authentication.py +++ b/deebot_client/authentication.py @@ -11,7 +11,7 @@ from aiohttp import ClientResponseError, ClientSession, ClientTimeout, hdrs -from .const import COUNTRY_CHINA, PATH_API_USERS_USER, REALM +from .const import COUNTRY_CHINA, PATH_API_USERS_USER, REALM, PATH_API_IOT_CONTROL, PATH_API_ISSUE_NEW_PERMISSION from .exceptions import ( ApiError, ApiTimeoutError, @@ -56,6 +56,7 @@ class RestConfiguration: portal_url: str login_url: str auth_code_url: str + api_base_url: str def create_rest_config( @@ -76,6 +77,7 @@ def create_rest_config( tld = "com" if alpha_2_country != COUNTRY_CHINA else country_url login_url = f"https://gl-{country_url}-api.ecovacs.{tld}" auth_code_url = f"https://gl-{country_url}-openapi.ecovacs.{tld}" + api_base_url = f"https://api-base.dc-{country_url}.ww.ecouser.{tld}" return RestConfiguration( session=session, @@ -84,6 +86,7 @@ def create_rest_config( portal_url=portal_url, login_url=login_url, auth_code_url=auth_code_url, + api_base_url=api_base_url, ) @@ -266,21 +269,30 @@ async def post( credentials: Credentials | None = None, ) -> dict[str, Any]: """Perform a post request.""" - url = urljoin(self._config.portal_url, "api/" + path) + if path == PATH_API_ISSUE_NEW_PERMISSION: + url == urljoin(self._config.api_base_url, "api/" + path) + else: + url = urljoin(self._config.portal_url, "api/" + path) + logger_request_params = f"url={url}, params={query_params}, json={json}" - if credentials is not None: - json.update( - { - "auth": { - "with": "users", - "userid": credentials.user_id, - "realm": REALM, - "token": credentials.token, - "resource": self._config.device_id, + if path == PATH_API_IOT_CONTROL or path == PATH_API_ISSUE_NEW_PERMISSION: + headers.update({ + "Authorization": f"Bearer {credentials.token}", + }) + else: + if credentials is not None: + json.update( + { + "auth": { + "with": "users", + "userid": credentials.user_id, + "realm": REALM, + "token": credentials.token, + "resource": self._config.device_id, + } } - } - ) + ) for i in range(MAX_RETRIES): _LOGGER.debug( From 05c625f994778fd69c9e881e9f630ef6f3ca652f Mon Sep 17 00:00:00 2001 From: Alec <30310787+alec-pinson@users.noreply.github.com> Date: Fri, 23 May 2025 16:20:53 +0100 Subject: [PATCH 10/42] move credentials if statement --- deebot_client/authentication.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/deebot_client/authentication.py b/deebot_client/authentication.py index b71e5d768..557a4367f 100644 --- a/deebot_client/authentication.py +++ b/deebot_client/authentication.py @@ -275,13 +275,12 @@ async def post( url = urljoin(self._config.portal_url, "api/" + path) logger_request_params = f"url={url}, params={query_params}, json={json}" - - if path == PATH_API_IOT_CONTROL or path == PATH_API_ISSUE_NEW_PERMISSION: - headers.update({ - "Authorization": f"Bearer {credentials.token}", - }) - else: - if credentials is not None: + if credentials is not None: + if path == PATH_API_IOT_CONTROL or path == PATH_API_ISSUE_NEW_PERMISSION: + headers.update({ + "Authorization": f"Bearer {credentials.token}", + }) + else: json.update( { "auth": { From 9eeefe3ae843aaed20d0a9bb0595ffb8c4c190e8 Mon Sep 17 00:00:00 2001 From: JNickson Date: Fri, 23 May 2025 17:59:13 +0100 Subject: [PATCH 11/42] sst token --- deebot_client/authentication.py | 59 +++++++++++++++++++++------------ deebot_client/command.py | 13 +++++++- 2 files changed, 50 insertions(+), 22 deletions(-) diff --git a/deebot_client/authentication.py b/deebot_client/authentication.py index 557a4367f..79fc7aed9 100644 --- a/deebot_client/authentication.py +++ b/deebot_client/authentication.py @@ -259,6 +259,8 @@ async def __call_login_by_it_token( raise AuthenticationError("failed to login with token") + + async def post( self, path: str, @@ -269,29 +271,21 @@ async def post( credentials: Credentials | None = None, ) -> dict[str, Any]: """Perform a post request.""" - if path == PATH_API_ISSUE_NEW_PERMISSION: - url == urljoin(self._config.api_base_url, "api/" + path) - else: - url = urljoin(self._config.portal_url, "api/" + path) - + url = urljoin(self._config.portal_url, "api/" + path) logger_request_params = f"url={url}, params={query_params}, json={json}" - if credentials is not None: - if path == PATH_API_IOT_CONTROL or path == PATH_API_ISSUE_NEW_PERMISSION: - headers.update({ - "Authorization": f"Bearer {credentials.token}", - }) - else: - json.update( - { - "auth": { - "with": "users", - "userid": credentials.user_id, - "realm": REALM, - "token": credentials.token, - "resource": self._config.device_id, - } + + if credentials is not None and (headers is None or "Authorization" not in headers): + json.update( + { + "auth": { + "with": "users", + "userid": credentials.user_id, + "realm": REALM, + "token": credentials.token, + "resource": self._config.device_id, } - ) + } + ) for i in range(MAX_RETRIES): _LOGGER.debug( @@ -424,6 +418,29 @@ async def post_authenticated( headers=headers, credentials=await self.authenticate(), ) + + async def get_sst_token(self, device_id: str, resource_id: str) -> str: + credentials = await self.authenticate() + perm_payload = { + "acl": [{ + "policy": [{ + "obj": [f"Endpoint:{resource_id}:{device_id}"], + "perms": ["Control"] + }], + "svc": "dim" + }], + "exp": 600, + "sub": resource_id + } + response = await self._auth_client.post( + PATH_API_ISSUE_NEW_PERMISSION, + perm_payload, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {credentials.token}" + } + ) + return response["data"]["data"]["token"] async def teardown(self) -> None: """Teardown authenticator.""" diff --git a/deebot_client/command.py b/deebot_client/command.py index 74c674719..0bc2463db 100644 --- a/deebot_client/command.py +++ b/deebot_client/command.py @@ -192,6 +192,10 @@ async def _execute_api_request( "tzm": 60, "tzc": "Europe/London" }) + device_id = device_info["did"] + resource_id = device_info["resource"] + + sst_token = await authenticator.get_sst_token(device_id, resource_id) query_params = { "fmt": self.DATA_TYPE.value, "ct": "q", @@ -201,11 +205,17 @@ async def _execute_api_request( "apn": self.NAME, # (clean|charge) "si": device_info["resource"] # new http param si (some random id which matches request header X-ECO-REQUEST-ID) } + + headers = { + **REQUEST_HEADERS, + "Authorization": f"Bearer {sst_token}", + } + return await authenticator.post_authenticated( self._api_path, body, query_params=query_params, - headers=REQUEST_HEADERS, + headers=headers, ) def __handle_response( @@ -236,6 +246,7 @@ def __handle_response( ) return CommandResult(HandlingState.ERROR) + @abstractmethod def _handle_response( self, event_bus: EventBus, response: dict[str, Any] From ce5c9fc3f2af9a132ef289bb701ca7eb6a730f2d Mon Sep 17 00:00:00 2001 From: JNickson Date: Fri, 23 May 2025 18:03:03 +0100 Subject: [PATCH 12/42] added base url --- deebot_client/authentication.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/deebot_client/authentication.py b/deebot_client/authentication.py index 79fc7aed9..f4c14785c 100644 --- a/deebot_client/authentication.py +++ b/deebot_client/authentication.py @@ -271,7 +271,10 @@ async def post( credentials: Credentials | None = None, ) -> dict[str, Any]: """Perform a post request.""" - url = urljoin(self._config.portal_url, "api/" + path) + if path == PATH_API_ISSUE_NEW_PERMISSION: + url = urljoin(self._config.api_base_url, "api/" + path) + else: + url = urljoin(self._config.portal_url, "api/" + path) logger_request_params = f"url={url}, params={query_params}, json={json}" if credentials is not None and (headers is None or "Authorization" not in headers): From df9f4466e2f3883d4a9ebc0c923ac424319985c2 Mon Sep 17 00:00:00 2001 From: Alec <30310787+alec-pinson@users.noreply.github.com> Date: Fri, 23 May 2025 18:11:15 +0100 Subject: [PATCH 13/42] added logging + corrected userid --- deebot_client/authentication.py | 37 ++++++++++++++++++++++++++------- deebot_client/command.py | 6 +++--- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/deebot_client/authentication.py b/deebot_client/authentication.py index f4c14785c..9d12020d0 100644 --- a/deebot_client/authentication.py +++ b/deebot_client/authentication.py @@ -259,7 +259,7 @@ async def __call_login_by_it_token( raise AuthenticationError("failed to login with token") - + async def post( self, @@ -299,6 +299,13 @@ async def post( ) try: + _LOGGER.debug( + "Request info: url=%s, json=%s, params=%s, headers=%s", + url, + json, + query_params, + headers, + ) async with self._config.session.post( url, json=json, @@ -421,7 +428,7 @@ async def post_authenticated( headers=headers, credentials=await self.authenticate(), ) - + async def get_sst_token(self, device_id: str, resource_id: str) -> str: credentials = await self.authenticate() perm_payload = { @@ -433,15 +440,31 @@ async def get_sst_token(self, device_id: str, resource_id: str) -> str: "svc": "dim" }], "exp": 600, - "sub": resource_id + "sub": credentials.user_id } + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {credentials.token}" + } + + _LOGGER.debug( + "Get SST Token Request info: url=%s, json=%s, headers=%s", + PATH_API_ISSUE_NEW_PERMISSION, + perm_payload, + headers, + ) response = await self._auth_client.post( PATH_API_ISSUE_NEW_PERMISSION, perm_payload, - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {credentials.token}" - } + headers=headers + ) + raw_content = await response.read() + _LOGGER.debug( + "Get SST Token Response info: status=%s, content_type=%s, headers=%s, raw=%s", + response.status, + response.content_type, + response.headers, + raw_content, ) return response["data"]["data"]["token"] diff --git a/deebot_client/command.py b/deebot_client/command.py index 0bc2463db..c630e875d 100644 --- a/deebot_client/command.py +++ b/deebot_client/command.py @@ -199,11 +199,11 @@ async def _execute_api_request( query_params = { "fmt": self.DATA_TYPE.value, "ct": "q", - "eid": device_info["did"], - "er": device_info["resource"], + "eid": device_id, + "er": resource_id, "et": device_info["class"], "apn": self.NAME, # (clean|charge) - "si": device_info["resource"] # new http param si (some random id which matches request header X-ECO-REQUEST-ID) + "si": resource_id # new http param si (some random id which matches request header X-ECO-REQUEST-ID) } headers = { From a3d7d82ce70cb7de7c43f90584687529b45b5937 Mon Sep 17 00:00:00 2001 From: Alec <30310787+alec-pinson@users.noreply.github.com> Date: Fri, 23 May 2025 18:46:06 +0100 Subject: [PATCH 14/42] some fixes --- deebot_client/authentication.py | 30 +++++++----------------------- deebot_client/command.py | 10 +++++----- 2 files changed, 12 insertions(+), 28 deletions(-) diff --git a/deebot_client/authentication.py b/deebot_client/authentication.py index 9d12020d0..58083645a 100644 --- a/deebot_client/authentication.py +++ b/deebot_client/authentication.py @@ -77,7 +77,7 @@ def create_rest_config( tld = "com" if alpha_2_country != COUNTRY_CHINA else country_url login_url = f"https://gl-{country_url}-api.ecovacs.{tld}" auth_code_url = f"https://gl-{country_url}-openapi.ecovacs.{tld}" - api_base_url = f"https://api-base.dc-{country_url}.ww.ecouser.{tld}" + api_base_url = f"https://api-base.dc{continent_postfix}.ww.ecouser.net" return RestConfiguration( session=session, @@ -429,12 +429,12 @@ async def post_authenticated( credentials=await self.authenticate(), ) - async def get_sst_token(self, device_id: str, resource_id: str) -> str: + async def get_sst_token(self, device_id: str, device_class: str) -> str: credentials = await self.authenticate() perm_payload = { "acl": [{ "policy": [{ - "obj": [f"Endpoint:{resource_id}:{device_id}"], + "obj": [f"Endpoint:{device_class}:{device_id}"], "perms": ["Control"] }], "svc": "dim" @@ -442,29 +442,13 @@ async def get_sst_token(self, device_id: str, resource_id: str) -> str: "exp": 600, "sub": credentials.user_id } - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {credentials.token}" - } - - _LOGGER.debug( - "Get SST Token Request info: url=%s, json=%s, headers=%s", - PATH_API_ISSUE_NEW_PERMISSION, - perm_payload, - headers, - ) response = await self._auth_client.post( PATH_API_ISSUE_NEW_PERMISSION, perm_payload, - headers=headers - ) - raw_content = await response.read() - _LOGGER.debug( - "Get SST Token Response info: status=%s, content_type=%s, headers=%s, raw=%s", - response.status, - response.content_type, - response.headers, - raw_content, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {credentials.token}" + } ) return response["data"]["data"]["token"] diff --git a/deebot_client/command.py b/deebot_client/command.py index c630e875d..0fa007fea 100644 --- a/deebot_client/command.py +++ b/deebot_client/command.py @@ -193,17 +193,17 @@ async def _execute_api_request( "tzc": "Europe/London" }) device_id = device_info["did"] - resource_id = device_info["resource"] + device_class = device_info["class"] - sst_token = await authenticator.get_sst_token(device_id, resource_id) + sst_token = await authenticator.get_sst_token(device_id, device_class) query_params = { "fmt": self.DATA_TYPE.value, "ct": "q", "eid": device_id, - "er": resource_id, - "et": device_info["class"], + "er": device_info["resource"], + "et": device_class, "apn": self.NAME, # (clean|charge) - "si": resource_id # new http param si (some random id which matches request header X-ECO-REQUEST-ID) + "si": device_info["resource"] # new http param si (some random id which matches request header X-ECO-REQUEST-ID) } headers = { From eb3e1eadfbe14ddad152fe2941392c20b4edf9ab Mon Sep 17 00:00:00 2001 From: JNickson Date: Fri, 23 May 2025 18:59:40 +0100 Subject: [PATCH 15/42] updating error checking --- deebot_client/authentication.py | 37 +++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/deebot_client/authentication.py b/deebot_client/authentication.py index 58083645a..361b16745 100644 --- a/deebot_client/authentication.py +++ b/deebot_client/authentication.py @@ -429,12 +429,12 @@ async def post_authenticated( credentials=await self.authenticate(), ) - async def get_sst_token(self, device_id: str, device_class: str) -> str: + async def get_sst_token(self, device_id: str, resource_id: str) -> str: credentials = await self.authenticate() perm_payload = { "acl": [{ "policy": [{ - "obj": [f"Endpoint:{device_class}:{device_id}"], + "obj": [f"Endpoint:{resource_id}:{device_id}"], "perms": ["Control"] }], "svc": "dim" @@ -442,15 +442,30 @@ async def get_sst_token(self, device_id: str, device_class: str) -> str: "exp": 600, "sub": credentials.user_id } - response = await self._auth_client.post( - PATH_API_ISSUE_NEW_PERMISSION, - perm_payload, - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {credentials.token}" - } - ) - return response["data"]["data"]["token"] + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {credentials.token}" + } + + try: + _LOGGER.debug( + "Requesting SST token with payload: %s", perm_payload + ) + response = await self._auth_client.post( + PATH_API_ISSUE_NEW_PERMISSION, + perm_payload, + headers=headers + ) + + # Check response content + if response.get("code") != 0 or "data" not in response or "data" not in response["data"]: + raise AuthenticationError(f"Invalid SST token response: {response}") + + return response["data"]["data"]["token"] + + except (ApiTimeoutError, ClientResponseError, ApiError, AuthenticationError) as err: + _LOGGER.error("Failed to get SST token: %s", err) + raise async def teardown(self) -> None: """Teardown authenticator.""" From 79ec9780326c43cbdcb39ee4829f49410f28178c Mon Sep 17 00:00:00 2001 From: Alec <30310787+alec-pinson@users.noreply.github.com> Date: Fri, 23 May 2025 19:07:05 +0100 Subject: [PATCH 16/42] Revert "updating error checking" This reverts commit eb3e1eadfbe14ddad152fe2941392c20b4edf9ab. --- deebot_client/authentication.py | 37 ++++++++++----------------------- 1 file changed, 11 insertions(+), 26 deletions(-) diff --git a/deebot_client/authentication.py b/deebot_client/authentication.py index 361b16745..58083645a 100644 --- a/deebot_client/authentication.py +++ b/deebot_client/authentication.py @@ -429,12 +429,12 @@ async def post_authenticated( credentials=await self.authenticate(), ) - async def get_sst_token(self, device_id: str, resource_id: str) -> str: + async def get_sst_token(self, device_id: str, device_class: str) -> str: credentials = await self.authenticate() perm_payload = { "acl": [{ "policy": [{ - "obj": [f"Endpoint:{resource_id}:{device_id}"], + "obj": [f"Endpoint:{device_class}:{device_id}"], "perms": ["Control"] }], "svc": "dim" @@ -442,30 +442,15 @@ async def get_sst_token(self, device_id: str, resource_id: str) -> str: "exp": 600, "sub": credentials.user_id } - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {credentials.token}" - } - - try: - _LOGGER.debug( - "Requesting SST token with payload: %s", perm_payload - ) - response = await self._auth_client.post( - PATH_API_ISSUE_NEW_PERMISSION, - perm_payload, - headers=headers - ) - - # Check response content - if response.get("code") != 0 or "data" not in response or "data" not in response["data"]: - raise AuthenticationError(f"Invalid SST token response: {response}") - - return response["data"]["data"]["token"] - - except (ApiTimeoutError, ClientResponseError, ApiError, AuthenticationError) as err: - _LOGGER.error("Failed to get SST token: %s", err) - raise + response = await self._auth_client.post( + PATH_API_ISSUE_NEW_PERMISSION, + perm_payload, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {credentials.token}" + } + ) + return response["data"]["data"]["token"] async def teardown(self) -> None: """Teardown authenticator.""" From 3b73ef5ac68ed2145e3d159b0704d72eb513ff71 Mon Sep 17 00:00:00 2001 From: Alec <30310787+alec-pinson@users.noreply.github.com> Date: Fri, 23 May 2025 19:30:41 +0100 Subject: [PATCH 17/42] setError comment --- deebot_client/command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deebot_client/command.py b/deebot_client/command.py index 0fa007fea..8cf49afce 100644 --- a/deebot_client/command.py +++ b/deebot_client/command.py @@ -202,7 +202,7 @@ async def _execute_api_request( "eid": device_id, "er": device_info["resource"], "et": device_class, - "apn": self.NAME, # (clean|charge) + "apn": self.NAME, # (clean|charge|setError) "si": device_info["resource"] # new http param si (some random id which matches request header X-ECO-REQUEST-ID) } From b5a916d8935568a3b2a2032db4ae164f91bc490f Mon Sep 17 00:00:00 2001 From: Alec <30310787+alec-pinson@users.noreply.github.com> Date: Fri, 23 May 2025 19:37:38 +0100 Subject: [PATCH 18/42] add set_error --- deebot_client/commands/json/set_error.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 deebot_client/commands/json/set_error.py diff --git a/deebot_client/commands/json/set_error.py b/deebot_client/commands/json/set_error.py new file mode 100644 index 000000000..a8fc09667 --- /dev/null +++ b/deebot_client/commands/json/set_error.py @@ -0,0 +1,21 @@ +"""SetError commands.""" + +from __future__ import annotations + +from .common import ExecuteCommand + +from deebot_client.const import PATH_API_IOT_CONTROL + +class SetError(ExecuteCommand): + """SetError state command.""" + + NAME = "setError" + + def __init__(self, code: int) -> None: + super().__init__({ + "data": { + "act": "remove", + "code": [code] + } + }) + self._api_path = PATH_API_IOT_CONTROL From f628a7b3d9d32d171d04756b63143005c27ccdaa Mon Sep 17 00:00:00 2001 From: JNickson Date: Fri, 23 May 2025 19:45:34 +0100 Subject: [PATCH 19/42] added set errors for cleanv3 cannot inherit from clean --- deebot_client/commands/json/clean.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/deebot_client/commands/json/clean.py b/deebot_client/commands/json/clean.py index 1d279b6a9..e48be72d7 100644 --- a/deebot_client/commands/json/clean.py +++ b/deebot_client/commands/json/clean.py @@ -11,6 +11,7 @@ from deebot_client.const import PATH_API_IOT_CONTROL from .common import ExecuteCommand, JsonCommandWithMessageHandling +from .set_error import SetError if TYPE_CHECKING: from deebot_client.authentication import Authenticator @@ -104,25 +105,33 @@ def _get_args(self, action: CleanAction) -> dict[str, Any]: args["content"].update(self._additional_content) return args -class CleanV3(Clean): +class CleanV3(ExecuteCommand): """Clean V3 command.""" NAME = "clean" def __init__(self, action: CleanAction) -> None: - super().__init__(action) + self._action = action + super().__init__(self._get_args(action)) self._api_path = PATH_API_IOT_CONTROL def _get_args(self, action: CleanAction) -> dict[str, Any]: - content: dict[str, str] = {} + content = {} args = {"act": action.value, "content": content} - match action: - case CleanAction.START: - content["type"] = CleanMode.AUTO.value - case CleanAction.STOP | CleanAction.PAUSE: - content["type"] = "" + if action == CleanAction.START: + content["type"] = CleanMode.AUTO.value return args + async def _execute(self, authenticator, device_info, event_bus): + # 🔧 Clear error before starting + if self._action == CleanAction.START: + try: + await SetError(505).execute(authenticator, device_info, event_bus) + except Exception: + _LOGGER.warning("Could not clear error 505") + + return await super()._execute(authenticator, device_info, event_bus) + class CleanAreaV3(CleanV3): """Clean area command.""" From e033e06b7fe3b60b24e153430897e88d443647cb Mon Sep 17 00:00:00 2001 From: JNickson Date: Fri, 23 May 2025 19:47:37 +0100 Subject: [PATCH 20/42] added clean logic in cleanv3 --- deebot_client/commands/json/clean.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/deebot_client/commands/json/clean.py b/deebot_client/commands/json/clean.py index e48be72d7..dadb3359c 100644 --- a/deebot_client/commands/json/clean.py +++ b/deebot_client/commands/json/clean.py @@ -120,16 +120,37 @@ def _get_args(self, action: CleanAction) -> dict[str, Any]: args = {"act": action.value, "content": content} if action == CleanAction.START: content["type"] = CleanMode.AUTO.value + elif action in (CleanAction.STOP, CleanAction.PAUSE): + content["type"] = "" return args - async def _execute(self, authenticator, device_info, event_bus): - # 🔧 Clear error before starting + async def _execute( + self, + authenticator: Authenticator, + device_info: ApiDeviceInfo, + event_bus: EventBus, + ) -> tuple[CommandResult, dict[str, Any]]: + # Clear error before starting if self._action == CleanAction.START: try: await SetError(505).execute(authenticator, device_info, event_bus) except Exception: _LOGGER.warning("Could not clear error 505") + # Resume ↔ Start logic + state = event_bus.get_last_event(StateEvent) + if state and isinstance(self._args, dict): + if ( + self._args["act"] == CleanAction.RESUME.value + and state.state != State.PAUSED + ): + self._args = self._get_args(CleanAction.START) + elif ( + self._args["act"] == CleanAction.START.value + and state.state == State.PAUSED + ): + self._args = self._get_args(CleanAction.RESUME) + return await super()._execute(authenticator, device_info, event_bus) From 1a67bf99dd13db4de04603d91bb115722c43209e Mon Sep 17 00:00:00 2001 From: JNickson Date: Fri, 23 May 2025 19:54:35 +0100 Subject: [PATCH 21/42] after start clear 505 + extra resume step --- deebot_client/commands/json/clean.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/deebot_client/commands/json/clean.py b/deebot_client/commands/json/clean.py index dadb3359c..90f6ba4ff 100644 --- a/deebot_client/commands/json/clean.py +++ b/deebot_client/commands/json/clean.py @@ -130,13 +130,6 @@ async def _execute( device_info: ApiDeviceInfo, event_bus: EventBus, ) -> tuple[CommandResult, dict[str, Any]]: - # Clear error before starting - if self._action == CleanAction.START: - try: - await SetError(505).execute(authenticator, device_info, event_bus) - except Exception: - _LOGGER.warning("Could not clear error 505") - # Resume ↔ Start logic state = event_bus.get_last_event(StateEvent) if state and isinstance(self._args, dict): @@ -151,7 +144,21 @@ async def _execute( ): self._args = self._get_args(CleanAction.RESUME) - return await super()._execute(authenticator, device_info, event_bus) + result = await super()._execute(authenticator, device_info, event_bus) + + if self._action == CleanAction.START: + try: + await SetError(505).execute(authenticator, device_info, event_bus) + except Exception: + _LOGGER.warning("Could not clear error 505") + + # Do an extra resume if required + try: + await CleanV3(CleanAction.RESUME).execute(authenticator, device_info, event_bus) + except Exception: + _LOGGER.warning("Could not resume after clearing error") + + return result class CleanAreaV3(CleanV3): From 0c00cac2a0b58038db58d9185d4056e7a675793a Mon Sep 17 00:00:00 2001 From: JNickson Date: Fri, 23 May 2025 20:12:09 +0100 Subject: [PATCH 22/42] added debug for action executed --- deebot_client/commands/json/clean.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/deebot_client/commands/json/clean.py b/deebot_client/commands/json/clean.py index 90f6ba4ff..6c10c58a9 100644 --- a/deebot_client/commands/json/clean.py +++ b/deebot_client/commands/json/clean.py @@ -146,7 +146,9 @@ async def _execute( result = await super()._execute(authenticator, device_info, event_bus) - if self._action == CleanAction.START: + _LOGGER.debug("Post-clean action executed: %s", self._args.get("act")) + + if self._args.get("act") == CleanAction.START.value: try: await SetError(505).execute(authenticator, device_info, event_bus) except Exception: From 698afeb42effe9c2d5d823694fa546882729e90e Mon Sep 17 00:00:00 2001 From: JNickson Date: Fri, 23 May 2025 20:28:15 +0100 Subject: [PATCH 23/42] added resume to args --- deebot_client/commands/json/clean.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/deebot_client/commands/json/clean.py b/deebot_client/commands/json/clean.py index 6c10c58a9..d143748c2 100644 --- a/deebot_client/commands/json/clean.py +++ b/deebot_client/commands/json/clean.py @@ -118,10 +118,11 @@ def __init__(self, action: CleanAction) -> None: def _get_args(self, action: CleanAction) -> dict[str, Any]: content = {} args = {"act": action.value, "content": content} - if action == CleanAction.START: - content["type"] = CleanMode.AUTO.value - elif action in (CleanAction.STOP, CleanAction.PAUSE): - content["type"] = "" + match action: + case CleanAction.START | CleanAction.RESUME: + content["type"] = CleanMode.AUTO.value + case CleanAction.STOP | CleanAction.PAUSE: + content["type"] = "" return args async def _execute( From a089f3cd6b40ec5722de04e3415597360d9a6aa9 Mon Sep 17 00:00:00 2001 From: JNickson Date: Fri, 23 May 2025 20:54:59 +0100 Subject: [PATCH 24/42] set errors no data --- deebot_client/commands/json/set_error.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/deebot_client/commands/json/set_error.py b/deebot_client/commands/json/set_error.py index a8fc09667..0a1815fd3 100644 --- a/deebot_client/commands/json/set_error.py +++ b/deebot_client/commands/json/set_error.py @@ -13,9 +13,7 @@ class SetError(ExecuteCommand): def __init__(self, code: int) -> None: super().__init__({ - "data": { - "act": "remove", - "code": [code] - } + "act": "remove", + "code": [code] }) self._api_path = PATH_API_IOT_CONTROL From 3d4004c8496bbd60774b24f26d5f4e8f665d0ebd Mon Sep 17 00:00:00 2001 From: JNickson Date: Fri, 23 May 2025 21:44:58 +0100 Subject: [PATCH 25/42] added state check before error and resume to make sure in paused --- deebot_client/commands/json/clean.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/deebot_client/commands/json/clean.py b/deebot_client/commands/json/clean.py index d143748c2..83937db9d 100644 --- a/deebot_client/commands/json/clean.py +++ b/deebot_client/commands/json/clean.py @@ -150,16 +150,17 @@ async def _execute( _LOGGER.debug("Post-clean action executed: %s", self._args.get("act")) if self._args.get("act") == CleanAction.START.value: - try: - await SetError(505).execute(authenticator, device_info, event_bus) - except Exception: - _LOGGER.warning("Could not clear error 505") - - # Do an extra resume if required - try: - await CleanV3(CleanAction.RESUME).execute(authenticator, device_info, event_bus) - except Exception: - _LOGGER.warning("Could not resume after clearing error") + new_state = event_bus.get_last_event(StateEvent) + if new_state and new_state.state == State.PAUSED: + try: + await SetError(505).execute(authenticator, device_info, event_bus) + except Exception: + _LOGGER.warning("Could not clear error 505") + + try: + await CleanV3(CleanAction.RESUME).execute(authenticator, device_info, event_bus) + except Exception: + _LOGGER.warning("Could not resume after clearing error") return result From 64de88a5ff728da66038cab13122440be83942f6 Mon Sep 17 00:00:00 2001 From: Alec <30310787+alec-pinson@users.noreply.github.com> Date: Sat, 24 May 2025 09:00:44 +0100 Subject: [PATCH 26/42] slight cleanup --- deebot_client/authentication.py | 2 -- deebot_client/command.py | 16 +++++++--------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/deebot_client/authentication.py b/deebot_client/authentication.py index 58083645a..9a65cd0dc 100644 --- a/deebot_client/authentication.py +++ b/deebot_client/authentication.py @@ -259,8 +259,6 @@ async def __call_login_by_it_token( raise AuthenticationError("failed to login with token") - - async def post( self, path: str, diff --git a/deebot_client/command.py b/deebot_client/command.py index 8cf49afce..b59e28a24 100644 --- a/deebot_client/command.py +++ b/deebot_client/command.py @@ -152,15 +152,14 @@ async def _execute( async def _execute_api_request( self, authenticator: Authenticator, device_info: ApiDeviceInfo ) -> dict[str, Any]: - payload = { - "cmdName": self.NAME, - "payload": self._get_payload(), - "payloadType": self.DATA_TYPE.value, - "td": "q", - "toId": device_info["did"], - "toRes": device_info["resource"], - "toType": device_info["class"], + "cmdName": self.NAME, + "payload": self._get_payload(), + "payloadType": self.DATA_TYPE.value, + "td": "q", + "toId": device_info["did"], + "toRes": device_info["resource"], + "toType": device_info["class"], } credentials = await authenticator.authenticate() @@ -246,7 +245,6 @@ def __handle_response( ) return CommandResult(HandlingState.ERROR) - @abstractmethod def _handle_response( self, event_bus: EventBus, response: dict[str, Any] From 07158de7f5f0c9355e80dbc71f39f7baef5f2718 Mon Sep 17 00:00:00 2001 From: Alec <30310787+alec-pinson@users.noreply.github.com> Date: Sat, 24 May 2025 09:48:19 +0100 Subject: [PATCH 27/42] try clear error 505 on get error --- deebot_client/commands/json/error.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/deebot_client/commands/json/error.py b/deebot_client/commands/json/error.py index 8f9bfc051..ab0db2dfc 100644 --- a/deebot_client/commands/json/error.py +++ b/deebot_client/commands/json/error.py @@ -8,12 +8,15 @@ from deebot_client.events import ErrorEvent, StateEvent from deebot_client.message import HandlingResult, MessageBodyDataDict from deebot_client.models import State +from deebot_client.logging_filter import get_logger from .common import JsonCommandWithMessageHandling +from .set_error import SetError if TYPE_CHECKING: from deebot_client.event_bus import EventBus +_LOGGER = get_logger(__name__) class GetError(JsonCommandWithMessageHandling, MessageBodyDataDict): """Get error command.""" @@ -34,6 +37,10 @@ def _handle_body_data_dict( error: int | None = 0 + if 505 in codes: + _LOGGER.debug("Clearing error 505") + SetError(505).execute(event_bus.authenticator, event_bus.device_info, event_bus) + if codes: # the last error code error = codes[-1] From 409dc4b6473e4a88728a8e15e62c55d6bdf1b72b Mon Sep 17 00:00:00 2001 From: JNickson Date: Sat, 24 May 2025 10:12:06 +0100 Subject: [PATCH 28/42] updated set error and clean --- deebot_client/commands/json/clean.py | 13 ------------- deebot_client/commands/json/error.py | 5 ++++- deebot_client/commands/json/set_error.py | 14 ++++++++++++++ 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/deebot_client/commands/json/clean.py b/deebot_client/commands/json/clean.py index 83937db9d..96e515656 100644 --- a/deebot_client/commands/json/clean.py +++ b/deebot_client/commands/json/clean.py @@ -149,19 +149,6 @@ async def _execute( _LOGGER.debug("Post-clean action executed: %s", self._args.get("act")) - if self._args.get("act") == CleanAction.START.value: - new_state = event_bus.get_last_event(StateEvent) - if new_state and new_state.state == State.PAUSED: - try: - await SetError(505).execute(authenticator, device_info, event_bus) - except Exception: - _LOGGER.warning("Could not clear error 505") - - try: - await CleanV3(CleanAction.RESUME).execute(authenticator, device_info, event_bus) - except Exception: - _LOGGER.warning("Could not resume after clearing error") - return result diff --git a/deebot_client/commands/json/error.py b/deebot_client/commands/json/error.py index ab0db2dfc..e081e0069 100644 --- a/deebot_client/commands/json/error.py +++ b/deebot_client/commands/json/error.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any +import asyncio from deebot_client.const import ERROR_CODES from deebot_client.events import ErrorEvent, StateEvent @@ -39,7 +40,9 @@ def _handle_body_data_dict( if 505 in codes: _LOGGER.debug("Clearing error 505") - SetError(505).execute(event_bus.authenticator, event_bus.device_info, event_bus) + asyncio.create_task( + SetError(505).execute(event_bus.authenticator, event_bus.device_info, event_bus) + ) if codes: # the last error code diff --git a/deebot_client/commands/json/set_error.py b/deebot_client/commands/json/set_error.py index 0a1815fd3..4e41007e7 100644 --- a/deebot_client/commands/json/set_error.py +++ b/deebot_client/commands/json/set_error.py @@ -5,6 +5,12 @@ from .common import ExecuteCommand from deebot_client.const import PATH_API_IOT_CONTROL +from typing import Any, TYPE_CHECKING +if TYPE_CHECKING: + from deebot_client.authentication import Authenticator + from deebot_client.models import ApiDeviceInfo + from deebot_client.command import CommandResult + from deebot_client.event_bus import EventBus class SetError(ExecuteCommand): """SetError state command.""" @@ -17,3 +23,11 @@ def __init__(self, code: int) -> None: "code": [code] }) self._api_path = PATH_API_IOT_CONTROL + + async def _execute( + self, + authenticator: Authenticator, + device_info: ApiDeviceInfo, + event_bus: EventBus, + ) -> tuple[CommandResult, dict[str, Any]]: + return await super()._execute(authenticator, device_info, event_bus) From f2df532ca30d050b77cf981150eab73e20411078 Mon Sep 17 00:00:00 2001 From: JNickson Date: Sat, 24 May 2025 10:16:48 +0100 Subject: [PATCH 29/42] added resume if still paused after start --- deebot_client/commands/json/clean.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/deebot_client/commands/json/clean.py b/deebot_client/commands/json/clean.py index 96e515656..32ce6c8b6 100644 --- a/deebot_client/commands/json/clean.py +++ b/deebot_client/commands/json/clean.py @@ -146,6 +146,13 @@ async def _execute( self._args = self._get_args(CleanAction.RESUME) result = await super()._execute(authenticator, device_info, event_bus) + if self._args.get("act") == CleanAction.START.value: + new_state = event_bus.get_last_event(StateEvent) + if new_state and new_state.state == State.PAUSED: + try: + await CleanV3(CleanAction.RESUME)._execute(authenticator, device_info, event_bus) + except Exception: + _LOGGER.warning("Could not resume after clearing error") _LOGGER.debug("Post-clean action executed: %s", self._args.get("act")) From 8f36c1a5b41c073f1de8decb4cc0bbe3a5004d7f Mon Sep 17 00:00:00 2001 From: JNickson Date: Sat, 24 May 2025 10:26:09 +0100 Subject: [PATCH 30/42] added _execute --- deebot_client/commands/json/error.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deebot_client/commands/json/error.py b/deebot_client/commands/json/error.py index e081e0069..806597046 100644 --- a/deebot_client/commands/json/error.py +++ b/deebot_client/commands/json/error.py @@ -41,7 +41,7 @@ def _handle_body_data_dict( if 505 in codes: _LOGGER.debug("Clearing error 505") asyncio.create_task( - SetError(505).execute(event_bus.authenticator, event_bus.device_info, event_bus) + SetError(505)._execute(event_bus.authenticator, event_bus.device_info, event_bus) ) if codes: From cdd445fd70a2d4d1cb1d817d6e3874560dd67155 Mon Sep 17 00:00:00 2001 From: JNickson Date: Sat, 24 May 2025 10:44:54 +0100 Subject: [PATCH 31/42] removed clean execute resume part --- deebot_client/commands/json/clean.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/deebot_client/commands/json/clean.py b/deebot_client/commands/json/clean.py index 32ce6c8b6..b3309e074 100644 --- a/deebot_client/commands/json/clean.py +++ b/deebot_client/commands/json/clean.py @@ -146,15 +146,6 @@ async def _execute( self._args = self._get_args(CleanAction.RESUME) result = await super()._execute(authenticator, device_info, event_bus) - if self._args.get("act") == CleanAction.START.value: - new_state = event_bus.get_last_event(StateEvent) - if new_state and new_state.state == State.PAUSED: - try: - await CleanV3(CleanAction.RESUME)._execute(authenticator, device_info, event_bus) - except Exception: - _LOGGER.warning("Could not resume after clearing error") - - _LOGGER.debug("Post-clean action executed: %s", self._args.get("act")) return result From de433bad88da12b2c3055e8eea5b1f13bfb381f4 Mon Sep 17 00:00:00 2001 From: Alec <30310787+alec-pinson@users.noreply.github.com> Date: Sat, 24 May 2025 11:45:35 +0100 Subject: [PATCH 32/42] send resume and start if docked --- deebot_client/commands/json/clean.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/deebot_client/commands/json/clean.py b/deebot_client/commands/json/clean.py index b3309e074..c9e497f89 100644 --- a/deebot_client/commands/json/clean.py +++ b/deebot_client/commands/json/clean.py @@ -135,6 +135,20 @@ async def _execute( state = event_bus.get_last_event(StateEvent) if state and isinstance(self._args, dict): if ( + state.state == State.DOCKED + and self._args["act"] == CleanAction.START.value + ): + # always send resume whenever we send start if docked + # because we can't tell if state is paused or not + resume_cmd = CleanV3(CleanAction.RESUME) + await resume_cmd._execute(authenticator, device_info, event_bus) + elif ( + state.state == State.DOCKED + and self._args["act"] == CleanAction.RESUME.value + ): + # if docked and resume, send resume... + self._args = self._get_args(CleanAction.RESUME) + elif ( self._args["act"] == CleanAction.RESUME.value and state.state != State.PAUSED ): From b618b3d15aa2bc7443ae45986426c96c8bd5a2ed Mon Sep 17 00:00:00 2001 From: Alec <30310787+alec-pinson@users.noreply.github.com> Date: Sun, 25 May 2025 09:48:45 +0100 Subject: [PATCH 33/42] remove GetCleanInfoV3 --- deebot_client/commands/json/__init__.py | 5 +---- deebot_client/commands/json/clean.py | 18 +++++------------- deebot_client/hardware/deebot/2px96q.py | 4 ++-- 3 files changed, 8 insertions(+), 19 deletions(-) diff --git a/deebot_client/commands/json/__init__.py b/deebot_client/commands/json/__init__.py index e5c52cc47..0987bebe8 100644 --- a/deebot_client/commands/json/__init__.py +++ b/deebot_client/commands/json/__init__.py @@ -12,7 +12,7 @@ from .charge import Charge from .charge_state import GetChargeState from .child_lock import GetChildLock, SetChildLock -from .clean import Clean, CleanArea, CleanV2, CleanV3, GetCleanInfo, GetCleanInfoV2, GetCleanInfoV3 +from .clean import Clean, CleanArea, CleanV2, CleanV3, GetCleanInfo, GetCleanInfoV2 from .clean_count import GetCleanCount, SetCleanCount from .clean_logs import GetCleanLogs from .clean_preference import GetCleanPreference, SetCleanPreference @@ -71,7 +71,6 @@ "GetCleanCount", "GetCleanInfo", "GetCleanInfoV2", - "GetCleanInfoV3", "GetCleanLogs", "GetCleanPreference", "GetContinuousCleaning", @@ -166,8 +165,6 @@ CleanArea, GetCleanInfo, GetCleanInfoV2, - GetCleanInfoV3, - GetCleanLogs, diff --git a/deebot_client/commands/json/clean.py b/deebot_client/commands/json/clean.py index c9e497f89..e6221c6b6 100644 --- a/deebot_client/commands/json/clean.py +++ b/deebot_client/commands/json/clean.py @@ -135,19 +135,16 @@ async def _execute( state = event_bus.get_last_event(StateEvent) if state and isinstance(self._args, dict): if ( - state.state == State.DOCKED - and self._args["act"] == CleanAction.START.value - ): - # always send resume whenever we send start if docked - # because we can't tell if state is paused or not - resume_cmd = CleanV3(CleanAction.RESUME) - await resume_cmd._execute(authenticator, device_info, event_bus) - elif ( state.state == State.DOCKED and self._args["act"] == CleanAction.RESUME.value ): # if docked and resume, send resume... self._args = self._get_args(CleanAction.RESUME) + elif ( + state.state == State.DOCKED + and self._args["act"] == CleanAction.START.value + ): + self._args = self._get_args(CleanAction.START) elif ( self._args["act"] == CleanAction.RESUME.value and state.state != State.PAUSED @@ -233,8 +230,3 @@ class GetCleanInfoV2(GetCleanInfo): """Get clean info v2 command.""" NAME = "getCleanInfo_V2" - -class GetCleanInfoV3(GetCleanInfo): - """Get clean info v3 command.""" - - NAME = "getCleanInfo_V3" diff --git a/deebot_client/hardware/deebot/2px96q.py b/deebot_client/hardware/deebot/2px96q.py index 6c1cb9280..ad84c0fc6 100644 --- a/deebot_client/hardware/deebot/2px96q.py +++ b/deebot_client/hardware/deebot/2px96q.py @@ -34,7 +34,7 @@ from deebot_client.commands.json.battery import GetBattery from deebot_client.commands.json.charge import Charge from deebot_client.commands.json.charge_state import GetChargeState -from deebot_client.commands.json.clean import CleanV3, GetCleanInfoV3 +from deebot_client.commands.json.clean import CleanV3, GetCleanInfoV2 from deebot_client.commands.json.custom import CustomCommand from deebot_client.commands.json.error import GetError from deebot_client.commands.json.life_span import GetLifeSpan, ResetLifeSpan @@ -131,7 +131,7 @@ ), volume=CapabilitySet(VolumeEvent, [GetVolume()], SetVolume), ), - state=CapabilityEvent(StateEvent, [GetChargeState(), GetCleanInfoV3()]), + state=CapabilityEvent(StateEvent, [GetChargeState(), GetCleanInfoV2()]), stats=CapabilityStats( clean=CapabilityEvent(StatsEvent, [GetStats()]), report=CapabilityEvent(ReportStatsEvent, []), From 8853022c5cb6851deac53a1c5ef2da00cfe40620 Mon Sep 17 00:00:00 2001 From: Alec <30310787+alec-pinson@users.noreply.github.com> Date: Tue, 27 May 2025 09:25:17 +0100 Subject: [PATCH 34/42] cleanup --- deebot_client/commands/json/clean.py | 13 ------------- deebot_client/commands/json/error.py | 27 +++++++++++++++++++++++++-- deebot_client/const.py | 1 + 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/deebot_client/commands/json/clean.py b/deebot_client/commands/json/clean.py index e6221c6b6..045ff3e7e 100644 --- a/deebot_client/commands/json/clean.py +++ b/deebot_client/commands/json/clean.py @@ -11,7 +11,6 @@ from deebot_client.const import PATH_API_IOT_CONTROL from .common import ExecuteCommand, JsonCommandWithMessageHandling -from .set_error import SetError if TYPE_CHECKING: from deebot_client.authentication import Authenticator @@ -135,17 +134,6 @@ async def _execute( state = event_bus.get_last_event(StateEvent) if state and isinstance(self._args, dict): if ( - state.state == State.DOCKED - and self._args["act"] == CleanAction.RESUME.value - ): - # if docked and resume, send resume... - self._args = self._get_args(CleanAction.RESUME) - elif ( - state.state == State.DOCKED - and self._args["act"] == CleanAction.START.value - ): - self._args = self._get_args(CleanAction.START) - elif ( self._args["act"] == CleanAction.RESUME.value and state.state != State.PAUSED ): @@ -160,7 +148,6 @@ async def _execute( return result - class CleanAreaV3(CleanV3): """Clean area command.""" diff --git a/deebot_client/commands/json/error.py b/deebot_client/commands/json/error.py index 806597046..e88f618f8 100644 --- a/deebot_client/commands/json/error.py +++ b/deebot_client/commands/json/error.py @@ -11,10 +11,13 @@ from deebot_client.models import State from deebot_client.logging_filter import get_logger -from .common import JsonCommandWithMessageHandling -from .set_error import SetError +from .common import JsonCommandWithMessageHandling, ExecuteCommand +from deebot_client.const import PATH_API_IOT_CONTROL if TYPE_CHECKING: + from deebot_client.authentication import Authenticator + from deebot_client.models import ApiDeviceInfo + from deebot_client.command import CommandResult from deebot_client.event_bus import EventBus _LOGGER = get_logger(__name__) @@ -56,3 +59,23 @@ def _handle_body_data_dict( return HandlingResult.success() return HandlingResult.analyse() + +class SetError(ExecuteCommand): + """SetError state command.""" + + NAME = "setError" + + def __init__(self, code: int) -> None: + super().__init__({ + "act": "remove", + "code": [code] + }) + self._api_path = PATH_API_IOT_CONTROL + + async def _execute( + self, + authenticator: Authenticator, + device_info: ApiDeviceInfo, + event_bus: EventBus, + ) -> tuple[CommandResult, dict[str, Any]]: + return await super()._execute(authenticator, device_info, event_bus) diff --git a/deebot_client/const.py b/deebot_client/const.py index 592d6e9b0..6ee5445de 100644 --- a/deebot_client/const.py +++ b/deebot_client/const.py @@ -106,6 +106,7 @@ class UndefinedType(Enum): 319: "Cleaning solution is running low", 404: "Recipient unavailable", 500: "Request Timeout", + 505: "An error occurred, please clear it and try again", 601: "ERROR_ClosedAIVISideAbnormal", 602: "ClosedAIVIRollAbnormal", 1007: "Mop plugged", From 7aa74914daaad2b8d2ee0232382fc2351539e09f Mon Sep 17 00:00:00 2001 From: Alec <30310787+alec-pinson@users.noreply.github.com> Date: Tue, 27 May 2025 09:31:46 +0100 Subject: [PATCH 35/42] configured precommit From 5820e172dad548f56945912e6a70164bd0ed7796 Mon Sep 17 00:00:00 2001 From: Alec <30310787+alec-pinson@users.noreply.github.com> Date: Tue, 27 May 2025 09:54:19 +0100 Subject: [PATCH 36/42] fix precommit --- deebot_client/authentication.py | 1 + deebot_client/commands/json/error.py | 9 ++++--- deebot_client/commands/json/set_error.py | 33 ------------------------ 3 files changed, 7 insertions(+), 36 deletions(-) delete mode 100644 deebot_client/commands/json/set_error.py diff --git a/deebot_client/authentication.py b/deebot_client/authentication.py index 9a65cd0dc..3999cd3e4 100644 --- a/deebot_client/authentication.py +++ b/deebot_client/authentication.py @@ -428,6 +428,7 @@ async def post_authenticated( ) async def get_sst_token(self, device_id: str, device_class: str) -> str: + """Get access token for a device.""" credentials = await self.authenticate() perm_payload = { "acl": [{ diff --git a/deebot_client/commands/json/error.py b/deebot_client/commands/json/error.py index e88f618f8..1f8ba9be4 100644 --- a/deebot_client/commands/json/error.py +++ b/deebot_client/commands/json/error.py @@ -41,11 +41,14 @@ def _handle_body_data_dict( error: int | None = 0 + background_tasks = set() if 505 in codes: _LOGGER.debug("Clearing error 505") - asyncio.create_task( - SetError(505)._execute(event_bus.authenticator, event_bus.device_info, event_bus) + task = asyncio.create_task( + SetError(505).execute(event_bus.authenticator, event_bus.device_info, event_bus) ) + background_tasks.add(task) + task.add_done_callback(background_tasks.discard) if codes: # the last error code @@ -72,7 +75,7 @@ def __init__(self, code: int) -> None: }) self._api_path = PATH_API_IOT_CONTROL - async def _execute( + async def execute( self, authenticator: Authenticator, device_info: ApiDeviceInfo, diff --git a/deebot_client/commands/json/set_error.py b/deebot_client/commands/json/set_error.py deleted file mode 100644 index 4e41007e7..000000000 --- a/deebot_client/commands/json/set_error.py +++ /dev/null @@ -1,33 +0,0 @@ -"""SetError commands.""" - -from __future__ import annotations - -from .common import ExecuteCommand - -from deebot_client.const import PATH_API_IOT_CONTROL -from typing import Any, TYPE_CHECKING -if TYPE_CHECKING: - from deebot_client.authentication import Authenticator - from deebot_client.models import ApiDeviceInfo - from deebot_client.command import CommandResult - from deebot_client.event_bus import EventBus - -class SetError(ExecuteCommand): - """SetError state command.""" - - NAME = "setError" - - def __init__(self, code: int) -> None: - super().__init__({ - "act": "remove", - "code": [code] - }) - self._api_path = PATH_API_IOT_CONTROL - - async def _execute( - self, - authenticator: Authenticator, - device_info: ApiDeviceInfo, - event_bus: EventBus, - ) -> tuple[CommandResult, dict[str, Any]]: - return await super()._execute(authenticator, device_info, event_bus) From 073b0d77666d76438f73d56c8bb2b2ed0c680bb5 Mon Sep 17 00:00:00 2001 From: Alec <30310787+alec-pinson@users.noreply.github.com> Date: Tue, 27 May 2025 09:58:44 +0100 Subject: [PATCH 37/42] remove extra logging --- deebot_client/authentication.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/deebot_client/authentication.py b/deebot_client/authentication.py index 3999cd3e4..4540cd6da 100644 --- a/deebot_client/authentication.py +++ b/deebot_client/authentication.py @@ -297,13 +297,6 @@ async def post( ) try: - _LOGGER.debug( - "Request info: url=%s, json=%s, params=%s, headers=%s", - url, - json, - query_params, - headers, - ) async with self._config.session.post( url, json=json, @@ -311,14 +304,6 @@ async def post( headers=headers, timeout=_TIMEOUT, ) as res: - raw_content = await res.read() - _LOGGER.debug( - "Response info: status=%s, content_type=%s, headers=%s, raw=%s", - res.status, - res.content_type, - res.headers, - raw_content, - ) if res.status == HTTPStatus.OK: response_data: dict[str, Any] = await res.json() _LOGGER.debug( From b06ba3cfa50963526c0ac62ed7a4accb2fdd1526 Mon Sep 17 00:00:00 2001 From: Alec <30310787+alec-pinson@users.noreply.github.com> Date: Tue, 27 May 2025 09:59:57 +0100 Subject: [PATCH 38/42] doc string --- deebot_client/commands/json/error.py | 1 + 1 file changed, 1 insertion(+) diff --git a/deebot_client/commands/json/error.py b/deebot_client/commands/json/error.py index 1f8ba9be4..4c08f31bc 100644 --- a/deebot_client/commands/json/error.py +++ b/deebot_client/commands/json/error.py @@ -81,4 +81,5 @@ async def execute( device_info: ApiDeviceInfo, event_bus: EventBus, ) -> tuple[CommandResult, dict[str, Any]]: + """Execute the command to set an error state.""" return await super()._execute(authenticator, device_info, event_bus) From 6f30c3d559e7618820b8e99061bc8eba01fcd97f Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 11 Aug 2025 15:44:01 +0000 Subject: [PATCH 39/42] Fix pre-commit findings --- deebot_client/authentication.py | 35 ++++++++++----- deebot_client/command.py | 66 ++++++++++++++++------------ deebot_client/commands/json/clean.py | 6 +-- deebot_client/commands/json/error.py | 22 +++++----- 4 files changed, 74 insertions(+), 55 deletions(-) diff --git a/deebot_client/authentication.py b/deebot_client/authentication.py index 4540cd6da..8a3aeec8f 100644 --- a/deebot_client/authentication.py +++ b/deebot_client/authentication.py @@ -11,7 +11,12 @@ from aiohttp import ClientResponseError, ClientSession, ClientTimeout, hdrs -from .const import COUNTRY_CHINA, PATH_API_USERS_USER, REALM, PATH_API_IOT_CONTROL, PATH_API_ISSUE_NEW_PERMISSION +from .const import ( + COUNTRY_CHINA, + PATH_API_ISSUE_NEW_PERMISSION, + PATH_API_USERS_USER, + REALM, +) from .exceptions import ( ApiError, ApiTimeoutError, @@ -275,7 +280,9 @@ async def post( url = urljoin(self._config.portal_url, "api/" + path) logger_request_params = f"url={url}, params={query_params}, json={json}" - if credentials is not None and (headers is None or "Authorization" not in headers): + if credentials is not None and ( + headers is None or "Authorization" not in headers + ): json.update( { "auth": { @@ -416,23 +423,27 @@ async def get_sst_token(self, device_id: str, device_class: str) -> str: """Get access token for a device.""" credentials = await self.authenticate() perm_payload = { - "acl": [{ - "policy": [{ - "obj": [f"Endpoint:{device_class}:{device_id}"], - "perms": ["Control"] - }], - "svc": "dim" - }], + "acl": [ + { + "policy": [ + { + "obj": [f"Endpoint:{device_class}:{device_id}"], + "perms": ["Control"], + } + ], + "svc": "dim", + } + ], "exp": 600, - "sub": credentials.user_id + "sub": credentials.user_id, } response = await self._auth_client.post( PATH_API_ISSUE_NEW_PERMISSION, perm_payload, headers={ "Content-Type": "application/json", - "Authorization": f"Bearer {credentials.token}" - } + "Authorization": f"Bearer {credentials.token}", + }, ) return response["data"]["data"]["token"] diff --git a/deebot_client/command.py b/deebot_client/command.py index b59e28a24..3b01e1540 100644 --- a/deebot_client/command.py +++ b/deebot_client/command.py @@ -14,7 +14,12 @@ ) from deebot_client.util import verify_required_class_variables_exists -from .const import PATH_API_IOT_DEVMANAGER, PATH_API_IOT_CONTROL, REQUEST_HEADERS, DataType +from .const import ( + PATH_API_IOT_CONTROL, + PATH_API_IOT_DEVMANAGER, + REQUEST_HEADERS, + DataType, +) from .logging_filter import get_logger from .message import HandlingResult, HandlingState, Message @@ -164,33 +169,18 @@ async def _execute_api_request( credentials = await authenticator.authenticate() - if self._api_path == PATH_API_IOT_DEVMANAGER: - query_params = { - "mid": payload["toType"], - "did": payload["toId"], - "td": payload["td"], - "u": credentials.user_id, - "cv": "1.67.3", - "t": "a", - "av": "1.3.1", - } - return await authenticator.post_authenticated( - self._api_path, - payload, - query_params=query_params, - headers=REQUEST_HEADERS, - ) - - elif self._api_path == PATH_API_IOT_CONTROL: + if self._api_path == PATH_API_IOT_CONTROL: body = payload["payload"] - body["header"].update({ - "channel": "Android", - "m": "request", - "pri": 2, - "ver": "0.0.22", - "tzm": 60, - "tzc": "Europe/London" - }) + body["header"].update( + { + "channel": "Android", + "m": "request", + "pri": 2, + "ver": "0.0.22", + "tzm": 60, + "tzc": "Europe/London", + } + ) device_id = device_info["did"] device_class = device_info["class"] @@ -201,8 +191,10 @@ async def _execute_api_request( "eid": device_id, "er": device_info["resource"], "et": device_class, - "apn": self.NAME, # (clean|charge|setError) - "si": device_info["resource"] # new http param si (some random id which matches request header X-ECO-REQUEST-ID) + "apn": self.NAME, # (clean|charge|setError) + "si": device_info[ + "resource" + ], # new http param si (some random id which matches request header X-ECO-REQUEST-ID) } headers = { @@ -217,6 +209,22 @@ async def _execute_api_request( headers=headers, ) + query_params = { + "mid": payload["toType"], + "did": payload["toId"], + "td": payload["td"], + "u": credentials.user_id, + "cv": "1.67.3", + "t": "a", + "av": "1.3.1", + } + return await authenticator.post_authenticated( + self._api_path, + payload, + query_params=query_params, + headers=REQUEST_HEADERS, + ) + def __handle_response( self, event_bus: EventBus, response: dict[str, Any] ) -> CommandResult: diff --git a/deebot_client/commands/json/clean.py b/deebot_client/commands/json/clean.py index 045ff3e7e..25e2f6eb2 100644 --- a/deebot_client/commands/json/clean.py +++ b/deebot_client/commands/json/clean.py @@ -4,11 +4,11 @@ from typing import TYPE_CHECKING, Any +from deebot_client.const import PATH_API_IOT_CONTROL from deebot_client.events import StateEvent from deebot_client.logging_filter import get_logger from deebot_client.message import HandlingResult, MessageBodyDataDict from deebot_client.models import ApiDeviceInfo, CleanAction, CleanMode, State -from deebot_client.const import PATH_API_IOT_CONTROL from .common import ExecuteCommand, JsonCommandWithMessageHandling @@ -104,6 +104,7 @@ def _get_args(self, action: CleanAction) -> dict[str, Any]: args["content"].update(self._additional_content) return args + class CleanV3(ExecuteCommand): """Clean V3 command.""" @@ -144,9 +145,8 @@ async def _execute( ): self._args = self._get_args(CleanAction.RESUME) - result = await super()._execute(authenticator, device_info, event_bus) + return await super()._execute(authenticator, device_info, event_bus) - return result class CleanAreaV3(CleanV3): """Clean area command.""" diff --git a/deebot_client/commands/json/error.py b/deebot_client/commands/json/error.py index 4c08f31bc..a0c1aca16 100644 --- a/deebot_client/commands/json/error.py +++ b/deebot_client/commands/json/error.py @@ -2,26 +2,26 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any import asyncio +from typing import TYPE_CHECKING, Any -from deebot_client.const import ERROR_CODES +from deebot_client.const import ERROR_CODES, PATH_API_IOT_CONTROL from deebot_client.events import ErrorEvent, StateEvent +from deebot_client.logging_filter import get_logger from deebot_client.message import HandlingResult, MessageBodyDataDict from deebot_client.models import State -from deebot_client.logging_filter import get_logger -from .common import JsonCommandWithMessageHandling, ExecuteCommand -from deebot_client.const import PATH_API_IOT_CONTROL +from .common import ExecuteCommand, JsonCommandWithMessageHandling if TYPE_CHECKING: from deebot_client.authentication import Authenticator - from deebot_client.models import ApiDeviceInfo from deebot_client.command import CommandResult from deebot_client.event_bus import EventBus + from deebot_client.models import ApiDeviceInfo _LOGGER = get_logger(__name__) + class GetError(JsonCommandWithMessageHandling, MessageBodyDataDict): """Get error command.""" @@ -45,7 +45,9 @@ def _handle_body_data_dict( if 505 in codes: _LOGGER.debug("Clearing error 505") task = asyncio.create_task( - SetError(505).execute(event_bus.authenticator, event_bus.device_info, event_bus) + SetError(505).execute( + event_bus.authenticator, event_bus.device_info, event_bus + ) ) background_tasks.add(task) task.add_done_callback(background_tasks.discard) @@ -63,16 +65,14 @@ def _handle_body_data_dict( return HandlingResult.analyse() + class SetError(ExecuteCommand): """SetError state command.""" NAME = "setError" def __init__(self, code: int) -> None: - super().__init__({ - "act": "remove", - "code": [code] - }) + super().__init__({"act": "remove", "code": [code]}) self._api_path = PATH_API_IOT_CONTROL async def execute( From eaa7c0710d7b70193fb52e370caac0b3b1597f09 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 11 Aug 2025 19:53:10 +0000 Subject: [PATCH 40/42] Create DeviceAuthenticator --- deebot_client/authentication.py | 411 ++++++++++++++++-------- deebot_client/command.py | 80 +---- deebot_client/commands/json/__init__.py | 5 +- deebot_client/commands/json/clean.py | 58 ---- deebot_client/commands/json/common.py | 3 +- deebot_client/commands/json/error.py | 41 +-- deebot_client/commands/xml/common.py | 3 +- deebot_client/const.py | 4 +- deebot_client/hardware/deebot/2px96q.py | 142 +------- scripts/check_for_similar_models.py | 4 +- tests/commands/__init__.py | 17 +- tests/commands/json/test_clean_log.py | 9 +- tests/commands/json/test_common.py | 2 +- tests/conftest.py | 9 +- tests/test_authentication.py | 4 +- tests/test_command.py | 5 +- 16 files changed, 330 insertions(+), 467 deletions(-) mode change 100644 => 120000 deebot_client/hardware/deebot/2px96q.py diff --git a/deebot_client/authentication.py b/deebot_client/authentication.py index 63f70c3e8..ecb030c8b 100644 --- a/deebot_client/authentication.py +++ b/deebot_client/authentication.py @@ -2,6 +2,7 @@ from __future__ import annotations +from abc import ABC, abstractmethod import asyncio from dataclasses import dataclass from http import HTTPStatus @@ -13,9 +14,12 @@ from .const import ( COUNTRY_CHINA, + PATH_API_IOT_CONTROL, + PATH_API_IOT_DEVMANAGER, PATH_API_ISSUE_NEW_PERMISSION, PATH_API_USERS_USER, REALM, + REQUEST_HEADERS, ) from .exceptions import ( ApiError, @@ -24,13 +28,17 @@ InvalidAuthenticationError, ) from .logging_filter import get_logger -from .models import Credentials +from .models import ApiDeviceInfo, Credentials from .util import cancel, create_task, md5 from .util.continents import get_continent_url_postfix from .util.countries import get_ecovacs_country if TYPE_CHECKING: - from collections.abc import Callable, Coroutine, Mapping + from collections.abc import Awaitable, Callable, Coroutine, Mapping + + from multidict import istr + + from .command import Command _LOGGER = get_logger(__name__) @@ -75,7 +83,7 @@ def create_rest_config( continent_postfix = get_continent_url_postfix(alpha_2_country) country = get_ecovacs_country(alpha_2_country) if override_rest_url: - portal_url = login_url = auth_code_url = override_rest_url + portal_url = login_url = auth_code_url = api_base_url = override_rest_url else: portal_url = f"https://portal{continent_postfix}.ecouser.net" country_url = country.lower() @@ -116,6 +124,9 @@ def __init__( "country": self._config.country.lower(), "deviceId": self._config.device_id, } + self._api_users_url = urljoin( + self._config.portal_url, "api/" + PATH_API_USERS_USER + ) async def login(self) -> Credentials: """Login using username and password.""" @@ -250,7 +261,7 @@ async def __call_login_by_it_token( } for i in range(3): - resp = await self.post(PATH_API_USERS_USER, data) + resp = await _post(self._config.session, self._api_users_url, data) if resp["result"] == "ok": return resp if resp["result"] == "fail" and resp["error"] == "set token error.": @@ -264,108 +275,83 @@ async def __call_login_by_it_token( raise AuthenticationError("failed to login with token") - async def post( - self, - path: str, - json: dict[str, Any], - *, - query_params: dict[str, Any] | None = None, - headers: dict[str, Any] | None = None, - credentials: Credentials | None = None, - ) -> dict[str, Any]: - """Perform a post request.""" - if path == PATH_API_ISSUE_NEW_PERMISSION: - url = urljoin(self._config.api_base_url, "api/" + path) - else: - url = urljoin(self._config.portal_url, "api/" + path) - logger_request_params = f"url={url}, params={query_params}, json={json}" - - if credentials is not None and ( - headers is None or "Authorization" not in headers - ): - json.update( - { - "auth": { - "with": "users", - "userid": credentials.user_id, - "realm": REALM, - "token": credentials.token, - "resource": self._config.device_id, - } - } - ) - - for i in range(MAX_RETRIES): - _LOGGER.debug( - "Calling api(%d/%d): %s", - i + 1, - MAX_RETRIES, - logger_request_params, - ) - - try: - async with self._config.session.post( - url, - json=json, - params=query_params, - headers=headers, - timeout=_TIMEOUT, - ) as res: - res.raise_for_status() - - if res.status == HTTPStatus.OK: - response_data: dict[str, Any] = await res.json() - _LOGGER.debug( - "Success calling api %s, response=%s", - logger_request_params, - response_data, - ) - return response_data +async def _post( + session: ClientSession, + url: str, + json: dict[str | istr, Any], + *, + query_params: dict[str, Any] | None = None, + headers: dict[istr, str] | None = None, +) -> dict[str, Any]: + """Perform a post request.""" + logger_request_params = f"url={url}, params={query_params}, json={json}" + + for i in range(MAX_RETRIES): + _LOGGER.debug( + "Calling api(%d/%d): %s", + i + 1, + MAX_RETRIES, + logger_request_params, + ) + + try: + async with session.post( + url, + json=json, + params=query_params, + headers=headers, + timeout=_TIMEOUT, + ) as res: + res.raise_for_status() + + if res.status == HTTPStatus.OK: + response_data: dict[str, Any] = await res.json() _LOGGER.debug( - "Error calling api %s, response=%s", logger_request_params, res - ) - raise ApiError("Request failed") from ClientResponseError( - res.request_info, - res.history, - status=res.status, - message=str(res.reason), - headers=res.headers, + "Success calling api %s, response=%s", + logger_request_params, + response_data, ) - except TimeoutError as ex: - _LOGGER.debug("Timeout (%d) reached on path: %s", _TIMEOUT, path) - raise ApiTimeoutError(path=path, timeout=_TIMEOUT) from ex - except ClientResponseError as ex: - _LOGGER.debug("Error: %s", logger_request_params, exc_info=True) - if ex.status == HTTPStatus.BAD_GATEWAY: - seconds_to_sleep = 10 - _LOGGER.info( - "Retry calling API due 502: Unfortunately the ecovacs api is unreliable. Retrying in %d seconds", - seconds_to_sleep, - ) - - await asyncio.sleep(seconds_to_sleep) - continue + return response_data + + _LOGGER.debug( + "Error calling api %s, response=%s", logger_request_params, res + ) + raise ApiError("Request failed") from ClientResponseError( + res.request_info, + res.history, + status=res.status, + message=str(res.reason), + headers=res.headers, + ) + except TimeoutError as ex: + _LOGGER.debug("Timeout (%d) reached on path: %s", _TIMEOUT, url) + raise ApiTimeoutError(path=url, timeout=_TIMEOUT) from ex + except ClientResponseError as ex: + _LOGGER.debug("Error: %s", logger_request_params, exc_info=True) + if ex.status == HTTPStatus.BAD_GATEWAY: + seconds_to_sleep = 10 + _LOGGER.info( + "Retry calling API due 502: Unfortunately the ecovacs api is unreliable. Retrying in %d seconds", + seconds_to_sleep, + ) + + await asyncio.sleep(seconds_to_sleep) + continue - raise ApiError from ex + raise ApiError from ex - raise ApiError("Unknown error occurred") + raise ApiError("Unknown error occurred") -class Authenticator: - """Authenticator.""" +class Authenticator(ABC): + """Base authenticator.""" def __init__( self, - config: RestConfiguration, - account_id: str, - password_hash: str, + credentials_fn: Callable[[], Awaitable[Credentials]], ) -> None: - self._auth_client = _AuthClient( - config, - account_id, - password_hash, - ) + self._credentials_fn = credentials_fn self._lock = asyncio.Lock() self._on_credentials_changed: set[ @@ -384,8 +370,8 @@ async def authenticate(self, *, force: bool = False) -> Credentials: or self._credentials.expires_at < time.time() ): _LOGGER.debug("Performing login") - self._credentials = await self._auth_client.login() self._cancel_refresh_task() + self._credentials = await self._credentials_fn() self._create_refresh_task(self._credentials) for on_changed in self._on_credentials_changed: @@ -404,74 +390,235 @@ def unsubscribe() -> None: self._on_credentials_changed.add(callback) return unsubscribe + async def teardown(self) -> None: + """Teardown authenticator.""" + self._cancel_refresh_task() + await cancel(self._tasks) + + def _cancel_refresh_task(self) -> None: + if self._refresh_handle and not self._refresh_handle.cancelled(): + self._refresh_handle.cancel() + + def _create_refresh_task(self, credentials: Credentials) -> None: + # refresh at 99% of validity + def refresh() -> None: + _LOGGER.debug("Refresh token") + + async def async_refresh() -> None: + try: + await self.authenticate(force=True) + except Exception: + _LOGGER.exception("An exception occurred during refreshing token") + + create_task(self._tasks, async_refresh()) + self._refresh_handle = None + + validity = (credentials.expires_at - time.time()) * 0.99 + + self._refresh_handle = asyncio.get_event_loop().call_later(validity, refresh) + + @abstractmethod async def post_authenticated( self, path: str, json: dict[str, Any], *, query_params: dict[str, Any] | None = None, - headers: dict[str, Any] | None = None, + headers: dict[istr, str] | None = None, ) -> dict[str, Any]: """Perform an authenticated post request.""" - return await self._auth_client.post( - path, + + @abstractmethod + async def execute_command_request( + self, command: Command, device_info: ApiDeviceInfo + ) -> dict[str, Any]: + """Execute a command request.""" + + +class UserAuthenticator(Authenticator): + """User authenticator.""" + + def __init__( + self, config: RestConfiguration, account_id: str, password_hash: str + ) -> None: + self._config = config + auth_client = _AuthClient(config, account_id, password_hash) + + super().__init__(auth_client.login) + + async def post_authenticated( + self, + path: str, + json: dict[str, Any], + *, + query_params: dict[str, Any] | None = None, + headers: dict[istr, str] | None = None, + ) -> dict[str, Any]: + """Perform an authenticated post request.""" + credentials = await self.authenticate() + json.update( + { + "auth": { + "with": "users", + "userid": credentials.user_id, + "realm": REALM, + "token": credentials.token, + "resource": self._config.device_id, + } + } + ) + + url = urljoin(self._config.portal_url, "api/" + path) + return await _post( + self._config.session, + url, json, query_params=query_params, headers=headers, - credentials=await self.authenticate(), ) - async def get_sst_token(self, device_id: str, device_class: str) -> str: - """Get access token for a device.""" + async def execute_command_request( + self, command: Command, device_info: ApiDeviceInfo + ) -> dict[str, Any]: + """Execute a command request.""" credentials = await self.authenticate() + + payload = { + "cmdName": command.NAME, + "payload": command.get_payload(), + "payloadType": command.DATA_TYPE.value, + "td": "q", + "toId": device_info["did"], + "toRes": device_info["resource"], + "toType": device_info["class"], + } + + query_params = { + "mid": payload["toType"], + "did": payload["toId"], + "td": payload["td"], + "u": credentials.user_id, + "cv": "1.67.3", + "t": "a", + "av": "1.3.1", + } + return await self.post_authenticated( + PATH_API_IOT_DEVMANAGER, + payload, + query_params=query_params, + headers=REQUEST_HEADERS, + ) + + +class DeviceAuthenticator(Authenticator): + """Device authenticator.""" + + def __init__( + self, + config: RestConfiguration, + user_authenticator: UserAuthenticator, + device_info: ApiDeviceInfo, + ) -> None: + self._config = config + self._user_authenticator = user_authenticator + self._device_info = device_info + + super().__init__(self._get_device_token) + + async def _get_device_token(self) -> Credentials: + """Get access token for a device.""" + user_credentials = await self._user_authenticator.authenticate() + validity = 600 + expires_at = int(time.time() + validity) perm_payload = { "acl": [ { "policy": [ { - "obj": [f"Endpoint:{device_class}:{device_id}"], + "obj": [ + f"Endpoint:{self._device_info['class']}:{self._device_info['did']}" + ], "perms": ["Control"], } ], "svc": "dim", } ], - "exp": 600, - "sub": credentials.user_id, + "exp": validity, + "sub": user_credentials.user_id, } - response = await self._auth_client.post( - PATH_API_ISSUE_NEW_PERMISSION, + url = urljoin(self._config.api_base_url, "api/" + PATH_API_ISSUE_NEW_PERMISSION) + response = await _post( + self._config.session, + url, perm_payload, headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {credentials.token}", + hdrs.CONTENT_TYPE: "application/json", + hdrs.AUTHORIZATION: f"Bearer {user_credentials.token}", }, ) - return response["data"]["data"]["token"] - - async def teardown(self) -> None: - """Teardown authenticator.""" - self._cancel_refresh_task() - await cancel(self._tasks) - - def _cancel_refresh_task(self) -> None: - if self._refresh_handle and not self._refresh_handle.cancelled(): - self._refresh_handle.cancel() + return Credentials( + response["data"]["data"]["token"], + user_credentials.user_id, + expires_at, + ) - def _create_refresh_task(self, credentials: Credentials) -> None: - # refresh at 99% of validity - def refresh() -> None: - _LOGGER.debug("Refresh token") + async def post_authenticated( + self, + path: str, + json: dict[str, Any], + *, + query_params: dict[str, Any] | None = None, + headers: dict[istr, str] | None = None, + ) -> dict[str, Any]: + """Perform an authenticated post request.""" + return await self._user_authenticator.post_authenticated( + path, + json, + query_params=query_params, + headers=headers, + ) - async def async_refresh() -> None: - try: - await self.authenticate(force=True) - except Exception: - _LOGGER.exception("An exception occurred during refreshing token") + async def execute_command_request( + self, command: Command, device_info: ApiDeviceInfo + ) -> dict[str, Any]: + """Execute a command request.""" + body = { + "header": { + "channel": "Android", + "m": "request", + "pri": 2, + "ver": "0.0.22", + "tzm": 60, + "tzc": "Europe/London", + }, + "payload": command.get_payload(), + } - create_task(self._tasks, async_refresh()) - self._refresh_handle = None + credentials = await self.authenticate() + query_params = { + "fmt": command.DATA_TYPE.value, + "ct": "q", + "eid": device_info["did"], + "er": device_info["resource"], + "et": device_info["class"], + "apn": command.NAME, + "si": device_info[ + "resource" + ], # new http param si (some random id which matches request header X-ECO-REQUEST-ID) + } - validity = (credentials.expires_at - time.time()) * 0.99 + headers = { + **REQUEST_HEADERS, + hdrs.AUTHORIZATION: f"Bearer {credentials.token}", + hdrs.ACCEPT: "application/json", + } - self._refresh_handle = asyncio.get_event_loop().call_later(validity, refresh) + url = urljoin(self._config.portal_url, "api/" + PATH_API_IOT_CONTROL) + return await _post( + self._config.session, + url, + body, + query_params=query_params, + headers=headers, + ) diff --git a/deebot_client/command.py b/deebot_client/command.py index cf7fa8ba4..2bee8f097 100644 --- a/deebot_client/command.py +++ b/deebot_client/command.py @@ -14,12 +14,6 @@ ) from deebot_client.util import verify_required_class_variables_exists -from .const import ( - PATH_API_IOT_CONTROL, - PATH_API_IOT_DEVMANAGER, - REQUEST_HEADERS, - DataType, -) from .logging_filter import get_logger from .message import HandlingResult, HandlingState, Message @@ -27,6 +21,9 @@ from types import MappingProxyType from .authentication import Authenticator + from .const import ( + DataType, + ) from .event_bus import EventBus from .models import ApiDeviceInfo @@ -81,10 +78,9 @@ def __init__(self, args: dict[str, Any] | list[Any] | None = None) -> None: if args is None: args = {} self._args = args - self._api_path = PATH_API_IOT_DEVMANAGER @abstractmethod - def _get_payload(self) -> dict[str, Any] | list[Any] | str: + def get_payload(self) -> dict[str, Any] | list[Any] | str: """Get the payload for the rest call.""" @final @@ -157,73 +153,7 @@ async def _execute( async def _execute_api_request( self, authenticator: Authenticator, device_info: ApiDeviceInfo ) -> dict[str, Any]: - payload = { - "cmdName": self.NAME, - "payload": self._get_payload(), - "payloadType": self.DATA_TYPE.value, - "td": "q", - "toId": device_info["did"], - "toRes": device_info["resource"], - "toType": device_info["class"], - } - - credentials = await authenticator.authenticate() - - if self._api_path == PATH_API_IOT_CONTROL: - body = payload["payload"] - body["header"].update( - { - "channel": "Android", - "m": "request", - "pri": 2, - "ver": "0.0.22", - "tzm": 60, - "tzc": "Europe/London", - } - ) - device_id = device_info["did"] - device_class = device_info["class"] - - sst_token = await authenticator.get_sst_token(device_id, device_class) - query_params = { - "fmt": self.DATA_TYPE.value, - "ct": "q", - "eid": device_id, - "er": device_info["resource"], - "et": device_class, - "apn": self.NAME, # (clean|charge|setError) - "si": device_info[ - "resource" - ], # new http param si (some random id which matches request header X-ECO-REQUEST-ID) - } - - headers = { - **REQUEST_HEADERS, - "Authorization": f"Bearer {sst_token}", - } - - return await authenticator.post_authenticated( - self._api_path, - body, - query_params=query_params, - headers=headers, - ) - - query_params = { - "mid": payload["toType"], - "did": payload["toId"], - "td": payload["td"], - "u": credentials.user_id, - "cv": "1.67.3", - "t": "a", - "av": "1.3.1", - } - return await authenticator.post_authenticated( - self._api_path, - payload, - query_params=query_params, - headers=REQUEST_HEADERS, - ) + return await authenticator.execute_command_request(self, device_info) def __handle_response( self, event_bus: EventBus, response: dict[str, Any] diff --git a/deebot_client/commands/json/__init__.py b/deebot_client/commands/json/__init__.py index 0987bebe8..6f1db25f1 100644 --- a/deebot_client/commands/json/__init__.py +++ b/deebot_client/commands/json/__init__.py @@ -12,7 +12,7 @@ from .charge import Charge from .charge_state import GetChargeState from .child_lock import GetChildLock, SetChildLock -from .clean import Clean, CleanArea, CleanV2, CleanV3, GetCleanInfo, GetCleanInfoV2 +from .clean import Clean, CleanArea, CleanV2, GetCleanInfo, GetCleanInfoV2 from .clean_count import GetCleanCount, SetCleanCount from .clean_logs import GetCleanLogs from .clean_preference import GetCleanPreference, SetCleanPreference @@ -59,7 +59,6 @@ "Clean", "CleanArea", "CleanV2", - "CleanV3", "ClearMap", "GetAdvancedMode", "GetBattery", @@ -161,11 +160,11 @@ Clean, CleanV2, - CleanV3, CleanArea, GetCleanInfo, GetCleanInfoV2, + GetCleanLogs, GetContinuousCleaning, diff --git a/deebot_client/commands/json/clean.py b/deebot_client/commands/json/clean.py index 3f1398ae7..d29874002 100644 --- a/deebot_client/commands/json/clean.py +++ b/deebot_client/commands/json/clean.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING, Any -from deebot_client.const import PATH_API_IOT_CONTROL from deebot_client.events import StateEvent from deebot_client.logging_filter import get_logger from deebot_client.message import HandlingResult, MessageBodyDataDict @@ -105,63 +104,6 @@ def _get_args(self, action: CleanAction) -> dict[str, Any]: return args -class CleanV3(ExecuteCommand): - """Clean V3 command.""" - - NAME = "clean" - - def __init__(self, action: CleanAction) -> None: - self._action = action - super().__init__(self._get_args(action)) - self._api_path = PATH_API_IOT_CONTROL - - def _get_args(self, action: CleanAction) -> dict[str, Any]: - content = {} - args = {"act": action.value, "content": content} - match action: - case CleanAction.START | CleanAction.RESUME: - content["type"] = CleanMode.AUTO.value - case CleanAction.STOP | CleanAction.PAUSE: - content["type"] = "" - return args - - async def _execute( - self, - authenticator: Authenticator, - device_info: ApiDeviceInfo, - event_bus: EventBus, - ) -> tuple[CommandResult, dict[str, Any]]: - # Resume ↔ Start logic - state = event_bus.get_last_event(StateEvent) - if state and isinstance(self._args, dict): - if ( - self._args["act"] == CleanAction.RESUME.value - and state.state != State.PAUSED - ): - self._args = self._get_args(CleanAction.START) - elif ( - self._args["act"] == CleanAction.START.value - and state.state == State.PAUSED - ): - self._args = self._get_args(CleanAction.RESUME) - - return await super()._execute(authenticator, device_info, event_bus) - - -class CleanAreaV3(CleanV3): - """Clean area command.""" - - def __init__(self, mode: CleanMode, area: str, _: int = 1) -> None: - self._additional_content = {"type": mode.value, "value": area} - super().__init__(CleanAction.START) - - def _get_args(self, action: CleanAction) -> dict[str, Any]: - args = super()._get_args(action) - if action == CleanAction.START: - args["content"].update(self._additional_content) - return args - - class GetCleanInfo(JsonCommandWithMessageHandling, MessageBodyDataDict): """Get clean info command.""" diff --git a/deebot_client/commands/json/common.py b/deebot_client/commands/json/common.py index c2b0ea64a..3a1dbe5a6 100644 --- a/deebot_client/commands/json/common.py +++ b/deebot_client/commands/json/common.py @@ -40,7 +40,8 @@ class JsonCommand(Command, ABC): DATA_TYPE = DataType.JSON - def _get_payload(self) -> dict[str, Any] | list[Any]: + def get_payload(self) -> dict[str, Any] | list[Any]: + """Get the payload for the rest call.""" payload = { "header": { "pri": "1", diff --git a/deebot_client/commands/json/error.py b/deebot_client/commands/json/error.py index a0c1aca16..8f9bfc051 100644 --- a/deebot_client/commands/json/error.py +++ b/deebot_client/commands/json/error.py @@ -2,24 +2,17 @@ from __future__ import annotations -import asyncio from typing import TYPE_CHECKING, Any -from deebot_client.const import ERROR_CODES, PATH_API_IOT_CONTROL +from deebot_client.const import ERROR_CODES from deebot_client.events import ErrorEvent, StateEvent -from deebot_client.logging_filter import get_logger from deebot_client.message import HandlingResult, MessageBodyDataDict from deebot_client.models import State -from .common import ExecuteCommand, JsonCommandWithMessageHandling +from .common import JsonCommandWithMessageHandling if TYPE_CHECKING: - from deebot_client.authentication import Authenticator - from deebot_client.command import CommandResult from deebot_client.event_bus import EventBus - from deebot_client.models import ApiDeviceInfo - -_LOGGER = get_logger(__name__) class GetError(JsonCommandWithMessageHandling, MessageBodyDataDict): @@ -41,17 +34,6 @@ def _handle_body_data_dict( error: int | None = 0 - background_tasks = set() - if 505 in codes: - _LOGGER.debug("Clearing error 505") - task = asyncio.create_task( - SetError(505).execute( - event_bus.authenticator, event_bus.device_info, event_bus - ) - ) - background_tasks.add(task) - task.add_done_callback(background_tasks.discard) - if codes: # the last error code error = codes[-1] @@ -64,22 +46,3 @@ def _handle_body_data_dict( return HandlingResult.success() return HandlingResult.analyse() - - -class SetError(ExecuteCommand): - """SetError state command.""" - - NAME = "setError" - - def __init__(self, code: int) -> None: - super().__init__({"act": "remove", "code": [code]}) - self._api_path = PATH_API_IOT_CONTROL - - async def execute( - self, - authenticator: Authenticator, - device_info: ApiDeviceInfo, - event_bus: EventBus, - ) -> tuple[CommandResult, dict[str, Any]]: - """Execute the command to set an error state.""" - return await super()._execute(authenticator, device_info, event_bus) diff --git a/deebot_client/commands/xml/common.py b/deebot_client/commands/xml/common.py index 940a5daf3..4bf850e8c 100644 --- a/deebot_client/commands/xml/common.py +++ b/deebot_client/commands/xml/common.py @@ -32,7 +32,8 @@ class XmlCommand(Command, ABC): DATA_TYPE = DataType.XML HAS_SUB_ELEMENT = False - def _get_payload(self) -> str: + def get_payload(self) -> str: + """Get the payload for the rest call.""" element = ctl_element = Element("ctl") if len(self._args) > 0: diff --git a/deebot_client/const.py b/deebot_client/const.py index 7883ca582..bbe4c0aeb 100644 --- a/deebot_client/const.py +++ b/deebot_client/const.py @@ -5,6 +5,8 @@ from enum import Enum, StrEnum from typing import Self +from aiohttp import hdrs + REALM = "ecouser.net" PATH_API_APPSVR_APP = "appsvr/app.do" PATH_API_PIM_PRODUCT_IOT_MAP = "pim/product/getProductIotMap" @@ -14,7 +16,7 @@ PATH_API_LG_LOG = "lg/log.do" PATH_API_USERS_USER = "users/user.do" REQUEST_HEADERS = { - "User-Agent": "Dalvik/2.1.0 (Linux; U; Android 5.1.1; A5010 Build/LMY48Z)", + hdrs.USER_AGENT: "Dalvik/2.1.0 (Linux; U; Android 5.1.1; A5010 Build/LMY48Z)", } COUNTRY_CHINA = "CN" diff --git a/deebot_client/hardware/deebot/2px96q.py b/deebot_client/hardware/deebot/2px96q.py deleted file mode 100644 index ad84c0fc6..000000000 --- a/deebot_client/hardware/deebot/2px96q.py +++ /dev/null @@ -1,141 +0,0 @@ -"""DEEBOT GOAT O800 Capabilities.""" - -from __future__ import annotations - -from deebot_client.capabilities import ( - Capabilities, - CapabilityClean, - CapabilityCleanAction, - CapabilityCustomCommand, - CapabilityEvent, - CapabilityExecute, - CapabilityLifeSpan, - CapabilitySet, - CapabilitySetEnable, - CapabilitySettings, - CapabilityStats, - DeviceType, -) -from deebot_client.commands.json import ( - GetBorderSwitch, - GetChildLock, - GetCrossMapBorderWarning, - GetCutDirection, - GetMoveUpWarning, - GetSafeProtect, - SetBorderSwitch, - SetChildLock, - SetCrossMapBorderWarning, - SetCutDirection, - SetMoveUpWarning, - SetSafeProtect, -) -from deebot_client.commands.json.advanced_mode import GetAdvancedMode, SetAdvancedMode -from deebot_client.commands.json.battery import GetBattery -from deebot_client.commands.json.charge import Charge -from deebot_client.commands.json.charge_state import GetChargeState -from deebot_client.commands.json.clean import CleanV3, GetCleanInfoV2 -from deebot_client.commands.json.custom import CustomCommand -from deebot_client.commands.json.error import GetError -from deebot_client.commands.json.life_span import GetLifeSpan, ResetLifeSpan -from deebot_client.commands.json.network import GetNetInfo -from deebot_client.commands.json.play_sound import PlaySound -from deebot_client.commands.json.stats import GetStats, GetTotalStats -from deebot_client.commands.json.true_detect import GetTrueDetect, SetTrueDetect -from deebot_client.commands.json.volume import GetVolume, SetVolume -from deebot_client.const import DataType -from deebot_client.events import ( - AdvancedModeEvent, - AvailabilityEvent, - BatteryEvent, - BorderSwitchEvent, - ChildLockEvent, - CrossMapBorderWarningEvent, - CustomCommandEvent, - CutDirectionEvent, - ErrorEvent, - LifeSpan, - LifeSpanEvent, - MoveUpWarningEvent, - NetworkInfoEvent, - ReportStatsEvent, - SafeProtectEvent, - StateEvent, - StatsEvent, - TotalStatsEvent, - TrueDetectEvent, - VolumeEvent, -) -from deebot_client.models import StaticDeviceInfo -from deebot_client.util import short_name - -from . import DEVICES - -DEVICES[short_name(__name__)] = StaticDeviceInfo( - DataType.JSON, - Capabilities( - device_type=DeviceType.MOWER, - availability=CapabilityEvent( - AvailabilityEvent, [GetBattery(is_available_check=True)] - ), - battery=CapabilityEvent(BatteryEvent, [GetBattery()]), - charge=CapabilityExecute(Charge), - clean=CapabilityClean( - action=CapabilityCleanAction(command=CleanV3), - ), - custom=CapabilityCustomCommand( - event=CustomCommandEvent, get=[], set=CustomCommand - ), - error=CapabilityEvent(ErrorEvent, [GetError()]), - life_span=CapabilityLifeSpan( - types=(LifeSpan.BLADE, LifeSpan.LENS_BRUSH), - event=LifeSpanEvent, - get=[ - GetLifeSpan( - [ - LifeSpan.BLADE, - LifeSpan.LENS_BRUSH, - ] - ) - ], - reset=ResetLifeSpan, - ), - network=CapabilityEvent(NetworkInfoEvent, [GetNetInfo()]), - play_sound=CapabilityExecute(PlaySound), - settings=CapabilitySettings( - advanced_mode=CapabilitySetEnable( - AdvancedModeEvent, [GetAdvancedMode()], SetAdvancedMode - ), - border_switch=CapabilitySetEnable( - BorderSwitchEvent, [GetBorderSwitch()], SetBorderSwitch - ), - cut_direction=CapabilitySet( - CutDirectionEvent, [GetCutDirection()], SetCutDirection - ), - child_lock=CapabilitySetEnable( - ChildLockEvent, [GetChildLock()], SetChildLock - ), - moveup_warning=CapabilitySetEnable( - MoveUpWarningEvent, [GetMoveUpWarning()], SetMoveUpWarning - ), - cross_map_border_warning=CapabilitySetEnable( - CrossMapBorderWarningEvent, - [GetCrossMapBorderWarning()], - SetCrossMapBorderWarning, - ), - safe_protect=CapabilitySetEnable( - SafeProtectEvent, [GetSafeProtect()], SetSafeProtect - ), - true_detect=CapabilitySetEnable( - TrueDetectEvent, [GetTrueDetect()], SetTrueDetect - ), - volume=CapabilitySet(VolumeEvent, [GetVolume()], SetVolume), - ), - state=CapabilityEvent(StateEvent, [GetChargeState(), GetCleanInfoV2()]), - stats=CapabilityStats( - clean=CapabilityEvent(StatsEvent, [GetStats()]), - report=CapabilityEvent(ReportStatsEvent, []), - total=CapabilityEvent(TotalStatsEvent, [GetTotalStats()]), - ), - ), -) diff --git a/deebot_client/hardware/deebot/2px96q.py b/deebot_client/hardware/deebot/2px96q.py new file mode 120000 index 000000000..28514679f --- /dev/null +++ b/deebot_client/hardware/deebot/2px96q.py @@ -0,0 +1 @@ +5xu9h3.py \ No newline at end of file diff --git a/scripts/check_for_similar_models.py b/scripts/check_for_similar_models.py index 5b8a09f5d..d67c4fcbb 100644 --- a/scripts/check_for_similar_models.py +++ b/scripts/check_for_similar_models.py @@ -12,7 +12,7 @@ import aiohttp from deebot_client.api_client import ApiClient -from deebot_client.authentication import Authenticator, create_rest_config +from deebot_client.authentication import UserAuthenticator, create_rest_config from deebot_client.hardware.deebot import DEVICES, _load from deebot_client.util import md5 @@ -58,7 +58,7 @@ async def main() -> None: alpha_2_country=os.environ["ECOVACS_COUNTRY"], ) - authenticator = Authenticator( + authenticator = UserAuthenticator( rest, os.environ["ECOVACS_USERNAME"], md5(os.environ["ECOVACS_PASSWORD"]) ) api_client = ApiClient(authenticator) diff --git a/tests/commands/__init__.py b/tests/commands/__init__.py index bd20f29a2..7d1bea8b9 100644 --- a/tests/commands/__init__.py +++ b/tests/commands/__init__.py @@ -4,7 +4,9 @@ from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, Mock, call -from deebot_client.authentication import Authenticator +from deebot_client.authentication import ( + Authenticator, +) from deebot_client.command import Command, CommandResult from deebot_client.event_bus import EventBus from deebot_client.models import ( @@ -51,19 +53,24 @@ async def assert_command( *, command_result: CommandResult | None = None, expected_raw_response: dict[str, Any] | None = None, + mock_authenticator_func_name: str = "execute_command_request", ) -> None: command_result = command_result or CommandResult.success() event_bus = Mock(spec_set=EventBus) - authenticator = Mock(spec_set=Authenticator) + authenticator = Mock( + spec_set=Authenticator, + ) authenticator.authenticate = AsyncMock( return_value=Credentials("token", "user_id", 9999) ) + if isinstance(json_api_response, tuple): - authenticator.post_authenticated = AsyncMock(side_effect=json_api_response) + mock = AsyncMock(side_effect=json_api_response) else: - authenticator.post_authenticated = AsyncMock(return_value=json_api_response) + mock = AsyncMock(return_value=json_api_response) if expected_raw_response is None: expected_raw_response = json_api_response + setattr(authenticator, mock_authenticator_func_name, mock) device_info = ApiDeviceInfo( { "company": "company", @@ -81,7 +88,7 @@ async def assert_command( # verify verify_result(command_result, expected_raw_response) - authenticator.post_authenticated.assert_called() + getattr(authenticator, mock_authenticator_func_name).assert_called() if expected_events: if isinstance(expected_events, Sequence): event_bus.notify.assert_has_calls([call(x) for x in expected_events]) diff --git a/tests/commands/json/test_clean_log.py b/tests/commands/json/test_clean_log.py index 470cb0269..9512d9923 100644 --- a/tests/commands/json/test_clean_log.py +++ b/tests/commands/json/test_clean_log.py @@ -109,7 +109,12 @@ async def test_GetCleanLogs(caplog: pytest.LogCaptureFixture) -> None: ] ) - await assert_command(GetCleanLogs(), json, expected) + await assert_command( + GetCleanLogs(), + json, + expected, + mock_authenticator_func_name="post_authenticated", + ) assert ( "deebot_client.commands.json.clean_logs", @@ -130,6 +135,7 @@ async def test_GetCleanLogs_analyse_logged( json, None, command_result=CommandResult(HandlingState.ANALYSE_LOGGED), + mock_authenticator_func_name="post_authenticated", ) assert ( @@ -145,6 +151,7 @@ async def test_GetCleanLogs_handle_error(caplog: pytest.LogCaptureFixture) -> No {}, None, command_result=CommandResult(HandlingState.ERROR), + mock_authenticator_func_name="post_authenticated", ) assert ( diff --git a/tests/commands/json/test_common.py b/tests/commands/json/test_common.py index 24b299e74..88278a637 100644 --- a/tests/commands/json/test_common.py +++ b/tests/commands/json/test_common.py @@ -75,7 +75,7 @@ async def test_common_functionality( assert_func: Callable[[Mock], None], caplog: pytest.LogCaptureFixture, ) -> None: - authenticator.post_authenticated.return_value = response_json + authenticator.execute_command_request.return_value = response_json event_bus = Mock(spec_set=EventBus) available = await command.execute(authenticator, api_device_info, event_bus) diff --git a/tests/conftest.py b/tests/conftest.py index acd6d7663..76ea8a31f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,6 +12,7 @@ from deebot_client.authentication import ( Authenticator, RestConfiguration, + UserAuthenticator, create_rest_config as create_config_rest, ) from deebot_client.event_bus import EventBus @@ -58,10 +59,12 @@ def rest_config( @pytest.fixture -def authenticator() -> Authenticator: - authenticator = Mock(spec_set=Authenticator) +def authenticator() -> UserAuthenticator: + authenticator = Mock(spec_set=UserAuthenticator) authenticator.authenticate.return_value = Credentials("token", "user_id", 9999) - authenticator.post_authenticated.return_value = { + authenticator.execute_command_request.return_value = ( + authenticator.post_authenticated.return_value + ) = { "header": { "pri": 1, "tzm": 480, diff --git a/tests/test_authentication.py b/tests/test_authentication.py index ee43145cf..0ac072333 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -7,7 +7,7 @@ import pytest -from deebot_client.authentication import Authenticator, create_rest_config +from deebot_client.authentication import UserAuthenticator, create_rest_config from deebot_client.models import Credentials if TYPE_CHECKING: @@ -29,7 +29,7 @@ async def on_changed(_: Credentials) -> None: login_mock.return_value = Credentials( "token", "user_id", int(time.time() + 123456789) ) - authenticator = Authenticator(rest_config, "test", "test") + authenticator = UserAuthenticator(rest_config, "test", "test") unsub = authenticator.subscribe(on_changed) diff --git a/tests/test_command.py b/tests/test_command.py index 70eebfb40..4c1191036 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -31,7 +31,8 @@ def _handle_mqtt_p2p( ) -> None: pass - def _get_payload(self) -> dict[str, Any] | list[Any]: + def get_payload(self) -> dict[str, Any] | list[Any]: + """Get the payload for the rest call.""" return {} def _handle_response( @@ -103,7 +104,7 @@ async def test_execute_api_timeout_error( ) -> None: """Test that on api timeout the stack trace is not logged.""" command = _TestCommand(1) - authenticator.post_authenticated.side_effect = ApiTimeoutError( + authenticator.execute_command_request.side_effect = ApiTimeoutError( "test", ClientTimeout(60) ) result = await command.execute(authenticator, api_device_info, event_bus_mock) From da7a42ce7bdb4ad63b5d3e707552536b9499b247 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 11 Aug 2025 19:59:00 +0000 Subject: [PATCH 41/42] formatting --- deebot_client/command.py | 4 +--- tests/commands/__init__.py | 9 ++------- tests/conftest.py | 5 ++--- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/deebot_client/command.py b/deebot_client/command.py index 2bee8f097..70e4247d6 100644 --- a/deebot_client/command.py +++ b/deebot_client/command.py @@ -21,9 +21,7 @@ from types import MappingProxyType from .authentication import Authenticator - from .const import ( - DataType, - ) + from .const import DataType from .event_bus import EventBus from .models import ApiDeviceInfo diff --git a/tests/commands/__init__.py b/tests/commands/__init__.py index 7d1bea8b9..3488637fd 100644 --- a/tests/commands/__init__.py +++ b/tests/commands/__init__.py @@ -4,9 +4,7 @@ from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, Mock, call -from deebot_client.authentication import ( - Authenticator, -) +from deebot_client.authentication import Authenticator from deebot_client.command import Command, CommandResult from deebot_client.event_bus import EventBus from deebot_client.models import ( @@ -57,13 +55,10 @@ async def assert_command( ) -> None: command_result = command_result or CommandResult.success() event_bus = Mock(spec_set=EventBus) - authenticator = Mock( - spec_set=Authenticator, - ) + authenticator = Mock(spec_set=Authenticator) authenticator.authenticate = AsyncMock( return_value=Credentials("token", "user_id", 9999) ) - if isinstance(json_api_response, tuple): mock = AsyncMock(side_effect=json_api_response) else: diff --git a/tests/conftest.py b/tests/conftest.py index 76ea8a31f..8ccdcf6f1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,6 @@ from deebot_client.authentication import ( Authenticator, RestConfiguration, - UserAuthenticator, create_rest_config as create_config_rest, ) from deebot_client.event_bus import EventBus @@ -59,8 +58,8 @@ def rest_config( @pytest.fixture -def authenticator() -> UserAuthenticator: - authenticator = Mock(spec_set=UserAuthenticator) +def authenticator() -> Authenticator: + authenticator = Mock(spec_set=Authenticator) authenticator.authenticate.return_value = Credentials("token", "user_id", 9999) authenticator.execute_command_request.return_value = ( authenticator.post_authenticated.return_value From 1eedbbcc2a2bd39fd5c9dce6976dbd7c2b3f2390 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 12 Aug 2025 16:35:08 +0000 Subject: [PATCH 42/42] Fix wrong content type --- deebot_client/authentication.py | 8 +++++++- pyproject.toml | 5 +++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/deebot_client/authentication.py b/deebot_client/authentication.py index ecb030c8b..749336ed9 100644 --- a/deebot_client/authentication.py +++ b/deebot_client/authentication.py @@ -283,6 +283,7 @@ async def _post( *, query_params: dict[str, Any] | None = None, headers: dict[istr, str] | None = None, + expected_content_type: str = "application/json", ) -> dict[str, Any]: """Perform a post request.""" logger_request_params = f"url={url}, params={query_params}, json={json}" @@ -306,7 +307,9 @@ async def _post( res.raise_for_status() if res.status == HTTPStatus.OK: - response_data: dict[str, Any] = await res.json() + response_data: dict[str, Any] = await res.json( + content_type=expected_content_type + ) _LOGGER.debug( "Success calling api %s, response=%s", logger_request_params, @@ -615,10 +618,13 @@ async def execute_command_request( } url = urljoin(self._config.portal_url, "api/" + PATH_API_IOT_CONTROL) + # Ecovacs is setting not the correct content type in the response + # even if the response is json return await _post( self._config.session, url, body, query_params=query_params, headers=headers, + expected_content_type="application/octet-stream", ) diff --git a/pyproject.toml b/pyproject.toml index a130a39bc..e1829093d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,14 +116,12 @@ ignore = [ [tool.ruff.lint.flake8-pytest-style] fixture-parentheses = false - [tool.ruff.lint.isort] combine-as-imports = true force-sort-within-sections = true known-first-party = ["deebot_client"] required-imports = ["from __future__ import annotations"] - [tool.ruff.lint.per-file-ignores] "tests/fixtures/**" = [ "T201", # print found @@ -153,3 +151,6 @@ required-imports = ["from __future__ import annotations"] [tool.ruff.lint.mccabe] max-complexity = 13 + +[tool.ruff.lint.pylint] +max-args = 6