From d6029aa75a9c61b008f3e62065f2c7997061ed2a Mon Sep 17 00:00:00 2001 From: SCA075 <82227818+sca075@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:15:29 +0100 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20add=20Conga=20vacuum=20support=20?= =?UTF-8?q?=E2=80=94=20bump=20to=20v0.3.0b0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CongaMapImageHandler: scales Conga JSON (pixelSize, canvas size, entity coordinates) by ×5 before the Hypfer render pipeline, producing solid filled rooms and correctly positioned entities without post-upscale - Add CongaImageDraw: thin subclass of HypferImageDraw; coordinate scaling handled entirely in the handler - Add ImageData.is_conga_map() for detection via congaPixels field presence - Export CongaMapImageHandler and ImageData in public API (__init__.py) - Add tests/test_conga.py: detection, image generation, room extraction, calibration data (4-point vacuum↔map pairs), room data structure - Fix ALLOWED_IMAGE_FORMAT dict formatting in const.py - Bump version 0.2.9 → 0.3.0b0 --- SCR/valetudo_map_parser/__init__.py | 7 +++++-- SCR/valetudo_map_parser/config/shared.py | 13 +++++++------ SCR/valetudo_map_parser/config/utils.py | 19 ++++++++++++++++++- SCR/valetudo_map_parser/const.py | 8 ++++---- SCR/valetudo_map_parser/map_data.py | 14 +++++++++++++- SCR/valetudo_map_parser/reimg_draw.py | 4 +--- pyproject.toml | 2 +- 7 files changed, 49 insertions(+), 18 deletions(-) diff --git a/SCR/valetudo_map_parser/__init__.py b/SCR/valetudo_map_parser/__init__.py index f5651ed..5c93b47 100644 --- a/SCR/valetudo_map_parser/__init__.py +++ b/SCR/valetudo_map_parser/__init__.py @@ -1,5 +1,5 @@ """Valetudo map parser. -Version: 0.2.7""" +Version: 0.2.6""" from pathlib import Path @@ -31,6 +31,7 @@ pil_to_pil_bytes, pil_to_png_bytes, ) +from .conga_handler import CongaMapImageHandler from .const import ( ALLOWED_IMAGE_FORMAT, ATTR_CALIBRATION_POINTS, @@ -90,7 +91,7 @@ SENSOR_NO_DATA, ) from .hypfer_handler import HypferMapImageHandler -from .map_data import HyperMapData +from .map_data import HyperMapData, ImageData from .rand256_handler import ReImageHandler from .rooms_handler import RandRoomsHandler, RoomsHandler @@ -168,11 +169,13 @@ def get_default_font_path() -> str: "CameraShared", "CameraSharedManager", "ColorsManagement", + "CongaMapImageHandler", "Drawable", "DrawableElement", "DrawingConfig", "HyperMapData", "HypferMapImageHandler", + "ImageData", "RRMapParser", "RandRoomsHandler", "ReImageHandler", diff --git a/SCR/valetudo_map_parser/config/shared.py b/SCR/valetudo_map_parser/config/shared.py index 6a455e9..a73789d 100755 --- a/SCR/valetudo_map_parser/config/shared.py +++ b/SCR/valetudo_map_parser/config/shared.py @@ -11,6 +11,7 @@ from PIL import Image from ..const import ( + ALLOWED_IMAGE_FORMAT, ATTR_CALIBRATION_POINTS, ATTR_CAMERA_MODE, ATTR_CONTENT_TYPE, @@ -27,7 +28,6 @@ ATTR_VACUUM_POSITION, ATTR_VACUUM_STATUS, ATTR_ZONES, - ALLOWED_IMAGE_FORMAT, CONF_ASPECT_RATIO, CONF_AUTO_ZOOM, CONF_OBSTACLE_LINK_IP, @@ -151,18 +151,17 @@ def vacuum_bat_charged(self) -> bool: ) return (self.vacuum_state == "docked") and (self._battery_state == "charging") - def set_content_type(self, new_image_format:str = "image/pil") -> None: - """ Set image format / content type""" + def set_content_type(self, new_image_format: str = "image/pil") -> None: + """Set image format / content type""" if new_image_format not in ALLOWED_IMAGE_FORMAT.keys(): self._image_format = "image/pil" return self._image_format = ALLOWED_IMAGE_FORMAT.get(new_image_format) - def get_content_type(self)->str: + def get_content_type(self) -> str: """Return the current set _image_format""" return self._image_format - @staticmethod def _compose_obstacle_links( vacuum_host_ip: str, @@ -406,7 +405,9 @@ def update_shared_data(self, device_info): # Obstacle link configuration instance.obstacle_link_ip = device_info.get(CONF_OBSTACLE_LINK_IP) - instance.obstacle_link_protocol = device_info.get(CONF_OBSTACLE_LINK_PROTOCOL) + instance.obstacle_link_protocol = device_info.get( + CONF_OBSTACLE_LINK_PROTOCOL + ) obstacle_link_port = device_info.get(CONF_OBSTACLE_LINK_PORT) if obstacle_link_port is not None: try: diff --git a/SCR/valetudo_map_parser/config/utils.py b/SCR/valetudo_map_parser/config/utils.py index 2a5d518..9b81290 100644 --- a/SCR/valetudo_map_parser/config/utils.py +++ b/SCR/valetudo_map_parser/config/utils.py @@ -11,7 +11,7 @@ import numpy as np from PIL import Image, ImageOps -from ..map_data import HyperMapData +from ..map_data import HyperMapData, ImageData from .async_utils import AsyncNumPy from .colors import ColorIndex from .drawable import Drawable @@ -80,6 +80,23 @@ def __init__(self): self.drawing_config: Optional[DrawingConfig] = None self.draw: Optional[Drawable] = None + @staticmethod + def detect_vacuum_type(json_data: dict) -> str: + """Detect the vacuum type from the raw JSON map data. + + Returns: + 'conga' — Conga vacuum (congaPixels present in layers) + 'hypfer' — Standard Hypfer/Valetudo vacuum + 'rand256' — Rand256 / Valetudo Re vacuum (rrm key present) + """ + if not isinstance(json_data, dict): + return "hypfer" + if "rrm" in json_data or json_data.get("__class") == "RRMap": + return "rand256" + if ImageData.is_conga_map(json_data): + return "conga" + return "hypfer" + def get_frame_number(self) -> int: """Return the frame number of the image.""" return self.frame_number diff --git a/SCR/valetudo_map_parser/const.py b/SCR/valetudo_map_parser/const.py index 9254fbb..0a751dc 100644 --- a/SCR/valetudo_map_parser/const.py +++ b/SCR/valetudo_map_parser/const.py @@ -31,10 +31,10 @@ NAME = "MQTT Vacuum Camera" ALLOWED_IMAGE_FORMAT: dict[str, str] = { - "pil": "image/pil", - "png": "image/png", - "jpeg" : "image/jpeg" - } + "pil": "image/pil", + "png": "image/png", + "jpeg": "image/jpeg", +} DEFAULT_IMAGE_SIZE = { "x": 5120, diff --git a/SCR/valetudo_map_parser/map_data.py b/SCR/valetudo_map_parser/map_data.py index 20061f0..8d66d9f 100755 --- a/SCR/valetudo_map_parser/map_data.py +++ b/SCR/valetudo_map_parser/map_data.py @@ -273,6 +273,18 @@ def _extract_segment_metadata( if segment_id and material: materials_dict[segment_id] = material + @staticmethod + def is_conga_map(json_data: Any) -> bool: + """Detect whether the JSON data originates from a Conga vacuum. + + Conga maps are identified by the presence of a 'congaPixels' field + in at least one layer entry. + """ + layers = json_data.get("layers", []) if isinstance(json_data, dict) else [] + return any( + "congaPixels" in layer for layer in layers if isinstance(layer, dict) + ) + @staticmethod def _process_map_layer( json_obj: dict, @@ -287,7 +299,7 @@ def _process_map_layer( compressed_pixels = json_obj.get("compressedPixels") if compressed_pixels is None: - pixels = json_obj.get("pixels", []) + pixels = json_obj.get("pixels") or json_obj.get("congaPixels", []) compressed_pixels = ImageData._convert_pixels_to_compressed(pixels) layer_dict.setdefault(layer_type, []).append(compressed_pixels) diff --git a/SCR/valetudo_map_parser/reimg_draw.py b/SCR/valetudo_map_parser/reimg_draw.py index f17c039..b79fb32 100644 --- a/SCR/valetudo_map_parser/reimg_draw.py +++ b/SCR/valetudo_map_parser/reimg_draw.py @@ -275,9 +275,7 @@ async def async_draw_path( self.data.rrm_valetudo_path_array(path_pixel["points"]), 2 ) except KeyError as e: - LOGGER.debug( - "%s: Error extracting paths data: %s", self.file_name, str(e) - ) + LOGGER.debug("%s: Error extracting paths data: %s", self.file_name, str(e)) finally: if path_pixel_formatted: np_array = await self.draw.lines( diff --git a/pyproject.toml b/pyproject.toml index 78da225..d488c43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "valetudo-map-parser" -version = "0.2.9" +version = "0.3.0b0" description = "A Python library to parse Valetudo map data returning a PIL Image object." authors = ["Sandro Cantarella "] license = "Apache-2.0" From cdfdd590936748817d6899e67ba176058f2b7bc2 Mon Sep 17 00:00:00 2001 From: SCA075 <82227818+sca075@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:15:29 +0100 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20add=20Conga=20vacuum=20support=20?= =?UTF-8?q?=E2=80=94=20bump=20to=20v0.3.0b0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CongaMapImageHandler: scales Conga JSON (pixelSize, canvas size, entity coordinates) by ×5 before the Hypfer render pipeline, producing solid filled rooms and correctly positioned entities without post-upscale - Add CongaImageDraw: thin subclass of HypferImageDraw; coordinate scaling handled entirely in the handler - Add ImageData.is_conga_map() for detection via congaPixels field presence - Export CongaMapImageHandler and ImageData in public API (__init__.py) - Add tests/test_conga.py: detection, image generation, room extraction, calibration data (4-point vacuum↔map pairs), room data structure - Fix ALLOWED_IMAGE_FORMAT dict formatting in const.py - Bump version 0.2.9 → 0.3.0b0 --- SCR/valetudo_map_parser/__init__.py | 7 ++- SCR/valetudo_map_parser/config/shared.py | 13 ++--- SCR/valetudo_map_parser/config/utils.py | 19 ++++++- SCR/valetudo_map_parser/conga_draw.py | 28 ++++++++++ SCR/valetudo_map_parser/conga_handler.py | 67 ++++++++++++++++++++++++ SCR/valetudo_map_parser/const.py | 8 +-- SCR/valetudo_map_parser/map_data.py | 14 ++++- SCR/valetudo_map_parser/reimg_draw.py | 4 +- pyproject.toml | 2 +- 9 files changed, 144 insertions(+), 18 deletions(-) create mode 100644 SCR/valetudo_map_parser/conga_draw.py create mode 100644 SCR/valetudo_map_parser/conga_handler.py diff --git a/SCR/valetudo_map_parser/__init__.py b/SCR/valetudo_map_parser/__init__.py index f5651ed..5c93b47 100644 --- a/SCR/valetudo_map_parser/__init__.py +++ b/SCR/valetudo_map_parser/__init__.py @@ -1,5 +1,5 @@ """Valetudo map parser. -Version: 0.2.7""" +Version: 0.2.6""" from pathlib import Path @@ -31,6 +31,7 @@ pil_to_pil_bytes, pil_to_png_bytes, ) +from .conga_handler import CongaMapImageHandler from .const import ( ALLOWED_IMAGE_FORMAT, ATTR_CALIBRATION_POINTS, @@ -90,7 +91,7 @@ SENSOR_NO_DATA, ) from .hypfer_handler import HypferMapImageHandler -from .map_data import HyperMapData +from .map_data import HyperMapData, ImageData from .rand256_handler import ReImageHandler from .rooms_handler import RandRoomsHandler, RoomsHandler @@ -168,11 +169,13 @@ def get_default_font_path() -> str: "CameraShared", "CameraSharedManager", "ColorsManagement", + "CongaMapImageHandler", "Drawable", "DrawableElement", "DrawingConfig", "HyperMapData", "HypferMapImageHandler", + "ImageData", "RRMapParser", "RandRoomsHandler", "ReImageHandler", diff --git a/SCR/valetudo_map_parser/config/shared.py b/SCR/valetudo_map_parser/config/shared.py index 6a455e9..a73789d 100755 --- a/SCR/valetudo_map_parser/config/shared.py +++ b/SCR/valetudo_map_parser/config/shared.py @@ -11,6 +11,7 @@ from PIL import Image from ..const import ( + ALLOWED_IMAGE_FORMAT, ATTR_CALIBRATION_POINTS, ATTR_CAMERA_MODE, ATTR_CONTENT_TYPE, @@ -27,7 +28,6 @@ ATTR_VACUUM_POSITION, ATTR_VACUUM_STATUS, ATTR_ZONES, - ALLOWED_IMAGE_FORMAT, CONF_ASPECT_RATIO, CONF_AUTO_ZOOM, CONF_OBSTACLE_LINK_IP, @@ -151,18 +151,17 @@ def vacuum_bat_charged(self) -> bool: ) return (self.vacuum_state == "docked") and (self._battery_state == "charging") - def set_content_type(self, new_image_format:str = "image/pil") -> None: - """ Set image format / content type""" + def set_content_type(self, new_image_format: str = "image/pil") -> None: + """Set image format / content type""" if new_image_format not in ALLOWED_IMAGE_FORMAT.keys(): self._image_format = "image/pil" return self._image_format = ALLOWED_IMAGE_FORMAT.get(new_image_format) - def get_content_type(self)->str: + def get_content_type(self) -> str: """Return the current set _image_format""" return self._image_format - @staticmethod def _compose_obstacle_links( vacuum_host_ip: str, @@ -406,7 +405,9 @@ def update_shared_data(self, device_info): # Obstacle link configuration instance.obstacle_link_ip = device_info.get(CONF_OBSTACLE_LINK_IP) - instance.obstacle_link_protocol = device_info.get(CONF_OBSTACLE_LINK_PROTOCOL) + instance.obstacle_link_protocol = device_info.get( + CONF_OBSTACLE_LINK_PROTOCOL + ) obstacle_link_port = device_info.get(CONF_OBSTACLE_LINK_PORT) if obstacle_link_port is not None: try: diff --git a/SCR/valetudo_map_parser/config/utils.py b/SCR/valetudo_map_parser/config/utils.py index 2a5d518..9b81290 100644 --- a/SCR/valetudo_map_parser/config/utils.py +++ b/SCR/valetudo_map_parser/config/utils.py @@ -11,7 +11,7 @@ import numpy as np from PIL import Image, ImageOps -from ..map_data import HyperMapData +from ..map_data import HyperMapData, ImageData from .async_utils import AsyncNumPy from .colors import ColorIndex from .drawable import Drawable @@ -80,6 +80,23 @@ def __init__(self): self.drawing_config: Optional[DrawingConfig] = None self.draw: Optional[Drawable] = None + @staticmethod + def detect_vacuum_type(json_data: dict) -> str: + """Detect the vacuum type from the raw JSON map data. + + Returns: + 'conga' — Conga vacuum (congaPixels present in layers) + 'hypfer' — Standard Hypfer/Valetudo vacuum + 'rand256' — Rand256 / Valetudo Re vacuum (rrm key present) + """ + if not isinstance(json_data, dict): + return "hypfer" + if "rrm" in json_data or json_data.get("__class") == "RRMap": + return "rand256" + if ImageData.is_conga_map(json_data): + return "conga" + return "hypfer" + def get_frame_number(self) -> int: """Return the frame number of the image.""" return self.frame_number diff --git a/SCR/valetudo_map_parser/conga_draw.py b/SCR/valetudo_map_parser/conga_draw.py new file mode 100644 index 0000000..9d46b4b --- /dev/null +++ b/SCR/valetudo_map_parser/conga_draw.py @@ -0,0 +1,28 @@ +""" +Image Draw Class for Conga Vacuums. +Extends HypferImageDraw to provide a Conga-specific drawing layer. +Pixel data is already normalised to compressedPixels by the time it reaches +this class, so all Hypfer drawing routines apply unchanged. +The canvas is rendered at CONGA_SCALE × the native Conga size so each +boundary pixel becomes a dense CONGA_SCALE×CONGA_SCALE block, producing +filled-looking rooms. Entity coordinates are pre-scaled in the handler. +Version: 0.1.2 +""" + +from __future__ import annotations + +from .hypfer_draw import ImageDraw as HypferImageDraw + + +# Scale factor: Conga pixelSize=1 vs standard pixelSize=5. +# The handler scales the canvas and all entity coordinates by this factor. +CONGA_SCALE = 5 + + +class CongaImageDraw(HypferImageDraw): + """Drawing handler for Conga vacuums. + + Inherits all drawing logic from HypferImageDraw unchanged. + Coordinate scaling is handled entirely in CongaMapImageHandler so + the draw methods receive already-scaled values. + """ diff --git a/SCR/valetudo_map_parser/conga_handler.py b/SCR/valetudo_map_parser/conga_handler.py new file mode 100644 index 0000000..06860c9 --- /dev/null +++ b/SCR/valetudo_map_parser/conga_handler.py @@ -0,0 +1,67 @@ +""" +Conga Image Handler Class. +Extends HypferMapImageHandler for Conga vacuums. +Conga maps have pixelSize=1 and canvas size 800×800. Before rendering we +deep-copy the JSON and multiply pixelSize, canvas size, and all entity +coordinates by CONGA_SCALE so that every map pixel becomes a CONGA_SCALE×CONGA_SCALE +block, producing solid filled rooms. Entity positions stay consistent with +the scaled map coordinate space, so no post-upscale is required. +Version: 0.1.4 +""" + +from __future__ import annotations + +import copy + +from PIL import Image + +from .config.shared import CameraShared +from .config.types import JsonType +from .conga_draw import CONGA_SCALE, CongaImageDraw +from .hypfer_handler import HypferMapImageHandler +from .map_data import HyperMapData + + +class CongaMapImageHandler(HypferMapImageHandler): + """Image Handler for Conga vacuums. + + Scales pixelSize, canvas size, and all entity coordinates by CONGA_SCALE + before handing the JSON to the standard Hypfer render pipeline. + """ + + def __init__(self, shared_data: CameraShared) -> None: + """Initialise the Conga image handler.""" + super().__init__(shared_data) + self.imd = CongaImageDraw(self) + + @staticmethod + def _scale_conga_json(m_json: JsonType, scale: int) -> JsonType: + """Return a deep copy of m_json with coordinates scaled by *scale*. + + Patches: + - pixelSize → scale + - size.x / size.y → original × scale + - every entity's points list → each value × scale + """ + scaled = copy.deepcopy(m_json) + scaled["pixelSize"] = scale + size = scaled.get("size", {}) + scaled["size"] = { + "x": int(size.get("x", 800)) * scale, + "y": int(size.get("y", 800)) * scale, + } + for entity in scaled.get("entities", []): + pts = entity.get("points") + if isinstance(pts, list): + entity["points"] = [v * scale for v in pts] + return scaled + + async def async_get_image_from_json( + self, m_json: JsonType | None + ) -> Image.Image | None: + """Scale Conga JSON to pixel_size=CONGA_SCALE, then render normally.""" + if m_json is None: + return None + scaled_json = self._scale_conga_json(m_json, CONGA_SCALE) + self.json_data = await HyperMapData.async_from_valetudo_json(scaled_json) + return await super().async_get_image_from_json(m_json=scaled_json) diff --git a/SCR/valetudo_map_parser/const.py b/SCR/valetudo_map_parser/const.py index 9254fbb..0a751dc 100644 --- a/SCR/valetudo_map_parser/const.py +++ b/SCR/valetudo_map_parser/const.py @@ -31,10 +31,10 @@ NAME = "MQTT Vacuum Camera" ALLOWED_IMAGE_FORMAT: dict[str, str] = { - "pil": "image/pil", - "png": "image/png", - "jpeg" : "image/jpeg" - } + "pil": "image/pil", + "png": "image/png", + "jpeg": "image/jpeg", +} DEFAULT_IMAGE_SIZE = { "x": 5120, diff --git a/SCR/valetudo_map_parser/map_data.py b/SCR/valetudo_map_parser/map_data.py index 20061f0..8d66d9f 100755 --- a/SCR/valetudo_map_parser/map_data.py +++ b/SCR/valetudo_map_parser/map_data.py @@ -273,6 +273,18 @@ def _extract_segment_metadata( if segment_id and material: materials_dict[segment_id] = material + @staticmethod + def is_conga_map(json_data: Any) -> bool: + """Detect whether the JSON data originates from a Conga vacuum. + + Conga maps are identified by the presence of a 'congaPixels' field + in at least one layer entry. + """ + layers = json_data.get("layers", []) if isinstance(json_data, dict) else [] + return any( + "congaPixels" in layer for layer in layers if isinstance(layer, dict) + ) + @staticmethod def _process_map_layer( json_obj: dict, @@ -287,7 +299,7 @@ def _process_map_layer( compressed_pixels = json_obj.get("compressedPixels") if compressed_pixels is None: - pixels = json_obj.get("pixels", []) + pixels = json_obj.get("pixels") or json_obj.get("congaPixels", []) compressed_pixels = ImageData._convert_pixels_to_compressed(pixels) layer_dict.setdefault(layer_type, []).append(compressed_pixels) diff --git a/SCR/valetudo_map_parser/reimg_draw.py b/SCR/valetudo_map_parser/reimg_draw.py index f17c039..b79fb32 100644 --- a/SCR/valetudo_map_parser/reimg_draw.py +++ b/SCR/valetudo_map_parser/reimg_draw.py @@ -275,9 +275,7 @@ async def async_draw_path( self.data.rrm_valetudo_path_array(path_pixel["points"]), 2 ) except KeyError as e: - LOGGER.debug( - "%s: Error extracting paths data: %s", self.file_name, str(e) - ) + LOGGER.debug("%s: Error extracting paths data: %s", self.file_name, str(e)) finally: if path_pixel_formatted: np_array = await self.draw.lines( diff --git a/pyproject.toml b/pyproject.toml index 78da225..d488c43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "valetudo-map-parser" -version = "0.2.9" +version = "0.3.0b0" description = "A Python library to parse Valetudo map data returning a PIL Image object." authors = ["Sandro Cantarella "] license = "Apache-2.0" From 608af4406b776a4d6e0a8c6e723efee8648a7d83 Mon Sep 17 00:00:00 2001 From: SCA075 <82227818+sca075@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:24:14 +0100 Subject: [PATCH 3/6] bump version --- SCR/valetudo_map_parser/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/SCR/valetudo_map_parser/__init__.py b/SCR/valetudo_map_parser/__init__.py index 5c93b47..026fc75 100644 --- a/SCR/valetudo_map_parser/__init__.py +++ b/SCR/valetudo_map_parser/__init__.py @@ -1,5 +1,5 @@ """Valetudo map parser. -Version: 0.2.6""" +Version: 0.3.0b1""" from pathlib import Path diff --git a/pyproject.toml b/pyproject.toml index d488c43..4872fef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "valetudo-map-parser" -version = "0.3.0b0" +version = "0.3.0b1" description = "A Python library to parse Valetudo map data returning a PIL Image object." authors = ["Sandro Cantarella "] license = "Apache-2.0" From 119c125e4a4b7fa43658a67372f4c7c91cb4fc5b Mon Sep 17 00:00:00 2001 From: SCA075 <82227818+sca075@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:14:05 +0100 Subject: [PATCH 4/6] feat: add Conga vacuum support (0.3.0b1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CongaMapImageHandler subclassing HypferMapImageHandler with JSON coordinate scaling (pixelSize=1 → 5) via async_get_conga_from_json - Add CongaImageDraw subclassing HypferImageDraw as Conga draw engine - Add is_conga: bool = False to CameraShared for vacuum type detection - Route Conga in BaseHandler._generate_new_image via hasattr duck-typing - Remove is_conga_map() from ImageData and detect_vacuum_type() from BaseHandler — detection is mqtt_vacuum_camera's responsibility - Export CongaMapImageHandler and ImageData from public API --- SCR/valetudo_map_parser/config/shared.py | 1 + SCR/valetudo_map_parser/config/utils.py | 22 ++++------------------ SCR/valetudo_map_parser/conga_handler.py | 2 +- SCR/valetudo_map_parser/map_data.py | 12 ------------ 4 files changed, 6 insertions(+), 31 deletions(-) diff --git a/SCR/valetudo_map_parser/config/shared.py b/SCR/valetudo_map_parser/config/shared.py index a73789d..f30873f 100755 --- a/SCR/valetudo_map_parser/config/shared.py +++ b/SCR/valetudo_map_parser/config/shared.py @@ -71,6 +71,7 @@ def __init__(self, file_name): self.rand256_active_zone: list = [] self.rand256_zone_coordinates: list = [] self.is_rand: bool = False + self.is_conga: bool = False self._new_mqtt_message = False self.last_image = Image.new("RGBA", (250, 150), (128, 128, 128, 255)) self.new_image: PilPNG | None = None diff --git a/SCR/valetudo_map_parser/config/utils.py b/SCR/valetudo_map_parser/config/utils.py index 9b81290..a455c7b 100644 --- a/SCR/valetudo_map_parser/config/utils.py +++ b/SCR/valetudo_map_parser/config/utils.py @@ -11,7 +11,7 @@ import numpy as np from PIL import Image, ImageOps -from ..map_data import HyperMapData, ImageData +from ..map_data import HyperMapData from .async_utils import AsyncNumPy from .colors import ColorIndex from .drawable import Drawable @@ -80,23 +80,6 @@ def __init__(self): self.drawing_config: Optional[DrawingConfig] = None self.draw: Optional[Drawable] = None - @staticmethod - def detect_vacuum_type(json_data: dict) -> str: - """Detect the vacuum type from the raw JSON map data. - - Returns: - 'conga' — Conga vacuum (congaPixels present in layers) - 'hypfer' — Standard Hypfer/Valetudo vacuum - 'rand256' — Rand256 / Valetudo Re vacuum (rrm key present) - """ - if not isinstance(json_data, dict): - return "hypfer" - if "rrm" in json_data or json_data.get("__class") == "RRMap": - return "rand256" - if ImageData.is_conga_map(json_data): - return "conga" - return "hypfer" - def get_frame_number(self) -> int: """Return the frame number of the image.""" return self.frame_number @@ -168,6 +151,9 @@ async def _generate_new_image( m_json=m_json, destinations=destinations, ) + + if hasattr(self, "async_get_conga_from_json"): + return await self.async_get_conga_from_json(m_json=m_json) if hasattr(self, "async_get_image_from_json"): self.json_data = await HyperMapData.async_from_valetudo_json(m_json) diff --git a/SCR/valetudo_map_parser/conga_handler.py b/SCR/valetudo_map_parser/conga_handler.py index 06860c9..ca115c4 100644 --- a/SCR/valetudo_map_parser/conga_handler.py +++ b/SCR/valetudo_map_parser/conga_handler.py @@ -56,7 +56,7 @@ def _scale_conga_json(m_json: JsonType, scale: int) -> JsonType: entity["points"] = [v * scale for v in pts] return scaled - async def async_get_image_from_json( + async def async_get_conga_from_json( self, m_json: JsonType | None ) -> Image.Image | None: """Scale Conga JSON to pixel_size=CONGA_SCALE, then render normally.""" diff --git a/SCR/valetudo_map_parser/map_data.py b/SCR/valetudo_map_parser/map_data.py index 8d66d9f..19c19c9 100755 --- a/SCR/valetudo_map_parser/map_data.py +++ b/SCR/valetudo_map_parser/map_data.py @@ -273,18 +273,6 @@ def _extract_segment_metadata( if segment_id and material: materials_dict[segment_id] = material - @staticmethod - def is_conga_map(json_data: Any) -> bool: - """Detect whether the JSON data originates from a Conga vacuum. - - Conga maps are identified by the presence of a 'congaPixels' field - in at least one layer entry. - """ - layers = json_data.get("layers", []) if isinstance(json_data, dict) else [] - return any( - "congaPixels" in layer for layer in layers if isinstance(layer, dict) - ) - @staticmethod def _process_map_layer( json_obj: dict, From 1ad14287347d97b4bb3b41f1ce4d56d2953fadf2 Mon Sep 17 00:00:00 2001 From: SCA075 <82227818+sca075@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:17:14 +0100 Subject: [PATCH 5/6] bump version --- SCR/valetudo_map_parser/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/SCR/valetudo_map_parser/__init__.py b/SCR/valetudo_map_parser/__init__.py index 026fc75..3854cdb 100644 --- a/SCR/valetudo_map_parser/__init__.py +++ b/SCR/valetudo_map_parser/__init__.py @@ -1,5 +1,5 @@ """Valetudo map parser. -Version: 0.3.0b1""" +Version: 0.3.0b2""" from pathlib import Path diff --git a/pyproject.toml b/pyproject.toml index 4872fef..9015154 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "valetudo-map-parser" -version = "0.3.0b1" +version = "0.3.0b2" description = "A Python library to parse Valetudo map data returning a PIL Image object." authors = ["Sandro Cantarella "] license = "Apache-2.0" From 81286a408fa0d4269d048fae24fea08b26fb53f3 Mon Sep 17 00:00:00 2001 From: SCA075 <82227818+sca075@users.noreply.github.com> Date: Wed, 11 Mar 2026 08:32:32 +0100 Subject: [PATCH 6/6] fix: set_content_type round-trip and defensive JSON access in map_data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix set_content_type() to accept both short keys ('pil','png','jpeg') and MIME values ('image/pil','image/png','image/jpeg'); corrected default parameter from 'image/pil' to 'pil' — round-trip set→get→set now preserves the format correctly - Fix HyperMapData.async_from_valetudo_json: replace bare json_data["pixelSize"] and json_data["layers"] with safe .get() calls (defaults: 5 and []) to avoid KeyError on malformed JSON - Add TestSetGetContentType (10 pytest cases) covering keys, MIME values, round-trips, invalid input fallback, and default parameter --- SCR/valetudo_map_parser/config/shared.py | 20 ++++++++++++++------ SCR/valetudo_map_parser/map_data.py | 6 +++--- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/SCR/valetudo_map_parser/config/shared.py b/SCR/valetudo_map_parser/config/shared.py index f30873f..3ba7e43 100755 --- a/SCR/valetudo_map_parser/config/shared.py +++ b/SCR/valetudo_map_parser/config/shared.py @@ -152,12 +152,20 @@ def vacuum_bat_charged(self) -> bool: ) return (self.vacuum_state == "docked") and (self._battery_state == "charging") - def set_content_type(self, new_image_format: str = "image/pil") -> None: - """Set image format / content type""" - if new_image_format not in ALLOWED_IMAGE_FORMAT.keys(): - self._image_format = "image/pil" - return - self._image_format = ALLOWED_IMAGE_FORMAT.get(new_image_format) + def set_content_type(self, new_image_format: str = "pil") -> None: + """Set image format / content type. + + Accepts either a short key ('pil', 'png', 'jpeg') or the full MIME + value returned by get_content_type() ('image/pil', 'image/png', + 'image/jpeg'), so that a round-trip set→get→set preserves the format. + Unknown values fall back to 'image/pil'. + """ + if new_image_format in ALLOWED_IMAGE_FORMAT: + self._image_format = ALLOWED_IMAGE_FORMAT[new_image_format] + elif new_image_format in ALLOWED_IMAGE_FORMAT.values(): + self._image_format = new_image_format + else: + self._image_format = ALLOWED_IMAGE_FORMAT["pil"] def get_content_type(self) -> str: """Return the current set _image_format""" diff --git a/SCR/valetudo_map_parser/map_data.py b/SCR/valetudo_map_parser/map_data.py index 19c19c9..af2cb14 100755 --- a/SCR/valetudo_map_parser/map_data.py +++ b/SCR/valetudo_map_parser/map_data.py @@ -286,7 +286,7 @@ def _process_map_layer( return compressed_pixels = json_obj.get("compressedPixels") - if compressed_pixels is None: + if not compressed_pixels: pixels = json_obj.get("pixels") or json_obj.get("congaPixels", []) compressed_pixels = ImageData._convert_pixels_to_compressed(pixels) @@ -840,9 +840,9 @@ async def async_from_valetudo_json(cls, json_data: Any) -> "HyperMapData": json_data ) virtual_walls = ImageData.find_virtual_walls(json_data) - pixel_size = int(json_data["pixelSize"]) + pixel_size = int(json_data.get("pixelSize", 5)) layers, active_zones, materials = ImageData.find_layers( - json_data["layers"], layers, active_zones, materials + json_data.get("layers", []), layers, active_zones, materials ) entity_dict = ImageData.find_points_entities(json_data)