valetudo-map-parser 0.1.11b1__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.
@@ -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 Image, ImageDraw, ImageFont
20
+ from PIL import ImageDraw, ImageFont
20
21
 
21
- from .types import Color, NumpyArray, PilPNG, Point, Tuple, Union
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([int(pts[i] - min_x) for i in range(0, len(pts), 2)], dtype=np.int32)
423
- ys = np.array([int(pts[i] - min_y) for i in range(1, len(pts), 2)], dtype=np.int32)
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 = (mask_rgba[:, :, 0] > 0)
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
- # Process data sequentially - segments are organized as blocks
334
- current_segments = {}
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 FIRST
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
- if self.Types.GOTO_PREDICTED_PATH.value in blocks:
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 missing fields to match expected JSON format
467
- parsed_map_data["currently_cleaned_zones"] = (
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 .types import (
14
+ from ..const import (
15
15
  ATTR_CALIBRATION_POINTS,
16
16
  ATTR_CAMERA_MODE,
17
17
  ATTR_CONTENT_TYPE,
@@ -39,7 +39,10 @@ from .types import (
39
39
  CONF_VAC_STAT_POS,
40
40
  CONF_VAC_STAT_SIZE,
41
41
  CONF_ZOOM_LOCK_RATIO,
42
+ NOT_STREAMING_STATES,
42
43
  DEFAULT_VALUES,
44
+ )
45
+ from .types import (
43
46
  CameraModes,
44
47
  Colors,
45
48
  PilPNG,
@@ -119,10 +122,21 @@ class CameraShared:
119
122
  self.trims = TrimsData.from_dict(DEFAULT_VALUES["trims_data"])
120
123
  self.skip_room_ids: List[str] = []
121
124
  self.device_info = None
125
+ self._battery_state = None
122
126
 
123
127
  def vacuum_bat_charged(self) -> bool:
124
128
  """Check if the vacuum is charging."""
125
- return (self.vacuum_state == "docked") and (int(self.vacuum_battery) < 100)
129
+ if self.vacuum_state != "docked":
130
+ self._battery_state = "not_charging"
131
+ elif (self._battery_state == "charging_done") and (
132
+ int(self.vacuum_battery) == 100
133
+ ):
134
+ self._battery_state = "charged"
135
+ else:
136
+ self._battery_state = (
137
+ "charging" if int(self.vacuum_battery) < 100 else "charging_done"
138
+ )
139
+ return (self.vacuum_state == "docked") and (self._battery_state == "charging")
126
140
 
127
141
  @staticmethod
128
142
  def _compose_obstacle_links(vacuum_host_ip: str, obstacles: list) -> list | None:
@@ -209,17 +223,25 @@ class CameraShared:
209
223
 
210
224
  return attrs
211
225
 
226
+ def is_streaming(self) -> bool:
227
+ """Return true if the device is streaming."""
228
+ updated_status = self.vacuum_state
229
+ attr_is_streaming = (
230
+ updated_status not in NOT_STREAMING_STATES or self.vacuum_bat_charged()
231
+ ) or not self.binary_image
232
+ return attr_is_streaming
233
+
212
234
  def to_dict(self) -> dict:
213
235
  """Return a dictionary with image and attributes data."""
214
-
215
- return {
236
+ data = {
216
237
  "image": {
217
238
  "binary": self.binary_image,
218
- "pil_image": self.new_image,
219
239
  "size": pil_size_rotation(self.image_rotate, self.new_image),
240
+ "streaming": self.is_streaming(),
220
241
  },
221
242
  "attributes": self.generate_attributes(),
222
243
  }
244
+ return data
223
245
 
224
246
 
225
247
  class CameraSharedManager:
@@ -233,9 +255,6 @@ class CameraSharedManager:
233
255
  self.device_info = device_info
234
256
  self.update_shared_data(device_info)
235
257
 
236
- # Automatically initialize shared data for the instance
237
- # self._init_shared_data(device_info)
238
-
239
258
  def update_shared_data(self, device_info):
240
259
  """Initialize the shared data with device_info."""
241
260
  instance = self.get_instance() # Retrieve the correct instance
@@ -0,0 +1,6 @@
1
+ """Status text module for vacuum cleaners."""
2
+
3
+ from .status_text import StatusText
4
+ from .translations import translations
5
+
6
+ __all__ = ["StatusText", "translations"]
@@ -1,15 +1,16 @@
1
1
  """
2
- Version: 0.1.10
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 get_vacuum_status_translation(
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 translate_vacuum_status(self) -> str:
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.get_vacuum_status_translation(language)
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
- charge_level = "\u03de" # unicode Koppa symbol
55
- charging = "\u2211" # unicode Charging symbol
56
- vacuum_state = await self.translate_vacuum_status()
57
- if self._shared.show_vacuum_state:
58
- status_text = [f"{self.file_name}: {vacuum_state}"]
59
- language = (self._shared.user_language or "en").lower()
60
- lang_map = translations.get(language) or translations.get("en", {})
61
- if not self._shared.vacuum_connection:
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