valetudo-map-parser 0.1.11b2__py3-none-any.whl → 0.1.12b0__py3-none-any.whl
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.
- valetudo_map_parser/__init__.py +124 -15
- valetudo_map_parser/config/async_utils.py +1 -1
- valetudo_map_parser/config/colors.py +2 -419
- valetudo_map_parser/config/drawable.py +10 -73
- valetudo_map_parser/config/rand256_parser.py +129 -111
- valetudo_map_parser/config/shared.py +15 -12
- valetudo_map_parser/config/status_text/__init__.py +6 -0
- valetudo_map_parser/config/status_text/status_text.py +74 -49
- valetudo_map_parser/config/types.py +74 -408
- valetudo_map_parser/config/utils.py +99 -73
- valetudo_map_parser/const.py +288 -0
- valetudo_map_parser/hypfer_draw.py +121 -150
- valetudo_map_parser/hypfer_handler.py +210 -183
- valetudo_map_parser/map_data.py +31 -0
- valetudo_map_parser/rand256_handler.py +207 -218
- valetudo_map_parser/rooms_handler.py +4 -5
- {valetudo_map_parser-0.1.11b2.dist-info → valetudo_map_parser-0.1.12b0.dist-info}/METADATA +1 -1
- valetudo_map_parser-0.1.12b0.dist-info/RECORD +34 -0
- valetudo_map_parser-0.1.11b2.dist-info/RECORD +0 -32
- {valetudo_map_parser-0.1.11b2.dist-info → valetudo_map_parser-0.1.12b0.dist-info}/WHEEL +0 -0
- {valetudo_map_parser-0.1.11b2.dist-info → valetudo_map_parser-0.1.12b0.dist-info}/licenses/LICENSE +0 -0
- {valetudo_map_parser-0.1.11b2.dist-info → valetudo_map_parser-0.1.12b0.dist-info}/licenses/NOTICE.txt +0 -0
|
@@ -12,13 +12,14 @@ from __future__ import annotations
|
|
|
12
12
|
|
|
13
13
|
import logging
|
|
14
14
|
from pathlib import Path
|
|
15
|
+
from typing import Union
|
|
15
16
|
|
|
16
17
|
import numpy as np
|
|
17
18
|
from mvcrender.blend import get_blended_color, sample_and_blend_color
|
|
18
19
|
from mvcrender.draw import circle_u8, line_u8, polygon_u8
|
|
19
|
-
from PIL import
|
|
20
|
+
from PIL import ImageDraw, ImageFont
|
|
20
21
|
|
|
21
|
-
from .types import Color, NumpyArray, PilPNG, Point
|
|
22
|
+
from .types import Color, NumpyArray, PilPNG, Point
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
_LOGGER = logging.getLogger(__name__)
|
|
@@ -204,24 +205,6 @@ class Drawable:
|
|
|
204
205
|
layer = Drawable._line(layer, xp1, yp1, xp2, yp2, pole_color, pole_width)
|
|
205
206
|
return layer
|
|
206
207
|
|
|
207
|
-
@staticmethod
|
|
208
|
-
def point_inside(x: int, y: int, points: list[Tuple[int, int]]) -> bool:
|
|
209
|
-
"""Check if a point (x, y) is inside a polygon defined by a list of points."""
|
|
210
|
-
n = len(points)
|
|
211
|
-
inside = False
|
|
212
|
-
inters_x = 0.0
|
|
213
|
-
p1x, p1y = points[0]
|
|
214
|
-
for i in range(1, n + 1):
|
|
215
|
-
p2x, p2y = points[i % n]
|
|
216
|
-
if y > min(p1y, p2y):
|
|
217
|
-
if y <= max(p1y, p2y) and x <= max(p1x, p2x):
|
|
218
|
-
if p1y != p2y:
|
|
219
|
-
inters_x = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
|
|
220
|
-
if p1x == p2x or x <= inters_x:
|
|
221
|
-
inside = not inside
|
|
222
|
-
p1x, p1y = p2x, p2y
|
|
223
|
-
return inside
|
|
224
|
-
|
|
225
208
|
@staticmethod
|
|
226
209
|
def _line(
|
|
227
210
|
layer: NumpyArray,
|
|
@@ -328,56 +311,6 @@ class Drawable:
|
|
|
328
311
|
image[y1:y2, x1:x2] = color
|
|
329
312
|
return image
|
|
330
313
|
|
|
331
|
-
@staticmethod
|
|
332
|
-
def _polygon_outline(
|
|
333
|
-
arr: NumpyArray,
|
|
334
|
-
points: list[Tuple[int, int]],
|
|
335
|
-
width: int,
|
|
336
|
-
outline_color: Color,
|
|
337
|
-
fill_color: Color = None,
|
|
338
|
-
) -> NumpyArray:
|
|
339
|
-
"""
|
|
340
|
-
Draw the outline of a polygon on the array using _line, and optionally fill it.
|
|
341
|
-
Uses NumPy vectorized operations for improved performance.
|
|
342
|
-
"""
|
|
343
|
-
# Draw the outline
|
|
344
|
-
for i, _ in enumerate(points):
|
|
345
|
-
current_point = points[i]
|
|
346
|
-
next_point = points[(i + 1) % len(points)]
|
|
347
|
-
arr = Drawable._line(
|
|
348
|
-
arr,
|
|
349
|
-
current_point[0],
|
|
350
|
-
current_point[1],
|
|
351
|
-
next_point[0],
|
|
352
|
-
next_point[1],
|
|
353
|
-
outline_color,
|
|
354
|
-
width,
|
|
355
|
-
)
|
|
356
|
-
|
|
357
|
-
# Fill the polygon if a fill color is provided
|
|
358
|
-
if fill_color is not None:
|
|
359
|
-
# Get the bounding box of the polygon
|
|
360
|
-
min_x = max(0, min(p[0] for p in points))
|
|
361
|
-
max_x = min(arr.shape[1] - 1, max(p[0] for p in points))
|
|
362
|
-
min_y = max(0, min(p[1] for p in points))
|
|
363
|
-
max_y = min(arr.shape[0] - 1, max(p[1] for p in points))
|
|
364
|
-
|
|
365
|
-
# Create a mask for the polygon region
|
|
366
|
-
mask = np.zeros((max_y - min_y + 1, max_x - min_x + 1), dtype=bool)
|
|
367
|
-
|
|
368
|
-
# Adjust points to the mask's coordinate system
|
|
369
|
-
adjusted_points = [(p[0] - min_x, p[1] - min_y) for p in points]
|
|
370
|
-
|
|
371
|
-
# Test each point in the grid
|
|
372
|
-
for i in range(mask.shape[0]):
|
|
373
|
-
for j in range(mask.shape[1]):
|
|
374
|
-
mask[i, j] = Drawable.point_inside(j, i, adjusted_points)
|
|
375
|
-
|
|
376
|
-
# Apply the fill color to the masked region
|
|
377
|
-
arr[min_y : max_y + 1, min_x : max_x + 1][mask] = fill_color
|
|
378
|
-
|
|
379
|
-
return arr
|
|
380
|
-
|
|
381
314
|
@staticmethod
|
|
382
315
|
async def zones(layers: NumpyArray, coordinates, color: Color) -> NumpyArray:
|
|
383
316
|
"""
|
|
@@ -419,14 +352,18 @@ class Drawable:
|
|
|
419
352
|
mask_rgba = np.zeros((box_h, box_w, 4), dtype=np.uint8)
|
|
420
353
|
|
|
421
354
|
# Convert points to xs, ys arrays (adjusted to local bbox coordinates)
|
|
422
|
-
xs = np.array(
|
|
423
|
-
|
|
355
|
+
xs = np.array(
|
|
356
|
+
[int(pts[i] - min_x) for i in range(0, len(pts), 2)], dtype=np.int32
|
|
357
|
+
)
|
|
358
|
+
ys = np.array(
|
|
359
|
+
[int(pts[i] - min_y) for i in range(1, len(pts), 2)], dtype=np.int32
|
|
360
|
+
)
|
|
424
361
|
|
|
425
362
|
# Draw filled polygon on mask
|
|
426
363
|
polygon_u8(mask_rgba, xs, ys, (0, 0, 0, 0), 0, (255, 255, 255, 255))
|
|
427
364
|
|
|
428
365
|
# Extract boolean mask from first channel
|
|
429
|
-
zone_mask =
|
|
366
|
+
zone_mask = mask_rgba[:, :, 0] > 0
|
|
430
367
|
del mask_rgba
|
|
431
368
|
del xs
|
|
432
369
|
del ys
|
|
@@ -281,6 +281,49 @@ class RRMapParser:
|
|
|
281
281
|
break
|
|
282
282
|
return blocks
|
|
283
283
|
|
|
284
|
+
def _process_image_pixels(
|
|
285
|
+
self,
|
|
286
|
+
buf: bytes,
|
|
287
|
+
offset: int,
|
|
288
|
+
g3offset: int,
|
|
289
|
+
length: int,
|
|
290
|
+
pixels: bool,
|
|
291
|
+
parameters: Dict[str, Any],
|
|
292
|
+
) -> None:
|
|
293
|
+
"""Process image pixels sequentially - segments are organized as blocks."""
|
|
294
|
+
current_segments = {}
|
|
295
|
+
|
|
296
|
+
for i in range(length):
|
|
297
|
+
pixel_byte = struct.unpack(
|
|
298
|
+
"<B",
|
|
299
|
+
buf[offset + 24 + g3offset + i : offset + 25 + g3offset + i],
|
|
300
|
+
)[0]
|
|
301
|
+
|
|
302
|
+
segment_type = pixel_byte & 0x07
|
|
303
|
+
if segment_type == 0:
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
if segment_type == 1 and pixels:
|
|
307
|
+
# Wall pixel
|
|
308
|
+
parameters["pixels"]["walls"].append(i)
|
|
309
|
+
else:
|
|
310
|
+
# Floor or room segment
|
|
311
|
+
segment_id = pixel_byte >> 3
|
|
312
|
+
if segment_id == 0 and pixels:
|
|
313
|
+
# Floor pixel
|
|
314
|
+
parameters["pixels"]["floor"].append(i)
|
|
315
|
+
elif segment_id != 0:
|
|
316
|
+
# Room segment - segments are sequential blocks
|
|
317
|
+
if segment_id not in current_segments:
|
|
318
|
+
parameters["segments"]["id"].append(segment_id)
|
|
319
|
+
parameters["segments"]["pixels_seg_" + str(segment_id)] = []
|
|
320
|
+
current_segments[segment_id] = True
|
|
321
|
+
|
|
322
|
+
if pixels:
|
|
323
|
+
parameters["segments"]["pixels_seg_" + str(segment_id)].append(
|
|
324
|
+
i
|
|
325
|
+
)
|
|
326
|
+
|
|
284
327
|
def _parse_image_block(
|
|
285
328
|
self, buf: bytes, offset: int, length: int, hlength: int, pixels: bool = True
|
|
286
329
|
) -> Dict[str, Any]:
|
|
@@ -330,41 +373,9 @@ class RRMapParser:
|
|
|
330
373
|
parameters["dimensions"]["height"] > 0
|
|
331
374
|
and parameters["dimensions"]["width"] > 0
|
|
332
375
|
):
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
for i in range(length):
|
|
337
|
-
pixel_byte = struct.unpack(
|
|
338
|
-
"<B",
|
|
339
|
-
buf[offset + 24 + g3offset + i : offset + 25 + g3offset + i],
|
|
340
|
-
)[0]
|
|
341
|
-
|
|
342
|
-
segment_type = pixel_byte & 0x07
|
|
343
|
-
if segment_type == 0:
|
|
344
|
-
continue
|
|
345
|
-
|
|
346
|
-
if segment_type == 1 and pixels:
|
|
347
|
-
# Wall pixel
|
|
348
|
-
parameters["pixels"]["walls"].append(i)
|
|
349
|
-
else:
|
|
350
|
-
# Floor or room segment
|
|
351
|
-
segment_id = pixel_byte >> 3
|
|
352
|
-
if segment_id == 0 and pixels:
|
|
353
|
-
# Floor pixel
|
|
354
|
-
parameters["pixels"]["floor"].append(i)
|
|
355
|
-
elif segment_id != 0:
|
|
356
|
-
# Room segment - segments are sequential blocks
|
|
357
|
-
if segment_id not in current_segments:
|
|
358
|
-
parameters["segments"]["id"].append(segment_id)
|
|
359
|
-
parameters["segments"][
|
|
360
|
-
"pixels_seg_" + str(segment_id)
|
|
361
|
-
] = []
|
|
362
|
-
current_segments[segment_id] = True
|
|
363
|
-
|
|
364
|
-
if pixels:
|
|
365
|
-
parameters["segments"][
|
|
366
|
-
"pixels_seg_" + str(segment_id)
|
|
367
|
-
].append(i)
|
|
376
|
+
self._process_image_pixels(
|
|
377
|
+
buf, offset, g3offset, length, pixels, parameters
|
|
378
|
+
)
|
|
368
379
|
|
|
369
380
|
parameters["segments"]["count"] = len(parameters["segments"]["id"])
|
|
370
381
|
return parameters
|
|
@@ -377,6 +388,79 @@ class RRMapParser:
|
|
|
377
388
|
"pixels": {"floor": [], "walls": [], "segments": {}},
|
|
378
389
|
}
|
|
379
390
|
|
|
391
|
+
def _calculate_angle_from_points(self, points: list) -> Optional[float]:
|
|
392
|
+
"""Calculate angle from last two points in a path."""
|
|
393
|
+
if len(points) >= 2:
|
|
394
|
+
last_point = points[-1]
|
|
395
|
+
second_last = points[-2]
|
|
396
|
+
dx = last_point[0] - second_last[0]
|
|
397
|
+
dy = last_point[1] - second_last[1]
|
|
398
|
+
if dx != 0 or dy != 0:
|
|
399
|
+
angle_rad = math.atan2(dy, dx)
|
|
400
|
+
return math.degrees(angle_rad)
|
|
401
|
+
return None
|
|
402
|
+
|
|
403
|
+
def _transform_path_coordinates(self, points: list) -> list:
|
|
404
|
+
"""Apply coordinate transformation to path points."""
|
|
405
|
+
return [[point[0], self.Tools.DIMENSION_MM - point[1]] for point in points]
|
|
406
|
+
|
|
407
|
+
def _parse_path_data(self, blocks: dict, parsed_map_data: dict) -> list:
|
|
408
|
+
"""Parse path data with coordinate transformation."""
|
|
409
|
+
transformed_path_points = []
|
|
410
|
+
if self.Types.PATH.value in blocks:
|
|
411
|
+
path_data = blocks[self.Types.PATH.value].copy()
|
|
412
|
+
transformed_path_points = self._transform_path_coordinates(
|
|
413
|
+
path_data["points"]
|
|
414
|
+
)
|
|
415
|
+
path_data["points"] = transformed_path_points
|
|
416
|
+
|
|
417
|
+
angle = self._calculate_angle_from_points(transformed_path_points)
|
|
418
|
+
if angle is not None:
|
|
419
|
+
path_data["current_angle"] = angle
|
|
420
|
+
parsed_map_data["path"] = path_data
|
|
421
|
+
return transformed_path_points
|
|
422
|
+
|
|
423
|
+
def _parse_goto_path_data(self, blocks: dict, parsed_map_data: dict) -> None:
|
|
424
|
+
"""Parse goto predicted path with coordinate transformation."""
|
|
425
|
+
if self.Types.GOTO_PREDICTED_PATH.value in blocks:
|
|
426
|
+
goto_path_data = blocks[self.Types.GOTO_PREDICTED_PATH.value].copy()
|
|
427
|
+
goto_path_data["points"] = self._transform_path_coordinates(
|
|
428
|
+
goto_path_data["points"]
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
angle = self._calculate_angle_from_points(goto_path_data["points"])
|
|
432
|
+
if angle is not None:
|
|
433
|
+
goto_path_data["current_angle"] = angle
|
|
434
|
+
parsed_map_data["goto_predicted_path"] = goto_path_data
|
|
435
|
+
|
|
436
|
+
def _add_zone_data(self, blocks: dict, parsed_map_data: dict) -> None:
|
|
437
|
+
"""Add zone and area data to parsed map."""
|
|
438
|
+
parsed_map_data["currently_cleaned_zones"] = (
|
|
439
|
+
blocks[self.Types.CURRENTLY_CLEANED_ZONES.value]["zones"]
|
|
440
|
+
if self.Types.CURRENTLY_CLEANED_ZONES.value in blocks
|
|
441
|
+
else []
|
|
442
|
+
)
|
|
443
|
+
parsed_map_data["forbidden_zones"] = (
|
|
444
|
+
blocks[self.Types.FORBIDDEN_ZONES.value]["forbidden_zones"]
|
|
445
|
+
if self.Types.FORBIDDEN_ZONES.value in blocks
|
|
446
|
+
else []
|
|
447
|
+
)
|
|
448
|
+
parsed_map_data["forbidden_mop_zones"] = (
|
|
449
|
+
blocks[self.Types.FORBIDDEN_MOP_ZONES.value]["forbidden_mop_zones"]
|
|
450
|
+
if self.Types.FORBIDDEN_MOP_ZONES.value in blocks
|
|
451
|
+
else []
|
|
452
|
+
)
|
|
453
|
+
parsed_map_data["virtual_walls"] = (
|
|
454
|
+
blocks[self.Types.VIRTUAL_WALLS.value]["virtual_walls"]
|
|
455
|
+
if self.Types.VIRTUAL_WALLS.value in blocks
|
|
456
|
+
else []
|
|
457
|
+
)
|
|
458
|
+
parsed_map_data["carpet_areas"] = (
|
|
459
|
+
blocks[self.Types.CARPET_MAP.value]["carpet_map"]
|
|
460
|
+
if self.Types.CARPET_MAP.value in blocks
|
|
461
|
+
else []
|
|
462
|
+
)
|
|
463
|
+
|
|
380
464
|
def parse_rrm_data(
|
|
381
465
|
self, map_buf: bytes, pixels: bool = False
|
|
382
466
|
) -> Optional[Dict[str, Any]]:
|
|
@@ -393,39 +477,14 @@ class RRMapParser:
|
|
|
393
477
|
robot_data = blocks[self.Types.ROBOT_POSITION.value]
|
|
394
478
|
parsed_map_data["robot"] = robot_data["position"]
|
|
395
479
|
|
|
396
|
-
# Parse path data with coordinate transformation
|
|
397
|
-
transformed_path_points =
|
|
398
|
-
if self.Types.PATH.value in blocks:
|
|
399
|
-
path_data = blocks[self.Types.PATH.value].copy()
|
|
400
|
-
# Apply coordinate transformation like current parser
|
|
401
|
-
transformed_path_points = [
|
|
402
|
-
[point[0], self.Tools.DIMENSION_MM - point[1]]
|
|
403
|
-
for point in path_data["points"]
|
|
404
|
-
]
|
|
405
|
-
path_data["points"] = transformed_path_points
|
|
406
|
-
|
|
407
|
-
# Calculate current angle from transformed points
|
|
408
|
-
if len(transformed_path_points) >= 2:
|
|
409
|
-
last_point = transformed_path_points[-1]
|
|
410
|
-
second_last = transformed_path_points[-2]
|
|
411
|
-
dx = last_point[0] - second_last[0]
|
|
412
|
-
dy = last_point[1] - second_last[1]
|
|
413
|
-
if dx != 0 or dy != 0:
|
|
414
|
-
angle_rad = math.atan2(dy, dx)
|
|
415
|
-
path_data["current_angle"] = math.degrees(angle_rad)
|
|
416
|
-
parsed_map_data["path"] = path_data
|
|
417
|
-
|
|
418
|
-
# Get robot angle from TRANSFORMED path data (like current implementation)
|
|
419
|
-
robot_angle = 0
|
|
420
|
-
if len(transformed_path_points) >= 2:
|
|
421
|
-
last_point = transformed_path_points[-1]
|
|
422
|
-
second_last = transformed_path_points[-2]
|
|
423
|
-
dx = last_point[0] - second_last[0]
|
|
424
|
-
dy = last_point[1] - second_last[1]
|
|
425
|
-
if dx != 0 or dy != 0:
|
|
426
|
-
angle_rad = math.atan2(dy, dx)
|
|
427
|
-
robot_angle = int(math.degrees(angle_rad))
|
|
480
|
+
# Parse path data with coordinate transformation
|
|
481
|
+
transformed_path_points = self._parse_path_data(blocks, parsed_map_data)
|
|
428
482
|
|
|
483
|
+
# Get robot angle from transformed path data
|
|
484
|
+
robot_angle = 0
|
|
485
|
+
angle = self._calculate_angle_from_points(transformed_path_points)
|
|
486
|
+
if angle is not None:
|
|
487
|
+
robot_angle = int(angle)
|
|
429
488
|
parsed_map_data["robot_angle"] = robot_angle
|
|
430
489
|
|
|
431
490
|
# Parse charger position
|
|
@@ -438,24 +497,7 @@ class RRMapParser:
|
|
|
438
497
|
parsed_map_data["image"] = blocks[self.Types.IMAGE.value]
|
|
439
498
|
|
|
440
499
|
# Parse goto predicted path
|
|
441
|
-
|
|
442
|
-
goto_path_data = blocks[self.Types.GOTO_PREDICTED_PATH.value].copy()
|
|
443
|
-
# Apply coordinate transformation
|
|
444
|
-
goto_path_data["points"] = [
|
|
445
|
-
[point[0], self.Tools.DIMENSION_MM - point[1]]
|
|
446
|
-
for point in goto_path_data["points"]
|
|
447
|
-
]
|
|
448
|
-
# Calculate current angle from transformed points (like working parser)
|
|
449
|
-
if len(goto_path_data["points"]) >= 2:
|
|
450
|
-
points = goto_path_data["points"]
|
|
451
|
-
last_point = points[-1]
|
|
452
|
-
second_last = points[-2]
|
|
453
|
-
dx = last_point[0] - second_last[0]
|
|
454
|
-
dy = last_point[1] - second_last[1]
|
|
455
|
-
if dx != 0 or dy != 0:
|
|
456
|
-
angle_rad = math.atan2(dy, dx)
|
|
457
|
-
goto_path_data["current_angle"] = math.degrees(angle_rad)
|
|
458
|
-
parsed_map_data["goto_predicted_path"] = goto_path_data
|
|
500
|
+
self._parse_goto_path_data(blocks, parsed_map_data)
|
|
459
501
|
|
|
460
502
|
# Parse goto target
|
|
461
503
|
if self.Types.GOTO_TARGET.value in blocks:
|
|
@@ -463,32 +505,8 @@ class RRMapParser:
|
|
|
463
505
|
"position"
|
|
464
506
|
]
|
|
465
507
|
|
|
466
|
-
# Add
|
|
467
|
-
parsed_map_data
|
|
468
|
-
blocks[self.Types.CURRENTLY_CLEANED_ZONES.value]["zones"]
|
|
469
|
-
if self.Types.CURRENTLY_CLEANED_ZONES.value in blocks
|
|
470
|
-
else []
|
|
471
|
-
)
|
|
472
|
-
parsed_map_data["forbidden_zones"] = (
|
|
473
|
-
blocks[self.Types.FORBIDDEN_ZONES.value]["forbidden_zones"]
|
|
474
|
-
if self.Types.FORBIDDEN_ZONES.value in blocks
|
|
475
|
-
else []
|
|
476
|
-
)
|
|
477
|
-
parsed_map_data["forbidden_mop_zones"] = (
|
|
478
|
-
blocks[self.Types.FORBIDDEN_MOP_ZONES.value]["forbidden_mop_zones"]
|
|
479
|
-
if self.Types.FORBIDDEN_MOP_ZONES.value in blocks
|
|
480
|
-
else []
|
|
481
|
-
)
|
|
482
|
-
parsed_map_data["virtual_walls"] = (
|
|
483
|
-
blocks[self.Types.VIRTUAL_WALLS.value]["virtual_walls"]
|
|
484
|
-
if self.Types.VIRTUAL_WALLS.value in blocks
|
|
485
|
-
else []
|
|
486
|
-
)
|
|
487
|
-
parsed_map_data["carpet_areas"] = (
|
|
488
|
-
blocks[self.Types.CARPET_MAP.value]["carpet_map"]
|
|
489
|
-
if self.Types.CARPET_MAP.value in blocks
|
|
490
|
-
else []
|
|
491
|
-
)
|
|
508
|
+
# Add zone and area data
|
|
509
|
+
self._add_zone_data(blocks, parsed_map_data)
|
|
492
510
|
parsed_map_data["is_valid"] = self.is_valid
|
|
493
511
|
|
|
494
512
|
return parsed_map_data
|
|
@@ -11,7 +11,7 @@ from typing import List
|
|
|
11
11
|
from PIL import Image
|
|
12
12
|
|
|
13
13
|
from .utils import pil_size_rotation
|
|
14
|
-
from
|
|
14
|
+
from ..const import (
|
|
15
15
|
ATTR_CALIBRATION_POINTS,
|
|
16
16
|
ATTR_CAMERA_MODE,
|
|
17
17
|
ATTR_CONTENT_TYPE,
|
|
@@ -39,8 +39,10 @@ from .types import (
|
|
|
39
39
|
CONF_VAC_STAT_POS,
|
|
40
40
|
CONF_VAC_STAT_SIZE,
|
|
41
41
|
CONF_ZOOM_LOCK_RATIO,
|
|
42
|
-
DEFAULT_VALUES,
|
|
43
42
|
NOT_STREAMING_STATES,
|
|
43
|
+
DEFAULT_VALUES,
|
|
44
|
+
)
|
|
45
|
+
from .types import (
|
|
44
46
|
CameraModes,
|
|
45
47
|
Colors,
|
|
46
48
|
PilPNG,
|
|
@@ -125,11 +127,15 @@ class CameraShared:
|
|
|
125
127
|
def vacuum_bat_charged(self) -> bool:
|
|
126
128
|
"""Check if the vacuum is charging."""
|
|
127
129
|
if self.vacuum_state != "docked":
|
|
128
|
-
|
|
129
|
-
elif (self._battery_state == "charging_done") and (
|
|
130
|
+
self._battery_state = "not_charging"
|
|
131
|
+
elif (self._battery_state == "charging_done") and (
|
|
132
|
+
int(self.vacuum_battery) == 100
|
|
133
|
+
):
|
|
130
134
|
self._battery_state = "charged"
|
|
131
135
|
else:
|
|
132
|
-
self._battery_state =
|
|
136
|
+
self._battery_state = (
|
|
137
|
+
"charging" if int(self.vacuum_battery) < 100 else "charging_done"
|
|
138
|
+
)
|
|
133
139
|
return (self.vacuum_state == "docked") and (self._battery_state == "charging")
|
|
134
140
|
|
|
135
141
|
@staticmethod
|
|
@@ -220,9 +226,9 @@ class CameraShared:
|
|
|
220
226
|
def is_streaming(self) -> bool:
|
|
221
227
|
"""Return true if the device is streaming."""
|
|
222
228
|
updated_status = self.vacuum_state
|
|
223
|
-
attr_is_streaming = (
|
|
224
|
-
|
|
225
|
-
|
|
229
|
+
attr_is_streaming = (
|
|
230
|
+
updated_status not in NOT_STREAMING_STATES or self.vacuum_bat_charged()
|
|
231
|
+
) or not self.binary_image
|
|
226
232
|
return attr_is_streaming
|
|
227
233
|
|
|
228
234
|
def to_dict(self) -> dict:
|
|
@@ -231,7 +237,7 @@ class CameraShared:
|
|
|
231
237
|
"image": {
|
|
232
238
|
"binary": self.binary_image,
|
|
233
239
|
"size": pil_size_rotation(self.image_rotate, self.new_image),
|
|
234
|
-
"streaming": self.is_streaming()
|
|
240
|
+
"streaming": self.is_streaming(),
|
|
235
241
|
},
|
|
236
242
|
"attributes": self.generate_attributes(),
|
|
237
243
|
}
|
|
@@ -249,9 +255,6 @@ class CameraSharedManager:
|
|
|
249
255
|
self.device_info = device_info
|
|
250
256
|
self.update_shared_data(device_info)
|
|
251
257
|
|
|
252
|
-
# Automatically initialize shared data for the instance
|
|
253
|
-
# self._init_shared_data(device_info)
|
|
254
|
-
|
|
255
258
|
def update_shared_data(self, device_info):
|
|
256
259
|
"""Initialize the shared data with device_info."""
|
|
257
260
|
instance = self.get_instance() # Retrieve the correct instance
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Version: 0.1.
|
|
2
|
+
Version: 0.1.12
|
|
3
3
|
Status text of the vacuum cleaners.
|
|
4
4
|
Class to handle the status text of the vacuum cleaners.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
|
+
from typing import Callable
|
|
8
9
|
|
|
10
|
+
from ...const import text_size_coverage, charge_level, charging, dot
|
|
9
11
|
from ..types import LOGGER, PilPNG
|
|
10
12
|
from .translations import translations
|
|
11
13
|
|
|
12
|
-
|
|
13
14
|
LOGGER.propagate = True
|
|
14
15
|
|
|
15
16
|
|
|
@@ -21,9 +22,18 @@ class StatusText:
|
|
|
21
22
|
def __init__(self, camera_shared):
|
|
22
23
|
self._shared = camera_shared
|
|
23
24
|
self.file_name = self._shared.file_name
|
|
25
|
+
self._language = (self._shared.user_language or "en").lower()
|
|
26
|
+
self._lang_map = translations.get(self._language) or translations.get("en", {})
|
|
27
|
+
self._compose_functions: list[Callable[[list[str]], list[str]]] = [
|
|
28
|
+
self._current_room,
|
|
29
|
+
self._docked_charged,
|
|
30
|
+
self._docked_ready,
|
|
31
|
+
self._active,
|
|
32
|
+
self._mqtt_disconnected,
|
|
33
|
+
] # static ordered sequence of compose functions
|
|
24
34
|
|
|
25
35
|
@staticmethod
|
|
26
|
-
async def
|
|
36
|
+
async def _get_vacuum_status_translation(
|
|
27
37
|
language: str = "en",
|
|
28
38
|
) -> dict[str, str] | None:
|
|
29
39
|
"""
|
|
@@ -33,64 +43,79 @@ class StatusText:
|
|
|
33
43
|
"""
|
|
34
44
|
return translations.get((language or "en").lower())
|
|
35
45
|
|
|
36
|
-
async def
|
|
46
|
+
async def _translate_vacuum_status(self) -> str:
|
|
37
47
|
"""Return the translated status with EN fallback and safe default."""
|
|
38
48
|
status = self._shared.vacuum_state or "unknown"
|
|
39
49
|
language = (self._shared.user_language or "en").lower()
|
|
40
|
-
translation = await self.
|
|
50
|
+
translation = await self._get_vacuum_status_translation(language)
|
|
41
51
|
if not translation:
|
|
42
52
|
translation = translations.get("en", {})
|
|
43
53
|
return translation.get(status, str(status).capitalize())
|
|
44
54
|
|
|
55
|
+
def _mqtt_disconnected(self, current_state: list[str]) -> list[str]:
|
|
56
|
+
"""Return the translated MQTT disconnected status."""
|
|
57
|
+
if not self._shared.vacuum_connection:
|
|
58
|
+
mqtt_disc = (self._lang_map or {}).get(
|
|
59
|
+
"mqtt_disconnected",
|
|
60
|
+
translations.get("en", {}).get(
|
|
61
|
+
"mqtt_disconnected", "Disconnected from MQTT?"
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
return [f"{self.file_name}: {mqtt_disc}"]
|
|
65
|
+
return current_state
|
|
66
|
+
|
|
67
|
+
def _docked_charged(self, current_state: list[str]) -> list[str]:
|
|
68
|
+
"""Return the translated docked and charging status."""
|
|
69
|
+
if self._shared.vacuum_state == "docked" and self._shared.vacuum_bat_charged():
|
|
70
|
+
current_state.append(dot)
|
|
71
|
+
current_state.append(f"{charging}{charge_level} ")
|
|
72
|
+
current_state.append(f"{self._shared.vacuum_battery}%")
|
|
73
|
+
return current_state
|
|
74
|
+
|
|
75
|
+
def _docked_ready(self, current_state: list[str]) -> list[str]:
|
|
76
|
+
"""Return the translated docked and ready status."""
|
|
77
|
+
if (
|
|
78
|
+
self._shared.vacuum_state == "docked"
|
|
79
|
+
and not self._shared.vacuum_bat_charged()
|
|
80
|
+
):
|
|
81
|
+
current_state.append(dot)
|
|
82
|
+
current_state.append(f"{charge_level} ")
|
|
83
|
+
ready_txt = (self._lang_map or {}).get(
|
|
84
|
+
"ready",
|
|
85
|
+
translations.get("en", {}).get("ready", "Ready."),
|
|
86
|
+
)
|
|
87
|
+
current_state.append(ready_txt)
|
|
88
|
+
return current_state
|
|
89
|
+
|
|
90
|
+
def _current_room(self, current_state: list[str]) -> list[str]:
|
|
91
|
+
"""Return the current room information."""
|
|
92
|
+
if self._shared.current_room:
|
|
93
|
+
in_room = self._shared.current_room.get("in_room")
|
|
94
|
+
if in_room and in_room != "Room 31":
|
|
95
|
+
current_state.append(f" ({in_room})")
|
|
96
|
+
return current_state
|
|
97
|
+
|
|
98
|
+
def _active(self, current_state: list[str]) -> list[str]:
|
|
99
|
+
"""Return the translated active status."""
|
|
100
|
+
if self._shared.vacuum_state != "docked":
|
|
101
|
+
current_state.append(dot)
|
|
102
|
+
current_state.append(f"{charge_level}")
|
|
103
|
+
current_state.append(f" {self._shared.vacuum_battery}%")
|
|
104
|
+
return current_state
|
|
105
|
+
|
|
45
106
|
async def get_status_text(self, text_img: PilPNG) -> tuple[list[str], int]:
|
|
46
107
|
"""
|
|
47
108
|
Compose the image status text.
|
|
48
109
|
:param text_img: Image to draw the text on.
|
|
49
110
|
:return status_text, text_size: List of the status text and the text size.
|
|
50
111
|
"""
|
|
51
|
-
status_text = ["If you read me, something really went wrong.."] # default text
|
|
52
|
-
text_size_coverage = 1.5 # resize factor for the text
|
|
53
112
|
text_size = self._shared.vacuum_status_size # default text size
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
status_text =
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
mqtt_disc = lang_map.get(
|
|
63
|
-
"mqtt_disconnected",
|
|
64
|
-
translations.get("en", {}).get(
|
|
65
|
-
"mqtt_disconnected", "Disconnected from MQTT?"
|
|
66
|
-
),
|
|
67
|
-
)
|
|
68
|
-
status_text = [f"{self.file_name}: {mqtt_disc}"]
|
|
69
|
-
else:
|
|
70
|
-
if self._shared.current_room:
|
|
71
|
-
in_room = self._shared.current_room.get("in_room")
|
|
72
|
-
if in_room:
|
|
73
|
-
status_text.append(f" ({in_room})")
|
|
74
|
-
if self._shared.vacuum_state == "docked":
|
|
75
|
-
if self._shared.vacuum_bat_charged():
|
|
76
|
-
status_text.append(" \u00b7 ")
|
|
77
|
-
status_text.append(f"{charging}{charge_level} ")
|
|
78
|
-
status_text.append(f"{self._shared.vacuum_battery}%")
|
|
79
|
-
else:
|
|
80
|
-
status_text.append(" \u00b7 ")
|
|
81
|
-
status_text.append(f"{charge_level} ")
|
|
82
|
-
ready_txt = lang_map.get(
|
|
83
|
-
"ready",
|
|
84
|
-
translations.get("en", {}).get("ready", "Ready."),
|
|
85
|
-
)
|
|
86
|
-
status_text.append(ready_txt)
|
|
87
|
-
else:
|
|
88
|
-
status_text.append(" \u00b7 ")
|
|
89
|
-
status_text.append(f"{charge_level}")
|
|
90
|
-
status_text.append(f" {self._shared.vacuum_battery}%")
|
|
91
|
-
if text_size >= 50 and getattr(text_img, "width", None):
|
|
92
|
-
text_pixels = max(1, sum(len(text) for text in status_text))
|
|
93
|
-
text_size = int(
|
|
94
|
-
(text_size_coverage * text_img.width) // text_pixels
|
|
95
|
-
)
|
|
113
|
+
vacuum_state = await self._translate_vacuum_status()
|
|
114
|
+
status_text = [f"{self.file_name}: {vacuum_state}"]
|
|
115
|
+
# Compose Status Text with available data.
|
|
116
|
+
for func in self._compose_functions:
|
|
117
|
+
status_text = func(status_text)
|
|
118
|
+
if text_size >= 50 and getattr(text_img, "width", None):
|
|
119
|
+
text_pixels = max(1, sum(len(text) for text in status_text))
|
|
120
|
+
text_size = int((text_size_coverage * text_img.width) // text_pixels)
|
|
96
121
|
return status_text, text_size
|