From 3433f282e9ca28c31ce29d0c577c6fe7e7bdae44 Mon Sep 17 00:00:00 2001 From: MVladislav <7400017+MVladislav@users.noreply.github.com> Date: Thu, 15 Feb 2024 21:59:55 +0100 Subject: [PATCH] Add `GetMapSetV2` command (#372) Co-authored-by: Robert Resch --- deebot_client/commands/json/__init__.py | 3 + deebot_client/commands/json/map.py | 108 ++++++++++++++-- deebot_client/hardware/deebot/p95mgv.py | 8 +- deebot_client/map.py | 36 ++---- deebot_client/messages/json/__init__.py | 8 +- deebot_client/messages/json/map.py | 35 +++++ deebot_client/util/__init__.py | 22 ++++ tests/commands/json/test_map.py | 162 ++++++++++++++++++++++-- tests/messages/json/test_map.py | 37 ++++++ 9 files changed, 362 insertions(+), 57 deletions(-) create mode 100644 deebot_client/messages/json/map.py create mode 100644 tests/messages/json/test_map.py diff --git a/deebot_client/commands/json/__init__.py b/deebot_client/commands/json/__init__.py index 26702288..1368dd9c 100644 --- a/deebot_client/commands/json/__init__.py +++ b/deebot_client/commands/json/__init__.py @@ -24,6 +24,7 @@ GetCachedMapInfo, GetMajorMap, GetMapSet, + GetMapSetV2, GetMapSubSet, GetMapTrace, GetMinorMap, @@ -73,6 +74,7 @@ "GetCachedMapInfo", "GetMajorMap", "GetMapSet", + "GetMapSetV2", "GetMapSubSet", "GetMapTrace", "GetMinorMap", @@ -144,6 +146,7 @@ GetCachedMapInfo, GetMajorMap, GetMapSet, + GetMapSetV2, GetMapSubSet, GetMapTrace, GetMinorMap, diff --git a/deebot_client/commands/json/map.py b/deebot_client/commands/json/map.py index 708e6414..39117ddf 100644 --- a/deebot_client/commands/json/map.py +++ b/deebot_client/commands/json/map.py @@ -1,6 +1,7 @@ """Maps commands.""" from __future__ import annotations +import json from types import MappingProxyType from typing import TYPE_CHECKING, Any @@ -15,6 +16,7 @@ ) from deebot_client.events.map import CachedMapInfoEvent from deebot_client.message import HandlingResult, HandlingState, MessageBodyDataDict +from deebot_client.util import decompress_7z_base64_data from .common import JsonCommandWithMessageHandling @@ -26,6 +28,22 @@ class GetCachedMapInfo(JsonCommandWithMessageHandling, MessageBodyDataDict): """Get cached map info command.""" name = "getCachedMapInfo" + # version definition for using type of getMapSet v1 or v2 + _map_set_command: type[GetMapSet | GetMapSetV2] + + def __init__( + self, args: dict[str, Any] | list[Any] | None = None, version: int = 1 + ) -> None: + match version: + case 1: + self._map_set_command = GetMapSet + case 2: + self._map_set_command = GetMapSetV2 + case _: + error_wrong_version = f"version={version} is not supported" + raise ValueError(error_wrong_version) + + super().__init__(args) @classmethod def _handle_body_data_dict( @@ -59,7 +77,10 @@ def _handle_response( return CommandResult( result.state, result.args, - [GetMapSet(result.args["map_id"], entry) for entry in MapSetType], + [ + self._map_set_command(result.args["map_id"], entry) + for entry in MapSetType + ], ) return result @@ -131,16 +152,24 @@ def _handle_body_data_dict( :return: A message response """ - subsets = [int(subset["mssid"]) for subset in data["subsets"]] - args = { - cls._ARGS_ID: data["mid"], - cls._ARGS_SET_ID: data.get("msid", None), - cls._ARGS_TYPE: data["type"], - cls._ARGS_SUBSETS: subsets, - } + if not MapSetType.has_value(data["type"]) or not data.get("subsets"): + return HandlingResult.analyse() - event_bus.notify(MapSetEvent(MapSetType(data["type"]), subsets)) - return HandlingResult(HandlingState.SUCCESS, args) + if subset_ids := cls._get_subset_ids(event_bus, data): + event_bus.notify(MapSetEvent(MapSetType(data["type"]), subset_ids)) + args = { + cls._ARGS_ID: data["mid"], + cls._ARGS_SET_ID: data.get("msid"), + cls._ARGS_TYPE: data["type"], + cls._ARGS_SUBSETS: subset_ids, + } + return HandlingResult(HandlingState.SUCCESS, args) + return HandlingResult(HandlingState.SUCCESS) + + @classmethod + def _get_subset_ids(cls, _: EventBus, data: dict[str, Any]) -> list[int] | None: + """Return subset ids.""" + return [int(subset["mssid"]) for subset in data["subsets"]] def _handle_response( self, event_bus: EventBus, response: dict[str, Any] @@ -205,7 +234,8 @@ def __init__( type = type.value if msid is None and type == MapSetType.ROOMS.value: - raise ValueError("msid is required when type='vw'") + error_msid_type = f"msid is required when type='{MapSetType.ROOMS.value}'" + raise ValueError(error_msid_type) super().__init__( { @@ -225,18 +255,26 @@ def _handle_body_data_dict( :return: A message response """ if MapSetType.has_value(data["type"]): - subtype = data.get("subtype", data.get("subType", None)) + subtype = data.get("subtype", data.get("subType")) name = None if subtype == "15": - name = data.get("name", None) + name = data.get("name") elif subtype: name = cls._ROOM_NUM_TO_NAME.get(subtype, None) + # This command is used by new and old bots + if data.get("compress", 0) == 1: + # Newer bot's return coordinates as base64 decoded string + coordinates = decompress_7z_base64_data(data["value"]).decode() + else: + # Older bot's return coordinates direct as comma/semicolon separated list + coordinates = data["value"] + event_bus.notify( MapSubsetEvent( id=int(data["mssid"]), type=MapSetType(data["type"]), - coordinates=data["value"], + coordinates=coordinates, name=name, ) ) @@ -246,6 +284,48 @@ def _handle_body_data_dict( return HandlingResult.analyse() +class GetMapSetV2(GetMapSet): + """Get map set v2 command.""" + + name = "getMapSet_V2" + + @classmethod + def _get_subset_ids( + cls, event_bus: EventBus, data: dict[str, Any] + ) -> list[int] | None: + """Return subset ids.""" + # subset is based64 7z compressed + subsets = json.loads(decompress_7z_base64_data(data["subsets"]).decode()) + + match data["type"]: + case MapSetType.ROOMS: + # subset values + # 1 -> id + # 2 -> unknown + # 3 -> unknown + # 4 -> room clean order + # 5 -> room center x + # 6 -> room center y + # 7 -> room clean configs as '--' + # 8 -> named all as 'settingName1' + return [int(subset[0]) for subset in subsets] + + case MapSetType.VIRTUAL_WALLS | MapSetType.NO_MOP_ZONES: + for subset in subsets: + mssid = subset[0] # first entry in list is mssid + coordinates = str(subset[1:]) # all other in list are coordinates + + event_bus.notify( + MapSubsetEvent( + id=int(mssid), + type=MapSetType(data["type"]), + coordinates=coordinates, + ) + ) + + return None + + class GetMapTrace(JsonCommandWithMessageHandling, MessageBodyDataDict): """Get map trace command.""" diff --git a/deebot_client/hardware/deebot/p95mgv.py b/deebot_client/hardware/deebot/p95mgv.py index 3e5ed915..0767cc39 100644 --- a/deebot_client/hardware/deebot/p95mgv.py +++ b/deebot_client/hardware/deebot/p95mgv.py @@ -39,8 +39,6 @@ from deebot_client.commands.json.error import GetError from deebot_client.commands.json.fan_speed import GetFanSpeed, SetFanSpeed from deebot_client.commands.json.life_span import GetLifeSpan, ResetLifeSpan - -# getMapSet from deebot_client.commands.json.map import ( GetCachedMapInfo, GetMajorMap, @@ -160,7 +158,9 @@ reset=ResetLifeSpan, ), map=CapabilityMap( - chached_info=CapabilityEvent(CachedMapInfoEvent, [GetCachedMapInfo()]), + chached_info=CapabilityEvent( + CachedMapInfoEvent, [GetCachedMapInfo(version=2)] + ), changed=CapabilityEvent(MapChangedEvent, []), major=CapabilityEvent(MajorMapEvent, [GetMajorMap()]), multi_state=CapabilitySetEnable( @@ -168,7 +168,7 @@ ), position=CapabilityEvent(PositionsEvent, [GetPos()]), relocation=CapabilityExecute(SetRelocationState), - rooms=CapabilityEvent(RoomsEvent, [GetCachedMapInfo()]), + rooms=CapabilityEvent(RoomsEvent, [GetCachedMapInfo(version=2)]), trace=CapabilityEvent(MapTraceEvent, [GetMapTrace()]), ), network=CapabilityEvent(NetworkInfoEvent, [GetNetInfo()]), diff --git a/deebot_client/map.py b/deebot_client/map.py index 47371b09..32345284 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -9,7 +9,6 @@ from decimal import Decimal from io import BytesIO import itertools -import lzma import struct from typing import TYPE_CHECKING, Any, Final import zlib @@ -35,7 +34,11 @@ from .exceptions import MapError from .logging_filter import get_logger from .models import Room -from .util import OnChangedDict, OnChangedList +from .util import ( + OnChangedDict, + OnChangedList, + decompress_7z_base64_data, +) if TYPE_CHECKING: from collections.abc import Callable, Coroutine, Sequence @@ -117,7 +120,7 @@ class _PositionSvg: _TRACE_MAP = "trace_map" _COLORS = { _TRACE_MAP: "#fff", - MapSetType.VIRTUAL_WALLS: "#f00", + MapSetType.VIRTUAL_WALLS: "#f00000", MapSetType.NO_MOP_ZONES: "#ffa500", } _DEFAULT_MAP_BACKGROUND_COLOR = ImageColor.getrgb("#badaff") # floor @@ -233,27 +236,6 @@ class BackgroundImage: ) -def _decompress_7z_base64_data(data: str) -> bytes: - _LOGGER.debug("[decompress7zBase64Data] Begin") - final_array = bytearray() - - # Decode Base64 - decoded = base64.b64decode(data) - - i = 0 - for idx in decoded: - if i == 8: - final_array += b"\x00\x00\x00\x00" - final_array.append(idx) - i += 1 - - dec = lzma.LZMADecompressor(lzma.FORMAT_AUTO, None, None) - decompressed_data = dec.decompress(final_array) - - _LOGGER.debug("[decompress7zBase64Data] Done") - return decompressed_data - - def _calc_value(value: float, axis_manipulation: AxisManipulation) -> float: try: if value is not None: @@ -352,7 +334,7 @@ def _get_svg_subset( # For any other points count, return a polygon that should fit any required shape return svg.Polygon( - fill=_COLORS[subset.type] + "90", # Set alpha channel to 90 for fill color + fill=_COLORS[subset.type] + "30", # Set alpha channel to 30 for fill color stroke=_COLORS[subset.type], stroke_width=1.5, stroke_dasharray=[4], @@ -432,7 +414,7 @@ async def on_map_subset(event: MapSubsetEvent) -> None: def _update_trace_points(self, data: str) -> None: _LOGGER.debug("[_update_trace_points] Begin") - trace_points = _decompress_7z_base64_data(data) + trace_points = decompress_7z_base64_data(data) for i in range(0, len(trace_points), 5): position_x, position_y = struct.unpack(" Image.Image: def update_points(self, base64_data: str) -> None: """Add map piece points.""" - decoded = _decompress_7z_base64_data(base64_data) + decoded = decompress_7z_base64_data(base64_data) old_crc32 = self._crc32 self._crc32 = zlib.crc32(decoded) diff --git a/deebot_client/messages/json/__init__.py b/deebot_client/messages/json/__init__.py index 5b93bf82..c42050c7 100644 --- a/deebot_client/messages/json/__init__.py +++ b/deebot_client/messages/json/__init__.py @@ -4,17 +4,23 @@ from typing import TYPE_CHECKING from .battery import OnBattery +from .map import OnMapSetV2 from .stats import ReportStats if TYPE_CHECKING: from deebot_client.message import Message -__all__ = ["OnBattery", "ReportStats"] +__all__ = [ + "OnBattery", + "OnMapSetV2", + "ReportStats", +] # fmt: off # ordered by file asc _MESSAGES: list[type[Message]] = [ OnBattery, + OnMapSetV2, ReportStats ] diff --git a/deebot_client/messages/json/map.py b/deebot_client/messages/json/map.py new file mode 100644 index 00000000..582bb1fb --- /dev/null +++ b/deebot_client/messages/json/map.py @@ -0,0 +1,35 @@ +"""Map set v2 messages.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from deebot_client.events.map import MapSetType +from deebot_client.message import HandlingResult, HandlingState, MessageBodyDataDict + +if TYPE_CHECKING: + from deebot_client.event_bus import EventBus + + +class OnMapSetV2(MessageBodyDataDict): + """On map set v2 message.""" + + name = "onMapSet_V2" + + @classmethod + def _handle_body_data_dict( + cls, _: EventBus, data: dict[str, Any] + ) -> HandlingResult: + """Handle message->body->data and notify the correct event subscribers. + + :return: A message response + """ + # check if type is know and mid us given + if not MapSetType.has_value(data["type"]) or not data.get("mid"): + return HandlingResult.analyse() + + # NOTE: here would be needed to call 'GetMapSetV2' again with 'mid' and 'type', + # that on event will update the map set changes, + # messages current cannot call commands again + return HandlingResult( + HandlingState.SUCCESS, {"mid": data["mid"], "type": data["type"]} + ) diff --git a/deebot_client/util/__init__.py b/deebot_client/util/__init__.py index a2edb740..afb433f1 100644 --- a/deebot_client/util/__init__.py +++ b/deebot_client/util/__init__.py @@ -2,11 +2,17 @@ from __future__ import annotations import asyncio +import base64 from contextlib import suppress from enum import IntEnum, unique import hashlib +import lzma from typing import TYPE_CHECKING, Any, Self, TypeVar +from deebot_client.logging_filter import get_logger + +_LOGGER = get_logger(__name__) + if TYPE_CHECKING: from collections.abc import Callable, Coroutine, Iterable, Mapping @@ -18,6 +24,22 @@ def md5(text: str) -> str: return hashlib.md5(bytes(str(text), "utf8")).hexdigest() # noqa: S324 +def decompress_7z_base64_data(data: str) -> bytes: + """Decomporess base64 decoded 7z compressed string.""" + final_array = bytearray() + + # Decode Base64 + decoded = base64.b64decode(data) + + for i, idx in enumerate(decoded): + if i == 8: + final_array.extend(b"\x00\x00\x00\x00") + final_array.append(idx) + + dec = lzma.LZMADecompressor(lzma.FORMAT_AUTO, None, None) + return dec.decompress(final_array) + + def create_task( tasks: set[asyncio.Future[Any]], target: Coroutine[Any, Any, _T] ) -> asyncio.Task[_T]: diff --git a/tests/commands/json/test_map.py b/tests/commands/json/test_map.py index 80c637c7..41838b61 100644 --- a/tests/commands/json/test_map.py +++ b/tests/commands/json/test_map.py @@ -1,5 +1,7 @@ from __future__ import annotations +import pytest + from deebot_client.command import CommandResult from deebot_client.commands.json import ( GetCachedMapInfo, @@ -8,6 +10,7 @@ GetMapSubSet, GetMapTrace, ) +from deebot_client.commands.json.map import GetMapSetV2 from deebot_client.events import ( MajorMapEvent, MapSetEvent, @@ -22,10 +25,28 @@ from . import assert_command -async def test_getMapSubSet_customName() -> None: +@pytest.mark.parametrize( + ("compress", "value", "expected"), + [ + ( + 1, + "XQAABACZAAAAABaOQmW9Bsibxz42rKUpGlV7Rr4D1S/9x9mDa60v4J1BKrEsnk34EAt6X5gKkxwYzfOu3T8GAPpmIy5o4A==", + "-9125,3225;-9025,3225;-8975,3175;-8975,2475;-8925,2425;-8925,2375;-8325,2375;-8275,2425;-8225,2375;-8225,2425;-8174,2475;-8024,2475;-8024,4375;-9125,4375", + ), + ( + 0, + "1400,1800;1400,3250;3000,3250;3000,2700;2900,2850;2750,2700;2800,1250;2700,1050;2700,850;1450,850;1400,1800", + "1400,1800;1400,3250;3000,3250;3000,2700;2900,2850;2750,2700;2800,1250;2700,1050;2700,850;1450,850;1400,1800", + ), + ], +) +async def test_getMapSubSet_customName( + compress: int, value: str, expected: str +) -> None: type = MapSetType.ROOMS - value = "XQAABAB5AgAAABaOQok5MfkIKbGTBxaUTX13SjXBAI1/Q3A9Kkx2gYZ1QdgwfwOSlU3hbRjNJYgr2Pr3WgFez3Gcoj3R2JmzAuc436F885ZKt5NF2AE1UPAF4qq67tK6TSA64PPfmZQ0lqwInQmqKG5/KO59RyFBbV1NKnDIGNBGVCWpH62WLlMu8N4zotA8dYMQ/UBMwr/gddQO5HU01OQM2YvF" name = "Levin" + mid = "98100521" + mssid = "8" json = get_request_json( get_success_body( { @@ -40,18 +61,18 @@ async def test_getMapSubSet_customName() -> None: "index": 0, "cleanset": "1,0,2", "valueSize": 633, - "compress": 1, + "compress": compress, "center": "-6775,-9225", - "mssid": "8", + "mssid": mssid, "value": value, - "mid": "98100521", + "mid": mid, } ) ) await assert_command( - GetMapSubSet(mid="98100521", mssid="8", msid="1"), + GetMapSubSet(mid=mid, mssid=mssid, msid="1"), json, - MapSubsetEvent(8, type, value, name), + MapSubsetEvent(8, type, expected, name), ) @@ -77,7 +98,17 @@ async def test_getMapSubSet_living_room() -> None: ) -async def test_getCachedMapInfo() -> None: +@pytest.mark.parametrize( + ("command", "map_set_type"), + [ + (GetCachedMapInfo(), GetMapSet), + (GetCachedMapInfo(version=1), GetMapSet), + (GetCachedMapInfo(version=2), GetMapSetV2), + ], +) +async def test_getCachedMapInfo( + command: GetCachedMapInfo, map_set_type: type[GetMapSet | GetMapSetV2] +) -> None: expected_mid = "199390082" expected_name = "Erdgeschoss" json = get_request_json( @@ -106,13 +137,13 @@ async def test_getCachedMapInfo() -> None: ) ) await assert_command( - GetCachedMapInfo(), + command, json, CachedMapInfoEvent(expected_name, active=True), command_result=CommandResult( HandlingState.SUCCESS, {"map_id": expected_mid}, - [GetMapSet(expected_mid, entry) for entry in MapSetType], + [map_set_type(expected_mid, entry) for entry in MapSetType], ), ) @@ -169,7 +200,7 @@ async def test_getMapSet() -> None: MapSetEvent(MapSetType.ROOMS, subsets), command_result=CommandResult( HandlingState.SUCCESS, - {"id": "199390082", "set_id": "8", "type": "ar", "subsets": subsets}, + {"id": mid, "set_id": msid, "type": MapSetType.ROOMS, "subsets": subsets}, [ GetMapSubSet(mid=mid, msid=msid, type=MapSetType.ROOMS, mssid=s) for s in subsets @@ -178,6 +209,115 @@ async def test_getMapSet() -> None: ) +async def test_getMapSetV2_no_mop_zones() -> None: + mid = "199390082" + type = MapSetType.NO_MOP_ZONES + json = get_request_json( + get_success_body( + { + "type": type, + "mid": mid, + "batid": "fbfebf", + "serial": 1, + "index": 1, + "subsets": "XQAABABBAAAAAC2WwEIwUhHX3vfFDfs1H1PUqtdWgakwVnMBz3Bb3yaoE5OYkdYA", + "infoSize": 65, + } + ) + ) + await assert_command( + GetMapSetV2(mid, type), + json, + ( + MapSubsetEvent( + 4, + type, + str(["-6217", "3919", "-6217", "231", "-2642", "231", "-2642", "3919"]), + ), + ), + ) + + +async def test_getMapSetV2_rooms() -> None: + mid = "199390082" + msid = "8" + type = MapSetType.ROOMS + subsets_comp = ( + "XQAABADnAQAAAC2WwEHwYhHYFuLu9964T0CAIjkOBSGKBW+PcTQDCjKFThR86eaw4bFiV2BKLAP+0lTYd1ADOkmjNPrfSqBeHZLY4JNCaEMc2H245BSG143miuQm6X6" + "KeTCnXV7Er028XLcnN9q/immzxeoPpkdhnbhuL9f8jW5kgVLGPJnfv2V2a79W4PjkSR4b4Px632ID+UKVwGL1mYiwNnMO35XA41W+pPsgW12ZRnsMDvGMAlv4VLhDJFAy4AA=" + ) + json = get_request_json( + get_success_body( + { + "type": type, + "mid": mid, + "msid": msid, + "batid": "gheijg", + "serial": 1, + "index": 1, + "subsets": subsets_comp, + "infoSize": 199, + } + ) + ) + subsets = [0, 1, 6, 2, 7, 3, 5] + + await assert_command( + GetMapSetV2(mid, type), + json, + MapSetEvent(MapSetType(type), subsets), + command_result=CommandResult( + HandlingState.SUCCESS, + {"id": mid, "set_id": msid, "type": MapSetType(type), "subsets": subsets}, + [GetMapSubSet(mid=mid, msid=msid, type=type, mssid=s) for s in subsets], + ), + ) + + +async def test_getMapSetV2_virtual_walls() -> None: + mid = "199390082" + type = MapSetType.VIRTUAL_WALLS + json = get_request_json( + get_success_body( + { + "type": type, + "mid": mid, + "batid": "gheijg", + "serial": 1, + "index": 1, + "subsets": "XQAABADHAAAAAC2WwEHwYhHX3vWwDK80QCnaQU0mwUd9Vk34ub6OxzOk6kdFfbFvpVp4iIlKisAvp0MznQNYEZ8koxFHnO,+iM44GUKgujGQKgzl0bScbQgaon1jI3eyCRikWlkmrbwA=", + "infoSize": 199, + } + ) + ) + + expected_walls: list[dict[str, str | int]] = [ + { + "mssid": 0, + "coordinates": str( + ["-5195", "-1059", "-5195", "-37", "-5806", "-37", "-5806", "-1059"] + ), + }, + { + "mssid": 1, + "coordinates": str( + ["-7959", "220", "-7959", "1083", "-9254", "1083", "-9254", "220"] + ), + }, + {"mssid": 2, "coordinates": str(["-9437", "347", "-5387", "410"])}, + {"mssid": 3, "coordinates": str(["-5667", "317", "-4888", "-56"])}, + ] + + await assert_command( + GetMapSetV2(mid, type), + json, + [ + MapSubsetEvent(int(subs["mssid"]), type, str(subs["coordinates"])) + for subs in expected_walls + ], + ) + + async def test_getMapTrace() -> None: start = 0 total = 160 diff --git a/tests/messages/json/test_map.py b/tests/messages/json/test_map.py new file mode 100644 index 00000000..c2332aea --- /dev/null +++ b/tests/messages/json/test_map.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from unittest.mock import Mock + +import pytest + +from deebot_client.event_bus import EventBus +from deebot_client.events.map import MapSetType +from deebot_client.message import HandlingState +from deebot_client.messages.json import OnMapSetV2 + + +@pytest.mark.parametrize( + ("mid", "type"), + [ + ("199390082", MapSetType.ROOMS), + ("199390082", MapSetType.NO_MOP_ZONES), + ("199390082", MapSetType.VIRTUAL_WALLS), + ], +) +def test_onMapSetV2(mid: str, type: MapSetType) -> None: + data = { + "header": { + "pri": 1, + "tzm": 480, + "ts": "1304637391896", + "ver": "0.0.1", + "fwVer": "1.8.2", + "hwVer": "0.1.1", + }, + "body": {"data": {"mid": mid, "type": type.value}}, + } + + # NOTE: this needs to be update when OnMapSetV2 can call commands + event_bus = Mock(spec_set=EventBus) + result = OnMapSetV2.handle(event_bus, data) + assert result.state == HandlingState.SUCCESS