diff --git a/custom_components/valetudo_vacuum_camera/camera.py b/custom_components/valetudo_vacuum_camera/camera.py index 2072fe6c..9f34d5f5 100644 --- a/custom_components/valetudo_vacuum_camera/camera.py +++ b/custom_components/valetudo_vacuum_camera/camera.py @@ -1,5 +1,8 @@ -"""Camera Version 1.5.4 -Valetudo Re Test image. +""" +Camera Version 1.5.5 +Valetudo Firmwares Vacuums maps. +for Valetudo Hypfer and rand256 maps. +From PI4 up to all other Home Assistant supported platforms. """ from __future__ import annotations @@ -60,7 +63,7 @@ ALPHA_ROOM_3, ALPHA_ROOM_4, ALPHA_ROOM_5, ALPHA_ROOM_6, ALPHA_ROOM_7, ALPHA_ROOM_8, ALPHA_ROOM_9, ALPHA_ROOM_10, ALPHA_ROOM_11, ALPHA_ROOM_12, ALPHA_ROOM_13, ALPHA_ROOM_14, - ALPHA_ROOM_15, + ALPHA_ROOM_15 ) from custom_components.valetudo_vacuum_camera.common import get_vacuum_unique_id_from_mqtt_topic @@ -142,7 +145,7 @@ def __init__(self, hass, device_info): self._map_pred_points = None self._vacuum_shared = Vacuum() self._vacuum_state = None - self._frame_interval = 1 + self._attr_frame_interval = 1 self._vac_img_data = None self._vac_json_data = None self._vac_json_id = None @@ -350,7 +353,7 @@ async def take_snapshot(self, json_data, image_data): _LOGGER.info(f"{self.file_name}: Camera Snapshot Taken.") except IOError: self._snapshot_taken = None - _LOGGER.warning(f"Error Saving{self.file_name}: Snapshot, will not be available till restart.") + _LOGGER.warning(f"Error Saving {self.file_name}: Snapshot, will not be available till restart.") else: _LOGGER.debug(f"{self.file_name}: Snapshot acquired during {self._vacuum_state} Vacuum State.") @@ -417,102 +420,12 @@ async def async_update(self): pid = os.getpid() # Start to log the CPU usage of this PID. proc = proc_insp.PsutilWrapper().psutil.Process(pid) # Get the process PID. self._cpu_percent = round((proc.cpu_percent() / proc_insp.PsutilWrapper().psutil.cpu_count()) / 2, 2) + _LOGGER.debug(f"{self.file_name} System CPU usage stat (1/2): {self._cpu_percent}%") if parsed_json is not None: if self._rrm_data: - destinations = await self._mqtt.get_destinations() - self._cpu_percent = round((proc.cpu_percent() / proc_insp.PsutilWrapper().psutil.cpu_count()) - / 2, 2) - _LOGGER.debug(f"{self.file_name} System CPU usage stat (1/2): {self._cpu_percent}%") - pil_img = await self._re_handler.get_image_from_rrm( - m_json=self._rrm_data, - img_rotation=self._image_rotate, - margins=self._margins, - user_colors=self._vacuum_shared.get_user_colors(), - rooms_colors=self._vacuum_shared.get_rooms_colors(), - file_name=self.file_name, - destinations=destinations, - drawing_limit=self._cpu_percent - ) - else: - pil_img = await self._map_handler.get_image_from_json( - m_json=parsed_json, - robot_state=self._vacuum_state, - img_rotation=self._image_rotate, - margins=self._margins, - user_colors=self._vacuum_shared.get_user_colors(), - rooms_colors=self._vacuum_shared.get_rooms_colors(), - file_name=self.file_name, - ) - if pil_img is not None: - if self._map_rooms is None: - if self._rrm_data is None: - self._map_rooms = await self._map_handler.get_rooms_attributes() - elif (self._map_rooms is None) and (self._rrm_data is not None): - destinations = await self._mqtt.get_destinations() - if destinations is not None: - self._map_rooms, self._map_pred_zones, self._map_pred_points = \ - await self._re_handler.get_rooms_attributes(destinations) - if self._map_rooms: - _LOGGER.debug( - f"State attributes rooms update: {self._map_rooms}" - ) - if self._show_vacuum_state: - self._map_handler.draw.status_text( - pil_img, - 50, - self._vacuum_shared.user_colors[8], - self.file_name + ": " + self._vacuum_state, - ) - if self._attr_calibration_points is None: - if self._rrm_data is None: - self._attr_calibration_points = ( - self._map_handler.get_calibration_data(self._image_rotate) - ) - else: - self._attr_calibration_points = ( - self._re_handler.get_calibration_data(self._image_rotate) - ) - - if self._rrm_data is None: - self._vac_json_id = self._map_handler.get_json_id() - if not self._base: - self._base = self._map_handler.get_charger_position() - self._current = self._map_handler.get_robot_position() - if not self._vac_img_data: - self._vac_img_data = self._map_handler.get_img_size() - - else: - self._vac_json_id = self._re_handler.get_json_id() - if not self._base: - self._base = self._re_handler.get_charger_position() - self._current = self._re_handler.get_robot_position() - if not self._vac_img_data: - self._vac_img_data = self._re_handler.get_img_size() - - if not self._snapshot_taken and ( - self._vacuum_state == "idle" - or self._vacuum_state == "docked" - or self._vacuum_state == "error" - ): - # suspend image processing if we are at the next frame. - if not self._rrm_data: - if ( - self._frame_nuber - is not self._map_handler.get_frame_number() - ): - self._image_grab = False - _LOGGER.info( - f"Suspended the camera data processing for: {self.file_name}." - ) - # take a snapshot - await self.take_snapshot(parsed_json, pil_img) - else: - _LOGGER.info( - f"Suspended the camera data processing for: {self.file_name}." - ) - # take a snapshot - await self.take_snapshot(self._rrm_data, pil_img) - self._image_grab = False + pil_img = await self.process_rand256_data(parsed_json) + elif self._rrm_data is None: + pil_img = await self.proces_valetudo_data(parsed_json) else: # if no image was processed empty or last snapshot/frame pil_img = self.empty_if_no_data() @@ -537,14 +450,14 @@ async def async_update(self): del buffered, pil_img, bytes_data _LOGGER.debug(f"{self.file_name}: Image update complete") processing_time = (datetime.now() - start_time).total_seconds() - self._frame_interval = max(0.1, processing_time) - _LOGGER.debug(f"Adjusted {self.file_name}: Frame interval: {self._frame_interval}") + self._attr_frame_interval = max(0.1, processing_time) + _LOGGER.debug(f"Adjusted {self.file_name}: Frame interval: {self._attr_frame_interval}") else: _LOGGER.info( f"{self.file_name}: Image not processed. Returning not updated image." ) - self._frame_interval = 0.1 + self._attr_frame_interval = 0.1 self.camera_image(self._image_w, self._image_h) # HA supervised memory and CUP usage report. self._cpu_percent = round(((self._cpu_percent + proc.cpu_percent()) @@ -560,3 +473,121 @@ async def async_update(self): self._processing = False # threading.Thread(target=self.async_update).start() return self._image + + # let's separate the vacuums: + async def proces_valetudo_data(self, parsed_json): + if parsed_json is not None: + pil_img = await self._map_handler.get_image_from_json( + m_json=parsed_json, + robot_state=self._vacuum_state, + img_rotation=self._image_rotate, + margins=self._margins, + user_colors=self._vacuum_shared.get_user_colors(), + rooms_colors=self._vacuum_shared.get_rooms_colors(), + file_name=self.file_name, + ) + if pil_img is not None: + if self._map_rooms is None: + if self._rrm_data is None: + self._map_rooms = await self._map_handler.get_rooms_attributes() + if self._map_rooms: + _LOGGER.debug( + f"State attributes rooms update: {self._map_rooms}" + ) + if self._show_vacuum_state: + self._map_handler.draw.status_text( + pil_img, + 50, + self._vacuum_shared.user_colors[8], + self.file_name + ": " + self._vacuum_state, + ) + + if self._attr_calibration_points is None: + self._attr_calibration_points = ( + self._map_handler.get_calibration_data(self._image_rotate) + ) + + self._vac_json_id = self._map_handler.get_json_id() + if not self._base: + self._base = self._map_handler.get_charger_position() + self._current = self._map_handler.get_robot_position() + if not self._vac_img_data: + self._vac_img_data = self._map_handler.get_img_size() + + if not self._snapshot_taken and ( + self._vacuum_state == "idle" + or self._vacuum_state == "docked" + or self._vacuum_state == "error" + ): + # suspend image processing if we are at the next frame. + if ( + self._frame_nuber + is not self._map_handler.get_frame_number() + ): + self._image_grab = False + _LOGGER.info( + f"Suspended the camera data processing for: {self.file_name}." + ) + # take a snapshot + await self.take_snapshot(parsed_json, pil_img) + return pil_img + return None + + async def process_rand256_data(self, parsed_json): + if parsed_json is not None: + destinations = await self._mqtt.get_destinations() + pil_img = await self._re_handler.get_image_from_rrm( + m_json=self._rrm_data, + img_rotation=self._image_rotate, + margins=self._margins, + user_colors=self._vacuum_shared.get_user_colors(), + rooms_colors=self._vacuum_shared.get_rooms_colors(), + file_name=self.file_name, + destinations=destinations, + drawing_limit=self._cpu_percent + ) + + if pil_img is not None: + if self._map_rooms is None: + destinations = await self._mqtt.get_destinations() + if destinations is not None: + self._map_rooms, self._map_pred_zones, self._map_pred_points = \ + await self._re_handler.get_rooms_attributes(destinations) + if self._map_rooms: + _LOGGER.debug( + f"State attributes rooms update: {self._map_rooms}" + ) + if self._show_vacuum_state: + self._map_handler.draw.status_text( + pil_img, + 50, + self._vacuum_shared.user_colors[8], + self.file_name + ": " + self._vacuum_state, + ) + + if self._attr_calibration_points is None: + self._attr_calibration_points = ( + self._re_handler.get_calibration_data(self._image_rotate) + ) + + self._vac_json_id = self._re_handler.get_json_id() + if not self._base: + self._base = self._re_handler.get_charger_position() + self._current = self._re_handler.get_robot_position() + if not self._vac_img_data: + self._vac_img_data = self._re_handler.get_img_size() + + if not self._snapshot_taken and ( + self._vacuum_state == "idle" + or self._vacuum_state == "docked" + or self._vacuum_state == "error" + ): + # suspend image processing if we are at the next frame. + _LOGGER.info( + f"Suspended the camera data processing for: {self.file_name}." + ) + # take a snapshot + await self.take_snapshot(self._rrm_data, pil_img) + self._image_grab = False + return pil_img + return None diff --git a/custom_components/valetudo_vacuum_camera/manifest.json b/custom_components/valetudo_vacuum_camera/manifest.json index 06d1af28..e58127b7 100644 --- a/custom_components/valetudo_vacuum_camera/manifest.json +++ b/custom_components/valetudo_vacuum_camera/manifest.json @@ -15,5 +15,5 @@ "pillow", "numpy" ], - "version": "v1.5.4" + "version": "v1.5.5" } diff --git a/custom_components/valetudo_vacuum_camera/utils/draweble.py b/custom_components/valetudo_vacuum_camera/utils/draweble.py index 31085474..0e0fa7fe 100644 --- a/custom_components/valetudo_vacuum_camera/utils/draweble.py +++ b/custom_components/valetudo_vacuum_camera/utils/draweble.py @@ -3,7 +3,7 @@ Drawable is part of the Image_Handler used functions to draw the elements on the Numpy Array that is actually our camera frame. -Last changes on Version: 1.4.7 +Last changes on Version: 1.5.5 """ import logging @@ -404,6 +404,30 @@ def overlay_robot(background_image, robot_image, x, y): background_image[top_left_y:bottom_right_y, top_left_x:bottom_right_x] = robot_image return background_image + @staticmethod + def draw_obstacles(image, obstacle_info_list, color): + """ + Draw filled circles for obstacles on the image. + + Parameters: + - image: NumPy array representing the image. + - obstacle_info_list: List of dictionaries containing obstacle information. + + Returns: + - Modified image with filled circles for obstacles. + """ + for obstacle_info in obstacle_info_list: + enter = obstacle_info.get("points", {}) + label = obstacle_info.get("label", {}) + center = (enter['x'], enter['y']) + + radius = 6 + + # Draw filled circle + image = Drawable._filled_circle(image, center, radius, color) + + return image + @staticmethod def status_text(image, size, color, status): # Load a font diff --git a/custom_components/valetudo_vacuum_camera/valetudo/image_handler.py b/custom_components/valetudo_vacuum_camera/valetudo/image_handler.py index 700f2452..65cca79a 100644 --- a/custom_components/valetudo_vacuum_camera/valetudo/image_handler.py +++ b/custom_components/valetudo_vacuum_camera/valetudo/image_handler.py @@ -2,12 +2,14 @@ Image Handler Module. It returns the PIL PNG image frame relative to the Map Data extrapolated from the vacuum json. It also returns calibration, rooms data to the card and other images information to the camera. -Last Changed on Version: 1.5.3 +Last Changed on Version: 1.5.5 """ from __future__ import annotations import logging import numpy as np +import hashlib +import json from PIL import Image from custom_components.valetudo_vacuum_camera.utils.colors_man import color_grey @@ -19,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) -# noinspection PyTypeChecker +# noinspection PyTypeChecker,PyUnboundLocalVariable class MapImageHandler(object): def __init__(self): self.auto_crop = None @@ -31,6 +33,7 @@ def __init__(self): self.draw = Drawable self.frame_number = 0 self.go_to = None + self.img_hash = None self.img_base_layer = None self.img_rotate = 0 self.img_size = None @@ -313,12 +316,32 @@ async def get_image_from_json( } _LOGGER.debug(f"Charger Position: {list(self.charger_pos.items())}") + if entity_dict: + try: + obstacle_data = entity_dict.get("obstacle") + except KeyError: + _LOGGER.info("No obstacle found.") + else: + obstacle_positions = [] + if obstacle_data: + for obstacle in obstacle_data: + label = obstacle.get("metaData", {}).get("label") + points = obstacle.get("points", []) + + if label and points: + obstacle_pos = {"label": label, "points": {"x": points[0], "y": points[1]}} + obstacle_positions.append(obstacle_pos) + + # List of dictionaries containing label and points for each obstacle + _LOGGER.debug("All obstacle positions: %s", obstacle_positions) + go_to = entity_dict.get("go_to_target") pixel_size = int(m_json["pixelSize"]) - + layers, active = self.data.find_layers(m_json["layers"]) + new_frame_hash = await self.calculate_array_hash(layers, active) if self.frame_number == 0: + self.img_hash = await self.calculate_array_hash(layers, active) # The below is drawing the base layer that will be reused at the next frame. - layers, active = self.data.find_layers(m_json["layers"]) _LOGGER.debug(f"{file_name}: Layers to draw: {layers.keys()}") _LOGGER.info(f"{file_name}: Empty image with background color") img_np_array = await self.draw.create_empty_image(size_x, size_y, color_background) @@ -335,10 +358,10 @@ async def get_image_from_json( # Check if the room is active and set a modified color if active and len(active) > room_id and active[room_id] == 1: room_color = ( - room_color[0], - room_color[1], - room_color[2], - room_color[3] == 50 + ((2 * room_color[0]) + color_zone_clean[0]) // 3, + ((2 * room_color[1]) + color_zone_clean[1]) // 3, + ((2 * room_color[2]) + color_zone_clean[2]) // 3, + ((2 * room_color[3]) + color_zone_clean[3]) // 3 ) img_np_array = await self.draw.from_json_to_image( img_np_array, pixels, pixel_size, room_color @@ -383,6 +406,11 @@ async def get_image_from_json( img_np_array, charger_pos[0], charger_pos[1], color_charger ) + if obstacle_positions: + self.draw.draw_obstacles(img_np_array, + obstacle_positions, + color_no_go) + if (room_id > 0) and not self.room_propriety: self.room_propriety = self.extract_room_properties(self.json_data) if self.rooms_pos: @@ -395,7 +423,7 @@ async def get_image_from_json( self.img_base_layer = await self.async_copy_array(img_np_array) self.frame_number += 1 - if self.frame_number > 14: + if (self.frame_number > 1024) or (new_frame_hash != self.img_hash): self.frame_number = 0 _LOGGER.debug(f"{file_name}: Frame number %s", self.frame_number) @@ -593,4 +621,18 @@ def get_calibration_data(self, rotation_angle): async def async_copy_array(self, original_array): copied_array = np.copy(original_array) + return copied_array + + # noinspection PyUnresolvedReferences + async def calculate_array_hash(self, layers: None, active: None): + if layers and active: + data_to_hash = { + 'layers': len(layers["wall"][0]), + 'active_segments': tuple(active), + } + data_json = json.dumps(data_to_hash, sort_keys=True) + hash_value = hashlib.sha256(data_json.encode()).hexdigest() + else: + hash_value = None + return hash_value