valetudo-map-parser 0.1.9b74__tar.gz → 0.1.10rc2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/PKG-INFO +7 -4
  2. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/SCR/valetudo_map_parser/__init__.py +27 -5
  3. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/SCR/valetudo_map_parser/config/auto_crop.py +2 -27
  4. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/SCR/valetudo_map_parser/config/colors.py +2 -2
  5. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/SCR/valetudo_map_parser/config/drawable.py +69 -62
  6. valetudo_map_parser-0.1.10rc2/SCR/valetudo_map_parser/config/fonts/FiraSans.ttf +0 -0
  7. valetudo_map_parser-0.1.10rc2/SCR/valetudo_map_parser/config/fonts/Inter-VF.ttf +0 -0
  8. valetudo_map_parser-0.1.10rc2/SCR/valetudo_map_parser/config/fonts/Lato-Regular.ttf +0 -0
  9. valetudo_map_parser-0.1.10rc2/SCR/valetudo_map_parser/config/fonts/MPLUSRegular.ttf +0 -0
  10. valetudo_map_parser-0.1.10rc2/SCR/valetudo_map_parser/config/fonts/NotoKufiArabic-VF.ttf +0 -0
  11. valetudo_map_parser-0.1.10rc2/SCR/valetudo_map_parser/config/fonts/NotoSansCJKhk-VF.ttf +0 -0
  12. valetudo_map_parser-0.1.10rc2/SCR/valetudo_map_parser/config/fonts/NotoSansKhojki.ttf +0 -0
  13. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/SCR/valetudo_map_parser/config/shared.py +12 -11
  14. valetudo_map_parser-0.1.10rc2/SCR/valetudo_map_parser/config/status_text/status_text.py +95 -0
  15. valetudo_map_parser-0.1.10rc2/SCR/valetudo_map_parser/config/status_text/translations.py +280 -0
  16. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/SCR/valetudo_map_parser/config/types.py +8 -8
  17. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/SCR/valetudo_map_parser/config/utils.py +35 -61
  18. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/SCR/valetudo_map_parser/hypfer_draw.py +2 -90
  19. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/SCR/valetudo_map_parser/hypfer_handler.py +29 -47
  20. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/SCR/valetudo_map_parser/map_data.py +394 -81
  21. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/SCR/valetudo_map_parser/rand256_handler.py +6 -59
  22. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/SCR/valetudo_map_parser/reimg_draw.py +1 -6
  23. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/SCR/valetudo_map_parser/rooms_handler.py +4 -10
  24. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/pyproject.toml +3 -2
  25. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/LICENSE +0 -0
  26. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/NOTICE.txt +0 -0
  27. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/README.md +0 -0
  28. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/SCR/valetudo_map_parser/config/__init__.py +0 -0
  29. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/SCR/valetudo_map_parser/config/async_utils.py +0 -0
  30. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/SCR/valetudo_map_parser/config/color_utils.py +0 -0
  31. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/SCR/valetudo_map_parser/config/drawable_elements.py +0 -0
  32. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/SCR/valetudo_map_parser/config/enhanced_drawable.py +0 -0
  33. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/SCR/valetudo_map_parser/config/optimized_element_map.py +0 -0
  34. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/SCR/valetudo_map_parser/config/rand256_parser.py +0 -0
  35. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/SCR/valetudo_map_parser/hypfer_rooms_handler.py +0 -0
  36. {valetudo_map_parser-0.1.9b74 → valetudo_map_parser-0.1.10rc2}/SCR/valetudo_map_parser/py.typed +0 -0
@@ -1,16 +1,19 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: valetudo-map-parser
3
- Version: 0.1.9b74
3
+ Version: 0.1.10rc2
4
4
  Summary: A Python library to parse Valetudo map data returning a PIL Image object.
5
5
  License: Apache-2.0
6
+ License-File: LICENSE
7
+ License-File: NOTICE.txt
6
8
  Author: Sandro Cantarella
7
9
  Author-email: gsca075@gmail.com
8
- Requires-Python: >=3.12
10
+ Requires-Python: >=3.13
9
11
  Classifier: License :: OSI Approved :: Apache Software License
10
12
  Classifier: Programming Language :: Python :: 3
11
- Classifier: Programming Language :: Python :: 3.12
12
13
  Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
13
15
  Requires-Dist: Pillow (>=10.3.0)
16
+ Requires-Dist: mvcrender (>=0.0.2)
14
17
  Requires-Dist: numpy (>=1.26.4)
15
18
  Requires-Dist: scipy (>=1.12.0)
16
19
  Project-URL: Bug Tracker, https://github.com/sca075/Python-package-valetudo-map-parser/issues
@@ -1,11 +1,12 @@
1
1
  """Valetudo map parser.
2
- Version: 0.1.9"""
2
+ Version: 0.1.10"""
3
+
4
+ from pathlib import Path
3
5
 
4
6
  from .config.colors import ColorsManagement
5
7
  from .config.drawable import Drawable
6
8
  from .config.drawable_elements import DrawableElement, DrawingConfig
7
9
  from .config.enhanced_drawable import EnhancedDrawable
8
- from .config.utils import webp_bytes_to_pil
9
10
  from .config.rand256_parser import RRMapParser
10
11
  from .config.shared import CameraShared, CameraSharedManager
11
12
  from .config.types import (
@@ -15,16 +16,32 @@ from .config.types import (
15
16
  SnapshotStore,
16
17
  TrimCropData,
17
18
  UserLanguageStore,
18
- WebPBytes,
19
+ JsonType,
20
+ PilPNG,
21
+ NumpyArray,
22
+ ImageSize,
19
23
  )
24
+ from .config.status_text.status_text import StatusText
25
+ from .config.status_text.translations import translations as STATUS_TEXT_TRANSLATIONS
20
26
  from .hypfer_handler import HypferMapImageHandler
21
27
  from .rand256_handler import ReImageHandler
22
28
  from .rooms_handler import RoomsHandler, RandRoomsHandler
29
+ from .map_data import HyperMapData
30
+
31
+
32
+ def get_default_font_path() -> str:
33
+ """Return the absolute path to the bundled default font directory.
34
+
35
+ This returns the path to the fonts folder; the caller can join a specific font file
36
+ to avoid hard-coding a particular font here.
37
+ """
38
+ return str((Path(__file__).resolve().parent / "config" / "fonts").resolve())
23
39
 
24
40
 
25
41
  __all__ = [
26
42
  "RoomsHandler",
27
43
  "RandRoomsHandler",
44
+ "HyperMapData",
28
45
  "HypferMapImageHandler",
29
46
  "ReImageHandler",
30
47
  "RRMapParser",
@@ -41,6 +58,11 @@ __all__ = [
41
58
  "RoomsProperties",
42
59
  "TrimCropData",
43
60
  "CameraModes",
44
- "WebPBytes",
45
- "webp_bytes_to_pil",
61
+ "JsonType",
62
+ "PilPNG",
63
+ "NumpyArray",
64
+ "ImageSize",
65
+ "StatusText",
66
+ "STATUS_TEXT_TRANSLATIONS",
67
+ "get_default_font_path",
46
68
  ]
@@ -6,10 +6,9 @@ from __future__ import annotations
6
6
  import logging
7
7
 
8
8
  import numpy as np
9
- from numpy import rot90
10
9
  from scipy import ndimage
11
10
 
12
- from .async_utils import AsyncNumPy, make_async
11
+ from .async_utils import AsyncNumPy
13
12
  from .types import Color, NumpyArray, TrimCropData, TrimsData
14
13
  from .utils import BaseHandler
15
14
 
@@ -91,7 +90,6 @@ class AutoCrop:
91
90
 
92
91
  async def _async_auto_crop_data(self, tdata: TrimsData): # , tdata=None
93
92
  """Load the auto crop data from the Camera config."""
94
- _LOGGER.debug("Auto Crop init data: %s, %s", str(tdata), str(self.auto_crop))
95
93
  if not self.auto_crop:
96
94
  trims_data = TrimCropData.from_dict(dict(tdata.to_dict())).to_list()
97
95
  (
@@ -100,7 +98,6 @@ class AutoCrop:
100
98
  self.trim_right,
101
99
  self.trim_down,
102
100
  ) = trims_data
103
- _LOGGER.debug("Auto Crop trims data: %s", trims_data)
104
101
  if trims_data != [0, 0, 0, 0]:
105
102
  self._calculate_trimmed_dimensions()
106
103
  else:
@@ -118,10 +115,6 @@ class AutoCrop:
118
115
 
119
116
  async def _init_auto_crop(self):
120
117
  """Initialize the auto crop data."""
121
- _LOGGER.debug("Auto Crop Init data: %s", str(self.auto_crop))
122
- _LOGGER.debug(
123
- "Auto Crop Init trims data: %r", self.handler.shared.trims.to_dict()
124
- )
125
118
  if not self.auto_crop: # and self.handler.shared.vacuum_state == "docked":
126
119
  self.auto_crop = await self._async_auto_crop_data(self.handler.shared.trims)
127
120
  if self.auto_crop:
@@ -131,7 +124,6 @@ class AutoCrop:
131
124
 
132
125
  # Fallback: Ensure auto_crop is valid
133
126
  if not self.auto_crop or any(v < 0 for v in self.auto_crop):
134
- _LOGGER.debug("Auto-crop data unavailable. Scanning full image.")
135
127
  self.auto_crop = None
136
128
 
137
129
  return self.auto_crop
@@ -164,14 +156,6 @@ class AutoCrop:
164
156
  min_y, max_y = y_slice.start, y_slice.stop - 1
165
157
  min_x, max_x = x_slice.start, x_slice.stop - 1
166
158
 
167
- _LOGGER.debug(
168
- "%s: Found trims max and min values (y,x) (%s, %s) (%s, %s)...",
169
- self.handler.file_name,
170
- int(max_y),
171
- int(max_x),
172
- int(min_y),
173
- int(min_x),
174
- )
175
159
  return min_y, min_x, max_x, max_y
176
160
 
177
161
  async def async_get_room_bounding_box(
@@ -247,7 +231,7 @@ class AutoCrop:
247
231
  return None
248
232
 
249
233
  except Exception as e:
250
- _LOGGER.error(
234
+ _LOGGER.warning(
251
235
  "%s: Error calculating room bounding box for '%s': %s",
252
236
  self.handler.file_name,
253
237
  room_name,
@@ -403,7 +387,6 @@ class AutoCrop:
403
387
  try:
404
388
  self.auto_crop = await self._init_auto_crop()
405
389
  if (self.auto_crop is None) or (self.auto_crop == [0, 0, 0, 0]):
406
- _LOGGER.debug("%s: Calculating auto trim box", self.handler.file_name)
407
390
  # Find the coordinates of the first occurrence of a non-background color
408
391
  min_y, min_x, max_x, max_y = await self.async_image_margins(
409
392
  image_array, detect_colour
@@ -456,15 +439,7 @@ class AutoCrop:
456
439
  # Rotate the cropped image based on the given angle
457
440
  rotated = await self.async_rotate_the_image(trimmed, rotate)
458
441
  del trimmed # Free memory.
459
- _LOGGER.debug(
460
- "%s: Auto Trim Box data: %s", self.handler.file_name, self.crop_area
461
- )
462
442
  self.handler.crop_img_size = [rotated.shape[1], rotated.shape[0]]
463
- _LOGGER.debug(
464
- "%s: Auto Trimmed image size: %s",
465
- self.handler.file_name,
466
- self.handler.crop_img_size,
467
- )
468
443
 
469
444
  except RuntimeError as e:
470
445
  _LOGGER.warning(
@@ -250,7 +250,7 @@ class ColorsManagement:
250
250
  List[Tuple[int, int, int, int]]: List of RGBA colors with alpha channel added.
251
251
  """
252
252
  if len(alpha_channels) != len(rgb_colors):
253
- LOGGER.error("Input lists must have the same length.")
253
+ LOGGER.warning("Input lists must have the same length.")
254
254
  return []
255
255
 
256
256
  # Fast path for empty lists
@@ -357,7 +357,7 @@ class ColorsManagement:
357
357
  self.color_cache.clear()
358
358
 
359
359
  except (ValueError, IndexError, UnboundLocalError) as e:
360
- LOGGER.error("Error while populating colors: %s", e)
360
+ LOGGER.warning("Error while populating colors: %s", e)
361
361
 
362
362
  def initialize_user_colors(self, device_info: dict) -> List[Color]:
363
363
  """
@@ -11,7 +11,7 @@ Optimized with NumPy and SciPy for better performance.
11
11
  from __future__ import annotations
12
12
 
13
13
  import logging
14
- import math
14
+ from pathlib import Path
15
15
 
16
16
  import numpy as np
17
17
  from PIL import Image, ImageDraw, ImageFont
@@ -160,7 +160,7 @@ class Drawable:
160
160
 
161
161
  # Get blended colors for flag and pole
162
162
  flag_alpha = flag_color[3] if len(flag_color) == 4 else 255
163
- pole_color_base = (0, 0, 255) # Blue for the pole
163
+ pole_color_base = [0, 0, 255] # Blue for the pole
164
164
  pole_alpha = 255
165
165
 
166
166
  # Blend flag color if needed
@@ -170,7 +170,12 @@ class Drawable:
170
170
  )
171
171
 
172
172
  # Create pole color with alpha
173
- pole_color: Color = (*pole_color_base, pole_alpha)
173
+ pole_color: Color = (
174
+ pole_color_base[0],
175
+ pole_color_base[1],
176
+ pole_color_base[2],
177
+ pole_alpha,
178
+ )
174
179
 
175
180
  # Blend pole color if needed
176
181
  if pole_alpha < 255:
@@ -223,20 +228,18 @@ class Drawable:
223
228
 
224
229
  @staticmethod
225
230
  def point_inside(x: int, y: int, points: list[Tuple[int, int]]) -> bool:
226
- """
227
- Check if a point (x, y) is inside a polygon defined by a list of points.
228
- """
231
+ """Check if a point (x, y) is inside a polygon defined by a list of points."""
229
232
  n = len(points)
230
233
  inside = False
231
- xinters = 0.0
234
+ inters_x = 0.0
232
235
  p1x, p1y = points[0]
233
236
  for i in range(1, n + 1):
234
237
  p2x, p2y = points[i % n]
235
238
  if y > min(p1y, p2y):
236
239
  if y <= max(p1y, p2y) and x <= max(p1x, p2x):
237
240
  if p1y != p2y:
238
- xinters = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
239
- if p1x == p2x or x <= xinters:
241
+ inters_x = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
242
+ if p1x == p2x or x <= inters_x:
240
243
  inside = not inside
241
244
  p1x, p1y = p2x, p2y
242
245
  return inside
@@ -251,8 +254,7 @@ class Drawable:
251
254
  color: Color,
252
255
  width: int = 3,
253
256
  ) -> np.ndarray:
254
- """
255
- Draw a line on a NumPy array (layer) from point A to B using Bresenham's algorithm.
257
+ """Draw a line on a NumPy array (layer) from point A to B using Bresenham's algorithm.
256
258
 
257
259
  Args:
258
260
  layer: The numpy array to draw on (H, W, C)
@@ -283,11 +285,11 @@ class Drawable:
283
285
  x_min = max(0, x1 - half_w)
284
286
  x_max = min(w, x1 + half_w + 1)
285
287
 
286
- submask = mask[
288
+ sub_mask = mask[
287
289
  (y_min - (y1 - half_w)) : (y_max - (y1 - half_w)),
288
290
  (x_min - (x1 - half_w)) : (x_max - (x1 - half_w)),
289
291
  ]
290
- layer[y_min:y_max, x_min:x_max][submask] = blended_color
292
+ layer[y_min:y_max, x_min:x_max][sub_mask] = blended_color
291
293
 
292
294
  if x1 == x2 and y1 == y2:
293
295
  break
@@ -317,12 +319,14 @@ class Drawable:
317
319
  return layer
318
320
 
319
321
  @staticmethod
320
- async def lines(arr: NumpyArray, coords, width: int, color: Color) -> NumpyArray:
322
+ async def lines(
323
+ arr: NumpyArray, coordinates, width: int, color: Color
324
+ ) -> NumpyArray:
321
325
  """
322
326
  Join the coordinates creating a continuous line (path).
323
327
  Optimized with vectorized operations for better performance.
324
328
  """
325
- for coord in coords:
329
+ for coord in coordinates:
326
330
  x0, y0 = coord[0]
327
331
  try:
328
332
  x1, y1 = coord[1]
@@ -466,9 +470,6 @@ class Drawable:
466
470
  # Adjust points to the mask's coordinate system
467
471
  adjusted_points = [(p[0] - min_x, p[1] - min_y) for p in points]
468
472
 
469
- # Create a grid of coordinates and use it to test all points at once
470
- y_indices, x_indices = np.mgrid[0 : mask.shape[0], 0 : mask.shape[1]]
471
-
472
473
  # Test each point in the grid
473
474
  for i in range(mask.shape[0]):
474
475
  for j in range(mask.shape[1]):
@@ -545,68 +546,63 @@ class Drawable:
545
546
  angle: float,
546
547
  fill: Color,
547
548
  robot_state: str | None = None,
549
+ radius: int = 25, # user-configurable
548
550
  ) -> NumpyArray:
549
551
  """
550
- Draw the robot on a smaller array to reduce memory cost.
551
- Optimized with NumPy vectorized operations for better performance.
552
+ Draw the robot with configurable size. All elements scale with radius.
552
553
  """
553
- # Ensure coordinates are within bounds
554
+ # Minimum radius to keep things visible
555
+ radius = max(8, min(radius, 25))
556
+
554
557
  height, width = layers.shape[:2]
555
558
  if not (0 <= x < width and 0 <= y < height):
556
559
  return layers
557
560
 
558
- # Calculate the bounding box for the robot
559
- radius = 25
560
- box_size = radius * 2 + 2 # Add a small margin
561
-
562
- # Calculate the region to draw on
561
+ # Bounding box
563
562
  top_left_x = max(0, x - radius - 1)
564
563
  top_left_y = max(0, y - radius - 1)
565
564
  bottom_right_x = min(width, x + radius + 1)
566
565
  bottom_right_y = min(height, y + radius + 1)
567
566
 
568
- # Skip if the robot is completely outside the image
569
567
  if top_left_x >= bottom_right_x or top_left_y >= bottom_right_y:
570
568
  return layers
571
569
 
572
- # Create a temporary layer for the robot
573
570
  tmp_width = bottom_right_x - top_left_x
574
571
  tmp_height = bottom_right_y - top_left_y
575
572
  tmp_layer = layers[top_left_y:bottom_right_y, top_left_x:bottom_right_x].copy()
576
573
 
577
- # Calculate the robot center in the temporary layer
578
574
  tmp_x = x - top_left_x
579
575
  tmp_y = y - top_left_y
580
576
 
581
- # Calculate robot parameters
582
- r_scaled = radius // 11
583
- r_cover = r_scaled * 12
577
+ # All geometry proportional to radius
578
+ r_scaled: float = max(1.0, radius / 11.0)
579
+ r_cover = int(r_scaled * 10)
580
+ r_lidar = max(1, int(r_scaled * 3))
581
+ r_button = max(1, int(r_scaled * 1))
582
+ lidar_offset = int(radius * 0.6) # was fixed 15
583
+ button_offset = int(radius * 0.8) # was fixed 20
584
+
584
585
  lidar_angle = np.deg2rad(angle + 90)
585
- r_lidar = r_scaled * 3
586
- r_button = r_scaled * 1
587
586
 
588
- # Set colors based on robot state
589
587
  if robot_state == "error":
590
588
  outline = Drawable.ERROR_OUTLINE
591
589
  fill = Drawable.ERROR_COLOR
592
590
  else:
593
591
  outline = (fill[0] // 2, fill[1] // 2, fill[2] // 2, fill[3])
594
592
 
595
- # Draw the main robot body
593
+ # Body
596
594
  tmp_layer = Drawable._filled_circle(
597
595
  tmp_layer, (tmp_y, tmp_x), radius, fill, outline, 1
598
596
  )
599
597
 
600
- # Draw the robot direction indicator
598
+ # Direction wedge
601
599
  angle -= 90
602
- a1 = ((angle + 90) - 80) / 180 * math.pi
603
- a2 = ((angle + 90) + 80) / 180 * math.pi
604
- x1 = int(tmp_x - r_cover * math.sin(a1))
605
- y1 = int(tmp_y + r_cover * math.cos(a1))
606
- x2 = int(tmp_x - r_cover * math.sin(a2))
607
- y2 = int(tmp_y + r_cover * math.cos(a2))
608
-
609
- # Draw the direction line
600
+ a1 = np.deg2rad((angle + 90) - 80)
601
+ a2 = np.deg2rad((angle + 90) + 80)
602
+ x1 = int(tmp_x - r_cover * np.sin(a1))
603
+ y1 = int(tmp_y + r_cover * np.cos(a1))
604
+ x2 = int(tmp_x - r_cover * np.sin(a2))
605
+ y2 = int(tmp_y + r_cover * np.cos(a2))
610
606
  if (
611
607
  0 <= x1 < tmp_width
612
608
  and 0 <= y1 < tmp_height
@@ -615,25 +611,23 @@ class Drawable:
615
611
  ):
616
612
  tmp_layer = Drawable._line(tmp_layer, x1, y1, x2, y2, outline, width=1)
617
613
 
618
- # Draw the lidar indicator
619
- lidar_x = int(tmp_x + 15 * np.cos(lidar_angle))
620
- lidar_y = int(tmp_y + 15 * np.sin(lidar_angle))
614
+ # Lidar
615
+ lidar_x = int(tmp_x + lidar_offset * np.cos(lidar_angle))
616
+ lidar_y = int(tmp_y + lidar_offset * np.sin(lidar_angle))
621
617
  if 0 <= lidar_x < tmp_width and 0 <= lidar_y < tmp_height:
622
618
  tmp_layer = Drawable._filled_circle(
623
619
  tmp_layer, (lidar_y, lidar_x), r_lidar, outline
624
620
  )
625
621
 
626
- # Draw the button indicator
627
- butt_x = int(tmp_x - 20 * np.cos(lidar_angle))
628
- butt_y = int(tmp_y - 20 * np.sin(lidar_angle))
622
+ # Button
623
+ butt_x = int(tmp_x - button_offset * np.cos(lidar_angle))
624
+ butt_y = int(tmp_y - button_offset * np.sin(lidar_angle))
629
625
  if 0 <= butt_x < tmp_width and 0 <= butt_y < tmp_height:
630
626
  tmp_layer = Drawable._filled_circle(
631
627
  tmp_layer, (butt_y, butt_x), r_button, outline
632
628
  )
633
629
 
634
- # Copy the robot layer back to the main layer
635
630
  layers[top_left_y:bottom_right_y, top_left_x:bottom_right_x] = tmp_layer
636
-
637
631
  return layers
638
632
 
639
633
  @staticmethod
@@ -781,11 +775,11 @@ class Drawable:
781
775
  continue
782
776
 
783
777
  t = np.linspace(0, 1, length * 2)
784
- x_coords = np.round(x1 * (1 - t) + x2 * t).astype(int)
785
- y_coords = np.round(y1 * (1 - t) + y2 * t).astype(int)
778
+ x_coordinates = np.round(x1 * (1 - t) + x2 * t).astype(int)
779
+ y_coordinates = np.round(y1 * (1 - t) + y2 * t).astype(int)
786
780
 
787
781
  # Add line points to mask
788
- for x, y in zip(x_coords, y_coords):
782
+ for x, y in zip(x_coordinates, y_coordinates):
789
783
  if width == 1:
790
784
  mask[y, x] = True
791
785
  else:
@@ -827,7 +821,6 @@ class Drawable:
827
821
 
828
822
  # Precompute circular mask for radius
829
823
  radius = 6
830
- diameter = radius * 2 + 1
831
824
  yy, xx = np.ogrid[-radius : radius + 1, -radius : radius + 1]
832
825
  circle_mask = (xx**2 + yy**2) <= radius**2
833
826
 
@@ -882,11 +875,25 @@ class Drawable:
882
875
  position: bool,
883
876
  ) -> None:
884
877
  """Draw the status text on the image."""
885
- path_default_font = (
886
- "custom_components/mqtt_vacuum_camera/utils/fonts/FiraSans.ttf"
887
- )
888
- default_font = ImageFont.truetype(path_default_font, size)
889
- user_font = ImageFont.truetype(path_font, size)
878
+ module_dir = Path(__file__).resolve().parent
879
+ default_font_path = module_dir / "fonts" / "FiraSans.ttf"
880
+ # Load default font with safety fallback to PIL's built-in if missing
881
+ try:
882
+ default_font = ImageFont.truetype(str(default_font_path), size)
883
+ except OSError:
884
+ _LOGGER.warning(
885
+ "Default font not found at %s; using PIL default font",
886
+ default_font_path,
887
+ )
888
+ default_font = ImageFont.load_default()
889
+
890
+ # Use provided font directly if available; else fall back to default
891
+ user_font = default_font
892
+ if path_font:
893
+ try:
894
+ user_font = ImageFont.truetype(str(path_font), size)
895
+ except OSError:
896
+ user_font = default_font
890
897
  if position:
891
898
  x, y = 10, 10
892
899
  else:
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Class Camera Shared.
3
3
  Keep the data between the modules.
4
- Version: v0.1.9
4
+ Version: v0.1.10
5
5
  """
6
6
 
7
7
  import asyncio
@@ -58,6 +58,7 @@ class CameraShared:
58
58
  self.frame_number: int = 0 # camera Frame number
59
59
  self.destinations: list = [] # MQTT rand destinations
60
60
  self.rand256_active_zone: list = [] # Active zone for rand256
61
+ self.rand256_zone_coordinates: list = [] # Active zone coordinates for rand256
61
62
  self.is_rand: bool = False # MQTT rand data
62
63
  self._new_mqtt_message = False # New MQTT message
63
64
  # Initialize last_image with default gray image (250x150 minimum)
@@ -69,6 +70,7 @@ class CameraShared:
69
70
  self.image_last_updated: float = 0.0 # Last image update time
70
71
  self.image_format = "image/pil" # Image format
71
72
  self.image_size = None # Image size
73
+ self.robot_size = None # Robot size
72
74
  self.image_auto_zoom: bool = False # Auto zoom image
73
75
  self.image_zoom_lock_ratio: bool = True # Zoom lock ratio
74
76
  self.image_ref_height: int = 0 # Image reference height
@@ -81,8 +83,7 @@ class CameraShared:
81
83
  self.user_colors = Colors # User base colors
82
84
  self.rooms_colors = Colors # Rooms colors
83
85
  self.vacuum_battery = 0 # Vacuum battery state
84
- self.vacuum_bat_charged: bool = True # Vacuum charged and ready
85
- self.vacuum_connection = None # Vacuum connection state
86
+ self.vacuum_connection = False # Vacuum connection state
86
87
  self.vacuum_state = None # Vacuum state
87
88
  self.charger_position = None # Vacuum Charger position
88
89
  self.show_vacuum_state = None # Show vacuum state on the map
@@ -197,14 +198,13 @@ class CameraShared:
197
198
  attrs = {
198
199
  ATTR_CAMERA_MODE: self.camera_mode,
199
200
  ATTR_VACUUM_BATTERY: f"{self.vacuum_battery}%",
200
- ATTR_VACUUM_CHARGING: self.vacuum_bat_charged,
201
+ ATTR_VACUUM_CHARGING: self.vacuum_bat_charged(),
201
202
  ATTR_VACUUM_POSITION: self.current_room,
202
203
  ATTR_VACUUM_STATUS: self.vacuum_state,
203
204
  ATTR_VACUUM_JSON_ID: self.vac_json_id,
204
205
  ATTR_CALIBRATION_POINTS: self.attr_calibration_points,
205
206
  }
206
207
  if self.obstacles_pos and self.vacuum_ips:
207
- _LOGGER.debug("Generating obstacle links from: %s", self.obstacles_pos)
208
208
  self.obstacles_data = self._compose_obstacle_links(
209
209
  self.vacuum_ips, self.obstacles_pos
210
210
  )
@@ -302,19 +302,20 @@ class CameraSharedManager:
302
302
  )
303
303
  # Ensure trims are updated correctly
304
304
  trim_data = device_info.get("trims_data", DEFAULT_VALUES["trims_data"])
305
- _LOGGER.debug(
306
- "%s: Updating shared trims with: %s", instance.file_name, trim_data
307
- )
308
305
  instance.trims = TrimsData.from_dict(trim_data)
306
+ # Robot size
307
+ instance.robot_size = device_info.get("robot_size", 25)
309
308
 
310
309
  except TypeError as ex:
311
- _LOGGER.error("Shared data can't be initialized due to a TypeError! %s", ex)
310
+ _LOGGER.warning(
311
+ "Shared data can't be initialized due to a TypeError! %s", ex
312
+ )
312
313
  except AttributeError as ex:
313
- _LOGGER.error(
314
+ _LOGGER.warning(
314
315
  "Shared data can't be initialized due to an AttributeError! %s", ex
315
316
  )
316
317
  except RuntimeError as ex:
317
- _LOGGER.error(
318
+ _LOGGER.warning(
318
319
  "An unexpected error occurred while initializing shared data %s:", ex
319
320
  )
320
321
 
@@ -0,0 +1,95 @@
1
+ """
2
+ Version: 0.1.10
3
+ Status text of the vacuum cleaners.
4
+ Class to handle the status text of the vacuum cleaners.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ..types import LOGGER, PilPNG
10
+ from .translations import translations
11
+
12
+ LOGGER.propagate = True
13
+
14
+
15
+ class StatusText:
16
+ """
17
+ Status text of the vacuum cleaners.
18
+ """
19
+
20
+ def __init__(self, camera_shared):
21
+ self._shared = camera_shared
22
+ self.file_name = self._shared.file_name
23
+
24
+ @staticmethod
25
+ async def get_vacuum_status_translation(
26
+ language: str = "en",
27
+ ) -> dict[str, str] | None:
28
+ """
29
+ Get the vacuum status translation.
30
+ @param language: Language code, default 'en'.
31
+ @return: Mapping for the given language or None.
32
+ """
33
+ return translations.get((language or "en").lower())
34
+
35
+ async def translate_vacuum_status(self) -> str:
36
+ """Return the translated status with EN fallback and safe default."""
37
+ status = self._shared.vacuum_state or "unknown"
38
+ language = (self._shared.user_language or "en").lower()
39
+ translation = await self.get_vacuum_status_translation(language)
40
+ if not translation:
41
+ translation = translations.get("en", {})
42
+ return translation.get(status, str(status).capitalize())
43
+
44
+ async def get_status_text(self, text_img: PilPNG) -> tuple[list[str], int]:
45
+ """
46
+ Compose the image status text.
47
+ :param text_img: Image to draw the text on.
48
+ :return status_text, text_size: List of the status text and the text size.
49
+ """
50
+ status_text = ["If you read me, something really went wrong.."] # default text
51
+ text_size_coverage = 1.5 # resize factor for the text
52
+ text_size = self._shared.vacuum_status_size # default text size
53
+ charge_level = "\u03de" # unicode Koppa symbol
54
+ charging = "\u2211" # unicode Charging symbol
55
+ vacuum_state = await self.translate_vacuum_status()
56
+ if self._shared.show_vacuum_state:
57
+ status_text = [f"{self.file_name}: {vacuum_state}"]
58
+ language = (self._shared.user_language or "en").lower()
59
+ lang_map = translations.get(language) or translations.get("en", {})
60
+ if not self._shared.vacuum_connection:
61
+ mqtt_disc = lang_map.get(
62
+ "mqtt_disconnected",
63
+ translations.get("en", {}).get(
64
+ "mqtt_disconnected", "Disconnected from MQTT?"
65
+ ),
66
+ )
67
+ status_text = [f"{self.file_name}: {mqtt_disc}"]
68
+ else:
69
+ if self._shared.current_room:
70
+ in_room = self._shared.current_room.get("in_room")
71
+ if in_room:
72
+ status_text.append(f" ({in_room})")
73
+ if self._shared.vacuum_state == "docked":
74
+ if self._shared.vacuum_bat_charged():
75
+ status_text.append(" \u00b7 ")
76
+ status_text.append(f"{charging}{charge_level} ")
77
+ status_text.append(f"{self._shared.vacuum_battery}%")
78
+ else:
79
+ status_text.append(" \u00b7 ")
80
+ status_text.append(f"{charge_level} ")
81
+ ready_txt = lang_map.get(
82
+ "ready",
83
+ translations.get("en", {}).get("ready", "Ready."),
84
+ )
85
+ status_text.append(ready_txt)
86
+ else:
87
+ status_text.append(" \u00b7 ")
88
+ status_text.append(f"{charge_level}")
89
+ status_text.append(f" {self._shared.vacuum_battery}%")
90
+ if text_size >= 50 and getattr(text_img, "width", None):
91
+ text_pixels = max(1, sum(len(text) for text in status_text))
92
+ text_size = int(
93
+ (text_size_coverage * text_img.width) // text_pixels
94
+ )
95
+ return status_text, text_size