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,1174 @@
1
+ """Device-create / device-restore step machinery.
2
+
3
+ The hub's device-create flow is a strictly serialised sequence of
4
+ ``(opcode, payload, ack)`` exchanges. Each phase -- create-header,
5
+ per-command record pages, button bindings, idle behaviour, macros,
6
+ inputs slot, device-update commit, remote sync -- follows the same
7
+ transport pattern, differing only in opcode and ack-correlation.
8
+
9
+ This module factors that pattern into two layers so the device-restore
10
+ flow and the existing WiFi-create flow can share code:
11
+
12
+ - :class:`CreateStep` (Layer 2): one frame to send and one ack to
13
+ wait for. Carries the family byte, the payload bytes, the expected
14
+ ack opcode, and an optional ack-correlation byte (set when the ack
15
+ echoes a request-side byte such as a button id).
16
+ - :func:`run_create_sequence` (Layer 2): drives a list of steps in
17
+ order against a duck-typed proxy. Captures the assigned device id
18
+ from a flagged step's ack payload so subsequent steps can target
19
+ the new device. Aborts on first failure.
20
+ - Step builders (Layer 3): pure functions that produce
21
+ :class:`CreateStep` (or lists of them) for each phase. Frame layout
22
+ is locked in here, isolated from transport concerns.
23
+
24
+ The proxy is duck-typed: callers pass anything that exposes
25
+ ``_send_family_frame(family, payload)`` and
26
+ ``wait_for_ack_any(candidates, *, timeout, not_before)``. That
27
+ lets tests substitute a lightweight fake without spinning up the full
28
+ :class:`X1Proxy`.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import time
34
+ from dataclasses import dataclass, field
35
+ from typing import Any, Iterable, Literal, Protocol
36
+
37
+ from .devices import DeviceConfig, build_device_create_payload
38
+ from .wire_schema import schema_for
39
+
40
+
41
+ #: Discriminant for :class:`DeviceCreateRequest.transport`. ``"ir"`` covers
42
+ #: every record-shaped device class -- IR, Bluetooth, RF -- that uses the
43
+ #: canonical ``build_device_create_step`` / ``build_command_write_steps``
44
+ #: pipeline. ``"network_callback"`` covers WiFi-commands devices whose
45
+ #: writes are wifi-create-shape payloads bound to a callback service on
46
+ #: the integration; the Roku-on-X1 vs IP-generic-on-X1S/X2 split is an
47
+ #: internal variant of this transport, resolved from ``hub_version`` /
48
+ #: ``device_class_code`` inside the orchestrator rather than from the
49
+ #: request.
50
+ TransportKind = Literal["ir", "network_callback"]
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Wire-format constants
55
+ #
56
+ # Each constant carries a short note pointing to where it was observed on
57
+ # the wire (frame numbers refer to the user's full IR-create capture).
58
+ # ---------------------------------------------------------------------------
59
+
60
+
61
+ #: Family byte for the device-create header (A->H). Wire example:
62
+ #: ``OP_7B07`` on frame #191. Hub assigns ``device_id`` and replies on
63
+ #: :data:`ACK_OPCODE_DEVICE_CREATE`.
64
+ FAMILY_DEVICE_CREATE = 0x07
65
+
66
+ #: Family byte for the device-update commit (A->H, sent at the end of
67
+ #: the create flow with the real ``device_id`` and ``tail_marker=1``).
68
+ #: Wire example: ``OP_7B08`` on frame #485. Acked via the generic
69
+ #: STATUS_ACK family.
70
+ FAMILY_DEVICE_UPDATE = 0x08
71
+
72
+ #: Family byte for the activity-create header (A->H). Mirrors the
73
+ #: device-create flow but writes the same 120/210-byte body to a
74
+ #: different opcode family. Acked via :data:`ACK_OPCODE_DEVICE_CREATE`
75
+ #: with the assigned activity id in ``payload[0]`` (the hub uses the
76
+ #: same id space for devices and activities).
77
+ FAMILY_ACTIVITY_CREATE = 0x37
78
+
79
+ #: Family byte for per-command record writes (A->H, paged). Used for
80
+ #: all device classes -- IR-DB, BT, RF, learned codes -- with the codec
81
+ #: selected by the ``library_type`` field inside the body, not by
82
+ #: opcode. The opcode-hi byte equals the payload length so a full page
83
+ #: is ``OP_FA0E`` (250-byte payload) and a final short page is e.g.
84
+ #: ``OP_6C0E`` (108-byte payload).
85
+ FAMILY_COMMAND_WRITE = 0x0E
86
+
87
+ #: Family byte for button bindings (A->H). Wire example: ``OP_193E``
88
+ #: on frame #445. Acked via :data:`ACK_OPCODE_BUTTON_BINDING` with the
89
+ #: request's button id echoed at ``payload[0]``.
90
+ FAMILY_BUTTON_BINDING = 0x3E
91
+
92
+ #: Family byte for ``SET_IDLE_BEHAVIOR`` (A->H). Wire example:
93
+ #: ``OP_0241`` on frame #477. Acked via the generic STATUS_ACK family.
94
+ FAMILY_SET_IDLE_BEHAVIOR = 0x41
95
+
96
+ #: Family byte for macro placeholder writes (A->H). Wire example:
97
+ #: ``OP_3212`` on frames #479, #481 (``POWER_ON``, ``POWER_OFF``).
98
+ #: Acked via :data:`ACK_OPCODE_MACRO` with the macro's key id echoed
99
+ #: at ``payload[0]``.
100
+ FAMILY_MACRO = 0x12
101
+
102
+ #: Family byte for the inputs slot write (A->H). Wire example:
103
+ #: ``OP_7746`` on frame #483 (all-zeros body == "no input switching").
104
+ #: Acked via the generic STATUS_ACK family.
105
+ FAMILY_INPUTS = 0x46
106
+
107
+ #: Family byte for device key-sort writes.
108
+ FAMILY_KEY_SORT = 0x61
109
+
110
+ #: Family byte for ``REMOTE_SYNC`` (A->H). Wire example: ``OP_0064``
111
+ #: on frame #487. Acked via the generic STATUS_ACK family.
112
+ FAMILY_REMOTE_SYNC = 0x64
113
+
114
+
115
+ #: Full ack opcode the hub returns for a device-create. ``payload[0]``
116
+ #: carries the freshly assigned ``device_id``.
117
+ ACK_OPCODE_DEVICE_CREATE = 0x0107
118
+
119
+ #: Full ack opcode the X1 returns for an activity-create. Observed as an
120
+ #: immediate reply to family-0x37 writes, with ``payload[0]`` carrying the
121
+ #: freshly assigned activity id.
122
+ ACK_OPCODE_ACTIVITY_CREATE = 0x0137
123
+
124
+ #: Full ack opcode for a button-binding write. ``payload[0]`` echoes
125
+ #: the request's button id.
126
+ ACK_OPCODE_BUTTON_BINDING = 0x013E
127
+
128
+ #: Full ack opcode for a macro write. ``payload[0]`` echoes the
129
+ #: request's macro key id.
130
+ ACK_OPCODE_MACRO = 0x0112
131
+
132
+ #: Full ack opcode for a generic STATUS_ACK (used by most other write
133
+ #: steps). The success rule is ``payload[0] == 0x00``.
134
+ ACK_OPCODE_STATUS = 0x0103
135
+
136
+ #: Status byte value that indicates "accepted" on the generic ack.
137
+ ACK_STATUS_BYTE_OK = 0x00
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Layer 2 -- step sequencer
142
+ # ---------------------------------------------------------------------------
143
+
144
+
145
+ _PAGED_WRITE_OUTER_WRAPPER_LEN = 3
146
+ _PAGED_WRITE_BODY_CHUNK = 247
147
+
148
+
149
+ class _ProxyLike(Protocol):
150
+ """Subset of :class:`X1Proxy` the sequencer needs."""
151
+
152
+ def _send_family_frame(self, family: int, payload: bytes) -> None: ...
153
+
154
+ def wait_for_ack_any(
155
+ self,
156
+ candidates: list[tuple[int, int | None]],
157
+ *,
158
+ timeout: float = 5.0,
159
+ not_before: float | None = None,
160
+ ) -> tuple[int, bytes] | None: ...
161
+
162
+
163
+ @dataclass(frozen=True, slots=True)
164
+ class CreateStep:
165
+ """One create-flow exchange: send a frame, wait for a specific ack.
166
+
167
+ Attributes:
168
+ label: Human-readable identifier shown in logs ("device-create",
169
+ "button-binding btn=0xBD", etc.). Has no semantic meaning.
170
+ family: Opcode low byte. The high byte is derived from the
171
+ payload length by :meth:`_send_family_frame`, so we never
172
+ store an explicit opcode-hi.
173
+ payload: Bytes to send as the frame body (no checksum, no magic).
174
+ ack_opcode: Full opcode of the ack frame we wait for. ``0x0103``
175
+ for STATUS_ACK, ``0x0107`` for device-create ack, etc.
176
+ ack_first_byte: Optional ``payload[0]`` correlation. ``None``
177
+ means "any byte"; an integer matches only when the ack's
178
+ first payload byte equals it. Used for STATUS_ACK success
179
+ checks (``ack_first_byte=0``) and for correlation acks that
180
+ echo a request id (button id, macro key id).
181
+ ack_reject_first_bytes: ``payload[0]`` values that indicate the
182
+ hub *explicitly rejected* this write. The sequencer accepts
183
+ these as ack-arrivals (so it does not time out waiting) but
184
+ reports them as a rejection rather than success. The
185
+ classic example is STATUS_ACK with ``payload[0] == 0x0C``
186
+ for a malformed save page.
187
+ capture_device_id: When ``True``, the sequencer reads the ack
188
+ ``payload[0]`` as the freshly-assigned device id. Set on the
189
+ device-create step.
190
+ timeout: Per-attempt ack-wait timeout in seconds.
191
+ retries: Number of additional resend attempts on ack timeout
192
+ (default ``0`` = send once).
193
+ retry_delay: Seconds to sleep between retries.
194
+ """
195
+
196
+ label: str
197
+ family: int
198
+ payload: bytes
199
+ ack_opcode: int
200
+ ack_first_byte: int | None = None
201
+ ack_reject_first_bytes: tuple[int, ...] = ()
202
+ capture_device_id: bool = False
203
+ timeout: float = 5.0
204
+ retries: int = 0
205
+ retry_delay: float = 0.0
206
+
207
+
208
+ @dataclass(frozen=True, slots=True)
209
+ class CreateSequenceResult:
210
+ """Outcome of :func:`run_create_sequence`.
211
+
212
+ On success, ``failed_step`` and ``failed_index`` are ``None`` and
213
+ ``assigned_device_id`` is set iff one of the steps had
214
+ ``capture_device_id=True``.
215
+
216
+ On failure, ``failed_step`` and ``failed_index`` point at the first
217
+ step that did not get a successful ack. ``rejected`` distinguishes
218
+ "hub said no" (an ack arrived with a byte listed in
219
+ ``ack_reject_first_bytes``) from "no ack at all within the retry
220
+ budget" (a timeout). ``reject_payload`` carries the rejection ack's
221
+ full payload when ``rejected`` is True, for diagnostics.
222
+ """
223
+
224
+ success: bool
225
+ assigned_device_id: int | None
226
+ failed_step: CreateStep | None
227
+ failed_index: int | None
228
+ rejected: bool = False
229
+ reject_payload: bytes | None = None
230
+
231
+
232
+ def _page_create_step_payloads(step: CreateStep) -> list[bytes]:
233
+ """Return one or more wire payloads for a create step.
234
+
235
+ Most create-step payloads already fit in one family frame. The
236
+ family-0x46 inputs writer is different: ``build_inputs_write``
237
+ returns the full canonical payload with ``total_pages`` already
238
+ populated in the body header, and callers rely on the transport
239
+ layer to split oversized bodies into 247-byte chunks with a fresh
240
+ 3-byte ``[0x01][page_no_be]`` wrapper on each page.
241
+ """
242
+
243
+ if (
244
+ step.family != FAMILY_INPUTS
245
+ or len(step.payload) <= 250
246
+ or len(step.payload) <= _PAGED_WRITE_OUTER_WRAPPER_LEN
247
+ ):
248
+ return [step.payload]
249
+
250
+ body = step.payload[_PAGED_WRITE_OUTER_WRAPPER_LEN:]
251
+ payloads: list[bytes] = []
252
+ total_pages = max(1, (len(body) + _PAGED_WRITE_BODY_CHUNK - 1) // _PAGED_WRITE_BODY_CHUNK)
253
+ for seq in range(1, total_pages + 1):
254
+ chunk = body[(seq - 1) * _PAGED_WRITE_BODY_CHUNK : seq * _PAGED_WRITE_BODY_CHUNK]
255
+ payloads.append(bytes([0x01]) + seq.to_bytes(2, "big") + bytes(chunk))
256
+ return payloads
257
+
258
+
259
+ def run_create_sequence(
260
+ proxy: _ProxyLike,
261
+ steps: Iterable[CreateStep],
262
+ ) -> CreateSequenceResult:
263
+ """Drive a serial sequence of :class:`CreateStep` exchanges.
264
+
265
+ For each step:
266
+
267
+ 1. Send the frame via ``proxy._send_family_frame(family, payload)``.
268
+ 2. Wait for an ack matching ``(ack_opcode, ack_first_byte)``.
269
+ 3. On match, advance. If ``capture_device_id`` is set, record the
270
+ ack's ``payload[0]`` as :attr:`CreateSequenceResult.assigned_device_id`.
271
+ 4. On timeout, retry up to ``retries`` times. If still no ack, abort
272
+ the sequence and return a failure result pointing at this step.
273
+
274
+ The caller is responsible for clearing any leftover ack state before
275
+ calling (typically via ``proxy.reset_ack_queues()``). This function
276
+ deliberately does not touch proxy lifecycle so the same sequencer
277
+ can be reused mid-session if needed.
278
+ """
279
+
280
+ assigned_device_id: int | None = None
281
+ steps_list = list(steps)
282
+
283
+ for index, step in enumerate(steps_list):
284
+ # Build the list of (opcode, first_byte) candidates the wait
285
+ # call should accept. We include both the success-first-byte
286
+ # and any rejection-first-bytes so the wait returns as soon as
287
+ # the hub answers either way; the sequencer then classifies.
288
+ #
289
+ # Special case: a STATUS_ACK step that expects success on
290
+ # ``payload[0]==0x00`` should also wake up on any non-zero
291
+ # first byte. STATUS_ACK frames carry a binary verdict in
292
+ # ``payload[0]`` (``0x00`` = accepted, anything else = rejected
293
+ # with a status code), so a non-zero byte is always a
294
+ # rejection of the in-flight write. Without this, a rejection
295
+ # silently waits out the per-step timeout (5 s by default)
296
+ # instead of failing fast with the status code in the log.
297
+ candidates: list[tuple[int, int | None]] = [
298
+ (step.ack_opcode, step.ack_first_byte)
299
+ ]
300
+ wildcard_status_reject = (
301
+ step.ack_opcode == ACK_OPCODE_STATUS
302
+ and step.ack_first_byte == ACK_STATUS_BYTE_OK
303
+ )
304
+ if wildcard_status_reject:
305
+ candidates.append((step.ack_opcode, None))
306
+ for reject_byte in step.ack_reject_first_bytes:
307
+ candidates.append((step.ack_opcode, reject_byte & 0xFF))
308
+
309
+ matched: tuple[int, bytes] | None = None
310
+ page_payloads = _page_create_step_payloads(step)
311
+
312
+ for page_payload in page_payloads:
313
+ attempts_left = max(1, int(step.retries) + 1)
314
+ matched = None
315
+
316
+ while attempts_left > 0:
317
+ attempts_left -= 1
318
+ send_ts = time.monotonic()
319
+ proxy._send_family_frame(step.family, page_payload)
320
+ matched = proxy.wait_for_ack_any(
321
+ candidates,
322
+ timeout=step.timeout,
323
+ not_before=send_ts,
324
+ )
325
+ if matched is not None:
326
+ break
327
+ if attempts_left > 0 and step.retry_delay > 0:
328
+ time.sleep(step.retry_delay)
329
+
330
+ if matched is None:
331
+ break
332
+
333
+ if matched is None:
334
+ return CreateSequenceResult(
335
+ success=False,
336
+ assigned_device_id=assigned_device_id,
337
+ failed_step=step,
338
+ failed_index=index,
339
+ rejected=False,
340
+ reject_payload=None,
341
+ )
342
+
343
+ _ack_opcode, ack_payload = matched
344
+ first_byte = ack_payload[0] if ack_payload else None
345
+ is_explicit_reject = (
346
+ step.ack_reject_first_bytes
347
+ and first_byte is not None
348
+ and (first_byte & 0xFF) in step.ack_reject_first_bytes
349
+ )
350
+ is_status_wildcard_reject = (
351
+ wildcard_status_reject
352
+ and first_byte is not None
353
+ and (first_byte & 0xFF) != ACK_STATUS_BYTE_OK
354
+ )
355
+ if is_explicit_reject or is_status_wildcard_reject:
356
+ return CreateSequenceResult(
357
+ success=False,
358
+ assigned_device_id=assigned_device_id,
359
+ failed_step=step,
360
+ failed_index=index,
361
+ rejected=True,
362
+ reject_payload=bytes(ack_payload),
363
+ )
364
+
365
+ if step.capture_device_id and ack_payload:
366
+ assigned_device_id = ack_payload[0] & 0xFF
367
+
368
+ return CreateSequenceResult(
369
+ success=True,
370
+ assigned_device_id=assigned_device_id,
371
+ failed_step=None,
372
+ failed_index=None,
373
+ rejected=False,
374
+ reject_payload=None,
375
+ )
376
+
377
+
378
+ # ---------------------------------------------------------------------------
379
+ # Layer 1 -- unified device-create request / orchestrator
380
+ #
381
+ # Phase 7 of the protocol refactor folds the four previously-parallel
382
+ # entry points -- ``create_wifi_device`` (X1 Roku), ``_create_virtual_ip_wifi_device``
383
+ # (X1S/X2 IP), ``_restore_network_callback_device`` and the IR/BT/RF
384
+ # branch of ``restore_device`` -- into a single :func:`run_device_create`
385
+ # orchestrator that takes a typed :class:`DeviceCreateRequest`. The
386
+ # orchestrator dispatches on :attr:`DeviceCreateRequest.transport`;
387
+ # per-transport details (variant selection inside ``network_callback``,
388
+ # command-class selection inside ``ir``) live on the proxy mixins so
389
+ # this module stays free of wire layout for the wifi family-0x07 body.
390
+ # ---------------------------------------------------------------------------
391
+
392
+
393
+ @dataclass(frozen=True, slots=True)
394
+ class DeviceCreateRequest:
395
+ """Unified input for :func:`run_device_create`.
396
+
397
+ Both the user-driven "create wifi device" entry point and the
398
+ backup-driven "restore device" entry point construct one of these
399
+ and hand it to the orchestrator. The orchestrator decides which
400
+ on-the-wire sequence to issue based on :attr:`transport`; everything
401
+ else in this dataclass is the data the chosen pipeline needs.
402
+
403
+ Field groupings:
404
+
405
+ - **Always set:** :attr:`transport`.
406
+ - **IR transport:** :attr:`device_block` (full backup device block
407
+ including ``device_class``, ``device_class_code``, body fields),
408
+ :attr:`commands` (rows with ``restore_data`` carrying
409
+ ``library_type`` / ``button_code`` / ``data_hex``),
410
+ :attr:`button_bindings`, :attr:`macros`, :attr:`inputs`,
411
+ :attr:`favorites`.
412
+ - **Network-callback transport:** :attr:`network_callback_profile`
413
+ (carries ``device_name``, ``brand_name``, ``ip_address``,
414
+ ``request_port``, ``slots``, ``input_command_ids``,
415
+ ``power_on_command_id``, ``power_off_command_id``,
416
+ ``device_class``, ``device_class_code``). The orchestrator
417
+ reads everything wifi-specific from this dict so the top-level
418
+ dataclass shape stays the same across transports.
419
+
420
+ :attr:`entity_kind` reserves a slot for Phase 8's activity-restore
421
+ unification: ``"device"`` writes the family-0x07 create body,
422
+ ``"activity"`` writes family-0x37. Phase 7 ships only the
423
+ ``"device"`` path; passing ``"activity"`` raises until Phase 8.
424
+ """
425
+
426
+ transport: TransportKind
427
+ device_block: dict[str, Any] = field(default_factory=dict)
428
+ commands: list[dict[str, Any]] = field(default_factory=list)
429
+ button_bindings: list[dict[str, Any]] = field(default_factory=list)
430
+ macros: list[dict[str, Any]] = field(default_factory=list)
431
+ inputs: list[dict[str, Any]] = field(default_factory=list)
432
+ input_record: dict[str, Any] | None = None
433
+ favorites: list[dict[str, Any]] = field(default_factory=list)
434
+ key_sort: dict[str, Any] | None = None
435
+ network_callback_profile: dict[str, Any] | None = None
436
+ entity_kind: Literal["device", "activity"] = "device"
437
+ #: Source-device-id -> destination-device-id translation used when
438
+ #: ``entity_kind="activity"``. Activity content references commands
439
+ #: on *other* devices, and the destination hub will have assigned
440
+ #: those devices new ids at restore time. Empty for device-create
441
+ #: requests (which have no cross-device references).
442
+ device_id_map: dict[int, int] = field(default_factory=dict)
443
+ #: Source-device-id -> {source_command_id: new_command_id} mapping
444
+ #: captured during a bundle restore's devices phase. Used by the
445
+ #: activity-create path to resolve macro steps whose ``key_id`` is
446
+ #: ``0xC5`` (the "switch input on device" marker): the step's
447
+ #: ``duration`` byte is a 1-based ordinal into the *source* device's
448
+ #: input list, which has to be re-resolved against the *destination*
449
+ #: device's freshly-assigned command ids. Empty outside the
450
+ #: bundle-restore code path.
451
+ command_id_maps_by_source_device_id: dict[int, dict[int, int]] = field(
452
+ default_factory=dict
453
+ )
454
+ #: Per-device backup payloads from the bundle currently being
455
+ #: restored, keyed by source device_id. The activity-create path
456
+ #: uses this to look up each step's source-device input table when
457
+ #: resolving ``0xC5`` ordinals. Empty outside the bundle-restore
458
+ #: code path; the per-entity restore_activity entry point never
459
+ #: populates this.
460
+ bundle_devices_by_source_id: dict[int, dict[str, Any]] = field(
461
+ default_factory=dict
462
+ )
463
+
464
+
465
+ @dataclass(frozen=True, slots=True)
466
+ class DeviceCreateResult:
467
+ """Outcome of :func:`run_device_create`.
468
+
469
+ On success, ``device_id`` carries the freshly assigned id and the
470
+ ``restored_*`` / ``skipped_*`` counters describe what the
471
+ orchestrator wrote (or chose to skip with a log line). The
472
+ ``command_id_map`` translates source-side command ids (from a
473
+ backup payload) to the ids the destination hub assigned at write
474
+ time -- empty for fresh-create calls that didn't reference any
475
+ backup ids.
476
+
477
+ On failure, ``success`` is ``False`` and ``failed_step_label``
478
+ points at the step that didn't get a successful ack.
479
+ """
480
+
481
+ success: bool
482
+ device_id: int | None = None
483
+ restored_commands: int = 0
484
+ restored_button_bindings: int = 0
485
+ restored_macros: int = 0
486
+ restored_inputs: int = 0
487
+ skipped_favorites: int = 0
488
+ skipped_macro_steps: int = 0
489
+ #: Bundle-restore specific: macro 0xC5 ("set input on device") rows
490
+ #: whose source-ordinal could not be re-resolved against the
491
+ #: destination device's freshly-assigned command ids. Always 0
492
+ #: outside the bundle-restore code path.
493
+ skipped_input_ordinals: int = 0
494
+ command_id_map: dict[int, int] = field(default_factory=dict)
495
+ failed_step_label: str | None = None
496
+
497
+
498
+ class _DeviceCreateProxyLike(Protocol):
499
+ """Subset of :class:`X1Proxy` the unified orchestrator needs.
500
+
501
+ Each pipeline lives on a proxy mixin so the wire-layout code stays
502
+ close to the rest of the mixin family (wifi-create stays with the
503
+ wifi flow, IR restore stays with the rest of the restore
504
+ plumbing). The orchestrator only dispatches.
505
+ """
506
+
507
+ def _run_network_callback_create(
508
+ self, request: "DeviceCreateRequest"
509
+ ) -> "DeviceCreateResult": ...
510
+
511
+ def _run_ir_device_create(
512
+ self, request: "DeviceCreateRequest"
513
+ ) -> "DeviceCreateResult": ...
514
+
515
+ def _run_activity_create(
516
+ self, request: "DeviceCreateRequest"
517
+ ) -> "DeviceCreateResult": ...
518
+
519
+
520
+ def run_device_create(
521
+ proxy: _DeviceCreateProxyLike,
522
+ request: DeviceCreateRequest,
523
+ ) -> DeviceCreateResult:
524
+ """Drive the unified device-create pipeline.
525
+
526
+ Both ``create_wifi_device`` and ``restore_device`` route through
527
+ this entry point; the public methods are thin adapters that build
528
+ a :class:`DeviceCreateRequest` from their callers' inputs (live
529
+ user kwargs vs a backup payload) and hand it off here.
530
+
531
+ The orchestrator dispatches in two layers:
532
+
533
+ - ``entity_kind="activity"`` -- the activity-create pipeline on
534
+ the restore mixin. Activities always use the canonical
535
+ schema-driven record path (family ``0x37``) and reference
536
+ commands on other devices via :attr:`DeviceCreateRequest.device_id_map`;
537
+ transport is therefore implicit.
538
+ - ``entity_kind="device"`` -- dispatch by transport:
539
+
540
+ - ``"network_callback"`` -- the wifi-create flow on the wifi
541
+ mixin. Variant resolution between Roku-on-X1 and
542
+ IP-generic-on-X1S/X2 is internal to that pipeline.
543
+ - ``"ir"`` -- the IR / Bluetooth / RF restore flow on the
544
+ restore mixin. Command persistence goes through the
545
+ codec-aware :meth:`persist_command_record` /
546
+ :meth:`persist_ir_blob` writers; the post-create step
547
+ sequence is the canonical bindings/macros/inputs/update/sync
548
+ sequence built from schema-driven step builders.
549
+ """
550
+
551
+ if request.entity_kind == "activity":
552
+ return proxy._run_activity_create(request)
553
+ if request.entity_kind != "device":
554
+ raise ValueError(
555
+ f"run_device_create received unsupported entity_kind={request.entity_kind!r}; "
556
+ "expected 'device' or 'activity'"
557
+ )
558
+
559
+ if request.transport == "network_callback":
560
+ return proxy._run_network_callback_create(request)
561
+ if request.transport == "ir":
562
+ return proxy._run_ir_device_create(request)
563
+ raise ValueError(
564
+ f"run_device_create received unsupported transport={request.transport!r}; "
565
+ "expected one of 'ir', 'network_callback'"
566
+ )
567
+
568
+
569
+ # ---------------------------------------------------------------------------
570
+ # Layer 3 -- step builders for the IR-create flow
571
+ #
572
+ # Each builder is a pure function: same inputs -> same bytes -> testable
573
+ # against captured wire frames without any proxy involvement.
574
+ # ---------------------------------------------------------------------------
575
+
576
+
577
+ # Common 6-byte header observed at payload[0..5] of nearly every A->H
578
+ # write in the create flow: button bindings, macros, inputs, etc. The
579
+ # meaning of the trailing ``01 00 01`` isn't fully decoded; treating it
580
+ # as a fixed constant matches every captured frame.
581
+ _COMMON_WRITE_PREAMBLE = bytes([0x01, 0x00, 0x01, 0x01, 0x00, 0x01])
582
+
583
+
584
+ def build_device_create_step(
585
+ config: DeviceConfig,
586
+ *,
587
+ hub_version: str,
588
+ family: int = FAMILY_DEVICE_CREATE,
589
+ ) -> CreateStep:
590
+ """Build the device-create step (``OP_7B07`` on X1).
591
+
592
+ ``config`` should carry ``device_id=0xFF`` (the create sentinel) and
593
+ ``tail_marker=0`` (the pre-commit value -- the device-update step
594
+ later in the flow sets it to ``1``). The hub assigns the real
595
+ ``device_id`` and returns it via the :data:`ACK_OPCODE_DEVICE_CREATE`
596
+ ack.
597
+
598
+ Pass ``family=FAMILY_ACTIVITY_CREATE`` (``0x37``) to write an
599
+ activity record instead of a device record. The body layout is the
600
+ same 120/210-byte schema; only the opcode family changes. The hub
601
+ assigns activity ids out of the same id space and returns them on
602
+ the same ack opcode.
603
+ """
604
+
605
+ payload = build_device_create_payload(config, hub_version=hub_version)
606
+ label = (
607
+ "activity-create" if family == FAMILY_ACTIVITY_CREATE else "device-create"
608
+ )
609
+ ack_opcode = (
610
+ ACK_OPCODE_ACTIVITY_CREATE
611
+ if family == FAMILY_ACTIVITY_CREATE
612
+ else ACK_OPCODE_DEVICE_CREATE
613
+ )
614
+
615
+ return CreateStep(
616
+ label=label,
617
+ family=family,
618
+ payload=payload,
619
+ ack_opcode=ack_opcode,
620
+ ack_first_byte=None, # any byte -- the assigned id is captured, not matched
621
+ capture_device_id=True,
622
+ )
623
+
624
+
625
+ def build_device_update_step(
626
+ config: DeviceConfig,
627
+ *,
628
+ hub_version: str,
629
+ ) -> CreateStep:
630
+ """Build the device-update / commit step (``OP_7B08`` on X1).
631
+
632
+ Sent at the end of the create flow with the real ``device_id``
633
+ assigned by the hub and ``tail_marker=1`` (the "device is committed"
634
+ value). The body shape is identical to the create body.
635
+ """
636
+
637
+ payload = build_device_create_payload(config, hub_version=hub_version)
638
+ return CreateStep(
639
+ label="device-update",
640
+ family=FAMILY_DEVICE_UPDATE,
641
+ payload=payload,
642
+ ack_opcode=ACK_OPCODE_STATUS,
643
+ ack_first_byte=ACK_STATUS_BYTE_OK,
644
+ )
645
+
646
+
647
+ def _body_checksum(body: bytes) -> int:
648
+ """Compute the internal body-checksum byte.
649
+
650
+ Every multi-byte write body in this family ends with a 1-byte
651
+ checksum at the last position; the hub validates it before
652
+ accepting the write. The reduction is ``sum(body[:-1]) & 0xFF``
653
+ (the same algorithm the transport uses for the outer frame
654
+ checksum). Pass the body **without** the trailing checksum byte
655
+ or with that byte zeroed; both produce the same result.
656
+ """
657
+
658
+ return sum(body[:-1] if body else b"") & 0xFF
659
+
660
+
661
+ def _seal_body(body: bytearray) -> bytes:
662
+ """Write the body checksum into ``body[-1]`` and return as bytes."""
663
+
664
+ body[-1] = sum(body[:-1]) & 0xFF
665
+ return bytes(body)
666
+
667
+
668
+ def synthesize_command_code(command_id: int) -> int:
669
+ """Return the X1 synthetic command-code used by keymap/input writes."""
670
+
671
+ if command_id < 0 or command_id > 0xFF:
672
+ raise ValueError(f"command_id {command_id} out of byte range")
673
+ return 0x4E20 + (command_id & 0xFF)
674
+
675
+
676
+ def build_macro_step_record(
677
+ *,
678
+ device_id: int,
679
+ command_id: int,
680
+ fid: int = 0,
681
+ duration: int = 0,
682
+ delay: int = 0xFF,
683
+ ) -> bytes:
684
+ """Build one 10-byte macro-step record for :func:`build_macro_step`."""
685
+
686
+ if device_id < 0 or device_id > 0xFF:
687
+ raise ValueError(f"device_id {device_id} out of byte range")
688
+ if command_id < 0 or command_id > 0xFF:
689
+ raise ValueError(f"command_id {command_id} out of byte range")
690
+ if fid < 0 or fid > 0xFFFFFFFFFFFF:
691
+ raise ValueError(f"fid {fid} out of 48-bit range")
692
+ if duration < 0 or duration > 0xFF:
693
+ raise ValueError(f"duration {duration} out of byte range")
694
+ if delay < 0 or delay > 0xFF:
695
+ raise ValueError(f"delay {delay} out of byte range")
696
+
697
+ return (
698
+ bytes([device_id & 0xFF, command_id & 0xFF])
699
+ + fid.to_bytes(6, "big")
700
+ + bytes([duration & 0xFF, delay & 0xFF])
701
+ )
702
+
703
+
704
+ # NOTE: ``build_x1_input_entry`` and ``build_inputs_step`` were removed
705
+ # in Phase 3 of the protocol refactor. The unified family-0x46 builder
706
+ # lives in :mod:`custom_components.sofabaton_x1s.lib.inputs`
707
+ # (:func:`~custom_components.sofabaton_x1s.lib.inputs.build_inputs_write`)
708
+ # and replaces the X1-only entry/page builders that used to live here.
709
+
710
+
711
+ #: Per-page payload capacity for command-record pages. 250 bytes maxes
712
+ #: out the opcode-hi byte (``0xFA``) which equals payload length on the
713
+ #: wire.
714
+ _COMMAND_PAGE_FULL_PAYLOAD = 250
715
+
716
+ #: Per-page header size: ``[command_seq, page_num_be]``. 3 bytes,
717
+ #: present on every page of every command.
718
+ _COMMAND_PAGE_HEADER_LEN = 3
719
+
720
+ #: Pre-label body-header size (size + total_pages + dev + btn + lib +
721
+ #: button_code). 12 bytes.
722
+ _COMMAND_BODY_PRELABEL_LEN = 12
723
+
724
+
725
+ #: Default rejection byte the hub sends on STATUS_ACK to indicate a
726
+ #: malformed or otherwise unacceptable save page. Used by command
727
+ #: writes so the sequencer can surface "hub said no" distinctly from
728
+ #: "no ack arrived".
729
+ _REJECT_BYTE_BAD_SAVE = 0x0C
730
+
731
+
732
+ def build_command_write_steps(
733
+ *,
734
+ hub_version: str,
735
+ command_seq: int,
736
+ command_burst_size: int,
737
+ device_id: int,
738
+ button_id: int,
739
+ library_type: int,
740
+ button_code: int,
741
+ label: str,
742
+ library_data: bytes,
743
+ ack_timeout: float = 5.0,
744
+ inter_page_retry_delay: float = 0.0,
745
+ ) -> list[CreateStep]:
746
+ """Build the paged command-record write for one command (family ``0x0E``).
747
+
748
+ The same wire shape carries IR-DB blobs, RF blobs, BT blobs, and
749
+ learned codes. The codec is selected by ``library_type``; the
750
+ payload bytes are opaque ``library_data``.
751
+
752
+ Wire layout. The "body" is a single logical record chunked across
753
+ one or more pages, each page prefixed with ``[command_seq,
754
+ page_num_be]``. The label slot width and encoding follow the hub
755
+ variant: 30-byte ASCII on X1, 60-byte UTF-16BE on X1S/X2 (read
756
+ from :func:`schema_for`)::
757
+
758
+ body[0] size (== command_burst_size)
759
+ body[1..2] total_pages_be (for *this* command)
760
+ body[3] device_id
761
+ body[4] button_id (default key-slot, 0 = unbound)
762
+ body[5] library_type (codec selector)
763
+ body[6..11] button_code (BE) (6-byte / 48-bit identifier)
764
+ body[12..label_end]
765
+ label slot (30 ASCII / 60 UTF-16BE,
766
+ null-padded)
767
+ body[label_end..-2]
768
+ library_data (opaque codec bytes)
769
+ body[-1] body checksum
770
+
771
+ Arguments:
772
+ command_seq: 1-based index of this command within the enclosing
773
+ burst. Appears in ``payload[0]`` of every page of this
774
+ command.
775
+ command_burst_size: Total number of commands in the burst the
776
+ caller intends to write. Written verbatim into ``body[0]``
777
+ of every page of every command in the burst.
778
+ device_id: 1-byte hub-assigned device id (returned by the
779
+ preceding device-create ack).
780
+ button_id: Default remote key slot the command should land on.
781
+ Often ``0`` (unbound at write time; bound later via the
782
+ button-binding step).
783
+ library_type: Codec selector. Observed values: ``0x0D`` on
784
+ IR-DB sourced devices, others for BT / RF / learned codes.
785
+ button_code: 48-bit canonical command identifier used by
786
+ binding and macro flows to reference this command.
787
+ label: User-visible command label, ASCII, truncated to 30
788
+ bytes.
789
+ library_data: Opaque codec bytes appended after the label
790
+ slot. Format depends on ``library_type``.
791
+ ack_timeout: Per-page ack timeout. Defaults to 5 s, matching
792
+ the hub's typical command-write turnaround.
793
+ inter_page_retry_delay: Sleep between page retries on the
794
+ sequencer. Defaults to ``0`` -- pages do not retry by
795
+ default; the caller can pass a positive value to enable a
796
+ single-retry pattern.
797
+ """
798
+
799
+ if command_seq < 1 or command_seq > 0xFF:
800
+ raise ValueError(f"command_seq {command_seq} out of byte range")
801
+ if command_burst_size < 1 or command_burst_size > 0xFF:
802
+ raise ValueError(f"command_burst_size {command_burst_size} out of byte range")
803
+ if device_id < 0 or device_id > 0xFF:
804
+ raise ValueError(f"device_id {device_id} out of byte range")
805
+ if button_id < 0 or button_id > 0xFF:
806
+ raise ValueError(f"button_id {button_id} out of byte range")
807
+ if library_type < 0 or library_type > 0xFF:
808
+ raise ValueError(f"library_type {library_type} out of byte range")
809
+ if button_code < 0 or button_code > 0xFFFFFFFFFFFF:
810
+ raise ValueError(f"button_code {button_code} out of 48-bit range")
811
+ if not library_data:
812
+ raise ValueError("library_data must not be empty")
813
+
814
+ schema = schema_for(hub_version)
815
+ label_slot_len = schema.command_label_slot_len
816
+ label_encoding = schema.command_label_encoding
817
+ label_bytes = label.encode(label_encoding, errors="replace")[:label_slot_len]
818
+ label_slot_bytes = label_bytes + b"\x00" * (label_slot_len - len(label_bytes))
819
+
820
+ # Body content excluding final checksum byte. Pages chunk this
821
+ # buffer; the checksum is computed over the whole sealed body and
822
+ # written at the last position before paging.
823
+ body_content_len = (
824
+ _COMMAND_BODY_PRELABEL_LEN
825
+ + len(label_slot_bytes)
826
+ + len(library_data)
827
+ )
828
+ body = bytearray(body_content_len + 1) # +1 for trailing body checksum
829
+
830
+ # Total pages is computed from final body length divided by the
831
+ # page-chunk capacity. The page chunk capacity is full-payload
832
+ # minus the 3-byte page header.
833
+ page_chunk_capacity = _COMMAND_PAGE_FULL_PAYLOAD - _COMMAND_PAGE_HEADER_LEN
834
+ total_pages = (len(body) + page_chunk_capacity - 1) // page_chunk_capacity
835
+
836
+ body[0] = command_burst_size & 0xFF
837
+ body[1:3] = total_pages.to_bytes(2, "big")
838
+ body[3] = device_id & 0xFF
839
+ body[4] = button_id & 0xFF
840
+ body[5] = library_type & 0xFF
841
+ body[6:12] = button_code.to_bytes(6, "big")
842
+ label_end = 12 + len(label_slot_bytes)
843
+ body[12:label_end] = label_slot_bytes
844
+ body[label_end : label_end + len(library_data)] = library_data
845
+ body_bytes = _seal_body(body)
846
+
847
+ steps: list[CreateStep] = []
848
+ for page_index in range(total_pages):
849
+ page_no = page_index + 1
850
+ start = page_index * page_chunk_capacity
851
+ chunk = body_bytes[start : start + page_chunk_capacity]
852
+ header = bytes([command_seq & 0xFF]) + page_no.to_bytes(2, "big")
853
+ steps.append(
854
+ CreateStep(
855
+ label=(
856
+ f"command-write code=0x{button_code:012X} "
857
+ f"page={page_no}/{total_pages}"
858
+ ),
859
+ family=FAMILY_COMMAND_WRITE,
860
+ payload=header + chunk,
861
+ ack_opcode=ACK_OPCODE_STATUS,
862
+ ack_first_byte=ACK_STATUS_BYTE_OK,
863
+ ack_reject_first_bytes=(_REJECT_BYTE_BAD_SAVE,),
864
+ timeout=ack_timeout,
865
+ retry_delay=inter_page_retry_delay,
866
+ )
867
+ )
868
+ return steps
869
+
870
+
871
+ def encode_command_sort_body(
872
+ ordered_pairs: list[tuple[int, int]],
873
+ ) -> bytes:
874
+ """Encode the inner key-sort body as a flat stream of ``(command_id,
875
+ sort_id)`` pairs.
876
+
877
+ The hub stores a per-device display ordering for the physical
878
+ remote's device-browse screen as a sequence of 2-byte pairs --
879
+ ``(command_id, sort_position)`` -- carried inside the family-0x61
880
+ paged write body (the portion between the device id and the
881
+ trailing checksum byte). Each entry is byte-clamped to fit the
882
+ wire field width.
883
+ """
884
+
885
+ out = bytearray(len(ordered_pairs) * 2)
886
+ for index, (command_id, sort_id) in enumerate(ordered_pairs):
887
+ out[index * 2] = command_id & 0xFF
888
+ out[index * 2 + 1] = sort_id & 0xFF
889
+ return bytes(out)
890
+
891
+
892
+ def build_key_sort_steps(
893
+ *,
894
+ device_id: int,
895
+ msg_hex: str,
896
+ ack_timeout: float = 5.0,
897
+ ) -> list[CreateStep]:
898
+ """Build the app-style family-0x61 device key-sort write."""
899
+
900
+ if device_id < 0 or device_id > 0xFF:
901
+ raise ValueError(f"device_id {device_id} out of byte range")
902
+ raw_hex = str(msg_hex or "").strip()
903
+ try:
904
+ msg_bytes = bytes.fromhex(raw_hex) if raw_hex else b""
905
+ except ValueError as exc:
906
+ raise ValueError(f"invalid key-sort msg_hex: {msg_hex!r}") from exc
907
+
908
+ page_chunk_capacity = _COMMAND_PAGE_FULL_PAYLOAD - _COMMAND_PAGE_HEADER_LEN
909
+ body = bytearray(len(msg_bytes) + 5)
910
+ total_pages = max(1, (len(body) + page_chunk_capacity - 1) // page_chunk_capacity)
911
+ body[0] = 0x01
912
+ body[1:3] = total_pages.to_bytes(2, "big")
913
+ body[3] = device_id & 0xFF
914
+ if msg_bytes:
915
+ body[4 : 4 + len(msg_bytes)] = msg_bytes
916
+ body_bytes = _seal_body(body)
917
+
918
+ steps: list[CreateStep] = []
919
+ for page_index in range(total_pages):
920
+ page_no = page_index + 1
921
+ start = page_index * page_chunk_capacity
922
+ chunk = body_bytes[start : start + page_chunk_capacity]
923
+ payload = bytes([0x01]) + page_no.to_bytes(2, "big") + chunk
924
+ steps.append(
925
+ CreateStep(
926
+ label=f"key-sort page={page_no}/{total_pages}",
927
+ family=FAMILY_KEY_SORT,
928
+ payload=payload,
929
+ ack_opcode=ACK_OPCODE_STATUS,
930
+ ack_first_byte=ACK_STATUS_BYTE_OK,
931
+ timeout=ack_timeout,
932
+ )
933
+ )
934
+ return steps
935
+
936
+
937
+ def build_button_binding_step(
938
+ *,
939
+ device_id: int,
940
+ button_id: int,
941
+ short_press_device_id: int,
942
+ short_press_button_code: int,
943
+ short_press_button_id: int = 0,
944
+ long_press_device_id: int = 0,
945
+ long_press_button_code: int = 0,
946
+ long_press_button_id: int = 0,
947
+ ) -> CreateStep:
948
+ """Build a button-binding write (``OP_193E``).
949
+
950
+ Writes one ``button_id`` slot on ``device_id`` with full short-
951
+ and long-press triples. Each triple is
952
+ ``(target_device_id, button_code, button_id)`` where
953
+ ``button_code`` is the 6-byte / 48-bit canonical command
954
+ identifier and ``button_id`` is an optional remote-key slot
955
+ reference (often ``0`` when the bound command is not also assigned
956
+ a default key slot).
957
+
958
+ Wire layout (3-byte outer wrapper + 22-byte body)::
959
+
960
+ payload[0..2] [01, 0, 1] outer wrapper
961
+ body[0..2] [01, 0, 1] page constants
962
+ body[3] device_id keymap entity
963
+ body[4] button_id slot being written
964
+ body[5] short_press_device_id
965
+ body[6..11] short_press_button_code (BE)
966
+ body[12] short_press_button_id
967
+ body[13] long_press_device_id
968
+ body[14..19] long_press_button_code (BE)
969
+ body[20] long_press_button_id
970
+ body[21] body checksum
971
+
972
+ Total payload = 25 bytes. The ack on :data:`ACK_OPCODE_BUTTON_BINDING`
973
+ carries ``button_id`` echoed at ``payload[0]`` for correlation.
974
+ """
975
+
976
+ for name, value in (
977
+ ("button_id", button_id),
978
+ ("device_id", device_id),
979
+ ("short_press_device_id", short_press_device_id),
980
+ ("short_press_button_id", short_press_button_id),
981
+ ("long_press_device_id", long_press_device_id),
982
+ ("long_press_button_id", long_press_button_id),
983
+ ):
984
+ if value < 0 or value > 0xFF:
985
+ raise ValueError(f"{name}={value} out of byte range")
986
+ for name, value in (
987
+ ("short_press_button_code", short_press_button_code),
988
+ ("long_press_button_code", long_press_button_code),
989
+ ):
990
+ if value < 0 or value > 0xFFFFFFFFFFFF:
991
+ raise ValueError(f"{name}={value} out of 48-bit range")
992
+
993
+ body = bytearray(22)
994
+ # body[0..2] is the inner-page header: [marker, total_pages_be=1].
995
+ # Button-binding is always a single-page write across X1 / X1S /
996
+ # X2 (the body never exceeds the per-page chunk), so [0x01, 0x00,
997
+ # 0x01] is correct on every variant -- not just an X1 coincidence.
998
+ body[0:3] = bytes([0x01, 0x00, 0x01])
999
+ body[3] = device_id & 0xFF
1000
+ body[4] = button_id & 0xFF
1001
+ body[5] = short_press_device_id & 0xFF
1002
+ body[6:12] = short_press_button_code.to_bytes(6, "big")
1003
+ body[12] = short_press_button_id & 0xFF
1004
+ body[13] = long_press_device_id & 0xFF
1005
+ body[14:20] = long_press_button_code.to_bytes(6, "big")
1006
+ body[20] = long_press_button_id & 0xFF
1007
+ body_bytes = _seal_body(body)
1008
+
1009
+ payload = bytes([0x01, 0x00, 0x01]) + body_bytes
1010
+ assert len(payload) == 25, (
1011
+ f"button-binding payload is {len(payload)} bytes, expected 25"
1012
+ )
1013
+
1014
+ return CreateStep(
1015
+ label=(
1016
+ f"button-binding btn=0x{button_id:02X} "
1017
+ f"-> dev=0x{short_press_device_id:02X} "
1018
+ f"code=0x{short_press_button_code:012X}"
1019
+ ),
1020
+ family=FAMILY_BUTTON_BINDING,
1021
+ payload=payload,
1022
+ ack_opcode=ACK_OPCODE_BUTTON_BINDING,
1023
+ ack_first_byte=button_id & 0xFF,
1024
+ )
1025
+
1026
+
1027
+ def build_set_idle_behavior_step(*, device_id: int, mode: int) -> CreateStep:
1028
+ """Build a ``SET_IDLE_BEHAVIOR`` write (``OP_0241``).
1029
+
1030
+ Two-byte payload: ``[device_id, mode]``. Acked via STATUS_ACK with
1031
+ ``payload[0] == 0x00``.
1032
+ """
1033
+
1034
+ return CreateStep(
1035
+ label=f"set-idle-behavior dev=0x{device_id:02X} mode={mode}",
1036
+ family=FAMILY_SET_IDLE_BEHAVIOR,
1037
+ payload=bytes([device_id & 0xFF, mode & 0xFF]),
1038
+ ack_opcode=ACK_OPCODE_STATUS,
1039
+ ack_first_byte=ACK_STATUS_BYTE_OK,
1040
+ )
1041
+
1042
+
1043
+ #: Width of one macro-step record. Observed at 10 bytes.
1044
+ MACRO_STEP_RECORD_SIZE = 10
1045
+
1046
+
1047
+ def build_macro_step(
1048
+ *,
1049
+ hub_version: str,
1050
+ device_id: int,
1051
+ key_id: int,
1052
+ label: str,
1053
+ step_records: bytes = b"",
1054
+ ) -> CreateStep:
1055
+ """Build a macro write (``OP_3212``).
1056
+
1057
+ Single-page write. The label slot width and encoding follow the
1058
+ hub variant (30-byte ASCII on X1, 60-byte UTF-16BE on X1S/X2,
1059
+ read from :func:`schema_for`)::
1060
+
1061
+ body[0..2] [01, 00, 01] page header (page 1 of 1)
1062
+ body[3] device_id
1063
+ body[4] key_id (macro key id, e.g. 0xC6 POWER_ON)
1064
+ body[5] step_count
1065
+ body[6 .. 6 + 10*step_count - 1] step records (10 bytes each)
1066
+ body[next .. next + L - 1] label slot (L=30/60)
1067
+ body[next + L] body checksum
1068
+
1069
+ ``step_records`` is the concatenated step-bytes blob (size must be
1070
+ a multiple of :data:`MACRO_STEP_RECORD_SIZE`). Pass ``b""`` to
1071
+ write a zero-step record (the hub then stores the macro as label
1072
+ only, no playback).
1073
+
1074
+ The hub's auto-written ``POWER_ON`` / ``POWER_OFF`` macros also
1075
+ use this builder; they always carry one step. The backup flow
1076
+ skips ``REQ_MACROS`` for unconfigured-power devices because those
1077
+ auto-written macros are not user content, but the wire write does
1078
+ include a real step.
1079
+ """
1080
+
1081
+ if key_id < 0 or key_id > 0xFF:
1082
+ raise ValueError(f"key_id {key_id} out of byte range")
1083
+ if device_id < 0 or device_id > 0xFF:
1084
+ raise ValueError(f"device_id {device_id} out of byte range")
1085
+ if len(step_records) % MACRO_STEP_RECORD_SIZE != 0:
1086
+ raise ValueError(
1087
+ f"step_records length {len(step_records)} is not a multiple of "
1088
+ f"MACRO_STEP_RECORD_SIZE ({MACRO_STEP_RECORD_SIZE})"
1089
+ )
1090
+ step_count = len(step_records) // MACRO_STEP_RECORD_SIZE
1091
+ if step_count > 0xFF:
1092
+ raise ValueError(f"too many steps ({step_count}); max 255")
1093
+
1094
+ schema = schema_for(hub_version)
1095
+ label_slot_len = schema.macro_label_slot_len
1096
+ label_encoding = schema.macro_label_encoding
1097
+ label_bytes = label.encode(label_encoding, errors="replace")[:label_slot_len]
1098
+ label_slot = label_bytes + b"\x00" * (label_slot_len - len(label_bytes))
1099
+
1100
+ # body layout: 6-byte preamble + steps + L-byte label slot + 1-byte checksum
1101
+ body_len = 6 + len(step_records) + label_slot_len + 1
1102
+ body = bytearray(body_len)
1103
+ body[0:3] = bytes([0x01, 0x00, 0x01])
1104
+ body[3] = device_id & 0xFF
1105
+ body[4] = key_id & 0xFF
1106
+ body[5] = step_count & 0xFF
1107
+ body[6 : 6 + len(step_records)] = step_records
1108
+ label_start = 6 + len(step_records)
1109
+ body[label_start : label_start + label_slot_len] = label_slot
1110
+ body_bytes = _seal_body(body)
1111
+
1112
+ # Outer 3-byte page wrapper: [page_const=1, page_num_be=1].
1113
+ payload = bytes([0x01, 0x00, 0x01]) + body_bytes
1114
+
1115
+ return CreateStep(
1116
+ label=f"macro key=0x{key_id:02X} label={label!r} steps={step_count}",
1117
+ family=FAMILY_MACRO,
1118
+ payload=payload,
1119
+ ack_opcode=ACK_OPCODE_MACRO,
1120
+ ack_first_byte=key_id & 0xFF,
1121
+ )
1122
+
1123
+
1124
+ def build_remote_sync_step() -> CreateStep:
1125
+ """Build the terminal ``REMOTE_SYNC`` step (``OP_0064``).
1126
+
1127
+ Sent at the very end of the create flow with an empty payload.
1128
+ Tells the remote to refresh its local view of the hub state.
1129
+ """
1130
+
1131
+ return CreateStep(
1132
+ label="remote-sync",
1133
+ family=FAMILY_REMOTE_SYNC,
1134
+ payload=b"",
1135
+ ack_opcode=ACK_OPCODE_STATUS,
1136
+ ack_first_byte=ACK_STATUS_BYTE_OK,
1137
+ )
1138
+
1139
+
1140
+ __all__ = [
1141
+ "ACK_OPCODE_BUTTON_BINDING",
1142
+ "ACK_OPCODE_DEVICE_CREATE",
1143
+ "ACK_OPCODE_MACRO",
1144
+ "ACK_OPCODE_STATUS",
1145
+ "ACK_STATUS_BYTE_OK",
1146
+ "CreateSequenceResult",
1147
+ "CreateStep",
1148
+ "DeviceCreateRequest",
1149
+ "DeviceCreateResult",
1150
+ "FAMILY_ACTIVITY_CREATE",
1151
+ "FAMILY_BUTTON_BINDING",
1152
+ "FAMILY_DEVICE_CREATE",
1153
+ "FAMILY_DEVICE_UPDATE",
1154
+ "FAMILY_INPUTS",
1155
+ "FAMILY_KEY_SORT",
1156
+ "FAMILY_COMMAND_WRITE",
1157
+ "FAMILY_MACRO",
1158
+ "FAMILY_REMOTE_SYNC",
1159
+ "FAMILY_SET_IDLE_BEHAVIOR",
1160
+ "MACRO_STEP_RECORD_SIZE",
1161
+ "build_button_binding_step",
1162
+ "build_device_create_step",
1163
+ "build_device_update_step",
1164
+ "build_command_write_steps",
1165
+ "build_key_sort_steps",
1166
+ "encode_command_sort_body",
1167
+ "build_macro_step_record",
1168
+ "build_macro_step",
1169
+ "build_remote_sync_step",
1170
+ "build_set_idle_behavior_step",
1171
+ "run_create_sequence",
1172
+ "run_device_create",
1173
+ "synthesize_command_code",
1174
+ ]