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