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/macros.py
ADDED
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Dict, List, Tuple
|
|
5
|
+
|
|
6
|
+
from .hub_versions import HUB_VERSION_X1, HUB_VERSION_X1S, HUB_VERSION_X2
|
|
7
|
+
from .protocol_const import FAMILY_MACROS, opcode_family, opcode_hi
|
|
8
|
+
from .wire_schema import schema_for
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class _MacroBurst:
|
|
13
|
+
total_frames: int | None = None
|
|
14
|
+
expected_records: int | None = None
|
|
15
|
+
record_starts_seen: int = 0
|
|
16
|
+
frames: Dict[int, bytes] = field(default_factory=dict)
|
|
17
|
+
activity_id: int | None = None
|
|
18
|
+
record_start_frames: set = field(default_factory=set)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class MacroBurstFrame:
|
|
23
|
+
opcode: int
|
|
24
|
+
role: str
|
|
25
|
+
fragment_index: int | None
|
|
26
|
+
total_fragments: int | None
|
|
27
|
+
activity_id: int | None
|
|
28
|
+
start_command_id: int | None
|
|
29
|
+
data_start: int
|
|
30
|
+
payload_length_matches_hi: bool
|
|
31
|
+
#: Number of frames this individual record spans (1 for the common
|
|
32
|
+
#: single-frame case, 2+ when the record's body is too large to
|
|
33
|
+
#: fit in one frame). Distinct from ``total_fragments`` which is
|
|
34
|
+
#: the total *records* in the surrounding burst.
|
|
35
|
+
record_pages: int | None = None
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def is_record_start(self) -> bool:
|
|
39
|
+
return self.role == "record_start"
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def display_name(self) -> str:
|
|
43
|
+
return "REQ_MACRO_LABELS_PAGE" if self.is_record_start else "REQ_MACRO_LABELS_CONT"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_macro_burst_frame(opcode: int, raw_frame: bytes) -> MacroBurstFrame | None:
|
|
47
|
+
"""Return parsed family metadata for a macro frame.
|
|
48
|
+
|
|
49
|
+
Macro pages are intended to assemble into one deterministic buffer rather
|
|
50
|
+
than being interpreted record-by-record at the frame level:
|
|
51
|
+
|
|
52
|
+
concat[3] = deviceID
|
|
53
|
+
concat[4] = keyID
|
|
54
|
+
concat[5] = N (count of 10-byte key entries)
|
|
55
|
+
concat[6 .. 6 + N*10] = N x 10-byte key entries
|
|
56
|
+
[deviceID, keyID, fid_byte*6,
|
|
57
|
+
duration (signed; -1 means delay-only),
|
|
58
|
+
delay]
|
|
59
|
+
If entry[1] == 0xFF, this is a
|
|
60
|
+
no-op / delay-only entry (type=0).
|
|
61
|
+
concat[length-31 .. length-1] = label, ASCII (X1)
|
|
62
|
+
concat[length-61 .. length-1] = label, UTF-16BE (X1S/X2)
|
|
63
|
+
|
|
64
|
+
Encoding selection is hub-model based, not byte-pattern heuristic.
|
|
65
|
+
UTF-16BE labels have no 0x0000 terminator and no length prefix; the full
|
|
66
|
+
fixed-width label slot is decoded and trimmed. ``0xFF`` mid-label is
|
|
67
|
+
legitimate data, never a delimiter.
|
|
68
|
+
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
if len(raw_frame) < 7 or opcode_family(opcode) != FAMILY_MACROS:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
payload = raw_frame[4:-1]
|
|
75
|
+
if not payload:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
payload_len_matches_hi = opcode_hi(opcode) == len(payload)
|
|
79
|
+
if len(payload) < 7:
|
|
80
|
+
return MacroBurstFrame(
|
|
81
|
+
opcode=opcode,
|
|
82
|
+
role="continuation",
|
|
83
|
+
fragment_index=None,
|
|
84
|
+
total_fragments=None,
|
|
85
|
+
activity_id=None,
|
|
86
|
+
start_command_id=None,
|
|
87
|
+
data_start=len(payload),
|
|
88
|
+
payload_length_matches_hi=payload_len_matches_hi,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
p0, _, x, p3, _, y, a = payload[:7]
|
|
92
|
+
if x == 0x01 and y in (0x01, 0x02) and a != 0x00:
|
|
93
|
+
# Two independent counts live in the record-start preamble:
|
|
94
|
+
# - payload[3] is the number of records in the surrounding burst
|
|
95
|
+
# (1 for a single-record fetch, N when the hub returns N macros
|
|
96
|
+
# at once).
|
|
97
|
+
# - payload[4..5] big-endian is the number of frames this
|
|
98
|
+
# individual record spans. Records whose body is too large to
|
|
99
|
+
# fit in one frame split across multiple frames; the trailing
|
|
100
|
+
# frames arrive as continuations with no preamble of their own.
|
|
101
|
+
total_fragments = p3 or None
|
|
102
|
+
if total_fragments is not None and not (1 <= total_fragments <= 64):
|
|
103
|
+
total_fragments = None
|
|
104
|
+
record_pages = int.from_bytes(payload[4:6], "big") or None
|
|
105
|
+
if record_pages is not None and not (1 <= record_pages <= 64):
|
|
106
|
+
record_pages = None
|
|
107
|
+
return MacroBurstFrame(
|
|
108
|
+
opcode=opcode,
|
|
109
|
+
role="record_start",
|
|
110
|
+
fragment_index=p0 or 1,
|
|
111
|
+
total_fragments=total_fragments,
|
|
112
|
+
activity_id=a,
|
|
113
|
+
start_command_id=payload[7] if len(payload) > 7 else None,
|
|
114
|
+
data_start=7,
|
|
115
|
+
payload_length_matches_hi=payload_len_matches_hi,
|
|
116
|
+
record_pages=record_pages,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return MacroBurstFrame(
|
|
120
|
+
opcode=opcode,
|
|
121
|
+
role="continuation",
|
|
122
|
+
fragment_index=None,
|
|
123
|
+
total_fragments=None,
|
|
124
|
+
activity_id=None,
|
|
125
|
+
start_command_id=None,
|
|
126
|
+
data_start=7 if len(payload) > 7 else len(payload),
|
|
127
|
+
payload_length_matches_hi=payload_len_matches_hi,
|
|
128
|
+
record_pages=None,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class MacroAssembler:
|
|
133
|
+
"""Reassemble multi-frame macro payloads returned by the hub."""
|
|
134
|
+
|
|
135
|
+
def __init__(self) -> None:
|
|
136
|
+
self._buffers: Dict[int, _MacroBurst] = {}
|
|
137
|
+
self._last_activity_id: int | None = None
|
|
138
|
+
|
|
139
|
+
def _get_buffer(self, activity_id: int) -> _MacroBurst:
|
|
140
|
+
buf = self._buffers.get(activity_id)
|
|
141
|
+
if buf is None:
|
|
142
|
+
buf = _MacroBurst(activity_id=activity_id)
|
|
143
|
+
self._buffers[activity_id] = buf
|
|
144
|
+
return buf
|
|
145
|
+
|
|
146
|
+
def _parse_header_from_payload(
|
|
147
|
+
self, payload: bytes, *, opcode_hi: int | None = None
|
|
148
|
+
) -> tuple[int | None, int | None, int | None, int | None, bytes, bool]:
|
|
149
|
+
"""Return (activity_id, frame_no, expected_records, record_pages, body, is_record_start).
|
|
150
|
+
|
|
151
|
+
Record-start frames strip 7 bytes (``payload[7:]``). Continuation
|
|
152
|
+
frames strip only 3 bytes (``payload[3:]``). The additional 4 bytes on
|
|
153
|
+
continuation pages belong to the assembled macro data and are commonly
|
|
154
|
+
absorbed into trailing padding; dropping them shifts later offsets and
|
|
155
|
+
misaligns the label slot on multi-page macros.
|
|
156
|
+
|
|
157
|
+
``expected_records`` is the burst-wide record count carried in
|
|
158
|
+
``payload[3]`` (how many distinct macros the hub is sending in this
|
|
159
|
+
burst). ``record_pages`` is the per-record frame count carried in
|
|
160
|
+
``payload[4..5]`` big-endian (how many frames this individual macro
|
|
161
|
+
spans). The assembler needs both to know when the burst is complete
|
|
162
|
+
even when one of the records is large enough to require continuation
|
|
163
|
+
frames of its own.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
if len(payload) < 7:
|
|
167
|
+
return self._last_activity_id, 1, None, None, payload, False
|
|
168
|
+
|
|
169
|
+
p0, _, x, p3, _, y, a = payload[:7]
|
|
170
|
+
|
|
171
|
+
activity_id: int | None
|
|
172
|
+
frame_no: int | None
|
|
173
|
+
expected_records: int | None
|
|
174
|
+
record_pages: int | None
|
|
175
|
+
|
|
176
|
+
# End the body at opcode_hi (the invariant-declared payload length)
|
|
177
|
+
# when known, so 1-byte transcription drift in synthetic fixtures
|
|
178
|
+
# doesn't shift the schema parser's offsets. Falls back to the full
|
|
179
|
+
# payload when opcode_hi isn't supplied.
|
|
180
|
+
body_end = opcode_hi if opcode_hi is not None else len(payload)
|
|
181
|
+
|
|
182
|
+
if x == 0x01 and y in (0x01, 0x02) and a != 0x00:
|
|
183
|
+
activity_id = a
|
|
184
|
+
frame_no = p0 or 1
|
|
185
|
+
expected_records = p3 or None
|
|
186
|
+
if expected_records is not None and not (1 <= expected_records <= 64):
|
|
187
|
+
expected_records = None
|
|
188
|
+
record_pages = int.from_bytes(payload[4:6], "big") or None
|
|
189
|
+
if record_pages is not None and not (1 <= record_pages <= 64):
|
|
190
|
+
record_pages = None
|
|
191
|
+
body = payload[7:body_end]
|
|
192
|
+
is_record_start = True
|
|
193
|
+
else:
|
|
194
|
+
activity_id = self._last_activity_id
|
|
195
|
+
frame_no = None
|
|
196
|
+
expected_records = None
|
|
197
|
+
record_pages = None
|
|
198
|
+
body = payload[3:body_end]
|
|
199
|
+
is_record_start = False
|
|
200
|
+
|
|
201
|
+
return activity_id, frame_no, expected_records, record_pages, body, is_record_start
|
|
202
|
+
|
|
203
|
+
def _process_fragment(
|
|
204
|
+
self,
|
|
205
|
+
*,
|
|
206
|
+
activity_id: int,
|
|
207
|
+
frame_no: int | None,
|
|
208
|
+
expected_records: int | None,
|
|
209
|
+
record_pages: int | None,
|
|
210
|
+
body: bytes,
|
|
211
|
+
is_record_start: bool,
|
|
212
|
+
) -> List[Tuple[int, bytes, List[int]]]:
|
|
213
|
+
burst = self._get_buffer(activity_id)
|
|
214
|
+
self._last_activity_id = activity_id
|
|
215
|
+
|
|
216
|
+
# Continuation frames belong to the most recent record but are tracked
|
|
217
|
+
# as their own frame entry so the assembler can count them against the
|
|
218
|
+
# expected per-record page total.
|
|
219
|
+
if frame_no is None:
|
|
220
|
+
frame_no = (max(burst.frames) + 1) if burst.frames else 1
|
|
221
|
+
|
|
222
|
+
while frame_no in burst.frames and body:
|
|
223
|
+
frame_no += 1
|
|
224
|
+
|
|
225
|
+
if body:
|
|
226
|
+
burst.frames[frame_no] = body
|
|
227
|
+
|
|
228
|
+
if is_record_start:
|
|
229
|
+
burst.record_start_frames.add(frame_no)
|
|
230
|
+
burst.record_starts_seen += 1
|
|
231
|
+
if record_pages is not None:
|
|
232
|
+
burst.total_frames = (burst.total_frames or 0) + record_pages
|
|
233
|
+
elif burst.total_frames is None:
|
|
234
|
+
burst.total_frames = burst.record_starts_seen
|
|
235
|
+
if burst.expected_records is None and expected_records is not None:
|
|
236
|
+
burst.expected_records = expected_records
|
|
237
|
+
|
|
238
|
+
finished = False
|
|
239
|
+
if burst.expected_records is not None and burst.total_frames is not None:
|
|
240
|
+
if (
|
|
241
|
+
burst.record_starts_seen >= burst.expected_records
|
|
242
|
+
and len(burst.frames) >= burst.total_frames
|
|
243
|
+
):
|
|
244
|
+
finished = True
|
|
245
|
+
elif burst.total_frames and len(burst.frames) >= burst.total_frames:
|
|
246
|
+
finished = True
|
|
247
|
+
elif burst.total_frames is None and len(burst.frames) >= 1:
|
|
248
|
+
# Unknown structure: accept after a single frame for the
|
|
249
|
+
# legacy single-frame, single-record case.
|
|
250
|
+
finished = True
|
|
251
|
+
|
|
252
|
+
if not finished:
|
|
253
|
+
return []
|
|
254
|
+
|
|
255
|
+
sorted_frames = sorted(burst.frames)
|
|
256
|
+
ordered = b"".join(burst.frames[i] for i in sorted_frames)
|
|
257
|
+
|
|
258
|
+
record_boundaries: List[int] = []
|
|
259
|
+
offset = 0
|
|
260
|
+
for frame_no_sorted in sorted_frames:
|
|
261
|
+
if frame_no_sorted in burst.record_start_frames:
|
|
262
|
+
record_boundaries.append(offset)
|
|
263
|
+
offset += len(burst.frames[frame_no_sorted])
|
|
264
|
+
|
|
265
|
+
del self._buffers[activity_id]
|
|
266
|
+
return [(activity_id, ordered, record_boundaries)]
|
|
267
|
+
|
|
268
|
+
def feed(self, opcode: int, payload: bytes, raw: bytes | None = None) -> List[Tuple[int, bytes, List[int]]]:
|
|
269
|
+
"""Feed a macro-family payload and return completed assemblies."""
|
|
270
|
+
|
|
271
|
+
if not payload and not raw:
|
|
272
|
+
return []
|
|
273
|
+
|
|
274
|
+
payload_bytes = payload
|
|
275
|
+
if raw and len(raw) >= 6 and raw[0] == 0xA5 and raw[1] == 0x5A:
|
|
276
|
+
payload_bytes = raw[4:-1]
|
|
277
|
+
|
|
278
|
+
opcode_hi = (opcode >> 8) & 0xFF
|
|
279
|
+
(
|
|
280
|
+
activity_id,
|
|
281
|
+
frame_no,
|
|
282
|
+
expected_records,
|
|
283
|
+
record_pages,
|
|
284
|
+
body,
|
|
285
|
+
is_record_start,
|
|
286
|
+
) = self._parse_header_from_payload(payload_bytes, opcode_hi=opcode_hi)
|
|
287
|
+
|
|
288
|
+
if activity_id is None:
|
|
289
|
+
return []
|
|
290
|
+
|
|
291
|
+
return self._process_fragment(
|
|
292
|
+
activity_id=activity_id,
|
|
293
|
+
frame_no=frame_no,
|
|
294
|
+
expected_records=expected_records,
|
|
295
|
+
record_pages=record_pages,
|
|
296
|
+
body=body,
|
|
297
|
+
is_record_start=is_record_start,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ---------------------------------------------------------------------------
|
|
302
|
+
# Pure-function REQ_MACROS record parser.
|
|
303
|
+
#
|
|
304
|
+
# Each completed page-cycle yields one macro region. The integration's
|
|
305
|
+
# MacroAssembler delivers a post-assembly byte sequence that begins after the
|
|
306
|
+
# page-1 preamble plus the device/activity byte. Concretely:
|
|
307
|
+
#
|
|
308
|
+
# region[0] = key_id (which activity button this macro
|
|
309
|
+
# is bound to; called command_id in
|
|
310
|
+
# the integration's legacy decoder)
|
|
311
|
+
# region[1] = N (count of inner key entries)
|
|
312
|
+
# region[2 + i*10 : 2 + (i+1)*10] = i-th key entry (10 bytes)
|
|
313
|
+
# region[-30:] = label slot, X1 ASCII
|
|
314
|
+
# region[-60:] = label slot, X1S/X2 UTF-16BE
|
|
315
|
+
#
|
|
316
|
+
# Key entry (10 bytes):
|
|
317
|
+
# [0] deviceID
|
|
318
|
+
# [1] keyID (0xFF means delay-only / no-op entry)
|
|
319
|
+
# [2..7] fid (6-byte BE int)
|
|
320
|
+
# [8] duration / inputSign
|
|
321
|
+
# [9] delay (ms before next entry)
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
#: Size in bytes of one key entry inside a macro region.
|
|
326
|
+
MACRO_KEY_ENTRY_SIZE = 10
|
|
327
|
+
|
|
328
|
+
#: Byte offset within a region where per-key macro entries begin.
|
|
329
|
+
MACRO_KEY_ENTRY_START = 2
|
|
330
|
+
|
|
331
|
+
#: Length of the trailing label slot for X1 hubs (ASCII). Mirrored
|
|
332
|
+
#: from :mod:`wire_schema` for backwards-compatible imports.
|
|
333
|
+
MACRO_LABEL_LEN_X1 = schema_for(HUB_VERSION_X1).macro_label_slot_len
|
|
334
|
+
|
|
335
|
+
#: Length of the trailing label slot for X1S/X2 hubs (UTF-16BE).
|
|
336
|
+
MACRO_LABEL_LEN_X1S_X2 = schema_for(HUB_VERSION_X1S).macro_label_slot_len
|
|
337
|
+
|
|
338
|
+
assert MACRO_LABEL_LEN_X1 == 30 and MACRO_LABEL_LEN_X1S_X2 == 60
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@dataclass(slots=True, frozen=True)
|
|
342
|
+
class MacroKeyEntry:
|
|
343
|
+
"""One 10-byte key entry from a macro's inner key sequence."""
|
|
344
|
+
|
|
345
|
+
device_id: int
|
|
346
|
+
key_id: int
|
|
347
|
+
fid: int
|
|
348
|
+
duration: int
|
|
349
|
+
delay: int
|
|
350
|
+
|
|
351
|
+
@property
|
|
352
|
+
def is_delay_only(self) -> bool:
|
|
353
|
+
"""A key_id of 0xFF indicates a delay-only / no-op entry."""
|
|
354
|
+
|
|
355
|
+
return self.key_id == 0xFF
|
|
356
|
+
|
|
357
|
+
@property
|
|
358
|
+
def entry_type(self) -> int:
|
|
359
|
+
"""Return 0 for delay-only entries, else 1."""
|
|
360
|
+
|
|
361
|
+
return 0 if self.is_delay_only else 1
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@dataclass(slots=True, frozen=True)
|
|
365
|
+
class MacroRecord:
|
|
366
|
+
"""One macro definition parsed from an assembled REQ_MACROS region.
|
|
367
|
+
|
|
368
|
+
``activity_id`` is supplied by the caller (the assembler keys bursts
|
|
369
|
+
by activity_id; the byte that holds it in the wire format lives in the
|
|
370
|
+
page-1 preamble the assembler strips before producing the region).
|
|
371
|
+
|
|
372
|
+
``raw_label_slot`` preserves the on-wire label-slot bytes verbatim
|
|
373
|
+
(30 bytes on X1, 60 on X1S/X2). Real hubs leave non-zero metadata in
|
|
374
|
+
the trailing portion of the slot (e.g. ``37 37 00 00 35 35`` after a
|
|
375
|
+
``POWER_ON`` label). The integration must round-trip those bytes when
|
|
376
|
+
re-saving the macro, otherwise the hub rejects the write.
|
|
377
|
+
"""
|
|
378
|
+
|
|
379
|
+
activity_id: int
|
|
380
|
+
key_id: int # which activity button this macro is bound to
|
|
381
|
+
label: str
|
|
382
|
+
key_sequence: tuple[MacroKeyEntry, ...]
|
|
383
|
+
raw_label_slot: bytes = b""
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _stride_label_len_for_macros(hub_version: str) -> tuple[int, str]:
|
|
387
|
+
"""Return ``(label_len, encoding)`` for the given hub model.
|
|
388
|
+
|
|
389
|
+
Thin wrapper over :func:`schema_for`; raises ``ValueError`` on
|
|
390
|
+
unknown variants via the shared schema.
|
|
391
|
+
"""
|
|
392
|
+
|
|
393
|
+
schema = schema_for(hub_version)
|
|
394
|
+
return schema.macro_label_slot_len, schema.macro_label_encoding
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
#: Auto-generated activity macro labels that real hubs sometimes preface
|
|
398
|
+
#: with extra binding bytes inside the label slot. When the decoder finds
|
|
399
|
+
#: one of these substrings, the actual label is taken from that point on.
|
|
400
|
+
_AUTO_GENERATED_LABEL_MARKERS: Tuple[str, ...] = ("POWER_ON", "POWER_OFF")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _decode_macro_schema_label(label_bytes: bytes, encoding: str) -> str:
|
|
404
|
+
"""Decode a fixed-width macro label slot.
|
|
405
|
+
|
|
406
|
+
The entire slot is decoded under the chosen encoding, then null padding
|
|
407
|
+
and surrounding whitespace are removed.
|
|
408
|
+
|
|
409
|
+
Some hubs occasionally store extra binding data at the start of an
|
|
410
|
+
auto-generated POWER_ON / POWER_OFF macro's label slot before the
|
|
411
|
+
label text itself. The decoder rebases on any such marker substring
|
|
412
|
+
so the surfaced label matches what the user-facing app shows.
|
|
413
|
+
"""
|
|
414
|
+
|
|
415
|
+
if encoding == "ascii":
|
|
416
|
+
try:
|
|
417
|
+
decoded = label_bytes.decode("ascii", errors="ignore")
|
|
418
|
+
except Exception:
|
|
419
|
+
decoded = ""
|
|
420
|
+
return _trim_macro_label(decoded)
|
|
421
|
+
|
|
422
|
+
raw = label_bytes
|
|
423
|
+
if len(raw) % 2:
|
|
424
|
+
raw = raw[:-1]
|
|
425
|
+
try:
|
|
426
|
+
decoded = raw.decode("utf-16-be", errors="ignore")
|
|
427
|
+
except Exception:
|
|
428
|
+
decoded = ""
|
|
429
|
+
return _trim_macro_label(decoded)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _trim_macro_label(decoded: str) -> str:
|
|
433
|
+
"""Clean a decoded label string, rebasing on known marker substrings."""
|
|
434
|
+
|
|
435
|
+
for marker in _AUTO_GENERATED_LABEL_MARKERS:
|
|
436
|
+
idx = decoded.find(marker)
|
|
437
|
+
if idx >= 0:
|
|
438
|
+
return decoded[idx:].split("\x00", 1)[0].strip()
|
|
439
|
+
return decoded.rstrip("\x00").strip()
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _encode_macro_schema_label(label: str, *, label_len: int, encoding: str) -> bytes:
|
|
443
|
+
"""Encode one fixed-width macro label slot."""
|
|
444
|
+
|
|
445
|
+
try:
|
|
446
|
+
encoded = label.encode(encoding, errors="ignore")
|
|
447
|
+
except Exception:
|
|
448
|
+
encoded = b""
|
|
449
|
+
return encoded[:label_len].ljust(label_len, b"\x00")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _parse_macro_key_entry(bean: bytes) -> MacroKeyEntry:
|
|
453
|
+
"""Parse one 10-byte macro key-entry payload."""
|
|
454
|
+
|
|
455
|
+
return MacroKeyEntry(
|
|
456
|
+
device_id=bean[0],
|
|
457
|
+
key_id=bean[1],
|
|
458
|
+
fid=int.from_bytes(bean[2:8], "big"),
|
|
459
|
+
duration=bean[8],
|
|
460
|
+
delay=bean[9],
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _serialize_macro_key_entry(entry: MacroKeyEntry) -> bytes:
|
|
465
|
+
"""Serialize one macro key entry using canonical layout."""
|
|
466
|
+
|
|
467
|
+
if entry.is_delay_only:
|
|
468
|
+
return b"\xFF" * 9 + bytes([entry.delay & 0xFF])
|
|
469
|
+
|
|
470
|
+
fid = entry.fid
|
|
471
|
+
if entry.key_id in (0xC5, 0xC6, 0xC7):
|
|
472
|
+
fid = 0
|
|
473
|
+
|
|
474
|
+
return (
|
|
475
|
+
bytes([entry.device_id & 0xFF, entry.key_id & 0xFF])
|
|
476
|
+
+ fid.to_bytes(6, "big")
|
|
477
|
+
+ bytes([entry.duration & 0xFF, 0xFF])
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def parse_macro_record_from_region(
|
|
482
|
+
region: bytes,
|
|
483
|
+
*,
|
|
484
|
+
activity_id: int,
|
|
485
|
+
hub_version: str,
|
|
486
|
+
) -> MacroRecord | None:
|
|
487
|
+
"""Parse one :class:`MacroRecord` from a post-assembly macro region.
|
|
488
|
+
|
|
489
|
+
``region`` is the slice of the assembler's ordered payload corresponding
|
|
490
|
+
to one completed page-cycle (one macro). The caller supplies
|
|
491
|
+
``activity_id`` (captured by the assembler from the page-1 header) and
|
|
492
|
+
``hub_version`` (which selects stride and label encoding).
|
|
493
|
+
|
|
494
|
+
Returns ``None`` if the region is too short to contain a valid macro
|
|
495
|
+
header (less than 2 bytes plus the label slot).
|
|
496
|
+
|
|
497
|
+
The parser does not scan for ``0xFF`` separators, does not use
|
|
498
|
+
byte-pattern heuristics to detect label encoding, and decodes the
|
|
499
|
+
label from the **end** of the region at the fixed
|
|
500
|
+
hub-version-determined slot length. It also exposes the inner
|
|
501
|
+
``MacroKeyEntry`` sequence so callers that want to introspect the
|
|
502
|
+
macro's button presses can do so without re-parsing the bytes.
|
|
503
|
+
|
|
504
|
+
"""
|
|
505
|
+
|
|
506
|
+
label_len, encoding = _stride_label_len_for_macros(hub_version)
|
|
507
|
+
|
|
508
|
+
# The label slot is read from the end of the region, skipping the final
|
|
509
|
+
# 1-byte terminator. The slot we want is ``region[-(label_len+1):-1]``.
|
|
510
|
+
if len(region) < MACRO_KEY_ENTRY_START + label_len + 1:
|
|
511
|
+
return None
|
|
512
|
+
|
|
513
|
+
key_id = region[0]
|
|
514
|
+
count = region[1]
|
|
515
|
+
|
|
516
|
+
sequence_end = MACRO_KEY_ENTRY_START + count * MACRO_KEY_ENTRY_SIZE
|
|
517
|
+
label_start = len(region) - (label_len + 1)
|
|
518
|
+
label_end = len(region) - 1 # skip the trailing terminator byte
|
|
519
|
+
|
|
520
|
+
# Defensive bounds check: if the declared count would overlap or pass
|
|
521
|
+
# the trailing label slot, clamp to what fits between the header and
|
|
522
|
+
# the label.
|
|
523
|
+
if sequence_end > label_start:
|
|
524
|
+
usable_bytes = max(0, label_start - MACRO_KEY_ENTRY_START)
|
|
525
|
+
count = usable_bytes // MACRO_KEY_ENTRY_SIZE
|
|
526
|
+
|
|
527
|
+
entries: list[MacroKeyEntry] = []
|
|
528
|
+
for i in range(count):
|
|
529
|
+
bean_start = MACRO_KEY_ENTRY_START + i * MACRO_KEY_ENTRY_SIZE
|
|
530
|
+
bean = region[bean_start : bean_start + MACRO_KEY_ENTRY_SIZE]
|
|
531
|
+
if len(bean) < MACRO_KEY_ENTRY_SIZE:
|
|
532
|
+
break
|
|
533
|
+
entries.append(
|
|
534
|
+
MacroKeyEntry(
|
|
535
|
+
device_id=bean[0],
|
|
536
|
+
key_id=bean[1],
|
|
537
|
+
fid=int.from_bytes(bean[2:8], "big"),
|
|
538
|
+
duration=bean[8],
|
|
539
|
+
delay=bean[9],
|
|
540
|
+
)
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
label_slot_bytes = bytes(region[label_start:label_end])
|
|
544
|
+
label = _decode_macro_schema_label(label_slot_bytes, encoding)
|
|
545
|
+
|
|
546
|
+
return MacroRecord(
|
|
547
|
+
activity_id=activity_id,
|
|
548
|
+
key_id=key_id,
|
|
549
|
+
label=label,
|
|
550
|
+
key_sequence=tuple(entries),
|
|
551
|
+
raw_label_slot=label_slot_bytes,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
#: Maximum body chunk size carried per family-0x12 write page.
|
|
556
|
+
MACRO_WRITE_PAGE_BODY_CHUNK = 247
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def build_macro_save_payload(
|
|
560
|
+
*,
|
|
561
|
+
activity_id: int,
|
|
562
|
+
key_id: int,
|
|
563
|
+
key_sequence: tuple[MacroKeyEntry, ...] | list[MacroKeyEntry],
|
|
564
|
+
label: str,
|
|
565
|
+
hub_version: str,
|
|
566
|
+
label_slot: bytes | None = None,
|
|
567
|
+
outer_sequence: int = 1,
|
|
568
|
+
) -> bytes:
|
|
569
|
+
"""Build one canonical macro-save payload matching.
|
|
570
|
+
|
|
571
|
+
Body layout: ``[0x01][total_pages_be][act_id][key_id][count] + Nx10 rows
|
|
572
|
+
+ 30B (X1) or 60B (X1S/X2) label slot + 1B checksum``.
|
|
573
|
+
|
|
574
|
+
``total_pages`` is computed from the final body length so the
|
|
575
|
+
checksum at ``body[-1]`` is always consistent with the bytes the
|
|
576
|
+
paged splitter will send. (The hub rejects writes whose declared
|
|
577
|
+
checksum disagrees with the sum of the body — the prior version
|
|
578
|
+
mutated ``body[1:3]`` after the checksum was computed, producing a
|
|
579
|
+
one-off mismatch on every multi-page macro.)
|
|
580
|
+
|
|
581
|
+
If ``label_slot`` is supplied (length must equal the hub's label slot
|
|
582
|
+
size), those bytes are written verbatim and ``label`` is ignored. This
|
|
583
|
+
matters for read-modify-write paths where the source hub stores
|
|
584
|
+
metadata in the trailing portion of the label slot (e.g. POWER_*
|
|
585
|
+
macros surface bytes like ``37 37 00 00 35 35`` after the encoded
|
|
586
|
+
name); dropping them causes the hub to reject the save with status
|
|
587
|
+
``0x0c``.
|
|
588
|
+
|
|
589
|
+
If ``label_slot`` is omitted, the slot is built from ``label`` using
|
|
590
|
+
the hub's encoding (ASCII for X1, UTF-16BE for X1S/X2) with zero
|
|
591
|
+
padding.
|
|
592
|
+
"""
|
|
593
|
+
|
|
594
|
+
label_len, encoding = _stride_label_len_for_macros(hub_version)
|
|
595
|
+
if label_slot is None:
|
|
596
|
+
slot_bytes = _encode_macro_schema_label(label, label_len=label_len, encoding=encoding)
|
|
597
|
+
elif len(label_slot) != label_len:
|
|
598
|
+
raise ValueError(
|
|
599
|
+
f"build_macro_save_payload: label_slot length {len(label_slot)} "
|
|
600
|
+
f"does not match expected slot length {label_len} for hub_version={hub_version!r}"
|
|
601
|
+
)
|
|
602
|
+
else:
|
|
603
|
+
slot_bytes = bytes(label_slot)
|
|
604
|
+
|
|
605
|
+
body = bytearray(
|
|
606
|
+
bytes([0x01, 0x00, 0x00]) # total_pages placeholder, set below
|
|
607
|
+
+ bytes([activity_id & 0xFF, key_id & 0xFF, len(tuple(key_sequence)) & 0xFF])
|
|
608
|
+
)
|
|
609
|
+
for entry in key_sequence:
|
|
610
|
+
body.extend(_serialize_macro_key_entry(entry))
|
|
611
|
+
body.extend(slot_bytes)
|
|
612
|
+
body.append(0x00) # checksum slot
|
|
613
|
+
|
|
614
|
+
total_pages = max(1, (len(body) + MACRO_WRITE_PAGE_BODY_CHUNK - 1) // MACRO_WRITE_PAGE_BODY_CHUNK)
|
|
615
|
+
body[1:3] = (total_pages & 0xFFFF).to_bytes(2, "big")
|
|
616
|
+
body[-1] = sum(body[:-1]) & 0xFF
|
|
617
|
+
|
|
618
|
+
return bytes([0x01]) + (outer_sequence & 0xFFFF).to_bytes(2, "big") + bytes(body)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def parse_macro_records_from_burst(
|
|
622
|
+
payload: bytes,
|
|
623
|
+
*,
|
|
624
|
+
activity_id: int,
|
|
625
|
+
record_boundaries: list[int],
|
|
626
|
+
hub_version: str,
|
|
627
|
+
) -> list[MacroRecord]:
|
|
628
|
+
"""Parse all macros in a completed burst's assembled payload.
|
|
629
|
+
|
|
630
|
+
Walks the ``record_boundaries`` produced by :class:`MacroAssembler` and
|
|
631
|
+
runs :func:`parse_macro_record_from_region` on each resulting region.
|
|
632
|
+
|
|
633
|
+
The parser produces ``(activity_id, key_id)``-keyed records, exposes
|
|
634
|
+
the inner key sequence, and walks records at fixed offsets within
|
|
635
|
+
each boundary-delimited region.
|
|
636
|
+
"""
|
|
637
|
+
|
|
638
|
+
records: list[MacroRecord] = []
|
|
639
|
+
for idx, boundary in enumerate(record_boundaries):
|
|
640
|
+
if boundary >= len(payload):
|
|
641
|
+
continue
|
|
642
|
+
region_end = (
|
|
643
|
+
record_boundaries[idx + 1]
|
|
644
|
+
if idx + 1 < len(record_boundaries)
|
|
645
|
+
else len(payload)
|
|
646
|
+
)
|
|
647
|
+
region = payload[boundary:region_end]
|
|
648
|
+
record = parse_macro_record_from_region(
|
|
649
|
+
region, activity_id=activity_id, hub_version=hub_version
|
|
650
|
+
)
|
|
651
|
+
if record is not None:
|
|
652
|
+
records.append(record)
|
|
653
|
+
return records
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
__all__ = [
|
|
657
|
+
"MACRO_KEY_ENTRY_SIZE",
|
|
658
|
+
"MACRO_KEY_ENTRY_START",
|
|
659
|
+
"MACRO_LABEL_LEN_X1",
|
|
660
|
+
"MACRO_LABEL_LEN_X1S_X2",
|
|
661
|
+
"MacroAssembler",
|
|
662
|
+
"MacroBurstFrame",
|
|
663
|
+
"MacroKeyEntry",
|
|
664
|
+
"MacroRecord",
|
|
665
|
+
"build_macro_save_payload",
|
|
666
|
+
"parse_macro_burst_frame",
|
|
667
|
+
"parse_macro_record_from_region",
|
|
668
|
+
"parse_macro_records_from_burst",
|
|
669
|
+
]
|