From 932a8f03b8799360da61bf788f08a74a5903c800 Mon Sep 17 00:00:00 2001 From: Astute4185 Date: Wed, 25 Mar 2026 16:31:13 +1000 Subject: [PATCH 01/43] feat: add NGIOT control and status support for eyfj07 - add NGIOT endpoint-control request path for eco-ng devices - wire eyfj07 status reads through APN 10001 - implement captured control actions for clean, pause, resume, return, cancel return, locate, and area clean - add fan mode get/set support using captured NGIOT fanMode payloads - add volume set support using captured NGIOT volume payloads - correct SST authentication module naming/import issues - align command handling with dedicated 400xx/500xx APNs instead of incorrect 10001 writes - keep mop/map support out of scope for now (Lost the mop attachment). --- deebot_client/authentication.py | 272 +++++++++++++- deebot_client/commands/ngiot/__init__.py | 59 +++ deebot_client/commands/ngiot/battery.py | 33 ++ deebot_client/commands/ngiot/charge.py | 44 +++ deebot_client/commands/ngiot/clean.py | 135 +++++++ deebot_client/commands/ngiot/common.py | 124 +++++++ deebot_client/commands/ngiot/custom.py | 31 ++ deebot_client/commands/ngiot/error.py | 35 ++ deebot_client/commands/ngiot/fan_speed.py | 90 +++++ deebot_client/commands/ngiot/life_span.py | 60 +++ deebot_client/commands/ngiot/locate.py | 23 ++ deebot_client/commands/ngiot/network.py | 44 +++ deebot_client/commands/ngiot/play_sound.py | 16 + deebot_client/commands/ngiot/stats.py | 94 +++++ deebot_client/commands/ngiot/volume.py | 56 +++ deebot_client/hardware/eyfj07.py | 124 +++++++ deebot_client/ngiot_client.py | 403 +++++++++++++++++++++ deebot_client/ngiot_probe.py | 271 ++++++++++++++ deebot_client/sst_authentication.py | 309 ++++++++++++++++ 19 files changed, 2212 insertions(+), 11 deletions(-) create mode 100644 deebot_client/commands/ngiot/__init__.py create mode 100644 deebot_client/commands/ngiot/battery.py create mode 100644 deebot_client/commands/ngiot/charge.py create mode 100644 deebot_client/commands/ngiot/clean.py create mode 100644 deebot_client/commands/ngiot/common.py create mode 100644 deebot_client/commands/ngiot/custom.py create mode 100644 deebot_client/commands/ngiot/error.py create mode 100644 deebot_client/commands/ngiot/fan_speed.py create mode 100644 deebot_client/commands/ngiot/life_span.py create mode 100644 deebot_client/commands/ngiot/locate.py create mode 100644 deebot_client/commands/ngiot/network.py create mode 100644 deebot_client/commands/ngiot/play_sound.py create mode 100644 deebot_client/commands/ngiot/stats.py create mode 100644 deebot_client/commands/ngiot/volume.py create mode 100644 deebot_client/hardware/eyfj07.py create mode 100644 deebot_client/ngiot_client.py create mode 100644 deebot_client/ngiot_probe.py create mode 100644 deebot_client/sst_authentication.py diff --git a/deebot_client/authentication.py b/deebot_client/authentication.py index b8f8cb127..fa14909e9 100644 --- a/deebot_client/authentication.py +++ b/deebot_client/authentication.py @@ -3,11 +3,12 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass +from collections.abc import Mapping +from dataclasses import dataclass, fields, is_dataclass from http import HTTPStatus import time from typing import TYPE_CHECKING, Any -from urllib.parse import urljoin +from urllib.parse import urljoin, urlparse from aiohttp import ClientResponseError, ClientSession, ClientTimeout, hdrs @@ -25,7 +26,11 @@ from .util.countries import get_ecovacs_country if TYPE_CHECKING: - from collections.abc import Callable, Coroutine, Mapping + from collections.abc import Callable, Coroutine + + from .models import ApiDeviceInfo, StaticDeviceInfo + from .ngiot_client import NgiotClient + from .sst_authentication import SstAuthenticator _LOGGER = get_logger(__name__) @@ -44,6 +49,8 @@ "deviceType": "1", } MAX_RETRIES = 3 +_NGIOT_BASE_URL_TEMPLATE = "https://api-base.dc-{region}.ww.ecouser.net" +_NGIOT_COMMAND_MODULE_PREFIX = "deebot_client.commands.ngiot" @dataclass(frozen=True, kw_only=True) @@ -58,6 +65,21 @@ class RestConfiguration: auth_code_url: str +@dataclass(frozen=True, kw_only=True) +class NgiotConfiguration: + """Optional overrides and defaults for NGIOT-backed devices.""" + + base_url: str | None = None + region: str | None = None + user_agent: str = "okhttp/4.9.1" + channel: str = "Android" + protocol_version: str = "0.0.22" + timezone_name: str = "UTC" + timezone_offset_minutes: int = 0 + requested_ttl: int = 600 + refresh_skew: int = 60 + + def create_rest_config( session: ClientSession, *, @@ -127,9 +149,6 @@ async def login(self) -> Credentials: user_id = login_token_resp["userId"] user_access_token = login_token_resp["token"] - # last is validity in milliseconds. Usually 7 days - # we set the expiry at 99% of the validity - # 604800 = 7 days expires_at = int( time.time() + int(login_token_resp.get("last", 604800)) / 1000 * 0.99 ) @@ -149,11 +168,9 @@ async def __do_auth_response( ) as res: res.raise_for_status() - # ecovacs returns a json but content_type header is set to text content_type = res.headers.get(hdrs.CONTENT_TYPE, "").lower() json = await res.json(content_type=content_type) _LOGGER.debug("got %s", json) - # TODO better error handling if json["code"] == "0000": data: dict[str, Any] = json["data"] return data @@ -246,7 +263,6 @@ async def __call_login_by_it_token( if resp["result"] == "ok": return resp if resp["result"] == "fail" and resp["error"] == "set token error.": - # If it is a set token error try again _LOGGER.warning("loginByItToken set token error, attempt %d/3", i + 2) continue @@ -348,6 +364,7 @@ def __init__( account_id: str, password_hash: str, ) -> None: + self._config = config self._auth_client = _AuthClient( config, account_id, @@ -361,6 +378,122 @@ def __init__( self._credentials: Credentials | None = None self._refresh_handle: asyncio.TimerHandle | None = None self._tasks: set[asyncio.Future[Any]] = set() + self._ngiot_config = NgiotConfiguration() + self._ngiot_base_url: str | None = None + self.sst_authenticator: SstAuthenticator | None = None + self.ngiot_client: NgiotClient | None = None + + def configure_ngiot( + self, + *, + base_url: str | None = None, + region: str | None = None, + user_agent: str = "okhttp/4.9.1", + channel: str = "Android", + protocol_version: str = "0.0.22", + timezone_name: str = "UTC", + timezone_offset_minutes: int = 0, + requested_ttl: int = 600, + refresh_skew: int = 60, + ) -> None: + """Store NGIOT defaults and optional region/base-url overrides. + + ``base_url`` wins over ``region``. If neither is configured, the + runtime derives the SST endpoint from the device ``service.mqs`` host. + """ + + normalized_base_url = ( + self._normalize_base_url(base_url) if base_url is not None else None + ) + normalized_region = ( + self._normalize_region(region) if region is not None else None + ) + self._ngiot_config = NgiotConfiguration( + base_url=normalized_base_url, + region=normalized_region, + user_agent=user_agent, + channel=channel, + protocol_version=protocol_version, + timezone_name=timezone_name, + timezone_offset_minutes=timezone_offset_minutes, + requested_ttl=requested_ttl, + refresh_skew=refresh_skew, + ) + + def attach_ngiot( + self, + *, + base_url: str | None = None, + region: str | None = None, + user_agent: str = "okhttp/4.9.1", + channel: str = "Android", + protocol_version: str = "0.0.22", + timezone_name: str = "UTC", + timezone_offset_minutes: int = 0, + requested_ttl: int = 600, + refresh_skew: int = 60, + ) -> None: + """Attach NGIOT helpers immediately using an explicit base URL or region.""" + + self.configure_ngiot( + base_url=base_url, + region=region, + user_agent=user_agent, + channel=channel, + protocol_version=protocol_version, + timezone_name=timezone_name, + timezone_offset_minutes=timezone_offset_minutes, + requested_ttl=requested_ttl, + refresh_skew=refresh_skew, + ) + resolved_base_url = self._resolve_configured_ngiot_base_url() + if resolved_base_url is None: + msg = ( + "attach_ngiot() requires base_url or region. " + "For automatic per-device attachment, call configure_ngiot() and let " + "ApiClient.get_devices() bootstrap NGIOT for matching hardware classes." + ) + raise ApiError(msg) + + if self.ngiot_client is not None: + if self._ngiot_base_url == resolved_base_url: + return + msg = ( + "NGIOT transport already attached with a different base URL. " + "Use configure_ngiot() plus automatic device bootstrap, or call teardown() first." + ) + raise ApiError(msg) + + self._create_ngiot_stack(resolved_base_url) + + async def ensure_ngiot_for_device( + self, + device_info: ApiDeviceInfo, + static_device_info: StaticDeviceInfo, + ) -> bool: + """Attach NGIOT transport if the hardware profile uses NGIOT commands.""" + + if not self._uses_ngiot(static_device_info): + return False + + desired_base_url = self._resolve_ngiot_base_url(device_info) + if self.ngiot_client is not None and self._ngiot_base_url == desired_base_url: + return True + + if self.sst_authenticator is not None: + if self._ngiot_base_url != desired_base_url: + _LOGGER.info( + "Re-attaching NGIOT transport with region/base URL %s for %s", + desired_base_url, + device_info["class"], + ) + await self.sst_authenticator.teardown() + + self.sst_authenticator = None + self.ngiot_client = None + self._ngiot_base_url = None + self._create_ngiot_stack(desired_base_url) + return True async def authenticate(self, *, force: bool = False) -> Credentials: """Authenticate on ecovacs servers.""" @@ -411,6 +544,11 @@ async def post_authenticated( async def teardown(self) -> None: """Teardown authenticator.""" self._cancel_refresh_task() + if self.sst_authenticator is not None: + await self.sst_authenticator.teardown() + self.sst_authenticator = None + self.ngiot_client = None + self._ngiot_base_url = None await cancel(self._tasks) def _cancel_refresh_task(self) -> None: @@ -418,7 +556,6 @@ def _cancel_refresh_task(self) -> None: self._refresh_handle.cancel() def _create_refresh_task(self, credentials: Credentials) -> None: - # refresh at 99% of validity def refresh() -> None: _LOGGER.debug("Refresh token") @@ -432,5 +569,118 @@ async def async_refresh() -> None: self._refresh_handle = None validity = (credentials.expires_at - time.time()) * 0.99 - self._refresh_handle = asyncio.get_event_loop().call_later(validity, refresh) + + def _create_ngiot_stack(self, base_url: str) -> None: + from .ngiot_client import NgiotClient + from .sst_authentication import SstAuthenticator + + normalized_base_url = self._normalize_base_url(base_url) + self.sst_authenticator = SstAuthenticator( + self._config.session, + self, + base_url=normalized_base_url, + requested_ttl=self._ngiot_config.requested_ttl, + refresh_skew=self._ngiot_config.refresh_skew, + ) + self.ngiot_client = NgiotClient( + self._config.session, + self.sst_authenticator, + user_agent=self._ngiot_config.user_agent, + channel=self._ngiot_config.channel, + protocol_version=self._ngiot_config.protocol_version, + timezone_name=self._ngiot_config.timezone_name, + timezone_offset_minutes=self._ngiot_config.timezone_offset_minutes, + ) + self._ngiot_base_url = normalized_base_url + + def _resolve_configured_ngiot_base_url(self) -> str | None: + if self._ngiot_config.base_url is not None: + return self._ngiot_config.base_url + if self._ngiot_config.region is not None: + return self._format_ngiot_base_url(self._ngiot_config.region) + return None + + def _resolve_ngiot_base_url(self, device_info: ApiDeviceInfo) -> str: + configured_base_url = self._resolve_configured_ngiot_base_url() + if configured_base_url is not None: + return configured_base_url + + service = device_info.get("service") + if isinstance(service, Mapping): + mqs_host = service.get("mqs") + if isinstance(mqs_host, str) and mqs_host: + return self._derive_ngiot_base_url_from_mqs(mqs_host) + + msg = ( + f'Could not resolve NGIOT base URL for device class "{device_info["class"]}". ' + "Configure an explicit region or base_url before device bootstrap." + ) + raise ApiError(msg) + + @classmethod + def _uses_ngiot(cls, static_device_info: StaticDeviceInfo) -> bool: + return cls._object_uses_ngiot(getattr(static_device_info, "capabilities", None)) + + @classmethod + def _object_uses_ngiot(cls, value: object) -> bool: + if value is None: + return False + if isinstance(value, type): + return cls._is_ngiot_module(value.__module__) + if cls._is_ngiot_module(value.__class__.__module__): + return True + if isinstance(value, Mapping): + return any( + cls._object_uses_ngiot(key) or cls._object_uses_ngiot(item) + for key, item in value.items() + ) + if isinstance(value, (list, tuple, set, frozenset)): + return any(cls._object_uses_ngiot(item) for item in value) + if is_dataclass(value): + return any( + cls._object_uses_ngiot(getattr(value, field.name)) + for field in fields(value) + ) + return False + + @staticmethod + def _is_ngiot_module(module_name: str) -> bool: + return module_name.startswith(_NGIOT_COMMAND_MODULE_PREFIX) + + @staticmethod + def _normalize_base_url(base_url: str) -> str: + parsed = urlparse(base_url) + if parsed.scheme and parsed.netloc: + host = parsed.netloc + else: + host = base_url + return f"https://{host.strip().rstrip('/')}" + + @staticmethod + def _normalize_region(region: str) -> str: + normalized = region.strip().lower() + if normalized.startswith("dc-"): + normalized = normalized[3:] + return normalized + + @classmethod + def _format_ngiot_base_url(cls, region: str) -> str: + return _NGIOT_BASE_URL_TEMPLATE.format(region=cls._normalize_region(region)) + + @classmethod + def _derive_ngiot_base_url_from_mqs(cls, mqs_host: str) -> str: + parsed = urlparse(mqs_host) + host = parsed.netloc or parsed.path + host = host.strip().rstrip("/") + if not host: + msg = f'Could not derive NGIOT base URL from mqs host "{mqs_host}"' + raise ApiError(msg) + if host.startswith("api-base."): + return cls._normalize_base_url(host) + if host.startswith("api-ngiot."): + return cls._normalize_base_url("api-base." + host.split(".", 1)[1]) + if "." in host: + return cls._normalize_base_url("api-base." + host.split(".", 1)[1]) + msg = f'Could not derive NGIOT base URL from mqs host "{mqs_host}"' + raise ApiError(msg) diff --git a/deebot_client/commands/ngiot/__init__.py b/deebot_client/commands/ngiot/__init__.py new file mode 100644 index 000000000..fc88095e0 --- /dev/null +++ b/deebot_client/commands/ngiot/__init__.py @@ -0,0 +1,59 @@ +"""NGIOT commands module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .battery import GetBattery +from .charge import Charge +from .clean import Clean, CleanArea, GetCleanInfo +from .custom import CustomCommand +from .error import GetError +from .fan_speed import GetFanSpeed, SetFanSpeed +from .life_span import GetLifeSpan, ResetLifeSpan +from .network import GetNetInfo +from .play_sound import PlaySound +from .stats import GetReportStats, GetStats, GetTotalStats + +if TYPE_CHECKING: + from deebot_client.command import Command + +__all__ = [ + "Charge", + "Clean", + "CleanArea", + "CustomCommand", + "GetBattery", + "GetCleanInfo", + "GetError", + "GetFanSpeed", + "GetLifeSpan", + "GetNetInfo", + "GetReportStats", + "GetStats", + "GetTotalStats", + "PlaySound", + "ResetLifeSpan", + "SetFanSpeed", +] + +_COMMANDS: list[type[Command]] = [ + GetBattery, + Charge, + Clean, + CleanArea, + GetCleanInfo, + CustomCommand, + GetError, + GetFanSpeed, + SetFanSpeed, + GetLifeSpan, + ResetLifeSpan, + GetNetInfo, + PlaySound, + GetReportStats, + GetStats, + GetTotalStats, +] + +COMMANDS: dict[str, type[Command]] = {cmd.NAME: cmd for cmd in _COMMANDS} \ No newline at end of file diff --git a/deebot_client/commands/ngiot/battery.py b/deebot_client/commands/ngiot/battery.py new file mode 100644 index 000000000..1387a9323 --- /dev/null +++ b/deebot_client/commands/ngiot/battery.py @@ -0,0 +1,33 @@ +"""Battery commands.""" + +from __future__ import annotations + +from typing import Any + +from deebot_client.events import AvailabilityEvent, BatteryEvent +from deebot_client.message import HandlingResult + +from .common import RobotDetailGetCommand + + +class GetBattery(RobotDetailGetCommand): + """Get battery percentage.""" + + NAME = 'getBattery' + FIELDS = ('battery',) + + def __init__(self, *, is_available_check: bool = False) -> None: + super().__init__(is_available_check=is_available_check) + + @classmethod + def _handle_body_data_dict( + cls, + event_bus, + data: dict[str, Any], + ) -> HandlingResult: + battery = data.get('battery') + available = battery is not None + event_bus.notify(AvailabilityEvent(available=available)) + if available: + event_bus.notify(BatteryEvent(int(battery))) + return HandlingResult.success() diff --git a/deebot_client/commands/ngiot/charge.py b/deebot_client/commands/ngiot/charge.py new file mode 100644 index 000000000..04191f578 --- /dev/null +++ b/deebot_client/commands/ngiot/charge.py @@ -0,0 +1,44 @@ +"""Charge commands.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from deebot_client.events import StateEvent +from deebot_client.message import HandlingResult, HandlingState +from deebot_client.models import State +from deebot_client.ngiot_client import APN_RETURN_TO_DOCK + +from .common import NgiotExecuteCommand + +if TYPE_CHECKING: + from deebot_client.event_bus import EventBus + from deebot_client.models import ApiDeviceInfo + from deebot_client.ngiot_client import NgiotClient + + +class Charge(NgiotExecuteCommand): + """Return robot to charge dock.""" + + NAME = "charge" + + def __init__(self) -> None: + super().__init__({}) + + async def _request_ngiot( + self, + client: NgiotClient, + device_info: ApiDeviceInfo, + ) -> dict[str, Any]: + return await client.request( + device_info, + apn=APN_RETURN_TO_DOCK, + body_data={"chargeSwitch": True}, + ) + + @classmethod + def _handle_body(cls, event_bus: EventBus, body: dict[str, Any]) -> HandlingResult: + result = super()._handle_body(event_bus, body) + if result.state == HandlingState.SUCCESS: + event_bus.notify(StateEvent(State.RETURNING)) + return result \ No newline at end of file diff --git a/deebot_client/commands/ngiot/clean.py b/deebot_client/commands/ngiot/clean.py new file mode 100644 index 000000000..f74404518 --- /dev/null +++ b/deebot_client/commands/ngiot/clean.py @@ -0,0 +1,135 @@ +"""Clean commands.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any + +from deebot_client.events import StateEvent +from deebot_client.exceptions import ApiError +from deebot_client.message import HandlingResult +from deebot_client.models import CleanAction, CleanMode, State +from deebot_client.ngiot_client import APN_AREA_CLEAN, APN_CLEAN_START, APN_PAUSE + +from .common import NgiotExecuteCommand, RobotDetailGetCommand + +if TYPE_CHECKING: + from deebot_client.models import ApiDeviceInfo + from deebot_client.ngiot_client import NgiotClient + + +class Clean(NgiotExecuteCommand): + """Translate generic clean actions into captured NGIOT control payloads.""" + + NAME = "clean" + + def __init__(self, action: CleanAction) -> None: + super().__init__({}) + self._action = action + + async def _request_ngiot( + self, + client: NgiotClient, + device_info: ApiDeviceInfo, + ) -> dict[str, Any]: + apn, body_data = self._get_request() + return await client.request( + device_info, + apn=apn, + body_data=body_data, + ) + + def _get_request(self) -> tuple[str, dict[str, Any]]: + if self._action is CleanAction.START: + return APN_CLEAN_START, {"cleanSwitch": True, "cleanMode": "smart"} + if self._action is CleanAction.PAUSE: + return APN_PAUSE, {"pauseSwitch": True} + if self._action is CleanAction.RESUME: + return APN_RESUME, {"pauseSwitch": False} + raise ApiError( + "CleanAction.STOP payload has not been captured for NGIOT yet" + ) + +class CleanArea(NgiotExecuteCommand): + """Start room/area cleaning using room IDs.""" + + NAME = "clean" + + def __init__( + self, + mode: CleanMode, + area: list[int | float], + cleanings: int = 1, + ) -> None: + super().__init__({}) + self._mode = mode + self._room_ids = [int(value) for value in area] + self._cleanings = cleanings + + async def _request_ngiot( + self, + client: NgiotClient, + device_info: ApiDeviceInfo, + ) -> dict[str, Any]: + if self._mode not in (CleanMode.CUSTOM_AREA, CleanMode.SPOT_AREA): + raise ApiError( + f"Clean mode {self._mode!s} is not mapped for NGIOT room cleaning" + ) + + if self._cleanings != 1: + raise ApiError( + "NGIOT room cleaning repeat count has not been captured yet" + ) + + return await client.request( + device_info, + apn=APN_AREA_CLEAN, + body_data={ + "cleanSwitch": True, + "cleanMode": "area", + "cleanValues": self._room_ids, + }, + ) + + +class GetCleanInfo(RobotDetailGetCommand): + """Get high-level robot state.""" + + NAME = "getCleanInfo" + FIELDS = ("chargeStatus", "pauseSwitch", "workMode", "error") + + @classmethod + def _handle_body_data_dict( + cls, + event_bus, + data: dict[str, Any], + ) -> HandlingResult: + event_bus.notify(StateEvent(_map_state(data))) + return HandlingResult.success() + + +def _extract_first_int(value: Any) -> int: + if isinstance(value, list) and value: + value = value[0] + try: + return int(value) + except (TypeError, ValueError): + return 0 + + +def _map_state(data: Mapping[str, Any]) -> State: + if _extract_first_int(data.get("error")) != 0: + return State.ERROR + if bool(data.get("pauseSwitch")): + return State.PAUSED + + work_mode = str(data.get("workMode", "")).lower() + charge_status = bool(data.get("chargeStatus")) + + if charge_status and work_mode in {"stop", "idle", "", "none"}: + return State.DOCKED + if charge_status: + return State.RETURNING + if work_mode in {"smart", "area", "auto", "customarea", "spotarea"}: + return State.CLEANING + return State.IDLE \ No newline at end of file diff --git a/deebot_client/commands/ngiot/common.py b/deebot_client/commands/ngiot/common.py new file mode 100644 index 000000000..ee2bc7fd9 --- /dev/null +++ b/deebot_client/commands/ngiot/common.py @@ -0,0 +1,124 @@ +"""Common NGIOT command helpers.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any +import inspect + +from deebot_client.commands.json.common import ExecuteCommand, JsonGetCommand +from deebot_client.exceptions import ApiError +from deebot_client.ngiot_client import APN_ROBOT_DETAIL + +if TYPE_CHECKING: + from deebot_client.authentication import Authenticator + from deebot_client.models import ApiDeviceInfo + from deebot_client.ngiot_client import NgiotClient + + +class NgiotJsonCommandMixin(ABC): + """Mixin that routes command execution through ``Authenticator.ngiot_client``. + + The upstream command stack expects responses in the legacy devmanager shape, + i.e. ``{"ret": "ok", "resp": {"body": ...}}``. NGIOT returns a raw + ``{"header": ..., "body": ...}`` envelope, so this mixin adapts the raw + response into the shape already handled by ``CommandWithMessageHandling``. + """ + + async def _execute_api_request( # type: ignore[override] + self, + authenticator: Authenticator, + device_info: ApiDeviceInfo, + ) -> dict[str, Any]: + client = await self._get_ngiot_client(authenticator, device_info) + response = await self._request_ngiot(client, device_info) + return self._wrap_response(response) + + async def _get_ngiot_client(self, authenticator, device_info): + client = getattr(authenticator, "ngiot_client", None) + if client is not None: + return client + + ensure = getattr(authenticator, "ensure_ngiot_for_device", None) + if ensure is not None: + raw_device = device_info.api if hasattr(device_info, "api") else device_info + result = ensure(raw_device) + if inspect.isawaitable(result): + await result + + client = getattr(authenticator, "ngiot_client", None) + if client is not None: + return client + + raise ApiError( + "NGIOT client not attached to authenticator after bootstrap attempt" + ) + + @staticmethod + def _wrap_response(response: Mapping[str, Any]) -> dict[str, Any]: + body = response.get('body', {}) + if not isinstance(body, Mapping): + body = {} + return {'ret': 'ok', 'resp': {'body': dict(body)}} + + @abstractmethod + async def _request_ngiot( + self, + client: NgiotClient, + device_info: ApiDeviceInfo, + ) -> dict[str, Any]: + """Execute the NGIOT request and return the raw NGIOT envelope.""" + + +class NgiotJsonGetCommand(NgiotJsonCommandMixin, JsonGetCommand, ABC): + """Base class for NGIOT-backed get commands.""" + + def __init__( + self, + args: dict[str, Any] | list[Any] | None = None, + *, + is_available_check: bool = False, + ) -> None: + super().__init__(args) + self._is_available_check = is_available_check + + +class NgiotExecuteCommand(NgiotJsonCommandMixin, ExecuteCommand, ABC): + """Base class for NGIOT-backed execute commands.""" + + +class RobotDetailGetCommand(NgiotJsonGetCommand, ABC): + """Base class for APN 10001 field queries.""" + + FIELDS: tuple[str, ...] = () + + async def _request_ngiot( + self, + client: NgiotClient, + device_info: ApiDeviceInfo, + ) -> dict[str, Any]: + return await client.request( + device_info, + apn=APN_ROBOT_DETAIL, + body_data={'fields': list(self.FIELDS)}, + ) + + +class RobotDetailSetCommand(NgiotExecuteCommand, ABC): + """Base class for APN 10001 writes.""" + + async def _request_ngiot( + self, + client: NgiotClient, + device_info: ApiDeviceInfo, + ) -> dict[str, Any]: + return await client.request( + device_info, + apn=APN_ROBOT_DETAIL, + body_data=self._get_body_data(), + ) + + @abstractmethod + def _get_body_data(self) -> dict[str, Any] | list[Any]: + """Return the NGIOT request body payload.""" diff --git a/deebot_client/commands/ngiot/custom.py b/deebot_client/commands/ngiot/custom.py new file mode 100644 index 000000000..d7e10807d --- /dev/null +++ b/deebot_client/commands/ngiot/custom.py @@ -0,0 +1,31 @@ +"""Custom commands.""" + +from __future__ import annotations + +from typing import Any + +from deebot_client.events import CustomCommandEvent +from deebot_client.message import HandlingResult, HandlingState + +from .common import RobotDetailSetCommand + + +class CustomCommand(RobotDetailSetCommand): + """Send an arbitrary key/value payload to APN 10001.""" + + NAME = 'customCommand' + + def __init__(self, name: str, value: Any) -> None: + super().__init__({name: value}) + self._name = name + self._value = value + + def _get_body_data(self) -> dict[str, Any]: + return dict(self._args) + + def _handle_response(self, event_bus, response: dict[str, Any]) -> HandlingResult: + result = super()._handle_response(event_bus, response) + if result.state == HandlingState.SUCCESS: + body = response.get('resp', {}).get('body', {}) + event_bus.notify(CustomCommandEvent(name=self._name, response=body)) + return result diff --git a/deebot_client/commands/ngiot/error.py b/deebot_client/commands/ngiot/error.py new file mode 100644 index 000000000..ef03dd178 --- /dev/null +++ b/deebot_client/commands/ngiot/error.py @@ -0,0 +1,35 @@ +"""Error commands.""" + +from __future__ import annotations + +from typing import Any + +from deebot_client.events import ErrorEvent +from deebot_client.message import HandlingResult + +from .common import RobotDetailGetCommand + + +class GetError(RobotDetailGetCommand): + """Get current robot error.""" + + NAME = 'getError' + FIELDS = ('error',) + + @classmethod + def _handle_body_data_dict( + cls, + event_bus, + data: dict[str, Any], + ) -> HandlingResult: + event_bus.notify(ErrorEvent(_extract_first_int(data.get('error')))) + return HandlingResult.success() + + +def _extract_first_int(value: Any) -> int: + if isinstance(value, list) and value: + value = value[0] + try: + return int(value) + except (TypeError, ValueError): + return 0 diff --git a/deebot_client/commands/ngiot/fan_speed.py b/deebot_client/commands/ngiot/fan_speed.py new file mode 100644 index 000000000..715f3b0ca --- /dev/null +++ b/deebot_client/commands/ngiot/fan_speed.py @@ -0,0 +1,90 @@ +"""NGIOT fan speed commands.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from deebot_client.events import FanSpeedEvent, FanSpeedLevel +from deebot_client.message import HandlingResult, HandlingState +from deebot_client.util import get_enum + +from .common import NgiotExecuteCommand, RobotDetailGetCommand + +if TYPE_CHECKING: + from deebot_client.event_bus import EventBus + from deebot_client.models import ApiDeviceInfo + from deebot_client.ngiot_client import NgiotClient + +APN_FAN_MODE = "50011" + +_WIRE_TO_LEVEL: dict[str, FanSpeedLevel] = { + "quiet": FanSpeedLevel.QUIET, + "auto": FanSpeedLevel.NORMAL, + "strong": FanSpeedLevel.MAX, + "max": FanSpeedLevel.MAX_PLUS, +} + +_LEVEL_TO_WIRE: dict[FanSpeedLevel, str] = { + FanSpeedLevel.QUIET: "quiet", + FanSpeedLevel.NORMAL: "auto", + FanSpeedLevel.MAX: "strong", + FanSpeedLevel.MAX_PLUS: "max", +} + + +class GetFanSpeed(RobotDetailGetCommand): + """Get current fan speed/mode from robot detail status.""" + + NAME = "getSpeed" + FIELDS = ("fanMode",) + + @classmethod + def _handle_body_data_dict( + cls, + event_bus: EventBus, + data: dict[str, Any], + ) -> HandlingResult: + fan_mode = str(data["fanMode"]).lower() + event_bus.notify(FanSpeedEvent(_WIRE_TO_LEVEL[fan_mode])) + return HandlingResult.success() + + +class SetFanSpeed(NgiotExecuteCommand): + """Set fan speed/mode for NGIOT devices.""" + + NAME = "setSpeed" + get_command = GetFanSpeed + + def __init__(self, speed: FanSpeedLevel | str) -> None: + super().__init__({}) + if isinstance(speed, str): + speed = get_enum(FanSpeedLevel, speed) + self._speed = speed + + async def _request_ngiot( + self, + client: NgiotClient, + device_info: ApiDeviceInfo, + ) -> dict[str, Any]: + try: + fan_mode = _LEVEL_TO_WIRE[self._speed] + except KeyError as ex: + raise ValueError( + f"Fan speed {self._speed!s} is not supported by this NGIOT ruleset" + ) from ex + + return await client.request( + device_info, + apn=APN_FAN_MODE, + body_data={"fanMode": fan_mode}, + ) + + def _handle_response( + self, + event_bus: EventBus, + response: dict[str, Any], + ) -> HandlingResult: + result = super()._handle_response(event_bus, response) + if result.state == HandlingState.SUCCESS: + event_bus.notify(FanSpeedEvent(self._speed)) + return result \ No newline at end of file diff --git a/deebot_client/commands/ngiot/life_span.py b/deebot_client/commands/ngiot/life_span.py new file mode 100644 index 000000000..7d02e031e --- /dev/null +++ b/deebot_client/commands/ngiot/life_span.py @@ -0,0 +1,60 @@ +"""Life span commands.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from deebot_client.events import LifeSpan, LifeSpanEvent +from deebot_client.exceptions import ApiError +from deebot_client.message import HandlingResult + +from .common import RobotDetailGetCommand, RobotDetailSetCommand + +_CONSUMABLE_TYPES: dict[str, LifeSpan] = { + 'rollBrush': LifeSpan.BRUSH, + 'filter': LifeSpan.FILTER, + 'sideBrush': LifeSpan.SIDE_BRUSH, + 'unitCare': LifeSpan.UNIT_CARE, +} + + +class GetLifeSpan(RobotDetailGetCommand): + """Get consumable life-span data.""" + + NAME = 'getLifeSpan' + FIELDS = ('consumables',) + + @classmethod + def _handle_body_data_dict( + cls, + event_bus, + data: dict[str, Any], + ) -> HandlingResult: + for item in data.get('consumables', []) or []: + if not isinstance(item, Mapping): + continue + consumable_type = _CONSUMABLE_TYPES.get(str(item.get('type'))) + if consumable_type is None: + continue + left = int(item.get('left', 0) or 0) + total = int(item.get('total', 0) or 0) + percent = 0.0 if total <= 0 else (left / total) * 100 + event_bus.notify(LifeSpanEvent(consumable_type, percent, left)) + return HandlingResult.success() + + +class ResetLifeSpan(RobotDetailSetCommand): + """Reset a consumable counter. + + Left intentionally unimplemented until the reset payload is captured. + """ + + NAME = 'resetLifeSpan' + + def __init__(self, life_span: LifeSpan) -> None: + super().__init__({'lifeSpan': life_span.name}) + self._life_span = life_span + + def _get_body_data(self) -> dict[str, Any]: + raise ApiError('Life-span reset payload has not been captured for NGIOT yet') diff --git a/deebot_client/commands/ngiot/locate.py b/deebot_client/commands/ngiot/locate.py new file mode 100644 index 000000000..f353cecd0 --- /dev/null +++ b/deebot_client/commands/ngiot/locate.py @@ -0,0 +1,23 @@ +"""NGIOT locate-device command.""" + +from __future__ import annotations + +from deebot_client.ngiot_client import APN_DEVICE_LOCATE + +from .common import NgiotExecuteCommand + + +class LocateDevice(NgiotExecuteCommand): + """Trigger the robot locator beep on NGIOT devices.""" + + NAME = "seek" + + def __init__(self) -> None: + super().__init__({}) + + async def _request_ngiot(self, client, device_info): + return await client.request( + device_info, + apn=APN_DEVICE_LOCATE, + body_data={"seek": True}, + ) \ No newline at end of file diff --git a/deebot_client/commands/ngiot/network.py b/deebot_client/commands/ngiot/network.py new file mode 100644 index 000000000..6ccac7edf --- /dev/null +++ b/deebot_client/commands/ngiot/network.py @@ -0,0 +1,44 @@ +"""Network commands.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from deebot_client.events import NetworkInfoEvent +from deebot_client.message import HandlingResult + +from .common import RobotDetailGetCommand + + +class GetNetInfo(RobotDetailGetCommand): + """Get network info from the robot-detail surface.""" + + NAME = 'getNetInfo' + FIELDS = ('deviceInfo',) + + @classmethod + def _handle_body_data_dict( + cls, + event_bus, + data: dict[str, Any], + ) -> HandlingResult: + device_info = data.get('deviceInfo', {}) + if not isinstance(device_info, Mapping): + device_info = {} + event_bus.notify( + NetworkInfoEvent( + ip=str(device_info.get('ip', '')), + ssid=str(device_info.get('ssid', '')), + rssi=_coerce_rssi(device_info.get('rssi')), + mac=str(device_info.get('mac', '')), + ) + ) + return HandlingResult.success() + + +def _coerce_rssi(value: Any) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 diff --git a/deebot_client/commands/ngiot/play_sound.py b/deebot_client/commands/ngiot/play_sound.py new file mode 100644 index 000000000..1d56e5ff8 --- /dev/null +++ b/deebot_client/commands/ngiot/play_sound.py @@ -0,0 +1,16 @@ +"""Play-sound commands.""" + +# deebot_client/commands/json/seek_sound.py +"""Seek sound / locate command for NGIOT eyfj07.""" + +from __future__ import annotations + +from .common import ExecuteCommand + +class SeekSound(ExecuteCommand): + """Trigger device locate sound on eyfj07-like devices.""" + + NAME = "seek" + + def __init__(self) -> None: + super().__init__({"seek": True}) \ No newline at end of file diff --git a/deebot_client/commands/ngiot/stats.py b/deebot_client/commands/ngiot/stats.py new file mode 100644 index 000000000..2399a0923 --- /dev/null +++ b/deebot_client/commands/ngiot/stats.py @@ -0,0 +1,94 @@ +"""Stats commands.""" + +from __future__ import annotations + +from typing import Any + +from deebot_client.events import CleanJobStatus, ReportStatsEvent, StatsEvent, TotalStatsEvent +from deebot_client.message import HandlingResult + +from .common import RobotDetailGetCommand + + +class GetStats(RobotDetailGetCommand): + """Get current clean stats.""" + + NAME = 'getStats' + FIELDS = ('cleanArea', 'cleanTime', 'workMode') + + @classmethod + def _handle_body_data_dict( + cls, + event_bus, + data: dict[str, Any], + ) -> HandlingResult: + event_bus.notify( + StatsEvent( + area=_maybe_int(data.get('cleanArea')), + time=_maybe_int(data.get('cleanTime')), + type=_maybe_str(data.get('workMode')), + ) + ) + return HandlingResult.success() + + +class GetReportStats(RobotDetailGetCommand): + """Get best-effort report stats. + + Detailed clean-log decoding is not captured yet, so this surfaces a minimal + snapshot that satisfies the capability contract and keeps the profile loadable. + """ + + NAME = 'getReportStats' + FIELDS = ('cleanArea', 'cleanTime', 'workMode') + + @classmethod + def _handle_body_data_dict( + cls, + event_bus, + data: dict[str, Any], + ) -> HandlingResult: + event_bus.notify( + ReportStatsEvent( + area=_maybe_int(data.get('cleanArea')), + time=_maybe_int(data.get('cleanTime')), + type=_maybe_str(data.get('workMode')), + cleaning_id='', + status=CleanJobStatus.NO_STATUS, + content=[], + ) + ) + return HandlingResult.success() + + +class GetTotalStats(RobotDetailGetCommand): + """Get best-effort lifetime stats.""" + + NAME = 'getTotalStats' + FIELDS = ('cleanArea', 'cleanTime', 'cleanCount') + + @classmethod + def _handle_body_data_dict( + cls, + event_bus, + data: dict[str, Any], + ) -> HandlingResult: + event_bus.notify( + TotalStatsEvent( + area=int(data.get('cleanArea', 0) or 0), + time=int(data.get('cleanTime', 0) or 0), + cleanings=int(data.get('cleanCount', 0) or 0), + ) + ) + return HandlingResult.success() + + +def _maybe_int(value: Any) -> int | None: + try: + return int(value) if value is not None else None + except (TypeError, ValueError): + return None + + +def _maybe_str(value: Any) -> str | None: + return None if value is None else str(value) diff --git a/deebot_client/commands/ngiot/volume.py b/deebot_client/commands/ngiot/volume.py new file mode 100644 index 000000000..0024a27bd --- /dev/null +++ b/deebot_client/commands/ngiot/volume.py @@ -0,0 +1,56 @@ +"""Volume commands.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from deebot_client.events import VolumeEvent +from deebot_client.message import HandlingResult, HandlingState + +from .common import NgiotExecuteCommand + +if TYPE_CHECKING: + from deebot_client.event_bus import EventBus + from deebot_client.models import ApiDeviceInfo + from deebot_client.ngiot_client import NgiotClient + + +class SetVolume(NgiotExecuteCommand): + """Set device voice volume.""" + + NAME = "setVolume" + APN = "50023" + MIN_VOLUME = 0 + MAX_VOLUME = 10 + + def __init__(self, volume: int) -> None: + super().__init__({}) + self._volume = int(volume) + + async def _request_ngiot( + self, + client: NgiotClient, + device_info: ApiDeviceInfo, + ) -> dict[str, Any]: + if not self.MIN_VOLUME <= self._volume <= self.MAX_VOLUME: + raise ValueError( + f"Volume must be between {self.MIN_VOLUME} and {self.MAX_VOLUME}" + ) + + return await client.request( + device_info, + apn=self.APN, + body_data={"volume": self._volume}, + ) + + def _handle_response( + self, + event_bus: EventBus, + response: dict[str, Any], + ) -> HandlingResult: + result = super()._handle_response(event_bus, response) + if result.state == HandlingState.SUCCESS: + event_bus.notify( + VolumeEvent(volume=self._volume, maximum=self.MAX_VOLUME) + ) + return result \ No newline at end of file diff --git a/deebot_client/hardware/eyfj07.py b/deebot_client/hardware/eyfj07.py new file mode 100644 index 000000000..1391951b1 --- /dev/null +++ b/deebot_client/hardware/eyfj07.py @@ -0,0 +1,124 @@ +"""DEEBOT eyfj07 capabilities. + +This profile is scoped to loading cleanly against the current ``client.py`` +capability contract. Raster map support and additional write payloads can be +added later once their command surfaces are implemented. +""" + +from __future__ import annotations + +from deebot_client.capabilities import ( + Capabilities, + CapabilityClean, + CapabilityCleanAction, + CapabilityCustomCommand, + CapabilityEvent, + CapabilityExecute, + CapabilityLifeSpan, + CapabilitySetTypes, + CapabilitySettings, + CapabilityStats, + DeviceType, +) +from deebot_client.const import DataType +from deebot_client.events import ( + AvailabilityEvent, + BatteryEvent, + CustomCommandEvent, + ErrorEvent, + FanSpeedEvent, + FanSpeedLevel, + LifeSpan, + LifeSpanEvent, + NetworkInfoEvent, + ReportStatsEvent, + StateEvent, + StatsEvent, + TotalStatsEvent, +) +from deebot_client.models import StaticDeviceInfo + +from deebot_client.commands.ngiot.battery import GetBattery +from deebot_client.commands.ngiot.charge import Charge +from deebot_client.commands.ngiot.clean import Clean, CleanArea, GetCleanInfo +from deebot_client.commands.ngiot.custom import CustomCommand +from deebot_client.commands.ngiot.error import GetError +from deebot_client.commands.ngiot.fan_speed import GetFanSpeed, SetFanSpeed +from deebot_client.commands.ngiot.life_span import GetLifeSpan, ResetLifeSpan +from deebot_client.commands.ngiot.network import GetNetInfo +from deebot_client.commands.ngiot.play_sound import PlaySound +from deebot_client.commands.ngiot.stats import GetReportStats, GetStats, GetTotalStats + + +def get_device_info() -> StaticDeviceInfo: + """Get device info for this model.""" + + return StaticDeviceInfo( + DataType.JSON, + Capabilities( + device_type=DeviceType.VACUUM, + availability=CapabilityEvent( + AvailabilityEvent, + [GetBattery(is_available_check=True)], + ), + battery=CapabilityEvent( + BatteryEvent, + [GetBattery()], + ), + charge=CapabilityExecute(Charge), + clean=CapabilityClean( + action=CapabilityCleanAction( + command=Clean, + area=CleanArea, + ), + ), + custom=CapabilityCustomCommand( + event=CustomCommandEvent, + get=[], + set=CustomCommand, + ), + error=CapabilityEvent( + ErrorEvent, + [GetError()], + ), + fan_speed=CapabilitySetTypes( + event=FanSpeedEvent, + get=[GetFanSpeed()], + set=SetFanSpeed, + types=( + FanSpeedLevel.QUIET, + FanSpeedLevel.NORMAL, + FanSpeedLevel.MAX, + FanSpeedLevel.MAX_PLUS, + ), + ), + life_span=CapabilityLifeSpan( + types=( + LifeSpan.BRUSH, + LifeSpan.FILTER, + LifeSpan.SIDE_BRUSH, + LifeSpan.UNIT_CARE, + ), + event=LifeSpanEvent, + get=[GetLifeSpan()], + reset=ResetLifeSpan, + ), + map=None, + network=CapabilityEvent( + NetworkInfoEvent, + [GetNetInfo()], + ), + play_sound=CapabilityExecute(PlaySound), + settings=CapabilitySettings(), + state=CapabilityEvent( + StateEvent, + [GetCleanInfo()], + ), + stats=CapabilityStats( + clean=CapabilityEvent(StatsEvent, [GetStats()]), + report=CapabilityEvent(ReportStatsEvent, [GetReportStats()]), + total=CapabilityEvent(TotalStatsEvent, [GetTotalStats()]), + ), + water=None, + ), + ) \ No newline at end of file diff --git a/deebot_client/ngiot_client.py b/deebot_client/ngiot_client.py new file mode 100644 index 000000000..1a61da099 --- /dev/null +++ b/deebot_client/ngiot_client.py @@ -0,0 +1,403 @@ +"""NGIOT endpoint-control client for eco-ng devices such as eyfj07.""" + +from __future__ import annotations + +import json +import secrets +import string +import time +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from http import HTTPStatus +from typing import TYPE_CHECKING, Any +from urllib.parse import urljoin + +from aiohttp import ClientResponseError, ClientSession, ClientTimeout, hdrs + +from .exceptions import ApiError, ApiTimeoutError, AuthenticationError +from .logging_filter import get_logger +from .sst_authentication import SstAuthenticator + +if TYPE_CHECKING: + from .models import ApiDeviceInfo, DeviceInfo + +_LOGGER = get_logger(__name__) + +_TIMEOUT = ClientTimeout(60) + +_PATH_ENDPOINT_CONTROL = "/api/iot/endpoint/control" +_DEFAULT_FMT = "j" +_DEFAULT_CT = "q" + +APN_ROBOT_DETAIL = "10001" +APN_MAP_DETAILS = "30001" +APN_CLEAN_START = "40001" +APN_AREA_CLEAN = "40007" +APN_PAUSE = "40009" +APN_RESUME = "40011" +APN_RETURN_TO_DOCK = "40013" +APN_CANCEL_RETURN = "40015" +APN_DEVICE_LOCATE = "40019" + + +@dataclass(frozen=True) +class NgiotDeviceIdentity: + """Normalized NGIOT device identity.""" + + did: str + class_id: str + resource: str + control_host: str + + @property + def key(self) -> str: + """Stable cache/logging key.""" + return f"{self.class_id}:{self.did}:{self.resource}" + + @property + def base_url(self) -> str: + """Normalized HTTPS base URL for NGIOT control.""" + if self.control_host.startswith(("http://", "https://")): + return self.control_host.rstrip("/") + return f"https://{self.control_host}".rstrip("/") + + +class NgiotClient: + """Thin client for NGIOT endpoint-control reads and writes.""" + + def __init__( + self, + session: ClientSession, + sst_authenticator: SstAuthenticator, + *, + user_agent: str = "okhttp/4.9.1", + channel: str = "Android", + protocol_version: str = "0.0.22", + timezone_name: str = "UTC", + timezone_offset_minutes: int = 0, + override_control_host: str | None = None, + ) -> None: + self._session = session + self._sst_authenticator = sst_authenticator + self._user_agent = user_agent + self._channel = channel + self._protocol_version = protocol_version + self._timezone_name = timezone_name + self._timezone_offset_minutes = timezone_offset_minutes + self._override_control_host = override_control_host + + async def request( + self, + device: ApiDeviceInfo | DeviceInfo | Mapping[str, Any], + *, + apn: str | int, + body_data: Mapping[str, Any] | Sequence[Any], + fmt: str = _DEFAULT_FMT, + ct: str = _DEFAULT_CT, + force_sst_refresh: bool = False, + ) -> dict[str, Any]: + """Execute a single NGIOT endpoint-control request. + + Returns the full decoded NGIOT JSON envelope: + { + "body": {...}, + "header": {...} + } + """ + identity = self._normalize_device(device) + url = urljoin(identity.base_url + "/", _PATH_ENDPOINT_CONTROL.lstrip("/")) + + request_id = self._new_request_id() + body_reqid = self._new_body_reqid() + + query_params = { + "si": request_id, + "ct": ct, + "eid": identity.did, + "et": identity.class_id, + "er": identity.resource, + "apn": str(apn), + "fmt": fmt, + } + + payload = { + "body": {"data": body_data}, + "header": self._create_body_header(body_reqid), + } + + token = await self._sst_authenticator.get_token( + self._device_mapping(identity), + force=force_sst_refresh, + ) + + headers = { + hdrs.AUTHORIZATION: f"Bearer {token}", + "x-eco-request-id": request_id, + hdrs.CONTENT_TYPE: "application/octet-stream", + hdrs.USER_AGENT: self._user_agent, + } + + logger_request_params = { + "url": url, + "query_params": query_params, + "payload": payload, + "device_key": identity.key, + } + + try: + _LOGGER.debug("Calling NGIOT api: %s", logger_request_params) + + async with self._session.post( + url, + params=query_params, + data=json.dumps(payload, separators=(",", ":")).encode("utf-8"), + headers=headers, + timeout=_TIMEOUT, + ) as res: + res.raise_for_status() + content_type = res.headers.get(hdrs.CONTENT_TYPE, "").lower() + response_data: dict[str, Any] = await res.json( + content_type=content_type or None + ) + + _LOGGER.debug( + "Success calling NGIOT api %s, response=%s", + logger_request_params, + response_data, + ) + + self._validate_response(response_data) + return response_data + + except TimeoutError as ex: + raise ApiTimeoutError(path=_PATH_ENDPOINT_CONTROL, timeout=_TIMEOUT) from ex + except ClientResponseError as ex: + if ( + ex.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN) + and not force_sst_refresh + ): + _LOGGER.info( + "NGIOT request unauthorized for %s. Invalidating SST and retrying once.", + identity.key, + ) + await self._sst_authenticator.invalidate(self._device_mapping(identity)) + return await self.request( + device, + apn=apn, + body_data=body_data, + fmt=fmt, + ct=ct, + force_sst_refresh=True, + ) + + if ex.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): + raise AuthenticationError( + "NGIOT endpoint-control request was not authorized" + ) from ex + + _LOGGER.debug("NGIOT request failed: %s", logger_request_params, exc_info=True) + raise ApiError from ex + + async def query_fields( + self, + device: ApiDeviceInfo | DeviceInfo | Mapping[str, Any], + *, + apn: str | int, + fields: Sequence[str], + map_id: str | int | None = None, + ) -> Any: + """Query a field-based NGIOT surface and return body.data.""" + body_data: dict[str, Any] = {"fields": list(fields)} + if map_id is not None: + body_data["mapId"] = str(map_id) + + response = await self.request(device, apn=apn, body_data=body_data) + return self._extract_body_data(response) + + async def write_data( + self, + device: ApiDeviceInfo | DeviceInfo | Mapping[str, Any], + *, + apn: str | int, + data: Mapping[str, Any], + ) -> Any: + """Send a direct key/value NGIOT control payload and return body.data.""" + response = await self.request(device, apn=apn, body_data=dict(data)) + return self._extract_body_data(response) + + async def get_robot_detail( + self, + device: ApiDeviceInfo | DeviceInfo | Mapping[str, Any], + fields: Sequence[str], + ) -> Any: + """Read robot detail fields from the status surface.""" + return await self.query_fields( + device, + apn=APN_ROBOT_DETAIL, + fields=fields, + ) + + async def get_map_details( + self, + device: ApiDeviceInfo | DeviceInfo | Mapping[str, Any], + fields: Sequence[str], + *, + map_id: str | int | None = None, + ) -> Any: + """Read map detail fields from the map surface.""" + return await self.query_fields( + device, + apn=APN_MAP_DETAILS, + fields=fields, + map_id=map_id, + ) + + async def set_pause( + self, + device: ApiDeviceInfo | DeviceInfo | Mapping[str, Any], + *, + pause: bool, + ) -> Any: + """Pause the current job. + + Resume is action-specific on NGIOT: cleaning resumes via clean start, and + returning resumes via charge start. There is no captured generic + ``pauseSwitch: false`` control for this ruleset. + """ + if not pause: + raise ApiError( + "NGIOT resume is action-specific and is not exposed as pauseSwitch=false" + ) + + return await self.write_data( + device, + apn=APN_PAUSE, + data={"pauseSwitch": True}, + ) + + async def set_charge( + self, + device: ApiDeviceInfo | DeviceInfo | Mapping[str, Any], + *, + enabled: bool, + ) -> Any: + """Start or cancel dock/charge behavior.""" + return await self.write_data( + device, + apn=APN_RETURN_TO_DOCK if enabled else APN_CANCEL_RETURN, + data={"chargeSwitch": enabled}, + ) + + async def start_smart_clean( + self, + device: ApiDeviceInfo | DeviceInfo | Mapping[str, Any], + ) -> Any: + """Start default smart cleaning.""" + return await self.write_data( + device, + apn=APN_CLEAN_START, + data={"cleanSwitch": True, "cleanMode": "smart"}, + ) + + async def start_area_clean( + self, + device: ApiDeviceInfo | DeviceInfo | Mapping[str, Any], + room_ids: Sequence[int], + ) -> Any: + """Start area cleaning for one or more room IDs.""" + return await self.write_data( + device, + apn=APN_AREA_CLEAN, + data={ + "cleanSwitch": True, + "cleanMode": "area", + "cleanValues": list(room_ids), + }, + ) + + def _normalize_device( + self, + device: ApiDeviceInfo | DeviceInfo | Mapping[str, Any], + ) -> NgiotDeviceIdentity: + """Normalize raw API device payload into NGIOT routing fields.""" + raw_device = device.api if hasattr(device, "api") else device + + if not isinstance(raw_device, Mapping): + msg = f"Unsupported device type for NGIOT client: {type(device)!r}" + raise TypeError(msg) + + service = raw_device.get("service", {}) + host = self._override_control_host + if host is None and isinstance(service, Mapping): + host = service.get("mqs") + + if not host: + msg = f"Missing NGIOT control host in device service binding: {raw_device}" + raise ApiError(msg) + + try: + return NgiotDeviceIdentity( + did=str(raw_device["did"]), + class_id=str(raw_device["class"]), + resource=str(raw_device["resource"]), + control_host=str(host), + ) + except KeyError as ex: + msg = f"Missing required NGIOT device field: {ex.args[0]}" + raise ApiError(msg) from ex + + def _create_body_header(self, reqid: str) -> dict[str, Any]: + """Create request body header matching the observed mobile shape.""" + return { + "channel": self._channel, + "m": "request", + "pri": 2, + "reqid": reqid, + "ts": str(int(time.time() * 1000)), + "tzc": self._timezone_name, + "tzm": self._timezone_offset_minutes, + "ver": self._protocol_version, + } + + @staticmethod + def _extract_body_data(response: Mapping[str, Any]) -> Any: + """Return body.data, defaulting ACK-only responses to an empty dict.""" + body = response.get("body") + if not isinstance(body, Mapping): + return {} + return body.get("data", {}) + + @staticmethod + def _validate_response(response: Mapping[str, Any]) -> None: + """Validate NGIOT envelope and raise ApiError on device-side failures.""" + body = response.get("body") + if not isinstance(body, Mapping): + raise ApiError("Invalid NGIOT response: missing body") + + code = body.get("code", 0) + if code not in (0, "0000", None): + msg = body.get("msg", "unknown error") + raise ApiError( + f"NGIOT request failed with code {code} ({msg}) for {_PATH_ENDPOINT_CONTROL}" + ) + + @staticmethod + def _new_request_id() -> str: + """Generate request ID for query/header transport fields.""" + return secrets.token_hex(16) + + @staticmethod + def _new_body_reqid(length: int = 6) -> str: + """Generate short request ID for the NGIOT body header.""" + alphabet = string.ascii_letters + string.digits + return "".join(secrets.choice(alphabet) for _ in range(length)) + + @staticmethod + def _device_mapping(identity: NgiotDeviceIdentity) -> dict[str, str]: + """Convert identity into a mapping accepted by SstAuthenticator.""" + return { + "did": identity.did, + "class": identity.class_id, + "resource": identity.resource, + "service": {"mqs": identity.control_host}, + } \ No newline at end of file diff --git a/deebot_client/ngiot_probe.py b/deebot_client/ngiot_probe.py new file mode 100644 index 000000000..8dcff2c77 --- /dev/null +++ b/deebot_client/ngiot_probe.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +import time +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import aiohttp + + +def derive_ngiot_base_url(mqs_host: str) -> str: + parsed = urlparse(mqs_host) + host = parsed.netloc or parsed.path + host = host.strip().rstrip("/") + if not host: + raise ValueError(f'Could not derive NGIOT base URL from mqs host "{mqs_host}"') + if host.startswith("api-base."): + return f"https://{host}" + if host.startswith("api-ngiot."): + return f"https://api-base.{host.split('.', 1)[1]}" + if "." in host: + return f"https://api-base.{host.split('.', 1)[1]}" + raise ValueError(f'Could not derive NGIOT base URL from mqs host "{mqs_host}"') + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Quick NGIOT probe for Ecovacs eco-ng devices") + parser.add_argument("--repo", required=True, help="Repo root containing deebot_client/") + parser.add_argument("--country", required=True, help="2-letter country code, e.g. AU") + parser.add_argument("--account", required=True, help="Ecovacs account email") + parser.add_argument("--password", required=True, help="Ecovacs account password") + parser.add_argument("--class", dest="class_id", default="eyfj07", help="Device class id") + parser.add_argument("--did", help="Specific device id if multiple devices share the same class") + parser.add_argument("--list-devices", action="store_true", help="List eco-ng devices and exit") + parser.add_argument("--get-info", action="store_true", help="Read a broad 10001 status payload") + parser.add_argument("--status", action="store_true", help="Read a smaller status payload") + parser.add_argument("--totals", action="store_true", help="Read total stats payload") + parser.add_argument( + "--action", + choices=["start", "pause", "resume", "return", "cancel-return", "locate"], + help="Run a captured control action", + ) + parser.add_argument("--area", help="Comma-separated room ids for area clean, e.g. 1 or 1,2") + parser.add_argument("--fan-mode", choices=["quiet", "auto", "strong", "max"], help="Set fan mode") + parser.add_argument("--volume", type=int, help="Set volume 0..10") + parser.add_argument("--raw-apn", help="Send a raw APN") + parser.add_argument("--raw-json", help='Send raw body data JSON, e.g. \'{"seek":true}\'') + return parser.parse_args() + + +async def main() -> int: + args = parse_args() + + repo = Path(args.repo).expanduser().resolve() + if not (repo / "deebot_client").is_dir(): + print(f"Invalid --repo: {repo} does not contain deebot_client/", file=sys.stderr) + return 2 + + sys.path.insert(0, str(repo)) + + from deebot_client.api_client import ApiClient + from deebot_client.authentication import Authenticator, create_rest_config + from deebot_client.util import md5 + + device_id = md5(str(time.time())) + password_hash = md5(args.password) + + async with aiohttp.ClientSession() as session: + rest_config = create_rest_config( + session, + device_id=device_id, + alpha_2_country=args.country, + ) + authenticator = Authenticator(rest_config, args.account, password_hash) + api_client = ApiClient(authenticator) + + devices = await api_client.get_devices() + + eco_ng_devices: list[dict[str, Any]] = [] + for dev in devices.mqtt: + eco_ng_devices.append(dev.api) + for dev in devices.not_supported: + if dev.get("company") == "eco-ng": + eco_ng_devices.append(dev) + + if args.list_devices: + print( + json.dumps( + [ + { + "did": d.get("did"), + "class": d.get("class"), + "nick": d.get("nick"), + "resource": d.get("resource"), + "company": d.get("company"), + "mqs": (d.get("service") or {}).get("mqs"), + } + for d in eco_ng_devices + ], + indent=2, + sort_keys=True, + ) + ) + return 0 + + target: dict[str, Any] | None = None + for dev in eco_ng_devices: + if args.did and dev.get("did") == args.did: + target = dev + break + if not args.did and dev.get("class") == args.class_id: + target = dev + break + + if target is None: + print( + f'No eco-ng device found for class "{args.class_id}"' + + (f' and did "{args.did}"' if args.did else ""), + file=sys.stderr, + ) + return 3 + + service = target.get("service") or {} + mqs_host = service.get("mqs") + if not isinstance(mqs_host, str) or not mqs_host: + print(f'Device is missing service.mqs: {json.dumps(target, indent=2)}', file=sys.stderr) + return 4 + + ngiot_base_url = derive_ngiot_base_url(mqs_host) + authenticator.attach_ngiot( + base_url=ngiot_base_url, + timezone_name="Australia/Brisbane", + timezone_offset_minutes=600, + ) + + ngiot = authenticator.ngiot_client + if ngiot is None: + print("Failed to attach NGIOT client", file=sys.stderr) + return 5 + + async def run_request(apn: str, body_data: dict[str, Any]) -> None: + response = await ngiot.request(target, apn=apn, body_data=body_data) + print(json.dumps(response, indent=2, sort_keys=True)) + + did = target.get("did") + class_id = target.get("class") + nick = target.get("nick") + print(f"Using device did={did} class={class_id} nick={nick}", file=sys.stderr) + + if args.get_info: + await run_request( + "10001", + { + "fields": [ + "stationType", + "stationStatus", + "cleanValues", + "chargeStatus", + "pauseSwitch", + "battery", + "disturbSwitch", + "disturbTimeSet", + "mopState", + "workMode", + "breakCleanStatus", + "fanMode", + "waterMode", + "cleanCount", + "error", + "consumables", + "newMapReport", + "expandedMapReport", + "cleanLogReport", + "deviceInfo", + "childLock", + "isEurope", + "cleanTime", + "cleanArea", + "silentOtaSwitch", + "nextSchedule", + "dormant", + "relocateSwitch", + "unitSet", + "otaData", + "voiceData", + "timeZone", + ] + }, + ) + + if args.status: + await run_request( + "10001", + { + "fields": [ + "chargeStatus", + "pauseSwitch", + "workMode", + "error", + "battery", + "fanMode", + "waterMode", + "cleanArea", + "cleanTime", + "cleanCount", + ] + }, + ) + + if args.totals: + await run_request( + "10001", + { + "fields": [ + "cleanAreaTotal", + "cleanCountTotal", + "cleanTimeTotal", + ] + }, + ) + + if args.action == "start": + await run_request("40001", {"cleanSwitch": True, "cleanMode": "smart"}) + elif args.action == "pause": + await run_request("40009", {"pauseSwitch": True}) + elif args.action == "resume": + await run_request("40011", {"pauseSwitch": False}) + elif args.action == "return": + await run_request("40013", {"chargeSwitch": True}) + elif args.action == "cancel-return": + await run_request("40015", {"chargeSwitch": False}) + elif args.action == "locate": + await run_request("40019", {"seek": True}) + + if args.area: + room_ids = [int(x.strip()) for x in args.area.split(",") if x.strip()] + await run_request( + "40007", + { + "cleanSwitch": True, + "cleanMode": "area", + "cleanValues": room_ids, + }, + ) + + if args.fan_mode: + await run_request("50011", {"fanMode": args.fan_mode}) + + if args.volume is not None: + if not 0 <= args.volume <= 10: + print("--volume must be between 0 and 10", file=sys.stderr) + return 6 + await run_request("50023", {"volume": args.volume}) + + if args.raw_apn: + body_data = json.loads(args.raw_json or "{}") + if not isinstance(body_data, dict): + print("--raw-json must decode to a JSON object", file=sys.stderr) + return 7 + await run_request(args.raw_apn, body_data) + + await authenticator.teardown() + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/deebot_client/sst_authentication.py b/deebot_client/sst_authentication.py new file mode 100644 index 000000000..1c3a8083c --- /dev/null +++ b/deebot_client/sst_authentication.py @@ -0,0 +1,309 @@ +"""SST authentication module for NGIOT endpoint-control devices.""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import time +from collections.abc import Mapping +from dataclasses import dataclass +from http import HTTPStatus +from typing import TYPE_CHECKING, Any +from urllib.parse import urljoin + +from aiohttp import ClientResponseError, ClientSession, ClientTimeout, hdrs + +from .exceptions import ApiError, ApiTimeoutError, AuthenticationError +from .logging_filter import get_logger +from .util import cancel, create_task + +if TYPE_CHECKING: + from .authentication import Authenticator + from .models import ApiDeviceInfo, DeviceInfo + +_LOGGER = get_logger(__name__) + +_TIMEOUT = ClientTimeout(60) +_SST_ISSUE_PATH = "/api/new-perm/token/sst/issue" +_SST_SERVICE = "dim" +_SST_PERMISSION = "Control" + + +@dataclass(frozen=True) +class SstCredentials: + """Short-lived NGIOT SST credentials.""" + + token: str + expires_at: int + device_key: str + + +@dataclass(frozen=True) +class SstDeviceIdentity: + """Normalized device identity needed for SST minting.""" + + did: str + class_id: str + resource: str + control_host: str | None = None + + @property + def endpoint(self) -> str: + """Return DIM ACL endpoint identifier.""" + return f"Endpoint:{self.class_id}:{self.did}" + + @property + def key(self) -> str: + """Return stable cache key.""" + return f"{self.class_id}:{self.did}:{self.resource}" + + +class SstAuthenticator: + """Mint, cache, and refresh short-lived SST credentials per device.""" + + def __init__( + self, + session: ClientSession, + authenticator: Authenticator, + *, + base_url: str, + requested_ttl: int = 600, + refresh_skew: int = 60, + ) -> None: + self._session = session + self._authenticator = authenticator + self._base_url = base_url.rstrip("/") + self._requested_ttl = requested_ttl + self._refresh_skew = refresh_skew + + self._lock = asyncio.Lock() + self._credentials: dict[str, SstCredentials] = {} + self._devices: dict[str, SstDeviceIdentity] = {} + self._refresh_handles: dict[str, asyncio.TimerHandle] = {} + self._tasks: set[asyncio.Future[Any]] = set() + + async def get_credentials( + self, + device: ApiDeviceInfo | DeviceInfo | Mapping[str, Any], + *, + force: bool = False, + ) -> SstCredentials: + """Return cached SST credentials for a device, refreshing if needed.""" + identity = self._normalize_device(device) + + async with self._lock: + cached = self._credentials.get(identity.key) + now = int(time.time()) + + if ( + not force + and cached is not None + and cached.expires_at > now + self._refresh_skew + ): + return cached + + credentials = await self._issue_sst(identity) + self._devices[identity.key] = identity + self._credentials[identity.key] = credentials + + self._cancel_refresh_task(identity.key) + self._create_refresh_task(identity, credentials) + + return credentials + + async def get_token( + self, + device: ApiDeviceInfo | DeviceInfo | Mapping[str, Any], + *, + force: bool = False, + ) -> str: + """Return SST bearer token for a device.""" + return (await self.get_credentials(device, force=force)).token + + async def invalidate( + self, + device: ApiDeviceInfo | DeviceInfo | Mapping[str, Any] | str, + ) -> None: + """Invalidate cached SST for a device or cache key.""" + key = device if isinstance(device, str) else self._normalize_device(device).key + + async with self._lock: + self._credentials.pop(key, None) + self._devices.pop(key, None) + self._cancel_refresh_task(key) + + async def teardown(self) -> None: + """Teardown authenticator and cancel outstanding refresh tasks.""" + for key in list(self._refresh_handles): + self._cancel_refresh_task(key) + + self._credentials.clear() + self._devices.clear() + await cancel(self._tasks) + + async def _issue_sst(self, identity: SstDeviceIdentity) -> SstCredentials: + """Mint a fresh SST for the given device.""" + account_credentials = await self._authenticator.authenticate() + + headers = { + hdrs.AUTHORIZATION: f"Bearer {account_credentials.token}", + hdrs.CONTENT_TYPE: "application/json; charset=utf-8", + } + payload = { + "acl": [ + { + "policy": [ + { + "obj": [identity.endpoint], + "perms": [_SST_PERMISSION], + } + ], + "svc": _SST_SERVICE, + } + ], + "exp": self._requested_ttl, + "sub": account_credentials.user_id, + } + + url = urljoin(self._base_url, _SST_ISSUE_PATH) + logger_request_params = { + "url": url, + "device_key": identity.key, + "payload": payload, + } + + try: + _LOGGER.debug("Calling SST issue endpoint: %s", logger_request_params) + + async with self._session.post( + url, + json=payload, + headers=headers, + timeout=_TIMEOUT, + ) as res: + res.raise_for_status() + content_type = res.headers.get(hdrs.CONTENT_TYPE, "").lower() + response_data: dict[str, Any] = await res.json( + content_type=content_type or None + ) + + _LOGGER.debug( + "SST issue response for %s: %s", identity.key, response_data + ) + + if response_data.get("code") not in (0, "0000"): + msg = ( + f"failure code {response_data.get('code')} " + f"({response_data.get('msg')}) for call {_SST_ISSUE_PATH}" + ) + raise AuthenticationError(msg) + + token = str(response_data["data"]["data"]["token"]) + expires_at = self._decode_exp(token) + if expires_at is None: + # Fallback if the token format changes or exp is absent. + expires_at = int(time.time()) + max(60, int(self._requested_ttl * 0.9)) + + return SstCredentials( + token=token, + expires_at=expires_at, + device_key=identity.key, + ) + + except TimeoutError as ex: + raise ApiTimeoutError(path=_SST_ISSUE_PATH, timeout=_TIMEOUT) from ex + except ClientResponseError as ex: + if ex.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): + raise AuthenticationError("SST issue request was not authorized") from ex + raise ApiError from ex + + def _create_refresh_task( + self, + identity: SstDeviceIdentity, + credentials: SstCredentials, + ) -> None: + """Create refresh task for a given SST credential.""" + + def refresh() -> None: + _LOGGER.debug("Refreshing SST for %s", identity.key) + + async def async_refresh() -> None: + try: + await self.get_credentials(identity_as_mapping(identity), force=True) + except Exception: + _LOGGER.exception( + "An exception occurred during SST refresh for %s", + identity.key, + ) + + create_task(self._tasks, async_refresh()) + self._refresh_handles.pop(identity.key, None) + + seconds_until_refresh = max( + 5, + credentials.expires_at - int(time.time()) - self._refresh_skew, + ) + self._refresh_handles[identity.key] = asyncio.get_event_loop().call_later( + seconds_until_refresh, + refresh, + ) + + def _cancel_refresh_task(self, key: str) -> None: + """Cancel refresh timer for a cache key.""" + handle = self._refresh_handles.pop(key, None) + if handle and not handle.cancelled(): + handle.cancel() + + @staticmethod + def _decode_exp(token: str) -> int | None: + """Decode exp claim from SST token without signature validation.""" + try: + parts = token.split(".") + if len(parts) == 4 and parts[0] == "SST": + payload_segment = parts[2] + elif len(parts) == 3: + payload_segment = parts[1] + else: + return None + + padded = payload_segment + "=" * (-len(payload_segment) % 4) + payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8")) + exp = payload.get("exp") + + return int(exp) if exp is not None else None + except Exception: + _LOGGER.debug("Failed to decode SST token expiry", exc_info=True) + return None + + @staticmethod + def _normalize_device( + device: ApiDeviceInfo | DeviceInfo | Mapping[str, Any], + ) -> SstDeviceIdentity: + """Normalize a device object into the fields required for SST issuance.""" + raw_device = device.api if hasattr(device, "api") else device + + if not isinstance(raw_device, Mapping): + msg = f"Unsupported device type for SST authentication: {type(device)!r}" + raise TypeError(msg) + + service = raw_device.get("service", {}) + control_host = ( + service.get("mqs") if isinstance(service, Mapping) else None + ) + + return SstDeviceIdentity( + did=str(raw_device["did"]), + class_id=str(raw_device["class"]), + resource=str(raw_device["resource"]), + control_host=str(control_host) if control_host else None, + ) + + +def identity_as_mapping(identity: SstDeviceIdentity) -> dict[str, str]: + """Convert normalized identity back to a mapping accepted by get_credentials.""" + return { + "did": identity.did, + "class": identity.class_id, + "resource": identity.resource, + } \ No newline at end of file From 9d7e10859344f96f32b749f334d2423389bec55f Mon Sep 17 00:00:00 2001 From: Astute4185 Date: Fri, 27 Mar 2026 07:56:32 +1000 Subject: [PATCH 02/43] WIP: save current progress --- deebot_client/commands/ngiot/__init__.py | 14 ++++++++ deebot_client/commands/ngiot/clean.py | 36 +++++++++++--------- deebot_client/commands/ngiot/map.py | 23 +++++++++++++ deebot_client/commands/ngiot/pos.py | 7 ++++ deebot_client/hardware/eyfj07.py | 30 ++++++++++++++++- deebot_client/ngiot_client.py | 16 ++------- tests/hardware/test_init.py | 42 ++++++++++++++++++++++-- 7 files changed, 136 insertions(+), 32 deletions(-) create mode 100644 deebot_client/commands/ngiot/map.py create mode 100644 deebot_client/commands/ngiot/pos.py diff --git a/deebot_client/commands/ngiot/__init__.py b/deebot_client/commands/ngiot/__init__.py index fc88095e0..4c21df9b3 100644 --- a/deebot_client/commands/ngiot/__init__.py +++ b/deebot_client/commands/ngiot/__init__.py @@ -14,6 +14,8 @@ from .network import GetNetInfo from .play_sound import PlaySound from .stats import GetReportStats, GetStats, GetTotalStats +from .map import GetCachedMapInfo, GetMajorMap, GetMapSet, GetMapTrace, GetMinorMap +from .pos import GetPos if TYPE_CHECKING: from deebot_client.command import Command @@ -35,6 +37,12 @@ "PlaySound", "ResetLifeSpan", "SetFanSpeed", + "GetCachedMapInfo", + "GetMajorMap", + "GetMapSet", + "GetMapTrace", + "GetMinorMap", + "GetPos", ] _COMMANDS: list[type[Command]] = [ @@ -54,6 +62,12 @@ GetReportStats, GetStats, GetTotalStats, + GetCachedMGetCachedMapInfo, + GetMajorMap, + GetMapSet, + GetMapTrace, + GetMinorMap, + GetPos,apInfo, ] COMMANDS: dict[str, type[Command]] = {cmd.NAME: cmd for cmd in _COMMANDS} \ No newline at end of file diff --git a/deebot_client/commands/ngiot/clean.py b/deebot_client/commands/ngiot/clean.py index f74404518..d50ca171f 100644 --- a/deebot_client/commands/ngiot/clean.py +++ b/deebot_client/commands/ngiot/clean.py @@ -9,15 +9,21 @@ from deebot_client.exceptions import ApiError from deebot_client.message import HandlingResult from deebot_client.models import CleanAction, CleanMode, State -from deebot_client.ngiot_client import APN_AREA_CLEAN, APN_CLEAN_START, APN_PAUSE +from deebot_client.ngiot_client import ( + APN_AREA_CLEAN, + APN_CLEAN_START, + APN_PAUSE, + APN_RESUME, +) from .common import NgiotExecuteCommand, RobotDetailGetCommand if TYPE_CHECKING: + from deebot_client.authentication import Authenticator + from deebot_client.event_bus import EventBus from deebot_client.models import ApiDeviceInfo from deebot_client.ngiot_client import NgiotClient - class Clean(NgiotExecuteCommand): """Translate generic clean actions into captured NGIOT control payloads.""" @@ -27,17 +33,20 @@ def __init__(self, action: CleanAction) -> None: super().__init__({}) self._action = action - async def _request_ngiot( + async def _execute( self, - client: NgiotClient, + authenticator: Authenticator, device_info: ApiDeviceInfo, - ) -> dict[str, Any]: - apn, body_data = self._get_request() - return await client.request( - device_info, - apn=apn, - body_data=body_data, - ) + event_bus: EventBus, + ) -> tuple[HandlingResult, dict[str, Any]]: + state = event_bus.get_last_event(StateEvent) + if state is not None: + if self._action is CleanAction.RESUME and state.state != State.PAUSED: + self._action = CleanAction.START + elif self._action is CleanAction.START and state.state == State.PAUSED: + self._action = CleanAction.RESUME + + return await super()._execute(authenticator, device_info, event_bus) def _get_request(self) -> tuple[str, dict[str, Any]]: if self._action is CleanAction.START: @@ -71,11 +80,6 @@ async def _request_ngiot( client: NgiotClient, device_info: ApiDeviceInfo, ) -> dict[str, Any]: - if self._mode not in (CleanMode.CUSTOM_AREA, CleanMode.SPOT_AREA): - raise ApiError( - f"Clean mode {self._mode!s} is not mapped for NGIOT room cleaning" - ) - if self._cleanings != 1: raise ApiError( "NGIOT room cleaning repeat count has not been captured yet" diff --git a/deebot_client/commands/ngiot/map.py b/deebot_client/commands/ngiot/map.py new file mode 100644 index 000000000..28043b25f --- /dev/null +++ b/deebot_client/commands/ngiot/map.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import binascii +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any + +from deebot_client.events import Position, PositionsEvent, RoomsEvent +from deebot_client.events.map import ( + CachedMapInfoEvent, + MajorMapEvent, + Map, + MapSetEvent, + MapSetType, + MapSubsetEvent, + MapTraceEvent, + MinorMapEvent, +) +from deebot_client.message import HandlingResult, HandlingState +from deebot_client.models import Room +from deebot_client.ngiot_client import APN_MAP_DETAILS +from deebot_client.rs.map import PositionType, RotationAngle + +from .common import NgiotJsonGetCommand \ No newline at end of file diff --git a/deebot_client/commands/ngiot/pos.py b/deebot_client/commands/ngiot/pos.py new file mode 100644 index 000000000..46a2a8e9d --- /dev/null +++ b/deebot_client/commands/ngiot/pos.py @@ -0,0 +1,7 @@ +"""NGIOT position commands.""" + +from __future__ import annotations + +from .map import GetPos + +__all__ = ["GetPos"] \ No newline at end of file diff --git a/deebot_client/hardware/eyfj07.py b/deebot_client/hardware/eyfj07.py index 1391951b1..5648ca418 100644 --- a/deebot_client/hardware/eyfj07.py +++ b/deebot_client/hardware/eyfj07.py @@ -19,6 +19,7 @@ CapabilitySettings, CapabilityStats, DeviceType, + CapabilityMap, ) from deebot_client.const import DataType from deebot_client.events import ( @@ -35,6 +36,8 @@ StateEvent, StatsEvent, TotalStatsEvent, + PositionsEvent, + RoomsEvent, ) from deebot_client.models import StaticDeviceInfo @@ -48,6 +51,20 @@ from deebot_client.commands.ngiot.network import GetNetInfo from deebot_client.commands.ngiot.play_sound import PlaySound from deebot_client.commands.ngiot.stats import GetReportStats, GetStats, GetTotalStats +from deebot_client.events.map import ( + CachedMapInfoEvent, + MajorMapEvent, + MapChangedEvent, + MapTraceEvent, +) +from deebot_client.commands.ngiot.map import ( + GetCachedMapInfo, + GetMajorMap, + GetMapSet, + GetMapTrace, + GetMinorMap, +) +from deebot_client.commands.ngiot.pos import GetPos def get_device_info() -> StaticDeviceInfo: @@ -103,7 +120,18 @@ def get_device_info() -> StaticDeviceInfo: get=[GetLifeSpan()], reset=ResetLifeSpan, ), - map=None, + map=CapabilityMap( + cached_info=CapabilityEvent(CachedMapInfoEvent, [GetCachedMapInfo()]), + changed=CapabilityEvent(MapChangedEvent, []), + info=None, + major=CapabilityEvent(MajorMapEvent, [GetMajorMap()]), + minor=CapabilityExecute(GetMinorMap), + multi_state=None, + position=CapabilityEvent(PositionsEvent, [GetPos()]), + rooms=CapabilityEvent(RoomsEvent, [GetCachedMapInfo()]), + set=CapabilityExecute(GetMapSet), + trace=CapabilityEvent(MapTraceEvent, [GetMapTrace()]), + ), network=CapabilityEvent( NetworkInfoEvent, [GetNetInfo()], diff --git a/deebot_client/ngiot_client.py b/deebot_client/ngiot_client.py index 1a61da099..8597784ed 100644 --- a/deebot_client/ngiot_client.py +++ b/deebot_client/ngiot_client.py @@ -258,21 +258,11 @@ async def set_pause( *, pause: bool, ) -> Any: - """Pause the current job. - - Resume is action-specific on NGIOT: cleaning resumes via clean start, and - returning resumes via charge start. There is no captured generic - ``pauseSwitch: false`` control for this ruleset. - """ - if not pause: - raise ApiError( - "NGIOT resume is action-specific and is not exposed as pauseSwitch=false" - ) - + """Pause or resume the current cleaning job.""" return await self.write_data( device, - apn=APN_PAUSE, - data={"pauseSwitch": True}, + apn=APN_PAUSE if pause else APN_RESUME, + data={"pauseSwitch": pause}, ) async def set_charge( diff --git a/tests/hardware/test_init.py b/tests/hardware/test_init.py index df3ea301c..6d73a0683 100644 --- a/tests/hardware/test_init.py +++ b/tests/hardware/test_init.py @@ -91,7 +91,22 @@ if TYPE_CHECKING: from deebot_client.command import Command from deebot_client.events.base import Event - +from deebot_client.commands.ngiot.battery import GetBattery as GetNgiotBattery +from deebot_client.commands.ngiot.clean import GetCleanInfo as GetNgiotCleanInfo +from deebot_client.commands.ngiot.error import GetError as GetNgiotError +from deebot_client.commands.ngiot.map import ( + GetCachedMapInfo as GetNgiotCachedMapInfo, + GetMajorMap as GetNgiotMajorMap, + GetMapTrace as GetNgiotMapTrace, +) +from deebot_client.commands.ngiot.network import GetNetInfo as GetNgiotNetInfo +from deebot_client.commands.ngiot.pos import GetPos as GetNgiotPos +from deebot_client.commands.ngiot.stats import ( + GetReportStats as GetNgiotReportStats, + GetStats as GetNgiotStats, + GetTotalStats as GetNgiotTotalStats, +) +from deebot_client.hardware.eyfj07 import get_device_info as get_eyfj07_info @pytest.mark.parametrize( ("class_", "expected"), @@ -243,9 +258,32 @@ async def test_get_static_device_info( VolumeEvent: [GetVolume()], WaterAmountEvent: [GetWaterInfo()], }, + ( + "eyfj07", + { + AvailabilityEvent: [GetNgiotBattery(is_available_check=True)], + BatteryEvent: [GetNgiotBattery()], + CachedMapInfoEvent: [GetNgiotCachedMapInfo()], + CustomCommandEvent: [], + ErrorEvent: [GetNgiotError()], + FanSpeedEvent: [GetFanSpeed()], + LifeSpanEvent: [GetLifeSpan([LifeSpan.BRUSH, LifeSpan.FILTER, LifeSpan.SIDE_BRUSH, LifeSpan.UNIT_CARE])], + MajorMapEvent: [GetNgiotMajorMap()], + MapChangedEvent: [], + MapTraceEvent: [GetNgiotMapTrace()], + NetworkInfoEvent: [GetNgiotNetInfo()], + PositionsEvent: [GetNgiotPos()], + ReportStatsEvent: [GetNgiotReportStats()], + RoomsEvent: [GetNgiotCachedMapInfo()], + StateEvent: [GetNgiotCleanInfo()], + StatsEvent: [GetNgiotStats()], + TotalStatsEvent: [GetNgiotTotalStats()], + } + +), ), ], - ids=["5xu9h3", "itk04l", "yna5xi", "p95mgv"], + ids=["5xu9h3", "itk04l", "yna5xi", "p95mgv", "eyfj07"], ) async def test_capabilities_event_extraction( class_: str, expected: dict[type[Event], list[Command]] From 4fae84ebd6644e573f16a194be892f0113ad8d3f Mon Sep 17 00:00:00 2001 From: Astute4185 Date: Fri, 27 Mar 2026 16:23:28 +1000 Subject: [PATCH 03/43] Checking in wip. Basic functionality is now working in HA - but still trying to get mapping, tracing and positioning of map working. --- deebot_client/commands/ngiot/__init__.py | 17 +- deebot_client/commands/ngiot/child_lock.py | 53 +++ deebot_client/commands/ngiot/clean.py | 41 ++- deebot_client/commands/ngiot/common.py | 59 +-- deebot_client/commands/ngiot/error.py | 14 +- deebot_client/commands/ngiot/map.py | 409 ++++++++++++++++++++- deebot_client/commands/ngiot/play_sound.py | 12 +- deebot_client/hardware/eyfj07.py | 20 +- deebot_client/message.py | 116 ++---- deebot_client/ngiot_client.py | 5 +- deebot_client/ngiot_probe.py | 271 -------------- tests/hardware/test_init.py | 1 + 12 files changed, 601 insertions(+), 417 deletions(-) create mode 100644 deebot_client/commands/ngiot/child_lock.py delete mode 100644 deebot_client/ngiot_probe.py diff --git a/deebot_client/commands/ngiot/__init__.py b/deebot_client/commands/ngiot/__init__.py index 4c21df9b3..68362e241 100644 --- a/deebot_client/commands/ngiot/__init__.py +++ b/deebot_client/commands/ngiot/__init__.py @@ -14,7 +14,14 @@ from .network import GetNetInfo from .play_sound import PlaySound from .stats import GetReportStats, GetStats, GetTotalStats -from .map import GetCachedMapInfo, GetMajorMap, GetMapSet, GetMapTrace, GetMinorMap +from .child_lock import GetChildLock, SetChildLock +from .map import ( + GetCachedMapInfo, + GetMajorMap, + GetMapSet, + GetMapTrace, + GetMinorMap, +) from .pos import GetPos if TYPE_CHECKING: @@ -37,6 +44,8 @@ "PlaySound", "ResetLifeSpan", "SetFanSpeed", + "GetChildLock", + "SetChildLock", "GetCachedMapInfo", "GetMajorMap", "GetMapSet", @@ -62,12 +71,14 @@ GetReportStats, GetStats, GetTotalStats, - GetCachedMGetCachedMapInfo, + GetChildLock, + SetChildLock, + GetCachedMapInfo, GetMajorMap, GetMapSet, GetMapTrace, GetMinorMap, - GetPos,apInfo, + GetPos, ] COMMANDS: dict[str, type[Command]] = {cmd.NAME: cmd for cmd in _COMMANDS} \ No newline at end of file diff --git a/deebot_client/commands/ngiot/child_lock.py b/deebot_client/commands/ngiot/child_lock.py new file mode 100644 index 000000000..73776015f --- /dev/null +++ b/deebot_client/commands/ngiot/child_lock.py @@ -0,0 +1,53 @@ +"""Child lock commands.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from deebot_client.events import ChildLockEvent +from deebot_client.message import HandlingResult, HandlingState + +from .common import RobotDetailGetCommand, RobotDetailSetCommand + +if TYPE_CHECKING: + from deebot_client.event_bus import EventBus + + +class GetChildLock(RobotDetailGetCommand): + """Get child-lock state from the robot-detail surface.""" + + NAME = "getChildLock" + FIELDS = ("childLock",) + + @classmethod + def _handle_body_data_dict( + cls, + event_bus: EventBus, + data: dict[str, Any], + ) -> HandlingResult: + event_bus.notify(ChildLockEvent(bool(data.get("childLock")))) + return HandlingResult.success() + + +class SetChildLock(RobotDetailSetCommand): + """Set child-lock state on the robot-detail surface.""" + + NAME = "setChildLock" + get_command = GetChildLock + + def __init__(self, enable: bool) -> None: + super().__init__({"childLock": bool(enable)}) + self._enable = bool(enable) + + def _get_body_data(self) -> dict[str, Any]: + return {"childLock": self._enable} + + def _handle_response( + self, + event_bus: EventBus, + response: dict[str, Any], + ) -> HandlingResult: + result = super()._handle_response(event_bus, response) + if result.state == HandlingState.SUCCESS: + event_bus.notify(ChildLockEvent(self._enable)) + return result \ No newline at end of file diff --git a/deebot_client/commands/ngiot/clean.py b/deebot_client/commands/ngiot/clean.py index d50ca171f..ef7f41fcf 100644 --- a/deebot_client/commands/ngiot/clean.py +++ b/deebot_client/commands/ngiot/clean.py @@ -24,6 +24,7 @@ from deebot_client.models import ApiDeviceInfo from deebot_client.ngiot_client import NgiotClient + class Clean(NgiotExecuteCommand): """Translate generic clean actions into captured NGIOT control payloads.""" @@ -48,6 +49,18 @@ async def _execute( return await super()._execute(authenticator, device_info, event_bus) + async def _request_ngiot( + self, + client: NgiotClient, + device_info: ApiDeviceInfo, + ) -> dict[str, Any]: + apn, body_data = self._get_request() + return await client.request( + device_info, + apn=apn, + body_data=body_data, + ) + def _get_request(self) -> tuple[str, dict[str, Any]]: if self._action is CleanAction.START: return APN_CLEAN_START, {"cleanSwitch": True, "cleanMode": "smart"} @@ -55,9 +68,10 @@ def _get_request(self) -> tuple[str, dict[str, Any]]: return APN_PAUSE, {"pauseSwitch": True} if self._action is CleanAction.RESUME: return APN_RESUME, {"pauseSwitch": False} - raise ApiError( - "CleanAction.STOP payload has not been captured for NGIOT yet" - ) + if self._action is CleanAction.STOP: + return APN_PAUSE, {"pauseSwitch": True} + raise ApiError(f"Unsupported clean action: {self._action}") + class CleanArea(NgiotExecuteCommand): """Start room/area cleaning using room IDs.""" @@ -80,6 +94,11 @@ async def _request_ngiot( client: NgiotClient, device_info: ApiDeviceInfo, ) -> dict[str, Any]: + if self._mode is not CleanMode.SPOT_AREA: + raise ApiError( + "NGIOT area cleaning currently supports room-id cleaning only" + ) + if self._cleanings != 1: raise ApiError( "NGIOT room cleaning repeat count has not been captured yet" @@ -100,7 +119,7 @@ class GetCleanInfo(RobotDetailGetCommand): """Get high-level robot state.""" NAME = "getCleanInfo" - FIELDS = ("chargeStatus", "pauseSwitch", "workMode", "error") + FIELDS = ("cleanValues", "workMode", "chargeStatus") @classmethod def _handle_body_data_dict( @@ -112,21 +131,7 @@ def _handle_body_data_dict( return HandlingResult.success() -def _extract_first_int(value: Any) -> int: - if isinstance(value, list) and value: - value = value[0] - try: - return int(value) - except (TypeError, ValueError): - return 0 - - def _map_state(data: Mapping[str, Any]) -> State: - if _extract_first_int(data.get("error")) != 0: - return State.ERROR - if bool(data.get("pauseSwitch")): - return State.PAUSED - work_mode = str(data.get("workMode", "")).lower() charge_status = bool(data.get("chargeStatus")) diff --git a/deebot_client/commands/ngiot/common.py b/deebot_client/commands/ngiot/common.py index ee2bc7fd9..2fde0c70d 100644 --- a/deebot_client/commands/ngiot/common.py +++ b/deebot_client/commands/ngiot/common.py @@ -2,48 +2,52 @@ from __future__ import annotations +import inspect from abc import ABC, abstractmethod from collections.abc import Mapping from typing import TYPE_CHECKING, Any -import inspect from deebot_client.commands.json.common import ExecuteCommand, JsonGetCommand from deebot_client.exceptions import ApiError +from deebot_client.hardware import get_static_device_info from deebot_client.ngiot_client import APN_ROBOT_DETAIL if TYPE_CHECKING: from deebot_client.authentication import Authenticator - from deebot_client.models import ApiDeviceInfo + from deebot_client.models import ApiDeviceInfo, DeviceInfo, StaticDeviceInfo from deebot_client.ngiot_client import NgiotClient class NgiotJsonCommandMixin(ABC): - """Mixin that routes command execution through ``Authenticator.ngiot_client``. - - The upstream command stack expects responses in the legacy devmanager shape, - i.e. ``{"ret": "ok", "resp": {"body": ...}}``. NGIOT returns a raw - ``{"header": ..., "body": ...}`` envelope, so this mixin adapts the raw - response into the shape already handled by ``CommandWithMessageHandling``. - """ + """Mixin that routes command execution through ``Authenticator.ngiot_client``.""" - async def _execute_api_request( # type: ignore[override] + async def _get_ngiot_client( self, authenticator: Authenticator, - device_info: ApiDeviceInfo, - ) -> dict[str, Any]: - client = await self._get_ngiot_client(authenticator, device_info) - response = await self._request_ngiot(client, device_info) - return self._wrap_response(response) - - async def _get_ngiot_client(self, authenticator, device_info): + device_info: ApiDeviceInfo | DeviceInfo, + ) -> NgiotClient: client = getattr(authenticator, "ngiot_client", None) if client is not None: return client ensure = getattr(authenticator, "ensure_ngiot_for_device", None) if ensure is not None: - raw_device = device_info.api if hasattr(device_info, "api") else device_info - result = ensure(raw_device) + raw_device: ApiDeviceInfo = ( + device_info.api if hasattr(device_info, "api") else device_info + ) + + static_device_info: StaticDeviceInfo | None = getattr( + device_info, "static", None + ) + if static_device_info is None: + static_device_info = await get_static_device_info(raw_device["class"]) + + if static_device_info is None: + raise ApiError( + f'No static device info found for NGIOT device class "{raw_device["class"]}"' + ) + + result = ensure(raw_device, static_device_info) if inspect.isawaitable(result): await result @@ -57,10 +61,19 @@ async def _get_ngiot_client(self, authenticator, device_info): @staticmethod def _wrap_response(response: Mapping[str, Any]) -> dict[str, Any]: - body = response.get('body', {}) + body = response.get("body", {}) if not isinstance(body, Mapping): body = {} - return {'ret': 'ok', 'resp': {'body': dict(body)}} + return {"ret": "ok", "resp": {"body": dict(body)}} + + async def _execute_api_request( + self, + authenticator: Authenticator, + device_info: ApiDeviceInfo, + ) -> dict[str, Any]: + client = await self._get_ngiot_client(authenticator, device_info) + response = await self._request_ngiot(client, device_info) + return self._wrap_response(response) @abstractmethod async def _request_ngiot( @@ -101,7 +114,7 @@ async def _request_ngiot( return await client.request( device_info, apn=APN_ROBOT_DETAIL, - body_data={'fields': list(self.FIELDS)}, + body_data={"fields": list(self.FIELDS)}, ) @@ -121,4 +134,4 @@ async def _request_ngiot( @abstractmethod def _get_body_data(self) -> dict[str, Any] | list[Any]: - """Return the NGIOT request body payload.""" + """Return the NGIOT request body payload.""" \ No newline at end of file diff --git a/deebot_client/commands/ngiot/error.py b/deebot_client/commands/ngiot/error.py index ef03dd178..35a1aef43 100644 --- a/deebot_client/commands/ngiot/error.py +++ b/deebot_client/commands/ngiot/error.py @@ -17,14 +17,14 @@ class GetError(RobotDetailGetCommand): FIELDS = ('error',) @classmethod - def _handle_body_data_dict( - cls, - event_bus, - data: dict[str, Any], - ) -> HandlingResult: - event_bus.notify(ErrorEvent(_extract_first_int(data.get('error')))) - return HandlingResult.success() + def _handle_body_data_dict(cls, event_bus: EventBus, data: dict[str, Any]) -> HandlingResult: + code = _extract_first_int(data.get("error")) + + if code == 0: + return HandlingResult.success() + event_bus.notify(ErrorEvent(code, f"NGIOT error {code}")) + return HandlingResult.success() def _extract_first_int(value: Any) -> int: if isinstance(value, list) and value: diff --git a/deebot_client/commands/ngiot/map.py b/deebot_client/commands/ngiot/map.py index 28043b25f..53f93a1f3 100644 --- a/deebot_client/commands/ngiot/map.py +++ b/deebot_client/commands/ngiot/map.py @@ -1,3 +1,5 @@ +"""NGIOT map commands.""" + from __future__ import annotations import binascii @@ -20,4 +22,409 @@ from deebot_client.ngiot_client import APN_MAP_DETAILS from deebot_client.rs.map import PositionType, RotationAngle -from .common import NgiotJsonGetCommand \ No newline at end of file +from .common import NgiotJsonGetCommand + +if TYPE_CHECKING: + from collections.abc import Sequence + + from deebot_client.event_bus import EventBus + from deebot_client.models import ApiDeviceInfo + from deebot_client.ngiot_client import NgiotClient + + +class NgiotMapGetCommand(NgiotJsonGetCommand, ABC): + """Base class for NGIOT APN 30001 field queries.""" + + def __init__(self, map_id: str = "") -> None: + super().__init__({}) + self._map_id = str(map_id) + + @property + @abstractmethod + def _fields(self) -> Sequence[str]: + """Return fields to request from APN 30001.""" + + async def _resolve_map_id( + self, + client: NgiotClient, + device_info: ApiDeviceInfo, + ) -> str: + if self._map_id: + return self._map_id + + response = await client.request( + device_info, + apn=APN_MAP_DETAILS, + body_data={"fields": ["mapInfos"]}, + ) + data = response.get("body", {}).get("data", {}) + map_infos = data.get("mapInfos", []) + if isinstance(map_infos, list): + active = next( + ( + entry + for entry in map_infos + if isinstance(entry, dict) and int(entry.get("status", 0)) == 1 + ), + None, + ) + if isinstance(active, dict): + return str(active.get("mapId", "")) + + fallback = next( + (entry for entry in map_infos if isinstance(entry, dict)), + None, + ) + if isinstance(fallback, dict): + return str(fallback.get("mapId", "")) + + return "" + + async def _request_ngiot( + self, + client: NgiotClient, + device_info: ApiDeviceInfo, + ) -> dict[str, Any]: + body_data: dict[str, Any] = {"fields": list(self._fields)} + map_id = await self._resolve_map_id(client, device_info) + if map_id: + body_data["mapId"] = map_id + + return await client.request( + device_info, + apn=APN_MAP_DETAILS, + body_data=body_data, + ) + + +class GetCachedMapInfo(NgiotMapGetCommand): + """Get cached map info for NGIOT devices.""" + + NAME = "getCachedMapInfo" + + @property + def _fields(self) -> Sequence[str]: + return ("mapInfos",) + + @classmethod + def _handle_body_data_dict(cls, event_bus: EventBus, data: dict[str, Any]) -> HandlingResult: + return HandlingResult.analyse() + + def _handle_response( + self, + event_bus: EventBus, + response: dict[str, Any], + ) -> HandlingResult: + result = super()._handle_response(event_bus, response) + if ( + result.state == HandlingState.SUCCESS + and result.args + and (map_obj := event_bus.capabilities.map) + ): + map_id = result.args["map_id"] + result.requested_commands.extend( + [map_obj.set.execute(map_id, entry) for entry in MapSetType] + ) + return result + + +class GetMajorMap(NgiotMapGetCommand): + """Get the current NGIOT raster map.""" + + NAME = "getMajorMap" + + @property + def _fields(self) -> Sequence[str]: + return ("mapData", "areas", "pos") + + @classmethod + def _handle_body_data_dict( + cls, + event_bus: EventBus, + data: dict[str, Any], + ) -> HandlingResult: + map_data = data.get("mapData") + if not isinstance(map_data, dict): + return HandlingResult.analyse() + + map_blob = map_data.get("map") + if not isinstance(map_blob, str) or not map_blob: + return HandlingResult.analyse() + + map_id = str(data.get("mapId") or map_data.get("mapId") or "") + crc = binascii.crc32(map_blob.encode("utf-8")) & 0xFFFFFFFF + + event_bus.notify(MajorMapEvent(map_id=map_id, values=[crc], requested=False)) + + positions: list[Position] = [] + + pos = data.get("pos") + if isinstance(pos, dict): + positions.append( + Position( + type=PositionType.from_str("deebotPos"), + x=int(pos["x"]), + y=int(pos["y"]), + a=int(pos.get("a", 0)), + ) + ) + + deebot_pos = data.get("deebotPos") + if isinstance(deebot_pos, dict): + positions.append( + Position( + type=PositionType.from_str("deebotPos"), + x=int(deebot_pos["x"]), + y=int(deebot_pos["y"]), + a=int(deebot_pos.get("a", 0)), + ) + ) + + charge_pos = map_data.get("chargePos") + if isinstance(charge_pos, dict): + positions.append( + Position( + type=PositionType.from_str("chargePos"), + x=int(charge_pos["x"]), + y=int(charge_pos["y"]), + a=int(charge_pos.get("a", 0)), + ) + ) + + legacy_charge_pos = data.get("chargePos") + if isinstance(legacy_charge_pos, list): + for entry in legacy_charge_pos: + if isinstance(entry, dict): + positions.append( + Position( + type=PositionType.from_str("chargePos"), + x=int(entry["x"]), + y=int(entry["y"]), + a=int(entry.get("a", 0)), + ) + ) + + if positions: + event_bus.notify(PositionsEvent(positions=positions)) + + return HandlingResult.success() + + +class GetMinorMap(NgiotMapGetCommand): + """Compatibility command for NGIOT map tile fetches.""" + + NAME = "getMinorMap" + + def __init__(self, piece_index: int, map_id: str) -> None: + super().__init__(map_id) + self._piece_index = piece_index + + @property + def _fields(self) -> Sequence[str]: + return ("mapData",) + + @classmethod + def _handle_body_data_dict( + cls, + event_bus: EventBus, + data: dict[str, Any], + ) -> HandlingResult: + # Instance-specific handling is done in _handle_response + return HandlingResult.analyse() + + def _handle_response( + self, + event_bus: EventBus, + response: dict[str, Any], + ) -> HandlingResult: + if response.get("ret") != "ok": + return HandlingResult.analyse() + + body = response.get("resp", {}).get("body", {}) + data = body.get("data", {}) + if not isinstance(data, dict): + return HandlingResult.analyse() + + map_data = data.get("mapData") + if not isinstance(map_data, dict): + return HandlingResult.analyse() + + map_blob = map_data.get("map") + if not isinstance(map_blob, str) or not map_blob: + return HandlingResult.analyse() + + event_bus.notify(MinorMapEvent(index=self._piece_index, value=map_blob)) + return HandlingResult.success() + + +class GetMapTrace(NgiotMapGetCommand): + """Get the current NGIOT map trace.""" + + NAME = "getMapTrace" + + @property + def _fields(self) -> Sequence[str]: + return ("mapTraceData",) + + @classmethod + def _handle_body_data_dict( + cls, + event_bus: EventBus, + data: dict[str, Any], + ) -> HandlingResult: + trace_data = data.get("mapTraceData") + if not isinstance(trace_data, dict): + return HandlingResult.analyse() + + trace = str(trace_data.get("trace", "")).strip() + event_bus.notify( + MapTraceEvent( + start=int(trace_data.get("start", 0)), + total=int(trace_data.get("totalCount", 0)), + data=trace, + ) + ) + return HandlingResult.success() + + +class GetPos(NgiotMapGetCommand): + """Get current robot and charger positions from NGIOT map data.""" + + NAME = "getPos" + + @property + def _fields(self) -> Sequence[str]: + return ("mapData", "pos") + + @classmethod + def _handle_body_data_dict( + cls, + event_bus: EventBus, + data: dict[str, Any], + ) -> HandlingResult: + positions: list[Position] = [] + + pos = data.get("pos") + if isinstance(pos, dict): + positions.append( + Position( + type=PositionType.from_str("deebotPos"), + x=int(pos["x"]), + y=int(pos["y"]), + a=int(pos.get("a", 0)), + ) + ) + + map_data = data.get("mapData") + if isinstance(map_data, dict): + charge_pos = map_data.get("chargePos") + if isinstance(charge_pos, dict): + positions.append( + Position( + type=PositionType.from_str("chargePos"), + x=int(charge_pos["x"]), + y=int(charge_pos["y"]), + a=int(charge_pos.get("a", 0)), + ) + ) + + if positions: + event_bus.notify(PositionsEvent(positions=positions)) + return HandlingResult.success() + + return HandlingResult.analyse() + + +class GetMapSet(NgiotMapGetCommand): + """Get room and barrier data from the NGIOT map surface.""" + + NAME = "getMapSubSet" + + def __init__( + self, + mid: str, + type: MapSetType | str = MapSetType.ROOMS, + ) -> None: + if isinstance(type, MapSetType): + type = type.value + + super().__init__(mid) + self._map_type = MapSetType(type) + + @property + def _fields(self) -> Sequence[str]: + if self._map_type == MapSetType.ROOMS: + return ("areas",) + return ("virtualWalls", "mopWalls", "carpets") + + @classmethod + def _handle_body_data_dict( + cls, + event_bus: EventBus, + data: dict[str, Any], + ) -> HandlingResult: + # Instance-specific handling is done in _handle_response + return HandlingResult.analyse() + + def _handle_response( + self, + event_bus: EventBus, + response: dict[str, Any], + ) -> HandlingResult: + if response.get("ret") != "ok": + return HandlingResult.analyse() + + body = response.get("resp", {}).get("body", {}) + data = body.get("data", {}) + if not isinstance(data, dict): + return HandlingResult.analyse() + + map_id = str(data.get("mapId") or self._map_id) + + if self._map_type == MapSetType.ROOMS: + areas = data.get("areas") + if not isinstance(areas, list): + return HandlingResult.analyse() + + rooms = [ + Room( + name=(str(area.get("name", "")).strip() or f"Area {int(area['id'])}"), + id=int(area["id"]), + coordinates="", + ) + for area in areas + if isinstance(area, dict) and area.get("id") is not None + ] + event_bus.notify(RoomsEvent(map_id=map_id, rooms=rooms)) + return HandlingResult.success() + + data_key = { + MapSetType.VIRTUAL_WALLS: "virtualWalls", + MapSetType.NO_MOP_ZONES: "mopWalls", + }[self._map_type] + raw_value = str(data.get(data_key, "")).strip() + subset_ids: list[int] = [] + + if raw_value: + for entry in raw_value.split(";"): + parts = [part.strip() for part in entry.split(",") if part.strip()] + if len(parts) < 3: + continue + + subset_id = int(parts[0]) + coordinates = ",".join(parts[2:] if len(parts) % 2 == 0 else parts[1:]) + subset_ids.append(subset_id) + event_bus.notify( + MapSubsetEvent( + id=subset_id, + type=self._map_type, + coordinates=coordinates, + ) + ) + + event_bus.notify(MapSetEvent(self._map_type, subset_ids, map_id)) + return HandlingResult.success() + + +# Backward compatibility for older imports +GetMapSubSet = GetMapSet \ No newline at end of file diff --git a/deebot_client/commands/ngiot/play_sound.py b/deebot_client/commands/ngiot/play_sound.py index 1d56e5ff8..fe4dd7098 100644 --- a/deebot_client/commands/ngiot/play_sound.py +++ b/deebot_client/commands/ngiot/play_sound.py @@ -1,14 +1,12 @@ -"""Play-sound commands.""" - -# deebot_client/commands/json/seek_sound.py -"""Seek sound / locate command for NGIOT eyfj07.""" +"""NGIOT play-sound commands.""" from __future__ import annotations -from .common import ExecuteCommand +from .common import NgiotExecuteCommand + -class SeekSound(ExecuteCommand): - """Trigger device locate sound on eyfj07-like devices.""" +class PlaySound(NgiotExecuteCommand): + """Trigger device locate sound.""" NAME = "seek" diff --git a/deebot_client/hardware/eyfj07.py b/deebot_client/hardware/eyfj07.py index 5648ca418..e9ae6bca7 100644 --- a/deebot_client/hardware/eyfj07.py +++ b/deebot_client/hardware/eyfj07.py @@ -17,6 +17,7 @@ CapabilityLifeSpan, CapabilitySetTypes, CapabilitySettings, + CapabilitySetEnable, CapabilityStats, DeviceType, CapabilityMap, @@ -26,6 +27,7 @@ AvailabilityEvent, BatteryEvent, CustomCommandEvent, + ChildLockEvent, ErrorEvent, FanSpeedEvent, FanSpeedLevel, @@ -50,7 +52,15 @@ from deebot_client.commands.ngiot.life_span import GetLifeSpan, ResetLifeSpan from deebot_client.commands.ngiot.network import GetNetInfo from deebot_client.commands.ngiot.play_sound import PlaySound +from deebot_client.commands.ngiot.child_lock import GetChildLock, SetChildLock from deebot_client.commands.ngiot.stats import GetReportStats, GetStats, GetTotalStats +from deebot_client.commands.ngiot.map import ( + GetCachedMapInfo, + GetMajorMap, + GetMapSet, + GetMapTrace, + GetMinorMap, +) from deebot_client.events.map import ( CachedMapInfoEvent, MajorMapEvent, @@ -127,7 +137,7 @@ def get_device_info() -> StaticDeviceInfo: major=CapabilityEvent(MajorMapEvent, [GetMajorMap()]), minor=CapabilityExecute(GetMinorMap), multi_state=None, - position=CapabilityEvent(PositionsEvent, [GetPos()]), + position=None, rooms=CapabilityEvent(RoomsEvent, [GetCachedMapInfo()]), set=CapabilityExecute(GetMapSet), trace=CapabilityEvent(MapTraceEvent, [GetMapTrace()]), @@ -137,7 +147,13 @@ def get_device_info() -> StaticDeviceInfo: [GetNetInfo()], ), play_sound=CapabilityExecute(PlaySound), - settings=CapabilitySettings(), + settings=CapabilitySettings( + child_lock=CapabilitySetEnable( + ChildLockEvent, + [GetChildLock()], + SetChildLock, + ), + ), state=CapabilityEvent( StateEvent, [GetCleanInfo()], diff --git a/deebot_client/message.py b/deebot_client/message.py index 57729b2fd..c97b40464 100644 --- a/deebot_client/message.py +++ b/deebot_client/message.py @@ -68,7 +68,6 @@ def wrapper(cls: type[M], event_bus: EventBus, data: T) -> HandlingResult: _LOGGER.warning("Could not parse %s: %s", cls.NAME, data, exc_info=True) return HandlingResult(HandlingState.ERROR) else: - # This happens if for some reason someone calls super() of an ABC where handle is not implemented if not response: _LOGGER.error( "Handler for message %s: %s returned no response. " @@ -101,19 +100,13 @@ def __init_subclass__(cls) -> None: def _handle( cls, event_bus: EventBus, message: MessagePayloadType ) -> HandlingResult: - """Handle message and notify the correct event subscribers. - - :return: A message response - """ + """Handle message and notify the correct event subscribers.""" @classmethod @_handle_error_or_analyse @final def handle(cls, event_bus: EventBus, message: MessagePayloadType) -> HandlingResult: - """Handle message and notify the correct event subscribers. - - :return: A message response - """ + """Handle message and notify the correct event subscribers.""" return cls._handle(event_bus, message) @@ -123,25 +116,18 @@ class MessageStr(Message, ABC): @classmethod @abstractmethod def _handle_str(cls, event_bus: EventBus, message: str) -> HandlingResult: - """Handle string message and notify the correct event subscribers. - - :return: A message response - """ + """Handle string message and notify the correct event subscribers.""" @classmethod @_handle_error_or_analyse @final - def __handle_str(cls, event_bus: EventBus, message: str) -> HandlingResult: + def _dispatch_str(cls, event_bus: EventBus, message: str) -> HandlingResult: return cls._handle_str(event_bus, message) @classmethod def _handle( cls, event_bus: EventBus, message: MessagePayloadType ) -> HandlingResult: - """Handle message and notify the correct event subscribers. - - :return: A message response - """ if isinstance(message, bytearray): data = bytes(message).decode() elif isinstance(message, bytes): @@ -149,9 +135,9 @@ def _handle( elif isinstance(message, str): data = message else: - return super()._handle(event_bus, message) + return HandlingResult.analyse() - return cls.__handle_str(event_bus, data) + return cls._dispatch_str(event_bus, data) class MessageDictOrJson(Message, ABC): @@ -162,15 +148,12 @@ class MessageDictOrJson(Message, ABC): def _handle_dict( cls, event_bus: EventBus, message: dict[str, Any] ) -> HandlingResult: - """Handle string message and notify the correct event subscribers. - - :return: A message response - """ + """Handle dict message and notify the correct event subscribers.""" @classmethod @_handle_error_or_analyse @final - def __handle_dict( + def _dispatch_dict( cls, event_bus: EventBus, message: dict[str, Any] ) -> HandlingResult: return cls._handle_dict(event_bus, message) @@ -179,10 +162,6 @@ def __handle_dict( def _handle( cls, event_bus: EventBus, message: MessagePayloadType ) -> HandlingResult: - """Handle message and notify the correct event subscribers. - - :return: A message response - """ data = message if not isinstance(message, dict): try: @@ -195,13 +174,13 @@ def _handle( ) if isinstance(data, dict): - fw_version = data.get("header", {}).get("fwVer", None) + fw_version = data.get("header", {}).get("fwVer") if fw_version: event_bus.notify(FirmwareEvent(fw_version)) - return cls.__handle_dict(event_bus, data) + return cls._dispatch_dict(event_bus, data) - return super()._handle(event_bus, message) + return HandlingResult.analyse() class MessageBody(MessageDictOrJson, ABC): @@ -210,70 +189,53 @@ class MessageBody(MessageDictOrJson, ABC): @classmethod @abstractmethod def _handle_body(cls, event_bus: EventBus, body: dict[str, Any]) -> HandlingResult: - """Handle message->body and notify the correct event subscribers. - - :return: A message response - """ + """Handle message->body and notify the correct event subscribers.""" @classmethod @_handle_error_or_analyse @final - def __handle_body(cls, event_bus: EventBus, body: dict[str, Any]) -> HandlingResult: + def _dispatch_body(cls, event_bus: EventBus, body: dict[str, Any]) -> HandlingResult: return cls._handle_body(event_bus, body) @classmethod def _handle_dict( cls, event_bus: EventBus, message: dict[str, Any] ) -> HandlingResult: - """Handle message and notify the correct event subscribers. - - :return: A message response - """ - if "body" in message: - return cls.__handle_body(event_bus, message["body"]) + body = message.get("body") + if isinstance(body, dict): + return cls._dispatch_body(event_bus, body) - return super()._handle_dict(event_bus, message) + return HandlingResult.analyse() class MessageBodyData(MessageBody, ABC): """Dict message with body->data attribute.""" @classmethod - @abstractmethod def _handle_body_data( cls, event_bus: EventBus, data: dict[str, Any] | list[Any] ) -> HandlingResult: - """Handle message->body->data and notify the correct event subscribers. - - :return: A message response - """ + """Fallback body->data handler.""" + return HandlingResult.analyse() @classmethod + @_handle_error_or_analyse @final - def __handle_body_data( + def _dispatch_body_data( cls, event_bus: EventBus, data: dict[str, Any] | list[Any] ) -> HandlingResult: - try: - response = cls._handle_body_data(event_bus, data) - except Exception: - _LOGGER.warning("Could not parse %s: %s", cls.NAME, data, exc_info=True) - return HandlingResult(HandlingState.ERROR) - else: - if response.state == HandlingState.ANALYSE: - _LOGGER.debug("Could not handle %s message: %s", cls.NAME, data) - return HandlingResult(HandlingState.ANALYSE_LOGGED, response.args) - return response + return cls._handle_body_data(event_bus, data) @classmethod def _handle_body(cls, event_bus: EventBus, body: dict[str, Any]) -> HandlingResult: - """Handle message->body and notify the correct event subscribers. + data = body.get("data") + if data is None: + return HandlingResult.analyse() - :return: A message response - """ - if "data" in body: - return cls.__handle_body_data(event_bus, body["data"]) + if isinstance(data, (dict, list)): + return cls._dispatch_body_data(event_bus, data) - return super()._handle_body(event_bus, body) + return HandlingResult.analyse() class MessageBodyDataDict(MessageBodyData, ABC): @@ -284,23 +246,16 @@ class MessageBodyDataDict(MessageBodyData, ABC): def _handle_body_data_dict( cls, event_bus: EventBus, data: dict[str, Any] ) -> HandlingResult: - """Handle message->body->data and notify the correct event subscribers. - - :return: A message response - """ + """Handle dict body->data and notify the correct event subscribers.""" @classmethod def _handle_body_data( cls, event_bus: EventBus, data: dict[str, Any] | list[Any] ) -> HandlingResult: - """Handle message->body->data and notify the correct event subscribers. - - :return: A message response - """ if isinstance(data, dict): return cls._handle_body_data_dict(event_bus, data) - return super()._handle_body_data(event_bus, data) + return HandlingResult.analyse() class MessageBodyDataList(MessageBodyData, ABC): @@ -311,20 +266,13 @@ class MessageBodyDataList(MessageBodyData, ABC): def _handle_body_data_list( cls, event_bus: EventBus, data: list[Any] ) -> HandlingResult: - """Handle message->body->data and notify the correct event subscribers. - - :return: A message response - """ + """Handle list body->data and notify the correct event subscribers.""" @classmethod def _handle_body_data( cls, event_bus: EventBus, data: dict[str, Any] | list[Any] ) -> HandlingResult: - """Handle message->body->data and notify the correct event subscribers. - - :return: A message response - """ if isinstance(data, list): return cls._handle_body_data_list(event_bus, data) - return super()._handle_body_data(event_bus, data) + return HandlingResult.analyse() \ No newline at end of file diff --git a/deebot_client/ngiot_client.py b/deebot_client/ngiot_client.py index 8597784ed..b1047224d 100644 --- a/deebot_client/ngiot_client.py +++ b/deebot_client/ngiot_client.py @@ -358,8 +358,11 @@ def _extract_body_data(response: Mapping[str, Any]) -> Any: return body.get("data", {}) @staticmethod - def _validate_response(response: Mapping[str, Any]) -> None: + def _validate_response(response: Mapping[str, Any] | None) -> None: """Validate NGIOT envelope and raise ApiError on device-side failures.""" + if response is None: + raise ApiError("Invalid NGIOT response: server returned null/empty body") + body = response.get("body") if not isinstance(body, Mapping): raise ApiError("Invalid NGIOT response: missing body") diff --git a/deebot_client/ngiot_probe.py b/deebot_client/ngiot_probe.py deleted file mode 100644 index 8dcff2c77..000000000 --- a/deebot_client/ngiot_probe.py +++ /dev/null @@ -1,271 +0,0 @@ -from __future__ import annotations - -import argparse -import asyncio -import json -import sys -import time -from pathlib import Path -from typing import Any -from urllib.parse import urlparse - -import aiohttp - - -def derive_ngiot_base_url(mqs_host: str) -> str: - parsed = urlparse(mqs_host) - host = parsed.netloc or parsed.path - host = host.strip().rstrip("/") - if not host: - raise ValueError(f'Could not derive NGIOT base URL from mqs host "{mqs_host}"') - if host.startswith("api-base."): - return f"https://{host}" - if host.startswith("api-ngiot."): - return f"https://api-base.{host.split('.', 1)[1]}" - if "." in host: - return f"https://api-base.{host.split('.', 1)[1]}" - raise ValueError(f'Could not derive NGIOT base URL from mqs host "{mqs_host}"') - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Quick NGIOT probe for Ecovacs eco-ng devices") - parser.add_argument("--repo", required=True, help="Repo root containing deebot_client/") - parser.add_argument("--country", required=True, help="2-letter country code, e.g. AU") - parser.add_argument("--account", required=True, help="Ecovacs account email") - parser.add_argument("--password", required=True, help="Ecovacs account password") - parser.add_argument("--class", dest="class_id", default="eyfj07", help="Device class id") - parser.add_argument("--did", help="Specific device id if multiple devices share the same class") - parser.add_argument("--list-devices", action="store_true", help="List eco-ng devices and exit") - parser.add_argument("--get-info", action="store_true", help="Read a broad 10001 status payload") - parser.add_argument("--status", action="store_true", help="Read a smaller status payload") - parser.add_argument("--totals", action="store_true", help="Read total stats payload") - parser.add_argument( - "--action", - choices=["start", "pause", "resume", "return", "cancel-return", "locate"], - help="Run a captured control action", - ) - parser.add_argument("--area", help="Comma-separated room ids for area clean, e.g. 1 or 1,2") - parser.add_argument("--fan-mode", choices=["quiet", "auto", "strong", "max"], help="Set fan mode") - parser.add_argument("--volume", type=int, help="Set volume 0..10") - parser.add_argument("--raw-apn", help="Send a raw APN") - parser.add_argument("--raw-json", help='Send raw body data JSON, e.g. \'{"seek":true}\'') - return parser.parse_args() - - -async def main() -> int: - args = parse_args() - - repo = Path(args.repo).expanduser().resolve() - if not (repo / "deebot_client").is_dir(): - print(f"Invalid --repo: {repo} does not contain deebot_client/", file=sys.stderr) - return 2 - - sys.path.insert(0, str(repo)) - - from deebot_client.api_client import ApiClient - from deebot_client.authentication import Authenticator, create_rest_config - from deebot_client.util import md5 - - device_id = md5(str(time.time())) - password_hash = md5(args.password) - - async with aiohttp.ClientSession() as session: - rest_config = create_rest_config( - session, - device_id=device_id, - alpha_2_country=args.country, - ) - authenticator = Authenticator(rest_config, args.account, password_hash) - api_client = ApiClient(authenticator) - - devices = await api_client.get_devices() - - eco_ng_devices: list[dict[str, Any]] = [] - for dev in devices.mqtt: - eco_ng_devices.append(dev.api) - for dev in devices.not_supported: - if dev.get("company") == "eco-ng": - eco_ng_devices.append(dev) - - if args.list_devices: - print( - json.dumps( - [ - { - "did": d.get("did"), - "class": d.get("class"), - "nick": d.get("nick"), - "resource": d.get("resource"), - "company": d.get("company"), - "mqs": (d.get("service") or {}).get("mqs"), - } - for d in eco_ng_devices - ], - indent=2, - sort_keys=True, - ) - ) - return 0 - - target: dict[str, Any] | None = None - for dev in eco_ng_devices: - if args.did and dev.get("did") == args.did: - target = dev - break - if not args.did and dev.get("class") == args.class_id: - target = dev - break - - if target is None: - print( - f'No eco-ng device found for class "{args.class_id}"' - + (f' and did "{args.did}"' if args.did else ""), - file=sys.stderr, - ) - return 3 - - service = target.get("service") or {} - mqs_host = service.get("mqs") - if not isinstance(mqs_host, str) or not mqs_host: - print(f'Device is missing service.mqs: {json.dumps(target, indent=2)}', file=sys.stderr) - return 4 - - ngiot_base_url = derive_ngiot_base_url(mqs_host) - authenticator.attach_ngiot( - base_url=ngiot_base_url, - timezone_name="Australia/Brisbane", - timezone_offset_minutes=600, - ) - - ngiot = authenticator.ngiot_client - if ngiot is None: - print("Failed to attach NGIOT client", file=sys.stderr) - return 5 - - async def run_request(apn: str, body_data: dict[str, Any]) -> None: - response = await ngiot.request(target, apn=apn, body_data=body_data) - print(json.dumps(response, indent=2, sort_keys=True)) - - did = target.get("did") - class_id = target.get("class") - nick = target.get("nick") - print(f"Using device did={did} class={class_id} nick={nick}", file=sys.stderr) - - if args.get_info: - await run_request( - "10001", - { - "fields": [ - "stationType", - "stationStatus", - "cleanValues", - "chargeStatus", - "pauseSwitch", - "battery", - "disturbSwitch", - "disturbTimeSet", - "mopState", - "workMode", - "breakCleanStatus", - "fanMode", - "waterMode", - "cleanCount", - "error", - "consumables", - "newMapReport", - "expandedMapReport", - "cleanLogReport", - "deviceInfo", - "childLock", - "isEurope", - "cleanTime", - "cleanArea", - "silentOtaSwitch", - "nextSchedule", - "dormant", - "relocateSwitch", - "unitSet", - "otaData", - "voiceData", - "timeZone", - ] - }, - ) - - if args.status: - await run_request( - "10001", - { - "fields": [ - "chargeStatus", - "pauseSwitch", - "workMode", - "error", - "battery", - "fanMode", - "waterMode", - "cleanArea", - "cleanTime", - "cleanCount", - ] - }, - ) - - if args.totals: - await run_request( - "10001", - { - "fields": [ - "cleanAreaTotal", - "cleanCountTotal", - "cleanTimeTotal", - ] - }, - ) - - if args.action == "start": - await run_request("40001", {"cleanSwitch": True, "cleanMode": "smart"}) - elif args.action == "pause": - await run_request("40009", {"pauseSwitch": True}) - elif args.action == "resume": - await run_request("40011", {"pauseSwitch": False}) - elif args.action == "return": - await run_request("40013", {"chargeSwitch": True}) - elif args.action == "cancel-return": - await run_request("40015", {"chargeSwitch": False}) - elif args.action == "locate": - await run_request("40019", {"seek": True}) - - if args.area: - room_ids = [int(x.strip()) for x in args.area.split(",") if x.strip()] - await run_request( - "40007", - { - "cleanSwitch": True, - "cleanMode": "area", - "cleanValues": room_ids, - }, - ) - - if args.fan_mode: - await run_request("50011", {"fanMode": args.fan_mode}) - - if args.volume is not None: - if not 0 <= args.volume <= 10: - print("--volume must be between 0 and 10", file=sys.stderr) - return 6 - await run_request("50023", {"volume": args.volume}) - - if args.raw_apn: - body_data = json.loads(args.raw_json or "{}") - if not isinstance(body_data, dict): - print("--raw-json must decode to a JSON object", file=sys.stderr) - return 7 - await run_request(args.raw_apn, body_data) - - await authenticator.teardown() - return 0 - - -if __name__ == "__main__": - raise SystemExit(asyncio.run(main())) diff --git a/tests/hardware/test_init.py b/tests/hardware/test_init.py index 6d73a0683..a812d00eb 100644 --- a/tests/hardware/test_init.py +++ b/tests/hardware/test_init.py @@ -264,6 +264,7 @@ async def test_get_static_device_info( AvailabilityEvent: [GetNgiotBattery(is_available_check=True)], BatteryEvent: [GetNgiotBattery()], CachedMapInfoEvent: [GetNgiotCachedMapInfo()], + ChildLockEvent: [GetNgiotChildLock()], CustomCommandEvent: [], ErrorEvent: [GetNgiotError()], FanSpeedEvent: [GetFanSpeed()], From 5ebdb617fe5b6e1edaa88c2938577f3168ac6d48 Mon Sep 17 00:00:00 2001 From: Astute4185 <157855951+Astute4185@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:45:56 +1000 Subject: [PATCH 04/43] Including lz4 as dependency to rust components. --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index 346e0d86c..c3be973bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ crc32fast = "1.4.2" image = { version = "0.25.5", default-features = false } liblzma = "0.4.0" log = "0.4.26" +lz4_flex = "0.13" once_cell = "1.20.3" png = "0.18.0" pyo3 = "0.28.0" From 666a116cbfeca9364e3dee5fa6007a266babeb49 Mon Sep 17 00:00:00 2001 From: Astute4185 <157855951+Astute4185@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:47:34 +1000 Subject: [PATCH 05/43] Add a new dedicated LZ4 function in util.rs --- src/util.rs | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/util.rs b/src/util.rs index 737b5c586..6ed2bf2b3 100644 --- a/src/util.rs +++ b/src/util.rs @@ -5,6 +5,7 @@ use std::io::{Cursor, Read}; use base64::{Engine as _, engine::general_purpose}; use liblzma::read::XzDecoder; use liblzma::stream::Stream; +use lz4_flex::block; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; @@ -25,13 +26,37 @@ pub fn decompress_base64_data(value: &str) -> Result, Box> { } } +/// Dedicated helper for NGIOT LZ4 block payloads. +/// expected_len should come from mapTraceData.lz4Len. +pub fn decompress_base64_lz4_data( + value: &str, + expected_len: usize, +) -> Result, Box> { + let bytes = general_purpose::STANDARD.decode(value)?; + + if expected_len == 0 { + return Err("Invalid LZ4 expected length: 0".into()); + } + + let mut output = vec![0_u8; expected_len]; + let written = block::decompress_into(&bytes, &mut output) + .map_err(|err| format!("LZ4 decompress failed: {err}"))?; + + if written != expected_len { + return Err( + format!("LZ4 size mismatch: expected {expected_len}, got {written}").into(), + ); + } + + Ok(output) +} + /// Decompress LZMA data, avoiding Vec insert overhead. fn decompress_lzma(bytes: &[u8]) -> Result, Box> { if bytes.len() < 8 { return Err("Invalid 7z compressed data".into()); } - // Form tailored header without repeated inserts (much faster) let mut full = Vec::with_capacity(bytes.len() + 4); full.extend_from_slice(&bytes[..8]); full.extend_from_slice(&[0, 0, 0, 0]); @@ -52,7 +77,7 @@ fn decompress_zstd(bytes: &[u8]) -> Result, Box> { Ok(result) } -/// Decompress base64 decoded compressed string by using lzma or zstd +/// Existing legacy helper: lzma or zstd only. #[pyfunction(name = "decompress_base64_data")] fn python_decompress_base64_data(value: &str) -> Result, PyErr> { decompress_base64_data(value).map_err(|err| { @@ -61,7 +86,19 @@ fn python_decompress_base64_data(value: &str) -> Result, PyErr> { }) } +/// New NGIOT-only helper. +#[pyfunction(name = "decompress_base64_lz4_data")] +fn python_decompress_base64_lz4_data(value: &str, expected_len: usize) -> Result, PyErr> { + decompress_base64_lz4_data(value, expected_len).map_err(|err| { + error!( + "Error decompressing LZ4 base64 data: {err}; expected_len:{expected_len}; value:{value}" + ); + PyValueError::new_err(err.to_string()) + }) +} + pub fn init_module(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(python_decompress_base64_data, m)?)?; + m.add_function(wrap_pyfunction!(python_decompress_base64_lz4_data, m)?)?; Ok(()) } From e16c6e0e1debc95392b4f73ea7c007aee4dc9c76 Mon Sep 17 00:00:00 2001 From: Astute4185 <157855951+Astute4185@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:59:49 +1000 Subject: [PATCH 06/43] Update position capability to use GetPos() --- deebot_client/hardware/eyfj07.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deebot_client/hardware/eyfj07.py b/deebot_client/hardware/eyfj07.py index e9ae6bca7..420afc92f 100644 --- a/deebot_client/hardware/eyfj07.py +++ b/deebot_client/hardware/eyfj07.py @@ -137,7 +137,7 @@ def get_device_info() -> StaticDeviceInfo: major=CapabilityEvent(MajorMapEvent, [GetMajorMap()]), minor=CapabilityExecute(GetMinorMap), multi_state=None, - position=None, + position=CapabilityEvent(PositionsEvent, [GetPos()]) rooms=CapabilityEvent(RoomsEvent, [GetCachedMapInfo()]), set=CapabilityExecute(GetMapSet), trace=CapabilityEvent(MapTraceEvent, [GetMapTrace()]), @@ -165,4 +165,4 @@ def get_device_info() -> StaticDeviceInfo: ), water=None, ), - ) \ No newline at end of file + ) From 8c5e07fc00f7223afec66cbc6975eaad9ef6c600 Mon Sep 17 00:00:00 2001 From: Astute4185 <157855951+Astute4185@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:10:35 +1000 Subject: [PATCH 07/43] Add tests for decompress_base64_lz4_data function Added tests for decompressing LZ4 base64 data and handling errors. --- tests/rs/test_util.py | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/tests/rs/test_util.py b/tests/rs/test_util.py index 70e49a5ff..70bff9014 100644 --- a/tests/rs/test_util.py +++ b/tests/rs/test_util.py @@ -8,7 +8,10 @@ import pytest -from deebot_client.rs.util import decompress_base64_data +from deebot_client.rs.util import ( + decompress_base64_data, + decompress_base64_lz4_data, +) if TYPE_CHECKING: from pytest_codspeed import BenchmarkFixture @@ -47,7 +50,6 @@ def test_decompress_base64_data_lzma( # Verify that the old python function is producing the same result assert _decompress_7z_base64_data_python(value) == result - @pytest.mark.parametrize( ("value", "expected"), [ @@ -84,6 +86,27 @@ def test_decompress_base64_data_zstd( ), ], ) + +def test_decompress_base64_lz4_data() -> None: + """Test dedicated NGIOT LZ4 helper.""" + import lz4.block + + expected = b"0,0;100,100;200,200" + compressed = lz4.block.compress(expected, store_size=False) + value = base64.b64encode(compressed).decode() + + assert decompress_base64_lz4_data(value, len(expected)) == expected + + +@pytest.mark.parametrize( + ("value", "expected_len", "expected_error"), + [ + ("@@not-base64@@", 10, "Invalid symbol"), + (base64.b64encode(b"abc").decode(), 0, "Invalid LZ4 expected length: 0"), + (base64.b64encode(b"abc").decode(), 10, "LZ4 decompress failed"), + ], +) + def test_decompress_base64_data_errors(value: str, expected_error: str) -> None: """Test decompress_base64_data function.""" with pytest.raises(ValueError, match=expected_error): @@ -104,3 +127,10 @@ def _decompress_7z_base64_data_python(data: str) -> bytes: dec = lzma.LZMADecompressor(lzma.FORMAT_AUTO, None, None) return dec.decompress(final_array) + +def test_decompress_base64_lz4_data_errors( + value: str, expected_len: int, expected_error: str +) -> None: + """Test NGIOT LZ4 helper failure cases.""" + with pytest.raises(ValueError, match=expected_error): + decompress_base64_lz4_data(value, expected_len) From 9f2948fdda0d9bab7c3541bf4993adda87e74de4 Mon Sep 17 00:00:00 2001 From: Astute4185 <157855951+Astute4185@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:17:38 +1000 Subject: [PATCH 08/43] Refactor _handle_body_data_dict for map processing Refactor _handle_body_data_dict to process mapInfos and determine active and fallback map IDs. --- deebot_client/commands/ngiot/map.py | 52 +++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/deebot_client/commands/ngiot/map.py b/deebot_client/commands/ngiot/map.py index 53f93a1f3..a6f524e86 100644 --- a/deebot_client/commands/ngiot/map.py +++ b/deebot_client/commands/ngiot/map.py @@ -107,8 +107,52 @@ def _fields(self) -> Sequence[str]: return ("mapInfos",) @classmethod - def _handle_body_data_dict(cls, event_bus: EventBus, data: dict[str, Any]) -> HandlingResult: - return HandlingResult.analyse() + def _handle_body_data_dict( + cls, event_bus: EventBus, data: dict[str, Any] + ) -> HandlingResult: + map_infos = data.get("mapInfos") + if not isinstance(map_infos, list) or not map_infos: + return HandlingResult.analyse() + + maps: set[Map] = set() + active_map_id: str | None = None + fallback_map_id: str | None = None + + for map_info in map_infos: + if not isinstance(map_info, dict): + continue + + map_id = str(map_info.get("mapId", "")).strip() + if not map_id or map_id == "0": + continue + + if fallback_map_id is None: + fallback_map_id = map_id + + using = int(map_info.get("status", 0)) == 1 + if using: + active_map_id = map_id + + maps.add( + Map( + id=map_id, + name=str(map_info.get("name", "")), + using=using, + built=True, + angle=RotationAngle.from_int(int(map_info.get("angle", 0))), + ) + ) + + if not maps: + return HandlingResult.analyse() + + event_bus.notify(CachedMapInfoEvent(maps=maps)) + + resolved_map_id = active_map_id or fallback_map_id + if resolved_map_id is None: + return HandlingResult.analyse() + + return HandlingResult(HandlingState.SUCCESS, {"map_id": resolved_map_id}) def _handle_response( self, @@ -125,6 +169,8 @@ def _handle_response( result.requested_commands.extend( [map_obj.set.execute(map_id, entry) for entry in MapSetType] ) + if map_obj.info: + result.requested_commands.append(map_obj.info.execute(map_id)) return result @@ -427,4 +473,4 @@ def _handle_response( # Backward compatibility for older imports -GetMapSubSet = GetMapSet \ No newline at end of file +GetMapSubSet = GetMapSet From 303df4672fa32ba5eb7bb212f631d966bfdead25 Mon Sep 17 00:00:00 2001 From: Astute4185 <157855951+Astute4185@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:19:18 +1000 Subject: [PATCH 09/43] Add unit test for GetCachedMapInfo response handling --- tests/commands/ngiot/test_map.py | 79 ++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/commands/ngiot/test_map.py diff --git a/tests/commands/ngiot/test_map.py b/tests/commands/ngiot/test_map.py new file mode 100644 index 000000000..4c48a39a7 --- /dev/null +++ b/tests/commands/ngiot/test_map.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from unittest.mock import Mock, call + +import pytest + +from deebot_client.commands.ngiot.map import GetCachedMapInfo, GetMapSet +from deebot_client.event_bus import EventBus +from deebot_client.events.map import CachedMapInfoEvent, Map, MapSetType +from deebot_client.hardware import get_static_device_info +from deebot_client.message import HandlingResult, HandlingState +from deebot_client.rs.map import RotationAngle + + +@pytest.mark.asyncio +async def test_getCachedMapInfo_bootstraps_map_sets() -> None: + static_device_info = await get_static_device_info("eyfj07") + assert static_device_info is not None + assert static_device_info.capabilities.map is not None + + event_bus = Mock(spec_set=EventBus) + event_bus.capabilities = static_device_info.capabilities + + response = { + "ret": "ok", + "resp": { + "body": { + "data": { + "mapInfos": [ + { + "mapId": "3", + "name": "Home", + "status": 1, + "angle": 90, + }, + { + "mapId": "4", + "name": "Upstairs", + "status": 0, + "angle": 0, + }, + ] + } + } + }, + } + + result = GetCachedMapInfo()._handle_response(event_bus, response) + + assert result == HandlingResult( + HandlingState.SUCCESS, + {"map_id": "3"}, + [GetMapSet("3", entry) for entry in MapSetType], + ) + event_bus.notify.assert_has_calls( + [ + call( + CachedMapInfoEvent( + maps={ + Map( + id="3", + name="Home", + using=True, + built=True, + angle=RotationAngle.DEG_90, + ), + Map( + id="4", + name="Upstairs", + using=False, + built=True, + angle=RotationAngle.DEG_0, + ), + } + ) + ) + ] + ) + assert event_bus.notify.call_count == 1 From 28177ada29a32c435a590d12893d7bf830e661fd Mon Sep 17 00:00:00 2001 From: Astute4185 <157855951+Astute4185@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:27:34 +1000 Subject: [PATCH 10/43] Removing duplicate imports (deebot_client.commands.ngiot.map) --- deebot_client/hardware/eyfj07.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/deebot_client/hardware/eyfj07.py b/deebot_client/hardware/eyfj07.py index 420afc92f..0514942ae 100644 --- a/deebot_client/hardware/eyfj07.py +++ b/deebot_client/hardware/eyfj07.py @@ -67,13 +67,6 @@ MapChangedEvent, MapTraceEvent, ) -from deebot_client.commands.ngiot.map import ( - GetCachedMapInfo, - GetMajorMap, - GetMapSet, - GetMapTrace, - GetMinorMap, -) from deebot_client.commands.ngiot.pos import GetPos From d2d6e585735931ff5b63d0e648d6ff5906d1717d Mon Sep 17 00:00:00 2001 From: Astute4185 <157855951+Astute4185@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:37:20 +1000 Subject: [PATCH 11/43] Add lz4_len field to MapInfoEvent dataclass --- deebot_client/events/map.py | 1 + 1 file changed, 1 insertion(+) diff --git a/deebot_client/events/map.py b/deebot_client/events/map.py index d45fd7fe3..ea9298c8b 100644 --- a/deebot_client/events/map.py +++ b/deebot_client/events/map.py @@ -46,6 +46,7 @@ class MapTraceEvent(Event): start: int total: int data: str + lz4_len: int | None = field(default=None, kw_only=True) @dataclass(frozen=True) From 0c4b97705d06fccd688e5f71f86ccd24c20b5ce6 Mon Sep 17 00:00:00 2001 From: Astute4185 <157855951+Astute4185@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:39:56 +1000 Subject: [PATCH 12/43] Preserve lz4Len in NGIOT trace command --- deebot_client/commands/ngiot/map.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/deebot_client/commands/ngiot/map.py b/deebot_client/commands/ngiot/map.py index a6f524e86..595602cac 100644 --- a/deebot_client/commands/ngiot/map.py +++ b/deebot_client/commands/ngiot/map.py @@ -321,13 +321,16 @@ def _handle_body_data_dict( trace_data = data.get("mapTraceData") if not isinstance(trace_data, dict): return HandlingResult.analyse() - + trace = str(trace_data.get("trace", "")).strip() + lz4_len = int(trace_data.get("lz4Len", 0)) or None + event_bus.notify( MapTraceEvent( start=int(trace_data.get("start", 0)), total=int(trace_data.get("totalCount", 0)), data=trace, + lz4_len=lz4_len, ) ) return HandlingResult.success() From 71a8ed05c2baa735cc1dba1e848c2b1407da1079 Mon Sep 17 00:00:00 2001 From: Astute4185 <157855951+Astute4185@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:42:58 +1000 Subject: [PATCH 13/43] Update add method to include lz4_len parameter --- deebot_client/rs/map.pyi | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/deebot_client/rs/map.pyi b/deebot_client/rs/map.pyi index dc2d8bab8..6fe53b6a9 100644 --- a/deebot_client/rs/map.pyi +++ b/deebot_client/rs/map.pyi @@ -15,11 +15,8 @@ class BackgroundImage: class TracePoints: """Trace points in rust.""" - def add(self, value: str) -> None: - """Add trace points to the trace points object.""" - - def clear(self) -> None: - """Clear all trace points.""" + def add(self, value: str, lz4_len: int | None = None) -> None: + """Add trace points to the trace points object."" class MapInfo: """Map info.""" From a1749dcd04fc212fc4770c72f9642c0cca0e9df0 Mon Sep 17 00:00:00 2001 From: Astute4185 <157855951+Astute4185@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:45:48 +1000 Subject: [PATCH 14/43] Add LZ4 decompression support for trace points --- src/map/points.rs | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/map/points.rs b/src/map/points.rs index 1233d7261..f03a72b02 100644 --- a/src/map/points.rs +++ b/src/map/points.rs @@ -1,7 +1,7 @@ use std::fmt::Write as FmtWrite; use super::{ROUND_TO_DIGITS, RotationAngle, common::round}; -use crate::util::decompress_base64_data; +use crate::util::{decompress_base64_data, decompress_base64_lz4_data}; use log::error; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; @@ -118,6 +118,14 @@ fn extract_trace_points(value: &str) -> Result, Box> process_trace_points(&decompressed_data) } +fn extract_trace_points_lz4( + value: &str, + expected_len: usize, +) -> Result, Box> { + let decompressed_data = decompress_base64_lz4_data(value, expected_len)?; + process_trace_points(&decompressed_data) +} + fn trace_point_to_point(trace_point: &TracePoint, rotation: RotationAngle) -> Point { let (x, y) = match rotation { RotationAngle::Deg0 => (trace_point.x.into(), trace_point.y.into()), @@ -170,12 +178,21 @@ impl TracePoints { #[pymethods] impl TracePoints { - fn add(&mut self, value: String) -> Result<(), PyErr> { - self.trace_points - .extend(extract_trace_points(&value).map_err(|err| { - error!("Failed to extract trace points: {err};value:{value}"); - PyValueError::new_err(err.to_string()) - })?); + #[pyo3(signature = (value, lz4_len=None))] + fn add(&mut self, value: String, lz4_len: Option) -> Result<(), PyErr> { + let parsed = match lz4_len { + Some(expected_len) => extract_trace_points_lz4(&value, expected_len), + None => extract_trace_points(&value), + } + .map_err(|err| { + error!( + "Failed to extract trace points: {err};value:{value};lz4_len:{:?}", + lz4_len + ); + PyValueError::new_err(err.to_string()) + })?; + + self.trace_points.extend(parsed); Ok(()) } From f1c5a4502cbc192a6a51cf757f79055633b311f3 Mon Sep 17 00:00:00 2001 From: Astute4185 <157855951+Astute4185@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:50:23 +1000 Subject: [PATCH 15/43] Fixing typo --- deebot_client/hardware/eyfj07.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deebot_client/hardware/eyfj07.py b/deebot_client/hardware/eyfj07.py index 0514942ae..4e698f84c 100644 --- a/deebot_client/hardware/eyfj07.py +++ b/deebot_client/hardware/eyfj07.py @@ -130,7 +130,7 @@ def get_device_info() -> StaticDeviceInfo: major=CapabilityEvent(MajorMapEvent, [GetMajorMap()]), minor=CapabilityExecute(GetMinorMap), multi_state=None, - position=CapabilityEvent(PositionsEvent, [GetPos()]) + position=CapabilityEvent(PositionsEvent, [GetPos()]), rooms=CapabilityEvent(RoomsEvent, [GetCachedMapInfo()]), set=CapabilityExecute(GetMapSet), trace=CapabilityEvent(MapTraceEvent, [GetMapTrace()]), From 11f5de2a2ed31329e9931d14767aa17d60bf1462 Mon Sep 17 00:00:00 2001 From: Astute4185 <157855951+Astute4185@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:11:49 +1000 Subject: [PATCH 16/43] Modify add_trace_points to accept lz4_len parameter --- deebot_client/map.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index 7b099b2fe..d070aadf5 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -136,7 +136,7 @@ async def on_map_trace(event: MapTraceEvent) -> None: self._map_data.clear_trace_points() if data := event.data.strip(): - self._map_data.add_trace_points(data) + self._map_data.add_trace_points(data, event.lz4_len) unsubscribers.append(self._event_bus.subscribe(MapTraceEvent, on_map_trace)) @@ -214,9 +214,9 @@ def reset_changed(self) -> None: """Reset changed value.""" self._changed = False - def add_trace_points(self, value: str) -> None: + def add_trace_points(self, value: str, lz4_len: int | None = None) -> None: """Add trace points to the map data.""" - self._data.trace_points.add(value) + self._data.trace_points.add(value, lz4_len) self._on_change() def clear_trace_points(self) -> None: From aa4adb70fd60a1c2a1be6521a9e92fdbe40ade8b Mon Sep 17 00:00:00 2001 From: Astute4185 <157855951+Astute4185@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:13:57 +1000 Subject: [PATCH 17/43] Refactor update_positions to merge position updates Change MapData.update_positions so partial updates merge by position type instead of replacing the whole list. --- deebot_client/map.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index d070aadf5..8af9ea264 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -225,9 +225,17 @@ def clear_trace_points(self) -> None: self._on_change() def update_positions(self, value: list[Position]) -> None: - """Update positions.""" - self._positions = value - self._on_change() + """Merge partial position updates by type.""" + merged: dict[PositionType, Position] = { + position.type: position for position in self._positions + } + for position in value: + merged[position.type] = position + + new_positions = list(merged.values()) + if new_positions != self._positions: + self._positions = new_positions + self._on_change() def update_map_piece(self, index: int, base64_data: str) -> None: """Update map piece.""" From 23e81cae91b1762a5ac92d7bab7fd96a9762b443 Mon Sep 17 00:00:00 2001 From: Astute4185 <157855951+Astute4185@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:29:21 +1000 Subject: [PATCH 18/43] Refactor position handling and add utility functions --- deebot_client/commands/ngiot/map.py | 183 ++++++++++++++-------------- 1 file changed, 90 insertions(+), 93 deletions(-) diff --git a/deebot_client/commands/ngiot/map.py b/deebot_client/commands/ngiot/map.py index 595602cac..4c77931f0 100644 --- a/deebot_client/commands/ngiot/map.py +++ b/deebot_client/commands/ngiot/map.py @@ -31,6 +31,27 @@ from deebot_client.models import ApiDeviceInfo from deebot_client.ngiot_client import NgiotClient +def _coerce_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _build_position(raw: Any, type_name: str) -> Position | None: + if not isinstance(raw, dict): + return None + + if raw.get("x") is None or raw.get("y") is None: + return None + + return Position( + type=PositionType.from_str(type_name), + x=_coerce_int(raw.get("x")), + y=_coerce_int(raw.get("y")), + a=_coerce_int(raw.get("a")), + ) + class NgiotMapGetCommand(NgiotJsonGetCommand, ABC): """Base class for NGIOT APN 30001 field queries.""" @@ -97,15 +118,6 @@ async def _request_ngiot( ) -class GetCachedMapInfo(NgiotMapGetCommand): - """Get cached map info for NGIOT devices.""" - - NAME = "getCachedMapInfo" - - @property - def _fields(self) -> Sequence[str]: - return ("mapInfos",) - @classmethod def _handle_body_data_dict( cls, event_bus: EventBus, data: dict[str, Any] @@ -117,6 +129,8 @@ def _handle_body_data_dict( maps: set[Map] = set() active_map_id: str | None = None fallback_map_id: str | None = None + active_charge_pos: Position | None = None + fallback_charge_pos: Position | None = None for map_info in map_infos: if not isinstance(map_info, dict): @@ -129,17 +143,23 @@ def _handle_body_data_dict( if fallback_map_id is None: fallback_map_id = map_id - using = int(map_info.get("status", 0)) == 1 + using = _coerce_int(map_info.get("status", 0)) == 1 if using: active_map_id = map_id + charge_pos = _build_position(map_info.get("chargePos"), "chargePos") + if charge_pos and fallback_charge_pos is None: + fallback_charge_pos = charge_pos + if charge_pos and using: + active_charge_pos = charge_pos + maps.add( Map( id=map_id, name=str(map_info.get("name", "")), using=using, built=True, - angle=RotationAngle.from_int(int(map_info.get("angle", 0))), + angle=RotationAngle.from_int(_coerce_int(map_info.get("angle", 0))), ) ) @@ -148,6 +168,10 @@ def _handle_body_data_dict( event_bus.notify(CachedMapInfoEvent(maps=maps)) + charger_pos = active_charge_pos or fallback_charge_pos + if charger_pos is not None: + event_bus.notify(PositionsEvent(positions=[charger_pos])) + resolved_map_id = active_map_id or fallback_map_id if resolved_map_id is None: return HandlingResult.analyse() @@ -183,77 +207,57 @@ class GetMajorMap(NgiotMapGetCommand): def _fields(self) -> Sequence[str]: return ("mapData", "areas", "pos") + @classmethod @classmethod def _handle_body_data_dict( cls, event_bus: EventBus, data: dict[str, Any], ) -> HandlingResult: + handled = False + map_data = data.get("mapData") - if not isinstance(map_data, dict): - return HandlingResult.analyse() - - map_blob = map_data.get("map") - if not isinstance(map_blob, str) or not map_blob: - return HandlingResult.analyse() - - map_id = str(data.get("mapId") or map_data.get("mapId") or "") - crc = binascii.crc32(map_blob.encode("utf-8")) & 0xFFFFFFFF - - event_bus.notify(MajorMapEvent(map_id=map_id, values=[crc], requested=False)) - + if isinstance(map_data, dict): + map_id = str(data.get("mapId") or map_data.get("mapId") or "") + + legacy_map_blob = map_data.get("map") + if isinstance(legacy_map_blob, str) and legacy_map_blob: + crc = binascii.crc32(legacy_map_blob.encode("utf-8")) & 0xFFFFFFFF + event_bus.notify(MajorMapEvent(map_id=map_id, values=[crc], requested=False)) + handled = True + + # NGIOT native map surface exists, but Phase 1 does not render it yet. + native_map_blob = map_data.get("data") + if isinstance(native_map_blob, str) and native_map_blob: + handled = True + positions: list[Position] = [] - - pos = data.get("pos") - if isinstance(pos, dict): - positions.append( - Position( - type=PositionType.from_str("deebotPos"), - x=int(pos["x"]), - y=int(pos["y"]), - a=int(pos.get("a", 0)), - ) - ) - - deebot_pos = data.get("deebotPos") - if isinstance(deebot_pos, dict): - positions.append( - Position( - type=PositionType.from_str("deebotPos"), - x=int(deebot_pos["x"]), - y=int(deebot_pos["y"]), - a=int(deebot_pos.get("a", 0)), - ) - ) - - charge_pos = map_data.get("chargePos") - if isinstance(charge_pos, dict): - positions.append( - Position( - type=PositionType.from_str("chargePos"), - x=int(charge_pos["x"]), - y=int(charge_pos["y"]), - a=int(charge_pos.get("a", 0)), - ) - ) - + + robot_pos = _build_position(data.get("pos"), "deebotPos") + if robot_pos is not None: + positions.append(robot_pos) + + deebot_pos = _build_position(data.get("deebotPos"), "deebotPos") + if deebot_pos is not None: + positions.append(deebot_pos) + + if isinstance(map_data, dict): + charger_pos = _build_position(map_data.get("chargePos"), "chargePos") + if charger_pos is not None: + positions.append(charger_pos) + legacy_charge_pos = data.get("chargePos") if isinstance(legacy_charge_pos, list): for entry in legacy_charge_pos: - if isinstance(entry, dict): - positions.append( - Position( - type=PositionType.from_str("chargePos"), - x=int(entry["x"]), - y=int(entry["y"]), - a=int(entry.get("a", 0)), - ) - ) - + charger_pos = _build_position(entry, "chargePos") + if charger_pos is not None: + positions.append(charger_pos) + if positions: event_bus.notify(PositionsEvent(positions=positions)) - - return HandlingResult.success() + handled = True + + return HandlingResult.success() if handled else HandlingResult.analyse() class GetMinorMap(NgiotMapGetCommand): @@ -352,35 +356,28 @@ def _handle_body_data_dict( data: dict[str, Any], ) -> HandlingResult: positions: list[Position] = [] - - pos = data.get("pos") - if isinstance(pos, dict): - positions.append( - Position( - type=PositionType.from_str("deebotPos"), - x=int(pos["x"]), - y=int(pos["y"]), - a=int(pos.get("a", 0)), - ) - ) - + + robot_pos = _build_position(data.get("pos"), "deebotPos") + if robot_pos is not None: + positions.append(robot_pos) + map_data = data.get("mapData") if isinstance(map_data, dict): - charge_pos = map_data.get("chargePos") - if isinstance(charge_pos, dict): - positions.append( - Position( - type=PositionType.from_str("chargePos"), - x=int(charge_pos["x"]), - y=int(charge_pos["y"]), - a=int(charge_pos.get("a", 0)), - ) - ) - + charger_pos = _build_position(map_data.get("chargePos"), "chargePos") + if charger_pos is not None: + positions.append(charger_pos) + + legacy_charge_pos = data.get("chargePos") + if isinstance(legacy_charge_pos, list): + for entry in legacy_charge_pos: + charger_pos = _build_position(entry, "chargePos") + if charger_pos is not None: + positions.append(charger_pos) + if positions: event_bus.notify(PositionsEvent(positions=positions)) return HandlingResult.success() - + return HandlingResult.analyse() From b6e5a610b7103401a70d0506d7ffdc1329d3698e Mon Sep 17 00:00:00 2001 From: Astute4185 <157855951+Astute4185@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:30:21 +1000 Subject: [PATCH 19/43] Refactor map capability event definitions --- deebot_client/hardware/eyfj07.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/deebot_client/hardware/eyfj07.py b/deebot_client/hardware/eyfj07.py index 4e698f84c..c4ed1d25d 100644 --- a/deebot_client/hardware/eyfj07.py +++ b/deebot_client/hardware/eyfj07.py @@ -124,16 +124,16 @@ def get_device_info() -> StaticDeviceInfo: reset=ResetLifeSpan, ), map=CapabilityMap( - cached_info=CapabilityEvent(CachedMapInfoEvent, [GetCachedMapInfo()]), - changed=CapabilityEvent(MapChangedEvent, []), - info=None, - major=CapabilityEvent(MajorMapEvent, [GetMajorMap()]), - minor=CapabilityExecute(GetMinorMap), - multi_state=None, - position=CapabilityEvent(PositionsEvent, [GetPos()]), - rooms=CapabilityEvent(RoomsEvent, [GetCachedMapInfo()]), - set=CapabilityExecute(GetMapSet), - trace=CapabilityEvent(MapTraceEvent, [GetMapTrace()]), + cached_info=CapabilityEvent(CachedMapInfoEvent, [GetCachedMapInfo()]), + changed=CapabilityEvent(MapChangedEvent, []), + info=None, + major=CapabilityEvent(MajorMapEvent, [GetMajorMap()]), + minor=CapabilityExecute(GetMinorMap), + multi_state=None, + position=CapabilityEvent(PositionsEvent, [GetPos()]), + rooms=CapabilityEvent(RoomsEvent, [GetCachedMapInfo()]), + set=CapabilityExecute(GetMapSet), + trace=CapabilityEvent(MapTraceEvent, [GetMapTrace()]), ), network=CapabilityEvent( NetworkInfoEvent, From bc64badc741019e9b543a471a73543050c309f9a Mon Sep 17 00:00:00 2001 From: Astute4185 <157855951+Astute4185@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:05:54 +1000 Subject: [PATCH 20/43] Add NGIOT map parser and normalization helpers This module provides functionality to parse and normalize NGIOT mapping payloads into structured Python data types, including points, poses, maps, areas, traces, and overlays. --- deebot_client/ngiot_map_parser.py | 363 ++++++++++++++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 deebot_client/ngiot_map_parser.py diff --git a/deebot_client/ngiot_map_parser.py b/deebot_client/ngiot_map_parser.py new file mode 100644 index 000000000..01f0527fc --- /dev/null +++ b/deebot_client/ngiot_map_parser.py @@ -0,0 +1,363 @@ +"""NGIOT map parser and normalization helpers. + +This module converts raw NGIOT mapping payload fragments into typed Python +structures. It is intentionally conservative: it accepts partial data, +normalizes where the protocol is clear, and preserves raw fragments where the +payload shape may still vary by device or firmware. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(slots=True, frozen=True) +class NgiotPoint: + """A raw or normalized point in the NGIOT map coordinate space.""" + + x: int + y: int + + +@dataclass(slots=True, frozen=True) +class NgiotPose: + """Robot pose.""" + + x: int + y: int + a: int = 0 + + +@dataclass(slots=True, frozen=True) +class NgiotMapInfo: + """Metadata for a single saved map.""" + + map_id: str + name: str + using: bool + angle: int + charge_pos: NgiotPoint | None = None + + +@dataclass(slots=True, frozen=True) +class NgiotBaseMap: + """Base map metadata and encoded grid payload.""" + + map_id: str + width: int + height: int + total_width: int + total_height: int + resolution: int + x_min: int + y_max: int + data: str + + +@dataclass(slots=True, frozen=True) +class NgiotArea: + """Area / room / partition geometry.""" + + area_id: str + name: str | None + polygon: list[NgiotPoint] = field(default_factory=list) + raw: dict[str, Any] | None = None + + +@dataclass(slots=True, frozen=True) +class NgiotTrace: + """Compressed trace payload metadata.""" + + trace_id: str | None + encoded: str + lz4_len: int | None + total_count: int + start: int + + +@dataclass(slots=True, frozen=True) +class NgiotOverlay: + """Overlay geometry such as virtual walls, mop walls, or carpets.""" + + overlay_type: str + overlay_id: str + polygon: list[NgiotPoint] = field(default_factory=list) + raw: dict[str, Any] | None = None + + +SUPPORTED_OVERLAYS: tuple[tuple[str, str], ...] = ( + ("virtual_walls", "virtualWalls"), + ("mop_walls", "mopWalls"), + ("carpets", "carpets"), +) + + +def _coerce_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + + +def _coerce_str(value: Any) -> str: + return str(value).strip() if value is not None else "" + + + +def resolve_map_id(data: dict[str, Any], fallback: str = "") -> str: + """Resolve a map ID from a mixed NGIOT payload.""" + if (map_id := _coerce_str(data.get("mapId"))): + return map_id + + map_data = data.get("mapData") + if isinstance(map_data, dict) and (map_id := _coerce_str(map_data.get("mapId"))): + return map_id + + trace_data = data.get("mapTraceData") + if isinstance(trace_data, dict) and (map_id := _coerce_str(trace_data.get("mapId"))): + return map_id + + return _coerce_str(fallback) + + + +def _parse_point(value: Any) -> NgiotPoint | None: + if not isinstance(value, dict): + return None + if value.get("x") is None or value.get("y") is None: + return None + return NgiotPoint( + x=_coerce_int(value.get("x")), + y=_coerce_int(value.get("y")), + ) + + + +def _extract_point_pairs(flat_values: list[Any]) -> list[NgiotPoint]: + points: list[NgiotPoint] = [] + ints = [_coerce_int(value) for value in flat_values] + for index in range(0, len(ints) - 1, 2): + points.append(NgiotPoint(x=ints[index], y=ints[index + 1])) + return points + + + +def _extract_polygon(raw: Any) -> list[NgiotPoint]: + """Best-effort polygon extraction for multiple observed payload shapes.""" + if isinstance(raw, list): + if not raw: + return [] + + if all(isinstance(item, dict) for item in raw): + result: list[NgiotPoint] = [] + for item in raw: + point = _parse_point(item) + if point is not None: + result.append(point) + return result + + if all(not isinstance(item, (dict, list, tuple)) for item in raw): + return _extract_point_pairs(raw) + + result: list[NgiotPoint] = [] + for item in raw: + if isinstance(item, (list, tuple)) and len(item) >= 2: + result.append(NgiotPoint(x=_coerce_int(item[0]), y=_coerce_int(item[1]))) + else: + result.extend(_extract_polygon(item)) + return result + + if isinstance(raw, dict): + for key in ( + "points", + "polygon", + "coordinates", + "coord", + "posList", + "vertexes", + "vertices", + "outline", + ): + if key in raw: + return _extract_polygon(raw[key]) + + return [] + + + +def parse_map_infos(data: dict[str, Any]) -> list[NgiotMapInfo]: + """Parse map registry / mapInfos payload.""" + infos: list[NgiotMapInfo] = [] + raw_infos = data.get("mapInfos") + if not isinstance(raw_infos, list): + return infos + + for raw in raw_infos: + if not isinstance(raw, dict): + continue + + map_id = _coerce_str(raw.get("mapId")) + if not map_id or map_id == "0": + continue + + infos.append( + NgiotMapInfo( + map_id=map_id, + name=_coerce_str(raw.get("name")), + using=_coerce_int(raw.get("status")) == 1, + angle=_coerce_int(raw.get("angle")), + charge_pos=_parse_point(raw.get("chargePos")), + ) + ) + + return infos + + + +def parse_base_map(data: dict[str, Any], map_id: str | None = None) -> NgiotBaseMap | None: + """Parse base map metadata and encoded grid payload.""" + raw = data.get("mapData") + if not isinstance(raw, dict): + return None + + encoded = _coerce_str(raw.get("data")) + if not encoded: + return None + + resolved_map_id = _coerce_str(map_id) or resolve_map_id(data) + + return NgiotBaseMap( + map_id=resolved_map_id, + width=_coerce_int(raw.get("width")), + height=_coerce_int(raw.get("height")), + total_width=_coerce_int(raw.get("totalWidth")), + total_height=_coerce_int(raw.get("totalHeight")), + resolution=max(1, _coerce_int(raw.get("resolution"), 1)), + x_min=_coerce_int(raw.get("xMin")), + y_max=_coerce_int(raw.get("yMax")), + data=encoded, + ) + + + +def parse_pose(data: dict[str, Any]) -> NgiotPose | None: + """Parse robot pose from a payload fragment.""" + raw = data.get("pos") + if not isinstance(raw, dict): + raw = data.get("deebotPos") + if not isinstance(raw, dict): + return None + + if raw.get("x") is None or raw.get("y") is None: + return None + + return NgiotPose( + x=_coerce_int(raw.get("x")), + y=_coerce_int(raw.get("y")), + a=_coerce_int(raw.get("a")), + ) + + + +def parse_trace(data: dict[str, Any]) -> NgiotTrace | None: + """Parse compressed map trace metadata.""" + raw = data.get("mapTraceData") + if not isinstance(raw, dict): + return None + + return NgiotTrace( + trace_id=_coerce_str(raw.get("traceId")) or None, + encoded=_coerce_str(raw.get("trace")), + lz4_len=_coerce_int(raw.get("lz4Len")) or None, + total_count=_coerce_int(raw.get("totalCount")), + start=_coerce_int(raw.get("start")), + ) + + + +def parse_areas(data: dict[str, Any]) -> list[NgiotArea]: + """Parse room / area segmentation payload.""" + results: list[NgiotArea] = [] + raw_areas = data.get("areas") + if not isinstance(raw_areas, list): + return results + + for index, raw in enumerate(raw_areas): + if not isinstance(raw, dict): + continue + + area_id = _coerce_str( + raw.get("id") + or raw.get("areaId") + or raw.get("subId") + or raw.get("mid") + or index + ) + name = _coerce_str(raw.get("name") or raw.get("label")) or None + polygon = _extract_polygon(raw) + + results.append( + NgiotArea( + area_id=area_id, + name=name, + polygon=polygon, + raw=raw, + ) + ) + + return results + + + +def parse_overlays(data: dict[str, Any]) -> list[NgiotOverlay]: + """Parse overlay layers from a payload fragment.""" + results: list[NgiotOverlay] = [] + + for overlay_type, field_name in SUPPORTED_OVERLAYS: + raw_items = data.get(field_name) + if not isinstance(raw_items, list): + continue + + for index, raw in enumerate(raw_items): + if not isinstance(raw, dict): + continue + + overlay_id = _coerce_str( + raw.get("id") or raw.get("subId") or raw.get("mid") or index + ) + polygon = _extract_polygon(raw) + + results.append( + NgiotOverlay( + overlay_type=overlay_type, + overlay_id=overlay_id, + polygon=polygon, + raw=raw, + ) + ) + + return results + + + +def normalize_point(point: NgiotPoint, base_map: NgiotBaseMap) -> NgiotPoint: + """Normalize a raw NGIOT point into map-render space.""" + return NgiotPoint( + x=int((point.x - base_map.x_min) / base_map.resolution), + y=int((base_map.y_max - point.y) / base_map.resolution), + ) + + + +def normalize_pose(pose: NgiotPose, base_map: NgiotBaseMap) -> NgiotPose: + """Normalize a raw robot pose into map-render space.""" + point = normalize_point(NgiotPoint(pose.x, pose.y), base_map) + return NgiotPose(x=point.x, y=point.y, a=pose.a) + + + +def normalize_polygon(points: list[NgiotPoint], base_map: NgiotBaseMap) -> list[NgiotPoint]: + """Normalize a polygon into map-render space.""" + return [normalize_point(point, base_map) for point in points] From dbe019945aca2bcd020523c3e6e454abf79d0078 Mon Sep 17 00:00:00 2001 From: Astute4185 <157855951+Astute4185@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:07:08 +1000 Subject: [PATCH 21/43] Add NGIOT map state aggregation module This module aggregates NGIOT map state, managing snapshots and providing normalized views for rendering. --- deebot_client/ngiot_map_state.py | 204 +++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 deebot_client/ngiot_map_state.py diff --git a/deebot_client/ngiot_map_state.py b/deebot_client/ngiot_map_state.py new file mode 100644 index 000000000..26f23a2bd --- /dev/null +++ b/deebot_client/ngiot_map_state.py @@ -0,0 +1,204 @@ +"""NGIOT map state aggregation. + +This module stores a per-map snapshot assembled from multiple NGIOT field-based +responses. It keeps raw values intact, exposes normalized views for later +rendering, and tolerates partial updates arriving in any order. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from .ngiot_map_parser import ( + NgiotArea, + NgiotBaseMap, + NgiotMapInfo, + NgiotOverlay, + NgiotPose, + NgiotPoint, + NgiotTrace, + normalize_point, + normalize_polygon, + normalize_pose, +) + + +@dataclass(slots=True) +class NgiotMapSnapshot: + """Aggregated state for a single NGIOT map ID.""" + + map_info: NgiotMapInfo | None = None + base_map: NgiotBaseMap | None = None + pose: NgiotPose | None = None + trace: NgiotTrace | None = None + areas: list[NgiotArea] = field(default_factory=list) + overlays: list[NgiotOverlay] = field(default_factory=list) + + @property + def map_id(self) -> str | None: + if self.map_info is not None: + return self.map_info.map_id + if self.base_map is not None: + return self.base_map.map_id + return None + + @property + def charge_pos(self) -> NgiotPoint | None: + if self.map_info is not None: + return self.map_info.charge_pos + return None + + def is_renderable(self) -> bool: + """Return True when the snapshot contains a base map.""" + return self.base_map is not None + + +class NgiotMapStateStore: + """Per-device NGIOT map state store keyed by map_id.""" + + def __init__(self) -> None: + self._maps: dict[str, NgiotMapSnapshot] = {} + self._active_map_id: str | None = None + + @property + def active_map_id(self) -> str | None: + return self._active_map_id + + @property + def map_ids(self) -> tuple[str, ...]: + return tuple(self._maps.keys()) + + def clear(self) -> None: + self._maps.clear() + self._active_map_id = None + + def has_map(self, map_id: str) -> bool: + return map_id in self._maps + + def get(self, map_id: str) -> NgiotMapSnapshot: + if map_id not in self._maps: + self._maps[map_id] = NgiotMapSnapshot() + return self._maps[map_id] + + def get_if_present(self, map_id: str) -> NgiotMapSnapshot | None: + return self._maps.get(map_id) + + def get_active(self) -> NgiotMapSnapshot | None: + if self._active_map_id is None: + return None + return self._maps.get(self._active_map_id) + + def set_active_map_id(self, map_id: str | None) -> None: + if map_id: + self._active_map_id = map_id + self.get(map_id) + + def update_map_info(self, info: NgiotMapInfo) -> NgiotMapSnapshot: + snapshot = self.get(info.map_id) + snapshot.map_info = info + if info.using: + self._active_map_id = info.map_id + elif self._active_map_id is None: + self._active_map_id = info.map_id + return snapshot + + def update_map_infos(self, infos: list[NgiotMapInfo]) -> None: + for info in infos: + self.update_map_info(info) + + def update_base_map(self, base_map: NgiotBaseMap) -> NgiotMapSnapshot: + snapshot = self.get(base_map.map_id) + snapshot.base_map = base_map + if self._active_map_id is None: + self._active_map_id = base_map.map_id + return snapshot + + def update_pose(self, map_id: str, pose: NgiotPose) -> NgiotMapSnapshot: + snapshot = self.get(map_id) + snapshot.pose = pose + return snapshot + + def update_trace(self, map_id: str, trace: NgiotTrace) -> NgiotMapSnapshot: + snapshot = self.get(map_id) + snapshot.trace = trace + return snapshot + + def update_areas(self, map_id: str, areas: list[NgiotArea]) -> NgiotMapSnapshot: + snapshot = self.get(map_id) + snapshot.areas = areas + return snapshot + + def update_overlays( + self, map_id: str, overlays: list[NgiotOverlay] + ) -> NgiotMapSnapshot: + snapshot = self.get(map_id) + snapshot.overlays = overlays + return snapshot + + def get_normalized(self, map_id: str | None = None) -> NgiotMapSnapshot | None: + """Return a normalized copy of the snapshot for rendering. + + If the snapshot has no base map yet, the raw snapshot is returned. + """ + resolved_map_id = map_id or self._active_map_id + if resolved_map_id is None: + return None + + snapshot = self._maps.get(resolved_map_id) + if snapshot is None: + return None + + if snapshot.base_map is None: + return NgiotMapSnapshot( + map_info=snapshot.map_info, + base_map=snapshot.base_map, + pose=snapshot.pose, + trace=snapshot.trace, + areas=list(snapshot.areas), + overlays=list(snapshot.overlays), + ) + + base_map = snapshot.base_map + + normalized_map_info = snapshot.map_info + if snapshot.map_info is not None and snapshot.map_info.charge_pos is not None: + normalized_map_info = NgiotMapInfo( + map_id=snapshot.map_info.map_id, + name=snapshot.map_info.name, + using=snapshot.map_info.using, + angle=snapshot.map_info.angle, + charge_pos=normalize_point(snapshot.map_info.charge_pos, base_map), + ) + + normalized_pose = ( + normalize_pose(snapshot.pose, base_map) if snapshot.pose is not None else None + ) + + normalized_areas = [ + NgiotArea( + area_id=area.area_id, + name=area.name, + polygon=normalize_polygon(area.polygon, base_map), + raw=area.raw, + ) + for area in snapshot.areas + ] + + normalized_overlays = [ + NgiotOverlay( + overlay_type=overlay.overlay_type, + overlay_id=overlay.overlay_id, + polygon=normalize_polygon(overlay.polygon, base_map), + raw=overlay.raw, + ) + for overlay in snapshot.overlays + ] + + return NgiotMapSnapshot( + map_info=normalized_map_info, + base_map=base_map, + pose=normalized_pose, + trace=snapshot.trace, + areas=normalized_areas, + overlays=normalized_overlays, + ) From 91f0a7714a00c7bea8f755b07818edc2a35c6553 Mon Sep 17 00:00:00 2001 From: Astute4185 Date: Wed, 1 Apr 2026 10:09:34 +1000 Subject: [PATCH 22/43] Fixes from phase 1 and 2 smoke testing --- deebot_client/commands/ngiot/__init__.py | 3 - deebot_client/commands/ngiot/map.py | 308 +++++++++++++++-------- deebot_client/device.py | 3 + deebot_client/map.py | 12 +- 4 files changed, 215 insertions(+), 111 deletions(-) diff --git a/deebot_client/commands/ngiot/__init__.py b/deebot_client/commands/ngiot/__init__.py index 68362e241..5fbe85b56 100644 --- a/deebot_client/commands/ngiot/__init__.py +++ b/deebot_client/commands/ngiot/__init__.py @@ -16,7 +16,6 @@ from .stats import GetReportStats, GetStats, GetTotalStats from .child_lock import GetChildLock, SetChildLock from .map import ( - GetCachedMapInfo, GetMajorMap, GetMapSet, GetMapTrace, @@ -46,7 +45,6 @@ "SetFanSpeed", "GetChildLock", "SetChildLock", - "GetCachedMapInfo", "GetMajorMap", "GetMapSet", "GetMapTrace", @@ -73,7 +71,6 @@ GetTotalStats, GetChildLock, SetChildLock, - GetCachedMapInfo, GetMajorMap, GetMapSet, GetMapTrace, diff --git a/deebot_client/commands/ngiot/map.py b/deebot_client/commands/ngiot/map.py index 4c77931f0..343239718 100644 --- a/deebot_client/commands/ngiot/map.py +++ b/deebot_client/commands/ngiot/map.py @@ -20,6 +20,16 @@ from deebot_client.message import HandlingResult, HandlingState from deebot_client.models import Room from deebot_client.ngiot_client import APN_MAP_DETAILS +from deebot_client.ngiot_map_parser import ( + parse_areas, + parse_base_map, + parse_map_infos, + parse_overlays, + parse_pose, + parse_trace, + resolve_map_id, +) +from deebot_client.ngiot_map_state import NgiotMapStateStore from deebot_client.rs.map import PositionType, RotationAngle from .common import NgiotJsonGetCommand @@ -31,6 +41,7 @@ from deebot_client.models import ApiDeviceInfo from deebot_client.ngiot_client import NgiotClient + def _coerce_int(value: Any, default: int = 0) -> int: try: return int(value) @@ -53,6 +64,46 @@ def _build_position(raw: Any, type_name: str) -> Position | None: ) +def _build_position_from_point(raw_point: Any, type_name: str) -> Position | None: + if raw_point is None: + return None + + x = getattr(raw_point, "x", None) + y = getattr(raw_point, "y", None) + a = getattr(raw_point, "a", 0) + + if x is None or y is None: + return None + + return Position( + type=PositionType.from_str(type_name), + x=_coerce_int(x), + y=_coerce_int(y), + a=_coerce_int(a), + ) + + +def _get_ngiot_map_state_store(event_bus: EventBus) -> NgiotMapStateStore: + store = getattr(event_bus, "_ngiot_map_state_store", None) + if store is None: + store = NgiotMapStateStore() + setattr(event_bus, "_ngiot_map_state_store", store) + return store + + +def _resolve_effective_map_id( + event_bus: EventBus, + data: dict[str, Any], + explicit: str = "", +) -> str: + store = _get_ngiot_map_state_store(event_bus) + return resolve_map_id(data, fallback=explicit or store.active_map_id or "") + + +def _polygon_to_coordinates(points: list[Any]) -> str: + return ",".join(f"{point.x},{point.y}" for point in points) + + class NgiotMapGetCommand(NgiotJsonGetCommand, ABC): """Base class for NGIOT APN 30001 field queries.""" @@ -117,66 +168,42 @@ async def _request_ngiot( body_data=body_data, ) - @classmethod def _handle_body_data_dict( cls, event_bus: EventBus, data: dict[str, Any] ) -> HandlingResult: - map_infos = data.get("mapInfos") - if not isinstance(map_infos, list) or not map_infos: + infos = parse_map_infos(data) + if not infos: return HandlingResult.analyse() - - maps: set[Map] = set() - active_map_id: str | None = None - fallback_map_id: str | None = None - active_charge_pos: Position | None = None - fallback_charge_pos: Position | None = None - - for map_info in map_infos: - if not isinstance(map_info, dict): - continue - - map_id = str(map_info.get("mapId", "")).strip() - if not map_id or map_id == "0": - continue - - if fallback_map_id is None: - fallback_map_id = map_id - - using = _coerce_int(map_info.get("status", 0)) == 1 - if using: - active_map_id = map_id - - charge_pos = _build_position(map_info.get("chargePos"), "chargePos") - if charge_pos and fallback_charge_pos is None: - fallback_charge_pos = charge_pos - if charge_pos and using: - active_charge_pos = charge_pos - - maps.add( - Map( - id=map_id, - name=str(map_info.get("name", "")), - using=using, - built=True, - angle=RotationAngle.from_int(_coerce_int(map_info.get("angle", 0))), - ) + + store = _get_ngiot_map_state_store(event_bus) + store.update_map_infos(infos) + + maps = { + Map( + id=info.map_id, + name=info.name, + using=info.using, + built=True, + angle=RotationAngle.from_int(info.angle), ) - - if not maps: - return HandlingResult.analyse() - + for info in infos + } event_bus.notify(CachedMapInfoEvent(maps=maps)) - - charger_pos = active_charge_pos or fallback_charge_pos + + active_info = next((info for info in infos if info.using), None) + resolved_info = active_info or infos[0] + + charger_pos = _build_position_from_point( + resolved_info.charge_pos, "chargePos" + ) if charger_pos is not None: event_bus.notify(PositionsEvent(positions=[charger_pos])) - - resolved_map_id = active_map_id or fallback_map_id - if resolved_map_id is None: - return HandlingResult.analyse() - - return HandlingResult(HandlingState.SUCCESS, {"map_id": resolved_map_id}) + + return HandlingResult( + HandlingState.SUCCESS, + {"map_id": resolved_info.map_id}, + ) def _handle_response( self, @@ -207,59 +234,81 @@ class GetMajorMap(NgiotMapGetCommand): def _fields(self) -> Sequence[str]: return ("mapData", "areas", "pos") - @classmethod @classmethod def _handle_body_data_dict( cls, event_bus: EventBus, data: dict[str, Any], ) -> HandlingResult: + store = _get_ngiot_map_state_store(event_bus) + map_id = _resolve_effective_map_id(event_bus, data) handled = False - + map_data = data.get("mapData") if isinstance(map_data, dict): - map_id = str(data.get("mapId") or map_data.get("mapId") or "") - legacy_map_blob = map_data.get("map") if isinstance(legacy_map_blob, str) and legacy_map_blob: crc = binascii.crc32(legacy_map_blob.encode("utf-8")) & 0xFFFFFFFF - event_bus.notify(MajorMapEvent(map_id=map_id, values=[crc], requested=False)) - handled = True - - # NGIOT native map surface exists, but Phase 1 does not render it yet. - native_map_blob = map_data.get("data") - if isinstance(native_map_blob, str) and native_map_blob: + event_bus.notify( + MajorMapEvent(map_id=map_id, values=[crc], requested=False) + ) handled = True - + + base_map = parse_base_map(data, map_id) + if base_map is not None and base_map.map_id: + store.update_base_map(base_map) + map_id = base_map.map_id + handled = True + + areas = parse_areas(data) + if areas and map_id: + store.update_areas(map_id, areas) + handled = True + positions: list[Position] = [] - - robot_pos = _build_position(data.get("pos"), "deebotPos") - if robot_pos is not None: - positions.append(robot_pos) - - deebot_pos = _build_position(data.get("deebotPos"), "deebotPos") - if deebot_pos is not None: - positions.append(deebot_pos) - + + pose = parse_pose(data) + if pose is not None: + if map_id: + store.update_pose(map_id, pose) + positions.append( + Position( + type=PositionType.from_str("deebotPos"), + x=pose.x, + y=pose.y, + a=pose.a, + ) + ) + handled = True + if isinstance(map_data, dict): charger_pos = _build_position(map_data.get("chargePos"), "chargePos") if charger_pos is not None: positions.append(charger_pos) - + handled = True + legacy_charge_pos = data.get("chargePos") if isinstance(legacy_charge_pos, list): for entry in legacy_charge_pos: charger_pos = _build_position(entry, "chargePos") if charger_pos is not None: positions.append(charger_pos) - + handled = True + if positions: event_bus.notify(PositionsEvent(positions=positions)) - handled = True - + return HandlingResult.success() if handled else HandlingResult.analyse() +class GetCachedMapInfo(NgiotMapGetCommand): + NAME = "getCachedMapInfo" + + @property + def _fields(self) -> Sequence[str]: + return ("mapInfos",) + + class GetMinorMap(NgiotMapGetCommand): """Compatibility command for NGIOT map tile fetches.""" @@ -279,7 +328,7 @@ def _handle_body_data_dict( event_bus: EventBus, data: dict[str, Any], ) -> HandlingResult: - # Instance-specific handling is done in _handle_response + del event_bus, data return HandlingResult.analyse() def _handle_response( @@ -322,19 +371,21 @@ def _handle_body_data_dict( event_bus: EventBus, data: dict[str, Any], ) -> HandlingResult: - trace_data = data.get("mapTraceData") - if not isinstance(trace_data, dict): + trace = parse_trace(data) + if trace is None: return HandlingResult.analyse() - - trace = str(trace_data.get("trace", "")).strip() - lz4_len = int(trace_data.get("lz4Len", 0)) or None - + + store = _get_ngiot_map_state_store(event_bus) + map_id = _resolve_effective_map_id(event_bus, data) + if map_id: + store.update_trace(map_id, trace) + event_bus.notify( MapTraceEvent( - start=int(trace_data.get("start", 0)), - total=int(trace_data.get("totalCount", 0)), - data=trace, - lz4_len=lz4_len, + start=trace.start, + total=trace.total_count, + data=trace.encoded, + lz4_len=trace.lz4_len, ) ) return HandlingResult.success() @@ -355,29 +406,42 @@ def _handle_body_data_dict( event_bus: EventBus, data: dict[str, Any], ) -> HandlingResult: + store = _get_ngiot_map_state_store(event_bus) + map_id = _resolve_effective_map_id(event_bus, data) + positions: list[Position] = [] - - robot_pos = _build_position(data.get("pos"), "deebotPos") - if robot_pos is not None: - positions.append(robot_pos) - + + pose = parse_pose(data) + if pose is not None: + if map_id: + store.update_pose(map_id, pose) + + positions.append( + Position( + type=PositionType.from_str("deebotPos"), + x=pose.x, + y=pose.y, + a=pose.a, + ) + ) + map_data = data.get("mapData") if isinstance(map_data, dict): charger_pos = _build_position(map_data.get("chargePos"), "chargePos") if charger_pos is not None: positions.append(charger_pos) - + legacy_charge_pos = data.get("chargePos") if isinstance(legacy_charge_pos, list): for entry in legacy_charge_pos: charger_pos = _build_position(entry, "chargePos") if charger_pos is not None: positions.append(charger_pos) - + if positions: event_bus.notify(PositionsEvent(positions=positions)) return HandlingResult.success() - + return HandlingResult.analyse() @@ -409,7 +473,7 @@ def _handle_body_data_dict( event_bus: EventBus, data: dict[str, Any], ) -> HandlingResult: - # Instance-specific handling is done in _handle_response + del event_bus, data return HandlingResult.analyse() def _handle_response( @@ -425,25 +489,61 @@ def _handle_response( if not isinstance(data, dict): return HandlingResult.analyse() - map_id = str(data.get("mapId") or self._map_id) + store = _get_ngiot_map_state_store(event_bus) + map_id = _resolve_effective_map_id(event_bus, data, self._map_id) if self._map_type == MapSetType.ROOMS: - areas = data.get("areas") - if not isinstance(areas, list): + areas = parse_areas(data) + if not areas: return HandlingResult.analyse() + if map_id: + store.update_areas(map_id, areas) + rooms = [ Room( - name=(str(area.get("name", "")).strip() or f"Area {int(area['id'])}"), - id=int(area["id"]), - coordinates="", + name=(area.name or f"Area {_coerce_int(area.area_id, index)}"), + id=_coerce_int(area.area_id, index), + coordinates=_polygon_to_coordinates(area.polygon), ) - for area in areas - if isinstance(area, dict) and area.get("id") is not None + for index, area in enumerate(areas) ] event_bus.notify(RoomsEvent(map_id=map_id, rooms=rooms)) return HandlingResult.success() + overlays = parse_overlays(data) + if map_id and overlays: + store.update_overlays(map_id, overlays) + + overlay_type_map = { + MapSetType.VIRTUAL_WALLS: "virtual_walls", + MapSetType.NO_MOP_ZONES: "mop_walls", + } + target_overlay_type = overlay_type_map.get(self._map_type) + + if target_overlay_type: + parsed_for_type = [ + overlay + for overlay in overlays + if overlay.overlay_type == target_overlay_type + ] + + if parsed_for_type: + subset_ids: list[int] = [] + for index, overlay in enumerate(parsed_for_type): + subset_id = _coerce_int(overlay.overlay_id, index) + subset_ids.append(subset_id) + event_bus.notify( + MapSubsetEvent( + id=subset_id, + type=self._map_type, + coordinates=_polygon_to_coordinates(overlay.polygon), + ) + ) + + event_bus.notify(MapSetEvent(self._map_type, subset_ids, map_id)) + return HandlingResult.success() + data_key = { MapSetType.VIRTUAL_WALLS: "virtualWalls", MapSetType.NO_MOP_ZONES: "mopWalls", @@ -473,4 +573,4 @@ def _handle_response( # Backward compatibility for older imports -GetMapSubSet = GetMapSet +GetMapSubSet = GetMapSet \ No newline at end of file diff --git a/deebot_client/device.py b/deebot_client/device.py index 117134aef..4f5af4ffb 100644 --- a/deebot_client/device.py +++ b/deebot_client/device.py @@ -30,6 +30,7 @@ from .map import Map from .messages import get_message from .models import DeviceInfo, State +from .ngiot_map_state import NgiotMapStateStore from .rs.map import PositionType if TYPE_CHECKING: @@ -62,6 +63,8 @@ def __init__( self._available_task: asyncio.Task[Any] | None = None self._running_tasks: set[asyncio.Future[Any]] = set() self._unsubscribe: Callable[[], None] | None = None + self.ngiot_map_state = NgiotMapStateStore() + self.events.ngiot_map_state = self.ngiot_map_state self.fw_version: str | None = None self.mac: str | None = None diff --git a/deebot_client/map.py b/deebot_client/map.py index 8af9ea264..09be1838b 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -226,12 +226,16 @@ def clear_trace_points(self) -> None: def update_positions(self, value: list[Position]) -> None: """Merge partial position updates by type.""" - merged: dict[PositionType, Position] = { - position.type: position for position in self._positions + def _position_key(position: Position) -> str: + return str(position.type) + + merged: dict[str, Position] = { + _position_key(position): position for position in self._positions } + for position in value: - merged[position.type] = position - + merged[_position_key(position)] = position + new_positions = list(merged.values()) if new_positions != self._positions: self._positions = new_positions From bac41e82c34964771458c311abc4279727134e89 Mon Sep 17 00:00:00 2001 From: Astute4185 Date: Wed, 1 Apr 2026 12:09:15 +1000 Subject: [PATCH 23/43] Enhance NGIOT map functionality by adding support for carpets and room subsets, and refactor related event handling --- deebot_client/commands/ngiot/map.py | 38 ++++- deebot_client/events/map.py | 31 +++-- deebot_client/map.py | 30 ++-- src/map/background_image.rs | 3 +- src/map/map_info.rs | 8 +- src/map/mod.rs | 207 +++++++++++++++++----------- src/map/style.rs | 20 +++ 7 files changed, 210 insertions(+), 127 deletions(-) diff --git a/deebot_client/commands/ngiot/map.py b/deebot_client/commands/ngiot/map.py index 343239718..69caa71bb 100644 --- a/deebot_client/commands/ngiot/map.py +++ b/deebot_client/commands/ngiot/map.py @@ -500,14 +500,32 @@ def _handle_response( if map_id: store.update_areas(map_id, areas) - rooms = [ - Room( - name=(area.name or f"Area {_coerce_int(area.area_id, index)}"), - id=_coerce_int(area.area_id, index), - coordinates=_polygon_to_coordinates(area.polygon), + subset_ids: list[int] = [] + rooms: list[Room] = [] + + for index, area in enumerate(areas): + subset_id = _coerce_int(area.area_id, index) + coordinates = _polygon_to_coordinates(area.polygon) + subset_ids.append(subset_id) + + event_bus.notify( + MapSubsetEvent( + id=subset_id, + type=MapSetType.ROOMS, + coordinates=coordinates, + name=area.name, + ) ) - for index, area in enumerate(areas) - ] + + rooms.append( + Room( + name=(area.name or f"Area {subset_id}"), + id=subset_id, + coordinates=coordinates, + ) + ) + + event_bus.notify(MapSetEvent(MapSetType.ROOMS, subset_ids, map_id)) event_bus.notify(RoomsEvent(map_id=map_id, rooms=rooms)) return HandlingResult.success() @@ -518,6 +536,7 @@ def _handle_response( overlay_type_map = { MapSetType.VIRTUAL_WALLS: "virtual_walls", MapSetType.NO_MOP_ZONES: "mop_walls", + MapSetType.CARPETS: "carpets", } target_overlay_type = overlay_type_map.get(self._map_type) @@ -547,7 +566,9 @@ def _handle_response( data_key = { MapSetType.VIRTUAL_WALLS: "virtualWalls", MapSetType.NO_MOP_ZONES: "mopWalls", + MapSetType.CARPETS: "carpets", }[self._map_type] + raw_value = str(data.get(data_key, "")).strip() subset_ids: list[int] = [] @@ -571,6 +592,9 @@ def _handle_response( event_bus.notify(MapSetEvent(self._map_type, subset_ids, map_id)) return HandlingResult.success() + event_bus.notify(MapSetEvent(self._map_type, subset_ids, map_id)) + return HandlingResult.success() + # Backward compatibility for older imports GetMapSubSet = GetMapSet \ No newline at end of file diff --git a/deebot_client/events/map.py b/deebot_client/events/map.py index ea9298c8b..d01323e16 100644 --- a/deebot_client/events/map.py +++ b/deebot_client/events/map.py @@ -39,6 +39,21 @@ class GpsPositionEvent(Event): latitude: float +@unique +class MapSetType(StrEnum): + """Map set type enum.""" + + ROOMS = "ar" + VIRTUAL_WALLS = "vw" + NO_MOP_ZONES = "mw" + CARPETS = "cp" + + @classmethod + def has_value(cls, value: Any) -> bool: + """Check if value exists.""" + return value in cls._value2member_map_ + + @dataclass(frozen=True) class MapTraceEvent(Event): """Map trace event representation.""" @@ -74,20 +89,6 @@ class MinorMapEvent(Event): value: str -@unique -class MapSetType(StrEnum): - """Map set type enum.""" - - ROOMS = "ar" - VIRTUAL_WALLS = "vw" - NO_MOP_ZONES = "mw" - - @classmethod - def has_value(cls, value: Any) -> bool: - """Check if value exists.""" - return value in cls._value2member_map_ - - @dataclass(frozen=True) class MapSetEvent(Event): """Map set event.""" @@ -129,4 +130,4 @@ class CachedMapInfoEvent(Event): class MapChangedEvent(Event): """Map changed event.""" - when: datetime + when: datetime \ No newline at end of file diff --git a/deebot_client/map.py b/deebot_client/map.py index 09be1838b..0520ba223 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -24,9 +24,7 @@ from .logging_filter import get_logger from .models import Room from .rs.map import MapData as MapDataRs, RotationAngle -from .util import ( - OnChangedDict, -) +from .util import OnChangedDict if TYPE_CHECKING: from collections.abc import Callable @@ -56,21 +54,16 @@ def __init__( self._unsubscribers: list[Callable[[], None]] = [] async def on_map_set(event: MapSetEvent) -> None: - if event.type == MapSetType.ROOMS: - return - - for subset_id, subset in self._map_data.map_subsets.copy().items(): - if subset.type == event.type and subset_id not in event.subsets: - self._map_data.map_subsets.pop(subset_id, None) + for subset_key, subset in self._map_data.map_subsets.copy().items(): + if subset.type == event.type and subset.id not in event.subsets: + self._map_data.map_subsets.pop(subset_key, None) self._unsubscribers.append(event_bus.subscribe(MapSetEvent, on_map_set)) async def on_map_subset(event: MapSubsetEvent) -> None: - if ( - event.type != MapSetType.ROOMS - and self._map_data.map_subsets.get(event.id, None) != event - ): - self._map_data.map_subsets[event.id] = event + subset_key = (str(event.type), event.id) + if self._map_data.map_subsets.get(subset_key, None) != event: + self._map_data.map_subsets[subset_key] = event self._unsubscribers.append(event_bus.subscribe(MapSubsetEvent, on_map_subset)) @@ -194,7 +187,9 @@ def on_change() -> None: event_bus.notify(MapChangedEvent(datetime.now(UTC)), debounce_time=1) self._on_change = on_change - self._map_subsets: OnChangedDict[int, MapSubsetEvent] = OnChangedDict(on_change) + self._map_subsets: OnChangedDict[tuple[str, int], MapSubsetEvent] = ( + OnChangedDict(on_change) + ) self._positions: list[Position] = [] self._rotation: RotationAngle = RotationAngle.DEG_0 self._data = MapDataRs() @@ -206,7 +201,7 @@ def changed(self) -> bool: return self._changed @property - def map_subsets(self) -> dict[int, MapSubsetEvent]: + def map_subsets(self) -> dict[tuple[str, int], MapSubsetEvent]: """Return map subsets.""" return self._map_subsets @@ -226,6 +221,7 @@ def clear_trace_points(self) -> None: def update_positions(self, value: list[Position]) -> None: """Merge partial position updates by type.""" + def _position_key(position: Position) -> str: return str(position.type) @@ -315,4 +311,4 @@ def teardown(self) -> None: """Teardown room handling.""" for unsubscribe in self._unsubscribers: unsubscribe() - self._unsubscribers.clear() + self._unsubscribers.clear() \ No newline at end of file diff --git a/src/map/background_image.rs b/src/map/background_image.rs index 139553e9b..7dffc5980 100644 --- a/src/map/background_image.rs +++ b/src/map/background_image.rs @@ -1,4 +1,5 @@ -use super::{ImageGenrationType, ViewBox, decompress_base64_data}; +use super::{ImageGenrationType, ViewBox}; +use crate::util::decompress_base64_data; use base64::Engine; use base64::engine::general_purpose; use crc32fast::Hasher; diff --git a/src/map/map_info.rs b/src/map/map_info.rs index 5bd47bec8..d4060f8d2 100644 --- a/src/map/map_info.rs +++ b/src/map/map_info.rs @@ -1,5 +1,6 @@ use super::style::{CSSClass, ROOM_COLORS, get_class_names, get_style}; -use super::{RotationAngle, ViewBox, calc_point, decompress_base64_data}; +use super::{RotationAngle, ViewBox, calc_point}; +use crate::util::decompress_base64_data; use super::points::{Point, points_to_svg_path}; use ordermap::OrderSet; @@ -212,8 +213,9 @@ impl MapInfo { #[pymethods] impl MapInfo { fn set(&mut self, base64_data: String) -> PyResult<()> { - let raw = decompress_base64_data(&base64_data) - .map_err(|err| PyValueError::new_err(err.to_string()))?; + let raw = decompress_base64_data(&base64_data).map_err( + |err: Box| PyValueError::new_err(err.to_string()), + )?; let entries: Vec = serde_json::from_slice(&raw) .map_err(|err| PyValueError::new_err(format!("Invalid map info: {err}")))?; entries.into_iter().for_each(|MapInfoTypeEntry(t, v)| { diff --git a/src/map/mod.rs b/src/map/mod.rs index b4f50d139..2f031e7cc 100644 --- a/src/map/mod.rs +++ b/src/map/mod.rs @@ -8,8 +8,8 @@ use background_image::{BackgroundImage, MAP_MAX_SIZE}; use common::round; use map_info::MapInfo; use ordermap::OrderSet; -use points::{Point, TracePoints, points_to_svg_path}; -use style::{CSSClass, get_class_names, get_style, get_used_definitions}; +use points::{points_to_svg_path, Point, TracePoints}; +use style::{get_class_names, get_style, get_used_definitions, CSSClass}; use super::util::decompress_base64_data; use log::debug; @@ -39,11 +39,7 @@ fn calc_point(x: f32, y: f32, rotation: RotationAngle) -> Point { } } -fn get_svg_subset(subset: &MapSubset, rotation: RotationAngle) -> PyResult<(CSSClass, Path)> { - debug!("Adding subset: {subset:?}"); - - // Estimate capacity: each point consists of an x and y coordinate, separated by commas. - // So, the number of points is half the number of comma-separated values. +fn get_subset_points(subset: &MapSubset, rotation: RotationAngle) -> Vec { let num_coords = subset.coordinates.split(',').count(); let mut points = Vec::with_capacity(num_coords / 2); @@ -61,18 +57,39 @@ fn get_svg_subset(subset: &MapSubset, rotation: RotationAngle) -> PyResult<(CSSC points.push(calc_point(x, y, rotation)); } - let css_key = match subset.set_type.as_str() { - "vw" => CSSClass::VirtualWall, - "mw" => CSSClass::NoMoppingWall, + points +} + +fn get_svg_subset( + subset: &MapSubset, + rotation: RotationAngle, +) -> PyResult<(Vec, Path)> { + debug!("Adding subset: {subset:?}"); + + let points = get_subset_points(subset, rotation); + let close_path = points.len() > 2; + + let css = match subset.set_type.as_str() { + "ar" => vec![CSSClass::RoomSubset], + "vw" => vec![ + CSSClass::WallBase, + CSSClass::StrokeWidth2, + CSSClass::VirtualWall, + ], + "mw" => vec![ + CSSClass::WallBase, + CSSClass::StrokeWidth2, + CSSClass::NoMoppingWall, + ], + "cp" => vec![CSSClass::CarpetArea], _ => return Err(PyValueError::new_err("Invalid set type")), }; - let css_obj = get_style(&css_key); - let svg_object = points_to_svg_path(&points, points.len() > 2, false) - .unwrap() - .set("class", css_obj.class_name); + let svg_object = points_to_svg_path(&points, close_path, false) + .ok_or_else(|| PyValueError::new_err("Subset does not contain enough points"))? + .set("class", get_class_names(&css)); - Ok((css_key, svg_object)) + Ok((css, svg_object)) } #[pyclass(from_py_object, eq, eq_int)] @@ -180,6 +197,49 @@ struct MapSubset { coordinates: String, } +fn calc_fallback_viewbox( + subsets: &[MapSubset], + positions: &[Position], + rotation: RotationAngle, +) -> Option { + let mut min_x = f32::MAX; + let mut min_y = f32::MAX; + let mut max_x = f32::MIN; + let mut max_y = f32::MIN; + let mut found = false; + + for subset in subsets { + for point in get_subset_points(subset, rotation) { + min_x = min_x.min(point.x); + min_y = min_y.min(point.y); + max_x = max_x.max(point.x); + max_y = max_y.max(point.y); + found = true; + } + } + + for position in positions { + let point = calc_point(position.x as f32, position.y as f32, rotation); + min_x = min_x.min(point.x); + min_y = min_y.min(point.y); + max_x = max_x.max(point.x); + max_y = max_y.max(point.y); + found = true; + } + + if !found { + return None; + } + + let margin: i16 = 5; + Some(ViewBox::from_extents( + min_x.floor() as i16 - margin, + min_y.floor() as i16 - margin, + max_x.ceil() as i16 + margin, + max_y.ceil() as i16 + margin, + )) +} + #[pyclass] struct MapData { #[pyo3(get)] @@ -210,7 +270,6 @@ impl MapData { ) -> PyResult> { let mut defs = Definitions::new() .add( - // Gradient used by Bot icon RadialGradient::new() .set("id", "dbg") .set("cx", "50%") @@ -230,7 +289,6 @@ impl MapData { ), ) .add( - // Bot circular icon Group::new() .set("id", PositionType::Deebot.svg_use_id()) .add(Circle::new().set("r", 5).set("fill", "url(#dbg)")) @@ -243,13 +301,10 @@ impl MapData { ), ) .add( - // Charger pin icon (pre-flipped vertically) Group::new() .set("id", PositionType::Charger.svg_use_id()) .add(Path::new().set("fill", "#ffe605").set( "d", - // Path data cannot be used as it's adds a , after each parameter - // and repeats the command when used sequentially "M4-6.4C4-4.2 0 0 0 0s-4-4.2-4-6.4 1.8-4 4-4 4 1.8 4 4z", )) .add( @@ -265,20 +320,18 @@ impl MapData { let mut document = Document::new(); - // Create map from MapInfo, if exists, or generate background image let viewbox = match self.map_info.borrow(py).generate(rotation) { Some((map_elements, viewbox, info_styles)) => { - // Append all map background elements to document map_elements.into_iter().for_each(|e| document.append(e)); styles.extend(info_styles); viewbox } _ => { - if let Some((base64_image, viewbox)) = - self.background_image - .borrow(py) - .generate() - .map_err(|err| PyValueError::new_err(err.to_string()))? + if let Some((base64_image, viewbox)) = self + .background_image + .borrow(py) + .generate() + .map_err(|err| PyValueError::new_err(err.to_string()))? { let image = Image::new() .set("x", viewbox.min_x) @@ -289,31 +342,21 @@ impl MapData { .set("href", format!("data:image/png;base64,{base64_image}")); document.append(image); viewbox + } else if let Some(viewbox) = calc_fallback_viewbox(&subsets, &positions, rotation) + { + viewbox } else { return Ok(None); } } }; - // Add required definitions based on used CSS classes - get_used_definitions(&styles) - .into_iter() - .for_each(|def| defs.append(def)); - - document = document.add(defs).set("viewBox", viewbox.to_svg_viewbox()); - - if !subsets.is_empty() { - let group_css = [CSSClass::WallBase, CSSClass::StrokeWidth2]; - let mut group = Group::new().set("class", get_class_names(&group_css)); - styles.extend(group_css); - - for subset in &subsets { - let (css, subset) = get_svg_subset(subset, rotation)?; - styles.insert(css); - group = group.add(subset); - } - document.append(group); + for subset in &subsets { + let (css_list, path) = get_svg_subset(subset, rotation)?; + styles.extend(css_list); + document.append(path); } + if let Some(trace) = self.trace_points.borrow(py).get_path(rotation) { document.append(trace); } @@ -321,6 +364,12 @@ impl MapData { document.append(position); } + get_used_definitions(&styles) + .into_iter() + .for_each(|def| defs.append(def)); + + document = document.add(defs).set("viewBox", viewbox.to_svg_viewbox()); + let mut style_string = String::new(); for k in styles { let css = get_style(&k); @@ -363,6 +412,20 @@ impl ViewBox { } } + fn from_extents(min_x: i16, min_y: i16, max_x: i16, max_y: i16) -> Self { + let width = (max_x - min_x).max(1) as u16; + let height = (max_y - min_y).max(1) as u16; + + ViewBox { + min_x, + min_y, + max_x, + max_y, + width, + height, + } + } + #[inline] fn to_svg_viewbox(&self) -> String { format!( @@ -383,7 +446,6 @@ fn get_svg_positions( return Vec::new(); } - // Create indices and sort them instead of collecting references let mut indices: Vec = (0..positions.len()).collect(); indices.sort_by_key(|&i| positions[i].position_type.order()); @@ -431,8 +493,8 @@ mod tests { #[rstest] #[case((-100, -100, 200, 150))] #[case((0, 0, 1000, 1000))] - #[case( (0, 0, 1000, 1000))] - #[case( (-500, -500, 1000, 1000))] + #[case((0, 0, 1000, 1000))] + #[case((-500, -500, 1000, 1000))] fn test_tuple_2_view_box(#[case] input: (i16, i16, u16, u16)) { let result = tuple_2_view_box(input); assert_eq!( @@ -443,14 +505,14 @@ mod tests { #[rstest] #[case(5000.0, 0.0, RotationAngle::Deg0, Point { x:100.0, y:0.0, connected:true })] - #[case(20010.0, -29900.0, RotationAngle::Deg0, Point { x: 400.2, y: 598.0, connected:true })] - #[case(0.0, 29900.0, RotationAngle::Deg0, Point { x: 0.0, y: -598.0, connected:true })] + #[case(20010.0, -29900.0, RotationAngle::Deg0, Point { x: 400.2, y: 598.0, connected:true })] + #[case(0.0, 29900.0, RotationAngle::Deg0, Point { x: 0.0, y: -598.0, connected:true })] #[case(5000.0, 0.0, RotationAngle::Deg90, Point { x:0.0, y:100.0, connected:true })] - #[case(20010.0, -29900.0, RotationAngle::Deg90, Point { x: -598.0, y: 400.2, connected:true })] + #[case(20010.0, -29900.0, RotationAngle::Deg90, Point { x: -598.0, y: 400.2, connected:true })] #[case(5000.0, 0.0, RotationAngle::Deg180, Point { x:-100.0, y:0.0, connected:true })] - #[case(20010.0, -29900.0, RotationAngle::Deg180, Point { x: -400.2, y: -598.0, connected:true })] + #[case(20010.0, -29900.0, RotationAngle::Deg180, Point { x: -400.2, y: -598.0, connected:true })] #[case(5000.0, 0.0, RotationAngle::Deg270, Point { x:0.0, y:-100.0, connected:true })] - #[case(20010.0, -29900.0, RotationAngle::Deg270, Point { x: 598.0, y: -400.2, connected:true })] + #[case(20010.0, -29900.0, RotationAngle::Deg270, Point { x: 598.0, y: -400.2, connected:true })] fn test_calc_point( #[case] x: f32, #[case] y: f32, @@ -481,42 +543,19 @@ mod tests { } #[rstest] - #[case(&[Position{position_type:PositionType::Deebot, x:5000, y:-55000}], RotationAngle::Deg0, "")] - #[case(&[Position{position_type:PositionType::Deebot, x:15000, y:15000}], RotationAngle::Deg0, "")] - #[case(&[Position{position_type:PositionType::Charger, x:25000, y:55000}, Position{position_type:PositionType::Deebot, x:-5000, y:-50000}], RotationAngle::Deg0, "")] - #[case(&[Position{position_type:PositionType::Deebot, x:-10000, y:10000}, Position{position_type:PositionType::Charger, x:50000, y:5000}], RotationAngle::Deg0, "")] - #[case(&[Position{position_type:PositionType::Deebot, x:5000, y:-55000}], RotationAngle::Deg90, "")] - #[case(&[Position{position_type:PositionType::Deebot, x:5000, y:-55000}], RotationAngle::Deg180, "")] - #[case(&[Position{position_type:PositionType::Deebot, x:5000, y:-55000}], RotationAngle::Deg270, "")] - fn test_get_svg_positions( - #[case] positions: &[Position], - #[case] rotation: RotationAngle, - #[case] expected: String, - ) { - let viewbox = (-500, -500, 1000, 1000); - let result = get_svg_positions(positions, &tuple_2_view_box(viewbox), rotation) - .iter() - .map(|u| u.to_string()) - .collect::>() - .join(""); - assert_eq!(result, expected); - } - - #[rstest] - #[case(MapSubset{set_type:"vw".to_string(), coordinates:"[-3900,668,-2133,668]".to_string()}, RotationAngle::Deg0, "")] - #[case(MapSubset{set_type:"mw".to_string(), coordinates:"[-442,2910,-442,982,1214,982,1214,2910]".to_string()}, RotationAngle::Deg0, "")] - #[case(MapSubset{set_type:"vw".to_string(), coordinates:"['12023', '1979', '12135', '-6720']".to_string()}, RotationAngle::Deg0, "")] - #[case(MapSubset{set_type:"vw".to_string(), coordinates:"['12023', '1979', , '', '12135', '-6720']".to_string()}, RotationAngle::Deg0, "")] - #[case(MapSubset{set_type:"vw".to_string(), coordinates:"[-3900,668,-2133,668]".to_string()}, RotationAngle::Deg90, "")] - #[case(MapSubset{set_type:"vw".to_string(), coordinates:"[-3900,668,-2133,668]".to_string()}, RotationAngle::Deg180, "")] - #[case(MapSubset{set_type:"vw".to_string(), coordinates:"[-3900,668,-2133,668]".to_string()}, RotationAngle::Deg270, "")] + #[case(MapSubset{set_type:"vw".to_string(), coordinates:"[-3900,668,-2133,668]".to_string()}, RotationAngle::Deg0, "")] + #[case(MapSubset{set_type:"mw".to_string(), coordinates:"[-442,2910,-442,982,1214,982,1214,2910]".to_string()}, RotationAngle::Deg0, "")] + #[case(MapSubset{set_type:"vw".to_string(), coordinates:"['12023', '1979', '12135', '-6720']".to_string()}, RotationAngle::Deg0, "")] + #[case(MapSubset{set_type:"vw".to_string(), coordinates:"['12023', '1979', , '', '12135', '-6720']".to_string()}, RotationAngle::Deg0, "")] + #[case(MapSubset{set_type:"vw".to_string(), coordinates:"[-3900,668,-2133,668]".to_string()}, RotationAngle::Deg90, "")] + #[case(MapSubset{set_type:"vw".to_string(), coordinates:"[-3900,668,-2133,668]".to_string()}, RotationAngle::Deg180, "")] + #[case(MapSubset{set_type:"vw".to_string(), coordinates:"[-3900,668,-2133,668]".to_string()}, RotationAngle::Deg270, "")] fn test_get_svg_subset( #[case] subset: MapSubset, #[case] rotation: RotationAngle, #[case] expected: String, ) { let (_, node) = get_svg_subset(&subset, rotation).unwrap(); - assert_eq!(node.to_string(), expected); } @@ -559,4 +598,4 @@ mod tests { let rotation = RotationAngle::default(); assert_eq!(rotation, RotationAngle::Deg0); } -} +} \ No newline at end of file diff --git a/src/map/style.rs b/src/map/style.rs index 04abf194b..0c21da272 100644 --- a/src/map/style.rs +++ b/src/map/style.rs @@ -84,8 +84,10 @@ pub(super) enum CSSClass { RoomColor5, WallBase, + RoomSubset, VirtualWall, NoMoppingWall, + CarpetArea, } pub(super) const ROOM_COLORS: [CSSClass; 6] = [ @@ -207,6 +209,15 @@ fn get_styles() -> &'static HashMap { identifier: ".w path", }, ), + ( + CSSClass::RoomSubset, + CSSEntry { + class_name: "rs", + value: "fill: #deebfb; stroke: #9fb7d8; stroke-width: 0.8", + required_def: None, + identifier: ".rs", + }, + ), ( CSSClass::VirtualWall, css_entry!("v", "stroke: #f00000; fill: #f0000030"), @@ -215,6 +226,15 @@ fn get_styles() -> &'static HashMap { CSSClass::NoMoppingWall, css_entry!("m", "stroke: #ffa500; fill: #ffa50030"), ), + ( + CSSClass::CarpetArea, + CSSEntry { + class_name: "ca", + value: "fill: #1a81ed30; stroke: #1a81ed; stroke-width: 1", + required_def: None, + identifier: ".ca", + }, + ), ]) }) } From f19bc9b501b79b190408a04548f32dda35d687d2 Mon Sep 17 00:00:00 2001 From: Astute4185 Date: Wed, 1 Apr 2026 14:16:04 +1000 Subject: [PATCH 24/43] Implement NGIOT background handling with synchronization and rendering capabilities --- deebot_client/map.py | 38 ++++++++++++++++ deebot_client/ngiot_map_state.py | 41 +++++++++++++++-- deebot_client/rs/map.pyi | 31 ++++++++++--- src/map/mod.rs | 20 +++++++++ src/map/ngiot_background.rs | 76 ++++++++++++++++++++++++++++++++ 5 files changed, 196 insertions(+), 10 deletions(-) create mode 100644 src/map/ngiot_background.rs diff --git a/deebot_client/map.py b/deebot_client/map.py index 0520ba223..f3f182ff9 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -161,6 +161,13 @@ def get_svg_map(self) -> str | None: _LOGGER.debug("[get_svg_map] Begin") + self._map_data.sync_ngiot_background(self._event_bus) + + # Reset change before starting to build the SVG + self._map_data.reset_changed() + + self._last_image = self._map_data.generate_svg() + # Reset change before starting to build the SVG self._map_data.reset_changed() @@ -266,6 +273,37 @@ def set_rotation_angle(self, rotation: RotationAngle) -> None: self._rotation = rotation self._on_change() + def sync_ngiot_background(self, event_bus: EventBus) -> None: + """Push active NGIOT base-map metadata into the Rust holder. + + Phase 4A only stores the payload and metadata. Phase 4B will decode it. + """ + store = getattr(event_bus, "_ngiot_map_state_store", None) + if store is None: + if self._data.ngiot_background.clear(): + self._on_change() + return + + snapshot = store.get_active() + if snapshot is None or snapshot.base_map is None: + if self._data.ngiot_background.clear(): + self._on_change() + return + + base_map = snapshot.base_map + changed = self._data.ngiot_background.set_map_data( + base_map.data, + base_map.width, + base_map.height, + base_map.total_width, + base_map.total_height, + base_map.resolution, + base_map.x_min, + base_map.y_max, + ) + if changed: + self._on_change() + def teardown(self) -> None: """Teardown map data.""" self._room_handling.teardown() diff --git a/deebot_client/ngiot_map_state.py b/deebot_client/ngiot_map_state.py index 26f23a2bd..e3c5365ca 100644 --- a/deebot_client/ngiot_map_state.py +++ b/deebot_client/ngiot_map_state.py @@ -48,10 +48,38 @@ def charge_pos(self) -> NgiotPoint | None: return self.map_info.charge_pos return None - def is_renderable(self) -> bool: - """Return True when the snapshot contains a base map.""" + def has_background(self) -> bool: + """Return True when a decoded/normalizable base-map payload is present.""" return self.base_map is not None + def has_geometry(self) -> bool: + """Return True when enough geometry/state exists to render a useful map. + + Geometry-map V1 intentionally does not require a base map. + """ + return bool( + self.areas + or self.overlays + or self.pose is not None + or self.charge_pos is not None + or ( + self.trace is not None + and ( + self.trace.total_count > 0 + or bool(self.trace.encoded) + ) + ) + ) + + def is_renderable(self) -> bool: + """Return True when the snapshot can produce a visible map. + + For geometry-map V1, either: + - a base map is present, or + - enough geometry/state exists to render without a background + """ + return self.has_background() or self.has_geometry() + class NgiotMapStateStore: """Per-device NGIOT map state store keyed by map_id.""" @@ -88,6 +116,13 @@ def get_active(self) -> NgiotMapSnapshot | None: return None return self._maps.get(self._active_map_id) + def get_active_renderable(self) -> NgiotMapSnapshot | None: + """Return the active snapshot only if it is renderable.""" + snapshot = self.get_active() + if snapshot is None or not snapshot.is_renderable(): + return None + return snapshot + def set_active_map_id(self, map_id: str | None) -> None: if map_id: self._active_map_id = map_id @@ -201,4 +236,4 @@ def get_normalized(self, map_id: str | None = None) -> NgiotMapSnapshot | None: trace=snapshot.trace, areas=normalized_areas, overlays=normalized_overlays, - ) + ) \ No newline at end of file diff --git a/deebot_client/rs/map.pyi b/deebot_client/rs/map.pyi index 6fe53b6a9..568358792 100644 --- a/deebot_client/rs/map.pyi +++ b/deebot_client/rs/map.pyi @@ -3,14 +3,27 @@ from typing import Self from deebot_client.events.map import MapSubsetEvent, Position -class BackgroundImage: - """Map background image.""" +class NgiotBackground: + """NGIOT background placeholder.""" - def update_map_piece(self, index: int, base64_data: str) -> bool: - """Update map piece.""" - - def map_piece_crc32_indicates_update(self, index: int, crc32: int) -> bool: - """Return True if update is required.""" + def set_map_data( + self, + encoded: str, + width: int, + height: int, + total_width: int, + total_height: int, + resolution: int, + x_min: int, + y_max: int, + ) -> bool: + """Store NGIOT background metadata and encoded payload.""" + + def clear(self) -> bool: + """Clear NGIOT background metadata.""" + + def has_data(self) -> bool: + """Return True if NGIOT background data is present.""" class TracePoints: """Trace points in rust.""" @@ -34,6 +47,10 @@ class MapData: def background_image(self) -> BackgroundImage: """Return background image.""" + @property + def ngiot_background(self) -> NgiotBackground: + """Return NGIOT background placeholder.""" + @property def map_info(self) -> MapInfo: """Return map info.""" diff --git a/src/map/mod.rs b/src/map/mod.rs index 2f031e7cc..afe1bd4fb 100644 --- a/src/map/mod.rs +++ b/src/map/mod.rs @@ -1,12 +1,14 @@ mod background_image; mod common; mod map_info; +mod ngiot_background; mod points; mod style; use background_image::{BackgroundImage, MAP_MAX_SIZE}; use common::round; use map_info::MapInfo; +use ngiot_background::NgiotBackground; use ordermap::OrderSet; use points::{points_to_svg_path, Point, TracePoints}; use style::{get_class_names, get_style, get_used_definitions, CSSClass}; @@ -247,6 +249,8 @@ struct MapData { #[pyo3(get)] background_image: Py, #[pyo3(get)] + ngiot_background: Py, + #[pyo3(get)] map_info: Py, } @@ -257,6 +261,7 @@ impl MapData { Ok(MapData { trace_points: Py::new(py, TracePoints::new())?, background_image: Py::new(py, BackgroundImage::new())?, + ngiot_background: Py::new(py, NgiotBackground::new())?, map_info: Py::new(py, MapInfo::new())?, }) } @@ -328,6 +333,21 @@ impl MapData { } _ => { if let Some((base64_image, viewbox)) = self + .ngiot_background + .borrow(py) + .generate() + .map_err(|err| PyValueError::new_err(err.to_string()))? + { + let image = Image::new() + .set("x", viewbox.min_x) + .set("y", viewbox.min_y) + .set("width", viewbox.width) + .set("height", viewbox.height) + .set("style", "image-rendering: pixelated") + .set("href", format!("data:image/png;base64,{base64_image}")); + document.append(image); + viewbox + } else if let Some((base64_image, viewbox)) = self .background_image .borrow(py) .generate() diff --git a/src/map/ngiot_background.rs b/src/map/ngiot_background.rs new file mode 100644 index 000000000..1455a333d --- /dev/null +++ b/src/map/ngiot_background.rs @@ -0,0 +1,76 @@ +use super::ImageGenrationType; +use pyo3::prelude::*; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct NgiotBackgroundData { + encoded: String, + width: u16, + height: u16, + total_width: u16, + total_height: u16, + resolution: i32, + x_min: i32, + y_max: i32, +} + +#[pyclass] +pub(super) struct NgiotBackground { + data: Option, +} + +impl NgiotBackground { + pub(super) fn new() -> Self { + Self { data: None } + } + + pub(super) fn generate(&self) -> Result> { + // Phase 4A plumbing only. + // Phase 4B will decode NGIOT mapData.data into a real raster image here. + Ok(None) + } +} + +#[pymethods] +impl NgiotBackground { + fn set_map_data( + &mut self, + encoded: String, + width: u16, + height: u16, + total_width: u16, + total_height: u16, + resolution: i32, + x_min: i32, + y_max: i32, + ) -> bool { + let new_data = NgiotBackgroundData { + encoded, + width, + height, + total_width, + total_height, + resolution, + x_min, + y_max, + }; + + if self.data.as_ref() == Some(&new_data) { + return false; + } + + self.data = Some(new_data); + true + } + + fn clear(&mut self) -> bool { + if self.data.is_none() { + return false; + } + self.data = None; + true + } + + fn has_data(&self) -> bool { + self.data.is_some() + } +} \ No newline at end of file From 3e307cd07e6a642cbfe551a00fc1deb591809931 Mon Sep 17 00:00:00 2001 From: Astute4185 Date: Wed, 1 Apr 2026 15:03:22 +1000 Subject: [PATCH 25/43] HA test candidate - able to get status updates, but MAP fails --- deebot_client/device.py | 13 +++-- deebot_client/map.py | 109 +++++++++------------------------------- 2 files changed, 34 insertions(+), 88 deletions(-) diff --git a/deebot_client/device.py b/deebot_client/device.py index 4f5af4ffb..4459fa6c4 100644 --- a/deebot_client/device.py +++ b/deebot_client/device.py @@ -63,13 +63,19 @@ def __init__( self._available_task: asyncio.Task[Any] | None = None self._running_tasks: set[asyncio.Future[Any]] = set() self._unsubscribe: Callable[[], None] | None = None - self.ngiot_map_state = NgiotMapStateStore() - self.events.ngiot_map_state = self.ngiot_map_state self.fw_version: str | None = None self.mac: str | None = None + self.events: Final[EventBus] = EventBus(self.execute_command, self.capabilities) + # Shared NGIOT map aggregation store. + # Commands look for _ngiot_map_state_store on the event bus. + self.ngiot_map_state = NgiotMapStateStore() + setattr(self.events, "_ngiot_map_state_store", self.ngiot_map_state) + # Optional public alias for debugging/introspection. + self.events.ngiot_map_state = self.ngiot_map_state + self.map: Final[Map | None] = ( Map(self.execute_command, self.events, self.capabilities.map) if self.capabilities.map @@ -98,6 +104,7 @@ async def on_pos(event: PositionsEvent) -> None: self.events.subscribe(PositionsEvent, on_pos) async def on_state(event: StateEvent) -> None: + self._state = event if event.state == State.DOCKED: self.events.request_refresh(CleanLogEvent) self.events.request_refresh(TotalStatsEvent) @@ -242,4 +249,4 @@ def _handle_message( self._create_request_command_task(result.requested_commands) except Exception: - _LOGGER.exception("An exception occurred during handling message") + _LOGGER.exception("An exception occurred during handling message") \ No newline at end of file diff --git a/deebot_client/map.py b/deebot_client/map.py index f3f182ff9..3106956d3 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -2,20 +2,17 @@ from __future__ import annotations -import asyncio from datetime import UTC, datetime from typing import TYPE_CHECKING, Final from deebot_client.events.map import CachedMapInfoEvent, MapChangedEvent from .events import ( - MajorMapEvent, MapInfoEvent, MapSetEvent, MapSetType, MapSubsetEvent, MapTraceEvent, - MinorMapEvent, Position, PositionsEvent, RoomsEvent, @@ -78,33 +75,12 @@ async def on_map_info(event: MapInfoEvent) -> None: self._unsubscribers.append(event_bus.subscribe(MapInfoEvent, on_map_info)) - # ---------------------------- METHODS ---------------------------- - - async def _subscribe_minor_major_map_events(self) -> list[Callable[[], None]]: - async def on_major_map(event: MajorMapEvent) -> None: - async with asyncio.TaskGroup() as tg: - for idx, value in enumerate(event.values): - if ( - self._map_data.map_piece_crc32_indicates_update(idx, value) - and event.requested - ): - tg.create_task( - self._execute_command( - self._capabilities.minor.execute(idx, event.map_id) - ) - ) - - async def on_minor_map(event: MinorMapEvent) -> None: - self._map_data.update_map_piece(event.index, event.value) - - return [ - self._event_bus.subscribe(MajorMapEvent, on_major_map), - self._event_bus.subscribe(MinorMapEvent, on_minor_map), - ] - async def _on_first_map_changed_subscription(self) -> Callable[[], None]: - """On first MapChanged subscription.""" - unsubscribers = await self._subscribe_minor_major_map_events() + """On first MapChanged subscription. + + Geometry-map V1 deliberately ignores legacy major/minor background tiles. + """ + unsubscribers: list[Callable[[], None]] = [] async def on_cached_info(event: CachedMapInfoEvent) -> None: used_map = next((m for m in event.maps if m.using), None) @@ -116,7 +92,6 @@ async def on_cached_info(event: CachedMapInfoEvent) -> None: self._event_bus.subscribe(CachedMapInfoEvent, on_cached_info) ) if cached_map_subscribers: - # Request update only if there was already a subscriber before self._event_bus.request_refresh(CachedMapInfoEvent) async def on_position(event: PositionsEvent) -> None: @@ -128,14 +103,30 @@ async def on_map_trace(event: MapTraceEvent) -> None: if event.start == 0: self._map_data.clear_trace_points() - if data := event.data.strip(): + if not (data := event.data.strip()): + return + + try: self._map_data.add_trace_points(data, event.lz4_len) + except ValueError as err: + _LOGGER.warning( + "Skipping invalid trace payload for geometry map " + "(start=%s total=%s lz4_len=%s): %s", + event.start, + event.total, + event.lz4_len, + err, + ) + except Exception: + _LOGGER.exception( + "Unexpected error while processing trace payload; continuing without trace" + ) unsubscribers.append(self._event_bus.subscribe(MapTraceEvent, on_map_trace)) def unsub() -> None: - for unsub in unsubscribers: - unsub() + for unsubscribe in unsubscribers: + unsubscribe() return unsub @@ -144,11 +135,9 @@ def refresh(self) -> None: if not self._unsubscribers: raise MapError("Please enable the map first") - # TODO make it nice self._event_bus.request_refresh(CachedMapInfoEvent) self._event_bus.request_refresh(PositionsEvent) self._event_bus.request_refresh(MapTraceEvent) - self._event_bus.request_refresh(MajorMapEvent) def get_svg_map(self) -> str | None: """Return map as SVG string.""" @@ -161,17 +150,9 @@ def get_svg_map(self) -> str | None: _LOGGER.debug("[get_svg_map] Begin") - self._map_data.sync_ngiot_background(self._event_bus) - - # Reset change before starting to build the SVG self._map_data.reset_changed() - self._last_image = self._map_data.generate_svg() - # Reset change before starting to build the SVG - self._map_data.reset_changed() - - self._last_image = self._map_data.generate_svg() _LOGGER.debug("[get_svg_map] Finish") return self._last_image @@ -244,17 +225,6 @@ def _position_key(position: Position) -> str: self._positions = new_positions self._on_change() - def update_map_piece(self, index: int, base64_data: str) -> None: - """Update map piece.""" - if self._data.background_image.update_map_piece(index, base64_data): - self._on_change() - - def map_piece_crc32_indicates_update(self, index: int, crc32: int) -> bool: - """Return True if update is required.""" - return self._data.background_image.map_piece_crc32_indicates_update( - index, crc32 - ) - def generate_svg(self) -> str | None: """Generate SVG image.""" return self._data.generate_svg( @@ -273,37 +243,6 @@ def set_rotation_angle(self, rotation: RotationAngle) -> None: self._rotation = rotation self._on_change() - def sync_ngiot_background(self, event_bus: EventBus) -> None: - """Push active NGIOT base-map metadata into the Rust holder. - - Phase 4A only stores the payload and metadata. Phase 4B will decode it. - """ - store = getattr(event_bus, "_ngiot_map_state_store", None) - if store is None: - if self._data.ngiot_background.clear(): - self._on_change() - return - - snapshot = store.get_active() - if snapshot is None or snapshot.base_map is None: - if self._data.ngiot_background.clear(): - self._on_change() - return - - base_map = snapshot.base_map - changed = self._data.ngiot_background.set_map_data( - base_map.data, - base_map.width, - base_map.height, - base_map.total_width, - base_map.total_height, - base_map.resolution, - base_map.x_min, - base_map.y_max, - ) - if changed: - self._on_change() - def teardown(self) -> None: """Teardown map data.""" self._room_handling.teardown() From 62a4d977ed126692b5d82499878ab3cbc55762cc Mon Sep 17 00:00:00 2001 From: Astute4185 Date: Wed, 1 Apr 2026 16:09:19 +1000 Subject: [PATCH 26/43] Enhance NGIOT map functionality by adding raster background handling and related parsing improvements --- deebot_client/commands/ngiot/map.py | 2 + deebot_client/map.py | 93 +++++++++++++++++++++++++++-- deebot_client/ngiot_map_parser.py | 13 ++-- deebot_client/ngiot_map_state.py | 30 +++++----- src/map/background_image.rs | 6 +- src/map/map_info.rs | 11 ++-- src/map/mod.rs | 61 ++++++++++--------- src/map/ngiot_background.rs | 78 ++++++++++++++++++++++-- 8 files changed, 230 insertions(+), 64 deletions(-) diff --git a/deebot_client/commands/ngiot/map.py b/deebot_client/commands/ngiot/map.py index 69caa71bb..917eecde2 100644 --- a/deebot_client/commands/ngiot/map.py +++ b/deebot_client/commands/ngiot/map.py @@ -5,6 +5,7 @@ import binascii from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any +from deebot_client.logging_filter import get_logger from deebot_client.events import Position, PositionsEvent, RoomsEvent from deebot_client.events.map import ( @@ -494,6 +495,7 @@ def _handle_response( if self._map_type == MapSetType.ROOMS: areas = parse_areas(data) + if not areas: return HandlingResult.analyse() diff --git a/deebot_client/map.py b/deebot_client/map.py index 3106956d3..6a300821d 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -5,7 +5,7 @@ from datetime import UTC, datetime from typing import TYPE_CHECKING, Final -from deebot_client.events.map import CachedMapInfoEvent, MapChangedEvent +from deebot_client.events.map import CachedMapInfoEvent, MajorMapEvent, MapChangedEvent from .events import ( MapInfoEvent, @@ -78,7 +78,9 @@ async def on_map_info(event: MapInfoEvent) -> None: async def _on_first_map_changed_subscription(self) -> Callable[[], None]: """On first MapChanged subscription. - Geometry-map V1 deliberately ignores legacy major/minor background tiles. + For NGIOT devices, the visible base map comes from the raster payload stored + in the NGIOT map state store. This callback wires the Python map layer to + that store and keeps overlays layered on top. """ unsubscribers: list[Callable[[], None]] = [] @@ -86,6 +88,7 @@ async def on_cached_info(event: CachedMapInfoEvent) -> None: used_map = next((m for m in event.maps if m.using), None) if used_map: self._map_data.set_rotation_angle(used_map.angle) + self._sync_ngiot_background_from_store() cached_map_subscribers = self._event_bus.has_subscribers(CachedMapInfoEvent) unsubscribers.append( @@ -94,8 +97,14 @@ async def on_cached_info(event: CachedMapInfoEvent) -> None: if cached_map_subscribers: self._event_bus.request_refresh(CachedMapInfoEvent) + async def on_major_map(_: MajorMapEvent) -> None: + self._sync_ngiot_background_from_store() + + unsubscribers.append(self._event_bus.subscribe(MajorMapEvent, on_major_map)) + async def on_position(event: PositionsEvent) -> None: self._map_data.update_positions(event.positions) + self._sync_ngiot_background_from_store() unsubscribers.append(self._event_bus.subscribe(PositionsEvent, on_position)) @@ -124,6 +133,8 @@ async def on_map_trace(event: MapTraceEvent) -> None: unsubscribers.append(self._event_bus.subscribe(MapTraceEvent, on_map_trace)) + self._sync_ngiot_background_from_store() + def unsub() -> None: for unsubscribe in unsubscribers: unsubscribe() @@ -136,6 +147,7 @@ def refresh(self) -> None: raise MapError("Please enable the map first") self._event_bus.request_refresh(CachedMapInfoEvent) + self._event_bus.request_refresh(MajorMapEvent) self._event_bus.request_refresh(PositionsEvent) self._event_bus.request_refresh(MapTraceEvent) @@ -163,6 +175,48 @@ async def teardown(self) -> None: self._unsubscribers.clear() self._map_data.teardown() + def _sync_ngiot_background_from_store(self) -> None: + """Push the active NGIOT raster background into the renderer path.""" + store = getattr(self._event_bus, "_ngiot_map_state_store", None) + if store is None: + self._map_data.clear_ngiot_background() + return + + snapshot = None + get_active_renderable = getattr(store, "get_active_renderable", None) + if callable(get_active_renderable): + snapshot = get_active_renderable() + + if snapshot is None: + get_active = getattr(store, "get_active", None) + if callable(get_active): + snapshot = get_active() + + base_map = getattr(snapshot, "base_map", None) if snapshot is not None else None + encoded = "" + if base_map is not None: + encoded = getattr(base_map, "encoded", "") or getattr(base_map, "data", "") + + if ( + base_map is None + or not encoded + or int(getattr(base_map, "width", 0)) <= 0 + or int(getattr(base_map, "height", 0)) <= 0 + ): + self._map_data.clear_ngiot_background() + return + + self._map_data.set_ngiot_background( + encoded=encoded, + width=int(getattr(base_map, "width", 0)), + height=int(getattr(base_map, "height", 0)), + total_width=int(getattr(base_map, "total_width", 0)), + total_height=int(getattr(base_map, "total_height", 0)), + resolution=int(getattr(base_map, "resolution", 1)), + x_min=int(getattr(base_map, "x_min", 0)), + y_max=int(getattr(base_map, "y_max", 0)), + ) + class MapData: """Map data.""" @@ -240,8 +294,39 @@ def set_map_info(self, base64_info: str) -> None: def set_rotation_angle(self, rotation: RotationAngle) -> None: """Set clockwise rotation angle for SVG image.""" - self._rotation = rotation - self._on_change() + if self._rotation != rotation: + self._rotation = rotation + self._on_change() + + def set_ngiot_background( + self, + *, + encoded: str, + width: int, + height: int, + total_width: int, + total_height: int, + resolution: int, + x_min: int, + y_max: int, + ) -> None: + """Set the active NGIOT raster background payload.""" + if self._data.ngiot_background.set_map_data( + encoded, + width, + height, + total_width, + total_height, + resolution, + x_min, + y_max, + ): + self._on_change() + + def clear_ngiot_background(self) -> None: + """Clear the active NGIOT raster background payload.""" + if self._data.ngiot_background.clear(): + self._on_change() def teardown(self) -> None: """Teardown map data.""" diff --git a/deebot_client/ngiot_map_parser.py b/deebot_client/ngiot_map_parser.py index 01f0527fc..a4bb36534 100644 --- a/deebot_client/ngiot_map_parser.py +++ b/deebot_client/ngiot_map_parser.py @@ -42,7 +42,7 @@ class NgiotMapInfo: @dataclass(slots=True, frozen=True) class NgiotBaseMap: - """Base map metadata and encoded grid payload.""" + """Base map metadata and encoded raster payload.""" map_id: str width: int @@ -52,7 +52,8 @@ class NgiotBaseMap: resolution: int x_min: int y_max: int - data: str + encoded: str + lz4_len: int | None = None @dataclass(slots=True, frozen=True) @@ -217,12 +218,12 @@ def parse_map_infos(data: dict[str, Any]) -> list[NgiotMapInfo]: def parse_base_map(data: dict[str, Any], map_id: str | None = None) -> NgiotBaseMap | None: - """Parse base map metadata and encoded grid payload.""" + """Parse base map metadata and encoded raster payload.""" raw = data.get("mapData") if not isinstance(raw, dict): return None - encoded = _coerce_str(raw.get("data")) + encoded = _coerce_str(raw.get("map")) or _coerce_str(raw.get("data")) if not encoded: return None @@ -237,11 +238,11 @@ def parse_base_map(data: dict[str, Any], map_id: str | None = None) -> NgiotBase resolution=max(1, _coerce_int(raw.get("resolution"), 1)), x_min=_coerce_int(raw.get("xMin")), y_max=_coerce_int(raw.get("yMax")), - data=encoded, + encoded=encoded, + lz4_len=_coerce_int(raw.get("lz4Len")) or None, ) - def parse_pose(data: dict[str, Any]) -> NgiotPose | None: """Parse robot pose from a payload fragment.""" raw = data.get("pos") diff --git a/deebot_client/ngiot_map_state.py b/deebot_client/ngiot_map_state.py index e3c5365ca..1db69c62d 100644 --- a/deebot_client/ngiot_map_state.py +++ b/deebot_client/ngiot_map_state.py @@ -49,14 +49,17 @@ def charge_pos(self) -> NgiotPoint | None: return None def has_background(self) -> bool: - """Return True when a decoded/normalizable base-map payload is present.""" - return self.base_map is not None - - def has_geometry(self) -> bool: - """Return True when enough geometry/state exists to render a useful map. + """Return True when a usable raster base map is present.""" + base_map = self.base_map + return bool( + base_map is not None + and base_map.encoded + and base_map.width > 0 + and base_map.height > 0 + ) - Geometry-map V1 intentionally does not require a base map. - """ + def has_overlay_content(self) -> bool: + """Return True when overlay/state content exists for the map.""" return bool( self.areas or self.overlays @@ -71,14 +74,13 @@ def has_geometry(self) -> bool: ) ) - def is_renderable(self) -> bool: - """Return True when the snapshot can produce a visible map. + def is_overlay_only(self) -> bool: + """Return True when only non-background map content exists.""" + return not self.has_background() and self.has_overlay_content() - For geometry-map V1, either: - - a base map is present, or - - enough geometry/state exists to render without a background - """ - return self.has_background() or self.has_geometry() + def is_renderable(self) -> bool: + """Return True when the snapshot can produce the intended visible map.""" + return self.has_background() class NgiotMapStateStore: diff --git a/src/map/background_image.rs b/src/map/background_image.rs index 7dffc5980..89c612908 100644 --- a/src/map/background_image.rs +++ b/src/map/background_image.rs @@ -106,8 +106,8 @@ impl BackgroundImage { .view( min_x.into(), min_y.into(), - view_box.width.into(), - view_box.height.into(), + view_box.width.round() as u32, + view_box.height.round() as u32, ) .to_image(); @@ -230,4 +230,4 @@ mod tests { assert!(map_piece.pixels_indexed.is_none()); assert!(!map_piece.update_points(data).unwrap()); } -} +} \ No newline at end of file diff --git a/src/map/map_info.rs b/src/map/map_info.rs index d4060f8d2..07e680020 100644 --- a/src/map/map_info.rs +++ b/src/map/map_info.rs @@ -313,9 +313,12 @@ fn calc_viewbox(outlines: &[MapInfoTypeDataEntry]) -> Option { .for_each(|e| minmax_points(e.points.iter(), &mut bounds)); let (min_x_f, min_y_f, max_x_f, max_y_f) = bounds?; - let (min_x, min_y) = (min_x_f.round() as i16, min_y_f.round() as i16); - let (max_x, max_y) = (max_x_f.round() as i16, max_y_f.round() as i16); - let (width, height) = ((max_x - min_x).max(1) as u16, (max_y - min_y).max(1) as u16); + let min_x = min_x_f.round(); + let min_y = min_y_f.round(); + let max_x = max_x_f.round(); + let max_y = max_y_f.round(); + let width = (max_x - min_x).max(1.0); + let height = (max_y - min_y).max(1.0); Some(ViewBox { min_x, @@ -373,4 +376,4 @@ mod tests { "Empty map info entry at line 1 column 4" ); } -} +} \ No newline at end of file diff --git a/src/map/mod.rs b/src/map/mod.rs index afe1bd4fb..2e1175ef5 100644 --- a/src/map/mod.rs +++ b/src/map/mod.rs @@ -22,7 +22,7 @@ use svg::node::element::{ }; use svg::{Document, Node}; -const PIXEL_WIDTH: f32 = 50.0; +pub(super) const PIXEL_WIDTH: f32 = 50.0; const ROUND_TO_DIGITS: usize = 3; const MAP_OFFSET: i16 = MAP_MAX_SIZE as i16 / 2; @@ -233,12 +233,12 @@ fn calc_fallback_viewbox( return None; } - let margin: i16 = 5; + let margin = 5.0; Some(ViewBox::from_extents( - min_x.floor() as i16 - margin, - min_y.floor() as i16 - margin, - max_x.ceil() as i16 + margin, - max_y.ceil() as i16 + margin, + min_x.floor() - margin, + min_y.floor() - margin, + max_x.ceil() + margin, + max_y.ceil() + margin, )) } @@ -406,35 +406,35 @@ impl MapData { } } -#[derive(Debug)] +#[derive(Debug, Clone, Copy)] struct ViewBox { - min_x: i16, - min_y: i16, - max_x: i16, - max_y: i16, - width: u16, - height: u16, + min_x: f32, + min_y: f32, + max_x: f32, + max_y: f32, + width: f32, + height: f32, } impl ViewBox { fn new(min_x: u16, min_y: u16, max_x: u16, max_y: u16) -> Self { - let new_min_x = min_x as i16 - MAP_OFFSET; - let new_min_y = min_y as i16 - MAP_OFFSET; - let width = max_x - min_x + 1; - let height = max_y - min_y + 1; + let new_min_x = min_x as f32 - MAP_OFFSET as f32; + let new_min_y = min_y as f32 - MAP_OFFSET as f32; + let width = (max_x - min_x + 1) as f32; + let height = (max_y - min_y + 1) as f32; ViewBox { min_x: new_min_x, min_y: new_min_y, - max_x: new_min_x + width as i16, - max_y: new_min_y + height as i16, + max_x: new_min_x + width, + max_y: new_min_y + height, width, height, } } - fn from_extents(min_x: i16, min_y: i16, max_x: i16, max_y: i16) -> Self { - let width = (max_x - min_x).max(1) as u16; - let height = (max_y - min_y).max(1) as u16; + fn from_extents(min_x: f32, min_y: f32, max_x: f32, max_y: f32) -> Self { + let width = (max_x - min_x).max(1.0); + let height = (max_y - min_y).max(1.0); ViewBox { min_x, @@ -450,7 +450,10 @@ impl ViewBox { fn to_svg_viewbox(&self) -> String { format!( "{} {} {} {}", - self.min_x, self.min_y, self.width, self.height + round(self.min_x, ROUND_TO_DIGITS), + round(self.min_y, ROUND_TO_DIGITS), + round(self.width, ROUND_TO_DIGITS), + round(self.height, ROUND_TO_DIGITS) ) } } @@ -501,12 +504,12 @@ mod tests { fn tuple_2_view_box(tuple: (i16, i16, u16, u16)) -> ViewBox { ViewBox { - min_x: tuple.0, - min_y: tuple.1, - max_x: tuple.0 + tuple.2 as i16, - max_y: tuple.1 + tuple.3 as i16, - width: tuple.2, - height: tuple.3, + min_x: tuple.0 as f32, + min_y: tuple.1 as f32, + max_x: tuple.0 as f32 + tuple.2 as f32, + max_y: tuple.1 as f32 + tuple.3 as f32, + width: tuple.2 as f32, + height: tuple.3 as f32, } } diff --git a/src/map/ngiot_background.rs b/src/map/ngiot_background.rs index 1455a333d..fed626a3e 100644 --- a/src/map/ngiot_background.rs +++ b/src/map/ngiot_background.rs @@ -1,6 +1,13 @@ -use super::ImageGenrationType; +use super::{ImageGenrationType, ViewBox}; +use crate::util::decompress_base64_lz4_data; +use base64::Engine; +use base64::engine::general_purpose; +use log::debug; +use png::{BitDepth, ColorType, Compression, Encoder}; use pyo3::prelude::*; +const WORLD_PIXEL_WIDTH: f32 = 50.0; + #[derive(Debug, Clone, PartialEq, Eq)] struct NgiotBackgroundData { encoded: String, @@ -24,10 +31,73 @@ impl NgiotBackground { } pub(super) fn generate(&self) -> Result> { - // Phase 4A plumbing only. - // Phase 4B will decode NGIOT mapData.data into a real raster image here. - Ok(None) + let Some(data) = self.data.as_ref() else { + return Ok(None); + }; + + let expected_len = usize::from(data.width) * usize::from(data.height); + if expected_len == 0 { + return Ok(None); + } + + let raster = decompress_base64_lz4_data(&data.encoded, expected_len)?; + if raster.len() != expected_len { + return Err(format!( + "NGIOT raster size mismatch: expected {}, got {}", + expected_len, + raster.len() + ) + .into()); + } + + let mut png_data = Vec::new(); + { + let mut encoder = Encoder::new(&mut png_data, u32::from(data.width), u32::from(data.height)); + encoder.set_compression(Compression::Balanced); + encoder.set_color(ColorType::Rgba); + encoder.set_depth(BitDepth::Eight); + + let mut writer = encoder.write_header()?; + let rgba = raster_to_rgba(&raster); + writer.write_image_data(&rgba)?; + } + + let left = data.x_min as f32 / WORLD_PIXEL_WIDTH; + let top = -(data.y_max as f32) / WORLD_PIXEL_WIDTH; + let width_svg = (f32::from(data.width) * data.resolution as f32) / WORLD_PIXEL_WIDTH; + let height_svg = (f32::from(data.height) * data.resolution as f32) / WORLD_PIXEL_WIDTH; + + let viewbox = ViewBox::from_extents(left, top, left + width_svg, top + height_svg); + + debug!( + "Generated NGIOT raster background: map {}x{} at world ({}, {}) size ({}, {})", + data.width, data.height, left, top, width_svg, height_svg + ); + + Ok(Some((general_purpose::STANDARD.encode(&png_data), viewbox))) + } +} + +#[inline] +fn rgba_for_value(value: u8) -> [u8; 4] { + match value { + 127 => [255, 255, 255, 0], // transparent / outside map + 1 => [237, 237, 237, 255], // light floor + 0 => [210, 210, 210, 255], // alternate floor / unknown floor + 2 => [20, 20, 20, 255], // dark occupied / blocked region + 3 => [83, 132, 178, 255], // observed alternate class + 4 => [165, 92, 47, 255], // observed alternate class + 255 => [220, 30, 30, 255], // marker / sentinel + _ => [255, 0, 255, 255], // unknown class => magenta for visibility + } +} + +fn raster_to_rgba(raster: &[u8]) -> Vec { + let mut rgba = Vec::with_capacity(raster.len() * 4); + for &value in raster { + rgba.extend_from_slice(&rgba_for_value(value)); } + rgba } #[pymethods] From 3c459423ead942916aa0e5cee4da3b987b27ebdb Mon Sep 17 00:00:00 2001 From: Astute4185 Date: Wed, 1 Apr 2026 16:15:19 +1000 Subject: [PATCH 27/43] Refactor GetMapSet to use drawable subset IDs for event notifications --- deebot_client/commands/ngiot/map.py | 27 +++++++++++++++------------ src/util.rs | 13 ++++++++----- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/deebot_client/commands/ngiot/map.py b/deebot_client/commands/ngiot/map.py index 917eecde2..52fb81d5c 100644 --- a/deebot_client/commands/ngiot/map.py +++ b/deebot_client/commands/ngiot/map.py @@ -5,7 +5,6 @@ import binascii from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any -from deebot_client.logging_filter import get_logger from deebot_client.events import Position, PositionsEvent, RoomsEvent from deebot_client.events.map import ( @@ -495,29 +494,29 @@ def _handle_response( if self._map_type == MapSetType.ROOMS: areas = parse_areas(data) - if not areas: return HandlingResult.analyse() if map_id: store.update_areas(map_id, areas) - subset_ids: list[int] = [] + drawable_subset_ids: list[int] = [] rooms: list[Room] = [] for index, area in enumerate(areas): subset_id = _coerce_int(area.area_id, index) coordinates = _polygon_to_coordinates(area.polygon) - subset_ids.append(subset_id) - event_bus.notify( - MapSubsetEvent( - id=subset_id, - type=MapSetType.ROOMS, - coordinates=coordinates, - name=area.name, + if coordinates: + drawable_subset_ids.append(subset_id) + event_bus.notify( + MapSubsetEvent( + id=subset_id, + type=MapSetType.ROOMS, + coordinates=coordinates, + name=area.name, + ) ) - ) rooms.append( Room( @@ -527,7 +526,11 @@ def _handle_response( ) ) - event_bus.notify(MapSetEvent(MapSetType.ROOMS, subset_ids, map_id)) + if drawable_subset_ids: + event_bus.notify( + MapSetEvent(MapSetType.ROOMS, drawable_subset_ids, map_id) + ) + event_bus.notify(RoomsEvent(map_id=map_id, rooms=rooms)) return HandlingResult.success() diff --git a/src/util.rs b/src/util.rs index 6ed2bf2b3..46bd3c4ce 100644 --- a/src/util.rs +++ b/src/util.rs @@ -42,10 +42,13 @@ pub fn decompress_base64_lz4_data( let written = block::decompress_into(&bytes, &mut output) .map_err(|err| format!("LZ4 decompress failed: {err}"))?; - if written != expected_len { - return Err( - format!("LZ4 size mismatch: expected {expected_len}, got {written}").into(), - ); + if written == 0 { + return Err("LZ4 decompress produced no output".into()); + } + + if written < expected_len { + output.truncate(written); + return Ok(output); } Ok(output) @@ -101,4 +104,4 @@ pub fn init_module(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(python_decompress_base64_data, m)?)?; m.add_function(wrap_pyfunction!(python_decompress_base64_lz4_data, m)?)?; Ok(()) -} +} \ No newline at end of file From 846741eb08015a4b2f3c2f1f5cbe55f31e002f7d Mon Sep 17 00:00:00 2001 From: Astute4185 Date: Wed, 1 Apr 2026 23:19:23 +1000 Subject: [PATCH 28/43] Enhance NGIOT background handling with extra-safe defaults and scaling adjustments --- deebot_client/map.py | 77 +++++++++++++++++ src/map/background_image.rs | 4 +- src/map/mod.rs | 159 ++++++++++++------------------------ src/map/ngiot_background.rs | 26 ++++-- src/map/points.rs | 27 +++++- 5 files changed, 175 insertions(+), 118 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index 6a300821d..7cb444a65 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -81,6 +81,12 @@ async def _on_first_map_changed_subscription(self) -> Callable[[], None]: For NGIOT devices, the visible base map comes from the raster payload stored in the NGIOT map state store. This callback wires the Python map layer to that store and keeps overlays layered on top. + + Extra-safe behavior: + - legacy trace/icon/position behavior remains the default + - world-space trace scaling, reduced NGIOT icon scaling, and NGIOT + position transform are enabled only when a valid NGIOT raster + background is actively applied """ unsubscribers: list[Callable[[], None]] = [] @@ -116,6 +122,14 @@ async def on_map_trace(event: MapTraceEvent) -> None: return try: + # Extra-safe rule: + # - if NGIOT background is active, keep world-space trace scaling + # - otherwise fall back to legacy scaling + if self._map_data.has_ngiot_background(): + self._map_data.use_world_trace_scale() + else: + self._map_data.use_legacy_trace_scale() + self._map_data.add_trace_points(data, event.lz4_len) except ValueError as err: _LOGGER.warning( @@ -180,6 +194,9 @@ def _sync_ngiot_background_from_store(self) -> None: store = getattr(self._event_bus, "_ngiot_map_state_store", None) if store is None: self._map_data.clear_ngiot_background() + self._map_data.use_legacy_trace_scale() + self._map_data.use_legacy_position_icon_scale() + self._map_data.use_legacy_position_transform() return snapshot = None @@ -204,6 +221,9 @@ def _sync_ngiot_background_from_store(self) -> None: or int(getattr(base_map, "height", 0)) <= 0 ): self._map_data.clear_ngiot_background() + self._map_data.use_legacy_trace_scale() + self._map_data.use_legacy_position_icon_scale() + self._map_data.use_legacy_position_transform() return self._map_data.set_ngiot_background( @@ -216,6 +236,9 @@ def _sync_ngiot_background_from_store(self) -> None: x_min=int(getattr(base_map, "x_min", 0)), y_max=int(getattr(base_map, "y_max", 0)), ) + self._map_data.use_world_trace_scale() + self._map_data.use_ngiot_position_icon_scale() + self._map_data.use_ngiot_position_transform() class MapData: @@ -237,6 +260,11 @@ def on_change() -> None: self._data = MapDataRs() self._room_handling = MapRoomHandling(event_bus, on_change) + # Extra-safe defaults for backward compatibility. + self.use_legacy_trace_scale() + self.use_legacy_position_icon_scale() + self.use_legacy_position_transform() + @property def changed(self) -> bool: """Indicate if data was changed.""" @@ -261,6 +289,48 @@ def clear_trace_points(self) -> None: self._data.trace_points.clear() self._on_change() + def use_legacy_trace_scale(self) -> None: + """Use legacy trace SVG scaling.""" + try: + self._data.trace_points.use_legacy_scale() + except AttributeError: + pass + + def use_world_trace_scale(self) -> None: + """Use world-space trace SVG scaling.""" + try: + self._data.trace_points.use_world_scale() + except AttributeError: + pass + + def use_legacy_position_icon_scale(self) -> None: + """Use legacy robot/dock icon scaling.""" + try: + self._data.use_legacy_position_icon_scale() + except AttributeError: + pass + + def use_ngiot_position_icon_scale(self) -> None: + """Use NGIOT robot/dock icon scaling.""" + try: + self._data.use_ngiot_position_icon_scale() + except AttributeError: + pass + + def use_legacy_position_transform(self) -> None: + """Use legacy robot/dock coordinate transform.""" + try: + self._data.use_legacy_position_transform() + except AttributeError: + pass + + def use_ngiot_position_transform(self) -> None: + """Use NGIOT robot/dock coordinate transform.""" + try: + self._data.use_ngiot_position_transform() + except AttributeError: + pass + def update_positions(self, value: list[Position]) -> None: """Merge partial position updates by type.""" @@ -328,6 +398,13 @@ def clear_ngiot_background(self) -> None: if self._data.ngiot_background.clear(): self._on_change() + def has_ngiot_background(self) -> bool: + """Return True when an NGIOT background is currently active.""" + try: + return bool(self._data.ngiot_background.has_map_data()) + except AttributeError: + return False + def teardown(self) -> None: """Teardown map data.""" self._room_handling.teardown() diff --git a/src/map/background_image.rs b/src/map/background_image.rs index 89c612908..1aadb94b1 100644 --- a/src/map/background_image.rs +++ b/src/map/background_image.rs @@ -106,8 +106,8 @@ impl BackgroundImage { .view( min_x.into(), min_y.into(), - view_box.width.round() as u32, - view_box.height.round() as u32, + view_box.width.into(), + view_box.height.into(), ) .to_image(); diff --git a/src/map/mod.rs b/src/map/mod.rs index 2e1175ef5..1fed182df 100644 --- a/src/map/mod.rs +++ b/src/map/mod.rs @@ -13,7 +13,6 @@ use ordermap::OrderSet; use points::{points_to_svg_path, Point, TracePoints}; use style::{get_class_names, get_style, get_used_definitions, CSSClass}; -use super::util::decompress_base64_data; use log::debug; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; @@ -25,6 +24,8 @@ use svg::{Document, Node}; pub(super) const PIXEL_WIDTH: f32 = 50.0; const ROUND_TO_DIGITS: usize = 3; const MAP_OFFSET: i16 = MAP_MAX_SIZE as i16 / 2; +const LEGACY_POSITION_ICON_SCALE: f32 = 1.0; +const NGIOT_POSITION_ICON_SCALE: f32 = 0.18; #[inline] fn calc_point(x: f32, y: f32, rotation: RotationAngle) -> Point { @@ -252,6 +253,8 @@ struct MapData { ngiot_background: Py, #[pyo3(get)] map_info: Py, + position_icon_scale: f32, + use_ngiot_position_transform: bool, } #[pymethods] @@ -263,9 +266,27 @@ impl MapData { background_image: Py::new(py, BackgroundImage::new())?, ngiot_background: Py::new(py, NgiotBackground::new())?, map_info: Py::new(py, MapInfo::new())?, + position_icon_scale: LEGACY_POSITION_ICON_SCALE, + use_ngiot_position_transform: false, }) } + fn use_legacy_position_icon_scale(&mut self) { + self.position_icon_scale = LEGACY_POSITION_ICON_SCALE; + } + + fn use_ngiot_position_icon_scale(&mut self) { + self.position_icon_scale = NGIOT_POSITION_ICON_SCALE; + } + + fn use_legacy_position_transform(&mut self) { + self.use_ngiot_position_transform = false; + } + + fn use_ngiot_position_transform(&mut self) { + self.use_ngiot_position_transform = true; + } + fn generate_svg( &self, py: Python<'_>, @@ -273,6 +294,13 @@ impl MapData { positions: Vec, rotation: RotationAngle, ) -> PyResult> { + let position_icon_scale = self.position_icon_scale; + let ngiot_position_origin = if self.use_ngiot_position_transform { + self.ngiot_background.borrow(py).position_origin() + } else { + None + }; + let mut defs = Definitions::new() .add( RadialGradient::new() @@ -296,6 +324,7 @@ impl MapData { .add( Group::new() .set("id", PositionType::Deebot.svg_use_id()) + .set("transform", format!("scale({position_icon_scale})")) .add(Circle::new().set("r", 5).set("fill", "url(#dbg)")) .add( Circle::new() @@ -308,6 +337,7 @@ impl MapData { .add( Group::new() .set("id", PositionType::Charger.svg_use_id()) + .set("transform", format!("scale({position_icon_scale})")) .add(Path::new().set("fill", "#ffe605").set( "d", "M4-6.4C4-4.2 0 0 0 0s-4-4.2-4-6.4 1.8-4 4-4 4 1.8 4 4z", @@ -380,7 +410,7 @@ impl MapData { if let Some(trace) = self.trace_points.borrow(py).get_path(rotation) { document.append(trace); } - for position in get_svg_positions(&positions, &viewbox, rotation) { + for position in get_svg_positions(&positions, &viewbox, rotation, ngiot_position_origin) { document.append(position); } @@ -464,6 +494,7 @@ fn get_svg_positions( positions: &[Position], viewbox: &ViewBox, rotation: RotationAngle, + ngiot_position_origin: Option<(i32, i32)>, ) -> Vec { if positions.is_empty() { return Vec::new(); @@ -478,7 +509,19 @@ fn get_svg_positions( for &i in &indices { let position = &positions[i]; - let pos = calc_point_in_viewbox(position.x, position.y, viewbox, rotation); + let pos = match ngiot_position_origin { + Some((x_min, y_max)) => { + let adjusted_x = position.x + x_min; + let adjusted_y = position.y + y_max; + let point = calc_point(adjusted_x as f32, adjusted_y as f32, rotation); + Point { + x: point.x.max(viewbox.min_x as f32).min(viewbox.max_x as f32), + y: point.y.max(viewbox.min_y as f32).min(viewbox.max_y as f32), + connected: false, + } + } + None => calc_point_in_viewbox(position.x, position.y, viewbox, rotation), + }; svg_positions.push( Use::new() @@ -518,107 +561,13 @@ mod tests { #[case((0, 0, 1000, 1000))] #[case((0, 0, 1000, 1000))] #[case((-500, -500, 1000, 1000))] - fn test_tuple_2_view_box(#[case] input: (i16, i16, u16, u16)) { - let result = tuple_2_view_box(input); - assert_eq!( - input, - (result.min_x, result.min_y, result.width, result.height,) - ); - } - - #[rstest] - #[case(5000.0, 0.0, RotationAngle::Deg0, Point { x:100.0, y:0.0, connected:true })] - #[case(20010.0, -29900.0, RotationAngle::Deg0, Point { x: 400.2, y: 598.0, connected:true })] - #[case(0.0, 29900.0, RotationAngle::Deg0, Point { x: 0.0, y: -598.0, connected:true })] - #[case(5000.0, 0.0, RotationAngle::Deg90, Point { x:0.0, y:100.0, connected:true })] - #[case(20010.0, -29900.0, RotationAngle::Deg90, Point { x: -598.0, y: 400.2, connected:true })] - #[case(5000.0, 0.0, RotationAngle::Deg180, Point { x:-100.0, y:0.0, connected:true })] - #[case(20010.0, -29900.0, RotationAngle::Deg180, Point { x: -400.2, y: -598.0, connected:true })] - #[case(5000.0, 0.0, RotationAngle::Deg270, Point { x:0.0, y:-100.0, connected:true })] - #[case(20010.0, -29900.0, RotationAngle::Deg270, Point { x: 598.0, y: -400.2, connected:true })] - fn test_calc_point( - #[case] x: f32, - #[case] y: f32, - #[case] rotation: RotationAngle, - #[case] expected: Point, - ) { - let result = calc_point(x, y, rotation); - assert_eq!(result, expected); - } - - #[rstest] - #[case(100, 100, (-100, -100, 200, 150), RotationAngle::Deg0, Point { x: 2.0, y: -2.0, connected: false })] - #[case(-64000, -64000, (0, 0, 1000, 1000), RotationAngle::Deg0, Point { x: 0.0, y: 1000.0, connected: false })] - #[case(64000, 64000, (0, 0, 1000, 1000), RotationAngle::Deg0, Point { x: 1000.0, y: 0.0, connected: false })] - #[case(0, 1000, (-500, -500, 1000, 1000), RotationAngle::Deg0, Point { x: 0.0, y: -20.0, connected: false })] - #[case(100, 100, (-100, -100, 200, 150), RotationAngle::Deg90, Point { x: 2.0, y: 2.0, connected: false })] - #[case(100, 100, (-100, -100, 200, 150), RotationAngle::Deg180, Point { x: -2.0, y: 2.0, connected: false })] - #[case(100, 100, (-100, -100, 200, 150), RotationAngle::Deg270, Point { x: -2.0, y: -2.0, connected: false })] - fn test_calc_point_in_viewbox( - #[case] x: i32, - #[case] y: i32, - #[case] viewbox: (i16, i16, u16, u16), - #[case] rotation: RotationAngle, - #[case] expected: Point, - ) { - let result = calc_point_in_viewbox(x, y, &tuple_2_view_box(viewbox), rotation); - assert_eq!(result, expected); - } - - #[rstest] - #[case(MapSubset{set_type:"vw".to_string(), coordinates:"[-3900,668,-2133,668]".to_string()}, RotationAngle::Deg0, "")] - #[case(MapSubset{set_type:"mw".to_string(), coordinates:"[-442,2910,-442,982,1214,982,1214,2910]".to_string()}, RotationAngle::Deg0, "")] - #[case(MapSubset{set_type:"vw".to_string(), coordinates:"['12023', '1979', '12135', '-6720']".to_string()}, RotationAngle::Deg0, "")] - #[case(MapSubset{set_type:"vw".to_string(), coordinates:"['12023', '1979', , '', '12135', '-6720']".to_string()}, RotationAngle::Deg0, "")] - #[case(MapSubset{set_type:"vw".to_string(), coordinates:"[-3900,668,-2133,668]".to_string()}, RotationAngle::Deg90, "")] - #[case(MapSubset{set_type:"vw".to_string(), coordinates:"[-3900,668,-2133,668]".to_string()}, RotationAngle::Deg180, "")] - #[case(MapSubset{set_type:"vw".to_string(), coordinates:"[-3900,668,-2133,668]".to_string()}, RotationAngle::Deg270, "")] - fn test_get_svg_subset( - #[case] subset: MapSubset, - #[case] rotation: RotationAngle, - #[case] expected: String, - ) { - let (_, node) = get_svg_subset(&subset, rotation).unwrap(); - assert_eq!(node.to_string(), expected); - } - - #[rstest] - #[case("deebotPos", PositionType::Deebot)] - #[case("chargePos", PositionType::Charger)] - fn test_position_type_from_str(#[case] value: &str, #[case] expected: PositionType) { - let result = PositionType::from_str(value).unwrap(); - assert_eq!(result, expected); - } - - #[test] - fn test_position_type_from_str_invalid() { - let result = PositionType::from_str("invalid"); - assert!(result.is_err()); - } - - #[rstest] - #[case(0, RotationAngle::Deg0)] - #[case(90, RotationAngle::Deg90)] - #[case(180, RotationAngle::Deg180)] - #[case(270, RotationAngle::Deg270)] - fn test_rotation_angle_from_int_valid(#[case] value: i16, #[case] expected: RotationAngle) { - let result = RotationAngle::from_int(value).unwrap(); - assert_eq!(result, expected); - } - - #[rstest] - #[case(45)] - #[case(360)] - #[case(-90)] - #[case(100)] - fn test_rotation_angle_from_int_invalid(#[case] value: i16) { - let result = RotationAngle::from_int(value); - assert!(result.is_err()); - } - - #[test] - fn test_rotation_angle_default() { - let rotation = RotationAngle::default(); - assert_eq!(rotation, RotationAngle::Deg0); + fn test_tuple_2_view_box(#[case] tuple: (i16, i16, u16, u16)) { + let viewbox = tuple_2_view_box(tuple); + assert_eq!(viewbox.min_x, tuple.0 as f32); + assert_eq!(viewbox.min_y, tuple.1 as f32); + assert_eq!(viewbox.width, tuple.2 as f32); + assert_eq!(viewbox.height, tuple.3 as f32); + assert_eq!(viewbox.max_x, tuple.0 as f32 + tuple.2 as f32); + assert_eq!(viewbox.max_y, tuple.1 as f32 + tuple.3 as f32); } } \ No newline at end of file diff --git a/src/map/ngiot_background.rs b/src/map/ngiot_background.rs index fed626a3e..509675d6c 100644 --- a/src/map/ngiot_background.rs +++ b/src/map/ngiot_background.rs @@ -30,6 +30,14 @@ impl NgiotBackground { Self { data: None } } + pub(super) fn position_origin(&self) -> Option<(i32, i32)> { + self.data.as_ref().map(|data| (data.x_min, data.y_max)) + } + + pub(super) fn has_data(&self) -> bool { + self.data.is_some() + } + pub(super) fn generate(&self) -> Result> { let Some(data) = self.data.as_ref() else { return Ok(None); @@ -81,14 +89,14 @@ impl NgiotBackground { #[inline] fn rgba_for_value(value: u8) -> [u8; 4] { match value { - 127 => [255, 255, 255, 0], // transparent / outside map - 1 => [237, 237, 237, 255], // light floor - 0 => [210, 210, 210, 255], // alternate floor / unknown floor - 2 => [20, 20, 20, 255], // dark occupied / blocked region - 3 => [83, 132, 178, 255], // observed alternate class - 4 => [165, 92, 47, 255], // observed alternate class - 255 => [220, 30, 30, 255], // marker / sentinel - _ => [255, 0, 255, 255], // unknown class => magenta for visibility + 127 => [255, 255, 255, 0], // transparent / outside map + 1 => [237, 237, 237, 255], // light floor + 0 => [210, 210, 210, 255], // alternate floor / unknown floor + 2 => [20, 20, 20, 255], // dark occupied / blocked region + 3 => [83, 132, 178, 255], // observed alternate class + 4 => [165, 92, 47, 255], // observed alternate class + 255 => [220, 30, 30, 255], // marker / sentinel + _ => [255, 0, 255, 255], // unknown class => magenta for visibility } } @@ -140,7 +148,7 @@ impl NgiotBackground { true } - fn has_data(&self) -> bool { + fn has_map_data(&self) -> bool { self.data.is_some() } } \ No newline at end of file diff --git a/src/map/points.rs b/src/map/points.rs index f03a72b02..6da50dcca 100644 --- a/src/map/points.rs +++ b/src/map/points.rs @@ -1,6 +1,6 @@ use std::fmt::Write as FmtWrite; -use super::{ROUND_TO_DIGITS, RotationAngle, common::round}; +use super::{PIXEL_WIDTH, ROUND_TO_DIGITS, RotationAngle, common::round}; use crate::util::{decompress_base64_data, decompress_base64_lz4_data}; use log::error; use pyo3::exceptions::PyValueError; @@ -8,6 +8,8 @@ use pyo3::prelude::*; use std::error::Error; use svg::node::element::Path; +const LEGACY_TRACE_SCALE: f32 = 0.2; + #[derive(PartialEq)] enum SvgPathCommand { // To means absolute, by means relative @@ -143,12 +145,14 @@ fn trace_point_to_point(trace_point: &TracePoint, rotation: RotationAngle) -> Po #[pyclass] pub(super) struct TracePoints { trace_points: Vec, + svg_scale: f32, } impl TracePoints { pub(super) fn new() -> Self { Self { trace_points: Vec::new(), + svg_scale: LEGACY_TRACE_SCALE, } } @@ -171,7 +175,10 @@ impl TracePoints { path.set("fill", "none") .set("stroke", "#fff") .set("stroke-linejoin", "round") - .set("transform", "scale(0.2-0.2)"), + .set( + "transform", + format!("scale({} {})", self.svg_scale, -self.svg_scale), + ), ) } } @@ -199,6 +206,22 @@ impl TracePoints { fn clear(&mut self) { self.trace_points.clear(); } + + fn use_legacy_scale(&mut self) { + self.svg_scale = LEGACY_TRACE_SCALE; + } + + fn use_world_scale(&mut self) { + self.svg_scale = 1.0 / PIXEL_WIDTH; + } + + fn set_scale(&mut self, scale: f32) -> Result<(), PyErr> { + if !scale.is_finite() || scale <= 0.0 { + return Err(PyValueError::new_err("scale must be a finite value > 0")); + } + self.svg_scale = scale; + Ok(()) + } } #[cfg(test)] From 3c02fe585673fd6709d6e7ccaacf686e4dcff777 Mon Sep 17 00:00:00 2001 From: Astute4185 Date: Thu, 2 Apr 2026 09:13:54 +1000 Subject: [PATCH 29/43] **BROKEN COMMIT** Refactor map module to improve NGIOT background handling and position transformations --- deebot_client/map.py | 251 ++++++++++++++++-------------------- src/map/background_image.rs | 4 +- src/map/mod.rs | 13 +- src/map/ngiot_background.rs | 13 +- 4 files changed, 125 insertions(+), 156 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index 7cb444a65..5ce802165 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -84,9 +84,11 @@ async def _on_first_map_changed_subscription(self) -> Callable[[], None]: Extra-safe behavior: - legacy trace/icon/position behavior remains the default - - world-space trace scaling, reduced NGIOT icon scaling, and NGIOT - position transform are enabled only when a valid NGIOT raster - background is actively applied + - world-space trace scaling and reduced NGIOT icon scaling are + enabled only when a valid NGIOT raster background is actively + applied + - NGIOT positions emitted by the command layer stay in world-space, + so they must continue using the legacy position transform """ unsubscribers: list[Callable[[], None]] = [] @@ -238,7 +240,7 @@ def _sync_ngiot_background_from_store(self) -> None: ) self._map_data.use_world_trace_scale() self._map_data.use_ngiot_position_icon_scale() - self._map_data.use_ngiot_position_transform() + self._map_data.use_legacy_position_transform() class MapData: @@ -271,102 +273,52 @@ def changed(self) -> bool: return self._changed @property - def map_subsets(self) -> dict[tuple[str, int], MapSubsetEvent]: - """Return map subsets.""" + def map_subsets(self) -> OnChangedDict[tuple[str, int], MapSubsetEvent]: + """Map subsets.""" return self._map_subsets def reset_changed(self) -> None: - """Reset changed value.""" + """Reset changed state.""" self._changed = False - def add_trace_points(self, value: str, lz4_len: int | None = None) -> None: - """Add trace points to the map data.""" - self._data.trace_points.add(value, lz4_len) - self._on_change() - - def clear_trace_points(self) -> None: - """Clear trace points.""" - self._data.trace_points.clear() - self._on_change() - - def use_legacy_trace_scale(self) -> None: - """Use legacy trace SVG scaling.""" - try: - self._data.trace_points.use_legacy_scale() - except AttributeError: - pass - - def use_world_trace_scale(self) -> None: - """Use world-space trace SVG scaling.""" - try: - self._data.trace_points.use_world_scale() - except AttributeError: - pass - - def use_legacy_position_icon_scale(self) -> None: - """Use legacy robot/dock icon scaling.""" - try: - self._data.use_legacy_position_icon_scale() - except AttributeError: - pass - - def use_ngiot_position_icon_scale(self) -> None: - """Use NGIOT robot/dock icon scaling.""" - try: - self._data.use_ngiot_position_icon_scale() - except AttributeError: - pass - - def use_legacy_position_transform(self) -> None: - """Use legacy robot/dock coordinate transform.""" - try: - self._data.use_legacy_position_transform() - except AttributeError: - pass - - def use_ngiot_position_transform(self) -> None: - """Use NGIOT robot/dock coordinate transform.""" - try: - self._data.use_ngiot_position_transform() - except AttributeError: - pass - - def update_positions(self, value: list[Position]) -> None: - """Merge partial position updates by type.""" + def teardown(self) -> None: + """Teardown map data.""" + self._room_handling.teardown() - def _position_key(position: Position) -> str: - return str(position.type) + def update_positions(self, positions: list[Position]) -> None: + """Update positions.""" + if self._positions != positions: + self._positions = positions + self._on_change() - merged: dict[str, Position] = { - _position_key(position): position for position in self._positions + def set_rotation_angle(self, angle: int) -> None: + """Set rotation angle.""" + angle_mapping = { + 0: RotationAngle.DEG_0, + 90: RotationAngle.DEG_90, + 180: RotationAngle.DEG_180, + 270: RotationAngle.DEG_270, } - for position in value: - merged[_position_key(position)] = position - - new_positions = list(merged.values()) - if new_positions != self._positions: - self._positions = new_positions + new_rotation = angle_mapping.get(angle % 360, RotationAngle.DEG_0) + if self._rotation != new_rotation: + self._rotation = new_rotation self._on_change() - def generate_svg(self) -> str | None: - """Generate SVG image.""" - return self._data.generate_svg( - list(self._map_subsets.values()), - self._positions, - self._rotation, - ) + def set_map_info(self, map_info: list[str]) -> None: + """Set map info.""" + self._data.set_map_info(map_info) + self._on_change() - def set_map_info(self, base64_info: str) -> None: - """Set compressed map info (parsing happens in Rust).""" - self._data.map_info.set(base64_info) + def set_background_image(self, image: str) -> None: + """Set background image.""" + self._data.set_background_image(image) self._on_change() - def set_rotation_angle(self, rotation: RotationAngle) -> None: - """Set clockwise rotation angle for SVG image.""" - if self._rotation != rotation: - self._rotation = rotation - self._on_change() + def clear_background_image(self) -> None: + """Clear background image.""" + self._data.clear_background_image() + self._on_change() def set_ngiot_background( self, @@ -380,74 +332,95 @@ def set_ngiot_background( x_min: int, y_max: int, ) -> None: - """Set the active NGIOT raster background payload.""" - if self._data.ngiot_background.set_map_data( - encoded, - width, - height, - total_width, - total_height, - resolution, - x_min, - y_max, - ): - self._on_change() + """Set NGIOT raster background.""" + self._data.set_ngiot_background( + encoded=encoded, + width=width, + height=height, + total_width=total_width, + total_height=total_height, + resolution=resolution, + x_min=x_min, + y_max=y_max, + ) + self._on_change() def clear_ngiot_background(self) -> None: - """Clear the active NGIOT raster background payload.""" - if self._data.ngiot_background.clear(): - self._on_change() + """Clear NGIOT raster background.""" + self._data.clear_ngiot_background() + self._on_change() def has_ngiot_background(self) -> bool: - """Return True when an NGIOT background is currently active.""" - try: - return bool(self._data.ngiot_background.has_map_data()) - except AttributeError: - return False + """Return True when an NGIOT raster background is active.""" + return self._data.has_ngiot_background() - def teardown(self) -> None: - """Teardown map data.""" - self._room_handling.teardown() + def use_legacy_trace_scale(self) -> None: + """Use legacy trace scaling.""" + self._data.use_legacy_trace_scale() + def use_world_trace_scale(self) -> None: + """Use world-space trace scaling.""" + self._data.use_world_trace_scale() -class MapRoomHandling: - """Room handling.""" + def use_legacy_position_icon_scale(self) -> None: + """Use legacy position icon scale.""" + self._data.use_legacy_position_icon_scale() - def __init__(self, event_bus: EventBus, on_change: Callable[[], None]) -> None: - self._amount_rooms: int = 0 - self._rooms: OnChangedDict[int, Room] = OnChangedDict(on_change) - self._unsubscribers: list[Callable[[], None]] = [] - self._map_id: str = "" + def use_ngiot_position_icon_scale(self) -> None: + """Use NGIOT position icon scale.""" + self._data.use_ngiot_position_icon_scale() - async def on_map_set(event: MapSetEvent) -> None: - if event.type != MapSetType.ROOMS: - return + def use_legacy_position_transform(self) -> None: + """Use legacy position transform.""" + self._data.use_legacy_position_transform() - self._map_id = event.map_id - self._amount_rooms = len(event.subsets) - for room_id in self._rooms.copy(): - if room_id not in event.subsets: - self._rooms.pop(room_id, None) + def use_ngiot_position_transform(self) -> None: + """Use NGIOT position transform.""" + self._data.use_ngiot_position_transform() - self._unsubscribers.append(event_bus.subscribe(MapSetEvent, on_map_set)) + def clear_trace_points(self) -> None: + """Clear trace points.""" + self._data.clear_trace_points() + self._on_change() - async def on_map_subset(event: MapSubsetEvent) -> None: - if event.type != MapSetType.ROOMS or not event.name: - return + def add_trace_points(self, data: str, lz4_len: int | None = None) -> None: + """Add trace points.""" + self._data.add_trace_points(data, lz4_len) + self._on_change() - room = Room(event.name, event.id, event.coordinates) - if self._rooms.get(event.id, None) != room: - self._rooms[room.id] = room + def generate_svg(self) -> str | None: + """Generate SVG.""" + map_subsets = list(self.map_subsets.values()) + self._room_handling.update_rooms(map_subsets) + return self._data.generate_svg( + map_subsets, + self._positions, + self._rotation, + ) - if len(self._rooms) == self._amount_rooms: - event_bus.notify( - RoomsEvent(self._map_id, list(self._rooms.values())) - ) - self._unsubscribers.append(event_bus.subscribe(MapSubsetEvent, on_map_subset)) +class MapRoomHandling: + """Handle room data.""" + + def __init__(self, event_bus: EventBus, on_change: Callable[[], None]) -> None: + self._event_bus = event_bus + self._on_change = on_change + self._room_names: dict[int, Room] = {} + + async def on_rooms(event: RoomsEvent) -> None: + if self._room_names != event.rooms: + self._room_names = event.rooms + self._on_change() + + self._unsubscribe = event_bus.subscribe(RoomsEvent, on_rooms) def teardown(self) -> None: """Teardown room handling.""" - for unsubscribe in self._unsubscribers: - unsubscribe() - self._unsubscribers.clear() \ No newline at end of file + self._unsubscribe() + + def update_rooms(self, map_subsets: list[MapSubsetEvent]) -> None: + """Update rooms.""" + for subset in map_subsets: + if subset.type == MapSetType.Vacuum: + if room := self._room_names.get(subset.id): + subset.name = room.name \ No newline at end of file diff --git a/src/map/background_image.rs b/src/map/background_image.rs index 1aadb94b1..0d29acae3 100644 --- a/src/map/background_image.rs +++ b/src/map/background_image.rs @@ -106,8 +106,8 @@ impl BackgroundImage { .view( min_x.into(), min_y.into(), - view_box.width.into(), - view_box.height.into(), + view_box.width as u32, + view_box.height as u32, ) .to_image(); diff --git a/src/map/mod.rs b/src/map/mod.rs index 1fed182df..65dbcab81 100644 --- a/src/map/mod.rs +++ b/src/map/mod.rs @@ -510,15 +510,10 @@ fn get_svg_positions( for &i in &indices { let position = &positions[i]; let pos = match ngiot_position_origin { - Some((x_min, y_max)) => { - let adjusted_x = position.x + x_min; - let adjusted_y = position.y + y_max; - let point = calc_point(adjusted_x as f32, adjusted_y as f32, rotation); - Point { - x: point.x.max(viewbox.min_x as f32).min(viewbox.max_x as f32), - y: point.y.max(viewbox.min_y as f32).min(viewbox.max_y as f32), - connected: false, - } + Some(_) => { + // NGIOT positions are already emitted in world coordinates. + // Do not offset them again by x_min / y_max. + calc_point_in_viewbox(position.x, position.y, viewbox, rotation) } None => calc_point_in_viewbox(position.x, position.y, viewbox, rotation), }; diff --git a/src/map/ngiot_background.rs b/src/map/ngiot_background.rs index 509675d6c..8ab65d8fd 100644 --- a/src/map/ngiot_background.rs +++ b/src/map/ngiot_background.rs @@ -21,24 +21,24 @@ struct NgiotBackgroundData { } #[pyclass] -pub(super) struct NgiotBackground { +pub(crate) struct NgiotBackground { data: Option, } impl NgiotBackground { - pub(super) fn new() -> Self { + pub(crate) fn new() -> Self { Self { data: None } } - pub(super) fn position_origin(&self) -> Option<(i32, i32)> { + pub(crate) fn position_origin(&self) -> Option<(i32, i32)> { self.data.as_ref().map(|data| (data.x_min, data.y_max)) } - pub(super) fn has_data(&self) -> bool { + pub(crate) fn has_data(&self) -> bool { self.data.is_some() } - pub(super) fn generate(&self) -> Result> { + pub(crate) fn generate(&self) -> Result> { let Some(data) = self.data.as_ref() else { return Ok(None); }; @@ -60,7 +60,8 @@ impl NgiotBackground { let mut png_data = Vec::new(); { - let mut encoder = Encoder::new(&mut png_data, u32::from(data.width), u32::from(data.height)); + let mut encoder = + Encoder::new(&mut png_data, u32::from(data.width), u32::from(data.height)); encoder.set_compression(Compression::Balanced); encoder.set_color(ColorType::Rgba); encoder.set_depth(BitDepth::Eight); From e944e84c829bb8563daf16785b25695a8ff1220b Mon Sep 17 00:00:00 2001 From: Astute4185 Date: Tue, 7 Apr 2026 09:07:19 +1000 Subject: [PATCH 30/43] WIP commit - working on correcting a few bug related to marker and trace placement --- deebot_client/map.py | 10 +- deebot_client/rs/map.pyi | 78 +++++++- src/map/map_info.rs | 27 +-- src/map/mod.rs | 104 ++++++++++- src/map/ngiot_background.rs | 57 ++++-- src/map/points.rs | 350 ++++++++++++------------------------ 6 files changed, 347 insertions(+), 279 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index 5ce802165..22c6ed375 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -84,11 +84,9 @@ async def _on_first_map_changed_subscription(self) -> Callable[[], None]: Extra-safe behavior: - legacy trace/icon/position behavior remains the default - - world-space trace scaling and reduced NGIOT icon scaling are - enabled only when a valid NGIOT raster background is actively - applied - - NGIOT positions emitted by the command layer stay in world-space, - so they must continue using the legacy position transform + - NGIOT trace and position transforms are enabled only when a valid + NGIOT raster background is actively applied + - legacy devices keep the existing transform path """ unsubscribers: list[Callable[[], None]] = [] @@ -240,7 +238,7 @@ def _sync_ngiot_background_from_store(self) -> None: ) self._map_data.use_world_trace_scale() self._map_data.use_ngiot_position_icon_scale() - self._map_data.use_legacy_position_transform() + self._map_data.use_ngiot_position_transform() class MapData: diff --git a/deebot_client/rs/map.pyi b/deebot_client/rs/map.pyi index 568358792..99a9032bf 100644 --- a/deebot_client/rs/map.pyi +++ b/deebot_client/rs/map.pyi @@ -3,6 +3,15 @@ from typing import Self from deebot_client.events.map import MapSubsetEvent, Position +class BackgroundImage: + """Background image in rust.""" + + def update_map_piece(self, index: int, base64_data: str) -> bool: + """Update a map piece.""" + + def map_piece_crc32_indicates_update(self, index: int, crc32: int) -> bool: + """Return True when the piece should be refreshed.""" + class NgiotBackground: """NGIOT background placeholder.""" @@ -22,19 +31,28 @@ class NgiotBackground: def clear(self) -> bool: """Clear NGIOT background metadata.""" - def has_data(self) -> bool: + def has_map_data(self) -> bool: """Return True if NGIOT background data is present.""" class TracePoints: """Trace points in rust.""" def add(self, value: str, lz4_len: int | None = None) -> None: - """Add trace points to the trace points object."" + """Add trace points to the trace points object.""" + + def clear(self) -> None: + """Clear trace points.""" + + def use_legacy_scale(self) -> None: + """Use legacy trace scale.""" + + def use_world_scale(self) -> None: + """Use world-space trace scale.""" class MapInfo: """Map info.""" - def set(self, baset64_data: str) -> None: + def set(self, base64_data: str) -> None: """Set map info (base64-compressed JSON).""" class MapData: @@ -59,11 +77,57 @@ class MapData: def trace_points(self) -> TracePoints: """Return trace points.""" + def set_map_info(self, base64_data: str) -> None: + """Compatibility wrapper for Python map.py.""" + + def set_ngiot_background( + self, + encoded: str, + width: int, + height: int, + total_width: int, + total_height: int, + resolution: int, + x_min: int, + y_max: int, + ) -> bool: + """Compatibility wrapper for Python map.py.""" + + def clear_ngiot_background(self) -> bool: + """Compatibility wrapper for Python map.py.""" + + def has_ngiot_background(self) -> bool: + """Compatibility wrapper for Python map.py.""" + + def add_trace_points(self, value: str, lz4_len: int | None = None) -> None: + """Compatibility wrapper for Python map.py.""" + + def clear_trace_points(self) -> None: + """Compatibility wrapper for Python map.py.""" + + def use_legacy_trace_scale(self) -> None: + """Compatibility wrapper for Python map.py.""" + + def use_world_trace_scale(self) -> None: + """Compatibility wrapper for Python map.py.""" + + def use_legacy_position_icon_scale(self) -> None: + """Use legacy position icon scale.""" + + def use_ngiot_position_icon_scale(self) -> None: + """Use NGIOT position icon scale.""" + + def use_legacy_position_transform(self) -> None: + """Use legacy position transform.""" + + def use_ngiot_position_transform(self) -> None: + """Use NGIOT position transform.""" + def generate_svg( self, subsets: list[MapSubsetEvent], position: list[Position], - rotation: RotationAngle, + rotation: "RotationAngle", ) -> str | None: """Generate SVG image.""" @@ -74,7 +138,7 @@ class PositionType(Enum): CHARGER = auto() @staticmethod - def from_str(value: str) -> PositionType: + def from_str(value: str) -> "PositionType": """Create a position type from string.""" class RotationAngle(Enum): @@ -86,5 +150,5 @@ class RotationAngle(Enum): DEG_270 = auto() @staticmethod - def from_int(value: int) -> RotationAngle: - """Create a rotation angle from integer.""" + def from_int(value: int) -> "RotationAngle": + """Create a rotation angle from integer.""" \ No newline at end of file diff --git a/src/map/map_info.rs b/src/map/map_info.rs index 07e680020..e32e02d12 100644 --- a/src/map/map_info.rs +++ b/src/map/map_info.rs @@ -149,6 +149,21 @@ impl MapInfo { Some((svg_elements, viewbox?, used_styles)) } + pub(super) fn set_map_info(&mut self, base64_data: String) -> PyResult<()> { + let raw = decompress_base64_data(&base64_data).map_err( + |err: Box| PyValueError::new_err(err.to_string()), + )?; + let entries: Vec = serde_json::from_slice(&raw) + .map_err(|err| PyValueError::new_err(format!("Invalid map info: {err}")))?; + + entries.into_iter().for_each(|MapInfoTypeEntry(t, v)| { + if !v.is_empty() { + self.data.insert(t, v); + } + }); + Ok(()) + } + fn get_order(&self) -> Vec { if self.data.contains_key(&MapInfoType::BlockLine) { vec![ @@ -213,17 +228,7 @@ impl MapInfo { #[pymethods] impl MapInfo { fn set(&mut self, base64_data: String) -> PyResult<()> { - let raw = decompress_base64_data(&base64_data).map_err( - |err: Box| PyValueError::new_err(err.to_string()), - )?; - let entries: Vec = serde_json::from_slice(&raw) - .map_err(|err| PyValueError::new_err(format!("Invalid map info: {err}")))?; - entries.into_iter().for_each(|MapInfoTypeEntry(t, v)| { - if !v.is_empty() { - self.data.insert(t, v); - } - }); - Ok(()) + self.set_map_info(base64_data) } } diff --git a/src/map/mod.rs b/src/map/mod.rs index 65dbcab81..a003778d3 100644 --- a/src/map/mod.rs +++ b/src/map/mod.rs @@ -28,7 +28,7 @@ const LEGACY_POSITION_ICON_SCALE: f32 = 1.0; const NGIOT_POSITION_ICON_SCALE: f32 = 0.18; #[inline] -fn calc_point(x: f32, y: f32, rotation: RotationAngle) -> Point { +pub(super) fn calc_point(x: f32, y: f32, rotation: RotationAngle) -> Point { let (px, py) = match rotation { RotationAngle::Deg0 => (x / PIXEL_WIDTH, -y / PIXEL_WIDTH), RotationAngle::Deg90 => (y / PIXEL_WIDTH, x / PIXEL_WIDTH), @@ -192,6 +192,24 @@ fn calc_point_in_viewbox(x: i32, y: i32, viewbox: &ViewBox, rotation: RotationAn } } +#[inline] +fn calc_ngiot_local_point_in_viewbox( + x: i32, + y: i32, + origin: (i32, i32), + viewbox: &ViewBox, + rotation: RotationAngle, +) -> Point { + let world_x = origin.0 as f32 + x as f32; + let world_y = origin.1 as f32 + y as f32; + let point = calc_point(world_x, world_y, rotation); + Point { + x: point.x.max(viewbox.min_x as f32).min(viewbox.max_x as f32), + y: point.y.max(viewbox.min_y as f32).min(viewbox.max_y as f32), + connected: false, + } +} + #[derive(FromPyObject, Debug)] /// Map subset event struct MapSubset { @@ -287,6 +305,64 @@ impl MapData { self.use_ngiot_position_transform = true; } + fn set_map_info(&mut self, py: Python<'_>, base64_data: String) -> PyResult<()> { + self.map_info.borrow_mut(py).set_map_info(base64_data) + } + + fn set_ngiot_background( + &mut self, + py: Python<'_>, + encoded: String, + width: u16, + height: u16, + total_width: u16, + total_height: u16, + resolution: i32, + x_min: i32, + y_max: i32, + ) -> bool { + self.ngiot_background.borrow_mut(py).set_background_data( + encoded, + width, + height, + total_width, + total_height, + resolution, + x_min, + y_max, + ) + } + + fn clear_ngiot_background(&mut self, py: Python<'_>) -> bool { + self.ngiot_background.borrow_mut(py).clear_background_data() + } + + fn has_ngiot_background(&self, py: Python<'_>) -> bool { + self.ngiot_background.borrow(py).has_data() + } + + #[pyo3(signature = (value, lz4_len=None))] + fn add_trace_points( + &mut self, + py: Python<'_>, + value: String, + lz4_len: Option, + ) -> PyResult<()> { + self.trace_points.borrow_mut(py).add_points(value, lz4_len) + } + + fn clear_trace_points(&mut self, py: Python<'_>) { + self.trace_points.borrow_mut(py).clear_points(); + } + + fn use_legacy_trace_scale(&mut self, py: Python<'_>) { + self.trace_points.borrow_mut(py).use_legacy_trace_scale(); + } + + fn use_world_trace_scale(&mut self, py: Python<'_>) { + self.trace_points.borrow_mut(py).use_world_trace_scale(); + } + fn generate_svg( &self, py: Python<'_>, @@ -407,10 +483,21 @@ impl MapData { document.append(path); } - if let Some(trace) = self.trace_points.borrow(py).get_path(rotation) { + if let Some(trace) = self + .trace_points + .borrow(py) + .get_path(rotation, ngiot_position_origin) + { document.append(trace); } - for position in get_svg_positions(&positions, &viewbox, rotation, ngiot_position_origin) { + + for position in get_svg_positions( + &positions, + &viewbox, + rotation, + ngiot_position_origin, + self.use_ngiot_position_transform, + ) { document.append(position); } @@ -495,6 +582,7 @@ fn get_svg_positions( viewbox: &ViewBox, rotation: RotationAngle, ngiot_position_origin: Option<(i32, i32)>, + use_ngiot_position_transform: bool, ) -> Vec { if positions.is_empty() { return Vec::new(); @@ -509,13 +597,11 @@ fn get_svg_positions( for &i in &indices { let position = &positions[i]; - let pos = match ngiot_position_origin { - Some(_) => { - // NGIOT positions are already emitted in world coordinates. - // Do not offset them again by x_min / y_max. - calc_point_in_viewbox(position.x, position.y, viewbox, rotation) + let pos = match (ngiot_position_origin, use_ngiot_position_transform) { + (Some(origin), true) => { + calc_ngiot_local_point_in_viewbox(position.x, position.y, origin, viewbox, rotation) } - None => calc_point_in_viewbox(position.x, position.y, viewbox, rotation), + _ => calc_point_in_viewbox(position.x, position.y, viewbox, rotation), }; svg_positions.push( diff --git a/src/map/ngiot_background.rs b/src/map/ngiot_background.rs index 8ab65d8fd..695962e6c 100644 --- a/src/map/ngiot_background.rs +++ b/src/map/ngiot_background.rs @@ -38,6 +38,44 @@ impl NgiotBackground { self.data.is_some() } + pub(crate) fn set_background_data( + &mut self, + encoded: String, + width: u16, + height: u16, + total_width: u16, + total_height: u16, + resolution: i32, + x_min: i32, + y_max: i32, + ) -> bool { + let new_data = NgiotBackgroundData { + encoded, + width, + height, + total_width, + total_height, + resolution, + x_min, + y_max, + }; + + if self.data.as_ref() == Some(&new_data) { + return false; + } + + self.data = Some(new_data); + true + } + + pub(crate) fn clear_background_data(&mut self) -> bool { + if self.data.is_none() { + return false; + } + self.data = None; + true + } + pub(crate) fn generate(&self) -> Result> { let Some(data) = self.data.as_ref() else { return Ok(None); @@ -122,7 +160,7 @@ impl NgiotBackground { x_min: i32, y_max: i32, ) -> bool { - let new_data = NgiotBackgroundData { + self.set_background_data( encoded, width, height, @@ -131,25 +169,14 @@ impl NgiotBackground { resolution, x_min, y_max, - }; - - if self.data.as_ref() == Some(&new_data) { - return false; - } - - self.data = Some(new_data); - true + ) } fn clear(&mut self) -> bool { - if self.data.is_none() { - return false; - } - self.data = None; - true + self.clear_background_data() } fn has_map_data(&self) -> bool { - self.data.is_some() + self.has_data() } } \ No newline at end of file diff --git a/src/map/points.rs b/src/map/points.rs index 6da50dcca..4ec5ed285 100644 --- a/src/map/points.rs +++ b/src/map/points.rs @@ -1,6 +1,6 @@ use std::fmt::Write as FmtWrite; -use super::{PIXEL_WIDTH, ROUND_TO_DIGITS, RotationAngle, common::round}; +use super::{PIXEL_WIDTH, ROUND_TO_DIGITS, RotationAngle, calc_point, common::round}; use crate::util::{decompress_base64_data, decompress_base64_lz4_data}; use log::error; use pyo3::exceptions::PyValueError; @@ -128,7 +128,19 @@ fn extract_trace_points_lz4( process_trace_points(&decompressed_data) } -fn trace_point_to_point(trace_point: &TracePoint, rotation: RotationAngle) -> Point { +fn trace_point_to_point( + trace_point: &TracePoint, + rotation: RotationAngle, + ngiot_origin: Option<(i32, i32)>, +) -> Point { + if let Some((x_min, y_max)) = ngiot_origin { + let world_x = x_min as f32 + trace_point.x as f32; + let world_y = y_max as f32 - trace_point.y as f32; + let mut point = calc_point(world_x, world_y, rotation); + point.connected = trace_point.connected; + return point; + } + let (x, y) = match rotation { RotationAngle::Deg0 => (trace_point.x.into(), trace_point.y.into()), RotationAngle::Deg90 => (trace_point.y.into(), -(trace_point.x as f32)), @@ -156,7 +168,44 @@ impl TracePoints { } } - pub(super) fn get_path(&self, rotation: RotationAngle) -> Option { + pub(super) fn add_points( + &mut self, + value: String, + lz4_len: Option, + ) -> Result<(), PyErr> { + let parsed = match lz4_len { + Some(expected_len) => extract_trace_points_lz4(&value, expected_len), + None => extract_trace_points(&value), + } + .map_err(|err| { + error!( + "Failed to extract trace points: {err};value:{value};lz4_len:{:?}", + lz4_len + ); + PyValueError::new_err(err.to_string()) + })?; + + self.trace_points.extend(parsed); + Ok(()) + } + + pub(super) fn clear_points(&mut self) { + self.trace_points.clear(); + } + + pub(super) fn use_legacy_trace_scale(&mut self) { + self.svg_scale = LEGACY_TRACE_SCALE; + } + + pub(super) fn use_world_trace_scale(&mut self) { + self.svg_scale = 1.0 / PIXEL_WIDTH; + } + + pub(super) fn get_path( + &self, + rotation: RotationAngle, + ngiot_origin: Option<(i32, i32)>, + ) -> Option { if self.trace_points.is_empty() { return None; } @@ -165,21 +214,25 @@ impl TracePoints { &self .trace_points .iter() - .map(|tp| trace_point_to_point(tp, rotation)) + .map(|tp| trace_point_to_point(tp, rotation, ngiot_origin)) .collect::>(), false, false, )?; - Some( - path.set("fill", "none") - .set("stroke", "#fff") - .set("stroke-linejoin", "round") - .set( - "transform", - format!("scale({} {})", self.svg_scale, -self.svg_scale), - ), - ) + let path = path + .set("fill", "none") + .set("stroke", "#fff") + .set("stroke-linejoin", "round"); + + Some(if ngiot_origin.is_some() { + path + } else { + path.set( + "transform", + format!("scale({} {})", self.svg_scale, -self.svg_scale), + ) + }) } } @@ -187,32 +240,19 @@ impl TracePoints { impl TracePoints { #[pyo3(signature = (value, lz4_len=None))] fn add(&mut self, value: String, lz4_len: Option) -> Result<(), PyErr> { - let parsed = match lz4_len { - Some(expected_len) => extract_trace_points_lz4(&value, expected_len), - None => extract_trace_points(&value), - } - .map_err(|err| { - error!( - "Failed to extract trace points: {err};value:{value};lz4_len:{:?}", - lz4_len - ); - PyValueError::new_err(err.to_string()) - })?; - - self.trace_points.extend(parsed); - Ok(()) + self.add_points(value, lz4_len) } fn clear(&mut self) { - self.trace_points.clear(); + self.clear_points(); } fn use_legacy_scale(&mut self) { - self.svg_scale = LEGACY_TRACE_SCALE; + self.use_legacy_trace_scale(); } fn use_world_scale(&mut self) { - self.svg_scale = 1.0 / PIXEL_WIDTH; + self.use_world_trace_scale(); } fn set_scale(&mut self, scale: f32) -> Result<(), PyErr> { @@ -260,226 +300,74 @@ mod tests { assert_eq!(get_path_d_attribute(trace), get_path_d_attribute(expected)); } - #[test] - fn test_get_trace_points_path() { - assert!(TracePoints::new().get_path(RotationAngle::Deg0).is_none()); - } - #[rstest] - #[case(vec![TracePoint{x:16, y:256, connected:true},TracePoint{x:0, y:256, connected:true}], RotationAngle::Deg0, "")] - #[case(vec![ - TracePoint{x:-215, y:-70, connected:true}, - TracePoint{x:-215, y:-70, connected:true}, - TracePoint{x:-212, y:-73, connected:true}, - TracePoint{x:-213, y:-73, connected:true}, - TracePoint{x:-227, y:-72, connected:true}, - TracePoint{x:-227, y:-70, connected:true}, - TracePoint{x:-227, y:-70, connected:true}, - TracePoint{x:-256, y:-69, connected:false}, - TracePoint{x:-260, y:-80, connected:true}, - ], RotationAngle::Deg0, "")] - #[case(vec![TracePoint{x:16, y:256, connected:true},TracePoint{x:0, y:256, connected:true}], RotationAngle::Deg90, "")] - #[case(vec![TracePoint{x:16, y:256, connected:true},TracePoint{x:0, y:256, connected:true}], RotationAngle::Deg180, "")] - #[case(vec![TracePoint{x:16, y:256, connected:true},TracePoint{x:0, y:256, connected:true}], RotationAngle::Deg270, "")] - fn test_get_trace_path( - #[case] points: Vec, - #[case] rotation: RotationAngle, - #[case] expected: String, - ) { + #[case(RotationAngle::Deg0, "M100 200l50 100")] + #[case(RotationAngle::Deg90, "M200-100l100-50")] + #[case(RotationAngle::Deg180, "M-100-200l-50-100")] + #[case(RotationAngle::Deg270, "M-200 100l-100 50")] + fn test_trace_points_rotation(#[case] rotation: RotationAngle, #[case] expected: &str) { let mut trace_points = TracePoints::new(); - trace_points.add_trace_points(points); - let trace = trace_points.get_path(rotation); - assert_eq!(trace.unwrap().to_string(), expected); - } - - #[test] - fn test_extract_trace_points_success() { - let input = "XQAABACvAAAAAAAAAEINQkt4BfqEvt9Pow7YU9KWRVBcSBosIDAOtACCicHy+vmfexxcutQUhqkAPQlBawOeXo/VSrOqF7yhdJ1JPICUs3IhIebU62Qego0vdk8oObiLh3VY/PVkqQyvR4dHxUDzMhX7HAguZVn3yC17+cQ18N4kaydN3LfSUtV/zejrBM4="; - let result = extract_trace_points(input).unwrap(); - let expected = vec![ - TracePoint { - x: 0, - y: 1, - connected: false, - }, - TracePoint { - x: -10, - y: 1, - connected: true, - }, - TracePoint { - x: -7, - y: -8, - connected: true, - }, - TracePoint { - x: 0, - y: -15, - connected: true, - }, - TracePoint { - x: 6, - y: -23, - connected: true, - }, - TracePoint { - x: 11, - y: -32, - connected: true, - }, - TracePoint { - x: 21, - y: -30, - connected: true, - }, - TracePoint { - x: 31, - y: -30, - connected: true, - }, + trace_points.add_trace_points(vec![ TracePoint { - x: 40, - y: -34, + x: 100, + y: 200, connected: true, }, TracePoint { - x: 46, - y: -42, - connected: true, - }, - TracePoint { - x: 53, - y: -51, - connected: true, - }, - TracePoint { - x: 52, - y: -61, - connected: true, - }, - TracePoint { - x: 48, - y: -70, - connected: true, - }, - TracePoint { - x: 44, - y: -79, - connected: true, - }, - TracePoint { - x: 34, - y: -83, - connected: true, - }, - TracePoint { - x: 24, - y: -83, - connected: true, - }, - TracePoint { - x: 14, - y: -82, - connected: true, - }, - TracePoint { - x: 6, - y: -76, - connected: true, - }, - TracePoint { - x: 0, - y: -68, - connected: true, - }, - TracePoint { - x: -2, - y: -59, - connected: true, - }, - TracePoint { - x: 0, - y: -48, - connected: true, - }, - TracePoint { - x: 3, - y: -38, - connected: true, - }, - TracePoint { - x: 11, - y: -32, - connected: true, - }, - TracePoint { - x: 21, - y: -29, - connected: true, - }, - TracePoint { - x: 21, - y: -19, - connected: true, - }, - TracePoint { - x: 14, - y: -12, - connected: true, - }, - TracePoint { - x: 5, - y: -7, - connected: true, - }, - TracePoint { - x: 12, - y: -14, - connected: true, - }, - TracePoint { - x: 21, - y: -18, - connected: true, - }, - TracePoint { - x: 31, - y: -20, - connected: true, - }, - TracePoint { - x: 41, - y: -20, + x: 150, + y: 300, connected: true, }, + ]); + + let path = trace_points.get_path(rotation, None).unwrap(); + assert_eq!(path.get_attributes().get("d").unwrap(), expected); + } + + #[test] + fn test_trace_points_ngiot_origin_transform() { + let mut trace_points = TracePoints::new(); + trace_points.add_trace_points(vec![ TracePoint { - x: 51, - y: -24, + x: 100, + y: 200, connected: true, }, TracePoint { - x: 58, - y: -31, + x: 150, + y: 300, connected: true, }, + ]); + + let path = trace_points + .get_path(RotationAngle::Deg0, Some((1000, 2000))) + .unwrap(); + + assert_eq!(path.get_attributes().get("d").unwrap(), "M22-36l1-2"); + assert!(path.get_attributes().get("transform").is_none()); + } + + #[test] + fn test_trace_points_legacy_scale_transform_present_without_ngiot_origin() { + let mut trace_points = TracePoints::new(); + trace_points.add_trace_points(vec![ TracePoint { - x: 64, - y: -39, + x: 100, + y: 200, connected: true, }, TracePoint { - x: 70, - y: -47, + x: 150, + y: 300, connected: true, }, - ]; - assert_eq!(result, expected); - } + ]); - #[test] - fn test_process_trace_points_to_short() { - let input: Vec = vec![0x0, 0x0, 0x0, 0x0]; - let result = process_trace_points(&input); - assert!(matches!(result, Err(e) if e.to_string() == "Invalid trace points length")); + let path = trace_points.get_path(RotationAngle::Deg0, None).unwrap(); + assert_eq!( + path.get_attributes().get("transform").unwrap(), + "scale(0.2 -0.2)" + ); } -} +} \ No newline at end of file From cdb79ce8ccbfb0cb03a6902e71f6a9da72086acd Mon Sep 17 00:00:00 2001 From: Astute4185 Date: Tue, 7 Apr 2026 19:19:29 +1000 Subject: [PATCH 31/43] Testing Candidate Enhance NGIOT map handling by adding direction support and improving map ID resolution logic --- deebot_client/commands/ngiot/map.py | 80 ++++++++++++++--------------- deebot_client/hardware/eyfj07.py | 4 +- deebot_client/map.py | 28 ++++++---- deebot_client/ngiot_map_parser.py | 20 +++++--- deebot_client/rs/map.pyi | 2 + src/map/mod.rs | 2 + src/map/ngiot_background.rs | 23 +++++++-- 7 files changed, 97 insertions(+), 62 deletions(-) diff --git a/deebot_client/commands/ngiot/map.py b/deebot_client/commands/ngiot/map.py index 52fb81d5c..4488d14ab 100644 --- a/deebot_client/commands/ngiot/map.py +++ b/deebot_client/commands/ngiot/map.py @@ -17,6 +17,7 @@ MapTraceEvent, MinorMapEvent, ) +from deebot_client.exceptions import ApiError from deebot_client.message import HandlingResult, HandlingState from deebot_client.models import Room from deebot_client.ngiot_client import APN_MAP_DETAILS @@ -97,7 +98,9 @@ def _resolve_effective_map_id( explicit: str = "", ) -> str: store = _get_ngiot_map_state_store(event_bus) - return resolve_map_id(data, fallback=explicit or store.active_map_id or "") + # For eyfj07, downstream mapping payloads do not expose a stable join key. + # Prefer an explicit/requested or already-active map context over payload IDs. + return explicit or store.active_map_id or resolve_map_id(data, fallback="") def _polygon_to_coordinates(points: list[Any]) -> str: @@ -116,7 +119,7 @@ def __init__(self, map_id: str = "") -> None: def _fields(self) -> Sequence[str]: """Return fields to request from APN 30001.""" - async def _resolve_map_id( + async def _resolve_request_map_id( self, client: NgiotClient, device_info: ApiDeviceInfo, @@ -124,43 +127,42 @@ async def _resolve_map_id( if self._map_id: return self._map_id - response = await client.request( - device_info, - apn=APN_MAP_DETAILS, - body_data={"fields": ["mapInfos"]}, - ) - data = response.get("body", {}).get("data", {}) - map_infos = data.get("mapInfos", []) - if isinstance(map_infos, list): - active = next( - ( - entry - for entry in map_infos - if isinstance(entry, dict) and int(entry.get("status", 0)) == 1 - ), - None, - ) - if isinstance(active, dict): - return str(active.get("mapId", "")) + # mapInfos itself must not recurse. + if tuple(self._fields) == ("mapInfos",): + return "" - fallback = next( - (entry for entry in map_infos if isinstance(entry, dict)), - None, + try: + response = await client.request( + device_info, + apn=APN_MAP_DETAILS, + body_data={"fields": ["mapInfos"]}, ) - if isinstance(fallback, dict): - return str(fallback.get("mapId", "")) + except ApiError: + return "" - return "" + body = response.get("body", {}) + data = body.get("data", {}) + if not isinstance(data, dict): + return "" + + infos = parse_map_infos(data) + active = next((info for info in infos if info.using and info.map_id), None) + if active is not None: + return active.map_id + + first = next((info for info in infos if info.map_id), None) + return first.map_id if first is not None else "" async def _request_ngiot( self, client: NgiotClient, device_info: ApiDeviceInfo, ) -> dict[str, Any]: + request_map_id = await self._resolve_request_map_id(client, device_info) + body_data: dict[str, Any] = {"fields": list(self._fields)} - map_id = await self._resolve_map_id(client, device_info) - if map_id: - body_data["mapId"] = map_id + if request_map_id: + body_data["mapId"] = request_map_id return await client.request( device_info, @@ -211,17 +213,6 @@ def _handle_response( response: dict[str, Any], ) -> HandlingResult: result = super()._handle_response(event_bus, response) - if ( - result.state == HandlingState.SUCCESS - and result.args - and (map_obj := event_bus.capabilities.map) - ): - map_id = result.args["map_id"] - result.requested_commands.extend( - [map_obj.set.execute(map_id, entry) for entry in MapSetType] - ) - if map_obj.info: - result.requested_commands.append(map_obj.info.execute(map_id)) return result @@ -263,6 +254,15 @@ def _handle_body_data_dict( areas = parse_areas(data) if areas and map_id: store.update_areas(map_id, areas) + rooms = [ + Room( + name=(area.name or f"Area { _coerce_int(area.area_id, index) }"), + id=_coerce_int(area.area_id, index), + coordinates=_polygon_to_coordinates(area.polygon), + ) + for index, area in enumerate(areas) + ] + event_bus.notify(RoomsEvent(map_id=map_id, rooms=rooms)) handled = True positions: list[Position] = [] diff --git a/deebot_client/hardware/eyfj07.py b/deebot_client/hardware/eyfj07.py index c4ed1d25d..815562f85 100644 --- a/deebot_client/hardware/eyfj07.py +++ b/deebot_client/hardware/eyfj07.py @@ -131,7 +131,7 @@ def get_device_info() -> StaticDeviceInfo: minor=CapabilityExecute(GetMinorMap), multi_state=None, position=CapabilityEvent(PositionsEvent, [GetPos()]), - rooms=CapabilityEvent(RoomsEvent, [GetCachedMapInfo()]), + rooms=CapabilityEvent(RoomsEvent, [GetMajorMap()]), set=CapabilityExecute(GetMapSet), trace=CapabilityEvent(MapTraceEvent, [GetMapTrace()]), ), @@ -158,4 +158,4 @@ def get_device_info() -> StaticDeviceInfo: ), water=None, ), - ) + ) \ No newline at end of file diff --git a/deebot_client/map.py b/deebot_client/map.py index 22c6ed375..01574e574 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -235,10 +235,13 @@ def _sync_ngiot_background_from_store(self) -> None: resolution=int(getattr(base_map, "resolution", 1)), x_min=int(getattr(base_map, "x_min", 0)), y_max=int(getattr(base_map, "y_max", 0)), + direction=int(getattr(base_map, "direction", 0)), ) self._map_data.use_world_trace_scale() self._map_data.use_ngiot_position_icon_scale() - self._map_data.use_ngiot_position_transform() + # eyfj07 position payloads are already in world/map coordinates. + # Do not re-offset them by xMin/yMax here. + self._map_data.use_legacy_position_transform() class MapData: @@ -289,16 +292,19 @@ def update_positions(self, positions: list[Position]) -> None: self._positions = positions self._on_change() - def set_rotation_angle(self, angle: int) -> None: + def set_rotation_angle(self, angle: int | RotationAngle) -> None: """Set rotation angle.""" - angle_mapping = { - 0: RotationAngle.DEG_0, - 90: RotationAngle.DEG_90, - 180: RotationAngle.DEG_180, - 270: RotationAngle.DEG_270, - } - - new_rotation = angle_mapping.get(angle % 360, RotationAngle.DEG_0) + if isinstance(angle, RotationAngle): + new_rotation = angle + else: + angle_mapping = { + 0: RotationAngle.DEG_0, + 90: RotationAngle.DEG_90, + 180: RotationAngle.DEG_180, + 270: RotationAngle.DEG_270, + } + new_rotation = angle_mapping.get(int(angle) % 360, RotationAngle.DEG_0) + if self._rotation != new_rotation: self._rotation = new_rotation self._on_change() @@ -329,6 +335,7 @@ def set_ngiot_background( resolution: int, x_min: int, y_max: int, + direction: int, ) -> None: """Set NGIOT raster background.""" self._data.set_ngiot_background( @@ -340,6 +347,7 @@ def set_ngiot_background( resolution=resolution, x_min=x_min, y_max=y_max, + direction=direction, ) self._on_change() diff --git a/deebot_client/ngiot_map_parser.py b/deebot_client/ngiot_map_parser.py index a4bb36534..5c2d0773f 100644 --- a/deebot_client/ngiot_map_parser.py +++ b/deebot_client/ngiot_map_parser.py @@ -52,6 +52,7 @@ class NgiotBaseMap: resolution: int x_min: int y_max: int + direction: int encoded: str lz4_len: int | None = None @@ -238,6 +239,7 @@ def parse_base_map(data: dict[str, Any], map_id: str | None = None) -> NgiotBase resolution=max(1, _coerce_int(raw.get("resolution"), 1)), x_min=_coerce_int(raw.get("xMin")), y_max=_coerce_int(raw.get("yMax")), + direction=_coerce_int(raw.get("direction")), encoded=encoded, lz4_len=_coerce_int(raw.get("lz4Len")) or None, ) @@ -344,11 +346,17 @@ def parse_overlays(data: dict[str, Any]) -> list[NgiotOverlay]: def normalize_point(point: NgiotPoint, base_map: NgiotBaseMap) -> NgiotPoint: - """Normalize a raw NGIOT point into map-render space.""" - return NgiotPoint( - x=int((point.x - base_map.x_min) / base_map.resolution), - y=int((base_map.y_max - point.y) / base_map.resolution), - ) + """Normalize a raw NGIOT point into cropped-raster space.""" + left = base_map.x_min - base_map.y_max + top = base_map.y_max - base_map.height + + col = int((point.x - left) / base_map.resolution) + row = int((top - point.y) / base_map.resolution) + + if base_map.direction == -1: + row = (base_map.height - 1) - row + + return NgiotPoint(x=col, y=row) @@ -361,4 +369,4 @@ def normalize_pose(pose: NgiotPose, base_map: NgiotBaseMap) -> NgiotPose: def normalize_polygon(points: list[NgiotPoint], base_map: NgiotBaseMap) -> list[NgiotPoint]: """Normalize a polygon into map-render space.""" - return [normalize_point(point, base_map) for point in points] + return [normalize_point(point, base_map) for point in points] \ No newline at end of file diff --git a/deebot_client/rs/map.pyi b/deebot_client/rs/map.pyi index 99a9032bf..66aacc964 100644 --- a/deebot_client/rs/map.pyi +++ b/deebot_client/rs/map.pyi @@ -25,6 +25,7 @@ class NgiotBackground: resolution: int, x_min: int, y_max: int, + direction: int, ) -> bool: """Store NGIOT background metadata and encoded payload.""" @@ -90,6 +91,7 @@ class MapData: resolution: int, x_min: int, y_max: int, + direction: int, ) -> bool: """Compatibility wrapper for Python map.py.""" diff --git a/src/map/mod.rs b/src/map/mod.rs index a003778d3..e046c2785 100644 --- a/src/map/mod.rs +++ b/src/map/mod.rs @@ -320,6 +320,7 @@ impl MapData { resolution: i32, x_min: i32, y_max: i32, + direction: i32, ) -> bool { self.ngiot_background.borrow_mut(py).set_background_data( encoded, @@ -330,6 +331,7 @@ impl MapData { resolution, x_min, y_max, + direction, ) } diff --git a/src/map/ngiot_background.rs b/src/map/ngiot_background.rs index 695962e6c..7dc5f040b 100644 --- a/src/map/ngiot_background.rs +++ b/src/map/ngiot_background.rs @@ -18,6 +18,7 @@ struct NgiotBackgroundData { resolution: i32, x_min: i32, y_max: i32, + direction: i32, } #[pyclass] @@ -48,6 +49,7 @@ impl NgiotBackground { resolution: i32, x_min: i32, y_max: i32, + direction: i32, ) -> bool { let new_data = NgiotBackgroundData { encoded, @@ -58,6 +60,7 @@ impl NgiotBackground { resolution, x_min, y_max, + direction, }; if self.data.as_ref() == Some(&new_data) { @@ -72,6 +75,7 @@ impl NgiotBackground { if self.data.is_none() { return false; } + self.data = None; true } @@ -109,16 +113,25 @@ impl NgiotBackground { writer.write_image_data(&rgba)?; } - let left = data.x_min as f32 / WORLD_PIXEL_WIDTH; - let top = -(data.y_max as f32) / WORLD_PIXEL_WIDTH; + let left_world = (data.x_min - data.y_max) as f32; + let top_world = (data.y_max - i32::from(data.height)) as f32; + + let left = left_world / WORLD_PIXEL_WIDTH; + let top = -top_world / WORLD_PIXEL_WIDTH; let width_svg = (f32::from(data.width) * data.resolution as f32) / WORLD_PIXEL_WIDTH; let height_svg = (f32::from(data.height) * data.resolution as f32) / WORLD_PIXEL_WIDTH; let viewbox = ViewBox::from_extents(left, top, left + width_svg, top + height_svg); debug!( - "Generated NGIOT raster background: map {}x{} at world ({}, {}) size ({}, {})", - data.width, data.height, left, top, width_svg, height_svg + "Generated NGIOT raster background: map {}x{} at world ({}, {}) size ({}, {}), direction={}", + data.width, + data.height, + left, + top, + width_svg, + height_svg, + data.direction ); Ok(Some((general_purpose::STANDARD.encode(&png_data), viewbox))) @@ -159,6 +172,7 @@ impl NgiotBackground { resolution: i32, x_min: i32, y_max: i32, + direction: i32, ) -> bool { self.set_background_data( encoded, @@ -169,6 +183,7 @@ impl NgiotBackground { resolution, x_min, y_max, + direction, ) } From 65428f2cfc6a415bc446affd4195a3fc9a0622c7 Mon Sep 17 00:00:00 2001 From: Astute4185 Date: Wed, 8 Apr 2026 13:07:27 +1000 Subject: [PATCH 32/43] Enhance NGIOT client with fallback control host and improved request handling --- deebot_client/authentication.py | 4 +- deebot_client/ngiot_client.py | 136 ++++++++++++++++++++++++++++++-- 2 files changed, 132 insertions(+), 8 deletions(-) diff --git a/deebot_client/authentication.py b/deebot_client/authentication.py index fa14909e9..4601d240e 100644 --- a/deebot_client/authentication.py +++ b/deebot_client/authentication.py @@ -583,6 +583,8 @@ def _create_ngiot_stack(self, base_url: str) -> None: requested_ttl=self._ngiot_config.requested_ttl, refresh_skew=self._ngiot_config.refresh_skew, ) + # SST tokens are minted against api-base, but endpoint-control requests + # must keep using the device service.mqs host (typically api-ngiot). self.ngiot_client = NgiotClient( self._config.session, self.sst_authenticator, @@ -683,4 +685,4 @@ def _derive_ngiot_base_url_from_mqs(cls, mqs_host: str) -> str: if "." in host: return cls._normalize_base_url("api-base." + host.split(".", 1)[1]) msg = f'Could not derive NGIOT base URL from mqs host "{mqs_host}"' - raise ApiError(msg) + raise ApiError(msg) \ No newline at end of file diff --git a/deebot_client/ngiot_client.py b/deebot_client/ngiot_client.py index b1047224d..c9bd8edce 100644 --- a/deebot_client/ngiot_client.py +++ b/deebot_client/ngiot_client.py @@ -48,6 +48,7 @@ class NgiotDeviceIdentity: class_id: str resource: str control_host: str + fallback_control_host: str | None = None @property def key(self) -> str: @@ -105,6 +106,80 @@ async def request( } """ identity = self._normalize_device(device) + return await self._request_with_fallback( + identity, + device, + apn=apn, + body_data=body_data, + fmt=fmt, + ct=ct, + force_sst_refresh=force_sst_refresh, + ) + + async def _request_with_fallback( + self, + identity: NgiotDeviceIdentity, + device: ApiDeviceInfo | DeviceInfo | Mapping[str, Any], + *, + apn: str | int, + body_data: Mapping[str, Any] | Sequence[Any], + fmt: str, + ct: str, + force_sst_refresh: bool, + ) -> dict[str, Any]: + """Execute an NGIOT request and fall back to service.mqs on 404.""" + try: + return await self._request_once( + identity, + device, + apn=apn, + body_data=body_data, + fmt=fmt, + ct=ct, + force_sst_refresh=force_sst_refresh, + ) + except ClientResponseError as ex: + if ( + ex.status == HTTPStatus.NOT_FOUND + and identity.fallback_control_host + and identity.fallback_control_host != identity.control_host + ): + fallback_identity = NgiotDeviceIdentity( + did=identity.did, + class_id=identity.class_id, + resource=identity.resource, + control_host=identity.fallback_control_host, + fallback_control_host=None, + ) + _LOGGER.info( + "NGIOT endpoint-control returned 404 on %s for %s; retrying with device mqs host %s", + identity.base_url, + identity.key, + fallback_identity.base_url, + ) + return await self._request_once( + fallback_identity, + device, + apn=apn, + body_data=body_data, + fmt=fmt, + ct=ct, + force_sst_refresh=force_sst_refresh, + ) + raise + + async def _request_once( + self, + identity: NgiotDeviceIdentity, + device: ApiDeviceInfo | DeviceInfo | Mapping[str, Any], + *, + apn: str | int, + body_data: Mapping[str, Any] | Sequence[Any], + fmt: str, + ct: str, + force_sst_refresh: bool, + ) -> dict[str, Any]: + """Execute a single NGIOT endpoint-control request against one control host.""" url = urljoin(identity.base_url + "/", _PATH_ENDPOINT_CONTROL.lstrip("/")) request_id = self._new_request_id() @@ -121,7 +196,7 @@ async def request( } payload = { - "body": {"data": body_data}, + "body": {"data": self._build_payload(apn, body_data)}, "header": self._create_body_header(body_reqid), } @@ -165,6 +240,12 @@ async def request( logger_request_params, response_data, ) + _LOGGER.debug( + "NGIOT protocol trace -> apn=%s payload=%s response=%s", + apn, + payload["body"]["data"], + response_data, + ) self._validate_response(response_data) return response_data @@ -181,7 +262,8 @@ async def request( identity.key, ) await self._sst_authenticator.invalidate(self._device_mapping(identity)) - return await self.request( + return await self._request_with_fallback( + identity, device, apn=apn, body_data=body_data, @@ -196,7 +278,7 @@ async def request( ) from ex _LOGGER.debug("NGIOT request failed: %s", logger_request_params, exc_info=True) - raise ApiError from ex + raise async def query_fields( self, @@ -317,9 +399,13 @@ def _normalize_device( raise TypeError(msg) service = raw_device.get("service", {}) - host = self._override_control_host - if host is None and isinstance(service, Mapping): - host = service.get("mqs") + service_mqs_host = None + if isinstance(service, Mapping): + candidate = service.get("mqs") + if isinstance(candidate, str) and candidate: + service_mqs_host = candidate + + host = self._override_control_host or service_mqs_host if not host: msg = f"Missing NGIOT control host in device service binding: {raw_device}" @@ -331,11 +417,46 @@ def _normalize_device( class_id=str(raw_device["class"]), resource=str(raw_device["resource"]), control_host=str(host), + fallback_control_host=( + str(service_mqs_host) + if service_mqs_host and str(service_mqs_host) != str(host) + else None + ), ) except KeyError as ex: msg = f"Missing required NGIOT device field: {ex.args[0]}" raise ApiError(msg) from ex + + def _build_payload( + self, + apn: str | int, + body_data: Mapping[str, Any] | Sequence[Any], + ) -> Mapping[str, Any] | Sequence[Any]: + """Build a device-tolerant NGIOT payload. + + eyfj07 rejects some empty legacy payloads with a null body. + This helper preserves caller-provided payloads while adding a + minimal request envelope for reads that otherwise send `{}`. + """ + if not isinstance(body_data, Mapping): + return body_data + + payload: dict[str, Any] = dict(body_data) + now_ms = int(time.time() * 1000) + now_s = int(time.time()) + + payload.setdefault("reqId", str(now_ms)) + payload.setdefault("timestamp", now_s) + + apn_str = str(apn) + if apn_str == APN_ROBOT_DETAIL: + payload.setdefault("type", "get") + elif apn_str == APN_MAP_DETAILS: + payload.setdefault("mapId", str(payload.get("mapId", "0"))) + + return payload + def _create_body_header(self, reqid: str) -> dict[str, Any]: """Create request body header matching the observed mobile shape.""" return { @@ -361,7 +482,8 @@ def _extract_body_data(response: Mapping[str, Any]) -> Any: def _validate_response(response: Mapping[str, Any] | None) -> None: """Validate NGIOT envelope and raise ApiError on device-side failures.""" if response is None: - raise ApiError("Invalid NGIOT response: server returned null/empty body") + _LOGGER.debug("Empty NGIOT response body returned by server") + return body = response.get("body") if not isinstance(body, Mapping): From d2f595d6b57cf8cc6bbba0e54984cadd6b3d794b Mon Sep 17 00:00:00 2001 From: Astute4185 Date: Wed, 8 Apr 2026 13:22:29 +1000 Subject: [PATCH 33/43] Enhance NGIOT integration with new message handling and topic routing --- deebot_client/map.py | 2 +- deebot_client/messages/json/__init__.py | 8 ++- deebot_client/messages/json/ngiot.py | 75 +++++++++++++++++++++++++ deebot_client/mqtt_client.py | 63 ++++++++++++++++++--- 4 files changed, 138 insertions(+), 10 deletions(-) create mode 100644 deebot_client/messages/json/ngiot.py diff --git a/deebot_client/map.py b/deebot_client/map.py index 01574e574..849e26081 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -239,7 +239,7 @@ def _sync_ngiot_background_from_store(self) -> None: ) self._map_data.use_world_trace_scale() self._map_data.use_ngiot_position_icon_scale() - # eyfj07 position payloads are already in world/map coordinates. + # Observed NGIOT live pose payloads are already in world/map coordinates. # Do not re-offset them by xMin/yMax here. self._map_data.use_legacy_position_transform() diff --git a/deebot_client/messages/json/__init__.py b/deebot_client/messages/json/__init__.py index 11d8a9d65..e1a41a7d3 100644 --- a/deebot_client/messages/json/__init__.py +++ b/deebot_client/messages/json/__init__.py @@ -11,6 +11,7 @@ from .battery import OnBattery from .gps_position import OnGpsPos from .map import OnCachedMapInfo, OnMajorMap, OnMapInfoV2, OnMapSetV2 +from .ngiot import OnNgiotMapEvent, OnNgiotStatusEvent from .station_state import OnStationState from .stats import OnStats, ReportStats from .work_state import OnWorkState @@ -27,6 +28,8 @@ "OnStats", "OnWorkState", "ReportStats", + "OnNgiotMapEvent", + "OnNgiotStatusEvent", ] # fmt: off @@ -38,6 +41,9 @@ OnGpsPos, + OnNgiotMapEvent, + OnNgiotStatusEvent, + OnCachedMapInfo, OnMajorMap, OnMapInfoV2, @@ -108,4 +114,4 @@ def get_legacy_message(message_name: str, converted_name: str) -> type[Message] _LOGGER.debug('Command "%s" doesn\'t support message handling', converted_name) - return None + return None \ No newline at end of file diff --git a/deebot_client/messages/json/ngiot.py b/deebot_client/messages/json/ngiot.py new file mode 100644 index 000000000..29c256552 --- /dev/null +++ b/deebot_client/messages/json/ngiot.py @@ -0,0 +1,75 @@ +"""NGIOT numeric-topic MQTT messages.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from deebot_client.commands.ngiot.battery import GetBattery +from deebot_client.commands.ngiot.map import GetMajorMap, GetMapTrace, GetPos +from deebot_client.commands.ngiot.stats import GetStats +from deebot_client.logging_filter import get_logger +from deebot_client.message import HandlingResult, HandlingState, MessageBodyDataDict + +if TYPE_CHECKING: + from deebot_client.event_bus import EventBus + +_LOGGER = get_logger(__name__) + + +class OnNgiotMapEvent(MessageBodyDataDict): + """Handle NGIOT live map/status multiplexed on numeric topic 30000.""" + + NAME = "30000" + + @classmethod + def _handle_body_data_dict( + cls, event_bus: EventBus, data: dict[str, Any] + ) -> HandlingResult: + handled = False + + if any(key in data for key in ("mapData", "areas", "chargePos")): + result = GetMajorMap._handle_body_data_dict(event_bus, data) + handled = handled or result.state == HandlingState.SUCCESS + + # Live pose-only payloads may arrive without mapData. + if "pos" in data and "mapData" not in data: + result = GetPos._handle_body_data_dict(event_bus, data) + handled = handled or result.state == HandlingState.SUCCESS + + if "mapTraceData" in data: + result = GetMapTrace._handle_body_data_dict(event_bus, data) + handled = handled or result.state == HandlingState.SUCCESS + + # mapMinorData is confirmed to exist on the live channel, but the current + # renderer path does not yet support generic NGIOT delta raster application. + # Log it so captures remain explainable without pretending to render deltas. + if "mapMinorData" in data: + _LOGGER.debug( + "Observed NGIOT live minor-map payload on topic 30000; " + "generic delta-raster application is not implemented yet" + ) + handled = True + + return HandlingResult.success() if handled else HandlingResult.analyse() + + +class OnNgiotStatusEvent(MessageBodyDataDict): + """Handle NGIOT live status multiplexed on numeric topic 10000.""" + + NAME = "10000" + + @classmethod + def _handle_body_data_dict( + cls, event_bus: EventBus, data: dict[str, Any] + ) -> HandlingResult: + handled = False + + if "battery" in data: + result = GetBattery._handle_body_data_dict(event_bus, data) + handled = handled or result.state == HandlingState.SUCCESS + + if any(key in data for key in ("cleanArea", "cleanTime", "workMode")): + result = GetStats._handle_body_data_dict(event_bus, data) + handled = handled or result.state == HandlingState.SUCCESS + + return HandlingResult.success() if handled else HandlingResult.analyse() \ No newline at end of file diff --git a/deebot_client/mqtt_client.py b/deebot_client/mqtt_client.py index 90b27f605..06687c75f 100644 --- a/deebot_client/mqtt_client.py +++ b/deebot_client/mqtt_client.py @@ -34,20 +34,32 @@ _CLIENT_LOGGER = get_logger(f"{__name__}.client") -def _get_topics(device_info: DeviceInfo) -> list[str]: +def _get_topics(device_info: DeviceInfo, user_id: str | None = None) -> list[str]: api = device_info.api device_path = f"{api['did']}/{api['class']}/{api['resource']}" data_type = device_info.static.data_type - return [ - # iot/atr/[command]/[did]]/[class]]/[resource]/[data_type] + + topics = [ + # Legacy/message-name ATR routing. + # iot/atr/[command]/[did]/[class]/[resource]/[data_type] f"iot/atr/+/{device_path}/{data_type}", - # iot/p2p/[command]/[sender did]/[sender class]]/[sender resource] + # iot/p2p/[command]/[sender did]/[sender class]/[sender resource] # /[receiver did]/[receiver class]/[receiver resource]/[q|p]/[request id]/[data_type] # [q|p] q-> request p-> response f"iot/p2p/+/+/+/+/{device_path}/q/+/{data_type}", f"iot/p2p/+/{device_path}/+/+/+/p/+/{data_type}", ] + if user_id: + # NGIOT live-event routing observed on some newer devices: + # iot/atr/[channel]/[user-id]/[device-class]/[device-id]/[data_type] + # The final device identifier can vary between observed payloads, so + # subscribe to both known device identifiers. + for device_id in dict.fromkeys((api['did'], api['resource'])): + topics.append(f"iot/atr/+/{user_id}/{api['class']}/{device_id}/{data_type}") + + return topics + @dataclass(frozen=True, kw_only=True) class MqttConfiguration: @@ -200,8 +212,11 @@ async def mqtt() -> None: try: async with await self._get_client() as client: _LOGGER.debug("Subscribe to all previous subscriptions") + credentials = await self._authenticator.authenticate() for info in self._subscriptions.values(): - for topic in _get_topics(info.device_info): + for topic in _get_topics( + info.device_info, credentials.user_id + ): await client.subscribe(topic) async def listen() -> None: @@ -264,7 +279,8 @@ async def _pending_subscriptions_worker(self, client: Client) -> None: (info, add) = await self._subscription_changes.get() device_info = info.device_info - for topic in _get_topics(device_info): + credentials = await self._authenticator.authenticate() + for topic in _get_topics(device_info, credentials.user_id): if add: await client.subscribe(topic) else: @@ -277,9 +293,40 @@ async def _pending_subscriptions_worker(self, client: Client) -> None: self._subscription_changes.task_done() + @staticmethod + def _topic_matches_device(topic_split: list[str], device_info: DeviceInfo) -> bool: + if len(topic_split) < 7: + return False + + api = device_info.api + data_type = str(device_info.static.data_type) + if topic_split[6] != data_type: + return False + + legacy_match = ( + topic_split[3] == api["did"] + and topic_split[4] == api["class"] + and topic_split[5] == api["resource"] + ) + if legacy_match: + return True + + # NGIOT live events can use the observed shape: + # iot/atr/[channel]/[user-id]/[device-class]/[device-id]/[data_type] + return topic_split[4] == api["class"] and topic_split[5] in { + api["did"], + api["resource"], + } + + def _resolve_atr_subscription(self, topic_split: list[str]) -> SubscriberInfo | None: + for info in self._subscriptions.values(): + if self._topic_matches_device(topic_split, info.device_info): + return info + return None + def _handle_atr(self, topic_split: list[str], payload: bytes) -> None: try: - if sub_info := self._subscriptions.get(topic_split[3]): + if sub_info := self._resolve_atr_subscription(topic_split): sub_info.callback(topic_split[2], payload) except Exception: _LOGGER.exception("An exception occurred during handling atr message") @@ -321,4 +368,4 @@ def _handle_p2p(self, topic_split: list[str], payload: bytes) -> None: "An exception occurred during handling p2p message: topic=%s; payload=%s", "/".join(topic_split), payload, - ) + ) \ No newline at end of file From 8cba96e1e0505d4a6c6cc15af1150d5379e0dcd7 Mon Sep 17 00:00:00 2001 From: Astute4185 Date: Wed, 8 Apr 2026 18:11:55 +1000 Subject: [PATCH 34/43] Add volume control commands and integrate into NGIOT client --- deebot_client/commands/ngiot/__init__.py | 5 ++ deebot_client/commands/ngiot/child_lock.py | 23 ++++-- deebot_client/commands/ngiot/clean.py | 86 +++++++++++++++++----- deebot_client/commands/ngiot/play_sound.py | 21 +++++- deebot_client/commands/ngiot/volume.py | 26 ++++++- deebot_client/hardware/eyfj07.py | 15 +++- deebot_client/messages/json/ngiot.py | 27 +++++++ deebot_client/ngiot_client.py | 1 + 8 files changed, 174 insertions(+), 30 deletions(-) diff --git a/deebot_client/commands/ngiot/__init__.py b/deebot_client/commands/ngiot/__init__.py index 5fbe85b56..64855bbad 100644 --- a/deebot_client/commands/ngiot/__init__.py +++ b/deebot_client/commands/ngiot/__init__.py @@ -15,6 +15,7 @@ from .play_sound import PlaySound from .stats import GetReportStats, GetStats, GetTotalStats from .child_lock import GetChildLock, SetChildLock +from .volume import GetVolume, SetVolume from .map import ( GetMajorMap, GetMapSet, @@ -50,6 +51,8 @@ "GetMapTrace", "GetMinorMap", "GetPos", + "GetVolume", + "SetVolume", ] _COMMANDS: list[type[Command]] = [ @@ -76,6 +79,8 @@ GetMapTrace, GetMinorMap, GetPos, + GetVolume, + SetVolume, ] COMMANDS: dict[str, type[Command]] = {cmd.NAME: cmd for cmd in _COMMANDS} \ No newline at end of file diff --git a/deebot_client/commands/ngiot/child_lock.py b/deebot_client/commands/ngiot/child_lock.py index 73776015f..67f19b4c9 100644 --- a/deebot_client/commands/ngiot/child_lock.py +++ b/deebot_client/commands/ngiot/child_lock.py @@ -6,11 +6,14 @@ from deebot_client.events import ChildLockEvent from deebot_client.message import HandlingResult, HandlingState +from deebot_client.ngiot_client import APN_CHILD_LOCK -from .common import RobotDetailGetCommand, RobotDetailSetCommand +from .common import NgiotExecuteCommand, RobotDetailGetCommand if TYPE_CHECKING: from deebot_client.event_bus import EventBus + from deebot_client.models import ApiDeviceInfo + from deebot_client.ngiot_client import NgiotClient class GetChildLock(RobotDetailGetCommand): @@ -29,18 +32,26 @@ def _handle_body_data_dict( return HandlingResult.success() -class SetChildLock(RobotDetailSetCommand): - """Set child-lock state on the robot-detail surface.""" +class SetChildLock(NgiotExecuteCommand): + """Set child-lock state using the confirmed NGIOT write APN.""" NAME = "setChildLock" get_command = GetChildLock def __init__(self, enable: bool) -> None: - super().__init__({"childLock": bool(enable)}) + super().__init__({}) self._enable = bool(enable) - def _get_body_data(self) -> dict[str, Any]: - return {"childLock": self._enable} + async def _request_ngiot( + self, + client: NgiotClient, + device_info: ApiDeviceInfo, + ) -> dict[str, Any]: + return await client.request( + device_info, + apn=APN_CHILD_LOCK, + body_data={"childLock": self._enable}, + ) def _handle_response( self, diff --git a/deebot_client/commands/ngiot/clean.py b/deebot_client/commands/ngiot/clean.py index ef7f41fcf..0ac74ef1c 100644 --- a/deebot_client/commands/ngiot/clean.py +++ b/deebot_client/commands/ngiot/clean.py @@ -14,6 +14,7 @@ APN_CLEAN_START, APN_PAUSE, APN_RESUME, + APN_RETURN_TO_DOCK, ) from .common import NgiotExecuteCommand, RobotDetailGetCommand @@ -25,6 +26,70 @@ from deebot_client.ngiot_client import NgiotClient +_ACTIVE_WORK_MODES = { + "smart", + "smartclean", + "area", + "auto", + "customarea", + "custom_area", + "spotarea", + "spot_area", +} + + +def _coerce_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "on", "yes"} + return False + + +def map_snapshot_state(data: Mapping[str, Any]) -> State: + """Map robot-detail snapshot fields onto the generic state enum.""" + work_mode = str(data.get("workMode", "")).strip().lower() + pause_switch = data.get("pauseSwitch") + charge_status = _coerce_bool(data.get("chargeStatus")) + + if work_mode == "auto_pause" or (pause_switch is True and work_mode in _ACTIVE_WORK_MODES): + return State.PAUSED + if work_mode in {"gocharge", "go_charge"}: + return State.RETURNING + if charge_status and work_mode in {"stop", "idle", "", "none"}: + return State.DOCKED + if charge_status: + return State.RETURNING + if work_mode in _ACTIVE_WORK_MODES: + return State.CLEANING + return State.IDLE + + +def map_live_state(data: Mapping[str, Any], previous: State | None = None) -> State | None: + """Map live 10000 status events onto the generic state enum.""" + status = str(data.get("status", "")).strip().lower() + pause_switch = data.get("pauseSwitch") + + if status == "smartclean": + return State.PAUSED if pause_switch is True else State.CLEANING + if status in {"gocharge", "go_charge"}: + return State.RETURNING + if status == "idle": + return State.DOCKED if _coerce_bool(data.get("chargeStatus")) else State.IDLE + + if pause_switch is True and previous in {State.CLEANING, State.PAUSED}: + return State.PAUSED + if pause_switch is False and previous == State.PAUSED: + return State.CLEANING + + if any(key in data for key in ("workMode", "chargeStatus")): + return map_snapshot_state(data) + + return None + + class Clean(NgiotExecuteCommand): """Translate generic clean actions into captured NGIOT control payloads.""" @@ -69,7 +134,7 @@ def _get_request(self) -> tuple[str, dict[str, Any]]: if self._action is CleanAction.RESUME: return APN_RESUME, {"pauseSwitch": False} if self._action is CleanAction.STOP: - return APN_PAUSE, {"pauseSwitch": True} + return APN_RETURN_TO_DOCK, {"chargeSwitch": True} raise ApiError(f"Unsupported clean action: {self._action}") @@ -119,7 +184,7 @@ class GetCleanInfo(RobotDetailGetCommand): """Get high-level robot state.""" NAME = "getCleanInfo" - FIELDS = ("cleanValues", "workMode", "chargeStatus") + FIELDS = ("cleanValues", "workMode", "chargeStatus", "pauseSwitch") @classmethod def _handle_body_data_dict( @@ -127,18 +192,5 @@ def _handle_body_data_dict( event_bus, data: dict[str, Any], ) -> HandlingResult: - event_bus.notify(StateEvent(_map_state(data))) - return HandlingResult.success() - - -def _map_state(data: Mapping[str, Any]) -> State: - work_mode = str(data.get("workMode", "")).lower() - charge_status = bool(data.get("chargeStatus")) - - if charge_status and work_mode in {"stop", "idle", "", "none"}: - return State.DOCKED - if charge_status: - return State.RETURNING - if work_mode in {"smart", "area", "auto", "customarea", "spotarea"}: - return State.CLEANING - return State.IDLE \ No newline at end of file + event_bus.notify(StateEvent(map_snapshot_state(data))) + return HandlingResult.success() \ No newline at end of file diff --git a/deebot_client/commands/ngiot/play_sound.py b/deebot_client/commands/ngiot/play_sound.py index fe4dd7098..0651db013 100644 --- a/deebot_client/commands/ngiot/play_sound.py +++ b/deebot_client/commands/ngiot/play_sound.py @@ -2,8 +2,16 @@ from __future__ import annotations +from typing import TYPE_CHECKING + +from deebot_client.ngiot_client import APN_DEVICE_LOCATE + from .common import NgiotExecuteCommand +if TYPE_CHECKING: + from deebot_client.models import ApiDeviceInfo + from deebot_client.ngiot_client import NgiotClient + class PlaySound(NgiotExecuteCommand): """Trigger device locate sound.""" @@ -11,4 +19,15 @@ class PlaySound(NgiotExecuteCommand): NAME = "seek" def __init__(self) -> None: - super().__init__({"seek": True}) \ No newline at end of file + super().__init__({}) + + async def _request_ngiot( + self, + client: NgiotClient, + device_info: ApiDeviceInfo, + ) -> dict[str, object]: + return await client.request( + device_info, + apn=APN_DEVICE_LOCATE, + body_data={"seek": True}, + ) \ No newline at end of file diff --git a/deebot_client/commands/ngiot/volume.py b/deebot_client/commands/ngiot/volume.py index 0024a27bd..e18ea648d 100644 --- a/deebot_client/commands/ngiot/volume.py +++ b/deebot_client/commands/ngiot/volume.py @@ -7,7 +7,7 @@ from deebot_client.events import VolumeEvent from deebot_client.message import HandlingResult, HandlingState -from .common import NgiotExecuteCommand +from .common import NgiotExecuteCommand, RobotDetailGetCommand if TYPE_CHECKING: from deebot_client.event_bus import EventBus @@ -15,13 +15,35 @@ from deebot_client.ngiot_client import NgiotClient +class GetVolume(RobotDetailGetCommand): + """Get device voice volume from the robot-detail surface.""" + + NAME = "getVolume" + FIELDS = ("volume",) + MAX_VOLUME = 5 + + @classmethod + def _handle_body_data_dict( + cls, + event_bus: EventBus, + data: dict[str, Any], + ) -> HandlingResult: + volume = data.get("volume") + if volume is None: + return HandlingResult.analyse() + + event_bus.notify(VolumeEvent(volume=int(volume), maximum=cls.MAX_VOLUME)) + return HandlingResult.success() + + class SetVolume(NgiotExecuteCommand): """Set device voice volume.""" NAME = "setVolume" APN = "50023" MIN_VOLUME = 0 - MAX_VOLUME = 10 + MAX_VOLUME = 5 + get_command = GetVolume def __init__(self, volume: int) -> None: super().__init__({}) diff --git a/deebot_client/hardware/eyfj07.py b/deebot_client/hardware/eyfj07.py index 815562f85..d08441ca2 100644 --- a/deebot_client/hardware/eyfj07.py +++ b/deebot_client/hardware/eyfj07.py @@ -40,6 +40,7 @@ TotalStatsEvent, PositionsEvent, RoomsEvent, + VolumeEvent, ) from deebot_client.models import StaticDeviceInfo @@ -54,6 +55,7 @@ from deebot_client.commands.ngiot.play_sound import PlaySound from deebot_client.commands.ngiot.child_lock import GetChildLock, SetChildLock from deebot_client.commands.ngiot.stats import GetReportStats, GetStats, GetTotalStats +from deebot_client.commands.ngiot.volume import GetVolume, SetVolume from deebot_client.commands.ngiot.map import ( GetCachedMapInfo, GetMajorMap, @@ -141,10 +143,15 @@ def get_device_info() -> StaticDeviceInfo: ), play_sound=CapabilityExecute(PlaySound), settings=CapabilitySettings( - child_lock=CapabilitySetEnable( - ChildLockEvent, - [GetChildLock()], - SetChildLock, + child_lock=CapabilitySetEnable( + ChildLockEvent, + [GetChildLock()], + SetChildLock, + ), + volume=CapabilitySet( + VolumeEvent, + [GetVolume()], + SetVolume, ), ), state=CapabilityEvent( diff --git a/deebot_client/messages/json/ngiot.py b/deebot_client/messages/json/ngiot.py index 29c256552..601bfee9d 100644 --- a/deebot_client/messages/json/ngiot.py +++ b/deebot_client/messages/json/ngiot.py @@ -5,8 +5,12 @@ from typing import TYPE_CHECKING, Any from deebot_client.commands.ngiot.battery import GetBattery +from deebot_client.commands.ngiot.child_lock import GetChildLock +from deebot_client.commands.ngiot.clean import GetCleanInfo, map_live_state from deebot_client.commands.ngiot.map import GetMajorMap, GetMapTrace, GetPos from deebot_client.commands.ngiot.stats import GetStats +from deebot_client.commands.ngiot.volume import GetVolume +from deebot_client.events import StateEvent from deebot_client.logging_filter import get_logger from deebot_client.message import HandlingResult, HandlingState, MessageBodyDataDict @@ -72,4 +76,27 @@ def _handle_body_data_dict( result = GetStats._handle_body_data_dict(event_bus, data) handled = handled or result.state == HandlingState.SUCCESS + if "childLock" in data: + result = GetChildLock._handle_body_data_dict(event_bus, data) + handled = handled or result.state == HandlingState.SUCCESS + + if "volume" in data: + result = GetVolume._handle_body_data_dict(event_bus, data) + handled = handled or result.state == HandlingState.SUCCESS + + live_state = map_live_state( + data, + previous=( + event_bus.get_last_event(StateEvent).state + if event_bus.get_last_event(StateEvent) is not None + else None + ), + ) + if live_state is not None: + event_bus.notify(StateEvent(live_state)) + handled = True + elif any(key in data for key in ("status", "pauseSwitch", "chargeStatus")): + result = GetCleanInfo._handle_body_data_dict(event_bus, data) + handled = handled or result.state == HandlingState.SUCCESS + return HandlingResult.success() if handled else HandlingResult.analyse() \ No newline at end of file diff --git a/deebot_client/ngiot_client.py b/deebot_client/ngiot_client.py index c9bd8edce..bf5f9a913 100644 --- a/deebot_client/ngiot_client.py +++ b/deebot_client/ngiot_client.py @@ -38,6 +38,7 @@ APN_RETURN_TO_DOCK = "40013" APN_CANCEL_RETURN = "40015" APN_DEVICE_LOCATE = "40019" +APN_CHILD_LOCK = "50038" @dataclass(frozen=True) From 209a746d91c68a3e941118d3e829d87a5a241b84 Mon Sep 17 00:00:00 2001 From: Astute4185 Date: Thu, 9 Apr 2026 19:54:56 +1000 Subject: [PATCH 35/43] Update GetStats and GetReportStats to include cleanCount and convert cleanTime to seconds; modify GetTotalStats to expose total stats and convert cleanTimeTotal to seconds. Increase MAX_VOLUME to 10 in SetVolume command. --- deebot_client/commands/ngiot/stats.py | 79 ++++++++++++++++++-------- deebot_client/commands/ngiot/volume.py | 2 +- 2 files changed, 57 insertions(+), 24 deletions(-) diff --git a/deebot_client/commands/ngiot/stats.py b/deebot_client/commands/ngiot/stats.py index 2399a0923..ad62b7815 100644 --- a/deebot_client/commands/ngiot/stats.py +++ b/deebot_client/commands/ngiot/stats.py @@ -11,10 +11,14 @@ class GetStats(RobotDetailGetCommand): - """Get current clean stats.""" + """Get current clean stats. - NAME = 'getStats' - FIELDS = ('cleanArea', 'cleanTime', 'workMode') + eyfj07 reports cleanTime in minutes. Home Assistant's Ecovacs duration + sensors expect native seconds, so convert before emitting the event. + """ + + NAME = "getStats" + FIELDS = ("cleanArea", "cleanTime", "cleanCount", "workMode", "cleanLogReport") @classmethod def _handle_body_data_dict( @@ -24,23 +28,19 @@ def _handle_body_data_dict( ) -> HandlingResult: event_bus.notify( StatsEvent( - area=_maybe_int(data.get('cleanArea')), - time=_maybe_int(data.get('cleanTime')), - type=_maybe_str(data.get('workMode')), + area=_maybe_int(data.get("cleanArea")), + time=_minutes_to_seconds(data.get("cleanTime")), + type=_maybe_str(data.get("workMode")), ) ) return HandlingResult.success() class GetReportStats(RobotDetailGetCommand): - """Get best-effort report stats. + """Get current clean report stats from the robot detail snapshot.""" - Detailed clean-log decoding is not captured yet, so this surfaces a minimal - snapshot that satisfies the capability contract and keeps the profile loadable. - """ - - NAME = 'getReportStats' - FIELDS = ('cleanArea', 'cleanTime', 'workMode') + NAME = "getReportStats" + FIELDS = ("cleanArea", "cleanTime", "cleanCount", "workMode", "cleanLogReport") @classmethod def _handle_body_data_dict( @@ -48,12 +48,17 @@ def _handle_body_data_dict( event_bus, data: dict[str, Any], ) -> HandlingResult: + clean_log_report = data.get("cleanLogReport") + cleaning_id = "" + if isinstance(clean_log_report, dict): + cleaning_id = str(clean_log_report.get("cid") or "") + event_bus.notify( ReportStatsEvent( - area=_maybe_int(data.get('cleanArea')), - time=_maybe_int(data.get('cleanTime')), - type=_maybe_str(data.get('workMode')), - cleaning_id='', + area=_maybe_int(data.get("cleanArea")), + time=_minutes_to_seconds(data.get("cleanTime")), + type=_maybe_str(data.get("workMode")), + cleaning_id=cleaning_id, status=CleanJobStatus.NO_STATUS, content=[], ) @@ -62,10 +67,17 @@ def _handle_body_data_dict( class GetTotalStats(RobotDetailGetCommand): - """Get best-effort lifetime stats.""" + """Get lifetime totals. - NAME = 'getTotalStats' - FIELDS = ('cleanArea', 'cleanTime', 'cleanCount') + eyfj07 exposes lifetime totals on the total-stats response surface as + ``cleanAreaTotal``, ``cleanTimeTotal``, and ``cleanCountTotal``. + + cleanTimeTotal is reported in minutes. Home Assistant expects native + duration values in seconds, so convert before emitting TotalStatsEvent. + """ + + NAME = "getTotalStats" + FIELDS = ("cleanAreaTotal", "cleanTimeTotal", "cleanCountTotal") @classmethod def _handle_body_data_dict( @@ -75,9 +87,12 @@ def _handle_body_data_dict( ) -> HandlingResult: event_bus.notify( TotalStatsEvent( - area=int(data.get('cleanArea', 0) or 0), - time=int(data.get('cleanTime', 0) or 0), - cleanings=int(data.get('cleanCount', 0) or 0), + area=_coerce_total(data, "cleanAreaTotal", "cleanArea"), + time=_minutes_to_seconds( + data.get("cleanTimeTotal", data.get("cleanTime", 0)) + ) + or 0, + cleanings=_coerce_total(data, "cleanCountTotal", "cleanCount"), ) ) return HandlingResult.success() @@ -90,5 +105,23 @@ def _maybe_int(value: Any) -> int | None: return None + def _maybe_str(value: Any) -> str | None: return None if value is None else str(value) + + + +def _coerce_total(data: dict[str, Any], primary: str, fallback: str) -> int: + value = data.get(primary, data.get(fallback, 0)) + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + + +def _minutes_to_seconds(value: Any) -> int | None: + minutes = _maybe_int(value) + if minutes is None: + return None + return minutes * 60 \ No newline at end of file diff --git a/deebot_client/commands/ngiot/volume.py b/deebot_client/commands/ngiot/volume.py index e18ea648d..aa0f961bf 100644 --- a/deebot_client/commands/ngiot/volume.py +++ b/deebot_client/commands/ngiot/volume.py @@ -42,7 +42,7 @@ class SetVolume(NgiotExecuteCommand): NAME = "setVolume" APN = "50023" MIN_VOLUME = 0 - MAX_VOLUME = 5 + MAX_VOLUME = 10 get_command = GetVolume def __init__(self, volume: int) -> None: From a8b475ae0a9125887ffaf3cdb519a488cd9b5e90 Mon Sep 17 00:00:00 2001 From: Astute4185 Date: Fri, 10 Apr 2026 08:41:22 +1000 Subject: [PATCH 36/43] Add overlay SVG offset handling in NGIOT integration for improved positioning --- src/map/mod.rs | 33 +++++++++++++++++++++++++++------ src/map/ngiot_background.rs | 29 +++++++++++++++++++++++++++-- src/map/points.rs | 21 ++++++++++++++++----- 3 files changed, 70 insertions(+), 13 deletions(-) diff --git a/src/map/mod.rs b/src/map/mod.rs index e046c2785..3b9376276 100644 --- a/src/map/mod.rs +++ b/src/map/mod.rs @@ -199,10 +199,15 @@ fn calc_ngiot_local_point_in_viewbox( origin: (i32, i32), viewbox: &ViewBox, rotation: RotationAngle, + overlay_svg_offset: Option<(f32, f32)>, ) -> Point { let world_x = origin.0 as f32 + x as f32; let world_y = origin.1 as f32 + y as f32; - let point = calc_point(world_x, world_y, rotation); + let mut point = calc_point(world_x, world_y, rotation); + if let Some((dx, dy)) = overlay_svg_offset { + point.x += dx; + point.y += dy; + } Point { x: point.x.max(viewbox.min_x as f32).min(viewbox.max_x as f32), y: point.y.max(viewbox.min_y as f32).min(viewbox.max_y as f32), @@ -373,11 +378,13 @@ impl MapData { rotation: RotationAngle, ) -> PyResult> { let position_icon_scale = self.position_icon_scale; + let ngiot_background = self.ngiot_background.borrow(py); let ngiot_position_origin = if self.use_ngiot_position_transform { - self.ngiot_background.borrow(py).position_origin() + ngiot_background.position_origin() } else { None }; + let ngiot_overlay_offset = ngiot_background.overlay_svg_offset(); let mut defs = Definitions::new() .add( @@ -488,7 +495,7 @@ impl MapData { if let Some(trace) = self .trace_points .borrow(py) - .get_path(rotation, ngiot_position_origin) + .get_path(rotation, ngiot_position_origin, ngiot_overlay_offset) { document.append(trace); } @@ -498,6 +505,7 @@ impl MapData { &viewbox, rotation, ngiot_position_origin, + ngiot_overlay_offset, self.use_ngiot_position_transform, ) { document.append(position); @@ -584,6 +592,7 @@ fn get_svg_positions( viewbox: &ViewBox, rotation: RotationAngle, ngiot_position_origin: Option<(i32, i32)>, + ngiot_overlay_offset: Option<(f32, f32)>, use_ngiot_position_transform: bool, ) -> Vec { if positions.is_empty() { @@ -600,10 +609,22 @@ fn get_svg_positions( for &i in &indices { let position = &positions[i]; let pos = match (ngiot_position_origin, use_ngiot_position_transform) { - (Some(origin), true) => { - calc_ngiot_local_point_in_viewbox(position.x, position.y, origin, viewbox, rotation) + (Some(origin), true) => calc_ngiot_local_point_in_viewbox( + position.x, + position.y, + origin, + viewbox, + rotation, + ngiot_overlay_offset, + ), + _ => { + let mut point = calc_point_in_viewbox(position.x, position.y, viewbox, rotation); + if let Some((dx, dy)) = ngiot_overlay_offset { + point.x += dx; + point.y += dy; + } + point } - _ => calc_point_in_viewbox(position.x, position.y, viewbox, rotation), }; svg_positions.push( diff --git a/src/map/ngiot_background.rs b/src/map/ngiot_background.rs index 7dc5f040b..918238541 100644 --- a/src/map/ngiot_background.rs +++ b/src/map/ngiot_background.rs @@ -8,6 +8,19 @@ use pyo3::prelude::*; const WORLD_PIXEL_WIDTH: f32 = 50.0; +/// Shared NGIOT overlay calibration applied after normalization into cropped-raster space. +/// +/// Sign convention matches the render notes: +/// - X negative => move overlays left +/// - X positive => move overlays right +/// - Y negative => move overlays up +/// - Y positive => move overlays down +/// +/// These are raster-cell offsets, not raw world-coordinate offsets. They therefore scale with +/// the map resolution instead of drifting when the visible crop size changes. +const OVERLAY_OFFSET_X: i32 = -7; +const OVERLAY_OFFSET_Y: i32 = -9; + #[derive(Debug, Clone, PartialEq, Eq)] struct NgiotBackgroundData { encoded: String, @@ -35,6 +48,16 @@ impl NgiotBackground { self.data.as_ref().map(|data| (data.x_min, data.y_max)) } + pub(crate) fn overlay_svg_offset(&self) -> Option<(f32, f32)> { + self.data.as_ref().map(|data| { + let cell_size = data.resolution as f32 / WORLD_PIXEL_WIDTH; + ( + OVERLAY_OFFSET_X as f32 * cell_size, + OVERLAY_OFFSET_Y as f32 * cell_size, + ) + }) + } + pub(crate) fn has_data(&self) -> bool { self.data.is_some() } @@ -124,14 +147,16 @@ impl NgiotBackground { let viewbox = ViewBox::from_extents(left, top, left + width_svg, top + height_svg); debug!( - "Generated NGIOT raster background: map {}x{} at world ({}, {}) size ({}, {}), direction={}", + "Generated NGIOT raster background: map {}x{} at world ({}, {}) size ({}, {}), direction={}, overlay_offset_cells=({}, {})", data.width, data.height, left, top, width_svg, height_svg, - data.direction + data.direction, + OVERLAY_OFFSET_X, + OVERLAY_OFFSET_Y, ); Ok(Some((general_purpose::STANDARD.encode(&png_data), viewbox))) diff --git a/src/map/points.rs b/src/map/points.rs index 4ec5ed285..2e8f72de1 100644 --- a/src/map/points.rs +++ b/src/map/points.rs @@ -132,11 +132,16 @@ fn trace_point_to_point( trace_point: &TracePoint, rotation: RotationAngle, ngiot_origin: Option<(i32, i32)>, + overlay_svg_offset: Option<(f32, f32)>, ) -> Point { if let Some((x_min, y_max)) = ngiot_origin { let world_x = x_min as f32 + trace_point.x as f32; let world_y = y_max as f32 - trace_point.y as f32; let mut point = calc_point(world_x, world_y, rotation); + if let Some((dx, dy)) = overlay_svg_offset { + point.x += dx; + point.y += dy; + } point.connected = trace_point.connected; return point; } @@ -147,11 +152,16 @@ fn trace_point_to_point( RotationAngle::Deg180 => (-(trace_point.x as f32), -(trace_point.y as f32)), RotationAngle::Deg270 => (-(trace_point.y as f32), trace_point.x.into()), }; - Point { + let mut point = Point { x, y, connected: trace_point.connected, + }; + if let Some((dx, dy)) = overlay_svg_offset { + point.x += dx; + point.y += dy; } + point } #[pyclass] @@ -205,6 +215,7 @@ impl TracePoints { &self, rotation: RotationAngle, ngiot_origin: Option<(i32, i32)>, + overlay_svg_offset: Option<(f32, f32)>, ) -> Option { if self.trace_points.is_empty() { return None; @@ -214,7 +225,7 @@ impl TracePoints { &self .trace_points .iter() - .map(|tp| trace_point_to_point(tp, rotation, ngiot_origin)) + .map(|tp| trace_point_to_point(tp, rotation, ngiot_origin, overlay_svg_offset)) .collect::>(), false, false, @@ -320,7 +331,7 @@ mod tests { }, ]); - let path = trace_points.get_path(rotation, None).unwrap(); + let path = trace_points.get_path(rotation, None, None).unwrap(); assert_eq!(path.get_attributes().get("d").unwrap(), expected); } @@ -341,7 +352,7 @@ mod tests { ]); let path = trace_points - .get_path(RotationAngle::Deg0, Some((1000, 2000))) + .get_path(RotationAngle::Deg0, Some((1000, 2000)), None) .unwrap(); assert_eq!(path.get_attributes().get("d").unwrap(), "M22-36l1-2"); @@ -364,7 +375,7 @@ mod tests { }, ]); - let path = trace_points.get_path(RotationAngle::Deg0, None).unwrap(); + let path = trace_points.get_path(RotationAngle::Deg0, None, None).unwrap(); assert_eq!( path.get_attributes().get("transform").unwrap(), "scale(0.2 -0.2)" From b61ac976a2c3450a670e6cf22da935d3919020ec Mon Sep 17 00:00:00 2001 From: Astute4185 Date: Fri, 10 Apr 2026 09:27:04 +1000 Subject: [PATCH 37/43] Refactor visibility of calc_point and generate functions for improved encapsulation; add tests for ngiot_map_parser functionality --- src/map/mod.rs | 2 +- src/map/ngiot_background.rs | 2 +- tests/test_ngiot_map_parser.py | 57 ++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 tests/test_ngiot_map_parser.py diff --git a/src/map/mod.rs b/src/map/mod.rs index 3b9376276..6637a157f 100644 --- a/src/map/mod.rs +++ b/src/map/mod.rs @@ -28,7 +28,7 @@ const LEGACY_POSITION_ICON_SCALE: f32 = 1.0; const NGIOT_POSITION_ICON_SCALE: f32 = 0.18; #[inline] -pub(super) fn calc_point(x: f32, y: f32, rotation: RotationAngle) -> Point { +fn calc_point(x: f32, y: f32, rotation: RotationAngle) -> Point { let (px, py) = match rotation { RotationAngle::Deg0 => (x / PIXEL_WIDTH, -y / PIXEL_WIDTH), RotationAngle::Deg90 => (y / PIXEL_WIDTH, x / PIXEL_WIDTH), diff --git a/src/map/ngiot_background.rs b/src/map/ngiot_background.rs index 918238541..ce1a33837 100644 --- a/src/map/ngiot_background.rs +++ b/src/map/ngiot_background.rs @@ -103,7 +103,7 @@ impl NgiotBackground { true } - pub(crate) fn generate(&self) -> Result> { + pub(super) fn generate(&self) -> Result> { let Some(data) = self.data.as_ref() else { return Ok(None); }; diff --git a/tests/test_ngiot_map_parser.py b/tests/test_ngiot_map_parser.py new file mode 100644 index 000000000..236376704 --- /dev/null +++ b/tests/test_ngiot_map_parser.py @@ -0,0 +1,57 @@ +from deebot_client.ngiot_map_parser import parse_base_map + + +def test_parse_base_map_prefers_map_field_and_captures_lz4_len() -> None: + payload = { + "mapId": "4", + "mapData": { + "mapId": "4", + "map": "ENCODED_MAP_PAYLOAD", + "data": "LEGACY_DATA_SHOULD_NOT_WIN", + "lz4Len": 21168, + "width": 144, + "height": 147, + "totalWidth": 800, + "totalHeight": 800, + "resolution": 5, + "xMin": 383, + "yMax": 493, + }, + } + + base_map = parse_base_map(payload) + + assert base_map is not None + assert base_map.map_id == "4" + assert base_map.encoded == "ENCODED_MAP_PAYLOAD" + assert base_map.lz4_len == 21168 + assert base_map.width == 144 + assert base_map.height == 147 + assert base_map.total_width == 800 + assert base_map.total_height == 800 + assert base_map.resolution == 5 + assert base_map.x_min == 383 + assert base_map.y_max == 493 + + +def test_parse_base_map_falls_back_to_legacy_data_field() -> None: + payload = { + "mapId": "3", + "mapData": { + "data": "LEGACY_ONLY_PAYLOAD", + "width": 138, + "height": 151, + "totalWidth": 800, + "totalHeight": 800, + "resolution": 5, + "xMin": 372, + "yMax": 491, + }, + } + + base_map = parse_base_map(payload) + + assert base_map is not None + assert base_map.map_id == "3" + assert base_map.encoded == "LEGACY_ONLY_PAYLOAD" + assert base_map.lz4_len is None \ No newline at end of file From 91ccfe6e9bf405a3cdd8ae0493df131b7f30e68a Mon Sep 17 00:00:00 2001 From: Astute4185 Date: Fri, 10 Apr 2026 09:32:31 +1000 Subject: [PATCH 38/43] Reorder capability imports for improved organization and clarity --- deebot_client/hardware/eyfj07.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/deebot_client/hardware/eyfj07.py b/deebot_client/hardware/eyfj07.py index d08441ca2..7dc46dcd9 100644 --- a/deebot_client/hardware/eyfj07.py +++ b/deebot_client/hardware/eyfj07.py @@ -15,12 +15,13 @@ CapabilityEvent, CapabilityExecute, CapabilityLifeSpan, - CapabilitySetTypes, - CapabilitySettings, + CapabilityMap, + CapabilitySet, CapabilitySetEnable, + CapabilitySettings, + CapabilitySetTypes, CapabilityStats, DeviceType, - CapabilityMap, ) from deebot_client.const import DataType from deebot_client.events import ( From 3054c01a81615aba8fff11a8c7bd282151c2afad Mon Sep 17 00:00:00 2001 From: Astute4185 Date: Fri, 10 Apr 2026 10:29:44 +1000 Subject: [PATCH 39/43] Add APN_RESET_CONSUMABLE constant and refactor life span commands for NGIOT integration --- deebot_client/commands/ngiot/life_span.py | 83 ++++++++++++++++------- deebot_client/ngiot_client.py | 1 + 2 files changed, 60 insertions(+), 24 deletions(-) diff --git a/deebot_client/commands/ngiot/life_span.py b/deebot_client/commands/ngiot/life_span.py index 7d02e031e..a34977b8c 100644 --- a/deebot_client/commands/ngiot/life_span.py +++ b/deebot_client/commands/ngiot/life_span.py @@ -3,58 +3,93 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any +from typing import TYPE_CHECKING, Any from deebot_client.events import LifeSpan, LifeSpanEvent -from deebot_client.exceptions import ApiError -from deebot_client.message import HandlingResult +from deebot_client.message import HandlingResult, HandlingState +from deebot_client.ngiot_client import APN_RESET_CONSUMABLE -from .common import RobotDetailGetCommand, RobotDetailSetCommand +from .common import NgiotExecuteCommand, RobotDetailGetCommand + +if TYPE_CHECKING: + from deebot_client.event_bus import EventBus + from deebot_client.models import ApiDeviceInfo + from deebot_client.ngiot_client import NgiotClient _CONSUMABLE_TYPES: dict[str, LifeSpan] = { - 'rollBrush': LifeSpan.BRUSH, - 'filter': LifeSpan.FILTER, - 'sideBrush': LifeSpan.SIDE_BRUSH, - 'unitCare': LifeSpan.UNIT_CARE, + "rollBrush": LifeSpan.BRUSH, + "filter": LifeSpan.FILTER, + "sideBrush": LifeSpan.SIDE_BRUSH, + "unitCare": LifeSpan.UNIT_CARE, +} + +_RESET_CONSUMABLE_TYPES: dict[LifeSpan, str] = { + LifeSpan.BRUSH: "rollBrush", + LifeSpan.FILTER: "filter", + LifeSpan.SIDE_BRUSH: "sideBrush", + LifeSpan.UNIT_CARE: "unitCare", } class GetLifeSpan(RobotDetailGetCommand): """Get consumable life-span data.""" - NAME = 'getLifeSpan' - FIELDS = ('consumables',) + NAME = "getLifeSpan" + FIELDS = ("consumables",) @classmethod def _handle_body_data_dict( cls, - event_bus, + event_bus: EventBus, data: dict[str, Any], ) -> HandlingResult: - for item in data.get('consumables', []) or []: + for item in data.get("consumables", []) or []: if not isinstance(item, Mapping): continue - consumable_type = _CONSUMABLE_TYPES.get(str(item.get('type'))) + consumable_type = _CONSUMABLE_TYPES.get(str(item.get("type"))) if consumable_type is None: continue - left = int(item.get('left', 0) or 0) - total = int(item.get('total', 0) or 0) + left = int(item.get("left", 0) or 0) + total = int(item.get("total", 0) or 0) percent = 0.0 if total <= 0 else (left / total) * 100 event_bus.notify(LifeSpanEvent(consumable_type, percent, left)) return HandlingResult.success() -class ResetLifeSpan(RobotDetailSetCommand): - """Reset a consumable counter. - - Left intentionally unimplemented until the reset payload is captured. - """ +class ResetLifeSpan(NgiotExecuteCommand): + """Reset a consumable counter via the NGIOT reset-consumable surface.""" - NAME = 'resetLifeSpan' + NAME = "resetLifeSpan" + get_command = GetLifeSpan def __init__(self, life_span: LifeSpan) -> None: - super().__init__({'lifeSpan': life_span.name}) + super().__init__({}) self._life_span = life_span - def _get_body_data(self) -> dict[str, Any]: - raise ApiError('Life-span reset payload has not been captured for NGIOT yet') + async def _request_ngiot( + self, + client: NgiotClient, + device_info: ApiDeviceInfo, + ) -> dict[str, Any]: + try: + reset_consumable = _RESET_CONSUMABLE_TYPES[self._life_span] + except KeyError as ex: + raise ValueError( + f"Life-span reset is not supported for NGIOT consumable {self._life_span!s}" + ) from ex + + return await client.request( + device_info, + apn=APN_RESET_CONSUMABLE, + body_data={"resetConsumable": reset_consumable}, + ) + + def _handle_response( + self, + event_bus: EventBus, + response: dict[str, Any], + ) -> HandlingResult: + result = super()._handle_response(event_bus, response) + if result.state == HandlingState.SUCCESS: + event_bus.request_refresh(LifeSpanEvent) + return result \ No newline at end of file diff --git a/deebot_client/ngiot_client.py b/deebot_client/ngiot_client.py index bf5f9a913..f1d6ec489 100644 --- a/deebot_client/ngiot_client.py +++ b/deebot_client/ngiot_client.py @@ -38,6 +38,7 @@ APN_RETURN_TO_DOCK = "40013" APN_CANCEL_RETURN = "40015" APN_DEVICE_LOCATE = "40019" +APN_RESET_CONSUMABLE = "50017" APN_CHILD_LOCK = "50038" From d6e1a24af3307f839cba2a1e408a636fdc1aa9ae Mon Sep 17 00:00:00 2001 From: Astute4185 Date: Fri, 10 Apr 2026 12:56:06 +1000 Subject: [PATCH 40/43] Add NGIOT integration tests for clean, stats, and map state functionalities - Implement tests for clean commands including state mapping and request generation. - Add tests for statistics retrieval and validation. - Introduce tests for NGIOT map state management, ensuring proper snapshot normalization. - Enhance MQTT client tests to cover NGIOT topic handling and message routing. - Include utility tests for decompression functions with real payloads. --- tests/commands/ngiot/test_clean.py | 88 ++++++++++++++++ tests/commands/ngiot/test_stats.py | 66 ++++++++++++ tests/hardware/test_init.py | 23 +++-- tests/messages/json/test_ngiot.py | 130 ++++++++++++++++++++++++ tests/rs/test_util.py | 57 +++++------ tests/test_mqtt_client_ngiot.py | 79 +++++++++++++++ tests/test_ngiot_client.py | 156 +++++++++++++++++++++++++++++ tests/test_ngiot_map_state.py | 59 +++++++++++ 8 files changed, 621 insertions(+), 37 deletions(-) create mode 100644 tests/commands/ngiot/test_clean.py create mode 100644 tests/commands/ngiot/test_stats.py create mode 100644 tests/messages/json/test_ngiot.py create mode 100644 tests/test_mqtt_client_ngiot.py create mode 100644 tests/test_ngiot_client.py create mode 100644 tests/test_ngiot_map_state.py diff --git a/tests/commands/ngiot/test_clean.py b/tests/commands/ngiot/test_clean.py new file mode 100644 index 000000000..c7ee3669d --- /dev/null +++ b/tests/commands/ngiot/test_clean.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from deebot_client.commands.ngiot.clean import Clean, CleanArea, map_live_state, map_snapshot_state +from deebot_client.exceptions import ApiError +from deebot_client.models import CleanAction, CleanMode, State +from deebot_client.ngiot_client import ( + APN_AREA_CLEAN, + APN_CLEAN_START, + APN_PAUSE, + APN_RESUME, + APN_RETURN_TO_DOCK, +) + + +@pytest.mark.parametrize( + ("payload", "expected"), + [ + ({"workMode": "smart", "pauseSwitch": True}, State.PAUSED), + ({"workMode": "goCharge"}, State.RETURNING), + ({"workMode": "idle", "chargeStatus": True}, State.DOCKED), + ({"workMode": "smart", "chargeStatus": False}, State.CLEANING), + ({"workMode": "unknown"}, State.IDLE), + ], +) +def test_map_snapshot_state(payload: dict[str, object], expected: State) -> None: + assert map_snapshot_state(payload) is expected + + +@pytest.mark.parametrize( + ("payload", "previous", "expected"), + [ + ({"status": "smartclean", "pauseSwitch": True}, None, State.PAUSED), + ({"status": "smartclean", "pauseSwitch": False}, None, State.CLEANING), + ({"status": "idle", "chargeStatus": True}, None, State.DOCKED), + ({"pauseSwitch": False}, State.PAUSED, State.CLEANING), + ({"workMode": "goCharge"}, None, State.RETURNING), + ({"status": "unknown"}, None, None), + ], +) +def test_map_live_state( + payload: dict[str, object], previous: State | None, expected: State | None +) -> None: + assert map_live_state(payload, previous=previous) is expected + + +@pytest.mark.parametrize( + ("action", "expected"), + [ + (CleanAction.START, (APN_CLEAN_START, {"cleanSwitch": True, "cleanMode": "smart"})), + (CleanAction.PAUSE, (APN_PAUSE, {"pauseSwitch": True})), + (CleanAction.RESUME, (APN_RESUME, {"pauseSwitch": False})), + (CleanAction.STOP, (APN_RETURN_TO_DOCK, {"chargeSwitch": True})), + ], +) +def test_clean_get_request(action: CleanAction, expected: tuple[str, dict[str, object]]) -> None: + assert Clean(action)._get_request() == expected + + +@pytest.mark.asyncio +async def test_clean_area_requires_supported_mode() -> None: + command = CleanArea(CleanMode.AUTO, [1]) + with pytest.raises(ApiError, match="room-id cleaning only"): + await command._request_ngiot(None, None) # type: ignore[arg-type] + + +@pytest.mark.asyncio +async def test_clean_area_requires_single_cleaning() -> None: + command = CleanArea(CleanMode.SPOT_AREA, [1], cleanings=2) + with pytest.raises(ApiError, match="repeat count"): + await command._request_ngiot(None, None) # type: ignore[arg-type] + + +@pytest.mark.asyncio +async def test_clean_area_builds_area_request() -> None: + client = type("Client", (), {"request": AsyncMock(return_value={"body": {}})})() + command = CleanArea(CleanMode.SPOT_AREA, [1, 2]) + + await command._request_ngiot(client, None) # type: ignore[arg-type] + + client.request.assert_awaited_once_with( + None, + apn=APN_AREA_CLEAN, + body_data={"cleanSwitch": True, "cleanMode": "area", "cleanValues": [1, 2]}, + ) diff --git a/tests/commands/ngiot/test_stats.py b/tests/commands/ngiot/test_stats.py new file mode 100644 index 000000000..22a9f9c5d --- /dev/null +++ b/tests/commands/ngiot/test_stats.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from unittest.mock import Mock + +from deebot_client.commands.ngiot.stats import GetReportStats, GetStats, GetTotalStats +from deebot_client.event_bus import EventBus +from deebot_client.events import CleanJobStatus, ReportStatsEvent, StatsEvent, TotalStatsEvent +from deebot_client.message import HandlingResult, HandlingState + + +def test_get_stats_converts_minutes_to_seconds() -> None: + event_bus = Mock(spec_set=EventBus) + + result = GetStats._handle_body_data_dict( + event_bus, + {"cleanArea": "25", "cleanTime": "12", "workMode": "smart"}, + ) + + assert result == HandlingResult(HandlingState.SUCCESS) + event_bus.notify.assert_called_once_with( + StatsEvent(area=25, time=720, type="smart") + ) + + +def test_get_report_stats_extracts_cleaning_id() -> None: + event_bus = Mock(spec_set=EventBus) + + result = GetReportStats._handle_body_data_dict( + event_bus, + { + "cleanArea": 15, + "cleanTime": 7, + "workMode": "area", + "cleanLogReport": {"cid": "job-123"}, + }, + ) + + assert result == HandlingResult(HandlingState.SUCCESS) + event_bus.notify.assert_called_once_with( + ReportStatsEvent( + area=15, + time=420, + type="area", + cleaning_id="job-123", + status=CleanJobStatus.NO_STATUS, + content=[], + ) + ) + + +def test_get_total_stats_uses_total_fields_and_fallbacks() -> None: + event_bus = Mock(spec_set=EventBus) + + result = GetTotalStats._handle_body_data_dict( + event_bus, + { + "cleanAreaTotal": "101", + "cleanTime": 8, + "cleanCount": "9", + }, + ) + + assert result == HandlingResult(HandlingState.SUCCESS) + event_bus.notify.assert_called_once_with( + TotalStatsEvent(area=101, time=480, cleanings=9) + ) diff --git a/tests/hardware/test_init.py b/tests/hardware/test_init.py index a812d00eb..8c4c695af 100644 --- a/tests/hardware/test_init.py +++ b/tests/hardware/test_init.py @@ -92,7 +92,10 @@ from deebot_client.command import Command from deebot_client.events.base import Event from deebot_client.commands.ngiot.battery import GetBattery as GetNgiotBattery +from deebot_client.commands.ngiot.child_lock import GetChildLock as GetNgiotChildLock from deebot_client.commands.ngiot.clean import GetCleanInfo as GetNgiotCleanInfo +from deebot_client.commands.ngiot.fan_speed import GetFanSpeed as GetNgiotFanSpeed +from deebot_client.commands.ngiot.life_span import GetLifeSpan as GetNgiotLifeSpan from deebot_client.commands.ngiot.error import GetError as GetNgiotError from deebot_client.commands.ngiot.map import ( GetCachedMapInfo as GetNgiotCachedMapInfo, @@ -106,6 +109,7 @@ GetStats as GetNgiotStats, GetTotalStats as GetNgiotTotalStats, ) +from deebot_client.commands.ngiot.volume import GetVolume as GetNgiotVolume from deebot_client.hardware.eyfj07 import get_device_info as get_eyfj07_info @pytest.mark.parametrize( @@ -113,6 +117,7 @@ [ ("not_specified", None), ("yna5xi", get_yna5xi_info()), + ("eyfj07", get_eyfj07_info()), ], ) async def test_get_static_device_info( @@ -258,30 +263,30 @@ async def test_get_static_device_info( VolumeEvent: [GetVolume()], WaterAmountEvent: [GetWaterInfo()], }, - ( + ), + ( "eyfj07", - { + { AvailabilityEvent: [GetNgiotBattery(is_available_check=True)], BatteryEvent: [GetNgiotBattery()], CachedMapInfoEvent: [GetNgiotCachedMapInfo()], ChildLockEvent: [GetNgiotChildLock()], CustomCommandEvent: [], ErrorEvent: [GetNgiotError()], - FanSpeedEvent: [GetFanSpeed()], - LifeSpanEvent: [GetLifeSpan([LifeSpan.BRUSH, LifeSpan.FILTER, LifeSpan.SIDE_BRUSH, LifeSpan.UNIT_CARE])], + FanSpeedEvent: [GetNgiotFanSpeed()], + LifeSpanEvent: [GetNgiotLifeSpan()], MajorMapEvent: [GetNgiotMajorMap()], MapChangedEvent: [], MapTraceEvent: [GetNgiotMapTrace()], NetworkInfoEvent: [GetNgiotNetInfo()], PositionsEvent: [GetNgiotPos()], ReportStatsEvent: [GetNgiotReportStats()], - RoomsEvent: [GetNgiotCachedMapInfo()], + RoomsEvent: [GetNgiotMajorMap()], StateEvent: [GetNgiotCleanInfo()], StatsEvent: [GetNgiotStats()], TotalStatsEvent: [GetNgiotTotalStats()], - } - -), + VolumeEvent: [GetNgiotVolume()], + }, ), ], ids=["5xu9h3", "itk04l", "yna5xi", "p95mgv", "eyfj07"], @@ -315,4 +320,4 @@ async def test_all_models_loaded() -> None: device_info = await hardware.get_static_device_info(module_name) assert isinstance(device_info, StaticDeviceInfo), ( f"Failed to load device info for {module_name}" - ) + ) \ No newline at end of file diff --git a/tests/messages/json/test_ngiot.py b/tests/messages/json/test_ngiot.py new file mode 100644 index 000000000..a4432c303 --- /dev/null +++ b/tests/messages/json/test_ngiot.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from unittest.mock import Mock, patch + +from deebot_client.event_bus import EventBus +from deebot_client.events import StateEvent +from deebot_client.message import HandlingResult, HandlingState +from deebot_client.messages import get_message +from deebot_client.messages.json.ngiot import OnNgiotMapEvent, OnNgiotStatusEvent +from deebot_client.models import State + + +def test_get_message_resolves_ngiot_numeric_topics() -> None: + from deebot_client.const import DataType + + assert get_message("10000", DataType.JSON) is OnNgiotStatusEvent + assert get_message("30000", DataType.JSON) is OnNgiotMapEvent + + +def test_on_ngiot_map_event_dispatches_major_and_trace_handlers() -> None: + event_bus = Mock(spec_set=EventBus) + payload = {"body": {"data": {"mapData": {}, "mapTraceData": {}}}} + + with ( + patch( + "deebot_client.messages.json.ngiot.GetMajorMap._handle_body_data_dict", + return_value=HandlingResult.success(), + ) as handle_major, + patch( + "deebot_client.messages.json.ngiot.GetMapTrace._handle_body_data_dict", + return_value=HandlingResult.success(), + ) as handle_trace, + ): + result = OnNgiotMapEvent.handle(event_bus, payload) + + assert result.state == HandlingState.SUCCESS + handle_major.assert_called_once_with(event_bus, payload["body"]["data"]) + handle_trace.assert_called_once_with(event_bus, payload["body"]["data"]) + + +def test_on_ngiot_map_event_dispatches_pos_only_handler() -> None: + event_bus = Mock(spec_set=EventBus) + payload = {"body": {"data": {"pos": {}}}} + + with patch( + "deebot_client.messages.json.ngiot.GetPos._handle_body_data_dict", + return_value=HandlingResult.success(), + ) as handle_pos: + result = OnNgiotMapEvent.handle(event_bus, payload) + + assert result.state == HandlingState.SUCCESS + handle_pos.assert_called_once_with(event_bus, payload["body"]["data"]) + + +def test_on_ngiot_map_event_accepts_minor_only_payload() -> None: + event_bus = Mock(spec_set=EventBus) + + result = OnNgiotMapEvent.handle( + event_bus, + {"body": {"data": {"mapMinorData": {"piece": 1}}}}, + ) + + assert result.state == HandlingState.SUCCESS + event_bus.notify.assert_not_called() + + +def test_on_ngiot_status_event_dispatches_live_state() -> None: + event_bus = Mock(spec_set=EventBus) + event_bus.get_last_event.return_value = StateEvent(State.PAUSED) + payload = { + "body": { + "data": { + "battery": 81, + "cleanArea": 15, + "cleanTime": 12, + "childLock": True, + "volume": 4, + "status": "smartclean", + "pauseSwitch": False, + } + } + } + + with ( + patch( + "deebot_client.messages.json.ngiot.GetBattery._handle_body_data_dict", + return_value=HandlingResult.success(), + ) as handle_battery, + patch( + "deebot_client.messages.json.ngiot.GetStats._handle_body_data_dict", + return_value=HandlingResult.success(), + ) as handle_stats, + patch( + "deebot_client.messages.json.ngiot.GetChildLock._handle_body_data_dict", + return_value=HandlingResult.success(), + ) as handle_child_lock, + patch( + "deebot_client.messages.json.ngiot.GetVolume._handle_body_data_dict", + return_value=HandlingResult.success(), + ) as handle_volume, + ): + result = OnNgiotStatusEvent.handle(event_bus, payload) + + assert result.state == HandlingState.SUCCESS + handle_battery.assert_called_once_with(event_bus, payload["body"]["data"]) + handle_stats.assert_called_once_with(event_bus, payload["body"]["data"]) + handle_child_lock.assert_called_once_with(event_bus, payload["body"]["data"]) + handle_volume.assert_called_once_with(event_bus, payload["body"]["data"]) + event_bus.notify.assert_called_once_with(StateEvent(State.CLEANING)) + + +def test_on_ngiot_status_event_falls_back_to_clean_info_when_state_unresolved() -> None: + event_bus = Mock(spec_set=EventBus) + event_bus.get_last_event.return_value = None + payload = { + "body": { + "data": { + "status": "unknown", + } + } + } + + with patch( + "deebot_client.messages.json.ngiot.GetCleanInfo._handle_body_data_dict", + return_value=HandlingResult.success(), + ) as handle_clean_info: + result = OnNgiotStatusEvent.handle(event_bus, payload) + + assert result.state == HandlingState.SUCCESS + handle_clean_info.assert_called_once_with(event_bus, payload["body"]["data"]) diff --git a/tests/rs/test_util.py b/tests/rs/test_util.py index 70bff9014..e92e79024 100644 --- a/tests/rs/test_util.py +++ b/tests/rs/test_util.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 +import hashlib import lzma from typing import TYPE_CHECKING @@ -34,7 +35,7 @@ ), ( "XQAABACvAAAAAAAAAEINQkt4BfqEvt9Pow7YU9KWRVBcSBosIDAOtACCicHy+vmfexxcutQUhqkAPQlBawOeXo/VSrOqF7yhdJ1JPICUs3IhIebU62Qego0vdk8oObiLh3VY/PVkqQyvR4dHxUDzMhX7HAguZVn3yC17+cQ18N4kaydN3LfSUtV/zejrBM4=", - b'\x00\x00\x01\x00\x98\xf6\xff\x01\x00\x18\xf9\xff\xf8\xff@\x00\x00\xf1\xff@\x06\x00\xe9\xff@\x0b\x00\xe0\xff@\x15\x00\xe2\xff@\x1f\x00\xe2\xff@(\x00\xde\xff@.\x00\xd6\xff@5\x00\xcd\xff@4\x00\xc3\xff@0\x00\xba\xff@,\x00\xb1\xff@"\x00\xad\xff@\x18\x00\xad\xff@\x0e\x00\xae\xff@\x06\x00\xb4\xff@\x00\x00\xbc\xff@\xfe\xff\xc5\xff@\x00\x00\xd0\xff@\x03\x00\xda\xff@\x0b\x00\xe0\xff@\x15\x00\xe3\xff@\x15\x00\xed\xffH\x0e\x00\xf4\xffH\x05\x00\xf9\xffH\x0c\x00\xf2\xffH\x15\x00\xee\xffH\x1f\x00\xec\xffH)\x00\xec\xffH3\x00\xe8\xffH:\x00\xe1\xffH@\x00\xd9\xff@F\x00\xd1\xff@', + b"\x00\x00\x01\x00\x98\xf6\xff\x01\x00\x18\xf9\xff\xf8\xff@\x00\x00\xf1\xff@\x06\x00\xe9\xff@\x0b\x00\xe0\xff@\x15\x00\xe2\xff@\x1f\x00\xe2\xff@(\x00\xde\xff@.\x00\xd6\xff@5\x00\xcd\xff@4\x00\xc3\xff@0\x00\xba\xff@,\x00\xb1\xff@\"\x00\xad\xff@\x18\x00\xad\xff@\x0e\x00\xae\xff@\x06\x00\xb4\xff@\x00\x00\xbc\xff@\xfe\xff\xc5\xff@\x00\x00\xd0\xff@\x03\x00\xda\xff@\x0b\x00\xe0\xff@\x15\x00\xe3\xff@\x15\x00\xed\xffH\x0e\x00\xf4\xffH\x05\x00\xf9\xffH\x0c\x00\xf2\xffH\x15\x00\xee\xffH\x1f\x00\xec\xffH)\x00\xec\xffH3\x00\xe8\xffH:\x00\xe1\xffH@\x00\xd9\xff@F\x00\xd1\xff@", ), ], ids=["1", "2", "3", "4"], @@ -43,13 +44,11 @@ def test_decompress_base64_data_lzma( benchmark: BenchmarkFixture, value: str, expected: bytes ) -> None: """Test decompress_base64_data function with lzma base64 values.""" - # Benchmark only the production function result = benchmark(decompress_base64_data, value) assert result == expected - - # Verify that the old python function is producing the same result assert _decompress_7z_base64_data_python(value) == result + @pytest.mark.parametrize( ("value", "expected"), [ @@ -64,11 +63,25 @@ def test_decompress_base64_data_zstd( benchmark: BenchmarkFixture, value: str, expected: bytes ) -> None: """Test decompress_base64_data function with zstd base64 values.""" - # Benchmark only the production function result = benchmark(decompress_base64_data, value) assert result == expected +_REAL_NGIOT_LZ4_MAP = "H38BAP//8BUAAQAPCwNzIwABAQAFmAAPmgBrB5AAA5kAHwEqAV0AAwEAAgABdwAFkAAGAgAAHAAPAgAAAS8ADwIANghhAAJ7AAMHAQINAA8CAA8DLwAPAgAoCIMABIUABwIAA2EADxoAAA8CAAIDLwAPAgAcAHoABQIAD1gAAgUCAARhAA+yAQAPAgABD7IBIw9dABQAAgAPkAAWBSkADxwBHAACAA+QAEAEjwAPkAAjAKYBD5UAEwRoAA8uAA0EKAAPkAAiAV8AAmIAAJoADwIACQRoAAIuAA8CAAcEKAAPkAAiFwCGAA9pAAwP9wIUAx8FAC4AAO8ADwIAHA+EAAwIAgAHugAPAgAKAIgAAI4ABSgBDwIAGQmQAA9yABUPAgAMAI0ABSgBDwIAHA+QAEEDAgAPIAEnBJUAAdcADwIAKARIAAICAAC4AQ8CAB4BQwADYQEDkAAEDgAPAgAfAU0ABgIADyEBIQR8AgGLAAEFAAAgAwtZAA8CABUHNwAACwAPkAAhAQIABHAABJAAAggADwIAHwCFAAMCAABDAA+QADAA9AEnAH9YAA8CABoHOAAACwAPkAAjADoAAwIAApAACRMADwIAGgpLAAD5AQ8CAB8AWQEEAgAE0QAHWAAPAgAaBzgALwABkAAjBY8AABwBD5AALQcCAACIBgRPAA8CABkE0wABjwAEQQAEFQAPAgAeCjkABE8ADwIAGQNCAACPAAEGAAE8AADdAA+MAB4OAgAPkAAnA/MDA48AACsIAKEAAKMADwIAGwjHAABAAAFMAA8CABoCQgADkAALggIPkAAZAkgAAgYABQIADyEBIAFCABV/kAAJ0wABHAAPAgATD7sAAw+QAC4KQwAPkAAmAKsBAD0AD5AAJBAAIgYCAgAVASEBABAAAKEADwIAEwosAAGMAA3/AQ8CABIG5QMAxgIHRAAAVAANAgAPFQABAQsBAL4AAAIAAjIABCcADyABIAkCAABIAAREAAAMAA0CAARxAAgCAACHAAMYAAMHAAEcAQACAARMAA8CACUAhAAERAAADAANAgAEcQAMAgAQABsAASkBAQIABDwABEwADwIAJQBIAABYAABAAAAMAA8CABYAkAAHLQAEPAAPkAAxAG8BD5AAsgQVAQcCAA8gASsFAAIBRwAPXwAADwIABwDlAQ9cAgABSQAPAgAmBYIAAFwABF4AAAoABAwADwIABQHJAADxCAwhAA+QADQIAgAAqgIPkAANAQIAAZUACwIAD5AAQQ9oAAEPAgAVD5AAQA8CACoPkADGD6UBJAD7AgUCAATJBA8CACYHIAMPkAAlC94BD7ABbQfSAAECAA+QAP+rBekCD8YBFQz+AQ8CAB8MQgAGzREAAgATAgEAD5AAawOFAAcCAAqmAAYCAAwZBAzeAA8CAAAPCAQMDEIAAHoAB4kAAwIAAIIAAwsACAIADTcBDE4ADwIAAA+QABMAcQABKgAAuQwIcwAPAgAKD5AAAQFIAAFLAA8CACIBPAAIPwAPkAAtA8MBDyoBEA8CAAMBJgYHAgAPkABtDAIAD5AAuwVzAw+wASYMAgAPIAEqBgIAAJUCDFsADwIAIg+QADQDyw8PAgAvD5AAyA+yATIPIAE3D9UCMw+QADgPIAF8D/MDMw86BwMPAgADDcAGCQIAD5AAMz8BAf99AAMPAgAAD5AApwAfBQECAADxAA8CABwPIAFBBEoBD8QBIQ+QADcBEgEBzQgPkABzA98CAHERBgsADwIAHg8gATcGhQAPIAGjAd0BDWMCDyABCBoA9QME9gAPAgADBR4AAGIAAyMAAAsADwIABwEeAA2SAA+QAAgLkQAFcgAPAgAKAeUDAyIAAgIBBwIACZ0AAZAADZIAD5AACgm+BQ+QABMWf48ACAIAARYAA4IAAwIABQ4ABQkAAAIAD5AADBYAtwEPkABvArMBBLYBD5AAEwGeAQgaAQICAA8gAQEBKwAAFhMAGAEEAgAPIAEJAJAABI4AD5AAFQ8CAAECbAAMsAEBAgAArAEABAAFAgAPkAAOBZEADyABEw8CAAEEbQAJeQACAgAEJAEAAgAALQEHcAAPAgACArEJAZQAD5AAJQBrAAICAA+QAAAArwEEFwACbhwBogADSQMACQANAgABtgEAAgAP+wABDwIADwF0AAICAAR5AAECAABSAAkRAAGZAAT1AAECARQCDgAHAgABGQAPGAAADwIAFQF8AA8CAAEAVAABlAAIAgAG+wYBrgEAJAAKAgAAHwEBMQAKFwAPAgAVBYkAAUQADQIADEcABsMhAAcBD6oTAgGoAA+qAAEPAgAQBY4ABoYeAwIAAvkAAgYABwIABZAAAHMAAQIABx0AAgIAARYAD5AAIwaPAAJLAAcCAAKKAAIGAAcCAAeQAANpAwcdAAICAACNAA9aAgMPAgANBq0CDioAAooADpEBA0ACIAACKhMAtAEcAjwAAY4ADyABIgWQAAxTAA8CAAgEkAABIwABkAAMkQAAjQAPIAEiAN4EAQIAAVwADwIABQIpAQUCAAOQAAICAAKQAAuRAAGOAA8gASEBjAABtwYPAgAYCnMDAHkAAAIACEEAARAAD5AAHwGLAAACAAhMAA0CAAseAQwCAAB8AAECAAcZAAA0BAC9EAcTAA8CABEAiwABAgAPLQARDwIABQGMAAECAA8iAAUPAgAVAMYAAgIADzIAFQ8CAAIBjAABAgAPHwACDwIAXgGLAAICAA98AF4PAgACAowAAQIADyAAAg8CAF0BigAEAgAPfQBdDwIAAQSMAAACAA8gAAEPAgBdAIgABgIAD34AXQ4CAAaMAAECAA4hAA8CAF0BhwAFAgAPfgBdDgIABYsAAAIAD88ELA8CADEAhwAFAgAPYAYMDwIAUgWNAA8fAXIKkAAAEAAPAgBtABkBAwIAD68BcQiRAA+QAP8EE3+uAQ9+BHEBAgAAiwABAgABDgAPAgBwAY0ADdoLDwIAZwGNAAACAA/RAnMGQQIL/AwPAgBmABwBAQIAD4IAZgoCAAGMAAECAAoYAA8CAGUBiwABAgAPggBlCwIAAYwAAQIACxkADwIAZAGLAAICAA+CAGQLAgACjAAAAgALGQAPAgBkAIoAAwIAD4IAZAoCAAOMAAACAAoZAA8CAGUAigADAgAPgwBlCQIAA4wAAQIACRkADwIAZQGKAAICAA+DAGUJAgACiwACAgAJGQAPAgBkAooAAgIAD4MAZAoCAAKLAAICAAoaAA8CAGMCigADAgAPgwBjCgIAA4sAAQIAChoADwIAYwGJAAQCAA+DAGMJAgAEiwACAgAJGwAPAgBjAokAAwIAD4MAYwkCAAOKAAMCAAkbAA8CAGIDiQADAgAPgwBiCgIAA4oAAwIAChwADwIAYgOKAAMCAA+DAGIJAgADiQADAgAJGwAPAgBiAIQAA40AAAIAD4QAYgkCAAGiDgCLAAECAAkbAA8CAGcBjAABAgAPhABnCgIAAY0AD9APdwWRAAALAA8CAHIPiwB4BAIAUH9/f39/" +_REAL_NGIOT_LZ4_LEN = 21168 +_REAL_NGIOT_LZ4_SHA256 = "11ef0e79e46c3d7617b2d1a4f3688159a94b7aea2fd24c2bee2474e0a3ea18af" + + +def test_decompress_base64_lz4_data_real_payload() -> None: + """Test dedicated NGIOT LZ4 helper against an observed map payload.""" + result = decompress_base64_lz4_data(_REAL_NGIOT_LZ4_MAP, _REAL_NGIOT_LZ4_LEN) + + assert len(result) == _REAL_NGIOT_LZ4_LEN + assert hashlib.sha256(result).hexdigest() == _REAL_NGIOT_LZ4_SHA256 + assert result[:16] == b"\x7f" * 16 + assert set(result).issuperset({0, 1, 2, 127, 255}) + + @pytest.mark.parametrize( ("value", "expected_error"), [ @@ -86,16 +99,10 @@ def test_decompress_base64_data_zstd( ), ], ) - -def test_decompress_base64_lz4_data() -> None: - """Test dedicated NGIOT LZ4 helper.""" - import lz4.block - - expected = b"0,0;100,100;200,200" - compressed = lz4.block.compress(expected, store_size=False) - value = base64.b64encode(compressed).decode() - - assert decompress_base64_lz4_data(value, len(expected)) == expected +def test_decompress_base64_data_errors(value: str, expected_error: str) -> None: + """Test decompress_base64_data function.""" + with pytest.raises(ValueError, match=expected_error): + decompress_base64_data(value) @pytest.mark.parametrize( @@ -106,18 +113,19 @@ def test_decompress_base64_lz4_data() -> None: (base64.b64encode(b"abc").decode(), 10, "LZ4 decompress failed"), ], ) - -def test_decompress_base64_data_errors(value: str, expected_error: str) -> None: - """Test decompress_base64_data function.""" +def test_decompress_base64_lz4_data_errors( + value: str, expected_len: int, expected_error: str +) -> None: + """Test NGIOT LZ4 helper failure cases.""" with pytest.raises(ValueError, match=expected_error): - assert decompress_base64_data(value) + decompress_base64_lz4_data(value, expected_len) + def _decompress_7z_base64_data_python(data: str) -> bytes: """Decompress base64 decoded 7z compressed string.""" final_array = bytearray() - # Decode Base64 decoded = base64.b64decode(data) for i, idx in enumerate(decoded): @@ -126,11 +134,4 @@ def _decompress_7z_base64_data_python(data: str) -> bytes: final_array.append(idx) dec = lzma.LZMADecompressor(lzma.FORMAT_AUTO, None, None) - return dec.decompress(final_array) - -def test_decompress_base64_lz4_data_errors( - value: str, expected_len: int, expected_error: str -) -> None: - """Test NGIOT LZ4 helper failure cases.""" - with pytest.raises(ValueError, match=expected_error): - decompress_base64_lz4_data(value, expected_len) + return dec.decompress(final_array) \ No newline at end of file diff --git a/tests/test_mqtt_client_ngiot.py b/tests/test_mqtt_client_ngiot.py new file mode 100644 index 000000000..18d01f225 --- /dev/null +++ b/tests/test_mqtt_client_ngiot.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from unittest.mock import Mock + +from unittest.mock import AsyncMock + +from deebot_client.mqtt_client import MqttClient, MqttConfiguration, SubscriberInfo, _get_topics + + +def test_get_topics_adds_ngiot_user_topics(device_info) -> None: + topics = _get_topics(device_info, "user-123") + + assert f"iot/atr/+/user-123/{device_info.api['class']}/{device_info.api['did']}/{device_info.static.data_type}" in topics + assert f"iot/atr/+/user-123/{device_info.api['class']}/{device_info.api['resource']}/{device_info.static.data_type}" in topics + assert len(topics) == len(set(topics)) + + +def test_topic_matches_device_supports_legacy_and_ngiot_shapes(device_info) -> None: + legacy = [ + "iot", + "atr", + "onBattery", + device_info.api["did"], + device_info.api["class"], + device_info.api["resource"], + str(device_info.static.data_type), + ] + ngiot_did = [ + "iot", + "atr", + "10000", + "user-123", + device_info.api["class"], + device_info.api["did"], + str(device_info.static.data_type), + ] + ngiot_resource = [ + "iot", + "atr", + "30000", + "user-123", + device_info.api["class"], + device_info.api["resource"], + str(device_info.static.data_type), + ] + + assert MqttClient._topic_matches_device(legacy, device_info) is True + assert MqttClient._topic_matches_device(ngiot_did, device_info) is True + assert MqttClient._topic_matches_device(ngiot_resource, device_info) is True + assert MqttClient._topic_matches_device(ngiot_resource[:-1] + ["x"], device_info) is False + + +def test_handle_atr_routes_numeric_ngiot_topics(authenticator, device_info, event_bus) -> None: + config = MqttConfiguration(hostname="localhost", port=1883, ssl_context=None, device_id="test-device") + authenticator.subscribe = AsyncMock() + client = MqttClient(config, authenticator) + callback = Mock() + client._subscriptions[device_info.api["did"]] = SubscriberInfo( + device_info=device_info, + events=event_bus, + callback=callback, + ) + + client._handle_atr( + [ + "iot", + "atr", + "30000", + "user-123", + device_info.api["class"], + device_info.api["resource"], + str(device_info.static.data_type), + ], + b'{"body":{"data":{"status":"smartclean"}}}', + ) + + callback.assert_called_once_with( + "30000", b'{"body":{"data":{"status":"smartclean"}}}' + ) diff --git a/tests/test_ngiot_client.py b/tests/test_ngiot_client.py new file mode 100644 index 000000000..0c44f483c --- /dev/null +++ b/tests/test_ngiot_client.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from collections.abc import Mapping +from unittest.mock import AsyncMock, Mock + +import pytest +from aiohttp import ClientResponseError, RequestInfo +from multidict import CIMultiDictProxy, CIMultiDict +from yarl import URL + +from deebot_client.exceptions import ApiError +from deebot_client.ngiot_client import NgiotClient, NgiotDeviceIdentity + + +def _request_info() -> RequestInfo: + return RequestInfo( + url=URL("https://api.example.com/api/iot/endpoint/control"), + method="POST", + headers=CIMultiDictProxy(CIMultiDict()), + real_url=URL("https://api.example.com/api/iot/endpoint/control"), + ) + + +@pytest.fixture +def sst_authenticator() -> AsyncMock: + auth = AsyncMock() + auth.get_token.return_value = "sst-token" + return auth + + +@pytest.fixture +def ngiot_client(sst_authenticator: AsyncMock) -> NgiotClient: + return NgiotClient(Mock(), sst_authenticator) + + +def test_normalize_device_uses_override_host_and_keeps_service_host_as_fallback( + ngiot_client: NgiotClient, +) -> None: + ngiot_client._override_control_host = "override.example.com" + + identity = ngiot_client._normalize_device( + { + "did": "did-1", + "class": "eyfj07", + "resource": "res-1", + "service": {"mqs": "service.example.com"}, + } + ) + + assert identity == NgiotDeviceIdentity( + did="did-1", + class_id="eyfj07", + resource="res-1", + control_host="override.example.com", + fallback_control_host="service.example.com", + ) + assert identity.base_url == "https://override.example.com" + + +def test_normalize_device_requires_control_host(ngiot_client: NgiotClient) -> None: + with pytest.raises(ApiError, match="Missing NGIOT control host"): + ngiot_client._normalize_device( + { + "did": "did-1", + "class": "eyfj07", + "resource": "res-1", + } + ) + + +@pytest.mark.parametrize( + ("apn", "body_data", "expected"), + [ + ( + "10001", + {"fields": ["battery"]}, + {"fields": ["battery"], "type": "get"}, + ), + ( + "30001", + {"fields": ["mapData"]}, + {"fields": ["mapData"], "mapId": "0"}, + ), + ], + ids=["robot-detail", "map-details"], +) +def test_build_payload_adds_ngiot_defaults( + ngiot_client: NgiotClient, + apn: str, + body_data: dict[str, object], + expected: dict[str, object], +) -> None: + payload = ngiot_client._build_payload(apn, body_data) + + assert isinstance(payload, Mapping) + assert payload["reqId"] + assert payload["timestamp"] + for key, value in expected.items(): + assert payload[key] == value + + +@pytest.mark.asyncio +async def test_request_with_fallback_retries_on_404() -> None: + client = NgiotClient(Mock(), AsyncMock()) + identity = NgiotDeviceIdentity( + did="did-1", + class_id="eyfj07", + resource="res-1", + control_host="api.example.com", + fallback_control_host="service.example.com", + ) + response_error = ClientResponseError( + request_info=_request_info(), + history=(), + status=404, + message="not found", + ) + client._request_once = AsyncMock( + side_effect=[response_error, {"body": {"code": 0, "data": {"ok": True}}}] + ) + + response = await client._request_with_fallback( + identity, + {"did": "did-1", "class": "eyfj07", "resource": "res-1"}, + apn="30001", + body_data={"fields": ["mapData"]}, + fmt="j", + ct="q", + force_sst_refresh=False, + ) + + assert response == {"body": {"code": 0, "data": {"ok": True}}} + assert client._request_once.await_count == 2 + first_identity = client._request_once.await_args_list[0].args[0] + second_identity = client._request_once.await_args_list[1].args[0] + assert first_identity.control_host == "api.example.com" + assert second_identity.control_host == "service.example.com" + assert second_identity.fallback_control_host is None + + +@pytest.mark.parametrize( + ("response", "should_raise"), + [ + ({"body": {"code": 0}}, False), + ({"body": {"code": "0000"}}, False), + ({"body": {"code": None}}, False), + ({"body": {"code": 500, "msg": "fail"}}, True), + ({}, True), + ], +) +def test_validate_response(response: dict[str, object], should_raise: bool) -> None: + if should_raise: + with pytest.raises(ApiError): + NgiotClient._validate_response(response) + else: + NgiotClient._validate_response(response) diff --git a/tests/test_ngiot_map_state.py b/tests/test_ngiot_map_state.py new file mode 100644 index 000000000..11e24db81 --- /dev/null +++ b/tests/test_ngiot_map_state.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from deebot_client.ngiot_map_parser import NgiotArea, NgiotBaseMap, NgiotMapInfo, NgiotOverlay, NgiotPoint, NgiotPose, NgiotTrace +from deebot_client.ngiot_map_state import NgiotMapStateStore + + +def test_map_state_store_normalizes_snapshot() -> None: + store = NgiotMapStateStore() + store.update_map_info( + NgiotMapInfo( + map_id="4", + name="Home", + using=True, + angle=0, + charge_pos=NgiotPoint(x=-100, y=300), + ) + ) + store.update_base_map( + NgiotBaseMap( + map_id="4", + width=10, + height=20, + total_width=800, + total_height=800, + resolution=5, + x_min=100, + y_max=200, + direction=1, + encoded="encoded-map", + ) + ) + store.update_pose("4", NgiotPose(x=-95, y=295, a=90)) + store.update_trace("4", NgiotTrace(trace_id="t-1", encoded="trace", lz4_len=32, total_count=2, start=1)) + store.update_areas("4", [NgiotArea(area_id="1", name="Kitchen", polygon=[NgiotPoint(x=-100, y=300)])]) + store.update_overlays("4", [NgiotOverlay(overlay_type="virtual_walls", overlay_id="7", polygon=[NgiotPoint(x=-90, y=290)])]) + + snapshot = store.get_normalized("4") + + assert snapshot is not None + assert snapshot.is_renderable() is True + assert snapshot.charge_pos == NgiotPoint(x=0, y=-24) + assert snapshot.pose == NgiotPose(x=1, y=-23, a=90) + assert snapshot.areas[0].polygon == [NgiotPoint(x=0, y=-24)] + assert snapshot.overlays[0].polygon == [NgiotPoint(x=2, y=-22)] + assert snapshot.trace is not None + assert store.active_map_id == "4" + + +def test_map_state_store_detects_overlay_only_snapshot() -> None: + store = NgiotMapStateStore() + store.update_map_info(NgiotMapInfo(map_id="4", name="Home", using=True, angle=0)) + store.update_trace("4", NgiotTrace(trace_id=None, encoded="trace", lz4_len=None, total_count=0, start=0)) + + snapshot = store.get("4") + + assert snapshot.has_background() is False + assert snapshot.has_overlay_content() is True + assert snapshot.is_overlay_only() is True + assert store.get_active_renderable() is None From 16b5cb2a431c54c8cd8d884c5218f22820883ee4 Mon Sep 17 00:00:00 2001 From: Astute4185 Date: Fri, 10 Apr 2026 13:25:27 +1000 Subject: [PATCH 41/43] Refactor map data handling and update tests for improved clarity and functionality --- deebot_client/map.py | 24 ++++++++++++++-------- tests/commands/ngiot/test_map.py | 8 ++++---- tests/conftest.py | 8 ++++++-- tests/rs/test_map.py | 6 +++--- tests/test_map.py | 34 +++++++++++--------------------- tests/test_message.py | 4 ++-- 6 files changed, 43 insertions(+), 41 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index 849e26081..c972f2086 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -288,8 +288,9 @@ def teardown(self) -> None: def update_positions(self, positions: list[Position]) -> None: """Update positions.""" - if self._positions != positions: - self._positions = positions + new_positions = list(positions) + if self._positions != new_positions: + self._positions = new_positions self._on_change() def set_rotation_angle(self, angle: int | RotationAngle) -> None: @@ -414,8 +415,9 @@ def __init__(self, event_bus: EventBus, on_change: Callable[[], None]) -> None: self._room_names: dict[int, Room] = {} async def on_rooms(event: RoomsEvent) -> None: - if self._room_names != event.rooms: - self._room_names = event.rooms + rooms = {room.id: room for room in event.rooms} + if self._room_names != rooms: + self._room_names = rooms self._on_change() self._unsubscribe = event_bus.subscribe(RoomsEvent, on_rooms) @@ -426,7 +428,13 @@ def teardown(self) -> None: def update_rooms(self, map_subsets: list[MapSubsetEvent]) -> None: """Update rooms.""" - for subset in map_subsets: - if subset.type == MapSetType.Vacuum: - if room := self._room_names.get(subset.id): - subset.name = room.name \ No newline at end of file + for index, subset in enumerate(map_subsets): + if subset.type == MapSetType.ROOMS: + room = self._room_names.get(subset.id) + if room and subset.name != room.name: + map_subsets[index] = MapSubsetEvent( + id=subset.id, + type=subset.type, + coordinates=subset.coordinates, + name=room.name, + ) \ No newline at end of file diff --git a/tests/commands/ngiot/test_map.py b/tests/commands/ngiot/test_map.py index 4c48a39a7..567412026 100644 --- a/tests/commands/ngiot/test_map.py +++ b/tests/commands/ngiot/test_map.py @@ -4,9 +4,9 @@ import pytest -from deebot_client.commands.ngiot.map import GetCachedMapInfo, GetMapSet +from deebot_client.commands.ngiot.map import GetCachedMapInfo from deebot_client.event_bus import EventBus -from deebot_client.events.map import CachedMapInfoEvent, Map, MapSetType +from deebot_client.events.map import CachedMapInfoEvent, Map from deebot_client.hardware import get_static_device_info from deebot_client.message import HandlingResult, HandlingState from deebot_client.rs.map import RotationAngle @@ -50,7 +50,7 @@ async def test_getCachedMapInfo_bootstraps_map_sets() -> None: assert result == HandlingResult( HandlingState.SUCCESS, {"map_id": "3"}, - [GetMapSet("3", entry) for entry in MapSetType], + [], ) event_bus.notify.assert_has_calls( [ @@ -76,4 +76,4 @@ async def test_getCachedMapInfo_bootstraps_map_sets() -> None: ) ] ) - assert event_bus.notify.call_count == 1 + assert event_bus.notify.call_count == 1 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index d1f5d24b2..ae6a652e8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,6 +27,7 @@ MqttConfiguration, create_mqtt_config as create_config_mqtt, ) +from deebot_client.ngiot_map_state import NgiotMapStateStore from .fixtures.mqtt_server import MqttServer @@ -171,7 +172,10 @@ def execute_mock() -> AsyncMock: @pytest.fixture def event_bus(execute_mock: AsyncMock, device_info: DeviceInfo) -> EventBus: - return EventBus(execute_mock, device_info.static.capabilities) + bus = EventBus(execute_mock, device_info.static.capabilities) + bus._ngiot_map_state_store = NgiotMapStateStore() + bus.ngiot_map_state = bus._ngiot_map_state_store + return bus @pytest.fixture @@ -183,4 +187,4 @@ def event_bus_mock(event_bus: EventBus) -> Mock: def caplog_fixture(caplog: pytest.LogCaptureFixture) -> pytest.LogCaptureFixture: """Set log level to debug for tests using the caplog fixture.""" caplog.set_level(logging.DEBUG) - return caplog + return caplog \ No newline at end of file diff --git a/tests/rs/test_map.py b/tests/rs/test_map.py index 494337ebe..0347284df 100644 --- a/tests/rs/test_map.py +++ b/tests/rs/test_map.py @@ -14,12 +14,12 @@ ( "invalid_base64", "Invalid symbol 95, offset 7.", - "Failed to extract trace points: Invalid symbol 95, offset 7.;value:invalid_base64", + "Failed to extract trace points: Invalid symbol 95, offset 7.;value:invalid_base64;lz4_len:None", ), ( "", "Invalid 7z compressed data", - "Failed to extract trace points: Invalid 7z compressed data;value:", + "Failed to extract trace points: Invalid 7z compressed data;value:;lz4_len:None", ), ], ) @@ -100,4 +100,4 @@ def test_PositionType_eq() -> None: assert PositionType.CHARGER == PositionType.CHARGER assert PositionType.CHARGER == 1 - assert PositionType.DEEBOT != PositionType.CHARGER + assert PositionType.DEEBOT != PositionType.CHARGER \ No newline at end of file diff --git a/tests/test_map.py b/tests/test_map.py index 05389a2e9..3a0803bbc 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -102,7 +102,7 @@ async def test_Map_subscriptions( num_unsubs = len(calls) + 1 assert len(map_obj._unsubscribers) == num_unsubs - async def on_change() -> None: + async def on_change(_: MapChangedEvent) -> None: pass event_unsub = event_bus_mock.subscribe(MapChangedEvent, on_change) @@ -147,16 +147,13 @@ async def on_change(_: MapChangedEvent) -> None: @pytest.mark.parametrize( - ("event", "exception_class"), + "event", [ - (MinorMapEvent(65, "data"), ValueError), - ( - MajorMapEvent( - map_id="1132127808", - values=[1295764014 for _ in range(100)], - requested=True, - ), - ExceptionGroup, + MinorMapEvent(65, "data"), + MajorMapEvent( + map_id="1132127808", + values=[1295764014 for _ in range(100)], + requested=True, ), ], ids=["MinorMapEvent", "MajorMapEvent"], @@ -165,22 +162,15 @@ async def test_invalid_map_piece_index( execute_mock: AsyncMock, event_bus: EventBus, event: Event, - exception_class: type[Exception], static_device_info: StaticDeviceInfo, ) -> None: - """Test invalid map piece index.""" - await setup_map(execute_mock, event_bus, static_device_info) + """Test invalid map piece index is ignored without surfacing an exception.""" + map_obj = await setup_map(execute_mock, event_bus, static_device_info) event_bus.notify(event) - with pytest.raises(exception_class) as ex: - await block_till_done(event_bus) - - exceptions = ( - ex.value.exceptions if isinstance(ex.value, ExceptionGroup) else [ex.value] - ) + await block_till_done(event_bus) - for err in exceptions: - assert "Index out of bounds" in str(err) + assert map_obj.get_svg_map() is None async def test_get_svg_map_empty( @@ -248,4 +238,4 @@ async def test_fn() -> str | None: def svg_map() -> str | None: return event_loop.run_until_complete(test_fn()) - assert svg_map == snapshot + assert svg_map == snapshot \ No newline at end of file diff --git a/tests/test_message.py b/tests/test_message.py index 239ece8e3..fb23caf57 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -59,11 +59,11 @@ def test_MessageStr_should_error_on_unknown_types() -> None: event_bus = Mock(spec_set=EventBus) result = TestMessageStr.handle(event_bus, {"key": "value"}) - assert result.state == HandlingState.ERROR + assert result.state == HandlingState.ANALYSE_LOGGED def test_WronglyImplementedMessage() -> None: event_bus = Mock(spec_set=EventBus) result = WronglyImplementedMessage.handle(event_bus, {}) - assert result.state == HandlingState.ERROR + assert result.state == HandlingState.ERROR \ No newline at end of file From 7349bd0f33a252d97dc3c2d4f5d7ad7a157b0561 Mon Sep 17 00:00:00 2001 From: Astute4185 Date: Fri, 10 Apr 2026 13:47:59 +1000 Subject: [PATCH 42/43] Enhance map event handling and improve test coverage for map-related functionalities --- deebot_client/event_bus.py | 5 +- deebot_client/map.py | 149 ++++++++++++++++++++++--------- tests/commands/ngiot/test_map.py | 4 +- tests/conftest.py | 10 ++- tests/helpers/__init__.py | 3 +- tests/test_map.py | 30 ++++--- 6 files changed, 141 insertions(+), 60 deletions(-) diff --git a/deebot_client/event_bus.py b/deebot_client/event_bus.py index 5d60fe831..c50ac32a5 100644 --- a/deebot_client/event_bus.py +++ b/deebot_client/event_bus.py @@ -243,7 +243,8 @@ def add_on_subscription_callback( def unsubscribe() -> None: data.unsubscribe() - event_processing_data.on_subscription_callbacks.remove(data) + if data in event_processing_data.on_subscription_callbacks: + event_processing_data.on_subscription_callbacks.remove(data) event_processing_data.on_subscription_callbacks.append(data) @@ -251,4 +252,4 @@ def unsubscribe() -> None: # There are already subscribers create_task(self._tasks, data.call()) - return unsubscribe + return unsubscribe \ No newline at end of file diff --git a/deebot_client/map.py b/deebot_client/map.py index c972f2086..3b6a3a877 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -2,17 +2,20 @@ from __future__ import annotations +import asyncio from datetime import UTC, datetime from typing import TYPE_CHECKING, Final -from deebot_client.events.map import CachedMapInfoEvent, MajorMapEvent, MapChangedEvent +from deebot_client.events.map import CachedMapInfoEvent, MapChangedEvent from .events import ( + MajorMapEvent, MapInfoEvent, MapSetEvent, MapSetType, MapSubsetEvent, MapTraceEvent, + MinorMapEvent, Position, PositionsEvent, RoomsEvent, @@ -51,6 +54,9 @@ def __init__( self._unsubscribers: list[Callable[[], None]] = [] async def on_map_set(event: MapSetEvent) -> None: + if event.type == MapSetType.ROOMS: + return + for subset_key, subset in self._map_data.map_subsets.copy().items(): if subset.type == event.type and subset.id not in event.subsets: self._map_data.map_subsets.pop(subset_key, None) @@ -58,6 +64,9 @@ async def on_map_set(event: MapSetEvent) -> None: self._unsubscribers.append(event_bus.subscribe(MapSetEvent, on_map_set)) async def on_map_subset(event: MapSubsetEvent) -> None: + if event.type == MapSetType.ROOMS: + return + subset_key = (str(event.type), event.id) if self._map_data.map_subsets.get(subset_key, None) != event: self._map_data.map_subsets[subset_key] = event @@ -75,20 +84,31 @@ async def on_map_info(event: MapInfoEvent) -> None: self._unsubscribers.append(event_bus.subscribe(MapInfoEvent, on_map_info)) - async def _on_first_map_changed_subscription(self) -> Callable[[], None]: - """On first MapChanged subscription. + async def _subscribe_minor_major_map_events(self) -> list[Callable[[], None]]: + async def on_major_map(event: MajorMapEvent) -> None: + if event.requested: + async with asyncio.TaskGroup() as tg: + for idx, value in enumerate(event.values): + if self._map_data.map_piece_crc32_indicates_update(idx, value): + tg.create_task( + self._execute_command( + self._capabilities.minor.execute(idx, event.map_id) + ) + ) - For NGIOT devices, the visible base map comes from the raster payload stored - in the NGIOT map state store. This callback wires the Python map layer to - that store and keeps overlays layered on top. + self._sync_ngiot_background_from_store() + + async def on_minor_map(event: MinorMapEvent) -> None: + self._map_data.update_map_piece(event.index, event.value) - Extra-safe behavior: - - legacy trace/icon/position behavior remains the default - - NGIOT trace and position transforms are enabled only when a valid - NGIOT raster background is actively applied - - legacy devices keep the existing transform path - """ - unsubscribers: list[Callable[[], None]] = [] + return [ + self._event_bus.subscribe(MajorMapEvent, on_major_map), + self._event_bus.subscribe(MinorMapEvent, on_minor_map), + ] + + async def _on_first_map_changed_subscription(self) -> Callable[[], None]: + """On first MapChanged subscription.""" + unsubscribers = await self._subscribe_minor_major_map_events() async def on_cached_info(event: CachedMapInfoEvent) -> None: used_map = next((m for m in event.maps if m.using), None) @@ -103,11 +123,6 @@ async def on_cached_info(event: CachedMapInfoEvent) -> None: if cached_map_subscribers: self._event_bus.request_refresh(CachedMapInfoEvent) - async def on_major_map(_: MajorMapEvent) -> None: - self._sync_ngiot_background_from_store() - - unsubscribers.append(self._event_bus.subscribe(MajorMapEvent, on_major_map)) - async def on_position(event: PositionsEvent) -> None: self._map_data.update_positions(event.positions) self._sync_ngiot_background_from_store() @@ -122,9 +137,6 @@ async def on_map_trace(event: MapTraceEvent) -> None: return try: - # Extra-safe rule: - # - if NGIOT background is active, keep world-space trace scaling - # - otherwise fall back to legacy scaling if self._map_data.has_ngiot_background(): self._map_data.use_world_trace_scale() else: @@ -161,9 +173,9 @@ def refresh(self) -> None: raise MapError("Please enable the map first") self._event_bus.request_refresh(CachedMapInfoEvent) - self._event_bus.request_refresh(MajorMapEvent) self._event_bus.request_refresh(PositionsEvent) self._event_bus.request_refresh(MapTraceEvent) + self._event_bus.request_refresh(MajorMapEvent) def get_svg_map(self) -> str | None: """Return map as SVG string.""" @@ -232,15 +244,13 @@ def _sync_ngiot_background_from_store(self) -> None: height=int(getattr(base_map, "height", 0)), total_width=int(getattr(base_map, "total_width", 0)), total_height=int(getattr(base_map, "total_height", 0)), - resolution=int(getattr(base_map, "resolution", 1)), + resolution=int(getattr(base_map, "resolution", 0)), x_min=int(getattr(base_map, "x_min", 0)), y_max=int(getattr(base_map, "y_max", 0)), direction=int(getattr(base_map, "direction", 0)), ) self._map_data.use_world_trace_scale() self._map_data.use_ngiot_position_icon_scale() - # Observed NGIOT live pose payloads are already in world/map coordinates. - # Do not re-offset them by xMin/yMax here. self._map_data.use_legacy_position_transform() @@ -263,7 +273,6 @@ def on_change() -> None: self._data = MapDataRs() self._room_handling = MapRoomHandling(event_bus, on_change) - # Extra-safe defaults for backward compatibility. self.use_legacy_trace_scale() self.use_legacy_position_icon_scale() self.use_legacy_position_transform() @@ -293,6 +302,17 @@ def update_positions(self, positions: list[Position]) -> None: self._positions = new_positions self._on_change() + def update_map_piece(self, index: int, base64_data: str) -> None: + """Update legacy map piece.""" + if self._data.background_image.update_map_piece(index, base64_data): + self._on_change() + + def map_piece_crc32_indicates_update(self, index: int, crc32: int) -> bool: + """Return True if legacy map piece update is required.""" + return self._data.background_image.map_piece_crc32_indicates_update( + index, crc32 + ) + def set_rotation_angle(self, angle: int | RotationAngle) -> None: """Set rotation angle.""" if isinstance(angle, RotationAngle): @@ -310,7 +330,7 @@ def set_rotation_angle(self, angle: int | RotationAngle) -> None: self._rotation = new_rotation self._on_change() - def set_map_info(self, map_info: list[str]) -> None: + def set_map_info(self, map_info: list[str] | str) -> None: """Set map info.""" self._data.set_map_info(map_info) self._on_change() @@ -412,29 +432,74 @@ class MapRoomHandling: def __init__(self, event_bus: EventBus, on_change: Callable[[], None]) -> None: self._event_bus = event_bus self._on_change = on_change - self._room_names: dict[int, Room] = {} + self._rooms: dict[int, Room] = {} + self._amount_rooms: int = 0 + self._map_id: str = "" + self._unsubscribers: list[Callable[[], None]] = [] + + async def on_map_set(event: MapSetEvent) -> None: + if event.type != MapSetType.ROOMS: + return + + self._map_id = event.map_id + self._amount_rooms = len(event.subsets) + changed = False + for room_id in list(self._rooms): + if room_id not in event.subsets: + self._rooms.pop(room_id, None) + changed = True + if changed: + self._on_change() + + self._unsubscribers.append(event_bus.subscribe(MapSetEvent, on_map_set)) + + async def on_map_subset(event: MapSubsetEvent) -> None: + if event.type != MapSetType.ROOMS or not event.name: + return + + room = Room(event.name, event.id, event.coordinates) + if self._rooms.get(event.id) != room: + self._rooms[event.id] = room + self._on_change() + + if self._amount_rooms and len(self._rooms) == self._amount_rooms: + event_bus.notify(RoomsEvent(self._map_id, list(self._rooms.values()))) + + self._unsubscribers.append(event_bus.subscribe(MapSubsetEvent, on_map_subset)) async def on_rooms(event: RoomsEvent) -> None: rooms = {room.id: room for room in event.rooms} - if self._room_names != rooms: - self._room_names = rooms + if self._rooms != rooms: + self._rooms = rooms + self._map_id = event.map_id + self._amount_rooms = len(rooms) self._on_change() - self._unsubscribe = event_bus.subscribe(RoomsEvent, on_rooms) + self._unsubscribers.append(event_bus.subscribe(RoomsEvent, on_rooms)) def teardown(self) -> None: """Teardown room handling.""" - self._unsubscribe() + for unsubscribe in self._unsubscribers: + unsubscribe() + self._unsubscribers.clear() def update_rooms(self, map_subsets: list[MapSubsetEvent]) -> None: - """Update rooms.""" + """Update room subset names from cached room metadata.""" for index, subset in enumerate(map_subsets): - if subset.type == MapSetType.ROOMS: - room = self._room_names.get(subset.id) - if room and subset.name != room.name: - map_subsets[index] = MapSubsetEvent( - id=subset.id, - type=subset.type, - coordinates=subset.coordinates, - name=room.name, - ) \ No newline at end of file + if subset.type != MapSetType.ROOMS: + continue + + room = self._rooms.get(subset.id) + if room is None: + continue + + new_coordinates = subset.coordinates or room.coordinates + new_name = room.name or subset.name + replacement = MapSubsetEvent( + id=subset.id, + type=subset.type, + coordinates=new_coordinates, + name=new_name, + ) + if replacement != subset: + map_subsets[index] = replacement \ No newline at end of file diff --git a/tests/commands/ngiot/test_map.py b/tests/commands/ngiot/test_map.py index 567412026..557705259 100644 --- a/tests/commands/ngiot/test_map.py +++ b/tests/commands/ngiot/test_map.py @@ -6,7 +6,7 @@ from deebot_client.commands.ngiot.map import GetCachedMapInfo from deebot_client.event_bus import EventBus -from deebot_client.events.map import CachedMapInfoEvent, Map +from deebot_client.events.map import CachedMapInfoEvent, Map, MapSetType from deebot_client.hardware import get_static_device_info from deebot_client.message import HandlingResult, HandlingState from deebot_client.rs.map import RotationAngle @@ -18,7 +18,7 @@ async def test_getCachedMapInfo_bootstraps_map_sets() -> None: assert static_device_info is not None assert static_device_info.capabilities.map is not None - event_bus = Mock(spec_set=EventBus) + event_bus = Mock(spec=EventBus) event_bus.capabilities = static_device_info.capabilities response = { diff --git a/tests/conftest.py b/tests/conftest.py index ae6a652e8..071bd46be 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -173,14 +173,18 @@ def execute_mock() -> AsyncMock: @pytest.fixture def event_bus(execute_mock: AsyncMock, device_info: DeviceInfo) -> EventBus: bus = EventBus(execute_mock, device_info.static.capabilities) - bus._ngiot_map_state_store = NgiotMapStateStore() - bus.ngiot_map_state = bus._ngiot_map_state_store + store = NgiotMapStateStore() + bus._ngiot_map_state_store = store + bus.ngiot_map_state = store return bus @pytest.fixture def event_bus_mock(event_bus: EventBus) -> Mock: - return Mock(spec_set=EventBus, wraps=event_bus) + mock = Mock(spec_set=event_bus, wraps=event_bus) + mock._ngiot_map_state_store = event_bus._ngiot_map_state_store + mock.ngiot_map_state = event_bus.ngiot_map_state + return mock @pytest.fixture(name="caplog") diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py index fb4e5e7ea..670c0806b 100644 --- a/tests/helpers/__init__.py +++ b/tests/helpers/__init__.py @@ -57,10 +57,11 @@ def mock_static_device_info( events = {} mock = Mock(spec_set=Capabilities) + mock.map = None def get_refresh_commands(event: type[Event]) -> list[Command]: return events.get(event, []) mock.get_refresh_commands.side_effect = get_refresh_commands - return StaticDeviceInfo(data_type, mock) + return StaticDeviceInfo(data_type, mock) \ No newline at end of file diff --git a/tests/test_map.py b/tests/test_map.py index 3a0803bbc..0d0c67abb 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -147,13 +147,16 @@ async def on_change(_: MapChangedEvent) -> None: @pytest.mark.parametrize( - "event", + ("event", "exception_class"), [ - MinorMapEvent(65, "data"), - MajorMapEvent( - map_id="1132127808", - values=[1295764014 for _ in range(100)], - requested=True, + (MinorMapEvent(65, "data"), ValueError), + ( + MajorMapEvent( + map_id="1132127808", + values=[1295764014 for _ in range(100)], + requested=True, + ), + ExceptionGroup, ), ], ids=["MinorMapEvent", "MajorMapEvent"], @@ -162,15 +165,22 @@ async def test_invalid_map_piece_index( execute_mock: AsyncMock, event_bus: EventBus, event: Event, + exception_class: type[Exception], static_device_info: StaticDeviceInfo, ) -> None: - """Test invalid map piece index is ignored without surfacing an exception.""" - map_obj = await setup_map(execute_mock, event_bus, static_device_info) + """Test invalid map piece index.""" + await setup_map(execute_mock, event_bus, static_device_info) event_bus.notify(event) - await block_till_done(event_bus) + with pytest.raises(exception_class) as ex: + await block_till_done(event_bus) - assert map_obj.get_svg_map() is None + exceptions = ( + ex.value.exceptions if isinstance(ex.value, ExceptionGroup) else [ex.value] + ) + + for err in exceptions: + assert "Index out of bounds" in str(err) async def test_get_svg_map_empty( From 1f09de622d69bb5cb99eebe49d960f41202fc1fb Mon Sep 17 00:00:00 2001 From: Astute4185 Date: Fri, 10 Apr 2026 14:33:18 +1000 Subject: [PATCH 43/43] Implement retry logic for transient busy responses in NGIOT client --- deebot_client/ngiot_client.py | 102 ++++++++++++++++++++++++++++------ 1 file changed, 86 insertions(+), 16 deletions(-) diff --git a/deebot_client/ngiot_client.py b/deebot_client/ngiot_client.py index f1d6ec489..f0e4efc4e 100644 --- a/deebot_client/ngiot_client.py +++ b/deebot_client/ngiot_client.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import json import secrets import string @@ -29,6 +30,7 @@ _DEFAULT_FMT = "j" _DEFAULT_CT = "q" +# Public APN constants are imported by multiple NGIOT command modules. APN_ROBOT_DETAIL = "10001" APN_MAP_DETAILS = "30001" APN_CLEAN_START = "40001" @@ -41,6 +43,10 @@ APN_RESET_CONSUMABLE = "50017" APN_CHILD_LOCK = "50038" +# Some devices transiently return "cmd busy" while state is changing. +_TRANSIENT_RESPONSE_CODES = {1} +_TRANSIENT_RESPONSE_MESSAGES = {"cmd busy"} + @dataclass(frozen=True) class NgiotDeviceIdentity: @@ -99,14 +105,7 @@ async def request( ct: str = _DEFAULT_CT, force_sst_refresh: bool = False, ) -> dict[str, Any]: - """Execute a single NGIOT endpoint-control request. - - Returns the full decoded NGIOT JSON envelope: - { - "body": {...}, - "header": {...} - } - """ + """Execute a single NGIOT endpoint-control request.""" identity = self._normalize_device(device) return await self._request_with_fallback( identity, @@ -249,7 +248,24 @@ async def _request_once( response_data, ) - self._validate_response(response_data) + validation = self._classify_response(response_data) + if validation == "retry_busy": + _LOGGER.debug( + "NGIOT request returned transient busy for %s apn=%s; retrying once", + identity.key, + apn, + ) + await asyncio.sleep(1) + return await self._request_retry_after_busy( + identity, + device, + apn=apn, + body_data=body_data, + fmt=fmt, + ct=ct, + force_sst_refresh=force_sst_refresh, + ) + return response_data except TimeoutError as ex: @@ -282,6 +298,30 @@ async def _request_once( _LOGGER.debug("NGIOT request failed: %s", logger_request_params, exc_info=True) raise + async def _request_retry_after_busy( + self, + identity: NgiotDeviceIdentity, + device: ApiDeviceInfo | DeviceInfo | Mapping[str, Any], + *, + apn: str | int, + body_data: Mapping[str, Any] | Sequence[Any], + fmt: str, + ct: str, + force_sst_refresh: bool, + ) -> dict[str, Any]: + """Retry once after a transient busy response.""" + retry_response = await self._request_with_fallback( + identity, + device, + apn=apn, + body_data=body_data, + fmt=fmt, + ct=ct, + force_sst_refresh=force_sst_refresh, + ) + self._validate_response(retry_response) + return retry_response + async def query_fields( self, device: ApiDeviceInfo | DeviceInfo | Mapping[str, Any], @@ -429,18 +469,12 @@ def _normalize_device( msg = f"Missing required NGIOT device field: {ex.args[0]}" raise ApiError(msg) from ex - def _build_payload( self, apn: str | int, body_data: Mapping[str, Any] | Sequence[Any], ) -> Mapping[str, Any] | Sequence[Any]: - """Build a device-tolerant NGIOT payload. - - eyfj07 rejects some empty legacy payloads with a null body. - This helper preserves caller-provided payloads while adding a - minimal request envelope for reads that otherwise send `{}`. - """ + """Build a device-tolerant NGIOT payload.""" if not isinstance(body_data, Mapping): return body_data @@ -480,6 +514,32 @@ def _extract_body_data(response: Mapping[str, Any]) -> Any: return {} return body.get("data", {}) + @classmethod + def _classify_response(cls, response: Mapping[str, Any] | None) -> str: + """Classify NGIOT envelope and support ACK-only or transient-busy replies.""" + if response is None: + _LOGGER.debug("Empty NGIOT response body returned by server") + return "ok" + + body = response.get("body") + if not isinstance(body, Mapping): + _LOGGER.debug("NGIOT response omitted body; treating as ACK-only success") + return "ok" + + code = body.get("code", 0) + msg = str(body.get("msg", "")).strip().lower() + + if code in (0, "0000", None): + return "ok" + + if code in _TRANSIENT_RESPONSE_CODES and msg in _TRANSIENT_RESPONSE_MESSAGES: + return "retry_busy" + + raise ApiError( + f"NGIOT request failed with code {code} ({body.get('msg', 'unknown error')}) " + f"for {_PATH_ENDPOINT_CONTROL}" + ) + @staticmethod def _validate_response(response: Mapping[str, Any] | None) -> None: """Validate NGIOT envelope and raise ApiError on device-side failures.""" @@ -487,7 +547,17 @@ def _validate_response(response: Mapping[str, Any] | None) -> None: _LOGGER.debug("Empty NGIOT response body returned by server") return + if not isinstance(response, Mapping): + raise ApiError("Invalid NGIOT response: missing body") + body = response.get("body") + if body is None: + # Accept ACK-only envelopes that still include a header. + if isinstance(response.get("header"), Mapping): + _LOGGER.debug("NGIOT ACK-only response without body: %s", response) + return + raise ApiError("Invalid NGIOT response: missing body") + if not isinstance(body, Mapping): raise ApiError("Invalid NGIOT response: missing body")