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.
@@ -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
+