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/commands.py ADDED
@@ -0,0 +1,1273 @@
1
+ """Helpers for assembling, parsing, and synthesizing command payloads."""
2
+
3
+ from __future__ import annotations
4
+ from dataclasses import dataclass, field
5
+ import re
6
+ from typing import Any, Dict, Iterable, Iterator, List, Tuple
7
+
8
+ from .hub_versions import HUB_VERSION_X1, HUB_VERSION_X1S, HUB_VERSION_X2
9
+ from .wire_schema import schema_for
10
+ from .protocol_const import (
11
+ FAMILY_KEYMAP,
12
+ FAMILY_DEVBTNS,
13
+ opcode_family,
14
+ OP_MARKER,
15
+ OP_DEVBTN_SINGLE,
16
+ OP_KEYMAP_CONT,
17
+ OP_KEYMAP_FINAL_X1S,
18
+ OP_KEYMAP_PAGE_X2_C03D,
19
+ OP_KEYMAP_EXTRA,
20
+ OP_KEYMAP_OVERLAY_X1,
21
+ OP_KEYMAP_PAGE_X1_663D,
22
+ OP_KEYMAP_PAGE_X1_AE3D,
23
+ OP_KEYMAP_PAGE_X1_E43D,
24
+ OP_KEYMAP_TBL_A,
25
+ OP_KEYMAP_TBL_B,
26
+ OP_KEYMAP_TBL_C,
27
+ OP_KEYMAP_TBL_D,
28
+ OP_KEYMAP_TBL_E,
29
+ OP_KEYMAP_TBL_F,
30
+ OP_KEYMAP_TBL_G,
31
+ )
32
+
33
+
34
+ def _is_devbtn_family(opcode: int) -> bool:
35
+ """Return True if the opcode belongs to the dev-button page family."""
36
+
37
+ return opcode_family(opcode) == FAMILY_DEVBTNS
38
+
39
+
40
+ def _is_keymap_family(opcode: int) -> bool:
41
+ """Return True if the opcode belongs to the keymap/button family."""
42
+
43
+ return opcode_family(opcode) == FAMILY_KEYMAP
44
+
45
+
46
+ _KEYMAP_HEADER_OPCODES: set[int] = {
47
+ OP_KEYMAP_TBL_A,
48
+ OP_KEYMAP_TBL_B,
49
+ OP_KEYMAP_TBL_C,
50
+ OP_KEYMAP_TBL_D,
51
+ OP_KEYMAP_TBL_E,
52
+ OP_KEYMAP_TBL_F,
53
+ OP_KEYMAP_TBL_G,
54
+ OP_KEYMAP_EXTRA,
55
+ }
56
+
57
+ _KEYMAP_X1_PAGE_OPCODES: set[int] = {
58
+ OP_KEYMAP_PAGE_X1_663D,
59
+ OP_KEYMAP_PAGE_X1_AE3D,
60
+ OP_KEYMAP_PAGE_X1_E43D,
61
+ }
62
+
63
+ _KEYMAP_X1S_PAGE_OPCODES: set[int] = {
64
+ OP_KEYMAP_CONT,
65
+ OP_KEYMAP_PAGE_X2_C03D,
66
+ }
67
+
68
+ @dataclass(slots=True)
69
+ class CommandRecord:
70
+ """Structured representation of a single device command label."""
71
+
72
+ dev_id: int
73
+ command_id: int
74
+ control: bytes
75
+ label: str
76
+ sort_id: int = 0
77
+
78
+
79
+ @dataclass(slots=True)
80
+ class _CommandBurst:
81
+ variant: str | None = None
82
+ total_frames: int | None = None
83
+ total_commands: int | None = None
84
+ frames: Dict[int, bytes] = field(default_factory=dict)
85
+
86
+ @property
87
+ def received(self) -> int:
88
+ return len(self.frames)
89
+
90
+
91
+ @dataclass(slots=True, frozen=True)
92
+ class CommandBurstFrame:
93
+ """Structured metadata extracted from a family-0x5D command frame."""
94
+
95
+ opcode: int
96
+ hub_line: str
97
+ layout_kind: str
98
+ role: str
99
+ frame_no: int
100
+ device_id: int
101
+ data_start: int
102
+ total_frames: int | None = None
103
+ total_commands: int | None = None
104
+ first_command_id: int | None = None
105
+ format_marker: int | None = None
106
+
107
+ @property
108
+ def is_header(self) -> bool:
109
+ return self.role == "header"
110
+
111
+ @property
112
+ def is_single(self) -> bool:
113
+ return self.role == "single"
114
+
115
+ @property
116
+ def is_final(self) -> bool:
117
+ return self.role == "final"
118
+
119
+
120
+ @dataclass(slots=True, frozen=True)
121
+ class IrCommandDumpFrame:
122
+ """Structured metadata extracted from raw IR command blob pages."""
123
+
124
+ opcode: int
125
+ family: int
126
+ response_index: int
127
+ command_id: int
128
+ page_no: int
129
+ device_id: int | None
130
+ total_commands: int | None
131
+ total_pages: int | None
132
+ format_marker: int | None
133
+ label: str | None
134
+
135
+ @property
136
+ def is_page_one(self) -> bool:
137
+ return self.page_no == 1
138
+
139
+
140
+ @dataclass(slots=True)
141
+ class _ButtonBurst:
142
+ variant: str | None = None
143
+ total_frames: int | None = None
144
+ total_rows: int | None = None
145
+ frames: Dict[int, bytes] = field(default_factory=dict)
146
+
147
+ @property
148
+ def received(self) -> int:
149
+ return len(self.frames)
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Fixed-width REQ_BUTTONS / keymap record iterator.
154
+ #
155
+ # Each assembled keymap record is 18 bytes; bursts are walked at fixed stride
156
+ # after page assembly.
157
+ # ---------------------------------------------------------------------------
158
+
159
+ #: Size in bytes of one REQ_BUTTONS / keymap record.
160
+ KEYMAP_RECORD_SIZE = 18
161
+
162
+
163
+ @dataclass(slots=True, frozen=True)
164
+ class KeymapRecord:
165
+ """One 18-byte keymap record from an assembled REQ_BUTTONS concat buffer.
166
+
167
+ Field layout (offsets within ``raw``):
168
+
169
+ ===== =====================================================================
170
+ Off. Meaning
171
+ ===== =====================================================================
172
+ 0 Activity id (matches the burst's activity).
173
+ 1 Button or favorite id. If this value is a known hardware button code
174
+ (``BUTTONNAME_BY_CODE``), the record describes a button-to-command
175
+ binding; otherwise it is interpreted as an activity-favorite slot.
176
+ 2 Device id that the bound command targets.
177
+ 9 Command id within that device.
178
+ 10-17 Optional long-press extension. Present iff::
179
+
180
+ raw[10] != 0 and raw[11:15] == b"\\x00\\x00\\x00\\x00"
181
+ and raw[15] == 0x4E
182
+
183
+ When present, ``raw[10]`` is the long-press device id and
184
+ ``raw[17]`` is the long-press command id.
185
+ ===== =====================================================================
186
+
187
+ Bytes 3-8, 16 and the slot occupied by the long-press marker when not in
188
+ long-press shape, are not used by the integration. This dataclass exposes
189
+ the raw 18 bytes so callers that need to inspect them retain full access.
190
+ """
191
+
192
+ raw: bytes
193
+
194
+ @property
195
+ def activity_id(self) -> int:
196
+ return self.raw[0]
197
+
198
+ @property
199
+ def button_id(self) -> int:
200
+ return self.raw[1]
201
+
202
+ @property
203
+ def device_id(self) -> int:
204
+ return self.raw[2]
205
+
206
+ @property
207
+ def command_id(self) -> int:
208
+ return self.raw[9]
209
+
210
+ @property
211
+ def has_long_press(self) -> bool:
212
+ return (
213
+ len(self.raw) >= 18
214
+ and self.raw[10] != 0
215
+ and self.raw[11:15] == b"\x00\x00\x00\x00"
216
+ and self.raw[15] == 0x4E
217
+ )
218
+
219
+ @property
220
+ def long_press_device_id(self) -> int | None:
221
+ return self.raw[10] if self.has_long_press else None
222
+
223
+ @property
224
+ def long_press_command_id(self) -> int | None:
225
+ return self.raw[17] if self.has_long_press else None
226
+
227
+
228
+ def iter_keymap_records(
229
+ concat: bytes,
230
+ *,
231
+ expected_activity_id: int | None = None,
232
+ ) -> Iterator[KeymapRecord]:
233
+ """Yield :class:`KeymapRecord` objects from an assembled keymap buffer.
234
+
235
+ ``concat`` is the post-assembly buffer produced by
236
+ :class:`DeviceButtonAssembler` i.e., the concatenated row stream with
237
+ per-frame transport headers already stripped. The iterator walks the
238
+ buffer in 18-byte strides; trailing bytes shorter than one record are
239
+ ignored.
240
+
241
+ When ``expected_activity_id`` is provided, records whose
242
+ :attr:`KeymapRecord.activity_id` does not match are silently skipped.
243
+ The assembler keys bursts by activity id, so mismatches normally only
244
+ occur on malformed firmware data; this guard mirrors the existing
245
+ behavior of ``StateHelpers._parse_keymap_record`` which returns early on
246
+ activity-id mismatch.
247
+
248
+ Note this iterator deliberately does **not** emit a padded short-tail
249
+ record for trailing fragments shorter than 18 bytes. The integration has
250
+ historically tolerated such fragments when they look like a valid record
251
+ start; that fallback behavior remains in ``state_helpers`` for now.
252
+ """
253
+
254
+ usable = len(concat) - (len(concat) % KEYMAP_RECORD_SIZE)
255
+ for start in range(0, usable, KEYMAP_RECORD_SIZE):
256
+ record = KeymapRecord(raw=bytes(concat[start : start + KEYMAP_RECORD_SIZE]))
257
+ if expected_activity_id is not None and record.activity_id != expected_activity_id:
258
+ continue
259
+ yield record
260
+
261
+
262
+ @dataclass(slots=True, frozen=True)
263
+ class ButtonBurstFrame:
264
+ """Structured metadata extracted from a family-0x3D button frame."""
265
+
266
+ opcode: int
267
+ hub_line: str
268
+ layout_kind: str
269
+ role: str
270
+ frame_no: int
271
+ activity_id: int | None
272
+ data_start: int
273
+ total_frames: int | None = None
274
+ total_rows: int | None = None
275
+ has_row_data: bool = True
276
+
277
+ @property
278
+ def is_header(self) -> bool:
279
+ return self.role == "header"
280
+
281
+ @property
282
+ def is_final(self) -> bool:
283
+ return self.role == "final"
284
+
285
+ @property
286
+ def is_marker(self) -> bool:
287
+ return self.role == "marker"
288
+
289
+
290
+ # ---------------------------------------------------------------------------
291
+ # Fixed-width REQ_COMMANDS record iterator.
292
+ #
293
+ # Reference layout for an assembled REQ_COMMANDS burst:
294
+ #
295
+ # concat = page1_body || page2_body || ... || pageN_body
296
+ # where pageK_body = raw_frame[7 : 7 + (raw_frame[2] - 3)]
297
+ # concat[3] = N (record count)
298
+ # concat[4 + i*stride : 4 + (i+1)*stride] = record i
299
+ #
300
+ # stride is 40 on X1 (ASCII), 70 on X1S/X2 (UTF-16BE). The label slot lives at
301
+ # record-offset 9 with length 30 (X1) or 60 (X1S/X2). Encoding is selected
302
+ # from the hub model, not from byte-pattern heuristics.
303
+ #
304
+ # The integration's existing assembler (`DeviceCommandAssembler`) strips
305
+ # `parsed.data_start` bytes per frame before concatenating. For page-1
306
+ # headers that's 7, for page-2+ pages it's 3. The 4-byte difference equals
307
+ # the page-1 preamble + count byte kept in `concat[0..3]`.
308
+ #
309
+ # Therefore, when the integration calls the new iterator on its post-assembly
310
+ # body, the body already starts at what the app sees as `concat[4]`. The
311
+ # record count must be supplied separately (by the caller, who has it as
312
+ # `parsed.total_commands`).
313
+ # ---------------------------------------------------------------------------
314
+
315
+
316
+ #: Per-record stride in the assembled body. 40 bytes on X1 (ASCII labels),
317
+ #: 70 bytes on X1S/X2 (UTF-16BE labels). Mirrored from
318
+ #: :mod:`wire_schema` for backwards-compatible external imports.
319
+ COMMAND_RECORD_STRIDE_X1 = schema_for(HUB_VERSION_X1).command_stride
320
+ COMMAND_RECORD_STRIDE_X1S_X2 = schema_for(HUB_VERSION_X1S).command_stride
321
+
322
+ #: Per-record byte offsets common to both X1 and X1S/X2 layouts.
323
+ COMMAND_RECORD_LABEL_OFFSET = 9
324
+ COMMAND_RECORD_LABEL_LEN_X1 = schema_for(HUB_VERSION_X1).command_label_slot_len
325
+ COMMAND_RECORD_LABEL_LEN_X1S_X2 = schema_for(HUB_VERSION_X1S).command_label_slot_len
326
+
327
+ assert COMMAND_RECORD_STRIDE_X1 == 40 and COMMAND_RECORD_STRIDE_X1S_X2 == 70
328
+ assert COMMAND_RECORD_LABEL_LEN_X1 == 30 and COMMAND_RECORD_LABEL_LEN_X1S_X2 == 60
329
+
330
+
331
+ def _stride_and_label_len(hub_version: str) -> tuple[int, int, str]:
332
+ """Return (stride, label_len, encoding) for the hub model.
333
+
334
+ Thin wrapper over :func:`schema_for`; encoding is the literal
335
+ codec name accepted by ``bytes.decode``. Unknown hub versions
336
+ raise ``ValueError`` via the shared schema.
337
+ """
338
+
339
+ schema = schema_for(hub_version)
340
+ return schema.command_stride, schema.command_label_slot_len, schema.command_label_encoding
341
+
342
+
343
+ def _decode_schema_label(label_bytes: bytes, encoding: str) -> str:
344
+ """Decode a fixed-width command label slot.
345
+
346
+ The full slot is decoded under the selected encoding, then null padding
347
+ and surrounding whitespace are removed. There is no embedded length
348
+ prefix or terminator scan.
349
+ """
350
+
351
+ if encoding == "ascii":
352
+ # ASCII path: decode strict bytes, strip nulls and whitespace.
353
+ try:
354
+ decoded = label_bytes.decode("ascii", errors="ignore")
355
+ except Exception:
356
+ decoded = ""
357
+ return decoded.rstrip("\x00").strip()
358
+
359
+ # UTF-16BE path: pad to even length defensively (real slots are always
360
+ # even on X1S/X2, but a malformed fixture could pass an odd length).
361
+ raw = label_bytes
362
+ if len(raw) % 2:
363
+ raw = raw[:-1]
364
+ try:
365
+ decoded = raw.decode("utf-16-be", errors="ignore")
366
+ except Exception:
367
+ decoded = ""
368
+ return decoded.rstrip("\x00").strip()
369
+
370
+
371
+ def iter_command_records_from_assembled(
372
+ body: bytes,
373
+ *,
374
+ count: int,
375
+ dev_id: int,
376
+ hub_version: str,
377
+ ) -> Iterator[CommandRecord]:
378
+ """Yield :class:`CommandRecord` objects from an assembled REQ_COMMANDS body.
379
+
380
+ ``body`` is the post-assembly buffer produced by
381
+ :class:`DeviceCommandAssembler` the per-frame transport headers and the
382
+ page-1 preamble (page metadata + count byte) are already stripped, so
383
+ ``body[0]`` is the first byte of the first record.
384
+
385
+ ``count`` is required; it should be the value the parser captured in
386
+ :attr:`CommandBurstFrame.total_commands` (the count byte from the
387
+ assembled header). Inference from ``len(body) // stride`` is deliberately
388
+ not done: a body that contains trailing padding or has been truncated
389
+ mid-record would silently miscount.
390
+
391
+ ``hub_version`` selects stride (40 X1 / 70 X1S/X2) and label encoding
392
+ (ASCII X1 / UTF-16BE X1S/X2). Unknown hub versions raise ``ValueError``.
393
+
394
+ Records beyond what ``body`` can supply are silently skipped this is
395
+ a tolerant choice for truncated-burst scenarios; the caller can detect
396
+ by comparing returned record count to the requested ``count``.
397
+
398
+ Per-record layout (offsets within the 40 or 70 byte stride):
399
+
400
+ - ``[0]`` device id
401
+ - ``[1]`` command id
402
+ - ``[2]`` code type / format marker
403
+ - ``[3..8]`` fid (6 bytes; treated as opaque control bytes here)
404
+ - ``[9..label_end]`` label slot (30 or 60 bytes)
405
+ - ``[stride-1]`` sort id (== command id on observed fixtures)
406
+
407
+ The yielded :class:`CommandRecord` retains the existing field set:
408
+ ``dev_id`` is taken from record byte 0, ``command_id`` from byte 1, and
409
+ ``control`` is the 7 bytes at record[2..9] for consistency with existing
410
+ CommandRecord callers.
411
+ """
412
+
413
+ stride, label_len, encoding = _stride_and_label_len(hub_version)
414
+ label_end = COMMAND_RECORD_LABEL_OFFSET + label_len
415
+
416
+ for i in range(count):
417
+ start = i * stride
418
+ end = start + stride
419
+ if end > len(body):
420
+ return # truncated body; caller can detect via returned-count diff
421
+ record = body[start:end]
422
+
423
+ label_bytes = record[COMMAND_RECORD_LABEL_OFFSET:label_end]
424
+ label = _decode_schema_label(label_bytes, encoding)
425
+
426
+ yield CommandRecord(
427
+ dev_id=record[0],
428
+ command_id=record[1],
429
+ control=bytes(record[2 : COMMAND_RECORD_LABEL_OFFSET]),
430
+ label=label,
431
+ sort_id=record[stride - 1] & 0xFF,
432
+ )
433
+
434
+
435
+ def _button_hub_line(hub_version: str) -> str:
436
+ """Return the burst-frame ``hub_line`` tag for the hub variant.
437
+
438
+ Validates ``hub_version`` against the shared schema so unknown
439
+ values fail at the call site rather than silently falling back to
440
+ a shape-sniffing heuristic.
441
+ """
442
+
443
+ schema_for(hub_version)
444
+ return "x1" if hub_version == HUB_VERSION_X1 else "x1s_x2"
445
+
446
+
447
+ def _command_hub_line(hub_version: str) -> str:
448
+ """Return the burst-frame ``hub_line`` tag for the hub variant."""
449
+
450
+ schema_for(hub_version)
451
+ return "x1" if hub_version == HUB_VERSION_X1 else "x1s_x2"
452
+
453
+
454
+ def parse_button_burst_frame(
455
+ opcode: int,
456
+ raw_frame: bytes,
457
+ *,
458
+ hub_version: str,
459
+ ) -> ButtonBurstFrame | None:
460
+ """Return parsed family metadata for a button-burst frame.
461
+
462
+ REQ_BUTTONS pages assemble into a counted fixed-width row stream. Marker
463
+ variants are treated as ordinary pages for assembly purposes, so the low
464
+ byte identifies the family while the high byte remains payload-length
465
+ metadata only.
466
+ """
467
+
468
+ if len(raw_frame) < 7:
469
+ return None
470
+
471
+ payload = raw_frame[4:-1]
472
+ if len(payload) < 3 or not _is_keymap_family(opcode):
473
+ return None
474
+
475
+ frame_no = payload[2]
476
+ hinted_line = _button_hub_line(hub_version)
477
+ total_frames = int.from_bytes(payload[4:6], "big") if len(payload) >= 6 else None
478
+ if total_frames == 0:
479
+ total_frames = None
480
+ total_rows = payload[6] if frame_no == 1 and len(payload) > 6 and payload[6] > 0 else None
481
+
482
+ if frame_no == 1 and total_frames is not None and total_frames > 0 and len(payload) > 7:
483
+ layout_kind = "header"
484
+ if opcode == OP_KEYMAP_OVERLAY_X1 or (hinted_line == "x1" and total_frames == 1):
485
+ layout_kind = "x1_overlay"
486
+ return ButtonBurstFrame(
487
+ opcode=opcode,
488
+ hub_line=hinted_line,
489
+ layout_kind=layout_kind,
490
+ role="header",
491
+ frame_no=frame_no,
492
+ activity_id=payload[7],
493
+ data_start=7,
494
+ total_frames=total_frames,
495
+ total_rows=total_rows,
496
+ has_row_data=len(payload) > 7,
497
+ )
498
+
499
+ if opcode == OP_MARKER:
500
+ return ButtonBurstFrame(
501
+ opcode=opcode,
502
+ hub_line="x1s_x2" if hinted_line != "x1" else "x1",
503
+ layout_kind="x1s_marker",
504
+ role="marker",
505
+ frame_no=frame_no,
506
+ activity_id=None,
507
+ data_start=len(payload),
508
+ total_frames=total_frames if total_frames and total_frames > 0 else None,
509
+ total_rows=total_rows,
510
+ has_row_data=False,
511
+ )
512
+
513
+ stream = payload[3:] if len(payload) > 3 else b""
514
+ if stream and len(stream) < 18:
515
+ inferred_line = hinted_line
516
+
517
+ role = "final" if total_frames is not None and frame_no >= total_frames else "page"
518
+ layout_kind = "page"
519
+ if inferred_line == "x1":
520
+ layout_kind = "x1_page"
521
+ elif inferred_line == "x1s_x2":
522
+ layout_kind = "x1s_final" if role == "final" else "x1s_page"
523
+
524
+ return ButtonBurstFrame(
525
+ opcode=opcode,
526
+ hub_line=inferred_line,
527
+ layout_kind=layout_kind,
528
+ role=role,
529
+ frame_no=frame_no,
530
+ activity_id=None,
531
+ data_start=3,
532
+ total_frames=total_frames,
533
+ total_rows=None,
534
+ has_row_data=True,
535
+ )
536
+
537
+ # Continuation pages carry only row bytes; the activity id was established
538
+ # by the page-1 header and is held by the burst assembler. Do not infer it
539
+ # from page-2+ row bytes -- inner record bytes can accidentally match a
540
+ # row-start shape and route the page to the wrong burst.
541
+ inferred_line = hinted_line
542
+ if inferred_line == "shared":
543
+ inferred_line = "x1s_x2" if frame_no > 1 else "shared"
544
+
545
+ role = "final" if total_frames is not None and frame_no >= total_frames else "page"
546
+ layout_kind = "page"
547
+ if inferred_line == "x1":
548
+ layout_kind = "x1_page"
549
+ elif inferred_line == "x1s_x2":
550
+ layout_kind = "x1s_final" if role == "final" else "x1s_page"
551
+
552
+ return ButtonBurstFrame(
553
+ opcode=opcode,
554
+ hub_line=inferred_line,
555
+ layout_kind=layout_kind,
556
+ role=role,
557
+ frame_no=frame_no,
558
+ activity_id=None,
559
+ data_start=3,
560
+ total_frames=total_frames if total_frames and total_frames > 0 else None,
561
+ total_rows=total_rows,
562
+ has_row_data=bool(stream),
563
+ )
564
+
565
+
566
+ def parse_command_burst_frame(
567
+ opcode: int,
568
+ raw_frame: bytes,
569
+ *,
570
+ hub_version: str,
571
+ ) -> CommandBurstFrame | None:
572
+ """Return parsed family metadata for a command-burst frame.
573
+
574
+ REQ_COMMANDS pages assemble into counted fixed-width records: 40-byte
575
+ rows on X1 and 70-byte rows on X1S/X2, with the label slot beginning at
576
+ offset 9. Labels are decoded from the hub model's expected encoding and
577
+ ``0xFF`` bytes inside the slot are treated as data, not delimiters.
578
+
579
+ The existing magic-byte sniffing below detects per-frame layout variants.
580
+ A future fully post-assembly path can replace that heuristic with the
581
+ fixed-width record walk used elsewhere in the module.
582
+ """
583
+
584
+ if len(raw_frame) < 7:
585
+ return None
586
+
587
+ payload = raw_frame[4:-1]
588
+ if len(payload) < 4:
589
+ return None
590
+
591
+ hinted_line = _command_hub_line(hub_version)
592
+ frame_no = payload[2]
593
+
594
+ is_input_refresh_layout = (
595
+ opcode_family(opcode) == 0x0D
596
+ and len(payload) > 8
597
+ and payload[:6] == b"\x01\x00\x01\x01\x00\x01"
598
+ )
599
+ is_prefixed_single_layout = (
600
+ opcode_family(opcode) == FAMILY_DEVBTNS
601
+ and len(payload) > 9
602
+ and payload[:6] == b"\x01\x00\x01\x01\x00\x01"
603
+ and payload[6] == 0x01
604
+ )
605
+ is_single_layout = (
606
+ opcode == OP_DEVBTN_SINGLE
607
+ or (
608
+ opcode_family(opcode) == 0x0D
609
+ and len(payload) > 7
610
+ and payload[:6] == b"\x01\x00\x01\x01\x00\x01"
611
+ )
612
+ or is_prefixed_single_layout
613
+ )
614
+ if is_single_layout:
615
+ device_id = payload[3]
616
+ layout_kind = "single"
617
+ data_start = 7
618
+ first_command_id = payload[8] if len(payload) > 8 else None
619
+ format_marker = payload[9] if len(payload) > 9 else None
620
+ if payload[:6] == b"\x01\x00\x01\x01\x00\x01" and len(payload) > 7:
621
+ device_id = payload[7]
622
+ if is_input_refresh_layout:
623
+ # 0x020C WiFi/input-config refresh replies reuse the single-frame
624
+ # envelope, but the payload fields differ from normal REQ_COMMANDS:
625
+ # <dev_id> <slot_id> <fmt> ...
626
+ device_id = payload[6]
627
+ layout_kind = "input_config_refresh"
628
+ data_start = 8
629
+ first_command_id = payload[7] if len(payload) > 7 else None
630
+ format_marker = payload[8] if len(payload) > 8 else None
631
+ return CommandBurstFrame(
632
+ opcode=opcode,
633
+ hub_line=hinted_line,
634
+ layout_kind=layout_kind,
635
+ role="single",
636
+ frame_no=frame_no,
637
+ device_id=device_id,
638
+ total_frames=1,
639
+ data_start=data_start,
640
+ first_command_id=first_command_id,
641
+ format_marker=format_marker,
642
+ )
643
+
644
+ if not _is_devbtn_family(opcode):
645
+ return None
646
+
647
+ if payload[:2] != b"\x01\x00":
648
+ return None
649
+
650
+ if frame_no == 1 and len(payload) > 7 and payload[4] == 0x00:
651
+ layout_kind = "shared_classic"
652
+ if hinted_line == "x1":
653
+ layout_kind = "x1_classic"
654
+ elif hinted_line == "x1s_x2":
655
+ layout_kind = "x1s_x2"
656
+ return CommandBurstFrame(
657
+ opcode=opcode,
658
+ hub_line=hinted_line,
659
+ layout_kind=layout_kind,
660
+ role="header",
661
+ frame_no=frame_no,
662
+ device_id=payload[7],
663
+ total_frames=int.from_bytes(payload[4:6], "big"),
664
+ total_commands=payload[6],
665
+ data_start=7,
666
+ first_command_id=payload[8] if len(payload) > 8 else None,
667
+ format_marker=payload[9] if len(payload) > 9 else None,
668
+ )
669
+
670
+ if frame_no == 1 and len(payload) > 8:
671
+ return CommandBurstFrame(
672
+ opcode=opcode,
673
+ hub_line="x1" if hinted_line == "shared" else hinted_line,
674
+ layout_kind="x1_wifi",
675
+ role="header",
676
+ frame_no=frame_no,
677
+ device_id=payload[6],
678
+ total_frames=payload[4],
679
+ total_commands=payload[5],
680
+ data_start=6,
681
+ first_command_id=payload[7] if len(payload) > 7 else None,
682
+ format_marker=payload[8] if len(payload) > 8 else None,
683
+ )
684
+
685
+ role = "page"
686
+ return CommandBurstFrame(
687
+ opcode=opcode,
688
+ hub_line=hinted_line,
689
+ layout_kind="x1s_x2" if hinted_line == "x1s_x2" else ("x1_page" if hinted_line == "x1" else "page"),
690
+ role=role,
691
+ frame_no=frame_no,
692
+ device_id=payload[3],
693
+ data_start=3,
694
+ first_command_id=payload[4] if len(payload) > 4 else None,
695
+ format_marker=payload[5] if len(payload) > 5 else None,
696
+ )
697
+
698
+
699
+ def _looks_like_ir_dump_opcode(opcode: int) -> bool:
700
+ return opcode_family(opcode) in (0x0D, 0x0E)
701
+
702
+
703
+ def _looks_reasonable_ir_dump_label(text: str) -> bool:
704
+ stripped = str(text or "").strip()
705
+ if not stripped or len(stripped) > 40:
706
+ return False
707
+ if sum(ch.isalnum() for ch in stripped) == 0:
708
+ return False
709
+ return all(ch.isprintable() and ch not in "\r\n\t" for ch in stripped)
710
+
711
+
712
+ _IR_DUMP_LABEL_START = 15
713
+ _IR_DUMP_PAGE_ONE_BLOB_START_X1 = 45
714
+ _IR_DUMP_PAGE_ONE_BLOB_START_X1S = 75
715
+ _IR_DUMP_PAGE_ONE_BLOB_PREFIXES = (
716
+ b"\x01\x20\x00\x10",
717
+ b"\x01\x30\x00\x10",
718
+ b"\x03\x20\x00\x00",
719
+ b"\x01\x00\x00\x00",
720
+ )
721
+
722
+
723
+ def _sum8(data: bytes) -> int:
724
+ return sum(data) & 0xFF
725
+
726
+
727
+ def looks_like_descriptive_play_blob(blob: bytes) -> bool:
728
+ """Return True for human-readable protocol-descriptor replay blobs.
729
+
730
+ Descriptor library_data layout::
731
+
732
+ blob[0..1] = declared length BE (>= 1, == len of the ASCII descriptor)
733
+ blob[2..5] = 0x00 0x00 0x11 0x00
734
+ blob[6..7] = 0x94 0x70
735
+ blob[8..] = ASCII descriptor starting with "P:" + four trailing nulls
736
+ """
737
+
738
+ return (
739
+ len(blob) >= 14
740
+ and blob[2:6] == b"\x00\x00\x11\x00"
741
+ and blob[6:8] == b"\x94\x70"
742
+ and blob[8:10] == b"P:"
743
+ )
744
+
745
+
746
+ def descriptive_play_blob_text(blob: bytes) -> str | None:
747
+ """Return the human-readable descriptor text from a descriptive blob body."""
748
+
749
+ if not looks_like_descriptive_play_blob(blob):
750
+ return None
751
+ declared_len = int.from_bytes(blob[0:2], "big")
752
+ if declared_len <= 0:
753
+ return None
754
+ text_end = 8 + declared_len
755
+ if text_end > len(blob):
756
+ return None
757
+ try:
758
+ return blob[8:text_end].decode("ascii").rstrip("\x00")
759
+ except UnicodeDecodeError:
760
+ return None
761
+
762
+
763
+ def split_play_blob_tail(blob: bytes) -> tuple[bytes, int]:
764
+ """Return ``(blob_body, replay_tail_checksum)`` for a stored replay blob."""
765
+
766
+ if not isinstance(blob, (bytes, bytearray)) or len(blob) < 2:
767
+ raise ValueError("blob is too short to contain a replay-tail checksum")
768
+ data = bytes(blob)
769
+ return data[:-1], data[-1]
770
+
771
+
772
+ def _parse_descriptor_fields(descriptor: str) -> dict[str, str]:
773
+ fields: dict[str, str] = {}
774
+ for token in descriptor.split():
775
+ if ":" not in token:
776
+ continue
777
+ key, value = token.split(":", 1)
778
+ if key:
779
+ fields[key] = value
780
+ return fields
781
+
782
+
783
+ def _canonicalize_denonk_descriptor(descriptor: str) -> str:
784
+ fields = _parse_descriptor_fields(descriptor)
785
+ if fields.get("P") != "DenonK":
786
+ return descriptor
787
+ if "CHECKSUM" in fields:
788
+ return descriptor
789
+
790
+ missing = [key for key in ("R", "C0", "C1", "C2", "D", "S", "F") if key not in fields]
791
+ if missing:
792
+ raise ValueError(f"DenonK descriptor is missing required field(s): {', '.join(missing)}")
793
+
794
+ try:
795
+ carrier_hz = int(fields["R"], 10)
796
+ c0 = int(fields["C0"], 10)
797
+ c1 = int(fields["C1"], 10)
798
+ c2 = int(fields["C2"], 10)
799
+ device = int(fields["D"], 10)
800
+ subdevice = int(fields["S"], 10)
801
+ function = int(fields["F"], 10)
802
+ except ValueError as err:
803
+ raise ValueError(f"DenonK descriptor fields must be decimal integers: {err}") from err
804
+
805
+ checksum = denonk_checksum(c0, c1, c2, device, subdevice, function)
806
+ return (
807
+ f"P:DenonK "
808
+ f"R:{carrier_hz} "
809
+ f"C0:{c0} C1:{c1} C2:{c2} "
810
+ f"D:{device} S:{subdevice} F:{function} "
811
+ f"CHECKSUM:{checksum}"
812
+ )
813
+
814
+
815
+ def build_descriptive_ir_blob_body(descriptor: str) -> bytes:
816
+ """Build a descriptive replay-blob body without the final replay-tail byte.
817
+
818
+ Synthesis path for the Test / Save Blob flows: normalizes
819
+ whitespace, validates the ``P:`` prefix, canonicalizes DenonK
820
+ descriptors (which adds a ``CHECKSUM:`` field when missing), then
821
+ emits the canonical byte layout via
822
+ :func:`blob_decoders.render_ir_descriptive_blob_body` followed by
823
+ the writer's four trailing ``0x00`` bytes.
824
+
825
+ Round-trip callers MUST NOT go through this entry point because
826
+ the DenonK canonicalization mutates the descriptor and would
827
+ break byte-for-byte round-trip equality with a captured blob;
828
+ the backup-decoder round-trip path
829
+ (:func:`blob_decoders._encode_descriptive_ir`) uses
830
+ ``render_ir_descriptive_blob_body`` directly and re-emits whatever
831
+ trailer was captured.
832
+ """
833
+
834
+ text = re.sub(r"\s+", " ", str(descriptor or "").strip())
835
+ if not text:
836
+ raise ValueError("descriptor text is required")
837
+ if not text.startswith("P:"):
838
+ raise ValueError("descriptor text must start with 'P:'")
839
+
840
+ text = _canonicalize_denonk_descriptor(text)
841
+ # Import locally to avoid a circular import at module load time:
842
+ # blob_decoders only imports from protocol_const, but commands is
843
+ # a heavy dependency that some early-loaded modules pull in.
844
+ from .blob_decoders import render_ir_descriptive_blob_body
845
+
846
+ return render_ir_descriptive_blob_body(text) + b"\x00\x00\x00\x00"
847
+
848
+
849
+ def denonk_checksum(c0: int, c1: int, c2: int, d: int, s: int, f: int) -> int:
850
+ """Return the observed Sofabaton ``CHECKSUM:`` value for ``P:DenonK`` blobs.
851
+
852
+ The checksum is not the transport/frame checksum and is distinct from the
853
+ trailing replay-tail byte. It is derived from the protocol parameter nibbles
854
+ in the same order Sofabaton serializes them into its text descriptor.
855
+ """
856
+
857
+ values = (c0, c1, c2, d, s, f)
858
+ if any(v < 0 for v in values):
859
+ raise ValueError("DenonK fields must be non-negative")
860
+ if any(v > 0xFF for v in (c0, c1, c2, d, s)):
861
+ raise ValueError("DenonK C0/C1/C2/D/S fields must fit in one byte")
862
+ if f > 0xFFF:
863
+ raise ValueError("DenonK function must fit in 12 bits")
864
+
865
+ nibbles = [
866
+ c0 & 0x0F,
867
+ (c0 >> 4) & 0x0F,
868
+ c1 & 0x0F,
869
+ (c1 >> 4) & 0x0F,
870
+ c2 & 0x0F,
871
+ d & 0x0F,
872
+ s & 0x0F,
873
+ f & 0x0F,
874
+ (f >> 4) & 0x0F,
875
+ (f >> 8) & 0x0F,
876
+ ]
877
+ parity_even = nibbles[0] ^ nibbles[2] ^ nibbles[4] ^ nibbles[6] ^ nibbles[8]
878
+ parity_odd = nibbles[1] ^ nibbles[3] ^ nibbles[5] ^ nibbles[7] ^ nibbles[9]
879
+ return (((parity_odd << 4) | parity_even) ^ 0x66) & 0xFF
880
+
881
+
882
+ def build_denonk_ir_blob(
883
+ *,
884
+ carrier_hz: int = 37000,
885
+ c0: int = 84,
886
+ c1: int = 50,
887
+ c2: int = 0,
888
+ device: int,
889
+ subdevice: int,
890
+ function: int,
891
+ ) -> bytes:
892
+ """Build a canonical ``P:DenonK`` Sofabaton descriptor blob body.
893
+
894
+ This synthesizes the human-readable one-frame descriptor family observed in
895
+ ``dump_ir_blob`` responses. The returned bytes are the canonical replay
896
+ body expected by ``play_ir_blob``: no outer ``a5 5a`` frame header and no
897
+ final replay-tail checksum byte.
898
+ """
899
+
900
+ if carrier_hz <= 0:
901
+ raise ValueError("carrier_hz must be positive")
902
+
903
+ embedded_checksum = denonk_checksum(c0, c1, c2, device, subdevice, function)
904
+ descriptor = (
905
+ f"P:DenonK "
906
+ f"R:{carrier_hz} "
907
+ f"C0:{c0} C1:{c1} C2:{c2} "
908
+ f"D:{device} S:{subdevice} F:{function} "
909
+ f"CHECKSUM:{embedded_checksum}"
910
+ )
911
+ return build_descriptive_ir_blob_body(descriptor)
912
+
913
+
914
+ def _page_one_uses_ascii_label_layout(payload: bytes) -> bool:
915
+ """Return True when page 1 uses the compact X1 ASCII label slot."""
916
+
917
+ if len(payload) <= _IR_DUMP_LABEL_START:
918
+ return False
919
+ return payload[_IR_DUMP_LABEL_START] != 0
920
+
921
+
922
+ def _ir_dump_page_one_blob_start(payload: bytes) -> int:
923
+ """Return the fixed page-1 blob start for the observed hub layout."""
924
+
925
+ if _page_one_uses_ascii_label_layout(payload):
926
+ return _IR_DUMP_PAGE_ONE_BLOB_START_X1
927
+
928
+ for prefix in _IR_DUMP_PAGE_ONE_BLOB_PREFIXES:
929
+ idx = payload.find(prefix, _IR_DUMP_LABEL_START)
930
+ if idx != -1:
931
+ return idx
932
+
933
+ return _IR_DUMP_PAGE_ONE_BLOB_START_X1S
934
+
935
+
936
+ def extract_ir_dump_blob(payload: bytes, page_no: int) -> bytes | None:
937
+ """Return the IR-specific blob portion of an 0x020C dump page payload."""
938
+
939
+ if page_no == 1:
940
+ blob_start = _ir_dump_page_one_blob_start(payload)
941
+ if len(payload) <= blob_start:
942
+ return None
943
+ return payload[blob_start:]
944
+
945
+ if page_no >= 2:
946
+ return payload[3:] if len(payload) > 3 else b""
947
+
948
+ return None
949
+
950
+
951
+ def extract_ir_dump_label_field(payload: bytes) -> bytes | None:
952
+ """Return the 2-byte metadata field immediately before the page-1 label."""
953
+
954
+ return payload[13:15] if len(payload) >= 15 else None
955
+
956
+
957
+ def _extract_ir_dump_label(payload: bytes) -> str | None:
958
+ if len(payload) <= _IR_DUMP_LABEL_START:
959
+ return None
960
+
961
+ # Page-1 dump records are structured, not heuristic:
962
+ # - bytes 13..14 are a 2-byte metadata field
963
+ # - byte 15 onward is a fixed-width label slot
964
+ # - X1 uses an ASCII slot ending at byte 43
965
+ # - X1S/X2 use a UTF-16BE slot ending at byte 73
966
+ blob_start = _ir_dump_page_one_blob_start(payload)
967
+ if len(payload) <= blob_start:
968
+ return None
969
+
970
+ label_bytes = payload[_IR_DUMP_LABEL_START:blob_start]
971
+
972
+ if _page_one_uses_ascii_label_layout(payload):
973
+ label_bytes = label_bytes.split(b"\x00", 1)[0].rstrip(b"\x00")
974
+ if not label_bytes:
975
+ return None
976
+ try:
977
+ candidate = label_bytes.decode("latin-1").strip()
978
+ except UnicodeDecodeError:
979
+ return None
980
+ return candidate if _looks_reasonable_ir_dump_label(candidate) else None
981
+
982
+ label_bytes = label_bytes.rstrip(b"\x00")
983
+ if not label_bytes or len(label_bytes) % 2:
984
+ return None
985
+
986
+ try:
987
+ candidate = label_bytes.decode("utf-16-be").strip()
988
+ except UnicodeDecodeError:
989
+ return None
990
+
991
+ return candidate if _looks_reasonable_ir_dump_label(candidate) else None
992
+
993
+
994
+ def parse_ir_command_dump_frame(opcode: int, raw_frame: bytes) -> IrCommandDumpFrame | None:
995
+ """Parse a raw IR blob page from the 0x020C backup/restore family."""
996
+
997
+ if len(raw_frame) < 7 or not _looks_like_ir_dump_opcode(opcode):
998
+ return None
999
+
1000
+ payload = raw_frame[4:-1]
1001
+ if len(payload) < 4:
1002
+ return None
1003
+
1004
+ response_index = payload[0]
1005
+ page_no = payload[2]
1006
+ if response_index == 0 or page_no == 0:
1007
+ return None
1008
+
1009
+ command_id = response_index
1010
+ device_id: int | None = None
1011
+ total_commands: int | None = None
1012
+ total_pages: int | None = None
1013
+ format_marker: int | None = None
1014
+ label: str | None = None
1015
+
1016
+ if page_no == 1 and len(payload) >= 9:
1017
+ command_id = payload[7]
1018
+ if command_id == 0:
1019
+ return None
1020
+ device_id = payload[6]
1021
+ total_commands = payload[3] if payload[3] else None
1022
+ total_pages = payload[5] if payload[5] else None
1023
+ format_marker = payload[8]
1024
+ label = _extract_ir_dump_label(payload)
1025
+
1026
+ return IrCommandDumpFrame(
1027
+ opcode=opcode,
1028
+ family=opcode_family(opcode),
1029
+ response_index=response_index,
1030
+ command_id=command_id,
1031
+ page_no=page_no,
1032
+ device_id=device_id,
1033
+ total_commands=total_commands,
1034
+ total_pages=total_pages,
1035
+ format_marker=format_marker,
1036
+ label=label,
1037
+ )
1038
+
1039
+
1040
+ class DeviceCommandAssembler:
1041
+ """Reassembles multi-frame device-command bursts by device ID."""
1042
+
1043
+ def __init__(self) -> None:
1044
+ self._buffers: Dict[int, _CommandBurst] = {}
1045
+
1046
+ def _get_buffer(self, dev_id: int) -> _CommandBurst:
1047
+ if dev_id not in self._buffers:
1048
+ self._buffers[dev_id] = _CommandBurst()
1049
+ return self._buffers[dev_id]
1050
+
1051
+ def feed(
1052
+ self,
1053
+ opcode: int,
1054
+ raw_frame: bytes,
1055
+ *,
1056
+ dev_id_override: int | None = None,
1057
+ hub_version: str,
1058
+ ) -> List[Tuple[int, bytes]]:
1059
+ """Feed a raw frame and return completed payloads when available."""
1060
+
1061
+ if len(raw_frame) < 7:
1062
+ return []
1063
+
1064
+ payload = raw_frame[4:-1]
1065
+ if len(payload) < 4:
1066
+ return []
1067
+
1068
+ parsed = parse_command_burst_frame(opcode, raw_frame, hub_version=hub_version)
1069
+ if parsed is None:
1070
+ return []
1071
+
1072
+ dev_id = dev_id_override if dev_id_override is not None else parsed.device_id
1073
+ frame_no = parsed.frame_no
1074
+ burst = self._get_buffer(dev_id)
1075
+
1076
+ is_single_cmd = parsed.is_single
1077
+ if parsed.is_header:
1078
+ burst.variant = parsed.layout_kind
1079
+ burst.total_frames = parsed.total_frames
1080
+ burst.total_commands = parsed.total_commands
1081
+ burst.frames.clear()
1082
+ elif parsed.total_frames is not None and burst.total_frames is None:
1083
+ burst.total_frames = parsed.total_frames
1084
+ burst.total_commands = parsed.total_commands
1085
+
1086
+ if parsed.is_single:
1087
+ burst.frames.clear()
1088
+ burst.variant = parsed.layout_kind
1089
+ burst.total_frames = 1
1090
+
1091
+ frame_payload = payload[parsed.data_start :] if len(payload) > parsed.data_start else b""
1092
+ burst.frames[frame_no] = frame_payload
1093
+
1094
+ completed: List[Tuple[int, bytes]] = []
1095
+ if burst.frames:
1096
+ max_frame_no = max(burst.frames)
1097
+ frames_are_contiguous = len(burst.frames) == max_frame_no and 1 in burst.frames
1098
+ else:
1099
+ max_frame_no = 0
1100
+ frames_are_contiguous = False
1101
+
1102
+ if is_single_cmd:
1103
+ ordered_payload = b"".join(burst.frames[i] for i in sorted(burst.frames))
1104
+ completed.append((dev_id, ordered_payload))
1105
+ del self._buffers[dev_id]
1106
+ elif burst.total_frames and burst.received >= burst.total_frames:
1107
+ ordered_payload = b"".join(burst.frames[i] for i in sorted(burst.frames))
1108
+ completed.append((dev_id, ordered_payload))
1109
+ del self._buffers[dev_id]
1110
+
1111
+ return completed
1112
+
1113
+ def finalize_contiguous(self, dev_id: int | None = None) -> List[Tuple[int, bytes]]:
1114
+ """Flush buffered bursts whose frames are contiguous starting at 1.
1115
+
1116
+ Some hubs over-report the total frame count and never send a tail. In
1117
+ those cases, complete bursts manually once all contiguous frames have
1118
+ arrived.
1119
+ """
1120
+
1121
+ targets = [dev_id] if dev_id is not None else list(self._buffers)
1122
+ completed: List[Tuple[int, bytes]] = []
1123
+
1124
+ for target in targets:
1125
+ burst = self._buffers.get(target)
1126
+ if not burst or not burst.frames:
1127
+ continue
1128
+
1129
+ max_frame = max(burst.frames)
1130
+ if len(burst.frames) != max_frame or 1 not in burst.frames:
1131
+ continue
1132
+
1133
+ ordered_payload = b"".join(burst.frames[i] for i in sorted(burst.frames))
1134
+ completed.append((target, ordered_payload))
1135
+ del self._buffers[target]
1136
+
1137
+ return completed
1138
+
1139
+
1140
+ class DeviceButtonAssembler:
1141
+ """Reassembles multi-frame button/keymap bursts by activity ID."""
1142
+
1143
+ def __init__(self) -> None:
1144
+ self._buffers: Dict[int, _ButtonBurst] = {}
1145
+
1146
+ def _get_buffer(self, activity_id: int) -> _ButtonBurst:
1147
+ if activity_id not in self._buffers:
1148
+ self._buffers[activity_id] = _ButtonBurst()
1149
+ return self._buffers[activity_id]
1150
+
1151
+ def feed(
1152
+ self,
1153
+ opcode: int,
1154
+ raw_frame: bytes,
1155
+ *,
1156
+ activity_id_override: int | None = None,
1157
+ hub_version: str,
1158
+ ) -> List[Tuple[int, bytes, int | None]]:
1159
+ """Feed a raw keymap frame and return completed row streams when available."""
1160
+
1161
+ if len(raw_frame) < 7:
1162
+ return []
1163
+
1164
+ payload = raw_frame[4:-1]
1165
+ parsed = parse_button_burst_frame(opcode, raw_frame, hub_version=hub_version)
1166
+ if parsed is None:
1167
+ return []
1168
+
1169
+ activity_id = (
1170
+ activity_id_override
1171
+ if activity_id_override is not None
1172
+ else parsed.activity_id
1173
+ )
1174
+ if activity_id is None:
1175
+ return []
1176
+
1177
+ burst = self._get_buffer(activity_id)
1178
+
1179
+ if parsed.is_header:
1180
+ burst.variant = parsed.layout_kind
1181
+ burst.total_frames = parsed.total_frames
1182
+ burst.total_rows = parsed.total_rows
1183
+ burst.frames.clear()
1184
+ else:
1185
+ if parsed.total_frames is not None and burst.total_frames is None:
1186
+ burst.total_frames = parsed.total_frames
1187
+ if parsed.total_rows is not None and burst.total_rows is None:
1188
+ burst.total_rows = parsed.total_rows
1189
+ if burst.total_frames is None and parsed.is_final:
1190
+ burst.total_frames = parsed.frame_no
1191
+
1192
+ frame_payload = (
1193
+ payload[parsed.data_start:] if parsed.has_row_data and len(payload) > parsed.data_start else b""
1194
+ )
1195
+ burst.frames[parsed.frame_no] = frame_payload
1196
+
1197
+ completed: List[Tuple[int, bytes, int | None]] = []
1198
+ if burst.total_frames and burst.received >= burst.total_frames:
1199
+ ordered_payload = b"".join(burst.frames[i] for i in sorted(burst.frames))
1200
+ completed.append((activity_id, ordered_payload, burst.total_rows))
1201
+ del self._buffers[activity_id]
1202
+
1203
+ return completed
1204
+
1205
+
1206
+ # ---------------------------------------------------------------------------
1207
+ # Phase 9 -- per-family parse dispatcher
1208
+ #
1209
+ # Replaces the previous "import every burst parser at the call site" pattern
1210
+ # in :mod:`lib.opcode_handlers` with a single entry point that picks the
1211
+ # right metadata parser from the opcode's family code. Adding a new family
1212
+ # means adding one branch here; opcode_handlers stays family-agnostic.
1213
+ # ---------------------------------------------------------------------------
1214
+
1215
+
1216
+ def decode_burst_frame(
1217
+ opcode: int,
1218
+ raw_frame: bytes,
1219
+ *,
1220
+ hub_version: str,
1221
+ ) -> ButtonBurstFrame | CommandBurstFrame | None:
1222
+ """Dispatch to the per-family burst-frame metadata parser.
1223
+
1224
+ The integration receives multi-page bursts for keymaps (family
1225
+ ``0x3D``), per-device command records (family ``0x5D``) and a
1226
+ handful of marker / single-frame variants in adjacent families.
1227
+ This dispatcher routes each incoming frame to the parser that owns
1228
+ its family, returning the parser's structured frame record (or
1229
+ ``None`` when the opcode does not belong to a known burst family).
1230
+
1231
+ Callers should pass the proxy's ``hub_version`` -- the parsers use
1232
+ it to decide stride and label encoding, and reject unknown values
1233
+ via :func:`schema_for`.
1234
+ """
1235
+
1236
+ if _is_keymap_family(opcode):
1237
+ return parse_button_burst_frame(opcode, raw_frame, hub_version=hub_version)
1238
+ if (
1239
+ _is_devbtn_family(opcode)
1240
+ or opcode_family(opcode) == 0x0D
1241
+ or opcode == OP_DEVBTN_SINGLE
1242
+ ):
1243
+ return parse_command_burst_frame(opcode, raw_frame, hub_version=hub_version)
1244
+ return None
1245
+
1246
+
1247
+ __all__ = [
1248
+ "ButtonBurstFrame",
1249
+ "CommandRecord",
1250
+ "CommandBurstFrame",
1251
+ "DeviceButtonAssembler",
1252
+ "DeviceCommandAssembler",
1253
+ "IrCommandDumpFrame",
1254
+ "KEYMAP_RECORD_SIZE",
1255
+ "KeymapRecord",
1256
+ "build_descriptive_ir_blob_body",
1257
+ "build_denonk_ir_blob",
1258
+ "descriptive_play_blob_text",
1259
+ "denonk_checksum",
1260
+ "decode_burst_frame",
1261
+ "COMMAND_RECORD_LABEL_LEN_X1",
1262
+ "COMMAND_RECORD_LABEL_LEN_X1S_X2",
1263
+ "COMMAND_RECORD_LABEL_OFFSET",
1264
+ "COMMAND_RECORD_STRIDE_X1",
1265
+ "COMMAND_RECORD_STRIDE_X1S_X2",
1266
+ "iter_command_records_from_assembled",
1267
+ "iter_keymap_records",
1268
+ "looks_like_descriptive_play_blob",
1269
+ "parse_ir_command_dump_frame",
1270
+ "parse_button_burst_frame",
1271
+ "parse_command_burst_frame",
1272
+ "split_play_blob_tail",
1273
+ ]