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,507 @@
|
|
|
1
|
+
# backup_export.py — pure assembly of restore-oriented backup payloads.
|
|
2
|
+
#
|
|
3
|
+
# These functions turn already-fetched proxy state into the
|
|
4
|
+
# schema-versioned, restorable backup shapes that proxy_restore.py reads
|
|
5
|
+
# back. They are deliberately free of any fetch/orchestration logic (that
|
|
6
|
+
# lives in proxy_backup_export.BackupExportMixin) and of any Home
|
|
7
|
+
# Assistant dependency, so the same payloads can be produced in-tree and
|
|
8
|
+
# from the standalone library.
|
|
9
|
+
#
|
|
10
|
+
# The shapes here are the mirror image of the restore parsers; keep the
|
|
11
|
+
# two in lockstep and bump the matching *_SCHEMA_VERSION when either
|
|
12
|
+
# changes.
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import Any, Callable, Optional
|
|
17
|
+
|
|
18
|
+
from .blob_decoders import (
|
|
19
|
+
format_decoded_for_display,
|
|
20
|
+
is_decodable_class,
|
|
21
|
+
try_decode_blob,
|
|
22
|
+
)
|
|
23
|
+
from .commands import split_play_blob_tail
|
|
24
|
+
from .devices import DeviceConfig
|
|
25
|
+
from .hub_versions import (
|
|
26
|
+
ACTIVITY_BACKUP_SCHEMA_VERSION,
|
|
27
|
+
DEVICE_BACKUP_SCHEMA_VERSION,
|
|
28
|
+
HUB_BUNDLE_SCHEMA_VERSION,
|
|
29
|
+
)
|
|
30
|
+
from .protocol_const import (
|
|
31
|
+
BUTTONNAME_BY_CODE,
|
|
32
|
+
DEVICE_CLASS_BLUETOOTH,
|
|
33
|
+
DEVICE_CLASS_IR,
|
|
34
|
+
DEVICE_CLASS_RF_315,
|
|
35
|
+
DEVICE_CLASS_RF_433,
|
|
36
|
+
DEVICE_CLASS_WIFI_HUE,
|
|
37
|
+
DEVICE_CLASS_WIFI_IP,
|
|
38
|
+
DEVICE_CLASS_WIFI_MQTT,
|
|
39
|
+
DEVICE_CLASS_WIFI_ROKU,
|
|
40
|
+
DEVICE_CLASS_WIFI_SONOS,
|
|
41
|
+
normalize_device_class,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_NETWORK_CALLBACK_CLASSES = {
|
|
45
|
+
DEVICE_CLASS_WIFI_ROKU,
|
|
46
|
+
DEVICE_CLASS_WIFI_IP,
|
|
47
|
+
DEVICE_CLASS_WIFI_HUE,
|
|
48
|
+
DEVICE_CLASS_WIFI_MQTT,
|
|
49
|
+
DEVICE_CLASS_WIFI_SONOS,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _now_iso() -> str:
|
|
54
|
+
return datetime.now(timezone.utc).isoformat()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_network_callback_device_class(device_class: Any) -> bool:
|
|
58
|
+
return normalize_device_class(device_class) in _NETWORK_CALLBACK_CLASSES
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def uses_raw_command_dump(normalized_device_class: str | None) -> bool:
|
|
62
|
+
"""True when a device class round-trips via the raw 0x020C dump.
|
|
63
|
+
|
|
64
|
+
BT, RF and the wifi network-callback variants share the family-0x0E
|
|
65
|
+
command-record shape whose library_data is opaque to the IR-blob
|
|
66
|
+
normalizer, so the raw dump is the only byte-faithful source.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
return normalized_device_class in (
|
|
70
|
+
DEVICE_CLASS_BLUETOOTH,
|
|
71
|
+
DEVICE_CLASS_RF_315,
|
|
72
|
+
DEVICE_CLASS_RF_433,
|
|
73
|
+
) or is_network_callback_device_class(normalized_device_class)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_device_block(
|
|
77
|
+
device_id: int,
|
|
78
|
+
device_meta: dict[str, Any],
|
|
79
|
+
config: Optional[DeviceConfig],
|
|
80
|
+
) -> dict[str, Any]:
|
|
81
|
+
"""Build the ``device`` block of a backup payload.
|
|
82
|
+
|
|
83
|
+
``config`` is the device's parsed schema (from ``parse_device_record``
|
|
84
|
+
on the cached raw record body). When ``None`` the block falls back to
|
|
85
|
+
the minimal four-field shape.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
base: dict[str, Any] = {
|
|
89
|
+
"device_id": device_id,
|
|
90
|
+
"name": device_meta.get("name"),
|
|
91
|
+
"brand": device_meta.get("brand"),
|
|
92
|
+
"device_class": device_meta.get("device_class"),
|
|
93
|
+
"device_class_code": device_meta.get("device_class_code"),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if config is None:
|
|
97
|
+
return base
|
|
98
|
+
|
|
99
|
+
base.update(
|
|
100
|
+
{
|
|
101
|
+
"icon": config.icon,
|
|
102
|
+
"sort": config.sort,
|
|
103
|
+
"code_type": config.code_type,
|
|
104
|
+
"device_type": config.device_type,
|
|
105
|
+
"code_id_hex": config.code_id.hex(" "),
|
|
106
|
+
"hide": config.hide,
|
|
107
|
+
"input_flag": config.input_flag,
|
|
108
|
+
"channel": config.channel,
|
|
109
|
+
"power_state": config.power_state,
|
|
110
|
+
"ip_address": config.ip_address,
|
|
111
|
+
"poll_time": config.poll_time,
|
|
112
|
+
"input_mode": config.input_mode,
|
|
113
|
+
"inputs_configured": config.is_input_configured,
|
|
114
|
+
"power_mode": config.power_mode,
|
|
115
|
+
"power_style": config.power_style,
|
|
116
|
+
"share_mode": config.share_mode,
|
|
117
|
+
"tail_marker": config.tail_marker,
|
|
118
|
+
"extras": (
|
|
119
|
+
{"a": config.extra_a, "b": config.extra_b, "c": config.extra_c}
|
|
120
|
+
if config.extras_present
|
|
121
|
+
else None
|
|
122
|
+
),
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
# Schema-decoded name/brand are authoritative when present.
|
|
126
|
+
if config.name:
|
|
127
|
+
base["name"] = config.name
|
|
128
|
+
if config.brand:
|
|
129
|
+
base["brand"] = config.brand
|
|
130
|
+
return base
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def build_hub_code_record_restore_data(
|
|
134
|
+
command: dict[str, Any],
|
|
135
|
+
*,
|
|
136
|
+
device_class: str | None = None,
|
|
137
|
+
) -> dict[str, Any] | None:
|
|
138
|
+
"""Extract opaque command-record metadata from a raw 0x020C dump result.
|
|
139
|
+
|
|
140
|
+
For the virtual-device classes that carry user-meaningful structure
|
|
141
|
+
inside the blob, the result also gains a ``decoded`` block. That block
|
|
142
|
+
is purely additive: the wire-faithful ``data_hex`` remains the only
|
|
143
|
+
input the restore path reads.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
pages = command.get("pages")
|
|
147
|
+
if not isinstance(pages, list) or not pages:
|
|
148
|
+
return None
|
|
149
|
+
page_one = pages[0]
|
|
150
|
+
if not isinstance(page_one, dict):
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
payload_hex = str(page_one.get("payload_hex") or "").strip()
|
|
154
|
+
if not payload_hex:
|
|
155
|
+
return None
|
|
156
|
+
try:
|
|
157
|
+
payload = bytes.fromhex(payload_hex)
|
|
158
|
+
except ValueError:
|
|
159
|
+
return None
|
|
160
|
+
if len(payload) < 15:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
data_hex = str(command.get("ir_blob_hex") or "").strip()
|
|
164
|
+
if not data_hex:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
restore_data: dict[str, Any] = {
|
|
168
|
+
"transport": "hub_code_record",
|
|
169
|
+
"library_type": payload[8],
|
|
170
|
+
"command_code": payload[9:15].hex(" "),
|
|
171
|
+
"data_hex": data_hex,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if device_class and is_decodable_class(device_class):
|
|
175
|
+
decoded_block = try_decode_blob(device_class, data_hex)
|
|
176
|
+
if decoded_block is not None:
|
|
177
|
+
restore_data["decoded"] = decoded_block
|
|
178
|
+
|
|
179
|
+
return restore_data
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def normalize_dump_to_blobs(
|
|
183
|
+
dump_result: dict[str, Any] | None,
|
|
184
|
+
*,
|
|
185
|
+
resolve_device_class: Callable[[int], str | None],
|
|
186
|
+
fallback_device_id: int,
|
|
187
|
+
) -> dict[str, Any] | None:
|
|
188
|
+
"""Turn a raw IR-dump result into ``play_ir_blob``-shaped command blobs.
|
|
189
|
+
|
|
190
|
+
Mirrors the integration's ``async_fetch_blob`` normalization: splits
|
|
191
|
+
the replay tail, runs the uniform decoder for classes that carry
|
|
192
|
+
structure, and exposes ``command_blob`` (body hex) + ``decoded``.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
if dump_result is None:
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
commands_out: list[dict[str, Any]] = []
|
|
199
|
+
for command in dump_result.get("commands", []):
|
|
200
|
+
blob_hex = str(command.get("ir_blob_hex") or "").strip()
|
|
201
|
+
blob_bytes = bytes.fromhex(blob_hex) if blob_hex else b""
|
|
202
|
+
blob_body = b""
|
|
203
|
+
replay_tail_checksum: int | None = None
|
|
204
|
+
blob_kind = "raw"
|
|
205
|
+
parsed_blob: str | None = None
|
|
206
|
+
decoded_block: dict[str, Any] | None = None
|
|
207
|
+
|
|
208
|
+
command_device_id = command.get("device_id")
|
|
209
|
+
normalized_device_id = (
|
|
210
|
+
int(command_device_id) if command_device_id is not None else fallback_device_id
|
|
211
|
+
)
|
|
212
|
+
cached_device_class = resolve_device_class(normalized_device_id)
|
|
213
|
+
|
|
214
|
+
if blob_bytes:
|
|
215
|
+
blob_body, replay_tail_checksum = split_play_blob_tail(blob_bytes)
|
|
216
|
+
if blob_body and is_decodable_class(cached_device_class):
|
|
217
|
+
candidate = try_decode_blob(cached_device_class, blob_body)
|
|
218
|
+
if candidate is not None:
|
|
219
|
+
decoded_block = candidate
|
|
220
|
+
if candidate.get("class") == DEVICE_CLASS_IR:
|
|
221
|
+
blob_kind = "descriptive"
|
|
222
|
+
else:
|
|
223
|
+
blob_kind = "decoded"
|
|
224
|
+
parsed_blob = format_decoded_for_display(candidate)
|
|
225
|
+
|
|
226
|
+
commands_out.append(
|
|
227
|
+
{
|
|
228
|
+
"command_label": command.get("label"),
|
|
229
|
+
"device_id": normalized_device_id,
|
|
230
|
+
"command_id": command.get("command_id"),
|
|
231
|
+
"device_class": cached_device_class,
|
|
232
|
+
"blob_kind": blob_kind,
|
|
233
|
+
"command_blob": blob_body.hex(" ") if blob_body else None,
|
|
234
|
+
"parsed_blob": parsed_blob,
|
|
235
|
+
"decoded": decoded_block,
|
|
236
|
+
"replay_tail_checksum": replay_tail_checksum,
|
|
237
|
+
"command_checksum": replay_tail_checksum,
|
|
238
|
+
}
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
"device_id": dump_result.get("device_id"),
|
|
243
|
+
"requested_command_id": dump_result.get("requested_command_id"),
|
|
244
|
+
"total_commands": dump_result.get("total_commands"),
|
|
245
|
+
"received_command_count": dump_result.get("received_command_count"),
|
|
246
|
+
"complete": dump_result.get("complete"),
|
|
247
|
+
"commands": commands_out,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def build_device_command_rows(
|
|
252
|
+
*,
|
|
253
|
+
label_map: dict[int, str],
|
|
254
|
+
blob_by_command: dict[int, dict[str, Any]],
|
|
255
|
+
normalized_device_class: str | None,
|
|
256
|
+
command_metadata: dict[int, dict[str, int]],
|
|
257
|
+
raw_dump_class: bool,
|
|
258
|
+
) -> list[dict[str, Any]]:
|
|
259
|
+
"""Build the ``commands`` rows for a device backup."""
|
|
260
|
+
|
|
261
|
+
command_rows: list[dict[str, Any]] = []
|
|
262
|
+
for command_id in sorted(set(label_map) | set(blob_by_command)):
|
|
263
|
+
blob_command = blob_by_command.get(command_id, {})
|
|
264
|
+
row: dict[str, Any] = {
|
|
265
|
+
"command_id": command_id,
|
|
266
|
+
"name": label_map.get(command_id)
|
|
267
|
+
or blob_command.get("command_label")
|
|
268
|
+
or blob_command.get("label"),
|
|
269
|
+
}
|
|
270
|
+
if normalized_device_class == DEVICE_CLASS_IR:
|
|
271
|
+
blob_hex = blob_command.get("command_blob")
|
|
272
|
+
if blob_hex:
|
|
273
|
+
meta = command_metadata.get(command_id)
|
|
274
|
+
meta_dict = meta if isinstance(meta, dict) else {}
|
|
275
|
+
restore_data: dict[str, Any] = {
|
|
276
|
+
"transport": "hub_code_record",
|
|
277
|
+
"library_type": int(meta_dict.get("library_type", 0x0D)) & 0xFF,
|
|
278
|
+
"button_code": int(meta_dict.get("button_code", 0)) & 0xFFFFFFFFFFFF,
|
|
279
|
+
"data_hex": blob_hex,
|
|
280
|
+
}
|
|
281
|
+
decoded_block = try_decode_blob(DEVICE_CLASS_IR, blob_hex)
|
|
282
|
+
if decoded_block is not None:
|
|
283
|
+
restore_data["decoded"] = decoded_block
|
|
284
|
+
row["restore_data"] = restore_data
|
|
285
|
+
elif raw_dump_class:
|
|
286
|
+
restore_data = build_hub_code_record_restore_data(
|
|
287
|
+
blob_command, device_class=normalized_device_class
|
|
288
|
+
)
|
|
289
|
+
if restore_data is not None:
|
|
290
|
+
row["restore_data"] = restore_data
|
|
291
|
+
# Classes producing neither shape are not restorable: the row
|
|
292
|
+
# keeps only command_id + name as an editable label.
|
|
293
|
+
command_rows.append(row)
|
|
294
|
+
return command_rows
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def build_device_button_rows(
|
|
298
|
+
*,
|
|
299
|
+
button_codes: list[int],
|
|
300
|
+
button_details: dict[int, dict[str, Any]],
|
|
301
|
+
label_map: dict[int, str],
|
|
302
|
+
) -> list[dict[str, Any]]:
|
|
303
|
+
rows: list[dict[str, Any]] = []
|
|
304
|
+
for button_id in sorted(set(button_codes) | set(button_details)):
|
|
305
|
+
details = button_details.get(button_id, {})
|
|
306
|
+
command_id = int(details.get("command_id", 0)) & 0xFF
|
|
307
|
+
rows.append(
|
|
308
|
+
{
|
|
309
|
+
"button_id": button_id & 0xFF,
|
|
310
|
+
"button_name": BUTTONNAME_BY_CODE.get(button_id & 0xFF),
|
|
311
|
+
"command_id": command_id,
|
|
312
|
+
"command_name": label_map.get(command_id),
|
|
313
|
+
"long_press_command_id": (
|
|
314
|
+
int(details["long_press_command_id"]) & 0xFF
|
|
315
|
+
if details.get("long_press_command_id") is not None
|
|
316
|
+
else None
|
|
317
|
+
),
|
|
318
|
+
}
|
|
319
|
+
)
|
|
320
|
+
return rows
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def build_device_macro_rows(macro_records: list[Any]) -> list[dict[str, Any]]:
|
|
324
|
+
rows: list[dict[str, Any]] = []
|
|
325
|
+
for record in macro_records:
|
|
326
|
+
rows.append(
|
|
327
|
+
{
|
|
328
|
+
"button_id": record.key_id & 0xFF,
|
|
329
|
+
"name": record.label,
|
|
330
|
+
"steps": [
|
|
331
|
+
{
|
|
332
|
+
"command_id": entry.key_id & 0xFF,
|
|
333
|
+
"duration": entry.duration & 0xFF,
|
|
334
|
+
"delay": entry.delay & 0xFF,
|
|
335
|
+
}
|
|
336
|
+
for entry in record.key_sequence
|
|
337
|
+
],
|
|
338
|
+
}
|
|
339
|
+
)
|
|
340
|
+
return rows
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def build_activity_button_rows(
|
|
344
|
+
*,
|
|
345
|
+
button_codes: list[int],
|
|
346
|
+
button_details: dict[int, dict[str, Any]],
|
|
347
|
+
) -> tuple[list[dict[str, Any]], set[int]]:
|
|
348
|
+
"""Return (rows, referenced_source_device_ids) for an activity keymap."""
|
|
349
|
+
|
|
350
|
+
rows: list[dict[str, Any]] = []
|
|
351
|
+
referenced: set[int] = set()
|
|
352
|
+
for button_id in sorted(set(button_codes) | set(button_details)):
|
|
353
|
+
details = button_details.get(button_id, {})
|
|
354
|
+
target_device_id = int(details.get("device_id", 0)) & 0xFF
|
|
355
|
+
command_id = int(details.get("command_id", 0)) & 0xFF
|
|
356
|
+
if target_device_id == 0:
|
|
357
|
+
# Slot exists but isn't bound to a target device; skip.
|
|
358
|
+
continue
|
|
359
|
+
referenced.add(target_device_id)
|
|
360
|
+
rows.append(
|
|
361
|
+
{
|
|
362
|
+
"button_id": button_id & 0xFF,
|
|
363
|
+
"button_name": BUTTONNAME_BY_CODE.get(button_id & 0xFF),
|
|
364
|
+
"device_id": target_device_id,
|
|
365
|
+
"command_id": command_id,
|
|
366
|
+
"long_press_device_id": (
|
|
367
|
+
int(details["long_press_device_id"]) & 0xFF
|
|
368
|
+
if details.get("long_press_device_id") is not None
|
|
369
|
+
else None
|
|
370
|
+
),
|
|
371
|
+
"long_press_command_id": (
|
|
372
|
+
int(details["long_press_command_id"]) & 0xFF
|
|
373
|
+
if details.get("long_press_command_id") is not None
|
|
374
|
+
else None
|
|
375
|
+
),
|
|
376
|
+
}
|
|
377
|
+
)
|
|
378
|
+
if details.get("long_press_device_id") is not None:
|
|
379
|
+
referenced.add(int(details["long_press_device_id"]) & 0xFF)
|
|
380
|
+
return rows, referenced
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def build_activity_macro_rows(
|
|
384
|
+
macro_records: list[Any],
|
|
385
|
+
) -> tuple[list[dict[str, Any]], set[int]]:
|
|
386
|
+
rows: list[dict[str, Any]] = []
|
|
387
|
+
referenced: set[int] = set()
|
|
388
|
+
for record in macro_records:
|
|
389
|
+
step_entries: list[dict[str, Any]] = []
|
|
390
|
+
for entry in record.key_sequence:
|
|
391
|
+
step_device_id = entry.device_id & 0xFF
|
|
392
|
+
step_command_id = entry.key_id & 0xFF
|
|
393
|
+
is_delay_step = step_device_id == 0xFF or step_command_id == 0xFF
|
|
394
|
+
if not is_delay_step and step_device_id != 0:
|
|
395
|
+
referenced.add(step_device_id)
|
|
396
|
+
step_entries.append(
|
|
397
|
+
{
|
|
398
|
+
"device_id": step_device_id,
|
|
399
|
+
"command_id": step_command_id,
|
|
400
|
+
# The step's "fid" is the canonical 48-bit button_code;
|
|
401
|
+
# stored verbatim and translated on restore.
|
|
402
|
+
"button_code": int(entry.fid) & 0xFFFFFFFFFFFF,
|
|
403
|
+
"duration": entry.duration & 0xFF,
|
|
404
|
+
"delay": entry.delay & 0xFF,
|
|
405
|
+
}
|
|
406
|
+
)
|
|
407
|
+
rows.append(
|
|
408
|
+
{
|
|
409
|
+
"button_id": record.key_id & 0xFF,
|
|
410
|
+
"name": record.label,
|
|
411
|
+
"steps": step_entries,
|
|
412
|
+
}
|
|
413
|
+
)
|
|
414
|
+
return rows, referenced
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def build_activity_favorite_rows(
|
|
418
|
+
favorite_slots: list[dict[str, Any]],
|
|
419
|
+
) -> tuple[list[dict[str, Any]], set[int]]:
|
|
420
|
+
rows: list[dict[str, Any]] = []
|
|
421
|
+
referenced: set[int] = set()
|
|
422
|
+
for slot in favorite_slots:
|
|
423
|
+
if not isinstance(slot, dict):
|
|
424
|
+
continue
|
|
425
|
+
target_device_id = int(slot.get("device_id", 0)) & 0xFF
|
|
426
|
+
if target_device_id != 0:
|
|
427
|
+
referenced.add(target_device_id)
|
|
428
|
+
rows.append(
|
|
429
|
+
{
|
|
430
|
+
"button_id": int(slot.get("button_id", 0)) & 0xFF,
|
|
431
|
+
"device_id": target_device_id,
|
|
432
|
+
"command_id": int(slot.get("command_id", 0)) & 0xFF,
|
|
433
|
+
}
|
|
434
|
+
)
|
|
435
|
+
return rows, referenced
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def assemble_device_backup(
|
|
439
|
+
*,
|
|
440
|
+
device_block: dict[str, Any],
|
|
441
|
+
command_rows: list[dict[str, Any]],
|
|
442
|
+
button_rows: list[dict[str, Any]],
|
|
443
|
+
macro_rows: list[dict[str, Any]],
|
|
444
|
+
key_sort_row: dict[str, Any] | None,
|
|
445
|
+
input_record: dict[str, Any] | None,
|
|
446
|
+
complete: bool,
|
|
447
|
+
) -> dict[str, Any]:
|
|
448
|
+
return {
|
|
449
|
+
"kind": "device_backup",
|
|
450
|
+
"schema_version": DEVICE_BACKUP_SCHEMA_VERSION,
|
|
451
|
+
"captured_at": _now_iso(),
|
|
452
|
+
"complete": complete,
|
|
453
|
+
"device": device_block,
|
|
454
|
+
"commands": command_rows,
|
|
455
|
+
"key_sort": dict(key_sort_row) if isinstance(key_sort_row, dict) else None,
|
|
456
|
+
"input_record": input_record,
|
|
457
|
+
"button_bindings": button_rows,
|
|
458
|
+
"macros": macro_rows,
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def assemble_activity_backup(
|
|
463
|
+
*,
|
|
464
|
+
activity_block: dict[str, Any],
|
|
465
|
+
button_rows: list[dict[str, Any]],
|
|
466
|
+
favorite_rows: list[dict[str, Any]],
|
|
467
|
+
macro_rows: list[dict[str, Any]],
|
|
468
|
+
referenced_source_device_ids: set[int],
|
|
469
|
+
complete: bool,
|
|
470
|
+
) -> dict[str, Any]:
|
|
471
|
+
return {
|
|
472
|
+
"kind": "activity_backup",
|
|
473
|
+
"schema_version": ACTIVITY_BACKUP_SCHEMA_VERSION,
|
|
474
|
+
"captured_at": _now_iso(),
|
|
475
|
+
"complete": complete,
|
|
476
|
+
# Same "device" key as device_backup so the restore schema parser
|
|
477
|
+
# is reused; entity_type marks it as an activity.
|
|
478
|
+
"device": {**activity_block, "entity_type": "activity"},
|
|
479
|
+
"button_bindings": button_rows,
|
|
480
|
+
"favorite_slots": favorite_rows,
|
|
481
|
+
"macros": macro_rows,
|
|
482
|
+
"referenced_source_device_ids": sorted(referenced_source_device_ids),
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def assemble_hub_bundle(
|
|
487
|
+
*,
|
|
488
|
+
device_payloads: list[dict[str, Any]],
|
|
489
|
+
activity_payloads: list[dict[str, Any]],
|
|
490
|
+
hub_info: dict[str, Any],
|
|
491
|
+
total_steps: int | None = None,
|
|
492
|
+
) -> dict[str, Any]:
|
|
493
|
+
complete = all(bool(p.get("complete")) for p in device_payloads) and all(
|
|
494
|
+
bool(p.get("complete")) for p in activity_payloads
|
|
495
|
+
)
|
|
496
|
+
bundle: dict[str, Any] = {
|
|
497
|
+
"kind": "hub_bundle",
|
|
498
|
+
"schema_version": HUB_BUNDLE_SCHEMA_VERSION,
|
|
499
|
+
"captured_at": _now_iso(),
|
|
500
|
+
"complete": complete,
|
|
501
|
+
"hub": dict(hub_info),
|
|
502
|
+
"devices": device_payloads,
|
|
503
|
+
"activities": activity_payloads,
|
|
504
|
+
}
|
|
505
|
+
if total_steps is not None:
|
|
506
|
+
bundle["_progress_total_steps"] = total_steps
|
|
507
|
+
return bundle
|