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/commands.py
ADDED
|
@@ -0,0 +1,1273 @@
|
|
|
1
|
+
"""Helpers for assembling, parsing, and synthesizing command payloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Dict, Iterable, Iterator, List, Tuple
|
|
7
|
+
|
|
8
|
+
from .hub_versions import HUB_VERSION_X1, HUB_VERSION_X1S, HUB_VERSION_X2
|
|
9
|
+
from .wire_schema import schema_for
|
|
10
|
+
from .protocol_const import (
|
|
11
|
+
FAMILY_KEYMAP,
|
|
12
|
+
FAMILY_DEVBTNS,
|
|
13
|
+
opcode_family,
|
|
14
|
+
OP_MARKER,
|
|
15
|
+
OP_DEVBTN_SINGLE,
|
|
16
|
+
OP_KEYMAP_CONT,
|
|
17
|
+
OP_KEYMAP_FINAL_X1S,
|
|
18
|
+
OP_KEYMAP_PAGE_X2_C03D,
|
|
19
|
+
OP_KEYMAP_EXTRA,
|
|
20
|
+
OP_KEYMAP_OVERLAY_X1,
|
|
21
|
+
OP_KEYMAP_PAGE_X1_663D,
|
|
22
|
+
OP_KEYMAP_PAGE_X1_AE3D,
|
|
23
|
+
OP_KEYMAP_PAGE_X1_E43D,
|
|
24
|
+
OP_KEYMAP_TBL_A,
|
|
25
|
+
OP_KEYMAP_TBL_B,
|
|
26
|
+
OP_KEYMAP_TBL_C,
|
|
27
|
+
OP_KEYMAP_TBL_D,
|
|
28
|
+
OP_KEYMAP_TBL_E,
|
|
29
|
+
OP_KEYMAP_TBL_F,
|
|
30
|
+
OP_KEYMAP_TBL_G,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_devbtn_family(opcode: int) -> bool:
|
|
35
|
+
"""Return True if the opcode belongs to the dev-button page family."""
|
|
36
|
+
|
|
37
|
+
return opcode_family(opcode) == FAMILY_DEVBTNS
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_keymap_family(opcode: int) -> bool:
|
|
41
|
+
"""Return True if the opcode belongs to the keymap/button family."""
|
|
42
|
+
|
|
43
|
+
return opcode_family(opcode) == FAMILY_KEYMAP
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_KEYMAP_HEADER_OPCODES: set[int] = {
|
|
47
|
+
OP_KEYMAP_TBL_A,
|
|
48
|
+
OP_KEYMAP_TBL_B,
|
|
49
|
+
OP_KEYMAP_TBL_C,
|
|
50
|
+
OP_KEYMAP_TBL_D,
|
|
51
|
+
OP_KEYMAP_TBL_E,
|
|
52
|
+
OP_KEYMAP_TBL_F,
|
|
53
|
+
OP_KEYMAP_TBL_G,
|
|
54
|
+
OP_KEYMAP_EXTRA,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
_KEYMAP_X1_PAGE_OPCODES: set[int] = {
|
|
58
|
+
OP_KEYMAP_PAGE_X1_663D,
|
|
59
|
+
OP_KEYMAP_PAGE_X1_AE3D,
|
|
60
|
+
OP_KEYMAP_PAGE_X1_E43D,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_KEYMAP_X1S_PAGE_OPCODES: set[int] = {
|
|
64
|
+
OP_KEYMAP_CONT,
|
|
65
|
+
OP_KEYMAP_PAGE_X2_C03D,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@dataclass(slots=True)
|
|
69
|
+
class CommandRecord:
|
|
70
|
+
"""Structured representation of a single device command label."""
|
|
71
|
+
|
|
72
|
+
dev_id: int
|
|
73
|
+
command_id: int
|
|
74
|
+
control: bytes
|
|
75
|
+
label: str
|
|
76
|
+
sort_id: int = 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(slots=True)
|
|
80
|
+
class _CommandBurst:
|
|
81
|
+
variant: str | None = None
|
|
82
|
+
total_frames: int | None = None
|
|
83
|
+
total_commands: int | None = None
|
|
84
|
+
frames: Dict[int, bytes] = field(default_factory=dict)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def received(self) -> int:
|
|
88
|
+
return len(self.frames)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(slots=True, frozen=True)
|
|
92
|
+
class CommandBurstFrame:
|
|
93
|
+
"""Structured metadata extracted from a family-0x5D command frame."""
|
|
94
|
+
|
|
95
|
+
opcode: int
|
|
96
|
+
hub_line: str
|
|
97
|
+
layout_kind: str
|
|
98
|
+
role: str
|
|
99
|
+
frame_no: int
|
|
100
|
+
device_id: int
|
|
101
|
+
data_start: int
|
|
102
|
+
total_frames: int | None = None
|
|
103
|
+
total_commands: int | None = None
|
|
104
|
+
first_command_id: int | None = None
|
|
105
|
+
format_marker: int | None = None
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def is_header(self) -> bool:
|
|
109
|
+
return self.role == "header"
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def is_single(self) -> bool:
|
|
113
|
+
return self.role == "single"
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def is_final(self) -> bool:
|
|
117
|
+
return self.role == "final"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(slots=True, frozen=True)
|
|
121
|
+
class IrCommandDumpFrame:
|
|
122
|
+
"""Structured metadata extracted from raw IR command blob pages."""
|
|
123
|
+
|
|
124
|
+
opcode: int
|
|
125
|
+
family: int
|
|
126
|
+
response_index: int
|
|
127
|
+
command_id: int
|
|
128
|
+
page_no: int
|
|
129
|
+
device_id: int | None
|
|
130
|
+
total_commands: int | None
|
|
131
|
+
total_pages: int | None
|
|
132
|
+
format_marker: int | None
|
|
133
|
+
label: str | None
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def is_page_one(self) -> bool:
|
|
137
|
+
return self.page_no == 1
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(slots=True)
|
|
141
|
+
class _ButtonBurst:
|
|
142
|
+
variant: str | None = None
|
|
143
|
+
total_frames: int | None = None
|
|
144
|
+
total_rows: int | None = None
|
|
145
|
+
frames: Dict[int, bytes] = field(default_factory=dict)
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def received(self) -> int:
|
|
149
|
+
return len(self.frames)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# Fixed-width REQ_BUTTONS / keymap record iterator.
|
|
154
|
+
#
|
|
155
|
+
# Each assembled keymap record is 18 bytes; bursts are walked at fixed stride
|
|
156
|
+
# after page assembly.
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
#: Size in bytes of one REQ_BUTTONS / keymap record.
|
|
160
|
+
KEYMAP_RECORD_SIZE = 18
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(slots=True, frozen=True)
|
|
164
|
+
class KeymapRecord:
|
|
165
|
+
"""One 18-byte keymap record from an assembled REQ_BUTTONS concat buffer.
|
|
166
|
+
|
|
167
|
+
Field layout (offsets within ``raw``):
|
|
168
|
+
|
|
169
|
+
===== =====================================================================
|
|
170
|
+
Off. Meaning
|
|
171
|
+
===== =====================================================================
|
|
172
|
+
0 Activity id (matches the burst's activity).
|
|
173
|
+
1 Button or favorite id. If this value is a known hardware button code
|
|
174
|
+
(``BUTTONNAME_BY_CODE``), the record describes a button-to-command
|
|
175
|
+
binding; otherwise it is interpreted as an activity-favorite slot.
|
|
176
|
+
2 Device id that the bound command targets.
|
|
177
|
+
9 Command id within that device.
|
|
178
|
+
10-17 Optional long-press extension. Present iff::
|
|
179
|
+
|
|
180
|
+
raw[10] != 0 and raw[11:15] == b"\\x00\\x00\\x00\\x00"
|
|
181
|
+
and raw[15] == 0x4E
|
|
182
|
+
|
|
183
|
+
When present, ``raw[10]`` is the long-press device id and
|
|
184
|
+
``raw[17]`` is the long-press command id.
|
|
185
|
+
===== =====================================================================
|
|
186
|
+
|
|
187
|
+
Bytes 3-8, 16 and the slot occupied by the long-press marker when not in
|
|
188
|
+
long-press shape, are not used by the integration. This dataclass exposes
|
|
189
|
+
the raw 18 bytes so callers that need to inspect them retain full access.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
raw: bytes
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def activity_id(self) -> int:
|
|
196
|
+
return self.raw[0]
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def button_id(self) -> int:
|
|
200
|
+
return self.raw[1]
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def device_id(self) -> int:
|
|
204
|
+
return self.raw[2]
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def command_id(self) -> int:
|
|
208
|
+
return self.raw[9]
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def has_long_press(self) -> bool:
|
|
212
|
+
return (
|
|
213
|
+
len(self.raw) >= 18
|
|
214
|
+
and self.raw[10] != 0
|
|
215
|
+
and self.raw[11:15] == b"\x00\x00\x00\x00"
|
|
216
|
+
and self.raw[15] == 0x4E
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def long_press_device_id(self) -> int | None:
|
|
221
|
+
return self.raw[10] if self.has_long_press else None
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def long_press_command_id(self) -> int | None:
|
|
225
|
+
return self.raw[17] if self.has_long_press else None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def iter_keymap_records(
|
|
229
|
+
concat: bytes,
|
|
230
|
+
*,
|
|
231
|
+
expected_activity_id: int | None = None,
|
|
232
|
+
) -> Iterator[KeymapRecord]:
|
|
233
|
+
"""Yield :class:`KeymapRecord` objects from an assembled keymap buffer.
|
|
234
|
+
|
|
235
|
+
``concat`` is the post-assembly buffer produced by
|
|
236
|
+
:class:`DeviceButtonAssembler` i.e., the concatenated row stream with
|
|
237
|
+
per-frame transport headers already stripped. The iterator walks the
|
|
238
|
+
buffer in 18-byte strides; trailing bytes shorter than one record are
|
|
239
|
+
ignored.
|
|
240
|
+
|
|
241
|
+
When ``expected_activity_id`` is provided, records whose
|
|
242
|
+
:attr:`KeymapRecord.activity_id` does not match are silently skipped.
|
|
243
|
+
The assembler keys bursts by activity id, so mismatches normally only
|
|
244
|
+
occur on malformed firmware data; this guard mirrors the existing
|
|
245
|
+
behavior of ``StateHelpers._parse_keymap_record`` which returns early on
|
|
246
|
+
activity-id mismatch.
|
|
247
|
+
|
|
248
|
+
Note this iterator deliberately does **not** emit a padded short-tail
|
|
249
|
+
record for trailing fragments shorter than 18 bytes. The integration has
|
|
250
|
+
historically tolerated such fragments when they look like a valid record
|
|
251
|
+
start; that fallback behavior remains in ``state_helpers`` for now.
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
usable = len(concat) - (len(concat) % KEYMAP_RECORD_SIZE)
|
|
255
|
+
for start in range(0, usable, KEYMAP_RECORD_SIZE):
|
|
256
|
+
record = KeymapRecord(raw=bytes(concat[start : start + KEYMAP_RECORD_SIZE]))
|
|
257
|
+
if expected_activity_id is not None and record.activity_id != expected_activity_id:
|
|
258
|
+
continue
|
|
259
|
+
yield record
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@dataclass(slots=True, frozen=True)
|
|
263
|
+
class ButtonBurstFrame:
|
|
264
|
+
"""Structured metadata extracted from a family-0x3D button frame."""
|
|
265
|
+
|
|
266
|
+
opcode: int
|
|
267
|
+
hub_line: str
|
|
268
|
+
layout_kind: str
|
|
269
|
+
role: str
|
|
270
|
+
frame_no: int
|
|
271
|
+
activity_id: int | None
|
|
272
|
+
data_start: int
|
|
273
|
+
total_frames: int | None = None
|
|
274
|
+
total_rows: int | None = None
|
|
275
|
+
has_row_data: bool = True
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def is_header(self) -> bool:
|
|
279
|
+
return self.role == "header"
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def is_final(self) -> bool:
|
|
283
|
+
return self.role == "final"
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def is_marker(self) -> bool:
|
|
287
|
+
return self.role == "marker"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
# Fixed-width REQ_COMMANDS record iterator.
|
|
292
|
+
#
|
|
293
|
+
# Reference layout for an assembled REQ_COMMANDS burst:
|
|
294
|
+
#
|
|
295
|
+
# concat = page1_body || page2_body || ... || pageN_body
|
|
296
|
+
# where pageK_body = raw_frame[7 : 7 + (raw_frame[2] - 3)]
|
|
297
|
+
# concat[3] = N (record count)
|
|
298
|
+
# concat[4 + i*stride : 4 + (i+1)*stride] = record i
|
|
299
|
+
#
|
|
300
|
+
# stride is 40 on X1 (ASCII), 70 on X1S/X2 (UTF-16BE). The label slot lives at
|
|
301
|
+
# record-offset 9 with length 30 (X1) or 60 (X1S/X2). Encoding is selected
|
|
302
|
+
# from the hub model, not from byte-pattern heuristics.
|
|
303
|
+
#
|
|
304
|
+
# The integration's existing assembler (`DeviceCommandAssembler`) strips
|
|
305
|
+
# `parsed.data_start` bytes per frame before concatenating. For page-1
|
|
306
|
+
# headers that's 7, for page-2+ pages it's 3. The 4-byte difference equals
|
|
307
|
+
# the page-1 preamble + count byte kept in `concat[0..3]`.
|
|
308
|
+
#
|
|
309
|
+
# Therefore, when the integration calls the new iterator on its post-assembly
|
|
310
|
+
# body, the body already starts at what the app sees as `concat[4]`. The
|
|
311
|
+
# record count must be supplied separately (by the caller, who has it as
|
|
312
|
+
# `parsed.total_commands`).
|
|
313
|
+
# ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
#: Per-record stride in the assembled body. 40 bytes on X1 (ASCII labels),
|
|
317
|
+
#: 70 bytes on X1S/X2 (UTF-16BE labels). Mirrored from
|
|
318
|
+
#: :mod:`wire_schema` for backwards-compatible external imports.
|
|
319
|
+
COMMAND_RECORD_STRIDE_X1 = schema_for(HUB_VERSION_X1).command_stride
|
|
320
|
+
COMMAND_RECORD_STRIDE_X1S_X2 = schema_for(HUB_VERSION_X1S).command_stride
|
|
321
|
+
|
|
322
|
+
#: Per-record byte offsets common to both X1 and X1S/X2 layouts.
|
|
323
|
+
COMMAND_RECORD_LABEL_OFFSET = 9
|
|
324
|
+
COMMAND_RECORD_LABEL_LEN_X1 = schema_for(HUB_VERSION_X1).command_label_slot_len
|
|
325
|
+
COMMAND_RECORD_LABEL_LEN_X1S_X2 = schema_for(HUB_VERSION_X1S).command_label_slot_len
|
|
326
|
+
|
|
327
|
+
assert COMMAND_RECORD_STRIDE_X1 == 40 and COMMAND_RECORD_STRIDE_X1S_X2 == 70
|
|
328
|
+
assert COMMAND_RECORD_LABEL_LEN_X1 == 30 and COMMAND_RECORD_LABEL_LEN_X1S_X2 == 60
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _stride_and_label_len(hub_version: str) -> tuple[int, int, str]:
|
|
332
|
+
"""Return (stride, label_len, encoding) for the hub model.
|
|
333
|
+
|
|
334
|
+
Thin wrapper over :func:`schema_for`; encoding is the literal
|
|
335
|
+
codec name accepted by ``bytes.decode``. Unknown hub versions
|
|
336
|
+
raise ``ValueError`` via the shared schema.
|
|
337
|
+
"""
|
|
338
|
+
|
|
339
|
+
schema = schema_for(hub_version)
|
|
340
|
+
return schema.command_stride, schema.command_label_slot_len, schema.command_label_encoding
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _decode_schema_label(label_bytes: bytes, encoding: str) -> str:
|
|
344
|
+
"""Decode a fixed-width command label slot.
|
|
345
|
+
|
|
346
|
+
The full slot is decoded under the selected encoding, then null padding
|
|
347
|
+
and surrounding whitespace are removed. There is no embedded length
|
|
348
|
+
prefix or terminator scan.
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
if encoding == "ascii":
|
|
352
|
+
# ASCII path: decode strict bytes, strip nulls and whitespace.
|
|
353
|
+
try:
|
|
354
|
+
decoded = label_bytes.decode("ascii", errors="ignore")
|
|
355
|
+
except Exception:
|
|
356
|
+
decoded = ""
|
|
357
|
+
return decoded.rstrip("\x00").strip()
|
|
358
|
+
|
|
359
|
+
# UTF-16BE path: pad to even length defensively (real slots are always
|
|
360
|
+
# even on X1S/X2, but a malformed fixture could pass an odd length).
|
|
361
|
+
raw = label_bytes
|
|
362
|
+
if len(raw) % 2:
|
|
363
|
+
raw = raw[:-1]
|
|
364
|
+
try:
|
|
365
|
+
decoded = raw.decode("utf-16-be", errors="ignore")
|
|
366
|
+
except Exception:
|
|
367
|
+
decoded = ""
|
|
368
|
+
return decoded.rstrip("\x00").strip()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def iter_command_records_from_assembled(
|
|
372
|
+
body: bytes,
|
|
373
|
+
*,
|
|
374
|
+
count: int,
|
|
375
|
+
dev_id: int,
|
|
376
|
+
hub_version: str,
|
|
377
|
+
) -> Iterator[CommandRecord]:
|
|
378
|
+
"""Yield :class:`CommandRecord` objects from an assembled REQ_COMMANDS body.
|
|
379
|
+
|
|
380
|
+
``body`` is the post-assembly buffer produced by
|
|
381
|
+
:class:`DeviceCommandAssembler` the per-frame transport headers and the
|
|
382
|
+
page-1 preamble (page metadata + count byte) are already stripped, so
|
|
383
|
+
``body[0]`` is the first byte of the first record.
|
|
384
|
+
|
|
385
|
+
``count`` is required; it should be the value the parser captured in
|
|
386
|
+
:attr:`CommandBurstFrame.total_commands` (the count byte from the
|
|
387
|
+
assembled header). Inference from ``len(body) // stride`` is deliberately
|
|
388
|
+
not done: a body that contains trailing padding or has been truncated
|
|
389
|
+
mid-record would silently miscount.
|
|
390
|
+
|
|
391
|
+
``hub_version`` selects stride (40 X1 / 70 X1S/X2) and label encoding
|
|
392
|
+
(ASCII X1 / UTF-16BE X1S/X2). Unknown hub versions raise ``ValueError``.
|
|
393
|
+
|
|
394
|
+
Records beyond what ``body`` can supply are silently skipped this is
|
|
395
|
+
a tolerant choice for truncated-burst scenarios; the caller can detect
|
|
396
|
+
by comparing returned record count to the requested ``count``.
|
|
397
|
+
|
|
398
|
+
Per-record layout (offsets within the 40 or 70 byte stride):
|
|
399
|
+
|
|
400
|
+
- ``[0]`` device id
|
|
401
|
+
- ``[1]`` command id
|
|
402
|
+
- ``[2]`` code type / format marker
|
|
403
|
+
- ``[3..8]`` fid (6 bytes; treated as opaque control bytes here)
|
|
404
|
+
- ``[9..label_end]`` label slot (30 or 60 bytes)
|
|
405
|
+
- ``[stride-1]`` sort id (== command id on observed fixtures)
|
|
406
|
+
|
|
407
|
+
The yielded :class:`CommandRecord` retains the existing field set:
|
|
408
|
+
``dev_id`` is taken from record byte 0, ``command_id`` from byte 1, and
|
|
409
|
+
``control`` is the 7 bytes at record[2..9] for consistency with existing
|
|
410
|
+
CommandRecord callers.
|
|
411
|
+
"""
|
|
412
|
+
|
|
413
|
+
stride, label_len, encoding = _stride_and_label_len(hub_version)
|
|
414
|
+
label_end = COMMAND_RECORD_LABEL_OFFSET + label_len
|
|
415
|
+
|
|
416
|
+
for i in range(count):
|
|
417
|
+
start = i * stride
|
|
418
|
+
end = start + stride
|
|
419
|
+
if end > len(body):
|
|
420
|
+
return # truncated body; caller can detect via returned-count diff
|
|
421
|
+
record = body[start:end]
|
|
422
|
+
|
|
423
|
+
label_bytes = record[COMMAND_RECORD_LABEL_OFFSET:label_end]
|
|
424
|
+
label = _decode_schema_label(label_bytes, encoding)
|
|
425
|
+
|
|
426
|
+
yield CommandRecord(
|
|
427
|
+
dev_id=record[0],
|
|
428
|
+
command_id=record[1],
|
|
429
|
+
control=bytes(record[2 : COMMAND_RECORD_LABEL_OFFSET]),
|
|
430
|
+
label=label,
|
|
431
|
+
sort_id=record[stride - 1] & 0xFF,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _button_hub_line(hub_version: str) -> str:
|
|
436
|
+
"""Return the burst-frame ``hub_line`` tag for the hub variant.
|
|
437
|
+
|
|
438
|
+
Validates ``hub_version`` against the shared schema so unknown
|
|
439
|
+
values fail at the call site rather than silently falling back to
|
|
440
|
+
a shape-sniffing heuristic.
|
|
441
|
+
"""
|
|
442
|
+
|
|
443
|
+
schema_for(hub_version)
|
|
444
|
+
return "x1" if hub_version == HUB_VERSION_X1 else "x1s_x2"
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _command_hub_line(hub_version: str) -> str:
|
|
448
|
+
"""Return the burst-frame ``hub_line`` tag for the hub variant."""
|
|
449
|
+
|
|
450
|
+
schema_for(hub_version)
|
|
451
|
+
return "x1" if hub_version == HUB_VERSION_X1 else "x1s_x2"
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def parse_button_burst_frame(
|
|
455
|
+
opcode: int,
|
|
456
|
+
raw_frame: bytes,
|
|
457
|
+
*,
|
|
458
|
+
hub_version: str,
|
|
459
|
+
) -> ButtonBurstFrame | None:
|
|
460
|
+
"""Return parsed family metadata for a button-burst frame.
|
|
461
|
+
|
|
462
|
+
REQ_BUTTONS pages assemble into a counted fixed-width row stream. Marker
|
|
463
|
+
variants are treated as ordinary pages for assembly purposes, so the low
|
|
464
|
+
byte identifies the family while the high byte remains payload-length
|
|
465
|
+
metadata only.
|
|
466
|
+
"""
|
|
467
|
+
|
|
468
|
+
if len(raw_frame) < 7:
|
|
469
|
+
return None
|
|
470
|
+
|
|
471
|
+
payload = raw_frame[4:-1]
|
|
472
|
+
if len(payload) < 3 or not _is_keymap_family(opcode):
|
|
473
|
+
return None
|
|
474
|
+
|
|
475
|
+
frame_no = payload[2]
|
|
476
|
+
hinted_line = _button_hub_line(hub_version)
|
|
477
|
+
total_frames = int.from_bytes(payload[4:6], "big") if len(payload) >= 6 else None
|
|
478
|
+
if total_frames == 0:
|
|
479
|
+
total_frames = None
|
|
480
|
+
total_rows = payload[6] if frame_no == 1 and len(payload) > 6 and payload[6] > 0 else None
|
|
481
|
+
|
|
482
|
+
if frame_no == 1 and total_frames is not None and total_frames > 0 and len(payload) > 7:
|
|
483
|
+
layout_kind = "header"
|
|
484
|
+
if opcode == OP_KEYMAP_OVERLAY_X1 or (hinted_line == "x1" and total_frames == 1):
|
|
485
|
+
layout_kind = "x1_overlay"
|
|
486
|
+
return ButtonBurstFrame(
|
|
487
|
+
opcode=opcode,
|
|
488
|
+
hub_line=hinted_line,
|
|
489
|
+
layout_kind=layout_kind,
|
|
490
|
+
role="header",
|
|
491
|
+
frame_no=frame_no,
|
|
492
|
+
activity_id=payload[7],
|
|
493
|
+
data_start=7,
|
|
494
|
+
total_frames=total_frames,
|
|
495
|
+
total_rows=total_rows,
|
|
496
|
+
has_row_data=len(payload) > 7,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
if opcode == OP_MARKER:
|
|
500
|
+
return ButtonBurstFrame(
|
|
501
|
+
opcode=opcode,
|
|
502
|
+
hub_line="x1s_x2" if hinted_line != "x1" else "x1",
|
|
503
|
+
layout_kind="x1s_marker",
|
|
504
|
+
role="marker",
|
|
505
|
+
frame_no=frame_no,
|
|
506
|
+
activity_id=None,
|
|
507
|
+
data_start=len(payload),
|
|
508
|
+
total_frames=total_frames if total_frames and total_frames > 0 else None,
|
|
509
|
+
total_rows=total_rows,
|
|
510
|
+
has_row_data=False,
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
stream = payload[3:] if len(payload) > 3 else b""
|
|
514
|
+
if stream and len(stream) < 18:
|
|
515
|
+
inferred_line = hinted_line
|
|
516
|
+
|
|
517
|
+
role = "final" if total_frames is not None and frame_no >= total_frames else "page"
|
|
518
|
+
layout_kind = "page"
|
|
519
|
+
if inferred_line == "x1":
|
|
520
|
+
layout_kind = "x1_page"
|
|
521
|
+
elif inferred_line == "x1s_x2":
|
|
522
|
+
layout_kind = "x1s_final" if role == "final" else "x1s_page"
|
|
523
|
+
|
|
524
|
+
return ButtonBurstFrame(
|
|
525
|
+
opcode=opcode,
|
|
526
|
+
hub_line=inferred_line,
|
|
527
|
+
layout_kind=layout_kind,
|
|
528
|
+
role=role,
|
|
529
|
+
frame_no=frame_no,
|
|
530
|
+
activity_id=None,
|
|
531
|
+
data_start=3,
|
|
532
|
+
total_frames=total_frames,
|
|
533
|
+
total_rows=None,
|
|
534
|
+
has_row_data=True,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
# Continuation pages carry only row bytes; the activity id was established
|
|
538
|
+
# by the page-1 header and is held by the burst assembler. Do not infer it
|
|
539
|
+
# from page-2+ row bytes -- inner record bytes can accidentally match a
|
|
540
|
+
# row-start shape and route the page to the wrong burst.
|
|
541
|
+
inferred_line = hinted_line
|
|
542
|
+
if inferred_line == "shared":
|
|
543
|
+
inferred_line = "x1s_x2" if frame_no > 1 else "shared"
|
|
544
|
+
|
|
545
|
+
role = "final" if total_frames is not None and frame_no >= total_frames else "page"
|
|
546
|
+
layout_kind = "page"
|
|
547
|
+
if inferred_line == "x1":
|
|
548
|
+
layout_kind = "x1_page"
|
|
549
|
+
elif inferred_line == "x1s_x2":
|
|
550
|
+
layout_kind = "x1s_final" if role == "final" else "x1s_page"
|
|
551
|
+
|
|
552
|
+
return ButtonBurstFrame(
|
|
553
|
+
opcode=opcode,
|
|
554
|
+
hub_line=inferred_line,
|
|
555
|
+
layout_kind=layout_kind,
|
|
556
|
+
role=role,
|
|
557
|
+
frame_no=frame_no,
|
|
558
|
+
activity_id=None,
|
|
559
|
+
data_start=3,
|
|
560
|
+
total_frames=total_frames if total_frames and total_frames > 0 else None,
|
|
561
|
+
total_rows=total_rows,
|
|
562
|
+
has_row_data=bool(stream),
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def parse_command_burst_frame(
|
|
567
|
+
opcode: int,
|
|
568
|
+
raw_frame: bytes,
|
|
569
|
+
*,
|
|
570
|
+
hub_version: str,
|
|
571
|
+
) -> CommandBurstFrame | None:
|
|
572
|
+
"""Return parsed family metadata for a command-burst frame.
|
|
573
|
+
|
|
574
|
+
REQ_COMMANDS pages assemble into counted fixed-width records: 40-byte
|
|
575
|
+
rows on X1 and 70-byte rows on X1S/X2, with the label slot beginning at
|
|
576
|
+
offset 9. Labels are decoded from the hub model's expected encoding and
|
|
577
|
+
``0xFF`` bytes inside the slot are treated as data, not delimiters.
|
|
578
|
+
|
|
579
|
+
The existing magic-byte sniffing below detects per-frame layout variants.
|
|
580
|
+
A future fully post-assembly path can replace that heuristic with the
|
|
581
|
+
fixed-width record walk used elsewhere in the module.
|
|
582
|
+
"""
|
|
583
|
+
|
|
584
|
+
if len(raw_frame) < 7:
|
|
585
|
+
return None
|
|
586
|
+
|
|
587
|
+
payload = raw_frame[4:-1]
|
|
588
|
+
if len(payload) < 4:
|
|
589
|
+
return None
|
|
590
|
+
|
|
591
|
+
hinted_line = _command_hub_line(hub_version)
|
|
592
|
+
frame_no = payload[2]
|
|
593
|
+
|
|
594
|
+
is_input_refresh_layout = (
|
|
595
|
+
opcode_family(opcode) == 0x0D
|
|
596
|
+
and len(payload) > 8
|
|
597
|
+
and payload[:6] == b"\x01\x00\x01\x01\x00\x01"
|
|
598
|
+
)
|
|
599
|
+
is_prefixed_single_layout = (
|
|
600
|
+
opcode_family(opcode) == FAMILY_DEVBTNS
|
|
601
|
+
and len(payload) > 9
|
|
602
|
+
and payload[:6] == b"\x01\x00\x01\x01\x00\x01"
|
|
603
|
+
and payload[6] == 0x01
|
|
604
|
+
)
|
|
605
|
+
is_single_layout = (
|
|
606
|
+
opcode == OP_DEVBTN_SINGLE
|
|
607
|
+
or (
|
|
608
|
+
opcode_family(opcode) == 0x0D
|
|
609
|
+
and len(payload) > 7
|
|
610
|
+
and payload[:6] == b"\x01\x00\x01\x01\x00\x01"
|
|
611
|
+
)
|
|
612
|
+
or is_prefixed_single_layout
|
|
613
|
+
)
|
|
614
|
+
if is_single_layout:
|
|
615
|
+
device_id = payload[3]
|
|
616
|
+
layout_kind = "single"
|
|
617
|
+
data_start = 7
|
|
618
|
+
first_command_id = payload[8] if len(payload) > 8 else None
|
|
619
|
+
format_marker = payload[9] if len(payload) > 9 else None
|
|
620
|
+
if payload[:6] == b"\x01\x00\x01\x01\x00\x01" and len(payload) > 7:
|
|
621
|
+
device_id = payload[7]
|
|
622
|
+
if is_input_refresh_layout:
|
|
623
|
+
# 0x020C WiFi/input-config refresh replies reuse the single-frame
|
|
624
|
+
# envelope, but the payload fields differ from normal REQ_COMMANDS:
|
|
625
|
+
# <dev_id> <slot_id> <fmt> ...
|
|
626
|
+
device_id = payload[6]
|
|
627
|
+
layout_kind = "input_config_refresh"
|
|
628
|
+
data_start = 8
|
|
629
|
+
first_command_id = payload[7] if len(payload) > 7 else None
|
|
630
|
+
format_marker = payload[8] if len(payload) > 8 else None
|
|
631
|
+
return CommandBurstFrame(
|
|
632
|
+
opcode=opcode,
|
|
633
|
+
hub_line=hinted_line,
|
|
634
|
+
layout_kind=layout_kind,
|
|
635
|
+
role="single",
|
|
636
|
+
frame_no=frame_no,
|
|
637
|
+
device_id=device_id,
|
|
638
|
+
total_frames=1,
|
|
639
|
+
data_start=data_start,
|
|
640
|
+
first_command_id=first_command_id,
|
|
641
|
+
format_marker=format_marker,
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
if not _is_devbtn_family(opcode):
|
|
645
|
+
return None
|
|
646
|
+
|
|
647
|
+
if payload[:2] != b"\x01\x00":
|
|
648
|
+
return None
|
|
649
|
+
|
|
650
|
+
if frame_no == 1 and len(payload) > 7 and payload[4] == 0x00:
|
|
651
|
+
layout_kind = "shared_classic"
|
|
652
|
+
if hinted_line == "x1":
|
|
653
|
+
layout_kind = "x1_classic"
|
|
654
|
+
elif hinted_line == "x1s_x2":
|
|
655
|
+
layout_kind = "x1s_x2"
|
|
656
|
+
return CommandBurstFrame(
|
|
657
|
+
opcode=opcode,
|
|
658
|
+
hub_line=hinted_line,
|
|
659
|
+
layout_kind=layout_kind,
|
|
660
|
+
role="header",
|
|
661
|
+
frame_no=frame_no,
|
|
662
|
+
device_id=payload[7],
|
|
663
|
+
total_frames=int.from_bytes(payload[4:6], "big"),
|
|
664
|
+
total_commands=payload[6],
|
|
665
|
+
data_start=7,
|
|
666
|
+
first_command_id=payload[8] if len(payload) > 8 else None,
|
|
667
|
+
format_marker=payload[9] if len(payload) > 9 else None,
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
if frame_no == 1 and len(payload) > 8:
|
|
671
|
+
return CommandBurstFrame(
|
|
672
|
+
opcode=opcode,
|
|
673
|
+
hub_line="x1" if hinted_line == "shared" else hinted_line,
|
|
674
|
+
layout_kind="x1_wifi",
|
|
675
|
+
role="header",
|
|
676
|
+
frame_no=frame_no,
|
|
677
|
+
device_id=payload[6],
|
|
678
|
+
total_frames=payload[4],
|
|
679
|
+
total_commands=payload[5],
|
|
680
|
+
data_start=6,
|
|
681
|
+
first_command_id=payload[7] if len(payload) > 7 else None,
|
|
682
|
+
format_marker=payload[8] if len(payload) > 8 else None,
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
role = "page"
|
|
686
|
+
return CommandBurstFrame(
|
|
687
|
+
opcode=opcode,
|
|
688
|
+
hub_line=hinted_line,
|
|
689
|
+
layout_kind="x1s_x2" if hinted_line == "x1s_x2" else ("x1_page" if hinted_line == "x1" else "page"),
|
|
690
|
+
role=role,
|
|
691
|
+
frame_no=frame_no,
|
|
692
|
+
device_id=payload[3],
|
|
693
|
+
data_start=3,
|
|
694
|
+
first_command_id=payload[4] if len(payload) > 4 else None,
|
|
695
|
+
format_marker=payload[5] if len(payload) > 5 else None,
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _looks_like_ir_dump_opcode(opcode: int) -> bool:
|
|
700
|
+
return opcode_family(opcode) in (0x0D, 0x0E)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _looks_reasonable_ir_dump_label(text: str) -> bool:
|
|
704
|
+
stripped = str(text or "").strip()
|
|
705
|
+
if not stripped or len(stripped) > 40:
|
|
706
|
+
return False
|
|
707
|
+
if sum(ch.isalnum() for ch in stripped) == 0:
|
|
708
|
+
return False
|
|
709
|
+
return all(ch.isprintable() and ch not in "\r\n\t" for ch in stripped)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
_IR_DUMP_LABEL_START = 15
|
|
713
|
+
_IR_DUMP_PAGE_ONE_BLOB_START_X1 = 45
|
|
714
|
+
_IR_DUMP_PAGE_ONE_BLOB_START_X1S = 75
|
|
715
|
+
_IR_DUMP_PAGE_ONE_BLOB_PREFIXES = (
|
|
716
|
+
b"\x01\x20\x00\x10",
|
|
717
|
+
b"\x01\x30\x00\x10",
|
|
718
|
+
b"\x03\x20\x00\x00",
|
|
719
|
+
b"\x01\x00\x00\x00",
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _sum8(data: bytes) -> int:
|
|
724
|
+
return sum(data) & 0xFF
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def looks_like_descriptive_play_blob(blob: bytes) -> bool:
|
|
728
|
+
"""Return True for human-readable protocol-descriptor replay blobs.
|
|
729
|
+
|
|
730
|
+
Descriptor library_data layout::
|
|
731
|
+
|
|
732
|
+
blob[0..1] = declared length BE (>= 1, == len of the ASCII descriptor)
|
|
733
|
+
blob[2..5] = 0x00 0x00 0x11 0x00
|
|
734
|
+
blob[6..7] = 0x94 0x70
|
|
735
|
+
blob[8..] = ASCII descriptor starting with "P:" + four trailing nulls
|
|
736
|
+
"""
|
|
737
|
+
|
|
738
|
+
return (
|
|
739
|
+
len(blob) >= 14
|
|
740
|
+
and blob[2:6] == b"\x00\x00\x11\x00"
|
|
741
|
+
and blob[6:8] == b"\x94\x70"
|
|
742
|
+
and blob[8:10] == b"P:"
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def descriptive_play_blob_text(blob: bytes) -> str | None:
|
|
747
|
+
"""Return the human-readable descriptor text from a descriptive blob body."""
|
|
748
|
+
|
|
749
|
+
if not looks_like_descriptive_play_blob(blob):
|
|
750
|
+
return None
|
|
751
|
+
declared_len = int.from_bytes(blob[0:2], "big")
|
|
752
|
+
if declared_len <= 0:
|
|
753
|
+
return None
|
|
754
|
+
text_end = 8 + declared_len
|
|
755
|
+
if text_end > len(blob):
|
|
756
|
+
return None
|
|
757
|
+
try:
|
|
758
|
+
return blob[8:text_end].decode("ascii").rstrip("\x00")
|
|
759
|
+
except UnicodeDecodeError:
|
|
760
|
+
return None
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def split_play_blob_tail(blob: bytes) -> tuple[bytes, int]:
|
|
764
|
+
"""Return ``(blob_body, replay_tail_checksum)`` for a stored replay blob."""
|
|
765
|
+
|
|
766
|
+
if not isinstance(blob, (bytes, bytearray)) or len(blob) < 2:
|
|
767
|
+
raise ValueError("blob is too short to contain a replay-tail checksum")
|
|
768
|
+
data = bytes(blob)
|
|
769
|
+
return data[:-1], data[-1]
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _parse_descriptor_fields(descriptor: str) -> dict[str, str]:
|
|
773
|
+
fields: dict[str, str] = {}
|
|
774
|
+
for token in descriptor.split():
|
|
775
|
+
if ":" not in token:
|
|
776
|
+
continue
|
|
777
|
+
key, value = token.split(":", 1)
|
|
778
|
+
if key:
|
|
779
|
+
fields[key] = value
|
|
780
|
+
return fields
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _canonicalize_denonk_descriptor(descriptor: str) -> str:
|
|
784
|
+
fields = _parse_descriptor_fields(descriptor)
|
|
785
|
+
if fields.get("P") != "DenonK":
|
|
786
|
+
return descriptor
|
|
787
|
+
if "CHECKSUM" in fields:
|
|
788
|
+
return descriptor
|
|
789
|
+
|
|
790
|
+
missing = [key for key in ("R", "C0", "C1", "C2", "D", "S", "F") if key not in fields]
|
|
791
|
+
if missing:
|
|
792
|
+
raise ValueError(f"DenonK descriptor is missing required field(s): {', '.join(missing)}")
|
|
793
|
+
|
|
794
|
+
try:
|
|
795
|
+
carrier_hz = int(fields["R"], 10)
|
|
796
|
+
c0 = int(fields["C0"], 10)
|
|
797
|
+
c1 = int(fields["C1"], 10)
|
|
798
|
+
c2 = int(fields["C2"], 10)
|
|
799
|
+
device = int(fields["D"], 10)
|
|
800
|
+
subdevice = int(fields["S"], 10)
|
|
801
|
+
function = int(fields["F"], 10)
|
|
802
|
+
except ValueError as err:
|
|
803
|
+
raise ValueError(f"DenonK descriptor fields must be decimal integers: {err}") from err
|
|
804
|
+
|
|
805
|
+
checksum = denonk_checksum(c0, c1, c2, device, subdevice, function)
|
|
806
|
+
return (
|
|
807
|
+
f"P:DenonK "
|
|
808
|
+
f"R:{carrier_hz} "
|
|
809
|
+
f"C0:{c0} C1:{c1} C2:{c2} "
|
|
810
|
+
f"D:{device} S:{subdevice} F:{function} "
|
|
811
|
+
f"CHECKSUM:{checksum}"
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def build_descriptive_ir_blob_body(descriptor: str) -> bytes:
|
|
816
|
+
"""Build a descriptive replay-blob body without the final replay-tail byte.
|
|
817
|
+
|
|
818
|
+
Synthesis path for the Test / Save Blob flows: normalizes
|
|
819
|
+
whitespace, validates the ``P:`` prefix, canonicalizes DenonK
|
|
820
|
+
descriptors (which adds a ``CHECKSUM:`` field when missing), then
|
|
821
|
+
emits the canonical byte layout via
|
|
822
|
+
:func:`blob_decoders.render_ir_descriptive_blob_body` followed by
|
|
823
|
+
the writer's four trailing ``0x00`` bytes.
|
|
824
|
+
|
|
825
|
+
Round-trip callers MUST NOT go through this entry point because
|
|
826
|
+
the DenonK canonicalization mutates the descriptor and would
|
|
827
|
+
break byte-for-byte round-trip equality with a captured blob;
|
|
828
|
+
the backup-decoder round-trip path
|
|
829
|
+
(:func:`blob_decoders._encode_descriptive_ir`) uses
|
|
830
|
+
``render_ir_descriptive_blob_body`` directly and re-emits whatever
|
|
831
|
+
trailer was captured.
|
|
832
|
+
"""
|
|
833
|
+
|
|
834
|
+
text = re.sub(r"\s+", " ", str(descriptor or "").strip())
|
|
835
|
+
if not text:
|
|
836
|
+
raise ValueError("descriptor text is required")
|
|
837
|
+
if not text.startswith("P:"):
|
|
838
|
+
raise ValueError("descriptor text must start with 'P:'")
|
|
839
|
+
|
|
840
|
+
text = _canonicalize_denonk_descriptor(text)
|
|
841
|
+
# Import locally to avoid a circular import at module load time:
|
|
842
|
+
# blob_decoders only imports from protocol_const, but commands is
|
|
843
|
+
# a heavy dependency that some early-loaded modules pull in.
|
|
844
|
+
from .blob_decoders import render_ir_descriptive_blob_body
|
|
845
|
+
|
|
846
|
+
return render_ir_descriptive_blob_body(text) + b"\x00\x00\x00\x00"
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def denonk_checksum(c0: int, c1: int, c2: int, d: int, s: int, f: int) -> int:
|
|
850
|
+
"""Return the observed Sofabaton ``CHECKSUM:`` value for ``P:DenonK`` blobs.
|
|
851
|
+
|
|
852
|
+
The checksum is not the transport/frame checksum and is distinct from the
|
|
853
|
+
trailing replay-tail byte. It is derived from the protocol parameter nibbles
|
|
854
|
+
in the same order Sofabaton serializes them into its text descriptor.
|
|
855
|
+
"""
|
|
856
|
+
|
|
857
|
+
values = (c0, c1, c2, d, s, f)
|
|
858
|
+
if any(v < 0 for v in values):
|
|
859
|
+
raise ValueError("DenonK fields must be non-negative")
|
|
860
|
+
if any(v > 0xFF for v in (c0, c1, c2, d, s)):
|
|
861
|
+
raise ValueError("DenonK C0/C1/C2/D/S fields must fit in one byte")
|
|
862
|
+
if f > 0xFFF:
|
|
863
|
+
raise ValueError("DenonK function must fit in 12 bits")
|
|
864
|
+
|
|
865
|
+
nibbles = [
|
|
866
|
+
c0 & 0x0F,
|
|
867
|
+
(c0 >> 4) & 0x0F,
|
|
868
|
+
c1 & 0x0F,
|
|
869
|
+
(c1 >> 4) & 0x0F,
|
|
870
|
+
c2 & 0x0F,
|
|
871
|
+
d & 0x0F,
|
|
872
|
+
s & 0x0F,
|
|
873
|
+
f & 0x0F,
|
|
874
|
+
(f >> 4) & 0x0F,
|
|
875
|
+
(f >> 8) & 0x0F,
|
|
876
|
+
]
|
|
877
|
+
parity_even = nibbles[0] ^ nibbles[2] ^ nibbles[4] ^ nibbles[6] ^ nibbles[8]
|
|
878
|
+
parity_odd = nibbles[1] ^ nibbles[3] ^ nibbles[5] ^ nibbles[7] ^ nibbles[9]
|
|
879
|
+
return (((parity_odd << 4) | parity_even) ^ 0x66) & 0xFF
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def build_denonk_ir_blob(
|
|
883
|
+
*,
|
|
884
|
+
carrier_hz: int = 37000,
|
|
885
|
+
c0: int = 84,
|
|
886
|
+
c1: int = 50,
|
|
887
|
+
c2: int = 0,
|
|
888
|
+
device: int,
|
|
889
|
+
subdevice: int,
|
|
890
|
+
function: int,
|
|
891
|
+
) -> bytes:
|
|
892
|
+
"""Build a canonical ``P:DenonK`` Sofabaton descriptor blob body.
|
|
893
|
+
|
|
894
|
+
This synthesizes the human-readable one-frame descriptor family observed in
|
|
895
|
+
``dump_ir_blob`` responses. The returned bytes are the canonical replay
|
|
896
|
+
body expected by ``play_ir_blob``: no outer ``a5 5a`` frame header and no
|
|
897
|
+
final replay-tail checksum byte.
|
|
898
|
+
"""
|
|
899
|
+
|
|
900
|
+
if carrier_hz <= 0:
|
|
901
|
+
raise ValueError("carrier_hz must be positive")
|
|
902
|
+
|
|
903
|
+
embedded_checksum = denonk_checksum(c0, c1, c2, device, subdevice, function)
|
|
904
|
+
descriptor = (
|
|
905
|
+
f"P:DenonK "
|
|
906
|
+
f"R:{carrier_hz} "
|
|
907
|
+
f"C0:{c0} C1:{c1} C2:{c2} "
|
|
908
|
+
f"D:{device} S:{subdevice} F:{function} "
|
|
909
|
+
f"CHECKSUM:{embedded_checksum}"
|
|
910
|
+
)
|
|
911
|
+
return build_descriptive_ir_blob_body(descriptor)
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _page_one_uses_ascii_label_layout(payload: bytes) -> bool:
|
|
915
|
+
"""Return True when page 1 uses the compact X1 ASCII label slot."""
|
|
916
|
+
|
|
917
|
+
if len(payload) <= _IR_DUMP_LABEL_START:
|
|
918
|
+
return False
|
|
919
|
+
return payload[_IR_DUMP_LABEL_START] != 0
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _ir_dump_page_one_blob_start(payload: bytes) -> int:
|
|
923
|
+
"""Return the fixed page-1 blob start for the observed hub layout."""
|
|
924
|
+
|
|
925
|
+
if _page_one_uses_ascii_label_layout(payload):
|
|
926
|
+
return _IR_DUMP_PAGE_ONE_BLOB_START_X1
|
|
927
|
+
|
|
928
|
+
for prefix in _IR_DUMP_PAGE_ONE_BLOB_PREFIXES:
|
|
929
|
+
idx = payload.find(prefix, _IR_DUMP_LABEL_START)
|
|
930
|
+
if idx != -1:
|
|
931
|
+
return idx
|
|
932
|
+
|
|
933
|
+
return _IR_DUMP_PAGE_ONE_BLOB_START_X1S
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def extract_ir_dump_blob(payload: bytes, page_no: int) -> bytes | None:
|
|
937
|
+
"""Return the IR-specific blob portion of an 0x020C dump page payload."""
|
|
938
|
+
|
|
939
|
+
if page_no == 1:
|
|
940
|
+
blob_start = _ir_dump_page_one_blob_start(payload)
|
|
941
|
+
if len(payload) <= blob_start:
|
|
942
|
+
return None
|
|
943
|
+
return payload[blob_start:]
|
|
944
|
+
|
|
945
|
+
if page_no >= 2:
|
|
946
|
+
return payload[3:] if len(payload) > 3 else b""
|
|
947
|
+
|
|
948
|
+
return None
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def extract_ir_dump_label_field(payload: bytes) -> bytes | None:
|
|
952
|
+
"""Return the 2-byte metadata field immediately before the page-1 label."""
|
|
953
|
+
|
|
954
|
+
return payload[13:15] if len(payload) >= 15 else None
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def _extract_ir_dump_label(payload: bytes) -> str | None:
|
|
958
|
+
if len(payload) <= _IR_DUMP_LABEL_START:
|
|
959
|
+
return None
|
|
960
|
+
|
|
961
|
+
# Page-1 dump records are structured, not heuristic:
|
|
962
|
+
# - bytes 13..14 are a 2-byte metadata field
|
|
963
|
+
# - byte 15 onward is a fixed-width label slot
|
|
964
|
+
# - X1 uses an ASCII slot ending at byte 43
|
|
965
|
+
# - X1S/X2 use a UTF-16BE slot ending at byte 73
|
|
966
|
+
blob_start = _ir_dump_page_one_blob_start(payload)
|
|
967
|
+
if len(payload) <= blob_start:
|
|
968
|
+
return None
|
|
969
|
+
|
|
970
|
+
label_bytes = payload[_IR_DUMP_LABEL_START:blob_start]
|
|
971
|
+
|
|
972
|
+
if _page_one_uses_ascii_label_layout(payload):
|
|
973
|
+
label_bytes = label_bytes.split(b"\x00", 1)[0].rstrip(b"\x00")
|
|
974
|
+
if not label_bytes:
|
|
975
|
+
return None
|
|
976
|
+
try:
|
|
977
|
+
candidate = label_bytes.decode("latin-1").strip()
|
|
978
|
+
except UnicodeDecodeError:
|
|
979
|
+
return None
|
|
980
|
+
return candidate if _looks_reasonable_ir_dump_label(candidate) else None
|
|
981
|
+
|
|
982
|
+
label_bytes = label_bytes.rstrip(b"\x00")
|
|
983
|
+
if not label_bytes or len(label_bytes) % 2:
|
|
984
|
+
return None
|
|
985
|
+
|
|
986
|
+
try:
|
|
987
|
+
candidate = label_bytes.decode("utf-16-be").strip()
|
|
988
|
+
except UnicodeDecodeError:
|
|
989
|
+
return None
|
|
990
|
+
|
|
991
|
+
return candidate if _looks_reasonable_ir_dump_label(candidate) else None
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def parse_ir_command_dump_frame(opcode: int, raw_frame: bytes) -> IrCommandDumpFrame | None:
|
|
995
|
+
"""Parse a raw IR blob page from the 0x020C backup/restore family."""
|
|
996
|
+
|
|
997
|
+
if len(raw_frame) < 7 or not _looks_like_ir_dump_opcode(opcode):
|
|
998
|
+
return None
|
|
999
|
+
|
|
1000
|
+
payload = raw_frame[4:-1]
|
|
1001
|
+
if len(payload) < 4:
|
|
1002
|
+
return None
|
|
1003
|
+
|
|
1004
|
+
response_index = payload[0]
|
|
1005
|
+
page_no = payload[2]
|
|
1006
|
+
if response_index == 0 or page_no == 0:
|
|
1007
|
+
return None
|
|
1008
|
+
|
|
1009
|
+
command_id = response_index
|
|
1010
|
+
device_id: int | None = None
|
|
1011
|
+
total_commands: int | None = None
|
|
1012
|
+
total_pages: int | None = None
|
|
1013
|
+
format_marker: int | None = None
|
|
1014
|
+
label: str | None = None
|
|
1015
|
+
|
|
1016
|
+
if page_no == 1 and len(payload) >= 9:
|
|
1017
|
+
command_id = payload[7]
|
|
1018
|
+
if command_id == 0:
|
|
1019
|
+
return None
|
|
1020
|
+
device_id = payload[6]
|
|
1021
|
+
total_commands = payload[3] if payload[3] else None
|
|
1022
|
+
total_pages = payload[5] if payload[5] else None
|
|
1023
|
+
format_marker = payload[8]
|
|
1024
|
+
label = _extract_ir_dump_label(payload)
|
|
1025
|
+
|
|
1026
|
+
return IrCommandDumpFrame(
|
|
1027
|
+
opcode=opcode,
|
|
1028
|
+
family=opcode_family(opcode),
|
|
1029
|
+
response_index=response_index,
|
|
1030
|
+
command_id=command_id,
|
|
1031
|
+
page_no=page_no,
|
|
1032
|
+
device_id=device_id,
|
|
1033
|
+
total_commands=total_commands,
|
|
1034
|
+
total_pages=total_pages,
|
|
1035
|
+
format_marker=format_marker,
|
|
1036
|
+
label=label,
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
class DeviceCommandAssembler:
|
|
1041
|
+
"""Reassembles multi-frame device-command bursts by device ID."""
|
|
1042
|
+
|
|
1043
|
+
def __init__(self) -> None:
|
|
1044
|
+
self._buffers: Dict[int, _CommandBurst] = {}
|
|
1045
|
+
|
|
1046
|
+
def _get_buffer(self, dev_id: int) -> _CommandBurst:
|
|
1047
|
+
if dev_id not in self._buffers:
|
|
1048
|
+
self._buffers[dev_id] = _CommandBurst()
|
|
1049
|
+
return self._buffers[dev_id]
|
|
1050
|
+
|
|
1051
|
+
def feed(
|
|
1052
|
+
self,
|
|
1053
|
+
opcode: int,
|
|
1054
|
+
raw_frame: bytes,
|
|
1055
|
+
*,
|
|
1056
|
+
dev_id_override: int | None = None,
|
|
1057
|
+
hub_version: str,
|
|
1058
|
+
) -> List[Tuple[int, bytes]]:
|
|
1059
|
+
"""Feed a raw frame and return completed payloads when available."""
|
|
1060
|
+
|
|
1061
|
+
if len(raw_frame) < 7:
|
|
1062
|
+
return []
|
|
1063
|
+
|
|
1064
|
+
payload = raw_frame[4:-1]
|
|
1065
|
+
if len(payload) < 4:
|
|
1066
|
+
return []
|
|
1067
|
+
|
|
1068
|
+
parsed = parse_command_burst_frame(opcode, raw_frame, hub_version=hub_version)
|
|
1069
|
+
if parsed is None:
|
|
1070
|
+
return []
|
|
1071
|
+
|
|
1072
|
+
dev_id = dev_id_override if dev_id_override is not None else parsed.device_id
|
|
1073
|
+
frame_no = parsed.frame_no
|
|
1074
|
+
burst = self._get_buffer(dev_id)
|
|
1075
|
+
|
|
1076
|
+
is_single_cmd = parsed.is_single
|
|
1077
|
+
if parsed.is_header:
|
|
1078
|
+
burst.variant = parsed.layout_kind
|
|
1079
|
+
burst.total_frames = parsed.total_frames
|
|
1080
|
+
burst.total_commands = parsed.total_commands
|
|
1081
|
+
burst.frames.clear()
|
|
1082
|
+
elif parsed.total_frames is not None and burst.total_frames is None:
|
|
1083
|
+
burst.total_frames = parsed.total_frames
|
|
1084
|
+
burst.total_commands = parsed.total_commands
|
|
1085
|
+
|
|
1086
|
+
if parsed.is_single:
|
|
1087
|
+
burst.frames.clear()
|
|
1088
|
+
burst.variant = parsed.layout_kind
|
|
1089
|
+
burst.total_frames = 1
|
|
1090
|
+
|
|
1091
|
+
frame_payload = payload[parsed.data_start :] if len(payload) > parsed.data_start else b""
|
|
1092
|
+
burst.frames[frame_no] = frame_payload
|
|
1093
|
+
|
|
1094
|
+
completed: List[Tuple[int, bytes]] = []
|
|
1095
|
+
if burst.frames:
|
|
1096
|
+
max_frame_no = max(burst.frames)
|
|
1097
|
+
frames_are_contiguous = len(burst.frames) == max_frame_no and 1 in burst.frames
|
|
1098
|
+
else:
|
|
1099
|
+
max_frame_no = 0
|
|
1100
|
+
frames_are_contiguous = False
|
|
1101
|
+
|
|
1102
|
+
if is_single_cmd:
|
|
1103
|
+
ordered_payload = b"".join(burst.frames[i] for i in sorted(burst.frames))
|
|
1104
|
+
completed.append((dev_id, ordered_payload))
|
|
1105
|
+
del self._buffers[dev_id]
|
|
1106
|
+
elif burst.total_frames and burst.received >= burst.total_frames:
|
|
1107
|
+
ordered_payload = b"".join(burst.frames[i] for i in sorted(burst.frames))
|
|
1108
|
+
completed.append((dev_id, ordered_payload))
|
|
1109
|
+
del self._buffers[dev_id]
|
|
1110
|
+
|
|
1111
|
+
return completed
|
|
1112
|
+
|
|
1113
|
+
def finalize_contiguous(self, dev_id: int | None = None) -> List[Tuple[int, bytes]]:
|
|
1114
|
+
"""Flush buffered bursts whose frames are contiguous starting at 1.
|
|
1115
|
+
|
|
1116
|
+
Some hubs over-report the total frame count and never send a tail. In
|
|
1117
|
+
those cases, complete bursts manually once all contiguous frames have
|
|
1118
|
+
arrived.
|
|
1119
|
+
"""
|
|
1120
|
+
|
|
1121
|
+
targets = [dev_id] if dev_id is not None else list(self._buffers)
|
|
1122
|
+
completed: List[Tuple[int, bytes]] = []
|
|
1123
|
+
|
|
1124
|
+
for target in targets:
|
|
1125
|
+
burst = self._buffers.get(target)
|
|
1126
|
+
if not burst or not burst.frames:
|
|
1127
|
+
continue
|
|
1128
|
+
|
|
1129
|
+
max_frame = max(burst.frames)
|
|
1130
|
+
if len(burst.frames) != max_frame or 1 not in burst.frames:
|
|
1131
|
+
continue
|
|
1132
|
+
|
|
1133
|
+
ordered_payload = b"".join(burst.frames[i] for i in sorted(burst.frames))
|
|
1134
|
+
completed.append((target, ordered_payload))
|
|
1135
|
+
del self._buffers[target]
|
|
1136
|
+
|
|
1137
|
+
return completed
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
class DeviceButtonAssembler:
|
|
1141
|
+
"""Reassembles multi-frame button/keymap bursts by activity ID."""
|
|
1142
|
+
|
|
1143
|
+
def __init__(self) -> None:
|
|
1144
|
+
self._buffers: Dict[int, _ButtonBurst] = {}
|
|
1145
|
+
|
|
1146
|
+
def _get_buffer(self, activity_id: int) -> _ButtonBurst:
|
|
1147
|
+
if activity_id not in self._buffers:
|
|
1148
|
+
self._buffers[activity_id] = _ButtonBurst()
|
|
1149
|
+
return self._buffers[activity_id]
|
|
1150
|
+
|
|
1151
|
+
def feed(
|
|
1152
|
+
self,
|
|
1153
|
+
opcode: int,
|
|
1154
|
+
raw_frame: bytes,
|
|
1155
|
+
*,
|
|
1156
|
+
activity_id_override: int | None = None,
|
|
1157
|
+
hub_version: str,
|
|
1158
|
+
) -> List[Tuple[int, bytes, int | None]]:
|
|
1159
|
+
"""Feed a raw keymap frame and return completed row streams when available."""
|
|
1160
|
+
|
|
1161
|
+
if len(raw_frame) < 7:
|
|
1162
|
+
return []
|
|
1163
|
+
|
|
1164
|
+
payload = raw_frame[4:-1]
|
|
1165
|
+
parsed = parse_button_burst_frame(opcode, raw_frame, hub_version=hub_version)
|
|
1166
|
+
if parsed is None:
|
|
1167
|
+
return []
|
|
1168
|
+
|
|
1169
|
+
activity_id = (
|
|
1170
|
+
activity_id_override
|
|
1171
|
+
if activity_id_override is not None
|
|
1172
|
+
else parsed.activity_id
|
|
1173
|
+
)
|
|
1174
|
+
if activity_id is None:
|
|
1175
|
+
return []
|
|
1176
|
+
|
|
1177
|
+
burst = self._get_buffer(activity_id)
|
|
1178
|
+
|
|
1179
|
+
if parsed.is_header:
|
|
1180
|
+
burst.variant = parsed.layout_kind
|
|
1181
|
+
burst.total_frames = parsed.total_frames
|
|
1182
|
+
burst.total_rows = parsed.total_rows
|
|
1183
|
+
burst.frames.clear()
|
|
1184
|
+
else:
|
|
1185
|
+
if parsed.total_frames is not None and burst.total_frames is None:
|
|
1186
|
+
burst.total_frames = parsed.total_frames
|
|
1187
|
+
if parsed.total_rows is not None and burst.total_rows is None:
|
|
1188
|
+
burst.total_rows = parsed.total_rows
|
|
1189
|
+
if burst.total_frames is None and parsed.is_final:
|
|
1190
|
+
burst.total_frames = parsed.frame_no
|
|
1191
|
+
|
|
1192
|
+
frame_payload = (
|
|
1193
|
+
payload[parsed.data_start:] if parsed.has_row_data and len(payload) > parsed.data_start else b""
|
|
1194
|
+
)
|
|
1195
|
+
burst.frames[parsed.frame_no] = frame_payload
|
|
1196
|
+
|
|
1197
|
+
completed: List[Tuple[int, bytes, int | None]] = []
|
|
1198
|
+
if burst.total_frames and burst.received >= burst.total_frames:
|
|
1199
|
+
ordered_payload = b"".join(burst.frames[i] for i in sorted(burst.frames))
|
|
1200
|
+
completed.append((activity_id, ordered_payload, burst.total_rows))
|
|
1201
|
+
del self._buffers[activity_id]
|
|
1202
|
+
|
|
1203
|
+
return completed
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
# ---------------------------------------------------------------------------
|
|
1207
|
+
# Phase 9 -- per-family parse dispatcher
|
|
1208
|
+
#
|
|
1209
|
+
# Replaces the previous "import every burst parser at the call site" pattern
|
|
1210
|
+
# in :mod:`lib.opcode_handlers` with a single entry point that picks the
|
|
1211
|
+
# right metadata parser from the opcode's family code. Adding a new family
|
|
1212
|
+
# means adding one branch here; opcode_handlers stays family-agnostic.
|
|
1213
|
+
# ---------------------------------------------------------------------------
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def decode_burst_frame(
|
|
1217
|
+
opcode: int,
|
|
1218
|
+
raw_frame: bytes,
|
|
1219
|
+
*,
|
|
1220
|
+
hub_version: str,
|
|
1221
|
+
) -> ButtonBurstFrame | CommandBurstFrame | None:
|
|
1222
|
+
"""Dispatch to the per-family burst-frame metadata parser.
|
|
1223
|
+
|
|
1224
|
+
The integration receives multi-page bursts for keymaps (family
|
|
1225
|
+
``0x3D``), per-device command records (family ``0x5D``) and a
|
|
1226
|
+
handful of marker / single-frame variants in adjacent families.
|
|
1227
|
+
This dispatcher routes each incoming frame to the parser that owns
|
|
1228
|
+
its family, returning the parser's structured frame record (or
|
|
1229
|
+
``None`` when the opcode does not belong to a known burst family).
|
|
1230
|
+
|
|
1231
|
+
Callers should pass the proxy's ``hub_version`` -- the parsers use
|
|
1232
|
+
it to decide stride and label encoding, and reject unknown values
|
|
1233
|
+
via :func:`schema_for`.
|
|
1234
|
+
"""
|
|
1235
|
+
|
|
1236
|
+
if _is_keymap_family(opcode):
|
|
1237
|
+
return parse_button_burst_frame(opcode, raw_frame, hub_version=hub_version)
|
|
1238
|
+
if (
|
|
1239
|
+
_is_devbtn_family(opcode)
|
|
1240
|
+
or opcode_family(opcode) == 0x0D
|
|
1241
|
+
or opcode == OP_DEVBTN_SINGLE
|
|
1242
|
+
):
|
|
1243
|
+
return parse_command_burst_frame(opcode, raw_frame, hub_version=hub_version)
|
|
1244
|
+
return None
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
__all__ = [
|
|
1248
|
+
"ButtonBurstFrame",
|
|
1249
|
+
"CommandRecord",
|
|
1250
|
+
"CommandBurstFrame",
|
|
1251
|
+
"DeviceButtonAssembler",
|
|
1252
|
+
"DeviceCommandAssembler",
|
|
1253
|
+
"IrCommandDumpFrame",
|
|
1254
|
+
"KEYMAP_RECORD_SIZE",
|
|
1255
|
+
"KeymapRecord",
|
|
1256
|
+
"build_descriptive_ir_blob_body",
|
|
1257
|
+
"build_denonk_ir_blob",
|
|
1258
|
+
"descriptive_play_blob_text",
|
|
1259
|
+
"denonk_checksum",
|
|
1260
|
+
"decode_burst_frame",
|
|
1261
|
+
"COMMAND_RECORD_LABEL_LEN_X1",
|
|
1262
|
+
"COMMAND_RECORD_LABEL_LEN_X1S_X2",
|
|
1263
|
+
"COMMAND_RECORD_LABEL_OFFSET",
|
|
1264
|
+
"COMMAND_RECORD_STRIDE_X1",
|
|
1265
|
+
"COMMAND_RECORD_STRIDE_X1S_X2",
|
|
1266
|
+
"iter_command_records_from_assembled",
|
|
1267
|
+
"iter_keymap_records",
|
|
1268
|
+
"looks_like_descriptive_play_blob",
|
|
1269
|
+
"parse_ir_command_dump_frame",
|
|
1270
|
+
"parse_button_burst_frame",
|
|
1271
|
+
"parse_command_burst_frame",
|
|
1272
|
+
"split_play_blob_tail",
|
|
1273
|
+
]
|