Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
932a8f0
feat: add NGIOT control and status support for eyfj07
Mar 25, 2026
9d7e108
WIP: save current progress
Mar 26, 2026
4fae84e
Checking in wip.
Mar 27, 2026
5ebdb61
Including lz4 as dependency to rust components.
Astute4185 Mar 31, 2026
666a116
Add a new dedicated LZ4 function in util.rs
Astute4185 Mar 31, 2026
e16c6e0
Update position capability to use GetPos()
Astute4185 Mar 31, 2026
8c5e07f
Add tests for decompress_base64_lz4_data function
Astute4185 Mar 31, 2026
9f2948f
Refactor _handle_body_data_dict for map processing
Astute4185 Mar 31, 2026
303df46
Add unit test for GetCachedMapInfo response handling
Astute4185 Mar 31, 2026
28177ad
Removing duplicate imports (deebot_client.commands.ngiot.map)
Astute4185 Mar 31, 2026
d2d6e58
Add lz4_len field to MapInfoEvent dataclass
Astute4185 Mar 31, 2026
0c4b977
Preserve lz4Len in NGIOT trace command
Astute4185 Mar 31, 2026
71a8ed0
Update add method to include lz4_len parameter
Astute4185 Mar 31, 2026
a1749dc
Add LZ4 decompression support for trace points
Astute4185 Mar 31, 2026
f1c5a45
Fixing typo
Astute4185 Mar 31, 2026
11f5de2
Modify add_trace_points to accept lz4_len parameter
Astute4185 Mar 31, 2026
aa4adb7
Refactor update_positions to merge position updates
Astute4185 Mar 31, 2026
23e81ca
Refactor position handling and add utility functions
Astute4185 Mar 31, 2026
b6e5a61
Refactor map capability event definitions
Astute4185 Mar 31, 2026
bc64bad
Add NGIOT map parser and normalization helpers
Astute4185 Mar 31, 2026
dbe0199
Add NGIOT map state aggregation module
Astute4185 Mar 31, 2026
91f0a77
Fixes from phase 1 and 2 smoke testing
Apr 1, 2026
bac41e8
Enhance NGIOT map functionality by adding support for carpets and roo…
Apr 1, 2026
f19bc9b
Implement NGIOT background handling with synchronization and renderin…
Apr 1, 2026
3e307cd
HA test candidate - able to get status updates, but MAP fails
Apr 1, 2026
62a4d97
Enhance NGIOT map functionality by adding raster background handling …
Apr 1, 2026
3c45942
Refactor GetMapSet to use drawable subset IDs for event notifications
Apr 1, 2026
846741e
Enhance NGIOT background handling with extra-safe defaults and scalin…
Apr 1, 2026
3c02fe5
**BROKEN COMMIT** Refactor map module to improve NGIOT background han…
Apr 1, 2026
e944e84
WIP commit - working on correcting a few bug related to marker and tr…
Apr 6, 2026
cdb79ce
Testing Candidate
Apr 7, 2026
65428f2
Enhance NGIOT client with fallback control host and improved request …
Apr 8, 2026
d2f595d
Enhance NGIOT integration with new message handling and topic routing
Apr 8, 2026
8cba96e
Add volume control commands and integrate into NGIOT client
Apr 8, 2026
209a746
Update GetStats and GetReportStats to include cleanCount and convert …
Apr 9, 2026
a8b475a
Add overlay SVG offset handling in NGIOT integration for improved pos…
Apr 9, 2026
b61ac97
Refactor visibility of calc_point and generate functions for improved…
Apr 9, 2026
91ccfe6
Reorder capability imports for improved organization and clarity
Apr 9, 2026
49cbf27
Merge branch 'DeebotUniverse:dev' into add-printer-eyfj07-ngiot
Astute4185 Apr 9, 2026
3054c01
Add APN_RESET_CONSUMABLE constant and refactor life span commands for…
Apr 10, 2026
d6e1a24
Add NGIOT integration tests for clean, stats, and map state functiona…
Apr 10, 2026
16b5cb2
Refactor map data handling and update tests for improved clarity and …
Apr 10, 2026
7349bd0
Enhance map event handling and improve test coverage for map-related …
Apr 10, 2026
1f09de6
Implement retry logic for transient busy responses in NGIOT client
Apr 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
274 changes: 263 additions & 11 deletions deebot_client/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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__)
Expand All @@ -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)
Expand All @@ -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,
*,
Expand Down Expand Up @@ -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
)
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -348,6 +364,7 @@ def __init__(
account_id: str,
password_hash: str,
) -> None:
self._config = config
self._auth_client = _AuthClient(
config,
account_id,
Expand All @@ -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."""
Expand Down Expand Up @@ -411,14 +544,18 @@ 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:
if self._refresh_handle and not self._refresh_handle.cancelled():
self._refresh_handle.cancel()

def _create_refresh_task(self, credentials: Credentials) -> None:
# refresh at 99% of validity
def refresh() -> None:
_LOGGER.debug("Refresh token")

Expand All @@ -432,5 +569,120 @@ 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,
)
# 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,
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)
Loading
Loading