-
Notifications
You must be signed in to change notification settings - Fork 102
feat: parse Q10 archived map packets #936
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b8bdf3a
95d268e
1a4ffde
cbd6abc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,9 @@ | ||
| """Parser for Roborock Q10 (B01/ss07) map packets. | ||
|
|
||
| Q10 devices deliver map data as a protocol-301 ``MAP_RESPONSE`` message after a | ||
| ``dpMultiMap`` list/get request. Unlike the Q7 ``SCMap`` protobuf | ||
| format, the Q10 uses a custom, unencrypted binary packet: | ||
| Q10 devices deliver map data as protocol-301 ``MAP_RESPONSE`` pushes. Current | ||
| maps follow a read-only status request, while saved-map and clean-record detail | ||
| packets follow their respective ``select`` requests. Unlike the Q7 ``SCMap`` | ||
| protobuf format, the Q10 uses a custom, unencrypted binary packet: | ||
|
|
||
| - ``01 01`` marker, then a ``u32be`` map id (bytes 2-5) and two consecutive | ||
| ``u16be`` dimensions: grid width (bytes 7-8) and grid height (bytes 9-10). | ||
|
|
@@ -22,13 +23,15 @@ | |
| import io | ||
| import math | ||
| import statistics | ||
| import struct | ||
| from dataclasses import dataclass, field, replace | ||
|
|
||
| from PIL import Image | ||
| from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor | ||
| from vacuum_map_parser_base.config.image_config import ImageConfig | ||
| from vacuum_map_parser_base.map_data import ImageData, MapData, Point | ||
|
|
||
| from roborock.data.code_mappings import RoborockEnum | ||
| from roborock.data.containers import RoborockBase | ||
| from roborock.exceptions import RoborockException | ||
|
|
||
|
|
@@ -66,9 +69,6 @@ def classify_q10_cell(value: int) -> str: | |
| return LAYER_FLOOR | ||
|
|
||
|
|
||
| MAP_PACKET_MARKER = b"\x01\x01" | ||
| TRACE_PACKET_MARKER = b"\x02\x01" | ||
|
|
||
| _MAP_ID_OFFSET = 2 | ||
| # Width and height are two consecutive u16be fields. An earlier revision read the | ||
| # width as u16le at offset 8; that high byte is actually the height's high byte, | ||
|
|
@@ -83,6 +83,7 @@ def classify_q10_cell(value: int) -> str: | |
| _ROOM_RECORD_LENGTH = 47 | ||
| _ROOM_NAME_LENGTH_OFFSET = 26 | ||
| _MAX_ROOMS = 32 | ||
| _MAX_GRID_CELLS = 16_000_000 | ||
| # Sanity bound for the erase-zone vector section's vertices-per-polygon field. | ||
| _MAX_ERASE_ZONE_VERTICES = 16 | ||
|
|
||
|
|
@@ -194,10 +195,30 @@ def charger_pixels(self) -> tuple[float, float] | None: | |
| ) | ||
|
|
||
|
|
||
| class Q10MapPacketKind(RoborockEnum): | ||
| """Semantic kind identified by a Q10 map packet's two-byte marker.""" | ||
|
|
||
| CURRENT = 1 | ||
| TRACE = 2 | ||
| CLEAN_RECORD_DETAIL = 3 | ||
| SAVED_MAP_DETAIL = 4 | ||
|
|
||
| @property | ||
| def marker(self) -> bytes: | ||
| """Return the two-byte wire marker for this packet kind.""" | ||
| return bytes((self.value, 1)) | ||
|
|
||
| @classmethod | ||
| def from_payload(cls, payload: bytes) -> "Q10MapPacketKind | None": | ||
| """Return the recognized kind for a payload marker.""" | ||
| return next((kind for kind in cls if payload[:2] == kind.marker), None) | ||
|
|
||
|
|
||
| @dataclass | ||
| class Q10MapPacket: | ||
| """Decoded contents of a Q10 ``01 01`` map packet.""" | ||
| """Decoded contents of a Q10 current or archived map packet.""" | ||
|
|
||
| kind: Q10MapPacketKind | ||
| map_id: int | ||
| width: int | ||
| height: int | ||
|
|
@@ -220,6 +241,18 @@ def layers(self) -> GridLayers: | |
| return decompose_grid(self.width, self.height, self.grid, rooms, classify_q10_cell, flip=False) | ||
|
|
||
|
|
||
| @dataclass | ||
| class Q10CleanRecordMapPacket(Q10MapPacket): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seeing A clean-record response isn't really a specialized variant of a map raster; it's a compound payload containing both a map and a historical path. If we model it via composition instead: @dataclass
class Q10CleanRecordDetail:
map: Q10MapPacket
trace: Q10HistoricalTracePacket | None = NoneThen:
|
||
| """A clean-record map with its embedded historical cleaning path. | ||
|
|
||
| Current, saved and clean-record maps share the grid, rooms, erase zones, | ||
| carpet and calibration layout. Only clean-record packets carry this path; | ||
| it is not a separately received live trace. | ||
| """ | ||
|
|
||
| historical_trace: "Q10HistoricalTracePacket | None" = None | ||
|
|
||
|
|
||
| @dataclass | ||
| class Q10Point(RoborockBase): | ||
| """A single point in Q10 map/trace coordinate space.""" | ||
|
|
@@ -266,6 +299,27 @@ def robot_position(self) -> Q10Point | None: | |
| return self.points[-1] if self.points else None | ||
|
|
||
|
|
||
| @dataclass | ||
| class Q10HistoricalTracePacket: | ||
| """Cleaning path embedded in a Q10 ``03 01`` clean-record detail packet. | ||
|
|
||
| This is a different wire layout from the live ``02 01`` trace. Its header | ||
| carries a 16-bit format version, a 32-bit opaque value, a 32-bit | ||
| point count, a signed heading, and a zero reserved word. Points use the same | ||
| signed big-endian ``(x, y)`` coordinate pairs as the live trace. | ||
| """ | ||
|
|
||
| points: list[Q10Point] = field(default_factory=list) | ||
| version: int = 0 | ||
| opaque_value: int = 0 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need to expose
|
||
| heading: int = 0 | ||
|
|
||
| @property | ||
| def robot_position(self) -> Q10Point | None: | ||
| """The final recorded position, if the historical path is non-empty.""" | ||
| return self.points[-1] if self.points else None | ||
|
|
||
|
|
||
| # Trace packet (``02 01``): a 14-byte header followed by big-endian int16 (x, y) | ||
| # point pairs forming the accumulated session path. Header layout confirmed | ||
| # against live ss07 captures and cross-checked by @andrewlyeats: | ||
|
|
@@ -275,18 +329,27 @@ def robot_position(self) -> Q10Point | None: | |
| # - bytes 10-11: the 0201 SLAM heading (s16be degrees; 0 = +x, +90 = +y, | ||
| # +-180 = -x, -90 = -y) -- the robot's current orientation. | ||
| # - bytes 12-13: a constant (0x0000). | ||
| # - byte 14 onward: the path points. | ||
| # - byte 14 onward: exactly ``point_count`` path points. | ||
| # An earlier revision used a 10-byte header, which folded the heading word into | ||
| # a phantom leading point ``(heading, 0)`` -- that is the "stray point" the | ||
| # heuristic below was papering over, and why the count read "one high". The | ||
| # parser reads all 4-byte pairs in the body rather than trusting the count | ||
| # field, so a truncated tail can't desync it. | ||
| # parser requires the declared point count to match the complete body, so a | ||
| # truncated or extended tail cannot be silently interpreted as path data. | ||
| # NOTE: the format documented by roborock-qseries-map-bridge (18-byte header) | ||
| # did not match this firmware -- this 14-byte layout is what the device sent. | ||
| _TRACE_HEADER_LENGTH = 14 | ||
| _TRACE_SEQUENCE_OFFSET = 3 | ||
| _TRACE_POINT_COUNT_OFFSET = 8 | ||
| _TRACE_HEADING_OFFSET = 10 | ||
|
|
||
| _HISTORICAL_TRACE_HEADER_LENGTH = 14 | ||
| _HISTORICAL_TRACE_PREFIX_LENGTH = 1 | ||
| _HISTORICAL_TRACE_VERSION = 1 | ||
| _HISTORICAL_TRACE_OPAQUE_VALUE_OFFSET = 2 | ||
| _HISTORICAL_TRACE_POINT_COUNT_OFFSET = 6 | ||
| _HISTORICAL_TRACE_HEADING_OFFSET = 10 | ||
| _HISTORICAL_TRACE_RESERVED_OFFSET = 12 | ||
|
|
||
| # Some cleans still prepend a single near-origin sentinel as the first real | ||
| # point (e.g. ~(5, 76) / (-3, 0) when the path proper starts near (-1700, -800)); | ||
| # it skews the rendered start/bounding box and any path-based calibration. (This | ||
|
|
@@ -301,12 +364,22 @@ def robot_position(self) -> Q10Point | None: | |
|
|
||
| def is_map_packet(payload: bytes) -> bool: | ||
| """Return True if the payload is a Q10 full-map (``01 01``) packet.""" | ||
| return payload[:2] == MAP_PACKET_MARKER | ||
| return Q10MapPacketKind.from_payload(payload) is Q10MapPacketKind.CURRENT | ||
|
|
||
|
|
||
| def is_clean_record_map_packet(payload: bytes) -> bool: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we prune Similarly, |
||
| """Return True for a Q10 clean-record detail (``03 01``) packet.""" | ||
| return Q10MapPacketKind.from_payload(payload) is Q10MapPacketKind.CLEAN_RECORD_DETAIL | ||
|
|
||
|
|
||
| def is_saved_map_packet(payload: bytes) -> bool: | ||
| """Return True for a Q10 saved-map detail (``04 01``) packet.""" | ||
| return Q10MapPacketKind.from_payload(payload) is Q10MapPacketKind.SAVED_MAP_DETAIL | ||
|
|
||
|
|
||
| def is_trace_packet(payload: bytes) -> bool: | ||
| """Return True if the payload is a Q10 live trace (``02 01``) packet.""" | ||
| return payload[:2] == TRACE_PACKET_MARKER | ||
| return Q10MapPacketKind.from_payload(payload) is Q10MapPacketKind.TRACE | ||
|
|
||
|
|
||
| def parse_trace_packet(payload: bytes) -> Q10TracePacket: | ||
|
|
@@ -318,6 +391,9 @@ def parse_trace_packet(payload: bytes) -> Q10TracePacket: | |
| body = payload[_TRACE_HEADER_LENGTH:] | ||
| if len(body) % 4: | ||
| raise RoborockException("Q10 trace points are not 4-byte (x, y) pairs") | ||
| declared_point_count = int.from_bytes(payload[_TRACE_POINT_COUNT_OFFSET : _TRACE_POINT_COUNT_OFFSET + 2], "big") | ||
| if declared_point_count != len(body) // 4: | ||
| raise RoborockException("Q10 trace point count does not match its payload") | ||
|
|
||
| heading = int.from_bytes(payload[_TRACE_HEADING_OFFSET : _TRACE_HEADING_OFFSET + 2], "big", signed=True) | ||
| points = [ | ||
|
|
@@ -348,11 +424,13 @@ def _drop_stray_leading_point(points: list[Q10Point]) -> list[Q10Point]: | |
| return points | ||
|
|
||
|
|
||
| def lz4_block_decompress(data: bytes) -> bytes: | ||
| def lz4_block_decompress(data: bytes, max_output_size: int) -> bytes: | ||
| """Decompress a raw LZ4 *block* (no frame header). | ||
|
|
||
| The Q10 map grid is stored as a single LZ4 block. This implements the | ||
| standard LZ4 block format so we don't add a native dependency. | ||
| standard LZ4 block format so we don't add a native dependency. Expansion beyond | ||
| ``max_output_size`` is rejected before | ||
| allocating the excess output. | ||
| """ | ||
| index = 0 | ||
| output = bytearray() | ||
|
|
@@ -380,6 +458,8 @@ def read_length(value: int) -> int: | |
| end = index + literal_length | ||
| if end > len(data): | ||
| raise RoborockException("Truncated LZ4 block while reading literals") | ||
| if len(output) + literal_length > max_output_size: | ||
| raise RoborockException("LZ4 block exceeds maximum output size") | ||
| output.extend(data[index:end]) | ||
| index = end | ||
|
|
||
|
|
@@ -394,6 +474,8 @@ def read_length(value: int) -> int: | |
| raise RoborockException("Invalid LZ4 back-reference offset") | ||
|
|
||
| match_length = read_length(token & 0x0F) + 4 | ||
| if len(output) + match_length > max_output_size: | ||
| raise RoborockException("LZ4 block exceeds maximum output size") | ||
| for _ in range(match_length): | ||
| output.append(output[-offset]) | ||
|
|
||
|
|
@@ -458,15 +540,18 @@ def _parse_rooms(room_data: bytes, grid: bytes) -> list[Q10Room]: | |
|
|
||
|
|
||
| def parse_map_packet(payload: bytes) -> Q10MapPacket: | ||
| """Parse a Q10 ``01 01`` map packet into grid + room metadata.""" | ||
| if len(payload) < _LAYOUT_COMPRESSED_OFFSET or not is_map_packet(payload): | ||
| """Parse a Q10 current or archived map into typed source data.""" | ||
| kind = Q10MapPacketKind.from_payload(payload) | ||
| if len(payload) < _LAYOUT_COMPRESSED_OFFSET or kind is None or kind is Q10MapPacketKind.TRACE: | ||
| raise RoborockException("Payload is not a Q10 map packet") | ||
|
|
||
| map_id = int.from_bytes(payload[_MAP_ID_OFFSET : _MAP_ID_OFFSET + 4], "big") | ||
| width = int.from_bytes(payload[_WIDTH_OFFSET : _WIDTH_OFFSET + 2], "big") | ||
| height = int.from_bytes(payload[_HEIGHT_OFFSET : _HEIGHT_OFFSET + 2], "big") | ||
| if width <= 0: | ||
| raise RoborockException("Q10 map packet has invalid width") | ||
| if height > 0 and width * height > _MAX_GRID_CELLS: | ||
| raise RoborockException("Q10 map packet dimensions exceed the supported grid size") | ||
|
|
||
| compressed_length = int.from_bytes( | ||
| payload[_COMPRESSED_LAYOUT_LENGTH_OFFSET : _COMPRESSED_LAYOUT_LENGTH_OFFSET + 2], "big" | ||
|
|
@@ -475,7 +560,10 @@ def parse_map_packet(payload: bytes) -> Q10MapPacket: | |
| if compressed_length <= 0 or layout_end > len(payload): | ||
| raise RoborockException("Q10 map packet has invalid layout block length") | ||
|
|
||
| decoded = lz4_block_decompress(payload[_LAYOUT_COMPRESSED_OFFSET:layout_end]) | ||
| decoded = lz4_block_decompress( | ||
| payload[_LAYOUT_COMPRESSED_OFFSET:layout_end], | ||
| max_output_size=_MAX_GRID_CELLS + 2 + _MAX_ROOMS * _ROOM_RECORD_LENGTH, | ||
| ) | ||
| # Prefer the header height; fall back to inference if it doesn't line up | ||
| # (e.g. older captures/fixtures that don't populate the height field). | ||
| split = _split_with_dims(decoded, width, height) if height > 0 else None | ||
|
|
@@ -486,9 +574,14 @@ def parse_map_packet(payload: bytes) -> Q10MapPacket: | |
| rooms = _parse_rooms(room_data, grid) | ||
| tail = payload[layout_end:] | ||
| erase_zones = _parse_erase_zones(tail) | ||
| carpet_mask = _parse_carpet_mask(tail, width, height) | ||
| carpet_mask, carpet_end = _parse_carpet_block(tail, width, height) | ||
| if kind is Q10MapPacketKind.CLEAN_RECORD_DETAIL and carpet_end is not None: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. when this kind of map packet is received, how many of these other fields are also included? What i'm wondering is if a historical trace kind has a lot of overlap with other fields in Q10MapPacket or if it needs to be a separate type. basically as more fields are added the map packet seems like a sparse object. You could imagine each kind Q10MapPacketKind has a separate dataclass for example, if the overlap is low.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. re-reading the code I don't actually see the historical trace even being used anywhere yet, except in the tests. Naively, it seems to me like we shouldn't be sticking this on to a map package and instead just using the kind to parse a clean record? |
||
| historical_trace = _parse_clean_record_trace(tail, carpet_end) | ||
| else: | ||
| historical_trace = None | ||
| header_calibration = _parse_header_calibration(payload) | ||
| return Q10MapPacket( | ||
| packet = Q10MapPacket( | ||
| kind=kind, | ||
| map_id=map_id, | ||
| width=width, | ||
| height=height, | ||
|
|
@@ -498,6 +591,9 @@ def parse_map_packet(payload: bytes) -> Q10MapPacket: | |
| header_calibration=header_calibration, | ||
| carpet_mask=carpet_mask, | ||
| ) | ||
| if kind is Q10MapPacketKind.CLEAN_RECORD_DETAIL: | ||
| return Q10CleanRecordMapPacket(**vars(packet), historical_trace=historical_trace) | ||
| return packet | ||
|
|
||
|
|
||
| def _parse_header_calibration(payload: bytes) -> Q10HeaderCalibration | None: | ||
|
|
@@ -569,7 +665,18 @@ def _carpet_offset(tail: bytes) -> int: | |
| return 2 + count * vertices_per * 4 | ||
|
|
||
|
|
||
| def _parse_carpet_mask(tail: bytes, width: int, height: int) -> bytes | None: | ||
| def _erase_section_end(tail: bytes) -> int: | ||
| """Return the end of a complete, structurally valid erase section.""" | ||
| if len(tail) < 2: | ||
| return 0 | ||
| count, vertices_per = tail[0], tail[1] | ||
| if count and not 1 <= vertices_per <= _MAX_ERASE_ZONE_VERTICES: | ||
| return 0 | ||
| end = _carpet_offset(tail) | ||
| return end if end <= len(tail) else 0 | ||
|
|
||
|
|
||
| def _parse_carpet_block(tail: bytes, width: int, height: int) -> tuple[bytes | None, int | None]: | ||
| """Decode the carpet mask that follows the erase section in the packet tail. | ||
|
|
||
| Framing matches the main grid block: ``[u32 uncompressed_len]`` | ||
|
|
@@ -578,23 +685,83 @@ def _parse_carpet_mask(tail: bytes, width: int, height: int) -> bytes | None: | |
| non-zero cell is carpet (the value is the carpet kind). Confirmed byte-exact | ||
| on live ss07 captures (R1 / RDC), where ``uncompressed_len == width*height``. | ||
|
|
||
| Returns the decompressed mask, or ``None`` if the section is absent or does | ||
| not line up (the ``uncompressed_len == width*height`` invariant is used as the | ||
| guard so a mis-located section yields no carpet rather than garbage). | ||
| Returns the decompressed mask and its end offset. Both are ``None`` if the | ||
| section is absent or does not line up. The end offset is used to anchor | ||
| optional later sections without scanning arbitrary trailing bytes. | ||
| """ | ||
| offset = _carpet_offset(tail) | ||
| offset = _erase_section_end(tail) | ||
| if offset == 0: | ||
| return None, None | ||
| if offset + 6 > len(tail): | ||
| return None | ||
| return None, None | ||
| uncompressed_len = int.from_bytes(tail[offset : offset + 4], "big") | ||
| compressed_len = int.from_bytes(tail[offset + 4 : offset + 6], "big") | ||
| block_end = offset + 6 + compressed_len | ||
| if uncompressed_len != width * height or compressed_len <= 0 or block_end > len(tail): | ||
| return None | ||
| return None, None | ||
| try: | ||
| mask = lz4_block_decompress(tail[offset + 6 : block_end]) | ||
| mask = lz4_block_decompress(tail[offset + 6 : block_end], max_output_size=width * height) | ||
| except RoborockException: | ||
| return None, None | ||
| if len(mask) != width * height: | ||
| return None, None | ||
| return mask, block_end | ||
|
|
||
|
|
||
| def _parse_carpet_mask(tail: bytes, width: int, height: int) -> bytes | None: | ||
| """Decode only the optional carpet mask (compatibility helper).""" | ||
| return _parse_carpet_block(tail, width, height)[0] | ||
|
|
||
|
|
||
| def _parse_clean_record_trace( | ||
| tail: bytes, | ||
| offset: int, | ||
| ) -> Q10HistoricalTracePacket | None: | ||
| """Decode the bounded historical path following a ``03 01`` carpet block. | ||
|
|
||
| The header and declared point count were validated against a physical ss07 | ||
| clean-record response and its point bytes match captured prefixes of the | ||
| corresponding live trace exactly. One observed zero byte precedes the path; | ||
| its meaning is unknown, so a non-zero value makes the entire section opaque. | ||
| Any unsupported version, non-zero reserved word, or truncated point table is | ||
| likewise left completely opaque. Bytes after the declared points are | ||
| deliberately not consumed: the observed 12-byte suffix appears structured, | ||
| but there is not enough controlled evidence to name or decode it safely. | ||
| """ | ||
| if offset >= len(tail) or tail[offset] != 0: | ||
| return None | ||
| return mask if len(mask) == width * height else None | ||
| offset += _HISTORICAL_TRACE_PREFIX_LENGTH | ||
| header_end = offset + _HISTORICAL_TRACE_HEADER_LENGTH | ||
| if header_end > len(tail): | ||
| return None | ||
| version = int.from_bytes(tail[offset : offset + 2], "big") | ||
| reserved = int.from_bytes( | ||
| tail[offset + _HISTORICAL_TRACE_RESERVED_OFFSET : offset + _HISTORICAL_TRACE_RESERVED_OFFSET + 2], | ||
| "big", | ||
| ) | ||
| if version != _HISTORICAL_TRACE_VERSION or reserved != 0: | ||
| return None | ||
| point_count = int.from_bytes( | ||
| tail[offset + _HISTORICAL_TRACE_POINT_COUNT_OFFSET : offset + _HISTORICAL_TRACE_POINT_COUNT_OFFSET + 4], | ||
| "big", | ||
| ) | ||
| points_end = header_end + point_count * 4 | ||
| if points_end > len(tail): | ||
| return None | ||
| coordinates = struct.iter_unpack(">hh", memoryview(tail)[header_end:points_end]) | ||
| return Q10HistoricalTracePacket( | ||
| points=[Q10Point(x=x, y=y) for x, y in coordinates], | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we run If clean-record paths share the same firmware sentinel near |
||
| version=version, | ||
| opaque_value=int.from_bytes( | ||
| tail[offset + _HISTORICAL_TRACE_OPAQUE_VALUE_OFFSET : offset + _HISTORICAL_TRACE_OPAQUE_VALUE_OFFSET + 4], | ||
| "big", | ||
| ), | ||
| heading=int.from_bytes( | ||
| tail[offset + _HISTORICAL_TRACE_HEADING_OFFSET : offset + _HISTORICAL_TRACE_HEADING_OFFSET + 2], | ||
| "big", | ||
| signed=True, | ||
| ), | ||
| ) | ||
|
|
||
|
|
||
| def erased_packet(packet: "Q10MapPacket", cells: set[int]) -> "Q10MapPacket": | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because this inherits from
RoborockEnum,RoborockEnum._missing_expects anunknownmember (e.g.unknown = -1). Without it,_missing_defaults tonext(item for item in cls), which silently resolves unknown future wire markers toCURRENT = 1.Also, rather than iterating all members with
next((kind for kind in cls ...))on every push, we can do an O(1) lookup:(Or if this enum is strictly an internal framing marker classifier, a standard
IntEnumwithoutRoborockEnumwould avoid the fallback behavior altogether).