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,806 @@
|
|
|
1
|
+
"""Structural decoders for command-blob device classes.
|
|
2
|
+
|
|
3
|
+
Five device classes carry user-meaningful structure inside their
|
|
4
|
+
stored command blobs:
|
|
5
|
+
|
|
6
|
+
* ``wifi_ip`` — host + port + full HTTP request text
|
|
7
|
+
* ``wifi_roku`` — URL path fragment for Roku ECP
|
|
8
|
+
* ``wifi_hue`` — Hue REST URL path + body block
|
|
9
|
+
* ``wifi_sonos`` — Sonos UPnP/SOAP path + envelope body
|
|
10
|
+
* ``ir`` — descriptive replay blobs (``P:Sony12 R:... etc.``);
|
|
11
|
+
non-descriptive learned-IR blobs fail content-sniff and stay raw
|
|
12
|
+
|
|
13
|
+
This module provides round-trip decode / encode functions for each so
|
|
14
|
+
backup payloads can ship a parallel ``decoded`` block next to the raw
|
|
15
|
+
``data_hex`` (purely additive, never replacing the wire bytes), and so
|
|
16
|
+
the Fetch Blob tool can offer a descriptive/hex toggle for these
|
|
17
|
+
classes through a single uniform path.
|
|
18
|
+
|
|
19
|
+
Design rules (mirrors docs/protocol/command-blob-decoders.md):
|
|
20
|
+
|
|
21
|
+
* The decoder reads the structural prefix, then captures everything
|
|
22
|
+
after the body verbatim into ``trailer_hex``. Trailer bytes are
|
|
23
|
+
opaque — checksums, readback framing, padding nulls — and the
|
|
24
|
+
encoder re-emits them unchanged.
|
|
25
|
+
* Every public encode/decode pair MUST satisfy
|
|
26
|
+
``encode(decode(data)) == data`` byte-for-byte.
|
|
27
|
+
* ``try_decode_blob`` is the high-level entry point: it runs decode,
|
|
28
|
+
runs encode on the result, and only returns a structured block when
|
|
29
|
+
the round-trip is exact. Any mismatch returns ``None``, allowing
|
|
30
|
+
callers to fall back to raw ``data_hex`` without thinking about why.
|
|
31
|
+
* The IR decoder is *content-sniffed* — it only succeeds when the
|
|
32
|
+
blob carries the descriptive-protocol magic prefix. Non-descriptive
|
|
33
|
+
(raw learned-IR / database) blobs return ``None`` so the caller
|
|
34
|
+
keeps the existing raw-hex behavior.
|
|
35
|
+
|
|
36
|
+
This module does not import anything from the rest of the integration
|
|
37
|
+
and has no I/O. It is safe to import from both the device-backup path
|
|
38
|
+
and the Fetch Blob result builder.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
from typing import Any, Callable, Dict, Tuple
|
|
44
|
+
|
|
45
|
+
from .protocol_const import (
|
|
46
|
+
DEVICE_CLASS_IR,
|
|
47
|
+
DEVICE_CLASS_WIFI_HUE,
|
|
48
|
+
DEVICE_CLASS_WIFI_IP,
|
|
49
|
+
DEVICE_CLASS_WIFI_ROKU,
|
|
50
|
+
DEVICE_CLASS_WIFI_SONOS,
|
|
51
|
+
normalize_device_class,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Classes this module knows how to round-trip. Anything else returns
|
|
56
|
+
# ``None`` from :func:`try_decode_blob` and the caller treats the row
|
|
57
|
+
# as undecoded.
|
|
58
|
+
#
|
|
59
|
+
# Note: ``ir`` is in this list but the decoder is *content-sniffed*,
|
|
60
|
+
# not class-gated — non-descriptive IR blobs will still return ``None``
|
|
61
|
+
# and fall back to raw hex. ``is_decodable_class("ir")`` therefore
|
|
62
|
+
# means "this class *can* carry decoded structure," not "every blob in
|
|
63
|
+
# this class does."
|
|
64
|
+
DECODABLE_CLASSES: Tuple[str, ...] = (
|
|
65
|
+
DEVICE_CLASS_WIFI_IP,
|
|
66
|
+
DEVICE_CLASS_WIFI_ROKU,
|
|
67
|
+
DEVICE_CLASS_WIFI_HUE,
|
|
68
|
+
DEVICE_CLASS_WIFI_SONOS,
|
|
69
|
+
DEVICE_CLASS_IR,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Per-class decoders / encoders
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _coerce_blob_bytes(data: Any) -> bytes:
|
|
79
|
+
"""Accept bytes / bytearray / hex-string forms and return raw bytes.
|
|
80
|
+
|
|
81
|
+
The hex form is what every caller in this codebase actually has —
|
|
82
|
+
backup rows carry ``data_hex`` and the Fetch Blob result carries
|
|
83
|
+
``command_blob`` as a space-separated hex string. Accepting both
|
|
84
|
+
forms means the decoder can be invoked uniformly from either site.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
if isinstance(data, (bytes, bytearray)):
|
|
88
|
+
return bytes(data)
|
|
89
|
+
if isinstance(data, str):
|
|
90
|
+
cleaned = data.replace(" ", "").replace("\n", "").replace("\r", "")
|
|
91
|
+
if not cleaned:
|
|
92
|
+
return b""
|
|
93
|
+
try:
|
|
94
|
+
return bytes.fromhex(cleaned)
|
|
95
|
+
except ValueError as exc:
|
|
96
|
+
raise ValueError(f"invalid hex blob string: {data!r}") from exc
|
|
97
|
+
raise TypeError(f"unsupported blob input type: {type(data).__name__}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _bytes_to_hex(data: bytes) -> str:
|
|
101
|
+
"""Render bytes as the space-separated lowercase hex this project uses."""
|
|
102
|
+
|
|
103
|
+
return data.hex(" ")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---- wifi_ip (class 0x1C) -------------------------------------------------
|
|
107
|
+
#
|
|
108
|
+
# Wire body layout:
|
|
109
|
+
# [0..4] = IPv4 octets (network order)
|
|
110
|
+
# [4..6] = port (big-endian)
|
|
111
|
+
# [6..8] = HTTP text length N (big-endian)
|
|
112
|
+
# [8..8+N] = HTTP request text (ASCII)
|
|
113
|
+
# [8+N..] = opaque trailer
|
|
114
|
+
#
|
|
115
|
+
# HTTP text rendering rule (must round-trip exactly):
|
|
116
|
+
#
|
|
117
|
+
# "{method} {path} HTTP/1.1\r\n"
|
|
118
|
+
# "Host:{host}:{port}\r\n"
|
|
119
|
+
# "{header}\r\n" (only if header non-empty)
|
|
120
|
+
# "Content-Type:{content_type}\r\n" (only if content_type non-empty)
|
|
121
|
+
# "Content-Length:{len(body)}\r\n\r\n{body}" (only if body non-empty)
|
|
122
|
+
# "\r\n" (unconditional terminator)
|
|
123
|
+
#
|
|
124
|
+
# Notes:
|
|
125
|
+
# * When ``body`` is empty there is no Content-Length line and no
|
|
126
|
+
# blank-line separator; the text ends with the unconditional "\r\n"
|
|
127
|
+
# terminator (so a bodyless request ending in a Content-Type line
|
|
128
|
+
# finishes with "\r\n\r\n" — one from the line itself, one from
|
|
129
|
+
# the terminator).
|
|
130
|
+
# * The decoder stores neither ``Host`` nor ``Content-Length`` in the
|
|
131
|
+
# fields: ``Host`` is reconstructed from ``host`` + ``port`` on
|
|
132
|
+
# encode (and verified against the structural prefix on decode),
|
|
133
|
+
# and ``Content-Length`` is recomputed from ``len(body)`` on encode.
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _decode_wifi_ip(data: bytes) -> Dict[str, Any]:
|
|
137
|
+
if len(data) < 8:
|
|
138
|
+
raise ValueError("wifi_ip blob too short for IP/port/length header")
|
|
139
|
+
host = "{0}.{1}.{2}.{3}".format(data[0], data[1], data[2], data[3])
|
|
140
|
+
port = int.from_bytes(data[4:6], "big")
|
|
141
|
+
text_len = int.from_bytes(data[6:8], "big")
|
|
142
|
+
if len(data) < 8 + text_len:
|
|
143
|
+
raise ValueError("wifi_ip blob shorter than declared HTTP text length")
|
|
144
|
+
try:
|
|
145
|
+
text = data[8 : 8 + text_len].decode("ascii")
|
|
146
|
+
except UnicodeDecodeError as exc:
|
|
147
|
+
raise ValueError("wifi_ip HTTP text region is not ASCII") from exc
|
|
148
|
+
trailer = data[8 + text_len :]
|
|
149
|
+
|
|
150
|
+
method, path, header, content_type, body = _parse_wifi_ip_http_text(
|
|
151
|
+
text, host=host, port=port
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
"host": host,
|
|
156
|
+
"port": port,
|
|
157
|
+
"method": method,
|
|
158
|
+
"path": path,
|
|
159
|
+
"header": header,
|
|
160
|
+
"content_type": content_type,
|
|
161
|
+
"body": body,
|
|
162
|
+
"trailer_hex": _bytes_to_hex(trailer),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _parse_wifi_ip_http_text(
|
|
167
|
+
text: str, *, host: str, port: int
|
|
168
|
+
) -> Tuple[str, str, str, str, str]:
|
|
169
|
+
"""Split the HTTP text into ``(method, path, header, content_type, body)``.
|
|
170
|
+
|
|
171
|
+
The hub's text always ends with an unconditional "\r\n" terminator.
|
|
172
|
+
When there is a body, that terminator follows the body. When there
|
|
173
|
+
is no body, it follows the last header line.
|
|
174
|
+
|
|
175
|
+
The function does NOT enforce any structural rule beyond what the
|
|
176
|
+
encoder will round-trip — that means the inner round-trip check in
|
|
177
|
+
:func:`try_decode_blob` is the real gate.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
# The encoder always uses "\r\n\r\n" between Content-Length and the
|
|
181
|
+
# body. For bodyless requests, "\r\n\r\n" appears at the very end
|
|
182
|
+
# (Content-Type-line CRLF + terminator CRLF). The decision rule is:
|
|
183
|
+
# the first "\r\n\r\n" occurrence separates the header section
|
|
184
|
+
# from the body section; everything after it is either empty
|
|
185
|
+
# (bodyless) or "<body>\r\n" (with body — the trailing CRLF is the
|
|
186
|
+
# unconditional terminator).
|
|
187
|
+
separator = "\r\n\r\n"
|
|
188
|
+
sep_index = text.find(separator)
|
|
189
|
+
if sep_index < 0:
|
|
190
|
+
raise ValueError("wifi_ip HTTP text missing CRLFCRLF separator")
|
|
191
|
+
header_section = text[:sep_index]
|
|
192
|
+
body_section = text[sep_index + len(separator) :]
|
|
193
|
+
|
|
194
|
+
header_lines = header_section.split("\r\n")
|
|
195
|
+
if not header_lines:
|
|
196
|
+
raise ValueError("wifi_ip HTTP text has no request line")
|
|
197
|
+
request_line = header_lines[0]
|
|
198
|
+
remaining_header_lines = header_lines[1:]
|
|
199
|
+
|
|
200
|
+
# Request line: "METHOD PATH HTTP/1.1". Split off the HTTP-version
|
|
201
|
+
# from the right so PATH can theoretically contain spaces.
|
|
202
|
+
rsplit = request_line.rsplit(" ", 1)
|
|
203
|
+
if len(rsplit) != 2 or rsplit[1] != "HTTP/1.1":
|
|
204
|
+
raise ValueError(f"wifi_ip request line malformed: {request_line!r}")
|
|
205
|
+
method, _, path = rsplit[0].partition(" ")
|
|
206
|
+
if not method or not path:
|
|
207
|
+
raise ValueError(f"wifi_ip request line missing method or path: {request_line!r}")
|
|
208
|
+
|
|
209
|
+
# Walk header lines, pulling Host / Content-Type / Content-Length
|
|
210
|
+
# into structured slots and accumulating everything else into the
|
|
211
|
+
# free-form ``header`` field. Order within ``header`` is preserved
|
|
212
|
+
# so re-encoded text matches the original.
|
|
213
|
+
expected_host_line = "Host:{0}:{1}".format(host, port)
|
|
214
|
+
saw_host = False
|
|
215
|
+
content_type = ""
|
|
216
|
+
declared_content_length: int | None = None
|
|
217
|
+
extra_header_lines: list[str] = []
|
|
218
|
+
for line in remaining_header_lines:
|
|
219
|
+
if not saw_host and line == expected_host_line:
|
|
220
|
+
saw_host = True
|
|
221
|
+
continue
|
|
222
|
+
if line.startswith("Content-Type:"):
|
|
223
|
+
# First Content-Type wins; subsequent ones would have been
|
|
224
|
+
# written by the user into ``header`` originally, so route
|
|
225
|
+
# extras to the free-form bucket.
|
|
226
|
+
if content_type == "":
|
|
227
|
+
content_type = line[len("Content-Type:") :]
|
|
228
|
+
continue
|
|
229
|
+
extra_header_lines.append(line)
|
|
230
|
+
continue
|
|
231
|
+
if line.startswith("Content-Length:"):
|
|
232
|
+
try:
|
|
233
|
+
declared_content_length = int(line[len("Content-Length:") :])
|
|
234
|
+
except ValueError as exc:
|
|
235
|
+
raise ValueError(
|
|
236
|
+
f"wifi_ip Content-Length not an integer: {line!r}"
|
|
237
|
+
) from exc
|
|
238
|
+
continue
|
|
239
|
+
extra_header_lines.append(line)
|
|
240
|
+
|
|
241
|
+
if not saw_host:
|
|
242
|
+
raise ValueError(
|
|
243
|
+
f"wifi_ip Host header missing or disagrees with prefix bytes "
|
|
244
|
+
f"(expected {expected_host_line!r})"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# The body section is either "" (bodyless: terminator only) or
|
|
248
|
+
# "<body>\r\n" (with-body: body + terminator). Anything else means
|
|
249
|
+
# the encoder rule has been violated.
|
|
250
|
+
if body_section == "":
|
|
251
|
+
body = ""
|
|
252
|
+
if declared_content_length is not None:
|
|
253
|
+
raise ValueError(
|
|
254
|
+
"wifi_ip Content-Length present but body section is empty"
|
|
255
|
+
)
|
|
256
|
+
else:
|
|
257
|
+
if not body_section.endswith("\r\n"):
|
|
258
|
+
raise ValueError(
|
|
259
|
+
"wifi_ip body section missing the trailing CRLF terminator"
|
|
260
|
+
)
|
|
261
|
+
body = body_section[:-2]
|
|
262
|
+
if declared_content_length is None:
|
|
263
|
+
raise ValueError(
|
|
264
|
+
"wifi_ip body present but no Content-Length header was found"
|
|
265
|
+
)
|
|
266
|
+
if declared_content_length != len(body):
|
|
267
|
+
raise ValueError(
|
|
268
|
+
"wifi_ip Content-Length disagrees with body length "
|
|
269
|
+
f"(declared {declared_content_length}, actual {len(body)})"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
header_field = "\r\n".join(extra_header_lines)
|
|
273
|
+
return method, path, header_field, content_type, body
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def render_wifi_ip_http_text(
|
|
277
|
+
*,
|
|
278
|
+
host: str,
|
|
279
|
+
port: int,
|
|
280
|
+
method: str,
|
|
281
|
+
path: str,
|
|
282
|
+
header: str = "",
|
|
283
|
+
content_type: str = "",
|
|
284
|
+
body: str = "",
|
|
285
|
+
) -> bytes:
|
|
286
|
+
"""Render the HTTP-request text region of a ``wifi_ip`` command.
|
|
287
|
+
|
|
288
|
+
Returns just the bytes that go between the IP/port/length header
|
|
289
|
+
and the trailing checksum — i.e. the substring of ``data_hex``
|
|
290
|
+
starting at offset 8 and ending at offset ``8 + http_len``.
|
|
291
|
+
|
|
292
|
+
This is the canonical writer for these bytes. Both the backup
|
|
293
|
+
encoder (:func:`_encode_wifi_ip`) and the wifi-create protocol
|
|
294
|
+
flow (which sends this same text to the hub when defining a new
|
|
295
|
+
IP command) MUST go through it so the two paths never disagree
|
|
296
|
+
on rendering rules.
|
|
297
|
+
|
|
298
|
+
The rendering rule, mirrored from the hub's own writer:
|
|
299
|
+
|
|
300
|
+
.. code-block::
|
|
301
|
+
|
|
302
|
+
"{method} {path} HTTP/1.1\\r\\n"
|
|
303
|
+
"Host:{host}:{port}\\r\\n"
|
|
304
|
+
"{header}\\r\\n" (only if header non-empty)
|
|
305
|
+
"Content-Type:{content_type}\\r\\n" (only if content_type non-empty)
|
|
306
|
+
"Content-Length:{len(body)}\\r\\n\\r\\n{body}" (only if body non-empty)
|
|
307
|
+
"\\r\\n" (unconditional terminator)
|
|
308
|
+
|
|
309
|
+
``Content-Length`` is recomputed from ``len(body)`` — never
|
|
310
|
+
accepted as an input — so editing the body always produces a
|
|
311
|
+
well-formed request. The trailing ``\\r\\n`` is always emitted.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
chunks: list[str] = []
|
|
315
|
+
chunks.append("{0} {1} HTTP/1.1\r\n".format(method, path))
|
|
316
|
+
chunks.append("Host:{0}:{1}\r\n".format(host, port))
|
|
317
|
+
if header:
|
|
318
|
+
chunks.append(header + "\r\n")
|
|
319
|
+
if content_type:
|
|
320
|
+
chunks.append("Content-Type:" + content_type + "\r\n")
|
|
321
|
+
if body:
|
|
322
|
+
chunks.append("Content-Length:{0}\r\n\r\n".format(len(body)) + body)
|
|
323
|
+
chunks.append("\r\n")
|
|
324
|
+
return "".join(chunks).encode("ascii")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def render_wifi_ip_blob_body(
|
|
328
|
+
*,
|
|
329
|
+
host: str,
|
|
330
|
+
port: int,
|
|
331
|
+
method: str,
|
|
332
|
+
path: str,
|
|
333
|
+
header: str = "",
|
|
334
|
+
content_type: str = "",
|
|
335
|
+
body: str = "",
|
|
336
|
+
) -> bytes:
|
|
337
|
+
"""Render the structural body of a ``wifi_ip`` command blob.
|
|
338
|
+
|
|
339
|
+
Returns ``IP(4) + port(2 BE) + http_len(2 BE) + http_text`` — the
|
|
340
|
+
persisted-blob bytes that precede the per-record checksum
|
|
341
|
+
trailer. Callers that need the full ``data_hex`` form should
|
|
342
|
+
append their own trailer byte (the inner-record checksum, which
|
|
343
|
+
is computed over the outer command record and is NOT part of this
|
|
344
|
+
helper's contract).
|
|
345
|
+
|
|
346
|
+
This is the entry point for the wifi-create protocol flow when it
|
|
347
|
+
needs to build the IP block of a ``DEFINE_IP_CMD`` payload. The
|
|
348
|
+
backup encoder uses it too, then appends ``trailer_hex`` from the
|
|
349
|
+
captured row.
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
host_parts = host.split(".")
|
|
353
|
+
if len(host_parts) != 4:
|
|
354
|
+
raise ValueError(f"wifi_ip host is not a dotted quad: {host!r}")
|
|
355
|
+
try:
|
|
356
|
+
ip_bytes = bytes(int(part) & 0xFF for part in host_parts)
|
|
357
|
+
except ValueError as exc:
|
|
358
|
+
raise ValueError(f"wifi_ip host octet not an int: {host!r}") from exc
|
|
359
|
+
if not all(0 <= octet <= 255 for octet in ip_bytes):
|
|
360
|
+
raise ValueError(f"wifi_ip host octet out of range: {host!r}")
|
|
361
|
+
if port < 0 or port > 0xFFFF:
|
|
362
|
+
raise ValueError(f"wifi_ip port out of range: {port}")
|
|
363
|
+
|
|
364
|
+
text_bytes = render_wifi_ip_http_text(
|
|
365
|
+
host=host,
|
|
366
|
+
port=port,
|
|
367
|
+
method=method,
|
|
368
|
+
path=path,
|
|
369
|
+
header=header,
|
|
370
|
+
content_type=content_type,
|
|
371
|
+
body=body,
|
|
372
|
+
)
|
|
373
|
+
out = bytearray()
|
|
374
|
+
out += ip_bytes
|
|
375
|
+
out += port.to_bytes(2, "big")
|
|
376
|
+
out += len(text_bytes).to_bytes(2, "big")
|
|
377
|
+
out += text_bytes
|
|
378
|
+
return bytes(out)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _encode_wifi_ip(decoded: Dict[str, Any]) -> bytes:
|
|
382
|
+
body_bytes = render_wifi_ip_blob_body(
|
|
383
|
+
host=str(decoded["host"]),
|
|
384
|
+
port=int(decoded["port"]),
|
|
385
|
+
method=str(decoded["method"]),
|
|
386
|
+
path=str(decoded["path"]),
|
|
387
|
+
header=str(decoded.get("header") or ""),
|
|
388
|
+
content_type=str(decoded.get("content_type") or ""),
|
|
389
|
+
body=str(decoded.get("body") or ""),
|
|
390
|
+
)
|
|
391
|
+
trailer = bytes.fromhex(str(decoded.get("trailer_hex") or "").replace(" ", ""))
|
|
392
|
+
return body_bytes + trailer
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# ---- wifi_roku (class 0x0A) ----------------------------------------------
|
|
396
|
+
#
|
|
397
|
+
# Wire body layout:
|
|
398
|
+
# [0] = path length L
|
|
399
|
+
# [1..1+L] = URL path fragment (ASCII), e.g. "launch/<appid>"
|
|
400
|
+
# [1+L..] = opaque trailer
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def render_wifi_roku_blob_body(*, path: str) -> bytes:
|
|
404
|
+
"""Render the structural body of a ``wifi_roku`` command blob.
|
|
405
|
+
|
|
406
|
+
Returns ``len(path)(1) + path`` — the persisted-blob bytes that
|
|
407
|
+
precede the per-record checksum trailer. Callers that need the
|
|
408
|
+
full ``data_hex`` form should append their own trailer bytes
|
|
409
|
+
(the inner-record checksum, which is computed over the outer
|
|
410
|
+
command record and is NOT part of this helper's contract).
|
|
411
|
+
|
|
412
|
+
This is the entry point for the X1 wifi_roku create flow when it
|
|
413
|
+
needs to build the action-path region of its ``define-command``
|
|
414
|
+
payload. The backup encoder uses it too, then appends
|
|
415
|
+
``trailer_hex`` from the captured row.
|
|
416
|
+
|
|
417
|
+
Note: paths are encoded as ASCII with no leading slash — Roku ECP
|
|
418
|
+
paths are written as ``launch/<appid>``, ``keypress/<key>``, etc.
|
|
419
|
+
"""
|
|
420
|
+
|
|
421
|
+
path_bytes = path.encode("ascii")
|
|
422
|
+
if len(path_bytes) > 0xFF:
|
|
423
|
+
raise ValueError(f"wifi_roku path too long ({len(path_bytes)} bytes)")
|
|
424
|
+
out = bytearray()
|
|
425
|
+
out.append(len(path_bytes))
|
|
426
|
+
out += path_bytes
|
|
427
|
+
return bytes(out)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _decode_wifi_roku(data: bytes) -> Dict[str, Any]:
|
|
431
|
+
if len(data) < 1:
|
|
432
|
+
raise ValueError("wifi_roku blob is empty")
|
|
433
|
+
path_len = data[0]
|
|
434
|
+
if len(data) < 1 + path_len:
|
|
435
|
+
raise ValueError("wifi_roku blob shorter than declared path length")
|
|
436
|
+
try:
|
|
437
|
+
path = data[1 : 1 + path_len].decode("ascii")
|
|
438
|
+
except UnicodeDecodeError as exc:
|
|
439
|
+
raise ValueError("wifi_roku path is not ASCII") from exc
|
|
440
|
+
trailer = data[1 + path_len :]
|
|
441
|
+
return {
|
|
442
|
+
"path": path,
|
|
443
|
+
"trailer_hex": _bytes_to_hex(trailer),
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _encode_wifi_roku(decoded: Dict[str, Any]) -> bytes:
|
|
448
|
+
body = render_wifi_roku_blob_body(path=str(decoded["path"]))
|
|
449
|
+
trailer = bytes.fromhex(str(decoded.get("trailer_hex") or "").replace(" ", ""))
|
|
450
|
+
return body + trailer
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ---- wifi_hue (class 0x1A) and wifi_sonos (class 0x1B) -------------------
|
|
454
|
+
#
|
|
455
|
+
# These two classes share the same wire layout. The semantic difference
|
|
456
|
+
# (Hue REST vs Sonos UPnP/SOAP) lives in what the user puts into
|
|
457
|
+
# ``path`` and ``body_block``; the encoder/decoder is one routine.
|
|
458
|
+
#
|
|
459
|
+
# Wire body layout:
|
|
460
|
+
# [0] = path length P (1 byte)
|
|
461
|
+
# [1..3] = body-block length B (big-endian, 2 bytes)
|
|
462
|
+
# [3..3+P] = URL path fragment (ASCII)
|
|
463
|
+
# [3+P..3+P+B] = body block (ASCII)
|
|
464
|
+
# [3+P+B..] = opaque trailer
|
|
465
|
+
#
|
|
466
|
+
# ``body_block`` is a single ASCII region the hub injects between the
|
|
467
|
+
# request-line/Host headers (built from the device-level IP/port at
|
|
468
|
+
# replay time) and the network write. It contains, in observed
|
|
469
|
+
# samples, any extra header lines followed by a Content-Length line, a
|
|
470
|
+
# blank line, and the request body. The line terminator inside
|
|
471
|
+
# body_block is bare "\n", not "\r\n". Decoding treats it as a flat
|
|
472
|
+
# string; editors that want sub-structure can layer their own parser
|
|
473
|
+
# on top.
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _decode_wifi_hue_like(data: bytes) -> Dict[str, Any]:
|
|
477
|
+
if len(data) < 3:
|
|
478
|
+
raise ValueError("hue/sonos blob too short for length header")
|
|
479
|
+
path_len = data[0]
|
|
480
|
+
body_len = int.from_bytes(data[1:3], "big")
|
|
481
|
+
end = 3 + path_len + body_len
|
|
482
|
+
if len(data) < end:
|
|
483
|
+
raise ValueError(
|
|
484
|
+
"hue/sonos blob shorter than declared path + body length"
|
|
485
|
+
)
|
|
486
|
+
try:
|
|
487
|
+
path = data[3 : 3 + path_len].decode("ascii")
|
|
488
|
+
except UnicodeDecodeError as exc:
|
|
489
|
+
raise ValueError("hue/sonos path is not ASCII") from exc
|
|
490
|
+
try:
|
|
491
|
+
body_block = data[3 + path_len : end].decode("ascii")
|
|
492
|
+
except UnicodeDecodeError as exc:
|
|
493
|
+
raise ValueError("hue/sonos body block is not ASCII") from exc
|
|
494
|
+
trailer = data[end:]
|
|
495
|
+
return {
|
|
496
|
+
"path": path,
|
|
497
|
+
"body_block": body_block,
|
|
498
|
+
"trailer_hex": _bytes_to_hex(trailer),
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _encode_wifi_hue_like(decoded: Dict[str, Any]) -> bytes:
|
|
503
|
+
path = str(decoded["path"])
|
|
504
|
+
body_block = str(decoded.get("body_block") or "")
|
|
505
|
+
trailer = bytes.fromhex(str(decoded.get("trailer_hex") or "").replace(" ", ""))
|
|
506
|
+
|
|
507
|
+
path_bytes = path.encode("ascii")
|
|
508
|
+
body_bytes = body_block.encode("ascii")
|
|
509
|
+
if len(path_bytes) > 0xFF:
|
|
510
|
+
raise ValueError(f"hue/sonos path too long ({len(path_bytes)} bytes)")
|
|
511
|
+
if len(body_bytes) > 0xFFFF:
|
|
512
|
+
raise ValueError(
|
|
513
|
+
f"hue/sonos body block too long ({len(body_bytes)} bytes)"
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
out = bytearray()
|
|
517
|
+
out.append(len(path_bytes))
|
|
518
|
+
out += len(body_bytes).to_bytes(2, "big")
|
|
519
|
+
out += path_bytes
|
|
520
|
+
out += body_bytes
|
|
521
|
+
out += trailer
|
|
522
|
+
return bytes(out)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
# ---- ir descriptive replay blobs -----------------------------------------
|
|
526
|
+
#
|
|
527
|
+
# Wire body layout (descriptive variant only):
|
|
528
|
+
# [0..2] = declared length L (big-endian, 2 bytes)
|
|
529
|
+
# [2..6] = magic marker 00 00 11 00
|
|
530
|
+
# [6..8] = magic marker 94 70
|
|
531
|
+
# [8..8+L] = descriptor ASCII (e.g. "P:Sony12 R:40000 D:1 F:18 MUL:2")
|
|
532
|
+
# [8+L..] = opaque trailer (typically 4 trailing nulls
|
|
533
|
+
# from the hub's writer, captured verbatim)
|
|
534
|
+
#
|
|
535
|
+
# The decoder is content-sniffed via the magic bytes — non-descriptive
|
|
536
|
+
# IR blobs (raw learned-IR captures, database-style binary blobs) do
|
|
537
|
+
# not match and the decoder returns None so the caller falls back to
|
|
538
|
+
# raw hex. This preserves the existing behavior for IR rows that did
|
|
539
|
+
# not have a structured form before.
|
|
540
|
+
#
|
|
541
|
+
# IMPORTANT — round-trip fidelity:
|
|
542
|
+
# The descriptor is stored as raw bytes [8..8+L] without any
|
|
543
|
+
# normalization. The hub's own write-side builder canonicalizes
|
|
544
|
+
# DenonK descriptors (adds a CHECKSUM: field when missing), but
|
|
545
|
+
# running that canonicalizer on a dumped descriptor would change the
|
|
546
|
+
# bytes and break round-trip. The encoder here re-emits whatever the
|
|
547
|
+
# decoder captured.
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
_DESCRIPTIVE_IR_MAGIC = b"\x00\x00\x11\x00\x94\x70"
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def render_ir_descriptive_blob_body(descriptor: str) -> bytes:
|
|
554
|
+
"""Render the structural body of a descriptive IR command blob.
|
|
555
|
+
|
|
556
|
+
Returns ``declared_length(2 BE) + magic(6) + descriptor_bytes`` —
|
|
557
|
+
the persisted-blob bytes that precede the writer's trailing-nulls
|
|
558
|
+
convention and any readback framing.
|
|
559
|
+
|
|
560
|
+
This is the canonical byte-layout writer for descriptive replay
|
|
561
|
+
payloads. Two call sites share it:
|
|
562
|
+
|
|
563
|
+
* :func:`_encode_descriptive_ir` (round-trip path) — emits this
|
|
564
|
+
body, then appends ``trailer_hex`` from the captured row. The
|
|
565
|
+
trailer typically starts with four ``0x00`` bytes (the writer's
|
|
566
|
+
trailing-nulls convention) plus whatever framing bytes the
|
|
567
|
+
readback path added.
|
|
568
|
+
* :func:`lib.commands.build_descriptive_ir_blob_body` (synthesis
|
|
569
|
+
path) — runs whitespace normalization, ``P:`` validation, and
|
|
570
|
+
DenonK canonicalization on the descriptor first, then emits
|
|
571
|
+
this body followed by the writer's four trailing ``0x00``
|
|
572
|
+
bytes. Synthesis callers MUST keep that convention; round-trip
|
|
573
|
+
callers MUST NOT — they re-emit whatever trailer was captured.
|
|
574
|
+
|
|
575
|
+
The helper itself is intentionally minimal: it knows the byte
|
|
576
|
+
layout and nothing about policy. Callers add canonicalization,
|
|
577
|
+
validation, and trailer convention on top.
|
|
578
|
+
"""
|
|
579
|
+
|
|
580
|
+
descriptor_bytes = descriptor.encode("ascii")
|
|
581
|
+
if len(descriptor_bytes) > 0xFFFF:
|
|
582
|
+
raise ValueError(
|
|
583
|
+
f"ir descriptor too long ({len(descriptor_bytes)} bytes)"
|
|
584
|
+
)
|
|
585
|
+
return (
|
|
586
|
+
len(descriptor_bytes).to_bytes(2, "big")
|
|
587
|
+
+ _DESCRIPTIVE_IR_MAGIC
|
|
588
|
+
+ descriptor_bytes
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _decode_descriptive_ir(data: bytes) -> Dict[str, Any]:
|
|
593
|
+
if len(data) < 8:
|
|
594
|
+
raise ValueError("ir blob too short for descriptive header")
|
|
595
|
+
if data[2:8] != _DESCRIPTIVE_IR_MAGIC:
|
|
596
|
+
# Content sniff fails — caller falls back to raw hex via the
|
|
597
|
+
# None return from try_decode_blob.
|
|
598
|
+
raise ValueError("ir blob is not a descriptive replay payload")
|
|
599
|
+
declared_len = int.from_bytes(data[0:2], "big")
|
|
600
|
+
if declared_len <= 0:
|
|
601
|
+
raise ValueError("ir descriptor declared length is zero")
|
|
602
|
+
text_end = 8 + declared_len
|
|
603
|
+
if text_end > len(data):
|
|
604
|
+
raise ValueError("ir descriptor runs past end of blob")
|
|
605
|
+
try:
|
|
606
|
+
descriptor = data[8:text_end].decode("ascii")
|
|
607
|
+
except UnicodeDecodeError as exc:
|
|
608
|
+
raise ValueError("ir descriptor is not ASCII") from exc
|
|
609
|
+
trailer = data[text_end:]
|
|
610
|
+
return {
|
|
611
|
+
"descriptor": descriptor,
|
|
612
|
+
"trailer_hex": _bytes_to_hex(trailer),
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _encode_descriptive_ir(decoded: Dict[str, Any]) -> bytes:
|
|
617
|
+
body = render_ir_descriptive_blob_body(str(decoded["descriptor"]))
|
|
618
|
+
trailer = bytes.fromhex(str(decoded.get("trailer_hex") or "").replace(" ", ""))
|
|
619
|
+
return body + trailer
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
# ---------------------------------------------------------------------------
|
|
623
|
+
# Registry + high-level entry points
|
|
624
|
+
# ---------------------------------------------------------------------------
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
_DECODERS: Dict[str, Callable[[bytes], Dict[str, Any]]] = {
|
|
628
|
+
DEVICE_CLASS_WIFI_IP: _decode_wifi_ip,
|
|
629
|
+
DEVICE_CLASS_WIFI_ROKU: _decode_wifi_roku,
|
|
630
|
+
DEVICE_CLASS_WIFI_HUE: _decode_wifi_hue_like,
|
|
631
|
+
DEVICE_CLASS_WIFI_SONOS: _decode_wifi_hue_like,
|
|
632
|
+
DEVICE_CLASS_IR: _decode_descriptive_ir,
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
_ENCODERS: Dict[str, Callable[[Dict[str, Any]], bytes]] = {
|
|
636
|
+
DEVICE_CLASS_WIFI_IP: _encode_wifi_ip,
|
|
637
|
+
DEVICE_CLASS_WIFI_ROKU: _encode_wifi_roku,
|
|
638
|
+
DEVICE_CLASS_WIFI_HUE: _encode_wifi_hue_like,
|
|
639
|
+
DEVICE_CLASS_WIFI_SONOS: _encode_wifi_hue_like,
|
|
640
|
+
DEVICE_CLASS_IR: _encode_descriptive_ir,
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def is_decodable_class(device_class: Any) -> bool:
|
|
645
|
+
"""Return True for a device-class string this module can round-trip."""
|
|
646
|
+
|
|
647
|
+
return normalize_device_class(device_class) in DECODABLE_CLASSES
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def try_decode_blob(device_class: Any, data: Any) -> Dict[str, Any] | None:
|
|
651
|
+
"""Decode + round-trip-verify a virtual-device command blob.
|
|
652
|
+
|
|
653
|
+
Returns a structured ``{"class", "fields", "trailer_hex"}`` mapping
|
|
654
|
+
on success, ``None`` on any failure. Callers MUST treat a ``None``
|
|
655
|
+
return as "this row stays raw" — no exception is raised.
|
|
656
|
+
|
|
657
|
+
The shape of the returned dict mirrors
|
|
658
|
+
``docs/protocol/command-blob-decoders.md``:
|
|
659
|
+
|
|
660
|
+
.. code-block:: yaml
|
|
661
|
+
|
|
662
|
+
class: wifi_ip
|
|
663
|
+
trailer_hex: "f1"
|
|
664
|
+
fields:
|
|
665
|
+
host: "192.168.2.77"
|
|
666
|
+
port: 8060
|
|
667
|
+
method: "POST"
|
|
668
|
+
path: "/launch/.../short"
|
|
669
|
+
header: ""
|
|
670
|
+
content_type: "application/x-www-form-urlencoded"
|
|
671
|
+
body: ""
|
|
672
|
+
|
|
673
|
+
"""
|
|
674
|
+
|
|
675
|
+
normalized = normalize_device_class(device_class)
|
|
676
|
+
if normalized not in _DECODERS:
|
|
677
|
+
return None
|
|
678
|
+
try:
|
|
679
|
+
raw = _coerce_blob_bytes(data)
|
|
680
|
+
except (TypeError, ValueError):
|
|
681
|
+
return None
|
|
682
|
+
if not raw:
|
|
683
|
+
return None
|
|
684
|
+
|
|
685
|
+
try:
|
|
686
|
+
decoded = _DECODERS[normalized](raw)
|
|
687
|
+
except (ValueError, KeyError, IndexError, TypeError):
|
|
688
|
+
return None
|
|
689
|
+
|
|
690
|
+
# Separate trailer_hex from the structural fields for the public
|
|
691
|
+
# shape: external readers (backup format, UI) want
|
|
692
|
+
# {class, trailer_hex, fields: {...}}
|
|
693
|
+
# while the per-class encoder takes a flat dict because that is
|
|
694
|
+
# easier to write. Keep both shapes in sync.
|
|
695
|
+
trailer_hex = decoded.pop("trailer_hex", "")
|
|
696
|
+
flat_for_encode = dict(decoded)
|
|
697
|
+
flat_for_encode["trailer_hex"] = trailer_hex
|
|
698
|
+
|
|
699
|
+
try:
|
|
700
|
+
round_trip = _ENCODERS[normalized](flat_for_encode)
|
|
701
|
+
except (ValueError, KeyError, TypeError):
|
|
702
|
+
return None
|
|
703
|
+
if round_trip != raw:
|
|
704
|
+
return None
|
|
705
|
+
|
|
706
|
+
return {
|
|
707
|
+
"class": normalized,
|
|
708
|
+
"trailer_hex": trailer_hex,
|
|
709
|
+
"fields": decoded,
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def encode_decoded_blob(decoded_block: Dict[str, Any]) -> bytes:
|
|
714
|
+
"""Re-encode a structured ``decoded`` block back to raw bytes.
|
|
715
|
+
|
|
716
|
+
Inverse of :func:`try_decode_blob` for the success path. Raises
|
|
717
|
+
``ValueError`` on any class / shape mismatch; this is the editor
|
|
718
|
+
entry point and a malformed input should not be silently dropped.
|
|
719
|
+
"""
|
|
720
|
+
|
|
721
|
+
class_name = normalize_device_class(decoded_block.get("class"))
|
|
722
|
+
if class_name not in _ENCODERS:
|
|
723
|
+
raise ValueError(
|
|
724
|
+
f"encode_decoded_blob: unknown class {decoded_block.get('class')!r}"
|
|
725
|
+
)
|
|
726
|
+
fields = decoded_block.get("fields")
|
|
727
|
+
if not isinstance(fields, dict):
|
|
728
|
+
raise ValueError("encode_decoded_blob: missing or non-dict 'fields'")
|
|
729
|
+
flat = dict(fields)
|
|
730
|
+
flat["trailer_hex"] = decoded_block.get("trailer_hex", "")
|
|
731
|
+
return _ENCODERS[class_name](flat)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
# ---------------------------------------------------------------------------
|
|
735
|
+
# Display rendering (used by the Fetch Blob tools card)
|
|
736
|
+
# ---------------------------------------------------------------------------
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def format_decoded_for_display(decoded_block: Dict[str, Any]) -> str:
|
|
740
|
+
"""Render a structured ``decoded`` block as the text shown in the
|
|
741
|
+
Fetch Blob tool's "Descriptor" view.
|
|
742
|
+
|
|
743
|
+
The output is intentionally compact and aligned with the structural
|
|
744
|
+
fields, not the original wire bytes. The trailer is omitted from
|
|
745
|
+
the rendering — it carries no user-meaningful information.
|
|
746
|
+
"""
|
|
747
|
+
|
|
748
|
+
class_name = normalize_device_class(decoded_block.get("class"))
|
|
749
|
+
fields = decoded_block.get("fields")
|
|
750
|
+
if not isinstance(fields, dict):
|
|
751
|
+
return ""
|
|
752
|
+
|
|
753
|
+
if class_name == DEVICE_CLASS_WIFI_IP:
|
|
754
|
+
lines = [
|
|
755
|
+
"host: {0}".format(fields.get("host", "")),
|
|
756
|
+
"port: {0}".format(fields.get("port", "")),
|
|
757
|
+
"method: {0}".format(fields.get("method", "")),
|
|
758
|
+
"path: {0}".format(fields.get("path", "")),
|
|
759
|
+
]
|
|
760
|
+
header = str(fields.get("header") or "")
|
|
761
|
+
if header:
|
|
762
|
+
lines.append("header:")
|
|
763
|
+
for line in header.split("\r\n"):
|
|
764
|
+
lines.append(" {0}".format(line))
|
|
765
|
+
content_type = str(fields.get("content_type") or "")
|
|
766
|
+
if content_type:
|
|
767
|
+
lines.append("content_type: {0}".format(content_type))
|
|
768
|
+
body = str(fields.get("body") or "")
|
|
769
|
+
if body:
|
|
770
|
+
lines.append("body:")
|
|
771
|
+
for line in body.split("\n"):
|
|
772
|
+
lines.append(" {0}".format(line))
|
|
773
|
+
return "\n".join(lines)
|
|
774
|
+
|
|
775
|
+
if class_name == DEVICE_CLASS_WIFI_ROKU:
|
|
776
|
+
return "path: {0}".format(fields.get("path", ""))
|
|
777
|
+
|
|
778
|
+
if class_name in (DEVICE_CLASS_WIFI_HUE, DEVICE_CLASS_WIFI_SONOS):
|
|
779
|
+
lines = ["path: {0}".format(fields.get("path", ""))]
|
|
780
|
+
body_block = str(fields.get("body_block") or "")
|
|
781
|
+
if body_block:
|
|
782
|
+
lines.append("body_block:")
|
|
783
|
+
for line in body_block.split("\n"):
|
|
784
|
+
lines.append(" {0}".format(line))
|
|
785
|
+
return "\n".join(lines)
|
|
786
|
+
|
|
787
|
+
if class_name == DEVICE_CLASS_IR:
|
|
788
|
+
# Match the historical Fetch Blob descriptor view: the raw
|
|
789
|
+
# ``P:Sony12 R:... etc.`` text, unadorned. Existing UI tests
|
|
790
|
+
# and the tools card depend on this rendering being verbatim.
|
|
791
|
+
return str(fields.get("descriptor") or "")
|
|
792
|
+
|
|
793
|
+
return ""
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
__all__ = [
|
|
797
|
+
"DECODABLE_CLASSES",
|
|
798
|
+
"encode_decoded_blob",
|
|
799
|
+
"format_decoded_for_display",
|
|
800
|
+
"is_decodable_class",
|
|
801
|
+
"render_ir_descriptive_blob_body",
|
|
802
|
+
"render_wifi_ip_blob_body",
|
|
803
|
+
"render_wifi_ip_http_text",
|
|
804
|
+
"render_wifi_roku_blob_body",
|
|
805
|
+
"try_decode_blob",
|
|
806
|
+
]
|