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,660 @@
|
|
|
1
|
+
"""Ack-queue + burst-wait mixin for :class:`X1Proxy`.
|
|
2
|
+
|
|
3
|
+
Owns the synchronisation primitives the proxy uses to translate the
|
|
4
|
+
async hub-side opcode stream into blocking ``wait_for_*`` calls suitable
|
|
5
|
+
for driving multi-step orchestration sequences. Three distinct queues
|
|
6
|
+
live behind this surface:
|
|
7
|
+
|
|
8
|
+
* the generic ack queue (filled by :meth:`notify_ack`, consumed by
|
|
9
|
+
:meth:`wait_for_ack` / :meth:`wait_for_ack_any`);
|
|
10
|
+
* the macro-record cache, keyed by ``(activity_id, key_id)``;
|
|
11
|
+
* the activity-inputs burst buffer, which also recognises a hub-side
|
|
12
|
+
STATUS_ACK rejection of the in-flight ``REQ_ACTIVITY_INPUTS`` and
|
|
13
|
+
surfaces it as :attr:`AckOutcome.rejected`.
|
|
14
|
+
|
|
15
|
+
The ``query_device_input_index`` / ``fetch_device_input_entries`` helpers
|
|
16
|
+
live here because they are pure consumers of the inputs burst.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
from .ack import AckOutcome, InputsBurstResult
|
|
24
|
+
from .inputs import parse_inputs_burst
|
|
25
|
+
from .macros import MacroRecord
|
|
26
|
+
from .protocol_const import (
|
|
27
|
+
FAMILY_KEY_SORT_REQ,
|
|
28
|
+
OP_REQ_ACTIVITY_INPUTS,
|
|
29
|
+
OP_STATUS_ACK,
|
|
30
|
+
OPNAMES,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AckWaitersMixin:
|
|
35
|
+
"""Mixin providing ack-queue management and burst waits."""
|
|
36
|
+
|
|
37
|
+
def reset_ack_queues(self) -> None:
|
|
38
|
+
with self._pending_assigned_device_lock:
|
|
39
|
+
self._pending_assigned_device_event.clear()
|
|
40
|
+
self._pending_assigned_device_id = None
|
|
41
|
+
with self._ack_queue_lock:
|
|
42
|
+
self._ack_queue.clear()
|
|
43
|
+
self._ack_event.clear()
|
|
44
|
+
with self._macro_payload_lock:
|
|
45
|
+
self._macro_payload_events.clear()
|
|
46
|
+
self._macro_payload_event.clear()
|
|
47
|
+
with self._device_key_sort_lock:
|
|
48
|
+
self._device_key_sort_pending = None
|
|
49
|
+
self._device_key_sort_expected_pages = None
|
|
50
|
+
self._device_key_sort_pages.clear()
|
|
51
|
+
with self._activity_inputs_lock:
|
|
52
|
+
self._activity_inputs_seen = 0
|
|
53
|
+
self._activity_inputs_last_ts = 0.0
|
|
54
|
+
self._activity_inputs_event.clear()
|
|
55
|
+
|
|
56
|
+
def set_assigned_device_id(self, device_id: int) -> None:
|
|
57
|
+
with self._pending_assigned_device_lock:
|
|
58
|
+
self._pending_assigned_device_id = device_id & 0xFF
|
|
59
|
+
self._pending_assigned_device_event.set()
|
|
60
|
+
|
|
61
|
+
def wait_for_assigned_device_id(self, timeout: float = 5.0) -> int | None:
|
|
62
|
+
self._pending_assigned_device_event.wait(timeout)
|
|
63
|
+
with self._pending_assigned_device_lock:
|
|
64
|
+
return self._pending_assigned_device_id
|
|
65
|
+
|
|
66
|
+
def notify_ack(self, opcode: int, payload: bytes) -> None:
|
|
67
|
+
with self._ack_queue_lock:
|
|
68
|
+
self._ack_queue.append((opcode, payload, time.monotonic()))
|
|
69
|
+
self._ack_event.set()
|
|
70
|
+
name = OPNAMES.get(opcode, f"OP_{opcode:04X}")
|
|
71
|
+
if opcode == OP_STATUS_ACK:
|
|
72
|
+
status = payload[0] if payload else None
|
|
73
|
+
if status == 0x00:
|
|
74
|
+
detail = "accepted"
|
|
75
|
+
elif status == 0x0C:
|
|
76
|
+
detail = "rejected"
|
|
77
|
+
elif status is None:
|
|
78
|
+
detail = "empty-payload"
|
|
79
|
+
else:
|
|
80
|
+
detail = f"status=0x{status:02X}"
|
|
81
|
+
self._log.info("[ACK] %s (0x%04X) %s", name, opcode, detail)
|
|
82
|
+
# If we are waiting on REQ_ACTIVITY_INPUTS and no inputs frame has
|
|
83
|
+
# arrived yet, a non-zero STATUS_ACK is the hub's rejection of
|
|
84
|
+
# that request (commonly status=0x07 for "device not configured
|
|
85
|
+
# for power/inputs yet"). Trip the event so the wait can exit
|
|
86
|
+
# early instead of timing out after the full window.
|
|
87
|
+
if status is not None and status != 0x00:
|
|
88
|
+
self.note_catalog_status_ack(status)
|
|
89
|
+
with self._activity_inputs_lock:
|
|
90
|
+
if self._activity_inputs_pending and self._activity_inputs_seen == 0:
|
|
91
|
+
self._inputs_burst_reject_pending = True
|
|
92
|
+
self._activity_inputs_event.set()
|
|
93
|
+
return
|
|
94
|
+
self._log.info("[ACK] %s (0x%04X) payload_len=%d", name, opcode, len(payload))
|
|
95
|
+
|
|
96
|
+
def clear_ack_queue(self) -> None:
|
|
97
|
+
with self._ack_queue_lock:
|
|
98
|
+
self._ack_queue.clear()
|
|
99
|
+
self._ack_event.clear()
|
|
100
|
+
|
|
101
|
+
def wait_for_ack(
|
|
102
|
+
self,
|
|
103
|
+
opcode: int,
|
|
104
|
+
*,
|
|
105
|
+
first_byte: int | None = None,
|
|
106
|
+
timeout: float = 5.0,
|
|
107
|
+
not_before: float | None = None,
|
|
108
|
+
) -> bool:
|
|
109
|
+
deadline = time.monotonic() + timeout
|
|
110
|
+
while True:
|
|
111
|
+
with self._ack_queue_lock:
|
|
112
|
+
for ack_opcode, ack_payload, ack_ts in self._ack_queue:
|
|
113
|
+
if ack_opcode != opcode:
|
|
114
|
+
continue
|
|
115
|
+
if not_before is not None and ack_ts < not_before:
|
|
116
|
+
continue
|
|
117
|
+
if first_byte is not None and (not ack_payload or ack_payload[0] != (first_byte & 0xFF)):
|
|
118
|
+
continue
|
|
119
|
+
self._ack_queue.remove((ack_opcode, ack_payload, ack_ts))
|
|
120
|
+
if not self._ack_queue:
|
|
121
|
+
self._ack_event.clear()
|
|
122
|
+
return True
|
|
123
|
+
self._ack_event.clear()
|
|
124
|
+
|
|
125
|
+
remaining = deadline - time.monotonic()
|
|
126
|
+
if remaining <= 0:
|
|
127
|
+
self._log.warning(
|
|
128
|
+
"[ACK] timeout waiting opcode=0x%04X first_byte=%s",
|
|
129
|
+
opcode,
|
|
130
|
+
f"0x{first_byte:02X}" if first_byte is not None else "*",
|
|
131
|
+
)
|
|
132
|
+
return False
|
|
133
|
+
self._ack_event.wait(min(remaining, 0.2))
|
|
134
|
+
|
|
135
|
+
def _wait_for_ack_any_impl(
|
|
136
|
+
self,
|
|
137
|
+
candidates: list[tuple[int, int | None]],
|
|
138
|
+
*,
|
|
139
|
+
timeout: float = 5.0,
|
|
140
|
+
not_before: float | None = None,
|
|
141
|
+
log_timeout: bool,
|
|
142
|
+
) -> tuple[int, bytes] | None:
|
|
143
|
+
deadline = time.monotonic() + timeout
|
|
144
|
+
while True:
|
|
145
|
+
with self._ack_queue_lock:
|
|
146
|
+
for ack_opcode, ack_payload, ack_ts in self._ack_queue:
|
|
147
|
+
for want_opcode, want_first_byte in candidates:
|
|
148
|
+
if ack_opcode != want_opcode:
|
|
149
|
+
continue
|
|
150
|
+
if not_before is not None and ack_ts < not_before:
|
|
151
|
+
continue
|
|
152
|
+
if want_first_byte is not None and (not ack_payload or ack_payload[0] != (want_first_byte & 0xFF)):
|
|
153
|
+
continue
|
|
154
|
+
self._ack_queue.remove((ack_opcode, ack_payload, ack_ts))
|
|
155
|
+
if not self._ack_queue:
|
|
156
|
+
self._ack_event.clear()
|
|
157
|
+
return ack_opcode, ack_payload
|
|
158
|
+
self._ack_event.clear()
|
|
159
|
+
|
|
160
|
+
remaining = deadline - time.monotonic()
|
|
161
|
+
if remaining <= 0:
|
|
162
|
+
wanted = ", ".join(
|
|
163
|
+
f"0x{op:04X}/{('*' if first is None else f'0x{first:02X}') }" for op, first in candidates
|
|
164
|
+
)
|
|
165
|
+
if log_timeout:
|
|
166
|
+
self._log.warning("[ACK] timeout waiting any in [%s]", wanted)
|
|
167
|
+
return None
|
|
168
|
+
self._ack_event.wait(min(remaining, 0.2))
|
|
169
|
+
|
|
170
|
+
def wait_for_ack_any(
|
|
171
|
+
self,
|
|
172
|
+
candidates: list[tuple[int, int | None]],
|
|
173
|
+
*,
|
|
174
|
+
timeout: float = 5.0,
|
|
175
|
+
not_before: float | None = None,
|
|
176
|
+
) -> tuple[int, bytes] | None:
|
|
177
|
+
return self._wait_for_ack_any_impl(
|
|
178
|
+
candidates,
|
|
179
|
+
timeout=timeout,
|
|
180
|
+
not_before=not_before,
|
|
181
|
+
log_timeout=True,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def wait_for_ack_family_low(
|
|
185
|
+
self,
|
|
186
|
+
family_low: int,
|
|
187
|
+
*,
|
|
188
|
+
timeout: float = 5.0,
|
|
189
|
+
not_before: float | None = None,
|
|
190
|
+
) -> tuple[int, bytes] | None:
|
|
191
|
+
"""Wait for the next queued frame whose opcode low byte matches.
|
|
192
|
+
|
|
193
|
+
Some hub responses use a variable-length payload whose size is
|
|
194
|
+
encoded in the opcode high byte, so callers cannot pin a single
|
|
195
|
+
16-bit opcode value ahead of time.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
deadline = time.monotonic() + timeout
|
|
199
|
+
target_family = family_low & 0xFF
|
|
200
|
+
while True:
|
|
201
|
+
with self._ack_queue_lock:
|
|
202
|
+
for ack_opcode, ack_payload, ack_ts in self._ack_queue:
|
|
203
|
+
if (ack_opcode & 0xFF) != target_family:
|
|
204
|
+
continue
|
|
205
|
+
if not_before is not None and ack_ts < not_before:
|
|
206
|
+
continue
|
|
207
|
+
self._ack_queue.remove((ack_opcode, ack_payload, ack_ts))
|
|
208
|
+
if not self._ack_queue:
|
|
209
|
+
self._ack_event.clear()
|
|
210
|
+
return ack_opcode, ack_payload
|
|
211
|
+
self._ack_event.clear()
|
|
212
|
+
|
|
213
|
+
remaining = deadline - time.monotonic()
|
|
214
|
+
if remaining <= 0:
|
|
215
|
+
self._log.warning(
|
|
216
|
+
"[ACK] timeout waiting family(low)=0x%02X",
|
|
217
|
+
target_family,
|
|
218
|
+
)
|
|
219
|
+
return None
|
|
220
|
+
self._ack_event.wait(min(remaining, 0.2))
|
|
221
|
+
|
|
222
|
+
def wait_for_any_response(
|
|
223
|
+
self,
|
|
224
|
+
*,
|
|
225
|
+
timeout: float,
|
|
226
|
+
not_before: float,
|
|
227
|
+
poll_interval: float = 0.2,
|
|
228
|
+
disconnect_check=None,
|
|
229
|
+
) -> tuple[int, bytes] | None:
|
|
230
|
+
"""Wait for the next frame *of any opcode* arriving after ``not_before``.
|
|
231
|
+
|
|
232
|
+
Unlike :meth:`wait_for_ack_any` this does not filter by opcode --
|
|
233
|
+
the first queued frame whose timestamp is ``>= not_before`` is
|
|
234
|
+
consumed and returned. Intended for opcodes whose response
|
|
235
|
+
family the integration deliberately does not pin down (e.g.
|
|
236
|
+
the hub-erase opcode, which is treated as fire-and-forget once
|
|
237
|
+
any reply comes back).
|
|
238
|
+
|
|
239
|
+
``disconnect_check`` is an optional zero-arg callable returning
|
|
240
|
+
``True`` when the underlying transport has dropped *before* a
|
|
241
|
+
response arrived. When supplied and it returns ``True``, the
|
|
242
|
+
wait exits immediately with ``None`` so the caller can
|
|
243
|
+
distinguish "hub didn't answer" from "hub disconnected without
|
|
244
|
+
answering". ``poll_interval`` bounds how quickly that check
|
|
245
|
+
runs (defaults to 200 ms).
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
deadline = time.monotonic() + timeout
|
|
249
|
+
while True:
|
|
250
|
+
with self._ack_queue_lock:
|
|
251
|
+
for ack_opcode, ack_payload, ack_ts in self._ack_queue:
|
|
252
|
+
if ack_ts < not_before:
|
|
253
|
+
continue
|
|
254
|
+
self._ack_queue.remove((ack_opcode, ack_payload, ack_ts))
|
|
255
|
+
if not self._ack_queue:
|
|
256
|
+
self._ack_event.clear()
|
|
257
|
+
return ack_opcode, ack_payload
|
|
258
|
+
self._ack_event.clear()
|
|
259
|
+
|
|
260
|
+
if disconnect_check is not None and disconnect_check():
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
remaining = deadline - time.monotonic()
|
|
264
|
+
if remaining <= 0:
|
|
265
|
+
return None
|
|
266
|
+
self._ack_event.wait(min(remaining, poll_interval))
|
|
267
|
+
|
|
268
|
+
def cache_macro_record(self, record: MacroRecord) -> None:
|
|
269
|
+
"""Store a fully-assembled :class:`MacroRecord` keyed by ``(activity_id, key_id)``."""
|
|
270
|
+
|
|
271
|
+
key = (record.activity_id & 0xFF, record.key_id & 0xFF)
|
|
272
|
+
with self._macro_payload_lock:
|
|
273
|
+
self._macro_payload_events[key] = record
|
|
274
|
+
self._macro_payload_event.set()
|
|
275
|
+
|
|
276
|
+
def wait_for_macro_record(
|
|
277
|
+
self, activity_id: int, button_id: int, *, timeout: float = 5.0
|
|
278
|
+
) -> MacroRecord | None:
|
|
279
|
+
"""Wait until the macro for ``(activity_id, button_id)`` has been assembled."""
|
|
280
|
+
|
|
281
|
+
key = (activity_id & 0xFF, button_id & 0xFF)
|
|
282
|
+
deadline = time.monotonic() + timeout
|
|
283
|
+
while True:
|
|
284
|
+
with self._macro_payload_lock:
|
|
285
|
+
cached = self._macro_payload_events.pop(key, None)
|
|
286
|
+
if cached is not None:
|
|
287
|
+
if not self._macro_payload_events:
|
|
288
|
+
self._macro_payload_event.clear()
|
|
289
|
+
return cached
|
|
290
|
+
self._macro_payload_event.clear()
|
|
291
|
+
|
|
292
|
+
remaining = deadline - time.monotonic()
|
|
293
|
+
if remaining <= 0:
|
|
294
|
+
return None
|
|
295
|
+
self._macro_payload_event.wait(min(remaining, 0.2))
|
|
296
|
+
|
|
297
|
+
def get_cached_macro_records(self, activity_id: int) -> list[MacroRecord]:
|
|
298
|
+
"""Return cached assembled macro records for ``activity_id`` without consuming them."""
|
|
299
|
+
|
|
300
|
+
act_lo = activity_id & 0xFF
|
|
301
|
+
with self._macro_payload_lock:
|
|
302
|
+
records = [
|
|
303
|
+
record
|
|
304
|
+
for (cached_act_id, _button_id), record in self._macro_payload_events.items()
|
|
305
|
+
if (cached_act_id & 0xFF) == act_lo
|
|
306
|
+
]
|
|
307
|
+
records.sort(key=lambda record: record.key_id & 0xFF)
|
|
308
|
+
return records
|
|
309
|
+
|
|
310
|
+
def notify_activity_inputs_frame(self, payload: bytes = b"") -> None:
|
|
311
|
+
with self._activity_inputs_lock:
|
|
312
|
+
self._activity_inputs_payloads.append(bytes(payload))
|
|
313
|
+
self._activity_inputs_seen += 1
|
|
314
|
+
self._activity_inputs_last_ts = time.monotonic()
|
|
315
|
+
self._activity_inputs_event.set()
|
|
316
|
+
|
|
317
|
+
def _try_handle_device_key_sort_payload(self, payload: bytes) -> bool:
|
|
318
|
+
"""Assemble the hub's family-0x63 device key-sort response."""
|
|
319
|
+
|
|
320
|
+
with self._device_key_sort_lock:
|
|
321
|
+
pending_device_id = self._device_key_sort_pending
|
|
322
|
+
if pending_device_id is None:
|
|
323
|
+
return False
|
|
324
|
+
if len(payload) < 7:
|
|
325
|
+
return False
|
|
326
|
+
|
|
327
|
+
page_no = int.from_bytes(payload[1:3], "big")
|
|
328
|
+
chunk = bytes(payload[3:])
|
|
329
|
+
if page_no == 1:
|
|
330
|
+
if len(chunk) < 4 or chunk[0] != 0x01:
|
|
331
|
+
return False
|
|
332
|
+
expected_pages = int.from_bytes(chunk[1:3], "big")
|
|
333
|
+
reported_device_id = chunk[3] & 0xFF
|
|
334
|
+
if reported_device_id != pending_device_id:
|
|
335
|
+
self._log.warning(
|
|
336
|
+
"[KEY_SORT] pending dev=0x%02X but hub replied for dev=0x%02X",
|
|
337
|
+
pending_device_id,
|
|
338
|
+
reported_device_id,
|
|
339
|
+
)
|
|
340
|
+
self._device_key_sort_pending = None
|
|
341
|
+
self._device_key_sort_expected_pages = None
|
|
342
|
+
self._device_key_sort_pages.clear()
|
|
343
|
+
return False
|
|
344
|
+
self._device_key_sort_expected_pages = max(1, expected_pages)
|
|
345
|
+
self._device_key_sort_pages.clear()
|
|
346
|
+
|
|
347
|
+
expected_pages = self._device_key_sort_expected_pages
|
|
348
|
+
if not expected_pages:
|
|
349
|
+
return False
|
|
350
|
+
|
|
351
|
+
self._device_key_sort_pages[page_no] = chunk
|
|
352
|
+
if any(
|
|
353
|
+
page not in self._device_key_sort_pages
|
|
354
|
+
for page in range(1, expected_pages + 1)
|
|
355
|
+
):
|
|
356
|
+
return True
|
|
357
|
+
|
|
358
|
+
assembled = b"".join(
|
|
359
|
+
self._device_key_sort_pages[page]
|
|
360
|
+
for page in range(1, expected_pages + 1)
|
|
361
|
+
)
|
|
362
|
+
device_id = assembled[3] & 0xFF if len(assembled) >= 4 else pending_device_id
|
|
363
|
+
# The inbound family-0x63 body carries no trailing checksum
|
|
364
|
+
# (only the frame-level checksum, which the caller has already
|
|
365
|
+
# stripped from ``payload``). Mirror the official KeySortGets,
|
|
366
|
+
# which takes ``bArr[4:]`` verbatim -- earlier code chopped the
|
|
367
|
+
# last byte and turned the final ``(slot, 0xFF)`` pair into an
|
|
368
|
+
# orphan ``slot`` byte, which the destination hub then rejected
|
|
369
|
+
# with status=0x03 on restore.
|
|
370
|
+
msg_bytes = assembled[4:] if len(assembled) >= 4 else b""
|
|
371
|
+
self.state.device_key_sorts[device_id] = {
|
|
372
|
+
"device_id": device_id,
|
|
373
|
+
"msg_hex": msg_bytes.hex(" ").strip(),
|
|
374
|
+
}
|
|
375
|
+
self._device_key_sort_pending = None
|
|
376
|
+
self._device_key_sort_expected_pages = None
|
|
377
|
+
self._device_key_sort_pages.clear()
|
|
378
|
+
|
|
379
|
+
self.notify_ack(0xFF62, bytes([device_id]))
|
|
380
|
+
return True
|
|
381
|
+
|
|
382
|
+
def wait_for_activity_inputs_burst(
|
|
383
|
+
self,
|
|
384
|
+
*,
|
|
385
|
+
timeout: float = 5.0,
|
|
386
|
+
idle_window: float = 0.35,
|
|
387
|
+
min_frames: int = 1,
|
|
388
|
+
) -> InputsBurstResult:
|
|
389
|
+
"""Wait until at least one 0x47 frame arrives and the burst goes idle.
|
|
390
|
+
|
|
391
|
+
Returns an :class:`InputsBurstResult` whose ``outcome``
|
|
392
|
+
distinguishes:
|
|
393
|
+
|
|
394
|
+
* :attr:`AckOutcome.acked` -- the burst arrived and went idle;
|
|
395
|
+
``payloads`` carries a snapshot of the assembled frames and
|
|
396
|
+
the proxy's internal buffer is cleared.
|
|
397
|
+
* :attr:`AckOutcome.rejected` -- the hub answered the in-flight
|
|
398
|
+
``REQ_ACTIVITY_INPUTS`` with a non-zero ``STATUS_ACK``.
|
|
399
|
+
* :attr:`AckOutcome.timeout` -- nothing arrived before
|
|
400
|
+
``timeout``.
|
|
401
|
+
"""
|
|
402
|
+
|
|
403
|
+
deadline = time.monotonic() + timeout
|
|
404
|
+
while True:
|
|
405
|
+
now = time.monotonic()
|
|
406
|
+
with self._activity_inputs_lock:
|
|
407
|
+
if self._inputs_burst_reject_pending:
|
|
408
|
+
self._inputs_burst_reject_pending = False
|
|
409
|
+
self._activity_inputs_seen = 0
|
|
410
|
+
self._activity_inputs_last_ts = 0.0
|
|
411
|
+
self._activity_inputs_payloads.clear()
|
|
412
|
+
self._activity_inputs_event.clear()
|
|
413
|
+
return InputsBurstResult(outcome=AckOutcome.rejected)
|
|
414
|
+
seen = self._activity_inputs_seen
|
|
415
|
+
last_ts = self._activity_inputs_last_ts
|
|
416
|
+
if seen >= min_frames and last_ts > 0 and (now - last_ts) >= idle_window:
|
|
417
|
+
payloads = tuple(self._activity_inputs_payloads)
|
|
418
|
+
self._activity_inputs_payloads.clear()
|
|
419
|
+
self._activity_inputs_seen = 0
|
|
420
|
+
self._activity_inputs_last_ts = 0.0
|
|
421
|
+
self._activity_inputs_event.clear()
|
|
422
|
+
return InputsBurstResult(
|
|
423
|
+
outcome=AckOutcome.acked,
|
|
424
|
+
payloads=payloads,
|
|
425
|
+
)
|
|
426
|
+
self._activity_inputs_event.clear()
|
|
427
|
+
|
|
428
|
+
remaining = deadline - now
|
|
429
|
+
if remaining <= 0:
|
|
430
|
+
return InputsBurstResult(outcome=AckOutcome.timeout)
|
|
431
|
+
self._activity_inputs_event.wait(min(remaining, 0.2))
|
|
432
|
+
|
|
433
|
+
def query_device_input_index(self, device_id: int, cmd_id: int, *, timeout: float = 5.0) -> int | None:
|
|
434
|
+
"""Return the 1-based ordinal of cmd_id in the device's ACTIVITY_INPUTS list, or None if not found."""
|
|
435
|
+
with self._activity_inputs_lock:
|
|
436
|
+
self._activity_inputs_payloads.clear()
|
|
437
|
+
self._activity_inputs_seen = 0
|
|
438
|
+
self._activity_inputs_last_ts = 0.0
|
|
439
|
+
self._activity_inputs_event.clear()
|
|
440
|
+
|
|
441
|
+
self._send_cmd_frame(OP_REQ_ACTIVITY_INPUTS, bytes([device_id & 0xFF]))
|
|
442
|
+
burst = self.wait_for_activity_inputs_burst(timeout=timeout)
|
|
443
|
+
if burst.outcome is AckOutcome.rejected:
|
|
444
|
+
self._log.info(
|
|
445
|
+
"[INPUT_QUERY] hub rejected inputs request dev=0x%02X cmd=0x%02X",
|
|
446
|
+
device_id & 0xFF,
|
|
447
|
+
cmd_id & 0xFF,
|
|
448
|
+
)
|
|
449
|
+
return None
|
|
450
|
+
if burst.outcome is AckOutcome.timeout:
|
|
451
|
+
self._log.warning(
|
|
452
|
+
"[INPUT_QUERY] timeout waiting for inputs dev=0x%02X cmd=0x%02X",
|
|
453
|
+
device_id & 0xFF,
|
|
454
|
+
cmd_id & 0xFF,
|
|
455
|
+
)
|
|
456
|
+
return None
|
|
457
|
+
|
|
458
|
+
record = parse_inputs_burst(list(burst.payloads), hub_version=self.hub_version)
|
|
459
|
+
for index, entry in enumerate(record.entries, start=1):
|
|
460
|
+
if entry.key_id == (cmd_id & 0xFF):
|
|
461
|
+
# X1S/X2 stores an explicit 1-based ordinal on each entry;
|
|
462
|
+
# X1 has no ordinal byte and we report the positional
|
|
463
|
+
# index of the entry in the list.
|
|
464
|
+
return entry.ordinal or index
|
|
465
|
+
|
|
466
|
+
self._log.warning(
|
|
467
|
+
"[INPUT_QUERY] cmd_id=0x%02X not found in %d entries for dev=0x%02X",
|
|
468
|
+
cmd_id & 0xFF,
|
|
469
|
+
len(record.entries),
|
|
470
|
+
device_id & 0xFF,
|
|
471
|
+
)
|
|
472
|
+
return None
|
|
473
|
+
|
|
474
|
+
def fetch_device_input_entries(
|
|
475
|
+
self,
|
|
476
|
+
device_id: int,
|
|
477
|
+
*,
|
|
478
|
+
timeout: float = 5.0,
|
|
479
|
+
) -> list[dict[str, int]] | None:
|
|
480
|
+
"""Return fresh input ordering rows for ``device_id``.
|
|
481
|
+
|
|
482
|
+
Each returned row has ``command_id`` and 1-based ``input_index``.
|
|
483
|
+
|
|
484
|
+
Returns ``None`` only when the hub does not answer at all before
|
|
485
|
+
``timeout``. When the hub *does* answer but with a non-zero
|
|
486
|
+
STATUS_ACK (e.g. ``0x07`` for a device that has not been
|
|
487
|
+
configured for power/inputs), an empty list is returned --
|
|
488
|
+
semantically "this device has no input entries", which is what a
|
|
489
|
+
faithful backup needs.
|
|
490
|
+
"""
|
|
491
|
+
|
|
492
|
+
with self._activity_inputs_lock:
|
|
493
|
+
self._activity_inputs_payloads.clear()
|
|
494
|
+
self._activity_inputs_seen = 0
|
|
495
|
+
self._activity_inputs_last_ts = 0.0
|
|
496
|
+
self._activity_inputs_event.clear()
|
|
497
|
+
self._inputs_burst_reject_pending = False
|
|
498
|
+
self._activity_inputs_pending = True
|
|
499
|
+
|
|
500
|
+
try:
|
|
501
|
+
self._send_cmd_frame(OP_REQ_ACTIVITY_INPUTS, bytes([device_id & 0xFF]))
|
|
502
|
+
burst = self.wait_for_activity_inputs_burst(timeout=timeout)
|
|
503
|
+
finally:
|
|
504
|
+
with self._activity_inputs_lock:
|
|
505
|
+
self._activity_inputs_pending = False
|
|
506
|
+
|
|
507
|
+
if burst.outcome is AckOutcome.rejected:
|
|
508
|
+
self._log.info(
|
|
509
|
+
"[INPUT_QUERY] hub returned non-success status for dev=0x%02X; "
|
|
510
|
+
"treating as no inputs configured",
|
|
511
|
+
device_id & 0xFF,
|
|
512
|
+
)
|
|
513
|
+
return []
|
|
514
|
+
if burst.outcome is AckOutcome.timeout:
|
|
515
|
+
self._log.warning(
|
|
516
|
+
"[INPUT_QUERY] timeout waiting for full inputs list dev=0x%02X",
|
|
517
|
+
device_id & 0xFF,
|
|
518
|
+
)
|
|
519
|
+
return None
|
|
520
|
+
|
|
521
|
+
record = parse_inputs_burst(list(burst.payloads), hub_version=self.hub_version)
|
|
522
|
+
return [
|
|
523
|
+
{
|
|
524
|
+
"command_id": entry.key_id & 0xFF,
|
|
525
|
+
"input_index": (entry.ordinal or index) & 0xFF,
|
|
526
|
+
}
|
|
527
|
+
for index, entry in enumerate(record.entries, start=1)
|
|
528
|
+
]
|
|
529
|
+
|
|
530
|
+
def fetch_device_input_record(
|
|
531
|
+
self,
|
|
532
|
+
device_id: int,
|
|
533
|
+
*,
|
|
534
|
+
timeout: float = 5.0,
|
|
535
|
+
) -> dict[str, object] | None:
|
|
536
|
+
"""Return the full parsed family-0x46 record for ``device_id``.
|
|
537
|
+
|
|
538
|
+
The backup flow uses this richer form on X1 so restore can
|
|
539
|
+
preserve the trailing control-key/favorite rows, which are not
|
|
540
|
+
represented in the simplified ``fetch_device_input_entries``
|
|
541
|
+
surface.
|
|
542
|
+
"""
|
|
543
|
+
|
|
544
|
+
with self._activity_inputs_lock:
|
|
545
|
+
self._activity_inputs_payloads.clear()
|
|
546
|
+
self._activity_inputs_seen = 0
|
|
547
|
+
self._activity_inputs_last_ts = 0.0
|
|
548
|
+
self._activity_inputs_event.clear()
|
|
549
|
+
self._inputs_burst_reject_pending = False
|
|
550
|
+
self._activity_inputs_pending = True
|
|
551
|
+
|
|
552
|
+
try:
|
|
553
|
+
self._send_cmd_frame(OP_REQ_ACTIVITY_INPUTS, bytes([device_id & 0xFF]))
|
|
554
|
+
burst = self.wait_for_activity_inputs_burst(timeout=timeout)
|
|
555
|
+
finally:
|
|
556
|
+
with self._activity_inputs_lock:
|
|
557
|
+
self._activity_inputs_pending = False
|
|
558
|
+
|
|
559
|
+
if burst.outcome is AckOutcome.rejected:
|
|
560
|
+
self._log.info(
|
|
561
|
+
"[INPUT_QUERY] hub returned non-success status for dev=0x%02X; "
|
|
562
|
+
"treating as no inputs configured",
|
|
563
|
+
device_id & 0xFF,
|
|
564
|
+
)
|
|
565
|
+
return None
|
|
566
|
+
if burst.outcome is AckOutcome.timeout:
|
|
567
|
+
self._log.warning(
|
|
568
|
+
"[INPUT_QUERY] timeout waiting for full input record dev=0x%02X",
|
|
569
|
+
device_id & 0xFF,
|
|
570
|
+
)
|
|
571
|
+
return None
|
|
572
|
+
|
|
573
|
+
record = parse_inputs_burst(list(burst.payloads), hub_version=self.hub_version)
|
|
574
|
+
return {
|
|
575
|
+
"device_id": record.device_id & 0xFF,
|
|
576
|
+
"source_id_byte": record.source_id_byte & 0xFF,
|
|
577
|
+
"flag_a": record.flag_a & 0xFF,
|
|
578
|
+
"flag_b": record.flag_b & 0xFF,
|
|
579
|
+
"state_byte": record.state_byte & 0xFF,
|
|
580
|
+
"entries": [
|
|
581
|
+
{
|
|
582
|
+
"command_id": entry.key_id & 0xFF,
|
|
583
|
+
"input_index": (entry.ordinal or index) & 0xFF,
|
|
584
|
+
"fid": entry.fid & 0xFFFFFFFFFFFF,
|
|
585
|
+
"name": entry.label,
|
|
586
|
+
}
|
|
587
|
+
for index, entry in enumerate(record.entries, start=1)
|
|
588
|
+
],
|
|
589
|
+
"control_keys": {
|
|
590
|
+
"input_list": record.control_keys.input_list.hex(" "),
|
|
591
|
+
"input_up": record.control_keys.input_up.hex(" "),
|
|
592
|
+
"input_down": record.control_keys.input_down.hex(" "),
|
|
593
|
+
"input_confirm": record.control_keys.input_confirm.hex(" "),
|
|
594
|
+
},
|
|
595
|
+
"favorites": [
|
|
596
|
+
slot.payload.hex(" ") for slot in record.favorites
|
|
597
|
+
],
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
def fetch_device_key_sort(
|
|
601
|
+
self,
|
|
602
|
+
device_id: int,
|
|
603
|
+
*,
|
|
604
|
+
timeout: float = 5.0,
|
|
605
|
+
) -> dict[str, int | str] | None:
|
|
606
|
+
"""Return the hub's raw key-sort blob for ``device_id``."""
|
|
607
|
+
|
|
608
|
+
dev_lo = device_id & 0xFF
|
|
609
|
+
with self._device_key_sort_lock:
|
|
610
|
+
self.state.device_key_sorts.pop(dev_lo, None)
|
|
611
|
+
self._device_key_sort_pending = dev_lo
|
|
612
|
+
self._device_key_sort_expected_pages = None
|
|
613
|
+
self._device_key_sort_pages.clear()
|
|
614
|
+
|
|
615
|
+
send_ts = time.monotonic()
|
|
616
|
+
self._send_family_frame(FAMILY_KEY_SORT_REQ, bytes([dev_lo]))
|
|
617
|
+
# Accept either the family-0x63 paged reply (assembled into
|
|
618
|
+
# state.device_key_sorts and notified as 0xFF62) OR a STATUS_ACK
|
|
619
|
+
# from the hub. The hub replies with STATUS_ACK status=0x07 when
|
|
620
|
+
# the requested device has no key-sort blob configured -- mirror
|
|
621
|
+
# the inputs path and treat that as "device has no key-sort data"
|
|
622
|
+
# (empty msg_hex) rather than waiting out the full timeout.
|
|
623
|
+
result = self.wait_for_ack_any(
|
|
624
|
+
[(0xFF62, dev_lo), (OP_STATUS_ACK, None)],
|
|
625
|
+
timeout=timeout,
|
|
626
|
+
not_before=send_ts,
|
|
627
|
+
)
|
|
628
|
+
if result is None:
|
|
629
|
+
with self._device_key_sort_lock:
|
|
630
|
+
self._device_key_sort_pending = None
|
|
631
|
+
self._device_key_sort_expected_pages = None
|
|
632
|
+
self._device_key_sort_pages.clear()
|
|
633
|
+
self._log.warning(
|
|
634
|
+
"[KEY_SORT] timeout waiting for device sort dev=0x%02X",
|
|
635
|
+
dev_lo,
|
|
636
|
+
)
|
|
637
|
+
return None
|
|
638
|
+
|
|
639
|
+
ack_opcode, ack_payload = result
|
|
640
|
+
if ack_opcode == OP_STATUS_ACK:
|
|
641
|
+
status = ack_payload[0] if ack_payload else None
|
|
642
|
+
with self._device_key_sort_lock:
|
|
643
|
+
self._device_key_sort_pending = None
|
|
644
|
+
self._device_key_sort_expected_pages = None
|
|
645
|
+
self._device_key_sort_pages.clear()
|
|
646
|
+
self._log.info(
|
|
647
|
+
"[KEY_SORT] hub returned STATUS_ACK status=%s for dev=0x%02X; "
|
|
648
|
+
"treating as no key-sort data configured",
|
|
649
|
+
f"0x{status:02X}" if status is not None else "(empty)",
|
|
650
|
+
dev_lo,
|
|
651
|
+
)
|
|
652
|
+
return {"device_id": dev_lo, "msg_hex": ""}
|
|
653
|
+
|
|
654
|
+
cached = self.state.device_key_sorts.get(dev_lo)
|
|
655
|
+
if isinstance(cached, dict):
|
|
656
|
+
return dict(cached)
|
|
657
|
+
return {"device_id": dev_lo, "msg_hex": ""}
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
__all__ = ["AckWaitersMixin"]
|