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