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/inputs.py ADDED
@@ -0,0 +1,501 @@
1
+ """Structured builder + parser for the family-0x46 inputs page.
2
+
3
+ The inputs page is what the hub stores when the user has configured a
4
+ device's "input" buttons (HDMI 1, HDMI 2, Roku, etc.) on the remote.
5
+ It carries one entry per configured input plus a fixed trailing region
6
+ that captures four control-key bindings (input list, up, down,
7
+ confirm) and ten favorite-slot rows.
8
+
9
+ This module is the *single* place that serialises and parses the page.
10
+ Earlier revisions of the integration carried three independent
11
+ builders -- one for device-create finalize, one for the wifi-device
12
+ input-config step, and one for activity-create finalize -- each with a
13
+ slightly different layout. They all happened to wire-pass on
14
+ all-zero entry sets, then corrupted favorites / confirm slots on any
15
+ populated payload. The canonical layout below is what real captures
16
+ agree on across the X1 / X1S / X2 product lines.
17
+
18
+ Layout (described in our own field names)::
19
+
20
+ outer wrapper (3 bytes)
21
+ [0] page-marker (0x01)
22
+ [1..2] outer page-seq big-endian (single-page = 1)
23
+
24
+ body header (8 bytes)
25
+ [0] body marker (0x01)
26
+ [1..2] total_pages big-endian
27
+ [3] device_id
28
+ [4] source_id_byte (what the app sometimes calls
29
+ "source_type" or "input_id" --
30
+ 0x00 for "no inputs configured",
31
+ 0x01 for direct-inputs, 0x02 for
32
+ "no input switching", etc.)
33
+ [5] entry_count (== len(entries))
34
+ [6] flag_a (start position; usually 0)
35
+ [7] flag_b (restart position; usually 0)
36
+
37
+ entry region (entry_count * stride)
38
+ X1 stride 27:
39
+ [0] key_id (1-byte command id)
40
+ [1..6] fid (6-byte big-endian opaque id)
41
+ [7..26] label (20 bytes, ASCII, null-padded)
42
+ X1S/X2 stride 48:
43
+ [0] key_id
44
+ [1..6] fid
45
+ [7] ordinal (1-based position, redundant with
46
+ entry index but emitted on the wire)
47
+ [8..47] label (40 bytes, UTF-16BE, null-padded)
48
+
49
+ trailing region (107 bytes total)
50
+ 4 control-key rows (9 bytes each = 36)
51
+ input_list, input_up, input_down, input_confirm
52
+ 10 favorite rows (7 bytes each = 70)
53
+ opaque per-row payload; structured fields are not yet
54
+ decoded (Phase 4 confirms the layout against a capture)
55
+ 1 state byte
56
+
57
+ body checksum (1 byte, == sum(body[:-1]) & 0xFF)
58
+
59
+ The total page is therefore ``3 + 8 + entry_count*stride + 107 + 1``
60
+ bytes. Bodies that exceed the per-page chunk limit are split by the
61
+ existing paging helper at the call site, which re-uses the
62
+ ``total_pages`` written into the body header here.
63
+ """
64
+
65
+ from __future__ import annotations
66
+
67
+ from dataclasses import dataclass, field
68
+ from typing import Final, Sequence
69
+
70
+ from .wire_schema import InputEntryLayout, schema_for
71
+
72
+
73
+ #: Width of the trailing region in bytes: four control-key rows + ten
74
+ #: favorite rows + one state byte. Identical across hub variants.
75
+ _TRAILING_CONTROL_KEY_ROW_LEN: Final[int] = 9
76
+ _TRAILING_CONTROL_KEY_ROWS: Final[int] = 4
77
+ _TRAILING_FAVORITE_ROW_LEN: Final[int] = 7
78
+ _TRAILING_FAVORITE_ROWS: Final[int] = 10
79
+ _TRAILING_REGION_LEN: Final[int] = (
80
+ _TRAILING_CONTROL_KEY_ROWS * _TRAILING_CONTROL_KEY_ROW_LEN
81
+ + _TRAILING_FAVORITE_ROWS * _TRAILING_FAVORITE_ROW_LEN
82
+ + 1 # state byte
83
+ )
84
+
85
+ assert _TRAILING_REGION_LEN == 107
86
+
87
+ #: Length of the body header (marker through flag_b). Caller-visible so
88
+ #: the parser can re-use it without re-counting fields.
89
+ INPUTS_BODY_HEADER_LEN: Final[int] = 8
90
+
91
+ #: Length of the per-page outer wrapper that prefixes every family-0x46
92
+ #: page on the wire (page marker + 2-byte sequence number).
93
+ INPUTS_OUTER_WRAPPER_LEN: Final[int] = 3
94
+
95
+ #: Page chunk size used by the family-0x12 / family-0x46 paged writers.
96
+ #: Mirrored from :data:`~custom_components.sofabaton_x1s.lib.macros.MACRO_WRITE_PAGE_BODY_CHUNK`
97
+ #: so we don't import the macros module from here.
98
+ _PAGE_BODY_CHUNK: Final[int] = 247
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Dataclasses
103
+ # ---------------------------------------------------------------------------
104
+
105
+
106
+ @dataclass(slots=True, frozen=True)
107
+ class InputEntry:
108
+ """One row in the inputs entry region.
109
+
110
+ ``ordinal`` is the 1-based position of this entry within the list.
111
+ It is redundant with the entry's index on X1 (where it is omitted
112
+ from the wire layout), but the X1S/X2 stride dedicates a byte to
113
+ it. The dataclass carries it on both lines so a round-trip stays
114
+ lossless.
115
+ """
116
+
117
+ key_id: int
118
+ fid: int # 48-bit big-endian on the wire
119
+ ordinal: int = 0
120
+ label: str = ""
121
+
122
+ def __post_init__(self) -> None:
123
+ if not 0 <= self.key_id <= 0xFF:
124
+ raise ValueError(f"InputEntry.key_id out of byte range: {self.key_id}")
125
+ if not 0 <= self.fid < (1 << 48):
126
+ raise ValueError(f"InputEntry.fid out of 48-bit range: {self.fid}")
127
+ if not 0 <= self.ordinal <= 0xFF:
128
+ raise ValueError(f"InputEntry.ordinal out of byte range: {self.ordinal}")
129
+
130
+
131
+ @dataclass(slots=True, frozen=True)
132
+ class ControlKeyBlock:
133
+ """Four 9-byte control-key rows that follow the entry region.
134
+
135
+ Each row is an opaque key-binding descriptor; this module does not
136
+ decode the internal structure. Pass ``b""`` for an unbound slot
137
+ (it gets zero-padded to 9 bytes on serialisation).
138
+ """
139
+
140
+ input_list: bytes = b""
141
+ input_up: bytes = b""
142
+ input_down: bytes = b""
143
+ input_confirm: bytes = b""
144
+
145
+ def __post_init__(self) -> None:
146
+ for name, value in (
147
+ ("input_list", self.input_list),
148
+ ("input_up", self.input_up),
149
+ ("input_down", self.input_down),
150
+ ("input_confirm", self.input_confirm),
151
+ ):
152
+ if len(value) > _TRAILING_CONTROL_KEY_ROW_LEN:
153
+ raise ValueError(
154
+ f"ControlKeyBlock.{name} exceeds "
155
+ f"{_TRAILING_CONTROL_KEY_ROW_LEN} bytes: {len(value)}"
156
+ )
157
+
158
+
159
+ @dataclass(slots=True, frozen=True)
160
+ class FavoriteSlot:
161
+ """One 7-byte favorite-slot row in the trailing region.
162
+
163
+ The structured field layout inside the 7 bytes has not been
164
+ confirmed yet against a real capture; that is a Phase 4 audit
165
+ target. Until then this dataclass exposes the raw bytes so callers
166
+ that already hold a captured slot can round-trip it unchanged, and
167
+ callers that just want to seed an empty page can omit favorites
168
+ entirely.
169
+ """
170
+
171
+ payload: bytes = b""
172
+
173
+ def __post_init__(self) -> None:
174
+ if len(self.payload) > _TRAILING_FAVORITE_ROW_LEN:
175
+ raise ValueError(
176
+ f"FavoriteSlot.payload exceeds "
177
+ f"{_TRAILING_FAVORITE_ROW_LEN} bytes: {len(self.payload)}"
178
+ )
179
+
180
+
181
+ @dataclass(slots=True, frozen=True)
182
+ class InputsRecord:
183
+ """Parsed view of one family-0x46 inputs page (or paged burst).
184
+
185
+ Mirrors the builder's input set so ``build_inputs_write(**fields(record))``
186
+ reconstructs an equivalent payload.
187
+ """
188
+
189
+ device_id: int
190
+ source_id_byte: int
191
+ flag_a: int
192
+ flag_b: int
193
+ state_byte: int
194
+ entries: tuple[InputEntry, ...] = ()
195
+ control_keys: ControlKeyBlock = field(default_factory=ControlKeyBlock)
196
+ favorites: tuple[FavoriteSlot, ...] = ()
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Builder
201
+ # ---------------------------------------------------------------------------
202
+
203
+
204
+ def _encode_label(label: str, *, slot_len: int, encoding: str) -> bytes:
205
+ raw = label.encode(encoding, errors="ignore")
206
+ if len(raw) > slot_len:
207
+ raw = raw[:slot_len]
208
+ return raw + b"\x00" * (slot_len - len(raw))
209
+
210
+
211
+ def _encode_entry(entry: InputEntry, *, layout: InputEntryLayout, stride: int) -> bytes:
212
+ head = bytes([entry.key_id & 0xFF]) + entry.fid.to_bytes(6, "big")
213
+ if layout is InputEntryLayout.NARROW_ASCII:
214
+ # X1: 27-byte stride; no ordinal byte; 20-byte ASCII label.
215
+ label_slot = _encode_label(entry.label, slot_len=20, encoding="ascii")
216
+ row = head + label_slot
217
+ else:
218
+ # X1S/X2: 48-byte stride; explicit 1-byte ordinal; 40-byte
219
+ # UTF-16BE label slot.
220
+ label_slot = _encode_label(entry.label, slot_len=40, encoding="utf-16-be")
221
+ row = head + bytes([entry.ordinal & 0xFF]) + label_slot
222
+ if len(row) != stride:
223
+ raise AssertionError(
224
+ f"inputs entry encoding produced {len(row)} bytes; expected {stride}"
225
+ )
226
+ return row
227
+
228
+
229
+ def _encode_control_keys(block: ControlKeyBlock) -> bytes:
230
+ def pad(value: bytes) -> bytes:
231
+ return value + b"\x00" * (_TRAILING_CONTROL_KEY_ROW_LEN - len(value))
232
+
233
+ return (
234
+ pad(block.input_list)
235
+ + pad(block.input_up)
236
+ + pad(block.input_down)
237
+ + pad(block.input_confirm)
238
+ )
239
+
240
+
241
+ def _encode_favorites(favorites: Sequence[FavoriteSlot]) -> bytes:
242
+ if len(favorites) > _TRAILING_FAVORITE_ROWS:
243
+ raise ValueError(
244
+ f"favorites exceeds the {_TRAILING_FAVORITE_ROWS}-slot capacity: "
245
+ f"{len(favorites)}"
246
+ )
247
+ region = bytearray(_TRAILING_FAVORITE_ROWS * _TRAILING_FAVORITE_ROW_LEN)
248
+ for idx, slot in enumerate(favorites):
249
+ start = idx * _TRAILING_FAVORITE_ROW_LEN
250
+ region[start : start + len(slot.payload)] = slot.payload
251
+ return bytes(region)
252
+
253
+
254
+ def build_inputs_write(
255
+ *,
256
+ hub_version: str,
257
+ device_id: int,
258
+ entries: Sequence[InputEntry] = (),
259
+ source_id_byte: int = 0x01,
260
+ flag_a: int = 0x00,
261
+ flag_b: int = 0x00,
262
+ control_keys: ControlKeyBlock | None = None,
263
+ favorites: Sequence[FavoriteSlot] | None = None,
264
+ state_byte: int = 0x00,
265
+ ) -> bytes:
266
+ """Serialise one family-0x46 inputs page.
267
+
268
+ Returns the canonical outer-wrapped form ``[0x01][outer_seq_be] +
269
+ body``; bodies that exceed the per-page chunk limit will still be
270
+ re-split by the paging helper at the call site, which honours the
271
+ ``total_pages`` value written into the body header below.
272
+
273
+ ``hub_version`` must be a known variant (``HUB_VERSION_X1`` /
274
+ ``HUB_VERSION_X1S`` / ``HUB_VERSION_X2``); unknown values raise
275
+ ``ValueError`` via :func:`schema_for`.
276
+
277
+ ``source_id_byte`` defaults to ``0x01`` (direct-inputs) -- pass
278
+ ``0x00`` to emit a "no inputs configured" page (``entries`` must
279
+ be empty in that case).
280
+ """
281
+
282
+ if not 0 <= device_id <= 0xFF:
283
+ raise ValueError(f"device_id out of byte range: {device_id}")
284
+ if not 0 <= source_id_byte <= 0xFF:
285
+ raise ValueError(f"source_id_byte out of byte range: {source_id_byte}")
286
+ if not 0 <= flag_a <= 0xFF:
287
+ raise ValueError(f"flag_a out of byte range: {flag_a}")
288
+ if not 0 <= flag_b <= 0xFF:
289
+ raise ValueError(f"flag_b out of byte range: {flag_b}")
290
+ if not 0 <= state_byte <= 0xFF:
291
+ raise ValueError(f"state_byte out of byte range: {state_byte}")
292
+ if len(entries) > 0xFF:
293
+ raise ValueError(
294
+ f"entries exceeds 1-byte entry_count capacity: {len(entries)}"
295
+ )
296
+
297
+ schema = schema_for(hub_version)
298
+ stride = schema.input_entry_stride
299
+ layout = schema.input_entry_layout
300
+
301
+ entry_region = bytearray()
302
+ for entry in entries:
303
+ entry_region.extend(_encode_entry(entry, layout=layout, stride=stride))
304
+
305
+ trailing = _encode_control_keys(control_keys or ControlKeyBlock())
306
+ trailing += _encode_favorites(favorites or ())
307
+ trailing += bytes([state_byte & 0xFF])
308
+ if len(trailing) != _TRAILING_REGION_LEN:
309
+ raise AssertionError(
310
+ f"inputs trailing region produced {len(trailing)} bytes; "
311
+ f"expected {_TRAILING_REGION_LEN}"
312
+ )
313
+
314
+ # Assemble the inner body with a zero in the checksum slot, fix up
315
+ # total_pages, then compute the checksum over everything-but-itself.
316
+ body = bytearray()
317
+ body.append(0x01) # body marker
318
+ body.extend(b"\x00\x00") # total_pages placeholder
319
+ body.append(device_id & 0xFF)
320
+ body.append(source_id_byte & 0xFF)
321
+ body.append(len(entries) & 0xFF)
322
+ body.append(flag_a & 0xFF)
323
+ body.append(flag_b & 0xFF)
324
+ body.extend(entry_region)
325
+ body.extend(trailing)
326
+ body.append(0x00) # checksum slot
327
+
328
+ total_pages = max(1, (len(body) + _PAGE_BODY_CHUNK - 1) // _PAGE_BODY_CHUNK)
329
+ body[1:3] = (total_pages & 0xFFFF).to_bytes(2, "big")
330
+ body[-1] = sum(body[:-1]) & 0xFF
331
+
332
+ # Outer wrapper is single-page from this builder's perspective; the
333
+ # paging helper re-numbers the outer sequence when it chunks the
334
+ # body. We emit seq=1 here so the unpaged path is byte-correct.
335
+ return bytes([0x01, 0x00, 0x01]) + bytes(body)
336
+
337
+
338
+ # ---------------------------------------------------------------------------
339
+ # Parser
340
+ # ---------------------------------------------------------------------------
341
+
342
+
343
+ def _decode_label(slot: bytes, *, encoding: str) -> str:
344
+ raw = slot
345
+ if encoding == "utf-16-be" and len(raw) % 2:
346
+ raw = raw[:-1]
347
+ try:
348
+ decoded = raw.decode(encoding, errors="ignore")
349
+ except Exception:
350
+ decoded = ""
351
+ return decoded.rstrip("\x00").strip()
352
+
353
+
354
+ def _decode_entry(row: bytes, *, layout: InputEntryLayout) -> InputEntry:
355
+ key_id = row[0]
356
+ fid = int.from_bytes(row[1:7], "big")
357
+ if layout is InputEntryLayout.NARROW_ASCII:
358
+ label = _decode_label(row[7:27], encoding="ascii")
359
+ return InputEntry(key_id=key_id, fid=fid, ordinal=0, label=label)
360
+ ordinal = row[7]
361
+ label = _decode_label(row[8:48], encoding="utf-16-be")
362
+ return InputEntry(key_id=key_id, fid=fid, ordinal=ordinal, label=label)
363
+
364
+
365
+ def _decode_control_keys(region: bytes) -> ControlKeyBlock:
366
+ rl = _TRAILING_CONTROL_KEY_ROW_LEN
367
+ return ControlKeyBlock(
368
+ input_list=bytes(region[0 * rl : 1 * rl]),
369
+ input_up=bytes(region[1 * rl : 2 * rl]),
370
+ input_down=bytes(region[2 * rl : 3 * rl]),
371
+ input_confirm=bytes(region[3 * rl : 4 * rl]),
372
+ )
373
+
374
+
375
+ def _decode_favorites(region: bytes) -> tuple[FavoriteSlot, ...]:
376
+ rl = _TRAILING_FAVORITE_ROW_LEN
377
+ return tuple(
378
+ FavoriteSlot(payload=bytes(region[i * rl : (i + 1) * rl]))
379
+ for i in range(_TRAILING_FAVORITE_ROWS)
380
+ )
381
+
382
+
383
+ def parse_inputs_burst(payloads: Sequence[bytes], *, hub_version: str) -> InputsRecord:
384
+ """Decode an accumulated family-0x46 burst into an :class:`InputsRecord`.
385
+
386
+ ``payloads`` is the list of per-page payloads returned by the
387
+ burst assembler (page 1 first). The function stitches the body
388
+ bytes back together, then walks the entry region and trailing
389
+ region using the per-variant stride from :func:`schema_for`.
390
+
391
+ Returns an empty record (``entries == ()``) when the input list is
392
+ too short to carry even the body header. The function never
393
+ raises on truncated content -- the burst-completion check sits at
394
+ a higher layer and decides whether to retry or accept partial
395
+ data.
396
+ """
397
+
398
+ if not payloads:
399
+ return InputsRecord(
400
+ device_id=0,
401
+ source_id_byte=0,
402
+ flag_a=0,
403
+ flag_b=0,
404
+ state_byte=0,
405
+ )
406
+
407
+ page1 = payloads[0]
408
+ header_offset = INPUTS_OUTER_WRAPPER_LEN
409
+ if len(page1) < header_offset + INPUTS_BODY_HEADER_LEN:
410
+ return InputsRecord(
411
+ device_id=0,
412
+ source_id_byte=0,
413
+ flag_a=0,
414
+ flag_b=0,
415
+ state_byte=0,
416
+ )
417
+
418
+ header = page1[header_offset : header_offset + INPUTS_BODY_HEADER_LEN]
419
+ device_id = header[3]
420
+ source_id_byte = header[4]
421
+ entry_count = header[5]
422
+ flag_a = header[6]
423
+ flag_b = header[7]
424
+
425
+ # Stitch the body bytes that follow the header on page 1 with the
426
+ # continuation bytes that follow each subsequent page's 3-byte
427
+ # outer wrapper.
428
+ body_after_header = bytearray()
429
+ body_after_header.extend(page1[header_offset + INPUTS_BODY_HEADER_LEN :])
430
+ for page in payloads[1:]:
431
+ if len(page) <= INPUTS_OUTER_WRAPPER_LEN:
432
+ continue
433
+ body_after_header.extend(page[INPUTS_OUTER_WRAPPER_LEN:])
434
+
435
+ schema = schema_for(hub_version)
436
+ stride = schema.input_entry_stride
437
+ layout = schema.input_entry_layout
438
+
439
+ entries: list[InputEntry] = []
440
+ cursor = 0
441
+ for _ in range(entry_count):
442
+ chunk = body_after_header[cursor : cursor + stride]
443
+ if len(chunk) < stride:
444
+ break
445
+ if chunk[0] == 0x00:
446
+ # Defensive guard: real captures never emit a zero key_id in
447
+ # the middle of the entry region; treat as end-of-list.
448
+ break
449
+ entries.append(_decode_entry(bytes(chunk), layout=layout))
450
+ cursor += stride
451
+
452
+ # The trailing region starts immediately after the entry region.
453
+ # The last body byte before checksum is the state byte; the 107th
454
+ # byte from the end (i.e. the start of the trailing region) is the
455
+ # first control-key row. We slice from the end so trailing slack
456
+ # bytes (page padding, hub-emitted zeros) don't shift the offsets.
457
+ trailing_end = len(body_after_header) - 1 # excludes checksum
458
+ trailing_start = trailing_end - _TRAILING_REGION_LEN
459
+ if trailing_start < cursor:
460
+ # Burst was truncated below the trailing region; surface what
461
+ # we have without inventing zero defaults.
462
+ return InputsRecord(
463
+ device_id=device_id,
464
+ source_id_byte=source_id_byte,
465
+ flag_a=flag_a,
466
+ flag_b=flag_b,
467
+ state_byte=0,
468
+ entries=tuple(entries),
469
+ )
470
+
471
+ trailing = bytes(body_after_header[trailing_start:trailing_end])
472
+ control_keys = _decode_control_keys(trailing[: 4 * _TRAILING_CONTROL_KEY_ROW_LEN])
473
+ favorites_region = trailing[
474
+ 4 * _TRAILING_CONTROL_KEY_ROW_LEN : 4 * _TRAILING_CONTROL_KEY_ROW_LEN
475
+ + _TRAILING_FAVORITE_ROWS * _TRAILING_FAVORITE_ROW_LEN
476
+ ]
477
+ favorites = _decode_favorites(favorites_region)
478
+ state_byte = trailing[-1]
479
+
480
+ return InputsRecord(
481
+ device_id=device_id,
482
+ source_id_byte=source_id_byte,
483
+ flag_a=flag_a,
484
+ flag_b=flag_b,
485
+ state_byte=state_byte,
486
+ entries=tuple(entries),
487
+ control_keys=control_keys,
488
+ favorites=favorites,
489
+ )
490
+
491
+
492
+ __all__ = [
493
+ "ControlKeyBlock",
494
+ "FavoriteSlot",
495
+ "INPUTS_BODY_HEADER_LEN",
496
+ "INPUTS_OUTER_WRAPPER_LEN",
497
+ "InputEntry",
498
+ "InputsRecord",
499
+ "build_inputs_write",
500
+ "parse_inputs_burst",
501
+ ]