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,504 @@
1
+ """Cache export/import + catalog clearing mixin for :class:`X1Proxy`.
2
+
3
+ Provides the serialisable view of the proxy's in-memory catalog (used by
4
+ the persistent cache store and the control-panel websocket feed) along
5
+ with the small family of bulk-clear methods that prepare the proxy for
6
+ a fresh hub poll. The export side deliberately strips ``raw_body`` from
7
+ device entries -- those bytes are not JSON-safe and are re-populated on
8
+ the next catalog refresh after a restart.
9
+
10
+ The mixin owns no state of its own; all reads and writes go through
11
+ ``self.state`` and the per-snapshot completion sets carried on the
12
+ ``X1Proxy`` instance.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import time
18
+ from typing import Any
19
+
20
+ from .protocol_const import OP_ERASE_CONFIGURATION
21
+ from .state_helpers import normalize_device_entry
22
+
23
+
24
+ class CacheBackupMixin:
25
+ """Mixin providing cache export/import and catalog clearing."""
26
+
27
+ def export_cache_state(self) -> dict[str, Any]:
28
+ # ``raw_body`` is the original device record bytes kept in memory for
29
+ # on-demand schema parsing (e.g. by the backup flow). It is excluded
30
+ # from exports because:
31
+ # 1) it is not JSON-serializable (bytes), and the export feeds both
32
+ # the persistent cache and the control-panel WS payload;
33
+ # 2) it would not survive a JSON round-trip anyway, and is
34
+ # re-populated on the next catalog refresh after a restart.
35
+ def _device_for_export(v: dict[str, Any]) -> dict[str, Any]:
36
+ entry = dict(v)
37
+ entry.pop("raw_body", None)
38
+ return entry
39
+
40
+ return {
41
+ "banner_info": self.get_banner_info(),
42
+ "devices": {str(k): _device_for_export(v) for k, v in self.state.entities("device").items()},
43
+ "buttons": {str(k): sorted(v) for k, v in self.state.buttons.items()},
44
+ "commands": {
45
+ str(k): {str(cmd_id): label for cmd_id, label in commands.items()}
46
+ for k, commands in self.state.commands.items()
47
+ },
48
+ "device_key_sorts": {
49
+ str(k): dict(v) for k, v in self.state.device_key_sorts.items()
50
+ },
51
+ "ip_devices": {str(k): dict(v) for k, v in self.state.ip_devices.items()},
52
+ "ip_buttons": {
53
+ str(k): {str(btn_id): dict(meta) for btn_id, meta in buttons.items()}
54
+ for k, buttons in self.state.ip_buttons.items()
55
+ },
56
+ "activity_macros": {
57
+ str(k): list(macros) for k, macros in self.state.activity_macros.items()
58
+ },
59
+ "activity_command_refs": {
60
+ str(k): [[dev_id, command_id] for dev_id, command_id in sorted(refs)]
61
+ for k, refs in self.state.activity_command_refs.items()
62
+ },
63
+ "activity_favorite_slots": {
64
+ str(k): [dict(slot) for slot in slots]
65
+ for k, slots in self.state.activity_favorite_slots.items()
66
+ },
67
+ "activity_keybinding_slots": {
68
+ str(k): [dict(slot) for slot in slots]
69
+ for k, slots in self.state.activity_keybinding_slots.items()
70
+ },
71
+ "activity_members": {
72
+ str(k): sorted(members)
73
+ for k, members in self.state.activity_members.items()
74
+ },
75
+ "activity_favorite_labels": {
76
+ str(k): [
77
+ {
78
+ "device_id": dev_id,
79
+ "command_id": command_id,
80
+ "label": label,
81
+ }
82
+ for (dev_id, command_id), label in labels.items()
83
+ ]
84
+ for k, labels in self.state.activity_favorite_labels.items()
85
+ },
86
+ "activity_keybinding_labels": {
87
+ str(k): [
88
+ {
89
+ "device_id": dev_id,
90
+ "command_id": command_id,
91
+ "label": label,
92
+ }
93
+ for (dev_id, command_id), label in labels.items()
94
+ ]
95
+ for k, labels in self.state.activity_keybinding_labels.items()
96
+ },
97
+ }
98
+
99
+ def import_cache_state(self, payload: dict[str, Any]) -> None:
100
+ from .x1_proxy import _normalize_banner_model
101
+
102
+ data = payload if isinstance(payload, dict) else {}
103
+
104
+ banner_info = data.get("banner_info", {})
105
+ if isinstance(banner_info, dict):
106
+ sanitized: dict[str, Any] = {}
107
+ model = _normalize_banner_model(banner_info.get("model"))
108
+ if model:
109
+ sanitized["model"] = model
110
+ batch = str(banner_info.get("production_batch", "")).strip()
111
+ if batch:
112
+ sanitized["production_batch"] = batch
113
+ firmware_version = banner_info.get("firmware_version")
114
+ if isinstance(firmware_version, (int, float)):
115
+ sanitized["firmware_version"] = int(firmware_version)
116
+ name = str(banner_info.get("name", "")).strip()
117
+ if name:
118
+ sanitized["name"] = name
119
+ with self._banner_info_lock:
120
+ self._banner_info = sanitized
121
+ if sanitized.get("model"):
122
+ self.hub_version = str(sanitized["model"])
123
+ self._banner_info_event.set()
124
+
125
+ has_devices_catalog = "devices" in data
126
+ devices = data.get("devices", {})
127
+ self.state.devices = {
128
+ int(k) & 0xFF: normalize_device_entry(v)
129
+ for k, v in devices.items()
130
+ if isinstance(v, dict)
131
+ }
132
+ self._devices_catalog_ready = has_devices_catalog and isinstance(devices, dict)
133
+
134
+ buttons = data.get("buttons", {})
135
+ self.state.buttons = {
136
+ int(k) & 0xFF: {int(btn) & 0xFF for btn in v}
137
+ for k, v in buttons.items()
138
+ if isinstance(v, list)
139
+ }
140
+
141
+ self.state.commands.clear()
142
+ commands = data.get("commands", {})
143
+ for key, entity_commands in commands.items():
144
+ if not isinstance(entity_commands, dict):
145
+ continue
146
+ ent_id = int(key) & 0xFF
147
+ self.state.commands[ent_id] = {
148
+ int(cmd_id) & 0xFF: str(label)
149
+ for cmd_id, label in entity_commands.items()
150
+ }
151
+
152
+ self.state.device_key_sorts.clear()
153
+ device_key_sorts = data.get("device_key_sorts", {})
154
+ for key, sort_meta in device_key_sorts.items():
155
+ if isinstance(sort_meta, dict):
156
+ self.state.device_key_sorts[int(key) & 0xFF] = dict(sort_meta)
157
+
158
+ ip_devices = data.get("ip_devices", {})
159
+ self.state.ip_devices = {
160
+ int(k) & 0xFF: dict(v) for k, v in ip_devices.items() if isinstance(v, dict)
161
+ }
162
+
163
+ self.state.ip_buttons.clear()
164
+ ip_buttons = data.get("ip_buttons", {})
165
+ for key, button_map in ip_buttons.items():
166
+ if not isinstance(button_map, dict):
167
+ continue
168
+ ent_id = int(key) & 0xFF
169
+ self.state.ip_buttons[ent_id] = {
170
+ int(btn_id) & 0xFF: dict(meta)
171
+ for btn_id, meta in button_map.items()
172
+ if isinstance(meta, dict)
173
+ }
174
+
175
+ activity_macros = data.get("activity_macros", {})
176
+ self.state.activity_macros = {
177
+ int(k) & 0xFF: list(v)
178
+ for k, v in activity_macros.items()
179
+ if isinstance(v, list)
180
+ }
181
+
182
+ self.state.activity_command_refs.clear()
183
+ activity_command_refs = data.get("activity_command_refs", {})
184
+ for key, refs in activity_command_refs.items():
185
+ if not isinstance(refs, list):
186
+ continue
187
+ act_lo = int(key) & 0xFF
188
+ parsed_refs: set[tuple[int, int]] = set()
189
+ for item in refs:
190
+ if isinstance(item, (list, tuple)) and len(item) == 2:
191
+ parsed_refs.add((int(item[0]) & 0xFF, int(item[1]) & 0xFF))
192
+ if parsed_refs:
193
+ self.state.activity_command_refs[act_lo] = parsed_refs
194
+
195
+ self.state.activity_favorite_slots.clear()
196
+ activity_favorite_slots = data.get("activity_favorite_slots", {})
197
+ for key, slots in activity_favorite_slots.items():
198
+ if not isinstance(slots, list):
199
+ continue
200
+ act_lo = int(key) & 0xFF
201
+ normalized_slots: list[dict[str, int]] = []
202
+ for slot in slots:
203
+ if not isinstance(slot, dict):
204
+ continue
205
+ normalized_slots.append(
206
+ {
207
+ "button_id": int(slot.get("button_id", 0)) & 0xFF,
208
+ "device_id": int(slot.get("device_id", 0)) & 0xFF,
209
+ "command_id": int(slot.get("command_id", 0)) & 0xFF,
210
+ "source": str(slot.get("source", "cache")),
211
+ }
212
+ )
213
+ if normalized_slots:
214
+ self.state.activity_favorite_slots[act_lo] = normalized_slots
215
+
216
+ self.state.activity_keybinding_slots.clear()
217
+ activity_keybinding_slots = data.get("activity_keybinding_slots", {})
218
+ for key, slots in activity_keybinding_slots.items():
219
+ if not isinstance(slots, list):
220
+ continue
221
+ act_lo = int(key) & 0xFF
222
+ normalized_slots: list[dict[str, int]] = []
223
+ for slot in slots:
224
+ if not isinstance(slot, dict):
225
+ continue
226
+ normalized_slots.append(
227
+ {
228
+ "button_id": int(slot.get("button_id", 0)) & 0xFF,
229
+ "device_id": int(slot.get("device_id", 0)) & 0xFF,
230
+ "command_id": int(slot.get("command_id", 0)) & 0xFF,
231
+ "source": str(slot.get("source", "cache")),
232
+ }
233
+ )
234
+ if normalized_slots:
235
+ self.state.activity_keybinding_slots[act_lo] = normalized_slots
236
+
237
+ self.state.activity_members.clear()
238
+ activity_members = data.get("activity_members", {})
239
+ for key, members in activity_members.items():
240
+ if isinstance(members, list):
241
+ self.state.activity_members[int(key) & 0xFF] = {int(member) & 0xFF for member in members}
242
+
243
+ self.state.activity_favorite_labels.clear()
244
+ activity_favorite_labels = data.get("activity_favorite_labels", {})
245
+ for key, labels in activity_favorite_labels.items():
246
+ if not isinstance(labels, list):
247
+ continue
248
+ act_lo = int(key) & 0xFF
249
+ parsed_labels: dict[tuple[int, int], str] = {}
250
+ for row in labels:
251
+ if not isinstance(row, dict):
252
+ continue
253
+ dev_id = int(row.get("device_id", 0)) & 0xFF
254
+ command_id = int(row.get("command_id", 0)) & 0xFF
255
+ label = str(row.get("label", "")).strip()
256
+ if dev_id and command_id and label:
257
+ parsed_labels[(dev_id, command_id)] = label
258
+ if parsed_labels:
259
+ self.state.activity_favorite_labels[act_lo] = parsed_labels
260
+
261
+ self.state.activity_keybinding_labels.clear()
262
+ activity_keybinding_labels = data.get("activity_keybinding_labels", {})
263
+ for key, labels in activity_keybinding_labels.items():
264
+ if not isinstance(labels, list):
265
+ continue
266
+ act_lo = int(key) & 0xFF
267
+ parsed_labels: dict[tuple[int, int], str] = {}
268
+ for row in labels:
269
+ if not isinstance(row, dict):
270
+ continue
271
+ dev_id = int(row.get("device_id", 0)) & 0xFF
272
+ command_id = int(row.get("command_id", 0)) & 0xFF
273
+ label = str(row.get("label", "")).strip()
274
+ if dev_id and command_id and label:
275
+ parsed_labels[(dev_id, command_id)] = label
276
+ if parsed_labels:
277
+ self.state.activity_keybinding_labels[act_lo] = parsed_labels
278
+
279
+ has_activities_catalog = "activities" in data
280
+ activities = data.get("activities", {})
281
+ if has_activities_catalog and isinstance(activities, dict):
282
+ self.state.activities = {
283
+ int(k) & 0xFF: dict(v)
284
+ for k, v in activities.items()
285
+ if isinstance(v, dict)
286
+ }
287
+ self._activities_catalog_ready = True
288
+ else:
289
+ self._activities_catalog_ready = False
290
+
291
+ self._commands_complete = set(self.state.commands.keys())
292
+ self._macros_complete = set(self.state.activity_macros.keys())
293
+
294
+ self._activity_map_complete = {
295
+ act_lo
296
+ for act_lo in set(self.state.activity_favorite_slots.keys())
297
+ | set(self.state.activity_members.keys())
298
+ | set(self.state.activity_command_refs.keys())
299
+ }
300
+
301
+ self._pending_button_requests.clear()
302
+ self._pending_command_requests.clear()
303
+ self._pending_macro_requests.clear()
304
+ self._pending_activity_map_requests.clear()
305
+
306
+ def clear_cached_entity_detail(self, ent_id: int, *, kind: str) -> None:
307
+ ent_lo = ent_id & 0xFF
308
+ if kind == "device":
309
+ self.state.devices.pop(ent_lo, None)
310
+ self.state.buttons.pop(ent_lo, None)
311
+ self.state.commands.pop(ent_lo, None)
312
+ self.state.device_key_sorts.pop(ent_lo, None)
313
+ self.state.ip_devices.pop(ent_lo, None)
314
+ self.state.ip_buttons.pop(ent_lo, None)
315
+ self._commands_complete.discard(ent_lo)
316
+ return
317
+
318
+ if kind == "activity":
319
+ self.state.activity_macros.pop(ent_lo, None)
320
+ self.state.activity_members.pop(ent_lo, None)
321
+ self.state.activity_favorite_slots.pop(ent_lo, None)
322
+ self.state.activity_keybinding_slots.pop(ent_lo, None)
323
+ self.state.activity_favorite_labels.pop(ent_lo, None)
324
+ self.state.activity_keybinding_labels.pop(ent_lo, None)
325
+ self.state.activity_command_refs.pop(ent_lo, None)
326
+ self._macros_complete.discard(ent_lo)
327
+
328
+ def get_known_device_ids(self) -> set[int]:
329
+ """Return the set of device IDs currently known from the catalog."""
330
+ return set(self.state.entities("device").keys()) | set(self.state.ip_devices.keys())
331
+
332
+ def get_known_activity_ids(self) -> set[int]:
333
+ """Return the set of activity IDs currently known from the catalog."""
334
+ return set(self.state.entities("activity").keys())
335
+
336
+ def get_cached_activity_detail_ids(self) -> set[int]:
337
+ """Return activity IDs referenced by per-activity cached detail tables."""
338
+
339
+ return (
340
+ set(self.state.activity_macros.keys())
341
+ | set(self.state.activity_members.keys())
342
+ | set(self.state.activity_favorite_slots.keys())
343
+ | set(self.state.activity_keybinding_slots.keys())
344
+ | set(self.state.activity_favorite_labels.keys())
345
+ | set(self.state.activity_keybinding_labels.keys())
346
+ | set(self.state.activity_command_refs.keys())
347
+ )
348
+
349
+ def clear_devices_catalog(self) -> None:
350
+ """Clear only the device name catalog before a fresh device list fetch.
351
+
352
+ Deliberately does NOT clear per-device commands or ip_buttons — those are
353
+ preserved for devices that still exist and pruned separately (via
354
+ clear_cached_entity_detail) for devices that were removed.
355
+ """
356
+ self.state.devices.clear()
357
+ self.state.ip_devices.clear()
358
+ self._devices_catalog_ready = False
359
+
360
+ def clear_activities_catalog(self) -> None:
361
+ """Clear only the activity name catalog before a fresh activity list fetch.
362
+
363
+ Deliberately does NOT clear per-activity keymaps, favorites, keybindings,
364
+ or macros — those are not returned by OP_REQ_ACTIVITIES and would not be
365
+ repopulated by the burst. Per-activity detail data for removed activities
366
+ is pruned separately via clear_cached_entity_detail.
367
+ """
368
+ self.state.activities.clear()
369
+ self._activity_row_payloads.clear()
370
+ self.state.set_hint(None)
371
+ self._activities_catalog_ready = False
372
+
373
+ def wipe_all_cached_state(self) -> None:
374
+ """Drop every per-entity cache so a fresh catalog poll is required.
375
+
376
+ Called by :meth:`erase_configuration` after the hub confirms it
377
+ has wiped its persistent tables. Everything keyed by device or
378
+ activity id is no longer valid; the next catalog request must
379
+ start from zero. The banner / hub-identity state is preserved
380
+ (the hub model didn't change) along with the proxy's transport
381
+ and listener wiring.
382
+ """
383
+
384
+ # Top-level name catalogs (parallel to clear_devices_catalog +
385
+ # clear_activities_catalog).
386
+ self.clear_devices_catalog()
387
+ self.clear_activities_catalog()
388
+
389
+ # Per-device detail surfaces.
390
+ self.state.commands.clear()
391
+ self.state.device_key_sorts.clear()
392
+ self.state.buttons.clear()
393
+ if hasattr(self.state, "button_details"):
394
+ self.state.button_details.clear()
395
+ if hasattr(self.state, "command_metadata"):
396
+ self.state.command_metadata.clear()
397
+ self.state.ip_buttons.clear()
398
+ self.state.ip_devices.clear()
399
+
400
+ # Per-activity detail surfaces.
401
+ self.state.activity_macros.clear()
402
+ self.state.activity_members.clear()
403
+ self.state.activity_favorite_slots.clear()
404
+ self.state.activity_keybinding_slots.clear()
405
+ self.state.activity_favorite_labels.clear()
406
+ self.state.activity_keybinding_labels.clear()
407
+ self.state.activity_command_refs.clear()
408
+
409
+ # Completion / pending sets.
410
+ self._commands_complete.clear()
411
+ self._macros_complete.clear()
412
+ self._activity_map_complete.clear()
413
+ self._pending_button_requests.clear()
414
+ self._pending_command_requests.clear()
415
+ self._pending_macro_requests.clear()
416
+ self._pending_activity_map_requests.clear()
417
+
418
+
419
+ def erase_configuration(
420
+ self,
421
+ *,
422
+ timeout: float = 120.0,
423
+ settle_seconds: float = 2.0,
424
+ ) -> bool:
425
+ """Wipe the hub's user-visible configuration tables (opcode ``0x001D``).
426
+
427
+ Sends the empty-payload erase frame, waits up to ``timeout``
428
+ seconds for any first response from the hub, then clears the
429
+ proxy's catalog mirrors and sleeps ``settle_seconds`` before
430
+ returning so callers can immediately issue follow-up requests.
431
+
432
+ Returns ``True`` on success, ``False`` when the hub disconnects
433
+ before any response arrives or when no response arrives within
434
+ ``timeout``.
435
+
436
+ The hub commonly drops the session after the ack. A
437
+ ``False`` return that's actually caused by a hub-initiated
438
+ disconnect *after* an ack would be a misreport; the wait loop
439
+ therefore requires the ack first and treats any later
440
+ disconnect as the expected post-erase behaviour. See
441
+ ``docs/protocol/erase.md`` for the wire layout.
442
+ """
443
+
444
+ if not self.can_issue_commands():
445
+ self._log.info(
446
+ "[ERASE] erase_configuration ignored: proxy client is connected"
447
+ )
448
+ return False
449
+
450
+ self.clear_ack_queue()
451
+ send_ts = time.monotonic()
452
+ self._log.info(
453
+ "[ERASE] sending opcode 0x%04X (timeout=%.0fs)",
454
+ OP_ERASE_CONFIGURATION,
455
+ timeout,
456
+ )
457
+ self._send_cmd_frame(OP_ERASE_CONFIGURATION, b"")
458
+
459
+ def _disconnected() -> bool:
460
+ # ``_hub_connected`` is updated by the transport bridge as
461
+ # frames arrive / connections drop. A drop arriving before
462
+ # any ack means the hub didn't even acknowledge the erase
463
+ # request; treat as failure.
464
+ return not getattr(self, "_hub_connected", True)
465
+
466
+ result = self.wait_for_any_response(
467
+ timeout=timeout,
468
+ not_before=send_ts,
469
+ disconnect_check=_disconnected,
470
+ )
471
+ if result is None:
472
+ if _disconnected():
473
+ self._log.warning(
474
+ "[ERASE] hub disconnected before any ack -- treating as failure"
475
+ )
476
+ else:
477
+ self._log.warning(
478
+ "[ERASE] no response within %.0fs -- treating as failure",
479
+ timeout,
480
+ )
481
+ return False
482
+
483
+ ack_opcode, ack_payload = result
484
+ self._log.info(
485
+ "[ERASE] hub answered opcode=0x%04X payload_len=%d -- wiping local caches",
486
+ ack_opcode,
487
+ len(ack_payload),
488
+ )
489
+
490
+ # The persistent tables on the hub are now empty; everything
491
+ # we have cached locally is stale.
492
+ self.wipe_all_cached_state()
493
+
494
+ # The hub commonly cycles the session after the ack and needs
495
+ # a moment before answering anything new. A brief sleep here
496
+ # lets callers (e.g. the bundle-restore orchestrator) issue
497
+ # follow-up requests immediately without retry loops.
498
+ if settle_seconds > 0:
499
+ time.sleep(settle_seconds)
500
+
501
+ return True
502
+
503
+
504
+ __all__ = ["CacheBackupMixin"]