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,1655 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Opcode-specific frame handlers used by :class:`~.x1_proxy.X1Proxy`."""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
import unicodedata
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from .hub_versions import HUB_VERSION_X1
|
|
11
|
+
from .commands import decode_burst_frame, parse_ir_command_dump_frame
|
|
12
|
+
from .frame_handlers import BaseFrameHandler, FrameContext, register_handler
|
|
13
|
+
from .macros import (
|
|
14
|
+
MacroAssembler,
|
|
15
|
+
parse_macro_burst_frame,
|
|
16
|
+
parse_macro_records_from_burst,
|
|
17
|
+
)
|
|
18
|
+
from .protocol_const import (
|
|
19
|
+
BUTTONNAME_BY_CODE,
|
|
20
|
+
ButtonName,
|
|
21
|
+
FAMILY_DEVBTNS,
|
|
22
|
+
FAMILY_FAV_ORDER_RESP,
|
|
23
|
+
FAMILY_HUB_NAME_REPLY,
|
|
24
|
+
FAMILY_MACROS,
|
|
25
|
+
FAMILY_KEYMAP,
|
|
26
|
+
OP_ACK_READY,
|
|
27
|
+
OP_CATALOG_ROW_ACTIVITY,
|
|
28
|
+
OP_CATALOG_ROW_DEVICE,
|
|
29
|
+
OP_DEVBTN_HEADER,
|
|
30
|
+
OP_DEVBTN_MORE,
|
|
31
|
+
OP_DEVBTN_PAGE,
|
|
32
|
+
OP_DEVBTN_PAGE_ALT1,
|
|
33
|
+
OP_DEVBTN_PAGE_ALT2,
|
|
34
|
+
OP_DEVBTN_PAGE_ALT3,
|
|
35
|
+
OP_DEVBTN_PAGE_ALT4,
|
|
36
|
+
OP_DEVBTN_PAGE_ALT5,
|
|
37
|
+
OP_DEVBTN_PAGE_ALT6,
|
|
38
|
+
OP_DEVBTN_SINGLE,
|
|
39
|
+
OP_DEVBTN_TAIL,
|
|
40
|
+
OP_IDLE_BEHAVIOR,
|
|
41
|
+
OP_MACROS_A1,
|
|
42
|
+
OP_MACROS_A2,
|
|
43
|
+
OP_MACROS_B1,
|
|
44
|
+
OP_MACROS_B2,
|
|
45
|
+
OP_KEYMAP_CONT,
|
|
46
|
+
OP_KEYMAP_FINAL_X1S,
|
|
47
|
+
OP_KEYMAP_OVERLAY_X1,
|
|
48
|
+
OP_KEYMAP_PAGE_X1_663D,
|
|
49
|
+
OP_KEYMAP_PAGE_X1_AE3D,
|
|
50
|
+
OP_KEYMAP_PAGE_X1_E43D,
|
|
51
|
+
OP_KEYMAP_PAGE_X2_C03D,
|
|
52
|
+
OP_KEYMAP_TBL_A,
|
|
53
|
+
OP_KEYMAP_TBL_B,
|
|
54
|
+
OP_KEYMAP_TBL_C,
|
|
55
|
+
OP_KEYMAP_TBL_D,
|
|
56
|
+
OP_KEYMAP_TBL_F,
|
|
57
|
+
OP_KEYMAP_TBL_E,
|
|
58
|
+
OP_KEYMAP_TBL_G,
|
|
59
|
+
OP_CREATE_DEVICE_HEAD,
|
|
60
|
+
OP_DEFINE_IP_CMD,
|
|
61
|
+
OP_DEFINE_IP_CMD_EXISTING,
|
|
62
|
+
OP_PREPARE_SAVE,
|
|
63
|
+
OP_FINALIZE_DEVICE,
|
|
64
|
+
OP_DEVICE_SAVE_HEAD,
|
|
65
|
+
OP_SAVE_COMMIT,
|
|
66
|
+
OP_REQ_IPCMD_SYNC,
|
|
67
|
+
OP_IPCMD_ROW_A,
|
|
68
|
+
OP_IPCMD_ROW_B,
|
|
69
|
+
OP_IPCMD_ROW_C,
|
|
70
|
+
OP_IPCMD_ROW_D,
|
|
71
|
+
ACK_SUCCESS,
|
|
72
|
+
OP_MARKER,
|
|
73
|
+
OP_REQ_ACTIVATE,
|
|
74
|
+
OP_REQ_ACTIVITY_MAP,
|
|
75
|
+
OP_REQ_BUTTONS,
|
|
76
|
+
OP_REQ_COMMANDS,
|
|
77
|
+
OP_REQ_IDLE_BEHAVIOR,
|
|
78
|
+
OP_REQ_ACTIVITIES,
|
|
79
|
+
OP_SET_IDLE_BEHAVIOR,
|
|
80
|
+
OP_X2_REMOTE_LIST_ROW,
|
|
81
|
+
OP_ACTIVITY_MAP_PAGE,
|
|
82
|
+
OP_ACTIVITY_MAP_PAGE_X1S,
|
|
83
|
+
OP_ACTIVITY_CREATE_ACK,
|
|
84
|
+
OP_X1_ACTIVITY,
|
|
85
|
+
OP_X1_DEVICE,
|
|
86
|
+
OP_KEYMAP_EXTRA,
|
|
87
|
+
classify_device_class_code,
|
|
88
|
+
opcode_family,
|
|
89
|
+
)
|
|
90
|
+
if TYPE_CHECKING:
|
|
91
|
+
from .x1_proxy import X1Proxy
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
OP_CREATE_DEVICE_ACK = 0x0107
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _consume_length_prefixed_string(buf: bytes, offset: int) -> tuple[str, int]:
|
|
98
|
+
"""Decode a length-prefixed UTF-8 string from ``buf`` starting at ``offset``."""
|
|
99
|
+
|
|
100
|
+
if offset >= len(buf):
|
|
101
|
+
return "", offset
|
|
102
|
+
|
|
103
|
+
length = buf[offset]
|
|
104
|
+
start = offset + 1
|
|
105
|
+
end = min(len(buf), start + length)
|
|
106
|
+
try:
|
|
107
|
+
return buf[start:end].decode("utf-8", errors="ignore").strip("\x00"), end
|
|
108
|
+
except Exception:
|
|
109
|
+
return "", end
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _extract_text_fields(payload: bytes, start: int, count: int = 2) -> list[str]:
|
|
113
|
+
"""Return up to ``count`` decoded fields from a length-prefixed payload segment."""
|
|
114
|
+
|
|
115
|
+
cursor = start
|
|
116
|
+
fields: list[str] = []
|
|
117
|
+
for _ in range(count):
|
|
118
|
+
text, cursor = _consume_length_prefixed_string(payload, cursor)
|
|
119
|
+
fields.append(text)
|
|
120
|
+
|
|
121
|
+
remaining = payload[cursor:]
|
|
122
|
+
if remaining:
|
|
123
|
+
parts = [p.decode("utf-8", errors="ignore").strip("\x00") for p in remaining.split(b"\x00") if p]
|
|
124
|
+
for idx, part in enumerate(parts):
|
|
125
|
+
if idx >= len(fields):
|
|
126
|
+
break
|
|
127
|
+
if not fields[idx]:
|
|
128
|
+
fields[idx] = part
|
|
129
|
+
|
|
130
|
+
return fields
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _decode_utf16le_segment(payload: bytes, *, start: int = 0, length: int | None = None) -> str:
|
|
134
|
+
"""Decode a UTF-16LE string from ``payload`` with optional bounds."""
|
|
135
|
+
|
|
136
|
+
end = None if length is None else start + length
|
|
137
|
+
segment = payload[start:end]
|
|
138
|
+
if not segment:
|
|
139
|
+
return ""
|
|
140
|
+
try:
|
|
141
|
+
text = segment.decode("utf-16le", errors="ignore").replace("\x00", "")
|
|
142
|
+
text = re.sub(r"[^\x20-\x7E]", "", text)
|
|
143
|
+
return text.strip()
|
|
144
|
+
except Exception:
|
|
145
|
+
return ""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _decode_ascii_blocks(payload: bytes) -> list[str]:
|
|
149
|
+
"""Split an ASCII-ish payload into human-readable blocks."""
|
|
150
|
+
|
|
151
|
+
decoded = payload.decode("utf-8", errors="ignore")
|
|
152
|
+
parts = [p.strip("\x00") for p in decoded.replace("\r", "\n").split("\n") if p.strip("\x00")]
|
|
153
|
+
return parts
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@register_handler(opcode_families_low=(0x02,), directions=("H→A",))
|
|
158
|
+
class BannerInfoHandler(BaseFrameHandler):
|
|
159
|
+
"""Capture family-0x02 banner replies with model/build/firmware metadata."""
|
|
160
|
+
|
|
161
|
+
def handle(self, frame: FrameContext) -> None:
|
|
162
|
+
frame.proxy.record_banner_payload(frame.opcode, frame.payload)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@register_handler(opcode_families_low=(FAMILY_MACROS,), directions=("H→A",))
|
|
166
|
+
class MacroHandler(BaseFrameHandler):
|
|
167
|
+
"""Decode macro pages and populate the activity cache."""
|
|
168
|
+
|
|
169
|
+
def handle(self, frame: FrameContext) -> None:
|
|
170
|
+
proxy: X1Proxy = frame.proxy
|
|
171
|
+
|
|
172
|
+
now = time.monotonic()
|
|
173
|
+
parsed = parse_macro_burst_frame(frame.opcode, frame.raw)
|
|
174
|
+
if parsed is not None:
|
|
175
|
+
frag = (
|
|
176
|
+
f"{parsed.fragment_index}/{parsed.total_fragments}"
|
|
177
|
+
if parsed.fragment_index is not None and parsed.total_fragments is not None
|
|
178
|
+
else (f"{parsed.fragment_index}" if parsed.fragment_index is not None else "?")
|
|
179
|
+
)
|
|
180
|
+
activity = f" act=0x{parsed.activity_id:02X}" if parsed.activity_id is not None else ""
|
|
181
|
+
start_cmd = (
|
|
182
|
+
f" start_cmd=0x{parsed.start_command_id:02X}"
|
|
183
|
+
if parsed.start_command_id is not None
|
|
184
|
+
else ""
|
|
185
|
+
)
|
|
186
|
+
len_ok = " len_ok=yes" if parsed.payload_length_matches_hi else " len_ok=no"
|
|
187
|
+
proxy._log.debug(
|
|
188
|
+
"[REQ_MACROS] role=%s frag=%s%s%s%s",
|
|
189
|
+
parsed.role,
|
|
190
|
+
frag,
|
|
191
|
+
activity,
|
|
192
|
+
start_cmd,
|
|
193
|
+
len_ok,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
completed = proxy._macro_assembler.feed(frame.opcode, frame.payload, frame.raw)
|
|
197
|
+
activity_hint = proxy._macro_assembler._last_activity_id
|
|
198
|
+
burst_key = "macros" if activity_hint is None else f"macros:{activity_hint & 0xFF}"
|
|
199
|
+
|
|
200
|
+
if proxy._burst.active and proxy._burst.kind and proxy._burst.kind.startswith("macros"):
|
|
201
|
+
proxy._burst.last_ts = now + proxy._burst.response_grace
|
|
202
|
+
if proxy._burst.kind == "macros":
|
|
203
|
+
proxy._burst.kind = burst_key
|
|
204
|
+
else:
|
|
205
|
+
proxy._burst.start(burst_key, now=now)
|
|
206
|
+
|
|
207
|
+
if not completed:
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
for activity_id, assembled, boundaries in completed:
|
|
211
|
+
act_lo = activity_id & 0xFF
|
|
212
|
+
macros: list[dict[str, int | str]] = []
|
|
213
|
+
# Production REQ_MACROS uses the assembled fixed-width parser via
|
|
214
|
+
# `parse_macro_records_from_burst`. The legacy
|
|
215
|
+
# `decode_macro_records` remains importable for tests and
|
|
216
|
+
# external callers.
|
|
217
|
+
for record in parse_macro_records_from_burst(
|
|
218
|
+
assembled,
|
|
219
|
+
activity_id=activity_id,
|
|
220
|
+
record_boundaries=boundaries,
|
|
221
|
+
hub_version=proxy.hub_version,
|
|
222
|
+
):
|
|
223
|
+
# Surface every assembled record (including POWER_*) for
|
|
224
|
+
# read-modify-write callers like add_device_to_activity.
|
|
225
|
+
proxy.cache_macro_record(record)
|
|
226
|
+
|
|
227
|
+
# Suppress auto-generated POWER_* macros from the activity
|
|
228
|
+
# UI list.
|
|
229
|
+
if not record.label or record.label.upper().startswith("POWER_"):
|
|
230
|
+
continue
|
|
231
|
+
macros.append({"command_id": record.key_id, "label": record.label})
|
|
232
|
+
|
|
233
|
+
proxy.state.replace_activity_macros(act_lo, macros)
|
|
234
|
+
proxy._macros_complete.add(act_lo)
|
|
235
|
+
proxy._pending_macro_requests.discard(act_lo)
|
|
236
|
+
if macros:
|
|
237
|
+
proxy._log.info(
|
|
238
|
+
"[MACRO] act=0x%02X macros{%d}: %s",
|
|
239
|
+
act_lo,
|
|
240
|
+
len(macros),
|
|
241
|
+
", ".join(f"{m['command_id']}: {m['label']}" for m in macros),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if completed:
|
|
245
|
+
proxy._burst.finish(
|
|
246
|
+
burst_key,
|
|
247
|
+
can_issue=proxy.can_issue_commands,
|
|
248
|
+
sender=proxy._send_cmd_frame,
|
|
249
|
+
now=now,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@register_handler(opcode_families_low=(0x47,), directions=("H→A",))
|
|
256
|
+
class ActivityInputsHandler(BaseFrameHandler):
|
|
257
|
+
"""Capture activity-inputs list frames used by the macro assignment wizard."""
|
|
258
|
+
|
|
259
|
+
def handle(self, frame: FrameContext) -> None:
|
|
260
|
+
proxy: X1Proxy = frame.proxy
|
|
261
|
+
proxy.notify_activity_inputs_frame(frame.payload)
|
|
262
|
+
|
|
263
|
+
def _parse_header_lines(lines: list[str]) -> dict[str, str]:
|
|
264
|
+
headers: dict[str, str] = {}
|
|
265
|
+
for line in lines:
|
|
266
|
+
if ":" not in line:
|
|
267
|
+
continue
|
|
268
|
+
key, val = line.split(":", 1)
|
|
269
|
+
headers[key.strip()] = val.strip()
|
|
270
|
+
return headers
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _parse_ip_command_fields(payload: bytes) -> tuple[str, str, dict[str, str]]:
|
|
274
|
+
"""Extract HTTP method, URL, and headers from an IP command payload."""
|
|
275
|
+
|
|
276
|
+
method = ""
|
|
277
|
+
url = ""
|
|
278
|
+
headers: dict[str, str] = {}
|
|
279
|
+
|
|
280
|
+
if payload:
|
|
281
|
+
try:
|
|
282
|
+
cursor = 0
|
|
283
|
+
if cursor < len(payload):
|
|
284
|
+
m_len = payload[cursor]
|
|
285
|
+
cursor += 1
|
|
286
|
+
method = payload[cursor : cursor + m_len].decode("utf-8", errors="ignore")
|
|
287
|
+
cursor += m_len
|
|
288
|
+
if cursor < len(payload):
|
|
289
|
+
u_len = payload[cursor]
|
|
290
|
+
cursor += 1
|
|
291
|
+
url = payload[cursor : cursor + u_len].decode("utf-8", errors="ignore")
|
|
292
|
+
cursor += u_len
|
|
293
|
+
if cursor < len(payload):
|
|
294
|
+
h_len = payload[cursor]
|
|
295
|
+
cursor += 1
|
|
296
|
+
header_blob = payload[cursor : cursor + h_len].decode("utf-8", errors="ignore")
|
|
297
|
+
headers = _parse_header_lines(header_blob.split("\n"))
|
|
298
|
+
except Exception:
|
|
299
|
+
# fall through to heuristics
|
|
300
|
+
pass
|
|
301
|
+
|
|
302
|
+
ascii_parts = _decode_ascii_blocks(payload)
|
|
303
|
+
for part in ascii_parts:
|
|
304
|
+
clean = re.sub(r"[^\x20-\x7E]", "", part)
|
|
305
|
+
upper_clean = clean.upper()
|
|
306
|
+
|
|
307
|
+
if not method:
|
|
308
|
+
for verb in ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"):
|
|
309
|
+
if verb in upper_clean:
|
|
310
|
+
method = verb
|
|
311
|
+
break
|
|
312
|
+
if not method and clean.isalpha():
|
|
313
|
+
method = upper_clean
|
|
314
|
+
if not url:
|
|
315
|
+
lower_clean = clean.lower()
|
|
316
|
+
if lower_clean.startswith("http"):
|
|
317
|
+
url = clean
|
|
318
|
+
elif method and "http" in lower_clean:
|
|
319
|
+
for tok in clean.split():
|
|
320
|
+
if tok.lower().startswith("http/"):
|
|
321
|
+
continue
|
|
322
|
+
if tok.lower().startswith("http"):
|
|
323
|
+
url = tok
|
|
324
|
+
break
|
|
325
|
+
if method and not url and "http/" not in clean.lower():
|
|
326
|
+
tokens = clean.split()
|
|
327
|
+
if method in tokens and len(tokens) > tokens.index(method) + 1:
|
|
328
|
+
candidate = tokens[tokens.index(method) + 1]
|
|
329
|
+
if not candidate.lower().startswith("http/" ):
|
|
330
|
+
url = candidate
|
|
331
|
+
if ":" in clean:
|
|
332
|
+
headers |= _parse_header_lines([clean])
|
|
333
|
+
|
|
334
|
+
if method and not method.isalpha():
|
|
335
|
+
match = re.search(r"\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b", method, re.IGNORECASE)
|
|
336
|
+
if match:
|
|
337
|
+
method = match.group(1).upper()
|
|
338
|
+
|
|
339
|
+
if url.upper().startswith("HTTP/"):
|
|
340
|
+
url = ""
|
|
341
|
+
|
|
342
|
+
if not url or url.startswith("-") or url.lower().startswith("content-"):
|
|
343
|
+
for part in ascii_parts:
|
|
344
|
+
if method:
|
|
345
|
+
tokens = part.split()
|
|
346
|
+
for idx, tok in enumerate(tokens):
|
|
347
|
+
if method in tok.upper() and idx + 1 < len(tokens):
|
|
348
|
+
candidate = tokens[idx + 1]
|
|
349
|
+
if not candidate.lower().startswith("http/"):
|
|
350
|
+
url = candidate if candidate.startswith("/") else url
|
|
351
|
+
if candidate.startswith("/"):
|
|
352
|
+
break
|
|
353
|
+
if tok.lower().startswith("http/"):
|
|
354
|
+
continue
|
|
355
|
+
if tok.startswith("/") or tok.lower().startswith("http"):
|
|
356
|
+
url = tok
|
|
357
|
+
break
|
|
358
|
+
if url:
|
|
359
|
+
break
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
return method, url, headers
|
|
363
|
+
|
|
364
|
+
def _extract_dev_id(
|
|
365
|
+
raw: bytes,
|
|
366
|
+
payload: bytes,
|
|
367
|
+
opcode: int,
|
|
368
|
+
*,
|
|
369
|
+
hub_version: str,
|
|
370
|
+
) -> int:
|
|
371
|
+
"""Determine device ID for a command burst frame."""
|
|
372
|
+
|
|
373
|
+
parsed = decode_burst_frame(opcode, raw, hub_version=hub_version)
|
|
374
|
+
if parsed is not None and hasattr(parsed, "device_id"):
|
|
375
|
+
return parsed.device_id
|
|
376
|
+
|
|
377
|
+
if len(payload) >= 4:
|
|
378
|
+
return payload[3]
|
|
379
|
+
|
|
380
|
+
return 0
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@register_handler(opcodes=(OP_CREATE_DEVICE_HEAD,), directions=("A→H",))
|
|
384
|
+
class CreateVirtualDeviceHandler(BaseFrameHandler):
|
|
385
|
+
"""Capture app-initiated virtual device creation requests."""
|
|
386
|
+
|
|
387
|
+
def handle(self, frame: FrameContext) -> None:
|
|
388
|
+
proxy: X1Proxy = frame.proxy
|
|
389
|
+
payload = frame.payload
|
|
390
|
+
device_name = _decode_utf16le_segment(payload, start=0, length=64) or _decode_utf16le_segment(payload)
|
|
391
|
+
proxy.start_virtual_device(device_name=device_name)
|
|
392
|
+
proxy._log.info("[CREATE] device name='%s' (%dB payload)", device_name, len(payload))
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@register_handler(opcodes=(OP_DEFINE_IP_CMD,), directions=("A→H",))
|
|
396
|
+
class DefineIpCommandHandler(BaseFrameHandler):
|
|
397
|
+
"""Decode IP command metadata sent from the app."""
|
|
398
|
+
|
|
399
|
+
def handle(self, frame: FrameContext) -> None:
|
|
400
|
+
proxy: X1Proxy = frame.proxy
|
|
401
|
+
payload = frame.payload
|
|
402
|
+
button_name = _decode_utf16le_segment(payload, start=0, length=64) or _decode_utf16le_segment(payload)
|
|
403
|
+
method, url, headers = _parse_ip_command_fields(payload[64:])
|
|
404
|
+
proxy.update_virtual_device(button_name=button_name, method=method, url=url, headers=headers)
|
|
405
|
+
proxy._log.info(
|
|
406
|
+
"[CREATE] button='%s' method=%s url='%s' headers=%s",
|
|
407
|
+
button_name,
|
|
408
|
+
method or "?",
|
|
409
|
+
url,
|
|
410
|
+
", ".join(f"{k}: {v}" for k, v in headers.items()) if headers else "{}",
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@register_handler(opcodes=(OP_DEFINE_IP_CMD_EXISTING,), directions=("A→H",))
|
|
415
|
+
class DefineExistingIpCommandHandler(BaseFrameHandler):
|
|
416
|
+
"""Capture metadata when the app adds an IP command to an existing device."""
|
|
417
|
+
|
|
418
|
+
def handle(self, frame: FrameContext) -> None:
|
|
419
|
+
proxy: X1Proxy = frame.proxy
|
|
420
|
+
payload = frame.payload
|
|
421
|
+
button_name = _decode_utf16le_segment(payload, start=16, length=64) or _decode_utf16le_segment(payload, start=16)
|
|
422
|
+
method, url, headers = _parse_ip_command_fields(payload[64:])
|
|
423
|
+
proxy.update_virtual_device(button_name=button_name, method=method, url=url, headers=headers)
|
|
424
|
+
proxy._log.info(
|
|
425
|
+
"[CREATE] existing dev button='%s' method=%s url='%s' headers=%s",
|
|
426
|
+
button_name,
|
|
427
|
+
method or "?",
|
|
428
|
+
url,
|
|
429
|
+
", ".join(f"{k}: {v}" for k, v in headers.items()) if headers else "{}",
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@register_handler(
|
|
434
|
+
opcodes=(OP_IPCMD_ROW_A, OP_IPCMD_ROW_B, OP_IPCMD_ROW_C, OP_IPCMD_ROW_D),
|
|
435
|
+
directions=("H→A",),
|
|
436
|
+
)
|
|
437
|
+
class IpCommandSyncRowHandler(BaseFrameHandler):
|
|
438
|
+
"""Decode IP command rows returned when syncing commands for an existing device."""
|
|
439
|
+
|
|
440
|
+
def handle(self, frame: FrameContext) -> None:
|
|
441
|
+
proxy: X1Proxy = frame.proxy
|
|
442
|
+
payload = frame.payload
|
|
443
|
+
proxy._burst.start("commands", now=time.monotonic())
|
|
444
|
+
if len(payload) > 6:
|
|
445
|
+
proxy._burst.start(f"commands:{payload[6]}", now=time.monotonic())
|
|
446
|
+
|
|
447
|
+
device_id = payload[6] if len(payload) > 6 else None
|
|
448
|
+
button_id = payload[7] if len(payload) > 7 else None
|
|
449
|
+
button_name = _decode_utf16le_segment(payload, start=16, length=64) or _decode_utf16le_segment(payload, start=16)
|
|
450
|
+
method, url, headers = _parse_ip_command_fields(payload[64:])
|
|
451
|
+
|
|
452
|
+
if device_id is None:
|
|
453
|
+
return
|
|
454
|
+
|
|
455
|
+
device_meta = proxy.state.entities("device").get(device_id & 0xFF, {})
|
|
456
|
+
proxy.state.record_virtual_device(
|
|
457
|
+
device_id,
|
|
458
|
+
name=device_meta.get("name") or f"Device {device_id}",
|
|
459
|
+
button_id=button_id,
|
|
460
|
+
method=method,
|
|
461
|
+
url=url,
|
|
462
|
+
headers=headers,
|
|
463
|
+
button_name=button_name,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
proxy._log.info(
|
|
467
|
+
"[CREATE] sync dev=0x%04X btn=0x%02X name='%s' method=%s url='%s'",
|
|
468
|
+
device_id,
|
|
469
|
+
button_id or 0,
|
|
470
|
+
button_name,
|
|
471
|
+
method or "?",
|
|
472
|
+
url,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@register_handler(opcodes=(OP_PREPARE_SAVE,), directions=("A→H", "H→A"))
|
|
477
|
+
class PrepareSaveHandler(BaseFrameHandler):
|
|
478
|
+
"""Track the start of a save transaction for IP buttons."""
|
|
479
|
+
|
|
480
|
+
def handle(self, frame: FrameContext) -> None:
|
|
481
|
+
proxy: X1Proxy = frame.proxy
|
|
482
|
+
proxy._log.info("[CREATE] prepare/save transaction len=%d", len(frame.payload))
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
@register_handler(opcodes=(OP_DEVICE_SAVE_HEAD,), directions=("H→A",))
|
|
486
|
+
class DeviceSaveHeadHandler(BaseFrameHandler):
|
|
487
|
+
"""Record hub-assigned device identifiers for virtual devices."""
|
|
488
|
+
|
|
489
|
+
def handle(self, frame: FrameContext) -> None:
|
|
490
|
+
proxy: X1Proxy = frame.proxy
|
|
491
|
+
payload = frame.payload
|
|
492
|
+
device_id = int.from_bytes(payload[:2], "big") if len(payload) >= 2 else None
|
|
493
|
+
button_id = payload[2] if len(payload) > 2 else None
|
|
494
|
+
proxy.update_virtual_device(device_id=device_id, button_id=button_id)
|
|
495
|
+
proxy._log.info(
|
|
496
|
+
"[CREATE] hub assigned dev=0x%04X btn=0x%02X", device_id or 0, button_id or 0
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@register_handler(opcodes=(OP_FINALIZE_DEVICE,), directions=("H→A",))
|
|
501
|
+
class FinalizeDeviceHandler(BaseFrameHandler):
|
|
502
|
+
"""Note finalize frames emitted during virtual device creation."""
|
|
503
|
+
|
|
504
|
+
def handle(self, frame: FrameContext) -> None:
|
|
505
|
+
proxy: X1Proxy = frame.proxy
|
|
506
|
+
payload = frame.payload
|
|
507
|
+
if len(payload) >= 3:
|
|
508
|
+
device_id = int.from_bytes(payload[:2], "big")
|
|
509
|
+
button_id = payload[2]
|
|
510
|
+
proxy.update_virtual_device(device_id=device_id, button_id=button_id)
|
|
511
|
+
proxy._log.info("[CREATE] finalize dev=0x%04X btn=0x%02X", device_id, button_id)
|
|
512
|
+
else:
|
|
513
|
+
proxy._log.info("[CREATE] finalize len=%d", len(payload))
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
@register_handler(opcodes=(OP_SAVE_COMMIT, ACK_SUCCESS), directions=("H→A",))
|
|
517
|
+
class SaveCommitHandler(BaseFrameHandler):
|
|
518
|
+
"""Acknowledge successful save of a virtual device/button."""
|
|
519
|
+
|
|
520
|
+
def handle(self, frame: FrameContext) -> None:
|
|
521
|
+
proxy: X1Proxy = frame.proxy
|
|
522
|
+
if getattr(proxy, "_pending_virtual", None) is None:
|
|
523
|
+
return
|
|
524
|
+
proxy.update_virtual_device(status="success")
|
|
525
|
+
proxy._log.info("[CREATE] save commit/ack success")
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
@register_handler(
|
|
529
|
+
opcodes=(OP_CREATE_DEVICE_ACK, OP_ACTIVITY_CREATE_ACK),
|
|
530
|
+
directions=("H→A",),
|
|
531
|
+
)
|
|
532
|
+
class CreateDeviceAckHandler(BaseFrameHandler):
|
|
533
|
+
"""Capture assigned ids from create-device/activity-create acks."""
|
|
534
|
+
|
|
535
|
+
def handle(self, frame: FrameContext) -> None:
|
|
536
|
+
payload = frame.payload
|
|
537
|
+
if len(payload) < 1:
|
|
538
|
+
return
|
|
539
|
+
proxy: X1Proxy = frame.proxy
|
|
540
|
+
proxy.set_assigned_device_id(payload[0])
|
|
541
|
+
proxy.notify_ack(frame.opcode, payload)
|
|
542
|
+
entity = "activity" if frame.opcode == OP_ACTIVITY_CREATE_ACK else "device"
|
|
543
|
+
proxy._log.info("[WIFI] create ack %s_id=0x%02X", entity, payload[0])
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
@register_handler(opcodes=(0x0103, 0x013E, 0x0112), directions=("H→A",))
|
|
547
|
+
class GenericCreateAckHandler(BaseFrameHandler):
|
|
548
|
+
"""Capture Create-sequence ACK frames so replay can gate each next step."""
|
|
549
|
+
|
|
550
|
+
def handle(self, frame: FrameContext) -> None:
|
|
551
|
+
proxy: X1Proxy = frame.proxy
|
|
552
|
+
proxy.notify_ack(frame.opcode, frame.payload)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@register_handler(opcode_families_low=(FAMILY_HUB_NAME_REPLY,), directions=("H→A",))
|
|
556
|
+
class HubNameReplyHandler(BaseFrameHandler):
|
|
557
|
+
"""Queue variable-length hub-name replies for synchronous waiters."""
|
|
558
|
+
|
|
559
|
+
def handle(self, frame: FrameContext) -> None:
|
|
560
|
+
proxy: X1Proxy = frame.proxy
|
|
561
|
+
proxy.notify_ack(frame.opcode, frame.payload)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
@register_handler(opcodes=(OP_X2_REMOTE_LIST_ROW,), directions=("H→A",))
|
|
567
|
+
class X2RemoteListRowHandler(BaseFrameHandler):
|
|
568
|
+
"""Capture the first remote id from X2 remote-list response rows."""
|
|
569
|
+
|
|
570
|
+
def handle(self, frame: FrameContext) -> None:
|
|
571
|
+
payload = frame.payload
|
|
572
|
+
if len(payload) < 4:
|
|
573
|
+
return
|
|
574
|
+
|
|
575
|
+
# Observed layout starts with a count byte then a 3-byte remote id.
|
|
576
|
+
remote_id = payload[1:4]
|
|
577
|
+
proxy: X1Proxy = frame.proxy
|
|
578
|
+
proxy.update_x2_remote_sync_id(remote_id)
|
|
579
|
+
proxy._log.info("[REMOTE_SYNC] X2 remote id=%s", remote_id.hex(" "))
|
|
580
|
+
|
|
581
|
+
@register_handler(opcodes=(OP_REQ_ACTIVATE,), directions=("A→H",))
|
|
582
|
+
class ActivateRequestHandler(BaseFrameHandler):
|
|
583
|
+
"""Log activation requests and track activity hints."""
|
|
584
|
+
|
|
585
|
+
def handle(self, frame: FrameContext) -> None:
|
|
586
|
+
payload = frame.payload
|
|
587
|
+
if len(payload) != 2:
|
|
588
|
+
return
|
|
589
|
+
|
|
590
|
+
proxy: X1Proxy = frame.proxy
|
|
591
|
+
ent_id, code = payload
|
|
592
|
+
|
|
593
|
+
activities_view = proxy.state.entities("activity")
|
|
594
|
+
devices_view = proxy.state.entities("device")
|
|
595
|
+
if ent_id in activities_view:
|
|
596
|
+
kind = "act"
|
|
597
|
+
record_kind = "activity"
|
|
598
|
+
name = activities_view[ent_id].get("name", "")
|
|
599
|
+
if code == ButtonName.POWER_ON:
|
|
600
|
+
proxy.state.set_hint(ent_id)
|
|
601
|
+
elif ent_id in devices_view:
|
|
602
|
+
kind = "dev"
|
|
603
|
+
record_kind = "device"
|
|
604
|
+
name = devices_view[ent_id].get("name", "")
|
|
605
|
+
else:
|
|
606
|
+
kind = "id"
|
|
607
|
+
record_kind = "unknown"
|
|
608
|
+
name = ""
|
|
609
|
+
|
|
610
|
+
cmd = proxy.state.commands.get(ent_id, {}).get(code)
|
|
611
|
+
btn = BUTTONNAME_BY_CODE.get(code) if cmd is None else None
|
|
612
|
+
extra = f" cmd='{cmd}'" if cmd else (f" btn='{btn}'" if btn else "")
|
|
613
|
+
|
|
614
|
+
proxy._log.info(
|
|
615
|
+
"[KEY] %s %s=0x%02X (%d) name='%s' key=0x%02X%s",
|
|
616
|
+
frame.direction,
|
|
617
|
+
kind,
|
|
618
|
+
ent_id,
|
|
619
|
+
ent_id,
|
|
620
|
+
name,
|
|
621
|
+
code,
|
|
622
|
+
extra,
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
proxy.record_app_activation(
|
|
626
|
+
ent_id=ent_id,
|
|
627
|
+
ent_kind=record_kind,
|
|
628
|
+
ent_name=name,
|
|
629
|
+
command_id=code,
|
|
630
|
+
command_label=cmd,
|
|
631
|
+
button_label=btn,
|
|
632
|
+
direction=frame.direction,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
@register_handler(opcode_families_low=(0x67,), directions=("H→A",))
|
|
637
|
+
class OtaUpdatePushHandler(BaseFrameHandler):
|
|
638
|
+
"""Handle the hub's OTA-in-progress push (opcode-lo 0x67).
|
|
639
|
+
|
|
640
|
+
The hub emits this frame when it begins a firmware update and then
|
|
641
|
+
goes silent for several minutes. We notify listeners so the
|
|
642
|
+
integration can tear down the session, back off reconnects, and
|
|
643
|
+
surface a notification to the user.
|
|
644
|
+
"""
|
|
645
|
+
|
|
646
|
+
def handle(self, frame: FrameContext) -> None:
|
|
647
|
+
proxy: X1Proxy = frame.proxy
|
|
648
|
+
proxy._log.warning(
|
|
649
|
+
"[OTA] H→A OTA-update push (opcode=0x%04X len=%d)",
|
|
650
|
+
frame.opcode,
|
|
651
|
+
len(frame.payload),
|
|
652
|
+
)
|
|
653
|
+
proxy.notify_ota_in_progress()
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
@register_handler(opcodes=(OP_ACK_READY,), directions=("H→A",))
|
|
657
|
+
class AckReadyHandler(BaseFrameHandler):
|
|
658
|
+
"""Handle ACK_READY frames and optionally trigger data refreshes."""
|
|
659
|
+
|
|
660
|
+
def handle(self, frame: FrameContext) -> None:
|
|
661
|
+
proxy: X1Proxy = frame.proxy
|
|
662
|
+
proxy._log.info("[HINT] ACK_READY from hub")
|
|
663
|
+
if proxy.can_issue_commands():
|
|
664
|
+
proxy._log.info("[HINT] no proxy client; auto-REQ_ACTIVITIES")
|
|
665
|
+
proxy.enqueue_cmd(OP_REQ_ACTIVITIES, expects_burst=True, burst_kind="activities")
|
|
666
|
+
if proxy.state.current_activity_hint is not None:
|
|
667
|
+
ent_lo = proxy.state.current_activity_hint & 0xFF
|
|
668
|
+
|
|
669
|
+
_, buttons_ready = proxy.get_buttons_for_entity(
|
|
670
|
+
ent_lo,
|
|
671
|
+
fetch_if_missing=False,
|
|
672
|
+
)
|
|
673
|
+
if not buttons_ready:
|
|
674
|
+
proxy.request_buttons_for_entity(ent_lo)
|
|
675
|
+
else:
|
|
676
|
+
proxy._log.info("[HINT] proxy client connected; skipping auto-requests")
|
|
677
|
+
new_id, old_id = proxy.state.update_activity_state()
|
|
678
|
+
if new_id != old_id:
|
|
679
|
+
proxy._log.info("[HINT] current activity differs from hint; notifying listeners")
|
|
680
|
+
proxy._notify_activity_change(
|
|
681
|
+
new_id & 0xFF if new_id is not None else None,
|
|
682
|
+
old_id & 0xFF if old_id is not None else None,
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
@register_handler(opcodes=(OP_CATALOG_ROW_DEVICE,), directions=("H→A",))
|
|
687
|
+
class CatalogDeviceHandler(BaseFrameHandler):
|
|
688
|
+
"""Handle catalog device rows emitted by the hub."""
|
|
689
|
+
|
|
690
|
+
def handle(self, frame: FrameContext) -> None:
|
|
691
|
+
proxy: X1Proxy = frame.proxy
|
|
692
|
+
now = time.monotonic()
|
|
693
|
+
|
|
694
|
+
payload = frame.payload
|
|
695
|
+
raw = frame.raw
|
|
696
|
+
row_idx = payload[0] if len(payload) >= 1 else None
|
|
697
|
+
expected_rows = payload[3] if len(payload) >= 4 and payload[3] > 0 else None
|
|
698
|
+
dev_id = int.from_bytes(payload[6:8], "big") if len(payload) >= 8 else None
|
|
699
|
+
device_class_code = payload[10] if len(payload) > 10 else None
|
|
700
|
+
device_class = classify_device_class_code(device_class_code)
|
|
701
|
+
name_bytes_raw = raw[36 : 36 + 60]
|
|
702
|
+
device_label = name_bytes_raw.decode("utf-16be").strip("\x00")
|
|
703
|
+
brand_bytes_raw = raw[96 : 96 + 60]
|
|
704
|
+
brand_label = brand_bytes_raw.decode("utf-16be", errors="ignore").strip("\x00")
|
|
705
|
+
|
|
706
|
+
# Keep the raw record body so the schema parser (parse_device_record)
|
|
707
|
+
# can rebuild a faithful DeviceConfig on demand (e.g. for backup), with
|
|
708
|
+
# no live parsed-dict cached in RAM.
|
|
709
|
+
record_body = bytes(payload[3:]) if len(payload) > 3 else b""
|
|
710
|
+
|
|
711
|
+
if dev_id is not None:
|
|
712
|
+
accepted = proxy.ingest_device_row(
|
|
713
|
+
row_idx=row_idx,
|
|
714
|
+
expected_rows=expected_rows,
|
|
715
|
+
dev_id=dev_id,
|
|
716
|
+
device={
|
|
717
|
+
"brand": brand_label,
|
|
718
|
+
"name": device_label,
|
|
719
|
+
"device_class": device_class,
|
|
720
|
+
"device_class_code": device_class_code,
|
|
721
|
+
"raw_body": record_body,
|
|
722
|
+
},
|
|
723
|
+
)
|
|
724
|
+
if not accepted:
|
|
725
|
+
return
|
|
726
|
+
proxy._burst.start("devices", now=now)
|
|
727
|
+
proxy._log.info(
|
|
728
|
+
"[DEV] #%s/%s id=0x%04X (%d) class=%s/0x%02X brand='%s' name='%s'",
|
|
729
|
+
row_idx,
|
|
730
|
+
expected_rows if expected_rows is not None else "?",
|
|
731
|
+
dev_id,
|
|
732
|
+
dev_id,
|
|
733
|
+
device_class or "?",
|
|
734
|
+
device_class_code or 0,
|
|
735
|
+
brand_label,
|
|
736
|
+
device_label,
|
|
737
|
+
)
|
|
738
|
+
elif device_label:
|
|
739
|
+
proxy._log.info("[DEV] name='%s'", device_label)
|
|
740
|
+
|
|
741
|
+
proxy.try_finish_devices_burst()
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
@register_handler(opcodes=(OP_X1_DEVICE,), directions=("H→A",))
|
|
745
|
+
class X1CatalogDeviceHandler(BaseFrameHandler):
|
|
746
|
+
"""Handle X1 firmware device rows."""
|
|
747
|
+
|
|
748
|
+
def handle(self, frame: FrameContext) -> None:
|
|
749
|
+
proxy: X1Proxy = frame.proxy
|
|
750
|
+
now = time.monotonic()
|
|
751
|
+
|
|
752
|
+
payload = frame.payload
|
|
753
|
+
row_idx = payload[0] if payload else None
|
|
754
|
+
expected_rows = payload[3] if len(payload) >= 4 and payload[3] > 0 else None
|
|
755
|
+
dev_id = int.from_bytes(payload[6:8], "big") if len(payload) >= 8 else None
|
|
756
|
+
device_class_code = payload[10] if len(payload) > 10 else None
|
|
757
|
+
device_class = classify_device_class_code(device_class_code)
|
|
758
|
+
|
|
759
|
+
name_bytes = payload[32:62]
|
|
760
|
+
device_label = name_bytes.split(b"\x00", 1)[0].decode("utf-8", errors="ignore")
|
|
761
|
+
|
|
762
|
+
brand_bytes = payload[62:]
|
|
763
|
+
brand_label = brand_bytes.split(b"\x00", 1)[0].decode("utf-8", errors="ignore")
|
|
764
|
+
|
|
765
|
+
record_body = bytes(payload[3:]) if len(payload) > 3 else b""
|
|
766
|
+
|
|
767
|
+
if dev_id is not None:
|
|
768
|
+
accepted = proxy.ingest_device_row(
|
|
769
|
+
row_idx=row_idx,
|
|
770
|
+
expected_rows=expected_rows,
|
|
771
|
+
dev_id=dev_id,
|
|
772
|
+
device={
|
|
773
|
+
"brand": brand_label,
|
|
774
|
+
"name": device_label,
|
|
775
|
+
"device_class": device_class,
|
|
776
|
+
"device_class_code": device_class_code,
|
|
777
|
+
"raw_body": record_body,
|
|
778
|
+
},
|
|
779
|
+
)
|
|
780
|
+
if not accepted:
|
|
781
|
+
return
|
|
782
|
+
proxy._burst.start("devices", now=now)
|
|
783
|
+
proxy._log.info(
|
|
784
|
+
"[DEV] #%s/%s id=0x%04X (%d) class=%s/0x%02X brand='%s' name='%s'",
|
|
785
|
+
row_idx,
|
|
786
|
+
expected_rows if expected_rows is not None else "?",
|
|
787
|
+
dev_id,
|
|
788
|
+
dev_id,
|
|
789
|
+
device_class or "?",
|
|
790
|
+
device_class_code or 0,
|
|
791
|
+
brand_label,
|
|
792
|
+
device_label,
|
|
793
|
+
)
|
|
794
|
+
elif device_label:
|
|
795
|
+
proxy._log.info("[DEV] name='%s'", device_label)
|
|
796
|
+
|
|
797
|
+
proxy.try_finish_devices_burst()
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
# --- X1S/X2 activity-row schema (CATALOG_ROW_ACTIVITY, 0xD53B) -------------
|
|
802
|
+
#
|
|
803
|
+
# The row body sits immediately after the 7-byte per-frame transport header.
|
|
804
|
+
# All offsets below are absolute positions into the full frame (``raw``).
|
|
805
|
+
#
|
|
806
|
+
# raw[35] active-state byte (0x01 = active)
|
|
807
|
+
# raw[36..96) primary name slot, 60 bytes UTF-16BE, null-padded
|
|
808
|
+
# raw[96..156) secondary label slot (unused for activities; zero-filled)
|
|
809
|
+
# raw[156..216) tail token block: fc-prefixed sub-tokens encoding
|
|
810
|
+
# wifi/ip, idle timeout, input/power modes, and the
|
|
811
|
+
# needs-confirm flag, followed by zero padding.
|
|
812
|
+
#
|
|
813
|
+
# The previous label decoder scanned a 92-byte region with two byte-shift
|
|
814
|
+
# candidates; the schema makes the slot exactly 60 bytes wide with a fixed
|
|
815
|
+
# UTF-16BE encoding, so no shift heuristic is needed.
|
|
816
|
+
ACTIVITY_ROW_LABEL_OFFSET = 36
|
|
817
|
+
ACTIVITY_ROW_LABEL_LEN = 60
|
|
818
|
+
ACTIVITY_ROW_TAIL_OFFSET_IN_PAYLOAD = 152
|
|
819
|
+
ACTIVITY_ROW_TAIL_LEN = 60
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _decode_x1s_needs_confirm_flag(payload: bytes) -> bool:
|
|
823
|
+
"""Return ``True`` when an X1S/X2 activity row carries the confirm-needed flag.
|
|
824
|
+
|
|
825
|
+
Activity rows end in a 60-byte tail token block (see schema comment above)
|
|
826
|
+
composed of ``fc``-prefixed sub-tokens with trailing zero padding. The
|
|
827
|
+
confirm flag is encoded as the value byte of the final ``fc XX fc YY``
|
|
828
|
+
sub-token pair within that block; ``YY == 0x01`` means the activity was
|
|
829
|
+
impacted by a recent device delete and must be re-saved by the app.
|
|
830
|
+
|
|
831
|
+
No structured parse for this flag is exposed by the official app's row
|
|
832
|
+
parser, so we locate it by scanning the tail block for the trailing
|
|
833
|
+
``fc XX fc YY`` pair. The scan is intentionally scoped to the schema's
|
|
834
|
+
tail region rather than an arbitrary window at the end of the payload.
|
|
835
|
+
"""
|
|
836
|
+
|
|
837
|
+
tail_start = ACTIVITY_ROW_TAIL_OFFSET_IN_PAYLOAD
|
|
838
|
+
tail_end = min(len(payload), tail_start + ACTIVITY_ROW_TAIL_LEN)
|
|
839
|
+
if tail_end - tail_start < 4:
|
|
840
|
+
return False
|
|
841
|
+
|
|
842
|
+
marker_indexes = [
|
|
843
|
+
idx
|
|
844
|
+
for idx in range(tail_start, tail_end - 3)
|
|
845
|
+
if payload[idx] == 0xFC and payload[idx + 2] == 0xFC
|
|
846
|
+
]
|
|
847
|
+
if not marker_indexes:
|
|
848
|
+
return False
|
|
849
|
+
|
|
850
|
+
flag_index = marker_indexes[-1] + 3
|
|
851
|
+
return flag_index < tail_end and payload[flag_index] == 0x01
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _decode_x1s_activity_label(label_bytes: bytes) -> str:
|
|
855
|
+
"""Decode the activity name from the X1S/X2 row's fixed UTF-16BE slot.
|
|
856
|
+
|
|
857
|
+
``label_bytes`` is the 60-byte slot from the row body (see schema comment
|
|
858
|
+
above). It is always UTF-16BE, null-padded to fill the slot; the first
|
|
859
|
+
null code unit terminates the visible label.
|
|
860
|
+
"""
|
|
861
|
+
|
|
862
|
+
even_bytes = label_bytes[: len(label_bytes) & ~1]
|
|
863
|
+
text = even_bytes.decode("utf-16be", errors="ignore")
|
|
864
|
+
text = text.split("\x00", 1)[0]
|
|
865
|
+
text = text.strip()
|
|
866
|
+
while text and unicodedata.category(text[0]).startswith("C"):
|
|
867
|
+
text = text[1:].lstrip()
|
|
868
|
+
return text
|
|
869
|
+
|
|
870
|
+
@register_handler(opcodes=(OP_CATALOG_ROW_ACTIVITY,), directions=("H→A",))
|
|
871
|
+
class CatalogActivityHandler(BaseFrameHandler):
|
|
872
|
+
"""Handle activity catalog rows."""
|
|
873
|
+
|
|
874
|
+
def handle(self, frame: FrameContext) -> None:
|
|
875
|
+
proxy: X1Proxy = frame.proxy
|
|
876
|
+
now = time.monotonic()
|
|
877
|
+
|
|
878
|
+
payload = frame.payload
|
|
879
|
+
raw = frame.raw
|
|
880
|
+
row_idx = payload[0] if len(payload) >= 1 else None
|
|
881
|
+
# Start of a fresh activities list → reset 'active'
|
|
882
|
+
act_id = int.from_bytes(payload[6:8], "big") if len(payload) >= 8 else None
|
|
883
|
+
label_slot = raw[
|
|
884
|
+
ACTIVITY_ROW_LABEL_OFFSET : ACTIVITY_ROW_LABEL_OFFSET + ACTIVITY_ROW_LABEL_LEN
|
|
885
|
+
]
|
|
886
|
+
activity_label = _decode_x1s_activity_label(label_slot)
|
|
887
|
+
active_state_byte = raw[35] if len(raw) > 35 else 0
|
|
888
|
+
is_active = active_state_byte == 0x01
|
|
889
|
+
needs_confirm = _decode_x1s_needs_confirm_flag(payload)
|
|
890
|
+
|
|
891
|
+
if act_id is not None:
|
|
892
|
+
accepted = proxy.ingest_activity_row(
|
|
893
|
+
row_idx=row_idx,
|
|
894
|
+
expected_rows=payload[3] if len(payload) >= 4 and payload[3] > 0 else None,
|
|
895
|
+
act_id=act_id,
|
|
896
|
+
activity={
|
|
897
|
+
"id": act_id,
|
|
898
|
+
"name": activity_label,
|
|
899
|
+
"active": is_active,
|
|
900
|
+
"needs_confirm": needs_confirm,
|
|
901
|
+
},
|
|
902
|
+
payload=payload,
|
|
903
|
+
)
|
|
904
|
+
if not accepted:
|
|
905
|
+
return
|
|
906
|
+
proxy._burst.start("activities", now=now)
|
|
907
|
+
if row_idx == 1:
|
|
908
|
+
proxy._log.info("[ACT] reset active (start of new activities list)")
|
|
909
|
+
elif activity_label:
|
|
910
|
+
proxy._log.info("[ACT] name='%s'", activity_label)
|
|
911
|
+
|
|
912
|
+
state = "ACTIVE" if is_active else "idle"
|
|
913
|
+
if row_idx is not None and act_id is not None:
|
|
914
|
+
proxy._log.info(
|
|
915
|
+
"[ACT] #%d/%s name='%s' act_id=0x%04X (%d) state=%s",
|
|
916
|
+
row_idx,
|
|
917
|
+
payload[3] if len(payload) >= 4 and payload[3] > 0 else "?",
|
|
918
|
+
activity_label,
|
|
919
|
+
act_id,
|
|
920
|
+
act_id,
|
|
921
|
+
state,
|
|
922
|
+
)
|
|
923
|
+
elif act_id is not None:
|
|
924
|
+
proxy._log.info(
|
|
925
|
+
"[ACT] name='%s' act_id=0x%04X (%d) state=%s",
|
|
926
|
+
activity_label,
|
|
927
|
+
act_id,
|
|
928
|
+
act_id,
|
|
929
|
+
state,
|
|
930
|
+
)
|
|
931
|
+
else:
|
|
932
|
+
proxy._log.info("[ACT] name='%s' state=%s", activity_label, state)
|
|
933
|
+
|
|
934
|
+
proxy.try_finish_activities_burst()
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
@register_handler(opcodes=(OP_X1_ACTIVITY,), directions=("H→A",))
|
|
938
|
+
class X1CatalogActivityHandler(BaseFrameHandler):
|
|
939
|
+
"""Handle activity catalog rows emitted by X1 firmware."""
|
|
940
|
+
|
|
941
|
+
def handle(self, frame: FrameContext) -> None:
|
|
942
|
+
proxy: X1Proxy = frame.proxy
|
|
943
|
+
now = time.monotonic()
|
|
944
|
+
|
|
945
|
+
payload = frame.payload
|
|
946
|
+
row_idx = payload[0] if payload else None
|
|
947
|
+
|
|
948
|
+
act_id = int.from_bytes(payload[6:8], "big") if len(payload) >= 8 else None
|
|
949
|
+
active_flag = frame.raw[35] if len(frame.raw) > 35 else 0
|
|
950
|
+
needs_confirm_flag = payload[95] if len(payload) > 95 else 0
|
|
951
|
+
activity_label = payload[32:].split(b"\x00", 1)[0].decode("utf-8", errors="ignore")
|
|
952
|
+
is_active = active_flag == 1
|
|
953
|
+
needs_confirm = needs_confirm_flag == 1
|
|
954
|
+
|
|
955
|
+
if act_id is not None:
|
|
956
|
+
accepted = proxy.ingest_activity_row(
|
|
957
|
+
row_idx=row_idx,
|
|
958
|
+
expected_rows=payload[3] if len(payload) >= 4 and payload[3] > 0 else None,
|
|
959
|
+
act_id=act_id,
|
|
960
|
+
activity={
|
|
961
|
+
"id": act_id,
|
|
962
|
+
"name": activity_label,
|
|
963
|
+
"active": is_active,
|
|
964
|
+
"needs_confirm": needs_confirm,
|
|
965
|
+
},
|
|
966
|
+
payload=payload,
|
|
967
|
+
)
|
|
968
|
+
if not accepted:
|
|
969
|
+
return
|
|
970
|
+
proxy._burst.start("activities", now=now)
|
|
971
|
+
if row_idx == 1:
|
|
972
|
+
proxy._log.info("[ACT] reset active (start of new activities list)")
|
|
973
|
+
elif activity_label:
|
|
974
|
+
proxy._log.info("[ACT] name='%s'", activity_label)
|
|
975
|
+
|
|
976
|
+
state = "ACTIVE" if is_active else "idle"
|
|
977
|
+
if row_idx is not None and act_id is not None:
|
|
978
|
+
proxy._log.info(
|
|
979
|
+
"[ACT] #%d/%s name='%s' act_id=0x%04X (%d) state=%s",
|
|
980
|
+
row_idx,
|
|
981
|
+
payload[3] if len(payload) >= 4 and payload[3] > 0 else "?",
|
|
982
|
+
activity_label,
|
|
983
|
+
act_id,
|
|
984
|
+
act_id,
|
|
985
|
+
state,
|
|
986
|
+
)
|
|
987
|
+
elif act_id is not None:
|
|
988
|
+
proxy._log.info(
|
|
989
|
+
"[ACT] name='%s' act_id=0x%04X (%d) state=%s",
|
|
990
|
+
activity_label,
|
|
991
|
+
act_id,
|
|
992
|
+
act_id,
|
|
993
|
+
state,
|
|
994
|
+
)
|
|
995
|
+
else:
|
|
996
|
+
proxy._log.info("[ACT] name='%s' state=%s", activity_label, state)
|
|
997
|
+
|
|
998
|
+
proxy.try_finish_activities_burst()
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
@register_handler(opcodes=(OP_REQ_ACTIVITY_MAP,), directions=("A→H",))
|
|
1002
|
+
class RequestActivityMapHandler(BaseFrameHandler):
|
|
1003
|
+
"""Log activity mapping requests from the app."""
|
|
1004
|
+
|
|
1005
|
+
def handle(self, frame: FrameContext) -> None:
|
|
1006
|
+
proxy: X1Proxy = frame.proxy
|
|
1007
|
+
payload = frame.payload
|
|
1008
|
+
act_id = payload[0] if payload else 0
|
|
1009
|
+
proxy._log.info("[ACTMAP] A→H requesting mapping act=0x%02X (%d)", act_id, act_id)
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
@register_handler(opcodes=(OP_ACTIVITY_MAP_PAGE, OP_ACTIVITY_MAP_PAGE_X1S), directions=("H→A",))
|
|
1013
|
+
class ActivityMapHandler(BaseFrameHandler):
|
|
1014
|
+
"""Accumulate activity-member rows for an activity roster (X1/X1S/X2)."""
|
|
1015
|
+
|
|
1016
|
+
def handle(self, frame: FrameContext) -> None:
|
|
1017
|
+
proxy: X1Proxy = frame.proxy
|
|
1018
|
+
payload = frame.payload
|
|
1019
|
+
|
|
1020
|
+
if len(payload) < 8:
|
|
1021
|
+
return
|
|
1022
|
+
act_lo = self._burst_activity(proxy)
|
|
1023
|
+
if act_lo is None:
|
|
1024
|
+
act_lo = self._pending_activity(proxy)
|
|
1025
|
+
if act_lo is None:
|
|
1026
|
+
return
|
|
1027
|
+
|
|
1028
|
+
# REQ_ACTIVITY_MAP response frames use the same wire format as
|
|
1029
|
+
# REQ_DEVICES. Each frame here is a complete 1-page device row burst
|
|
1030
|
+
# whose body starts at raw[7] (= payload[3]). Layout:
|
|
1031
|
+
#
|
|
1032
|
+
# body[3] sign (= payload[6])
|
|
1033
|
+
# body[4] deviceID (= payload[7]) ← we use this
|
|
1034
|
+
# body[5] icon
|
|
1035
|
+
# body[6] sort
|
|
1036
|
+
# body[7] codeType
|
|
1037
|
+
# body[8] type
|
|
1038
|
+
# body[9..24] codeId (16 bytes)
|
|
1039
|
+
# body[25..28] hide / input / tongdao / powerState
|
|
1040
|
+
# body[29..88] name (60B UTF-16BE on X1S/X2, 30B ASCII on X1)
|
|
1041
|
+
# body[89..148] brand
|
|
1042
|
+
# body[149..208] ip/extras
|
|
1043
|
+
#
|
|
1044
|
+
# We only need the device id to record activity membership.
|
|
1045
|
+
# If a future feature wants the richer full device-record data, the
|
|
1046
|
+
# full schema is right here for the picking.
|
|
1047
|
+
row_idx = payload[0]
|
|
1048
|
+
total_rows = payload[3]
|
|
1049
|
+
dev_lo = payload[7]
|
|
1050
|
+
if dev_lo == 0:
|
|
1051
|
+
return
|
|
1052
|
+
|
|
1053
|
+
proxy.state.record_activity_member(act_lo, dev_lo)
|
|
1054
|
+
|
|
1055
|
+
now = time.monotonic()
|
|
1056
|
+
burst_key = f"activity_map:{act_lo}"
|
|
1057
|
+
if proxy._burst.active and proxy._burst.kind == burst_key:
|
|
1058
|
+
proxy._burst.last_ts = now + proxy._burst.response_grace
|
|
1059
|
+
else:
|
|
1060
|
+
proxy._burst.start(burst_key, now=now)
|
|
1061
|
+
|
|
1062
|
+
proxy._log.info(
|
|
1063
|
+
"[ACTMAP] act=0x%02X member row=%d/%d dev=0x%02X",
|
|
1064
|
+
act_lo,
|
|
1065
|
+
row_idx,
|
|
1066
|
+
total_rows,
|
|
1067
|
+
dev_lo,
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
if self._is_last_page(payload):
|
|
1071
|
+
proxy._pending_activity_map_requests.discard(act_lo)
|
|
1072
|
+
proxy._activity_map_complete.add(act_lo)
|
|
1073
|
+
proxy.try_finish_activity_map_burst(act_lo)
|
|
1074
|
+
|
|
1075
|
+
def _burst_activity(self, proxy: "X1Proxy") -> int | None:
|
|
1076
|
+
burst_kind = getattr(proxy._burst, "kind", None)
|
|
1077
|
+
if proxy._burst.active and burst_kind and burst_kind.startswith("activity_map:"):
|
|
1078
|
+
try:
|
|
1079
|
+
return int(burst_kind.split(":", 1)[1])
|
|
1080
|
+
except ValueError:
|
|
1081
|
+
return None
|
|
1082
|
+
return None
|
|
1083
|
+
|
|
1084
|
+
def _pending_activity(self, proxy: "X1Proxy") -> int | None:
|
|
1085
|
+
if proxy._pending_activity_map_requests:
|
|
1086
|
+
return next(iter(proxy._pending_activity_map_requests))
|
|
1087
|
+
return None
|
|
1088
|
+
|
|
1089
|
+
def _is_last_page(self, payload: bytes) -> bool:
|
|
1090
|
+
if len(payload) < 4:
|
|
1091
|
+
return False
|
|
1092
|
+
page_no = payload[0]
|
|
1093
|
+
total_pages = payload[3]
|
|
1094
|
+
return total_pages > 0 and page_no >= total_pages
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
@register_handler(
|
|
1098
|
+
opcode_families_low=(FAMILY_KEYMAP,),
|
|
1099
|
+
directions=("H→A",),
|
|
1100
|
+
)
|
|
1101
|
+
class KeymapHandler(BaseFrameHandler):
|
|
1102
|
+
"""Accumulate keymap table pages for activities."""
|
|
1103
|
+
|
|
1104
|
+
def handle(self, frame: FrameContext) -> None:
|
|
1105
|
+
proxy: X1Proxy = frame.proxy
|
|
1106
|
+
raw = frame.raw
|
|
1107
|
+
payload = frame.payload
|
|
1108
|
+
now = time.monotonic()
|
|
1109
|
+
burst_act_lo = self._burst_activity(proxy)
|
|
1110
|
+
parsed = decode_burst_frame(frame.opcode, raw, hub_version=proxy.hub_version)
|
|
1111
|
+
if parsed is not None:
|
|
1112
|
+
# Header frames carry the burst's activity id at payload[7].
|
|
1113
|
+
# Continuation pages do not; the activity id is held by the active
|
|
1114
|
+
# burst (keyed by header). Prefer burst_act_lo for non-header
|
|
1115
|
+
# frames so a stray byte pattern in a row cannot redirect the
|
|
1116
|
+
# burst to a different activity.
|
|
1117
|
+
if parsed.is_header:
|
|
1118
|
+
activity_id_decimal = parsed.activity_id if parsed.activity_id is not None else burst_act_lo
|
|
1119
|
+
else:
|
|
1120
|
+
activity_id_decimal = burst_act_lo if burst_act_lo is not None else parsed.activity_id
|
|
1121
|
+
if activity_id_decimal is None:
|
|
1122
|
+
return
|
|
1123
|
+
|
|
1124
|
+
burst_key = f"buttons:{activity_id_decimal}"
|
|
1125
|
+
if proxy._burst.active and proxy._burst.kind == burst_key:
|
|
1126
|
+
proxy._burst.last_ts = now + proxy._burst.response_grace
|
|
1127
|
+
else:
|
|
1128
|
+
proxy._burst.start(burst_key, now=now)
|
|
1129
|
+
|
|
1130
|
+
total_frames = parsed.total_frames
|
|
1131
|
+
if total_frames is not None:
|
|
1132
|
+
proxy.note_buttons_frame(
|
|
1133
|
+
activity_id_decimal,
|
|
1134
|
+
frame_no=parsed.frame_no,
|
|
1135
|
+
total_frames=total_frames,
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
total_rows = (
|
|
1139
|
+
f" total_rows={parsed.total_rows}"
|
|
1140
|
+
if parsed.total_rows is not None
|
|
1141
|
+
else ""
|
|
1142
|
+
)
|
|
1143
|
+
activity_note = f" act=0x{activity_id_decimal:02X}"
|
|
1144
|
+
row_data_note = " row_data=yes" if parsed.has_row_data else " row_data=no"
|
|
1145
|
+
totals = (
|
|
1146
|
+
f"{parsed.frame_no}/{parsed.total_frames}"
|
|
1147
|
+
if parsed.total_frames is not None
|
|
1148
|
+
else f"{parsed.frame_no}"
|
|
1149
|
+
)
|
|
1150
|
+
proxy._log.debug(
|
|
1151
|
+
"[REQ_BUTTONS] role=%s variant=%s page=%s%s%s%s",
|
|
1152
|
+
parsed.role,
|
|
1153
|
+
parsed.layout_kind,
|
|
1154
|
+
totals,
|
|
1155
|
+
activity_note,
|
|
1156
|
+
total_rows,
|
|
1157
|
+
row_data_note,
|
|
1158
|
+
)
|
|
1159
|
+
|
|
1160
|
+
completed = proxy._button_assembler.feed(
|
|
1161
|
+
frame.opcode,
|
|
1162
|
+
raw,
|
|
1163
|
+
activity_id_override=activity_id_decimal,
|
|
1164
|
+
hub_version=proxy.hub_version,
|
|
1165
|
+
)
|
|
1166
|
+
for act_lo, row_stream, row_count in completed:
|
|
1167
|
+
proxy.state.replace_keymap_rows(act_lo, row_stream)
|
|
1168
|
+
keys = [
|
|
1169
|
+
f"{BUTTONNAME_BY_CODE.get(c, f'0x{c:02X}')}(0x{c:02X})"
|
|
1170
|
+
for c in sorted(proxy.state.buttons.get(act_lo, set()))
|
|
1171
|
+
]
|
|
1172
|
+
row_summary = f" rows={row_count}" if row_count is not None else ""
|
|
1173
|
+
proxy._log.info(
|
|
1174
|
+
"[KEYMAP] act=0x%02X mapped{%d}%s: %s",
|
|
1175
|
+
act_lo,
|
|
1176
|
+
len(keys),
|
|
1177
|
+
row_summary,
|
|
1178
|
+
", ".join(keys),
|
|
1179
|
+
)
|
|
1180
|
+
proxy._burst.finish(
|
|
1181
|
+
f"buttons:{act_lo}",
|
|
1182
|
+
can_issue=proxy.can_issue_commands,
|
|
1183
|
+
sender=proxy._send_cmd_frame,
|
|
1184
|
+
)
|
|
1185
|
+
return
|
|
1186
|
+
|
|
1187
|
+
def _burst_activity(self, proxy: "X1Proxy") -> int | None:
|
|
1188
|
+
burst_kind = getattr(proxy._burst, "kind", None)
|
|
1189
|
+
if proxy._burst.active and burst_kind and burst_kind.startswith("buttons:"):
|
|
1190
|
+
try:
|
|
1191
|
+
return int(burst_kind.split(":", 1)[1])
|
|
1192
|
+
except ValueError:
|
|
1193
|
+
return None
|
|
1194
|
+
return None
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
@register_handler(opcodes=(OP_REQ_COMMANDS,), directions=("A→H",))
|
|
1198
|
+
class RequestCommandsHandler(BaseFrameHandler):
|
|
1199
|
+
"""Log command list requests from the app."""
|
|
1200
|
+
|
|
1201
|
+
def handle(self, frame: FrameContext) -> None:
|
|
1202
|
+
proxy: X1Proxy = frame.proxy
|
|
1203
|
+
payload = frame.payload
|
|
1204
|
+
dev_id = payload[0] if payload else 0
|
|
1205
|
+
proxy._log.info("[DEVCTL] A→H requesting commands dev=0x%02X (%d)", dev_id, dev_id)
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
@register_handler(opcodes=(OP_REQ_IDLE_BEHAVIOR,), directions=("A→H",))
|
|
1209
|
+
class RequestIdleBehaviorHandler(BaseFrameHandler):
|
|
1210
|
+
"""Log app requests for a device's idle/power behavior."""
|
|
1211
|
+
|
|
1212
|
+
def handle(self, frame: FrameContext) -> None:
|
|
1213
|
+
proxy: X1Proxy = frame.proxy
|
|
1214
|
+
payload = frame.payload
|
|
1215
|
+
dev_id = payload[0] if payload else 0
|
|
1216
|
+
proxy._log.info(
|
|
1217
|
+
"[IDLE] A→H requesting idle behavior dev=0x%02X (%d)",
|
|
1218
|
+
dev_id,
|
|
1219
|
+
dev_id,
|
|
1220
|
+
)
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
@register_handler(opcodes=(OP_SET_IDLE_BEHAVIOR,), directions=("A→H",))
|
|
1224
|
+
class SetIdleBehaviorHandler(BaseFrameHandler):
|
|
1225
|
+
"""Track app-initiated idle/power behavior changes."""
|
|
1226
|
+
|
|
1227
|
+
def handle(self, frame: FrameContext) -> None:
|
|
1228
|
+
proxy: X1Proxy = frame.proxy
|
|
1229
|
+
payload = frame.payload
|
|
1230
|
+
if len(payload) < 2:
|
|
1231
|
+
proxy._log.info("[IDLE] A→H set idle behavior payload too short (%dB)", len(payload))
|
|
1232
|
+
return
|
|
1233
|
+
|
|
1234
|
+
dev_id = payload[0]
|
|
1235
|
+
mode = payload[1]
|
|
1236
|
+
proxy.record_idle_behavior_value(dev_id, mode, source="app_set")
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
@register_handler(opcodes=(OP_IDLE_BEHAVIOR,), directions=("H→A",))
|
|
1240
|
+
class IdleBehaviorHandler(BaseFrameHandler):
|
|
1241
|
+
"""Capture current device idle/power behavior replies from the hub."""
|
|
1242
|
+
|
|
1243
|
+
def handle(self, frame: FrameContext) -> None:
|
|
1244
|
+
proxy: X1Proxy = frame.proxy
|
|
1245
|
+
payload = frame.payload
|
|
1246
|
+
if len(payload) < 2:
|
|
1247
|
+
proxy._log.info("[IDLE] H→A idle behavior payload too short (%dB)", len(payload))
|
|
1248
|
+
return
|
|
1249
|
+
|
|
1250
|
+
dev_id = payload[0]
|
|
1251
|
+
mode = payload[1]
|
|
1252
|
+
proxy.record_idle_behavior_value(dev_id, mode, source="hub_reply")
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
class DeviceButtonSingleHandler(BaseFrameHandler):
|
|
1256
|
+
"""Handle single-command payloads and WiFi input-refresh labels."""
|
|
1257
|
+
|
|
1258
|
+
def handle(self, frame: FrameContext) -> None:
|
|
1259
|
+
proxy: X1Proxy = frame.proxy
|
|
1260
|
+
payload = frame.payload
|
|
1261
|
+
raw = frame.raw
|
|
1262
|
+
family = opcode_family(frame.opcode)
|
|
1263
|
+
|
|
1264
|
+
effective_opcode = (
|
|
1265
|
+
OP_DEVBTN_SINGLE
|
|
1266
|
+
if family in (FAMILY_DEVBTNS, 0x0D)
|
|
1267
|
+
else frame.opcode
|
|
1268
|
+
)
|
|
1269
|
+
|
|
1270
|
+
if len(payload) < 4:
|
|
1271
|
+
return
|
|
1272
|
+
|
|
1273
|
+
if (
|
|
1274
|
+
family == 0x0D
|
|
1275
|
+
and len(payload) >= 16
|
|
1276
|
+
and payload[:6] == b"\x01\x00\x01\x01\x00\x01"
|
|
1277
|
+
):
|
|
1278
|
+
# 0x020C WiFi/input-config refresh replies return a single label
|
|
1279
|
+
# using a distinct field layout from normal REQ_COMMANDS singles:
|
|
1280
|
+
# <dev_id> <slot_id> <fmt> ...
|
|
1281
|
+
dev_id = payload[6]
|
|
1282
|
+
command_id = payload[7]
|
|
1283
|
+
if len(payload) >= 76 and payload[8] == 0x1C:
|
|
1284
|
+
label = payload[16:76].decode("utf-16le", errors="ignore").split("\x00", 1)[0].strip()
|
|
1285
|
+
else:
|
|
1286
|
+
label_bytes = payload[15:45]
|
|
1287
|
+
label = label_bytes.split(b"\x00", 1)[0].decode("ascii", errors="ignore").strip()
|
|
1288
|
+
if label:
|
|
1289
|
+
proxy.state.commands.setdefault(dev_id & 0xFF, {})[command_id & 0xFF] = label
|
|
1290
|
+
return
|
|
1291
|
+
|
|
1292
|
+
dev_id = _extract_dev_id(
|
|
1293
|
+
raw,
|
|
1294
|
+
payload,
|
|
1295
|
+
effective_opcode,
|
|
1296
|
+
hub_version=proxy.hub_version,
|
|
1297
|
+
)
|
|
1298
|
+
|
|
1299
|
+
now = time.monotonic()
|
|
1300
|
+
burst_kind = proxy._burst.kind or ""
|
|
1301
|
+
targeted_burst_key: str | None = None
|
|
1302
|
+
if proxy._burst.active and burst_kind.startswith("commands:"):
|
|
1303
|
+
# For targeted command fetches (commands:<dev>:<cmd>) we already
|
|
1304
|
+
# have the response frame, so we can finish the burst as soon as
|
|
1305
|
+
# this handler finishes processing the label and immediately drain
|
|
1306
|
+
# the next queued request.
|
|
1307
|
+
if burst_kind.count(":") >= 2:
|
|
1308
|
+
targeted_burst_key = burst_kind
|
|
1309
|
+
proxy._burst.last_ts = now
|
|
1310
|
+
else:
|
|
1311
|
+
proxy._burst.last_ts = now + proxy._burst.response_grace
|
|
1312
|
+
else:
|
|
1313
|
+
proxy._burst.start(f"commands:{dev_id}", now=now)
|
|
1314
|
+
|
|
1315
|
+
completed = proxy._command_assembler.feed(
|
|
1316
|
+
effective_opcode,
|
|
1317
|
+
raw,
|
|
1318
|
+
dev_id_override=dev_id,
|
|
1319
|
+
hub_version=proxy.hub_version,
|
|
1320
|
+
)
|
|
1321
|
+
for complete_dev_id, assembled_payload in completed:
|
|
1322
|
+
commands = proxy.parse_device_commands(assembled_payload, complete_dev_id)
|
|
1323
|
+
if (
|
|
1324
|
+
effective_opcode == OP_DEVBTN_SINGLE
|
|
1325
|
+
and len(assembled_payload) >= 2
|
|
1326
|
+
and len(commands) == 1
|
|
1327
|
+
):
|
|
1328
|
+
expected_cmd_id = assembled_payload[1]
|
|
1329
|
+
parsed_cmd_id = next(iter(commands))
|
|
1330
|
+
# Some X1S single-command responses encode the command tuple as:
|
|
1331
|
+
# <dev> <cmd> 0x1C ...
|
|
1332
|
+
# The generic parser can lock onto 0x1C as the command id when
|
|
1333
|
+
# scanning candidates, which causes the requested favorite label
|
|
1334
|
+
# to be associated with cmd 28 instead of the real command.
|
|
1335
|
+
if (
|
|
1336
|
+
parsed_cmd_id == 0x1C
|
|
1337
|
+
and expected_cmd_id not in (0x00, 0xFC)
|
|
1338
|
+
and expected_cmd_id != parsed_cmd_id
|
|
1339
|
+
):
|
|
1340
|
+
commands = {expected_cmd_id: next(iter(commands.values()))}
|
|
1341
|
+
if commands:
|
|
1342
|
+
dev_key = complete_dev_id & 0xFF
|
|
1343
|
+
for cmd_id, label in commands.items():
|
|
1344
|
+
pair = (complete_dev_id, cmd_id)
|
|
1345
|
+
awaiting = proxy._favorite_label_requests.get(pair)
|
|
1346
|
+
awaiting_keybindings = proxy._keybinding_label_requests.get(pair)
|
|
1347
|
+
if awaiting or awaiting_keybindings:
|
|
1348
|
+
for act_id in awaiting or set():
|
|
1349
|
+
proxy.state.record_favorite_label(act_id, complete_dev_id, cmd_id, label)
|
|
1350
|
+
for act_id in awaiting_keybindings or set():
|
|
1351
|
+
proxy.state.record_keybinding_label(act_id, complete_dev_id, cmd_id, label)
|
|
1352
|
+
proxy._favorite_label_requests.pop(pair, None)
|
|
1353
|
+
proxy._keybinding_label_requests.pop(pair, None)
|
|
1354
|
+
continue
|
|
1355
|
+
|
|
1356
|
+
pending_for_device = [
|
|
1357
|
+
candidate
|
|
1358
|
+
for candidate in set(proxy._favorite_label_requests) | set(proxy._keybinding_label_requests)
|
|
1359
|
+
if candidate[0] == complete_dev_id
|
|
1360
|
+
]
|
|
1361
|
+
|
|
1362
|
+
if len(pending_for_device) == 1:
|
|
1363
|
+
pending_pair = pending_for_device[0]
|
|
1364
|
+
pending_cmd_id = pending_pair[1]
|
|
1365
|
+
for act_id in proxy._favorite_label_requests.get(pending_pair, set()):
|
|
1366
|
+
proxy.state.record_favorite_label(
|
|
1367
|
+
act_id, complete_dev_id, pending_cmd_id, label
|
|
1368
|
+
)
|
|
1369
|
+
for act_id in proxy._keybinding_label_requests.get(pending_pair, set()):
|
|
1370
|
+
proxy.state.record_keybinding_label(
|
|
1371
|
+
act_id, complete_dev_id, pending_cmd_id, label
|
|
1372
|
+
)
|
|
1373
|
+
proxy._favorite_label_requests.pop(pending_pair, None)
|
|
1374
|
+
proxy._keybinding_label_requests.pop(pending_pair, None)
|
|
1375
|
+
|
|
1376
|
+
cmds = proxy.state.commands.setdefault(dev_key, {})
|
|
1377
|
+
cmds[cmd_id] = label
|
|
1378
|
+
cmds[pending_cmd_id] = label
|
|
1379
|
+
continue
|
|
1380
|
+
|
|
1381
|
+
proxy.state.commands.setdefault(dev_key, {})[cmd_id] = label
|
|
1382
|
+
|
|
1383
|
+
if dev_key in proxy.state.commands:
|
|
1384
|
+
proxy._log.info(
|
|
1385
|
+
" ".join(
|
|
1386
|
+
f"{cmd_id:2d} : {label}" for cmd_id, label in proxy.state.commands[dev_key].items()
|
|
1387
|
+
)
|
|
1388
|
+
)
|
|
1389
|
+
|
|
1390
|
+
if targeted_burst_key is not None:
|
|
1391
|
+
proxy._burst.finish(
|
|
1392
|
+
targeted_burst_key,
|
|
1393
|
+
can_issue=proxy.can_issue_commands,
|
|
1394
|
+
sender=proxy._send_cmd_frame,
|
|
1395
|
+
now=now,
|
|
1396
|
+
)
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
class DeviceButtonHeaderHandler(BaseFrameHandler):
|
|
1400
|
+
"""Start device-command burst parsing."""
|
|
1401
|
+
|
|
1402
|
+
def handle(self, frame: FrameContext) -> None:
|
|
1403
|
+
proxy: X1Proxy = frame.proxy
|
|
1404
|
+
payload = frame.payload
|
|
1405
|
+
raw = frame.raw
|
|
1406
|
+
|
|
1407
|
+
if len(payload) < 4:
|
|
1408
|
+
return
|
|
1409
|
+
|
|
1410
|
+
dev_id = _extract_dev_id(
|
|
1411
|
+
raw,
|
|
1412
|
+
payload,
|
|
1413
|
+
frame.opcode,
|
|
1414
|
+
hub_version=proxy.hub_version,
|
|
1415
|
+
)
|
|
1416
|
+
|
|
1417
|
+
now = time.monotonic()
|
|
1418
|
+
burst_key = f"commands:{dev_id}"
|
|
1419
|
+
proxy._burst.start(burst_key, now=now)
|
|
1420
|
+
|
|
1421
|
+
completed = proxy._command_assembler.feed(
|
|
1422
|
+
frame.opcode,
|
|
1423
|
+
raw,
|
|
1424
|
+
dev_id_override=dev_id,
|
|
1425
|
+
hub_version=proxy.hub_version,
|
|
1426
|
+
)
|
|
1427
|
+
for complete_dev_id, assembled_payload in completed:
|
|
1428
|
+
commands = proxy.parse_device_commands(assembled_payload, complete_dev_id)
|
|
1429
|
+
if commands:
|
|
1430
|
+
dev_key = complete_dev_id & 0xFF
|
|
1431
|
+
existing = proxy.state.commands.setdefault(dev_key, {})
|
|
1432
|
+
existing.update(commands)
|
|
1433
|
+
proxy._log.info(
|
|
1434
|
+
" ".join(f"{cmd_id:2d} : {label}" for cmd_id, label in existing.items())
|
|
1435
|
+
)
|
|
1436
|
+
|
|
1437
|
+
if completed:
|
|
1438
|
+
proxy._burst.finish(
|
|
1439
|
+
burst_key,
|
|
1440
|
+
can_issue=proxy.can_issue_commands,
|
|
1441
|
+
sender=proxy._send_cmd_frame,
|
|
1442
|
+
now=now,
|
|
1443
|
+
)
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
class DeviceButtonPayloadHandler(BaseFrameHandler):
|
|
1447
|
+
"""Accumulate device command pages."""
|
|
1448
|
+
|
|
1449
|
+
def handle(self, frame: FrameContext) -> None:
|
|
1450
|
+
proxy: X1Proxy = frame.proxy
|
|
1451
|
+
payload = frame.payload
|
|
1452
|
+
raw = frame.raw
|
|
1453
|
+
|
|
1454
|
+
if len(payload) < 4:
|
|
1455
|
+
return
|
|
1456
|
+
|
|
1457
|
+
dev_id = _extract_dev_id(
|
|
1458
|
+
raw,
|
|
1459
|
+
payload,
|
|
1460
|
+
frame.opcode,
|
|
1461
|
+
hub_version=proxy.hub_version,
|
|
1462
|
+
)
|
|
1463
|
+
|
|
1464
|
+
now = time.monotonic()
|
|
1465
|
+
burst_key = f"commands:{dev_id}"
|
|
1466
|
+
if not proxy._burst.active:
|
|
1467
|
+
proxy._burst.start(burst_key, now=now)
|
|
1468
|
+
else:
|
|
1469
|
+
proxy._burst.last_ts = now + proxy._burst.response_grace
|
|
1470
|
+
|
|
1471
|
+
completed = proxy._command_assembler.feed(
|
|
1472
|
+
frame.opcode,
|
|
1473
|
+
raw,
|
|
1474
|
+
dev_id_override=dev_id,
|
|
1475
|
+
hub_version=proxy.hub_version,
|
|
1476
|
+
)
|
|
1477
|
+
for complete_dev_id, assembled_payload in completed:
|
|
1478
|
+
commands = proxy.parse_device_commands(assembled_payload, complete_dev_id)
|
|
1479
|
+
if commands:
|
|
1480
|
+
dev_key = complete_dev_id & 0xFF
|
|
1481
|
+
existing = proxy.state.commands.setdefault(dev_key, {})
|
|
1482
|
+
existing.update(commands)
|
|
1483
|
+
proxy._log.info(
|
|
1484
|
+
" ".join(f"{cmd_id:2d} : {label}" for cmd_id, label in existing.items())
|
|
1485
|
+
)
|
|
1486
|
+
|
|
1487
|
+
if completed:
|
|
1488
|
+
proxy._burst.finish(
|
|
1489
|
+
burst_key,
|
|
1490
|
+
can_issue=proxy.can_issue_commands,
|
|
1491
|
+
sender=proxy._send_cmd_frame,
|
|
1492
|
+
now=now,
|
|
1493
|
+
)
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
@register_handler(opcode_families_low=(FAMILY_DEVBTNS, 0x0D), directions=("H→A",))
|
|
1497
|
+
class DeviceButtonFamilyHandler(BaseFrameHandler):
|
|
1498
|
+
"""Route device-command family responses using parsed frame metadata."""
|
|
1499
|
+
|
|
1500
|
+
def __init__(self) -> None:
|
|
1501
|
+
self._single = DeviceButtonSingleHandler()
|
|
1502
|
+
self._header = DeviceButtonHeaderHandler()
|
|
1503
|
+
self._payload = DeviceButtonPayloadHandler()
|
|
1504
|
+
|
|
1505
|
+
def handle(self, frame: FrameContext) -> None:
|
|
1506
|
+
opcode = frame.opcode
|
|
1507
|
+
burst_kind = str(frame.proxy._burst.kind or "")
|
|
1508
|
+
|
|
1509
|
+
if frame.proxy._burst.active and burst_kind.startswith("ir_dump:"):
|
|
1510
|
+
parsed_ir_dump = parse_ir_command_dump_frame(opcode, frame.raw)
|
|
1511
|
+
if parsed_ir_dump is not None:
|
|
1512
|
+
frame.proxy._record_ir_dump_frame(parsed_ir_dump, frame.raw)
|
|
1513
|
+
frame.proxy._burst.last_ts = time.monotonic() + frame.proxy._burst.response_grace
|
|
1514
|
+
parts = burst_kind.split(":")
|
|
1515
|
+
if len(parts) >= 3:
|
|
1516
|
+
try:
|
|
1517
|
+
request_key = (int(parts[1]) & 0xFF, int(parts[2]) & 0xFF)
|
|
1518
|
+
except ValueError:
|
|
1519
|
+
request_key = None
|
|
1520
|
+
if request_key is not None:
|
|
1521
|
+
frame.proxy.try_finish_ir_dump_burst(request_key)
|
|
1522
|
+
return
|
|
1523
|
+
|
|
1524
|
+
parsed = decode_burst_frame(
|
|
1525
|
+
opcode,
|
|
1526
|
+
frame.raw,
|
|
1527
|
+
hub_version=frame.proxy.hub_version,
|
|
1528
|
+
)
|
|
1529
|
+
|
|
1530
|
+
if parsed is not None:
|
|
1531
|
+
totals = (
|
|
1532
|
+
f"{parsed.frame_no}/{parsed.total_frames}"
|
|
1533
|
+
if parsed.total_frames is not None
|
|
1534
|
+
else f"{parsed.frame_no}"
|
|
1535
|
+
)
|
|
1536
|
+
fmt = (
|
|
1537
|
+
f" fmt=0x{parsed.format_marker:02X}"
|
|
1538
|
+
if parsed.format_marker is not None
|
|
1539
|
+
else ""
|
|
1540
|
+
)
|
|
1541
|
+
if parsed.layout_kind == "input_config_refresh":
|
|
1542
|
+
slot = (
|
|
1543
|
+
f" slot=0x{parsed.first_command_id:02X}"
|
|
1544
|
+
if parsed.first_command_id is not None
|
|
1545
|
+
else ""
|
|
1546
|
+
)
|
|
1547
|
+
frame.proxy._log.debug(
|
|
1548
|
+
"[INPUT_REFRESH] role=%s variant=%s page=%s dev=0x%02X%s%s",
|
|
1549
|
+
parsed.role,
|
|
1550
|
+
parsed.layout_kind,
|
|
1551
|
+
totals,
|
|
1552
|
+
parsed.device_id,
|
|
1553
|
+
slot,
|
|
1554
|
+
fmt,
|
|
1555
|
+
)
|
|
1556
|
+
else:
|
|
1557
|
+
first_cmd = (
|
|
1558
|
+
f" first_cmd=0x{parsed.first_command_id:02X}"
|
|
1559
|
+
if parsed.first_command_id is not None
|
|
1560
|
+
else ""
|
|
1561
|
+
)
|
|
1562
|
+
total_commands = (
|
|
1563
|
+
f" total_cmds={parsed.total_commands}"
|
|
1564
|
+
if parsed.total_commands is not None
|
|
1565
|
+
else ""
|
|
1566
|
+
)
|
|
1567
|
+
frame.proxy._log.debug(
|
|
1568
|
+
"[REQ_COMMANDS] role=%s variant=%s page=%s dev=0x%02X%s%s%s",
|
|
1569
|
+
parsed.role,
|
|
1570
|
+
parsed.layout_kind,
|
|
1571
|
+
totals,
|
|
1572
|
+
parsed.device_id,
|
|
1573
|
+
total_commands,
|
|
1574
|
+
first_cmd,
|
|
1575
|
+
fmt,
|
|
1576
|
+
)
|
|
1577
|
+
|
|
1578
|
+
if parsed is None:
|
|
1579
|
+
frame.proxy._log.debug(
|
|
1580
|
+
"[REQ_COMMANDS] ignoring unparsed family frame opcode=0x%04X len=%d",
|
|
1581
|
+
opcode,
|
|
1582
|
+
len(frame.payload),
|
|
1583
|
+
)
|
|
1584
|
+
return
|
|
1585
|
+
|
|
1586
|
+
if parsed.is_single:
|
|
1587
|
+
self._single.handle(frame)
|
|
1588
|
+
return
|
|
1589
|
+
|
|
1590
|
+
if parsed.is_header:
|
|
1591
|
+
self._header.handle(frame)
|
|
1592
|
+
return
|
|
1593
|
+
|
|
1594
|
+
self._payload.handle(frame)
|
|
1595
|
+
|
|
1596
|
+
|
|
1597
|
+
@register_handler(opcode_families_low=(FAMILY_FAV_ORDER_RESP,), directions=("H→A",))
|
|
1598
|
+
class FavoritesOrderHandler(BaseFrameHandler):
|
|
1599
|
+
"""Parse hub response containing current favorites ordering for an activity.
|
|
1600
|
+
|
|
1601
|
+
Triggered by the app sending OP_FAV_ORDER_REQ (family 0x62 / opcode 0x0162).
|
|
1602
|
+
The hub replies with a family-0x63 frame whose payload is:
|
|
1603
|
+
|
|
1604
|
+
[01 00 01 01 00 01] [act_lo] [fav_id slot] × N
|
|
1605
|
+
|
|
1606
|
+
Each (fav_id, slot) pair describes which hub-internal favorite identifier
|
|
1607
|
+
occupies which display position (slot 1 = first shown).
|
|
1608
|
+
|
|
1609
|
+
After parsing the pairs are stored in ``proxy.state.activity_favorites_order``
|
|
1610
|
+
and a synthetic ACK ``0xFF63`` is fired so that
|
|
1611
|
+
``wait_for_ack_any([(0xFF63, act_lo)])`` can unblock.
|
|
1612
|
+
"""
|
|
1613
|
+
|
|
1614
|
+
# Synthetic opcode used to signal completion via notify_ack
|
|
1615
|
+
SYNTHETIC_ACK = 0xFF63
|
|
1616
|
+
|
|
1617
|
+
def handle(self, frame: FrameContext) -> None:
|
|
1618
|
+
proxy = frame.proxy
|
|
1619
|
+
payload = frame.payload
|
|
1620
|
+
|
|
1621
|
+
if proxy._try_handle_device_key_sort_payload(payload):
|
|
1622
|
+
return
|
|
1623
|
+
|
|
1624
|
+
# Minimum: 6-byte fixed header + 1 act_lo byte
|
|
1625
|
+
if len(payload) < 7:
|
|
1626
|
+
proxy._log.debug("[FAV_ORDER] payload too short (%dB), skipping", len(payload))
|
|
1627
|
+
return
|
|
1628
|
+
|
|
1629
|
+
act_lo = payload[6] & 0xFF
|
|
1630
|
+
pairs_data = payload[7:]
|
|
1631
|
+
|
|
1632
|
+
if len(pairs_data) % 2 != 0:
|
|
1633
|
+
proxy._log.warning(
|
|
1634
|
+
"[FAV_ORDER] act=0x%02X odd pairs length %d, truncating",
|
|
1635
|
+
act_lo,
|
|
1636
|
+
len(pairs_data),
|
|
1637
|
+
)
|
|
1638
|
+
pairs_data = pairs_data[: len(pairs_data) - 1]
|
|
1639
|
+
|
|
1640
|
+
pairs: list[tuple[int, int]] = [
|
|
1641
|
+
(pairs_data[i], pairs_data[i + 1])
|
|
1642
|
+
for i in range(0, len(pairs_data), 2)
|
|
1643
|
+
]
|
|
1644
|
+
|
|
1645
|
+
proxy.state.activity_favorites_order[act_lo] = pairs
|
|
1646
|
+
proxy._log.info(
|
|
1647
|
+
"[FAV_ORDER] act=0x%02X received %d favorite(s): %s",
|
|
1648
|
+
act_lo,
|
|
1649
|
+
len(pairs),
|
|
1650
|
+
" ".join(f"fav{fav}→slot{slot}" for fav, slot in pairs),
|
|
1651
|
+
)
|
|
1652
|
+
|
|
1653
|
+
# Signal any waiting request_favorites_order() call
|
|
1654
|
+
proxy.notify_ack(self.SYNTHETIC_ACK, bytes([act_lo]))
|
|
1655
|
+
|