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