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,915 @@
1
+ """Catalog request / snapshot / cache mixin for :class:`X1Proxy`.
2
+
3
+ Centralises the read-side traffic against the hub: the
4
+ ``request_*`` ↦ burst ↦ ``ingest_*`` ↦ ``_commit_pending_*_snapshot``
5
+ pipeline that keeps ``self.state.devices`` / ``self.state.activities``
6
+ in sync with the hub's authoritative catalog, plus the per-entity
7
+ ``get_*`` / ``ensure_*`` / ``clear_entity_cache`` helpers that other
8
+ mixins call when they need a specific row.
9
+
10
+ The IR-dump assembly helpers (``_record_ir_dump_frame``,
11
+ ``_build_ir_dump_result``, ``_ir_dump_snapshot_complete``,
12
+ ``try_finish_ir_dump_burst``) live here because they share the same
13
+ "per-frame ingest, finish-on-completion" shape as the device/activity
14
+ ingest path.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import threading
20
+ import time
21
+ from typing import Any
22
+
23
+ from .hub_versions import HUB_VERSION_X2
24
+ from .commands import extract_ir_dump_blob, extract_ir_dump_label_field
25
+ from .protocol_const import (
26
+ OP_REQ_ACTIVITY_MAP,
27
+ OP_REQ_BUTTONS,
28
+ OP_REQ_COMMANDS,
29
+ OP_REQ_DEVICES,
30
+ OP_REQ_IPCMD_SYNC,
31
+ OP_REQ_MACRO_LABELS,
32
+ )
33
+ from .state_helpers import normalize_device_entry
34
+
35
+
36
+ ACTIVITY_INCOMPLETE_RETRY_DELAY_S = 0.75
37
+
38
+
39
+ def _to_export_view():
40
+ from .x1_proxy import to_export_view
41
+
42
+ return to_export_view
43
+
44
+
45
+ class CatalogMixin:
46
+ """Mixin providing catalog request, snapshot ingest, and cache reads."""
47
+
48
+ def request_activity_mapping(self, act_id: int) -> bool:
49
+ if not self.can_issue_commands():
50
+ self._log.info("[CMD] request_activity_mapping ignored: proxy client is connected"); return False
51
+
52
+ act_lo = act_id & 0xFF
53
+ if act_lo in self._pending_activity_map_requests:
54
+ self._log.debug(
55
+ "[CMD] request_activity_mapping ignored: burst already pending for 0x%02X",
56
+ act_lo,
57
+ )
58
+ return False
59
+
60
+ self._pending_activity_map_requests.add(act_lo)
61
+ self._log.info("[ACTMAP] local request act=0x%02X (%d)", act_lo, act_lo)
62
+ return self.enqueue_cmd(
63
+ OP_REQ_ACTIVITY_MAP,
64
+ bytes([act_lo]),
65
+ expects_burst=True,
66
+ burst_kind=f"activity_map:{act_lo}",
67
+ )
68
+
69
+ def request_macros_for_activity(self, act_id: int) -> bool:
70
+ if not self.can_issue_commands():
71
+ self._log.info("[CMD] request_macros_for_activity ignored: proxy client is connected"); return False
72
+
73
+ act_lo = act_id & 0xFF
74
+ if act_lo in self._pending_macro_requests:
75
+ self._log.debug(
76
+ "[CMD] request_macros_for_activity ignored: burst already pending for 0x%02X",
77
+ act_lo,
78
+ )
79
+ return False
80
+
81
+ self._pending_macro_requests.add(act_lo)
82
+ return self.enqueue_cmd(
83
+ OP_REQ_MACRO_LABELS,
84
+ bytes([act_lo, 0xFF]),
85
+ expects_burst=True,
86
+ burst_kind=f"macros:{act_lo}",
87
+ )
88
+
89
+ def request_ip_commands_for_device(self, dev_id: int, *, wait: bool = False, timeout: float = 1.0) -> bool:
90
+ """Fetch IP command definitions for an existing device."""
91
+
92
+ if not self.can_issue_commands():
93
+ self._log.info("[CMD] request_ip_commands_for_device ignored: proxy client is connected"); return False
94
+
95
+ dev_lo = dev_id & 0xFF
96
+ event = threading.Event() if wait else None
97
+
98
+ if event:
99
+ def _done(_: str) -> None:
100
+ event.set()
101
+
102
+ self._burst.on_burst_end(f"commands:{dev_lo}", _done)
103
+
104
+ ok = self.enqueue_cmd(
105
+ OP_REQ_IPCMD_SYNC,
106
+ bytes([dev_lo, 0xFF, 0x14]),
107
+ expects_burst=True,
108
+ burst_kind=f"commands:{dev_lo}",
109
+ )
110
+
111
+ if event:
112
+ event.wait(timeout)
113
+
114
+ return ok
115
+
116
+ def get_activities(self, *, force_refresh: bool = True) -> tuple[dict[int, dict], bool]:
117
+ to_export_view = _to_export_view()
118
+ if force_refresh:
119
+ if self.can_issue_commands():
120
+ self.request_activities()
121
+ return ({}, False)
122
+
123
+ activities_view = self.state.entities("activity")
124
+ if self._activities_catalog_ready:
125
+ return ({k: to_export_view(v) for k, v in activities_view.items()}, True)
126
+
127
+ return ({}, False)
128
+
129
+ def get_devices(self, *, force_refresh: bool = False) -> tuple[dict[int, dict], bool]:
130
+ to_export_view = _to_export_view()
131
+ if force_refresh:
132
+ if self.can_issue_commands():
133
+ self.enqueue_cmd(OP_REQ_DEVICES, expects_burst=True, burst_kind="devices")
134
+ return ({}, False)
135
+
136
+ devices_view = self.state.entities("device")
137
+ if self._devices_catalog_ready:
138
+ return ({k: to_export_view(v) for k, v in devices_view.items()}, True)
139
+
140
+ if self.can_issue_commands():
141
+ self.enqueue_cmd(OP_REQ_DEVICES, expects_burst=True, burst_kind="devices")
142
+ return ({}, False)
143
+
144
+ def _record_ir_dump_frame(self, parsed, raw_frame: bytes) -> None:
145
+ pending: dict[str, Any] | None = None
146
+ pending_dev_id: int | None = parsed.device_id
147
+ payload = raw_frame[4:-1]
148
+ ir_blob = extract_ir_dump_blob(payload, parsed.page_no)
149
+ label_field = extract_ir_dump_label_field(payload) if parsed.page_no == 1 else None
150
+
151
+ with self._ir_dump_lock:
152
+ _request_key, pending = self._get_active_ir_dump_pending(
153
+ device_id=pending_dev_id,
154
+ burst_kind=str(self._burst.kind or ""),
155
+ )
156
+ if pending is not None and pending_dev_id is None:
157
+ pending_dev_id = int(pending.get("device_id", 0)) & 0xFF
158
+
159
+ if pending is None:
160
+ return
161
+
162
+ if parsed.total_commands and pending.get("total_commands") is None:
163
+ pending["total_commands"] = parsed.total_commands
164
+ pending["last_progress_ts"] = time.monotonic()
165
+
166
+ response_index_map = pending.setdefault("response_index_to_command_id", {})
167
+ if parsed.is_page_one:
168
+ response_index_map[parsed.response_index] = parsed.command_id
169
+
170
+ effective_command_id = response_index_map.get(parsed.response_index, parsed.command_id)
171
+ commands = pending.setdefault("commands", {})
172
+ command_entry = commands.setdefault(
173
+ effective_command_id,
174
+ {
175
+ "command_id": effective_command_id,
176
+ "device_id": pending_dev_id,
177
+ "label": None,
178
+ "format_marker": None,
179
+ "expected_page_count": None,
180
+ "pages": {},
181
+ },
182
+ )
183
+
184
+ if pending_dev_id is not None:
185
+ command_entry["device_id"] = pending_dev_id
186
+ if parsed.label:
187
+ command_entry["label"] = parsed.label
188
+ if parsed.format_marker is not None:
189
+ command_entry["format_marker"] = parsed.format_marker
190
+ if parsed.total_pages is not None:
191
+ command_entry["expected_page_count"] = parsed.total_pages
192
+
193
+ command_entry["pages"][parsed.page_no] = {
194
+ "page_no": parsed.page_no,
195
+ "opcode": parsed.opcode,
196
+ "opcode_hex": f"0x{parsed.opcode:04X}",
197
+ "payload_hex": payload.hex(" "),
198
+ "frame_hex": raw_frame.hex(" "),
199
+ "ir_blob_hex": ir_blob.hex(" ") if ir_blob is not None else None,
200
+ "ir_blob_byte_count": len(ir_blob) if ir_blob is not None else None,
201
+ "_ir_blob_bytes": ir_blob,
202
+ "label_field_hex": label_field.hex(" ") if label_field is not None else None,
203
+ }
204
+
205
+ def _build_ir_dump_result(self, pending: dict[str, Any]) -> dict[str, Any]:
206
+ commands_out: list[dict[str, Any]] = []
207
+ total_commands = pending.get("total_commands")
208
+ requested_command_id = pending.get("requested_command_id")
209
+
210
+ for command_id in sorted(pending.get("commands", {})):
211
+ record = pending["commands"][command_id]
212
+ raw_page_items = [record["pages"][page_no] for page_no in sorted(record.get("pages", {}))]
213
+ blob_parts = [
214
+ bytes(page["_ir_blob_bytes"])
215
+ for page in raw_page_items
216
+ if page.get("_ir_blob_bytes") is not None
217
+ ]
218
+ page_items = [
219
+ {k: v for k, v in page.items() if not k.startswith("_")}
220
+ for page in raw_page_items
221
+ ]
222
+ expected_page_count = record.get("expected_page_count") or max((page["page_no"] for page in page_items), default=0)
223
+ complete = bool(expected_page_count) and len(page_items) >= expected_page_count
224
+ ir_blob = b"".join(blob_parts)
225
+
226
+ commands_out.append(
227
+ {
228
+ "command_id": command_id,
229
+ "device_id": record.get("device_id"),
230
+ "label": record.get("label"),
231
+ "format_marker": record.get("format_marker"),
232
+ "expected_page_count": expected_page_count,
233
+ "page_count": len(page_items),
234
+ "complete": complete,
235
+ "ir_blob_hex": ir_blob.hex(" ") if ir_blob else None,
236
+ "ir_blob_byte_count": len(ir_blob),
237
+ "pages": page_items,
238
+ }
239
+ )
240
+
241
+ overall_complete = bool(pending.get("burst_finished"))
242
+ if requested_command_id is None and total_commands is not None:
243
+ overall_complete = overall_complete and len(commands_out) >= int(total_commands)
244
+ if requested_command_id is None:
245
+ overall_complete = overall_complete and all(command["complete"] for command in commands_out)
246
+ else:
247
+ requested_entry = next(
248
+ (command for command in commands_out if command["command_id"] == requested_command_id),
249
+ None,
250
+ )
251
+ overall_complete = overall_complete and bool(requested_entry and requested_entry["complete"])
252
+
253
+ return {
254
+ "device_id": pending.get("device_id"),
255
+ "requested_command_id": requested_command_id,
256
+ "total_commands": total_commands,
257
+ "received_command_count": len(commands_out),
258
+ "complete": overall_complete,
259
+ "commands": commands_out,
260
+ }
261
+
262
+ def _ir_dump_snapshot_complete(self, pending: dict[str, Any]) -> bool:
263
+ requested_command_id = pending.get("requested_command_id")
264
+ commands: dict[int, dict[str, Any]] = pending.get("commands", {})
265
+ total_commands = pending.get("total_commands")
266
+
267
+ def _command_complete(record: dict[str, Any]) -> bool:
268
+ expected_page_count = record.get("expected_page_count")
269
+ if not expected_page_count:
270
+ return False
271
+ pages = record.get("pages", {})
272
+ return len(pages) >= int(expected_page_count)
273
+
274
+ if requested_command_id is not None:
275
+ record = commands.get(int(requested_command_id))
276
+ return bool(record and _command_complete(record))
277
+
278
+ if total_commands is None:
279
+ return False
280
+ if len(commands) < int(total_commands):
281
+ return False
282
+ return all(_command_complete(record) for record in commands.values())
283
+
284
+ def try_finish_ir_dump_burst(self, request_key: tuple[int, int]) -> bool:
285
+ with self._ir_dump_lock:
286
+ pending = self._ir_dump_pending.get(request_key)
287
+ if pending is None:
288
+ return False
289
+ if not self._ir_dump_snapshot_complete(pending):
290
+ return False
291
+
292
+ return self._burst.finish(
293
+ f"ir_dump:{request_key[0]}:{request_key[1]}",
294
+ can_issue=self.can_issue_commands,
295
+ sender=self._send_cmd_frame,
296
+ )
297
+
298
+ def get_buttons_for_entity(self, ent_id: int, *, fetch_if_missing: bool = True) -> tuple[list[int], bool]:
299
+ ent_lo = ent_id & 0xFF
300
+ if ent_lo in self.state.buttons:
301
+ self._pending_button_requests.discard(ent_lo)
302
+ return (sorted(self.state.buttons[ent_lo]), True)
303
+
304
+ if fetch_if_missing and self.can_issue_commands():
305
+ if ent_lo not in self._pending_button_requests:
306
+ self._pending_button_requests.add(ent_lo)
307
+ self.enqueue_cmd(
308
+ OP_REQ_BUTTONS,
309
+ bytes([ent_lo, 0xFF]),
310
+ expects_burst=True,
311
+ burst_kind=f"buttons:{ent_lo}",
312
+ )
313
+
314
+ return ([], False)
315
+
316
+ def get_commands_for_entity(self, ent_id: int, *, fetch_if_missing: bool = True) -> tuple[dict[int, str], bool]:
317
+ ent_lo = ent_id & 0xFF
318
+ commands = self.state.commands.get(ent_lo)
319
+ complete = ent_lo in self._commands_complete
320
+
321
+ if commands is not None and complete:
322
+ return (dict(commands), True)
323
+
324
+ if fetch_if_missing and self.can_issue_commands():
325
+ pending = self._pending_command_requests.setdefault(ent_lo, set())
326
+ if 0xFF not in pending:
327
+ pending.add(0xFF)
328
+ self.enqueue_cmd(
329
+ OP_REQ_COMMANDS,
330
+ bytes([ent_lo, 0xFF]),
331
+ expects_burst=True,
332
+ burst_kind=f"commands:{ent_lo}",
333
+ )
334
+
335
+ if commands is not None:
336
+ return (dict(commands), complete)
337
+
338
+ return ({}, False)
339
+
340
+ def _reset_pending_device_snapshot(self, generation: int | None = None) -> None:
341
+ self._device_pending_generation = generation
342
+ self._device_pending_expected_rows = None
343
+ self._device_pending_rows = {}
344
+
345
+ def _reset_pending_activity_snapshot(self, generation: int | None = None) -> None:
346
+ self._activity_pending_generation = generation
347
+ self._activity_pending_expected_rows = None
348
+ self._activity_pending_rows = {}
349
+ self._activity_pending_payloads = {}
350
+ self._activity_pending_hint = None
351
+
352
+ def _begin_device_request(self) -> None:
353
+ self._device_request_serial += 1
354
+ self._device_request_inflight = self._device_request_serial
355
+ self._reset_pending_device_snapshot(self._device_request_inflight)
356
+
357
+ def _begin_activity_request(self, *, is_retry: bool = False) -> None:
358
+ self._activity_request_serial += 1
359
+ self._activity_request_inflight = self._activity_request_serial
360
+ self._activity_retry_due_at = None
361
+ if not is_retry:
362
+ self._activity_retry_count = 0
363
+ self._reset_pending_activity_snapshot(self._activity_request_inflight)
364
+
365
+ def _schedule_activity_retry(self, *, now: float | None = None) -> None:
366
+ if self.hub_version != HUB_VERSION_X2:
367
+ return
368
+ if self._activity_retry_count >= 1:
369
+ return
370
+ base = time.monotonic() if now is None else now
371
+ self._activity_retry_due_at = base + ACTIVITY_INCOMPLETE_RETRY_DELAY_S
372
+ self._activity_retry_count += 1
373
+ self._log.warning(
374
+ "[ACT] incomplete activities snapshot on X2; retrying in %.2fs",
375
+ ACTIVITY_INCOMPLETE_RETRY_DELAY_S,
376
+ )
377
+
378
+ def _activity_snapshot_complete(self) -> bool:
379
+ expected = self._activity_pending_expected_rows
380
+ if expected == 0:
381
+ return True
382
+ if expected is None:
383
+ return len(self._activity_pending_rows) == 0
384
+ if expected < 0:
385
+ return False
386
+ seen = set(self._activity_pending_rows.keys())
387
+ return seen == set(range(1, expected + 1))
388
+
389
+ def _device_snapshot_complete(self) -> bool:
390
+ expected = self._device_pending_expected_rows
391
+ if expected == 0:
392
+ return True
393
+ if expected is None:
394
+ return len(self._device_pending_rows) == 0
395
+ if expected < 0:
396
+ return False
397
+ seen = set(self._device_pending_rows.keys())
398
+ return seen == set(range(1, expected + 1))
399
+
400
+ def try_finish_devices_burst(self) -> bool:
401
+ generation = self._device_request_inflight
402
+ if generation is None or self._device_pending_generation != generation:
403
+ return False
404
+ if not self._device_snapshot_complete():
405
+ return False
406
+ return self._burst.finish(
407
+ "devices",
408
+ can_issue=self.can_issue_commands,
409
+ sender=self._send_cmd_frame,
410
+ )
411
+
412
+ def try_finish_activities_burst(self) -> bool:
413
+ generation = self._activity_request_inflight
414
+ if generation is None or self._activity_pending_generation != generation:
415
+ return False
416
+ if not self._activity_snapshot_complete():
417
+ return False
418
+ return self._burst.finish(
419
+ "activities",
420
+ can_issue=self.can_issue_commands,
421
+ sender=self._send_cmd_frame,
422
+ )
423
+
424
+ def note_catalog_status_ack(self, status: int) -> bool:
425
+ """Handle hub status replies that mean an empty catalog request.
426
+
427
+ Some hubs answer ``REQ_ACTIVITIES`` / ``REQ_DEVICES`` with a plain
428
+ ``STATUS_ACK`` carrying ``0x07`` when the corresponding table is
429
+ genuinely empty. In that case there will be no row burst to drive the
430
+ normal completion path, so finish the active catalog burst immediately
431
+ instead of waiting for the idle timeout.
432
+ """
433
+
434
+ ack_status = int(status) & 0xFF
435
+ if ack_status != 0x07:
436
+ return False
437
+
438
+ if self._activity_request_inflight is not None and not self._activity_pending_rows:
439
+ if self._activity_pending_generation != self._activity_request_inflight:
440
+ self._reset_pending_activity_snapshot(self._activity_request_inflight)
441
+ self._activity_pending_expected_rows = 0
442
+ finished = self._burst.finish(
443
+ "activities",
444
+ can_issue=self.can_issue_commands,
445
+ sender=self._send_cmd_frame,
446
+ )
447
+ if finished:
448
+ self._log.info("[ACT] STATUS_ACK 0x07 indicates an empty activities catalog; finishing burst")
449
+ return finished
450
+
451
+ if self._device_request_inflight is not None and not self._device_pending_rows:
452
+ if self._device_pending_generation != self._device_request_inflight:
453
+ self._reset_pending_device_snapshot(self._device_request_inflight)
454
+ self._device_pending_expected_rows = 0
455
+ finished = self._burst.finish(
456
+ "devices",
457
+ can_issue=self.can_issue_commands,
458
+ sender=self._send_cmd_frame,
459
+ )
460
+ if finished:
461
+ self._log.info("[DEV] STATUS_ACK 0x07 indicates an empty devices catalog; finishing burst")
462
+ return finished
463
+
464
+ return False
465
+
466
+ def note_buttons_frame(self, act_lo: int, *, frame_no: int | None, total_frames: int | None) -> None:
467
+ if frame_no != 1:
468
+ return
469
+ if total_frames is None or total_frames <= 0:
470
+ return
471
+ self._button_burst_expected_frames[act_lo & 0xFF] = total_frames
472
+
473
+ def try_finish_buttons_burst(self, act_lo: int, *, frame_no: int | None) -> bool:
474
+ ent_lo = act_lo & 0xFF
475
+ expected = self._button_burst_expected_frames.get(ent_lo)
476
+ if expected is None or frame_no is None or frame_no < expected:
477
+ return False
478
+ return self._burst.finish(
479
+ f"buttons:{ent_lo}",
480
+ can_issue=self.can_issue_commands,
481
+ sender=self._send_cmd_frame,
482
+ )
483
+
484
+ def try_finish_activity_map_burst(self, act_lo: int) -> bool:
485
+ ent_lo = act_lo & 0xFF
486
+ if ent_lo not in self._activity_map_complete:
487
+ return False
488
+ return self._burst.finish(
489
+ f"activity_map:{ent_lo}",
490
+ can_issue=self.can_issue_commands,
491
+ sender=self._send_cmd_frame,
492
+ )
493
+
494
+ def ingest_activity_row(
495
+ self,
496
+ *,
497
+ row_idx: int | None,
498
+ expected_rows: int | None,
499
+ act_id: int | None,
500
+ activity: dict[str, Any] | None,
501
+ payload: bytes | None = None,
502
+ ) -> bool:
503
+ generation = self._activity_request_inflight
504
+ if generation is None:
505
+ self._log.warning(
506
+ "[ACT] ignoring ghost activity row idx=%s act_id=%s: no request in flight",
507
+ row_idx,
508
+ act_id,
509
+ )
510
+ return False
511
+
512
+ if row_idx is None or row_idx <= 0 or act_id is None or activity is None:
513
+ return False
514
+
515
+ if row_idx == 1:
516
+ self._reset_pending_activity_snapshot(generation)
517
+ elif self._activity_pending_generation != generation:
518
+ self._log.warning(
519
+ "[ACT] ignoring activity row idx=%s act_id=%s before row #1 for request=%s",
520
+ row_idx,
521
+ act_id,
522
+ generation,
523
+ )
524
+ return False
525
+
526
+ if expected_rows is not None and expected_rows > 0:
527
+ if self._activity_pending_expected_rows is None:
528
+ self._activity_pending_expected_rows = expected_rows
529
+ elif self._activity_pending_expected_rows != expected_rows:
530
+ self._log.warning(
531
+ "[ACT] row-count mismatch in pending snapshot: had=%s got=%s idx=%s",
532
+ self._activity_pending_expected_rows,
533
+ expected_rows,
534
+ row_idx,
535
+ )
536
+ self._reset_pending_activity_snapshot(generation)
537
+ self._activity_pending_expected_rows = expected_rows
538
+ if row_idx != 1:
539
+ self._log.warning(
540
+ "[ACT] ignoring activity row idx=%s after row-count mismatch until row #1 restarts snapshot",
541
+ row_idx,
542
+ )
543
+ return False
544
+
545
+ self._activity_pending_rows[row_idx] = dict(activity)
546
+ if payload is not None:
547
+ self._activity_pending_payloads[act_id & 0xFF] = bytes(payload)
548
+
549
+ if bool(activity.get("active", False)):
550
+ self._activity_pending_hint = act_id
551
+
552
+ return True
553
+
554
+ def ingest_device_row(
555
+ self,
556
+ *,
557
+ row_idx: int | None,
558
+ expected_rows: int | None,
559
+ dev_id: int | None,
560
+ device: dict[str, Any] | None,
561
+ ) -> bool:
562
+ generation = self._device_request_inflight
563
+ if generation is None:
564
+ self._log.warning(
565
+ "[DEV] ignoring ghost device row idx=%s dev_id=%s: no request in flight",
566
+ row_idx,
567
+ dev_id,
568
+ )
569
+ return False
570
+
571
+ if row_idx is None or row_idx <= 0 or dev_id is None or device is None:
572
+ return False
573
+
574
+ if row_idx == 1:
575
+ self._reset_pending_device_snapshot(generation)
576
+ elif self._device_pending_generation != generation:
577
+ self._log.warning(
578
+ "[DEV] ignoring device row idx=%s dev_id=%s before row #1 for request=%s",
579
+ row_idx,
580
+ dev_id,
581
+ generation,
582
+ )
583
+ return False
584
+
585
+ if expected_rows is not None and expected_rows > 0:
586
+ if self._device_pending_expected_rows is None:
587
+ self._device_pending_expected_rows = expected_rows
588
+ elif self._device_pending_expected_rows != expected_rows:
589
+ self._log.warning(
590
+ "[DEV] row-count mismatch in pending snapshot: had=%s got=%s idx=%s",
591
+ self._device_pending_expected_rows,
592
+ expected_rows,
593
+ row_idx,
594
+ )
595
+ self._reset_pending_device_snapshot(generation)
596
+ self._device_pending_expected_rows = expected_rows
597
+ if row_idx != 1:
598
+ self._log.warning(
599
+ "[DEV] ignoring device row idx=%s after row-count mismatch until row #1 restarts snapshot",
600
+ row_idx,
601
+ )
602
+ return False
603
+
604
+ self._device_pending_rows[row_idx] = {
605
+ "id": dev_id & 0xFF,
606
+ **normalize_device_entry(device),
607
+ }
608
+ return True
609
+
610
+ def _commit_pending_device_snapshot(self) -> None:
611
+ ordered_rows = sorted(self._device_pending_rows.items())
612
+ committed: dict[int, dict[str, Any]] = {}
613
+ for _row_idx, row in ordered_rows:
614
+ dev_id = int(row["id"]) & 0xFF
615
+ merged = normalize_device_entry(
616
+ {
617
+ "brand": row.get("brand"),
618
+ "name": row.get("name"),
619
+ "device_class": row.get("device_class"),
620
+ "device_class_code": row.get("device_class_code"),
621
+ }
622
+ )
623
+ raw_body = row.get("raw_body")
624
+ if isinstance(raw_body, (bytes, bytearray)) and raw_body:
625
+ merged["raw_body"] = bytes(raw_body)
626
+ prior = self.state.entities("device").get(dev_id, {})
627
+ cached_mode = self._idle_behavior_values.get(dev_id)
628
+ if cached_mode is None and isinstance(prior.get("idle_behavior"), int):
629
+ cached_mode = int(prior["idle_behavior"]) & 0xFF
630
+ if cached_mode is not None:
631
+ merged["idle_behavior"] = cached_mode
632
+ merged["power_mode"] = cached_mode
633
+ merged["power_model"] = cached_mode
634
+ committed[dev_id] = merged
635
+ self.state.devices = committed
636
+ self._devices_catalog_ready = True
637
+
638
+ def _on_devices_burst_end(self, key: str) -> None:
639
+ generation = self._device_request_inflight
640
+ complete = generation is not None and self._device_pending_generation == generation and self._device_snapshot_complete()
641
+
642
+ if complete:
643
+ self._commit_pending_device_snapshot()
644
+ self._log.info(
645
+ "[DEV] committed complete devices snapshot rows=%d request=%s",
646
+ len(self._device_pending_rows),
647
+ generation,
648
+ )
649
+
650
+ self._device_request_inflight = None
651
+
652
+ if not complete:
653
+ expected = self._device_pending_expected_rows
654
+ seen = sorted(self._device_pending_rows.keys())
655
+ if generation is not None:
656
+ self._log.warning(
657
+ "[DEV] discarding incomplete devices snapshot request=%s expected=%s seen=%s",
658
+ generation,
659
+ expected,
660
+ seen,
661
+ )
662
+ self._reset_pending_device_snapshot()
663
+
664
+ def _commit_pending_activity_snapshot(self) -> None:
665
+ ordered_rows = sorted(self._activity_pending_rows.items())
666
+ committed: dict[int, dict[str, Any]] = {}
667
+ for _row_idx, row in ordered_rows:
668
+ act_id = int(row["id"]) & 0xFF
669
+ entry: dict[str, Any] = {
670
+ "name": row["name"],
671
+ "active": bool(row["active"]),
672
+ "needs_confirm": bool(row["needs_confirm"]),
673
+ }
674
+ # Activity records share the device-record body layout
675
+ # (just with family 0x37 on the create write). The pending
676
+ # payload is ``payload[3:]`` of the catalog-row frame --
677
+ # i.e., the full body, ready for parse_device_record.
678
+ raw_payload = self._activity_pending_payloads.get(act_id)
679
+ if isinstance(raw_payload, (bytes, bytearray)) and len(raw_payload) > 3:
680
+ entry["raw_body"] = bytes(raw_payload[3:])
681
+ committed[act_id] = entry
682
+
683
+ self.state.activities = committed
684
+ self._activity_row_payloads = dict(self._activity_pending_payloads)
685
+ self.state.set_hint(self._activity_pending_hint)
686
+ self._activities_catalog_ready = True
687
+
688
+ def _on_activities_burst_end(self, key: str) -> None:
689
+ generation = self._activity_request_inflight
690
+ complete = generation is not None and self._activity_pending_generation == generation and self._activity_snapshot_complete()
691
+
692
+ if complete:
693
+ self._commit_pending_activity_snapshot()
694
+ self._log.info(
695
+ "[ACT] committed complete activities snapshot rows=%d request=%s",
696
+ len(self._activity_pending_rows),
697
+ generation,
698
+ )
699
+ self._activity_request_inflight = None
700
+
701
+ if not complete:
702
+ expected = self._activity_pending_expected_rows
703
+ seen = sorted(self._activity_pending_rows.keys())
704
+ if generation is not None:
705
+ self._log.warning(
706
+ "[ACT] discarding incomplete activities snapshot request=%s expected=%s seen=%s",
707
+ generation,
708
+ expected,
709
+ seen,
710
+ )
711
+ self._schedule_activity_retry()
712
+ self._reset_pending_activity_snapshot()
713
+
714
+ def get_macros_for_activity(self, act_id: int, *, fetch_if_missing: bool = True) -> tuple[list[dict[str, int | str]], bool]:
715
+ act_lo = act_id & 0xFF
716
+ macros = self.state.get_activity_macros(act_lo)
717
+ ready = act_lo in self._macros_complete
718
+
719
+ if macros and ready:
720
+ return (macros, True)
721
+
722
+ if fetch_if_missing and self.can_issue_commands():
723
+ if act_lo not in self._pending_macro_requests:
724
+ self._pending_macro_requests.add(act_lo)
725
+ self.enqueue_cmd(
726
+ OP_REQ_MACRO_LABELS,
727
+ bytes([act_lo, 0xFF]),
728
+ expects_burst=True,
729
+ burst_kind=f"macros:{act_lo}",
730
+ )
731
+
732
+ return (macros, ready)
733
+
734
+ def get_single_command_for_entity(
735
+ self,
736
+ ent_id: int,
737
+ command_id: int,
738
+ *,
739
+ fetch_if_missing: bool = True,
740
+ ) -> tuple[dict[int, str], bool]:
741
+ """Fetch metadata for a single command on a device.
742
+
743
+ Returns:
744
+ (commands, ready)
745
+
746
+ commands: mapping {command_id: label} if known; may be empty.
747
+ ready: True if we have the answer (either from cache or after a completed burst),
748
+ False if we have just enqueued a targeted request and are still waiting.
749
+ """
750
+
751
+ ent_lo = ent_id & 0xFF
752
+
753
+ device_cmds = self.state.commands.get(ent_lo)
754
+ if device_cmds is not None and command_id in device_cmds:
755
+ return ({command_id: device_cmds[command_id]}, True)
756
+
757
+ if not fetch_if_missing or not self.can_issue_commands():
758
+ return ({}, False)
759
+
760
+ pending = self._pending_command_requests.setdefault(ent_lo, set())
761
+
762
+ if command_id <= 0xFF:
763
+ if command_id in pending or 0xFF in pending:
764
+ return ({}, False)
765
+ payload = bytes([ent_lo, command_id & 0xFF])
766
+ burst_kind = f"commands:{ent_lo}:{command_id}"
767
+ pending.add(command_id)
768
+ else:
769
+ if 0xFF in pending:
770
+ return ({}, False)
771
+ payload = bytes([ent_lo, 0xFF])
772
+ burst_kind = f"commands:{ent_lo}"
773
+ pending.add(0xFF)
774
+
775
+ self.enqueue_cmd(
776
+ OP_REQ_COMMANDS,
777
+ payload,
778
+ expects_burst=True,
779
+ burst_kind=burst_kind,
780
+ )
781
+
782
+ return ({}, False)
783
+
784
+ def ensure_commands_for_activity(
785
+ self,
786
+ act_id: int,
787
+ *,
788
+ fetch_if_missing: bool = True,
789
+ ) -> tuple[dict[int, dict[int, str]], bool]:
790
+ """Fetch command labels for an activity's favorite slots.
791
+
792
+ The REQ_BUTTONS response already describes physical button mappings, so
793
+ the only follow-up requests we need are for favorite commands that
794
+ require labels. If no favorites exist, nothing is fetched.
795
+ """
796
+
797
+ act_lo = act_id & 0xFF
798
+ favorites = self.state.get_activity_favorite_slots(act_lo)
799
+
800
+ if not favorites:
801
+ # If there are no favorite slots, there is nothing to resolve.
802
+ return ({}, True)
803
+
804
+ refs: set[tuple[int, int]] = {
805
+ (slot["device_id"], slot["command_id"]) for slot in favorites
806
+ }
807
+
808
+ commands_by_device: dict[int, dict[int, str]] = {}
809
+ all_ready = True
810
+
811
+ seen_pairs: set[tuple[int, int]] = set()
812
+
813
+ for dev_id, command_id in refs:
814
+ pair = (dev_id, command_id)
815
+ if pair in seen_pairs:
816
+ continue
817
+
818
+ seen_pairs.add(pair)
819
+
820
+ favorite_label = self.state.get_favorite_label(act_lo, dev_id, command_id)
821
+ if favorite_label:
822
+ self.state.record_favorite_label(act_lo, dev_id, command_id, favorite_label)
823
+ continue
824
+
825
+ device_cmds = self.state.commands.get(dev_id & 0xFF)
826
+ if device_cmds and command_id in device_cmds:
827
+ label = device_cmds[command_id]
828
+ self.state.record_favorite_label(act_lo, dev_id, command_id, label)
829
+ continue
830
+
831
+ self._favorite_label_requests[pair].add(act_id)
832
+
833
+ single_cmds, ready = self.get_single_command_for_entity(
834
+ dev_id, command_id, fetch_if_missing=fetch_if_missing
835
+ )
836
+ if not ready:
837
+ all_ready = False
838
+
839
+ if single_cmds:
840
+ dev_lo = dev_id & 0xFF
841
+ if dev_lo not in commands_by_device:
842
+ commands_by_device[dev_lo] = {}
843
+ commands_by_device[dev_lo].update(single_cmds)
844
+
845
+ label = single_cmds.get(command_id)
846
+ if label:
847
+ self.state.record_favorite_label(act_lo, dev_id, command_id, label)
848
+
849
+ if ready:
850
+ self._favorite_label_requests.pop(pair, None)
851
+
852
+ return (commands_by_device, all_ready)
853
+
854
+ def clear_entity_cache(
855
+ self,
856
+ ent_id: int,
857
+ clear_buttons: bool = False,
858
+ clear_favorites: bool = False,
859
+ clear_macros: bool = False,
860
+ ) -> None:
861
+ """Remove cached data for a given entity."""
862
+
863
+ ent_lo = ent_id & 0xFF
864
+
865
+ self.state.commands.pop(ent_lo, None)
866
+ self.state.device_key_sorts.pop(ent_lo, None)
867
+ self._commands_complete.discard(ent_lo)
868
+ self._pending_command_requests.pop(ent_lo, None)
869
+
870
+ if clear_buttons:
871
+ self.state.buttons.pop(ent_lo, None)
872
+ self.state.button_details.pop(ent_lo, None)
873
+ self._pending_button_requests.discard(ent_lo)
874
+
875
+ if clear_favorites:
876
+ self.state.activity_command_refs.pop(ent_lo, None)
877
+ self.state.activity_favorite_slots.pop(ent_lo, None)
878
+ self.state.activity_keybinding_slots.pop(ent_lo, None)
879
+ self.state.activity_members.pop(ent_lo, None)
880
+ self.state.activity_favorite_labels.pop(ent_lo, None)
881
+ self.state.activity_keybinding_labels.pop(ent_lo, None)
882
+ self._clear_favorite_label_requests_for_activity(ent_lo)
883
+ self._clear_keybinding_label_requests_for_activity(ent_lo)
884
+ self._pending_activity_map_requests.discard(ent_lo)
885
+ self._activity_map_complete.discard(ent_lo)
886
+
887
+ if clear_macros:
888
+ self.state.activity_macros.pop(ent_lo, None)
889
+ self._macros_complete.discard(ent_lo)
890
+ self._pending_macro_requests.discard(ent_lo)
891
+
892
+ def _clear_favorite_label_requests_for_activity(self, act_lo: int) -> None:
893
+ to_delete: list[tuple[int, int]] = []
894
+
895
+ for pair, act_ids in self._favorite_label_requests.items():
896
+ act_ids.discard(act_lo)
897
+ if not act_ids:
898
+ to_delete.append(pair)
899
+
900
+ for pair in to_delete:
901
+ self._favorite_label_requests.pop(pair, None)
902
+
903
+ def _clear_keybinding_label_requests_for_activity(self, act_lo: int) -> None:
904
+ to_delete: list[tuple[int, int]] = []
905
+
906
+ for pair, act_ids in self._keybinding_label_requests.items():
907
+ act_ids.discard(act_lo)
908
+ if not act_ids:
909
+ to_delete.append(pair)
910
+
911
+ for pair in to_delete:
912
+ self._keybinding_label_requests.pop(pair, None)
913
+
914
+
915
+ __all__ = ["CatalogMixin"]