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.
@@ -0,0 +1,676 @@
1
+ """IR playback + single-command persist mixin for :class:`X1Proxy`.
2
+
3
+ Carries everything tied to family-0x0F replay frames and the
4
+ single-command save path (family-0x0E paged writes):
5
+
6
+ * :meth:`play_ir_blob` and the lower-level :meth:`_play_ir_blob_body`
7
+ loop that drives the per-frame ack pacing for a one-shot playback.
8
+ * The persist write-pipeline -- :meth:`persist_ir_blob`,
9
+ :meth:`persist_command_record`, and the
10
+ ``_build_command_write_steps_for_persist`` / ``_allocate_command_id``
11
+ / ``_run_persist_write`` helpers that translate "save this one
12
+ command on this device" into a paged family-0x0E burst.
13
+ * Shape sniffers and tail-checksum diagnostics for replay payloads.
14
+ * :meth:`_get_active_ir_dump_pending`, used by the IR-dump ingest path
15
+ to look up the in-flight burst keyed off the current burst kind.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import time
21
+ from typing import Any
22
+
23
+ from .commands import descriptive_play_blob_text, looks_like_descriptive_play_blob
24
+ from .device_create import (
25
+ build_command_write_steps,
26
+ build_key_sort_steps,
27
+ encode_command_sort_body,
28
+ )
29
+ from .protocol_const import (
30
+ FAMILY_PLAY_BLOB,
31
+ PLAY_BLOB_BODY_HEADER_LEN,
32
+ PLAY_BLOB_CHUNK_SIZE,
33
+ PLAY_BLOB_MAX_PAYLOAD,
34
+ PLAY_BLOB_PAGE_HEADER_LEN,
35
+ )
36
+
37
+
38
+ def _run_create_sequence(*args, **kwargs):
39
+ from . import x1_proxy as _xp
40
+
41
+ return _xp.run_create_sequence(*args, **kwargs)
42
+
43
+
44
+ class IrBlobMixin:
45
+ """Mixin providing IR playback and single-command persist writes."""
46
+
47
+ def play_ir_blob(
48
+ self,
49
+ blob: bytes,
50
+ *,
51
+ inter_frame_delay: float = 0.08,
52
+ ack_timeout: float = 1.0,
53
+ final_ack_timeout: float = 0.25,
54
+ ) -> bool:
55
+ """Send a canonical IR blob body to the hub for one-shot playback.
56
+
57
+ ``blob`` is the raw library_data the hub stores for this command —
58
+ the same bytes returned by ``fetch_blob`` and produced by the
59
+ descriptive-descriptor builder. The wire body buffer (header +
60
+ library_data + sum8) is constructed here and then chunked across
61
+ family-0x0F frames.
62
+
63
+ Returns True on success; False if the proxy is not in a state to
64
+ issue commands or the blob is too short to be valid.
65
+ """
66
+
67
+ if not self.can_issue_commands():
68
+ self._log.info("[PLAY_BLOB] ignored: proxy client is connected")
69
+ return False
70
+
71
+ if not isinstance(blob, (bytes, bytearray)) or len(blob) < 10:
72
+ self._log.warning("[PLAY_BLOB] blob too short or wrong type: %r", type(blob))
73
+ return False
74
+
75
+ body_buffer = self._build_play_blob_body_buffer(bytes(blob))
76
+ ok, rejected = self._play_ir_blob_body(
77
+ body_buffer,
78
+ inter_frame_delay=inter_frame_delay,
79
+ ack_timeout=ack_timeout,
80
+ final_ack_timeout=final_ack_timeout,
81
+ )
82
+ if ok:
83
+ return True
84
+ return False
85
+
86
+ @staticmethod
87
+ def _next_available_command_id(existing_command_ids: list[int]) -> int:
88
+ used = {int(command_id) & 0xFF for command_id in existing_command_ids if 1 <= int(command_id) <= 255}
89
+ for candidate in range(1, 256):
90
+ if candidate not in used:
91
+ return candidate
92
+ raise ValueError("device already uses all 255 command ids")
93
+
94
+ @staticmethod
95
+ def _validated_command_label(command_name: str) -> str:
96
+ """Return the command label after the persist write-path's basic
97
+ validation. The actual encoding into a fixed-width slot is the
98
+ builder's responsibility.
99
+ """
100
+
101
+ text = str(command_name or "").strip()
102
+ if not text:
103
+ raise ValueError("command_name is required")
104
+ return text
105
+
106
+ def _build_command_write_steps_for_persist(
107
+ self,
108
+ *,
109
+ device_id: int,
110
+ command_id: int,
111
+ command_name: str,
112
+ library_type: int,
113
+ library_data: bytes,
114
+ button_code: int = 0,
115
+ ack_timeout: float = 5.0,
116
+ ) -> list[Any]:
117
+ """Build paged command-write steps for the single-command persist path.
118
+
119
+ A persist write is just a burst of size 1 -- the same wire shape
120
+ the device-create burst uses for each of its N commands, with
121
+ ``command_seq=1`` and ``command_burst_size=1``.
122
+ """
123
+
124
+ if command_id < 1 or command_id > 0xFF:
125
+ raise ValueError(f"command_id {command_id} out of byte range")
126
+
127
+ return build_command_write_steps(
128
+ hub_version=self.hub_version,
129
+ command_seq=1,
130
+ command_burst_size=1,
131
+ device_id=device_id & 0xFF,
132
+ button_id=command_id & 0xFF,
133
+ library_type=library_type & 0xFF,
134
+ button_code=button_code & 0xFFFFFFFFFFFF,
135
+ label=self._validated_command_label(command_name),
136
+ library_data=bytes(library_data),
137
+ ack_timeout=ack_timeout,
138
+ )
139
+
140
+ def _allocate_command_id(
141
+ self,
142
+ device_commands: dict[int, str] | None,
143
+ command_id: int | None,
144
+ ) -> int:
145
+ """Pick the slot id this persist write should land on.
146
+
147
+ Either accept the caller's explicit ``command_id`` (validated
148
+ against the existing slots on the device) or auto-allocate the
149
+ next free id.
150
+ """
151
+
152
+ existing_command_ids = (
153
+ sorted(int(existing_id) & 0xFF for existing_id in device_commands.keys())
154
+ if isinstance(device_commands, dict)
155
+ else []
156
+ )
157
+ if command_id is None:
158
+ return self._next_available_command_id(existing_command_ids)
159
+ new_command_id = int(command_id) & 0xFF
160
+ if new_command_id < 1 or new_command_id > 0xFF:
161
+ raise ValueError(f"command_id {new_command_id} out of byte range")
162
+ if new_command_id in existing_command_ids:
163
+ raise ValueError(
164
+ f"command_id {new_command_id} already exists on the target device"
165
+ )
166
+ return new_command_id
167
+
168
+ def _run_persist_write(
169
+ self,
170
+ *,
171
+ log_prefix: str,
172
+ device_id: int,
173
+ command_id: int,
174
+ command_name: str,
175
+ library_type: int,
176
+ library_data: bytes,
177
+ button_code: int,
178
+ ack_timeout: float,
179
+ ) -> dict[str, Any] | None:
180
+ """Shared driver for single-command persist writes.
181
+
182
+ Builds the family-0x0E steps via :func:`build_command_write_steps`,
183
+ clears the ack queue, then feeds the step list through
184
+ :func:`run_create_sequence`. Surfaces hub rejection
185
+ (``STATUS_ACK 0x0C``) as a warning distinct from timeout.
186
+ """
187
+
188
+ steps = self._build_command_write_steps_for_persist(
189
+ device_id=device_id,
190
+ command_id=command_id,
191
+ command_name=command_name,
192
+ library_type=library_type,
193
+ library_data=library_data,
194
+ button_code=button_code,
195
+ ack_timeout=ack_timeout,
196
+ )
197
+ self._log.info(
198
+ "[%s] uploading dev=0x%02X new_command_id=0x%02X lib=0x%02X pages=%d data=%dB",
199
+ log_prefix,
200
+ device_id & 0xFF,
201
+ command_id & 0xFF,
202
+ library_type & 0xFF,
203
+ len(steps),
204
+ len(library_data) + 1,
205
+ )
206
+
207
+ self.clear_ack_queue()
208
+ result = _run_create_sequence(self, steps)
209
+ if not result.success:
210
+ if result.rejected:
211
+ self._log.warning(
212
+ "[%s] hub rejected page %d/%d dev=0x%02X lib=0x%02X",
213
+ log_prefix,
214
+ (result.failed_index or 0) + 1,
215
+ len(steps),
216
+ device_id & 0xFF,
217
+ library_type & 0xFF,
218
+ )
219
+ else:
220
+ self._log.warning(
221
+ "[%s] timeout waiting for page ack %d/%d dev=0x%02X",
222
+ log_prefix,
223
+ (result.failed_index or 0) + 1,
224
+ len(steps),
225
+ device_id & 0xFF,
226
+ )
227
+ return None
228
+ return {"page_count": len(steps)}
229
+
230
+ def persist_ir_blob(
231
+ self,
232
+ *,
233
+ device_id: int,
234
+ command_name: str,
235
+ blob: bytes,
236
+ command_id: int | None = None,
237
+ inter_frame_delay: float = 0.08, # retained for API compat; unused
238
+ ack_timeout: float = 5.0,
239
+ ) -> dict[str, Any] | None:
240
+ """Persist a new IR command blob onto an existing device.
241
+
242
+ Uploads family ``0x0E`` save pages (the same wire format used
243
+ by all single-command saves regardless of codec). The codec
244
+ selector is fixed at ``library_type=0x0D`` (IR-DB), and no
245
+ canonical button-code is asserted -- the hub assigns one on
246
+ accept. Use :meth:`persist_command_record` for non-IR codecs.
247
+ """
248
+
249
+ del inter_frame_delay # paging cadence now lives in the sequencer
250
+
251
+ if not self.can_issue_commands():
252
+ self._log.info("[PERSIST_IR_BLOB] ignored: proxy client is connected")
253
+ return None
254
+
255
+ if not isinstance(blob, (bytes, bytearray)) or len(blob) < 10:
256
+ self._log.warning("[PERSIST_IR_BLOB] blob too short or wrong type: %r", type(blob))
257
+ return None
258
+
259
+ dev_lo = device_id & 0xFF
260
+ device_commands = self.state.commands.get(dev_lo, {})
261
+ new_command_id = self._allocate_command_id(device_commands, command_id)
262
+
263
+ outcome = self._run_persist_write(
264
+ log_prefix="PERSIST_IR_BLOB",
265
+ device_id=dev_lo,
266
+ command_id=new_command_id,
267
+ command_name=command_name,
268
+ library_type=0x0D,
269
+ library_data=bytes(blob),
270
+ button_code=0,
271
+ ack_timeout=ack_timeout,
272
+ )
273
+ if outcome is None:
274
+ return None
275
+
276
+ if not isinstance(device_commands, dict):
277
+ device_commands = {}
278
+ self.state.commands[dev_lo] = device_commands
279
+ device_commands[new_command_id] = (
280
+ str(command_name or "").strip() or f"Command {new_command_id}"
281
+ )
282
+ self._commands_complete.add(dev_lo)
283
+
284
+ # The save above leaves the new command with sort_id=0, which
285
+ # keeps it off the physical remote's device-browse screen even
286
+ # though it plays back fine when bound to a button or invoked
287
+ # by name. Push an updated per-device sort table so the new
288
+ # entry takes the next free display slot. Failure here is not
289
+ # fatal -- the command itself is already saved.
290
+ self._register_command_in_device_sort(
291
+ dev_lo=dev_lo,
292
+ new_command_id=new_command_id,
293
+ ack_timeout=ack_timeout,
294
+ )
295
+
296
+ return {
297
+ "status": "success",
298
+ "device_id": dev_lo,
299
+ "command_id": new_command_id,
300
+ "command_name": device_commands[new_command_id],
301
+ "page_count": outcome["page_count"],
302
+ }
303
+
304
+ def _register_command_in_device_sort(
305
+ self,
306
+ *,
307
+ dev_lo: int,
308
+ new_command_id: int,
309
+ ack_timeout: float,
310
+ ) -> None:
311
+ """Append the freshly-saved command to the device's display order.
312
+
313
+ Re-emits the full family-0x61 ``(command_id, sort_position)``
314
+ table for ``dev_lo`` with the new command tacked onto the end
315
+ at the next free position. Existing commands keep whatever
316
+ positions the hub had already assigned them; commands the hub
317
+ has on record but that have never been assigned a position
318
+ (sort_id==0) are folded in after the previously-positioned
319
+ ones so the table stays a complete enumeration.
320
+ """
321
+
322
+ metadata = self.state.command_metadata.get(dev_lo) or {}
323
+ known_command_ids = set(metadata.keys())
324
+ device_commands = self.state.commands.get(dev_lo) or {}
325
+ known_command_ids.update(device_commands.keys())
326
+ known_command_ids.add(new_command_id)
327
+
328
+ positioned: list[tuple[int, int]] = []
329
+ unpositioned: list[int] = []
330
+ for command_id in known_command_ids:
331
+ if command_id == new_command_id:
332
+ continue
333
+ entry = metadata.get(command_id) or {}
334
+ sort_id = int(entry.get("sort_id", 0)) & 0xFF
335
+ if sort_id:
336
+ positioned.append((command_id & 0xFF, sort_id))
337
+ else:
338
+ unpositioned.append(command_id & 0xFF)
339
+
340
+ positioned.sort(key=lambda pair: pair[1])
341
+ next_position = (positioned[-1][1] if positioned else 0) + 1
342
+ ordered_pairs: list[tuple[int, int]] = list(positioned)
343
+ for command_id in sorted(unpositioned):
344
+ ordered_pairs.append((command_id, next_position))
345
+ next_position += 1
346
+ ordered_pairs.append((new_command_id & 0xFF, next_position))
347
+
348
+ try:
349
+ body = encode_command_sort_body(ordered_pairs)
350
+ steps = build_key_sort_steps(
351
+ device_id=dev_lo,
352
+ msg_hex=body.hex(),
353
+ ack_timeout=ack_timeout,
354
+ )
355
+ except ValueError as exc:
356
+ self._log.warning(
357
+ "[PERSIST_IR_BLOB] could not build sort write dev=0x%02X: %s",
358
+ dev_lo,
359
+ exc,
360
+ )
361
+ return
362
+
363
+ self.clear_ack_queue()
364
+ result = _run_create_sequence(self, steps)
365
+ if not result.success:
366
+ self._log.warning(
367
+ "[PERSIST_IR_BLOB] sort write %s dev=0x%02X page=%d/%d "
368
+ "(command still saved, may not appear in the remote's "
369
+ "device-browse list until the next reorder)",
370
+ "rejected" if result.rejected else "timed out",
371
+ dev_lo,
372
+ (result.failed_index or 0) + 1,
373
+ len(steps),
374
+ )
375
+ return
376
+
377
+ # Mirror the position we just told the hub about so a follow-up
378
+ # save against the same device doesn't reuse the same slot.
379
+ bucket = self.state.command_metadata.setdefault(dev_lo, {})
380
+ bucket[new_command_id & 0xFF] = {
381
+ **(bucket.get(new_command_id & 0xFF) or {}),
382
+ "sort_id": ordered_pairs[-1][1] & 0xFF,
383
+ }
384
+
385
+ def persist_command_record(
386
+ self,
387
+ *,
388
+ device_id: int,
389
+ command_name: str,
390
+ library_type: int,
391
+ command_data: bytes,
392
+ command_code: int = 0,
393
+ command_id: int | None = None,
394
+ inter_frame_delay: float = 0.08, # retained for API compat; unused
395
+ ack_timeout: float = 5.0,
396
+ ) -> dict[str, Any] | None:
397
+ """Persist an opaque hub-owned command record onto an existing device.
398
+
399
+ ``library_type`` selects the codec (``0x03`` Bluetooth, RF
400
+ variants, learned-IR, etc.). ``command_code`` is the 48-bit
401
+ canonical identifier the hub stores alongside the codec bytes
402
+ and that downstream button-binding / macro writes reference.
403
+ """
404
+
405
+ del inter_frame_delay
406
+
407
+ if not self.can_issue_commands():
408
+ self._log.info("[PERSIST_CMD] ignored: proxy client is connected")
409
+ return None
410
+
411
+ if not isinstance(command_data, (bytes, bytearray)) or len(command_data) < 1:
412
+ raise ValueError("command_data is too short to persist")
413
+ if library_type < 0 or library_type > 0xFF:
414
+ raise ValueError(f"library_type {library_type} out of byte range")
415
+ if command_code < 0 or command_code > 0xFFFFFFFFFFFF:
416
+ raise ValueError(f"command_code {command_code} out of 48-bit range")
417
+
418
+ dev_lo = device_id & 0xFF
419
+ device_commands = self.state.commands.get(dev_lo, {})
420
+ new_command_id = self._allocate_command_id(device_commands, command_id)
421
+
422
+ outcome = self._run_persist_write(
423
+ log_prefix="PERSIST_CMD",
424
+ device_id=dev_lo,
425
+ command_id=new_command_id,
426
+ command_name=command_name,
427
+ library_type=library_type,
428
+ library_data=bytes(command_data),
429
+ button_code=command_code,
430
+ ack_timeout=ack_timeout,
431
+ )
432
+ if outcome is None:
433
+ return None
434
+
435
+ if not isinstance(device_commands, dict):
436
+ device_commands = {}
437
+ self.state.commands[dev_lo] = device_commands
438
+ device_commands[new_command_id] = (
439
+ str(command_name or "").strip() or f"Command {new_command_id}"
440
+ )
441
+ self._commands_complete.add(dev_lo)
442
+ return {
443
+ "status": "success",
444
+ "device_id": dev_lo,
445
+ "command_id": new_command_id,
446
+ "command_name": device_commands[new_command_id],
447
+ "page_count": outcome["page_count"],
448
+ "library_type": library_type & 0xFF,
449
+ }
450
+
451
+ def _play_ir_blob_body(
452
+ self,
453
+ body_buffer: bytes,
454
+ *,
455
+ inter_frame_delay: float,
456
+ ack_timeout: float,
457
+ final_ack_timeout: float,
458
+ ) -> tuple[bool, bool]:
459
+ """Chunk a fully-built playback body buffer across family-0x0F frames.
460
+
461
+ ``body_buffer`` is the complete sealed body: 12-byte header,
462
+ library_data, and trailing sum8. Each wire frame carries a
463
+ ``PLAY_BLOB_CHUNK_SIZE``-byte (247B) slice of this buffer prefixed
464
+ with a 3-byte page header ``[0x01, 0x00, page_no_lo]``.
465
+
466
+ Returns ``(ok, rejected)`` where ``rejected`` is true only when the
467
+ hub explicitly NACKs playback with ``0x0103/0x0C``.
468
+ """
469
+
470
+ body_len = len(body_buffer)
471
+ total_frames = self._play_blob_total_frames(body_len)
472
+
473
+ self._log.info(
474
+ "[PLAY_BLOB] sending %dB body in %d frame(s)", body_len, total_frames,
475
+ )
476
+
477
+ # Ignore any stale ACKs already queued from prior traffic; playback must
478
+ # pace itself only on ACKs caused by the chunks we are about to send.
479
+ self.clear_ack_queue()
480
+
481
+ send_ts = time.monotonic()
482
+ for seq in range(1, total_frames + 1):
483
+ if seq > 1 and inter_frame_delay > 0:
484
+ time.sleep(inter_frame_delay)
485
+ slice_start = (seq - 1) * PLAY_BLOB_CHUNK_SIZE
486
+ slice_end = min(slice_start + PLAY_BLOB_CHUNK_SIZE, body_len)
487
+ body_slice = body_buffer[slice_start:slice_end]
488
+ frame_payload = bytes([0x01, 0x00, seq & 0xFF]) + body_slice
489
+ send_ts = time.monotonic()
490
+ self._send_family_play_frame(frame_payload)
491
+ candidates = [(0x0103, 0x00)]
492
+ if seq == total_frames:
493
+ candidates.append((0x0103, 0x0C))
494
+ chunk_ack = self.wait_for_ack_any(candidates, timeout=ack_timeout, not_before=send_ts)
495
+ if chunk_ack is None:
496
+ self._log.warning(
497
+ "[PLAY_BLOB] timeout waiting for chunk ack seq=%d/%d",
498
+ seq,
499
+ total_frames,
500
+ )
501
+ return False, False
502
+ if chunk_ack[1][:1] == b"\x0c":
503
+ self._log.warning(
504
+ "[PLAY_BLOB] chunk rejected seq=%d/%d %s",
505
+ seq,
506
+ total_frames,
507
+ self._play_blob_tail_diagnostics(body_buffer),
508
+ )
509
+ return False, True
510
+
511
+ # A late 0x0103/0x0C after a successful final 0x00 indicates the hub
512
+ # rejected playback after processing the last chunk.
513
+ completion_ack = self._wait_for_ack_any_impl(
514
+ [(0x0103, 0x0C)],
515
+ timeout=final_ack_timeout,
516
+ not_before=send_ts,
517
+ log_timeout=False,
518
+ )
519
+ if completion_ack is not None:
520
+ self._log.warning(
521
+ "[PLAY_BLOB] hub reported playback failure after final chunk %s",
522
+ self._play_blob_tail_diagnostics(body_buffer),
523
+ )
524
+ return False, True
525
+
526
+ return True, False
527
+
528
+ def _send_family_play_frame(self, payload: bytes) -> None:
529
+ """Send one family-0x0F playback frame, encoding payload length into the opcode high byte."""
530
+ opcode = ((len(payload) & 0xFF) << 8) | (FAMILY_PLAY_BLOB & 0xFF)
531
+ self._send_cmd_frame(opcode, payload)
532
+
533
+ @staticmethod
534
+ def _looks_like_descriptive_play_blob(blob: bytes) -> bool:
535
+ """Return True for human-readable protocol-descriptor replay blobs."""
536
+ return looks_like_descriptive_play_blob(blob)
537
+
538
+ @staticmethod
539
+ def _looks_like_x1_database_capture_blob(blob: bytes) -> bool:
540
+ """Return True for observed non-descriptor X1/X1S database-style blobs."""
541
+ return (
542
+ len(blob) >= 14
543
+ and blob[2:6] == b"\x00\x00\x00\x00"
544
+ and blob[6:8] in (b"\x9c\x40", b"\x94\xcf", b"\x94\x74")
545
+ )
546
+
547
+ @staticmethod
548
+ def _extract_single_frame_play_blob(payload: bytes) -> bytes | None:
549
+ """Extract a complete single-frame replay library_data from a family-0x0F payload.
550
+
551
+ Single-frame replay requests use the layout:
552
+ ``[01 00 01] [01 00 <total_pages_be> 00 00 00 00 00 00 00 00 00]`` +
553
+ library_data + sum8. ``total_pages_be`` must be 1 for a single-frame
554
+ replay. Returns the embedded library_data (without the trailing sum8),
555
+ or None if the payload doesn't match.
556
+ """
557
+ preface_len = PLAY_BLOB_PAGE_HEADER_LEN + PLAY_BLOB_BODY_HEADER_LEN # 15
558
+ if len(payload) < preface_len + 1:
559
+ return None
560
+ if payload[0:3] != b"\x01\x00\x01":
561
+ return None
562
+ if payload[3:5] != b"\x01\x00":
563
+ return None
564
+ if payload[5] != 0x01:
565
+ return None
566
+ if payload[6:preface_len] != b"\x00" * (preface_len - 6):
567
+ return None
568
+ # Drop the trailing body sum8 byte to surface only library_data.
569
+ return payload[preface_len:-1] or None
570
+
571
+ @staticmethod
572
+ def _descriptive_play_blob_text(blob: bytes) -> str | None:
573
+ """Return the human-readable descriptor string from a descriptive blob."""
574
+ return descriptive_play_blob_text(blob)
575
+
576
+ def _build_play_blob_body_buffer(self, library_data: bytes) -> bytes:
577
+ """Return the fully sealed body buffer for a playback of ``library_data``.
578
+
579
+ Layout::
580
+
581
+ body[0] = 0x01
582
+ body[1..2] = total_pages BE
583
+ body[3..5] = 0x00 0x00 0x00
584
+ body[6..11] = 0x00 * 6
585
+ body[12..] = library_data
586
+ body[-1] = sum8(body[:-1])
587
+ """
588
+
589
+ body_len = PLAY_BLOB_BODY_HEADER_LEN + len(library_data) + 1
590
+ total_pages = (body_len + PLAY_BLOB_CHUNK_SIZE - 1) // PLAY_BLOB_CHUNK_SIZE
591
+ body = bytearray(body_len)
592
+ body[0] = 0x01
593
+ body[1] = (total_pages >> 8) & 0xFF
594
+ body[2] = total_pages & 0xFF
595
+ body[PLAY_BLOB_BODY_HEADER_LEN:PLAY_BLOB_BODY_HEADER_LEN + len(library_data)] = library_data
596
+ body[-1] = sum(body[:-1]) & 0xFF
597
+ return bytes(body)
598
+
599
+ def _finalize_play_blob_body(self, library_data: bytes) -> bytes:
600
+ """Return ``library_data`` with the trailing body sum8 byte appended.
601
+
602
+ Equivalent to slicing off the 12-byte body header from the sealed
603
+ body buffer built by :meth:`_build_play_blob_body_buffer`.
604
+ """
605
+
606
+ body_buffer = self._build_play_blob_body_buffer(bytes(library_data))
607
+ return body_buffer[PLAY_BLOB_BODY_HEADER_LEN:]
608
+
609
+ def _play_blob_total_frames(self, body_len: int) -> int:
610
+ """Return the number of family-0x0F frames needed for a body buffer."""
611
+
612
+ if body_len <= 0:
613
+ return 0
614
+ return (body_len + PLAY_BLOB_CHUNK_SIZE - 1) // PLAY_BLOB_CHUNK_SIZE
615
+
616
+ def _play_blob_tail_diagnostics(self, blob: bytes) -> str:
617
+ """Return compact checksum candidates for blob-tail replay failures."""
618
+ if not blob:
619
+ return "len=0"
620
+
621
+ body = blob[:-1]
622
+ sum8 = sum(body) & 0xFF
623
+ xor8 = 0
624
+ for value in body:
625
+ xor8 ^= value
626
+
627
+ def _crc8_maxim(data: bytes) -> int:
628
+ crc = 0x00
629
+ for byte in data:
630
+ crc ^= byte
631
+ for _ in range(8):
632
+ if crc & 0x01:
633
+ crc = ((crc >> 1) ^ 0x8C) & 0xFF
634
+ else:
635
+ crc = (crc >> 1) & 0xFF
636
+ return crc & 0xFF
637
+
638
+ last_words = " ".join(f"{value:02x}" for value in blob[-8:])
639
+ return (
640
+ f"len={len(blob)} last=0x{blob[-1]:02X} "
641
+ f"sum=0x{sum8:02X} plus1=0x{((sum8 + 1) & 0xFF):02X} "
642
+ f"plus2=0x{((sum8 + 2) & 0xFF):02X} negsum=0x{((0x100 - sum8) & 0xFF):02X} "
643
+ f"xor=0x{xor8:02X} crc8_maxim=0x{_crc8_maxim(body):02X} "
644
+ f"tail8=[{last_words}]"
645
+ )
646
+
647
+ def _get_active_ir_dump_pending(
648
+ self,
649
+ *,
650
+ device_id: int | None = None,
651
+ burst_kind: str | None = None,
652
+ ) -> tuple[tuple[int, int], dict[str, Any]] | tuple[None, None]:
653
+ if burst_kind and burst_kind.startswith("ir_dump:"):
654
+ parts = burst_kind.split(":")
655
+ if len(parts) >= 3:
656
+ try:
657
+ key = (int(parts[1]) & 0xFF, int(parts[2]) & 0xFF)
658
+ except ValueError:
659
+ key = None
660
+ if key is not None:
661
+ pending = self._ir_dump_pending.get(key)
662
+ if pending is not None:
663
+ return key, pending
664
+
665
+ if device_id is None:
666
+ return None, None
667
+
668
+ dev_lo = device_id & 0xFF
669
+ for key, pending in self._ir_dump_pending.items():
670
+ if key[0] == dev_lo and not pending["event"].is_set():
671
+ return key, pending
672
+
673
+ return None, None
674
+
675
+
676
+ __all__ = ["IrBlobMixin"]