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,504 @@
|
|
|
1
|
+
"""Cache export/import + catalog clearing mixin for :class:`X1Proxy`.
|
|
2
|
+
|
|
3
|
+
Provides the serialisable view of the proxy's in-memory catalog (used by
|
|
4
|
+
the persistent cache store and the control-panel websocket feed) along
|
|
5
|
+
with the small family of bulk-clear methods that prepare the proxy for
|
|
6
|
+
a fresh hub poll. The export side deliberately strips ``raw_body`` from
|
|
7
|
+
device entries -- those bytes are not JSON-safe and are re-populated on
|
|
8
|
+
the next catalog refresh after a restart.
|
|
9
|
+
|
|
10
|
+
The mixin owns no state of its own; all reads and writes go through
|
|
11
|
+
``self.state`` and the per-snapshot completion sets carried on the
|
|
12
|
+
``X1Proxy`` instance.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .protocol_const import OP_ERASE_CONFIGURATION
|
|
21
|
+
from .state_helpers import normalize_device_entry
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CacheBackupMixin:
|
|
25
|
+
"""Mixin providing cache export/import and catalog clearing."""
|
|
26
|
+
|
|
27
|
+
def export_cache_state(self) -> dict[str, Any]:
|
|
28
|
+
# ``raw_body`` is the original device record bytes kept in memory for
|
|
29
|
+
# on-demand schema parsing (e.g. by the backup flow). It is excluded
|
|
30
|
+
# from exports because:
|
|
31
|
+
# 1) it is not JSON-serializable (bytes), and the export feeds both
|
|
32
|
+
# the persistent cache and the control-panel WS payload;
|
|
33
|
+
# 2) it would not survive a JSON round-trip anyway, and is
|
|
34
|
+
# re-populated on the next catalog refresh after a restart.
|
|
35
|
+
def _device_for_export(v: dict[str, Any]) -> dict[str, Any]:
|
|
36
|
+
entry = dict(v)
|
|
37
|
+
entry.pop("raw_body", None)
|
|
38
|
+
return entry
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
"banner_info": self.get_banner_info(),
|
|
42
|
+
"devices": {str(k): _device_for_export(v) for k, v in self.state.entities("device").items()},
|
|
43
|
+
"buttons": {str(k): sorted(v) for k, v in self.state.buttons.items()},
|
|
44
|
+
"commands": {
|
|
45
|
+
str(k): {str(cmd_id): label for cmd_id, label in commands.items()}
|
|
46
|
+
for k, commands in self.state.commands.items()
|
|
47
|
+
},
|
|
48
|
+
"device_key_sorts": {
|
|
49
|
+
str(k): dict(v) for k, v in self.state.device_key_sorts.items()
|
|
50
|
+
},
|
|
51
|
+
"ip_devices": {str(k): dict(v) for k, v in self.state.ip_devices.items()},
|
|
52
|
+
"ip_buttons": {
|
|
53
|
+
str(k): {str(btn_id): dict(meta) for btn_id, meta in buttons.items()}
|
|
54
|
+
for k, buttons in self.state.ip_buttons.items()
|
|
55
|
+
},
|
|
56
|
+
"activity_macros": {
|
|
57
|
+
str(k): list(macros) for k, macros in self.state.activity_macros.items()
|
|
58
|
+
},
|
|
59
|
+
"activity_command_refs": {
|
|
60
|
+
str(k): [[dev_id, command_id] for dev_id, command_id in sorted(refs)]
|
|
61
|
+
for k, refs in self.state.activity_command_refs.items()
|
|
62
|
+
},
|
|
63
|
+
"activity_favorite_slots": {
|
|
64
|
+
str(k): [dict(slot) for slot in slots]
|
|
65
|
+
for k, slots in self.state.activity_favorite_slots.items()
|
|
66
|
+
},
|
|
67
|
+
"activity_keybinding_slots": {
|
|
68
|
+
str(k): [dict(slot) for slot in slots]
|
|
69
|
+
for k, slots in self.state.activity_keybinding_slots.items()
|
|
70
|
+
},
|
|
71
|
+
"activity_members": {
|
|
72
|
+
str(k): sorted(members)
|
|
73
|
+
for k, members in self.state.activity_members.items()
|
|
74
|
+
},
|
|
75
|
+
"activity_favorite_labels": {
|
|
76
|
+
str(k): [
|
|
77
|
+
{
|
|
78
|
+
"device_id": dev_id,
|
|
79
|
+
"command_id": command_id,
|
|
80
|
+
"label": label,
|
|
81
|
+
}
|
|
82
|
+
for (dev_id, command_id), label in labels.items()
|
|
83
|
+
]
|
|
84
|
+
for k, labels in self.state.activity_favorite_labels.items()
|
|
85
|
+
},
|
|
86
|
+
"activity_keybinding_labels": {
|
|
87
|
+
str(k): [
|
|
88
|
+
{
|
|
89
|
+
"device_id": dev_id,
|
|
90
|
+
"command_id": command_id,
|
|
91
|
+
"label": label,
|
|
92
|
+
}
|
|
93
|
+
for (dev_id, command_id), label in labels.items()
|
|
94
|
+
]
|
|
95
|
+
for k, labels in self.state.activity_keybinding_labels.items()
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
def import_cache_state(self, payload: dict[str, Any]) -> None:
|
|
100
|
+
from .x1_proxy import _normalize_banner_model
|
|
101
|
+
|
|
102
|
+
data = payload if isinstance(payload, dict) else {}
|
|
103
|
+
|
|
104
|
+
banner_info = data.get("banner_info", {})
|
|
105
|
+
if isinstance(banner_info, dict):
|
|
106
|
+
sanitized: dict[str, Any] = {}
|
|
107
|
+
model = _normalize_banner_model(banner_info.get("model"))
|
|
108
|
+
if model:
|
|
109
|
+
sanitized["model"] = model
|
|
110
|
+
batch = str(banner_info.get("production_batch", "")).strip()
|
|
111
|
+
if batch:
|
|
112
|
+
sanitized["production_batch"] = batch
|
|
113
|
+
firmware_version = banner_info.get("firmware_version")
|
|
114
|
+
if isinstance(firmware_version, (int, float)):
|
|
115
|
+
sanitized["firmware_version"] = int(firmware_version)
|
|
116
|
+
name = str(banner_info.get("name", "")).strip()
|
|
117
|
+
if name:
|
|
118
|
+
sanitized["name"] = name
|
|
119
|
+
with self._banner_info_lock:
|
|
120
|
+
self._banner_info = sanitized
|
|
121
|
+
if sanitized.get("model"):
|
|
122
|
+
self.hub_version = str(sanitized["model"])
|
|
123
|
+
self._banner_info_event.set()
|
|
124
|
+
|
|
125
|
+
has_devices_catalog = "devices" in data
|
|
126
|
+
devices = data.get("devices", {})
|
|
127
|
+
self.state.devices = {
|
|
128
|
+
int(k) & 0xFF: normalize_device_entry(v)
|
|
129
|
+
for k, v in devices.items()
|
|
130
|
+
if isinstance(v, dict)
|
|
131
|
+
}
|
|
132
|
+
self._devices_catalog_ready = has_devices_catalog and isinstance(devices, dict)
|
|
133
|
+
|
|
134
|
+
buttons = data.get("buttons", {})
|
|
135
|
+
self.state.buttons = {
|
|
136
|
+
int(k) & 0xFF: {int(btn) & 0xFF for btn in v}
|
|
137
|
+
for k, v in buttons.items()
|
|
138
|
+
if isinstance(v, list)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
self.state.commands.clear()
|
|
142
|
+
commands = data.get("commands", {})
|
|
143
|
+
for key, entity_commands in commands.items():
|
|
144
|
+
if not isinstance(entity_commands, dict):
|
|
145
|
+
continue
|
|
146
|
+
ent_id = int(key) & 0xFF
|
|
147
|
+
self.state.commands[ent_id] = {
|
|
148
|
+
int(cmd_id) & 0xFF: str(label)
|
|
149
|
+
for cmd_id, label in entity_commands.items()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
self.state.device_key_sorts.clear()
|
|
153
|
+
device_key_sorts = data.get("device_key_sorts", {})
|
|
154
|
+
for key, sort_meta in device_key_sorts.items():
|
|
155
|
+
if isinstance(sort_meta, dict):
|
|
156
|
+
self.state.device_key_sorts[int(key) & 0xFF] = dict(sort_meta)
|
|
157
|
+
|
|
158
|
+
ip_devices = data.get("ip_devices", {})
|
|
159
|
+
self.state.ip_devices = {
|
|
160
|
+
int(k) & 0xFF: dict(v) for k, v in ip_devices.items() if isinstance(v, dict)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
self.state.ip_buttons.clear()
|
|
164
|
+
ip_buttons = data.get("ip_buttons", {})
|
|
165
|
+
for key, button_map in ip_buttons.items():
|
|
166
|
+
if not isinstance(button_map, dict):
|
|
167
|
+
continue
|
|
168
|
+
ent_id = int(key) & 0xFF
|
|
169
|
+
self.state.ip_buttons[ent_id] = {
|
|
170
|
+
int(btn_id) & 0xFF: dict(meta)
|
|
171
|
+
for btn_id, meta in button_map.items()
|
|
172
|
+
if isinstance(meta, dict)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
activity_macros = data.get("activity_macros", {})
|
|
176
|
+
self.state.activity_macros = {
|
|
177
|
+
int(k) & 0xFF: list(v)
|
|
178
|
+
for k, v in activity_macros.items()
|
|
179
|
+
if isinstance(v, list)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
self.state.activity_command_refs.clear()
|
|
183
|
+
activity_command_refs = data.get("activity_command_refs", {})
|
|
184
|
+
for key, refs in activity_command_refs.items():
|
|
185
|
+
if not isinstance(refs, list):
|
|
186
|
+
continue
|
|
187
|
+
act_lo = int(key) & 0xFF
|
|
188
|
+
parsed_refs: set[tuple[int, int]] = set()
|
|
189
|
+
for item in refs:
|
|
190
|
+
if isinstance(item, (list, tuple)) and len(item) == 2:
|
|
191
|
+
parsed_refs.add((int(item[0]) & 0xFF, int(item[1]) & 0xFF))
|
|
192
|
+
if parsed_refs:
|
|
193
|
+
self.state.activity_command_refs[act_lo] = parsed_refs
|
|
194
|
+
|
|
195
|
+
self.state.activity_favorite_slots.clear()
|
|
196
|
+
activity_favorite_slots = data.get("activity_favorite_slots", {})
|
|
197
|
+
for key, slots in activity_favorite_slots.items():
|
|
198
|
+
if not isinstance(slots, list):
|
|
199
|
+
continue
|
|
200
|
+
act_lo = int(key) & 0xFF
|
|
201
|
+
normalized_slots: list[dict[str, int]] = []
|
|
202
|
+
for slot in slots:
|
|
203
|
+
if not isinstance(slot, dict):
|
|
204
|
+
continue
|
|
205
|
+
normalized_slots.append(
|
|
206
|
+
{
|
|
207
|
+
"button_id": int(slot.get("button_id", 0)) & 0xFF,
|
|
208
|
+
"device_id": int(slot.get("device_id", 0)) & 0xFF,
|
|
209
|
+
"command_id": int(slot.get("command_id", 0)) & 0xFF,
|
|
210
|
+
"source": str(slot.get("source", "cache")),
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
if normalized_slots:
|
|
214
|
+
self.state.activity_favorite_slots[act_lo] = normalized_slots
|
|
215
|
+
|
|
216
|
+
self.state.activity_keybinding_slots.clear()
|
|
217
|
+
activity_keybinding_slots = data.get("activity_keybinding_slots", {})
|
|
218
|
+
for key, slots in activity_keybinding_slots.items():
|
|
219
|
+
if not isinstance(slots, list):
|
|
220
|
+
continue
|
|
221
|
+
act_lo = int(key) & 0xFF
|
|
222
|
+
normalized_slots: list[dict[str, int]] = []
|
|
223
|
+
for slot in slots:
|
|
224
|
+
if not isinstance(slot, dict):
|
|
225
|
+
continue
|
|
226
|
+
normalized_slots.append(
|
|
227
|
+
{
|
|
228
|
+
"button_id": int(slot.get("button_id", 0)) & 0xFF,
|
|
229
|
+
"device_id": int(slot.get("device_id", 0)) & 0xFF,
|
|
230
|
+
"command_id": int(slot.get("command_id", 0)) & 0xFF,
|
|
231
|
+
"source": str(slot.get("source", "cache")),
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
if normalized_slots:
|
|
235
|
+
self.state.activity_keybinding_slots[act_lo] = normalized_slots
|
|
236
|
+
|
|
237
|
+
self.state.activity_members.clear()
|
|
238
|
+
activity_members = data.get("activity_members", {})
|
|
239
|
+
for key, members in activity_members.items():
|
|
240
|
+
if isinstance(members, list):
|
|
241
|
+
self.state.activity_members[int(key) & 0xFF] = {int(member) & 0xFF for member in members}
|
|
242
|
+
|
|
243
|
+
self.state.activity_favorite_labels.clear()
|
|
244
|
+
activity_favorite_labels = data.get("activity_favorite_labels", {})
|
|
245
|
+
for key, labels in activity_favorite_labels.items():
|
|
246
|
+
if not isinstance(labels, list):
|
|
247
|
+
continue
|
|
248
|
+
act_lo = int(key) & 0xFF
|
|
249
|
+
parsed_labels: dict[tuple[int, int], str] = {}
|
|
250
|
+
for row in labels:
|
|
251
|
+
if not isinstance(row, dict):
|
|
252
|
+
continue
|
|
253
|
+
dev_id = int(row.get("device_id", 0)) & 0xFF
|
|
254
|
+
command_id = int(row.get("command_id", 0)) & 0xFF
|
|
255
|
+
label = str(row.get("label", "")).strip()
|
|
256
|
+
if dev_id and command_id and label:
|
|
257
|
+
parsed_labels[(dev_id, command_id)] = label
|
|
258
|
+
if parsed_labels:
|
|
259
|
+
self.state.activity_favorite_labels[act_lo] = parsed_labels
|
|
260
|
+
|
|
261
|
+
self.state.activity_keybinding_labels.clear()
|
|
262
|
+
activity_keybinding_labels = data.get("activity_keybinding_labels", {})
|
|
263
|
+
for key, labels in activity_keybinding_labels.items():
|
|
264
|
+
if not isinstance(labels, list):
|
|
265
|
+
continue
|
|
266
|
+
act_lo = int(key) & 0xFF
|
|
267
|
+
parsed_labels: dict[tuple[int, int], str] = {}
|
|
268
|
+
for row in labels:
|
|
269
|
+
if not isinstance(row, dict):
|
|
270
|
+
continue
|
|
271
|
+
dev_id = int(row.get("device_id", 0)) & 0xFF
|
|
272
|
+
command_id = int(row.get("command_id", 0)) & 0xFF
|
|
273
|
+
label = str(row.get("label", "")).strip()
|
|
274
|
+
if dev_id and command_id and label:
|
|
275
|
+
parsed_labels[(dev_id, command_id)] = label
|
|
276
|
+
if parsed_labels:
|
|
277
|
+
self.state.activity_keybinding_labels[act_lo] = parsed_labels
|
|
278
|
+
|
|
279
|
+
has_activities_catalog = "activities" in data
|
|
280
|
+
activities = data.get("activities", {})
|
|
281
|
+
if has_activities_catalog and isinstance(activities, dict):
|
|
282
|
+
self.state.activities = {
|
|
283
|
+
int(k) & 0xFF: dict(v)
|
|
284
|
+
for k, v in activities.items()
|
|
285
|
+
if isinstance(v, dict)
|
|
286
|
+
}
|
|
287
|
+
self._activities_catalog_ready = True
|
|
288
|
+
else:
|
|
289
|
+
self._activities_catalog_ready = False
|
|
290
|
+
|
|
291
|
+
self._commands_complete = set(self.state.commands.keys())
|
|
292
|
+
self._macros_complete = set(self.state.activity_macros.keys())
|
|
293
|
+
|
|
294
|
+
self._activity_map_complete = {
|
|
295
|
+
act_lo
|
|
296
|
+
for act_lo in set(self.state.activity_favorite_slots.keys())
|
|
297
|
+
| set(self.state.activity_members.keys())
|
|
298
|
+
| set(self.state.activity_command_refs.keys())
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
self._pending_button_requests.clear()
|
|
302
|
+
self._pending_command_requests.clear()
|
|
303
|
+
self._pending_macro_requests.clear()
|
|
304
|
+
self._pending_activity_map_requests.clear()
|
|
305
|
+
|
|
306
|
+
def clear_cached_entity_detail(self, ent_id: int, *, kind: str) -> None:
|
|
307
|
+
ent_lo = ent_id & 0xFF
|
|
308
|
+
if kind == "device":
|
|
309
|
+
self.state.devices.pop(ent_lo, None)
|
|
310
|
+
self.state.buttons.pop(ent_lo, None)
|
|
311
|
+
self.state.commands.pop(ent_lo, None)
|
|
312
|
+
self.state.device_key_sorts.pop(ent_lo, None)
|
|
313
|
+
self.state.ip_devices.pop(ent_lo, None)
|
|
314
|
+
self.state.ip_buttons.pop(ent_lo, None)
|
|
315
|
+
self._commands_complete.discard(ent_lo)
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
if kind == "activity":
|
|
319
|
+
self.state.activity_macros.pop(ent_lo, None)
|
|
320
|
+
self.state.activity_members.pop(ent_lo, None)
|
|
321
|
+
self.state.activity_favorite_slots.pop(ent_lo, None)
|
|
322
|
+
self.state.activity_keybinding_slots.pop(ent_lo, None)
|
|
323
|
+
self.state.activity_favorite_labels.pop(ent_lo, None)
|
|
324
|
+
self.state.activity_keybinding_labels.pop(ent_lo, None)
|
|
325
|
+
self.state.activity_command_refs.pop(ent_lo, None)
|
|
326
|
+
self._macros_complete.discard(ent_lo)
|
|
327
|
+
|
|
328
|
+
def get_known_device_ids(self) -> set[int]:
|
|
329
|
+
"""Return the set of device IDs currently known from the catalog."""
|
|
330
|
+
return set(self.state.entities("device").keys()) | set(self.state.ip_devices.keys())
|
|
331
|
+
|
|
332
|
+
def get_known_activity_ids(self) -> set[int]:
|
|
333
|
+
"""Return the set of activity IDs currently known from the catalog."""
|
|
334
|
+
return set(self.state.entities("activity").keys())
|
|
335
|
+
|
|
336
|
+
def get_cached_activity_detail_ids(self) -> set[int]:
|
|
337
|
+
"""Return activity IDs referenced by per-activity cached detail tables."""
|
|
338
|
+
|
|
339
|
+
return (
|
|
340
|
+
set(self.state.activity_macros.keys())
|
|
341
|
+
| set(self.state.activity_members.keys())
|
|
342
|
+
| set(self.state.activity_favorite_slots.keys())
|
|
343
|
+
| set(self.state.activity_keybinding_slots.keys())
|
|
344
|
+
| set(self.state.activity_favorite_labels.keys())
|
|
345
|
+
| set(self.state.activity_keybinding_labels.keys())
|
|
346
|
+
| set(self.state.activity_command_refs.keys())
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
def clear_devices_catalog(self) -> None:
|
|
350
|
+
"""Clear only the device name catalog before a fresh device list fetch.
|
|
351
|
+
|
|
352
|
+
Deliberately does NOT clear per-device commands or ip_buttons — those are
|
|
353
|
+
preserved for devices that still exist and pruned separately (via
|
|
354
|
+
clear_cached_entity_detail) for devices that were removed.
|
|
355
|
+
"""
|
|
356
|
+
self.state.devices.clear()
|
|
357
|
+
self.state.ip_devices.clear()
|
|
358
|
+
self._devices_catalog_ready = False
|
|
359
|
+
|
|
360
|
+
def clear_activities_catalog(self) -> None:
|
|
361
|
+
"""Clear only the activity name catalog before a fresh activity list fetch.
|
|
362
|
+
|
|
363
|
+
Deliberately does NOT clear per-activity keymaps, favorites, keybindings,
|
|
364
|
+
or macros — those are not returned by OP_REQ_ACTIVITIES and would not be
|
|
365
|
+
repopulated by the burst. Per-activity detail data for removed activities
|
|
366
|
+
is pruned separately via clear_cached_entity_detail.
|
|
367
|
+
"""
|
|
368
|
+
self.state.activities.clear()
|
|
369
|
+
self._activity_row_payloads.clear()
|
|
370
|
+
self.state.set_hint(None)
|
|
371
|
+
self._activities_catalog_ready = False
|
|
372
|
+
|
|
373
|
+
def wipe_all_cached_state(self) -> None:
|
|
374
|
+
"""Drop every per-entity cache so a fresh catalog poll is required.
|
|
375
|
+
|
|
376
|
+
Called by :meth:`erase_configuration` after the hub confirms it
|
|
377
|
+
has wiped its persistent tables. Everything keyed by device or
|
|
378
|
+
activity id is no longer valid; the next catalog request must
|
|
379
|
+
start from zero. The banner / hub-identity state is preserved
|
|
380
|
+
(the hub model didn't change) along with the proxy's transport
|
|
381
|
+
and listener wiring.
|
|
382
|
+
"""
|
|
383
|
+
|
|
384
|
+
# Top-level name catalogs (parallel to clear_devices_catalog +
|
|
385
|
+
# clear_activities_catalog).
|
|
386
|
+
self.clear_devices_catalog()
|
|
387
|
+
self.clear_activities_catalog()
|
|
388
|
+
|
|
389
|
+
# Per-device detail surfaces.
|
|
390
|
+
self.state.commands.clear()
|
|
391
|
+
self.state.device_key_sorts.clear()
|
|
392
|
+
self.state.buttons.clear()
|
|
393
|
+
if hasattr(self.state, "button_details"):
|
|
394
|
+
self.state.button_details.clear()
|
|
395
|
+
if hasattr(self.state, "command_metadata"):
|
|
396
|
+
self.state.command_metadata.clear()
|
|
397
|
+
self.state.ip_buttons.clear()
|
|
398
|
+
self.state.ip_devices.clear()
|
|
399
|
+
|
|
400
|
+
# Per-activity detail surfaces.
|
|
401
|
+
self.state.activity_macros.clear()
|
|
402
|
+
self.state.activity_members.clear()
|
|
403
|
+
self.state.activity_favorite_slots.clear()
|
|
404
|
+
self.state.activity_keybinding_slots.clear()
|
|
405
|
+
self.state.activity_favorite_labels.clear()
|
|
406
|
+
self.state.activity_keybinding_labels.clear()
|
|
407
|
+
self.state.activity_command_refs.clear()
|
|
408
|
+
|
|
409
|
+
# Completion / pending sets.
|
|
410
|
+
self._commands_complete.clear()
|
|
411
|
+
self._macros_complete.clear()
|
|
412
|
+
self._activity_map_complete.clear()
|
|
413
|
+
self._pending_button_requests.clear()
|
|
414
|
+
self._pending_command_requests.clear()
|
|
415
|
+
self._pending_macro_requests.clear()
|
|
416
|
+
self._pending_activity_map_requests.clear()
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def erase_configuration(
|
|
420
|
+
self,
|
|
421
|
+
*,
|
|
422
|
+
timeout: float = 120.0,
|
|
423
|
+
settle_seconds: float = 2.0,
|
|
424
|
+
) -> bool:
|
|
425
|
+
"""Wipe the hub's user-visible configuration tables (opcode ``0x001D``).
|
|
426
|
+
|
|
427
|
+
Sends the empty-payload erase frame, waits up to ``timeout``
|
|
428
|
+
seconds for any first response from the hub, then clears the
|
|
429
|
+
proxy's catalog mirrors and sleeps ``settle_seconds`` before
|
|
430
|
+
returning so callers can immediately issue follow-up requests.
|
|
431
|
+
|
|
432
|
+
Returns ``True`` on success, ``False`` when the hub disconnects
|
|
433
|
+
before any response arrives or when no response arrives within
|
|
434
|
+
``timeout``.
|
|
435
|
+
|
|
436
|
+
The hub commonly drops the session after the ack. A
|
|
437
|
+
``False`` return that's actually caused by a hub-initiated
|
|
438
|
+
disconnect *after* an ack would be a misreport; the wait loop
|
|
439
|
+
therefore requires the ack first and treats any later
|
|
440
|
+
disconnect as the expected post-erase behaviour. See
|
|
441
|
+
``docs/protocol/erase.md`` for the wire layout.
|
|
442
|
+
"""
|
|
443
|
+
|
|
444
|
+
if not self.can_issue_commands():
|
|
445
|
+
self._log.info(
|
|
446
|
+
"[ERASE] erase_configuration ignored: proxy client is connected"
|
|
447
|
+
)
|
|
448
|
+
return False
|
|
449
|
+
|
|
450
|
+
self.clear_ack_queue()
|
|
451
|
+
send_ts = time.monotonic()
|
|
452
|
+
self._log.info(
|
|
453
|
+
"[ERASE] sending opcode 0x%04X (timeout=%.0fs)",
|
|
454
|
+
OP_ERASE_CONFIGURATION,
|
|
455
|
+
timeout,
|
|
456
|
+
)
|
|
457
|
+
self._send_cmd_frame(OP_ERASE_CONFIGURATION, b"")
|
|
458
|
+
|
|
459
|
+
def _disconnected() -> bool:
|
|
460
|
+
# ``_hub_connected`` is updated by the transport bridge as
|
|
461
|
+
# frames arrive / connections drop. A drop arriving before
|
|
462
|
+
# any ack means the hub didn't even acknowledge the erase
|
|
463
|
+
# request; treat as failure.
|
|
464
|
+
return not getattr(self, "_hub_connected", True)
|
|
465
|
+
|
|
466
|
+
result = self.wait_for_any_response(
|
|
467
|
+
timeout=timeout,
|
|
468
|
+
not_before=send_ts,
|
|
469
|
+
disconnect_check=_disconnected,
|
|
470
|
+
)
|
|
471
|
+
if result is None:
|
|
472
|
+
if _disconnected():
|
|
473
|
+
self._log.warning(
|
|
474
|
+
"[ERASE] hub disconnected before any ack -- treating as failure"
|
|
475
|
+
)
|
|
476
|
+
else:
|
|
477
|
+
self._log.warning(
|
|
478
|
+
"[ERASE] no response within %.0fs -- treating as failure",
|
|
479
|
+
timeout,
|
|
480
|
+
)
|
|
481
|
+
return False
|
|
482
|
+
|
|
483
|
+
ack_opcode, ack_payload = result
|
|
484
|
+
self._log.info(
|
|
485
|
+
"[ERASE] hub answered opcode=0x%04X payload_len=%d -- wiping local caches",
|
|
486
|
+
ack_opcode,
|
|
487
|
+
len(ack_payload),
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
# The persistent tables on the hub are now empty; everything
|
|
491
|
+
# we have cached locally is stale.
|
|
492
|
+
self.wipe_all_cached_state()
|
|
493
|
+
|
|
494
|
+
# The hub commonly cycles the session after the ack and needs
|
|
495
|
+
# a moment before answering anything new. A brief sleep here
|
|
496
|
+
# lets callers (e.g. the bundle-restore orchestrator) issue
|
|
497
|
+
# follow-up requests immediately without retry loops.
|
|
498
|
+
if settle_seconds > 0:
|
|
499
|
+
time.sleep(settle_seconds)
|
|
500
|
+
|
|
501
|
+
return True
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
__all__ = ["CacheBackupMixin"]
|