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,713 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections import defaultdict, deque
|
|
5
|
+
from typing import Any, Callable, Deque, Dict, Literal, Mapping, Optional
|
|
6
|
+
|
|
7
|
+
from .hub_versions import HUB_VERSION_X1, HUB_VERSION_X1S, HUB_VERSION_X2
|
|
8
|
+
from .commands import (
|
|
9
|
+
COMMAND_RECORD_STRIDE_X1,
|
|
10
|
+
COMMAND_RECORD_STRIDE_X1S_X2,
|
|
11
|
+
KEYMAP_RECORD_SIZE,
|
|
12
|
+
iter_command_records_from_assembled,
|
|
13
|
+
iter_keymap_records,
|
|
14
|
+
)
|
|
15
|
+
from .protocol_const import (
|
|
16
|
+
BUTTONNAME_BY_CODE,
|
|
17
|
+
DEVICE_CLASS_WIFI_IP,
|
|
18
|
+
classify_device_class_code,
|
|
19
|
+
normalize_device_class,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def normalize_device_entry(
|
|
24
|
+
device: dict[str, Any] | None,
|
|
25
|
+
*,
|
|
26
|
+
default_class: str | None = None,
|
|
27
|
+
default_class_code: int | None = None,
|
|
28
|
+
) -> dict[str, Any]:
|
|
29
|
+
"""Return a cache-safe device row with normalized type metadata."""
|
|
30
|
+
|
|
31
|
+
source = dict(device) if isinstance(device, dict) else {}
|
|
32
|
+
|
|
33
|
+
brand = str(source.get("brand") or source.get("brand_name") or "").strip()
|
|
34
|
+
name = str(source.get("name") or source.get("device_name") or source.get("label") or "").strip()
|
|
35
|
+
device_class = normalize_device_class(
|
|
36
|
+
source.get("device_class", source.get("device_type"))
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
raw_class_code = source.get("device_class_code", source.get("device_type_code"))
|
|
40
|
+
try:
|
|
41
|
+
device_class_code = int(raw_class_code) & 0xFF
|
|
42
|
+
except (TypeError, ValueError):
|
|
43
|
+
device_class_code = None
|
|
44
|
+
|
|
45
|
+
if device_class_code is None and default_class_code is not None:
|
|
46
|
+
device_class_code = int(default_class_code) & 0xFF
|
|
47
|
+
|
|
48
|
+
if device_class is None and default_class is not None:
|
|
49
|
+
device_class = normalize_device_class(default_class)
|
|
50
|
+
|
|
51
|
+
if device_class is None and device_class_code is not None:
|
|
52
|
+
device_class = classify_device_class_code(device_class_code)
|
|
53
|
+
|
|
54
|
+
if brand:
|
|
55
|
+
source["brand"] = brand
|
|
56
|
+
else:
|
|
57
|
+
source.pop("brand", None)
|
|
58
|
+
|
|
59
|
+
if name:
|
|
60
|
+
source["name"] = name
|
|
61
|
+
else:
|
|
62
|
+
source.pop("name", None)
|
|
63
|
+
|
|
64
|
+
if device_class is not None:
|
|
65
|
+
source["device_class"] = device_class
|
|
66
|
+
else:
|
|
67
|
+
source.pop("device_class", None)
|
|
68
|
+
|
|
69
|
+
if device_class_code is not None:
|
|
70
|
+
source["device_class_code"] = device_class_code
|
|
71
|
+
else:
|
|
72
|
+
source.pop("device_class_code", None)
|
|
73
|
+
|
|
74
|
+
# Strip the temporary field names so exported cache only emits the new schema.
|
|
75
|
+
source.pop("device_type", None)
|
|
76
|
+
source.pop("device_type_code", None)
|
|
77
|
+
|
|
78
|
+
return source
|
|
79
|
+
|
|
80
|
+
class ActivityCache:
|
|
81
|
+
def __init__(self) -> None:
|
|
82
|
+
self.current_activity: Optional[int] = None
|
|
83
|
+
self.current_activity_hint: Optional[int] = None
|
|
84
|
+
self.activities: Dict[int, Dict[str, Any]] = {}
|
|
85
|
+
self.devices: Dict[int, Dict[str, Any]] = {}
|
|
86
|
+
self.buttons: Dict[int, set[int]] = {}
|
|
87
|
+
# Per-button mapping details: act_lo → {button_id → {device_id, command_id, long_press_device_id?, long_press_command_id?}}
|
|
88
|
+
self.button_details: Dict[int, Dict[int, Dict[str, int]]] = defaultdict(dict)
|
|
89
|
+
self.commands: dict[int, dict[int, str]] = defaultdict(dict)
|
|
90
|
+
# Per-command record metadata captured at REQ_COMMANDS parse
|
|
91
|
+
# time. Keyed dev_id -> command_id -> {"library_type": int,
|
|
92
|
+
# "button_code": int}. ``library_type`` is the codec selector
|
|
93
|
+
# the hub stored alongside the command (0x0D for IR-DB, others
|
|
94
|
+
# for BT/RF/learned). ``button_code`` is the 48-bit canonical
|
|
95
|
+
# command identifier the hub uses when keymap entries or macro
|
|
96
|
+
# steps reference this command. Both are needed for a faithful
|
|
97
|
+
# restore. Backed by the bytes ``CommandRecord.control[0]`` and
|
|
98
|
+
# ``CommandRecord.control[1:7]`` respectively.
|
|
99
|
+
self.command_metadata: dict[int, dict[int, dict[str, int]]] = defaultdict(dict)
|
|
100
|
+
self.ip_devices: Dict[int, Dict[str, Any]] = {}
|
|
101
|
+
self.ip_buttons: Dict[int, Dict[int, Dict[str, Any]]] = defaultdict(dict)
|
|
102
|
+
self.activity_command_refs: dict[int, set[tuple[int, int]]] = defaultdict(set)
|
|
103
|
+
self.activity_favorite_slots: dict[int, list[dict[str, int]]] = defaultdict(list)
|
|
104
|
+
self.activity_keybinding_slots: dict[int, list[dict[str, int]]] = defaultdict(list)
|
|
105
|
+
self.activity_members: dict[int, set[int]] = defaultdict(set)
|
|
106
|
+
# Favorites ordering: maps act_lo → list of (fav_id, slot) pairs in hub order
|
|
107
|
+
# Populated by OP_FAV_ORDER_RESP (family 0x63) response to OP_FAV_ORDER_REQ (0x0162)
|
|
108
|
+
self.activity_favorites_order: dict[int, list[tuple[int, int]]] = {}
|
|
109
|
+
self.device_key_sorts: dict[int, dict[str, Any]] = {}
|
|
110
|
+
self.activity_favorite_labels: dict[int, dict[tuple[int, int], str]] = defaultdict(dict)
|
|
111
|
+
self.activity_keybinding_labels: dict[int, dict[tuple[int, int], str]] = defaultdict(dict)
|
|
112
|
+
self.activity_macros: dict[int, list[dict[str, int | str]]] = defaultdict(list)
|
|
113
|
+
# Only track the most recent activation to avoid unbounded growth
|
|
114
|
+
self.app_activations: Deque[dict[str, Any]] = deque(maxlen=1)
|
|
115
|
+
|
|
116
|
+
def entities(
|
|
117
|
+
self, kind: Literal["device", "activity"]
|
|
118
|
+
) -> Mapping[int, Dict[str, Any]]:
|
|
119
|
+
"""Return the live id-keyed map for ``kind``.
|
|
120
|
+
|
|
121
|
+
Read-side accessor that lets call sites name the entity kind
|
|
122
|
+
instead of hard-coding the attribute. The returned mapping is
|
|
123
|
+
the same dict the cache uses internally, so callers should
|
|
124
|
+
treat it as read-only: mutations should still go through the
|
|
125
|
+
dedicated mutator methods on the cache (or, where one does
|
|
126
|
+
not yet exist, the direct attribute) so a future migration to
|
|
127
|
+
a typed container has one place to land.
|
|
128
|
+
|
|
129
|
+
``ip_devices`` remains a separate namespace and is not
|
|
130
|
+
unified by this accessor; callers that want the union still
|
|
131
|
+
reach into both maps explicitly.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
if kind == "device":
|
|
135
|
+
return self.devices
|
|
136
|
+
if kind == "activity":
|
|
137
|
+
return self.activities
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"ActivityCache.entities: unknown kind={kind!r}; "
|
|
140
|
+
"expected 'device' or 'activity'"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def set_hint(self, activity_id: Optional[int]) -> None:
|
|
144
|
+
self.current_activity_hint = activity_id
|
|
145
|
+
|
|
146
|
+
def update_activity_state(self) -> tuple[Optional[int], Optional[int]]:
|
|
147
|
+
if self.current_activity != self.current_activity_hint:
|
|
148
|
+
old = self.current_activity
|
|
149
|
+
self.current_activity = self.current_activity_hint
|
|
150
|
+
return self.current_activity, old
|
|
151
|
+
return self.current_activity, self.current_activity
|
|
152
|
+
|
|
153
|
+
def get_activity_name(self, act_id: Optional[int]) -> Optional[str]:
|
|
154
|
+
if act_id is None:
|
|
155
|
+
return None
|
|
156
|
+
return self.activities.get(act_id & 0xFF, {}).get("name")
|
|
157
|
+
|
|
158
|
+
def replace_keymap_rows(self, act_lo: int, row_stream: bytes) -> None:
|
|
159
|
+
"""Replace the physical-button view for ``act_lo`` from an assembled row stream.
|
|
160
|
+
|
|
161
|
+
Record-walking uses :func:`commands.iter_keymap_records`, which
|
|
162
|
+
encodes the documented 18-byte fixed-stride layout. The activity-id
|
|
163
|
+
filter inside the iterator subsumes the previous explicit
|
|
164
|
+
``act != act_lo`` early-return in ``_parse_keymap_record``.
|
|
165
|
+
|
|
166
|
+
A short trailing fragment shorter than 18 bytes that looks like a
|
|
167
|
+
valid record start is right-padded with zeros and processed via the
|
|
168
|
+
usual record classifier. That compatibility fallback is preserved
|
|
169
|
+
because some hub firmwares have been observed to truncate the final
|
|
170
|
+
record.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
self.buttons[act_lo] = set()
|
|
174
|
+
self.button_details.pop(act_lo, None)
|
|
175
|
+
|
|
176
|
+
favorites_allowed = True
|
|
177
|
+
|
|
178
|
+
for record in iter_keymap_records(row_stream, expected_activity_id=act_lo):
|
|
179
|
+
favorites_allowed, _ = self._parse_keymap_record(
|
|
180
|
+
act_lo,
|
|
181
|
+
record.raw,
|
|
182
|
+
favorites_allowed=favorites_allowed,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
usable = len(row_stream) - (len(row_stream) % KEYMAP_RECORD_SIZE)
|
|
186
|
+
remainder = row_stream[usable:]
|
|
187
|
+
if (
|
|
188
|
+
len(remainder) >= 2
|
|
189
|
+
and remainder[0] == act_lo
|
|
190
|
+
and remainder[1] in BUTTONNAME_BY_CODE
|
|
191
|
+
):
|
|
192
|
+
padded = remainder + b"\x00" * (KEYMAP_RECORD_SIZE - len(remainder))
|
|
193
|
+
self._parse_keymap_record(
|
|
194
|
+
act_lo,
|
|
195
|
+
padded,
|
|
196
|
+
favorites_allowed=favorites_allowed,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def _parse_keymap_record(
|
|
200
|
+
self, act_lo: int, record: bytes, *, favorites_allowed: bool
|
|
201
|
+
) -> tuple[bool, bool]:
|
|
202
|
+
act = record[0] if record else None
|
|
203
|
+
if act != act_lo:
|
|
204
|
+
return favorites_allowed, False
|
|
205
|
+
|
|
206
|
+
button_id = record[1]
|
|
207
|
+
device_id = record[2]
|
|
208
|
+
command_id = record[9] if len(record) > 9 else button_id
|
|
209
|
+
|
|
210
|
+
if button_id in BUTTONNAME_BY_CODE:
|
|
211
|
+
self.buttons[act_lo].add(button_id)
|
|
212
|
+
details: Dict[str, int] = {"device_id": device_id, "command_id": command_id}
|
|
213
|
+
# Per the official KeyToKeyGets parser, each 18-byte keymap
|
|
214
|
+
# record's long-press triple lives at:
|
|
215
|
+
# [10] long_press_device_id
|
|
216
|
+
# [11..16] long_press_button_code (6B BE)
|
|
217
|
+
# [17] long_press_button_id (== long_press_command_id)
|
|
218
|
+
# A row with no long press is simply ``long_press_device_id == 0``.
|
|
219
|
+
# Earlier code additionally required ``record[11:15] == 0`` and
|
|
220
|
+
# ``record[15] == 0x4E`` -- a signature that only matches the
|
|
221
|
+
# *synthetic* button codes our own writer produces, so genuine
|
|
222
|
+
# captured long-press codes (real IR, BT, etc.) were silently
|
|
223
|
+
# dropped on backup.
|
|
224
|
+
if len(record) >= 18 and record[10] != 0:
|
|
225
|
+
details["long_press_device_id"] = record[10]
|
|
226
|
+
details["long_press_command_id"] = record[17]
|
|
227
|
+
self.button_details[act_lo][button_id] = details
|
|
228
|
+
return False, True
|
|
229
|
+
|
|
230
|
+
if favorites_allowed:
|
|
231
|
+
self._upsert_activity_favorite_slot(
|
|
232
|
+
act_lo,
|
|
233
|
+
button_id=button_id,
|
|
234
|
+
device_id=device_id,
|
|
235
|
+
command_id=command_id,
|
|
236
|
+
source="keymap",
|
|
237
|
+
)
|
|
238
|
+
return True, True
|
|
239
|
+
return favorites_allowed, False
|
|
240
|
+
|
|
241
|
+
def _upsert_activity_keybinding_slot(
|
|
242
|
+
self,
|
|
243
|
+
act_lo: int,
|
|
244
|
+
*,
|
|
245
|
+
button_id: int,
|
|
246
|
+
device_id: int,
|
|
247
|
+
command_id: int,
|
|
248
|
+
source: str,
|
|
249
|
+
) -> None:
|
|
250
|
+
pair = (device_id & 0xFF, command_id & 0xFF)
|
|
251
|
+
if pair[0] == 0 or pair[1] in (0x00, 0xFC):
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
self.activity_command_refs[act_lo].add(pair)
|
|
255
|
+
slots = self.activity_keybinding_slots[act_lo]
|
|
256
|
+
|
|
257
|
+
for idx, slot in enumerate(slots):
|
|
258
|
+
if slot["button_id"] != (button_id & 0xFF):
|
|
259
|
+
continue
|
|
260
|
+
existing_source = slot.get("source", "keymap")
|
|
261
|
+
# Preserve legacy activity-map slots for compatibility, but treat
|
|
262
|
+
# keymap-derived data as the authoritative source when both exist.
|
|
263
|
+
if existing_source == "activity_map" and source != "activity_map":
|
|
264
|
+
slots[idx] = {
|
|
265
|
+
"button_id": button_id & 0xFF,
|
|
266
|
+
"device_id": pair[0],
|
|
267
|
+
"command_id": pair[1],
|
|
268
|
+
"source": source,
|
|
269
|
+
}
|
|
270
|
+
else:
|
|
271
|
+
slots[idx].update({
|
|
272
|
+
"device_id": pair[0],
|
|
273
|
+
"command_id": pair[1],
|
|
274
|
+
"source": source,
|
|
275
|
+
})
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
slots.append(
|
|
279
|
+
{
|
|
280
|
+
"button_id": button_id & 0xFF,
|
|
281
|
+
"device_id": pair[0],
|
|
282
|
+
"command_id": pair[1],
|
|
283
|
+
"source": source,
|
|
284
|
+
}
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def _upsert_activity_favorite_slot(
|
|
288
|
+
self,
|
|
289
|
+
act_lo: int,
|
|
290
|
+
*,
|
|
291
|
+
button_id: int,
|
|
292
|
+
device_id: int,
|
|
293
|
+
command_id: int,
|
|
294
|
+
source: str,
|
|
295
|
+
) -> None:
|
|
296
|
+
pair = (device_id & 0xFF, command_id & 0xFF)
|
|
297
|
+
if pair[0] == 0 or pair[1] in (0x00, 0xFC):
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
self.activity_command_refs[act_lo].add(pair)
|
|
301
|
+
slots = self.activity_favorite_slots[act_lo]
|
|
302
|
+
|
|
303
|
+
for idx, slot in enumerate(slots):
|
|
304
|
+
if (slot["device_id"], slot["command_id"]) != pair:
|
|
305
|
+
continue
|
|
306
|
+
existing_source = slot.get("source", "keymap")
|
|
307
|
+
# Preserve legacy activity-map slots for compatibility, but prefer
|
|
308
|
+
# keymap-derived favorite rows when both describe the same pair.
|
|
309
|
+
if existing_source == "activity_map" and source != "activity_map":
|
|
310
|
+
slots[idx] = {
|
|
311
|
+
"button_id": button_id,
|
|
312
|
+
"device_id": pair[0],
|
|
313
|
+
"command_id": pair[1],
|
|
314
|
+
"source": source,
|
|
315
|
+
}
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
slots.append(
|
|
319
|
+
{
|
|
320
|
+
"button_id": button_id,
|
|
321
|
+
"device_id": pair[0],
|
|
322
|
+
"command_id": pair[1],
|
|
323
|
+
"source": source,
|
|
324
|
+
}
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def get_activity_command_refs(self, act_lo: int) -> set[tuple[int, int]]:
|
|
328
|
+
"""Return the set of (device_id, command_id) pairs for the activity."""
|
|
329
|
+
|
|
330
|
+
return set(self.activity_command_refs.get(act_lo, set()))
|
|
331
|
+
|
|
332
|
+
def get_activity_favorite_slots(self, act_lo: int) -> list[dict[str, int]]:
|
|
333
|
+
"""Return metadata for favorite slots in this activity."""
|
|
334
|
+
|
|
335
|
+
return list(self.activity_favorite_slots.get(act_lo, []))
|
|
336
|
+
|
|
337
|
+
def get_activity_keybinding_slots(self, act_lo: int) -> list[dict[str, int]]:
|
|
338
|
+
"""Return metadata for keybinding slots in this activity."""
|
|
339
|
+
|
|
340
|
+
return list(self.activity_keybinding_slots.get(act_lo, []))
|
|
341
|
+
|
|
342
|
+
def record_activity_member(self, act_lo: int, device_id: int) -> None:
|
|
343
|
+
"""Record a device as being linked to the activity."""
|
|
344
|
+
|
|
345
|
+
dev_lo = device_id & 0xFF
|
|
346
|
+
if dev_lo:
|
|
347
|
+
self.activity_members[act_lo & 0xFF].add(dev_lo)
|
|
348
|
+
|
|
349
|
+
def get_activity_members(self, act_lo: int) -> list[int]:
|
|
350
|
+
"""Return linked device ids discovered for the activity."""
|
|
351
|
+
|
|
352
|
+
return sorted(self.activity_members.get(act_lo & 0xFF, set()))
|
|
353
|
+
|
|
354
|
+
def record_activity_mapping(
|
|
355
|
+
self,
|
|
356
|
+
act_lo: int,
|
|
357
|
+
device_id: int,
|
|
358
|
+
command_id: int,
|
|
359
|
+
*,
|
|
360
|
+
button_id: int | None = None,
|
|
361
|
+
) -> None:
|
|
362
|
+
"""Record a legacy activity-map favorite mapping entry.
|
|
363
|
+
|
|
364
|
+
Current protocol findings suggest activity favorites primarily come
|
|
365
|
+
from REQ_BUTTONS/keymap rows; REQ_ACTIVITY_MAP is now treated as a
|
|
366
|
+
membership roster. This helper remains for compatibility with restored
|
|
367
|
+
cache data and older tests.
|
|
368
|
+
"""
|
|
369
|
+
|
|
370
|
+
dev_lo = device_id & 0xFF
|
|
371
|
+
self.record_activity_member(act_lo, dev_lo)
|
|
372
|
+
|
|
373
|
+
self._upsert_activity_favorite_slot(
|
|
374
|
+
act_lo,
|
|
375
|
+
button_id=button_id if button_id is not None else 0,
|
|
376
|
+
device_id=device_id & 0xFF,
|
|
377
|
+
command_id=command_id & 0xFF,
|
|
378
|
+
source="activity_map",
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
def record_favorite_label(
|
|
382
|
+
self, act_lo: int, device_id: int, command_id: int, label: str
|
|
383
|
+
) -> None:
|
|
384
|
+
"""Store the resolved label for a favorite command."""
|
|
385
|
+
|
|
386
|
+
self.activity_favorite_labels[act_lo][(device_id, command_id)] = label
|
|
387
|
+
|
|
388
|
+
def record_keybinding_label(
|
|
389
|
+
self, act_lo: int, device_id: int, command_id: int, label: str
|
|
390
|
+
) -> None:
|
|
391
|
+
"""Store the resolved label for an activity keybinding command."""
|
|
392
|
+
|
|
393
|
+
self.activity_keybinding_labels[act_lo][(device_id, command_id)] = label
|
|
394
|
+
|
|
395
|
+
def get_favorite_label(
|
|
396
|
+
self, act_lo: int, device_id: int, command_id: int
|
|
397
|
+
) -> str | None:
|
|
398
|
+
"""Return the known label for a favorite command, if any."""
|
|
399
|
+
|
|
400
|
+
return self.activity_favorite_labels.get(act_lo, {}).get((device_id, command_id))
|
|
401
|
+
|
|
402
|
+
def get_keybinding_label(
|
|
403
|
+
self, act_lo: int, device_id: int, command_id: int
|
|
404
|
+
) -> str | None:
|
|
405
|
+
"""Return the known label for an activity keybinding command, if any."""
|
|
406
|
+
|
|
407
|
+
return self.activity_keybinding_labels.get(act_lo, {}).get((device_id, command_id))
|
|
408
|
+
|
|
409
|
+
def get_activity_favorite_labels(self, act_lo: int) -> list[dict[str, int | str]]:
|
|
410
|
+
"""Return favorite slots decorated with resolved labels."""
|
|
411
|
+
|
|
412
|
+
slots = self.activity_favorite_slots.get(act_lo, [])
|
|
413
|
+
labels = self.activity_favorite_labels.get(act_lo, {})
|
|
414
|
+
|
|
415
|
+
favorites: list[dict[str, int | str]] = []
|
|
416
|
+
seen: set[tuple[int, int]] = set()
|
|
417
|
+
for slot in slots:
|
|
418
|
+
pair = (slot["device_id"], slot["command_id"])
|
|
419
|
+
if pair in seen:
|
|
420
|
+
continue
|
|
421
|
+
label = labels.get(pair)
|
|
422
|
+
if not label:
|
|
423
|
+
continue
|
|
424
|
+
seen.add(pair)
|
|
425
|
+
favorites.append(
|
|
426
|
+
{
|
|
427
|
+
"name": label,
|
|
428
|
+
"device_id": slot["device_id"],
|
|
429
|
+
"command_id": slot["command_id"],
|
|
430
|
+
}
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
return favorites
|
|
434
|
+
|
|
435
|
+
def get_activity_keybinding_labels(self, act_lo: int) -> list[dict[str, int | str]]:
|
|
436
|
+
"""Return keybinding slots decorated with resolved labels."""
|
|
437
|
+
|
|
438
|
+
slots = self.activity_keybinding_slots.get(act_lo, [])
|
|
439
|
+
labels = self.activity_keybinding_labels.get(act_lo, {})
|
|
440
|
+
|
|
441
|
+
keybindings: list[dict[str, int | str]] = []
|
|
442
|
+
seen: set[int] = set()
|
|
443
|
+
for slot in slots:
|
|
444
|
+
button_id = slot["button_id"]
|
|
445
|
+
if button_id in seen:
|
|
446
|
+
continue
|
|
447
|
+
pair = (slot["device_id"], slot["command_id"])
|
|
448
|
+
label = labels.get(pair)
|
|
449
|
+
if not label:
|
|
450
|
+
continue
|
|
451
|
+
seen.add(button_id)
|
|
452
|
+
keybindings.append(
|
|
453
|
+
{
|
|
454
|
+
"button_id": button_id,
|
|
455
|
+
"name": label,
|
|
456
|
+
"device_id": slot["device_id"],
|
|
457
|
+
"command_id": slot["command_id"],
|
|
458
|
+
}
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
return keybindings
|
|
462
|
+
|
|
463
|
+
def replace_activity_macros(
|
|
464
|
+
self, act_lo: int, macros: list[dict[str, int | str]]
|
|
465
|
+
) -> None:
|
|
466
|
+
"""Replace the cached macro list for ``act_lo``."""
|
|
467
|
+
|
|
468
|
+
self.activity_macros[act_lo & 0xFF] = list(macros)
|
|
469
|
+
|
|
470
|
+
def append_activity_macro(self, act_lo: int, command_id: int, label: str) -> None:
|
|
471
|
+
"""Record a single macro entry for an activity."""
|
|
472
|
+
|
|
473
|
+
target = self.activity_macros.setdefault(act_lo & 0xFF, [])
|
|
474
|
+
for entry in target:
|
|
475
|
+
if entry.get("command_id") == command_id:
|
|
476
|
+
entry["label"] = label
|
|
477
|
+
return
|
|
478
|
+
|
|
479
|
+
target.append({"command_id": command_id, "label": label})
|
|
480
|
+
|
|
481
|
+
def get_activity_macros(self, act_lo: int) -> list[dict[str, int | str]]:
|
|
482
|
+
"""Return the known macro definitions for ``act_lo``."""
|
|
483
|
+
|
|
484
|
+
return list(self.activity_macros.get(act_lo & 0xFF, []))
|
|
485
|
+
|
|
486
|
+
def parse_device_commands(
|
|
487
|
+
self,
|
|
488
|
+
payload: bytes,
|
|
489
|
+
dev_id: int,
|
|
490
|
+
*,
|
|
491
|
+
hub_version: str,
|
|
492
|
+
count: int | None = None,
|
|
493
|
+
) -> Dict[int, str]:
|
|
494
|
+
"""Parse an assembled REQ_COMMANDS body into a ``{command_id: label}``.
|
|
495
|
+
|
|
496
|
+
Uses the assembled fixed-stride schema parser
|
|
497
|
+
:func:`commands.iter_command_records_from_assembled`
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
``count`` may be supplied explicitly (e.g. from the page-1 header's
|
|
502
|
+
``total_commands`` field). If omitted, it is inferred from
|
|
503
|
+
``len(payload) // stride`` — correct for well-formed real wire data
|
|
504
|
+
(always a clean multiple of the stride) and graceful for slightly
|
|
505
|
+
malformed inputs because the parser silently stops at truncated
|
|
506
|
+
records.
|
|
507
|
+
"""
|
|
508
|
+
|
|
509
|
+
stride = (
|
|
510
|
+
COMMAND_RECORD_STRIDE_X1
|
|
511
|
+
if hub_version == HUB_VERSION_X1
|
|
512
|
+
else COMMAND_RECORD_STRIDE_X1S_X2
|
|
513
|
+
)
|
|
514
|
+
effective_count = count if count is not None else len(payload) // stride
|
|
515
|
+
|
|
516
|
+
commands_found: Dict[int, str] = {}
|
|
517
|
+
for record in iter_command_records_from_assembled(
|
|
518
|
+
payload,
|
|
519
|
+
count=effective_count,
|
|
520
|
+
dev_id=dev_id,
|
|
521
|
+
hub_version=hub_version,
|
|
522
|
+
):
|
|
523
|
+
# control[0] is the codec selector; control[1..7] is the
|
|
524
|
+
# 6-byte canonical button code (BE). Surface both into the
|
|
525
|
+
# per-command metadata cache so backup can capture them
|
|
526
|
+
# without re-fetching the records.
|
|
527
|
+
if len(record.control) >= 7:
|
|
528
|
+
self.command_metadata[dev_id & 0xFF][record.command_id & 0xFF] = {
|
|
529
|
+
"library_type": record.control[0] & 0xFF,
|
|
530
|
+
"button_code": int.from_bytes(record.control[1:7], "big"),
|
|
531
|
+
"sort_id": record.sort_id & 0xFF,
|
|
532
|
+
}
|
|
533
|
+
if record.command_id not in commands_found and record.label:
|
|
534
|
+
commands_found[record.command_id] = record.label
|
|
535
|
+
return commands_found
|
|
536
|
+
|
|
537
|
+
def record_virtual_device(
|
|
538
|
+
self,
|
|
539
|
+
device_id: int,
|
|
540
|
+
*,
|
|
541
|
+
name: str,
|
|
542
|
+
button_id: int | None = None,
|
|
543
|
+
method: str | None = None,
|
|
544
|
+
url: str | None = None,
|
|
545
|
+
headers: dict[str, str] | None = None,
|
|
546
|
+
button_name: str | None = None,
|
|
547
|
+
) -> None:
|
|
548
|
+
brand = "Virtual HTTP"
|
|
549
|
+
self.devices[device_id & 0xFF] = normalize_device_entry(
|
|
550
|
+
{
|
|
551
|
+
**(self.devices.get(device_id & 0xFF, {})),
|
|
552
|
+
"brand": brand,
|
|
553
|
+
"name": name,
|
|
554
|
+
},
|
|
555
|
+
default_class=DEVICE_CLASS_WIFI_IP,
|
|
556
|
+
default_class_code=0x1C,
|
|
557
|
+
)
|
|
558
|
+
if button_id is not None:
|
|
559
|
+
self.buttons.setdefault(device_id & 0xFF, set()).add(button_id)
|
|
560
|
+
meta: Dict[str, Any] = {
|
|
561
|
+
"device_id": device_id & 0xFF,
|
|
562
|
+
"name": name,
|
|
563
|
+
"brand": brand,
|
|
564
|
+
}
|
|
565
|
+
if method is not None:
|
|
566
|
+
meta["method"] = method
|
|
567
|
+
if url is not None:
|
|
568
|
+
meta["url"] = url
|
|
569
|
+
if headers is not None:
|
|
570
|
+
meta["headers"] = headers
|
|
571
|
+
if button_name is not None:
|
|
572
|
+
meta["button_name"] = button_name
|
|
573
|
+
if button_id is not None:
|
|
574
|
+
self.ip_buttons[device_id & 0xFF][button_id] = meta
|
|
575
|
+
self.ip_devices[device_id & 0xFF] = meta
|
|
576
|
+
|
|
577
|
+
def record_app_activation(
|
|
578
|
+
self,
|
|
579
|
+
*,
|
|
580
|
+
ent_id: int,
|
|
581
|
+
ent_kind: str,
|
|
582
|
+
ent_name: str,
|
|
583
|
+
command_id: int,
|
|
584
|
+
command_label: str | None,
|
|
585
|
+
button_label: str | None,
|
|
586
|
+
direction: str,
|
|
587
|
+
ts: Optional[float] = None,
|
|
588
|
+
) -> dict[str, Any]:
|
|
589
|
+
timestamp = ts if ts is not None else time.time()
|
|
590
|
+
record = {
|
|
591
|
+
"timestamp": timestamp,
|
|
592
|
+
"iso_time": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(timestamp)),
|
|
593
|
+
"direction": direction,
|
|
594
|
+
"entity_id": ent_id,
|
|
595
|
+
"entity_kind": ent_kind,
|
|
596
|
+
"entity_name": ent_name,
|
|
597
|
+
"command_id": command_id,
|
|
598
|
+
"command_label": command_label,
|
|
599
|
+
"button_label": button_label,
|
|
600
|
+
}
|
|
601
|
+
self.app_activations.append(record)
|
|
602
|
+
return record
|
|
603
|
+
|
|
604
|
+
def get_app_activations(self) -> list[dict[str, Any]]:
|
|
605
|
+
return list(self.app_activations)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
class BurstScheduler:
|
|
609
|
+
def __init__(self, *, idle_s: float = 0.15, response_grace: float = 1.0) -> None:
|
|
610
|
+
self.idle_s = idle_s
|
|
611
|
+
self.response_grace = response_grace
|
|
612
|
+
self.active = False
|
|
613
|
+
self.kind: str | None = None
|
|
614
|
+
self.last_ts = 0.0
|
|
615
|
+
self.queue: list[tuple[int, bytes, bool, Optional[str]]] = []
|
|
616
|
+
self.listeners: dict[str, list[Callable[[str], None]]] = {}
|
|
617
|
+
|
|
618
|
+
def on_burst_end(self, key: str, cb: Callable[[str], None]) -> None:
|
|
619
|
+
self.listeners.setdefault(key, []).append(cb)
|
|
620
|
+
|
|
621
|
+
def start(self, kind: str, *, now: Optional[float] = None) -> None:
|
|
622
|
+
self.active = True
|
|
623
|
+
self.kind = kind
|
|
624
|
+
base = time.monotonic() if now is None else now
|
|
625
|
+
self.last_ts = base + self.response_grace
|
|
626
|
+
|
|
627
|
+
def queue_or_send(
|
|
628
|
+
self,
|
|
629
|
+
*,
|
|
630
|
+
opcode: int,
|
|
631
|
+
payload: bytes,
|
|
632
|
+
expects_burst: bool,
|
|
633
|
+
burst_kind: Optional[str],
|
|
634
|
+
can_issue: Callable[[], bool],
|
|
635
|
+
sender: Callable[[int, bytes], None],
|
|
636
|
+
now: Optional[float] = None,
|
|
637
|
+
) -> bool:
|
|
638
|
+
is_burst = expects_burst
|
|
639
|
+
current_time = time.monotonic() if now is None else now
|
|
640
|
+
|
|
641
|
+
if not can_issue():
|
|
642
|
+
return False
|
|
643
|
+
|
|
644
|
+
if self.active:
|
|
645
|
+
self.queue.append((opcode, payload, is_burst, burst_kind))
|
|
646
|
+
return True
|
|
647
|
+
|
|
648
|
+
if is_burst:
|
|
649
|
+
self.start(burst_kind or "generic", now=current_time)
|
|
650
|
+
|
|
651
|
+
sender(opcode, payload)
|
|
652
|
+
return True
|
|
653
|
+
|
|
654
|
+
def tick(
|
|
655
|
+
self,
|
|
656
|
+
now: float,
|
|
657
|
+
*,
|
|
658
|
+
can_issue: Callable[[], bool],
|
|
659
|
+
sender: Callable[[int, bytes], None],
|
|
660
|
+
) -> None:
|
|
661
|
+
if not self.active:
|
|
662
|
+
return
|
|
663
|
+
if now - self.last_ts < self.idle_s:
|
|
664
|
+
return
|
|
665
|
+
self._drain(can_issue=can_issue, sender=sender, now=now)
|
|
666
|
+
|
|
667
|
+
def finish(
|
|
668
|
+
self,
|
|
669
|
+
key: str,
|
|
670
|
+
*,
|
|
671
|
+
can_issue: Callable[[], bool],
|
|
672
|
+
sender: Callable[[int, bytes], None],
|
|
673
|
+
now: Optional[float] = None,
|
|
674
|
+
) -> bool:
|
|
675
|
+
if not self.active or self.kind != key:
|
|
676
|
+
return False
|
|
677
|
+
self._drain(
|
|
678
|
+
can_issue=can_issue,
|
|
679
|
+
sender=sender,
|
|
680
|
+
now=time.monotonic() if now is None else now,
|
|
681
|
+
)
|
|
682
|
+
return True
|
|
683
|
+
|
|
684
|
+
def _drain(
|
|
685
|
+
self,
|
|
686
|
+
*,
|
|
687
|
+
can_issue: Callable[[], bool],
|
|
688
|
+
sender: Callable[[int, bytes], None],
|
|
689
|
+
now: float,
|
|
690
|
+
) -> None:
|
|
691
|
+
finished_kind = self.kind or "generic"
|
|
692
|
+
self.active = False
|
|
693
|
+
self.kind = None
|
|
694
|
+
self._notify_burst_end(finished_kind)
|
|
695
|
+
|
|
696
|
+
while self.queue:
|
|
697
|
+
op, payload, is_burst, next_kind = self.queue.pop(0)
|
|
698
|
+
if not can_issue():
|
|
699
|
+
continue
|
|
700
|
+
if is_burst:
|
|
701
|
+
self.start(next_kind or "generic", now=now)
|
|
702
|
+
sender(op, payload)
|
|
703
|
+
if self.active:
|
|
704
|
+
break
|
|
705
|
+
|
|
706
|
+
def _notify_burst_end(self, key: str) -> None:
|
|
707
|
+
for cb in self.listeners.get(key, []):
|
|
708
|
+
cb(key)
|
|
709
|
+
if ":" in key:
|
|
710
|
+
prefix = key.split(":", 1)[0]
|
|
711
|
+
for cb in self.listeners.get(prefix, []):
|
|
712
|
+
cb(key)
|
|
713
|
+
|