sofapython 0.0.1rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sofapython/macros.py ADDED
@@ -0,0 +1,669 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Dict, List, Tuple
5
+
6
+ from .hub_versions import HUB_VERSION_X1, HUB_VERSION_X1S, HUB_VERSION_X2
7
+ from .protocol_const import FAMILY_MACROS, opcode_family, opcode_hi
8
+ from .wire_schema import schema_for
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class _MacroBurst:
13
+ total_frames: int | None = None
14
+ expected_records: int | None = None
15
+ record_starts_seen: int = 0
16
+ frames: Dict[int, bytes] = field(default_factory=dict)
17
+ activity_id: int | None = None
18
+ record_start_frames: set = field(default_factory=set)
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class MacroBurstFrame:
23
+ opcode: int
24
+ role: str
25
+ fragment_index: int | None
26
+ total_fragments: int | None
27
+ activity_id: int | None
28
+ start_command_id: int | None
29
+ data_start: int
30
+ payload_length_matches_hi: bool
31
+ #: Number of frames this individual record spans (1 for the common
32
+ #: single-frame case, 2+ when the record's body is too large to
33
+ #: fit in one frame). Distinct from ``total_fragments`` which is
34
+ #: the total *records* in the surrounding burst.
35
+ record_pages: int | None = None
36
+
37
+ @property
38
+ def is_record_start(self) -> bool:
39
+ return self.role == "record_start"
40
+
41
+ @property
42
+ def display_name(self) -> str:
43
+ return "REQ_MACRO_LABELS_PAGE" if self.is_record_start else "REQ_MACRO_LABELS_CONT"
44
+
45
+
46
+ def parse_macro_burst_frame(opcode: int, raw_frame: bytes) -> MacroBurstFrame | None:
47
+ """Return parsed family metadata for a macro frame.
48
+
49
+ Macro pages are intended to assemble into one deterministic buffer rather
50
+ than being interpreted record-by-record at the frame level:
51
+
52
+ concat[3] = deviceID
53
+ concat[4] = keyID
54
+ concat[5] = N (count of 10-byte key entries)
55
+ concat[6 .. 6 + N*10] = N x 10-byte key entries
56
+ [deviceID, keyID, fid_byte*6,
57
+ duration (signed; -1 means delay-only),
58
+ delay]
59
+ If entry[1] == 0xFF, this is a
60
+ no-op / delay-only entry (type=0).
61
+ concat[length-31 .. length-1] = label, ASCII (X1)
62
+ concat[length-61 .. length-1] = label, UTF-16BE (X1S/X2)
63
+
64
+ Encoding selection is hub-model based, not byte-pattern heuristic.
65
+ UTF-16BE labels have no 0x0000 terminator and no length prefix; the full
66
+ fixed-width label slot is decoded and trimmed. ``0xFF`` mid-label is
67
+ legitimate data, never a delimiter.
68
+
69
+ """
70
+
71
+ if len(raw_frame) < 7 or opcode_family(opcode) != FAMILY_MACROS:
72
+ return None
73
+
74
+ payload = raw_frame[4:-1]
75
+ if not payload:
76
+ return None
77
+
78
+ payload_len_matches_hi = opcode_hi(opcode) == len(payload)
79
+ if len(payload) < 7:
80
+ return MacroBurstFrame(
81
+ opcode=opcode,
82
+ role="continuation",
83
+ fragment_index=None,
84
+ total_fragments=None,
85
+ activity_id=None,
86
+ start_command_id=None,
87
+ data_start=len(payload),
88
+ payload_length_matches_hi=payload_len_matches_hi,
89
+ )
90
+
91
+ p0, _, x, p3, _, y, a = payload[:7]
92
+ if x == 0x01 and y in (0x01, 0x02) and a != 0x00:
93
+ # Two independent counts live in the record-start preamble:
94
+ # - payload[3] is the number of records in the surrounding burst
95
+ # (1 for a single-record fetch, N when the hub returns N macros
96
+ # at once).
97
+ # - payload[4..5] big-endian is the number of frames this
98
+ # individual record spans. Records whose body is too large to
99
+ # fit in one frame split across multiple frames; the trailing
100
+ # frames arrive as continuations with no preamble of their own.
101
+ total_fragments = p3 or None
102
+ if total_fragments is not None and not (1 <= total_fragments <= 64):
103
+ total_fragments = None
104
+ record_pages = int.from_bytes(payload[4:6], "big") or None
105
+ if record_pages is not None and not (1 <= record_pages <= 64):
106
+ record_pages = None
107
+ return MacroBurstFrame(
108
+ opcode=opcode,
109
+ role="record_start",
110
+ fragment_index=p0 or 1,
111
+ total_fragments=total_fragments,
112
+ activity_id=a,
113
+ start_command_id=payload[7] if len(payload) > 7 else None,
114
+ data_start=7,
115
+ payload_length_matches_hi=payload_len_matches_hi,
116
+ record_pages=record_pages,
117
+ )
118
+
119
+ return MacroBurstFrame(
120
+ opcode=opcode,
121
+ role="continuation",
122
+ fragment_index=None,
123
+ total_fragments=None,
124
+ activity_id=None,
125
+ start_command_id=None,
126
+ data_start=7 if len(payload) > 7 else len(payload),
127
+ payload_length_matches_hi=payload_len_matches_hi,
128
+ record_pages=None,
129
+ )
130
+
131
+
132
+ class MacroAssembler:
133
+ """Reassemble multi-frame macro payloads returned by the hub."""
134
+
135
+ def __init__(self) -> None:
136
+ self._buffers: Dict[int, _MacroBurst] = {}
137
+ self._last_activity_id: int | None = None
138
+
139
+ def _get_buffer(self, activity_id: int) -> _MacroBurst:
140
+ buf = self._buffers.get(activity_id)
141
+ if buf is None:
142
+ buf = _MacroBurst(activity_id=activity_id)
143
+ self._buffers[activity_id] = buf
144
+ return buf
145
+
146
+ def _parse_header_from_payload(
147
+ self, payload: bytes, *, opcode_hi: int | None = None
148
+ ) -> tuple[int | None, int | None, int | None, int | None, bytes, bool]:
149
+ """Return (activity_id, frame_no, expected_records, record_pages, body, is_record_start).
150
+
151
+ Record-start frames strip 7 bytes (``payload[7:]``). Continuation
152
+ frames strip only 3 bytes (``payload[3:]``). The additional 4 bytes on
153
+ continuation pages belong to the assembled macro data and are commonly
154
+ absorbed into trailing padding; dropping them shifts later offsets and
155
+ misaligns the label slot on multi-page macros.
156
+
157
+ ``expected_records`` is the burst-wide record count carried in
158
+ ``payload[3]`` (how many distinct macros the hub is sending in this
159
+ burst). ``record_pages`` is the per-record frame count carried in
160
+ ``payload[4..5]`` big-endian (how many frames this individual macro
161
+ spans). The assembler needs both to know when the burst is complete
162
+ even when one of the records is large enough to require continuation
163
+ frames of its own.
164
+ """
165
+
166
+ if len(payload) < 7:
167
+ return self._last_activity_id, 1, None, None, payload, False
168
+
169
+ p0, _, x, p3, _, y, a = payload[:7]
170
+
171
+ activity_id: int | None
172
+ frame_no: int | None
173
+ expected_records: int | None
174
+ record_pages: int | None
175
+
176
+ # End the body at opcode_hi (the invariant-declared payload length)
177
+ # when known, so 1-byte transcription drift in synthetic fixtures
178
+ # doesn't shift the schema parser's offsets. Falls back to the full
179
+ # payload when opcode_hi isn't supplied.
180
+ body_end = opcode_hi if opcode_hi is not None else len(payload)
181
+
182
+ if x == 0x01 and y in (0x01, 0x02) and a != 0x00:
183
+ activity_id = a
184
+ frame_no = p0 or 1
185
+ expected_records = p3 or None
186
+ if expected_records is not None and not (1 <= expected_records <= 64):
187
+ expected_records = None
188
+ record_pages = int.from_bytes(payload[4:6], "big") or None
189
+ if record_pages is not None and not (1 <= record_pages <= 64):
190
+ record_pages = None
191
+ body = payload[7:body_end]
192
+ is_record_start = True
193
+ else:
194
+ activity_id = self._last_activity_id
195
+ frame_no = None
196
+ expected_records = None
197
+ record_pages = None
198
+ body = payload[3:body_end]
199
+ is_record_start = False
200
+
201
+ return activity_id, frame_no, expected_records, record_pages, body, is_record_start
202
+
203
+ def _process_fragment(
204
+ self,
205
+ *,
206
+ activity_id: int,
207
+ frame_no: int | None,
208
+ expected_records: int | None,
209
+ record_pages: int | None,
210
+ body: bytes,
211
+ is_record_start: bool,
212
+ ) -> List[Tuple[int, bytes, List[int]]]:
213
+ burst = self._get_buffer(activity_id)
214
+ self._last_activity_id = activity_id
215
+
216
+ # Continuation frames belong to the most recent record but are tracked
217
+ # as their own frame entry so the assembler can count them against the
218
+ # expected per-record page total.
219
+ if frame_no is None:
220
+ frame_no = (max(burst.frames) + 1) if burst.frames else 1
221
+
222
+ while frame_no in burst.frames and body:
223
+ frame_no += 1
224
+
225
+ if body:
226
+ burst.frames[frame_no] = body
227
+
228
+ if is_record_start:
229
+ burst.record_start_frames.add(frame_no)
230
+ burst.record_starts_seen += 1
231
+ if record_pages is not None:
232
+ burst.total_frames = (burst.total_frames or 0) + record_pages
233
+ elif burst.total_frames is None:
234
+ burst.total_frames = burst.record_starts_seen
235
+ if burst.expected_records is None and expected_records is not None:
236
+ burst.expected_records = expected_records
237
+
238
+ finished = False
239
+ if burst.expected_records is not None and burst.total_frames is not None:
240
+ if (
241
+ burst.record_starts_seen >= burst.expected_records
242
+ and len(burst.frames) >= burst.total_frames
243
+ ):
244
+ finished = True
245
+ elif burst.total_frames and len(burst.frames) >= burst.total_frames:
246
+ finished = True
247
+ elif burst.total_frames is None and len(burst.frames) >= 1:
248
+ # Unknown structure: accept after a single frame for the
249
+ # legacy single-frame, single-record case.
250
+ finished = True
251
+
252
+ if not finished:
253
+ return []
254
+
255
+ sorted_frames = sorted(burst.frames)
256
+ ordered = b"".join(burst.frames[i] for i in sorted_frames)
257
+
258
+ record_boundaries: List[int] = []
259
+ offset = 0
260
+ for frame_no_sorted in sorted_frames:
261
+ if frame_no_sorted in burst.record_start_frames:
262
+ record_boundaries.append(offset)
263
+ offset += len(burst.frames[frame_no_sorted])
264
+
265
+ del self._buffers[activity_id]
266
+ return [(activity_id, ordered, record_boundaries)]
267
+
268
+ def feed(self, opcode: int, payload: bytes, raw: bytes | None = None) -> List[Tuple[int, bytes, List[int]]]:
269
+ """Feed a macro-family payload and return completed assemblies."""
270
+
271
+ if not payload and not raw:
272
+ return []
273
+
274
+ payload_bytes = payload
275
+ if raw and len(raw) >= 6 and raw[0] == 0xA5 and raw[1] == 0x5A:
276
+ payload_bytes = raw[4:-1]
277
+
278
+ opcode_hi = (opcode >> 8) & 0xFF
279
+ (
280
+ activity_id,
281
+ frame_no,
282
+ expected_records,
283
+ record_pages,
284
+ body,
285
+ is_record_start,
286
+ ) = self._parse_header_from_payload(payload_bytes, opcode_hi=opcode_hi)
287
+
288
+ if activity_id is None:
289
+ return []
290
+
291
+ return self._process_fragment(
292
+ activity_id=activity_id,
293
+ frame_no=frame_no,
294
+ expected_records=expected_records,
295
+ record_pages=record_pages,
296
+ body=body,
297
+ is_record_start=is_record_start,
298
+ )
299
+
300
+
301
+ # ---------------------------------------------------------------------------
302
+ # Pure-function REQ_MACROS record parser.
303
+ #
304
+ # Each completed page-cycle yields one macro region. The integration's
305
+ # MacroAssembler delivers a post-assembly byte sequence that begins after the
306
+ # page-1 preamble plus the device/activity byte. Concretely:
307
+ #
308
+ # region[0] = key_id (which activity button this macro
309
+ # is bound to; called command_id in
310
+ # the integration's legacy decoder)
311
+ # region[1] = N (count of inner key entries)
312
+ # region[2 + i*10 : 2 + (i+1)*10] = i-th key entry (10 bytes)
313
+ # region[-30:] = label slot, X1 ASCII
314
+ # region[-60:] = label slot, X1S/X2 UTF-16BE
315
+ #
316
+ # Key entry (10 bytes):
317
+ # [0] deviceID
318
+ # [1] keyID (0xFF means delay-only / no-op entry)
319
+ # [2..7] fid (6-byte BE int)
320
+ # [8] duration / inputSign
321
+ # [9] delay (ms before next entry)
322
+ # ---------------------------------------------------------------------------
323
+
324
+
325
+ #: Size in bytes of one key entry inside a macro region.
326
+ MACRO_KEY_ENTRY_SIZE = 10
327
+
328
+ #: Byte offset within a region where per-key macro entries begin.
329
+ MACRO_KEY_ENTRY_START = 2
330
+
331
+ #: Length of the trailing label slot for X1 hubs (ASCII). Mirrored
332
+ #: from :mod:`wire_schema` for backwards-compatible imports.
333
+ MACRO_LABEL_LEN_X1 = schema_for(HUB_VERSION_X1).macro_label_slot_len
334
+
335
+ #: Length of the trailing label slot for X1S/X2 hubs (UTF-16BE).
336
+ MACRO_LABEL_LEN_X1S_X2 = schema_for(HUB_VERSION_X1S).macro_label_slot_len
337
+
338
+ assert MACRO_LABEL_LEN_X1 == 30 and MACRO_LABEL_LEN_X1S_X2 == 60
339
+
340
+
341
+ @dataclass(slots=True, frozen=True)
342
+ class MacroKeyEntry:
343
+ """One 10-byte key entry from a macro's inner key sequence."""
344
+
345
+ device_id: int
346
+ key_id: int
347
+ fid: int
348
+ duration: int
349
+ delay: int
350
+
351
+ @property
352
+ def is_delay_only(self) -> bool:
353
+ """A key_id of 0xFF indicates a delay-only / no-op entry."""
354
+
355
+ return self.key_id == 0xFF
356
+
357
+ @property
358
+ def entry_type(self) -> int:
359
+ """Return 0 for delay-only entries, else 1."""
360
+
361
+ return 0 if self.is_delay_only else 1
362
+
363
+
364
+ @dataclass(slots=True, frozen=True)
365
+ class MacroRecord:
366
+ """One macro definition parsed from an assembled REQ_MACROS region.
367
+
368
+ ``activity_id`` is supplied by the caller (the assembler keys bursts
369
+ by activity_id; the byte that holds it in the wire format lives in the
370
+ page-1 preamble the assembler strips before producing the region).
371
+
372
+ ``raw_label_slot`` preserves the on-wire label-slot bytes verbatim
373
+ (30 bytes on X1, 60 on X1S/X2). Real hubs leave non-zero metadata in
374
+ the trailing portion of the slot (e.g. ``37 37 00 00 35 35`` after a
375
+ ``POWER_ON`` label). The integration must round-trip those bytes when
376
+ re-saving the macro, otherwise the hub rejects the write.
377
+ """
378
+
379
+ activity_id: int
380
+ key_id: int # which activity button this macro is bound to
381
+ label: str
382
+ key_sequence: tuple[MacroKeyEntry, ...]
383
+ raw_label_slot: bytes = b""
384
+
385
+
386
+ def _stride_label_len_for_macros(hub_version: str) -> tuple[int, str]:
387
+ """Return ``(label_len, encoding)`` for the given hub model.
388
+
389
+ Thin wrapper over :func:`schema_for`; raises ``ValueError`` on
390
+ unknown variants via the shared schema.
391
+ """
392
+
393
+ schema = schema_for(hub_version)
394
+ return schema.macro_label_slot_len, schema.macro_label_encoding
395
+
396
+
397
+ #: Auto-generated activity macro labels that real hubs sometimes preface
398
+ #: with extra binding bytes inside the label slot. When the decoder finds
399
+ #: one of these substrings, the actual label is taken from that point on.
400
+ _AUTO_GENERATED_LABEL_MARKERS: Tuple[str, ...] = ("POWER_ON", "POWER_OFF")
401
+
402
+
403
+ def _decode_macro_schema_label(label_bytes: bytes, encoding: str) -> str:
404
+ """Decode a fixed-width macro label slot.
405
+
406
+ The entire slot is decoded under the chosen encoding, then null padding
407
+ and surrounding whitespace are removed.
408
+
409
+ Some hubs occasionally store extra binding data at the start of an
410
+ auto-generated POWER_ON / POWER_OFF macro's label slot before the
411
+ label text itself. The decoder rebases on any such marker substring
412
+ so the surfaced label matches what the user-facing app shows.
413
+ """
414
+
415
+ if encoding == "ascii":
416
+ try:
417
+ decoded = label_bytes.decode("ascii", errors="ignore")
418
+ except Exception:
419
+ decoded = ""
420
+ return _trim_macro_label(decoded)
421
+
422
+ raw = label_bytes
423
+ if len(raw) % 2:
424
+ raw = raw[:-1]
425
+ try:
426
+ decoded = raw.decode("utf-16-be", errors="ignore")
427
+ except Exception:
428
+ decoded = ""
429
+ return _trim_macro_label(decoded)
430
+
431
+
432
+ def _trim_macro_label(decoded: str) -> str:
433
+ """Clean a decoded label string, rebasing on known marker substrings."""
434
+
435
+ for marker in _AUTO_GENERATED_LABEL_MARKERS:
436
+ idx = decoded.find(marker)
437
+ if idx >= 0:
438
+ return decoded[idx:].split("\x00", 1)[0].strip()
439
+ return decoded.rstrip("\x00").strip()
440
+
441
+
442
+ def _encode_macro_schema_label(label: str, *, label_len: int, encoding: str) -> bytes:
443
+ """Encode one fixed-width macro label slot."""
444
+
445
+ try:
446
+ encoded = label.encode(encoding, errors="ignore")
447
+ except Exception:
448
+ encoded = b""
449
+ return encoded[:label_len].ljust(label_len, b"\x00")
450
+
451
+
452
+ def _parse_macro_key_entry(bean: bytes) -> MacroKeyEntry:
453
+ """Parse one 10-byte macro key-entry payload."""
454
+
455
+ return MacroKeyEntry(
456
+ device_id=bean[0],
457
+ key_id=bean[1],
458
+ fid=int.from_bytes(bean[2:8], "big"),
459
+ duration=bean[8],
460
+ delay=bean[9],
461
+ )
462
+
463
+
464
+ def _serialize_macro_key_entry(entry: MacroKeyEntry) -> bytes:
465
+ """Serialize one macro key entry using canonical layout."""
466
+
467
+ if entry.is_delay_only:
468
+ return b"\xFF" * 9 + bytes([entry.delay & 0xFF])
469
+
470
+ fid = entry.fid
471
+ if entry.key_id in (0xC5, 0xC6, 0xC7):
472
+ fid = 0
473
+
474
+ return (
475
+ bytes([entry.device_id & 0xFF, entry.key_id & 0xFF])
476
+ + fid.to_bytes(6, "big")
477
+ + bytes([entry.duration & 0xFF, 0xFF])
478
+ )
479
+
480
+
481
+ def parse_macro_record_from_region(
482
+ region: bytes,
483
+ *,
484
+ activity_id: int,
485
+ hub_version: str,
486
+ ) -> MacroRecord | None:
487
+ """Parse one :class:`MacroRecord` from a post-assembly macro region.
488
+
489
+ ``region`` is the slice of the assembler's ordered payload corresponding
490
+ to one completed page-cycle (one macro). The caller supplies
491
+ ``activity_id`` (captured by the assembler from the page-1 header) and
492
+ ``hub_version`` (which selects stride and label encoding).
493
+
494
+ Returns ``None`` if the region is too short to contain a valid macro
495
+ header (less than 2 bytes plus the label slot).
496
+
497
+ The parser does not scan for ``0xFF`` separators, does not use
498
+ byte-pattern heuristics to detect label encoding, and decodes the
499
+ label from the **end** of the region at the fixed
500
+ hub-version-determined slot length. It also exposes the inner
501
+ ``MacroKeyEntry`` sequence so callers that want to introspect the
502
+ macro's button presses can do so without re-parsing the bytes.
503
+
504
+ """
505
+
506
+ label_len, encoding = _stride_label_len_for_macros(hub_version)
507
+
508
+ # The label slot is read from the end of the region, skipping the final
509
+ # 1-byte terminator. The slot we want is ``region[-(label_len+1):-1]``.
510
+ if len(region) < MACRO_KEY_ENTRY_START + label_len + 1:
511
+ return None
512
+
513
+ key_id = region[0]
514
+ count = region[1]
515
+
516
+ sequence_end = MACRO_KEY_ENTRY_START + count * MACRO_KEY_ENTRY_SIZE
517
+ label_start = len(region) - (label_len + 1)
518
+ label_end = len(region) - 1 # skip the trailing terminator byte
519
+
520
+ # Defensive bounds check: if the declared count would overlap or pass
521
+ # the trailing label slot, clamp to what fits between the header and
522
+ # the label.
523
+ if sequence_end > label_start:
524
+ usable_bytes = max(0, label_start - MACRO_KEY_ENTRY_START)
525
+ count = usable_bytes // MACRO_KEY_ENTRY_SIZE
526
+
527
+ entries: list[MacroKeyEntry] = []
528
+ for i in range(count):
529
+ bean_start = MACRO_KEY_ENTRY_START + i * MACRO_KEY_ENTRY_SIZE
530
+ bean = region[bean_start : bean_start + MACRO_KEY_ENTRY_SIZE]
531
+ if len(bean) < MACRO_KEY_ENTRY_SIZE:
532
+ break
533
+ entries.append(
534
+ MacroKeyEntry(
535
+ device_id=bean[0],
536
+ key_id=bean[1],
537
+ fid=int.from_bytes(bean[2:8], "big"),
538
+ duration=bean[8],
539
+ delay=bean[9],
540
+ )
541
+ )
542
+
543
+ label_slot_bytes = bytes(region[label_start:label_end])
544
+ label = _decode_macro_schema_label(label_slot_bytes, encoding)
545
+
546
+ return MacroRecord(
547
+ activity_id=activity_id,
548
+ key_id=key_id,
549
+ label=label,
550
+ key_sequence=tuple(entries),
551
+ raw_label_slot=label_slot_bytes,
552
+ )
553
+
554
+
555
+ #: Maximum body chunk size carried per family-0x12 write page.
556
+ MACRO_WRITE_PAGE_BODY_CHUNK = 247
557
+
558
+
559
+ def build_macro_save_payload(
560
+ *,
561
+ activity_id: int,
562
+ key_id: int,
563
+ key_sequence: tuple[MacroKeyEntry, ...] | list[MacroKeyEntry],
564
+ label: str,
565
+ hub_version: str,
566
+ label_slot: bytes | None = None,
567
+ outer_sequence: int = 1,
568
+ ) -> bytes:
569
+ """Build one canonical macro-save payload matching.
570
+
571
+ Body layout: ``[0x01][total_pages_be][act_id][key_id][count] + Nx10 rows
572
+ + 30B (X1) or 60B (X1S/X2) label slot + 1B checksum``.
573
+
574
+ ``total_pages`` is computed from the final body length so the
575
+ checksum at ``body[-1]`` is always consistent with the bytes the
576
+ paged splitter will send. (The hub rejects writes whose declared
577
+ checksum disagrees with the sum of the body — the prior version
578
+ mutated ``body[1:3]`` after the checksum was computed, producing a
579
+ one-off mismatch on every multi-page macro.)
580
+
581
+ If ``label_slot`` is supplied (length must equal the hub's label slot
582
+ size), those bytes are written verbatim and ``label`` is ignored. This
583
+ matters for read-modify-write paths where the source hub stores
584
+ metadata in the trailing portion of the label slot (e.g. POWER_*
585
+ macros surface bytes like ``37 37 00 00 35 35`` after the encoded
586
+ name); dropping them causes the hub to reject the save with status
587
+ ``0x0c``.
588
+
589
+ If ``label_slot`` is omitted, the slot is built from ``label`` using
590
+ the hub's encoding (ASCII for X1, UTF-16BE for X1S/X2) with zero
591
+ padding.
592
+ """
593
+
594
+ label_len, encoding = _stride_label_len_for_macros(hub_version)
595
+ if label_slot is None:
596
+ slot_bytes = _encode_macro_schema_label(label, label_len=label_len, encoding=encoding)
597
+ elif len(label_slot) != label_len:
598
+ raise ValueError(
599
+ f"build_macro_save_payload: label_slot length {len(label_slot)} "
600
+ f"does not match expected slot length {label_len} for hub_version={hub_version!r}"
601
+ )
602
+ else:
603
+ slot_bytes = bytes(label_slot)
604
+
605
+ body = bytearray(
606
+ bytes([0x01, 0x00, 0x00]) # total_pages placeholder, set below
607
+ + bytes([activity_id & 0xFF, key_id & 0xFF, len(tuple(key_sequence)) & 0xFF])
608
+ )
609
+ for entry in key_sequence:
610
+ body.extend(_serialize_macro_key_entry(entry))
611
+ body.extend(slot_bytes)
612
+ body.append(0x00) # checksum slot
613
+
614
+ total_pages = max(1, (len(body) + MACRO_WRITE_PAGE_BODY_CHUNK - 1) // MACRO_WRITE_PAGE_BODY_CHUNK)
615
+ body[1:3] = (total_pages & 0xFFFF).to_bytes(2, "big")
616
+ body[-1] = sum(body[:-1]) & 0xFF
617
+
618
+ return bytes([0x01]) + (outer_sequence & 0xFFFF).to_bytes(2, "big") + bytes(body)
619
+
620
+
621
+ def parse_macro_records_from_burst(
622
+ payload: bytes,
623
+ *,
624
+ activity_id: int,
625
+ record_boundaries: list[int],
626
+ hub_version: str,
627
+ ) -> list[MacroRecord]:
628
+ """Parse all macros in a completed burst's assembled payload.
629
+
630
+ Walks the ``record_boundaries`` produced by :class:`MacroAssembler` and
631
+ runs :func:`parse_macro_record_from_region` on each resulting region.
632
+
633
+ The parser produces ``(activity_id, key_id)``-keyed records, exposes
634
+ the inner key sequence, and walks records at fixed offsets within
635
+ each boundary-delimited region.
636
+ """
637
+
638
+ records: list[MacroRecord] = []
639
+ for idx, boundary in enumerate(record_boundaries):
640
+ if boundary >= len(payload):
641
+ continue
642
+ region_end = (
643
+ record_boundaries[idx + 1]
644
+ if idx + 1 < len(record_boundaries)
645
+ else len(payload)
646
+ )
647
+ region = payload[boundary:region_end]
648
+ record = parse_macro_record_from_region(
649
+ region, activity_id=activity_id, hub_version=hub_version
650
+ )
651
+ if record is not None:
652
+ records.append(record)
653
+ return records
654
+
655
+
656
+ __all__ = [
657
+ "MACRO_KEY_ENTRY_SIZE",
658
+ "MACRO_KEY_ENTRY_START",
659
+ "MACRO_LABEL_LEN_X1",
660
+ "MACRO_LABEL_LEN_X1S_X2",
661
+ "MacroAssembler",
662
+ "MacroBurstFrame",
663
+ "MacroKeyEntry",
664
+ "MacroRecord",
665
+ "build_macro_save_payload",
666
+ "parse_macro_burst_frame",
667
+ "parse_macro_record_from_region",
668
+ "parse_macro_records_from_burst",
669
+ ]