diff --git a/deebot_client/map.py b/deebot_client/map.py index fec2ead8..e326178b 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -2,7 +2,7 @@ import ast import asyncio import base64 -from collections.abc import Callable, Coroutine +from collections.abc import Callable, Coroutine, Sequence import dataclasses from datetime import UTC, datetime from io import BytesIO @@ -10,12 +10,14 @@ import lzma import re import struct +from textwrap import dedent from typing import Any, Final import zlib from numpy import float64, reshape, zeros from numpy.typing import NDArray from PIL import Image, ImageDraw +import svg from deebot_client.events.map import MapChangedEvent @@ -47,8 +49,6 @@ PositionType.CHARGER: 1, } -_SVG_COORDS_COMPACT = re.compile(r"(?:(?<=\D)\s)|(?:\s(?=\D))") - _SVG_MAP_MARGIN = 5 # Categorigal palette for 12 non related elements @@ -78,6 +78,31 @@ MapSetType.NO_MOP_ZONES: "#FFA500", } +# SVG definitions referred by map elements +_SVG_DEFS = svg.Defs( + text=dedent( + f""" + + + + + + + + + + + + + + + + + + """ + ) +) + def _decompress_7z_base64_data(data: str) -> bytes: _LOGGER.debug("[decompress7zBase64Data] Begin") @@ -100,7 +125,7 @@ def _decompress_7z_base64_data(data: str) -> bytes: return decompressed_data -def _calc_value(value: int, min_value: int, max_value: int) -> float: +def _calc_value(value: float, min_value: float, max_value: float) -> float: try: if value is not None: # SVG allows sub-pixel precision, so we use floating point coordinates for better placement. @@ -115,7 +140,7 @@ def _calc_value(value: int, min_value: int, max_value: int) -> float: def _calc_point( - x: int, y: int, image_box: tuple[int, int, int, int] | None + x: float, y: float, image_box: tuple[float, float, float, float] | None ) -> tuple[float, float]: if image_box is None: image_box = (0, 0, x, y) @@ -126,58 +151,72 @@ def _calc_point( ) -def _points_to_svg_path(points: list[Any]) -> None: +def _points_to_svg_path( + points: Sequence[tuple[float, float]] | Sequence[tuple[float, float, bool, int]], +) -> list[svg.PathData]: # Convert a set of simple point (x, y), or trace points (x, y, connected, type) to a compacted # SVG path instruction. - path_points = [] - for prev_p, p in itertools.pairwise([None, *points]): + path_data: list[svg.PathData] = [] + + # First instruction: move to the starting point using absolute coordinates + first_p = points[0] + path_data.append(svg.MoveTo(first_p[0], first_p[1])) + + for prev_p, p in itertools.pairwise(points): if p != prev_p: # Skip repeated points - if prev_p: - # Relativize coords in order to generate compacted path - path_points.append("l" if len(p) == 2 or p[2] else "m") - path_points.extend(map(lambda a, b: str(a - b), p[0:2], prev_p[0:2])) + if len(p) == 2 or p[2]: + path_data.append(svg.LineToRel(p[0] - prev_p[0], p[1] - prev_p[1])) else: - # No previous point, use absolute coordinates for initial position - path_points.append("M") - path_points.extend(map(str, p[0:2])) + path_data.append(svg.MoveToRel(p[0] - prev_p[0], p[1] - prev_p[1])) # Further compact the path (keep only whitespaces between two numeric characters) - return _SVG_COORDS_COMPACT.sub("", " ".join(path_points)) + return path_data def _get_svg_positions( positions: list[Position], image_box: tuple[int, int, int, int] | None, -) -> str: - svg_positions = [] +) -> list[svg.Element]: + svg_positions: list[svg.Element] = [] for position in sorted(positions, key=lambda x: _POSITIONS_SVG_ORDER[x.type]): pos = _calc_point(position.x, position.y, image_box) svg_positions.append( - f"" + svg.Use(href=f"#position_{position.type}", x=pos[0], y=pos[1]) ) - return "".join(svg_positions) + return svg_positions def _get_svg_subset( subset: MapSubsetEvent, image_box: tuple[int, int, int, int] | None, -) -> str: - coordinates_ = ast.literal_eval(subset.coordinates) +) -> svg.Path | svg.Polygon: + subset_coordinates: list[int] = ast.literal_eval(subset.coordinates) - points: list[tuple[int, int]] = [ - _calc_point(coordinates_[i], coordinates_[i + 1], image_box) - for i in range(0, len(coordinates_), 2) + points = [ + _calc_point(subset_coordinates[i], subset_coordinates[i + 1], image_box) + for i in range(0, len(subset_coordinates), 2) ] - if len(points) == 4: - # Return polygon - svg_coords = list(sum(points, ())) - return f"""""" - # Return path - return f"""""" + if len(points) == 2: + # Only 2 point, use a path + return svg.Path( + stroke=_COLORS[subset.type], + stroke_width=1.5, + stroke_dasharray=[4], + vector_effect="non-scaling-stroke", + d=_points_to_svg_path(points), + ) + + # 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 + stroke=_COLORS[subset.type], + stroke_width=1.5, + stroke_dasharray=[4], + vector_effect="non-scaling-stroke", + points=list(sum(points, [])), # Re-flatten the list of coordinates + ) class Map: @@ -238,16 +277,16 @@ def _update_trace_points(self, data: str) -> None: trace_points = _decompress_7z_base64_data(data) for i in range(0, len(trace_points), 5): - byte_position_x = struct.unpack("> 7 & 1 == 0 - type = point_data & 1 + point_type = point_data & 1 self._map_data.trace_values.append( - (byte_position_x[0], byte_position_y[0], connected, type) + (position_x, position_y, connected, point_type) ) _LOGGER.debug("[_update_trace_points] finish") @@ -283,23 +322,29 @@ def _draw_map_pieces(self, draw: ImageDraw.ImageDraw) -> None: if pixel_type in [0x01, 0x02, 0x03]: draw.point((point_x, point_y), fill=_COLORS[pixel_type]) - def _get_svg_traces_path(self) -> str: + def _get_svg_traces_path(self) -> svg.Path | None: if len(self._map_data.trace_values) > 0: _LOGGER.debug("[get_svg_map] Draw Trace") - return f"""""" + return svg.Path( + fill="none", + stroke=_COLORS[_TRACE_MAP], + stroke_width=1.5, + stroke_linejoin="round", + vector_effect="non-scaling-stroke", + transform=[svg.Translate(_OFFSET, _OFFSET), svg.Scale(0.2, 0.2)], + d=_points_to_svg_path(self._map_data.trace_values), + ) - return "" + return None def _get_svg_rooms( self, image_box: tuple[int, int, int, int], image_box_center: tuple[float, float], - ) -> tuple[list[str], list[str]]: - svg_rooms_elements = [] - svg_rooms_labels = [] + ) -> tuple[list[svg.Element], list[svg.Element]]: + svg_rooms_elements: list[svg.Element] = [] + svg_rooms_labels: list[svg.Element] = [] for room, color in zip( sorted(self._map_data.rooms.keys()), itertools.cycle(_ROOM_COLORS) @@ -312,21 +357,23 @@ def _get_svg_rooms( ).decode("ascii"), ) - # SVG compacted presentation - svg_room_coords = _SVG_COORDS_COMPACT.sub("", " ".join(room_coords)) - # Append to room svg elements svg_rooms_elements.append( - f"""""" + svg.Polygon( + id=f"room_{room}", + fill=color + "50", + stroke=color + "A0", + stroke_width=2, + vector_effect="non-scaling-stroke", + transform=[svg.Translate(_OFFSET, _OFFSET), svg.Scale(0.02, 0.02)], + points=list(map(int, room_coords)), + ) ) room_name = self._map_data.rooms[room].name if room_name != "Default": - # Calculate label positions (cannot use SVG transformations, as they are applied to the whole text, - # which would result in text to be vertically flipped...) + # Calculate label positions (cannot use SVG transformations to vertically flip coordinates, as transformations are + # applied to the whole text, which would result in text to be vertically flipped...) # Get a rough room center. room_center_x = sum(float(x) for x in room_coords[0::2]) / ( @@ -337,16 +384,21 @@ def _get_svg_rooms( ) # Get map relative position - room_center_pos = _calc_point(room_center_x, room_center_y, image_box) + room_center_p = _calc_point(room_center_x, room_center_y, image_box) # Add the text, with position vertically flipped on map center svg_rooms_labels.append( - f"""{room_name}""" + svg.Text( + id=f"room_label_{room}", + x=room_center_p[0], + y=image_box_center[1] - room_center_p[1] + image_box_center[1], + dominant_baseline="middle", + text_anchor="middle", + font_family="sans-serif", + font_size=svg.Length(4, "pt"), + style="user_select: none", + text=room_name, + ) ) return (svg_rooms_elements, svg_rooms_labels) @@ -467,7 +519,7 @@ def get_svg_map(self, width: int | None = None) -> str: svg_positions = _get_svg_positions(self._map_data.positions, image_box) - svg_subset_elements = [ + svg_subset_elements: list[svg.Element] = [ _get_svg_subset(subset, image_box) for subset in self._map_data.map_subsets.values() ] @@ -478,48 +530,64 @@ def get_svg_map(self, width: int | None = None) -> str: svg_traces_path = self._get_svg_traces_path() - svg_map = f""" - - - - - - - - - - - - - - - - - - - - - - - {"".join(svg_rooms_elements)} - {"".join(svg_subset_elements)} - {svg_traces_path} - {svg_positions} - - {"".join(svg_rooms_labels)} - - """ + # Elements of the SVG Map + svg_map_group_elements: list[svg.Element] = [] + + # Map background. + svg_map_group_elements.append( + svg.Image( + x=image_box[0], + y=image_box[1], + width=image_box[2] - image_box[0], + height=image_box[3] - image_box[1], + style="image-rendering: pixelated", + href=f"data:image/png;base64,{base64_bg.decode('ascii')}", + ) + ) + + # Rooms + svg_map_group_elements.extend(svg_rooms_elements) + + # Additional subsets (VirtualWalls and NoMopZones) + svg_map_group_elements.extend(svg_subset_elements) + + # Traces (if any) + if svg_traces_path: + svg_map_group_elements.append(svg_traces_path) + + # Bot and Charge stations + svg_map_group_elements.extend(svg_positions) + + # Build the complete SVG map + svg_map = svg.SVG( + viewBox=svg.ViewBoxSpec( + image_box[0] - _SVG_MAP_MARGIN, + image_box[1] - _SVG_MAP_MARGIN, + (image_box[2] - image_box[0]) + _SVG_MAP_MARGIN * 2, + image_box[3] - image_box[1] + _SVG_MAP_MARGIN * 2, + ), + elements=[ + _SVG_DEFS, + svg.G( + id="map_group", + transform_origin=f"{image_box_center[0]} {image_box_center[1]}", + transform=[svg.Scale(1, -1)], + elements=svg_map_group_elements, + ), + svg.G(elements=svg_rooms_labels), + ], + ) + else: # No map data yet, generate an empty SVG. - svg_map = """""" + svg_map = svg.SVG() + str_svg_map = str(svg_map) self._map_data.reset_changed() - self._last_image = LastImage(svg_map, width) + self._last_image = LastImage(str_svg_map, width) _LOGGER.debug("[get_svg_map] Finish") - return svg_map + return str_svg_map async def teardown(self) -> None: """Teardown map.""" diff --git a/requirements.txt b/requirements.txt index 25bd0299..22ee78dd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ cachetools>=5.0.0,<6.0 defusedxml numpy>=1.23.2,<2.0 Pillow>=10.0.1,<11.0 +svg.py>=1.4.2