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.
- sofapython/__init__.py +136 -0
- sofapython/ack.py +79 -0
- sofapython/aio.py +552 -0
- sofapython/backup_export.py +507 -0
- sofapython/blob_decoders.py +806 -0
- sofapython/cli.py +447 -0
- sofapython/commands.py +1273 -0
- sofapython/deframer.py +73 -0
- sofapython/device_create.py +1174 -0
- sofapython/devices.py +534 -0
- sofapython/discovery.py +315 -0
- sofapython/frame_handlers.py +131 -0
- sofapython/hub_listener.py +242 -0
- sofapython/hub_logging.py +152 -0
- sofapython/hub_versions.py +112 -0
- sofapython/inputs.py +501 -0
- sofapython/macros.py +669 -0
- sofapython/notify_demuxer.py +434 -0
- sofapython/opcode_handlers.py +1655 -0
- sofapython/protocol_const.py +633 -0
- sofapython/proxy_ack_waiters.py +660 -0
- sofapython/proxy_activity_ops.py +943 -0
- sofapython/proxy_backup.py +504 -0
- sofapython/proxy_backup_export.py +486 -0
- sofapython/proxy_catalog.py +915 -0
- sofapython/proxy_frame_decode.py +227 -0
- sofapython/proxy_ir_blob.py +676 -0
- sofapython/proxy_restore.py +2004 -0
- sofapython/proxy_wifi_device.py +1101 -0
- sofapython/state_helpers.py +713 -0
- sofapython/transport_bridge.py +876 -0
- sofapython/version.py +4 -0
- sofapython/wire_schema.py +164 -0
- sofapython/x1_proxy.py +1833 -0
- sofapython-0.0.1rc1.dist-info/METADATA +162 -0
- sofapython-0.0.1rc1.dist-info/RECORD +39 -0
- sofapython-0.0.1rc1.dist-info/WHEEL +4 -0
- sofapython-0.0.1rc1.dist-info/entry_points.txt +2 -0
- sofapython-0.0.1rc1.dist-info/licenses/LICENSE +21 -0
|
@@ -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"))
|