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,486 @@
1
+ # proxy_backup_export.py — synchronous backup-export orchestration.
2
+ #
3
+ # BackupExportMixin gives X1Proxy the export half of backup/restore: it
4
+ # refreshes catalogs, fetches per-entity detail (commands, buttons,
5
+ # macros, blobs, inputs, key-sort) using the proxy's own blocking
6
+ # primitives, then hands the gathered state to the pure assemblers in
7
+ # backup_export.py. It is the mirror image of RestoreMixin.
8
+ #
9
+ # All waits poll the proxy's OWN completion state (``_commands_complete``,
10
+ # ``_macros_complete``, ``_devices_catalog_ready``) and/or the proxy's
11
+ # burst-end signal — never any Home Assistant bookkeeping — so the same
12
+ # orchestration runs in-tree and standalone. The integration calls these
13
+ # sync methods through an executor, exactly as it calls restore.
14
+ from __future__ import annotations
15
+
16
+ import threading
17
+ import time
18
+ from typing import Any, Callable, Optional
19
+
20
+ from . import backup_export as _bx
21
+ from .devices import DeviceConfig, parse_device_record
22
+ from .protocol_const import DEVICE_CLASS_IR, normalize_device_class
23
+
24
+
25
+ class _SyncBurstWaiter:
26
+ """Hands out one-shot Events keyed by burst key (e.g. ``commands:7``).
27
+
28
+ One persistent dispatcher is registered per burst-kind on first use,
29
+ so repeated waits don't leak listeners onto the scheduler.
30
+ """
31
+
32
+ def __init__(self, proxy: "BackupExportMixin") -> None:
33
+ self._proxy = proxy
34
+ self._waiters: dict[str, list[threading.Event]] = {}
35
+ self._kinds: set[str] = set()
36
+ self._lock = threading.Lock()
37
+
38
+ def arm(self, key: str) -> threading.Event:
39
+ kind = key.split(":", 1)[0]
40
+ event = threading.Event()
41
+ with self._lock:
42
+ self._waiters.setdefault(key, []).append(event)
43
+ new_kind = kind not in self._kinds
44
+ if new_kind:
45
+ self._kinds.add(kind)
46
+ if new_kind:
47
+ # Registered outside the lock; on_burst_end is itself locked.
48
+ self._proxy.on_burst_end(kind, self._dispatch)
49
+ return event
50
+
51
+ def _dispatch(self, full_key: str) -> None:
52
+ with self._lock:
53
+ events = self._waiters.pop(full_key, [])
54
+ for event in events:
55
+ event.set()
56
+
57
+
58
+ class BackupExportMixin:
59
+ """Synchronous backup-export operations on :class:`X1Proxy`."""
60
+
61
+ # ------------------------------------------------------------------
62
+ # sync fetch/wait plumbing
63
+ # ------------------------------------------------------------------
64
+
65
+ @property
66
+ def _backup_burst_waiter(self) -> _SyncBurstWaiter:
67
+ waiter = getattr(self, "_backup_burst_waiter_obj", None)
68
+ if waiter is None:
69
+ waiter = _SyncBurstWaiter(self)
70
+ self._backup_burst_waiter_obj = waiter
71
+ return waiter
72
+
73
+ def _fetch_and_wait(
74
+ self,
75
+ burst_key: str,
76
+ kick: Callable[[], Any],
77
+ ready_check: Callable[[], bool],
78
+ *,
79
+ timeout: float,
80
+ ) -> bool:
81
+ """Kick a fetch and block until its burst lands (or ``timeout``).
82
+
83
+ Returns True when ``ready_check`` passes or the matching burst
84
+ fires; arms the burst waiter before kicking so the signal can't
85
+ be missed.
86
+ """
87
+
88
+ if ready_check():
89
+ return True
90
+ # Backup can only fetch while the proxy owns the hub (no app
91
+ # client connected). If it can't issue, there's nothing to wait
92
+ # for: report whatever is already cached rather than blocking.
93
+ if not self.can_issue_commands():
94
+ return ready_check()
95
+ event = self._backup_burst_waiter.arm(burst_key)
96
+ kick()
97
+ if ready_check():
98
+ return True
99
+ deadline = time.monotonic() + timeout
100
+ while True:
101
+ remaining = deadline - time.monotonic()
102
+ if remaining <= 0:
103
+ return ready_check()
104
+ if event.wait(min(remaining, 0.2)) or ready_check():
105
+ return True
106
+
107
+ def _refresh_catalog(self, kind: str, *, timeout: float) -> None:
108
+ """Request a fresh devices/activities burst and wait for it.
109
+
110
+ No-op when the proxy can't issue commands (no hub to refresh
111
+ from): callers fall back to the currently-cached catalog.
112
+ """
113
+
114
+ if not self.can_issue_commands():
115
+ return
116
+ event = self._backup_burst_waiter.arm(kind)
117
+ if kind == "devices":
118
+ self.request_devices()
119
+ else:
120
+ self.request_activities()
121
+ event.wait(timeout)
122
+
123
+ def _resolve_device_class(self, device_id: int) -> str | None:
124
+ dev_lo = device_id & 0xFF
125
+ for source in (self.state.entities("device"), self.state.ip_devices):
126
+ if not isinstance(source, dict):
127
+ continue
128
+ cached = source.get(dev_lo)
129
+ if isinstance(cached, dict):
130
+ device_class = str(cached.get("device_class") or "").strip()
131
+ if device_class:
132
+ return device_class
133
+ return None
134
+
135
+ @staticmethod
136
+ def _parse_config(raw_body: Any, *, hub_version: str) -> Optional[DeviceConfig]:
137
+ if isinstance(raw_body, (bytes, bytearray)) and raw_body:
138
+ try:
139
+ return parse_device_record(bytes(raw_body), hub_version=hub_version)
140
+ except ValueError:
141
+ return None
142
+ return None
143
+
144
+ # ------------------------------------------------------------------
145
+ # device backup
146
+ # ------------------------------------------------------------------
147
+
148
+ def backup_device(
149
+ self,
150
+ device_id: int,
151
+ *,
152
+ wait_timeout: float = 10.0,
153
+ ) -> dict[str, Any] | None:
154
+ """Build a restore-oriented ``device_backup`` payload from the hub.
155
+
156
+ Returns ``None`` when the device is unknown. Captures only what a
157
+ restore needs (schema, command table, keymap, macros, IR blobs);
158
+ runtime state is deliberately excluded.
159
+ """
160
+
161
+ dev_lo = device_id & 0xFF
162
+ device_snapshot = self._refresh_devices_snapshot(timeout=max(wait_timeout, 5.0))
163
+ device_meta = dict(device_snapshot.get(dev_lo) or {})
164
+ if not device_meta:
165
+ return None
166
+
167
+ device_config = self._parse_config(
168
+ device_meta.get("raw_body"), hub_version=self.hub_version
169
+ )
170
+ skip_macros = device_config is not None and not device_config.is_power_configured
171
+ skip_inputs = device_config is not None and not device_config.is_input_configured
172
+
173
+ self.clear_entity_cache(dev_lo, True, True, True)
174
+
175
+ commands_ready = self._fetch_and_wait(
176
+ f"commands:{dev_lo}",
177
+ lambda: self.get_commands_for_entity(dev_lo, fetch_if_missing=True),
178
+ lambda: dev_lo in self._commands_complete,
179
+ timeout=wait_timeout,
180
+ )
181
+ final_buttons_ready = self._fetch_and_wait(
182
+ f"buttons:{dev_lo}",
183
+ lambda: self.get_buttons_for_entity(dev_lo, fetch_if_missing=True),
184
+ lambda: dev_lo in self.state.buttons,
185
+ timeout=wait_timeout,
186
+ )
187
+ if skip_macros:
188
+ final_macros_ready = True
189
+ else:
190
+ final_macros_ready = self._fetch_and_wait(
191
+ f"macros:{dev_lo}",
192
+ lambda: self.get_macros_for_activity(dev_lo, fetch_if_missing=True),
193
+ lambda: dev_lo in self._macros_complete,
194
+ timeout=wait_timeout,
195
+ )
196
+
197
+ command_labels, _ = self.get_commands_for_entity(dev_lo, fetch_if_missing=False)
198
+ button_codes, _ = self.get_buttons_for_entity(dev_lo, fetch_if_missing=False)
199
+
200
+ normalized_device_class = normalize_device_class(
201
+ device_meta.get("device_class", device_meta.get("device_class_code"))
202
+ )
203
+ raw_dump_class = _bx.uses_raw_command_dump(normalized_device_class)
204
+
205
+ dump = self.request_ir_command_dump(
206
+ dev_lo, command_id=None, timeout=max(wait_timeout, 15.0)
207
+ )
208
+ if raw_dump_class:
209
+ blob_source = dump
210
+ else:
211
+ blob_source = _bx.normalize_dump_to_blobs(
212
+ dump,
213
+ resolve_device_class=self._resolve_device_class,
214
+ fallback_device_id=dev_lo,
215
+ )
216
+
217
+ if skip_inputs:
218
+ input_record: dict[str, Any] | None = None
219
+ input_entries: list[Any] | None = []
220
+ else:
221
+ input_record = self.fetch_device_input_record(dev_lo, timeout=wait_timeout)
222
+ input_entries = (
223
+ list(input_record.get("entries") or [])
224
+ if isinstance(input_record, dict)
225
+ else []
226
+ )
227
+ key_sort_row = self.fetch_device_key_sort(dev_lo, timeout=wait_timeout)
228
+
229
+ label_map = {
230
+ int(command_id) & 0xFF: str(label)
231
+ for command_id, label in dict(command_labels).items()
232
+ }
233
+ blob_by_command: dict[int, dict[str, Any]] = {}
234
+ blobs_complete = False
235
+ if isinstance(blob_source, dict):
236
+ blobs_complete = bool(blob_source.get("complete"))
237
+ for command in blob_source.get("commands", []):
238
+ if not isinstance(command, dict):
239
+ continue
240
+ command_id = int(command.get("command_id", 0)) & 0xFF
241
+ if command_id:
242
+ blob_by_command[command_id] = dict(command)
243
+
244
+ command_metadata = (
245
+ self.state.command_metadata.get(dev_lo, {})
246
+ if hasattr(self.state, "command_metadata")
247
+ else {}
248
+ )
249
+
250
+ command_rows = _bx.build_device_command_rows(
251
+ label_map=label_map,
252
+ blob_by_command=blob_by_command,
253
+ normalized_device_class=normalized_device_class,
254
+ command_metadata=command_metadata,
255
+ raw_dump_class=raw_dump_class,
256
+ )
257
+ button_rows = _bx.build_device_button_rows(
258
+ button_codes=list(button_codes),
259
+ button_details=self.state.button_details.get(dev_lo, {}),
260
+ label_map=label_map,
261
+ )
262
+ macro_rows = _bx.build_device_macro_rows(self.get_cached_macro_records(dev_lo))
263
+ device_block = _bx.build_device_block(dev_lo, device_meta, device_config)
264
+
265
+ complete = all(
266
+ [
267
+ bool(device_block),
268
+ commands_ready,
269
+ final_buttons_ready,
270
+ final_macros_ready,
271
+ input_entries is not None,
272
+ blobs_complete,
273
+ key_sort_row is not None,
274
+ ]
275
+ )
276
+
277
+ return _bx.assemble_device_backup(
278
+ device_block=device_block,
279
+ command_rows=command_rows,
280
+ button_rows=button_rows,
281
+ macro_rows=macro_rows,
282
+ key_sort_row=key_sort_row,
283
+ input_record=input_record,
284
+ complete=complete,
285
+ )
286
+
287
+ # ------------------------------------------------------------------
288
+ # activity backup
289
+ # ------------------------------------------------------------------
290
+
291
+ def backup_activity(
292
+ self,
293
+ activity_id: int,
294
+ *,
295
+ wait_timeout: float = 10.0,
296
+ ) -> dict[str, Any] | None:
297
+ """Build a restore-oriented ``activity_backup`` payload from the hub."""
298
+
299
+ act_lo = activity_id & 0xFF
300
+ self._refresh_catalog("activities", timeout=max(wait_timeout, 5.0))
301
+
302
+ activity_meta = dict(self.state.entities("activity").get(act_lo) or {})
303
+ if not activity_meta:
304
+ return None
305
+
306
+ activity_config = self._parse_config(
307
+ activity_meta.get("raw_body"), hub_version=self.hub_version
308
+ )
309
+
310
+ self.clear_entity_cache(act_lo, True, True, True)
311
+
312
+ final_buttons_ready = self._fetch_and_wait(
313
+ f"buttons:{act_lo}",
314
+ lambda: self.get_buttons_for_entity(act_lo, fetch_if_missing=True),
315
+ lambda: act_lo in self.state.buttons,
316
+ timeout=wait_timeout,
317
+ )
318
+ final_macros_ready = self._fetch_and_wait(
319
+ f"macros:{act_lo}",
320
+ lambda: self.get_macros_for_activity(act_lo, fetch_if_missing=True),
321
+ lambda: act_lo in self._macros_complete,
322
+ timeout=wait_timeout,
323
+ )
324
+
325
+ button_codes, _ = self.get_buttons_for_entity(act_lo, fetch_if_missing=False)
326
+
327
+ button_rows, referenced = _bx.build_activity_button_rows(
328
+ button_codes=list(button_codes),
329
+ button_details=self.state.button_details.get(act_lo, {}),
330
+ )
331
+ macro_rows, macro_refs = _bx.build_activity_macro_rows(
332
+ self.get_cached_macro_records(act_lo)
333
+ )
334
+ referenced |= macro_refs
335
+ favorite_rows, fav_refs = _bx.build_activity_favorite_rows(
336
+ self.state.get_activity_favorite_slots(act_lo)
337
+ )
338
+ referenced |= fav_refs
339
+
340
+ activity_block = _bx.build_device_block(act_lo, activity_meta, activity_config)
341
+
342
+ complete = all([bool(activity_block), final_buttons_ready, final_macros_ready])
343
+
344
+ return _bx.assemble_activity_backup(
345
+ activity_block=activity_block,
346
+ button_rows=button_rows,
347
+ favorite_rows=favorite_rows,
348
+ macro_rows=macro_rows,
349
+ referenced_source_device_ids=referenced,
350
+ complete=complete,
351
+ )
352
+
353
+ # ------------------------------------------------------------------
354
+ # hub bundle
355
+ # ------------------------------------------------------------------
356
+
357
+ def backup_hub_bundle(
358
+ self,
359
+ *,
360
+ device_ids: list[int] | None = None,
361
+ hub_info: dict[str, Any] | None = None,
362
+ wait_timeout: float = 10.0,
363
+ progress: Callable[..., None] | None = None,
364
+ ) -> dict[str, Any]:
365
+ """Build a ``hub_bundle`` covering the requested scope.
366
+
367
+ ``device_ids=None`` backs up every device and activity; a list
368
+ restricts to those devices (no activities). ``progress`` receives
369
+ the same status dicts the integration surfaces. ``hub_info``
370
+ overrides the bundle's informational ``hub`` block.
371
+ """
372
+
373
+ def _progress(**payload: Any) -> None:
374
+ if callable(progress):
375
+ progress(**payload)
376
+
377
+ if device_ids is None:
378
+ _progress(
379
+ status="running",
380
+ phase="preparing",
381
+ message="Refreshing devices and activities from the hub…",
382
+ completed_steps=0,
383
+ total_steps=0,
384
+ )
385
+ self._refresh_catalog("devices", timeout=max(wait_timeout, 5.0))
386
+ self._refresh_catalog("activities", timeout=max(wait_timeout, 5.0))
387
+ selected_device_ids = sorted(self.get_known_device_ids())
388
+ selected_activity_ids = sorted(self.get_known_activity_ids())
389
+ else:
390
+ normalized: list[int] = []
391
+ for raw in device_ids:
392
+ value = int(raw)
393
+ if value < 1 or value > 255:
394
+ raise ValueError(
395
+ f"backup_hub_bundle device_ids entries must be in 1..255 (got {raw!r})"
396
+ )
397
+ if value not in normalized:
398
+ normalized.append(value)
399
+ if not normalized:
400
+ raise ValueError(
401
+ "backup_hub_bundle device_ids must contain at least one device id "
402
+ "or be omitted entirely (to back up the whole hub)"
403
+ )
404
+ selected_device_ids = normalized
405
+ selected_activity_ids = []
406
+
407
+ total_steps = len(selected_device_ids) + len(selected_activity_ids) + 1
408
+ completed_steps = 0
409
+
410
+ device_payloads: list[dict[str, Any]] = []
411
+ for dev_id in selected_device_ids:
412
+ _progress(
413
+ status="running",
414
+ phase="device",
415
+ message=f"Backing up device {dev_id}…",
416
+ completed_steps=completed_steps,
417
+ total_steps=total_steps,
418
+ current_device_id=dev_id,
419
+ )
420
+ payload = self.backup_device(dev_id, wait_timeout=wait_timeout)
421
+ if payload is None:
422
+ raise ValueError(f"Hub did not return device data for device {dev_id}")
423
+ device_payloads.append(payload)
424
+ completed_steps += 1
425
+ _progress(
426
+ status="running",
427
+ phase="device",
428
+ message=f"Backed up device {dev_id}.",
429
+ completed_steps=completed_steps,
430
+ total_steps=total_steps,
431
+ current_device_id=dev_id,
432
+ )
433
+
434
+ activity_payloads: list[dict[str, Any]] = []
435
+ for act_id in selected_activity_ids:
436
+ _progress(
437
+ status="running",
438
+ phase="activity",
439
+ message=f"Backing up activity {act_id}…",
440
+ completed_steps=completed_steps,
441
+ total_steps=total_steps,
442
+ current_activity_id=act_id,
443
+ )
444
+ payload = self.backup_activity(act_id, wait_timeout=wait_timeout)
445
+ if payload is None:
446
+ raise ValueError(f"Hub did not return activity data for activity {act_id}")
447
+ activity_payloads.append(payload)
448
+ completed_steps += 1
449
+ _progress(
450
+ status="running",
451
+ phase="activity",
452
+ message=f"Backed up activity {act_id}.",
453
+ completed_steps=completed_steps,
454
+ total_steps=total_steps,
455
+ current_activity_id=act_id,
456
+ )
457
+
458
+ completed_steps += 1
459
+ _progress(
460
+ status="running",
461
+ phase="finalizing",
462
+ message="Finalizing backup bundle…",
463
+ completed_steps=completed_steps,
464
+ total_steps=total_steps,
465
+ )
466
+
467
+ resolved_hub_info = hub_info if hub_info is not None else {
468
+ "entry_id": self.proxy_id,
469
+ "name": self.get_banner_info().get("name") or self.mdns_instance,
470
+ "version": self.hub_version,
471
+ }
472
+
473
+ return _bx.assemble_hub_bundle(
474
+ device_payloads=device_payloads,
475
+ activity_payloads=activity_payloads,
476
+ hub_info=resolved_hub_info,
477
+ total_steps=total_steps,
478
+ )
479
+
480
+ # ------------------------------------------------------------------
481
+ # snapshot helper
482
+ # ------------------------------------------------------------------
483
+
484
+ def _refresh_devices_snapshot(self, *, timeout: float) -> dict[int, dict[str, Any]]:
485
+ self._refresh_catalog("devices", timeout=timeout)
486
+ return dict(self.state.entities("device"))