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