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,2004 @@
1
+ """Restore-side orchestration mixin for :class:`X1Proxy`.
2
+
3
+ Houses the device- and activity-backup replay code paths -- everything
4
+ that turns a backup payload back into a sequence of wire writes against
5
+ a live hub. The mixin captures roughly nine hundred lines that used to
6
+ sit inline in ``x1_proxy.py`` and presents them through one public entry
7
+ per entity kind (``restore_device``, ``restore_activity``) plus a small
8
+ family of private helpers that map source-side identifiers onto the ids
9
+ the destination hub assigns at create time.
10
+
11
+ The orchestrators are intentionally thin: each step either delegates to
12
+ an existing schema-driven builder (``build_device_create_step``,
13
+ ``build_inputs_write``, ``build_button_binding_step``, ...) or to a
14
+ ``persist_*`` method on the proxy. No wire bytes are constructed here.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import replace
20
+ from typing import Any
21
+
22
+ from .hub_versions import (
23
+ ACTIVITY_BACKUP_SCHEMA_VERSION,
24
+ DEVICE_BACKUP_SCHEMA_VERSION,
25
+ HUB_BUNDLE_SCHEMA_VERSION,
26
+ HUB_VERSION_X1,
27
+ )
28
+ from .device_create import (
29
+ FAMILY_ACTIVITY_CREATE,
30
+ DeviceCreateRequest,
31
+ DeviceCreateResult,
32
+ build_button_binding_step,
33
+ build_command_write_steps,
34
+ build_device_create_step,
35
+ build_device_update_step,
36
+ build_key_sort_steps,
37
+ build_macro_step,
38
+ build_macro_step_record,
39
+ build_remote_sync_step,
40
+ build_set_idle_behavior_step,
41
+ run_create_sequence,
42
+ run_device_create,
43
+ synthesize_command_code,
44
+ )
45
+ from .blob_decoders import encode_decoded_blob, try_decode_blob
46
+ from .devices import device_config_from_backup
47
+ from .inputs import ControlKeyBlock, FavoriteSlot, InputEntry, build_inputs_write
48
+ from .protocol_const import (
49
+ DEVICE_CLASS_BLUETOOTH,
50
+ DEVICE_CLASS_IR,
51
+ DEVICE_CLASS_RF_315,
52
+ DEVICE_CLASS_RF_433,
53
+ DEVICE_CLASS_WIFI_HUE,
54
+ DEVICE_CLASS_WIFI_IP,
55
+ DEVICE_CLASS_WIFI_MQTT,
56
+ DEVICE_CLASS_WIFI_ROKU,
57
+ DEVICE_CLASS_WIFI_SONOS,
58
+ known_public_device_classes,
59
+ normalize_device_class,
60
+ )
61
+
62
+
63
+ def _input_create_step_factory():
64
+ # Imported lazily to avoid a circular import at module load: the
65
+ # helper lives in ``x1_proxy`` because it sits next to the other
66
+ # module-level CreateStep builders that haven't moved yet.
67
+ from .x1_proxy import _input_create_step
68
+
69
+ return _input_create_step
70
+
71
+
72
+ def _restore_device_dict_from_result(
73
+ request: DeviceCreateRequest,
74
+ result: DeviceCreateResult,
75
+ ) -> dict[str, Any]:
76
+ """Convert a :class:`DeviceCreateResult` into the legacy restore dict.
77
+
78
+ All device classes now share the canonical IR/BT/RF result-counter
79
+ surface; wifi network-callback devices replay their command records
80
+ through :meth:`persist_command_record` the same way BT/RF do, so
81
+ their counters land in :class:`DeviceCreateResult` directly.
82
+ """
83
+
84
+ return {
85
+ "status": "success",
86
+ "device_id": result.device_id,
87
+ "restored_commands": result.restored_commands,
88
+ "restored_button_bindings": result.restored_button_bindings,
89
+ "restored_macros": result.restored_macros,
90
+ "restored_inputs": result.restored_inputs,
91
+ "skipped_favorites": result.skipped_favorites,
92
+ "skipped_macro_steps": result.skipped_macro_steps,
93
+ "command_id_map": {
94
+ str(old): new for old, new in sorted(result.command_id_map.items())
95
+ },
96
+ }
97
+
98
+
99
+ def _run_create_sequence(*args, **kwargs):
100
+ # Route through the ``x1_proxy`` module so call sites pick up any
101
+ # monkeypatch of ``x1_proxy.run_create_sequence`` -- test fixtures
102
+ # that stub create-sequence orchestration target that symbol.
103
+ from . import x1_proxy as _xp
104
+
105
+ return _xp.run_create_sequence(*args, **kwargs)
106
+
107
+
108
+ _X1_IMPORT_COMMAND_ACK_TIMEOUT = 10.0
109
+
110
+
111
+ class RestoreMixin:
112
+ """Mixin providing device/activity restore orchestration."""
113
+
114
+ def _restore_device_class(self, device_block: dict[str, Any]) -> str | None:
115
+ """Return the normalized device class declared by a backup payload."""
116
+
117
+ return normalize_device_class(
118
+ device_block.get("device_class", device_block.get("device_class_code"))
119
+ )
120
+
121
+ @staticmethod
122
+ def _command_restore_data(command_row: dict[str, Any]) -> dict[str, Any] | None:
123
+ restore_data = command_row.get("restore_data")
124
+ return dict(restore_data) if isinstance(restore_data, dict) else None
125
+
126
+ @staticmethod
127
+ def _edited_command_data_hex(
128
+ restore_data: dict[str, Any],
129
+ command_id: int,
130
+ ) -> str | None:
131
+ # When a backup row carries ``decoded.edited = true`` the user has
132
+ # hand-modified the structured payload; re-encode from ``decoded``
133
+ # and verify the encoder is round-trip-stable before letting those
134
+ # bytes near the hub. Returns the new hex string, or ``None`` if
135
+ # the row is a pristine capture (the common case). Raises
136
+ # ``ValueError`` on any encode / round-trip failure so a silent
137
+ # fallback to the stale ``data_hex`` cannot mask a dropped edit.
138
+ #
139
+ # Round-trip verification keys off ``decoded["class"]`` — the
140
+ # decoded block is self-describing, and the outer device-level
141
+ # ``device_class`` is not guaranteed to be present in
142
+ # hand-edited bundles.
143
+ decoded = restore_data.get("decoded")
144
+ if not isinstance(decoded, dict):
145
+ return None
146
+ if not bool(decoded.get("edited")):
147
+ return None
148
+ try:
149
+ encoded = encode_decoded_blob(decoded)
150
+ except (ValueError, KeyError, TypeError) as exc:
151
+ raise ValueError(
152
+ f"command_id {command_id}: edited decoded block could not be "
153
+ f"re-encoded ({exc})"
154
+ ) from exc
155
+ verify = try_decode_blob(decoded.get("class"), encoded)
156
+ if verify is None:
157
+ raise ValueError(
158
+ f"command_id {command_id}: edited decoded block failed "
159
+ f"round-trip self-check"
160
+ )
161
+ if verify.get("fields") != decoded.get("fields") or verify.get(
162
+ "trailer_hex", ""
163
+ ) != decoded.get("trailer_hex", ""):
164
+ raise ValueError(
165
+ f"command_id {command_id}: edited decoded block does not "
166
+ f"round-trip to the same fields"
167
+ )
168
+ return encoded.hex()
169
+
170
+ def _validate_restore_capabilities(
171
+ self,
172
+ *,
173
+ hub_version: str,
174
+ device_class: str | None,
175
+ payload: dict[str, Any],
176
+ ) -> None:
177
+ """Fail fast when the current restore path lacks a required writer."""
178
+
179
+ known_public_classes = set(known_public_device_classes())
180
+
181
+ if device_class is not None and device_class not in known_public_classes:
182
+ raise ValueError(
183
+ "restore_device does not recognize "
184
+ f"device_class={device_class!r} in the public device registry"
185
+ )
186
+
187
+ # Non-IR codecs (BT, RF, and the network-callback wifi variants)
188
+ # all live in the same family-0x0E command-record table; the
189
+ # restore path replays them through ``persist_command_record``
190
+ # using the ``hub_code_record`` metadata captured at backup time.
191
+ if device_class in (
192
+ DEVICE_CLASS_BLUETOOTH,
193
+ DEVICE_CLASS_RF_315,
194
+ DEVICE_CLASS_RF_433,
195
+ DEVICE_CLASS_WIFI_ROKU,
196
+ DEVICE_CLASS_WIFI_IP,
197
+ DEVICE_CLASS_WIFI_HUE,
198
+ DEVICE_CLASS_WIFI_MQTT,
199
+ DEVICE_CLASS_WIFI_SONOS,
200
+ ):
201
+ command_rows = payload.get("commands")
202
+ if not isinstance(command_rows, list) or not any(
203
+ isinstance(row, dict) for row in command_rows
204
+ ):
205
+ raise ValueError(
206
+ "restore_device for "
207
+ f"{device_class} devices needs command restore metadata "
208
+ "(library_type/data_hex/button slot) in each command row"
209
+ )
210
+ validated_rows = 0
211
+ for row in command_rows:
212
+ if not isinstance(row, dict):
213
+ continue
214
+ restore_data = self._command_restore_data(row)
215
+ if (
216
+ not isinstance(restore_data, dict)
217
+ or restore_data.get("transport") != "hub_code_record"
218
+ or restore_data.get("library_type") is None
219
+ or not str(restore_data.get("data_hex") or "").strip()
220
+ ):
221
+ raise ValueError(
222
+ "restore_device for "
223
+ f"{device_class} devices needs command restore metadata "
224
+ "(library_type/data_hex/button slot) in each command row"
225
+ )
226
+ validated_rows += 1
227
+ if validated_rows == 0:
228
+ raise ValueError(
229
+ "restore_device for "
230
+ f"{device_class} devices needs command restore metadata "
231
+ "(library_type/data_hex/button slot) in each command row"
232
+ )
233
+ return
234
+
235
+ if device_class != DEVICE_CLASS_IR:
236
+ raise ValueError(
237
+ "restore_device command replay is not implemented yet for "
238
+ f"device_class={device_class or 'unknown'}"
239
+ )
240
+
241
+ # Phase 3 unified the family-0x46 inputs writer; the historical
242
+ # "X1-only post-step" guard is gone. Button bindings, macros and
243
+ # inputs replay through schema-driven builders on every variant
244
+ # now; if a future variant lands an entirely new layout the
245
+ # mismatch surfaces as a wire-schema lookup error, not a guard
246
+ # at this layer.
247
+
248
+ def _restore_ir_commands(
249
+ self,
250
+ *,
251
+ payload: dict[str, Any],
252
+ device_id: int,
253
+ ) -> tuple[dict[int, int], int]:
254
+ """Replay IR command records from a backup onto ``device_id``.
255
+
256
+ Each command row carries a ``restore_data`` block with
257
+ ``library_type``, ``button_code`` (48-bit canonical identifier),
258
+ and ``data_hex``. These are written verbatim via
259
+ :meth:`persist_command_record`, preserving full wire fidelity.
260
+ Rows without a usable ``restore_data`` block are skipped.
261
+
262
+ Returns ``(command_id_map, restored_commands)`` where
263
+ ``command_id_map`` translates backup command ids to the
264
+ hub-assigned ids on the new device. The matching captured
265
+ ``button_code`` per new id is also recorded in
266
+ :attr:`_restore_button_code_map_buffer` for the post-create
267
+ binding step to read.
268
+ """
269
+
270
+ _command_steps, command_id_map, button_code_map, _command_names = (
271
+ self._build_restore_command_batch(
272
+ payload=payload,
273
+ device_id=device_id,
274
+ strict=False,
275
+ )
276
+ )
277
+ self._restore_button_code_map_buffer = dict(button_code_map)
278
+ return command_id_map, len(command_id_map)
279
+
280
+ def _resolve_macro_step_duration(
281
+ self,
282
+ *,
283
+ request: "DeviceCreateRequest",
284
+ button_id: int,
285
+ src_device_id: int,
286
+ new_step_device: int,
287
+ step_command_id: int,
288
+ raw_duration: int,
289
+ ) -> tuple[int, bool]:
290
+ """Translate an activity-macro step's ``duration`` byte at restore time.
291
+
292
+ For most macro steps the byte is an opaque hold/timing value
293
+ that round-trips byte-for-byte. The exception is
294
+ ``(device, key_id=0xC5)`` rows in a POWER_ON macro: there the
295
+ byte is a 1-based ordinal into the *source* device's input
296
+ list at backup time. The destination hub almost certainly
297
+ assigned a different ordinal layout to the freshly-restored
298
+ device, so the byte has to be re-resolved.
299
+
300
+ Resolution chain (only entered when the request carries
301
+ bundle context, i.e. during a hub-bundle restore):
302
+
303
+ 1. Source ordinal -> source device's input row's
304
+ ``command_id`` (via the bundled device's ``inputs`` block).
305
+ 2. Source ``command_id`` -> destination ``command_id`` via the
306
+ per-source-device map captured during the devices phase.
307
+ 3. Destination ``command_id`` -> destination ordinal via a
308
+ live ``query_device_input_index`` on the new device.
309
+
310
+ Any step that breaks the chain (no source input row matches,
311
+ no command_id translation, hub doesn't answer the input-index
312
+ query) keeps the raw duration and the caller increments
313
+ ``skipped_input_ordinals``; the surrounding macro entries
314
+ are emitted unchanged.
315
+
316
+ Returns ``(resolved_duration, ordinal_skipped)``.
317
+ """
318
+
319
+ if step_command_id != 0xC5 or raw_duration == 0:
320
+ return raw_duration, False
321
+
322
+ bundle_devices = request.bundle_devices_by_source_id
323
+ command_id_maps = request.command_id_maps_by_source_device_id
324
+ if not bundle_devices or not command_id_maps:
325
+ # No bundle context (standalone restore_activity call).
326
+ # The raw duration is the only signal we have; preserve
327
+ # it byte-for-byte. Callers that need correctness across
328
+ # hubs should restore via the bundle path.
329
+ return raw_duration, False
330
+
331
+ source_device_payload = bundle_devices.get(src_device_id)
332
+ if not isinstance(source_device_payload, dict):
333
+ self._log.warning(
334
+ "[RESTORE] activity macro key=0x%02X dev=0x%02X 0xC5 step "
335
+ "ordinal=%d: source device not present in bundle; "
336
+ "preserving raw duration",
337
+ button_id,
338
+ src_device_id,
339
+ raw_duration,
340
+ )
341
+ return raw_duration, True
342
+
343
+ source_inputs = source_device_payload.get("inputs")
344
+ if not isinstance(source_inputs, list):
345
+ self._log.warning(
346
+ "[RESTORE] activity macro key=0x%02X dev=0x%02X 0xC5 step "
347
+ "ordinal=%d: source device has no inputs block; "
348
+ "preserving raw duration",
349
+ button_id,
350
+ src_device_id,
351
+ raw_duration,
352
+ )
353
+ return raw_duration, True
354
+
355
+ source_command_id: int | None = None
356
+ for input_row in source_inputs:
357
+ if not isinstance(input_row, dict):
358
+ continue
359
+ if int(input_row.get("input_index", 0)) & 0xFF == raw_duration:
360
+ source_command_id = int(input_row.get("command_id", 0)) & 0xFF
361
+ break
362
+ if not source_command_id:
363
+ self._log.warning(
364
+ "[RESTORE] activity macro key=0x%02X dev=0x%02X 0xC5 step "
365
+ "ordinal=%d: source device has no input row with that "
366
+ "ordinal; preserving raw duration",
367
+ button_id,
368
+ src_device_id,
369
+ raw_duration,
370
+ )
371
+ return raw_duration, True
372
+
373
+ cmd_map = command_id_maps.get(src_device_id) or {}
374
+ new_command_id = int(cmd_map.get(source_command_id, 0)) & 0xFF
375
+ if not new_command_id:
376
+ self._log.warning(
377
+ "[RESTORE] activity macro key=0x%02X dev=0x%02X 0xC5 step "
378
+ "ordinal=%d -> source cmd=0x%02X has no destination "
379
+ "command_id in the per-device map; preserving raw duration",
380
+ button_id,
381
+ src_device_id,
382
+ raw_duration,
383
+ source_command_id,
384
+ )
385
+ return raw_duration, True
386
+
387
+ try:
388
+ new_ordinal = self.query_device_input_index(
389
+ new_step_device, new_command_id
390
+ )
391
+ except Exception:
392
+ # Defensive: a hub that errors mid-resolution shouldn't
393
+ # crash the whole macro replay -- log and preserve the
394
+ # raw byte.
395
+ self._log.exception(
396
+ "[RESTORE] activity macro key=0x%02X dev=0x%02X 0xC5 step: "
397
+ "query_device_input_index raised; preserving raw duration",
398
+ button_id,
399
+ new_step_device,
400
+ )
401
+ return raw_duration, True
402
+
403
+ if not new_ordinal:
404
+ self._log.warning(
405
+ "[RESTORE] activity macro key=0x%02X dev=0x%02X 0xC5 step "
406
+ "ordinal=%d -> src cmd=0x%02X -> new cmd=0x%02X: destination "
407
+ "hub returned no ordinal; preserving raw duration",
408
+ button_id,
409
+ new_step_device,
410
+ raw_duration,
411
+ source_command_id,
412
+ new_command_id,
413
+ )
414
+ return raw_duration, True
415
+
416
+ return new_ordinal & 0xFF, False
417
+
418
+ @staticmethod
419
+ def _coerce_button_code(raw: Any) -> int:
420
+ """Read a backup ``button_code`` field which may be int or hex string."""
421
+
422
+ if isinstance(raw, int):
423
+ return raw & 0xFFFFFFFFFFFF
424
+ if isinstance(raw, str):
425
+ stripped = raw.strip().replace(" ", "")
426
+ if not stripped:
427
+ return 0
428
+ try:
429
+ if any(ch in "abcdefABCDEF" for ch in stripped) or stripped.startswith(("0x", "0X")):
430
+ return int(stripped, 16) & 0xFFFFFFFFFFFF
431
+ return int(stripped) & 0xFFFFFFFFFFFF
432
+ except ValueError:
433
+ return 0
434
+ return 0
435
+
436
+ def _build_restore_command_batch(
437
+ self,
438
+ *,
439
+ payload: dict[str, Any],
440
+ device_id: int,
441
+ strict: bool,
442
+ ack_timeout: float = 5.0,
443
+ ) -> tuple[list[Any], dict[int, int], dict[int, int], dict[int, str]]:
444
+ """Build one app-style command burst for a restored device."""
445
+
446
+ command_rows = payload.get("commands")
447
+ if not isinstance(command_rows, list):
448
+ command_rows = []
449
+
450
+ descriptors: list[dict[str, Any]] = []
451
+ seen_command_ids: set[int] = set()
452
+ for row in sorted(
453
+ (item for item in command_rows if isinstance(item, dict)),
454
+ key=lambda item: int(item.get("command_id", 0)),
455
+ ):
456
+ command_id = int(row.get("command_id", 0)) & 0xFF
457
+ if command_id == 0:
458
+ continue
459
+ if command_id in seen_command_ids:
460
+ raise ValueError(
461
+ f"duplicate backup command_id 0x{command_id:02X} in restore payload"
462
+ )
463
+ seen_command_ids.add(command_id)
464
+
465
+ restore_data = self._command_restore_data(row)
466
+ if not isinstance(restore_data, dict):
467
+ if strict:
468
+ raise ValueError(
469
+ f"command_id {command_id} is missing hub_code_record restore data"
470
+ )
471
+ continue
472
+ if restore_data.get("transport") != "hub_code_record":
473
+ if strict:
474
+ raise ValueError(
475
+ f"command_id {command_id} is missing hub_code_record restore data"
476
+ )
477
+ continue
478
+
479
+ edited_hex = self._edited_command_data_hex(restore_data, command_id)
480
+ if edited_hex is not None:
481
+ data_hex = edited_hex
482
+ else:
483
+ data_hex = str(restore_data.get("data_hex") or "").strip()
484
+ if not data_hex:
485
+ if strict:
486
+ raise ValueError(
487
+ f"command_id {command_id} is missing non-IR command data"
488
+ )
489
+ continue
490
+ try:
491
+ library_data = bytes.fromhex(data_hex)
492
+ except ValueError as exc:
493
+ raise ValueError(
494
+ f"invalid data_hex for command_id {command_id}: {data_hex!r}"
495
+ ) from exc
496
+ if not library_data:
497
+ if strict:
498
+ raise ValueError(
499
+ f"command_id {command_id} is missing non-IR command data"
500
+ )
501
+ continue
502
+
503
+ library_type_raw = restore_data.get("library_type")
504
+ if library_type_raw is None:
505
+ if strict:
506
+ raise ValueError(
507
+ f"command_id {command_id} is missing a valid library_type"
508
+ )
509
+ continue
510
+ try:
511
+ library_type = int(library_type_raw) & 0xFF
512
+ except (TypeError, ValueError) as exc:
513
+ raise ValueError(
514
+ f"command_id {command_id} is missing a valid library_type"
515
+ ) from exc
516
+
517
+ command_code = self._coerce_button_code(restore_data.get("button_code", 0))
518
+ raw_command_code = restore_data.get("command_code")
519
+ if raw_command_code is not None:
520
+ if isinstance(raw_command_code, str):
521
+ try:
522
+ command_code = (
523
+ int.from_bytes(bytes.fromhex(raw_command_code), "big")
524
+ & 0xFFFFFFFFFFFF
525
+ )
526
+ except ValueError as exc:
527
+ raise ValueError(
528
+ f"invalid command_code for command_id {command_id}: "
529
+ f"{raw_command_code!r}"
530
+ ) from exc
531
+ else:
532
+ command_code = int(raw_command_code) & 0xFFFFFFFFFFFF
533
+
534
+ descriptors.append(
535
+ {
536
+ "command_id": command_id,
537
+ "command_name": str(row.get("name") or f"Command {command_id}"),
538
+ "library_type": library_type,
539
+ "library_data": library_data,
540
+ "command_code": command_code,
541
+ }
542
+ )
543
+
544
+ burst_size = len(descriptors)
545
+ command_steps: list[Any] = []
546
+ command_id_map: dict[int, int] = {}
547
+ button_code_map: dict[int, int] = {}
548
+ command_names: dict[int, str] = {}
549
+ for command_seq, descriptor in enumerate(descriptors, start=1):
550
+ command_id = int(descriptor["command_id"]) & 0xFF
551
+ command_name = str(descriptor["command_name"] or f"Command {command_id}")
552
+ command_steps.extend(
553
+ build_command_write_steps(
554
+ hub_version=self.hub_version,
555
+ command_seq=command_seq,
556
+ command_burst_size=burst_size,
557
+ device_id=device_id & 0xFF,
558
+ button_id=command_id,
559
+ library_type=int(descriptor["library_type"]) & 0xFF,
560
+ button_code=int(descriptor["command_code"]) & 0xFFFFFFFFFFFF,
561
+ label=command_name,
562
+ library_data=bytes(descriptor["library_data"]),
563
+ ack_timeout=ack_timeout,
564
+ )
565
+ )
566
+ command_id_map[command_id] = command_id
567
+ button_code_map[command_id] = int(descriptor["command_code"]) & 0xFFFFFFFFFFFF
568
+ command_names[command_id] = command_name
569
+
570
+ return command_steps, command_id_map, button_code_map, command_names
571
+
572
+ def _restore_input_payload(
573
+ self,
574
+ *,
575
+ device_id: int,
576
+ input_record: dict[str, Any] | None,
577
+ inputs: list[dict[str, Any]],
578
+ map_command_id,
579
+ ) -> tuple[bytes | None, int]:
580
+ """Build the family-0x46 payload from backup metadata.
581
+
582
+ Replays the captured page faithfully: entries (with command_id
583
+ remapped to the restored device), plus the captured
584
+ ``source_id_byte``, position flags, trailing control-key rows,
585
+ favorite rows and state byte. ``inputModel`` is ``1`` for *all*
586
+ list-based switching styles (direct, menu, up/down, number-key,
587
+ source-cycling) -- they differ only in the 0x46 page contents,
588
+ so the trailing region must be replayed rather than gated on the
589
+ device-tail mode. A real direct device carries an all-zero
590
+ trailing region, so verbatim replay reproduces it. The page
591
+ round-trips identically on X1 and X1S/X2 via
592
+ :func:`build_inputs_write`.
593
+ """
594
+
595
+ record = input_record if isinstance(input_record, dict) else {}
596
+ record_entries = record.get("entries")
597
+ entry_source = (
598
+ list(record_entries)
599
+ if isinstance(record_entries, list) and record_entries
600
+ else list(inputs)
601
+ )
602
+
603
+ entries: list[InputEntry] = []
604
+ restored_inputs = 0
605
+ ordered_entries = sorted(
606
+ (item for item in entry_source if isinstance(item, dict)),
607
+ key=lambda item: int(
608
+ item.get("input_index", item.get("ordinal", 0)) or 0
609
+ ),
610
+ )
611
+ for ordinal_pos, row in enumerate(ordered_entries, start=1):
612
+ mapped_command_id = map_command_id(row.get("command_id"))
613
+ if mapped_command_id is None:
614
+ continue
615
+ entries.append(
616
+ InputEntry(
617
+ key_id=mapped_command_id,
618
+ fid=self._coerce_button_code(row.get("fid")) or synthesize_command_code(mapped_command_id),
619
+ ordinal=int(row.get("input_index", row.get("ordinal", ordinal_pos))) & 0xFF,
620
+ label=str(row.get("name") or row.get("label") or f"Input {mapped_command_id}"),
621
+ )
622
+ )
623
+ restored_inputs += 1
624
+
625
+ control_keys_raw = record.get("control_keys")
626
+ control_keys = ControlKeyBlock()
627
+ if isinstance(control_keys_raw, dict):
628
+ try:
629
+ control_keys = ControlKeyBlock(
630
+ input_list=bytes.fromhex(str(control_keys_raw.get("input_list") or "").strip()) if str(control_keys_raw.get("input_list") or "").strip() else b"",
631
+ input_up=bytes.fromhex(str(control_keys_raw.get("input_up") or "").strip()) if str(control_keys_raw.get("input_up") or "").strip() else b"",
632
+ input_down=bytes.fromhex(str(control_keys_raw.get("input_down") or "").strip()) if str(control_keys_raw.get("input_down") or "").strip() else b"",
633
+ input_confirm=bytes.fromhex(str(control_keys_raw.get("input_confirm") or "").strip()) if str(control_keys_raw.get("input_confirm") or "").strip() else b"",
634
+ )
635
+ except ValueError:
636
+ self._log.warning("[RESTORE] invalid input_record control_keys; falling back to zeroed rows")
637
+
638
+ favorite_rows: list[FavoriteSlot] = []
639
+ favorites_raw = record.get("favorites")
640
+ if isinstance(favorites_raw, list):
641
+ for row in favorites_raw:
642
+ if not isinstance(row, str):
643
+ continue
644
+ stripped = row.strip()
645
+ if not stripped:
646
+ favorite_rows.append(FavoriteSlot())
647
+ continue
648
+ try:
649
+ favorite_rows.append(FavoriteSlot(payload=bytes.fromhex(stripped)))
650
+ except ValueError:
651
+ self._log.warning("[RESTORE] invalid input_record favorite row %r; zeroing", row)
652
+ favorite_rows.append(FavoriteSlot())
653
+
654
+ source_id_byte = int(record.get("source_id_byte", 0)) & 0xFF
655
+ if source_id_byte == 0 and entries:
656
+ source_id_byte = 1
657
+
658
+ payload = build_inputs_write(
659
+ hub_version=self.hub_version,
660
+ device_id=device_id,
661
+ entries=entries,
662
+ source_id_byte=source_id_byte,
663
+ flag_a=int(record.get("flag_a", 0)) & 0xFF,
664
+ flag_b=int(record.get("flag_b", 0)) & 0xFF,
665
+ control_keys=control_keys,
666
+ favorites=favorite_rows,
667
+ state_byte=int(record.get("state_byte", 0)) & 0xFF,
668
+ )
669
+ return payload, restored_inputs
670
+
671
+ def _finalize_restore_device_result(
672
+ self,
673
+ *,
674
+ device_block: dict[str, Any],
675
+ device_class: str | None,
676
+ device_id: int,
677
+ command_names: dict[int, str],
678
+ post_steps: list[Any],
679
+ restored_commands: int,
680
+ restored_inputs: int,
681
+ restored_macros: int,
682
+ skipped_macro_steps: int,
683
+ command_id_map: dict[int, int],
684
+ request: DeviceCreateRequest,
685
+ ) -> DeviceCreateResult:
686
+ """Persist local state and build the shared result surface."""
687
+
688
+ self.state.commands[device_id] = dict(command_names)
689
+ self._commands_complete.add(device_id)
690
+ self.state.devices[device_id] = {
691
+ "name": str(device_block.get("name") or ""),
692
+ "brand": str(device_block.get("brand") or ""),
693
+ "device_class": device_class,
694
+ "device_class_code": int(device_block.get("device_class_code", 0)) & 0xFF,
695
+ "power_mode": int(device_block.get("power_mode", 0)) & 0xFF,
696
+ "power_model": int(device_block.get("power_mode", 0)) & 0xFF,
697
+ }
698
+
699
+ return DeviceCreateResult(
700
+ success=True,
701
+ device_id=device_id,
702
+ restored_commands=restored_commands,
703
+ restored_button_bindings=sum(
704
+ 1 for step in post_steps if step.family == 0x3E
705
+ ),
706
+ restored_macros=restored_macros,
707
+ restored_inputs=restored_inputs,
708
+ skipped_favorites=len(request.favorites),
709
+ skipped_macro_steps=skipped_macro_steps,
710
+ command_id_map=dict(command_id_map),
711
+ )
712
+
713
+ def _refresh_destination_catalog(self, *, timeout: float = 5.0) -> None:
714
+ """Synchronously refresh the destination hub's device + activity lists.
715
+
716
+ Mirrors the official Android app's
717
+ :class:`LoadingActivity.setIds()` prelude: before allocating
718
+ restore ids, query the *live* hub via ``request_devices`` /
719
+ ``request_activities`` so the subsequent
720
+ :meth:`_allocate_restore_device_id` allocates against fresh
721
+ ground truth rather than the proxy's local state (which can be
722
+ stale and lead the hub to silently override our targeted id --
723
+ e.g. the symptom where a freshly-restored device never appears
724
+ because we were writing into an already-allocated slot).
725
+
726
+ Best-effort: if the hub doesn't answer one of the requests
727
+ within ``timeout`` the call returns anyway and allocation falls
728
+ back on whatever state is already cached. We do not raise --
729
+ the existing allocator already copes with empty state.
730
+ """
731
+
732
+ import threading
733
+
734
+ if not self.can_issue_commands():
735
+ return
736
+
737
+ devices_done = threading.Event()
738
+ activities_done = threading.Event()
739
+
740
+ def _devices_cb(_: str) -> None:
741
+ devices_done.set()
742
+
743
+ def _activities_cb(_: str) -> None:
744
+ activities_done.set()
745
+
746
+ self._burst.on_burst_end("devices", _devices_cb)
747
+ self._burst.on_burst_end("activities", _activities_cb)
748
+
749
+ if not self.request_devices():
750
+ devices_done.set()
751
+ if not self.request_activities():
752
+ activities_done.set()
753
+
754
+ devices_done.wait(timeout)
755
+ activities_done.wait(timeout)
756
+
757
+ def _allocate_restore_device_id(self, preferred_device_id: int) -> int:
758
+ """Pick a free destination id for restore replay."""
759
+
760
+ used_ids = {
761
+ int(entity_id) & 0xFF
762
+ for bucket in (self.state.devices, self.state.activities)
763
+ if isinstance(bucket, dict)
764
+ for entity_id in bucket.keys()
765
+ if 0 < (int(entity_id) & 0xFF) < 0xFF
766
+ }
767
+ preferred = preferred_device_id & 0xFF
768
+ if 0 < preferred < 0xFF and preferred not in used_ids:
769
+ return preferred
770
+ for candidate in range(1, 0xFF):
771
+ if candidate not in used_ids:
772
+ return candidate
773
+ raise ValueError("destination hub has no free device ids for restore")
774
+
775
+ def _restore_hub_code_record_commands(
776
+ self,
777
+ *,
778
+ payload: dict[str, Any],
779
+ device_id: int,
780
+ ) -> tuple[dict[int, int], int]:
781
+ """Replay opaque hub-owned command records for Bluetooth and RF devices."""
782
+ _command_steps, command_id_map, button_code_map, _command_names = (
783
+ self._build_restore_command_batch(
784
+ payload=payload,
785
+ device_id=device_id,
786
+ strict=True,
787
+ )
788
+ )
789
+ self._restore_button_code_map_buffer = dict(button_code_map)
790
+ return command_id_map, len(command_id_map)
791
+
792
+ def _restore_commands_for_device_class(
793
+ self,
794
+ *,
795
+ payload: dict[str, Any],
796
+ device_id: int,
797
+ device_class: str | None,
798
+ ) -> tuple[dict[int, int], int]:
799
+ """Dispatch command replay to the correct device-class writer."""
800
+
801
+ if device_class == DEVICE_CLASS_IR:
802
+ return self._restore_ir_commands(payload=payload, device_id=device_id)
803
+ if device_class in (
804
+ DEVICE_CLASS_BLUETOOTH,
805
+ DEVICE_CLASS_RF_315,
806
+ DEVICE_CLASS_RF_433,
807
+ DEVICE_CLASS_WIFI_ROKU,
808
+ DEVICE_CLASS_WIFI_IP,
809
+ DEVICE_CLASS_WIFI_HUE,
810
+ DEVICE_CLASS_WIFI_MQTT,
811
+ DEVICE_CLASS_WIFI_SONOS,
812
+ ):
813
+ return self._restore_hub_code_record_commands(
814
+ payload=payload,
815
+ device_id=device_id,
816
+ )
817
+
818
+ raise ValueError(
819
+ "restore_device command replay is not implemented yet for "
820
+ f"device_class={device_class or 'unknown'}"
821
+ )
822
+
823
+ def restore_device(
824
+ self,
825
+ payload: dict[str, Any],
826
+ *,
827
+ wifi_commands_request_port: int = 8060,
828
+ ) -> dict[str, Any] | None:
829
+ """Restore a device from the payload returned by ``backup_device``.
830
+
831
+ Validates the payload and translates it into a
832
+ :class:`DeviceCreateRequest`; the on-the-wire orchestration
833
+ lives behind :func:`run_device_create`. All device classes --
834
+ IR, BT, RF, and the network-callback wifi variants -- route
835
+ through the same generic create + replay pipeline now; the
836
+ per-class differences are the codec bytes captured in each
837
+ command row's ``restore_data``, not the orchestration shape.
838
+
839
+ The legacy ``wifi_commands_request_port`` keyword is retained
840
+ for call-site compatibility but is otherwise unused -- callback
841
+ URLs in restored wifi devices are replayed byte-for-byte from
842
+ the source hub's capture. Re-pointing a restored wifi device's
843
+ callbacks at a fresh hub's HTTP listener is handled separately
844
+ by the Wifi Commands sync flow.
845
+ """
846
+
847
+ del wifi_commands_request_port # retained for API compatibility
848
+
849
+ try:
850
+ if not self.can_issue_commands():
851
+ self._log.info("[RESTORE] restore_device ignored: proxy client is connected")
852
+ return None
853
+
854
+ if not isinstance(payload, dict):
855
+ raise ValueError("restore payload must be a dictionary")
856
+ if payload.get("kind") != "device_backup":
857
+ raise ValueError("restore payload kind must be 'device_backup'")
858
+ if int(payload.get("schema_version", 0)) != DEVICE_BACKUP_SCHEMA_VERSION:
859
+ raise ValueError(
860
+ "restore_device payload schema_version must be "
861
+ f"{DEVICE_BACKUP_SCHEMA_VERSION} (got "
862
+ f"{payload.get('schema_version')!r}); re-export the device "
863
+ "with the current backup format"
864
+ )
865
+
866
+ device_block = payload.get("device")
867
+ if not isinstance(device_block, dict):
868
+ raise ValueError("restore payload must include a 'device' block")
869
+ device_class = self._restore_device_class(device_block)
870
+
871
+ self._validate_restore_capabilities(
872
+ hub_version=self.hub_version,
873
+ device_class=device_class,
874
+ payload=payload,
875
+ )
876
+
877
+ # Input entries flow exclusively through ``input_record`` in
878
+ # the slim format; device-level favorites are an activity
879
+ # concept and are not part of a device backup.
880
+ request = DeviceCreateRequest(
881
+ transport="ir",
882
+ device_block=dict(device_block),
883
+ commands=list(payload.get("commands") or []),
884
+ button_bindings=list(payload.get("button_bindings") or []),
885
+ macros=list(payload.get("macros") or []),
886
+ input_record=(
887
+ dict(payload.get("input_record"))
888
+ if isinstance(payload.get("input_record"), dict)
889
+ else None
890
+ ),
891
+ key_sort=(
892
+ dict(payload.get("key_sort"))
893
+ if isinstance(payload.get("key_sort"), dict)
894
+ else None
895
+ ),
896
+ )
897
+
898
+ result = run_device_create(self, request)
899
+ if not result.success or result.device_id is None:
900
+ return None
901
+ return _restore_device_dict_from_result(request, result)
902
+ except Exception:
903
+ self._log.exception("[RESTORE] restore_device failed")
904
+ raise
905
+
906
+ def _run_ir_device_create(
907
+ self, request: DeviceCreateRequest
908
+ ) -> DeviceCreateResult:
909
+ """Run the IR / BT / RF device-create pipeline.
910
+
911
+ Body relocated from ``restore_device``; inputs flow in through
912
+ :class:`DeviceCreateRequest` rather than directly off the
913
+ backup payload. Phase 7 keeps this method's wire-orchestration
914
+ shape identical to the previous restore-device pipeline -- the
915
+ unification target was the *entry point*, not the wire
916
+ sequence, since that sequence was already canonical (no
917
+ wifi-specific quirks, schema-driven step builders throughout).
918
+ """
919
+ if self.hub_version == HUB_VERSION_X1:
920
+ return self._run_x1_import_device_create(request)
921
+
922
+ return self._run_restore_style_device_create(request)
923
+
924
+ def _run_restore_style_device_create(
925
+ self, request: DeviceCreateRequest
926
+ ) -> DeviceCreateResult:
927
+ """Run the restore-style device-create flow used on X1S/X2."""
928
+
929
+ _input_create_step = _input_create_step_factory()
930
+ device_block = request.device_block
931
+ device_class = self._restore_device_class(device_block)
932
+ # Match the official app's setIds() prelude: query the live hub
933
+ # for its current device/activity lists before picking an id, so
934
+ # the allocator avoids slots the hub already considers taken.
935
+ # Without this, stale proxy state can lead us to target an id
936
+ # the hub silently overrides -- and subsequent writes end up
937
+ # addressing the wrong (or no) device on the hub.
938
+ self._refresh_destination_catalog()
939
+ target_device_id = self._allocate_restore_device_id(
940
+ int(device_block.get("device_id", 0)) & 0xFF
941
+ )
942
+ create_config = replace(
943
+ device_config_from_backup(device_block, for_create=False),
944
+ device_id=target_device_id,
945
+ )
946
+
947
+ self.reset_ack_queues()
948
+ create_result = _run_create_sequence(
949
+ self,
950
+ [build_device_create_step(create_config, hub_version=self.hub_version)],
951
+ )
952
+ if not create_result.success or create_result.assigned_device_id is None:
953
+ failed = (
954
+ create_result.failed_step.label
955
+ if create_result.failed_step is not None
956
+ else "device-create"
957
+ )
958
+ self._log.warning("[RESTORE] create phase failed at step %s", failed)
959
+ return DeviceCreateResult(success=False, failed_step_label=failed)
960
+
961
+ old_device_id = int(device_block.get("device_id", 0)) & 0xFF
962
+ new_device_id = create_result.assigned_device_id & 0xFF
963
+ if new_device_id != target_device_id:
964
+ self._log.warning(
965
+ "[RESTORE] hub assigned device_id=0x%02X after targeting 0x%02X",
966
+ new_device_id,
967
+ target_device_id,
968
+ )
969
+ self._log.info(
970
+ "[RESTORE] created device from backup old=0x%02X new=0x%02X",
971
+ old_device_id,
972
+ new_device_id,
973
+ )
974
+
975
+ self.state.commands.pop(new_device_id, None)
976
+ self.state.buttons.pop(new_device_id, None)
977
+ self.state.button_details.pop(new_device_id, None)
978
+ self.clear_entity_cache(new_device_id, clear_buttons=True)
979
+
980
+ command_steps, command_id_map, button_code_map, command_names = (
981
+ self._build_restore_command_batch(
982
+ payload={"commands": request.commands},
983
+ device_id=new_device_id,
984
+ strict=(device_class != DEVICE_CLASS_IR),
985
+ )
986
+ )
987
+ restored_commands = len(command_id_map)
988
+ self._restore_button_code_map_buffer = dict(button_code_map)
989
+
990
+ def _map_command_id(raw_command_id: Any) -> int | None:
991
+ try:
992
+ old_command_id = int(raw_command_id) & 0xFF
993
+ except (TypeError, ValueError):
994
+ return None
995
+ if old_command_id == 0:
996
+ return None
997
+ return command_id_map.get(old_command_id)
998
+
999
+ def _button_code_for(command_id: int) -> int:
1000
+ captured = button_code_map.get(command_id, 0)
1001
+ return captured or synthesize_command_code(command_id)
1002
+
1003
+ post_steps = [
1004
+ build_set_idle_behavior_step(
1005
+ device_id=new_device_id,
1006
+ mode=int(device_block.get("power_mode", 0)) & 0xFF,
1007
+ )
1008
+ ]
1009
+ post_steps.extend(command_steps)
1010
+ if isinstance(request.key_sort, dict):
1011
+ key_sort_msg_hex = str(request.key_sort.get("msg_hex") or "").strip()
1012
+ if key_sort_msg_hex:
1013
+ post_steps.extend(
1014
+ build_key_sort_steps(
1015
+ device_id=new_device_id,
1016
+ msg_hex=key_sort_msg_hex,
1017
+ )
1018
+ )
1019
+
1020
+ restored_inputs = 0
1021
+ input_mode = int(device_block.get("input_mode", 0)) & 0xFF
1022
+ inputs_configured = bool(device_block.get("inputs_configured", input_mode != 0))
1023
+ if inputs_configured and (request.inputs or request.input_record):
1024
+ inputs_payload, restored_inputs = self._restore_input_payload(
1025
+ device_id=new_device_id,
1026
+ input_record=request.input_record,
1027
+ inputs=request.inputs,
1028
+ map_command_id=_map_command_id,
1029
+ )
1030
+ if inputs_payload is not None:
1031
+ post_steps.append(
1032
+ _input_create_step(
1033
+ device_id=new_device_id,
1034
+ payload=inputs_payload,
1035
+ label_suffix=f"count={restored_inputs}",
1036
+ )
1037
+ )
1038
+
1039
+ skipped_macro_steps = 0
1040
+ restored_macros = 0
1041
+ for row in sorted(
1042
+ (item for item in request.macros if isinstance(item, dict)),
1043
+ key=lambda item: int(item.get("button_id", 0)),
1044
+ ):
1045
+ button_id = int(row.get("button_id", 0)) & 0xFF
1046
+ if button_id == 0:
1047
+ continue
1048
+ step_records = bytearray()
1049
+ steps = row.get("steps")
1050
+ if isinstance(steps, list):
1051
+ for entry in steps:
1052
+ if not isinstance(entry, dict):
1053
+ continue
1054
+ raw_command_id = entry.get("command_id")
1055
+ raw_command_lo = int(raw_command_id or 0) & 0xFF
1056
+ if raw_command_lo == 0xFF:
1057
+ # Delay/wait row: emit the firmware sentinel
1058
+ # record. All head bytes are 0xFF (dev_id,
1059
+ # cmd_id, the 6-byte fid, and the duration
1060
+ # byte); the last byte holds the pause length.
1061
+ step_records.extend(
1062
+ build_macro_step_record(
1063
+ device_id=0xFF,
1064
+ command_id=0xFF,
1065
+ fid=0xFFFFFFFFFFFF,
1066
+ duration=0xFF,
1067
+ delay=int(entry.get("delay", 0xFF)) & 0xFF,
1068
+ )
1069
+ )
1070
+ continue
1071
+ mapped_command_id = _map_command_id(raw_command_id)
1072
+ if mapped_command_id is None:
1073
+ if raw_command_lo != 0:
1074
+ self._log.warning(
1075
+ "[RESTORE] macro key=0x%02X skipped step "
1076
+ "with unmapped command_id=%r",
1077
+ button_id,
1078
+ raw_command_id,
1079
+ )
1080
+ skipped_macro_steps += 1
1081
+ continue
1082
+ step_records.extend(
1083
+ build_macro_step_record(
1084
+ device_id=new_device_id,
1085
+ command_id=mapped_command_id,
1086
+ fid=_button_code_for(mapped_command_id),
1087
+ duration=int(entry.get("duration", 0)) & 0xFF,
1088
+ delay=int(entry.get("delay", 0xFF)) & 0xFF,
1089
+ )
1090
+ )
1091
+ post_steps.append(
1092
+ build_macro_step(
1093
+ hub_version=self.hub_version,
1094
+ device_id=new_device_id,
1095
+ key_id=button_id,
1096
+ label=str(row.get("name") or ""),
1097
+ step_records=bytes(step_records),
1098
+ )
1099
+ )
1100
+ restored_macros += 1
1101
+
1102
+ for row in sorted(
1103
+ (item for item in request.button_bindings if isinstance(item, dict)),
1104
+ key=lambda item: int(item.get("button_id", 0)),
1105
+ ):
1106
+ new_command_id = _map_command_id(row.get("command_id"))
1107
+ button_id = int(row.get("button_id", 0)) & 0xFF
1108
+ if button_id == 0 or new_command_id is None:
1109
+ continue
1110
+ long_press_command_id = _map_command_id(row.get("long_press_command_id"))
1111
+ kwargs: dict[str, Any] = {
1112
+ "device_id": new_device_id,
1113
+ "button_id": button_id,
1114
+ "short_press_device_id": new_device_id,
1115
+ "short_press_button_code": _button_code_for(new_command_id),
1116
+ "short_press_button_id": new_command_id,
1117
+ }
1118
+ if long_press_command_id is not None:
1119
+ kwargs["long_press_device_id"] = new_device_id
1120
+ kwargs["long_press_button_code"] = _button_code_for(long_press_command_id)
1121
+ kwargs["long_press_button_id"] = long_press_command_id
1122
+ post_steps.append(build_button_binding_step(**kwargs))
1123
+
1124
+ self.reset_ack_queues()
1125
+ post_result = _run_create_sequence(self, post_steps)
1126
+ if not post_result.success:
1127
+ failed = (
1128
+ post_result.failed_step.label
1129
+ if post_result.failed_step is not None
1130
+ else "post-create"
1131
+ )
1132
+ self._log.warning("[RESTORE] finalize phase failed at step %s", failed)
1133
+ return DeviceCreateResult(
1134
+ success=False,
1135
+ device_id=new_device_id,
1136
+ failed_step_label=failed,
1137
+ )
1138
+
1139
+ return self._finalize_restore_device_result(
1140
+ device_block=device_block,
1141
+ device_class=device_class,
1142
+ device_id=new_device_id,
1143
+ command_names=command_names,
1144
+ post_steps=post_steps,
1145
+ restored_commands=restored_commands,
1146
+ restored_inputs=restored_inputs,
1147
+ restored_macros=restored_macros,
1148
+ skipped_macro_steps=skipped_macro_steps,
1149
+ command_id_map=command_id_map,
1150
+ request=request,
1151
+ )
1152
+
1153
+ def _run_x1_import_device_create(
1154
+ self, request: DeviceCreateRequest
1155
+ ) -> DeviceCreateResult:
1156
+ """Run an X1-specific import flow modeled after normal app add-device."""
1157
+
1158
+ _input_create_step = _input_create_step_factory()
1159
+ device_block = request.device_block
1160
+ device_class = self._restore_device_class(device_block)
1161
+ create_config = replace(
1162
+ device_config_from_backup(device_block, for_create=True),
1163
+ # On X1, create the device as input-unconfigured and let the
1164
+ # later 0x46 + final 0x08 establish the real input state. Keep
1165
+ # power-related bytes as-is for now; that path is already
1166
+ # behaving correctly in live restores.
1167
+ input_flag=0,
1168
+ input_mode=0,
1169
+ )
1170
+
1171
+ self.reset_ack_queues()
1172
+ create_result = _run_create_sequence(
1173
+ self,
1174
+ [build_device_create_step(create_config, hub_version=self.hub_version)],
1175
+ )
1176
+ if not create_result.success or create_result.assigned_device_id is None:
1177
+ failed = (
1178
+ create_result.failed_step.label
1179
+ if create_result.failed_step is not None
1180
+ else "device-create"
1181
+ )
1182
+ self._log.warning("[RESTORE] X1 import create phase failed at step %s", failed)
1183
+ return DeviceCreateResult(success=False, failed_step_label=failed)
1184
+
1185
+ old_device_id = int(device_block.get("device_id", 0)) & 0xFF
1186
+ new_device_id = create_result.assigned_device_id & 0xFF
1187
+ self._log.info(
1188
+ "[RESTORE] X1 import created device from backup old=0x%02X new=0x%02X",
1189
+ old_device_id,
1190
+ new_device_id,
1191
+ )
1192
+
1193
+ self.state.commands.pop(new_device_id, None)
1194
+ self.state.buttons.pop(new_device_id, None)
1195
+ self.state.button_details.pop(new_device_id, None)
1196
+ self.clear_entity_cache(new_device_id, clear_buttons=True)
1197
+
1198
+ command_steps, command_id_map, button_code_map, command_names = (
1199
+ self._build_restore_command_batch(
1200
+ payload={"commands": request.commands},
1201
+ device_id=new_device_id,
1202
+ strict=(device_class != DEVICE_CLASS_IR),
1203
+ ack_timeout=_X1_IMPORT_COMMAND_ACK_TIMEOUT,
1204
+ )
1205
+ )
1206
+ restored_commands = len(command_id_map)
1207
+ self._restore_button_code_map_buffer = dict(button_code_map)
1208
+
1209
+ def _map_command_id(raw_command_id: Any) -> int | None:
1210
+ try:
1211
+ old_command_id = int(raw_command_id) & 0xFF
1212
+ except (TypeError, ValueError):
1213
+ return None
1214
+ if old_command_id == 0:
1215
+ return None
1216
+ return command_id_map.get(old_command_id)
1217
+
1218
+ def _button_code_for(command_id: int) -> int:
1219
+ captured = button_code_map.get(command_id, 0)
1220
+ return captured or synthesize_command_code(command_id)
1221
+
1222
+ post_steps = list(command_steps)
1223
+
1224
+ for row in sorted(
1225
+ (item for item in request.button_bindings if isinstance(item, dict)),
1226
+ key=lambda item: int(item.get("button_id", 0)),
1227
+ ):
1228
+ new_command_id = _map_command_id(row.get("command_id"))
1229
+ button_id = int(row.get("button_id", 0)) & 0xFF
1230
+ if button_id == 0 or new_command_id is None:
1231
+ continue
1232
+ long_press_command_id = _map_command_id(row.get("long_press_command_id"))
1233
+ kwargs: dict[str, Any] = {
1234
+ "device_id": new_device_id,
1235
+ "button_id": button_id,
1236
+ "short_press_device_id": new_device_id,
1237
+ "short_press_button_code": _button_code_for(new_command_id),
1238
+ "short_press_button_id": new_command_id,
1239
+ }
1240
+ if long_press_command_id is not None:
1241
+ kwargs["long_press_device_id"] = new_device_id
1242
+ kwargs["long_press_button_code"] = _button_code_for(long_press_command_id)
1243
+ kwargs["long_press_button_id"] = long_press_command_id
1244
+ post_steps.append(build_button_binding_step(**kwargs))
1245
+
1246
+ post_steps.append(
1247
+ build_set_idle_behavior_step(
1248
+ device_id=new_device_id,
1249
+ mode=int(device_block.get("power_mode", 0)) & 0xFF,
1250
+ )
1251
+ )
1252
+
1253
+ skipped_macro_steps = 0
1254
+ restored_macros = 0
1255
+ for row in sorted(
1256
+ (item for item in request.macros if isinstance(item, dict)),
1257
+ key=lambda item: int(item.get("button_id", 0)),
1258
+ ):
1259
+ button_id = int(row.get("button_id", 0)) & 0xFF
1260
+ if button_id == 0:
1261
+ continue
1262
+ step_records = bytearray()
1263
+ steps = row.get("steps")
1264
+ if isinstance(steps, list):
1265
+ for entry in steps:
1266
+ if not isinstance(entry, dict):
1267
+ continue
1268
+ raw_command_id = entry.get("command_id")
1269
+ raw_command_lo = int(raw_command_id or 0) & 0xFF
1270
+ if raw_command_lo == 0xFF:
1271
+ # Delay/wait row: emit the firmware sentinel
1272
+ # record. All head bytes are 0xFF (dev_id,
1273
+ # cmd_id, the 6-byte fid, and the duration
1274
+ # byte); the last byte holds the pause length.
1275
+ step_records.extend(
1276
+ build_macro_step_record(
1277
+ device_id=0xFF,
1278
+ command_id=0xFF,
1279
+ fid=0xFFFFFFFFFFFF,
1280
+ duration=0xFF,
1281
+ delay=int(entry.get("delay", 0xFF)) & 0xFF,
1282
+ )
1283
+ )
1284
+ continue
1285
+ mapped_command_id = _map_command_id(raw_command_id)
1286
+ if mapped_command_id is None:
1287
+ if raw_command_lo != 0:
1288
+ self._log.warning(
1289
+ "[RESTORE] macro key=0x%02X skipped step "
1290
+ "with unmapped command_id=%r",
1291
+ button_id,
1292
+ raw_command_id,
1293
+ )
1294
+ skipped_macro_steps += 1
1295
+ continue
1296
+ step_records.extend(
1297
+ build_macro_step_record(
1298
+ device_id=new_device_id,
1299
+ command_id=mapped_command_id,
1300
+ fid=_button_code_for(mapped_command_id),
1301
+ duration=int(entry.get("duration", 0)) & 0xFF,
1302
+ delay=int(entry.get("delay", 0xFF)) & 0xFF,
1303
+ )
1304
+ )
1305
+ post_steps.append(
1306
+ build_macro_step(
1307
+ hub_version=self.hub_version,
1308
+ device_id=new_device_id,
1309
+ key_id=button_id,
1310
+ label=str(row.get("name") or ""),
1311
+ step_records=bytes(step_records),
1312
+ )
1313
+ )
1314
+ restored_macros += 1
1315
+
1316
+ restored_inputs = 0
1317
+ input_mode = int(device_block.get("input_mode", 0)) & 0xFF
1318
+ inputs_configured = bool(device_block.get("inputs_configured", input_mode != 0))
1319
+ inputs_payload: bytes | None = None
1320
+ if inputs_configured and (request.inputs or request.input_record):
1321
+ inputs_payload, restored_inputs = self._restore_input_payload(
1322
+ device_id=new_device_id,
1323
+ input_record=request.input_record,
1324
+ inputs=request.inputs,
1325
+ map_command_id=_map_command_id,
1326
+ )
1327
+ if inputs_payload is not None:
1328
+ post_steps.append(
1329
+ _input_create_step(
1330
+ device_id=new_device_id,
1331
+ payload=inputs_payload,
1332
+ label_suffix=f"count={restored_inputs}",
1333
+ )
1334
+ )
1335
+ else:
1336
+ post_steps.append(
1337
+ _input_create_step(
1338
+ device_id=new_device_id,
1339
+ payload=build_inputs_write(
1340
+ hub_version=self.hub_version,
1341
+ device_id=new_device_id,
1342
+ source_id_byte=0,
1343
+ ),
1344
+ label_suffix="default",
1345
+ )
1346
+ )
1347
+
1348
+ finalize_config = replace(
1349
+ device_config_from_backup(device_block, for_create=False),
1350
+ device_id=new_device_id,
1351
+ )
1352
+ post_steps.append(
1353
+ build_device_update_step(
1354
+ finalize_config,
1355
+ hub_version=self.hub_version,
1356
+ )
1357
+ )
1358
+
1359
+ self.reset_ack_queues()
1360
+ post_result = _run_create_sequence(self, post_steps)
1361
+ if not post_result.success:
1362
+ failed = (
1363
+ post_result.failed_step.label
1364
+ if post_result.failed_step is not None
1365
+ else "post-create"
1366
+ )
1367
+ self._log.warning("[RESTORE] X1 import finalize phase failed at step %s", failed)
1368
+ return DeviceCreateResult(
1369
+ success=False,
1370
+ device_id=new_device_id,
1371
+ failed_step_label=failed,
1372
+ )
1373
+
1374
+ return self._finalize_restore_device_result(
1375
+ device_block=device_block,
1376
+ device_class=device_class,
1377
+ device_id=new_device_id,
1378
+ command_names=command_names,
1379
+ post_steps=post_steps,
1380
+ restored_commands=restored_commands,
1381
+ restored_inputs=restored_inputs,
1382
+ restored_macros=restored_macros,
1383
+ skipped_macro_steps=skipped_macro_steps,
1384
+ command_id_map=command_id_map,
1385
+ request=request,
1386
+ )
1387
+
1388
+ def restore_activity(
1389
+ self,
1390
+ payload: dict[str, Any],
1391
+ *,
1392
+ device_id_map: dict[int, int],
1393
+ bundle_devices_by_source_id: dict[int, dict[str, Any]] | None = None,
1394
+ command_id_maps_by_source_device_id: dict[int, dict[int, int]] | None = None,
1395
+ ) -> dict[str, Any] | None:
1396
+ """Restore an activity from a backup payload.
1397
+
1398
+ Thin adapter over :func:`run_device_create` with
1399
+ ``entity_kind='activity'``. Activities share the device-record
1400
+ schema but live in a different opcode family (``0x37``) and
1401
+ their content is references to other devices' commands; the
1402
+ caller-supplied ``device_id_map`` translates source-side
1403
+ device ids to the ids the destination hub has assigned to
1404
+ those devices.
1405
+
1406
+ ``bundle_devices_by_source_id`` and
1407
+ ``command_id_maps_by_source_device_id`` are populated by the
1408
+ bundle-restore orchestrator (:meth:`restore_hub_bundle`) and
1409
+ carry the data needed to re-resolve ``key_id=0xC5`` macro
1410
+ rows (the "set input on device" marker) against the
1411
+ freshly-restored devices' command ids. Both are optional;
1412
+ when omitted, ``0xC5`` rows preserve their raw ``duration``
1413
+ byte verbatim.
1414
+
1415
+ Validation:
1416
+
1417
+ - Payload must declare ``kind == 'activity_backup'``.
1418
+ - ``device_id_map`` must cover every distinct source device id
1419
+ referenced anywhere in the payload's button bindings, macro
1420
+ steps, and favourites; missing keys raise ``ValueError``.
1421
+ """
1422
+
1423
+ try:
1424
+ if not self.can_issue_commands():
1425
+ self._log.info("[RESTORE] restore_activity ignored: proxy client is connected")
1426
+ return None
1427
+ if not isinstance(payload, dict):
1428
+ raise ValueError("restore payload must be a dictionary")
1429
+ if payload.get("kind") != "activity_backup":
1430
+ raise ValueError("restore_activity expects kind == 'activity_backup'")
1431
+ if int(payload.get("schema_version", 0)) != ACTIVITY_BACKUP_SCHEMA_VERSION:
1432
+ raise ValueError(
1433
+ "restore_activity payload schema_version must be "
1434
+ f"{ACTIVITY_BACKUP_SCHEMA_VERSION} (got "
1435
+ f"{payload.get('schema_version')!r}); re-export the activity "
1436
+ "with the current backup format"
1437
+ )
1438
+ activity_block = payload.get("device")
1439
+ if not isinstance(activity_block, dict):
1440
+ raise ValueError("restore payload must include a 'device' block")
1441
+ if activity_block.get("entity_type") != "activity":
1442
+ raise ValueError(
1443
+ "restore_activity payload's 'device' block must mark entity_type='activity'"
1444
+ )
1445
+
1446
+ referenced = self._collect_referenced_source_device_ids(payload)
1447
+ missing = referenced - {int(k) & 0xFF for k in device_id_map.keys()}
1448
+ if missing:
1449
+ missing_list = ", ".join(f"0x{m:02X}" for m in sorted(missing))
1450
+ raise ValueError(
1451
+ "device_id_map is missing the following source device ids "
1452
+ f"referenced by this activity backup: {missing_list}"
1453
+ )
1454
+
1455
+ remap_lookup = {
1456
+ int(k) & 0xFF: int(v) & 0xFF for k, v in device_id_map.items()
1457
+ }
1458
+ bundle_devices = {
1459
+ int(k) & 0xFF: v
1460
+ for k, v in (bundle_devices_by_source_id or {}).items()
1461
+ if isinstance(v, dict)
1462
+ }
1463
+ command_id_maps = {
1464
+ int(k) & 0xFF: {
1465
+ int(src) & 0xFF: int(dst) & 0xFF for src, dst in (m or {}).items()
1466
+ }
1467
+ for k, m in (command_id_maps_by_source_device_id or {}).items()
1468
+ if isinstance(m, dict)
1469
+ }
1470
+ request = DeviceCreateRequest(
1471
+ transport="ir",
1472
+ entity_kind="activity",
1473
+ device_block=dict(activity_block),
1474
+ button_bindings=list(payload.get("button_bindings") or []),
1475
+ macros=list(payload.get("macros") or []),
1476
+ favorites=list(payload.get("favorite_slots") or []),
1477
+ device_id_map=remap_lookup,
1478
+ bundle_devices_by_source_id=bundle_devices,
1479
+ command_id_maps_by_source_device_id=command_id_maps,
1480
+ )
1481
+
1482
+ result = run_device_create(self, request)
1483
+ if not result.success or result.device_id is None:
1484
+ return None
1485
+ return {
1486
+ "status": "success",
1487
+ "activity_id": result.device_id,
1488
+ "restored_button_bindings": result.restored_button_bindings,
1489
+ "restored_macros": result.restored_macros,
1490
+ "restored_favorites": result.restored_inputs,
1491
+ "skipped_favorites": result.skipped_favorites,
1492
+ "skipped_macro_steps": result.skipped_macro_steps,
1493
+ "skipped_input_ordinals": result.skipped_input_ordinals,
1494
+ "device_id_map": {
1495
+ str(old): new for old, new in sorted(remap_lookup.items())
1496
+ },
1497
+ }
1498
+ except Exception:
1499
+ self._log.exception("[RESTORE] restore_activity failed")
1500
+ raise
1501
+
1502
+ def restore_hub_bundle(
1503
+ self,
1504
+ payload: dict[str, Any],
1505
+ *,
1506
+ wifi_commands_request_port: int = 8060,
1507
+ progress_callback=None,
1508
+ progress_offset: int = 0,
1509
+ progress_total_steps: int | None = None,
1510
+ ) -> dict[str, Any]:
1511
+ """Restore a ``hub_bundle`` payload onto the live hub.
1512
+
1513
+ Devices in the bundle are restored first; the
1514
+ ``source_device_id -> new_device_id`` map is auto-built from
1515
+ their results. Activities are restored second, threaded with
1516
+ that map plus the per-source-device ``command_id_map`` and
1517
+ the original device payloads so ``0xC5`` macro rows can be
1518
+ re-resolved against the freshly-restored devices.
1519
+
1520
+ Returns a dict describing the outcome:
1521
+
1522
+ - ``status`` -- ``"success"`` or ``"failed"``.
1523
+ - ``failed_at`` -- ``["device" | "activity", source_id]``
1524
+ when a phase fails; absent on success.
1525
+ - ``device_id_map`` -- mapping of source_device_id (string)
1526
+ to assigned device_id (int).
1527
+ - ``restored_devices`` / ``restored_activities`` -- counts.
1528
+
1529
+ On mid-bundle failure no rollback is attempted: previously
1530
+ restored devices stay on the hub and the unfinished tail is
1531
+ skipped. The caller surfaces the partial state to the user.
1532
+ """
1533
+
1534
+ def _progress(**progress_payload: Any) -> None:
1535
+ if callable(progress_callback):
1536
+ progress_callback(**progress_payload)
1537
+
1538
+ if not isinstance(payload, dict):
1539
+ raise ValueError("restore_hub_bundle payload must be a dict")
1540
+ if payload.get("kind") != "hub_bundle":
1541
+ raise ValueError(
1542
+ "restore_hub_bundle expects kind == 'hub_bundle'"
1543
+ )
1544
+ if int(payload.get("schema_version", 0)) != HUB_BUNDLE_SCHEMA_VERSION:
1545
+ raise ValueError(
1546
+ "restore_hub_bundle payload schema_version must be "
1547
+ f"{HUB_BUNDLE_SCHEMA_VERSION} "
1548
+ f"(got {payload.get('schema_version')!r})"
1549
+ )
1550
+ if not self.can_issue_commands():
1551
+ self._log.info(
1552
+ "[RESTORE] restore_hub_bundle ignored: proxy client is connected"
1553
+ )
1554
+ return {"status": "failed", "failed_at": ["proxy", None]}
1555
+
1556
+ devices = list(payload.get("devices") or [])
1557
+ activities = list(payload.get("activities") or [])
1558
+ total_steps = int(progress_total_steps or (progress_offset + len(devices) + len(activities)))
1559
+ completed_steps = int(progress_offset)
1560
+
1561
+ device_id_map: dict[int, int] = {}
1562
+ command_id_maps: dict[int, dict[int, int]] = {}
1563
+ bundle_devices_by_source_id: dict[int, dict[str, Any]] = {}
1564
+ restored_devices: list[dict[str, Any]] = []
1565
+ for device_payload in devices:
1566
+ if not isinstance(device_payload, dict):
1567
+ continue
1568
+ device_block = device_payload.get("device") or {}
1569
+ src_id = int(device_block.get("device_id", 0)) & 0xFF
1570
+ if src_id == 0:
1571
+ self._log.warning(
1572
+ "[RESTORE] bundle device payload has no source device_id; skipping"
1573
+ )
1574
+ continue
1575
+ _progress(
1576
+ status="running",
1577
+ phase="device",
1578
+ message=f"Restoring device {src_id}…",
1579
+ completed_steps=completed_steps,
1580
+ total_steps=total_steps,
1581
+ current_device_id=src_id,
1582
+ )
1583
+ bundle_devices_by_source_id[src_id] = device_payload
1584
+ result = self.restore_device(
1585
+ payload=device_payload,
1586
+ wifi_commands_request_port=wifi_commands_request_port,
1587
+ )
1588
+ if not isinstance(result, dict) or result.get("status") != "success":
1589
+ self._log.warning(
1590
+ "[RESTORE] bundle device 0x%02X failed -- "
1591
+ "leaving previously restored devices in place",
1592
+ src_id,
1593
+ )
1594
+ return {
1595
+ "status": "failed",
1596
+ "failed_at": ["device", src_id],
1597
+ "device_id_map": {
1598
+ str(s): n for s, n in sorted(device_id_map.items())
1599
+ },
1600
+ "restored_devices": restored_devices,
1601
+ "restored_activities": [],
1602
+ }
1603
+ new_id = int(result.get("device_id", 0)) & 0xFF
1604
+ device_id_map[src_id] = new_id
1605
+ cmd_map_raw = result.get("command_id_map") or {}
1606
+ command_id_maps[src_id] = {
1607
+ int(k) & 0xFF: int(v) & 0xFF for k, v in cmd_map_raw.items()
1608
+ }
1609
+ restored_devices.append(
1610
+ {
1611
+ "source_device_id": src_id,
1612
+ "device_id": new_id,
1613
+ "restored_commands": result.get("restored_commands", 0),
1614
+ }
1615
+ )
1616
+ completed_steps += 1
1617
+ _progress(
1618
+ status="running",
1619
+ phase="device",
1620
+ message=f"Restored device {src_id}.",
1621
+ completed_steps=completed_steps,
1622
+ total_steps=total_steps,
1623
+ current_device_id=src_id,
1624
+ )
1625
+
1626
+ restored_activities: list[dict[str, Any]] = []
1627
+ for activity_payload in activities:
1628
+ if not isinstance(activity_payload, dict):
1629
+ continue
1630
+ activity_block = activity_payload.get("device") or {}
1631
+ src_act_id = int(activity_block.get("device_id", 0)) & 0xFF
1632
+ _progress(
1633
+ status="running",
1634
+ phase="activity",
1635
+ message=f"Restoring activity {src_act_id}…",
1636
+ completed_steps=completed_steps,
1637
+ total_steps=total_steps,
1638
+ current_activity_id=src_act_id,
1639
+ )
1640
+ try:
1641
+ result = self.restore_activity(
1642
+ payload=activity_payload,
1643
+ device_id_map=device_id_map,
1644
+ bundle_devices_by_source_id=bundle_devices_by_source_id,
1645
+ command_id_maps_by_source_device_id=command_id_maps,
1646
+ )
1647
+ except Exception:
1648
+ self._log.exception(
1649
+ "[RESTORE] bundle activity 0x%02X raised", src_act_id
1650
+ )
1651
+ return {
1652
+ "status": "failed",
1653
+ "failed_at": ["activity", src_act_id],
1654
+ "device_id_map": {
1655
+ str(s): n for s, n in sorted(device_id_map.items())
1656
+ },
1657
+ "restored_devices": restored_devices,
1658
+ "restored_activities": restored_activities,
1659
+ }
1660
+ if not isinstance(result, dict) or result.get("status") != "success":
1661
+ return {
1662
+ "status": "failed",
1663
+ "failed_at": ["activity", src_act_id],
1664
+ "device_id_map": {
1665
+ str(s): n for s, n in sorted(device_id_map.items())
1666
+ },
1667
+ "restored_devices": restored_devices,
1668
+ "restored_activities": restored_activities,
1669
+ }
1670
+ restored_activities.append(
1671
+ {
1672
+ "source_activity_id": src_act_id,
1673
+ "activity_id": int(result.get("activity_id", 0)) & 0xFF,
1674
+ "skipped_input_ordinals": result.get(
1675
+ "skipped_input_ordinals", 0
1676
+ ),
1677
+ }
1678
+ )
1679
+ completed_steps += 1
1680
+ _progress(
1681
+ status="running",
1682
+ phase="activity",
1683
+ message=f"Restored activity {src_act_id}.",
1684
+ completed_steps=completed_steps,
1685
+ total_steps=total_steps,
1686
+ current_activity_id=src_act_id,
1687
+ )
1688
+
1689
+ return {
1690
+ "status": "success",
1691
+ "device_id_map": {
1692
+ str(s): n for s, n in sorted(device_id_map.items())
1693
+ },
1694
+ "restored_devices": restored_devices,
1695
+ "restored_activities": restored_activities,
1696
+ }
1697
+
1698
+ def _run_activity_create(
1699
+ self, request: DeviceCreateRequest
1700
+ ) -> DeviceCreateResult:
1701
+ """Run the family-0x37 activity-create pipeline.
1702
+
1703
+ Mirrors :meth:`_run_ir_device_create` but writes the activity
1704
+ record (family ``0x37``), skips the per-device command-write
1705
+ phase (activities reference commands on other devices, they
1706
+ own none), runs no device-update / inputs page, and replays
1707
+ favorites via :meth:`command_to_favorite` -- the same write
1708
+ path the live UI uses when the user adds a favorite.
1709
+
1710
+ Inputs come from :class:`DeviceCreateRequest`:
1711
+ :attr:`device_block` carries the activity record fields,
1712
+ :attr:`button_bindings` / :attr:`macros` / :attr:`favorites`
1713
+ carry the backup content, and :attr:`device_id_map` translates
1714
+ the source-side device ids embedded in those rows.
1715
+ """
1716
+
1717
+ activity_block = request.device_block
1718
+ remap_lookup = dict(request.device_id_map)
1719
+
1720
+ def _map_device_id(raw: Any) -> int | None:
1721
+ try:
1722
+ src = int(raw) & 0xFF
1723
+ except (TypeError, ValueError):
1724
+ return None
1725
+ if src == 0:
1726
+ return None
1727
+ return remap_lookup.get(src)
1728
+
1729
+ create_config = device_config_from_backup(activity_block, for_create=True)
1730
+ self.reset_ack_queues()
1731
+ create_result = _run_create_sequence(
1732
+ self,
1733
+ [
1734
+ build_device_create_step(
1735
+ create_config,
1736
+ hub_version=self.hub_version,
1737
+ family=FAMILY_ACTIVITY_CREATE,
1738
+ )
1739
+ ],
1740
+ )
1741
+ if not create_result.success or create_result.assigned_device_id is None:
1742
+ failed = (
1743
+ create_result.failed_step.label
1744
+ if create_result.failed_step is not None
1745
+ else "activity-create"
1746
+ )
1747
+ self._log.warning("[RESTORE] activity create phase failed at step %s", failed)
1748
+ return DeviceCreateResult(success=False, failed_step_label=failed)
1749
+
1750
+ old_activity_id = int(activity_block.get("device_id", 0)) & 0xFF
1751
+ new_activity_id = create_result.assigned_device_id & 0xFF
1752
+ self._log.info(
1753
+ "[RESTORE] created activity from backup old=0x%02X new=0x%02X",
1754
+ old_activity_id,
1755
+ new_activity_id,
1756
+ )
1757
+
1758
+ self.state.commands.pop(new_activity_id, None)
1759
+ self.state.buttons.pop(new_activity_id, None)
1760
+ self.state.button_details.pop(new_activity_id, None)
1761
+ self.clear_entity_cache(new_activity_id, clear_buttons=True)
1762
+
1763
+ post_steps = []
1764
+ restored_button_bindings = 0
1765
+
1766
+ for row in sorted(
1767
+ (item for item in request.button_bindings if isinstance(item, dict)),
1768
+ key=lambda item: int(item.get("button_id", 0)),
1769
+ ):
1770
+ button_id = int(row.get("button_id", 0)) & 0xFF
1771
+ new_target_device = _map_device_id(row.get("device_id"))
1772
+ if button_id == 0 or new_target_device is None:
1773
+ continue
1774
+ target_command_id = int(row.get("command_id", 0)) & 0xFF
1775
+ short_press_code = (
1776
+ synthesize_command_code(target_command_id)
1777
+ if target_command_id
1778
+ else 0
1779
+ )
1780
+ kwargs: dict[str, Any] = {
1781
+ "device_id": new_activity_id,
1782
+ "button_id": button_id,
1783
+ "short_press_device_id": new_target_device,
1784
+ "short_press_button_code": short_press_code,
1785
+ "short_press_button_id": target_command_id,
1786
+ }
1787
+ new_lp_device = _map_device_id(row.get("long_press_device_id"))
1788
+ if new_lp_device is not None:
1789
+ lp_command_id = int(row.get("long_press_command_id", 0)) & 0xFF
1790
+ kwargs["long_press_device_id"] = new_lp_device
1791
+ kwargs["long_press_button_code"] = (
1792
+ synthesize_command_code(lp_command_id) if lp_command_id else 0
1793
+ )
1794
+ kwargs["long_press_button_id"] = lp_command_id
1795
+ post_steps.append(build_button_binding_step(**kwargs))
1796
+ restored_button_bindings += 1
1797
+
1798
+ restored_macros = 0
1799
+ skipped_macro_steps = 0
1800
+ skipped_input_ordinals = 0
1801
+ for row in sorted(
1802
+ (item for item in request.macros if isinstance(item, dict)),
1803
+ key=lambda item: int(item.get("button_id", 0)),
1804
+ ):
1805
+ button_id = int(row.get("button_id", 0)) & 0xFF
1806
+ if button_id == 0:
1807
+ continue
1808
+ step_records = bytearray()
1809
+ steps = row.get("steps")
1810
+ if isinstance(steps, list):
1811
+ for entry in steps:
1812
+ if not isinstance(entry, dict):
1813
+ continue
1814
+ raw_device = entry.get("device_id")
1815
+ raw_device_lo = int(raw_device or 0) & 0xFF
1816
+ raw_step_command_lo = int(entry.get("command_id", 0)) & 0xFF
1817
+ if raw_device_lo == 0xFF or raw_step_command_lo == 0xFF:
1818
+ # Delay/wait row inside an activity power macro:
1819
+ # emit the firmware sentinel record verbatim.
1820
+ # All head bytes are 0xFF (incl. fid and the
1821
+ # duration byte); the last byte holds the pause.
1822
+ step_records.extend(
1823
+ build_macro_step_record(
1824
+ device_id=0xFF,
1825
+ command_id=0xFF,
1826
+ fid=0xFFFFFFFFFFFF,
1827
+ duration=0xFF,
1828
+ delay=int(entry.get("delay", 0xFF)) & 0xFF,
1829
+ )
1830
+ )
1831
+ continue
1832
+ new_step_device = _map_device_id(raw_device)
1833
+ if new_step_device is None:
1834
+ # E6 silent-drop fix (activity-side): a step
1835
+ # whose source device_id is 0 is a hub no-op;
1836
+ # any other unmapped id means the backup
1837
+ # referenced a device that wasn't in the
1838
+ # device_id_map (caller missed a remap,
1839
+ # caught at validation but logged here for
1840
+ # defence-in-depth).
1841
+ if int(raw_device or 0) & 0xFF != 0:
1842
+ self._log.warning(
1843
+ "[RESTORE] activity macro key=0x%02X skipped step "
1844
+ "with unmapped device_id=%r",
1845
+ button_id,
1846
+ raw_device,
1847
+ )
1848
+ skipped_macro_steps += 1
1849
+ continue
1850
+ src_device_id = int(raw_device) & 0xFF
1851
+ step_command_id = int(entry.get("command_id", 0)) & 0xFF
1852
+ raw_duration = int(entry.get("duration", 0)) & 0xFF
1853
+ resolved_duration, ordinal_skipped = (
1854
+ self._resolve_macro_step_duration(
1855
+ request=request,
1856
+ button_id=button_id,
1857
+ src_device_id=src_device_id,
1858
+ new_step_device=new_step_device,
1859
+ step_command_id=step_command_id,
1860
+ raw_duration=raw_duration,
1861
+ )
1862
+ )
1863
+ if ordinal_skipped:
1864
+ skipped_input_ordinals += 1
1865
+ step_records.extend(
1866
+ build_macro_step_record(
1867
+ device_id=new_step_device,
1868
+ command_id=step_command_id,
1869
+ fid=self._coerce_button_code(entry.get("button_code", 0)),
1870
+ duration=resolved_duration,
1871
+ delay=int(entry.get("delay", 0xFF)) & 0xFF,
1872
+ )
1873
+ )
1874
+ post_steps.append(
1875
+ build_macro_step(
1876
+ hub_version=self.hub_version,
1877
+ device_id=new_activity_id,
1878
+ key_id=button_id,
1879
+ label=str(row.get("name") or ""),
1880
+ step_records=bytes(step_records),
1881
+ )
1882
+ )
1883
+ restored_macros += 1
1884
+
1885
+ post_steps.append(build_remote_sync_step())
1886
+
1887
+ self.reset_ack_queues()
1888
+ post_result = _run_create_sequence(self, post_steps)
1889
+ if not post_result.success:
1890
+ failed = (
1891
+ post_result.failed_step.label
1892
+ if post_result.failed_step is not None
1893
+ else "activity post-create"
1894
+ )
1895
+ self._log.warning("[RESTORE] activity finalize phase failed at step %s", failed)
1896
+ return DeviceCreateResult(
1897
+ success=False,
1898
+ device_id=new_activity_id,
1899
+ failed_step_label=failed,
1900
+ )
1901
+
1902
+ # Replay favourites via the same write path the live UI uses
1903
+ # to add a favorite (family-0x3E map + family-0x61 stage +
1904
+ # family-0x65 commit). Each favorite is its own multi-step
1905
+ # sequence with dynamic payloads that read back fav_id from
1906
+ # the map ack, so the sequence does not fit inside the
1907
+ # post_steps CreateStep batch above.
1908
+ restored_favorites = 0
1909
+ skipped_favorites = 0
1910
+ for row in sorted(
1911
+ (item for item in request.favorites if isinstance(item, dict)),
1912
+ key=lambda item: int(item.get("button_id", 0)),
1913
+ ):
1914
+ new_target_device = _map_device_id(row.get("device_id"))
1915
+ target_command_id = int(row.get("command_id", 0)) & 0xFF
1916
+ slot_id = int(row.get("button_id", 0)) & 0xFF
1917
+ if new_target_device is None or target_command_id == 0:
1918
+ self._log.warning(
1919
+ "[RESTORE] skipped favorite slot=0x%02X: unmapped device_id=%r "
1920
+ "or zero command_id",
1921
+ slot_id,
1922
+ row.get("device_id"),
1923
+ )
1924
+ skipped_favorites += 1
1925
+ continue
1926
+ written = self.command_to_favorite(
1927
+ new_activity_id,
1928
+ new_target_device,
1929
+ target_command_id,
1930
+ slot_id=slot_id,
1931
+ refresh_after_write=False,
1932
+ query_existing_order=False,
1933
+ )
1934
+ if not written:
1935
+ self._log.warning(
1936
+ "[RESTORE] favorite slot=0x%02X write failed dev=0x%02X cmd=0x%02X",
1937
+ slot_id,
1938
+ new_target_device,
1939
+ target_command_id,
1940
+ )
1941
+ skipped_favorites += 1
1942
+ continue
1943
+ restored_favorites += 1
1944
+
1945
+ # Materialise the activity entry in local state so other
1946
+ # readers see it before the next catalog refresh.
1947
+ self.state.activities[new_activity_id] = {
1948
+ "name": str(activity_block.get("name") or ""),
1949
+ "active": False,
1950
+ "needs_confirm": False,
1951
+ }
1952
+
1953
+ # ``restored_inputs`` carries the favorite-replay count for
1954
+ # activities (DeviceCreateResult has no dedicated favorites
1955
+ # counter -- activities don't write an inputs page so the
1956
+ # field is otherwise unused on this path). The adapter at
1957
+ # :meth:`restore_activity` renames it back to
1958
+ # ``restored_favorites`` in the public dict surface.
1959
+ return DeviceCreateResult(
1960
+ success=True,
1961
+ device_id=new_activity_id,
1962
+ restored_button_bindings=restored_button_bindings,
1963
+ restored_macros=restored_macros,
1964
+ restored_inputs=restored_favorites,
1965
+ skipped_favorites=skipped_favorites,
1966
+ skipped_macro_steps=skipped_macro_steps,
1967
+ skipped_input_ordinals=skipped_input_ordinals,
1968
+ )
1969
+
1970
+ @staticmethod
1971
+ def _collect_referenced_source_device_ids(payload: dict[str, Any]) -> set[int]:
1972
+ """Walk an activity backup payload and return the set of source
1973
+ device ids referenced by buttons, macro steps, and favourites.
1974
+ """
1975
+
1976
+ referenced: set[int] = set()
1977
+
1978
+ def _add(raw: Any) -> None:
1979
+ try:
1980
+ value = int(raw) & 0xFF
1981
+ except (TypeError, ValueError):
1982
+ return
1983
+ # 0x00 = unset; 0xFF = delay/wait sentinel (no real device).
1984
+ if value != 0 and value != 0xFF:
1985
+ referenced.add(value)
1986
+
1987
+ for row in payload.get("button_bindings") or []:
1988
+ if not isinstance(row, dict):
1989
+ continue
1990
+ _add(row.get("device_id"))
1991
+ _add(row.get("long_press_device_id"))
1992
+ for row in payload.get("macros") or []:
1993
+ if not isinstance(row, dict):
1994
+ continue
1995
+ for entry in row.get("steps") or []:
1996
+ if isinstance(entry, dict):
1997
+ _add(entry.get("device_id"))
1998
+ for row in payload.get("favorite_slots") or []:
1999
+ if isinstance(row, dict):
2000
+ _add(row.get("device_id"))
2001
+ return referenced
2002
+
2003
+
2004
+ __all__ = ["RestoreMixin"]