From 030e0d201682f05bf7f097d23e5f459ded484754 Mon Sep 17 00:00:00 2001 From: Luca De Petrillo <972242+lukakama@users.noreply.github.com> Date: Wed, 13 Dec 2023 11:35:32 +0100 Subject: [PATCH 01/10] Used SVG for map image generation, added support for not connected traces and added room rendering with labels. --- deebot_client/map.py | 373 ++++++++++++++++++++++--------------------- 1 file changed, 193 insertions(+), 180 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index 04d315d9..de7d4169 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -11,10 +11,12 @@ import struct from typing import Any, Final import zlib +import re +import itertools from numpy import float64, reshape, zeros from numpy.typing import NDArray -from PIL import Image, ImageDraw, ImageOps +from PIL import Image, ImageDraw from deebot_client.events.map import MapChangedEvent @@ -40,10 +42,19 @@ _LOGGER = get_logger(__name__) _PIXEL_WIDTH = 50 -_POSITION_PNG = { - PositionType.DEEBOT: "iVBORw0KGgoAAAANSUhEUgAAAAYAAAAGCAIAAABvrngfAAAACXBIWXMAAAsTAAALEwEAmpwYAAAF0WlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdEV2dD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlRXZlbnQjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcDpDcmVhdGVEYXRlPSIyMDIwLTA1LTI0VDEyOjAzOjE2KzAyOjAwIiB4bXA6TWV0YWRhdGFEYXRlPSIyMDIwLTA1LTI0VDEyOjAzOjE2KzAyOjAwIiB4bXA6TW9kaWZ5RGF0ZT0iMjAyMC0wNS0yNFQxMjowMzoxNiswMjowMCIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDo0YWM4NWY5MC1hNWMwLTE2NDktYTQ0MC0xMWM0NWY5OGQ1MDYiIHhtcE1NOkRvY3VtZW50SUQ9ImFkb2JlOmRvY2lkOnBob3Rvc2hvcDo3Zjk3MTZjMi1kZDM1LWJiNDItYjMzZS1hYjYwY2Y4ZTZlZDYiIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDpiMzhiNGZlMS1lOGNkLTJjNDctYmQwZC1lNmZiNzRhMjFkMDciIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIj4gPHhtcE1NOkhpc3Rvcnk+IDxyZGY6U2VxPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0iY3JlYXRlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDpiMzhiNGZlMS1lOGNkLTJjNDctYmQwZC1lNmZiNzRhMjFkMDciIHN0RXZ0OndoZW49IjIwMjAtMDUtMjRUMTI6MDM6MTYrMDI6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE5IChXaW5kb3dzKSIvPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0ic2F2ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6NGFjODVmOTAtYTVjMC0xNjQ5LWE0NDAtMTFjNDVmOThkNTA2IiBzdEV2dDp3aGVuPSIyMDIwLTA1LTI0VDEyOjAzOjE2KzAyOjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHN0RXZ0OmNoYW5nZWQ9Ii8iLz4gPC9yZGY6U2VxPiA8L3htcE1NOkhpc3Rvcnk+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+AP7+NwAAAFpJREFUCJllzEEKgzAQhtFvMkSsEKj30oUXrYserELA1obhd+nCd4BnksZ53X4Cnr193ov59Iq+o2SA2vz4p/iKkgkRouTYlbhJ/jBqww03avPBTNI4rdtx9ScfWyYCg52e0gAAAABJRU5ErkJggg==", # nopep8 - PositionType.CHARGER: "iVBORw0KGgoAAAANSUhEUgAAAAoAAAAOCAYAAAAWo42rAAAAdUlEQVQoU2NkQAP/nzD8BwkxyjAwIkuhcEASRCmEKYKZhGwq3ER0ReiKSVOIyzRkU8EmwhUyKzAwSNyHyL9QZGD4+wDMBLmVEasimFHIiuEKpcHBhwmeQryBMJFohcjuw2s1SBKHZ8BWo/gauyshvobJEYoZAEOSPXnhzwZnAAAAAElFTkSuQmCC", # nopep8 + +_POSITIONS_SVG_ORDER = { + PositionType.DEEBOT: 0, + PositionType.CHARGER: 1, } + +_SVG_COORDS_COMPACT = re.compile(r"(?:(?<=\D)\s)|(?:\s(?=\D))") + +_SVG_MAP_MARGIN = 5 + +# Categorigal palette for 12 non related elements +_ROOM_COLORS = ["#a6cee3", "#1f78b4", "#b2df8a", "#33a02c", "#fb9a99", "#e31a1c", "#fdbf6f", "#ff7f00", "#cab2d6", "#6a3d9a", "#ffff99", "#b15928"] + _OFFSET = 400 _TRACE_MAP = "trace_map" _COLORS = { @@ -77,10 +88,11 @@ def _decompress_7z_base64_data(data: str) -> bytes: return decompressed_data -def _calc_value(value: int, min_value: int, max_value: int) -> int: +def _calc_value(value: int, min_value: int, max_value: int) -> float: try: if value is not None: - new_value = int((int(value) / _PIXEL_WIDTH) + _OFFSET) + # SVG allows sub-pixel precision, so we use floating point coordinates for better placement. + new_value = (float(value) / _PIXEL_WIDTH) + _OFFSET # return value inside min and max return min(max_value, max(min_value, new_value)) @@ -92,7 +104,7 @@ def _calc_value(value: int, min_value: int, max_value: int) -> int: def _calc_point( x: int, y: int, image_box: tuple[int, int, int, int] | None -) -> tuple[int, int]: +) -> tuple[float, float]: if image_box is None: image_box = (0, 0, x, y) @@ -101,44 +113,62 @@ def _calc_point( _calc_value(y, image_box[1], image_box[3]), ) - -def _draw_positions( - positions: list[Position], - image: Image.Image, - image_box: tuple[int, int, int, int] | None, +def _points_to_svg_path( + points: list[Any] ) -> None: - for position in positions: - icon = Image.open(BytesIO(base64.b64decode(_POSITION_PNG[position.type]))) - image.paste( - icon, - _calc_point(position.x, position.y, image_box), - icon.convert("RGBA"), - ) + # 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]): + 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])) + else: + # No previous point, use absolute coordinates for initial position + path_points.append("M") + path_points.extend(map(str, p[0 : 2])) + # Further compact the path (keep only whitespaces between two numeric characters) + return _SVG_COORDS_COMPACT.sub("", " ".join(path_points)) -def _draw_subset( + + +def _get_svg_positions( + positions: list[Position], + image_box: tuple[int, int, int, int] | None, +) -> str: + svg_positions = [] + 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"") + + return "".join(svg_positions) + +def _get_svg_subset( subset: MapSubsetEvent, - draw: "DashedImageDraw", image_box: tuple[int, int, int, int] | None, -) -> None: +) -> str: coordinates_ = 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) ] if len(points) == 4: - # close rectangle - points.append(points[0]) - - draw.dashed_line(points, dash=(3, 2), fill=_COLORS[subset.type], width=1) - + # Return polygon + svg_coords = list(sum(points, ())) + return f"""""" + # Return path + return f"""""" class Map: """Map representation.""" - RESIZE_FACTOR = 3 - def __init__( self, execute_command: Callable[[Command], Coroutine[Any, Any, None]], @@ -196,13 +226,13 @@ def _update_trace_points(self, data: str) -> None: for i in range(0, len(trace_points), 5): byte_position_x = struct.unpack("> 7) & 1) != 0) + type = point_data & 1 - self._map_data.trace_values.append(position_x) - self._map_data.trace_values.append(position_y) + self._map_data.trace_values.append((byte_position_x[0], byte_position_y[0], connected, type)) _LOGGER.debug("[_update_trace_points] finish") @@ -237,6 +267,57 @@ 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: + if len(self._map_data.trace_values) > 0: + _LOGGER.debug("[get_svg_map] Draw Trace") + + return f"""""" + + return "" + + 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 = [] + + for room, color in zip(sorted(self._map_data.rooms.keys()), itertools.cycle(_ROOM_COLORS)): + # Split coordinates into a flat sequence + room_coords = re.split("[;,]",_decompress_7z_base64_data(self._map_data.rooms[room].coordinates).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"""""") + + + 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...) + + # Get a rough room center. + room_center_x = sum(float(x) for x in room_coords[0::2]) / (len(room_coords) / 2) + room_center_y = sum(float(y) for y in room_coords[1::2]) / (len(room_coords) / 2) + + # Get map relative position + room_center_pos = _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}""") + + return (svg_rooms_elements, svg_rooms_labels) + def enable(self) -> None: """Enable map.""" if self._unsubscribers: @@ -304,8 +385,8 @@ def refresh(self) -> None: self._event_bus.request_refresh(MapTraceEvent) self._event_bus.request_refresh(MajorMapEvent) - def get_base64_map(self, width: int | None = None) -> bytes: - """Return map as base64 image string.""" + def get_svg_map(self, width: int | None = None) -> str: + """Return map as SVG string.""" if not self._unsubscribers: raise MapError("Please enable the map first") @@ -314,74 +395,91 @@ def get_base64_map(self, width: int | None = None) -> bytes: and width == self._last_image.width and not self._map_data.changed ): - _LOGGER.debug("[get_base64_map] No need to update") - return self._last_image.base64_image - - _LOGGER.debug("[get_base64_map] Begin") + _LOGGER.debug("[get_svg_map] No need to update") + return self._last_image.svg_image + + _LOGGER.debug("[get_svg_map] Begin") + image = Image.new("RGBA", (6400, 6400)) - draw = DashedImageDraw(image) - + draw = ImageDraw.ImageDraw(image) self._draw_map_pieces(draw) - - # Draw Trace Route - if len(self._map_data.trace_values) > 0: - _LOGGER.debug("[get_base64_map] Draw Trace") - draw.line(self._map_data.trace_values, fill=_COLORS[_TRACE_MAP], width=1) - - image_box = image.getbbox() - for subset in self._map_data.map_subsets.values(): - _draw_subset(subset, draw, image_box) - del draw - _draw_positions(self._map_data.positions, image, image_box) - - _LOGGER.debug("[get_base64_map] Crop Image") - cropped = image.crop(image_box) - del image + image_box = image.getbbox() - _LOGGER.debug("[get_base64_map] Flipping Image") - cropped = ImageOps.flip(cropped) + if image_box: + image_box_center = ((image_box[0] + image_box[2]) / 2, (image_box[1] + image_box[3]) / 2) - _LOGGER.debug( - "[get_base64_map] Map current Size: X: %d Y: %d", - cropped.size[0], - cropped.size[1], - ) + _LOGGER.debug("[get_svg_map] Crop Image") + cropped = image.crop(image_box) + del image - new_size = None - if width is not None and width > 0: - height = int((width / cropped.size[0]) * cropped.size[1]) _LOGGER.debug( - "[get_base64_map] Resize based on the requested width: %d and calculated height %d", - width, - height, + "[get_svg_map] Map current Size: X: %d Y: %d", + cropped.size[0], + cropped.size[1], ) - new_size = (width, height) - elif cropped.size[0] > 400 or cropped.size[1] > 400: - _LOGGER.debug("[get_base64_map] Resize disabled.. map over 400") - else: - resize_factor = Map.RESIZE_FACTOR - _LOGGER.debug("[get_base64_map] Resize factor: %d", resize_factor) - new_size = ( - cropped.size[0] * resize_factor, - cropped.size[1] * resize_factor, - ) - - if new_size is not None: - cropped = cropped.resize(new_size, Image.Resampling.NEAREST) - - _LOGGER.debug("[get_base64_map] Saving to buffer") - buffered = BytesIO() - cropped.save(buffered, format="PNG") - del cropped - base64_image = base64.b64encode(buffered.getvalue()) + _LOGGER.debug("[get_svg_map] Saving to buffer") + buffered = BytesIO() + cropped.save(buffered, format="PNG") + del cropped + + base64_bg = base64.b64encode(buffered.getvalue()) + + # Build the SVG XML + + svg_positions = _get_svg_positions(self._map_data.positions, image_box) + + svg_subset_elements = [_get_svg_subset(subset, image_box) for subset in self._map_data.map_subsets.values()] + + svg_rooms_elements, svg_rooms_labels = self._get_svg_rooms(image_box, image_box_center) + + 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)} + + """ + else: + # No map data yet, generate an empty SVG. + svg_map = """""" + self._map_data.reset_changed() - self._last_image = LastImage(base64_image, width) - _LOGGER.debug("[get_base64_map] Finish") + self._last_image = LastImage(svg_map, width) + _LOGGER.debug("[get_svg_map] Finish") + + return svg_map - return base64_image async def teardown(self) -> None: """Teardown map.""" @@ -448,95 +546,10 @@ def __eq__(self, obj: object) -> bool: return self._crc32 == obj._crc32 and self._index == obj._index -class DashedImageDraw(ImageDraw.ImageDraw): - """Class extend ImageDraw by dashed line.""" - - # Copied from https://stackoverflow.com/a/65893631 Credits ands - _FILL = str | int | tuple[int, int, int] | tuple[int, int, int, int] | None - - def _thick_line( - self, - xy: list[tuple[int, int]], - direction: list[tuple[int, int]], - fill: _FILL = None, - width: int = 0, - ) -> None: - if xy[0] != xy[1]: - self.line(xy, fill=fill, width=width) - else: - x1, y1 = xy[0] - delta_x = direction[1][0] - direction[0][0] - delta_y = direction[1][1] - direction[0][1] - - if delta_x < 0: - y1 -= 1 - - if delta_y < 0: - x1 -= 1 - - if delta_y != 0: - if delta_x != 0: - k = -delta_x / delta_y - a = 1 / math.sqrt(1 + k**2) - b = (width * a - 1) / 2 - else: - k = 0 - b = (width - 1) / 2 - x1 = x1 - math.floor(b) - y1 = y1 - int(k * b) - x2 = x1 + math.ceil(b) - y2 = y1 + int(k * b) - else: - y1 = y1 - math.floor((width - 1) / 2) - x2 = x1 - y2 = y1 + math.ceil((width - 1) / 2) - self.line([(x1, y1), (x2, y2)], fill=fill, width=1) - - def dashed_line( - self, - xy: list[tuple[int, int]], - dash: tuple[int, int] = (2, 2), - fill: _FILL = None, - width: int = 0, - ) -> None: - """Draw a dashed line, or a connected sequence of line segments.""" - for i in range(len(xy) - 1): - x1, y1 = xy[i] - x2, y2 = xy[i + 1] - x_length = x2 - x1 - y_length = y2 - y1 - length = math.sqrt(x_length**2 + y_length**2) - dash_enabled = True - position = 0 - while position < length: - for dash_step in dash: - if dash_enabled: - start = position / length - end = min((position + dash_step - 1) / length, 1) - self._thick_line( - [ - ( - round(x1 + start * x_length), - round(y1 + start * y_length), - ), - ( - round(x1 + end * x_length), - round(y1 + end * y_length), - ), - ], - xy, - fill, - width, - ) - dash_enabled = not dash_enabled - position += dash_step - - @dataclasses.dataclass(frozen=True) class LastImage: """Last created image.""" - - base64_image: bytes + svg_image: str width: int | None @@ -557,7 +570,7 @@ def on_change() -> None: self._map_subsets: OnChangedDict[int, MapSubsetEvent] = OnChangedDict(on_change) self._positions: OnChangedList[Position] = OnChangedList(on_change) self._rooms: OnChangedDict[int, Room] = OnChangedDict(on_change) - self._trace_values: OnChangedList[int] = OnChangedList(on_change) + self._trace_values: OnChangedList[tuple[int, int, bool, int]] = OnChangedList(on_change) @property def changed(self) -> bool: @@ -592,7 +605,7 @@ def rooms(self) -> dict[int, Room]: return self._rooms @property - def trace_values(self) -> OnChangedList[int]: + def trace_values(self) -> OnChangedList[tuple[int, int, bool, int]]: """Return trace values.""" return self._trace_values From 3d434932e63b77618eb0fbb7ab656f6f27ecfa5e Mon Sep 17 00:00:00 2001 From: Luca De Petrillo <972242+lukakama@users.noreply.github.com> Date: Wed, 13 Dec 2023 16:16:04 +0100 Subject: [PATCH 02/10] Code formatting --- deebot_client/map.py | 159 +++++++++++++++++++++++++++---------------- 1 file changed, 101 insertions(+), 58 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index de7d4169..fec2ead8 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -6,13 +6,12 @@ import dataclasses from datetime import UTC, datetime from io import BytesIO +import itertools import lzma -import math +import re import struct from typing import Any, Final import zlib -import re -import itertools from numpy import float64, reshape, zeros from numpy.typing import NDArray @@ -53,7 +52,20 @@ _SVG_MAP_MARGIN = 5 # Categorigal palette for 12 non related elements -_ROOM_COLORS = ["#a6cee3", "#1f78b4", "#b2df8a", "#33a02c", "#fb9a99", "#e31a1c", "#fdbf6f", "#ff7f00", "#cab2d6", "#6a3d9a", "#ffff99", "#b15928"] +_ROOM_COLORS = [ + "#a6cee3", + "#1f78b4", + "#b2df8a", + "#33a02c", + "#fb9a99", + "#e31a1c", + "#fdbf6f", + "#ff7f00", + "#cab2d6", + "#6a3d9a", + "#ffff99", + "#b15928", +] _OFFSET = 400 _TRACE_MAP = "trace_map" @@ -92,7 +104,7 @@ def _calc_value(value: int, min_value: int, max_value: int) -> float: try: if value is not None: # SVG allows sub-pixel precision, so we use floating point coordinates for better placement. - new_value = (float(value) / _PIXEL_WIDTH) + _OFFSET + new_value = (float(value) / _PIXEL_WIDTH) + _OFFSET # return value inside min and max return min(max_value, max(min_value, new_value)) @@ -113,28 +125,26 @@ def _calc_point( _calc_value(y, image_box[1], image_box[3]), ) -def _points_to_svg_path( - points: list[Any] -) -> None: - # Convert a set of simple point (x, y), or trace points (x, y, connected, type) to a compacted + +def _points_to_svg_path(points: list[Any]) -> None: + # 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]): - if p != prev_p: # Skip repeated points - if (prev_p): + 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])) + path_points.extend(map(lambda a, b: str(a - b), p[0:2], prev_p[0:2])) else: # No previous point, use absolute coordinates for initial position path_points.append("M") - path_points.extend(map(str, p[0 : 2])) + path_points.extend(map(str, p[0:2])) # Further compact the path (keep only whitespaces between two numeric characters) return _SVG_COORDS_COMPACT.sub("", " ".join(path_points)) - def _get_svg_positions( positions: list[Position], image_box: tuple[int, int, int, int] | None, @@ -142,16 +152,19 @@ def _get_svg_positions( svg_positions = [] 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_positions.append( + f"" + ) + return "".join(svg_positions) + def _get_svg_subset( subset: MapSubsetEvent, image_box: tuple[int, int, int, int] | None, ) -> str: coordinates_ = 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) @@ -160,12 +173,13 @@ def _get_svg_subset( if len(points) == 4: # Return polygon svg_coords = list(sum(points, ())) - return f"""""" # Return path - return f"""""" + class Map: """Map representation.""" @@ -226,13 +240,15 @@ def _update_trace_points(self, data: str) -> None: for i in range(0, len(trace_points), 5): byte_position_x = struct.unpack("> 7) & 1) != 0) + connected = point_data >> 7 & 1 == 0 type = point_data & 1 - self._map_data.trace_values.append((byte_position_x[0], byte_position_y[0], connected, type)) + self._map_data.trace_values.append( + (byte_position_x[0], byte_position_y[0], connected, type) + ) _LOGGER.debug("[_update_trace_points] finish") @@ -270,54 +286,71 @@ def _draw_map_pieces(self, draw: ImageDraw.ImageDraw) -> None: def _get_svg_traces_path(self) -> str: if len(self._map_data.trace_values) > 0: _LOGGER.debug("[get_svg_map] Draw Trace") - - return f"""""" - + return "" - def _get_svg_rooms(self, image_box: tuple[int, int, int, int], image_box_center: tuple[float, float]) -> tuple[list[str], list[str]] : + 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 = [] - for room, color in zip(sorted(self._map_data.rooms.keys()), itertools.cycle(_ROOM_COLORS)): + for room, color in zip( + sorted(self._map_data.rooms.keys()), itertools.cycle(_ROOM_COLORS) + ): # Split coordinates into a flat sequence - room_coords = re.split("[;,]",_decompress_7z_base64_data(self._map_data.rooms[room].coordinates).decode('ascii')) + room_coords = re.split( + "[;,]", + _decompress_7z_base64_data( + self._map_data.rooms[room].coordinates + ).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"""""") - + f"""""" + ) 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, + # Calculate label positions (cannot use SVG transformations, as they 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]) / (len(room_coords) / 2) - room_center_y = sum(float(y) for y in room_coords[1::2]) / (len(room_coords) / 2) + room_center_x = sum(float(x) for x in room_coords[0::2]) / ( + len(room_coords) / 2 + ) + room_center_y = sum(float(y) for y in room_coords[1::2]) / ( + len(room_coords) / 2 + ) # Get map relative position room_center_pos = _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}""") - + style='font: 4pt sans-serif; user-select: none'>{room_name}""" + ) + return (svg_rooms_elements, svg_rooms_labels) - + def enable(self) -> None: """Enable map.""" if self._unsubscribers: @@ -397,9 +430,9 @@ def get_svg_map(self, width: int | None = None) -> str: ): _LOGGER.debug("[get_svg_map] No need to update") return self._last_image.svg_image - + _LOGGER.debug("[get_svg_map] Begin") - + image = Image.new("RGBA", (6400, 6400)) draw = ImageDraw.ImageDraw(image) self._draw_map_pieces(draw) @@ -408,7 +441,10 @@ def get_svg_map(self, width: int | None = None) -> str: image_box = image.getbbox() if image_box: - image_box_center = ((image_box[0] + image_box[2]) / 2, (image_box[1] + image_box[3]) / 2) + image_box_center = ( + (image_box[0] + image_box[2]) / 2, + (image_box[1] + image_box[3]) / 2, + ) _LOGGER.debug("[get_svg_map] Crop Image") cropped = image.crop(image_box) @@ -426,19 +462,24 @@ def get_svg_map(self, width: int | None = None) -> str: del cropped base64_bg = base64.b64encode(buffered.getvalue()) - + # Build the SVG XML - + svg_positions = _get_svg_positions(self._map_data.positions, image_box) - svg_subset_elements = [_get_svg_subset(subset, image_box) for subset in self._map_data.map_subsets.values()] + svg_subset_elements = [ + _get_svg_subset(subset, image_box) + for subset in self._map_data.map_subsets.values() + ] - svg_rooms_elements, svg_rooms_labels = self._get_svg_rooms(image_box, image_box_center) + svg_rooms_elements, svg_rooms_labels = self._get_svg_rooms( + image_box, image_box_center + ) svg_traces_path = self._get_svg_traces_path() svg_map = f""" - @@ -460,7 +501,7 @@ def get_svg_map(self, width: int | None = None) -> str: - {"".join(svg_rooms_elements)} {"".join(svg_subset_elements)} @@ -473,14 +514,13 @@ def get_svg_map(self, width: int | None = None) -> str: else: # No map data yet, generate an empty SVG. svg_map = """""" - + self._map_data.reset_changed() self._last_image = LastImage(svg_map, width) _LOGGER.debug("[get_svg_map] Finish") return svg_map - async def teardown(self) -> None: """Teardown map.""" self.disable() @@ -549,6 +589,7 @@ def __eq__(self, obj: object) -> bool: @dataclasses.dataclass(frozen=True) class LastImage: """Last created image.""" + svg_image: str width: int | None @@ -570,7 +611,9 @@ def on_change() -> None: self._map_subsets: OnChangedDict[int, MapSubsetEvent] = OnChangedDict(on_change) self._positions: OnChangedList[Position] = OnChangedList(on_change) self._rooms: OnChangedDict[int, Room] = OnChangedDict(on_change) - self._trace_values: OnChangedList[tuple[int, int, bool, int]] = OnChangedList(on_change) + self._trace_values: OnChangedList[tuple[int, int, bool, int]] = OnChangedList( + on_change + ) @property def changed(self) -> bool: From 3ca558347942fbc702d9c51052c6427552c97f7e Mon Sep 17 00:00:00 2001 From: Luca De Petrillo <972242+lukakama@users.noreply.github.com> Date: Thu, 14 Dec 2023 17:35:20 +0000 Subject: [PATCH 03/10] Migrated code to use an high-level library (svg.py) to build SVG elements. Fixed various typing hints. Code cleanups. --- deebot_client/map.py | 264 +++++++++++++++++++++++++++---------------- requirements.txt | 1 + 2 files changed, 167 insertions(+), 98 deletions(-) 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 From f89fd0786c2eb7195205fd2e0abed576430047bb Mon Sep 17 00:00:00 2001 From: Luca De Petrillo <972242+lukakama@users.noreply.github.com> Date: Sat, 16 Dec 2023 14:10:47 +0100 Subject: [PATCH 04/10] Apply suggestions from code review Co-authored-by: Robert Resch --- deebot_client/map.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index e326178b..0e9c1bc0 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -490,9 +490,8 @@ def get_svg_map(self, width: int | None = None) -> str: self._draw_map_pieces(draw) del draw - image_box = image.getbbox() - if image_box: + if image_box := image.getbbox(): image_box_center = ( (image_box[0] + image_box[2]) / 2, (image_box[1] + image_box[3]) / 2, @@ -578,11 +577,8 @@ def get_svg_map(self, width: int | None = None) -> str: ], ) - else: - # No map data yet, generate an empty SVG. - svg_map = svg.SVG() - str_svg_map = str(svg_map) + str_svg_map = str(svg_map or svg.SVG()) self._map_data.reset_changed() self._last_image = LastImage(str_svg_map, width) _LOGGER.debug("[get_svg_map] Finish") From dc1d9bcacf07a120064bcc666e4550a508522df3 Mon Sep 17 00:00:00 2001 From: Luca De Petrillo <972242+lukakama@users.noreply.github.com> Date: Sat, 16 Dec 2023 15:00:38 +0000 Subject: [PATCH 05/10] Converted static Defs to svg.py elements. Fixed some comments. Fixed a pylance error on svg_map to str conversion. --- deebot_client/map.py | 115 +++++++++++++++++++++++++------------------ 1 file changed, 68 insertions(+), 47 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index 0e9c1bc0..f941221c 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -10,7 +10,6 @@ import lzma import re import struct -from textwrap import dedent from typing import Any, Final import zlib @@ -80,27 +79,48 @@ # SVG definitions referred by map elements _SVG_DEFS = svg.Defs( - text=dedent( - f""" - - - - - - - - - - - - - - - - - - """ - ) + elements=[ + # Gradient used by Bot icon + svg.RadialGradient( + id="device_bg", + cx=svg.Length(50, "%"), + cy=svg.Length(50, "%"), + r=svg.Length(50, "%"), + fx=svg.Length(50, "%"), + fy=svg.Length(50, "%"), + elements=[ + svg.Stop(offset=svg.Length(70, "%"), style="stop-color:#0000FF;"), + svg.Stop(offset=svg.Length(97, "%"), style="stop-color:#0000FF00;"), + ], + ), + # Bot circular icon + svg.G( + id=f"position_{PositionType.DEEBOT}", + elements=[ + svg.Circle(r=5, fill="url(#device_bg)"), + svg.Circle(r=3.5, stroke="white", fill="blue", stroke_width=0.5), + ], + ), + # Charger pin icon (pre-flipped vertically) + svg.G( + id=f"position_{PositionType.CHARGER}", + transform=[svg.Scale(4, -4)], + elements=[ + svg.Path( + fill="#ffe605", + d=[ + svg.M(1, -1.6), + svg.C(1, -1.05, 0, 0, 0, 0), + svg.c(0, 0, -1, -1.05, -1, -1.6), + svg.c(0, -0.55, 0.45, -1, 1, -1), + svg.c(0.55, 0, 1, 0.45, 1, 1), + svg.Z(), + ], + ), + svg.Circle(fill="white", r=0.7, cy=-1.6, cx=0), + ], + ), + ] ) @@ -154,8 +174,8 @@ def _calc_point( 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. + # Convert a set of simple point (x, y), or trace points (x, y, connected, type) to + # SVG path instructions. path_data: list[svg.PathData] = [] # First instruction: move to the starting point using absolute coordinates @@ -169,7 +189,6 @@ def _points_to_svg_path( else: 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 path_data @@ -490,7 +509,7 @@ def get_svg_map(self, width: int | None = None) -> str: self._draw_map_pieces(draw) del draw - + svg_map = svg.SVG() if image_box := image.getbbox(): image_box_center = ( (image_box[0] + image_box[2]) / 2, @@ -514,7 +533,7 @@ def get_svg_map(self, width: int | None = None) -> str: base64_bg = base64.b64encode(buffered.getvalue()) - # Build the SVG XML + # Build the SVG elements svg_positions = _get_svg_positions(self._map_data.positions, image_box) @@ -529,7 +548,7 @@ def get_svg_map(self, width: int | None = None) -> str: svg_traces_path = self._get_svg_traces_path() - # Elements of the SVG Map + # Elements of the SVG Map to vertically flip svg_map_group_elements: list[svg.Element] = [] # Map background. @@ -557,30 +576,32 @@ def get_svg_map(self, width: int | None = None) -> str: # 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), - ], + # Set map viewBox based on background map bounding box. + svg_map.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, ) + # Add all elements to the SVG map + svg_map.elements = [ + _SVG_DEFS, + # Elements to vertically flip + svg.G( + transform_origin=f"{image_box_center[0]} {image_box_center[1]}", + transform=[svg.Scale(1, -1)], + elements=svg_map_group_elements, + ), + # Elements with already flipped coordinates. + svg.G(elements=svg_rooms_labels), + ] + + str_svg_map = str(svg_map) - str_svg_map = str(svg_map or svg.SVG()) self._map_data.reset_changed() self._last_image = LastImage(str_svg_map, width) + _LOGGER.debug("[get_svg_map] Finish") return str_svg_map From 672b9596d578614137611db82e2acdf1faf995ec Mon Sep 17 00:00:00 2001 From: Luca De Petrillo <972242+lukakama@users.noreply.github.com> Date: Sat, 16 Dec 2023 15:11:04 +0000 Subject: [PATCH 06/10] Removed room rendering --- deebot_client/map.py | 91 -------------------------------------------- 1 file changed, 91 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index f941221c..6bd281b6 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -8,7 +8,6 @@ from io import BytesIO import itertools import lzma -import re import struct from typing import Any, Final import zlib @@ -50,22 +49,6 @@ _SVG_MAP_MARGIN = 5 -# Categorigal palette for 12 non related elements -_ROOM_COLORS = [ - "#a6cee3", - "#1f78b4", - "#b2df8a", - "#33a02c", - "#fb9a99", - "#e31a1c", - "#fdbf6f", - "#ff7f00", - "#cab2d6", - "#6a3d9a", - "#ffff99", - "#b15928", -] - _OFFSET = 400 _TRACE_MAP = "trace_map" _COLORS = { @@ -357,71 +340,6 @@ def _get_svg_traces_path(self) -> svg.Path | None: return None - def _get_svg_rooms( - self, - image_box: tuple[int, int, int, int], - image_box_center: tuple[float, float], - ) -> 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) - ): - # Split coordinates into a flat sequence - room_coords = re.split( - "[;,]", - _decompress_7z_base64_data( - self._map_data.rooms[room].coordinates - ).decode("ascii"), - ) - - # Append to room svg elements - svg_rooms_elements.append( - 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 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]) / ( - len(room_coords) / 2 - ) - room_center_y = sum(float(y) for y in room_coords[1::2]) / ( - len(room_coords) / 2 - ) - - # Get map relative position - 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( - 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) - def enable(self) -> None: """Enable map.""" if self._unsubscribers: @@ -542,10 +460,6 @@ def get_svg_map(self, width: int | None = None) -> str: for subset in self._map_data.map_subsets.values() ] - svg_rooms_elements, svg_rooms_labels = self._get_svg_rooms( - image_box, image_box_center - ) - svg_traces_path = self._get_svg_traces_path() # Elements of the SVG Map to vertically flip @@ -563,9 +477,6 @@ def get_svg_map(self, width: int | None = None) -> str: ) ) - # Rooms - svg_map_group_elements.extend(svg_rooms_elements) - # Additional subsets (VirtualWalls and NoMopZones) svg_map_group_elements.extend(svg_subset_elements) @@ -593,8 +504,6 @@ def get_svg_map(self, width: int | None = None) -> str: transform=[svg.Scale(1, -1)], elements=svg_map_group_elements, ), - # Elements with already flipped coordinates. - svg.G(elements=svg_rooms_labels), ] str_svg_map = str(svg_map) From 21a83e4bd6eba9e7fcc22c877a6a6a255feb0834 Mon Sep 17 00:00:00 2001 From: Luca De Petrillo <972242+lukakama@users.noreply.github.com> Date: Sat, 16 Dec 2023 16:45:19 +0000 Subject: [PATCH 07/10] Converted test_calc_point to floating point precision (for SVG) --- tests/test_map.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_map.py b/tests/test_map.py index cc56e326..f49b2dc6 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -15,9 +15,9 @@ from deebot_client.models import Room _test_calc_point_data = [ - (0, 10, None, (0, 10)), - (10, 100, (100, 0, 200, 50), (200, 50)), - (10, 100, (0, 0, 1000, 1000), (400, 402)), + (0, 10, None, (0.0, 10.0)), + (10, 100, (100, 0, 200, 50), (200.0, 50.0)), + (10, 100, (0, 0, 1000, 1000), (400.2, 402.0)), ] @@ -26,7 +26,7 @@ def test_calc_point( x: int, y: int, image_box: tuple[int, int, int, int] | None, - expected: tuple[int, int], + expected: tuple[float, float], ) -> None: result = _calc_point(x, y, image_box) assert result == expected From 9d4370997f1dfc8bfc31b857bd45bfa395c804cc Mon Sep 17 00:00:00 2001 From: Luca De Petrillo <972242+lukakama@users.noreply.github.com> Date: Sun, 17 Dec 2023 17:47:12 +0100 Subject: [PATCH 08/10] Apply suggestions from code review Co-authored-by: Robert Resch --- deebot_client/map.py | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index 6bd281b6..b92daecc 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -87,20 +87,19 @@ # Charger pin icon (pre-flipped vertically) svg.G( id=f"position_{PositionType.CHARGER}", - transform=[svg.Scale(4, -4)], elements=[ svg.Path( fill="#ffe605", d=[ - svg.M(1, -1.6), - svg.C(1, -1.05, 0, 0, 0, 0), - svg.c(0, 0, -1, -1.05, -1, -1.6), - svg.c(0, -0.55, 0.45, -1, 1, -1), - svg.c(0.55, 0, 1, 0.45, 1, 1), + svg.M(4, 6.4), + svg.C(4, 4.2, 0, 0, 0, 0), + svg.C(0, 0, -4, 4.2, -4, 6.4), + svg.C(-4, 8.6, -2.2, 10.4, 0, 10.4), + svg.C(2.2, 10.4, 4, 8.6, 4, 6.4), svg.Z(), ], ), - svg.Circle(fill="white", r=0.7, cy=-1.6, cx=0), + svg.Circle(fill="white", r=2.8, cy=6.4, cx=0), ], ), ] @@ -453,14 +452,6 @@ def get_svg_map(self, width: int | None = None) -> str: # Build the SVG elements - svg_positions = _get_svg_positions(self._map_data.positions, image_box) - - svg_subset_elements: list[svg.Element] = [ - _get_svg_subset(subset, image_box) - for subset in self._map_data.map_subsets.values() - ] - - svg_traces_path = self._get_svg_traces_path() # Elements of the SVG Map to vertically flip svg_map_group_elements: list[svg.Element] = [] @@ -478,14 +469,17 @@ def get_svg_map(self, width: int | None = None) -> str: ) # Additional subsets (VirtualWalls and NoMopZones) - svg_map_group_elements.extend(svg_subset_elements) + svg_map_group_elements.extend([ + _get_svg_subset(subset, image_box) + for subset in self._map_data.map_subsets.values() + ]) # Traces (if any) - if svg_traces_path: + if svg_traces_path := self._get_svg_traces_path(): svg_map_group_elements.append(svg_traces_path) # Bot and Charge stations - svg_map_group_elements.extend(svg_positions) + svg_map_group_elements.extend(_get_svg_positions(self._map_data.positions, image_box)) # Set map viewBox based on background map bounding box. svg_map.viewBox = svg.ViewBoxSpec( From 2b66c98ad902d97de5dfaf3fac91ddf3ff0eef43 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 21 Dec 2023 18:14:59 +0000 Subject: [PATCH 09/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- deebot_client/map.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index b92daecc..43b7d388 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -452,7 +452,6 @@ def get_svg_map(self, width: int | None = None) -> str: # Build the SVG elements - # Elements of the SVG Map to vertically flip svg_map_group_elements: list[svg.Element] = [] @@ -469,17 +468,21 @@ def get_svg_map(self, width: int | None = None) -> str: ) # Additional subsets (VirtualWalls and NoMopZones) - svg_map_group_elements.extend([ - _get_svg_subset(subset, image_box) - for subset in self._map_data.map_subsets.values() - ]) + svg_map_group_elements.extend( + [ + _get_svg_subset(subset, image_box) + for subset in self._map_data.map_subsets.values() + ] + ) # Traces (if any) if svg_traces_path := self._get_svg_traces_path(): svg_map_group_elements.append(svg_traces_path) # Bot and Charge stations - svg_map_group_elements.extend(_get_svg_positions(self._map_data.positions, image_box)) + svg_map_group_elements.extend( + _get_svg_positions(self._map_data.positions, image_box) + ) # Set map viewBox based on background map bounding box. svg_map.viewBox = svg.ViewBoxSpec( From 537bf895d5622eda38db62d220909a18b915cab6 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 23 Dec 2023 11:20:53 +0000 Subject: [PATCH 10/10] Remove margin and point type --- deebot_client/map.py | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index 43b7d388..1a957846 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -47,8 +47,6 @@ PositionType.CHARGER: 1, } -_SVG_MAP_MARGIN = 5 - _OFFSET = 400 _TRACE_MAP = "trace_map" _COLORS = { @@ -154,7 +152,7 @@ def _calc_point( def _points_to_svg_path( - points: Sequence[tuple[float, float]] | Sequence[tuple[float, float, bool, int]], + points: Sequence[tuple[float, float]] | Sequence[tuple[float, float, bool]], ) -> list[svg.PathData]: # Convert a set of simple point (x, y), or trace points (x, y, connected, type) to # SVG path instructions. @@ -284,11 +282,8 @@ def _update_trace_points(self, data: str) -> None: point_data = trace_points[i + 4] connected = point_data >> 7 & 1 == 0 - point_type = point_data & 1 - self._map_data.trace_values.append( - (position_x, position_y, connected, point_type) - ) + self._map_data.trace_values.append((position_x, position_y, connected)) _LOGGER.debug("[_update_trace_points] finish") @@ -486,10 +481,10 @@ def get_svg_map(self, width: int | None = None) -> str: # Set map viewBox based on background map bounding box. svg_map.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, + image_box[0], + image_box[1], + image_box[2] - image_box[0], + image_box[3] - image_box[1], ) # Add all elements to the SVG map @@ -602,7 +597,7 @@ def on_change() -> None: self._map_subsets: OnChangedDict[int, MapSubsetEvent] = OnChangedDict(on_change) self._positions: OnChangedList[Position] = OnChangedList(on_change) self._rooms: OnChangedDict[int, Room] = OnChangedDict(on_change) - self._trace_values: OnChangedList[tuple[int, int, bool, int]] = OnChangedList( + self._trace_values: OnChangedList[tuple[int, int, bool]] = OnChangedList( on_change ) @@ -639,7 +634,7 @@ def rooms(self) -> dict[int, Room]: return self._rooms @property - def trace_values(self) -> OnChangedList[tuple[int, int, bool, int]]: + def trace_values(self) -> OnChangedList[tuple[int, int, bool]]: """Return trace values.""" return self._trace_values