Skip to content

Commit 8c131fc

Browse files
committed
Merge branch 'dev'
2 parents bc60d45 + ab6db4d commit 8c131fc

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

70 files changed

+1013
-428
lines changed

.devcontainer.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// Configure properties specific to VS Code.
66
"vscode": {
77
"extensions": [
8+
"charliermarsh.ruff",
89
"eamodio.gitlens",
910
"github.vscode-pull-request-github",
1011
"ms-python.python",
@@ -15,6 +16,9 @@
1516
],
1617
// Set *default* container specific settings.json values on container create.
1718
"settings": {
19+
"[python]": {
20+
"editor.defaultFormatter": "charliermarsh.ruff"
21+
},
1822
"editor.formatOnPaste": false,
1923
"editor.formatOnSave": true,
2024
"editor.formatOnType": true,

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
- uses: "actions/checkout@v4"
1919
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
2020
id: python
21-
uses: actions/setup-python@v4.7.1
21+
uses: actions/setup-python@v5.0.0
2222
with:
2323
python-version: ${{ env.DEFAULT_PYTHON }}
2424
cache: "pip"
@@ -42,7 +42,7 @@ jobs:
4242
- uses: "actions/checkout@v4"
4343
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
4444
id: python
45-
uses: actions/setup-python@v4.7.1
45+
uses: actions/setup-python@v5.0.0
4646
with:
4747
python-version: ${{ env.DEFAULT_PYTHON }}
4848
cache: "pip"

.github/workflows/python-publish.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
uses: actions/checkout@v4
2424

2525
- name: Set up Python
26-
uses: actions/setup-python@v4.7.1
26+
uses: actions/setup-python@v5.0.0
2727
with:
2828
python-version: "3.11"
2929

.pre-commit-config.yaml

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
ci:
2+
autofix_prs: false
23
skip:
34
# This steps run in the ci workflow. Keep in sync
45
- mypy
@@ -10,23 +11,19 @@ default_language_version:
1011

1112
repos:
1213
- repo: https://github.com/astral-sh/ruff-pre-commit
13-
rev: v0.1.1
14+
rev: v0.1.6
1415
hooks:
1516
- id: ruff
1617
args:
1718
- --fix
19+
# - --unsafe-fixes
20+
- id: ruff-format
1821
- repo: https://github.com/asottile/pyupgrade
1922
rev: v3.15.0
2023
hooks:
2124
- id: pyupgrade
2225
args:
2326
- --py311-plus
24-
- repo: https://github.com/psf/black-pre-commit-mirror
25-
rev: 23.10.0
26-
hooks:
27-
- id: black
28-
args:
29-
- --quiet
3027
- repo: https://github.com/codespell-project/codespell
3128
rev: v2.2.6
3229
hooks:
@@ -48,16 +45,16 @@ repos:
4845
args: [--branch, main, --branch, dev]
4946
- id: requirements-txt-fixer
5047
- repo: https://github.com/pre-commit/mirrors-prettier
51-
rev: v3.0.3
48+
rev: v3.1.0
5249
hooks:
5350
- id: prettier
5451
additional_dependencies:
55-
- prettier@3.0.3
56-
- prettier-plugin-sort-json@3.0.1
52+
- prettier@3.1.0
53+
- prettier-plugin-sort-json@3.1.0
5754
exclude_types:
5855
- python
5956
- repo: https://github.com/adrienverge/yamllint.git
60-
rev: v1.32.0
57+
rev: v1.33.0
6158
hooks:
6259
- id: yamllint
6360
- repo: local

deebot_client/api_client.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
class ApiClient:
1616
"""Api client."""
1717

18-
def __init__(self, authenticator: Authenticator):
18+
def __init__(self, authenticator: Authenticator) -> None:
1919
self._authenticator = authenticator
2020

2121
async def get_devices(self) -> list[DeviceInfo]:
@@ -38,9 +38,8 @@ async def get_devices(self) -> list[DeviceInfo]:
3838
_LOGGER.debug("Skipping device as it is not supported: %s", device)
3939
return devices
4040
_LOGGER.error("Failed to get devices: %s", resp)
41-
raise ApiError(
42-
f"failure {resp.get('error', '')} ({resp.get('errno', '')}) on getting devices"
43-
)
41+
msg = f"failure {resp.get('error', '')} ({resp.get('errno', '')}) on getting devices"
42+
raise ApiError(msg)
4443

4544
async def get_product_iot_map(self) -> dict[str, Any]:
4645
"""Get product iot map."""
@@ -55,6 +54,5 @@ async def get_product_iot_map(self) -> dict[str, Any]:
5554
result[entry["classid"]] = entry["product"]
5655
return result
5756
_LOGGER.error("Failed to get product iot map")
58-
raise ApiError(
59-
f"failure {resp['error']} ({resp['errno']}) on getting product iot map"
60-
)
57+
msg = f"failure {resp['error']} ({resp['errno']}) on getting product iot map"
58+
raise ApiError(msg)

deebot_client/authentication.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def __init__(
5151
config: Configuration,
5252
account_id: str,
5353
password_hash: str,
54-
):
54+
) -> None:
5555
self._config = config
5656
self._account_id = account_id
5757
self._password_hash = password_hash
@@ -107,17 +107,16 @@ async def __do_auth_response(
107107
content_type = res.headers.get(hdrs.CONTENT_TYPE, "").lower()
108108
json = await res.json(content_type=content_type)
109109
_LOGGER.debug("got %s", json)
110-
# todo better error handling # pylint: disable=fixme
110+
# TODO better error handling # pylint: disable=fixme
111111
if json["code"] == "0000":
112112
data: dict[str, Any] = json["data"]
113113
return data
114114
if json["code"] in ["1005", "1010"]:
115115
raise InvalidAuthenticationError
116116

117117
_LOGGER.error("call to %s failed with %s", url, json)
118-
raise AuthenticationError(
119-
f"failure code {json['code']} ({json['msg']}) for call {url}"
120-
)
118+
msg = f"failure code {json['code']} ({json['msg']}) for call {url}"
119+
raise AuthenticationError(msg)
121120

122121
async def __call_login_api(
123122
self, account_id: str, password_hash: str
@@ -204,9 +203,10 @@ async def __call_login_by_it_token(
204203
continue
205204

206205
_LOGGER.error("call to %s failed with %s", _PATH_USERS_USER, resp)
207-
raise AuthenticationError(
206+
msg = (
208207
f"failure {resp['error']} ({resp['errno']}) for call {_PATH_USERS_USER}"
209208
)
209+
raise AuthenticationError(msg)
210210

211211
raise AuthenticationError("failed to login with token")
212212

@@ -302,7 +302,7 @@ def __init__(
302302
config: Configuration,
303303
account_id: str,
304304
password_hash: str,
305-
):
305+
) -> None:
306306
self._auth_client = _AuthClient(
307307
config,
308308
account_id,
@@ -317,7 +317,7 @@ def __init__(
317317
self._refresh_handle: asyncio.TimerHandle | None = None
318318
self._tasks: set[asyncio.Future[Any]] = set()
319319

320-
async def authenticate(self, force: bool = False) -> Credentials:
320+
async def authenticate(self, *, force: bool = False) -> Credentials:
321321
"""Authenticate on ecovacs servers."""
322322
async with self._lock:
323323
if (
@@ -379,7 +379,7 @@ def refresh() -> None:
379379

380380
async def async_refresh() -> None:
381381
try:
382-
await self.authenticate(True)
382+
await self.authenticate(force=True)
383383
except Exception: # pylint: disable=broad-except
384384
_LOGGER.exception("An exception occurred during refreshing token")
385385

deebot_client/capabilities.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@
44
from types import MappingProxyType
55
from typing import TYPE_CHECKING, Any, Generic, TypeVar
66

7-
from deebot_client.command import Command
8-
from deebot_client.commands.json.common import SetCommand
7+
from deebot_client.command import Command, SetCommand
98
from deebot_client.events import (
109
AdvancedModeEvent,
1110
AvailabilityEvent,
@@ -35,12 +34,14 @@
3534
StatsEvent,
3635
TotalStatsEvent,
3736
TrueDetectEvent,
37+
VoiceAssistantStateEvent,
3838
VolumeEvent,
3939
WaterAmount,
4040
WaterInfoEvent,
4141
WorkMode,
4242
WorkModeEvent,
4343
)
44+
from deebot_client.events.efficiency_mode import EfficiencyMode, EfficiencyModeEvent
4445
from deebot_client.models import CleanAction, CleanMode
4546

4647
if TYPE_CHECKING:
@@ -121,7 +122,7 @@ class CapabilityClean:
121122
action: CapabilityCleanAction
122123
continuous: CapabilitySetEnable[ContinuousCleaningEvent]
123124
count: CapabilitySet[CleanCountEvent, int] | None = None
124-
log: CapabilityEvent[CleanLogEvent]
125+
log: CapabilityEvent[CleanLogEvent] | None = None
125126
preference: CapabilitySetEnable[CleanPreferenceEvent] | None = None
126127
work_mode: CapabilitySetTypes[WorkModeEvent, WorkMode] | None = None
127128

@@ -169,7 +170,11 @@ class CapabilitySettings:
169170

170171
advanced_mode: CapabilitySetEnable[AdvancedModeEvent]
171172
carpet_auto_fan_boost: CapabilitySetEnable[CarpetAutoFanBoostEvent]
173+
efficiency_mode: (
174+
CapabilitySetTypes[EfficiencyModeEvent, EfficiencyMode] | None
175+
) = None
172176
true_detect: CapabilitySetEnable[TrueDetectEvent] | None = None
177+
voice_assistant: CapabilitySetEnable[VoiceAssistantStateEvent] | None = None
173178
volume: CapabilitySet[VolumeEvent, int]
174179

175180

deebot_client/command.py

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,17 @@
22
from abc import ABC, abstractmethod
33
import asyncio
44
from dataclasses import dataclass, field
5+
from types import MappingProxyType
56
from typing import Any, final
67

8+
from deebot_client.events import AvailabilityEvent
79
from deebot_client.exceptions import DeebotError
810

911
from .authentication import Authenticator
1012
from .const import PATH_API_IOT_DEVMANAGER, REQUEST_HEADERS, DataType
1113
from .event_bus import EventBus
1214
from .logging_filter import get_logger
13-
from .message import HandlingResult, HandlingState
15+
from .message import HandlingResult, HandlingState, MessageBody
1416
from .models import DeviceInfo
1517

1618
_LOGGER = get_logger(__name__)
@@ -53,7 +55,7 @@ def name(cls) -> str:
5355
@classmethod
5456
@abstractmethod
5557
def data_type(cls) -> DataType:
56-
"""Data type.""" # noqa: D401
58+
"""Data type."""
5759

5860
@abstractmethod
5961
def _get_payload(self) -> dict[str, Any] | list[Any] | str:
@@ -189,6 +191,49 @@ def __hash__(self) -> int:
189191
return hash(self.name) + hash(self._args)
190192

191193

194+
class CommandWithMessageHandling(Command, MessageBody, ABC):
195+
"""Command, which handle response by itself."""
196+
197+
_is_available_check: bool = False
198+
199+
def _handle_response(
200+
self, event_bus: EventBus, response: dict[str, Any]
201+
) -> CommandResult:
202+
"""Handle response from a command.
203+
204+
:return: A message response
205+
"""
206+
if response.get("ret") == "ok":
207+
data = response.get("resp", response)
208+
result = self.handle(event_bus, data)
209+
return CommandResult(result.state, result.args)
210+
211+
if errno := response.get("errno", None):
212+
match errno:
213+
case 4200:
214+
# bot offline
215+
_LOGGER.info(
216+
'Device is offline. Could not execute command "%s"', self.name
217+
)
218+
event_bus.notify(AvailabilityEvent(available=False))
219+
return CommandResult(HandlingState.FAILED)
220+
case 500:
221+
if self._is_available_check:
222+
_LOGGER.info(
223+
'No response received for command "%s" during availability-check.',
224+
self.name,
225+
)
226+
else:
227+
_LOGGER.warning(
228+
'No response received for command "%s". This can happen if the device has network issues or does not support the command',
229+
self.name,
230+
)
231+
return CommandResult(HandlingState.FAILED)
232+
233+
_LOGGER.warning('Command "%s" was not successfully.', self.name)
234+
return CommandResult(HandlingState.ANALYSE)
235+
236+
192237
@dataclass
193238
class InitParam:
194239
"""Init param."""
@@ -200,7 +245,7 @@ class InitParam:
200245
class CommandMqttP2P(Command, ABC):
201246
"""Command which can handle mqtt p2p messages."""
202247

203-
_mqtt_params: dict[str, InitParam | None]
248+
_mqtt_params: MappingProxyType[str, InitParam | None]
204249

205250
@abstractmethod
206251
def handle_mqtt_p2p(self, event_bus: EventBus, response: dict[str, Any]) -> None:
@@ -230,10 +275,29 @@ def _pop_or_raise(name: str, type_: type, data: dict[str, Any]) -> Any:
230275
try:
231276
value = data.pop(name)
232277
except KeyError as err:
233-
raise DeebotError(f'"{name}" is missing in {data}') from err
278+
msg = f'"{name}" is missing in {data}'
279+
raise DeebotError(msg) from err
234280
try:
235281
return type_(value)
236282
except ValueError as err:
237-
raise DeebotError(
238-
f'Could not convert "{value}" of {name} into {type_}'
239-
) from err
283+
msg = f'Could not convert "{value}" of {name} into {type_}'
284+
raise DeebotError(msg) from err
285+
286+
287+
class SetCommand(CommandWithMessageHandling, CommandMqttP2P, ABC):
288+
"""Base set command.
289+
290+
Command needs to be linked to the "get" command, for handling (updating) the sensors.
291+
"""
292+
293+
@property
294+
@abstractmethod
295+
def get_command(self) -> type[CommandWithMessageHandling]:
296+
"""Return the corresponding "get" command."""
297+
raise NotImplementedError # pragma: no cover
298+
299+
def handle_mqtt_p2p(self, event_bus: EventBus, response: dict[str, Any]) -> None:
300+
"""Handle response received over the mqtt channel "p2p"."""
301+
result = self.handle(event_bus, response)
302+
if result.state == HandlingState.SUCCESS and isinstance(self._args, dict):
303+
self.get_command.handle(event_bus, self._args)

0 commit comments

Comments
 (0)