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
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
"""IR playback + single-command persist mixin for :class:`X1Proxy`.
|
|
2
|
+
|
|
3
|
+
Carries everything tied to family-0x0F replay frames and the
|
|
4
|
+
single-command save path (family-0x0E paged writes):
|
|
5
|
+
|
|
6
|
+
* :meth:`play_ir_blob` and the lower-level :meth:`_play_ir_blob_body`
|
|
7
|
+
loop that drives the per-frame ack pacing for a one-shot playback.
|
|
8
|
+
* The persist write-pipeline -- :meth:`persist_ir_blob`,
|
|
9
|
+
:meth:`persist_command_record`, and the
|
|
10
|
+
``_build_command_write_steps_for_persist`` / ``_allocate_command_id``
|
|
11
|
+
/ ``_run_persist_write`` helpers that translate "save this one
|
|
12
|
+
command on this device" into a paged family-0x0E burst.
|
|
13
|
+
* Shape sniffers and tail-checksum diagnostics for replay payloads.
|
|
14
|
+
* :meth:`_get_active_ir_dump_pending`, used by the IR-dump ingest path
|
|
15
|
+
to look up the in-flight burst keyed off the current burst kind.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import time
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from .commands import descriptive_play_blob_text, looks_like_descriptive_play_blob
|
|
24
|
+
from .device_create import (
|
|
25
|
+
build_command_write_steps,
|
|
26
|
+
build_key_sort_steps,
|
|
27
|
+
encode_command_sort_body,
|
|
28
|
+
)
|
|
29
|
+
from .protocol_const import (
|
|
30
|
+
FAMILY_PLAY_BLOB,
|
|
31
|
+
PLAY_BLOB_BODY_HEADER_LEN,
|
|
32
|
+
PLAY_BLOB_CHUNK_SIZE,
|
|
33
|
+
PLAY_BLOB_MAX_PAYLOAD,
|
|
34
|
+
PLAY_BLOB_PAGE_HEADER_LEN,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _run_create_sequence(*args, **kwargs):
|
|
39
|
+
from . import x1_proxy as _xp
|
|
40
|
+
|
|
41
|
+
return _xp.run_create_sequence(*args, **kwargs)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class IrBlobMixin:
|
|
45
|
+
"""Mixin providing IR playback and single-command persist writes."""
|
|
46
|
+
|
|
47
|
+
def play_ir_blob(
|
|
48
|
+
self,
|
|
49
|
+
blob: bytes,
|
|
50
|
+
*,
|
|
51
|
+
inter_frame_delay: float = 0.08,
|
|
52
|
+
ack_timeout: float = 1.0,
|
|
53
|
+
final_ack_timeout: float = 0.25,
|
|
54
|
+
) -> bool:
|
|
55
|
+
"""Send a canonical IR blob body to the hub for one-shot playback.
|
|
56
|
+
|
|
57
|
+
``blob`` is the raw library_data the hub stores for this command —
|
|
58
|
+
the same bytes returned by ``fetch_blob`` and produced by the
|
|
59
|
+
descriptive-descriptor builder. The wire body buffer (header +
|
|
60
|
+
library_data + sum8) is constructed here and then chunked across
|
|
61
|
+
family-0x0F frames.
|
|
62
|
+
|
|
63
|
+
Returns True on success; False if the proxy is not in a state to
|
|
64
|
+
issue commands or the blob is too short to be valid.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
if not self.can_issue_commands():
|
|
68
|
+
self._log.info("[PLAY_BLOB] ignored: proxy client is connected")
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
if not isinstance(blob, (bytes, bytearray)) or len(blob) < 10:
|
|
72
|
+
self._log.warning("[PLAY_BLOB] blob too short or wrong type: %r", type(blob))
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
body_buffer = self._build_play_blob_body_buffer(bytes(blob))
|
|
76
|
+
ok, rejected = self._play_ir_blob_body(
|
|
77
|
+
body_buffer,
|
|
78
|
+
inter_frame_delay=inter_frame_delay,
|
|
79
|
+
ack_timeout=ack_timeout,
|
|
80
|
+
final_ack_timeout=final_ack_timeout,
|
|
81
|
+
)
|
|
82
|
+
if ok:
|
|
83
|
+
return True
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def _next_available_command_id(existing_command_ids: list[int]) -> int:
|
|
88
|
+
used = {int(command_id) & 0xFF for command_id in existing_command_ids if 1 <= int(command_id) <= 255}
|
|
89
|
+
for candidate in range(1, 256):
|
|
90
|
+
if candidate not in used:
|
|
91
|
+
return candidate
|
|
92
|
+
raise ValueError("device already uses all 255 command ids")
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def _validated_command_label(command_name: str) -> str:
|
|
96
|
+
"""Return the command label after the persist write-path's basic
|
|
97
|
+
validation. The actual encoding into a fixed-width slot is the
|
|
98
|
+
builder's responsibility.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
text = str(command_name or "").strip()
|
|
102
|
+
if not text:
|
|
103
|
+
raise ValueError("command_name is required")
|
|
104
|
+
return text
|
|
105
|
+
|
|
106
|
+
def _build_command_write_steps_for_persist(
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
device_id: int,
|
|
110
|
+
command_id: int,
|
|
111
|
+
command_name: str,
|
|
112
|
+
library_type: int,
|
|
113
|
+
library_data: bytes,
|
|
114
|
+
button_code: int = 0,
|
|
115
|
+
ack_timeout: float = 5.0,
|
|
116
|
+
) -> list[Any]:
|
|
117
|
+
"""Build paged command-write steps for the single-command persist path.
|
|
118
|
+
|
|
119
|
+
A persist write is just a burst of size 1 -- the same wire shape
|
|
120
|
+
the device-create burst uses for each of its N commands, with
|
|
121
|
+
``command_seq=1`` and ``command_burst_size=1``.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
if command_id < 1 or command_id > 0xFF:
|
|
125
|
+
raise ValueError(f"command_id {command_id} out of byte range")
|
|
126
|
+
|
|
127
|
+
return build_command_write_steps(
|
|
128
|
+
hub_version=self.hub_version,
|
|
129
|
+
command_seq=1,
|
|
130
|
+
command_burst_size=1,
|
|
131
|
+
device_id=device_id & 0xFF,
|
|
132
|
+
button_id=command_id & 0xFF,
|
|
133
|
+
library_type=library_type & 0xFF,
|
|
134
|
+
button_code=button_code & 0xFFFFFFFFFFFF,
|
|
135
|
+
label=self._validated_command_label(command_name),
|
|
136
|
+
library_data=bytes(library_data),
|
|
137
|
+
ack_timeout=ack_timeout,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def _allocate_command_id(
|
|
141
|
+
self,
|
|
142
|
+
device_commands: dict[int, str] | None,
|
|
143
|
+
command_id: int | None,
|
|
144
|
+
) -> int:
|
|
145
|
+
"""Pick the slot id this persist write should land on.
|
|
146
|
+
|
|
147
|
+
Either accept the caller's explicit ``command_id`` (validated
|
|
148
|
+
against the existing slots on the device) or auto-allocate the
|
|
149
|
+
next free id.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
existing_command_ids = (
|
|
153
|
+
sorted(int(existing_id) & 0xFF for existing_id in device_commands.keys())
|
|
154
|
+
if isinstance(device_commands, dict)
|
|
155
|
+
else []
|
|
156
|
+
)
|
|
157
|
+
if command_id is None:
|
|
158
|
+
return self._next_available_command_id(existing_command_ids)
|
|
159
|
+
new_command_id = int(command_id) & 0xFF
|
|
160
|
+
if new_command_id < 1 or new_command_id > 0xFF:
|
|
161
|
+
raise ValueError(f"command_id {new_command_id} out of byte range")
|
|
162
|
+
if new_command_id in existing_command_ids:
|
|
163
|
+
raise ValueError(
|
|
164
|
+
f"command_id {new_command_id} already exists on the target device"
|
|
165
|
+
)
|
|
166
|
+
return new_command_id
|
|
167
|
+
|
|
168
|
+
def _run_persist_write(
|
|
169
|
+
self,
|
|
170
|
+
*,
|
|
171
|
+
log_prefix: str,
|
|
172
|
+
device_id: int,
|
|
173
|
+
command_id: int,
|
|
174
|
+
command_name: str,
|
|
175
|
+
library_type: int,
|
|
176
|
+
library_data: bytes,
|
|
177
|
+
button_code: int,
|
|
178
|
+
ack_timeout: float,
|
|
179
|
+
) -> dict[str, Any] | None:
|
|
180
|
+
"""Shared driver for single-command persist writes.
|
|
181
|
+
|
|
182
|
+
Builds the family-0x0E steps via :func:`build_command_write_steps`,
|
|
183
|
+
clears the ack queue, then feeds the step list through
|
|
184
|
+
:func:`run_create_sequence`. Surfaces hub rejection
|
|
185
|
+
(``STATUS_ACK 0x0C``) as a warning distinct from timeout.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
steps = self._build_command_write_steps_for_persist(
|
|
189
|
+
device_id=device_id,
|
|
190
|
+
command_id=command_id,
|
|
191
|
+
command_name=command_name,
|
|
192
|
+
library_type=library_type,
|
|
193
|
+
library_data=library_data,
|
|
194
|
+
button_code=button_code,
|
|
195
|
+
ack_timeout=ack_timeout,
|
|
196
|
+
)
|
|
197
|
+
self._log.info(
|
|
198
|
+
"[%s] uploading dev=0x%02X new_command_id=0x%02X lib=0x%02X pages=%d data=%dB",
|
|
199
|
+
log_prefix,
|
|
200
|
+
device_id & 0xFF,
|
|
201
|
+
command_id & 0xFF,
|
|
202
|
+
library_type & 0xFF,
|
|
203
|
+
len(steps),
|
|
204
|
+
len(library_data) + 1,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
self.clear_ack_queue()
|
|
208
|
+
result = _run_create_sequence(self, steps)
|
|
209
|
+
if not result.success:
|
|
210
|
+
if result.rejected:
|
|
211
|
+
self._log.warning(
|
|
212
|
+
"[%s] hub rejected page %d/%d dev=0x%02X lib=0x%02X",
|
|
213
|
+
log_prefix,
|
|
214
|
+
(result.failed_index or 0) + 1,
|
|
215
|
+
len(steps),
|
|
216
|
+
device_id & 0xFF,
|
|
217
|
+
library_type & 0xFF,
|
|
218
|
+
)
|
|
219
|
+
else:
|
|
220
|
+
self._log.warning(
|
|
221
|
+
"[%s] timeout waiting for page ack %d/%d dev=0x%02X",
|
|
222
|
+
log_prefix,
|
|
223
|
+
(result.failed_index or 0) + 1,
|
|
224
|
+
len(steps),
|
|
225
|
+
device_id & 0xFF,
|
|
226
|
+
)
|
|
227
|
+
return None
|
|
228
|
+
return {"page_count": len(steps)}
|
|
229
|
+
|
|
230
|
+
def persist_ir_blob(
|
|
231
|
+
self,
|
|
232
|
+
*,
|
|
233
|
+
device_id: int,
|
|
234
|
+
command_name: str,
|
|
235
|
+
blob: bytes,
|
|
236
|
+
command_id: int | None = None,
|
|
237
|
+
inter_frame_delay: float = 0.08, # retained for API compat; unused
|
|
238
|
+
ack_timeout: float = 5.0,
|
|
239
|
+
) -> dict[str, Any] | None:
|
|
240
|
+
"""Persist a new IR command blob onto an existing device.
|
|
241
|
+
|
|
242
|
+
Uploads family ``0x0E`` save pages (the same wire format used
|
|
243
|
+
by all single-command saves regardless of codec). The codec
|
|
244
|
+
selector is fixed at ``library_type=0x0D`` (IR-DB), and no
|
|
245
|
+
canonical button-code is asserted -- the hub assigns one on
|
|
246
|
+
accept. Use :meth:`persist_command_record` for non-IR codecs.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
del inter_frame_delay # paging cadence now lives in the sequencer
|
|
250
|
+
|
|
251
|
+
if not self.can_issue_commands():
|
|
252
|
+
self._log.info("[PERSIST_IR_BLOB] ignored: proxy client is connected")
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
if not isinstance(blob, (bytes, bytearray)) or len(blob) < 10:
|
|
256
|
+
self._log.warning("[PERSIST_IR_BLOB] blob too short or wrong type: %r", type(blob))
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
dev_lo = device_id & 0xFF
|
|
260
|
+
device_commands = self.state.commands.get(dev_lo, {})
|
|
261
|
+
new_command_id = self._allocate_command_id(device_commands, command_id)
|
|
262
|
+
|
|
263
|
+
outcome = self._run_persist_write(
|
|
264
|
+
log_prefix="PERSIST_IR_BLOB",
|
|
265
|
+
device_id=dev_lo,
|
|
266
|
+
command_id=new_command_id,
|
|
267
|
+
command_name=command_name,
|
|
268
|
+
library_type=0x0D,
|
|
269
|
+
library_data=bytes(blob),
|
|
270
|
+
button_code=0,
|
|
271
|
+
ack_timeout=ack_timeout,
|
|
272
|
+
)
|
|
273
|
+
if outcome is None:
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
if not isinstance(device_commands, dict):
|
|
277
|
+
device_commands = {}
|
|
278
|
+
self.state.commands[dev_lo] = device_commands
|
|
279
|
+
device_commands[new_command_id] = (
|
|
280
|
+
str(command_name or "").strip() or f"Command {new_command_id}"
|
|
281
|
+
)
|
|
282
|
+
self._commands_complete.add(dev_lo)
|
|
283
|
+
|
|
284
|
+
# The save above leaves the new command with sort_id=0, which
|
|
285
|
+
# keeps it off the physical remote's device-browse screen even
|
|
286
|
+
# though it plays back fine when bound to a button or invoked
|
|
287
|
+
# by name. Push an updated per-device sort table so the new
|
|
288
|
+
# entry takes the next free display slot. Failure here is not
|
|
289
|
+
# fatal -- the command itself is already saved.
|
|
290
|
+
self._register_command_in_device_sort(
|
|
291
|
+
dev_lo=dev_lo,
|
|
292
|
+
new_command_id=new_command_id,
|
|
293
|
+
ack_timeout=ack_timeout,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
"status": "success",
|
|
298
|
+
"device_id": dev_lo,
|
|
299
|
+
"command_id": new_command_id,
|
|
300
|
+
"command_name": device_commands[new_command_id],
|
|
301
|
+
"page_count": outcome["page_count"],
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
def _register_command_in_device_sort(
|
|
305
|
+
self,
|
|
306
|
+
*,
|
|
307
|
+
dev_lo: int,
|
|
308
|
+
new_command_id: int,
|
|
309
|
+
ack_timeout: float,
|
|
310
|
+
) -> None:
|
|
311
|
+
"""Append the freshly-saved command to the device's display order.
|
|
312
|
+
|
|
313
|
+
Re-emits the full family-0x61 ``(command_id, sort_position)``
|
|
314
|
+
table for ``dev_lo`` with the new command tacked onto the end
|
|
315
|
+
at the next free position. Existing commands keep whatever
|
|
316
|
+
positions the hub had already assigned them; commands the hub
|
|
317
|
+
has on record but that have never been assigned a position
|
|
318
|
+
(sort_id==0) are folded in after the previously-positioned
|
|
319
|
+
ones so the table stays a complete enumeration.
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
metadata = self.state.command_metadata.get(dev_lo) or {}
|
|
323
|
+
known_command_ids = set(metadata.keys())
|
|
324
|
+
device_commands = self.state.commands.get(dev_lo) or {}
|
|
325
|
+
known_command_ids.update(device_commands.keys())
|
|
326
|
+
known_command_ids.add(new_command_id)
|
|
327
|
+
|
|
328
|
+
positioned: list[tuple[int, int]] = []
|
|
329
|
+
unpositioned: list[int] = []
|
|
330
|
+
for command_id in known_command_ids:
|
|
331
|
+
if command_id == new_command_id:
|
|
332
|
+
continue
|
|
333
|
+
entry = metadata.get(command_id) or {}
|
|
334
|
+
sort_id = int(entry.get("sort_id", 0)) & 0xFF
|
|
335
|
+
if sort_id:
|
|
336
|
+
positioned.append((command_id & 0xFF, sort_id))
|
|
337
|
+
else:
|
|
338
|
+
unpositioned.append(command_id & 0xFF)
|
|
339
|
+
|
|
340
|
+
positioned.sort(key=lambda pair: pair[1])
|
|
341
|
+
next_position = (positioned[-1][1] if positioned else 0) + 1
|
|
342
|
+
ordered_pairs: list[tuple[int, int]] = list(positioned)
|
|
343
|
+
for command_id in sorted(unpositioned):
|
|
344
|
+
ordered_pairs.append((command_id, next_position))
|
|
345
|
+
next_position += 1
|
|
346
|
+
ordered_pairs.append((new_command_id & 0xFF, next_position))
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
body = encode_command_sort_body(ordered_pairs)
|
|
350
|
+
steps = build_key_sort_steps(
|
|
351
|
+
device_id=dev_lo,
|
|
352
|
+
msg_hex=body.hex(),
|
|
353
|
+
ack_timeout=ack_timeout,
|
|
354
|
+
)
|
|
355
|
+
except ValueError as exc:
|
|
356
|
+
self._log.warning(
|
|
357
|
+
"[PERSIST_IR_BLOB] could not build sort write dev=0x%02X: %s",
|
|
358
|
+
dev_lo,
|
|
359
|
+
exc,
|
|
360
|
+
)
|
|
361
|
+
return
|
|
362
|
+
|
|
363
|
+
self.clear_ack_queue()
|
|
364
|
+
result = _run_create_sequence(self, steps)
|
|
365
|
+
if not result.success:
|
|
366
|
+
self._log.warning(
|
|
367
|
+
"[PERSIST_IR_BLOB] sort write %s dev=0x%02X page=%d/%d "
|
|
368
|
+
"(command still saved, may not appear in the remote's "
|
|
369
|
+
"device-browse list until the next reorder)",
|
|
370
|
+
"rejected" if result.rejected else "timed out",
|
|
371
|
+
dev_lo,
|
|
372
|
+
(result.failed_index or 0) + 1,
|
|
373
|
+
len(steps),
|
|
374
|
+
)
|
|
375
|
+
return
|
|
376
|
+
|
|
377
|
+
# Mirror the position we just told the hub about so a follow-up
|
|
378
|
+
# save against the same device doesn't reuse the same slot.
|
|
379
|
+
bucket = self.state.command_metadata.setdefault(dev_lo, {})
|
|
380
|
+
bucket[new_command_id & 0xFF] = {
|
|
381
|
+
**(bucket.get(new_command_id & 0xFF) or {}),
|
|
382
|
+
"sort_id": ordered_pairs[-1][1] & 0xFF,
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
def persist_command_record(
|
|
386
|
+
self,
|
|
387
|
+
*,
|
|
388
|
+
device_id: int,
|
|
389
|
+
command_name: str,
|
|
390
|
+
library_type: int,
|
|
391
|
+
command_data: bytes,
|
|
392
|
+
command_code: int = 0,
|
|
393
|
+
command_id: int | None = None,
|
|
394
|
+
inter_frame_delay: float = 0.08, # retained for API compat; unused
|
|
395
|
+
ack_timeout: float = 5.0,
|
|
396
|
+
) -> dict[str, Any] | None:
|
|
397
|
+
"""Persist an opaque hub-owned command record onto an existing device.
|
|
398
|
+
|
|
399
|
+
``library_type`` selects the codec (``0x03`` Bluetooth, RF
|
|
400
|
+
variants, learned-IR, etc.). ``command_code`` is the 48-bit
|
|
401
|
+
canonical identifier the hub stores alongside the codec bytes
|
|
402
|
+
and that downstream button-binding / macro writes reference.
|
|
403
|
+
"""
|
|
404
|
+
|
|
405
|
+
del inter_frame_delay
|
|
406
|
+
|
|
407
|
+
if not self.can_issue_commands():
|
|
408
|
+
self._log.info("[PERSIST_CMD] ignored: proxy client is connected")
|
|
409
|
+
return None
|
|
410
|
+
|
|
411
|
+
if not isinstance(command_data, (bytes, bytearray)) or len(command_data) < 1:
|
|
412
|
+
raise ValueError("command_data is too short to persist")
|
|
413
|
+
if library_type < 0 or library_type > 0xFF:
|
|
414
|
+
raise ValueError(f"library_type {library_type} out of byte range")
|
|
415
|
+
if command_code < 0 or command_code > 0xFFFFFFFFFFFF:
|
|
416
|
+
raise ValueError(f"command_code {command_code} out of 48-bit range")
|
|
417
|
+
|
|
418
|
+
dev_lo = device_id & 0xFF
|
|
419
|
+
device_commands = self.state.commands.get(dev_lo, {})
|
|
420
|
+
new_command_id = self._allocate_command_id(device_commands, command_id)
|
|
421
|
+
|
|
422
|
+
outcome = self._run_persist_write(
|
|
423
|
+
log_prefix="PERSIST_CMD",
|
|
424
|
+
device_id=dev_lo,
|
|
425
|
+
command_id=new_command_id,
|
|
426
|
+
command_name=command_name,
|
|
427
|
+
library_type=library_type,
|
|
428
|
+
library_data=bytes(command_data),
|
|
429
|
+
button_code=command_code,
|
|
430
|
+
ack_timeout=ack_timeout,
|
|
431
|
+
)
|
|
432
|
+
if outcome is None:
|
|
433
|
+
return None
|
|
434
|
+
|
|
435
|
+
if not isinstance(device_commands, dict):
|
|
436
|
+
device_commands = {}
|
|
437
|
+
self.state.commands[dev_lo] = device_commands
|
|
438
|
+
device_commands[new_command_id] = (
|
|
439
|
+
str(command_name or "").strip() or f"Command {new_command_id}"
|
|
440
|
+
)
|
|
441
|
+
self._commands_complete.add(dev_lo)
|
|
442
|
+
return {
|
|
443
|
+
"status": "success",
|
|
444
|
+
"device_id": dev_lo,
|
|
445
|
+
"command_id": new_command_id,
|
|
446
|
+
"command_name": device_commands[new_command_id],
|
|
447
|
+
"page_count": outcome["page_count"],
|
|
448
|
+
"library_type": library_type & 0xFF,
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
def _play_ir_blob_body(
|
|
452
|
+
self,
|
|
453
|
+
body_buffer: bytes,
|
|
454
|
+
*,
|
|
455
|
+
inter_frame_delay: float,
|
|
456
|
+
ack_timeout: float,
|
|
457
|
+
final_ack_timeout: float,
|
|
458
|
+
) -> tuple[bool, bool]:
|
|
459
|
+
"""Chunk a fully-built playback body buffer across family-0x0F frames.
|
|
460
|
+
|
|
461
|
+
``body_buffer`` is the complete sealed body: 12-byte header,
|
|
462
|
+
library_data, and trailing sum8. Each wire frame carries a
|
|
463
|
+
``PLAY_BLOB_CHUNK_SIZE``-byte (247B) slice of this buffer prefixed
|
|
464
|
+
with a 3-byte page header ``[0x01, 0x00, page_no_lo]``.
|
|
465
|
+
|
|
466
|
+
Returns ``(ok, rejected)`` where ``rejected`` is true only when the
|
|
467
|
+
hub explicitly NACKs playback with ``0x0103/0x0C``.
|
|
468
|
+
"""
|
|
469
|
+
|
|
470
|
+
body_len = len(body_buffer)
|
|
471
|
+
total_frames = self._play_blob_total_frames(body_len)
|
|
472
|
+
|
|
473
|
+
self._log.info(
|
|
474
|
+
"[PLAY_BLOB] sending %dB body in %d frame(s)", body_len, total_frames,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
# Ignore any stale ACKs already queued from prior traffic; playback must
|
|
478
|
+
# pace itself only on ACKs caused by the chunks we are about to send.
|
|
479
|
+
self.clear_ack_queue()
|
|
480
|
+
|
|
481
|
+
send_ts = time.monotonic()
|
|
482
|
+
for seq in range(1, total_frames + 1):
|
|
483
|
+
if seq > 1 and inter_frame_delay > 0:
|
|
484
|
+
time.sleep(inter_frame_delay)
|
|
485
|
+
slice_start = (seq - 1) * PLAY_BLOB_CHUNK_SIZE
|
|
486
|
+
slice_end = min(slice_start + PLAY_BLOB_CHUNK_SIZE, body_len)
|
|
487
|
+
body_slice = body_buffer[slice_start:slice_end]
|
|
488
|
+
frame_payload = bytes([0x01, 0x00, seq & 0xFF]) + body_slice
|
|
489
|
+
send_ts = time.monotonic()
|
|
490
|
+
self._send_family_play_frame(frame_payload)
|
|
491
|
+
candidates = [(0x0103, 0x00)]
|
|
492
|
+
if seq == total_frames:
|
|
493
|
+
candidates.append((0x0103, 0x0C))
|
|
494
|
+
chunk_ack = self.wait_for_ack_any(candidates, timeout=ack_timeout, not_before=send_ts)
|
|
495
|
+
if chunk_ack is None:
|
|
496
|
+
self._log.warning(
|
|
497
|
+
"[PLAY_BLOB] timeout waiting for chunk ack seq=%d/%d",
|
|
498
|
+
seq,
|
|
499
|
+
total_frames,
|
|
500
|
+
)
|
|
501
|
+
return False, False
|
|
502
|
+
if chunk_ack[1][:1] == b"\x0c":
|
|
503
|
+
self._log.warning(
|
|
504
|
+
"[PLAY_BLOB] chunk rejected seq=%d/%d %s",
|
|
505
|
+
seq,
|
|
506
|
+
total_frames,
|
|
507
|
+
self._play_blob_tail_diagnostics(body_buffer),
|
|
508
|
+
)
|
|
509
|
+
return False, True
|
|
510
|
+
|
|
511
|
+
# A late 0x0103/0x0C after a successful final 0x00 indicates the hub
|
|
512
|
+
# rejected playback after processing the last chunk.
|
|
513
|
+
completion_ack = self._wait_for_ack_any_impl(
|
|
514
|
+
[(0x0103, 0x0C)],
|
|
515
|
+
timeout=final_ack_timeout,
|
|
516
|
+
not_before=send_ts,
|
|
517
|
+
log_timeout=False,
|
|
518
|
+
)
|
|
519
|
+
if completion_ack is not None:
|
|
520
|
+
self._log.warning(
|
|
521
|
+
"[PLAY_BLOB] hub reported playback failure after final chunk %s",
|
|
522
|
+
self._play_blob_tail_diagnostics(body_buffer),
|
|
523
|
+
)
|
|
524
|
+
return False, True
|
|
525
|
+
|
|
526
|
+
return True, False
|
|
527
|
+
|
|
528
|
+
def _send_family_play_frame(self, payload: bytes) -> None:
|
|
529
|
+
"""Send one family-0x0F playback frame, encoding payload length into the opcode high byte."""
|
|
530
|
+
opcode = ((len(payload) & 0xFF) << 8) | (FAMILY_PLAY_BLOB & 0xFF)
|
|
531
|
+
self._send_cmd_frame(opcode, payload)
|
|
532
|
+
|
|
533
|
+
@staticmethod
|
|
534
|
+
def _looks_like_descriptive_play_blob(blob: bytes) -> bool:
|
|
535
|
+
"""Return True for human-readable protocol-descriptor replay blobs."""
|
|
536
|
+
return looks_like_descriptive_play_blob(blob)
|
|
537
|
+
|
|
538
|
+
@staticmethod
|
|
539
|
+
def _looks_like_x1_database_capture_blob(blob: bytes) -> bool:
|
|
540
|
+
"""Return True for observed non-descriptor X1/X1S database-style blobs."""
|
|
541
|
+
return (
|
|
542
|
+
len(blob) >= 14
|
|
543
|
+
and blob[2:6] == b"\x00\x00\x00\x00"
|
|
544
|
+
and blob[6:8] in (b"\x9c\x40", b"\x94\xcf", b"\x94\x74")
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
@staticmethod
|
|
548
|
+
def _extract_single_frame_play_blob(payload: bytes) -> bytes | None:
|
|
549
|
+
"""Extract a complete single-frame replay library_data from a family-0x0F payload.
|
|
550
|
+
|
|
551
|
+
Single-frame replay requests use the layout:
|
|
552
|
+
``[01 00 01] [01 00 <total_pages_be> 00 00 00 00 00 00 00 00 00]`` +
|
|
553
|
+
library_data + sum8. ``total_pages_be`` must be 1 for a single-frame
|
|
554
|
+
replay. Returns the embedded library_data (without the trailing sum8),
|
|
555
|
+
or None if the payload doesn't match.
|
|
556
|
+
"""
|
|
557
|
+
preface_len = PLAY_BLOB_PAGE_HEADER_LEN + PLAY_BLOB_BODY_HEADER_LEN # 15
|
|
558
|
+
if len(payload) < preface_len + 1:
|
|
559
|
+
return None
|
|
560
|
+
if payload[0:3] != b"\x01\x00\x01":
|
|
561
|
+
return None
|
|
562
|
+
if payload[3:5] != b"\x01\x00":
|
|
563
|
+
return None
|
|
564
|
+
if payload[5] != 0x01:
|
|
565
|
+
return None
|
|
566
|
+
if payload[6:preface_len] != b"\x00" * (preface_len - 6):
|
|
567
|
+
return None
|
|
568
|
+
# Drop the trailing body sum8 byte to surface only library_data.
|
|
569
|
+
return payload[preface_len:-1] or None
|
|
570
|
+
|
|
571
|
+
@staticmethod
|
|
572
|
+
def _descriptive_play_blob_text(blob: bytes) -> str | None:
|
|
573
|
+
"""Return the human-readable descriptor string from a descriptive blob."""
|
|
574
|
+
return descriptive_play_blob_text(blob)
|
|
575
|
+
|
|
576
|
+
def _build_play_blob_body_buffer(self, library_data: bytes) -> bytes:
|
|
577
|
+
"""Return the fully sealed body buffer for a playback of ``library_data``.
|
|
578
|
+
|
|
579
|
+
Layout::
|
|
580
|
+
|
|
581
|
+
body[0] = 0x01
|
|
582
|
+
body[1..2] = total_pages BE
|
|
583
|
+
body[3..5] = 0x00 0x00 0x00
|
|
584
|
+
body[6..11] = 0x00 * 6
|
|
585
|
+
body[12..] = library_data
|
|
586
|
+
body[-1] = sum8(body[:-1])
|
|
587
|
+
"""
|
|
588
|
+
|
|
589
|
+
body_len = PLAY_BLOB_BODY_HEADER_LEN + len(library_data) + 1
|
|
590
|
+
total_pages = (body_len + PLAY_BLOB_CHUNK_SIZE - 1) // PLAY_BLOB_CHUNK_SIZE
|
|
591
|
+
body = bytearray(body_len)
|
|
592
|
+
body[0] = 0x01
|
|
593
|
+
body[1] = (total_pages >> 8) & 0xFF
|
|
594
|
+
body[2] = total_pages & 0xFF
|
|
595
|
+
body[PLAY_BLOB_BODY_HEADER_LEN:PLAY_BLOB_BODY_HEADER_LEN + len(library_data)] = library_data
|
|
596
|
+
body[-1] = sum(body[:-1]) & 0xFF
|
|
597
|
+
return bytes(body)
|
|
598
|
+
|
|
599
|
+
def _finalize_play_blob_body(self, library_data: bytes) -> bytes:
|
|
600
|
+
"""Return ``library_data`` with the trailing body sum8 byte appended.
|
|
601
|
+
|
|
602
|
+
Equivalent to slicing off the 12-byte body header from the sealed
|
|
603
|
+
body buffer built by :meth:`_build_play_blob_body_buffer`.
|
|
604
|
+
"""
|
|
605
|
+
|
|
606
|
+
body_buffer = self._build_play_blob_body_buffer(bytes(library_data))
|
|
607
|
+
return body_buffer[PLAY_BLOB_BODY_HEADER_LEN:]
|
|
608
|
+
|
|
609
|
+
def _play_blob_total_frames(self, body_len: int) -> int:
|
|
610
|
+
"""Return the number of family-0x0F frames needed for a body buffer."""
|
|
611
|
+
|
|
612
|
+
if body_len <= 0:
|
|
613
|
+
return 0
|
|
614
|
+
return (body_len + PLAY_BLOB_CHUNK_SIZE - 1) // PLAY_BLOB_CHUNK_SIZE
|
|
615
|
+
|
|
616
|
+
def _play_blob_tail_diagnostics(self, blob: bytes) -> str:
|
|
617
|
+
"""Return compact checksum candidates for blob-tail replay failures."""
|
|
618
|
+
if not blob:
|
|
619
|
+
return "len=0"
|
|
620
|
+
|
|
621
|
+
body = blob[:-1]
|
|
622
|
+
sum8 = sum(body) & 0xFF
|
|
623
|
+
xor8 = 0
|
|
624
|
+
for value in body:
|
|
625
|
+
xor8 ^= value
|
|
626
|
+
|
|
627
|
+
def _crc8_maxim(data: bytes) -> int:
|
|
628
|
+
crc = 0x00
|
|
629
|
+
for byte in data:
|
|
630
|
+
crc ^= byte
|
|
631
|
+
for _ in range(8):
|
|
632
|
+
if crc & 0x01:
|
|
633
|
+
crc = ((crc >> 1) ^ 0x8C) & 0xFF
|
|
634
|
+
else:
|
|
635
|
+
crc = (crc >> 1) & 0xFF
|
|
636
|
+
return crc & 0xFF
|
|
637
|
+
|
|
638
|
+
last_words = " ".join(f"{value:02x}" for value in blob[-8:])
|
|
639
|
+
return (
|
|
640
|
+
f"len={len(blob)} last=0x{blob[-1]:02X} "
|
|
641
|
+
f"sum=0x{sum8:02X} plus1=0x{((sum8 + 1) & 0xFF):02X} "
|
|
642
|
+
f"plus2=0x{((sum8 + 2) & 0xFF):02X} negsum=0x{((0x100 - sum8) & 0xFF):02X} "
|
|
643
|
+
f"xor=0x{xor8:02X} crc8_maxim=0x{_crc8_maxim(body):02X} "
|
|
644
|
+
f"tail8=[{last_words}]"
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
def _get_active_ir_dump_pending(
|
|
648
|
+
self,
|
|
649
|
+
*,
|
|
650
|
+
device_id: int | None = None,
|
|
651
|
+
burst_kind: str | None = None,
|
|
652
|
+
) -> tuple[tuple[int, int], dict[str, Any]] | tuple[None, None]:
|
|
653
|
+
if burst_kind and burst_kind.startswith("ir_dump:"):
|
|
654
|
+
parts = burst_kind.split(":")
|
|
655
|
+
if len(parts) >= 3:
|
|
656
|
+
try:
|
|
657
|
+
key = (int(parts[1]) & 0xFF, int(parts[2]) & 0xFF)
|
|
658
|
+
except ValueError:
|
|
659
|
+
key = None
|
|
660
|
+
if key is not None:
|
|
661
|
+
pending = self._ir_dump_pending.get(key)
|
|
662
|
+
if pending is not None:
|
|
663
|
+
return key, pending
|
|
664
|
+
|
|
665
|
+
if device_id is None:
|
|
666
|
+
return None, None
|
|
667
|
+
|
|
668
|
+
dev_lo = device_id & 0xFF
|
|
669
|
+
for key, pending in self._ir_dump_pending.items():
|
|
670
|
+
if key[0] == dev_lo and not pending["event"].is_set():
|
|
671
|
+
return key, pending
|
|
672
|
+
|
|
673
|
+
return None, None
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
__all__ = ["IrBlobMixin"]
|