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,660 @@
1
+ """Ack-queue + burst-wait mixin for :class:`X1Proxy`.
2
+
3
+ Owns the synchronisation primitives the proxy uses to translate the
4
+ async hub-side opcode stream into blocking ``wait_for_*`` calls suitable
5
+ for driving multi-step orchestration sequences. Three distinct queues
6
+ live behind this surface:
7
+
8
+ * the generic ack queue (filled by :meth:`notify_ack`, consumed by
9
+ :meth:`wait_for_ack` / :meth:`wait_for_ack_any`);
10
+ * the macro-record cache, keyed by ``(activity_id, key_id)``;
11
+ * the activity-inputs burst buffer, which also recognises a hub-side
12
+ STATUS_ACK rejection of the in-flight ``REQ_ACTIVITY_INPUTS`` and
13
+ surfaces it as :attr:`AckOutcome.rejected`.
14
+
15
+ The ``query_device_input_index`` / ``fetch_device_input_entries`` helpers
16
+ live here because they are pure consumers of the inputs burst.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import time
22
+
23
+ from .ack import AckOutcome, InputsBurstResult
24
+ from .inputs import parse_inputs_burst
25
+ from .macros import MacroRecord
26
+ from .protocol_const import (
27
+ FAMILY_KEY_SORT_REQ,
28
+ OP_REQ_ACTIVITY_INPUTS,
29
+ OP_STATUS_ACK,
30
+ OPNAMES,
31
+ )
32
+
33
+
34
+ class AckWaitersMixin:
35
+ """Mixin providing ack-queue management and burst waits."""
36
+
37
+ def reset_ack_queues(self) -> None:
38
+ with self._pending_assigned_device_lock:
39
+ self._pending_assigned_device_event.clear()
40
+ self._pending_assigned_device_id = None
41
+ with self._ack_queue_lock:
42
+ self._ack_queue.clear()
43
+ self._ack_event.clear()
44
+ with self._macro_payload_lock:
45
+ self._macro_payload_events.clear()
46
+ self._macro_payload_event.clear()
47
+ with self._device_key_sort_lock:
48
+ self._device_key_sort_pending = None
49
+ self._device_key_sort_expected_pages = None
50
+ self._device_key_sort_pages.clear()
51
+ with self._activity_inputs_lock:
52
+ self._activity_inputs_seen = 0
53
+ self._activity_inputs_last_ts = 0.0
54
+ self._activity_inputs_event.clear()
55
+
56
+ def set_assigned_device_id(self, device_id: int) -> None:
57
+ with self._pending_assigned_device_lock:
58
+ self._pending_assigned_device_id = device_id & 0xFF
59
+ self._pending_assigned_device_event.set()
60
+
61
+ def wait_for_assigned_device_id(self, timeout: float = 5.0) -> int | None:
62
+ self._pending_assigned_device_event.wait(timeout)
63
+ with self._pending_assigned_device_lock:
64
+ return self._pending_assigned_device_id
65
+
66
+ def notify_ack(self, opcode: int, payload: bytes) -> None:
67
+ with self._ack_queue_lock:
68
+ self._ack_queue.append((opcode, payload, time.monotonic()))
69
+ self._ack_event.set()
70
+ name = OPNAMES.get(opcode, f"OP_{opcode:04X}")
71
+ if opcode == OP_STATUS_ACK:
72
+ status = payload[0] if payload else None
73
+ if status == 0x00:
74
+ detail = "accepted"
75
+ elif status == 0x0C:
76
+ detail = "rejected"
77
+ elif status is None:
78
+ detail = "empty-payload"
79
+ else:
80
+ detail = f"status=0x{status:02X}"
81
+ self._log.info("[ACK] %s (0x%04X) %s", name, opcode, detail)
82
+ # If we are waiting on REQ_ACTIVITY_INPUTS and no inputs frame has
83
+ # arrived yet, a non-zero STATUS_ACK is the hub's rejection of
84
+ # that request (commonly status=0x07 for "device not configured
85
+ # for power/inputs yet"). Trip the event so the wait can exit
86
+ # early instead of timing out after the full window.
87
+ if status is not None and status != 0x00:
88
+ self.note_catalog_status_ack(status)
89
+ with self._activity_inputs_lock:
90
+ if self._activity_inputs_pending and self._activity_inputs_seen == 0:
91
+ self._inputs_burst_reject_pending = True
92
+ self._activity_inputs_event.set()
93
+ return
94
+ self._log.info("[ACK] %s (0x%04X) payload_len=%d", name, opcode, len(payload))
95
+
96
+ def clear_ack_queue(self) -> None:
97
+ with self._ack_queue_lock:
98
+ self._ack_queue.clear()
99
+ self._ack_event.clear()
100
+
101
+ def wait_for_ack(
102
+ self,
103
+ opcode: int,
104
+ *,
105
+ first_byte: int | None = None,
106
+ timeout: float = 5.0,
107
+ not_before: float | None = None,
108
+ ) -> bool:
109
+ deadline = time.monotonic() + timeout
110
+ while True:
111
+ with self._ack_queue_lock:
112
+ for ack_opcode, ack_payload, ack_ts in self._ack_queue:
113
+ if ack_opcode != opcode:
114
+ continue
115
+ if not_before is not None and ack_ts < not_before:
116
+ continue
117
+ if first_byte is not None and (not ack_payload or ack_payload[0] != (first_byte & 0xFF)):
118
+ continue
119
+ self._ack_queue.remove((ack_opcode, ack_payload, ack_ts))
120
+ if not self._ack_queue:
121
+ self._ack_event.clear()
122
+ return True
123
+ self._ack_event.clear()
124
+
125
+ remaining = deadline - time.monotonic()
126
+ if remaining <= 0:
127
+ self._log.warning(
128
+ "[ACK] timeout waiting opcode=0x%04X first_byte=%s",
129
+ opcode,
130
+ f"0x{first_byte:02X}" if first_byte is not None else "*",
131
+ )
132
+ return False
133
+ self._ack_event.wait(min(remaining, 0.2))
134
+
135
+ def _wait_for_ack_any_impl(
136
+ self,
137
+ candidates: list[tuple[int, int | None]],
138
+ *,
139
+ timeout: float = 5.0,
140
+ not_before: float | None = None,
141
+ log_timeout: bool,
142
+ ) -> tuple[int, bytes] | None:
143
+ deadline = time.monotonic() + timeout
144
+ while True:
145
+ with self._ack_queue_lock:
146
+ for ack_opcode, ack_payload, ack_ts in self._ack_queue:
147
+ for want_opcode, want_first_byte in candidates:
148
+ if ack_opcode != want_opcode:
149
+ continue
150
+ if not_before is not None and ack_ts < not_before:
151
+ continue
152
+ if want_first_byte is not None and (not ack_payload or ack_payload[0] != (want_first_byte & 0xFF)):
153
+ continue
154
+ self._ack_queue.remove((ack_opcode, ack_payload, ack_ts))
155
+ if not self._ack_queue:
156
+ self._ack_event.clear()
157
+ return ack_opcode, ack_payload
158
+ self._ack_event.clear()
159
+
160
+ remaining = deadline - time.monotonic()
161
+ if remaining <= 0:
162
+ wanted = ", ".join(
163
+ f"0x{op:04X}/{('*' if first is None else f'0x{first:02X}') }" for op, first in candidates
164
+ )
165
+ if log_timeout:
166
+ self._log.warning("[ACK] timeout waiting any in [%s]", wanted)
167
+ return None
168
+ self._ack_event.wait(min(remaining, 0.2))
169
+
170
+ def wait_for_ack_any(
171
+ self,
172
+ candidates: list[tuple[int, int | None]],
173
+ *,
174
+ timeout: float = 5.0,
175
+ not_before: float | None = None,
176
+ ) -> tuple[int, bytes] | None:
177
+ return self._wait_for_ack_any_impl(
178
+ candidates,
179
+ timeout=timeout,
180
+ not_before=not_before,
181
+ log_timeout=True,
182
+ )
183
+
184
+ def wait_for_ack_family_low(
185
+ self,
186
+ family_low: int,
187
+ *,
188
+ timeout: float = 5.0,
189
+ not_before: float | None = None,
190
+ ) -> tuple[int, bytes] | None:
191
+ """Wait for the next queued frame whose opcode low byte matches.
192
+
193
+ Some hub responses use a variable-length payload whose size is
194
+ encoded in the opcode high byte, so callers cannot pin a single
195
+ 16-bit opcode value ahead of time.
196
+ """
197
+
198
+ deadline = time.monotonic() + timeout
199
+ target_family = family_low & 0xFF
200
+ while True:
201
+ with self._ack_queue_lock:
202
+ for ack_opcode, ack_payload, ack_ts in self._ack_queue:
203
+ if (ack_opcode & 0xFF) != target_family:
204
+ continue
205
+ if not_before is not None and ack_ts < not_before:
206
+ continue
207
+ self._ack_queue.remove((ack_opcode, ack_payload, ack_ts))
208
+ if not self._ack_queue:
209
+ self._ack_event.clear()
210
+ return ack_opcode, ack_payload
211
+ self._ack_event.clear()
212
+
213
+ remaining = deadline - time.monotonic()
214
+ if remaining <= 0:
215
+ self._log.warning(
216
+ "[ACK] timeout waiting family(low)=0x%02X",
217
+ target_family,
218
+ )
219
+ return None
220
+ self._ack_event.wait(min(remaining, 0.2))
221
+
222
+ def wait_for_any_response(
223
+ self,
224
+ *,
225
+ timeout: float,
226
+ not_before: float,
227
+ poll_interval: float = 0.2,
228
+ disconnect_check=None,
229
+ ) -> tuple[int, bytes] | None:
230
+ """Wait for the next frame *of any opcode* arriving after ``not_before``.
231
+
232
+ Unlike :meth:`wait_for_ack_any` this does not filter by opcode --
233
+ the first queued frame whose timestamp is ``>= not_before`` is
234
+ consumed and returned. Intended for opcodes whose response
235
+ family the integration deliberately does not pin down (e.g.
236
+ the hub-erase opcode, which is treated as fire-and-forget once
237
+ any reply comes back).
238
+
239
+ ``disconnect_check`` is an optional zero-arg callable returning
240
+ ``True`` when the underlying transport has dropped *before* a
241
+ response arrived. When supplied and it returns ``True``, the
242
+ wait exits immediately with ``None`` so the caller can
243
+ distinguish "hub didn't answer" from "hub disconnected without
244
+ answering". ``poll_interval`` bounds how quickly that check
245
+ runs (defaults to 200 ms).
246
+ """
247
+
248
+ deadline = time.monotonic() + timeout
249
+ while True:
250
+ with self._ack_queue_lock:
251
+ for ack_opcode, ack_payload, ack_ts in self._ack_queue:
252
+ if ack_ts < not_before:
253
+ continue
254
+ self._ack_queue.remove((ack_opcode, ack_payload, ack_ts))
255
+ if not self._ack_queue:
256
+ self._ack_event.clear()
257
+ return ack_opcode, ack_payload
258
+ self._ack_event.clear()
259
+
260
+ if disconnect_check is not None and disconnect_check():
261
+ return None
262
+
263
+ remaining = deadline - time.monotonic()
264
+ if remaining <= 0:
265
+ return None
266
+ self._ack_event.wait(min(remaining, poll_interval))
267
+
268
+ def cache_macro_record(self, record: MacroRecord) -> None:
269
+ """Store a fully-assembled :class:`MacroRecord` keyed by ``(activity_id, key_id)``."""
270
+
271
+ key = (record.activity_id & 0xFF, record.key_id & 0xFF)
272
+ with self._macro_payload_lock:
273
+ self._macro_payload_events[key] = record
274
+ self._macro_payload_event.set()
275
+
276
+ def wait_for_macro_record(
277
+ self, activity_id: int, button_id: int, *, timeout: float = 5.0
278
+ ) -> MacroRecord | None:
279
+ """Wait until the macro for ``(activity_id, button_id)`` has been assembled."""
280
+
281
+ key = (activity_id & 0xFF, button_id & 0xFF)
282
+ deadline = time.monotonic() + timeout
283
+ while True:
284
+ with self._macro_payload_lock:
285
+ cached = self._macro_payload_events.pop(key, None)
286
+ if cached is not None:
287
+ if not self._macro_payload_events:
288
+ self._macro_payload_event.clear()
289
+ return cached
290
+ self._macro_payload_event.clear()
291
+
292
+ remaining = deadline - time.monotonic()
293
+ if remaining <= 0:
294
+ return None
295
+ self._macro_payload_event.wait(min(remaining, 0.2))
296
+
297
+ def get_cached_macro_records(self, activity_id: int) -> list[MacroRecord]:
298
+ """Return cached assembled macro records for ``activity_id`` without consuming them."""
299
+
300
+ act_lo = activity_id & 0xFF
301
+ with self._macro_payload_lock:
302
+ records = [
303
+ record
304
+ for (cached_act_id, _button_id), record in self._macro_payload_events.items()
305
+ if (cached_act_id & 0xFF) == act_lo
306
+ ]
307
+ records.sort(key=lambda record: record.key_id & 0xFF)
308
+ return records
309
+
310
+ def notify_activity_inputs_frame(self, payload: bytes = b"") -> None:
311
+ with self._activity_inputs_lock:
312
+ self._activity_inputs_payloads.append(bytes(payload))
313
+ self._activity_inputs_seen += 1
314
+ self._activity_inputs_last_ts = time.monotonic()
315
+ self._activity_inputs_event.set()
316
+
317
+ def _try_handle_device_key_sort_payload(self, payload: bytes) -> bool:
318
+ """Assemble the hub's family-0x63 device key-sort response."""
319
+
320
+ with self._device_key_sort_lock:
321
+ pending_device_id = self._device_key_sort_pending
322
+ if pending_device_id is None:
323
+ return False
324
+ if len(payload) < 7:
325
+ return False
326
+
327
+ page_no = int.from_bytes(payload[1:3], "big")
328
+ chunk = bytes(payload[3:])
329
+ if page_no == 1:
330
+ if len(chunk) < 4 or chunk[0] != 0x01:
331
+ return False
332
+ expected_pages = int.from_bytes(chunk[1:3], "big")
333
+ reported_device_id = chunk[3] & 0xFF
334
+ if reported_device_id != pending_device_id:
335
+ self._log.warning(
336
+ "[KEY_SORT] pending dev=0x%02X but hub replied for dev=0x%02X",
337
+ pending_device_id,
338
+ reported_device_id,
339
+ )
340
+ self._device_key_sort_pending = None
341
+ self._device_key_sort_expected_pages = None
342
+ self._device_key_sort_pages.clear()
343
+ return False
344
+ self._device_key_sort_expected_pages = max(1, expected_pages)
345
+ self._device_key_sort_pages.clear()
346
+
347
+ expected_pages = self._device_key_sort_expected_pages
348
+ if not expected_pages:
349
+ return False
350
+
351
+ self._device_key_sort_pages[page_no] = chunk
352
+ if any(
353
+ page not in self._device_key_sort_pages
354
+ for page in range(1, expected_pages + 1)
355
+ ):
356
+ return True
357
+
358
+ assembled = b"".join(
359
+ self._device_key_sort_pages[page]
360
+ for page in range(1, expected_pages + 1)
361
+ )
362
+ device_id = assembled[3] & 0xFF if len(assembled) >= 4 else pending_device_id
363
+ # The inbound family-0x63 body carries no trailing checksum
364
+ # (only the frame-level checksum, which the caller has already
365
+ # stripped from ``payload``). Mirror the official KeySortGets,
366
+ # which takes ``bArr[4:]`` verbatim -- earlier code chopped the
367
+ # last byte and turned the final ``(slot, 0xFF)`` pair into an
368
+ # orphan ``slot`` byte, which the destination hub then rejected
369
+ # with status=0x03 on restore.
370
+ msg_bytes = assembled[4:] if len(assembled) >= 4 else b""
371
+ self.state.device_key_sorts[device_id] = {
372
+ "device_id": device_id,
373
+ "msg_hex": msg_bytes.hex(" ").strip(),
374
+ }
375
+ self._device_key_sort_pending = None
376
+ self._device_key_sort_expected_pages = None
377
+ self._device_key_sort_pages.clear()
378
+
379
+ self.notify_ack(0xFF62, bytes([device_id]))
380
+ return True
381
+
382
+ def wait_for_activity_inputs_burst(
383
+ self,
384
+ *,
385
+ timeout: float = 5.0,
386
+ idle_window: float = 0.35,
387
+ min_frames: int = 1,
388
+ ) -> InputsBurstResult:
389
+ """Wait until at least one 0x47 frame arrives and the burst goes idle.
390
+
391
+ Returns an :class:`InputsBurstResult` whose ``outcome``
392
+ distinguishes:
393
+
394
+ * :attr:`AckOutcome.acked` -- the burst arrived and went idle;
395
+ ``payloads`` carries a snapshot of the assembled frames and
396
+ the proxy's internal buffer is cleared.
397
+ * :attr:`AckOutcome.rejected` -- the hub answered the in-flight
398
+ ``REQ_ACTIVITY_INPUTS`` with a non-zero ``STATUS_ACK``.
399
+ * :attr:`AckOutcome.timeout` -- nothing arrived before
400
+ ``timeout``.
401
+ """
402
+
403
+ deadline = time.monotonic() + timeout
404
+ while True:
405
+ now = time.monotonic()
406
+ with self._activity_inputs_lock:
407
+ if self._inputs_burst_reject_pending:
408
+ self._inputs_burst_reject_pending = False
409
+ self._activity_inputs_seen = 0
410
+ self._activity_inputs_last_ts = 0.0
411
+ self._activity_inputs_payloads.clear()
412
+ self._activity_inputs_event.clear()
413
+ return InputsBurstResult(outcome=AckOutcome.rejected)
414
+ seen = self._activity_inputs_seen
415
+ last_ts = self._activity_inputs_last_ts
416
+ if seen >= min_frames and last_ts > 0 and (now - last_ts) >= idle_window:
417
+ payloads = tuple(self._activity_inputs_payloads)
418
+ self._activity_inputs_payloads.clear()
419
+ self._activity_inputs_seen = 0
420
+ self._activity_inputs_last_ts = 0.0
421
+ self._activity_inputs_event.clear()
422
+ return InputsBurstResult(
423
+ outcome=AckOutcome.acked,
424
+ payloads=payloads,
425
+ )
426
+ self._activity_inputs_event.clear()
427
+
428
+ remaining = deadline - now
429
+ if remaining <= 0:
430
+ return InputsBurstResult(outcome=AckOutcome.timeout)
431
+ self._activity_inputs_event.wait(min(remaining, 0.2))
432
+
433
+ def query_device_input_index(self, device_id: int, cmd_id: int, *, timeout: float = 5.0) -> int | None:
434
+ """Return the 1-based ordinal of cmd_id in the device's ACTIVITY_INPUTS list, or None if not found."""
435
+ with self._activity_inputs_lock:
436
+ self._activity_inputs_payloads.clear()
437
+ self._activity_inputs_seen = 0
438
+ self._activity_inputs_last_ts = 0.0
439
+ self._activity_inputs_event.clear()
440
+
441
+ self._send_cmd_frame(OP_REQ_ACTIVITY_INPUTS, bytes([device_id & 0xFF]))
442
+ burst = self.wait_for_activity_inputs_burst(timeout=timeout)
443
+ if burst.outcome is AckOutcome.rejected:
444
+ self._log.info(
445
+ "[INPUT_QUERY] hub rejected inputs request dev=0x%02X cmd=0x%02X",
446
+ device_id & 0xFF,
447
+ cmd_id & 0xFF,
448
+ )
449
+ return None
450
+ if burst.outcome is AckOutcome.timeout:
451
+ self._log.warning(
452
+ "[INPUT_QUERY] timeout waiting for inputs dev=0x%02X cmd=0x%02X",
453
+ device_id & 0xFF,
454
+ cmd_id & 0xFF,
455
+ )
456
+ return None
457
+
458
+ record = parse_inputs_burst(list(burst.payloads), hub_version=self.hub_version)
459
+ for index, entry in enumerate(record.entries, start=1):
460
+ if entry.key_id == (cmd_id & 0xFF):
461
+ # X1S/X2 stores an explicit 1-based ordinal on each entry;
462
+ # X1 has no ordinal byte and we report the positional
463
+ # index of the entry in the list.
464
+ return entry.ordinal or index
465
+
466
+ self._log.warning(
467
+ "[INPUT_QUERY] cmd_id=0x%02X not found in %d entries for dev=0x%02X",
468
+ cmd_id & 0xFF,
469
+ len(record.entries),
470
+ device_id & 0xFF,
471
+ )
472
+ return None
473
+
474
+ def fetch_device_input_entries(
475
+ self,
476
+ device_id: int,
477
+ *,
478
+ timeout: float = 5.0,
479
+ ) -> list[dict[str, int]] | None:
480
+ """Return fresh input ordering rows for ``device_id``.
481
+
482
+ Each returned row has ``command_id`` and 1-based ``input_index``.
483
+
484
+ Returns ``None`` only when the hub does not answer at all before
485
+ ``timeout``. When the hub *does* answer but with a non-zero
486
+ STATUS_ACK (e.g. ``0x07`` for a device that has not been
487
+ configured for power/inputs), an empty list is returned --
488
+ semantically "this device has no input entries", which is what a
489
+ faithful backup needs.
490
+ """
491
+
492
+ with self._activity_inputs_lock:
493
+ self._activity_inputs_payloads.clear()
494
+ self._activity_inputs_seen = 0
495
+ self._activity_inputs_last_ts = 0.0
496
+ self._activity_inputs_event.clear()
497
+ self._inputs_burst_reject_pending = False
498
+ self._activity_inputs_pending = True
499
+
500
+ try:
501
+ self._send_cmd_frame(OP_REQ_ACTIVITY_INPUTS, bytes([device_id & 0xFF]))
502
+ burst = self.wait_for_activity_inputs_burst(timeout=timeout)
503
+ finally:
504
+ with self._activity_inputs_lock:
505
+ self._activity_inputs_pending = False
506
+
507
+ if burst.outcome is AckOutcome.rejected:
508
+ self._log.info(
509
+ "[INPUT_QUERY] hub returned non-success status for dev=0x%02X; "
510
+ "treating as no inputs configured",
511
+ device_id & 0xFF,
512
+ )
513
+ return []
514
+ if burst.outcome is AckOutcome.timeout:
515
+ self._log.warning(
516
+ "[INPUT_QUERY] timeout waiting for full inputs list dev=0x%02X",
517
+ device_id & 0xFF,
518
+ )
519
+ return None
520
+
521
+ record = parse_inputs_burst(list(burst.payloads), hub_version=self.hub_version)
522
+ return [
523
+ {
524
+ "command_id": entry.key_id & 0xFF,
525
+ "input_index": (entry.ordinal or index) & 0xFF,
526
+ }
527
+ for index, entry in enumerate(record.entries, start=1)
528
+ ]
529
+
530
+ def fetch_device_input_record(
531
+ self,
532
+ device_id: int,
533
+ *,
534
+ timeout: float = 5.0,
535
+ ) -> dict[str, object] | None:
536
+ """Return the full parsed family-0x46 record for ``device_id``.
537
+
538
+ The backup flow uses this richer form on X1 so restore can
539
+ preserve the trailing control-key/favorite rows, which are not
540
+ represented in the simplified ``fetch_device_input_entries``
541
+ surface.
542
+ """
543
+
544
+ with self._activity_inputs_lock:
545
+ self._activity_inputs_payloads.clear()
546
+ self._activity_inputs_seen = 0
547
+ self._activity_inputs_last_ts = 0.0
548
+ self._activity_inputs_event.clear()
549
+ self._inputs_burst_reject_pending = False
550
+ self._activity_inputs_pending = True
551
+
552
+ try:
553
+ self._send_cmd_frame(OP_REQ_ACTIVITY_INPUTS, bytes([device_id & 0xFF]))
554
+ burst = self.wait_for_activity_inputs_burst(timeout=timeout)
555
+ finally:
556
+ with self._activity_inputs_lock:
557
+ self._activity_inputs_pending = False
558
+
559
+ if burst.outcome is AckOutcome.rejected:
560
+ self._log.info(
561
+ "[INPUT_QUERY] hub returned non-success status for dev=0x%02X; "
562
+ "treating as no inputs configured",
563
+ device_id & 0xFF,
564
+ )
565
+ return None
566
+ if burst.outcome is AckOutcome.timeout:
567
+ self._log.warning(
568
+ "[INPUT_QUERY] timeout waiting for full input record dev=0x%02X",
569
+ device_id & 0xFF,
570
+ )
571
+ return None
572
+
573
+ record = parse_inputs_burst(list(burst.payloads), hub_version=self.hub_version)
574
+ return {
575
+ "device_id": record.device_id & 0xFF,
576
+ "source_id_byte": record.source_id_byte & 0xFF,
577
+ "flag_a": record.flag_a & 0xFF,
578
+ "flag_b": record.flag_b & 0xFF,
579
+ "state_byte": record.state_byte & 0xFF,
580
+ "entries": [
581
+ {
582
+ "command_id": entry.key_id & 0xFF,
583
+ "input_index": (entry.ordinal or index) & 0xFF,
584
+ "fid": entry.fid & 0xFFFFFFFFFFFF,
585
+ "name": entry.label,
586
+ }
587
+ for index, entry in enumerate(record.entries, start=1)
588
+ ],
589
+ "control_keys": {
590
+ "input_list": record.control_keys.input_list.hex(" "),
591
+ "input_up": record.control_keys.input_up.hex(" "),
592
+ "input_down": record.control_keys.input_down.hex(" "),
593
+ "input_confirm": record.control_keys.input_confirm.hex(" "),
594
+ },
595
+ "favorites": [
596
+ slot.payload.hex(" ") for slot in record.favorites
597
+ ],
598
+ }
599
+
600
+ def fetch_device_key_sort(
601
+ self,
602
+ device_id: int,
603
+ *,
604
+ timeout: float = 5.0,
605
+ ) -> dict[str, int | str] | None:
606
+ """Return the hub's raw key-sort blob for ``device_id``."""
607
+
608
+ dev_lo = device_id & 0xFF
609
+ with self._device_key_sort_lock:
610
+ self.state.device_key_sorts.pop(dev_lo, None)
611
+ self._device_key_sort_pending = dev_lo
612
+ self._device_key_sort_expected_pages = None
613
+ self._device_key_sort_pages.clear()
614
+
615
+ send_ts = time.monotonic()
616
+ self._send_family_frame(FAMILY_KEY_SORT_REQ, bytes([dev_lo]))
617
+ # Accept either the family-0x63 paged reply (assembled into
618
+ # state.device_key_sorts and notified as 0xFF62) OR a STATUS_ACK
619
+ # from the hub. The hub replies with STATUS_ACK status=0x07 when
620
+ # the requested device has no key-sort blob configured -- mirror
621
+ # the inputs path and treat that as "device has no key-sort data"
622
+ # (empty msg_hex) rather than waiting out the full timeout.
623
+ result = self.wait_for_ack_any(
624
+ [(0xFF62, dev_lo), (OP_STATUS_ACK, None)],
625
+ timeout=timeout,
626
+ not_before=send_ts,
627
+ )
628
+ if result is None:
629
+ with self._device_key_sort_lock:
630
+ self._device_key_sort_pending = None
631
+ self._device_key_sort_expected_pages = None
632
+ self._device_key_sort_pages.clear()
633
+ self._log.warning(
634
+ "[KEY_SORT] timeout waiting for device sort dev=0x%02X",
635
+ dev_lo,
636
+ )
637
+ return None
638
+
639
+ ack_opcode, ack_payload = result
640
+ if ack_opcode == OP_STATUS_ACK:
641
+ status = ack_payload[0] if ack_payload else None
642
+ with self._device_key_sort_lock:
643
+ self._device_key_sort_pending = None
644
+ self._device_key_sort_expected_pages = None
645
+ self._device_key_sort_pages.clear()
646
+ self._log.info(
647
+ "[KEY_SORT] hub returned STATUS_ACK status=%s for dev=0x%02X; "
648
+ "treating as no key-sort data configured",
649
+ f"0x{status:02X}" if status is not None else "(empty)",
650
+ dev_lo,
651
+ )
652
+ return {"device_id": dev_lo, "msg_hex": ""}
653
+
654
+ cached = self.state.device_key_sorts.get(dev_lo)
655
+ if isinstance(cached, dict):
656
+ return dict(cached)
657
+ return {"device_id": dev_lo, "msg_hex": ""}
658
+
659
+
660
+ __all__ = ["AckWaitersMixin"]