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,1101 @@
1
+ """Wifi-device create flow mixin for :class:`X1Proxy`.
2
+
3
+ Provides the user-facing ``create_wifi_device`` entry point together
4
+ with the multi-step orchestration helpers it relies on (input
5
+ configuration writes, power-button bindings, IP-callback finalize).
6
+ The X1 and X1S/X2 variants share the high-level shape but differ in
7
+ the family-0x08 finalize and the slot stride; the variant branch is
8
+ taken in :meth:`create_wifi_device` based on ``self.hub_version``
9
+ rather than from any payload heuristic.
10
+
11
+ The constants near the top of the module describe the wire layout of
12
+ the user-defined wifi command slots and the X1S/X2 finalize bookends.
13
+ Everything else in this file is high-level orchestration that delegates
14
+ its wire-building back to :mod:`lib.inputs`, :mod:`lib.macros` and the
15
+ ``_build_wifi_device_payload`` helper preserved on the mixin.
16
+
17
+ HTTP request text for the ``DEFINE_IP_CMD`` payloads is rendered via
18
+ :func:`lib.blob_decoders.render_wifi_ip_http_text`, the same canonical
19
+ writer the backup encoder uses, so wifi-create output and backup-decoder
20
+ round-trip stay byte-aligned by construction.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import ipaddress
26
+ import re
27
+ import time
28
+ from typing import Any
29
+
30
+ from .hub_versions import HUB_VERSION_X1, HUB_VERSION_X1S, HUB_VERSION_X2
31
+ from .blob_decoders import render_wifi_ip_http_text, render_wifi_roku_blob_body
32
+ from .device_create import DeviceCreateRequest, DeviceCreateResult, run_device_create
33
+ from .devices import DeviceConfig, build_device_create_payload
34
+ from .inputs import InputEntry, build_inputs_write
35
+ from .macros import MacroKeyEntry, build_macro_save_payload
36
+ from .protocol_const import (
37
+ ButtonName,
38
+ DEVICE_CLASS_WIFI_IP,
39
+ DEVICE_CLASS_WIFI_ROKU,
40
+ OP_REQ_ACTIVITY_INPUTS,
41
+ OP_REQ_BLOB,
42
+ )
43
+ from .state_helpers import normalize_device_entry
44
+
45
+
46
+ def _hex_to_bytes(raw_hex: str) -> bytes:
47
+ return bytes.fromhex(raw_hex)
48
+
49
+
50
+ def _validate_wifi_input_ids(
51
+ raw_ids: list[int] | None, *, max_command_id: int
52
+ ) -> list[int] | None:
53
+ """Range-check the input_command_ids surface of the wifi profile.
54
+
55
+ Raises ``ValueError`` if any id falls outside 1..max_command_id.
56
+ Returns ``None`` for ``None`` input so the profile dict carries
57
+ "leave inputs unconfigured" through the orchestrator unchanged.
58
+ """
59
+
60
+ if raw_ids is None:
61
+ return None
62
+ normalized: list[int] = []
63
+ for raw in raw_ids:
64
+ command_id = int(raw)
65
+ if command_id < 1 or command_id > max_command_id:
66
+ raise ValueError(
67
+ f"Unsupported input command_id {command_id}; expected 1..{max_command_id}"
68
+ )
69
+ normalized.append(command_id)
70
+ return normalized
71
+
72
+
73
+ def _wifi_command_label(command_spec: Any, idx: int) -> str:
74
+ if isinstance(command_spec, dict):
75
+ return (
76
+ str(
77
+ command_spec.get("display_name")
78
+ or command_spec.get("name")
79
+ or f"Command {idx + 1}"
80
+ ).strip()
81
+ or f"Command {idx + 1}"
82
+ )
83
+ return str(command_spec or f"Command {idx + 1}").strip() or f"Command {idx + 1}"
84
+
85
+
86
+ # Per-slot (key_id, command_code) pairs assigned to the user-defined wifi
87
+ # command buttons. The 0x4E2X codes mirror the synthetic codes the
88
+ # keymap layer writes for the same slots so the binding rows can be
89
+ # round-tripped against the records the hub stores.
90
+ _ROKU_APP_SLOTS: list[tuple[int, int]] = [
91
+ (0x18, 0x4E21),
92
+ (0x19, 0x4E22),
93
+ (0x1A, 0x4E23),
94
+ (0x1B, 0x4E24),
95
+ (0x1C, 0x4E25),
96
+ (0x1D, 0x4E26),
97
+ (0x1E, 0x4E27),
98
+ (0x1F, 0x4E28),
99
+ (0x20, 0x4E29),
100
+ (0x21, 0x4E2A),
101
+ (0x22, 0x4E2B),
102
+ (0x23, 0x4E2C),
103
+ (0x24, 0x4E2D),
104
+ (0x25, 0x4E2E),
105
+ (0x26, 0x4E2F),
106
+ (0x27, 0x4E30),
107
+ (0x28, 0x4E31),
108
+ (0x29, 0x4E32),
109
+ (0x2A, 0x4E33),
110
+ (0x2B, 0x4E34),
111
+ ]
112
+
113
+
114
+ _ROKU_X1S_INPUT_FINALIZE_HEADER = _hex_to_bytes(
115
+ "01 00 01 01 00 01 00 0b 01 0b 1c 10 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00"
116
+ )
117
+
118
+ _ROKU_X1S_INPUT_FINALIZE_TAIL = _hex_to_bytes(
119
+ "fc 00 01 fc 01 01 01 00 fc 01 fc 01 "
120
+ "00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 "
121
+ "00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 "
122
+ "00"
123
+ )
124
+
125
+
126
+ class WifiDeviceMixin:
127
+ """Mixin providing the wifi-command and IP-button create flows."""
128
+
129
+ def _build_wifi_device_payload(
130
+ self,
131
+ *,
132
+ device_name: str,
133
+ ip_address: str,
134
+ state_byte: int,
135
+ device_id: int = 0xFF,
136
+ device_class_byte: int = 0x01,
137
+ ip_device: bool = False,
138
+ brand_name: str = "m3tac0de",
139
+ wifi_power_state: tuple[int, int, int] | None = None,
140
+ ) -> bytes:
141
+ """Serialise a wifi-callback device record into the family-0x07 body.
142
+
143
+ Wifi devices share the same on-the-wire body shape as IR/BT/RF
144
+ devices (120 bytes on X1, 210 bytes on X1S/X2 -- terminated by a
145
+ body-checksum byte and wrapped in the standard ``[01][seq_be]``
146
+ page header). This routes wifi creates through the canonical
147
+ :func:`build_device_create_payload` so the same builder/parser
148
+ round-trip applies, instead of hand-patching offsets into a
149
+ captured hex blob.
150
+
151
+ The Roku-on-X1 and IP-generic-on-X1S/X2 variants differ in three
152
+ structured fields rather than in body shape:
153
+
154
+ - ``code_type`` -- ``0x0A`` for the Roku launcher class,
155
+ ``0x1C`` for the IP-generic class.
156
+ - tail IP marker -- present (``fc 55 [ip]``) on Roku, suppressed
157
+ on IP-generic (the destination address is carried inside each
158
+ command payload instead of the device record).
159
+ - the ``state_byte`` argument maps onto ``power_mode`` for the
160
+ Roku flow (the X1 power-mode default of ``1`` for the
161
+ ``tail_marker`` is preserved) and onto ``tail_marker`` for the
162
+ IP-generic flow (which leaves ``power_mode`` / ``power_style``
163
+ at zero). ``wifi_power_state`` overrides both with an explicit
164
+ ``(power_mode, power_style, tail_marker)`` triple, used when
165
+ the caller is committing a fully-configured device record.
166
+
167
+ ``device_class_byte`` is accepted for caller-shape parity but
168
+ no longer affects the wire body; the body's sub-marker is
169
+ always ``0x01`` per the canonical schema.
170
+ """
171
+
172
+ del device_class_byte # legacy parameter; the body sub-marker is fixed.
173
+
174
+ if wifi_power_state is not None:
175
+ power_mode = wifi_power_state[0] & 0xFF
176
+ power_style = wifi_power_state[1] & 0xFF
177
+ tail_marker = wifi_power_state[2] & 0xFF
178
+ elif ip_device:
179
+ # IP-generic devices keep the power fields at zero; the
180
+ # state byte drives the commit marker (``tail_marker``).
181
+ power_mode = 0
182
+ power_style = 0
183
+ tail_marker = state_byte & 0xFF
184
+ else:
185
+ # Roku-on-X1 flow: the state byte drives the power-mode
186
+ # field. The commit marker stays at the X1 firmware's
187
+ # historical default of ``1`` for this device class.
188
+ power_mode = state_byte & 0xFF
189
+ power_style = 2
190
+ tail_marker = 1
191
+
192
+ config = DeviceConfig(
193
+ name=device_name,
194
+ brand=brand_name,
195
+ device_id=device_id & 0xFF,
196
+ record_kind=0,
197
+ icon=1,
198
+ sort=0,
199
+ code_type=(0x1C if ip_device else 0x0A),
200
+ device_type=0x10,
201
+ code_id=b"\x00" * 16,
202
+ hide=0,
203
+ input_flag=0,
204
+ channel=0,
205
+ power_state=0,
206
+ ip_address=(None if ip_device else ip_address),
207
+ poll_time=0,
208
+ input_mode=2,
209
+ power_mode=power_mode,
210
+ power_style=power_style,
211
+ share_mode=0,
212
+ tail_marker=tail_marker,
213
+ )
214
+ return build_device_create_payload(config, hub_version=self.hub_version)
215
+
216
+ def _cache_created_wifi_device(
217
+ self,
218
+ *,
219
+ device_id: int,
220
+ device_name: str,
221
+ brand_name: str,
222
+ device_class: str,
223
+ device_class_code: int,
224
+ ) -> None:
225
+ dev_lo = device_id & 0xFF
226
+ self.state.devices[dev_lo] = normalize_device_entry(
227
+ {"brand": brand_name, "name": device_name},
228
+ default_class=device_class,
229
+ default_class_code=device_class_code,
230
+ )
231
+
232
+ def _build_device_power_binding_payload(
233
+ self,
234
+ *,
235
+ device_id: int,
236
+ button_id: int,
237
+ command_id: int | None,
238
+ ) -> bytes:
239
+ """Build the family-0x12 payload that binds a wifi device's POWER button.
240
+
241
+ The wire layout is identical to the standard macro-save body used by
242
+ the activity power-macro flow: a one-row binding keyed on the
243
+ device id and the POWER_ON / POWER_OFF button, optionally pointing
244
+ at a wifi command slot to invoke. When command_id is None the body
245
+ carries an empty key sequence, which the hub treats as an unbound
246
+ slot during initial device creation.
247
+
248
+ The X1 firmware variant carries the slot's command code embedded in
249
+ the row's fid field; the newer firmware variant looks the code up
250
+ by slot id internally and the fid is left zero.
251
+ """
252
+
253
+ label = "POWER_ON" if button_id == ButtonName.POWER_ON else "POWER_OFF"
254
+
255
+ if command_id is None:
256
+ key_sequence: list[MacroKeyEntry] = []
257
+ else:
258
+ if command_id < 1 or command_id > len(_ROKU_APP_SLOTS):
259
+ raise ValueError(f"Unsupported power command_id {command_id}")
260
+
261
+ _slot_id, command_code = _ROKU_APP_SLOTS[command_id - 1]
262
+ # Per-variant schema property: on X1 the row's fid field
263
+ # carries the synthetic command code; on X1S/X2 the hub
264
+ # looks the code up by slot id and the fid is zero. The
265
+ # same convention appears in the wifi-inputs entry build
266
+ # path in :mod:`lib.inputs` (see ``InputEntry.fid`` use in
267
+ # ``_apply_wifi_input_configuration``).
268
+ row_fid = command_code if self.hub_version == HUB_VERSION_X1 else 0
269
+ key_sequence = [
270
+ MacroKeyEntry(
271
+ device_id=device_id & 0xFF,
272
+ key_id=command_id & 0xFF,
273
+ fid=row_fid,
274
+ duration=0,
275
+ delay=0xFF,
276
+ )
277
+ ]
278
+
279
+ return build_macro_save_payload(
280
+ activity_id=device_id,
281
+ key_id=button_id,
282
+ key_sequence=key_sequence,
283
+ label=label,
284
+ hub_version=self.hub_version,
285
+ )
286
+
287
+ def _build_virtual_ip_wifi_input_finalize_payload(
288
+ self,
289
+ *,
290
+ device_id: int,
291
+ device_name: str,
292
+ brand_name: str,
293
+ ) -> bytes:
294
+ """Build the X1S/X2 wifi-create finalize payload.
295
+
296
+ Despite the historical name (it was once filed under the
297
+ family-0x46 "inputs" umbrella) this is a family-0x08 step that
298
+ commits the new wifi device's identity and signals the hub to
299
+ publish it. Phase 7 of the protocol refactor folds this into
300
+ the unified ``run_device_create`` orchestrator; it lives here
301
+ in the meantime so the wifi-create flow keeps working.
302
+ """
303
+
304
+ payload = bytearray()
305
+ payload.extend(_ROKU_X1S_INPUT_FINALIZE_HEADER)
306
+ payload[7] = device_id & 0xFF
307
+ payload[9] = device_id & 0xFF
308
+ payload.extend(b"\x4d\x00")
309
+ payload.extend(b"\x00" + device_name.encode("utf-16le")[:59].ljust(59, b"\x00"))
310
+ payload.extend(b"\x00" + brand_name.encode("utf-16le")[:59].ljust(59, b"\x00"))
311
+ payload.extend(_ROKU_X1S_INPUT_FINALIZE_TAIL)
312
+ payload[-1] = (sum(payload[:-1]) - 0x02) & 0xFF
313
+ return bytes(payload)
314
+
315
+ def _send_virtual_ip_wifi_publish_finalize(
316
+ self,
317
+ *,
318
+ device_id: int,
319
+ device_name: str,
320
+ brand_name: str,
321
+ ) -> bool:
322
+ """Send the X1S/X2 wifi-device "publish identity" finalize (0xD508).
323
+
324
+ Despite living near the inputs flow historically, this step is
325
+ what flips the hub-side "device configured" flag on X1S/X2 and
326
+ must run on every wifi-create regardless of whether the caller
327
+ configured any input slots. (The canonical family-0x08 device
328
+ record finalize sent earlier in the create flow is not enough on
329
+ these variants -- the firmware also needs this identity-publish
330
+ body, which carries a fixed bookend header/tail wrapped around
331
+ the device_id, name, and brand.)
332
+ """
333
+
334
+ finalize_payload = self._build_virtual_ip_wifi_input_finalize_payload(
335
+ device_id=device_id,
336
+ device_name=device_name,
337
+ brand_name=brand_name,
338
+ )
339
+ self._log.info(
340
+ "[WIFI][STEP] publish-finalize tx opcode=0x%04X expect_ack=0x0103 first_byte=* attempt=1/1",
341
+ 0xD508,
342
+ )
343
+ send_ts = time.monotonic()
344
+ self._send_cmd_frame(0xD508, finalize_payload)
345
+ ack = self.wait_for_ack_any([(0x0103, None)], timeout=5.0, not_before=send_ts)
346
+ if ack is None:
347
+ self._log.warning(
348
+ "[WIFI][STEP] publish-finalize failed waiting ack=0x0103 first_byte=*"
349
+ )
350
+ return False
351
+ self._log.info("[WIFI][STEP] publish-finalize acked via 0x%04X", ack[0])
352
+ return True
353
+
354
+ def _wait_for_wifi_input_refresh(
355
+ self,
356
+ *,
357
+ device_id: int,
358
+ command_id: int,
359
+ timeout: float = 5.0,
360
+ ) -> bool:
361
+ dev_lo = device_id & 0xFF
362
+ deadline = time.monotonic() + timeout
363
+ while time.monotonic() < deadline:
364
+ device_commands = self.state.commands.get(dev_lo, {})
365
+ if command_id in device_commands:
366
+ return True
367
+ time.sleep(0.05)
368
+ self._log.warning(
369
+ "[WIFI] timeout waiting for input refresh dev=0x%02X slot=%d",
370
+ dev_lo,
371
+ command_id,
372
+ )
373
+ return False
374
+
375
+ def _apply_wifi_input_configuration(
376
+ self,
377
+ *,
378
+ device_id: int,
379
+ device_name: str,
380
+ ip_address: str,
381
+ brand_name: str,
382
+ commands: list[Any],
383
+ input_command_ids: list[int] | None,
384
+ ) -> bool:
385
+ if not input_command_ids:
386
+ return True
387
+
388
+ if self.hub_version not in (HUB_VERSION_X1, HUB_VERSION_X1S, HUB_VERSION_X2):
389
+ self._log.info(
390
+ "[WIFI] input configuration is not yet implemented for hub version %s; skipping ids=%s",
391
+ self.hub_version,
392
+ input_command_ids,
393
+ )
394
+ return True
395
+
396
+ self._send_cmd_frame(OP_REQ_ACTIVITY_INPUTS, bytes([device_id & 0xFF]))
397
+ burst = self.wait_for_activity_inputs_burst(timeout=5.0)
398
+ if not burst.ok:
399
+ self._log.warning(
400
+ "[WIFI] missing activity-input candidates before input config dev=0x%02X (%s)",
401
+ device_id & 0xFF,
402
+ burst.outcome.value,
403
+ )
404
+ return False
405
+
406
+ wifi_entries: list[InputEntry] = []
407
+ for ordinal, command_id in enumerate(input_command_ids, start=1):
408
+ label = _wifi_command_label(commands[command_id - 1], command_id - 1)
409
+ _slot_id, command_code = _ROKU_APP_SLOTS[command_id - 1]
410
+ # On X1 the entry's fid field carries the same code byte the
411
+ # keymap layer would; X1S/X2 keeps the field zero (the wide
412
+ # ordinal byte already disambiguates entries).
413
+ row_fid = command_code if self.hub_version == HUB_VERSION_X1 else 0
414
+ wifi_entries.append(
415
+ InputEntry(
416
+ key_id=command_id & 0xFF,
417
+ fid=row_fid,
418
+ ordinal=ordinal,
419
+ label=label,
420
+ )
421
+ )
422
+
423
+ input_config_payload = build_inputs_write(
424
+ hub_version=self.hub_version,
425
+ device_id=device_id,
426
+ entries=wifi_entries,
427
+ )
428
+
429
+ page_payloads = self._build_paged_macro_save_payloads(input_config_payload)
430
+ for seq, page_payload in enumerate(page_payloads, start=1):
431
+ _step = self._send_step(
432
+ step_name=f"input-config-save[{seq}/{len(page_payloads)}]",
433
+ family=0x46,
434
+ payload=page_payload,
435
+ ack_opcode=0x0103,
436
+ )
437
+ if not _step.ok:
438
+ return False
439
+
440
+ for command_id in input_command_ids:
441
+ dev_commands = self.state.commands.get(device_id & 0xFF)
442
+ if isinstance(dev_commands, dict):
443
+ dev_commands.pop(command_id, None)
444
+ self._log.info(
445
+ "[WIFI] refresh input-config entry dev=0x%02X slot=%d",
446
+ device_id & 0xFF,
447
+ command_id,
448
+ )
449
+ self._send_cmd_frame(OP_REQ_BLOB, bytes([device_id & 0xFF, command_id & 0xFF]))
450
+ if not self._wait_for_wifi_input_refresh(
451
+ device_id=device_id,
452
+ command_id=command_id,
453
+ timeout=5.0,
454
+ ):
455
+ return False
456
+
457
+ # X1 re-runs the canonical family-0x08 device-record finalize
458
+ # after writing inputs; the X1S/X2 identity-publish finalize
459
+ # (0xD508 with a different body shape) is sent unconditionally
460
+ # by :meth:`_run_wifi_create_virtual_ip` so it also fires when
461
+ # no inputs are configured.
462
+ if self.hub_version == HUB_VERSION_X1:
463
+ finalize_payload = self._build_wifi_device_payload(
464
+ device_name=device_name,
465
+ ip_address=ip_address,
466
+ state_byte=0x01,
467
+ device_id=device_id,
468
+ brand_name=brand_name,
469
+ )
470
+ _step = self._send_step(
471
+ step_name="input-config-finalize",
472
+ family=0x08,
473
+ payload=finalize_payload,
474
+ ack_opcode=0x0103,
475
+ )
476
+ if not _step.ok:
477
+ return False
478
+
479
+ return True
480
+
481
+ def _apply_wifi_power_configuration(
482
+ self,
483
+ *,
484
+ device_id: int,
485
+ device_name: str,
486
+ ip_address: str,
487
+ brand_name: str,
488
+ power_on_command_id: int | None,
489
+ power_off_command_id: int | None,
490
+ ) -> bool:
491
+ if power_on_command_id is None and power_off_command_id is None:
492
+ return True
493
+
494
+ payload_7b08 = self._build_wifi_device_payload(
495
+ device_name=device_name,
496
+ ip_address=ip_address,
497
+ state_byte=0x01,
498
+ device_id=device_id,
499
+ brand_name=brand_name,
500
+ wifi_power_state=(0x01, 0x03, 0x01),
501
+ )
502
+ _step = self._send_step(
503
+ step_name="power-config-7b08",
504
+ family=0x08,
505
+ payload=payload_7b08,
506
+ ack_opcode=0x0103,
507
+ )
508
+ if not _step.ok:
509
+ return False
510
+
511
+ _step = self._send_step(
512
+ step_name="power-config-enable",
513
+ family=0x41,
514
+ payload=bytes([device_id, 0x01]),
515
+ ack_opcode=0x0103,
516
+ )
517
+ if not _step.ok:
518
+ return False
519
+
520
+ for button_id, command_id, name in (
521
+ (ButtonName.POWER_ON, power_on_command_id, "POWER_ON"),
522
+ (ButtonName.POWER_OFF, power_off_command_id, "POWER_OFF"),
523
+ ):
524
+ payload = self._build_device_power_binding_payload(
525
+ device_id=device_id,
526
+ button_id=button_id,
527
+ command_id=command_id,
528
+ )
529
+ _step = self._send_step(
530
+ step_name=f"power-config[{name}]",
531
+ family=0x12,
532
+ payload=payload,
533
+ ack_opcode=0x0112,
534
+ ack_first_byte=button_id,
535
+ ack_fallback_opcodes=(0x0103,),
536
+ )
537
+ if not _step.ok:
538
+ return False
539
+
540
+ return True
541
+
542
+ def _apply_virtual_ip_wifi_power_configuration(
543
+ self,
544
+ *,
545
+ device_id: int,
546
+ device_name: str,
547
+ ip_address: str,
548
+ brand_name: str,
549
+ power_on_command_id: int | None,
550
+ power_off_command_id: int | None,
551
+ ) -> bool:
552
+ if power_on_command_id is None and power_off_command_id is None:
553
+ return True
554
+
555
+ payload_d508 = self._build_wifi_device_payload(
556
+ device_name=device_name,
557
+ ip_address=ip_address,
558
+ state_byte=0x01,
559
+ device_id=device_id,
560
+ ip_device=True,
561
+ brand_name=brand_name,
562
+ )
563
+ _step = self._send_step(
564
+ step_name="power-config-d508",
565
+ family=0x08,
566
+ payload=payload_d508,
567
+ ack_opcode=0x0103,
568
+ )
569
+ if not _step.ok:
570
+ return False
571
+
572
+ _step = self._send_step(
573
+ step_name="power-config-enable",
574
+ family=0x41,
575
+ payload=bytes([device_id, 0x01]),
576
+ ack_opcode=0x0103,
577
+ )
578
+ if not _step.ok:
579
+ return False
580
+
581
+ for button_id, command_id, name in (
582
+ (ButtonName.POWER_ON, power_on_command_id, "POWER_ON"),
583
+ (ButtonName.POWER_OFF, power_off_command_id, "POWER_OFF"),
584
+ ):
585
+ if command_id is None:
586
+ continue
587
+ payload = self._build_device_power_binding_payload(
588
+ device_id=device_id,
589
+ button_id=button_id,
590
+ command_id=command_id,
591
+ )
592
+ _step = self._send_step(
593
+ step_name=f"power-config[{name}]",
594
+ family=0x12,
595
+ payload=payload,
596
+ ack_opcode=0x0112,
597
+ ack_first_byte=button_id,
598
+ ack_fallback_opcodes=(0x0103,),
599
+ )
600
+ if not _step.ok:
601
+ return False
602
+
603
+ return True
604
+
605
+ def create_wifi_device(
606
+ self,
607
+ device_name: str = "Home Assistant",
608
+ commands: list[Any] | None = None,
609
+ request_port: int = 8060,
610
+ brand_name: str = "m3tac0de",
611
+ power_on_command_id: int | None = None,
612
+ power_off_command_id: int | None = None,
613
+ input_command_ids: list[int] | None = None,
614
+ ) -> dict[str, Any] | None:
615
+ """Build a network-callback :class:`DeviceCreateRequest` and run it.
616
+
617
+ Thin adapter over :func:`run_device_create`; the dict return
618
+ value preserves the legacy contract used by service / WS
619
+ callers.
620
+ """
621
+
622
+ if not self.can_issue_commands():
623
+ self._log.info("[WIFI] create_wifi_device ignored: proxy client is connected")
624
+ return None
625
+ normalized_commands = list(commands or [])
626
+ request = DeviceCreateRequest(
627
+ transport="network_callback",
628
+ network_callback_profile={
629
+ "device_name": device_name,
630
+ "brand_name": brand_name,
631
+ "ip_address": self.get_routed_local_ip(),
632
+ "request_port": request_port,
633
+ "slots": normalized_commands,
634
+ "power_on_command_id": power_on_command_id,
635
+ "power_off_command_id": power_off_command_id,
636
+ "input_command_ids": _validate_wifi_input_ids(
637
+ input_command_ids, max_command_id=len(normalized_commands)
638
+ ),
639
+ },
640
+ )
641
+ result = run_device_create(self, request)
642
+ if not result.success or result.device_id is None:
643
+ return None
644
+ return {"device_id": result.device_id, "status": "success"}
645
+
646
+ def _run_network_callback_create(
647
+ self, request: DeviceCreateRequest
648
+ ) -> DeviceCreateResult:
649
+ """Dispatch the WiFi-create pipeline by hub variant.
650
+
651
+ Both per-variant pipelines (Roku-on-X1, IP-generic-on-X1S/X2)
652
+ read their inputs from ``request.network_callback_profile``;
653
+ the variant selection itself is local to this method so the
654
+ :class:`DeviceCreateRequest` surface stays variant-agnostic.
655
+
656
+ Each internal pipeline still returns the legacy
657
+ ``{"device_id": ..., "status": "success"} | None`` shape; the
658
+ conversion to :class:`DeviceCreateResult` lives here so the
659
+ wifi-write bodies can stay focused on wire orchestration. The
660
+ ``restored_inputs`` counter reflects the input slots requested
661
+ by the caller (non-zero only when input switching was wired in
662
+ via ``input_command_ids``); the wifi pipelines do not write
663
+ backup-style ``commands`` / ``button_bindings`` / ``macros``
664
+ rows so the other counters stay at zero.
665
+ """
666
+
667
+ profile = request.network_callback_profile or {}
668
+ if self.hub_version in (HUB_VERSION_X1S, HUB_VERSION_X2):
669
+ legacy = self._run_wifi_create_virtual_ip(profile)
670
+ else:
671
+ legacy = self._run_wifi_create_x1_roku(profile)
672
+ if not legacy or legacy.get("device_id") is None:
673
+ return DeviceCreateResult(
674
+ success=False,
675
+ failed_step_label="network-callback-create",
676
+ )
677
+ input_ids = profile.get("input_command_ids") or []
678
+ return DeviceCreateResult(
679
+ success=True,
680
+ device_id=int(legacy["device_id"]) & 0xFF,
681
+ restored_inputs=len(input_ids),
682
+ )
683
+
684
+ def _run_wifi_create_x1_roku(
685
+ self, profile: dict[str, Any]
686
+ ) -> dict[str, Any] | None:
687
+ """Run the X1 Roku-style WiFi-create sequence.
688
+
689
+ Body relocated from the previous public ``create_wifi_device``
690
+ method; inputs flow in through the network-callback profile
691
+ dict carried on :class:`DeviceCreateRequest`.
692
+ """
693
+
694
+ device_name = str(profile.get("device_name") or "Home Assistant")
695
+ brand_name = str(profile.get("brand_name") or "m3tac0de")
696
+ ip_address = str(profile.get("ip_address") or self.get_routed_local_ip())
697
+ normalized_commands = list(profile.get("slots") or [])
698
+ power_on_command_id = profile.get("power_on_command_id")
699
+ power_off_command_id = profile.get("power_off_command_id")
700
+ normalized_input_command_ids = profile.get("input_command_ids")
701
+
702
+ self.reset_ack_queues()
703
+ self._log.info("[WIFI] starting exact Wifi Device create replay sequence")
704
+
705
+ _step = self._send_step(
706
+ step_name="create-device",
707
+ family=0x07,
708
+ payload=self._build_wifi_device_payload(device_name=device_name, ip_address=ip_address, state_byte=0x00, brand_name=brand_name),
709
+ ack_opcode=0x0107,
710
+ )
711
+ if not _step.ok:
712
+ return None
713
+
714
+ device_id = self.wait_for_assigned_device_id(timeout=5.0)
715
+ if device_id is None:
716
+ self._log.warning("[WIFI] hub did not provide device id after create request")
717
+ return None
718
+ self._log.info("[WIFI] hub assigned device id=0x%02X", device_id)
719
+
720
+ command_defs: list[tuple[int, int, str, str]] = []
721
+
722
+ if normalized_commands:
723
+ for idx, command_spec in enumerate(normalized_commands[: len(_ROKU_APP_SLOTS)]):
724
+ slot, code = _ROKU_APP_SLOTS[idx]
725
+ if isinstance(command_spec, dict):
726
+ command_name = _wifi_command_label(command_spec, idx)
727
+ trigger_name = str(
728
+ command_spec.get("trigger_name")
729
+ or command_spec.get("name")
730
+ or command_name
731
+ ).strip() or command_name
732
+ press_type = str(command_spec.get("press_type") or "short").strip().lower()
733
+ else:
734
+ command_name = _wifi_command_label(command_spec, idx)
735
+ trigger_name = command_name
736
+ press_type = "short"
737
+ command_index = int(command_spec.get("command_index", idx)) if isinstance(command_spec, dict) else idx
738
+ action = self._build_launch_action_path(
739
+ device_id=device_id,
740
+ command_index=command_index,
741
+ press_type=press_type,
742
+ )
743
+ command_defs.append((slot, code, command_name, action))
744
+
745
+ for slot, code, name, action in command_defs:
746
+ if self.hub_version in (HUB_VERSION_X1S, HUB_VERSION_X2):
747
+ name_utf16 = name.encode("utf-16le")[:59]
748
+ name_blob = b"\x00" + name_utf16
749
+ name_blob = name_blob.ljust(60, b"\x00")
750
+ else:
751
+ name_blob = name.encode("ascii", errors="ignore")[:30].ljust(30, b"\x00")
752
+ # Cap the path at 255 bytes so render_wifi_roku_blob_body's
753
+ # 1-byte length prefix never overflows. The canonical
754
+ # writer in blob_decoders is what backups round-trip
755
+ # against, so going through it here keeps wifi-create
756
+ # output and backup-decoder input byte-identical for the
757
+ # same path string.
758
+ safe_action = action.encode("ascii", errors="ignore")[:255].decode(
759
+ "ascii", errors="ignore"
760
+ )
761
+ roku_blob_body = render_wifi_roku_blob_body(path=safe_action)
762
+ payload_base = (
763
+ bytes([slot, 0x00, 0x01, 0x21, 0x00, 0x01, device_id, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x00])
764
+ + code.to_bytes(2, "big")
765
+ + name_blob
766
+ + roku_blob_body
767
+ )
768
+ payload_token = (sum(payload_base) - (slot + 1)) & 0xFF
769
+ payload = payload_base + bytes([payload_token])
770
+ _step = self._send_step(
771
+ step_name=f"define-command[{slot:02d}] {name}",
772
+ family=0x0E,
773
+ payload=payload,
774
+ ack_opcode=0x0103,
775
+ )
776
+ if not _step.ok:
777
+ return None
778
+
779
+ for button_id, name in (
780
+ (ButtonName.POWER_ON, "POWER_ON"),
781
+ (ButtonName.POWER_OFF, "POWER_OFF"),
782
+ ):
783
+ payload = self._build_device_power_binding_payload(
784
+ device_id=device_id,
785
+ button_id=button_id,
786
+ command_id=None,
787
+ )
788
+ _step = self._send_step(
789
+ step_name=f"configure-power[{name}]",
790
+ family=0x12,
791
+ payload=payload,
792
+ ack_opcode=0x0112,
793
+ ack_first_byte=button_id,
794
+ ack_fallback_opcodes=(0x0103,),
795
+ )
796
+ if not _step.ok:
797
+ return None
798
+
799
+ # Phase 10: the wifi-create "sync stage" mid-flow write is an
800
+ # empty/disabled family-0x46 inputs page. Route through the
801
+ # canonical builder so every family-0x46 send in the
802
+ # integration originates from :func:`build_inputs_write`.
803
+ _step = self._send_step(
804
+ step_name="sync-stage-7746",
805
+ family=0x46,
806
+ payload=build_inputs_write(
807
+ hub_version=self.hub_version,
808
+ device_id=device_id,
809
+ source_id_byte=0,
810
+ ),
811
+ ack_opcode=0x0103,
812
+ )
813
+ if not _step.ok:
814
+ return None
815
+
816
+ _step = self._send_step(
817
+ step_name="confirm-power-config",
818
+ family=0x41,
819
+ payload=bytes([device_id, 0x04]),
820
+ ack_opcode=0x0103,
821
+ )
822
+ if not _step.ok:
823
+ return None
824
+
825
+ payload_7b08 = self._build_wifi_device_payload(
826
+ device_name=device_name,
827
+ ip_address=ip_address,
828
+ state_byte=0x01,
829
+ device_id=device_id,
830
+ brand_name=brand_name,
831
+ )
832
+ _step = self._send_step(
833
+ step_name="finalize-device-7b08",
834
+ family=0x08,
835
+ payload=payload_7b08,
836
+ ack_opcode=0x0103,
837
+ )
838
+ if not _step.ok:
839
+ return None
840
+
841
+ _step = self._send_step(
842
+ step_name="save-tail-0064",
843
+ family=0x64,
844
+ payload=b"",
845
+ ack_opcode=0x0103,
846
+ )
847
+ if not _step.ok:
848
+ return None
849
+
850
+ self._cache_created_wifi_device(
851
+ device_id=device_id,
852
+ device_name=device_name,
853
+ brand_name=brand_name,
854
+ device_class=DEVICE_CLASS_WIFI_ROKU,
855
+ device_class_code=0x0A,
856
+ )
857
+
858
+ if not self._apply_wifi_power_configuration(
859
+ device_id=device_id,
860
+ device_name=device_name,
861
+ ip_address=ip_address,
862
+ brand_name=brand_name,
863
+ power_on_command_id=power_on_command_id,
864
+ power_off_command_id=power_off_command_id,
865
+ ):
866
+ return None
867
+
868
+ if not self._apply_wifi_input_configuration(
869
+ device_id=device_id,
870
+ device_name=device_name,
871
+ ip_address=ip_address,
872
+ brand_name=brand_name,
873
+ commands=normalized_commands,
874
+ input_command_ids=normalized_input_command_ids,
875
+ ):
876
+ return None
877
+
878
+ self._log.info("[WIFI] replayed Wifi Device create sequence for dev=0x%02X", device_id)
879
+ return {"device_id": device_id, "status": "success"}
880
+
881
+ def _run_wifi_create_virtual_ip(
882
+ self, profile: dict[str, Any]
883
+ ) -> dict[str, Any] | None:
884
+ """Run the X1S/X2 virtual-IP WiFi-create sequence.
885
+
886
+ Companion to :meth:`_run_wifi_create_x1_roku`; same dispatch
887
+ surface (a network-callback profile dict), different on-the-
888
+ wire shape (IP-generic family-0x0E payloads, the X1S/X2
889
+ finalize step, and the IP power configuration pass).
890
+ """
891
+
892
+ device_name = str(profile.get("device_name") or "Home Assistant")
893
+ brand_name = str(profile.get("brand_name") or "m3tac0de")
894
+ ip_address = str(profile.get("ip_address") or self.get_routed_local_ip())
895
+ commands = profile.get("slots")
896
+ request_port = int(profile.get("request_port") or 8060)
897
+ power_on_command_id = profile.get("power_on_command_id")
898
+ power_off_command_id = profile.get("power_off_command_id")
899
+ input_command_ids = profile.get("input_command_ids")
900
+
901
+ self.reset_ack_queues()
902
+ self._log.info("[WIFI] starting virtual IP Wifi Device create replay sequence")
903
+
904
+ _step = self._send_step(
905
+ step_name="create-device",
906
+ family=0x07,
907
+ payload=self._build_wifi_device_payload(device_name=device_name, ip_address=ip_address, state_byte=0x00, ip_device=True, brand_name=brand_name),
908
+ ack_opcode=0x0107,
909
+ )
910
+ if not _step.ok:
911
+ return None
912
+
913
+ device_id = self.wait_for_assigned_device_id(timeout=5.0)
914
+ if device_id is None:
915
+ self._log.warning("[WIFI] hub did not provide device id after create request")
916
+ return None
917
+
918
+ request_ip = ipaddress.IPv4Address(ip_address).packed
919
+ for idx, command_spec in enumerate((commands or [])[: len(_ROKU_APP_SLOTS)]):
920
+ slot = (idx + 1) & 0xFF
921
+ if isinstance(command_spec, dict):
922
+ command_name = _wifi_command_label(command_spec, idx)
923
+ trigger_name = str(
924
+ command_spec.get("trigger_name")
925
+ or command_spec.get("name")
926
+ or command_name
927
+ ).strip() or command_name
928
+ press_type = str(command_spec.get("press_type") or "short").strip().lower()
929
+ else:
930
+ command_name = _wifi_command_label(command_spec, idx)
931
+ trigger_name = command_name
932
+ press_type = "short"
933
+ # Observed X1S/X2 0x?E0E payloads encode command labels in a 59-byte field.
934
+ # Using 59 keeps downstream request bytes aligned so method parses as POST (not xPOST).
935
+ command_utf16 = command_name.encode("utf-16le")[:59].ljust(59, b"\x00")
936
+ command_index = int(command_spec.get("command_index", idx)) if isinstance(command_spec, dict) else idx
937
+ request_blob = self._build_virtual_ip_http_request(
938
+ host=ip_address,
939
+ port=request_port,
940
+ path=self._build_launch_action_path(
941
+ device_id=device_id,
942
+ command_index=command_index,
943
+ press_type=press_type,
944
+ ),
945
+ )
946
+ payload_base = (
947
+ bytes([slot, 0x00, 0x01, 0x03, 0x00, 0x01, device_id, 0x00, 0x1C])
948
+ + (b"\x00" * 7)
949
+ + command_utf16
950
+ + request_ip
951
+ + int(request_port & 0xFFFF).to_bytes(2, "big")
952
+ + b"\x00"
953
+ + bytes([len(request_blob) & 0xFF])
954
+ + request_blob
955
+ )
956
+ payload_token = (sum(payload_base) - (slot + 1)) & 0xFF
957
+ payload = payload_base + bytes([payload_token])
958
+ _step = self._send_step(
959
+ step_name=f"define-ip-command[{slot:02d}] {command_name}",
960
+ family=0x0E,
961
+ payload=payload,
962
+ ack_opcode=0x0103,
963
+ )
964
+ if not _step.ok:
965
+ return None
966
+
967
+ _step = self._send_step(
968
+ step_name="post-map-commit",
969
+ family=0x41,
970
+ payload=bytes([device_id, 0x04]),
971
+ ack_opcode=0x0103,
972
+ )
973
+ if not _step.ok:
974
+ return None
975
+
976
+ # Phase 10: the wifi-create "sync stage" mid-flow write is an
977
+ # empty/disabled family-0x46 inputs page. Route through the
978
+ # canonical builder so every family-0x46 send in the
979
+ # integration originates from :func:`build_inputs_write`.
980
+ _step = self._send_step(
981
+ step_name="sync-stage-7746",
982
+ family=0x46,
983
+ payload=build_inputs_write(
984
+ hub_version=self.hub_version,
985
+ device_id=device_id,
986
+ source_id_byte=0,
987
+ ),
988
+ ack_opcode=0x0103,
989
+ )
990
+ if not _step.ok:
991
+ return None
992
+
993
+ payload_7b08 = self._build_wifi_device_payload(
994
+ device_name=device_name,
995
+ ip_address=ip_address,
996
+ state_byte=0x01,
997
+ device_id=device_id,
998
+ ip_device=True,
999
+ brand_name=brand_name,
1000
+ )
1001
+ _step = self._send_step(
1002
+ step_name="finalize-device-7b08",
1003
+ family=0x08,
1004
+ payload=payload_7b08,
1005
+ ack_opcode=0x0103,
1006
+ )
1007
+ if not _step.ok:
1008
+ return None
1009
+
1010
+ _step = self._send_step(
1011
+ step_name="save-tail-0064",
1012
+ family=0x64,
1013
+ payload=b"",
1014
+ ack_opcode=0x0103,
1015
+ )
1016
+ if not _step.ok:
1017
+ return None
1018
+
1019
+ self._cache_created_wifi_device(
1020
+ device_id=device_id,
1021
+ device_name=device_name,
1022
+ brand_name=brand_name,
1023
+ device_class=DEVICE_CLASS_WIFI_IP,
1024
+ device_class_code=0x1C,
1025
+ )
1026
+
1027
+ if not self._apply_virtual_ip_wifi_power_configuration(
1028
+ device_id=device_id,
1029
+ device_name=device_name,
1030
+ ip_address=ip_address,
1031
+ brand_name=brand_name,
1032
+ power_on_command_id=power_on_command_id,
1033
+ power_off_command_id=power_off_command_id,
1034
+ ):
1035
+ return None
1036
+
1037
+ if not self._apply_wifi_input_configuration(
1038
+ device_id=device_id,
1039
+ device_name=device_name,
1040
+ ip_address=ip_address,
1041
+ brand_name=brand_name,
1042
+ commands=list(commands or []),
1043
+ input_command_ids=input_command_ids,
1044
+ ):
1045
+ return None
1046
+
1047
+ # X1S/X2 firmware only marks the device as "configured" once the
1048
+ # identity-publish finalize lands, regardless of whether any
1049
+ # input slots were configured. Always run it after power and
1050
+ # input writes so it observes their final state.
1051
+ if not self._send_virtual_ip_wifi_publish_finalize(
1052
+ device_id=device_id,
1053
+ device_name=device_name,
1054
+ brand_name=brand_name,
1055
+ ):
1056
+ return None
1057
+
1058
+ self._log.info("[WIFI] replayed virtual IP Wifi Device create sequence for dev=0x%02X", device_id)
1059
+ return {"device_id": device_id, "status": "success"}
1060
+
1061
+ def _build_launch_action_path(
1062
+ self,
1063
+ *,
1064
+ device_id: int,
1065
+ command_index: int,
1066
+ press_type: str = "short",
1067
+ ) -> str:
1068
+ hub_action_id = self._stable_hub_action_id()
1069
+ normalized_press_type = "long" if str(press_type).lower() == "long" else "short"
1070
+ return f"launch/{hub_action_id}/{device_id}/{command_index}/{normalized_press_type}"
1071
+
1072
+ def _build_virtual_ip_http_request(self, host: str, port: int, path: str) -> bytes:
1073
+ # Specialization of the canonical wifi_ip HTTP-text writer for
1074
+ # the "launch app" command pattern this flow uses (POST,
1075
+ # x-www-form-urlencoded Content-Type, no body). Both this site
1076
+ # and the backup encoder route through render_wifi_ip_http_text
1077
+ # so the bytes the hub stores after wifi-create are guaranteed
1078
+ # to be the same bytes the backup decoder round-trips.
1079
+ return render_wifi_ip_http_text(
1080
+ host=host,
1081
+ port=int(port) & 0xFFFF,
1082
+ method="POST",
1083
+ path=f"/{path.lstrip('/')}",
1084
+ header="",
1085
+ content_type="application/x-www-form-urlencoded",
1086
+ body="",
1087
+ )
1088
+
1089
+ def _stable_hub_action_id(self) -> str:
1090
+ """Return a stable hub identifier for WiFi command actions."""
1091
+
1092
+ raw_mac = str(self.mdns_txt.get("MAC") or self.mdns_txt.get("mac") or "").strip()
1093
+ if raw_mac:
1094
+ normalized_mac = re.sub(r"[^0-9A-Fa-f]", "", raw_mac).lower()
1095
+ if normalized_mac:
1096
+ return normalized_mac
1097
+
1098
+ return str(self.proxy_id).strip()
1099
+
1100
+
1101
+ __all__ = ["WifiDeviceMixin"]