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,915 @@
|
|
|
1
|
+
"""Catalog request / snapshot / cache mixin for :class:`X1Proxy`.
|
|
2
|
+
|
|
3
|
+
Centralises the read-side traffic against the hub: the
|
|
4
|
+
``request_*`` ↦ burst ↦ ``ingest_*`` ↦ ``_commit_pending_*_snapshot``
|
|
5
|
+
pipeline that keeps ``self.state.devices`` / ``self.state.activities``
|
|
6
|
+
in sync with the hub's authoritative catalog, plus the per-entity
|
|
7
|
+
``get_*`` / ``ensure_*`` / ``clear_entity_cache`` helpers that other
|
|
8
|
+
mixins call when they need a specific row.
|
|
9
|
+
|
|
10
|
+
The IR-dump assembly helpers (``_record_ir_dump_frame``,
|
|
11
|
+
``_build_ir_dump_result``, ``_ir_dump_snapshot_complete``,
|
|
12
|
+
``try_finish_ir_dump_burst``) live here because they share the same
|
|
13
|
+
"per-frame ingest, finish-on-completion" shape as the device/activity
|
|
14
|
+
ingest path.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from .hub_versions import HUB_VERSION_X2
|
|
24
|
+
from .commands import extract_ir_dump_blob, extract_ir_dump_label_field
|
|
25
|
+
from .protocol_const import (
|
|
26
|
+
OP_REQ_ACTIVITY_MAP,
|
|
27
|
+
OP_REQ_BUTTONS,
|
|
28
|
+
OP_REQ_COMMANDS,
|
|
29
|
+
OP_REQ_DEVICES,
|
|
30
|
+
OP_REQ_IPCMD_SYNC,
|
|
31
|
+
OP_REQ_MACRO_LABELS,
|
|
32
|
+
)
|
|
33
|
+
from .state_helpers import normalize_device_entry
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
ACTIVITY_INCOMPLETE_RETRY_DELAY_S = 0.75
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _to_export_view():
|
|
40
|
+
from .x1_proxy import to_export_view
|
|
41
|
+
|
|
42
|
+
return to_export_view
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CatalogMixin:
|
|
46
|
+
"""Mixin providing catalog request, snapshot ingest, and cache reads."""
|
|
47
|
+
|
|
48
|
+
def request_activity_mapping(self, act_id: int) -> bool:
|
|
49
|
+
if not self.can_issue_commands():
|
|
50
|
+
self._log.info("[CMD] request_activity_mapping ignored: proxy client is connected"); return False
|
|
51
|
+
|
|
52
|
+
act_lo = act_id & 0xFF
|
|
53
|
+
if act_lo in self._pending_activity_map_requests:
|
|
54
|
+
self._log.debug(
|
|
55
|
+
"[CMD] request_activity_mapping ignored: burst already pending for 0x%02X",
|
|
56
|
+
act_lo,
|
|
57
|
+
)
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
self._pending_activity_map_requests.add(act_lo)
|
|
61
|
+
self._log.info("[ACTMAP] local request act=0x%02X (%d)", act_lo, act_lo)
|
|
62
|
+
return self.enqueue_cmd(
|
|
63
|
+
OP_REQ_ACTIVITY_MAP,
|
|
64
|
+
bytes([act_lo]),
|
|
65
|
+
expects_burst=True,
|
|
66
|
+
burst_kind=f"activity_map:{act_lo}",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def request_macros_for_activity(self, act_id: int) -> bool:
|
|
70
|
+
if not self.can_issue_commands():
|
|
71
|
+
self._log.info("[CMD] request_macros_for_activity ignored: proxy client is connected"); return False
|
|
72
|
+
|
|
73
|
+
act_lo = act_id & 0xFF
|
|
74
|
+
if act_lo in self._pending_macro_requests:
|
|
75
|
+
self._log.debug(
|
|
76
|
+
"[CMD] request_macros_for_activity ignored: burst already pending for 0x%02X",
|
|
77
|
+
act_lo,
|
|
78
|
+
)
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
self._pending_macro_requests.add(act_lo)
|
|
82
|
+
return self.enqueue_cmd(
|
|
83
|
+
OP_REQ_MACRO_LABELS,
|
|
84
|
+
bytes([act_lo, 0xFF]),
|
|
85
|
+
expects_burst=True,
|
|
86
|
+
burst_kind=f"macros:{act_lo}",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def request_ip_commands_for_device(self, dev_id: int, *, wait: bool = False, timeout: float = 1.0) -> bool:
|
|
90
|
+
"""Fetch IP command definitions for an existing device."""
|
|
91
|
+
|
|
92
|
+
if not self.can_issue_commands():
|
|
93
|
+
self._log.info("[CMD] request_ip_commands_for_device ignored: proxy client is connected"); return False
|
|
94
|
+
|
|
95
|
+
dev_lo = dev_id & 0xFF
|
|
96
|
+
event = threading.Event() if wait else None
|
|
97
|
+
|
|
98
|
+
if event:
|
|
99
|
+
def _done(_: str) -> None:
|
|
100
|
+
event.set()
|
|
101
|
+
|
|
102
|
+
self._burst.on_burst_end(f"commands:{dev_lo}", _done)
|
|
103
|
+
|
|
104
|
+
ok = self.enqueue_cmd(
|
|
105
|
+
OP_REQ_IPCMD_SYNC,
|
|
106
|
+
bytes([dev_lo, 0xFF, 0x14]),
|
|
107
|
+
expects_burst=True,
|
|
108
|
+
burst_kind=f"commands:{dev_lo}",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if event:
|
|
112
|
+
event.wait(timeout)
|
|
113
|
+
|
|
114
|
+
return ok
|
|
115
|
+
|
|
116
|
+
def get_activities(self, *, force_refresh: bool = True) -> tuple[dict[int, dict], bool]:
|
|
117
|
+
to_export_view = _to_export_view()
|
|
118
|
+
if force_refresh:
|
|
119
|
+
if self.can_issue_commands():
|
|
120
|
+
self.request_activities()
|
|
121
|
+
return ({}, False)
|
|
122
|
+
|
|
123
|
+
activities_view = self.state.entities("activity")
|
|
124
|
+
if self._activities_catalog_ready:
|
|
125
|
+
return ({k: to_export_view(v) for k, v in activities_view.items()}, True)
|
|
126
|
+
|
|
127
|
+
return ({}, False)
|
|
128
|
+
|
|
129
|
+
def get_devices(self, *, force_refresh: bool = False) -> tuple[dict[int, dict], bool]:
|
|
130
|
+
to_export_view = _to_export_view()
|
|
131
|
+
if force_refresh:
|
|
132
|
+
if self.can_issue_commands():
|
|
133
|
+
self.enqueue_cmd(OP_REQ_DEVICES, expects_burst=True, burst_kind="devices")
|
|
134
|
+
return ({}, False)
|
|
135
|
+
|
|
136
|
+
devices_view = self.state.entities("device")
|
|
137
|
+
if self._devices_catalog_ready:
|
|
138
|
+
return ({k: to_export_view(v) for k, v in devices_view.items()}, True)
|
|
139
|
+
|
|
140
|
+
if self.can_issue_commands():
|
|
141
|
+
self.enqueue_cmd(OP_REQ_DEVICES, expects_burst=True, burst_kind="devices")
|
|
142
|
+
return ({}, False)
|
|
143
|
+
|
|
144
|
+
def _record_ir_dump_frame(self, parsed, raw_frame: bytes) -> None:
|
|
145
|
+
pending: dict[str, Any] | None = None
|
|
146
|
+
pending_dev_id: int | None = parsed.device_id
|
|
147
|
+
payload = raw_frame[4:-1]
|
|
148
|
+
ir_blob = extract_ir_dump_blob(payload, parsed.page_no)
|
|
149
|
+
label_field = extract_ir_dump_label_field(payload) if parsed.page_no == 1 else None
|
|
150
|
+
|
|
151
|
+
with self._ir_dump_lock:
|
|
152
|
+
_request_key, pending = self._get_active_ir_dump_pending(
|
|
153
|
+
device_id=pending_dev_id,
|
|
154
|
+
burst_kind=str(self._burst.kind or ""),
|
|
155
|
+
)
|
|
156
|
+
if pending is not None and pending_dev_id is None:
|
|
157
|
+
pending_dev_id = int(pending.get("device_id", 0)) & 0xFF
|
|
158
|
+
|
|
159
|
+
if pending is None:
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
if parsed.total_commands and pending.get("total_commands") is None:
|
|
163
|
+
pending["total_commands"] = parsed.total_commands
|
|
164
|
+
pending["last_progress_ts"] = time.monotonic()
|
|
165
|
+
|
|
166
|
+
response_index_map = pending.setdefault("response_index_to_command_id", {})
|
|
167
|
+
if parsed.is_page_one:
|
|
168
|
+
response_index_map[parsed.response_index] = parsed.command_id
|
|
169
|
+
|
|
170
|
+
effective_command_id = response_index_map.get(parsed.response_index, parsed.command_id)
|
|
171
|
+
commands = pending.setdefault("commands", {})
|
|
172
|
+
command_entry = commands.setdefault(
|
|
173
|
+
effective_command_id,
|
|
174
|
+
{
|
|
175
|
+
"command_id": effective_command_id,
|
|
176
|
+
"device_id": pending_dev_id,
|
|
177
|
+
"label": None,
|
|
178
|
+
"format_marker": None,
|
|
179
|
+
"expected_page_count": None,
|
|
180
|
+
"pages": {},
|
|
181
|
+
},
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if pending_dev_id is not None:
|
|
185
|
+
command_entry["device_id"] = pending_dev_id
|
|
186
|
+
if parsed.label:
|
|
187
|
+
command_entry["label"] = parsed.label
|
|
188
|
+
if parsed.format_marker is not None:
|
|
189
|
+
command_entry["format_marker"] = parsed.format_marker
|
|
190
|
+
if parsed.total_pages is not None:
|
|
191
|
+
command_entry["expected_page_count"] = parsed.total_pages
|
|
192
|
+
|
|
193
|
+
command_entry["pages"][parsed.page_no] = {
|
|
194
|
+
"page_no": parsed.page_no,
|
|
195
|
+
"opcode": parsed.opcode,
|
|
196
|
+
"opcode_hex": f"0x{parsed.opcode:04X}",
|
|
197
|
+
"payload_hex": payload.hex(" "),
|
|
198
|
+
"frame_hex": raw_frame.hex(" "),
|
|
199
|
+
"ir_blob_hex": ir_blob.hex(" ") if ir_blob is not None else None,
|
|
200
|
+
"ir_blob_byte_count": len(ir_blob) if ir_blob is not None else None,
|
|
201
|
+
"_ir_blob_bytes": ir_blob,
|
|
202
|
+
"label_field_hex": label_field.hex(" ") if label_field is not None else None,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
def _build_ir_dump_result(self, pending: dict[str, Any]) -> dict[str, Any]:
|
|
206
|
+
commands_out: list[dict[str, Any]] = []
|
|
207
|
+
total_commands = pending.get("total_commands")
|
|
208
|
+
requested_command_id = pending.get("requested_command_id")
|
|
209
|
+
|
|
210
|
+
for command_id in sorted(pending.get("commands", {})):
|
|
211
|
+
record = pending["commands"][command_id]
|
|
212
|
+
raw_page_items = [record["pages"][page_no] for page_no in sorted(record.get("pages", {}))]
|
|
213
|
+
blob_parts = [
|
|
214
|
+
bytes(page["_ir_blob_bytes"])
|
|
215
|
+
for page in raw_page_items
|
|
216
|
+
if page.get("_ir_blob_bytes") is not None
|
|
217
|
+
]
|
|
218
|
+
page_items = [
|
|
219
|
+
{k: v for k, v in page.items() if not k.startswith("_")}
|
|
220
|
+
for page in raw_page_items
|
|
221
|
+
]
|
|
222
|
+
expected_page_count = record.get("expected_page_count") or max((page["page_no"] for page in page_items), default=0)
|
|
223
|
+
complete = bool(expected_page_count) and len(page_items) >= expected_page_count
|
|
224
|
+
ir_blob = b"".join(blob_parts)
|
|
225
|
+
|
|
226
|
+
commands_out.append(
|
|
227
|
+
{
|
|
228
|
+
"command_id": command_id,
|
|
229
|
+
"device_id": record.get("device_id"),
|
|
230
|
+
"label": record.get("label"),
|
|
231
|
+
"format_marker": record.get("format_marker"),
|
|
232
|
+
"expected_page_count": expected_page_count,
|
|
233
|
+
"page_count": len(page_items),
|
|
234
|
+
"complete": complete,
|
|
235
|
+
"ir_blob_hex": ir_blob.hex(" ") if ir_blob else None,
|
|
236
|
+
"ir_blob_byte_count": len(ir_blob),
|
|
237
|
+
"pages": page_items,
|
|
238
|
+
}
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
overall_complete = bool(pending.get("burst_finished"))
|
|
242
|
+
if requested_command_id is None and total_commands is not None:
|
|
243
|
+
overall_complete = overall_complete and len(commands_out) >= int(total_commands)
|
|
244
|
+
if requested_command_id is None:
|
|
245
|
+
overall_complete = overall_complete and all(command["complete"] for command in commands_out)
|
|
246
|
+
else:
|
|
247
|
+
requested_entry = next(
|
|
248
|
+
(command for command in commands_out if command["command_id"] == requested_command_id),
|
|
249
|
+
None,
|
|
250
|
+
)
|
|
251
|
+
overall_complete = overall_complete and bool(requested_entry and requested_entry["complete"])
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
"device_id": pending.get("device_id"),
|
|
255
|
+
"requested_command_id": requested_command_id,
|
|
256
|
+
"total_commands": total_commands,
|
|
257
|
+
"received_command_count": len(commands_out),
|
|
258
|
+
"complete": overall_complete,
|
|
259
|
+
"commands": commands_out,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
def _ir_dump_snapshot_complete(self, pending: dict[str, Any]) -> bool:
|
|
263
|
+
requested_command_id = pending.get("requested_command_id")
|
|
264
|
+
commands: dict[int, dict[str, Any]] = pending.get("commands", {})
|
|
265
|
+
total_commands = pending.get("total_commands")
|
|
266
|
+
|
|
267
|
+
def _command_complete(record: dict[str, Any]) -> bool:
|
|
268
|
+
expected_page_count = record.get("expected_page_count")
|
|
269
|
+
if not expected_page_count:
|
|
270
|
+
return False
|
|
271
|
+
pages = record.get("pages", {})
|
|
272
|
+
return len(pages) >= int(expected_page_count)
|
|
273
|
+
|
|
274
|
+
if requested_command_id is not None:
|
|
275
|
+
record = commands.get(int(requested_command_id))
|
|
276
|
+
return bool(record and _command_complete(record))
|
|
277
|
+
|
|
278
|
+
if total_commands is None:
|
|
279
|
+
return False
|
|
280
|
+
if len(commands) < int(total_commands):
|
|
281
|
+
return False
|
|
282
|
+
return all(_command_complete(record) for record in commands.values())
|
|
283
|
+
|
|
284
|
+
def try_finish_ir_dump_burst(self, request_key: tuple[int, int]) -> bool:
|
|
285
|
+
with self._ir_dump_lock:
|
|
286
|
+
pending = self._ir_dump_pending.get(request_key)
|
|
287
|
+
if pending is None:
|
|
288
|
+
return False
|
|
289
|
+
if not self._ir_dump_snapshot_complete(pending):
|
|
290
|
+
return False
|
|
291
|
+
|
|
292
|
+
return self._burst.finish(
|
|
293
|
+
f"ir_dump:{request_key[0]}:{request_key[1]}",
|
|
294
|
+
can_issue=self.can_issue_commands,
|
|
295
|
+
sender=self._send_cmd_frame,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def get_buttons_for_entity(self, ent_id: int, *, fetch_if_missing: bool = True) -> tuple[list[int], bool]:
|
|
299
|
+
ent_lo = ent_id & 0xFF
|
|
300
|
+
if ent_lo in self.state.buttons:
|
|
301
|
+
self._pending_button_requests.discard(ent_lo)
|
|
302
|
+
return (sorted(self.state.buttons[ent_lo]), True)
|
|
303
|
+
|
|
304
|
+
if fetch_if_missing and self.can_issue_commands():
|
|
305
|
+
if ent_lo not in self._pending_button_requests:
|
|
306
|
+
self._pending_button_requests.add(ent_lo)
|
|
307
|
+
self.enqueue_cmd(
|
|
308
|
+
OP_REQ_BUTTONS,
|
|
309
|
+
bytes([ent_lo, 0xFF]),
|
|
310
|
+
expects_burst=True,
|
|
311
|
+
burst_kind=f"buttons:{ent_lo}",
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
return ([], False)
|
|
315
|
+
|
|
316
|
+
def get_commands_for_entity(self, ent_id: int, *, fetch_if_missing: bool = True) -> tuple[dict[int, str], bool]:
|
|
317
|
+
ent_lo = ent_id & 0xFF
|
|
318
|
+
commands = self.state.commands.get(ent_lo)
|
|
319
|
+
complete = ent_lo in self._commands_complete
|
|
320
|
+
|
|
321
|
+
if commands is not None and complete:
|
|
322
|
+
return (dict(commands), True)
|
|
323
|
+
|
|
324
|
+
if fetch_if_missing and self.can_issue_commands():
|
|
325
|
+
pending = self._pending_command_requests.setdefault(ent_lo, set())
|
|
326
|
+
if 0xFF not in pending:
|
|
327
|
+
pending.add(0xFF)
|
|
328
|
+
self.enqueue_cmd(
|
|
329
|
+
OP_REQ_COMMANDS,
|
|
330
|
+
bytes([ent_lo, 0xFF]),
|
|
331
|
+
expects_burst=True,
|
|
332
|
+
burst_kind=f"commands:{ent_lo}",
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
if commands is not None:
|
|
336
|
+
return (dict(commands), complete)
|
|
337
|
+
|
|
338
|
+
return ({}, False)
|
|
339
|
+
|
|
340
|
+
def _reset_pending_device_snapshot(self, generation: int | None = None) -> None:
|
|
341
|
+
self._device_pending_generation = generation
|
|
342
|
+
self._device_pending_expected_rows = None
|
|
343
|
+
self._device_pending_rows = {}
|
|
344
|
+
|
|
345
|
+
def _reset_pending_activity_snapshot(self, generation: int | None = None) -> None:
|
|
346
|
+
self._activity_pending_generation = generation
|
|
347
|
+
self._activity_pending_expected_rows = None
|
|
348
|
+
self._activity_pending_rows = {}
|
|
349
|
+
self._activity_pending_payloads = {}
|
|
350
|
+
self._activity_pending_hint = None
|
|
351
|
+
|
|
352
|
+
def _begin_device_request(self) -> None:
|
|
353
|
+
self._device_request_serial += 1
|
|
354
|
+
self._device_request_inflight = self._device_request_serial
|
|
355
|
+
self._reset_pending_device_snapshot(self._device_request_inflight)
|
|
356
|
+
|
|
357
|
+
def _begin_activity_request(self, *, is_retry: bool = False) -> None:
|
|
358
|
+
self._activity_request_serial += 1
|
|
359
|
+
self._activity_request_inflight = self._activity_request_serial
|
|
360
|
+
self._activity_retry_due_at = None
|
|
361
|
+
if not is_retry:
|
|
362
|
+
self._activity_retry_count = 0
|
|
363
|
+
self._reset_pending_activity_snapshot(self._activity_request_inflight)
|
|
364
|
+
|
|
365
|
+
def _schedule_activity_retry(self, *, now: float | None = None) -> None:
|
|
366
|
+
if self.hub_version != HUB_VERSION_X2:
|
|
367
|
+
return
|
|
368
|
+
if self._activity_retry_count >= 1:
|
|
369
|
+
return
|
|
370
|
+
base = time.monotonic() if now is None else now
|
|
371
|
+
self._activity_retry_due_at = base + ACTIVITY_INCOMPLETE_RETRY_DELAY_S
|
|
372
|
+
self._activity_retry_count += 1
|
|
373
|
+
self._log.warning(
|
|
374
|
+
"[ACT] incomplete activities snapshot on X2; retrying in %.2fs",
|
|
375
|
+
ACTIVITY_INCOMPLETE_RETRY_DELAY_S,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
def _activity_snapshot_complete(self) -> bool:
|
|
379
|
+
expected = self._activity_pending_expected_rows
|
|
380
|
+
if expected == 0:
|
|
381
|
+
return True
|
|
382
|
+
if expected is None:
|
|
383
|
+
return len(self._activity_pending_rows) == 0
|
|
384
|
+
if expected < 0:
|
|
385
|
+
return False
|
|
386
|
+
seen = set(self._activity_pending_rows.keys())
|
|
387
|
+
return seen == set(range(1, expected + 1))
|
|
388
|
+
|
|
389
|
+
def _device_snapshot_complete(self) -> bool:
|
|
390
|
+
expected = self._device_pending_expected_rows
|
|
391
|
+
if expected == 0:
|
|
392
|
+
return True
|
|
393
|
+
if expected is None:
|
|
394
|
+
return len(self._device_pending_rows) == 0
|
|
395
|
+
if expected < 0:
|
|
396
|
+
return False
|
|
397
|
+
seen = set(self._device_pending_rows.keys())
|
|
398
|
+
return seen == set(range(1, expected + 1))
|
|
399
|
+
|
|
400
|
+
def try_finish_devices_burst(self) -> bool:
|
|
401
|
+
generation = self._device_request_inflight
|
|
402
|
+
if generation is None or self._device_pending_generation != generation:
|
|
403
|
+
return False
|
|
404
|
+
if not self._device_snapshot_complete():
|
|
405
|
+
return False
|
|
406
|
+
return self._burst.finish(
|
|
407
|
+
"devices",
|
|
408
|
+
can_issue=self.can_issue_commands,
|
|
409
|
+
sender=self._send_cmd_frame,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
def try_finish_activities_burst(self) -> bool:
|
|
413
|
+
generation = self._activity_request_inflight
|
|
414
|
+
if generation is None or self._activity_pending_generation != generation:
|
|
415
|
+
return False
|
|
416
|
+
if not self._activity_snapshot_complete():
|
|
417
|
+
return False
|
|
418
|
+
return self._burst.finish(
|
|
419
|
+
"activities",
|
|
420
|
+
can_issue=self.can_issue_commands,
|
|
421
|
+
sender=self._send_cmd_frame,
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
def note_catalog_status_ack(self, status: int) -> bool:
|
|
425
|
+
"""Handle hub status replies that mean an empty catalog request.
|
|
426
|
+
|
|
427
|
+
Some hubs answer ``REQ_ACTIVITIES`` / ``REQ_DEVICES`` with a plain
|
|
428
|
+
``STATUS_ACK`` carrying ``0x07`` when the corresponding table is
|
|
429
|
+
genuinely empty. In that case there will be no row burst to drive the
|
|
430
|
+
normal completion path, so finish the active catalog burst immediately
|
|
431
|
+
instead of waiting for the idle timeout.
|
|
432
|
+
"""
|
|
433
|
+
|
|
434
|
+
ack_status = int(status) & 0xFF
|
|
435
|
+
if ack_status != 0x07:
|
|
436
|
+
return False
|
|
437
|
+
|
|
438
|
+
if self._activity_request_inflight is not None and not self._activity_pending_rows:
|
|
439
|
+
if self._activity_pending_generation != self._activity_request_inflight:
|
|
440
|
+
self._reset_pending_activity_snapshot(self._activity_request_inflight)
|
|
441
|
+
self._activity_pending_expected_rows = 0
|
|
442
|
+
finished = self._burst.finish(
|
|
443
|
+
"activities",
|
|
444
|
+
can_issue=self.can_issue_commands,
|
|
445
|
+
sender=self._send_cmd_frame,
|
|
446
|
+
)
|
|
447
|
+
if finished:
|
|
448
|
+
self._log.info("[ACT] STATUS_ACK 0x07 indicates an empty activities catalog; finishing burst")
|
|
449
|
+
return finished
|
|
450
|
+
|
|
451
|
+
if self._device_request_inflight is not None and not self._device_pending_rows:
|
|
452
|
+
if self._device_pending_generation != self._device_request_inflight:
|
|
453
|
+
self._reset_pending_device_snapshot(self._device_request_inflight)
|
|
454
|
+
self._device_pending_expected_rows = 0
|
|
455
|
+
finished = self._burst.finish(
|
|
456
|
+
"devices",
|
|
457
|
+
can_issue=self.can_issue_commands,
|
|
458
|
+
sender=self._send_cmd_frame,
|
|
459
|
+
)
|
|
460
|
+
if finished:
|
|
461
|
+
self._log.info("[DEV] STATUS_ACK 0x07 indicates an empty devices catalog; finishing burst")
|
|
462
|
+
return finished
|
|
463
|
+
|
|
464
|
+
return False
|
|
465
|
+
|
|
466
|
+
def note_buttons_frame(self, act_lo: int, *, frame_no: int | None, total_frames: int | None) -> None:
|
|
467
|
+
if frame_no != 1:
|
|
468
|
+
return
|
|
469
|
+
if total_frames is None or total_frames <= 0:
|
|
470
|
+
return
|
|
471
|
+
self._button_burst_expected_frames[act_lo & 0xFF] = total_frames
|
|
472
|
+
|
|
473
|
+
def try_finish_buttons_burst(self, act_lo: int, *, frame_no: int | None) -> bool:
|
|
474
|
+
ent_lo = act_lo & 0xFF
|
|
475
|
+
expected = self._button_burst_expected_frames.get(ent_lo)
|
|
476
|
+
if expected is None or frame_no is None or frame_no < expected:
|
|
477
|
+
return False
|
|
478
|
+
return self._burst.finish(
|
|
479
|
+
f"buttons:{ent_lo}",
|
|
480
|
+
can_issue=self.can_issue_commands,
|
|
481
|
+
sender=self._send_cmd_frame,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
def try_finish_activity_map_burst(self, act_lo: int) -> bool:
|
|
485
|
+
ent_lo = act_lo & 0xFF
|
|
486
|
+
if ent_lo not in self._activity_map_complete:
|
|
487
|
+
return False
|
|
488
|
+
return self._burst.finish(
|
|
489
|
+
f"activity_map:{ent_lo}",
|
|
490
|
+
can_issue=self.can_issue_commands,
|
|
491
|
+
sender=self._send_cmd_frame,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
def ingest_activity_row(
|
|
495
|
+
self,
|
|
496
|
+
*,
|
|
497
|
+
row_idx: int | None,
|
|
498
|
+
expected_rows: int | None,
|
|
499
|
+
act_id: int | None,
|
|
500
|
+
activity: dict[str, Any] | None,
|
|
501
|
+
payload: bytes | None = None,
|
|
502
|
+
) -> bool:
|
|
503
|
+
generation = self._activity_request_inflight
|
|
504
|
+
if generation is None:
|
|
505
|
+
self._log.warning(
|
|
506
|
+
"[ACT] ignoring ghost activity row idx=%s act_id=%s: no request in flight",
|
|
507
|
+
row_idx,
|
|
508
|
+
act_id,
|
|
509
|
+
)
|
|
510
|
+
return False
|
|
511
|
+
|
|
512
|
+
if row_idx is None or row_idx <= 0 or act_id is None or activity is None:
|
|
513
|
+
return False
|
|
514
|
+
|
|
515
|
+
if row_idx == 1:
|
|
516
|
+
self._reset_pending_activity_snapshot(generation)
|
|
517
|
+
elif self._activity_pending_generation != generation:
|
|
518
|
+
self._log.warning(
|
|
519
|
+
"[ACT] ignoring activity row idx=%s act_id=%s before row #1 for request=%s",
|
|
520
|
+
row_idx,
|
|
521
|
+
act_id,
|
|
522
|
+
generation,
|
|
523
|
+
)
|
|
524
|
+
return False
|
|
525
|
+
|
|
526
|
+
if expected_rows is not None and expected_rows > 0:
|
|
527
|
+
if self._activity_pending_expected_rows is None:
|
|
528
|
+
self._activity_pending_expected_rows = expected_rows
|
|
529
|
+
elif self._activity_pending_expected_rows != expected_rows:
|
|
530
|
+
self._log.warning(
|
|
531
|
+
"[ACT] row-count mismatch in pending snapshot: had=%s got=%s idx=%s",
|
|
532
|
+
self._activity_pending_expected_rows,
|
|
533
|
+
expected_rows,
|
|
534
|
+
row_idx,
|
|
535
|
+
)
|
|
536
|
+
self._reset_pending_activity_snapshot(generation)
|
|
537
|
+
self._activity_pending_expected_rows = expected_rows
|
|
538
|
+
if row_idx != 1:
|
|
539
|
+
self._log.warning(
|
|
540
|
+
"[ACT] ignoring activity row idx=%s after row-count mismatch until row #1 restarts snapshot",
|
|
541
|
+
row_idx,
|
|
542
|
+
)
|
|
543
|
+
return False
|
|
544
|
+
|
|
545
|
+
self._activity_pending_rows[row_idx] = dict(activity)
|
|
546
|
+
if payload is not None:
|
|
547
|
+
self._activity_pending_payloads[act_id & 0xFF] = bytes(payload)
|
|
548
|
+
|
|
549
|
+
if bool(activity.get("active", False)):
|
|
550
|
+
self._activity_pending_hint = act_id
|
|
551
|
+
|
|
552
|
+
return True
|
|
553
|
+
|
|
554
|
+
def ingest_device_row(
|
|
555
|
+
self,
|
|
556
|
+
*,
|
|
557
|
+
row_idx: int | None,
|
|
558
|
+
expected_rows: int | None,
|
|
559
|
+
dev_id: int | None,
|
|
560
|
+
device: dict[str, Any] | None,
|
|
561
|
+
) -> bool:
|
|
562
|
+
generation = self._device_request_inflight
|
|
563
|
+
if generation is None:
|
|
564
|
+
self._log.warning(
|
|
565
|
+
"[DEV] ignoring ghost device row idx=%s dev_id=%s: no request in flight",
|
|
566
|
+
row_idx,
|
|
567
|
+
dev_id,
|
|
568
|
+
)
|
|
569
|
+
return False
|
|
570
|
+
|
|
571
|
+
if row_idx is None or row_idx <= 0 or dev_id is None or device is None:
|
|
572
|
+
return False
|
|
573
|
+
|
|
574
|
+
if row_idx == 1:
|
|
575
|
+
self._reset_pending_device_snapshot(generation)
|
|
576
|
+
elif self._device_pending_generation != generation:
|
|
577
|
+
self._log.warning(
|
|
578
|
+
"[DEV] ignoring device row idx=%s dev_id=%s before row #1 for request=%s",
|
|
579
|
+
row_idx,
|
|
580
|
+
dev_id,
|
|
581
|
+
generation,
|
|
582
|
+
)
|
|
583
|
+
return False
|
|
584
|
+
|
|
585
|
+
if expected_rows is not None and expected_rows > 0:
|
|
586
|
+
if self._device_pending_expected_rows is None:
|
|
587
|
+
self._device_pending_expected_rows = expected_rows
|
|
588
|
+
elif self._device_pending_expected_rows != expected_rows:
|
|
589
|
+
self._log.warning(
|
|
590
|
+
"[DEV] row-count mismatch in pending snapshot: had=%s got=%s idx=%s",
|
|
591
|
+
self._device_pending_expected_rows,
|
|
592
|
+
expected_rows,
|
|
593
|
+
row_idx,
|
|
594
|
+
)
|
|
595
|
+
self._reset_pending_device_snapshot(generation)
|
|
596
|
+
self._device_pending_expected_rows = expected_rows
|
|
597
|
+
if row_idx != 1:
|
|
598
|
+
self._log.warning(
|
|
599
|
+
"[DEV] ignoring device row idx=%s after row-count mismatch until row #1 restarts snapshot",
|
|
600
|
+
row_idx,
|
|
601
|
+
)
|
|
602
|
+
return False
|
|
603
|
+
|
|
604
|
+
self._device_pending_rows[row_idx] = {
|
|
605
|
+
"id": dev_id & 0xFF,
|
|
606
|
+
**normalize_device_entry(device),
|
|
607
|
+
}
|
|
608
|
+
return True
|
|
609
|
+
|
|
610
|
+
def _commit_pending_device_snapshot(self) -> None:
|
|
611
|
+
ordered_rows = sorted(self._device_pending_rows.items())
|
|
612
|
+
committed: dict[int, dict[str, Any]] = {}
|
|
613
|
+
for _row_idx, row in ordered_rows:
|
|
614
|
+
dev_id = int(row["id"]) & 0xFF
|
|
615
|
+
merged = normalize_device_entry(
|
|
616
|
+
{
|
|
617
|
+
"brand": row.get("brand"),
|
|
618
|
+
"name": row.get("name"),
|
|
619
|
+
"device_class": row.get("device_class"),
|
|
620
|
+
"device_class_code": row.get("device_class_code"),
|
|
621
|
+
}
|
|
622
|
+
)
|
|
623
|
+
raw_body = row.get("raw_body")
|
|
624
|
+
if isinstance(raw_body, (bytes, bytearray)) and raw_body:
|
|
625
|
+
merged["raw_body"] = bytes(raw_body)
|
|
626
|
+
prior = self.state.entities("device").get(dev_id, {})
|
|
627
|
+
cached_mode = self._idle_behavior_values.get(dev_id)
|
|
628
|
+
if cached_mode is None and isinstance(prior.get("idle_behavior"), int):
|
|
629
|
+
cached_mode = int(prior["idle_behavior"]) & 0xFF
|
|
630
|
+
if cached_mode is not None:
|
|
631
|
+
merged["idle_behavior"] = cached_mode
|
|
632
|
+
merged["power_mode"] = cached_mode
|
|
633
|
+
merged["power_model"] = cached_mode
|
|
634
|
+
committed[dev_id] = merged
|
|
635
|
+
self.state.devices = committed
|
|
636
|
+
self._devices_catalog_ready = True
|
|
637
|
+
|
|
638
|
+
def _on_devices_burst_end(self, key: str) -> None:
|
|
639
|
+
generation = self._device_request_inflight
|
|
640
|
+
complete = generation is not None and self._device_pending_generation == generation and self._device_snapshot_complete()
|
|
641
|
+
|
|
642
|
+
if complete:
|
|
643
|
+
self._commit_pending_device_snapshot()
|
|
644
|
+
self._log.info(
|
|
645
|
+
"[DEV] committed complete devices snapshot rows=%d request=%s",
|
|
646
|
+
len(self._device_pending_rows),
|
|
647
|
+
generation,
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
self._device_request_inflight = None
|
|
651
|
+
|
|
652
|
+
if not complete:
|
|
653
|
+
expected = self._device_pending_expected_rows
|
|
654
|
+
seen = sorted(self._device_pending_rows.keys())
|
|
655
|
+
if generation is not None:
|
|
656
|
+
self._log.warning(
|
|
657
|
+
"[DEV] discarding incomplete devices snapshot request=%s expected=%s seen=%s",
|
|
658
|
+
generation,
|
|
659
|
+
expected,
|
|
660
|
+
seen,
|
|
661
|
+
)
|
|
662
|
+
self._reset_pending_device_snapshot()
|
|
663
|
+
|
|
664
|
+
def _commit_pending_activity_snapshot(self) -> None:
|
|
665
|
+
ordered_rows = sorted(self._activity_pending_rows.items())
|
|
666
|
+
committed: dict[int, dict[str, Any]] = {}
|
|
667
|
+
for _row_idx, row in ordered_rows:
|
|
668
|
+
act_id = int(row["id"]) & 0xFF
|
|
669
|
+
entry: dict[str, Any] = {
|
|
670
|
+
"name": row["name"],
|
|
671
|
+
"active": bool(row["active"]),
|
|
672
|
+
"needs_confirm": bool(row["needs_confirm"]),
|
|
673
|
+
}
|
|
674
|
+
# Activity records share the device-record body layout
|
|
675
|
+
# (just with family 0x37 on the create write). The pending
|
|
676
|
+
# payload is ``payload[3:]`` of the catalog-row frame --
|
|
677
|
+
# i.e., the full body, ready for parse_device_record.
|
|
678
|
+
raw_payload = self._activity_pending_payloads.get(act_id)
|
|
679
|
+
if isinstance(raw_payload, (bytes, bytearray)) and len(raw_payload) > 3:
|
|
680
|
+
entry["raw_body"] = bytes(raw_payload[3:])
|
|
681
|
+
committed[act_id] = entry
|
|
682
|
+
|
|
683
|
+
self.state.activities = committed
|
|
684
|
+
self._activity_row_payloads = dict(self._activity_pending_payloads)
|
|
685
|
+
self.state.set_hint(self._activity_pending_hint)
|
|
686
|
+
self._activities_catalog_ready = True
|
|
687
|
+
|
|
688
|
+
def _on_activities_burst_end(self, key: str) -> None:
|
|
689
|
+
generation = self._activity_request_inflight
|
|
690
|
+
complete = generation is not None and self._activity_pending_generation == generation and self._activity_snapshot_complete()
|
|
691
|
+
|
|
692
|
+
if complete:
|
|
693
|
+
self._commit_pending_activity_snapshot()
|
|
694
|
+
self._log.info(
|
|
695
|
+
"[ACT] committed complete activities snapshot rows=%d request=%s",
|
|
696
|
+
len(self._activity_pending_rows),
|
|
697
|
+
generation,
|
|
698
|
+
)
|
|
699
|
+
self._activity_request_inflight = None
|
|
700
|
+
|
|
701
|
+
if not complete:
|
|
702
|
+
expected = self._activity_pending_expected_rows
|
|
703
|
+
seen = sorted(self._activity_pending_rows.keys())
|
|
704
|
+
if generation is not None:
|
|
705
|
+
self._log.warning(
|
|
706
|
+
"[ACT] discarding incomplete activities snapshot request=%s expected=%s seen=%s",
|
|
707
|
+
generation,
|
|
708
|
+
expected,
|
|
709
|
+
seen,
|
|
710
|
+
)
|
|
711
|
+
self._schedule_activity_retry()
|
|
712
|
+
self._reset_pending_activity_snapshot()
|
|
713
|
+
|
|
714
|
+
def get_macros_for_activity(self, act_id: int, *, fetch_if_missing: bool = True) -> tuple[list[dict[str, int | str]], bool]:
|
|
715
|
+
act_lo = act_id & 0xFF
|
|
716
|
+
macros = self.state.get_activity_macros(act_lo)
|
|
717
|
+
ready = act_lo in self._macros_complete
|
|
718
|
+
|
|
719
|
+
if macros and ready:
|
|
720
|
+
return (macros, True)
|
|
721
|
+
|
|
722
|
+
if fetch_if_missing and self.can_issue_commands():
|
|
723
|
+
if act_lo not in self._pending_macro_requests:
|
|
724
|
+
self._pending_macro_requests.add(act_lo)
|
|
725
|
+
self.enqueue_cmd(
|
|
726
|
+
OP_REQ_MACRO_LABELS,
|
|
727
|
+
bytes([act_lo, 0xFF]),
|
|
728
|
+
expects_burst=True,
|
|
729
|
+
burst_kind=f"macros:{act_lo}",
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
return (macros, ready)
|
|
733
|
+
|
|
734
|
+
def get_single_command_for_entity(
|
|
735
|
+
self,
|
|
736
|
+
ent_id: int,
|
|
737
|
+
command_id: int,
|
|
738
|
+
*,
|
|
739
|
+
fetch_if_missing: bool = True,
|
|
740
|
+
) -> tuple[dict[int, str], bool]:
|
|
741
|
+
"""Fetch metadata for a single command on a device.
|
|
742
|
+
|
|
743
|
+
Returns:
|
|
744
|
+
(commands, ready)
|
|
745
|
+
|
|
746
|
+
commands: mapping {command_id: label} if known; may be empty.
|
|
747
|
+
ready: True if we have the answer (either from cache or after a completed burst),
|
|
748
|
+
False if we have just enqueued a targeted request and are still waiting.
|
|
749
|
+
"""
|
|
750
|
+
|
|
751
|
+
ent_lo = ent_id & 0xFF
|
|
752
|
+
|
|
753
|
+
device_cmds = self.state.commands.get(ent_lo)
|
|
754
|
+
if device_cmds is not None and command_id in device_cmds:
|
|
755
|
+
return ({command_id: device_cmds[command_id]}, True)
|
|
756
|
+
|
|
757
|
+
if not fetch_if_missing or not self.can_issue_commands():
|
|
758
|
+
return ({}, False)
|
|
759
|
+
|
|
760
|
+
pending = self._pending_command_requests.setdefault(ent_lo, set())
|
|
761
|
+
|
|
762
|
+
if command_id <= 0xFF:
|
|
763
|
+
if command_id in pending or 0xFF in pending:
|
|
764
|
+
return ({}, False)
|
|
765
|
+
payload = bytes([ent_lo, command_id & 0xFF])
|
|
766
|
+
burst_kind = f"commands:{ent_lo}:{command_id}"
|
|
767
|
+
pending.add(command_id)
|
|
768
|
+
else:
|
|
769
|
+
if 0xFF in pending:
|
|
770
|
+
return ({}, False)
|
|
771
|
+
payload = bytes([ent_lo, 0xFF])
|
|
772
|
+
burst_kind = f"commands:{ent_lo}"
|
|
773
|
+
pending.add(0xFF)
|
|
774
|
+
|
|
775
|
+
self.enqueue_cmd(
|
|
776
|
+
OP_REQ_COMMANDS,
|
|
777
|
+
payload,
|
|
778
|
+
expects_burst=True,
|
|
779
|
+
burst_kind=burst_kind,
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
return ({}, False)
|
|
783
|
+
|
|
784
|
+
def ensure_commands_for_activity(
|
|
785
|
+
self,
|
|
786
|
+
act_id: int,
|
|
787
|
+
*,
|
|
788
|
+
fetch_if_missing: bool = True,
|
|
789
|
+
) -> tuple[dict[int, dict[int, str]], bool]:
|
|
790
|
+
"""Fetch command labels for an activity's favorite slots.
|
|
791
|
+
|
|
792
|
+
The REQ_BUTTONS response already describes physical button mappings, so
|
|
793
|
+
the only follow-up requests we need are for favorite commands that
|
|
794
|
+
require labels. If no favorites exist, nothing is fetched.
|
|
795
|
+
"""
|
|
796
|
+
|
|
797
|
+
act_lo = act_id & 0xFF
|
|
798
|
+
favorites = self.state.get_activity_favorite_slots(act_lo)
|
|
799
|
+
|
|
800
|
+
if not favorites:
|
|
801
|
+
# If there are no favorite slots, there is nothing to resolve.
|
|
802
|
+
return ({}, True)
|
|
803
|
+
|
|
804
|
+
refs: set[tuple[int, int]] = {
|
|
805
|
+
(slot["device_id"], slot["command_id"]) for slot in favorites
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
commands_by_device: dict[int, dict[int, str]] = {}
|
|
809
|
+
all_ready = True
|
|
810
|
+
|
|
811
|
+
seen_pairs: set[tuple[int, int]] = set()
|
|
812
|
+
|
|
813
|
+
for dev_id, command_id in refs:
|
|
814
|
+
pair = (dev_id, command_id)
|
|
815
|
+
if pair in seen_pairs:
|
|
816
|
+
continue
|
|
817
|
+
|
|
818
|
+
seen_pairs.add(pair)
|
|
819
|
+
|
|
820
|
+
favorite_label = self.state.get_favorite_label(act_lo, dev_id, command_id)
|
|
821
|
+
if favorite_label:
|
|
822
|
+
self.state.record_favorite_label(act_lo, dev_id, command_id, favorite_label)
|
|
823
|
+
continue
|
|
824
|
+
|
|
825
|
+
device_cmds = self.state.commands.get(dev_id & 0xFF)
|
|
826
|
+
if device_cmds and command_id in device_cmds:
|
|
827
|
+
label = device_cmds[command_id]
|
|
828
|
+
self.state.record_favorite_label(act_lo, dev_id, command_id, label)
|
|
829
|
+
continue
|
|
830
|
+
|
|
831
|
+
self._favorite_label_requests[pair].add(act_id)
|
|
832
|
+
|
|
833
|
+
single_cmds, ready = self.get_single_command_for_entity(
|
|
834
|
+
dev_id, command_id, fetch_if_missing=fetch_if_missing
|
|
835
|
+
)
|
|
836
|
+
if not ready:
|
|
837
|
+
all_ready = False
|
|
838
|
+
|
|
839
|
+
if single_cmds:
|
|
840
|
+
dev_lo = dev_id & 0xFF
|
|
841
|
+
if dev_lo not in commands_by_device:
|
|
842
|
+
commands_by_device[dev_lo] = {}
|
|
843
|
+
commands_by_device[dev_lo].update(single_cmds)
|
|
844
|
+
|
|
845
|
+
label = single_cmds.get(command_id)
|
|
846
|
+
if label:
|
|
847
|
+
self.state.record_favorite_label(act_lo, dev_id, command_id, label)
|
|
848
|
+
|
|
849
|
+
if ready:
|
|
850
|
+
self._favorite_label_requests.pop(pair, None)
|
|
851
|
+
|
|
852
|
+
return (commands_by_device, all_ready)
|
|
853
|
+
|
|
854
|
+
def clear_entity_cache(
|
|
855
|
+
self,
|
|
856
|
+
ent_id: int,
|
|
857
|
+
clear_buttons: bool = False,
|
|
858
|
+
clear_favorites: bool = False,
|
|
859
|
+
clear_macros: bool = False,
|
|
860
|
+
) -> None:
|
|
861
|
+
"""Remove cached data for a given entity."""
|
|
862
|
+
|
|
863
|
+
ent_lo = ent_id & 0xFF
|
|
864
|
+
|
|
865
|
+
self.state.commands.pop(ent_lo, None)
|
|
866
|
+
self.state.device_key_sorts.pop(ent_lo, None)
|
|
867
|
+
self._commands_complete.discard(ent_lo)
|
|
868
|
+
self._pending_command_requests.pop(ent_lo, None)
|
|
869
|
+
|
|
870
|
+
if clear_buttons:
|
|
871
|
+
self.state.buttons.pop(ent_lo, None)
|
|
872
|
+
self.state.button_details.pop(ent_lo, None)
|
|
873
|
+
self._pending_button_requests.discard(ent_lo)
|
|
874
|
+
|
|
875
|
+
if clear_favorites:
|
|
876
|
+
self.state.activity_command_refs.pop(ent_lo, None)
|
|
877
|
+
self.state.activity_favorite_slots.pop(ent_lo, None)
|
|
878
|
+
self.state.activity_keybinding_slots.pop(ent_lo, None)
|
|
879
|
+
self.state.activity_members.pop(ent_lo, None)
|
|
880
|
+
self.state.activity_favorite_labels.pop(ent_lo, None)
|
|
881
|
+
self.state.activity_keybinding_labels.pop(ent_lo, None)
|
|
882
|
+
self._clear_favorite_label_requests_for_activity(ent_lo)
|
|
883
|
+
self._clear_keybinding_label_requests_for_activity(ent_lo)
|
|
884
|
+
self._pending_activity_map_requests.discard(ent_lo)
|
|
885
|
+
self._activity_map_complete.discard(ent_lo)
|
|
886
|
+
|
|
887
|
+
if clear_macros:
|
|
888
|
+
self.state.activity_macros.pop(ent_lo, None)
|
|
889
|
+
self._macros_complete.discard(ent_lo)
|
|
890
|
+
self._pending_macro_requests.discard(ent_lo)
|
|
891
|
+
|
|
892
|
+
def _clear_favorite_label_requests_for_activity(self, act_lo: int) -> None:
|
|
893
|
+
to_delete: list[tuple[int, int]] = []
|
|
894
|
+
|
|
895
|
+
for pair, act_ids in self._favorite_label_requests.items():
|
|
896
|
+
act_ids.discard(act_lo)
|
|
897
|
+
if not act_ids:
|
|
898
|
+
to_delete.append(pair)
|
|
899
|
+
|
|
900
|
+
for pair in to_delete:
|
|
901
|
+
self._favorite_label_requests.pop(pair, None)
|
|
902
|
+
|
|
903
|
+
def _clear_keybinding_label_requests_for_activity(self, act_lo: int) -> None:
|
|
904
|
+
to_delete: list[tuple[int, int]] = []
|
|
905
|
+
|
|
906
|
+
for pair, act_ids in self._keybinding_label_requests.items():
|
|
907
|
+
act_ids.discard(act_lo)
|
|
908
|
+
if not act_ids:
|
|
909
|
+
to_delete.append(pair)
|
|
910
|
+
|
|
911
|
+
for pair in to_delete:
|
|
912
|
+
self._keybinding_label_requests.pop(pair, None)
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
__all__ = ["CatalogMixin"]
|