From ac0c93b9ba432ea8c62149c02ff6d37e47f283da Mon Sep 17 00:00:00 2001 From: arduano Date: Sat, 28 Feb 2026 13:46:21 +1100 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=A6=8E=20q7:=20add=20clean=5Fsegments?= =?UTF-8?q?=20helper=20for=20room-based=20cleaning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- roborock/devices/traits/b01/q7/__init__.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/roborock/devices/traits/b01/q7/__init__.py b/roborock/devices/traits/b01/q7/__init__.py index 9c09c05c..0479df30 100644 --- a/roborock/devices/traits/b01/q7/__init__.py +++ b/roborock/devices/traits/b01/q7/__init__.py @@ -87,6 +87,17 @@ async def start_clean(self) -> None: }, ) + async def clean_segments(self, room_ids: list[int]) -> None: + """Start segment/room cleaning for the given room ids.""" + await self.send( + command=RoborockB01Q7Methods.SET_ROOM_CLEAN, + params={ + "clean_type": CleanTaskTypeMapping.ROOM.code, + "ctrl_value": SCDeviceCleanParam.START.code, + "room_ids": room_ids, + }, + ) + async def pause_clean(self) -> None: """Pause cleaning.""" await self.send( From 0eb572009d4efdf21b08df7a718fc9aa6485ee91 Mon Sep 17 00:00:00 2001 From: arduano Date: Sat, 28 Feb 2026 14:51:32 +1100 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=A6=8E=20q7:=20add=20B01=20map=20deco?= =?UTF-8?q?de/parse/render=20and=20map=20content=20trait?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- roborock/devices/device_manager.py | 7 +- roborock/devices/rpc/b01_q7_channel.py | 51 +++- roborock/devices/traits/b01/q7/__init__.py | 12 +- roborock/devices/traits/b01/q7/map_content.py | 46 ++++ roborock/map/__init__.py | 6 + roborock/map/b01_map_parser.py | 231 ++++++++++++++++++ tests/devices/traits/b01/q7/conftest.py | 2 +- .../b01/raw-mqtt-map301.bin.inflated.bin | Bin 0 -> 166774 bytes tests/map/test_b01_map_parser.py | 45 ++++ 9 files changed, 393 insertions(+), 7 deletions(-) create mode 100644 roborock/devices/traits/b01/q7/map_content.py create mode 100644 roborock/map/b01_map_parser.py create mode 100644 tests/fixtures/b01/raw-mqtt-map301.bin.inflated.bin create mode 100644 tests/map/test_b01_map_parser.py diff --git a/roborock/devices/device_manager.py b/roborock/devices/device_manager.py index b1ef6626..1d33f9c0 100644 --- a/roborock/devices/device_manager.py +++ b/roborock/devices/device_manager.py @@ -251,7 +251,12 @@ def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDat trait = b01.q10.create(channel) elif "sc" in model_part: # Q7 devices start with 'sc' in their model naming. - trait = b01.q7.create(channel) + trait = b01.q7.create( + channel, + local_key=device.local_key, + serial=device.sn, + model=product.model, + ) else: raise UnsupportedDeviceError(f"Device {device.name} has unsupported B01 model: {product.model}") case _: diff --git a/roborock/devices/rpc/b01_q7_channel.py b/roborock/devices/rpc/b01_q7_channel.py index add5bc97..6c8ab308 100644 --- a/roborock/devices/rpc/b01_q7_channel.py +++ b/roborock/devices/rpc/b01_q7_channel.py @@ -14,7 +14,7 @@ decode_rpc_response, encode_mqtt_payload, ) -from roborock.roborock_message import RoborockMessage +from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol _LOGGER = logging.getLogger(__name__) _TIMEOUT = 10.0 @@ -99,3 +99,52 @@ def find_response(response_message: RoborockMessage) -> None: raise finally: unsub() + + +async def send_map_command(mqtt_channel: MqttChannel, request_message: Q7RequestMessage) -> bytes: + """Send map upload command and wait for MAP_RESPONSE payload bytes.""" + + roborock_message = encode_mqtt_payload(request_message) + future: asyncio.Future[bytes] = asyncio.get_running_loop().create_future() + + def find_response(response_message: RoborockMessage) -> None: + if future.done(): + return + + if response_message.protocol == RoborockMessageProtocol.MAP_RESPONSE and response_message.payload: + future.set_result(response_message.payload) + return + + try: + decoded_dps = decode_rpc_response(response_message) + except RoborockException: + return + + for dps_value in decoded_dps.values(): + if not isinstance(dps_value, str): + continue + try: + inner = json.loads(dps_value) + except (json.JSONDecodeError, TypeError): + continue + if not isinstance(inner, dict) or inner.get("msgId") != str(request_message.msg_id): + continue + code = inner.get("code", 0) + if code != 0: + future.set_exception(RoborockException(f"B01 command failed with code {code} ({request_message})")) + return + data = inner.get("data") + if isinstance(data, dict) and isinstance(data.get("payload"), str): + try: + future.set_result(bytes.fromhex(data["payload"])) + except ValueError: + pass + + unsub = await mqtt_channel.subscribe(find_response) + try: + await mqtt_channel.publish(roborock_message) + return await asyncio.wait_for(future, timeout=_TIMEOUT) + except TimeoutError as ex: + raise RoborockException(f"B01 map command timed out after {_TIMEOUT}s ({request_message})") from ex + finally: + unsub() diff --git a/roborock/devices/traits/b01/q7/__init__.py b/roborock/devices/traits/b01/q7/__init__.py index 0479df30..77451c6a 100644 --- a/roborock/devices/traits/b01/q7/__init__.py +++ b/roborock/devices/traits/b01/q7/__init__.py @@ -21,10 +21,12 @@ from roborock.roborock_typing import RoborockB01Q7Methods from .clean_summary import CleanSummaryTrait +from .map_content import Q7MapContentTrait __all__ = [ "Q7PropertiesApi", "CleanSummaryTrait", + "Q7MapContentTrait", ] @@ -33,11 +35,13 @@ class Q7PropertiesApi(Trait): clean_summary: CleanSummaryTrait """Trait for clean records / clean summary (Q7 `service.get_record_list`).""" + map_content: Q7MapContentTrait - def __init__(self, channel: MqttChannel) -> None: + def __init__(self, channel: MqttChannel, *, local_key: str, serial: str, model: str) -> None: """Initialize the B01Props API.""" self._channel = channel self.clean_summary = CleanSummaryTrait(channel) + self.map_content = Q7MapContentTrait(channel, local_key=local_key, serial=serial, model=model) async def query_values(self, props: list[RoborockB01Props]) -> B01Props | None: """Query the device for the values of the given Q7 properties.""" @@ -142,6 +146,6 @@ async def send(self, command: CommandType, params: ParamsType) -> Any: ) -def create(channel: MqttChannel) -> Q7PropertiesApi: - """Create traits for B01 devices.""" - return Q7PropertiesApi(channel) +def create(channel: MqttChannel, *, local_key: str, serial: str, model: str) -> Q7PropertiesApi: + """Create traits for B01 Q7 devices.""" + return Q7PropertiesApi(channel, local_key=local_key, serial=serial, model=model) diff --git a/roborock/devices/traits/b01/q7/map_content.py b/roborock/devices/traits/b01/q7/map_content.py new file mode 100644 index 00000000..dadb29a8 --- /dev/null +++ b/roborock/devices/traits/b01/q7/map_content.py @@ -0,0 +1,46 @@ +"""Map content trait for B01/Q7 devices.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from roborock.devices.rpc.b01_q7_channel import send_map_command +from roborock.devices.transport.mqtt_channel import MqttChannel +from roborock.devices.traits import Trait +from roborock.devices.traits.v1.map_content import MapContent +from roborock.map.b01_map_parser import decode_b01_map_payload, parse_scmap_payload, render_map_png +from roborock.protocols.b01_q7_protocol import Q7RequestMessage +from roborock.roborock_typing import RoborockB01Q7Methods + + +@dataclass +class B01MapContent(MapContent): + """B01 map content wrapper.""" + + +class Q7MapContentTrait(B01MapContent, Trait): + """Fetch and parse map content from B01/Q7 devices.""" + + def __init__(self, channel: MqttChannel, *, local_key: str, serial: str, model: str) -> None: + super().__init__() + self._channel = channel + self._local_key = local_key + self._serial = serial + self._model = model + + async def refresh(self) -> B01MapContent: + raw_payload = await send_map_command( + self._channel, + Q7RequestMessage(dps=10000, command=RoborockB01Q7Methods.UPLOAD_BY_MAPTYPE, params={"maptype": 301}), + ) + inflated = decode_b01_map_payload( + raw_payload, + local_key=self._local_key, + serial=self._serial, + model=self._model, + ) + parsed = parse_scmap_payload(inflated) + self.raw_api_response = raw_payload + self.map_data = None + self.image_content = render_map_png(parsed) + return self diff --git a/roborock/map/__init__.py b/roborock/map/__init__.py index 9835b81d..9b8e160f 100644 --- a/roborock/map/__init__.py +++ b/roborock/map/__init__.py @@ -1,7 +1,13 @@ """Module for Roborock map related data classes.""" +from .b01_map_parser import B01MapData, decode_b01_map_payload, parse_scmap_payload, render_map_png from .map_parser import MapParserConfig, ParsedMapData __all__ = [ + "B01MapData", "MapParserConfig", + "ParsedMapData", + "decode_b01_map_payload", + "parse_scmap_payload", + "render_map_png", ] diff --git a/roborock/map/b01_map_parser.py b/roborock/map/b01_map_parser.py new file mode 100644 index 00000000..ecacf829 --- /dev/null +++ b/roborock/map/b01_map_parser.py @@ -0,0 +1,231 @@ +"""B01/Q7 SCMap decoding and rendering support.""" + +from __future__ import annotations + +import base64 +import hashlib +import io +import math +import zlib +from dataclasses import dataclass + +from Crypto.Cipher import AES +from Crypto.Util.Padding import pad, unpad +from PIL import Image + +from roborock.exceptions import RoborockException + +_B01_HASH = "5wwh9ikChRjASpMU8cxg7o1d2E" + + +@dataclass +class B01MapData: + """Parsed B01 map payload.""" + + size_x: int + size_y: int + map_data: bytes + + +def _read_varint(buf: bytes, idx: int) -> tuple[int, int]: + value = 0 + shift = 0 + while True: + if idx >= len(buf): + raise RoborockException("Truncated varint in B01 map payload") + b = buf[idx] + idx += 1 + value |= (b & 0x7F) << shift + if not b & 0x80: + return value, idx + shift += 7 + if shift > 63: + raise RoborockException("Invalid varint in B01 map payload") + + +def _read_len_delimited(buf: bytes, idx: int) -> tuple[bytes, int]: + length, idx = _read_varint(buf, idx) + end = idx + length + if end > len(buf): + raise RoborockException("Invalid length-delimited field in B01 map payload") + return buf[idx:end], end + + +def _parse_map_data_info(blob: bytes) -> bytes: + idx = 0 + while idx < len(blob): + key, idx = _read_varint(blob, idx) + field_no = key >> 3 + wire = key & 0x07 + if wire == 0: + _, idx = _read_varint(blob, idx) + elif wire == 2: + value, idx = _read_len_delimited(blob, idx) + if field_no == 1: + try: + return zlib.decompress(value) + except zlib.error: + return value + elif wire == 5: + idx += 4 + else: + raise RoborockException(f"Unsupported wire type {wire} in B01 map data info") + raise RoborockException("B01 map payload missing mapData") + + +def parse_scmap_payload(payload: bytes) -> B01MapData: + """Parse SCMap protobuf payload and extract occupancy grid bytes.""" + + size_x = 0 + size_y = 0 + grid = b"" + idx = 0 + while idx < len(payload): + key, idx = _read_varint(payload, idx) + field_no = key >> 3 + wire = key & 0x07 + if wire == 0: + _, idx = _read_varint(payload, idx) + continue + if wire != 2: + if wire == 5: + idx += 4 + continue + raise RoborockException(f"Unsupported wire type {wire} in B01 map payload") + value, idx = _read_len_delimited(payload, idx) + if field_no == 3: # mapHead + hidx = 0 + while hidx < len(value): + hkey, hidx = _read_varint(value, hidx) + hfield = hkey >> 3 + hwire = hkey & 0x07 + if hwire == 0: + hvalue, hidx = _read_varint(value, hidx) + if hfield == 2: + size_x = int(hvalue) + elif hfield == 3: + size_y = int(hvalue) + elif hwire == 5: + hidx += 4 + elif hwire == 2: + _, hidx = _read_len_delimited(value, hidx) + else: + raise RoborockException(f"Unsupported wire type {hwire} in B01 map header") + elif field_no == 4: # mapDataInfo + grid = _parse_map_data_info(value) + + if not size_x or not size_y or not grid: + raise RoborockException("Failed to parse B01 map header/grid") + if len(grid) < size_x * size_y: + raise RoborockException("B01 map data shorter than expected dimensions") + return B01MapData(size_x=size_x, size_y=size_y, map_data=grid) + + +def _derive_b01_iv(iv_seed: int) -> bytes: + random_hex = iv_seed.to_bytes(4, "big").hex().lower() + md5 = hashlib.md5((random_hex + _B01_HASH).encode(), usedforsecurity=False).hexdigest() + return md5[9:25].encode() + + +def derive_map_key(serial: str, model: str) -> bytes: + """Derive map decrypt key for B01/Q7 map payloads.""" + + model_suffix = model.split(".")[-1] + model_key = (model_suffix + "0" * 16)[:16].encode() + material = f"{serial}+{model_suffix}+{serial}".encode() + encrypted = AES.new(model_key, AES.MODE_ECB).encrypt(pad(material, AES.block_size)) + md5 = hashlib.md5(base64.b64encode(encrypted), usedforsecurity=False).hexdigest() + return md5[8:24].encode() + + +def _maybe_b64(data: bytes) -> bytes | None: + try: + return base64.b64decode(data, validate=False) + except Exception: + return None + + +def decode_b01_map_payload(raw_payload: bytes, *, local_key: str, serial: str, model: str) -> bytes: + """Decode raw B01 MAP_RESPONSE payload into inflated SCMap protobuf bytes.""" + + layers: list[bytes] = [] + l0 = _maybe_b64(raw_payload) + if l0 is not None: + layers.append(l0) + l1 = _maybe_b64(l0) + if l1 is not None: + layers.append(l1) + else: + layers.append(raw_payload) + + map_key = derive_map_key(serial, model) + for layer in layers: + candidates: list[bytes] = [layer] + if len(layer) > 19 and layer[:3] == b"B01": + iv_seed = int.from_bytes(layer[7:11], "big") + payload_len = int.from_bytes(layer[17:19], "big") + encrypted = layer[19 : 19 + payload_len] + try: + decrypted = AES.new(local_key.encode(), AES.MODE_CBC, _derive_b01_iv(iv_seed)).decrypt(encrypted) + candidates.append(unpad(decrypted, 16)) + except Exception: + pass + + for candidate in list(candidates): + if len(candidate) % 16 == 0: + try: + decrypted = AES.new(map_key, AES.MODE_ECB).decrypt(candidate) + candidates.append(decrypted) + candidates.append(unpad(decrypted, 16)) + except Exception: + pass + + for candidate in candidates: + variants = [candidate] + try: + text = candidate.decode("ascii").strip() + if len(text) > 16 and all(c in "0123456789abcdefABCDEF" for c in text[:32]): + variants.append(bytes.fromhex(text)) + except Exception: + pass + for variant in variants: + try: + inflated = zlib.decompress(variant) + except zlib.error: + continue + parse_scmap_payload(inflated) + return inflated + + raise RoborockException("Failed to decode B01 map payload") + + +def render_map_png(map_data: B01MapData) -> bytes: + """Render occupancy map bytes into PNG.""" + + img = Image.new("RGB", (map_data.size_x, map_data.size_y), (0, 0, 0)) + px = img.load() + room_colors = [ + (80, 150, 255), + (255, 170, 80), + (120, 220, 130), + (210, 130, 255), + (255, 120, 170), + (100, 220, 220), + ] + + for i, value in enumerate(map_data.map_data[: map_data.size_x * map_data.size_y]): + x = i % map_data.size_x + y = map_data.size_y - 1 - (i // map_data.size_x) + if value == 0: + color = (0, 0, 0) + elif value in (1, 127): + color = (180, 180, 180) + elif value >= 128: + color = (255, 255, 255) + else: + color = room_colors[(max(value - 2, 0)) % len(room_colors)] + px[x, y] = color + + output = io.BytesIO() + img.save(output, format="PNG") + return output.getvalue() diff --git a/tests/devices/traits/b01/q7/conftest.py b/tests/devices/traits/b01/q7/conftest.py index 5dc476f6..160c37d3 100644 --- a/tests/devices/traits/b01/q7/conftest.py +++ b/tests/devices/traits/b01/q7/conftest.py @@ -18,7 +18,7 @@ def fake_channel_fixture() -> FakeChannel: @pytest.fixture(name="q7_api") def q7_api_fixture(fake_channel: FakeChannel) -> Q7PropertiesApi: - return Q7PropertiesApi(fake_channel) # type: ignore[arg-type] + return Q7PropertiesApi(fake_channel, local_key="abcdefghijklmnop", serial="test_sn", model="roborock.vacuum.sc05") # type: ignore[arg-type] @pytest.fixture(name="expected_msg_id", autouse=True) diff --git a/tests/fixtures/b01/raw-mqtt-map301.bin.inflated.bin b/tests/fixtures/b01/raw-mqtt-map301.bin.inflated.bin new file mode 100644 index 0000000000000000000000000000000000000000..8c7621993415e70c85a8507482960bdb435aaf75 GIT binary patch literal 166774 zcmeI534q;Ib^l+Ygx8rUBhCCvsR=HB+fch<-0LLK)+X)WwuahTaZuTY9TL>43CLp! zAflt#>*Tq2SH2`Wp%qCx;c8FwXa)x@RS|9j54_nhzjz4sP?ZoY5R{s%vyep91? zT)r~>pev?7p;o*6`aiAJ9=!f3wc5gG57cV4XFqq#=I0GO?&p`y==sTIGjO5%=}Mq0 zfvyC)66i{xD}k;Ax)SJ0peuo{1iBLFN}wx&t^~Rg=t`g~fvyC)66i{xD}k;Ax)SJ0 zpeuo{1iBLFN}wx&$w;7)KbjaYnM}HADiUbavRK)zMC#&1Bye`~rv@ocB#Um`B!RPQ zXUo4fpRSm8A^`}m`O`+2cDbvl%>v^qBGDE?9aE-f*BYEf*WV-oh_BVPF{RpQO3}SQ z5-_u=7g)AQq^)ggS5HZRExfLeumP^IjVw~T=5hRTi;TLLLIPP)ACKH4C+c+ZZ#iZeUtUB~?uVRI%u# z77{)C?5<45)*A>dOE5>2D=N$8VQ(Vt?oP9)TDu}85n%Ws^fw9cw~HAz5a zsv=DSwClDUXerYvI`-vY4cqGU*@OMxAgSe%?!~3Rd_AHzJj#^t=(x(l9MhfDp&R9> z;!v*HRaea{s1_?zlWQZ#m=bQD9YQDIt3hE*SyJmo2DmVZ7Mb+oTgs6kfg&J5WT*v4 z#)?f}jfgNMd}x3*tZYdmVRp}kXA({GHUV&=*xe~XsM zrQI)6NK<-L)zw6UVwyjt44bI)gxcJMc2{=4Or@kGOIcfy?zXE#i|do9)*kPL7S(Kh zX%dxhsdkpTSm^YT=})X|jE@M*aoW3+sMc?+OhZbLCZ;WX$E6AQI!dO}1nDtx#?yvL zRKA^*v}h``CKuX8rg*Kqlc<)odQW^BDoj+;gxb16P@iZ9QcU#II@e~qDe>CGjq=<0 zCQ+@4HeD@#-@3WYE%Js> zrt)nHX;>E`YtP*aM*ZDC9!~?Ab;_qrbDNss@g`GwPh4iJ-D9J@DnK?iWui7TnQHB+ zK4{b2HZ{lP$yB~=A-XX|XjP=`i*FY)nQHAlneL>0lc{`LnWBrZcxor7iCmvdwf3G& zchbbkRDO&sA=BDKWY;cki)u;M`{dKy+?_PBMdf!^@v&C%&E>a~+Su2zh^WB`M^$tP z1ny1WMB1d%-W@U}Ay1@yG3H}Q7E<*O;x+cB94lpwRm~!7x5!jlExBrDXn0vdiA9*e z*fNdi8gF09R74!r*_m$=H7z8Oq?WH$ggAkUj8)N^WIKtPiYRvdBZU{|7D%37bSsC*HPJDX2}UO3hcOuC20Ydo8CcgoW&-{kfFv zLaT~sIqf)IXacHHMwTo4*Wr;`PoS1LX(24MUdl}6x@=W-Ehs0Oh?{24 zB1vNcRo$&oQ`@*Q5L~+tf+kSYoJ%3oe9>GA!q{z5nKF7iN+K<4p0Djzag8gp2{c1$ z40Re;wult%hqU~Y%S+)Y2-!*ulCUQjmnX}QBj;=5t?%xOm)l6SAqmjc2|Z75W+JEN zGE(M^s!X*-yDU?#dPm7Li=yg64&(S#Q-N8gDL7kB)l=X8w38`|szv@%gw>2o`puB3 zEtHxY%`Hu#NlhUOp7>OzO|kJ{MRT5&D-a;A6!E5lOm|ktN-GJArqXqIYe~V9njvPB zuF66IpE6xDN%e}WJFBl1xhK9;UD39pLahLm_EFlRDy@>(wbt^`0*i(N+RJoYg5^3C zS+}U~p1UN?klZVZP-`Uzxz@g>+@8|kDz7w9i|j;fs0eDrpGI!H1arVn{6*F|kG<<^ zb)!A)YTv%5?VH#-FYPuLm^D_6LDWHWtXUMvV?rVwqxbikU{PsVd?v}XtA1)>i(0i8 zX*qRRJcv$AcEC$rDVGV0nzgCU4xQ!M;=~|11 zS7IkUMeU>$Ch%#Ib-SwXiR%@KivCn9j~dFty1kxO6M%D7n=*^I$dW%uQc+VGAo`hF zGOa2pT3l_IT@gj82q6R#a#CLeKundejJV#MU5m&y5h`k#(~ZyF$9f9=D?T-mUh7Y@ z5VG_*e!gZdz;w#x)EnTk|$L(jR0D(yyRMwmyMq^FYiWNN;9(rA-fw5(x@IL5r+zWlM| z)TkueeIji()s>?59*R=bnpy_kswH)qcvU7K)3KsXR9zbrujw92p(1T1Q)!aQb;1Q# z1;t!xI0sEshL!joGxT+)X)*Lwpb)06$tNn*ilCtL@-Y#VGR;>j;+Yh7zD#Rn<0mB6 zs-!USt5Eg3$f+8$D$J(wQ;k5}K{C~bw)!d>uh3||)bdJIQY@Jwk7`8Ums%ItW-i30 zwUjtH2 zj9p`;ar+8ocvK{Ws??e;R^oILHjny!B}^|DYUvUZan>v(E_!H(de8uGOow=6sOQO! zWZIzuY*DQam1!eYwJX!zrs(Kt7b-lOZ@MrQoX#8jB0(1_{7I&bsUS_QV+W3X)k@UM zgg7;Ys%_}FuR^9e`dv|V0?Xl@sJ)#z_R+;jXsV~WHgTEO#0SB~1#l;QI##B(e}`n{ zBg06IfkL_>+S^H@^tyDgOhr}oBNBy7jZKnZ)m3jtCwV$qrlOiEPW=*PmH+dnE7L5A ziMdcyPY^!4RO`@@4?|zAs<)vdJSzM&_|-yU z?xv6Qr+u0wh$3>}PF>T}Xw1G`0-&;1yg1s1akOK618w3`FC~ZcI)uurtx0RXFXdR2 z?J_l?j!QSt*4_03~m`sZ|B-sy3~KVu%9mh7WRvAjkMX z&WIuvK%^G+;7bw>@pnVDc0CKPiP`|E*3N1#E!_2TB?p#C_9qb|OiD?VoE)cxi(2oV zI_-{JM5Z!Pq1uw&-qceqk9yD~mj%W;m&KdInq3iT$4FIViYg{eP^Y=Zl`AHG5y>up z+K-CnLfXfT`gMY2QJXZvl3XF(s9_slb8{yx+xDe37t%hi*0V9`O6>N8d7gl9QMH{5 zR3f4JyXh3Ty_{Oev`rV=<>vUiT~y=EP}S6GrAA=PdZ$2$v=P>8m!#Xi4=XZ4PfDM+ z{~)LFws{Z7i+MqO?Q2|wSaZb?OYL^4E$xe}$W*4!oqU>7>|xop$zJ4`WI2A3rk}|r zwGf-pCiih%ZZD`tCG}2v9F^z{nW`LjQrQ;Dkt;8$Wo$Zd-J-fZ9gYDs+fMYbH{Bk6qoWcqIXvtP2GUxn{9h z1pBTv@Sl9_TfOwvwqz7g`83At{O0(T4GL_j$zsmYbK=Y?j zMoQGS+-4cIjf!ry^Btpna!M|eRVnAM_9>!$Id0o>k$>eQ zwQL`~xeqGI_YStNVQZ=IRh3CCONK+*`O8MP6;4i$4r`QR5Nw&OhBRgFWx~BlyDZZz z(v~gF!pUyg>YXM*1Q8;A#GbZFrq{~cDb-%~wya?m!ESepR*$RbhRQ=lral;%cJow` z`Cb+y7Y8ld?SdO4!=l;aD!PH%N{0FcwN-oCI%SbLH*zogW#qP)n`(PV(nYgdOQzBz zB2)d?^(5L#B*P-}oNOPs+O9n$z!nWJnmsX@vSeH2ESI1k`#^+EMRL0e(H79A4HLgy zG@8q9n^lvP+NMH{n<6*0cW+$a8Wv(KplypM8n9<&TI9Yrq}RF(mgz(VG>P~{tEFtQ z=VNwXqgwAVq8GVW)9pRh8rIrNXN(rKPt-o7Xtm^BQ=yJgN|Q`$6YGM#CrU2~t=Wm9 zv=`7MLQ0wDT8LcRWi|z6T0@=NH>h1hCw;w;X%;Emwj!1Km7lR|+Z4S_ymtt#9J`j2 zmS3xcg-q@E1;w`)X^Sm9bh>R1w1~cUq*#_B^=n_pR&v>uYo$!bkyDW%*Sx(@8#&58 zbz94`eXYkKhcTiQB5GZ9nYu{ef~r(6GOEd$<39gHeagldRVv>q%7m^pxV>W+oh!g8 z++WtKq4c~(?sDcxfDIS!d)ZUyiJQWFz5iI>4RT*;gRDNTAaqQ%8QRn7MEz<3^OB0kz2u^irf~lKiD$Q&Xska>=?k7*P z0CCeKDrP>4zqh?!KWSODUSqHvyJCD!BnfhBEhB4l676^d&|Iq2rTo-zXIAae?NQN8`G@~>6nYhR>Ora|J~cwKVnyK|W1$l*tUDvyavajZ=Z{rYa&EuR?PT zQKj@jv7PZ5hfH&wMfKXqbdR^i>oA6=vBzsM&H|={Oj(dJ9;9pKtNct^nO27(+P3FE zS#}U(O>GQBigNR1xZ>X%H5pf1C#t9WLZ&V|sjRdr$VWm=8DH^#n5(#5AjGtuHi$1lUv=mdF69jY?oN`~K*S>}am{5>yI ztwj{8f=e=mKh+W9tu-{L1HPiNA;EUOeJInYlFBU0RPAUMYX`|xCsK&ky1Yro2$o8t zDeyJx^&Zz#l~`6stB7i+%2vL#b~(VW?n_USC_CB;|KgAaWaTrQoHN+br!n8ca{i)QAk?nI`NMUFZ_mrF$^@(=$_tq(-#6_(nIvOy(V zeI_E)$!}y@lsv^`szOXch0Lr$*{w7WI#WwJgBVbu)}l{&q)aF5R0bx!ql z8M#Noq*3}2HO)~WP$c4bIaYQhd{UpB0o+t}>c>gtwl5N;!LCIowX=P^2v9$+jv~{_ zr>#(T!3L&UNZGkUP32aq(P5Y*r?v9dh^T5#m~YhIEYkLhs%wPFH8sk$KHY`+ZgLga z3cw^D4UV{QWK84}9lJ_m{mnO55-Vs7`T z#vPv=U^9vg)dEc}tML`MXJVx`Xy&;+t%L;&kVn4?w^f{+9_|DMp}Ff9I+ecU@`X1BTh&ggXSc{yJEE1%u^M;gw`otR zTNX)cAyuc}9J@^y><*dMw0EbROjR%K>4AVkNw5iR>T*#JZMTy#ES|+NHzCkZ zl4&g9HEx6MqTa};)piP?*QnbdSwM*a;Ch5uf zR?00Z(TR}jotlm6k3*&v8z-Z4l42WIvMDFiTG1?MVq5uSJw<{_D{WOQd%df zW>Ke#1R=M!Dn}tjEB$({ZPHzna8X)O`_6m}M#oirkvywVQ3gZ;wo+YgC%eGHnv_u3h-8 zk?F39X+o)gTV!fuKADk5p{l!iC0f#mko0+J;EHW|`tjWH5H(h}YI95~cj6s7x#BhzC-rO-*fY zmZ_Lf`cMkwBLQP&N3qST`N!3%CR1xG8XZkAUlKw~S+tUA8Vn}bnmPGfo_B02pju!s zk|8(eF-l8?c9etaYI`TpU}>r~x=l+Y0;w&e*^roU@R30KWZ-KlsKzNIm873CA8S~s zL~ig-+%cM|DciJP#G~Q3(~2^2FGg;;lALp~wMLs9rov@vQH!SKCW>1GBUzi)X_7W! zxSVrL<-$q69N=#IJUTrjT2yPLFK8iDNk8M0sYGqxX3I&Upj?%&#`VjEr+qdZ9^x`> zBT+G(MN17tBrQG~*y6ffqI_flt1`tk_fUB9w#}-e1CmT@No|^&)%r?nnM&NYt&NPN zKg5>0Ftc=16vnpMb#g#b!v;4bn%~aXN~RiDMb)CcoRKMMAynj-KFy1+7P)o!rQ8;{ z6A-J!u6#sll}nB$(WIAjH8ho~(OeHtF*Q7Bi)cozA=PDN8r$D2)Aj;Q*?Rs`l6{cI zo_fg_;5PLCvUCcb5?+yM`ZUUxj73$MB5uy5>RL8>mAo8F(v}%mME|xP=Omr&hmPhC z1Vv&MB1{cTR}hp_i90TdB9?qOog_7`1ZYhEDhsP*(BX)>r*^17$1T&UoV=NqTgH;& zs`(I_FR9w5n#kow1WY+!G(%*H>L;y?>)B_)sevSmqRAFHE>$@vxAbaRJ#sEcT)#L4 zJ<$%Fvg@E6hfLcx*>WQ>G+oGHA+!)+)TM0!IeACB+zyo~8Y|Zdvnm>_pRt=_d2yVa zJ725#Sygj%xKnuSQ!CVqp^uM#Q;=TN>kvpg9g_HY_DOQfSyaT>gpyJ|IezA*h*RML zH6kC6irWw&R!*gIc$;QPPEF#@*|$lPFRQAGSaW>IYpQ;z4dT@)N?dLn#c8x{r$mBC zD&aY4<#HR$vCVB0sq-(F%`lPVYof0BWQ5z!#A%c!9YyPN+VKfO)jTKp%~4d7stMD+ zt&O#GskLwNUB}pSBVW332qD_@};5 zMQAQB(YETRv9mU&@M=0`PunuI&BaM2YLnN#yPSopZdna)pnA$Mp%mU6pjSL~oA8#G zCzYsWPW$d6G&L12GOg6Z8)%-^p_QwSp4hL9%afChD{@ChYIv=(X#IkCasx%&rNmoG znbYQ^Ei4L0Yx3uGO#xH!LJOhh_BE;SP9+-MF;SW3ew>OTY^rmqhZdZLDsChYV2%*- zc~ZJIE^kUWuDBf@iDX7Kb;*Pb$0gCMZ4!B+A1!mDi8?ZV%eIq9b2wa)Q^q!JNn3bH z-zG-;%fI0=WwV%tGcGAbXSA1HyO7@;nYM0W$VNpexN}n~TI>m1=R4+AEoICYQ#;UQ zlhrf3V>OFJnzehZ=#{YFbdiR>B|WXQzcucfOvf`2T9-RDu2$7_Txz$ABK>WVsjT`N z<5q)ePmDX^Z$(6vjtX@ABHdLr{f!rFlIQp(Dl6B})8BacOs;_w8ueC0XzJvO?51j# z01mO5FLtkVCD4^XR{~uLbS2Q0Kvx1?33MgUl|WYlT?uq0(3L<}0$mAoCD4^XR{~uL zbS2Q0Kvx1?33MgUl|WYlT?uq0(3L<}0$mAoCD4^XR{~uLbS2Q0Kvx1?33MgUl|WYl zT?uq0(3L<}0$mC0MhX1!%=%4@#+DfeKK$>GT6)-%{`05P>K}O2fivs1eko+Od?~G% z^~MFW2YR+HP};Kjk_EF@uez6L+md@)(IG2ePcfGbT)bfRAuDgSn6pMMTrm6absLP< z%~`o%_PlvlQ!eY~e86aQ`CAvv9^Uviqt&ZcEtowtbb-;@rSD!ad+VB|Mk9;fC-r=x z(a_KbCG0PZwrsv)!R#%YXBw@1=;I4!k1QIUuPqwh_^Ac6SIinQTD|JCQr;_#)-GMY zVD_Pd2N>0A*Dsjevvtk0G~M9vMy2&ZTQ)y$gVM;N8?;46($Lw9M}KX!c=Q@+`<&}E z=0MM97R=tZ%@j13m9BLZueWo;C6zqgw59 zk;G?=4qx}41+&-9`MlANxtA@Nz4DF?S}>;P8P&o3j6sDtb$J|F-n2v{K z0|y)FcsOL`9~d37@}trZsV=J5Ue8%0UtAEih5`^_V8~cWC9Hhtr=pRGwyvoyjIe>8 z{TD{q+NF;mS~sVk2$dp2vx$}s{4vqUqQi)0jXaI$@OASS!iJ>wLFp!K#*ZmpMp@V;J)U)-{h3MN9cIe zCy2Ie{^UZ;9lvkojV$^s$!v7_wG;+RyKuIT#(DEL5e*G}fnqKjxP`*jF5N-|lSC!J zTu~b~Z3m4m|G&8|car2+uVPxe^sa@lt$&oIbPv&%&G#>arA=Xn4t|}eR(o(E=Bhum zblaAI4jnwiXmI!&QeLt)JLY~%V*Vcs+p-xn(DQq(Xn5nd7s4v`S=g+R?@He$d$xMj z_laN-MMEBGF$a2nEP3v4gfYEv_Tcbzqt&ZG13kYmi>b6}`}c%u{Tee_+A0kV{YY~8 zhQ*vW59O_!^KGMA?R!K$Tfa%PbqyqeOBQo)CoM(Y7TwQF&W7-$-Fw*Fdwpp6lkUC#u!f5sfZ~wt7s=%tSlpUO}Xz!($$@at(!H zE+!gXj?p+Y^c`#a$f8w5SmY254!?y+Z3v}tu;6me0bV*9&BQZGf$^%;6Y~vc%WYm0oma3nHNs(wk0X&a<{X(=H5Wall7Ss0Q=*c_ zlxZjkTS+6$Q#w(@wk>(5%=S+)(iv8asD|n6ghjp0=sJ`5nH5e|q%JSCFkKhmlC2Bm z$PKgo8e22yP9JmJ2F+8~FQ~eau7WUY8;KCmf77qim?&6g<~c@NHg6MaI$$J|GEUQu zD#Fe((sdS$p^=y!qDTXGmD&$c%oUg^etlwh*hQ2Xi{C0DT`I z3J_~rnanXC{UE1%ERhU74m*rim3rzxt5KXf_&Qln9ejPQrw&f@=KT|`ul3Zy(?LL# z%d&x!s5~5D5^dW4YO2|`B@H5s03w{C5-lFRm}qF|jYJsXMC;~Yr{QwJ84{765O~H;N}y>BAjzdLPRgZ;Y`*6gw?|FNahovDY7F2_FH2^~oUArR^AI<;s9=HmZw z{ggHBcG+ETl@ez3ub8!iXlMw8%|NR#joBt_@hD30JWJBLq`l~jepJdeaz_8?a;6<~ zK^{|EgcXF+brZ$JR>VldoDNxuGXYpB z3)8a_98uuyT7W%E~v^n?JLB+Erl2$Bt%fmswMLryCnx>-itO%_v6 zl$;iiLhG>Vvaner*NM*GYNV%BTQ+Yt+PVhk1x{<1eu`+>z$b_XdN3Zm^fXpdF58xT zm|||(yq3ztHk86v%z6)zp7VL>hp&4(g<;P{G;3s-2dWo9)&$icK~LtDW%D4>x;g($v|<*{5?p%*dT^HD1Y<*_X9*rA3yX*; zOOPNvOYoRD$S%VAi4I@)NUF=Mk^PDEq?>f&*M1LW2G(aF*GO4PKtn!iI-xW;{C*?Z za$pusr0JY=hQ*dQr@LQDNTjniHoz99cZ@tt=Y9&q-oi~kcn)(_A8JdR`)yMDgxfh-+ng!%bw zM&(H~vgon81GAXA`c0=es#z4K?ej2gyOZvOopeW(sMfFNA-IQW_0+Qx4{Jfm($@O* z6d+j!-({`0T+*H^k?zNmt*iCx`V3PWmFFbeJ6T6snD#r0F~ZREzLmAX!)8a)&FEJeUH$^1bU!tP zrFE<9gt2nOFt?cN=HRq2ox;hijddgjsH}cx^sip^J4CYW72UNk9IDIQ_#PuU6bh2< z2rTA^g_R_mSy`o9n4W6ls8LS#W?-z~j1y;aa%Xu4^b>^Y%@2}Wo)Ht$CBltbBiure znJW=aTqKtibK8=-Shrz|scUGctdYtZGl`K}DJ%z!)Pl*4Of4Pak`fZ>y+JIpEeuN~ z-HRGwQ6;-pz5g!j9)zJ+EKH7G@9 z6Hc0^9ENG0S{FH>QPR_1IWknzQ(GAZO7fTkdh8uWIyPmCreQkb(jHIr^BT3@7N+A} z9x_OIB1~@VE9tWbl2}Ra_e;h~I#ScK6*KGGV(QtGOqWrYHl*Vn_s)u#>C{0_HT|qo3C9^0Q`5;U0xdyF zZXYPA{lxPV6t;Hh-xHN#PI@l9Y5QV}sTOSU=-(MZ`DNdjNE8RFwNwJuDAM*shYtRu z>~H@er+co<+J_qr4nId``xhCZ!n)fv(sM;w?r0lCiP1;L8)E7N+bwX}SYo4lSo3aenZl-H4I?H&TDl?3J3)6WMcF+iq#mF6;6o%yx6u*g0}$b*iP)ddfp0nu}WDG;(U#YvUO+4@*yZon|rh&Yg#0bK)lkItgkT|<@ig(bfhjDSZ<_RmvpJt zuNG4d(KM!7)1-B>X2;#fl@F;7`YEyMr<8XZg{fwFIwkKh)hxHks!ML?OVXHfGd;8cc~2{OOs%I=nTv)coloU? zYc;0NYCEMe-T298L#Tu71Jb;yCD<6+ukNoF;it-V0j*SemNdm8%u zpsqY>*kL|h)|fA|@{|r8e5sM{pmEF7Nca6tx|(rXHh{apo(@YTvAe3tvirmeY2BPe z8rFtn(T`D_Y9>9XrRhkrG$GZMtc9&?{ax5jjPT@qFT^Rd28a{y+w`REn3D0-GBPaW&3WQL4>d2(7;CDPNsKOg;` zR!?|hVm`F|OskpPB!tY;N=^(N3N9`+kh*?hW(&_?-0|QvQrIi5cG9V`lmM7-o z0hFFrC0bru7oB*&sDam7XS{Lny#iNO|E<5JF)xt``T7Q1*G%$|lV~>~i4=N>ZgG$N6ml8=4?v#l zLkr|c3c7TbwH&hKIsH;WC?`CVqUJLh8-O{FW1ELviv z>j?Lg8YORo(zYdwEoQp^u*gWCKXVD{(_yPuJ;TEEc`%djB(|7x*on5MXZEx_e@00j+`x!SCv7@g$WJ0v&r-~~k%q}(w1(+=$w}8>a`Q>U zFgB&uKehQC8y;DMecMQ%{Yg)NrMo+RcBtuaPb04&u+s4-z|vizFIt$cuhDHrx|)$q zyq2ypW%5jIz-~zn4V0i9M7pY!MTLfGugKwslFomAZVLUBdp+qCRc8c0Z$)|Y<(`J6 zQ`;(wWz9urE)Rq5iY}dJVdx&6DUEbC^wVWXUdnsAg~_2hY{4HA$&{fbK+9!T+s`Q3 z@~w6Xo^0}aGClrUvDZdA;xJDW`DuL_hCIb4>l3>=jy9kT`fIW@-Vr9x=~M~Xtw(tW zY)JJ4$>3GeJjIMDq53kyuQo!Z#CoqcQp=Z~GnV_|>&2FT*uvD#O9ahD9sC~F~9Tg3e2}E;&xWv-lZ=_*f zo>U+6%GMkP>*g^rie-LCxj=i?M@;Q$kBMdvVAYk{fUp7N@@Q*=uCx5!l4=jKvaqaY zMrtvkXGXf}!k{-oo^oGw5fKKq5n41qKUt;o%6ybQXkluz(o;RMyZS3?B>AbQp0I6DFK;!K7rQcbeLeo}Rmw>zTOgpx#Nt zP&RGVyH`Gn0~=8zrVG6pR#2XduF6zP8ucy zRBJ68R_QS#y}yYtvzVA=^bYEP#?&V}P=b-R0Ws%CE;6|&>0McwB$P0w^|xh4S{E7S z8rFhjs)=-{84*TYE7J006QSw!*DQX=R)5Wcacg1o<~>Y)p_Xz{oxrHFFx4|yS|bfh zzwZ#sj%Q9}@M;Nq$2L7hIs>PnKcjoJPyku6=<}xO9<3a$XgUppl-iQV)H_YGz|nLt zom7H8v*2Or4EOJH&92vTA|w4ZwH(Q4E}G8gcYRJ(ZVG6a9Ja{Q4MsYu#N?!Oxa%y( z`AXVaS*Qewsfw6#JfxKFWT!B-46@kLFeR^3KOcE0+i9LMJH9S4c1)(wk}3rFUt3?WWIU$f1aqpeL7de3I&N z4c(vCm~tyv!(evWhUxF-<$xw~k)xv^57V<)IRetKbTYe2NbZ_kBGy|ipPaj`7Au^7 z3#F$u?6WaOS`HB zH9I4N;v4CGwKQ_lGvu;x*O+>e2rES-hqW4p%w+_qwo0wBW>4!@e=7d{7E@Q-a+6Hc z>D@rsRwKQq?_qK|`j#b_P77UMr0YU^^n^}Cj1pwFKyUg}5qgIzJ&B&er0N<|PvLyz z=!u|@6+KN$BOuAl$Df`u%C5tdWhbkrLAg_?zq!+sFI@$RF3EZx zcQMcgBfY-~nHi}T_$;H6fM(^H3%_G13GBxa%*!-97Os2U}Wt~RH4EwzWO5dP$ z(K}7P^4EQw?;k{^W96@_JV?UQ>90~Sj@D}~8s;;zOcdD5q`7!3()$)^Y)Z{IT^Xk2 zc_vD&Ez>0`!%AvdGd&@I1WleOO7ALIuV@%L!zh;^Bs%{h?`gSXi#>Yk_mbqfpl8Zl ze5d82gk_oOnUWN$@>CL`2Kij2VLrxEE*Dx%C=QKxC7-L5(*#E!X=U`n;*d)YB%caE)Y>FLe=;U$tYOBS*eUGSq|8 zE-Sq$hQrc+42w{cpiCRE^EyAIFd0lLB@OdlQF2QpOFCZ7fZXnCn5drSqU2{6DQvoh z$)Yjqpgcc_Q+{7ZDq&e>ZC8@Vb~R5~BFo;w!el6*bU)<)dEaW7_pK85-+-K4ZqPh6 z%&m=5+E43zD?4c2u_y99y4DP%<2ubH)l*jjsYU2uD?!6hIuUASx|G9A>r_f|C=wDx zzgtWVa}vc7ov<)bmDrQ$36{53290Q)#niJruZxaNNZ!iR6G4)Mo{%lxAHWdpLr-jIMRlitV2Xw2X;;}NU?@~Vs1NZ z9R?v0YDNTGXQcg55^m{I7$!&}h>8d*ON8|`5f&pvxXVrCr#M223AZ@ZF@;*>6(IV$-Hj}msR)^;WIirJS;X3r9(lME#pl4{+QATuHvJ`pC< zR}kt+B#(D!nC=*S&QX#{KEiw!)i9rL(@rDJ%t{#M1Ch?xd>ZDbKT4R^t?fA-k)KOx z7$)TFB2SmKhIv0Ixqd47xK)B;P%hrn`m|3;ZYMMhOJ_@`gnJA`F!n^=?;6v`yOOWH zmAv1TdyCGzL>HOyBKIhO^R4l5*m8ivSwTEl#m zpoCgm2}+Qs^|X@D#Y(;|P)c)$-VOA9e)TE#I?T#!ll=N)dwNB5kQ5Un3O1|bxXWMCB zQA%}@eH?z}14}D)k#}g|7eHm0lm1=?Gl|7ar}cUVPM#iybW>~7ue2afi>YHQ**e*2 z;JL13+2t(g1G)>b5st>_4%*s~$y%%A>v<)&^NG@WUdipX5>`kHRAx%P8&r}9JvB_{ zVn4@J^4+JB?@Uw7$5J}kPDL&lgi>qy#UGxUbJA0sbYh*(W|E$zH!?V3GN`6YC8U_> zX(_LUHw581@l-Qe#a<*9Oiy&BH!qg3vn-~P9ExZ<@nB8+POEi3M={b|(z%;F!GP0A z^q7U|`I2;-wpLr@l%C&Bd18U!r+(=PT0f`NFeiP+(@D>X{0uksU&>P=MXfO`^`yq? zB2`4#tBrJylVY@<*gxpX$_R?1Prm1LnbdZ@w07A4Ri*t_p-cn zC5#nIrzm+;R)UPT2dX8m4FBo=$V|bQ&H=c7`yoV2wr37tvO2v2jBlm2qZZI;IL zTI*dJw{?0qx6R!h{nfXZuJ_f%bfj^F`#B{gi<0i0d6>=!E_uBp=Dni7l#qd~d6txa zsbpa~cX+xa>vJqjN4RSb7U4Adb(ZlkJ<0cT_VjG;YRg5*@7d^Ycl;g^rarxMZZWZc zm-C5a`BV$!@nUU*l3Or+D%Z=?JTY@wOeK=;d&JJa*~0XG2i7)5`h2pNr7-j)J=5{9bXNH`D?yG1ao&?i&xyR&=yG~u z3EetB?#}61rN`7$9KVMt$8qw!hLxb?qY>+n!b0QJNPZ5YvexIUT+8(?l!wVM$upk( zdK9yvYmeR?@VcZY!&8g&8Ee;M?IYJ7B_Fq<95Ksa0ZXLg-R+W+w@-fq?xd@9*9l#z zI;kyp%b=s!>r#$>wXJefK;@$Mg#FjX2*>2nM&~ANj+Wily%Q7GQ;XAf0gtu zsVKh2RI7p2c20-B_4S#Keo<$QnRG&*bx3zmp=`1)mlBXJ?f4R*1Y7-TE`D05gwCh1 zbmFNekfQT*uhTp=X4=iCu(X?(-+p{Z?(H@#rdlx6(?|@4tk#TlO$4(^1hpheZ#I#4 zfW2RKM7LX*+^h}K)hO1cIo&b3Yc)a@iKKfVi4;@MjJ*WR2y*(G!q7e<419Vgx@@d5 zZf}zk(sTYLrF!~r03NWIvN1ym+lcTW2oZh>a=qrMG4W)oksKqyy7e3B87_{djZ)3# z7-`M?9nhN237sq_y0a~&Eah<@A*GYWJM8>pEllqi;au1#oqs;gNH!-CQw&Cs7$KZk zrPzE?xA4ya6JWpl?BU(zl>HVFsd@qsuQLD&K;R*-4%U zOFE(CZ$Z~Lq{G%(i%Qa%9tIthr^Eg{$9(5P=-{6j^=y5YJdKuU-JEyJ6K8*7VI^S+ zB2TJ4A?JekpZQz*_07)y4sTc+$untrt@dGgo5*7CI3n7kz7# z_d8}-lG&Ec*V22>v7VRHii51Y9dmCaf*F@522rkVe(LW; zZ%H_gvY4B;|3sd^Otg0C59JK$VHURXq3_Exdk;4{eBF15v<)7nt;HjL7PDvTS1C_C z?JDCTm9TEkT}1fpIFY{944W3qW%a6?MS_V|%))!}F~3_F_WeWyJ$S1b%$Up%aw3R# z@Hwg0rMW|Xd5L%TVFP6`4_`MTI`}Ii%>6_wW_?h!?w1y}@*%vr%n3)WM9T(N%URwp zEar+?cyk%fO^xJr4?)8l-yjnFxrNEk$}k##W+X3FKtKG{2-^xGc|CzV7i(eqMn#x# zBl$5n>_j5WaWaa3WMT3{)gXNfdYV(^ybf9A}QZT*3f{+$Yce`NVK zN&Wj4{#Ho++ZWhiSUUZ?7*3dO{M~(0I%X0qUM(h87ov6FG?JgDLSIIW^hrZ3tBrKU ziGu+n-EYD?qoFBT6xKUjI5-)y1ZjdYcVm?lA$ zCzfSKx{^zG)#W$mSl7Aa<<>*=k?va221}<;%A`B)>G#^{KCJ8*@Kjm4XOn(+jbl$s zr`m(TYoyOW`g&g1<>`6-bb1bxV=?t~1j{)iERkhbaJ`Y9xR93PtP+bm3&Y-Cp2WM? zNL~tznx(O#qsrebt0T@yN28Pch@8{?2@y7XM2D>WOR>8TT6tJC7bEp=>2F`?->mTw zu5XWZTcE2ow;KAkMJIV#560j3tb{F_&m@wc`bHSkfvi;eD>cbxtz8OB>-KEZcC5(U z*6GU1Z6r==$bRa|$}R1ogYP9PtnadP3%z>PzmxUGxfId1C0CK1@7anu$E`lLa5N(f z4qroa$MDAWM9>MEWk#1{P2_V`t#*^Fbbf78+A()C(X5f%X^vYw`XyN<=-x?e0oFE7 zP#o#oG;gk$^);f^tFU76`F8EnZxd}@^F5lG^*yX<1tGtn#CjsF1oRZ#*CBF>!(kRbx>CK4g-L80Hi{+xL zE`L9!#>50dVR*dQNLOn(a5jQ*7RjeDm7s@Vc9I@TF%KQwN~EhG57RzMyLoBt9ipwe zyOzHjffcUPym`0E2)M~gS1oYD{*S`8EzuQqim7jU#euA)+q4}kanEzzoKF#L+J2Rc zoD_52oR3l1$Rg;xryJh*0g4IhMl>{peTT;^-|dQnNm-SoGl4@^UP`1pA`jEQ6XT>K z+zE$|lumxkA9?C}$z#gvfFewHgC2&-Tt>g1mFO7+k=iT|)3;$e>6@~hhBrQ!(&>)Y z!!Y?!nEYfn%G33d$Hbumg<;!7gxm5&YS}%V?%2IW7_y`VIzObDM4svXFa3RhX@Q2N zHBoveC8k6Q^Ct?@>D}d7>mJk-Ya@O86FNVK;kOD-dM1}nu+rZ{agyU{IfJ)!Y2MVc zu5^dd!_vE2QcO80#NO$HmJZuHIp_P3k)FojILb(l2C?qZIaS_CxxbvIZL%(cc^8Y^ja;UfnPl_!05X4c=F6Y2T9hv})26IL@m zql*Q@sgZ~2Igyi|3_0n^kdvMaIiWF>%YfVo^)T2vBE6gBVY(7`(!Yr3q#Eg@>j@`a z`8zEh{bR9wNfJ5&oOC_mr0WSM`N@2wlNXl+$q)U5bZ6t~^lv{o$*W2tCf4&r@&c|1 z+q$Mfr0P2IV~H2{d7{>>ZEqpX?P=S zveW8SA16Y;6RDl|nCWlQrGC&87Z209!)c%gCoWEd!?zF(Z`?vOvgl4CY|Mx@ZO6Xf zbHNl%q<=ro!?5+EFrBs2?o({mw|tc+QhYllQfY|=S`1^16vBy_ZfSb{vy>$pU84bzqHo}9Xid4u4a>+=ZW0FRb2FZc-Z^lRN1h_4&K(|xEspGH zQwcif2tD(FN4$CFcQ*82)_>dQW{nU>i=*QK24&3jI(@#J3u!FvJz+-B2YtQL<#8DqQV`2Yi zp7cN0J%8H2)?OxYdM(bABF+B8qf zEX8?*#d&hXdEEQYUC{sWAAWk^`43NV9%*r&5^?VM>LZ`szvPG4Z}74XusBbRIQReh zpBMHY`PnBf^g8!hoTo*cyB|1sLH{cryW)DU&!a5P(<9Dd?>b>&|39AkJM+D)11-)o zB2LeYFE8x>*$Y-}NOgXE{dW(Xp)&l7h;+^S-nX#-l{YR;g`G6ib8Kx|ZTiD%`_<~d zGfiY4pNETgy7B(C+QTBGra?1mNJXFhYq(OY)&0`%xbKXs{tP|3e|`P5zJEEW4$p?{ zxh{LI&z>8yXJhtk%AOmu=kwX~h3xra_S}>`H)qc+*>h|5Y|fr7*>hX=+@3vmWY3-1 z^Ck22*1tTh?}Z2bFFpqx*jxX~w7!3JxNBP9iyZD|*vfDZ!@UgmF?^Nbeuf7azQ*u% zh6kth9pTj(W%ve1`zFJ;xIN#FmhNBwjzA5a_ucIIUiN%Hdw!5T4`t7`?D=8#{3v^V zoIO9uo}XsVf0(DY{xhx)AjJD~ZqN2hs{oRmE$!_!+wO-}K`QO8pq zQ2WyuPG?w}VE;N=yzHR;QAfzZJdlogAT#qoq~?K;%>%KU2OVJ^bd7n?S>{3anFk$e z9(1vJ(COmotwRl#dA*<j0SW02uZFnEL=s0s+@De2(E7hR-sr zWB3firx`xQ@JWWN89u@AafYiHu4EWt_!z?#3?F6q2*bZKe3;=w3?F3p0K-~_H4N`( zcpt;%4DV%l55v0|E@OBX!)k_A4DV!k2gBPL-p24&hD#aV!o%#%9P*~HJNws%1*X9S zzA<~=kUf`V&+D`2;_P`{_FR-b7iP}|*|Rcx&d;9nvgh3FIVXD>=9y7HTdOmp{@Uc8 zmE1FvyCS*Ellz+FzB;+DO76cU_m#?o!0f4?;Kr?|e!NdY;?J{_`17|4`qFsY!W%-C5Ol-{3rRcucv;p zuLpbTr?`Jc{Z#lLe&CGyX~{i3xl5C~EV*YS_vOibMRH%6+j5zM0hkH`Fi8Yp`Ut?p5`ZZu0FzMwrl|l-U;&ug0x;Yi7Gys!p0H)mlOvnM4ssk{I2Vi;+z{DSbB|!id3jtV01Ym&?fTc(5>98pI z7c(jc-#@KqhRiLOi3^=l7wV)gG)Y}3k-E?yb)h=yLTl88!l(;fQ5R~WE;K}4D2KYx z3w5Cq>OvdTg(9d69Z(nDuP%CCU39v-=x=q=)#{>`)kO!Zi@sGC-HM_P=zG+`7f$cl zua4@kFy~Bj&NAn<=A4a|BT7Rg0Qa2ao}1kBl6!t~S0?v@(d0gu+^;A1Ysq~ex%VgctI54Dx%Vdbp5$&#?%m0~E4g1u z?w6DMrR3h3+&hwcdvb3}?v~_kPVTMAy(PIfC-LVRN$#5ZO^d}9v8H)cS5WB$W8W@epS=KK;4YTbMAM`U1L4Lbw}W=|X#JaJ&^#DS3$2j)#27&dWW(!_x=6GzM# z9PGEm!5&N;sJ1xQn{$IX8_hxAO4yC&eBPWdnDa$*ZZhX)b8a!`R&zF+v&Ed-%(>m1 zJIuM$oG+R4Wpln_&Ryo*ZO&G6?lI?HbM7QFdoFg=vam}9C+~0MHMk+$;Ee?^A2;~ZqD1x zd8;{>nuEDf5@4zn2Q#NQm`KHWqd9Ld=Mr;XZ_dT$yw049%(>8<3(Q$*&iUq?XU@6i zoMR3~|1y{eY{Kx3Ef~JB0mC=8U--u63*Xp!;Tsz-d}G^%Z*02ojV%|xvEjluwp;kd zW((igYT+9jEqr5}g>P)K@Qp1NzOli=H?~*!#^ws&*jnNHMEyQNzmM1NA^kp1zmL`L zWAyuI{XR;?{)jWKkQ*Gm(i^{@HaNY~LjmZu0Q6)4dN%+)9sn%}fMx_hTLPd#0nn-d zXj%ZYF8~@D04)uG<_18U1EApn(E0$3gaC|=0F0ObjG_RHtN@I*0F1zZ|6%xFhQ{>1 zgI(6=Oz-;xhDS5ZW|+fp2*YC-9?S4JhQ~AfUk6m@2@HS8@J9})=v;~KfUiy9ab`2z;GeMMGUWFxR~Mf43{vxf#Hn|!whd?cr(LW7%pXaE5q9u z-p=q2hIcZoVpz@aE{4k(-p%ly>3x6Z5`FLVzCUO93x>lOp2RSZ;mHh7VR$OT(-@x4 z@C=4$GW?|jYW*yR0S7d0z5|-QfMFrSvmKD;IS$DBuN)wdzjnBMdf#&$-pBBMj<$wj zE$8+DE(ak|13=Ub8APoC(H@i&Ed_)O03jWu3ArKbkSJ;p@Ii(TF?^We-x)r_@KJ^< zxX_O=jBwqqdGv0Ms%7LnZ)&DFDMP00S@pLo)!wIRN%80G2NRHZcI!G5~fo02Vd? zwl@G)IRN%L0G2!eHa_4@444-p1T#he=8^!+G69%>0x&ZLV9pA_Y!-ldE&wxNCp)qvvZ`uj2?icM*tQj0a&gCU?CHLB~AbqKLJ=41z^DxfTdIb7FhvUeg$A* z7R|q3rh1~nQ0y@KFxzAsd=zaH4m1r z=E0&?Ji644>fmZr6cB?J31c_|VxR-U1fZZW4uCKls7n|WK$sdp7$MX>%o89C7n&C) z4G_i-5M~e%1`-*B=>&vPMWJDCQPD8OXiu1EKp1a8m~}u*ZGe~x05P>eh%vQcTM<(m zHXIRxO<(|yO#*O~61m}cCCd3a*Whbhw+Fb^*u6)4u%i#SkKta1dpH9?G#e1j21IoL zQBgoN0T9gw#3YVFWAy}xbrtINRG%bJ>!&y%Bvxri6M(D(wlds3tuK~&fLH(mVrhsp zv1kOuauQj`LKB6?Ivx-!d_b)I0kJDU2C-iN#7+VbdkjGAK9E7|OVI292qXX<9DuG5 zfKmiNT_S5JQWOe^>HwmmfM^0B8V9wEW&=VX(B2RgAcP1d5Aj0%L+}_F(Ipr%0T@gH z7^jg2BRBwKJFRDv=&FJ~9)ub769ruzgsJzF zL70_46@&@<(?P#1=vUMFJ`-WTp4PW62#bTy7IaNPpDXCvg4PEi&kaE+@46t=<@zAh zlL&3NA;QqUjRkEAf?RG4f;>N8&=-Or`7ai9Q$aTeA(vZ%Py&U4C>?4>d7{=-9*F9K z_EA02b|T1y2r{E|Aj%U&c5^{n3c9VJ+Y7p*pgRltQbAuX z=qm-?RnXl9Z7t}Yg6=KozJk75(ESBHP|(+cAeXNPL4pqkp^u2re?;h8B4_~-G=vDc zL?u86sdQKdB3KQoCoBrJ0oH~HmWX1)N>MH#Di1_;LAudsAM*T05XvJ$U5HT6Z$?bC zfe7s*LfgL;F(DTs$n4t@26+-e)v9s^Ntf~coKqy-?-5D;k(h%^gCS_dMH1d+C4%|)7wMHgu~R$Vj#uKxCc>JU2+M6Eti-9-Sfo>nuzsi3-dE7Q1>IB7)`IRX z=&quKuN0+&sAeFtC`d<^2ze5rJR;PE2=ydF8_3py$VMV2*;eF2gc68QIuU9{gjy4Q zxv<_}D(KFF?kLjTUeIktJ+~BX*j&)9MQd*k>vxC zRoqxuOb}U95LsH#Ibor1nAUf0LFWZsKdtZlpzEgftqj^Qt?z=M^#xs9(C4Q0T^KR1 zDd@8Wtt;p=1%0}pPZcGAsAeFlHHcaSqShi$k_3pPgwjcZAexUrBze@DdId!D5r}#d zt)-p@(R>8CkcNOrdmus5ED&iOWK9|gqWK6!nhYW>M~~5b1ftP_zNL|aey33dqWK6K zLZcC3WTBA@5lWz#s0$HVM0uj^M354d4tWxxAE=(_KO*!rwdj)tU0t;O69s*|kkVBJ zU0KjbK_4q}0a59QNi{<*)CQD5twrfXs2NEKwI)J~NV;e(5hOwO6H+3A1ZjqabcrB& zvdQQbBJ>!|f9Opj^fb+C&;lZ82+eV@Hbk&QM6hQ>u!BUfpG2_GM6m2MZz7ClJfx%j z3i2dEc|@oS5$gH%Sks~nPIY-m4f~^q9jLws+;_kGuyahI_qp$0_hAQ`Lbtl_Zui~g zzOT6N%kIODIHkSQeRsGIJL?pBoBOu7Z?pJjO6_kgT6RkiT77d6Byv+hUo7Yg1%1At z8w=W0(8hvpDCqhi^v-ob=(!C+=v5;0I1#jg2&)AmtSgAH0wKa$g$U~yBCK(Uu>K*! z+K342CL*kolUf3c9=( zOy%ED<=<50-&p0}T;<aCU;3@+fH|)Zs1iO9T(Q6pPMGb@`RTdD9M>f$0Oj z^WC@7eHXazLib(dzSp_$V)wn?eV4fJ4eooR`-a{3CilJBeQ$B!rQ(}eM=Rc%>#Z$( zTM#s5%iw0Of*#L4@%_gkePKFuFb*` zo#8ZwQyETSIGN!jh7%c1U^t#(DEh3oeq3~?fa{WgtC@gnpnxlN zJ|76Z^Ilg5gDdK7xDe{~Ci^z{kCSn}pu_ zi~8JJ^wy6ELn7cdrnmm$Fh9NZ!9G}}{9cp6J{YS2*suVYwhTx1!QutL5C*_LW*F>) z+05|bK3LNXNAz7B@S;B0-W>9xzKa+xWVj&Uh(6f$oaP8I_W_s%GQ21z2$7D@9RfbB z^Z@dN(*sH6nWzVX%2QMi(UO$LGFD%5L zR~(XmT8f-;#q9bc@!;2gP3wD^_u7BU%U5z2{*SzXCBy$t>pR8)&($64fG6&bbHKBB zLk@UK?|271zZZZf`%dr>Jo6WTrvW1xo)e7R@C0F$gJ%n)26*Z)0M8=^;7P?ugJ&26 z@N{Ej4TwVV5M)#bk4K*96~%*-CpqAe$_T;3mXQV!xdEaaK-2(FYeq%!9A}6ZPk094 z+0Otx6&ir&MFa38X#k!v4Zzc<0eCJo3dIwv0eF@*08hC_Me+P=GyzY>M&kg{Y(NME z5TZf`C%fDLr!bt#a2mtu3`-qQw`B~mv_{rh(R=HFJOX;_s7@Xl3VF=**74YH9!v^( zlquxlr;x{`LLRURc_b@jTcwZdx4`37F*=!;_woQa5 zx`{qo5S|0~d2psYBJNY9lFyz>KCvp%k{{`OI-Xg_Q|Gkg2l*tfVLrdltm9#IpZb+( z$&WB!FlZPp`4L7-evq$pG^Q_tlrGQHr;;z7lzdgCIHKQO!V9Ys^{HB1~u0S`bMB zF-c0ug(Qf&kaR&Lc@Xsqhmtf~tT~yG8K`8HnAk<}LLFWgd*5?Hw=D9)0<(z^VnR@Fe zJukC*>*&l_fT5pbX$FWz8z7c*fLQ1OVhIR{#UUV;jVLD;ln9BXCR8mJq0quuok23>F?b1w{ZG&9&>IdM2`R#h5x*vS+ zaNj%Kx5|C1-S;l{UFN=byYD^jd$0R0ci;Qm_kQ=SNj@xri}PGuCu0F3Ljod`10r(- zB2xt-GX^3P2O{$aBGU*Wvk4-T3Zi8hYV8N5Sh!E?`)D3MSh!E?yCUdA1%0@ne=q1G z1%0%jD+cK= zy1Ka8jz>0bjW9g2L4-#(i15e;5gyqf!Xq0*cw~dQyQ5!(iJ`lAX zL~=o%Br}vp@B7e4 z5+HflJ|fseBG}4INJsV;MAjcA(98g$Sp!5f3W#PK5Y0p&nx#NAgMnyvL+Ld0foN7l z%v@S#mv}Qz$Xgvv{wI+zxp&(kL zf@u8;qO~oE*1aHF6N6~I40>f-ks=-^^dCe9oSQUng43Hr+yUqk0$>y5znuIl$bTl~a~AnuOa8MxzP{Qxem0FKHUjW4N5FafQ)@f| zk{jAv$I~IXb-i^wI1<0r#xo`HD{ee)62I>T#4o=A@tbhKlag`ktpnyUJelDs3{Pcv z8pG2Wp26@;hQDNZ7Q=u8+B4q)EnVOM83aH&0gzj~ivkjTwx@xtpTqE14tP@LuNj`p z@HY&L67=ACn#JV*Tlc@#U&hgcSN;7zy8b-!|2_Ht!To3Xn?QQ-g23m~^(F2<(_bpm zgVzWC6J39S`&an;N#rfu|B|lHkT;d;8^H6My7Qa6^P9Z$o4xa!zVn;E^P9l)o5Ay& z!tHeye9=sGW`rvfxo74S0DLr_7V)W(d{+1NUGy3~figPNZJ(co2mC8DW>Tn9x z?G&o>Db$vesf{O7+fOE$oJ6uZkz{xR`Hv_65XB#&e1<6hAu9Jcs?V`h&tu4cH2IGr z|I5h#Qu4oq{6~_1kYqAQvKk~A4w7t-B$>a2{4XW{%czfzBLC6Uk4O27Z)7UWgS)5x zQhx_dPyHq2Ka%`|c4{O5~j9F7f9{P5gPblYU8a;u-(>$oivj=z0A#I<~%nVI#vPh8w5( zx%W(Yp>QKsNT72HI=7(n3Oc`_l?7c;(1itERM6`Ry11a%7j#KMbo&9deq#|fT+o{e zdUHWk8UXRFr2Co;Z%iaEm6I`MoI22yx)EAb01DpkWxb9UIF&i=^A#Y z$%nmY@?m$GeArngA9j_=haF||VKIpp}f_{-zhTaiDCyAi1M9^g-=s6L_01?Iq5ylP?#uX9991+GN5ymRbB^bv} zy>h~Pc3SfG)-n78U>O2nGy-5x0zSn6D-x5fc+(;N`D z(*aqEGvx*Pt`;-pJ^G|TAX0k}4G^TG1qZUE1qX;093Wb7fM~%1q6G(t791d2aDZsR z0ip#5h!z|mT5y19!2zNL2Z$CNAX;#M+`!J1z9r)eBEt+xkkN*e$e=@Ah@eG8P%a|q z91&EJ2pUQR#U+A16G6?1p#4M`6hs&+MBgjuy9Irxpl=uSt%AN;&^HPiE$G35zFyGR z3VNWR`wRMNLH8B?aBtB^AnHF5_3b@Hzi%yc0z~?9ccDvn74(&YzFg=ai1ZUgx{GwA z*T|CyD4j+)YDR=w z6QM;!Xe|*WK?Et04S@v7zJSQ~fXFU^$YvoIvS%oPY#mA`Ld}R!Ya+CW2(5Md*;|Js zvT5zDLr7tC0bz~-VW-ETCLWBr01U?fjMM-O z;sA{A01W*AScL!>jF|0UOVT`e#zQmlm$h|Q9B0Qj@ImrMhD{7NGJKxl3k+XmxQXFr zhFchJW!TKHh2b`a+ZpcQ6Yo12zQh3K%SQ$J$FBpRdig-1w+_fB4GM8ej*xsdp&8@@ z4E?*>0Vp&dbM)2$`TRp6ABY%0hbV);=%^~?bN<;ENl?o&d#v$tf$RGgy6M&wJ^K(Ex zV(YB~@_A}+9lf0oT@~^Px<>muuN%_j^V{A!%E^c5D)@ZjtAfwR=nDVn8K4*P;jy+S zpQI}s?$OY=e`0t6!#^k3U%KA@WIU>39#qggXpniJ)aHSzn+F5HJQx<{!C)~DhLCwM zu*`$uW*!Ve^I&M22LskT7{=zo;5HA2ym_wA9<1H%%7*M&Z=PQHQ(U=83ei}EgctxJ zAV7!?5JClnxB<}-faoGXbRHnO6%ZW`2vq=tk^n+|0HIibP&q&-BOtUH5PA-X5des> z0Wrm>0mOI$#7G0g7zD)V1jM)n#E1sOSOo?5T<)efpXuICYh!_S%D|D!V(EX@9|{%XSl__r;& LXF)&vCC>i`FtzVF literal 0 HcmV?d00001 diff --git a/tests/map/test_b01_map_parser.py b/tests/map/test_b01_map_parser.py new file mode 100644 index 00000000..ea35d684 --- /dev/null +++ b/tests/map/test_b01_map_parser.py @@ -0,0 +1,45 @@ +"""Tests for B01/Q7 map decoder/parser/renderer.""" + +from __future__ import annotations + +import base64 +import zlib +from pathlib import Path + +from Crypto.Cipher import AES +from Crypto.Util.Padding import pad + +from roborock.map.b01_map_parser import decode_b01_map_payload, derive_map_key, parse_scmap_payload, render_map_png + +FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "b01" / "raw-mqtt-map301.bin.inflated.bin" + + +def test_parse_scmap_payload_fixture() -> None: + payload = FIXTURE.read_bytes() + parsed = parse_scmap_payload(payload) + assert parsed.size_x == 340 + assert parsed.size_y == 300 + assert len(parsed.map_data) >= parsed.size_x * parsed.size_y + + +def test_render_map_png_fixture() -> None: + payload = FIXTURE.read_bytes() + parsed = parse_scmap_payload(payload) + png = render_map_png(parsed) + assert png.startswith(b"\x89PNG\r\n\x1a\n") + assert len(png) > 1024 + + +def test_decode_b01_map_payload_round_trip() -> None: + local_key = "abcdefghijklmnop" + serial = "testsn012345" + model = "roborock.vacuum.sc05" + inflated = FIXTURE.read_bytes() + + compressed = zlib.compress(inflated) + map_key = derive_map_key(serial, model) + encrypted = AES.new(map_key, AES.MODE_ECB).encrypt(pad(compressed.hex().encode(), 16)) + payload = base64.b64encode(base64.b64encode(encrypted)) + + decoded = decode_b01_map_payload(payload, local_key=local_key, serial=serial, model=model) + assert decoded == inflated From acca3428f984a504070ea373aa2b16902c2f2aaa Mon Sep 17 00:00:00 2001 From: arduano Date: Sat, 28 Feb 2026 15:23:58 +1100 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=A6=8E=20q7:=20fetch=20map=20payload?= =?UTF-8?q?=20by=20map=5Fid=20from=20map=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- roborock/devices/traits/b01/q7/map_content.py | 43 ++++++++++-- roborock/map/b01_map_parser.py | 1 - tests/devices/traits/b01/q7/test_init.py | 70 ++++++++++++++++++- 3 files changed, 108 insertions(+), 6 deletions(-) diff --git a/roborock/devices/traits/b01/q7/map_content.py b/roborock/devices/traits/b01/q7/map_content.py index dadb29a8..efbebac6 100644 --- a/roborock/devices/traits/b01/q7/map_content.py +++ b/roborock/devices/traits/b01/q7/map_content.py @@ -3,12 +3,18 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any -from roborock.devices.rpc.b01_q7_channel import send_map_command -from roborock.devices.transport.mqtt_channel import MqttChannel +from roborock.devices.rpc.b01_q7_channel import send_decoded_command, send_map_command from roborock.devices.traits import Trait from roborock.devices.traits.v1.map_content import MapContent -from roborock.map.b01_map_parser import decode_b01_map_payload, parse_scmap_payload, render_map_png +from roborock.devices.transport.mqtt_channel import MqttChannel +from roborock.exceptions import RoborockException +from roborock.map.b01_map_parser import ( + decode_b01_map_payload, + parse_scmap_payload, + render_map_png, +) from roborock.protocols.b01_q7_protocol import Q7RequestMessage from roborock.roborock_typing import RoborockB01Q7Methods @@ -18,6 +24,23 @@ class B01MapContent(MapContent): """B01 map content wrapper.""" +def _extract_current_map_id(map_list_response: dict[str, Any] | None) -> int | None: + if not isinstance(map_list_response, dict): + return None + map_list = map_list_response.get("map_list") + if not isinstance(map_list, list) or not map_list: + return None + + for entry in map_list: + if isinstance(entry, dict) and entry.get("cur") and isinstance(entry.get("id"), int): + return entry["id"] + + first = map_list[0] + if isinstance(first, dict) and isinstance(first.get("id"), int): + return first["id"] + return None + + class Q7MapContentTrait(B01MapContent, Trait): """Fetch and parse map content from B01/Q7 devices.""" @@ -29,9 +52,21 @@ def __init__(self, channel: MqttChannel, *, local_key: str, serial: str, model: self._model = model async def refresh(self) -> B01MapContent: + map_list_response = await send_decoded_command( + self._channel, + Q7RequestMessage(dps=10000, command=RoborockB01Q7Methods.GET_MAP_LIST, params={}), + ) + map_id = _extract_current_map_id(map_list_response) + if map_id is None: + raise RoborockException(f"Unable to determine map_id from map list response: {map_list_response!r}") + raw_payload = await send_map_command( self._channel, - Q7RequestMessage(dps=10000, command=RoborockB01Q7Methods.UPLOAD_BY_MAPTYPE, params={"maptype": 301}), + Q7RequestMessage( + dps=10000, + command=RoborockB01Q7Methods.UPLOAD_BY_MAPID, + params={"map_id": map_id}, + ), ) inflated = decode_b01_map_payload( raw_payload, diff --git a/roborock/map/b01_map_parser.py b/roborock/map/b01_map_parser.py index ecacf829..f5a477c1 100644 --- a/roborock/map/b01_map_parser.py +++ b/roborock/map/b01_map_parser.py @@ -5,7 +5,6 @@ import base64 import hashlib import io -import math import zlib from dataclasses import dataclass diff --git a/tests/devices/traits/b01/q7/test_init.py b/tests/devices/traits/b01/q7/test_init.py index cb16299c..64265991 100644 --- a/tests/devices/traits/b01/q7/test_init.py +++ b/tests/devices/traits/b01/q7/test_init.py @@ -16,8 +16,9 @@ from roborock.devices.rpc.b01_q7_channel import send_decoded_command from roborock.devices.traits.b01.q7 import Q7PropertiesApi from roborock.exceptions import RoborockException +from roborock.map.b01_map_parser import B01MapData from roborock.protocols.b01_q7_protocol import B01_VERSION, Q7RequestMessage -from roborock.roborock_message import RoborockB01Props, RoborockMessageProtocol +from roborock.roborock_message import RoborockB01Props, RoborockMessage, RoborockMessageProtocol from tests.fixtures.channel_fixtures import FakeChannel from . import B01MessageBuilder @@ -257,3 +258,70 @@ async def test_q7_api_find_me(q7_api: Q7PropertiesApi, fake_channel: FakeChannel payload_data = json.loads(unpad(message.payload, AES.block_size)) assert payload_data["dps"]["10000"]["method"] == "service.find_device" assert payload_data["dps"]["10000"]["params"] == {} + + +async def test_q7_api_clean_segments( + q7_api: Q7PropertiesApi, fake_channel: FakeChannel, message_builder: B01MessageBuilder +): + """Test room/segment cleaning helper for Q7.""" + fake_channel.response_queue.append(message_builder.build({"result": "ok"})) + await q7_api.clean_segments([10, 11]) + + assert len(fake_channel.published_messages) == 1 + message = fake_channel.published_messages[0] + payload_data = json.loads(unpad(message.payload, AES.block_size)) + assert payload_data["dps"]["10000"]["method"] == "service.set_room_clean" + assert payload_data["dps"]["10000"]["params"] == { + "clean_type": CleanTaskTypeMapping.ROOM.code, + "ctrl_value": SCDeviceCleanParam.START.code, + "room_ids": [10, 11], + } + + +async def test_q7_map_content_refresh_from_map_response( + q7_api: Q7PropertiesApi, + fake_channel: FakeChannel, + message_builder: B01MessageBuilder, + monkeypatch: pytest.MonkeyPatch, +): + """Test Q7 map content refresh wiring through map list + MAP_RESPONSE payload path.""" + + fake_channel.response_queue.append(message_builder.build({"map_list": [{"id": 1772093512, "cur": True}]})) + fake_channel.response_queue.append( + RoborockMessage( + protocol=RoborockMessageProtocol.MAP_RESPONSE, + payload=b"raw-map-payload", + version=b"B01", + seq=message_builder.seq + 1, + ) + ) + + monkeypatch.setattr( + "roborock.devices.traits.b01.q7.map_content.decode_b01_map_payload", + lambda raw_payload, **kwargs: b"inflated-payload", + ) + monkeypatch.setattr( + "roborock.devices.traits.b01.q7.map_content.parse_scmap_payload", + lambda payload: B01MapData(size_x=1, size_y=1, map_data=b"\x01"), + ) + monkeypatch.setattr( + "roborock.devices.traits.b01.q7.map_content.render_map_png", + lambda parsed: b"\x89PNG-test", + ) + + result = await q7_api.map_content.refresh() + + assert result.image_content == b"\x89PNG-test" + assert result.raw_api_response == b"raw-map-payload" + + assert len(fake_channel.published_messages) == 2 + + first = fake_channel.published_messages[0] + first_payload = json.loads(unpad(first.payload, AES.block_size)) + assert first_payload["dps"]["10000"]["method"] == "service.get_map_list" + assert first_payload["dps"]["10000"]["params"] == {} + + second = fake_channel.published_messages[1] + second_payload = json.loads(unpad(second.payload, AES.block_size)) + assert second_payload["dps"]["10000"]["method"] == "service.upload_by_mapid" + assert second_payload["dps"]["10000"]["params"] == {"map_id": 1772093512} From b4fb0d4c65d85c8d37c1abd8a7b0579a8a600481 Mon Sep 17 00:00:00 2001 From: arduano Date: Sat, 28 Feb 2026 16:16:58 +1100 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=A6=8E=20q7:=20add=20map-list=20fallb?= =?UTF-8?q?ack=20and=20error-path=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/devices/traits/b01/q7/test_init.py | 53 ++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/devices/traits/b01/q7/test_init.py b/tests/devices/traits/b01/q7/test_init.py index 64265991..ef48269c 100644 --- a/tests/devices/traits/b01/q7/test_init.py +++ b/tests/devices/traits/b01/q7/test_init.py @@ -325,3 +325,56 @@ async def test_q7_map_content_refresh_from_map_response( second_payload = json.loads(unpad(second.payload, AES.block_size)) assert second_payload["dps"]["10000"]["method"] == "service.upload_by_mapid" assert second_payload["dps"]["10000"]["params"] == {"map_id": 1772093512} + + +async def test_q7_map_content_refresh_falls_back_to_first_map( + q7_api: Q7PropertiesApi, + fake_channel: FakeChannel, + message_builder: B01MessageBuilder, + monkeypatch: pytest.MonkeyPatch, +): + """If no map is marked current, use first map from map_list.""" + + fake_channel.response_queue.append( + message_builder.build({"map_list": [{"id": 111}, {"id": 222, "cur": False}]}) + ) + fake_channel.response_queue.append( + RoborockMessage( + protocol=RoborockMessageProtocol.MAP_RESPONSE, + payload=b"raw-map-payload", + version=b"B01", + seq=message_builder.seq + 1, + ) + ) + + monkeypatch.setattr( + "roborock.devices.traits.b01.q7.map_content.decode_b01_map_payload", + lambda raw_payload, **kwargs: b"inflated-payload", + ) + monkeypatch.setattr( + "roborock.devices.traits.b01.q7.map_content.parse_scmap_payload", + lambda payload: B01MapData(size_x=1, size_y=1, map_data=b"\x01"), + ) + monkeypatch.setattr( + "roborock.devices.traits.b01.q7.map_content.render_map_png", + lambda parsed: b"\x89PNG-test", + ) + + await q7_api.map_content.refresh() + + second = fake_channel.published_messages[1] + second_payload = json.loads(unpad(second.payload, AES.block_size)) + assert second_payload["dps"]["10000"]["params"] == {"map_id": 111} + + +async def test_q7_map_content_refresh_errors_without_map_list( + q7_api: Q7PropertiesApi, + fake_channel: FakeChannel, + message_builder: B01MessageBuilder, +): + """Map refresh should fail clearly when map list response is unusable.""" + + fake_channel.response_queue.append(message_builder.build({"map_list": []})) + + with pytest.raises(RoborockException, match="Unable to determine map_id"): + await q7_api.map_content.refresh() From aea98ed5a0fdd6093bb08a950697f70b6ee0293f Mon Sep 17 00:00:00 2001 From: arduano Date: Sat, 28 Feb 2026 16:53:13 +1100 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=A6=8E=20roborock:=20address=20review?= =?UTF-8?q?=20items=20for=20q7=20map=20+=20segment=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 1 + roborock/devices/rpc/b01_q7_channel.py | 89 +++++++++++-------- roborock/devices/traits/b01/q7/__init__.py | 33 +++++-- roborock/devices/traits/b01/q7/map_content.py | 9 +- roborock/map/__init__.py | 2 +- roborock/map/b01_map_parser.py | 33 ++++++- tests/devices/traits/b01/q7/test_init.py | 6 ++ tests/map/test_b01_map_parser.py | 2 + uv.lock | 2 + 9 files changed, 124 insertions(+), 53 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1e9694f4..91e0cf62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "pyrate-limiter>=3.7.0,<4", "aiomqtt>=2.5.0,<3", "click-shell~=2.1", + "Pillow>=10,<12", ] [project.urls] diff --git a/roborock/devices/rpc/b01_q7_channel.py b/roborock/devices/rpc/b01_q7_channel.py index 6c8ab308..3df79334 100644 --- a/roborock/devices/rpc/b01_q7_channel.py +++ b/roborock/devices/rpc/b01_q7_channel.py @@ -5,6 +5,7 @@ import asyncio import json import logging +import weakref from typing import Any from roborock.devices.transport.mqtt_channel import MqttChannel @@ -18,6 +19,15 @@ _LOGGER = logging.getLogger(__name__) _TIMEOUT = 10.0 +_map_command_locks: weakref.WeakKeyDictionary[MqttChannel, asyncio.Lock] = weakref.WeakKeyDictionary() + + +def _get_map_command_lock(mqtt_channel: MqttChannel) -> asyncio.Lock: + lock = _map_command_locks.get(mqtt_channel) + if lock is None: + lock = asyncio.Lock() + _map_command_locks[mqtt_channel] = lock + return lock async def send_decoded_command( @@ -102,49 +112,54 @@ def find_response(response_message: RoborockMessage) -> None: async def send_map_command(mqtt_channel: MqttChannel, request_message: Q7RequestMessage) -> bytes: - """Send map upload command and wait for MAP_RESPONSE payload bytes.""" + """Send map upload command and wait for MAP_RESPONSE payload bytes. - roborock_message = encode_mqtt_payload(request_message) - future: asyncio.Future[bytes] = asyncio.get_running_loop().create_future() + Map requests are serialized per channel so concurrent map calls cannot + cross-wire responses between callers. + """ - def find_response(response_message: RoborockMessage) -> None: - if future.done(): - return + async with _get_map_command_lock(mqtt_channel): + roborock_message = encode_mqtt_payload(request_message) + future: asyncio.Future[bytes] = asyncio.get_running_loop().create_future() - if response_message.protocol == RoborockMessageProtocol.MAP_RESPONSE and response_message.payload: - future.set_result(response_message.payload) - return + def find_response(response_message: RoborockMessage) -> None: + if future.done(): + return - try: - decoded_dps = decode_rpc_response(response_message) - except RoborockException: - return + if response_message.protocol == RoborockMessageProtocol.MAP_RESPONSE and response_message.payload: + future.set_result(response_message.payload) + return - for dps_value in decoded_dps.values(): - if not isinstance(dps_value, str): - continue try: - inner = json.loads(dps_value) - except (json.JSONDecodeError, TypeError): - continue - if not isinstance(inner, dict) or inner.get("msgId") != str(request_message.msg_id): - continue - code = inner.get("code", 0) - if code != 0: - future.set_exception(RoborockException(f"B01 command failed with code {code} ({request_message})")) + decoded_dps = decode_rpc_response(response_message) + except RoborockException: return - data = inner.get("data") - if isinstance(data, dict) and isinstance(data.get("payload"), str): + + for dps_value in decoded_dps.values(): + if not isinstance(dps_value, str): + continue try: - future.set_result(bytes.fromhex(data["payload"])) - except ValueError: - pass + inner = json.loads(dps_value) + except (json.JSONDecodeError, TypeError): + continue + if not isinstance(inner, dict) or inner.get("msgId") != str(request_message.msg_id): + continue + code = inner.get("code", 0) + if code != 0: + future.set_exception(RoborockException(f"B01 command failed with code {code} ({request_message})")) + return + data = inner.get("data") + if isinstance(data, dict) and isinstance(data.get("payload"), str): + try: + future.set_result(bytes.fromhex(data["payload"])) + except ValueError: + pass - unsub = await mqtt_channel.subscribe(find_response) - try: - await mqtt_channel.publish(roborock_message) - return await asyncio.wait_for(future, timeout=_TIMEOUT) - except TimeoutError as ex: - raise RoborockException(f"B01 map command timed out after {_TIMEOUT}s ({request_message})") from ex - finally: - unsub() + unsub = await mqtt_channel.subscribe(find_response) + try: + await mqtt_channel.publish(roborock_message) + return await asyncio.wait_for(future, timeout=_TIMEOUT) + except TimeoutError as ex: + raise RoborockException(f"B01 map command timed out after {_TIMEOUT}s ({request_message})") from ex + finally: + unsub() diff --git a/roborock/devices/traits/b01/q7/__init__.py b/roborock/devices/traits/b01/q7/__init__.py index 77451c6a..1af829d3 100644 --- a/roborock/devices/traits/b01/q7/__init__.py +++ b/roborock/devices/traits/b01/q7/__init__.py @@ -35,13 +35,24 @@ class Q7PropertiesApi(Trait): clean_summary: CleanSummaryTrait """Trait for clean records / clean summary (Q7 `service.get_record_list`).""" - map_content: Q7MapContentTrait - - def __init__(self, channel: MqttChannel, *, local_key: str, serial: str, model: str) -> None: + map_content: Q7MapContentTrait | None + + def __init__( + self, + channel: MqttChannel, + *, + local_key: str | None = None, + serial: str | None = None, + model: str | None = None, + ) -> None: """Initialize the B01Props API.""" self._channel = channel self.clean_summary = CleanSummaryTrait(channel) - self.map_content = Q7MapContentTrait(channel, local_key=local_key, serial=serial, model=model) + if local_key and serial and model: + self.map_content = Q7MapContentTrait(channel, local_key=local_key, serial=serial, model=model) + else: + # Keep backwards compatibility for direct callers that only use command/query traits. + self.map_content = None async def query_values(self, props: list[RoborockB01Props]) -> B01Props | None: """Query the device for the values of the given Q7 properties.""" @@ -91,14 +102,14 @@ async def start_clean(self) -> None: }, ) - async def clean_segments(self, room_ids: list[int]) -> None: - """Start segment/room cleaning for the given room ids.""" + async def clean_segments(self, segment_ids: list[int]) -> None: + """Start segment cleaning for the given ids (Q7 uses room ids).""" await self.send( command=RoborockB01Q7Methods.SET_ROOM_CLEAN, params={ "clean_type": CleanTaskTypeMapping.ROOM.code, "ctrl_value": SCDeviceCleanParam.START.code, - "room_ids": room_ids, + "room_ids": segment_ids, }, ) @@ -146,6 +157,12 @@ async def send(self, command: CommandType, params: ParamsType) -> Any: ) -def create(channel: MqttChannel, *, local_key: str, serial: str, model: str) -> Q7PropertiesApi: +def create( + channel: MqttChannel, + *, + local_key: str | None = None, + serial: str | None = None, + model: str | None = None, +) -> Q7PropertiesApi: """Create traits for B01 Q7 devices.""" return Q7PropertiesApi(channel, local_key=local_key, serial=serial, model=model) diff --git a/roborock/devices/traits/b01/q7/map_content.py b/roborock/devices/traits/b01/q7/map_content.py index efbebac6..0d9ac6ea 100644 --- a/roborock/devices/traits/b01/q7/map_content.py +++ b/roborock/devices/traits/b01/q7/map_content.py @@ -10,11 +10,7 @@ from roborock.devices.traits.v1.map_content import MapContent from roborock.devices.transport.mqtt_channel import MqttChannel from roborock.exceptions import RoborockException -from roborock.map.b01_map_parser import ( - decode_b01_map_payload, - parse_scmap_payload, - render_map_png, -) +from roborock.map.b01_map_parser import decode_b01_map_payload, parse_scmap_payload, render_map_png from roborock.protocols.b01_q7_protocol import Q7RequestMessage from roborock.roborock_typing import RoborockB01Q7Methods @@ -23,6 +19,8 @@ class B01MapContent(MapContent): """B01 map content wrapper.""" + rooms: dict[int, str] | None = None + def _extract_current_map_id(map_list_response: dict[str, Any] | None) -> int | None: if not isinstance(map_list_response, dict): @@ -77,5 +75,6 @@ async def refresh(self) -> B01MapContent: parsed = parse_scmap_payload(inflated) self.raw_api_response = raw_payload self.map_data = None + self.rooms = parsed.rooms self.image_content = render_map_png(parsed) return self diff --git a/roborock/map/__init__.py b/roborock/map/__init__.py index 9b8e160f..75adb4e1 100644 --- a/roborock/map/__init__.py +++ b/roborock/map/__init__.py @@ -1,4 +1,4 @@ -"""Module for Roborock map related data classes.""" +"""Utilities and data classes for working with Roborock maps.""" from .b01_map_parser import B01MapData, decode_b01_map_payload, parse_scmap_payload, render_map_png from .map_parser import MapParserConfig, ParsedMapData diff --git a/roborock/map/b01_map_parser.py b/roborock/map/b01_map_parser.py index f5a477c1..0a155fc7 100644 --- a/roborock/map/b01_map_parser.py +++ b/roborock/map/b01_map_parser.py @@ -24,6 +24,7 @@ class B01MapData: size_x: int size_y: int map_data: bytes + rooms: dict[int, str] | None = None def _read_varint(buf: bytes, idx: int) -> tuple[int, int]: @@ -72,12 +73,36 @@ def _parse_map_data_info(blob: bytes) -> bytes: raise RoborockException("B01 map payload missing mapData") +def _parse_room_data_info(blob: bytes) -> tuple[int | None, str | None]: + room_id: int | None = None + room_name: str | None = None + idx = 0 + while idx < len(blob): + key, idx = _read_varint(blob, idx) + field_no = key >> 3 + wire = key & 0x07 + if wire == 0: + value, idx = _read_varint(blob, idx) + if field_no == 1: + room_id = int(value) + elif wire == 2: + value, idx = _read_len_delimited(blob, idx) + if field_no == 2: + room_name = value.decode("utf-8", errors="replace") + elif wire == 5: + idx += 4 + else: + raise RoborockException(f"Unsupported wire type {wire} in B01 room data info") + return room_id, room_name + + def parse_scmap_payload(payload: bytes) -> B01MapData: - """Parse SCMap protobuf payload and extract occupancy grid bytes.""" + """Parse SCMap protobuf payload and extract occupancy grid bytes and room names.""" size_x = 0 size_y = 0 grid = b"" + rooms: dict[int, str] = {} idx = 0 while idx < len(payload): key, idx = _read_varint(payload, idx) @@ -112,12 +137,16 @@ def parse_scmap_payload(payload: bytes) -> B01MapData: raise RoborockException(f"Unsupported wire type {hwire} in B01 map header") elif field_no == 4: # mapDataInfo grid = _parse_map_data_info(value) + elif field_no == 12: # roomDataInfo (repeated) + room_id, room_name = _parse_room_data_info(value) + if room_id is not None: + rooms[room_id] = room_name or f"Room {room_id}" if not size_x or not size_y or not grid: raise RoborockException("Failed to parse B01 map header/grid") if len(grid) < size_x * size_y: raise RoborockException("B01 map data shorter than expected dimensions") - return B01MapData(size_x=size_x, size_y=size_y, map_data=grid) + return B01MapData(size_x=size_x, size_y=size_y, map_data=grid, rooms=rooms or None) def _derive_b01_iv(iv_seed: int) -> bytes: diff --git a/tests/devices/traits/b01/q7/test_init.py b/tests/devices/traits/b01/q7/test_init.py index ef48269c..79063487 100644 --- a/tests/devices/traits/b01/q7/test_init.py +++ b/tests/devices/traits/b01/q7/test_init.py @@ -378,3 +378,9 @@ async def test_q7_map_content_refresh_errors_without_map_list( with pytest.raises(RoborockException, match="Unable to determine map_id"): await q7_api.map_content.refresh() + + +async def test_q7_api_constructor_backwards_compatible_without_map_context(fake_channel: FakeChannel): + """Direct API construction without map context should still work.""" + api = Q7PropertiesApi(fake_channel) # type: ignore[arg-type] + assert api.map_content is None diff --git a/tests/map/test_b01_map_parser.py b/tests/map/test_b01_map_parser.py index ea35d684..d7eaf508 100644 --- a/tests/map/test_b01_map_parser.py +++ b/tests/map/test_b01_map_parser.py @@ -20,6 +20,8 @@ def test_parse_scmap_payload_fixture() -> None: assert parsed.size_x == 340 assert parsed.size_y == 300 assert len(parsed.map_data) >= parsed.size_x * parsed.size_y + assert parsed.rooms is not None + assert parsed.rooms.get(10) == "room1" def test_render_map_png_fixture() -> None: diff --git a/uv.lock b/uv.lock index 06463df6..afca5390 100644 --- a/uv.lock +++ b/uv.lock @@ -1329,6 +1329,7 @@ dependencies = [ { name = "click-shell" }, { name = "construct" }, { name = "paho-mqtt" }, + { name = "pillow" }, { name = "pycryptodome" }, { name = "pycryptodomex", marker = "sys_platform == 'darwin'" }, { name = "pyrate-limiter" }, @@ -1361,6 +1362,7 @@ requires-dist = [ { name = "click-shell", specifier = "~=2.1" }, { name = "construct", specifier = ">=2.10.57,<3" }, { name = "paho-mqtt", specifier = ">=1.6.1,<3.0.0" }, + { name = "pillow", specifier = ">=10,<12" }, { name = "pycryptodome", specifier = "~=3.18" }, { name = "pycryptodomex", marker = "sys_platform == 'darwin'", specifier = "~=3.18" }, { name = "pyrate-limiter", specifier = ">=3.7.0,<4" }, From 3a0b944bdc41c60c7b6c19a3d4f39343c229ca24 Mon Sep 17 00:00:00 2001 From: arduano Date: Sat, 28 Feb 2026 17:18:38 +1100 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=A6=8E=20b01:=20guard=20map=20command?= =?UTF-8?q?=20future=20completion=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- roborock/devices/rpc/b01_q7_channel.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/roborock/devices/rpc/b01_q7_channel.py b/roborock/devices/rpc/b01_q7_channel.py index 3df79334..eb5660a4 100644 --- a/roborock/devices/rpc/b01_q7_channel.py +++ b/roborock/devices/rpc/b01_q7_channel.py @@ -127,7 +127,8 @@ def find_response(response_message: RoborockMessage) -> None: return if response_message.protocol == RoborockMessageProtocol.MAP_RESPONSE and response_message.payload: - future.set_result(response_message.payload) + if not future.done(): + future.set_result(response_message.payload) return try: @@ -146,12 +147,16 @@ def find_response(response_message: RoborockMessage) -> None: continue code = inner.get("code", 0) if code != 0: - future.set_exception(RoborockException(f"B01 command failed with code {code} ({request_message})")) + if not future.done(): + future.set_exception( + RoborockException(f"B01 command failed with code {code} ({request_message})") + ) return data = inner.get("data") if isinstance(data, dict) and isinstance(data.get("payload"), str): try: - future.set_result(bytes.fromhex(data["payload"])) + if not future.done(): + future.set_result(bytes.fromhex(data["payload"])) except ValueError: pass