From 068b58e97279e6a903f10d0302ecafae0c4bf8bf Mon Sep 17 00:00:00 2001 From: SCA075 <82227818+sca075@users.noreply.github.com> Date: Tue, 30 Sep 2025 13:50:35 +0200 Subject: [PATCH 1/8] Update version to v0.1.12 and refactor attributes (#13) * Update version to v0.1.12 and refactor attributes * Add docstrings to methods in shared.py Added docstrings for methods in shared.py to improve code documentation and clarity. * Update SCR/valetudo_map_parser/config/shared.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update SCR/valetudo_map_parser/config/shared.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update SCR/valetudo_map_parser/config/shared.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update SCR/valetudo_map_parser/config/shared.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update SCR/valetudo_map_parser/config/shared.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update SCR/valetudo_map_parser/config/shared.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- SCR/valetudo_map_parser/config/shared.py | 169 +++++++++++------------ 1 file changed, 80 insertions(+), 89 deletions(-) diff --git a/SCR/valetudo_map_parser/config/shared.py b/SCR/valetudo_map_parser/config/shared.py index 839b1e7..a9736e0 100755 --- a/SCR/valetudo_map_parser/config/shared.py +++ b/SCR/valetudo_map_parser/config/shared.py @@ -1,7 +1,7 @@ """ Class Camera Shared. Keep the data between the modules. -Version: v0.1.10 +Version: v0.1.12 """ import asyncio @@ -54,71 +54,68 @@ class CameraShared: """ def __init__(self, file_name): - self.camera_mode: str = CameraModes.MAP_VIEW # Camera mode - self.frame_number: int = 0 # camera Frame number - self.destinations: list = [] # MQTT rand destinations - self.rand256_active_zone: list = [] # Active zone for rand256 - self.rand256_zone_coordinates: list = [] # Active zone coordinates for rand256 - self.is_rand: bool = False # MQTT rand data - self._new_mqtt_message = False # New MQTT message - # Initialize last_image with default gray image (250x150 minimum) - self.last_image = Image.new( - "RGBA", (250, 150), (128, 128, 128, 255) - ) # Gray default image - self.new_image: PilPNG | None = None # New image received - self.binary_image: bytes | None = None # Current image in binary format - self.image_last_updated: float = 0.0 # Last image update time - self.image_format = "image/pil" # Image format - self.image_size = None # Image size - self.robot_size = None # Robot size - self.image_auto_zoom: bool = False # Auto zoom image - self.image_zoom_lock_ratio: bool = True # Zoom lock ratio - self.image_ref_height: int = 0 # Image reference height - self.image_ref_width: int = 0 # Image reference width - self.image_aspect_ratio: str = "None" # Change Image aspect ratio - self.image_grab = True # Grab image from MQTT - self.image_rotate: int = 0 # Rotate image - self.drawing_limit: float = 0.0 # Drawing CPU limit - self.current_room = None # Current room of rhe vacuum - self.user_colors = Colors # User base colors - self.rooms_colors = Colors # Rooms colors - self.vacuum_battery = 0 # Vacuum battery state - self.vacuum_connection = False # Vacuum connection state - self.vacuum_state = None # Vacuum state - self.charger_position = None # Vacuum Charger position - self.show_vacuum_state = None # Show vacuum state on the map + self.camera_mode: str = CameraModes.MAP_VIEW + self.frame_number: int = 0 + self.destinations: list = [] + self.rand256_active_zone: list = [] + self.rand256_zone_coordinates: list = [] + self.is_rand: 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 + self.binary_image: bytes | None = None + self.image_last_updated: float = 0.0 + self.image_format = "image/pil" + self.image_size = None + self.robot_size = None + self.image_auto_zoom: bool = False + self.image_zoom_lock_ratio: bool = True + self.image_ref_height: int = 0 + self.image_ref_width: int = 0 + self.image_aspect_ratio: str = "None" + self.image_grab = True + self.image_rotate: int = 0 + self.drawing_limit: float = 0.0 + self.current_room = None + self.user_colors = Colors + self.rooms_colors = Colors + self.vacuum_battery = 0 + self.vacuum_connection = False + self.vacuum_state = None + self.charger_position = None + self.show_vacuum_state = None self.vacuum_status_font: str = ( - "custom_components/mqtt_vacuum_camera/utils/fonts/FiraSans.ttf" # Font + "custom_components/mqtt_vacuum_camera/utils/fonts/FiraSans.ttf" ) - self.vacuum_status_size: int = 50 # Vacuum status size - self.vacuum_status_position: bool = True # Vacuum status text image top - self.snapshot_take = False # Take snapshot - self.vacuum_error = None # Vacuum error - self.vacuum_api = None # Vacuum API - self.vacuum_ips = None # Vacuum IPs - self.vac_json_id = None # Vacuum json id - self.margins = "100" # Image margins - self.obstacles_data = None # Obstacles data - self.obstacles_pos = None # Obstacles position - self.offset_top = 0 # Image offset top - self.offset_down = 0 # Image offset down - self.offset_left = 0 # Image offset left - self.offset_right = 0 # Image offset right - self.export_svg = False # Export SVG - self.svg_path = None # SVG Export path - self.enable_snapshots = False # Enable snapshots - self.file_name = file_name # vacuum friendly name as File name - self.attr_calibration_points = None # Calibration points of the image - self.map_rooms = None # Rooms data from the vacuum - self.map_pred_zones = None # Predefined zones data - self.map_pred_points = None # Predefined points data - self.map_new_path = None # New path data - self.map_old_path = None # Old path data - self.user_language = None # User language + self.vacuum_status_size: int = 50 + self.vacuum_status_position: bool = True + self.snapshot_take = False + self.vacuum_error = None + self.vacuum_api = None + self.vacuum_ips = None + self.vac_json_id = None + self.margins = "100" + self.obstacles_data = None + self.obstacles_pos = None + self.offset_top = 0 + self.offset_down = 0 + self.offset_left = 0 + self.offset_right = 0 + self.export_svg = False + self.svg_path = None + self.enable_snapshots = False + self.file_name = file_name + self.attr_calibration_points = None + self.map_rooms = None + self.map_pred_zones = None + self.map_pred_points = None + self.map_new_path = None + self.map_old_path = None + self.user_language = None self.trim_crop_data = None - self.trims = TrimsData.from_dict(DEFAULT_VALUES["trims_data"]) # Trims data + self.trims = TrimsData.from_dict(DEFAULT_VALUES["trims_data"]) self.skip_room_ids: List[str] = [] - self.device_info = None # Store the device_info + self.device_info = None def vacuum_bat_charged(self) -> bool: """Check if the vacuum is charging.""" @@ -126,49 +123,35 @@ def vacuum_bat_charged(self) -> bool: @staticmethod def _compose_obstacle_links(vacuum_host_ip: str, obstacles: list) -> list | None: - """ - Compose JSON with obstacle details including the image link. - """ + """Compose JSON with obstacle details including the image link.""" obstacle_links = [] if not obstacles or not vacuum_host_ip: return None for obstacle in obstacles: - # Extract obstacle details label = obstacle.get("label", "") points = obstacle.get("points", {}) image_id = obstacle.get("id", "None") if label and points and image_id and vacuum_host_ip: - # Append formatted obstacle data if image_id != "None": - # Compose the link image_link = ( f"http://{vacuum_host_ip}" f"/api/v2/robot/capabilities/ObstacleImagesCapability/img/{image_id}" ) obstacle_links.append( - { - "point": points, - "label": label, - "link": image_link, - } + {"point": points, "label": label, "link": image_link} ) else: - obstacle_links.append( - { - "point": points, - "label": label, - } - ) + obstacle_links.append({"point": points, "label": label}) return obstacle_links def update_user_colors(self, user_colors): - """Update the user colors.""" + """Update user colors palette""" self.user_colors = user_colors def get_user_colors(self): - """Get the user colors.""" + """Return user colors""" return self.user_colors def update_rooms_colors(self, user_colors): @@ -176,7 +159,7 @@ def update_rooms_colors(self, user_colors): self.rooms_colors = user_colors def get_rooms_colors(self): - """Get the rooms colors.""" + """Return rooms colors""" return self.rooms_colors def reset_trims(self) -> dict: @@ -185,7 +168,7 @@ def reset_trims(self) -> dict: return self.trims async def batch_update(self, **kwargs): - """Batch update multiple attributes.""" + """Update the data of Shared in Batch""" for key, value in kwargs.items(): setattr(self, key, value) @@ -210,24 +193,32 @@ def generate_attributes(self) -> dict: ) attrs[ATTR_OBSTACLES] = self.obstacles_data - if self.enable_snapshots: - attrs[ATTR_SNAPSHOT] = self.snapshot_take - else: - attrs[ATTR_SNAPSHOT] = False + attrs[ATTR_SNAPSHOT] = self.snapshot_take if self.enable_snapshots else False - # Add dynamic shared attributes if they are available shared_attrs = { ATTR_ROOMS: self.map_rooms, ATTR_ZONES: self.map_pred_zones, ATTR_POINTS: self.map_pred_points, } - for key, value in shared_attrs.items(): if value is not None: attrs[key] = value return attrs + def to_dict(self) -> dict: + """Return a dictionary with image and attributes data.""" + return { + "image": { + "binary": self.binary_image, + "pil_last_image": self.last_image, + "size": self.image_size, + "format": self.image_format, + "updated": self.image_last_updated, + }, + "attributes": self.generate_attributes(), + } + class CameraSharedManager: """Camera Shared Manager class.""" From 3bad5a3c4ba468fed4f806f5693a704993571181 Mon Sep 17 00:00:00 2001 From: SCA075 <82227818+sca075@users.noreply.github.com> Date: Tue, 30 Sep 2025 14:44:22 +0200 Subject: [PATCH 2/8] Bump version from 0.1.10rc5 to 0.1.10rc6 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8a3c16a..b9a2747 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "valetudo-map-parser" -version = "0.1.10rc5" +version = "0.1.10rc6" description = "A Python library to parse Valetudo map data returning a PIL Image object." authors = ["Sandro Cantarella "] license = "Apache-2.0" From 21ea15293bff4295de839487e4d363bb0475dd70 Mon Sep 17 00:00:00 2001 From: SCA075 <82227818+sca075@users.noreply.github.com> Date: Tue, 30 Sep 2025 18:16:09 +0200 Subject: [PATCH 3/8] final adjustment to shared.py changed basehandler so that we have all shared updated locally Signed-off-by: Sandro Cantarella --- .github/workflows/release.yaml | 17 ++++--- SCR/valetudo_map_parser/config/shared.py | 4 +- SCR/valetudo_map_parser/config/types.py | 19 +++++++- SCR/valetudo_map_parser/config/utils.py | 56 ++++++++++++++++++++--- SCR/valetudo_map_parser/hypfer_handler.py | 3 +- 5 files changed, 82 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e1d1b65..e031825 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -8,6 +8,12 @@ jobs: build-and-publish-pypi: name: Build and publish release to PyPI runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + environment: + name: valetudo_map_parser_pypi + steps: # Step 1: Checkout the repository - uses: actions/checkout@v5 @@ -46,9 +52,8 @@ jobs: run: poetry build --no-interaction # Step 8: Publish to PyPI - - name: Publish to PyPi - env: - PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} - run: | - poetry config pypi-token.pypi "${PYPI_TOKEN}" - poetry publish --no-interaction + - name: Publish to PyPI (Trusted Publishing) + uses: pypa/gh-action-pypi-publish@release/v1 + with: + skip-existing: true + packages-dir: dist/ diff --git a/SCR/valetudo_map_parser/config/shared.py b/SCR/valetudo_map_parser/config/shared.py index a9736e0..3423645 100755 --- a/SCR/valetudo_map_parser/config/shared.py +++ b/SCR/valetudo_map_parser/config/shared.py @@ -211,8 +211,8 @@ def to_dict(self) -> dict: return { "image": { "binary": self.binary_image, - "pil_last_image": self.last_image, - "size": self.image_size, + "pil_last_image": self.new_image.size, + "size": self.new_image.size if self.new_image else None, "format": self.image_format, "updated": self.image_last_updated, }, diff --git a/SCR/valetudo_map_parser/config/types.py b/SCR/valetudo_map_parser/config/types.py index 9a56022..8624a56 100644 --- a/SCR/valetudo_map_parser/config/types.py +++ b/SCR/valetudo_map_parser/config/types.py @@ -8,7 +8,7 @@ import logging import threading from dataclasses import asdict, dataclass -from typing import Any, Dict, Optional, Tuple, TypedDict, Union +from typing import Any, Dict, Optional, Tuple, TypedDict, Union, List, NotRequired import numpy as np from PIL import Image @@ -18,6 +18,23 @@ LOGGER = logging.getLogger(__package__) +class Spot(TypedDict): + name: str + coordinates: List[int] # [x, y] + +class Zone(TypedDict): + name: str + coordinates: List[List[int]] # [[x1, y1, x2, y2, repeats], ...] + +class Room(TypedDict): + name: str + id: int + +class Destinations(TypedDict, total=False): + spots: NotRequired[Optional[List[Spot]]] + zones: NotRequired[Optional[List[Zone]]] + rooms: NotRequired[Optional[List[Room]]] + updated: NotRequired[Optional[int]] class RoomProperty(TypedDict): number: int diff --git a/SCR/valetudo_map_parser/config/utils.py b/SCR/valetudo_map_parser/config/utils.py index ce479b1..0404488 100644 --- a/SCR/valetudo_map_parser/config/utils.py +++ b/SCR/valetudo_map_parser/config/utils.py @@ -5,7 +5,7 @@ import hashlib import json from dataclasses import dataclass -from typing import Callable, List, Optional +from typing import Callable, List, Optional, Tuple import io import numpy as np @@ -23,6 +23,7 @@ NumpyArray, PilPNG, RobotPosition, + Destinations ) from ..map_data import HyperMapData from .async_utils import AsyncNumPy @@ -91,9 +92,9 @@ def get_robot_position(self) -> RobotPosition: async def async_get_image( self, m_json: dict | None, - destinations: list | None = None, + destinations: Destinations | None = None, bytes_format: bool = False, - ) -> PilPNG | bytes: + ) -> Tuple[PilPNG | bytes, dict]: """ Unified async function to get PIL image from JSON data for both Hypfer and Rand256 handlers. @@ -108,7 +109,7 @@ async def async_get_image( @param bytes_format: If True, also convert to PNG bytes and store in shared.binary_image @param text_enabled: If True, draw text on the image @param vacuum_status: Vacuum status to display on the image - @return: PIL Image or None + @return: PIL Image or None and data dictionary """ try: # Backup current image to last_image before processing new one @@ -122,6 +123,7 @@ async def async_get_image( m_json=m_json, destinations=destinations, ) + elif hasattr(self, "async_get_image_from_json"): # This is a Hypfer handler self.json_data = await HyperMapData.async_from_valetudo_json(m_json) @@ -141,7 +143,10 @@ async def async_get_image( # Store the new image in shared data if new_image is not None: + # Update shared data + await self._async_update_shared_data(destinations) self.shared.new_image = new_image + # Add text to the image if self.shared.show_vacuum_state: text_editor = StatusText(self.shared) img_text = await text_editor.get_status_text(new_image) @@ -160,13 +165,17 @@ async def async_get_image( self.shared.binary_image = pil_to_png_bytes(self.shared.last_image) # Update the timestamp with current datetime self.shared.image_last_updated = datetime.datetime.fromtimestamp(time()) - return new_image + LOGGER.debug("%s: Frame Completed.", self.file_name) + data = {} + if bytes_format: + data = self.shared.to_dict() + return new_image, data else: LOGGER.warning( "%s: Failed to generate image from JSON data", self.file_name ) if bytes_format and hasattr(self.shared, "last_image"): - return pil_to_png_bytes(self.shared.last_image) + return pil_to_png_bytes(self.shared.last_image), {} return ( self.shared.last_image if hasattr(self.shared, "last_image") @@ -184,6 +193,41 @@ async def async_get_image( self.shared.last_image if hasattr(self.shared, "last_image") else None ) + async def _async_update_shared_data(self, destinations: list | None = None): + """Update the shared data with the latest information.""" + + if hasattr(self, "get_rooms_attributes") and ( + self.shared.map_rooms is None and destinations + ): + ( + self.shared.map_rooms, + self.shared.map_pred_zones, + self.shared.map_pred_points, + ) = await self.get_rooms_attributes(destinations) + if self.shared.map_rooms: + LOGGER.debug("%s: Rand256 attributes rooms updated", self.file_name) + + if hasattr(self, "async_get_rooms_attributes") and ( + self.shared.map_rooms is None + ): + if self.shared.map_rooms is None: + self.shared.map_rooms = await self.async_get_rooms_attributes() + if self.shared.map_rooms: + LOGGER.debug("%s: Hyper attributes rooms updated", self.file_name) + + if hasattr(self, "get_calibration_data") and self.shared.attr_calibration_points is None: + self.shared.attr_calibration_points = self.get_calibration_data(self.shared.image_rotate) + + if not self.shared.image_size: + self.shared.image_size = self.get_img_size() + + self.shared.vac_json_id = self.get_json_id() + + if not self.shared.charger_position: + self.shared.charger_position = self.get_charger_position() + + self.shared.current_room = self.get_robot_position() + def prepare_resize_params(self, pil_img: PilPNG, rand: bool=False) -> ResizeParams: """Prepare resize parameters for image resizing.""" if self.shared.image_rotate in [0, 180]: diff --git a/SCR/valetudo_map_parser/hypfer_handler.py b/SCR/valetudo_map_parser/hypfer_handler.py index 85d78e4..0a93e50 100644 --- a/SCR/valetudo_map_parser/hypfer_handler.py +++ b/SCR/valetudo_map_parser/hypfer_handler.py @@ -409,11 +409,10 @@ async def async_get_rooms_attributes(self) -> RoomsProperties: ) return self.room_propriety - def get_calibration_data(self) -> CalibrationPoints: + def get_calibration_data(self, rotation_angle: int = 0) -> CalibrationPoints: """Get the calibration data from the JSON data. this will create the attribute calibration points.""" calibration_data = [] - rotation_angle = self.shared.image_rotate LOGGER.info("Getting %s Calibrations points.", self.file_name) # Define the map points (fixed) From ecd9ab602f744b654be24e980759c6b2b40e86c4 Mon Sep 17 00:00:00 2001 From: SCA075 <82227818+sca075@users.noreply.github.com> Date: Tue, 30 Sep 2025 18:29:03 +0200 Subject: [PATCH 4/8] Update SCR/valetudo_map_parser/config/shared.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- SCR/valetudo_map_parser/config/shared.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SCR/valetudo_map_parser/config/shared.py b/SCR/valetudo_map_parser/config/shared.py index 3423645..64f54c9 100755 --- a/SCR/valetudo_map_parser/config/shared.py +++ b/SCR/valetudo_map_parser/config/shared.py @@ -211,7 +211,7 @@ def to_dict(self) -> dict: return { "image": { "binary": self.binary_image, - "pil_last_image": self.new_image.size, + "pil_last_image": self.new_image.size, "size": self.new_image.size if self.new_image else None, "format": self.image_format, "updated": self.image_last_updated, From 56f4641c99ccc391c117753b04e7e9ae09143108 Mon Sep 17 00:00:00 2001 From: SCA075 <82227818+sca075@users.noreply.github.com> Date: Tue, 30 Sep 2025 18:30:29 +0200 Subject: [PATCH 5/8] Update SCR/valetudo_map_parser/config/utils.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- SCR/valetudo_map_parser/config/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SCR/valetudo_map_parser/config/utils.py b/SCR/valetudo_map_parser/config/utils.py index 0404488..383d111 100644 --- a/SCR/valetudo_map_parser/config/utils.py +++ b/SCR/valetudo_map_parser/config/utils.py @@ -193,7 +193,7 @@ async def async_get_image( self.shared.last_image if hasattr(self.shared, "last_image") else None ) - async def _async_update_shared_data(self, destinations: list | None = None): + async def _async_update_shared_data(self, destinations: Destinations | None = None): """Update the shared data with the latest information.""" if hasattr(self, "get_rooms_attributes") and ( From 418f119a1764eb039bb6895068df3d7b31c948d0 Mon Sep 17 00:00:00 2001 From: SCA075 <82227818+sca075@users.noreply.github.com> Date: Tue, 30 Sep 2025 18:36:57 +0200 Subject: [PATCH 6/8] solve inconstant return at line 183 Signed-off-by: Sandro Cantarella --- SCR/valetudo_map_parser/config/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SCR/valetudo_map_parser/config/utils.py b/SCR/valetudo_map_parser/config/utils.py index 0404488..edb3e81 100644 --- a/SCR/valetudo_map_parser/config/utils.py +++ b/SCR/valetudo_map_parser/config/utils.py @@ -180,7 +180,7 @@ async def async_get_image( self.shared.last_image if hasattr(self.shared, "last_image") else None - ) + ), {} except Exception as e: LOGGER.warning( From 100f9cd5d1c82840f9911e55019822278be2d9f7 Mon Sep 17 00:00:00 2001 From: SCA075 <82227818+sca075@users.noreply.github.com> Date: Tue, 30 Sep 2025 18:40:08 +0200 Subject: [PATCH 7/8] Update SCR/valetudo_map_parser/config/shared.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- SCR/valetudo_map_parser/config/shared.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SCR/valetudo_map_parser/config/shared.py b/SCR/valetudo_map_parser/config/shared.py index 64f54c9..3a29d77 100755 --- a/SCR/valetudo_map_parser/config/shared.py +++ b/SCR/valetudo_map_parser/config/shared.py @@ -211,7 +211,7 @@ def to_dict(self) -> dict: return { "image": { "binary": self.binary_image, - "pil_last_image": self.new_image.size, + "pil_image_size": self.new_image.size, "size": self.new_image.size if self.new_image else None, "format": self.image_format, "updated": self.image_last_updated, From eb8c24365f1f59807db23b448afd77b83ecc5ee1 Mon Sep 17 00:00:00 2001 From: SCA075 <82227818+sca075@users.noreply.github.com> Date: Tue, 30 Sep 2025 18:40:36 +0200 Subject: [PATCH 8/8] Update SCR/valetudo_map_parser/config/utils.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- SCR/valetudo_map_parser/config/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SCR/valetudo_map_parser/config/utils.py b/SCR/valetudo_map_parser/config/utils.py index 32c4802..456b59e 100644 --- a/SCR/valetudo_map_parser/config/utils.py +++ b/SCR/valetudo_map_parser/config/utils.py @@ -197,7 +197,7 @@ async def _async_update_shared_data(self, destinations: Destinations | None = No """Update the shared data with the latest information.""" if hasattr(self, "get_rooms_attributes") and ( - self.shared.map_rooms is None and destinations + self.shared.map_rooms is None and destinations is not None ): ( self.shared.map_rooms,