python-roborock 7.15.1__py3-none-any.whl → 7.15.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-roborock
3
- Version: 7.15.1
3
+ Version: 7.15.3
4
4
  Summary: A package to control Roborock vacuums.
5
5
  Project-URL: Repository, https://github.com/python-roborock/python-roborock
6
6
  Project-URL: Documentation, https://python-roborock.readthedocs.io/
@@ -11,7 +11,6 @@ roborock/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
11
  roborock/roborock_message.py,sha256=1PHla68g6o0SZjRFJp8QvVipnW-t-ZUmVY9ay_p7MJo,11915
12
12
  roborock/roborock_typing.py,sha256=xppavdf57-Cu6elj3MP403uT75Tjw5Zs6bLGPLQmZf4,15483
13
13
  roborock/util.py,sha256=4zibjn2MgPrLCiha66LUXZj907LOXUtNzugbbb0NIlo,1634
14
- roborock/web_api.py,sha256=G42qiPdTGx4LhMA4uTDASAMYw1HIipDnD6_eziwCPqs,43202
15
14
  roborock/data/__init__.py,sha256=mmFs7SGa1ciqXME5Al9gfIKiCnbhgu1V84wa4j5mdfs,300
16
15
  roborock/data/camera.py,sha256=AaTsDqNlUwV_6zdIX9wQkPXGFzyt2EiiKnitcnfQJA4,5792
17
16
  roborock/data/code_mappings.py,sha256=j-ZZRtQQdIMJQAgEwDAxWKreNhD6U36oIhBKr_nLbis,9783
@@ -23,11 +22,11 @@ roborock/data/b01_q7/__init__.py,sha256=D-Cd8eB0njf1PQPM6Exl_9A0AhpzIjrL0hapGOCE
23
22
  roborock/data/b01_q7/b01_q7_code_mappings.py,sha256=OY5ShERmGh4hKp9SCUohYEbU6FgRJuw34ucYJpjnEiI,11615
24
23
  roborock/data/b01_q7/b01_q7_containers.py,sha256=1PEPyKuaMsPXSOtUw8OQu6aSo5BBgQqMVnAE_rTvZ7w,10428
25
24
  roborock/data/dyad/__init__.py,sha256=Ksx7Ri1alsAYAyILOpj9iNbCC_6sF7xvETFRnTO4rdM,65
26
- roborock/data/dyad/dyad_code_mappings.py,sha256=PGzEpkdc1jsR5RLRnjVnI2Y87O_HFAFulwOjjjbT4B0,2872
25
+ roborock/data/dyad/dyad_code_mappings.py,sha256=HqtqppHTgNUhmJQrCiMQpB4gwrrThZvUuzAvSxhlGKo,3061
27
26
  roborock/data/dyad/dyad_containers.py,sha256=klJD2wV-2McjAoSN8Bthuyeid59L7M8nzSvc3MtmqOI,434
28
27
  roborock/data/mower/__init__.py,sha256=RXFcOE0-rchWViq3F-cLXB9Jnx4nKrn98TUR917szWM,114
29
- roborock/data/mower/mower_code_mappings.py,sha256=dKHgZxpNVaO-uFZvqjPouYbpzgukAlnFgHa8vGsNxKs,14110
30
- roborock/data/mower/mower_containers.py,sha256=s8xzDOW7Pgoipw7P2ROjj-4-c0qZRHuZ-kw_Zu-Aqt0,4938
28
+ roborock/data/mower/mower_code_mappings.py,sha256=QTMW3rfw3pnuTHLwj5RORj44QGjjNjQE3jCDSeGorQE,14251
29
+ roborock/data/mower/mower_containers.py,sha256=zsGJOIKs9Y95hHzOEBC85qy1RYC8V-StCZLR4SpTf8c,5019
31
30
  roborock/data/v1/__init__.py,sha256=3cdjyhCkD4EmEn9JVlSakGusnEgzGajpn8tAvzB3FkI,121
32
31
  roborock/data/v1/v1_clean_modes.py,sha256=uePsc82-7C6tjybYmnYtxA6WAOPS9ofnJXMEP218KTQ,15767
33
32
  roborock/data/v1/v1_code_mappings.py,sha256=9lbpJ5Ugzb0IhjiHjNNQvgLBxEsjZdfq1sEOHkjJIfI,12970
@@ -105,9 +104,9 @@ roborock/devices/transport/mqtt_channel.py,sha256=dpL1mZIoI4Glk3hfUO9JweWFHG1tZW
105
104
  roborock/map/__init__.py,sha256=L3WHujuxUHCTTPaOElkJsTN_FkYhw01drfR-W4blgFc,146
106
105
  roborock/map/b01_grid_layers.py,sha256=b87MsGXslqLR_5H_4z4aj1wQqYgeTuULWqt4_txOF-M,12303
107
106
  roborock/map/b01_map_parser.py,sha256=y4Qlgiwre3bn5_UpL6R-ytjg8kQqwkbvhIYFOtpAM3s,14814
108
- roborock/map/b01_q10_map_parser.py,sha256=3DawtwPqyKhLUAW5Y4D6Ebm16l6RdWYiti0McELJCYQ,37066
107
+ roborock/map/b01_q10_map_parser.py,sha256=O0y3uM7q3ve5_BFQzE1KxudpMxksuPLGR4AOJPc_GEI,32040
109
108
  roborock/map/b01_q10_overlays.py,sha256=JmriUbT4-oe6NiE94VttMplFc1HHQrAGNQIiZHj5QKk,6640
110
- roborock/map/b01_q10_render.py,sha256=PFAYqFxRANCH141drkHnB_0hNGsH5RR-jtkYIo7b46Y,13626
109
+ roborock/map/b01_q10_render.py,sha256=XALrXIMi44Odi0IQKH_OJlU_hjGkls6riW-Wki346r8,17086
111
110
  roborock/map/dynamic_blocks.py,sha256=bz_M6qL2bK50URvyamtU50LHfHFqVUaEpCrcsun4_5I,9096
112
111
  roborock/map/map_parser.py,sha256=EcJcz9yld_wu7d5QrmxR2BTfJ801F6SaEgJkbIIap4w,5111
113
112
  roborock/map/room_colors.py,sha256=YvjuqsUPPAH5exJ16V72OTikDT-KEjgF7vd91l78Pjs,2627
@@ -130,9 +129,21 @@ roborock/testing/b01_q10_simulator.py,sha256=7k2poUIDI0akTw0rEcCpTUZq0gjr8z3kfTk
130
129
  roborock/testing/channel.py,sha256=vjulT5MHBe8lAq6XITUqcxJVcCILWx7ZEcw5imTx0Tk,7243
131
130
  roborock/testing/cloud.py,sha256=L2L0KlZDYaMuoV19KUQUoq5EAUDeNQ1lJ8Ew5YaOgjo,11946
132
131
  roborock/testing/simulator.py,sha256=g8ZUWVSjgAzuRRxWOCeOq-7deodyEItwUpqEw9RzCmQ,4347
133
- roborock/testing/v1_simulator.py,sha256=dDlaaCqKMrE-IotSqaENGf-mZYLDFVelozDJAXnet0s,18929
134
- python_roborock-7.15.1.dist-info/METADATA,sha256=imRBbVjrD6yR90t7kIAW6XpX_jg_3X8pQ87w9TVZXQg,5279
135
- python_roborock-7.15.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
136
- python_roborock-7.15.1.dist-info/entry_points.txt,sha256=EvC1nMqi9ZXKgZnqlNXA33v_3nzgPNjnM0mzWlrehnY,47
137
- python_roborock-7.15.1.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
138
- python_roborock-7.15.1.dist-info/RECORD,,
132
+ roborock/testing/v1_simulator.py,sha256=CbASO1D1vIo_aex7qpOa2R1ZvE5lEmPFKGOxNwVLjm4,20905
133
+ roborock/web_api/__init__.py,sha256=e7x68amhcDG-z4qS-Et2DvqSxuD1OFa42VIb1lg16S8,1200
134
+ roborock/web_api/_base.py,sha256=1nibOL4b29vWHsKhNQzg4ALxdWxt0yRTKFfRosTR6yI,6551
135
+ roborock/web_api/_hawk.py,sha256=A-P0Zj3EH8gAcZNY1xImcE-kc4fkcJiSSrzkeVrVxqE,1649
136
+ roborock/web_api/_request.py,sha256=enMTzaOhK5fs6toYK9qO6jY5oC4PoGh7CAEAiwhUOiU,1792
137
+ roborock/web_api/auth.py,sha256=xks4IOwMkPndrfkiWoZ0_nik4qW6oKfP3XfL8YPOnrs,12920
138
+ roborock/web_api/client.py,sha256=1VLwVQX3-K4dobtM69rQSNzQRwgOcFUu36OrnoS6HxY,3070
139
+ roborock/web_api/devices.py,sha256=pRJjUJyILR0vFZom_tcaVecegnqh_WGzOLoYcYjJCK4,3205
140
+ roborock/web_api/homes.py,sha256=m1L6SDVuyHohsQIucZ9ZFgHlHPR4uBW8YnA5mRX43T8,4008
141
+ roborock/web_api/jobs.py,sha256=ksOBzBDVPpF8-dFADNeToHfo014dvS-5BDP6OUR25y4,2561
142
+ roborock/web_api/ota.py,sha256=mktoCjCI0UeGrQWkTxbk1C-HD2aOGo6NWd1l8orL0r0,2220
143
+ roborock/web_api/products.py,sha256=1rEuPKKnhVO1MA1lrfPTMKtBb62P_uXDbIF9edp8yE0,2085
144
+ roborock/web_api/scenes.py,sha256=m1iuwEbJdNs5JePptEkyeK_CTuCUsXijb2QrFJYrg1c,1283
145
+ python_roborock-7.15.3.dist-info/METADATA,sha256=YBsRJdQ_ipahZcDNFfK75XgW_SPHGmf_gt3F04GsZCA,5279
146
+ python_roborock-7.15.3.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
147
+ python_roborock-7.15.3.dist-info/entry_points.txt,sha256=EvC1nMqi9ZXKgZnqlNXA33v_3nzgPNjnM0mzWlrehnY,47
148
+ python_roborock-7.15.3.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
149
+ python_roborock-7.15.3.dist-info/RECORD,,
@@ -45,10 +45,14 @@ class DyadMode(RoborockEnum):
45
45
 
46
46
 
47
47
  class DyadCleanMode(RoborockEnum):
48
+ unknown = -1
48
49
  auto = 1
49
50
  max = 2
50
- dehydration = 3
51
+ dehydration = 3 # The F25 Ultra reports its "Sponge" mode with this code.
51
52
  power_saving = 4
53
+ # Heated modes on the F25 Ultra, confirmed by switching modes on the device.
54
+ hot_water = 5
55
+ steam = 6
52
56
 
53
57
 
54
58
  class DyadSuction(RoborockEnum):
@@ -1,9 +1,12 @@
1
1
  """Code mappings for Roborock mower devices."""
2
2
 
3
+ import logging
3
4
  from typing import Self, cast
4
5
 
5
6
  from roborock.data.code_mappings import RoborockModeEnum
6
7
 
8
+ _LOGGER = logging.getLogger(__name__)
9
+
7
10
  __all__ = [
8
11
  "MowerAfsStatus",
9
12
  "MowerChargeState",
@@ -38,6 +41,7 @@ class MowerEnum(RoborockModeEnum):
38
41
  """Resolve a wire value by name, string value, or integer code."""
39
42
  if (member := cls.from_any_optional(code)) is not None:
40
43
  return member
44
+ _LOGGER.debug("Unknown %s code %r; defaulting to UNKNOWN", cls.__name__, code)
41
45
  return cast(Self, cls.UNKNOWN) # type: ignore[attr-defined]
42
46
 
43
47
 
@@ -48,6 +48,7 @@ class MowerStatus(RoborockBase):
48
48
  """Core mower status backed by mower DPS updates (data points 120-145)."""
49
49
 
50
50
  error_code: MowerErrorCode | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.ERROR_CODE})
51
+ """None means no error code was supplied; UNKNOWN means an unknown error."""
51
52
  battery: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.BATTERY})
52
53
  mow_type: MowerMowType | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_TYPE})
53
54
  mow_state: MowerDetailState | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_STATE})
@@ -20,17 +20,11 @@ documentation that informed this clean-room implementation comes from the
20
20
  https://github.com/v1b3c0d3x3r/roborock-qseries-map-bridge
21
21
  """
22
22
 
23
- import io
24
23
  import math
25
24
  import statistics
26
25
  import struct
27
26
  from dataclasses import dataclass, field, replace
28
27
 
29
- from PIL import Image
30
- from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor
31
- from vacuum_map_parser_base.config.image_config import ImageConfig
32
- from vacuum_map_parser_base.map_data import ImageData, MapData, Point
33
-
34
28
  from roborock.data.b01_q10.b01_q10_containers import Q10RoborockPoint
35
29
  from roborock.data.code_mappings import RoborockEnum
36
30
  from roborock.data.containers import RoborockBase
@@ -44,10 +38,6 @@ from .b01_grid_layers import (
44
38
  GridLayers,
45
39
  decompose_grid,
46
40
  )
47
- from .map_parser import ParsedMapData
48
- from .room_colors import adjacency_aware_room_colors
49
-
50
- _MAP_FILE_FORMAT = "PNG"
51
41
 
52
42
  # Semantic raster classes, confirmed against real ss07 captures (rendered and
53
43
  # eyeballed): 243 is the background outside the home (~half the grid), 240 is
@@ -776,111 +766,3 @@ class B01Q10MapParserConfig:
776
766
 
777
767
  map_scale: int = 4
778
768
  """Scale factor for the rendered map image."""
779
-
780
-
781
- class B01Q10MapParser:
782
- """Decoder/renderer for Q10 ``MAP_RESPONSE`` (protocol 301) payloads."""
783
-
784
- def __init__(self, config: B01Q10MapParserConfig | None = None) -> None:
785
- self._config = config or B01Q10MapParserConfig()
786
-
787
- @property
788
- def config(self) -> B01Q10MapParserConfig:
789
- """The parser configuration (image scale, ...)."""
790
- return self._config
791
-
792
- def parse(self, payload: bytes) -> ParsedMapData:
793
- """Parse a raw Q10 map packet into a rendered PNG + ``MapData``."""
794
- return self.parsed_from_packet(parse_map_packet(payload))
795
-
796
- def parse_packet(self, packet: Q10MapPacket) -> ParsedMapData:
797
- """Render an already-decoded packet.
798
-
799
- This preserves the public API used by the existing map trait. Callers
800
- rendering a modified packet can use :meth:`parsed_from_packet` directly.
801
- """
802
- return self.parsed_from_packet(packet)
803
-
804
- def map_data_from_packet(self, packet: Q10MapPacket) -> MapData:
805
- """Build MapData with a rendered base image, without PNG serialization."""
806
- image = self._render(packet)
807
-
808
- map_data = MapData()
809
- map_data.image = ImageData(
810
- size=packet.width * packet.height,
811
- top=0,
812
- left=0,
813
- height=packet.height,
814
- width=packet.width,
815
- image_config=ImageConfig(scale=self._config.map_scale),
816
- data=image,
817
- # ImageDimensions uses V1's bottom-up map convention before
818
- # projecting into the top-down PNG. Q10 points are already
819
- # top-down grid pixels, so this adapter cancels that standard flip
820
- # and lets Q10 use the shared V1 ImageGenerator without moving
821
- # overlays vertically.
822
- img_transformation=lambda p: Point(p.x, packet.height - p.y - 1, p.a),
823
- )
824
- room_names = {room.id: room.name for room in packet.rooms}
825
- if room_names:
826
- map_data.additional_parameters["room_names"] = room_names
827
-
828
- # Carpet cells are flat grid indices (y*width + x) in the same top-down
829
- # pixel space as the rendered raster, so they line up with the image.
830
- if packet.carpet_mask is not None:
831
- map_data.carpet_map = {i for i, value in enumerate(packet.carpet_mask) if value}
832
-
833
- return map_data
834
-
835
- def parsed_from_packet(self, packet: Q10MapPacket) -> ParsedMapData:
836
- """Render a (possibly erase-modified) packet into a PNG + ``MapData``."""
837
- map_data = self.map_data_from_packet(packet)
838
- image_bytes = io.BytesIO()
839
- if map_data.image is not None:
840
- map_data.image.data.save(image_bytes, format=_MAP_FILE_FORMAT)
841
- return ParsedMapData(image_content=image_bytes.getvalue(), map_data=map_data)
842
-
843
- def _render(self, packet: Q10MapPacket) -> Image.Image:
844
- """Render the Q10 grid with the V1 map palette."""
845
- palette = _build_palette(packet.grid, packet.width)
846
- flat_palette = [channel for color in palette for channel in color]
847
- p_img = Image.frombytes("P", (packet.width, packet.height), packet.grid)
848
- p_img.putpalette(flat_palette, rawmode="RGBA")
849
- img = p_img.convert("RGBA")
850
- # The ss07 grid is stored top-down (row 0 = top of the home), so it is
851
- # rendered as-is -- unlike the V1/Q7 convention, no vertical flip.
852
- scale = self._config.map_scale
853
- if scale > 1:
854
- img = img.resize((packet.width * scale, packet.height * scale), resample=Image.Resampling.NEAREST)
855
- return img
856
-
857
-
858
- def _opaque(color: tuple[int, ...]) -> tuple[int, int, int, int]:
859
- """Return a palette color as RGBA."""
860
- return (color[0], color[1], color[2], color[3] if len(color) == 4 else 255)
861
-
862
-
863
- def _build_palette(grid: bytes, width: int) -> list[tuple[int, int, int, int]]:
864
- """Map Q10 cells onto the same colors used by the V1 map renderer."""
865
-
866
- def room_id(value: int) -> int | None:
867
- return max(1, value // 4) if 0 < value < _WALL_THRESHOLD else None
868
-
869
- colors = ColorsPalette()
870
- room_colors = adjacency_aware_room_colors(
871
- grid,
872
- width,
873
- colors,
874
- room_id,
875
- )
876
- outside = (0, 0, 0, 0)
877
- palette = [outside] * 256
878
- for value in {value for value in grid if 0 < value < _WALL_THRESHOLD}:
879
- palette[value] = _opaque(room_colors[max(1, value // 4)])
880
- wall = _opaque(colors.get_color(SupportedColor.GREY_WALL))
881
- for value in range(_WALL_THRESHOLD, 256):
882
- palette[value] = wall
883
- palette[_UNSEGMENTED_FLOOR_VALUE] = _opaque(colors.get_color(SupportedColor.MAP_INSIDE))
884
- palette[_BACKGROUND_VALUE] = outside
885
- palette[0] = outside
886
- return palette
@@ -1,7 +1,7 @@
1
1
  """Compose a Q10 (B01/ss07) map into a single rendered result.
2
2
 
3
- The :class:`~roborock.map.b01_q10_map_parser.B01Q10MapParser` turns wire bytes
4
- into a :class:`~roborock.map.b01_q10_map_parser.Q10MapPacket`; this module
3
+ The :func:`~roborock.map.b01_q10_map_parser.parse_map_packet` function turns wire
4
+ bytes into a :class:`~roborock.map.b01_q10_map_parser.Q10MapPacket`; this module
5
5
  combines that map-protocol packet with the latest trace-protocol packet and DPS
6
6
  overlay snapshot. Calibration, path and position are derived from those source
7
7
  objects rather than managed independently by callers.
@@ -18,9 +18,12 @@ import math
18
18
  from collections.abc import Sequence
19
19
  from dataclasses import dataclass
20
20
 
21
+ from PIL import Image
22
+ from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor
21
23
  from vacuum_map_parser_base.config.drawable import Drawable
24
+ from vacuum_map_parser_base.config.image_config import ImageConfig
22
25
  from vacuum_map_parser_base.config.size import Size, Sizes
23
- from vacuum_map_parser_base.map_data import Area, MapData, Path, Point, Wall
26
+ from vacuum_map_parser_base.map_data import Area, ImageData, MapData, Path, Point, Wall
24
27
 
25
28
  from roborock.exceptions import RoborockException
26
29
 
@@ -31,7 +34,9 @@ from .b01_grid_layers import (
31
34
  solve_calibration_with_origin,
32
35
  )
33
36
  from .b01_q10_map_parser import (
34
- B01Q10MapParser,
37
+ _BACKGROUND_VALUE,
38
+ _UNSEGMENTED_FLOOR_VALUE,
39
+ _WALL_THRESHOLD,
35
40
  B01Q10MapParserConfig,
36
41
  Q10EraseZone,
37
42
  Q10HistoricalTracePacket,
@@ -41,6 +46,7 @@ from .b01_q10_map_parser import (
41
46
  )
42
47
  from .b01_q10_overlays import ZONE_TYPE_NO_GO, ZONE_TYPE_NO_MOP, Q10Zone
43
48
  from .map_parser import DEFAULT_DRAWABLES, MapParserConfig, _create_image_generator
49
+ from .room_colors import adjacency_aware_room_colors
44
50
 
45
51
  # Path-units-per-pixel candidates for calibration. A dense ss07 path lands a
46
52
  # best fit of 20.0 around the header origin -- ground-truthed June 2026 on the
@@ -97,7 +103,6 @@ def render_q10_map(
97
103
  available trace and DPS overlays are projected and drawn in pixel space.
98
104
  Raises :class:`RoborockException` if map rendering fails.
99
105
  """
100
- parser = B01Q10MapParser(config)
101
106
  trace_calibration = solve_q10_calibration(packet, trace)
102
107
  vector_calibration = _vector_calibration(packet, trace_calibration)
103
108
 
@@ -109,7 +114,7 @@ def render_q10_map(
109
114
  # areas disappear (as the app shows).
110
115
  render_packet = erased_packet(packet, cells)
111
116
 
112
- map_data = parser.map_data_from_packet(render_packet)
117
+ map_data = _map_data_from_packet(render_packet, config=config)
113
118
  if map_data.image is None:
114
119
  raise RoborockException("Failed to render Q10 map image")
115
120
 
@@ -335,3 +340,84 @@ def _draw_map_content(
335
340
  buffer = io.BytesIO()
336
341
  map_data.image.data.save(buffer, format="PNG")
337
342
  return buffer.getvalue()
343
+
344
+
345
+ def _map_data_from_packet(
346
+ packet: Q10MapPacket,
347
+ *,
348
+ config: B01Q10MapParserConfig,
349
+ ) -> MapData:
350
+ """Build MapData with a rendered base image, without PNG serialization."""
351
+ image = _render_base_grid(packet, scale=config.map_scale)
352
+
353
+ map_data = MapData()
354
+ map_data.image = ImageData(
355
+ size=packet.width * packet.height,
356
+ top=0,
357
+ left=0,
358
+ height=packet.height,
359
+ width=packet.width,
360
+ image_config=ImageConfig(scale=config.map_scale),
361
+ data=image,
362
+ # ImageDimensions uses V1's bottom-up map convention before
363
+ # projecting into the top-down PNG. Q10 points are already
364
+ # top-down grid pixels, so this adapter cancels that standard flip
365
+ # and lets Q10 use the shared V1 ImageGenerator without moving
366
+ # overlays vertically.
367
+ img_transformation=lambda p: Point(p.x, packet.height - p.y - 1, p.a),
368
+ )
369
+ room_names = {room.id: room.name for room in packet.rooms}
370
+ if room_names:
371
+ map_data.additional_parameters["room_names"] = room_names
372
+
373
+ # Carpet cells are flat grid indices (y*width + x) in the same top-down
374
+ # pixel space as the rendered raster, so they line up with the image.
375
+ if packet.carpet_mask is not None:
376
+ map_data.carpet_map = {i for i, value in enumerate(packet.carpet_mask) if value}
377
+
378
+ return map_data
379
+
380
+
381
+ def _render_base_grid(packet: Q10MapPacket, *, scale: int) -> Image.Image:
382
+ """Render the Q10 grid with the V1 map palette."""
383
+ palette = _build_palette(packet.grid, packet.width)
384
+ flat_palette = [channel for color in palette for channel in color]
385
+ p_img = Image.frombytes("P", (packet.width, packet.height), packet.grid)
386
+ p_img.putpalette(flat_palette, rawmode="RGBA")
387
+ img = p_img.convert("RGBA")
388
+ # The ss07 grid is stored top-down (row 0 = top of the home), so it is
389
+ # rendered as-is -- unlike the V1/Q7 convention, no vertical flip.
390
+ if scale > 1:
391
+ img = img.resize((packet.width * scale, packet.height * scale), resample=Image.Resampling.NEAREST)
392
+ return img
393
+
394
+
395
+ def _opaque(color: tuple[int, ...]) -> tuple[int, int, int, int]:
396
+ """Return a palette color as RGBA."""
397
+ return (color[0], color[1], color[2], color[3] if len(color) == 4 else 255)
398
+
399
+
400
+ def _build_palette(grid: bytes, width: int) -> list[tuple[int, int, int, int]]:
401
+ """Map Q10 cells onto the same colors used by the V1 map renderer."""
402
+
403
+ def room_id(value: int) -> int | None:
404
+ return max(1, value // 4) if 0 < value < _WALL_THRESHOLD else None
405
+
406
+ colors = ColorsPalette()
407
+ room_colors = adjacency_aware_room_colors(
408
+ grid,
409
+ width,
410
+ colors,
411
+ room_id,
412
+ )
413
+ outside = (0, 0, 0, 0)
414
+ palette = [outside] * 256
415
+ for value in {value for value in grid if 0 < value < _WALL_THRESHOLD}:
416
+ palette[value] = _opaque(room_colors[max(1, value // 4)])
417
+ wall = _opaque(colors.get_color(SupportedColor.GREY_WALL))
418
+ for value in range(_WALL_THRESHOLD, 256):
419
+ palette[value] = wall
420
+ palette[_UNSEGMENTED_FLOOR_VALUE] = _opaque(colors.get_color(SupportedColor.MAP_INSIDE))
421
+ palette[_BACKGROUND_VALUE] = outside
422
+ palette[0] = outside
423
+ return palette
@@ -10,7 +10,7 @@ import json
10
10
  import logging
11
11
  import struct
12
12
  import time
13
- from collections.abc import Callable
13
+ from collections.abc import Callable, Sequence
14
14
  from dataclasses import asdict, replace
15
15
  from enum import Enum
16
16
  from typing import Any
@@ -45,6 +45,7 @@ from roborock.protocols.v1_protocol import SecurityData
45
45
  from roborock.roborock_message import RoborockDataProtocol, RoborockMessage, RoborockMessageProtocol
46
46
  from roborock.testing.channel import FakeChannel
47
47
  from roborock.testing.simulator import RoborockDeviceSimulator
48
+ from roborock.util import get_next_int
48
49
 
49
50
  _LOGGER = logging.getLogger(__name__)
50
51
 
@@ -255,8 +256,12 @@ class V1VacuumSimulator(RoborockDeviceSimulator):
255
256
 
256
257
  # The simulated robot's own view of its map. Tests extend the path
257
258
  # with advance_path() and move the robot via robot_position.
259
+ # Set map_parts > 1 (and optionally map_part_order) to split map
260
+ # responses across MAP_RESPONSE_PART (300) and MAP_RESPONSE (301) frames.
258
261
  self.path_length = 0
259
262
  self.robot_position = (25000, 24000, 90)
263
+ self.map_parts = 1
264
+ self.map_part_order: Sequence[int] | None = None
260
265
 
261
266
  self.device_cache = DeviceCache(self.duid, InMemoryCache())
262
267
  # The endpoint must fit the eight byte map response header field and the
@@ -416,11 +421,44 @@ class V1VacuumSimulator(RoborockDeviceSimulator):
416
421
  data = header + body + digest_header
417
422
  return data + hashlib.sha1(data, usedforsecurity=False).digest()
418
423
 
419
- def _map_response_message(self, request_id: int, map_bytes: bytes) -> RoborockMessage:
420
- """Frame the map as the encrypted binary response the device sends."""
424
+ def _map_response_messages(self, request_id: int, map_bytes: bytes) -> list[RoborockMessage]:
425
+ """Frame the map as one or more encrypted binary messages as the device sends."""
426
+ if self.map_parts < 1:
427
+ raise ValueError("map_parts must be at least 1")
421
428
  body = Utils.encrypt_cbc(gzip.compress(map_bytes), self.security_data.nonce)
422
429
  header = struct.pack("<8s8sH6s", self.security_data.endpoint.encode(), bytes(8), request_id & 0xFFFF, bytes(6))
423
- return RoborockMessage(protocol=RoborockMessageProtocol.MAP_RESPONSE, payload=header + body)
430
+ if self.map_parts == 1:
431
+ if self.map_part_order is not None and list(self.map_part_order) != [0]:
432
+ raise ValueError("map_part_order must be a permutation of range(map_parts)")
433
+ return [RoborockMessage(protocol=RoborockMessageProtocol.MAP_RESPONSE, payload=header + body)]
434
+ if self.map_parts > len(body):
435
+ raise ValueError("map_parts cannot exceed encrypted map body length")
436
+
437
+ # Keep the 24-byte header intact on the first part and split the encrypted
438
+ # body evenly into map_parts non-empty contiguous slices.
439
+ total = len(body)
440
+ boundaries = [(i * total) // self.map_parts for i in range(self.map_parts + 1)]
441
+ chunks = [body[boundaries[i] : boundaries[i + 1]] for i in range(self.map_parts)]
442
+ chunks[0] = header + chunks[0]
443
+ random_id = get_next_int(10000, 99999)
444
+ timestamp = int(time.time())
445
+ messages = [
446
+ RoborockMessage(
447
+ protocol=RoborockMessageProtocol.MAP_RESPONSE
448
+ if idx == len(chunks) - 1
449
+ else RoborockMessageProtocol.MAP_RESPONSE_PART,
450
+ payload=chunk,
451
+ seq=idx + 1,
452
+ random=random_id,
453
+ timestamp=timestamp,
454
+ )
455
+ for idx, chunk in enumerate(chunks)
456
+ ]
457
+ if self.map_part_order is not None:
458
+ if sorted(self.map_part_order) != list(range(self.map_parts)):
459
+ raise ValueError("map_part_order must be a permutation of range(map_parts)")
460
+ messages = [messages[i] for i in self.map_part_order]
461
+ return messages
424
462
 
425
463
  async def _handle_publish(self, message: RoborockMessage, channel: FakeChannel) -> None:
426
464
  if not message.payload:
@@ -455,7 +493,8 @@ class V1VacuumSimulator(RoborockDeviceSimulator):
455
493
 
456
494
  if method == "get_map_v1" and error is None and isinstance(result, bytes):
457
495
  # Custom handlers return decompressed map bytes, just like the default.
458
- channel.notify_subscribers(self._map_response_message(msg_id, result))
496
+ for map_msg in self._map_response_messages(msg_id, result):
497
+ channel.notify_subscribers(map_msg)
459
498
  return
460
499
 
461
500
  response_data = {
@@ -0,0 +1,37 @@
1
+ """Roborock cloud REST API clients.
2
+
3
+ The public entry points are :class:`RoborockApiClient` and :class:`UserWebApiClient`.
4
+ Endpoints are grouped by resource into submodules (``auth``, ``homes``, ``devices``,
5
+ ``scenes``, ``jobs``, ``ota``, ``products``); each exposes a mixin that
6
+ ``RoborockApiClient`` composes. Everything that was importable from the former
7
+ ``roborock.web_api`` module is re-exported here for backwards compatibility.
8
+ """
9
+
10
+ from ._base import BASE_URLS, IotLoginInfo, RoborockApiBase
11
+ from ._request import PreparedRequest
12
+ from .auth import DEFAULT_AGREEMENT_MAJOR_VERSION, DEFAULT_AGREEMENT_MINOR_VERSION, AuthApi
13
+ from .client import RoborockApiClient, UserWebApiClient
14
+ from .devices import DevicesApi
15
+ from .homes import HomesApi
16
+ from .jobs import JobsApi
17
+ from .ota import OtaApi
18
+ from .products import ProductsApi
19
+ from .scenes import ScenesApi
20
+
21
+ __all__ = [
22
+ "BASE_URLS",
23
+ "DEFAULT_AGREEMENT_MAJOR_VERSION",
24
+ "DEFAULT_AGREEMENT_MINOR_VERSION",
25
+ "AuthApi",
26
+ "DevicesApi",
27
+ "HomesApi",
28
+ "IotLoginInfo",
29
+ "JobsApi",
30
+ "OtaApi",
31
+ "PreparedRequest",
32
+ "ProductsApi",
33
+ "RoborockApiBase",
34
+ "RoborockApiClient",
35
+ "ScenesApi",
36
+ "UserWebApiClient",
37
+ ]