roborock.data.v1.v1_dynamic_map

Wire models for the V1 dynamic map diff commands.

These dataclasses describe the JSON results of get_dynamic_map_diff and get_dynamic_data after ordinary command-response decoding, plus the snapshot metadata a full map result can carry. Field names match the wire keys so RoborockBase.from_dict maps them directly.

The models are deliberately passive. They decode base64 and answer the spec's success predicate, but they hold no element state and make no merge decisions. That logic lives with the dynamic element state machine.

  1"""Wire models for the V1 dynamic map diff commands.
  2
  3These dataclasses describe the JSON results of ``get_dynamic_map_diff`` and
  4``get_dynamic_data`` after ordinary command-response decoding, plus the
  5snapshot metadata a full map result can carry. Field names match the wire keys
  6so ``RoborockBase.from_dict`` maps them directly.
  7
  8The models are deliberately passive. They decode base64 and answer the spec's
  9success predicate, but they hold no element state and make no merge decisions.
 10That logic lives with the dynamic element state machine.
 11"""
 12
 13import base64
 14import binascii
 15from dataclasses import dataclass
 16
 17from roborock.exceptions import RoborockException
 18
 19from ..code_mappings import RoborockEnum
 20from ..containers import RoborockBase
 21
 22__all__ = [
 23    "DynamicMapResult",
 24    "DynamicMapDiffEntry",
 25    "DynamicMapDiffResponse",
 26    "DynamicDataResponse",
 27    "DynamicMapSnapshotEntry",
 28    "DynamicMapSnapshot",
 29    "dynamic_map_diff_params",
 30    "dynamic_data_params",
 31]
 32
 33
 34class DynamicMapResult(RoborockEnum):
 35    """Application-level status returned by the dynamic map commands.
 36
 37    For ``get_dynamic_map_diff`` a ``nonce_mismatch`` means the requested base
 38    map is no longer current. For ``get_dynamic_data`` it means the requested
 39    element generation is no longer current. Neither establishes a new
 40    generation by itself; the accompanying nonce does.
 41    """
 42
 43    unknown = -1
 44    ok = 0
 45    nonce_mismatch = 1
 46    full_map_required = 2
 47
 48
 49def _decode_base64(value: str | None, field_name: str) -> bytes | None:
 50    """Decode standard or URL-safe base64, or return ``None`` when absent."""
 51    if value is None:
 52        return None
 53    try:
 54        return base64.b64decode(value, altchars=b"-_", validate=True)
 55    except (binascii.Error, ValueError) as err:
 56        raise RoborockException(f"Dynamic map field '{field_name}' is not valid base64") from err
 57
 58
 59def _validated_nonce(nonce: int) -> int:
 60    """Validate a 32-bit identity without changing its wire representation."""
 61    if not -(1 << 31) <= nonce <= 0xFFFFFFFF:
 62        raise ValueError("nonce must fit a signed or unsigned 32-bit integer")
 63    return nonce
 64
 65
 66@dataclass
 67class DynamicMapDiffEntry(RoborockBase):
 68    """One element entry inside a ``get_dynamic_map_diff`` result.
 69
 70    Refresh indicators (charger, map image, ...) carry only ``count``. Dynamic
 71    elements carry ``nonce`` and ``max_len``; the path element can also carry
 72    an inline page via ``start``, ``len``, and ``data``.
 73    """
 74
 75    count: int | None = None
 76    """Change indicator since the base-map snapshot. Not an item or byte count."""
 77
 78    result: DynamicMapResult | None = None
 79    """Optional page status; inline captures normally use ``count`` instead."""
 80
 81    nonce: int | None = None
 82    """Element generation identity."""
 83
 84    max_len: int | None = None
 85    """Total logical length currently available for this generation."""
 86
 87    start: int | None = None
 88    """Zero-based logical offset of an inline page."""
 89
 90    len: int | None = None
 91    """Logical item count of an inline page."""
 92
 93    data: str | None = None
 94    """Base64 block sequence of an inline page."""
 95
 96    obstacle: int | None = None
 97    """Obstacle component of the main map image count, when reported."""
 98
 99    space: int | None = None
100    """Space component of the main map image count, when reported."""
101
102    @property
103    def decoded_data(self) -> bytes | None:
104        """The inline page blocks, base64-decoded."""
105        return _decode_base64(self.data, "data")
106
107    @property
108    def has_inline_page(self) -> bool:
109        """Whether the inline range and data fields are present."""
110        return self.start is not None and self.len is not None and self.data is not None
111
112    @property
113    def is_success(self) -> bool:
114        """App acceptance predicate for a page: ``result == 0 or count == 0``."""
115        return self.result is DynamicMapResult.ok or self.count == 0
116
117
118@dataclass
119class DynamicMapDiffResponse(RoborockBase):
120    """Result object of ``get_dynamic_map_diff``."""
121
122    result: DynamicMapResult | None = None
123    """Application status. ``None`` when the device omitted it."""
124
125    nonce: int | None = None
126    """Map nonce the diff was computed against."""
127
128    diff: dict[int, DynamicMapDiffEntry] | None = None
129    """Element entries keyed by element id. An omitted element has no update."""
130
131    robot_data: str | None = None
132    """Base64 block sequence that updates the robot without adding path points."""
133
134    @property
135    def decoded_robot_data(self) -> bytes | None:
136        """The robot-only blocks, base64-decoded."""
137        return _decode_base64(self.robot_data, "robot_data")
138
139    def is_for_map(self, map_nonce: int) -> bool:
140        """Whether ``diff`` may be applied to the base map identified by ``map_nonce``."""
141        if self.result is not DynamicMapResult.ok or self.nonce is None or self.diff is None:
142            return False
143        try:
144            return (_validated_nonce(self.nonce) & 0xFFFFFFFF) == (_validated_nonce(map_nonce) & 0xFFFFFFFF)
145        except ValueError:
146            return False
147
148
149@dataclass
150class DynamicDataResponse(RoborockBase):
151    """Result object of ``get_dynamic_data``: one page of one element generation."""
152
153    result: DynamicMapResult | None = None
154    count: int | None = None
155    """Optional inline-page indicator accepted by the app's shared page handler."""
156
157    nonce: int | None = None
158    """Element nonce the page belongs to."""
159
160    data_id: int | None = None
161    start: int | None = None
162    len: int | None = None
163    """Actual logical range returned, which may be shorter than requested."""
164
165    max_len: int | None = None
166    """Total available length, which may have changed since it was announced."""
167
168    data: str | None = None
169
170    @property
171    def decoded_data(self) -> bytes | None:
172        """The page blocks, base64-decoded."""
173        return _decode_base64(self.data, "data")
174
175    @property
176    def is_success(self) -> bool:
177        """App acceptance predicate for a page: ``result == 0 or count == 0``."""
178        return self.result is DynamicMapResult.ok or self.count == 0
179
180
181@dataclass
182class DynamicMapSnapshotEntry(RoborockBase):
183    """Snapshot metadata for one element, supplied alongside a full map."""
184
185    nonce: int | None = None
186    limit_len: int | None = None
187    """Page limit for the element when it has no configured limit."""
188
189
190@dataclass
191class DynamicMapSnapshot(RoborockBase):
192    """Top-level ``nonce`` and per-element ``snapshot`` carried by a full map result."""
193
194    nonce: int | None = None
195    snapshot: dict[int, DynamicMapSnapshotEntry] | None = None
196
197
198def dynamic_map_diff_params(map_nonce: int, round_marker: int | None = None) -> dict[str, int]:
199    """Build ``get_dynamic_map_diff`` params for the cached base map.
200
201    ``round_marker`` is an opaque request marker, commonly the current time in
202    milliseconds. It can be omitted and is not a version, offset, or merge key.
203    Preserve signed or unsigned nonce representations supplied by the caller.
204    """
205    params = {"nonce": _validated_nonce(map_nonce)}
206    if round_marker is not None:
207        params["round"] = round_marker
208    return params
209
210
211def dynamic_data_params(element_nonce: int, data_id: int, start: int, length: int) -> dict[str, int]:
212    """Build ``get_dynamic_data`` params for one element range.
213
214    ``length`` of zero asks for the remaining available items, capped by the
215    element limit. Nonces retain the caller's signed or unsigned representation.
216    """
217    if start < 0 or length < 0:
218        raise ValueError("start and length must be nonnegative")
219    if data_id <= 0:
220        raise ValueError("data_id must be positive")
221    return {"nonce": _validated_nonce(element_nonce), "data_id": data_id, "start": start, "len": length}
class DynamicMapResult(roborock.data.code_mappings.RoborockEnum):
35class DynamicMapResult(RoborockEnum):
36    """Application-level status returned by the dynamic map commands.
37
38    For ``get_dynamic_map_diff`` a ``nonce_mismatch`` means the requested base
39    map is no longer current. For ``get_dynamic_data`` it means the requested
40    element generation is no longer current. Neither establishes a new
41    generation by itself; the accompanying nonce does.
42    """
43
44    unknown = -1
45    ok = 0
46    nonce_mismatch = 1
47    full_map_required = 2

Application-level status returned by the dynamic map commands.

For get_dynamic_map_diff a nonce_mismatch means the requested base map is no longer current. For get_dynamic_data it means the requested element generation is no longer current. Neither establishes a new generation by itself; the accompanying nonce does.

unknown = <DynamicMapResult.unknown: -1>
nonce_mismatch = <DynamicMapResult.nonce_mismatch: 1>
full_map_required = <DynamicMapResult.full_map_required: 2>
@dataclass
class DynamicMapDiffEntry(roborock.data.containers.RoborockBase):
 67@dataclass
 68class DynamicMapDiffEntry(RoborockBase):
 69    """One element entry inside a ``get_dynamic_map_diff`` result.
 70
 71    Refresh indicators (charger, map image, ...) carry only ``count``. Dynamic
 72    elements carry ``nonce`` and ``max_len``; the path element can also carry
 73    an inline page via ``start``, ``len``, and ``data``.
 74    """
 75
 76    count: int | None = None
 77    """Change indicator since the base-map snapshot. Not an item or byte count."""
 78
 79    result: DynamicMapResult | None = None
 80    """Optional page status; inline captures normally use ``count`` instead."""
 81
 82    nonce: int | None = None
 83    """Element generation identity."""
 84
 85    max_len: int | None = None
 86    """Total logical length currently available for this generation."""
 87
 88    start: int | None = None
 89    """Zero-based logical offset of an inline page."""
 90
 91    len: int | None = None
 92    """Logical item count of an inline page."""
 93
 94    data: str | None = None
 95    """Base64 block sequence of an inline page."""
 96
 97    obstacle: int | None = None
 98    """Obstacle component of the main map image count, when reported."""
 99
100    space: int | None = None
101    """Space component of the main map image count, when reported."""
102
103    @property
104    def decoded_data(self) -> bytes | None:
105        """The inline page blocks, base64-decoded."""
106        return _decode_base64(self.data, "data")
107
108    @property
109    def has_inline_page(self) -> bool:
110        """Whether the inline range and data fields are present."""
111        return self.start is not None and self.len is not None and self.data is not None
112
113    @property
114    def is_success(self) -> bool:
115        """App acceptance predicate for a page: ``result == 0 or count == 0``."""
116        return self.result is DynamicMapResult.ok or self.count == 0

One element entry inside a get_dynamic_map_diff result.

Refresh indicators (charger, map image, ...) carry only count. Dynamic elements carry nonce and max_len; the path element can also carry an inline page via start, len, and data.

DynamicMapDiffEntry( count: int | None = None, result: DynamicMapResult | None = None, nonce: int | None = None, max_len: int | None = None, start: int | None = None, len: int | None = None, data: str | None = None, obstacle: int | None = None, space: int | None = None)
count: int | None = None

Change indicator since the base-map snapshot. Not an item or byte count.

result: DynamicMapResult | None = None

Optional page status; inline captures normally use count instead.

nonce: int | None = None

Element generation identity.

max_len: int | None = None

Total logical length currently available for this generation.

start: int | None = None

Zero-based logical offset of an inline page.

len: int | None = None

Logical item count of an inline page.

data: str | None = None

Base64 block sequence of an inline page.

obstacle: int | None = None

Obstacle component of the main map image count, when reported.

space: int | None = None

Space component of the main map image count, when reported.

decoded_data: bytes | None
103    @property
104    def decoded_data(self) -> bytes | None:
105        """The inline page blocks, base64-decoded."""
106        return _decode_base64(self.data, "data")

The inline page blocks, base64-decoded.

has_inline_page: bool
108    @property
109    def has_inline_page(self) -> bool:
110        """Whether the inline range and data fields are present."""
111        return self.start is not None and self.len is not None and self.data is not None

Whether the inline range and data fields are present.

is_success: bool
113    @property
114    def is_success(self) -> bool:
115        """App acceptance predicate for a page: ``result == 0 or count == 0``."""
116        return self.result is DynamicMapResult.ok or self.count == 0

App acceptance predicate for a page: result == 0 or count == 0.

@dataclass
class DynamicMapDiffResponse(roborock.data.containers.RoborockBase):
119@dataclass
120class DynamicMapDiffResponse(RoborockBase):
121    """Result object of ``get_dynamic_map_diff``."""
122
123    result: DynamicMapResult | None = None
124    """Application status. ``None`` when the device omitted it."""
125
126    nonce: int | None = None
127    """Map nonce the diff was computed against."""
128
129    diff: dict[int, DynamicMapDiffEntry] | None = None
130    """Element entries keyed by element id. An omitted element has no update."""
131
132    robot_data: str | None = None
133    """Base64 block sequence that updates the robot without adding path points."""
134
135    @property
136    def decoded_robot_data(self) -> bytes | None:
137        """The robot-only blocks, base64-decoded."""
138        return _decode_base64(self.robot_data, "robot_data")
139
140    def is_for_map(self, map_nonce: int) -> bool:
141        """Whether ``diff`` may be applied to the base map identified by ``map_nonce``."""
142        if self.result is not DynamicMapResult.ok or self.nonce is None or self.diff is None:
143            return False
144        try:
145            return (_validated_nonce(self.nonce) & 0xFFFFFFFF) == (_validated_nonce(map_nonce) & 0xFFFFFFFF)
146        except ValueError:
147            return False

Result object of get_dynamic_map_diff.

DynamicMapDiffResponse( result: DynamicMapResult | None = None, nonce: int | None = None, diff: dict[int, DynamicMapDiffEntry] | None = None, robot_data: str | None = None)
result: DynamicMapResult | None = None

Application status. None when the device omitted it.

nonce: int | None = None

Map nonce the diff was computed against.

diff: dict[int, DynamicMapDiffEntry] | None = None

Element entries keyed by element id. An omitted element has no update.

robot_data: str | None = None

Base64 block sequence that updates the robot without adding path points.

decoded_robot_data: bytes | None
135    @property
136    def decoded_robot_data(self) -> bytes | None:
137        """The robot-only blocks, base64-decoded."""
138        return _decode_base64(self.robot_data, "robot_data")

The robot-only blocks, base64-decoded.

def is_for_map(self, map_nonce: int) -> bool:
140    def is_for_map(self, map_nonce: int) -> bool:
141        """Whether ``diff`` may be applied to the base map identified by ``map_nonce``."""
142        if self.result is not DynamicMapResult.ok or self.nonce is None or self.diff is None:
143            return False
144        try:
145            return (_validated_nonce(self.nonce) & 0xFFFFFFFF) == (_validated_nonce(map_nonce) & 0xFFFFFFFF)
146        except ValueError:
147            return False

Whether diff may be applied to the base map identified by map_nonce.

@dataclass
class DynamicDataResponse(roborock.data.containers.RoborockBase):
150@dataclass
151class DynamicDataResponse(RoborockBase):
152    """Result object of ``get_dynamic_data``: one page of one element generation."""
153
154    result: DynamicMapResult | None = None
155    count: int | None = None
156    """Optional inline-page indicator accepted by the app's shared page handler."""
157
158    nonce: int | None = None
159    """Element nonce the page belongs to."""
160
161    data_id: int | None = None
162    start: int | None = None
163    len: int | None = None
164    """Actual logical range returned, which may be shorter than requested."""
165
166    max_len: int | None = None
167    """Total available length, which may have changed since it was announced."""
168
169    data: str | None = None
170
171    @property
172    def decoded_data(self) -> bytes | None:
173        """The page blocks, base64-decoded."""
174        return _decode_base64(self.data, "data")
175
176    @property
177    def is_success(self) -> bool:
178        """App acceptance predicate for a page: ``result == 0 or count == 0``."""
179        return self.result is DynamicMapResult.ok or self.count == 0

Result object of get_dynamic_data: one page of one element generation.

DynamicDataResponse( result: DynamicMapResult | None = None, count: int | None = None, nonce: int | None = None, data_id: int | None = None, start: int | None = None, len: int | None = None, max_len: int | None = None, data: str | None = None)
result: DynamicMapResult | None = None
count: int | None = None

Optional inline-page indicator accepted by the app's shared page handler.

nonce: int | None = None

Element nonce the page belongs to.

data_id: int | None = None
start: int | None = None
len: int | None = None

Actual logical range returned, which may be shorter than requested.

max_len: int | None = None

Total available length, which may have changed since it was announced.

data: str | None = None
decoded_data: bytes | None
171    @property
172    def decoded_data(self) -> bytes | None:
173        """The page blocks, base64-decoded."""
174        return _decode_base64(self.data, "data")

The page blocks, base64-decoded.

is_success: bool
176    @property
177    def is_success(self) -> bool:
178        """App acceptance predicate for a page: ``result == 0 or count == 0``."""
179        return self.result is DynamicMapResult.ok or self.count == 0

App acceptance predicate for a page: result == 0 or count == 0.

@dataclass
class DynamicMapSnapshotEntry(roborock.data.containers.RoborockBase):
182@dataclass
183class DynamicMapSnapshotEntry(RoborockBase):
184    """Snapshot metadata for one element, supplied alongside a full map."""
185
186    nonce: int | None = None
187    limit_len: int | None = None
188    """Page limit for the element when it has no configured limit."""

Snapshot metadata for one element, supplied alongside a full map.

DynamicMapSnapshotEntry(nonce: int | None = None, limit_len: int | None = None)
nonce: int | None = None
limit_len: int | None = None

Page limit for the element when it has no configured limit.

@dataclass
class DynamicMapSnapshot(roborock.data.containers.RoborockBase):
191@dataclass
192class DynamicMapSnapshot(RoborockBase):
193    """Top-level ``nonce`` and per-element ``snapshot`` carried by a full map result."""
194
195    nonce: int | None = None
196    snapshot: dict[int, DynamicMapSnapshotEntry] | None = None

Top-level nonce and per-element snapshot carried by a full map result.

DynamicMapSnapshot( nonce: int | None = None, snapshot: dict[int, DynamicMapSnapshotEntry] | None = None)
nonce: int | None = None
snapshot: dict[int, DynamicMapSnapshotEntry] | None = None
def dynamic_map_diff_params(map_nonce: int, round_marker: int | None = None) -> dict[str, int]:
199def dynamic_map_diff_params(map_nonce: int, round_marker: int | None = None) -> dict[str, int]:
200    """Build ``get_dynamic_map_diff`` params for the cached base map.
201
202    ``round_marker`` is an opaque request marker, commonly the current time in
203    milliseconds. It can be omitted and is not a version, offset, or merge key.
204    Preserve signed or unsigned nonce representations supplied by the caller.
205    """
206    params = {"nonce": _validated_nonce(map_nonce)}
207    if round_marker is not None:
208        params["round"] = round_marker
209    return params

Build get_dynamic_map_diff params for the cached base map.

round_marker is an opaque request marker, commonly the current time in milliseconds. It can be omitted and is not a version, offset, or merge key. Preserve signed or unsigned nonce representations supplied by the caller.

def dynamic_data_params( element_nonce: int, data_id: int, start: int, length: int) -> dict[str, int]:
212def dynamic_data_params(element_nonce: int, data_id: int, start: int, length: int) -> dict[str, int]:
213    """Build ``get_dynamic_data`` params for one element range.
214
215    ``length`` of zero asks for the remaining available items, capped by the
216    element limit. Nonces retain the caller's signed or unsigned representation.
217    """
218    if start < 0 or length < 0:
219        raise ValueError("start and length must be nonnegative")
220    if data_id <= 0:
221        raise ValueError("data_id must be positive")
222    return {"nonce": _validated_nonce(element_nonce), "data_id": data_id, "start": start, "len": length}

Build get_dynamic_data params for one element range.

length of zero asks for the remaining available items, capped by the element limit. Nonces retain the caller's signed or unsigned representation.