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,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