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/x1_proxy.py ADDED
@@ -0,0 +1,1833 @@
1
+ # proxy.py — X1 Hub proxy (legible, opcode-forward)
2
+ # -------------------------------------------------
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import ipaddress
7
+ import re
8
+ import socket
9
+ import struct
10
+ import threading
11
+ import time
12
+ from collections import defaultdict, deque
13
+ from dataclasses import replace
14
+ from typing import Any, Callable, Dict, List, Optional, Tuple
15
+
16
+ from .hub_versions import (
17
+ HUB_VERSION_X1,
18
+ HUB_VERSION_X1S,
19
+ HUB_VERSION_X2,
20
+ PROXY_TXT_KEY,
21
+ PROXY_TXT_VALUE,
22
+ classify_hub_version,
23
+ mdns_service_type_for_props,
24
+ )
25
+ from .hub_logging import LogTag, get_hub_logger
26
+ from .ack import AckOutcome, InputsBurstResult, SendStepResult
27
+ from .commands import (
28
+ DeviceButtonAssembler,
29
+ DeviceCommandAssembler,
30
+ descriptive_play_blob_text,
31
+ extract_ir_dump_blob,
32
+ extract_ir_dump_label_field,
33
+ looks_like_descriptive_play_blob,
34
+ parse_ir_command_dump_frame,
35
+ )
36
+ from .device_create import (
37
+ ACK_OPCODE_STATUS,
38
+ ACK_STATUS_BYTE_OK,
39
+ FAMILY_ACTIVITY_CREATE,
40
+ FAMILY_INPUTS,
41
+ CreateStep,
42
+ build_button_binding_step,
43
+ build_command_write_steps,
44
+ build_device_create_step,
45
+ build_device_update_step,
46
+ build_macro_step,
47
+ build_macro_step_record,
48
+ build_remote_sync_step,
49
+ run_create_sequence,
50
+ synthesize_command_code,
51
+ )
52
+ from .devices import device_config_from_backup
53
+ from .inputs import (
54
+ ControlKeyBlock,
55
+ FavoriteSlot,
56
+ InputEntry,
57
+ InputsRecord,
58
+ build_inputs_write,
59
+ parse_inputs_burst,
60
+ )
61
+ from .wire_schema import InputEntryLayout, schema_for
62
+ from .macros import (
63
+ MACRO_WRITE_PAGE_BODY_CHUNK,
64
+ MacroAssembler,
65
+ MacroKeyEntry,
66
+ MacroRecord,
67
+ build_macro_save_payload,
68
+ )
69
+
70
+ from .protocol_const import (
71
+ BUTTONNAME_BY_CODE,
72
+ ButtonName,
73
+ DEVICE_CLASS_BLUETOOTH,
74
+ DEVICE_CLASS_IR,
75
+ DEVICE_CLASS_RF_315,
76
+ DEVICE_CLASS_RF_433,
77
+ DEVICE_CLASS_WIFI_HUE,
78
+ DEVICE_CLASS_WIFI_IP,
79
+ DEVICE_CLASS_WIFI_MQTT,
80
+ DEVICE_CLASS_WIFI_ROKU,
81
+ DEVICE_CLASS_WIFI_SONOS,
82
+ known_public_device_classes,
83
+ OPNAMES,
84
+ normalize_device_class,
85
+ opcode_family,
86
+ opcode_lo,
87
+ OP_ACK_READY,
88
+ OP_BANNER,
89
+ OP_CALL_ME,
90
+ OP_CATALOG_ROW_ACTIVITY,
91
+ OP_CATALOG_ROW_DEVICE,
92
+ OP_DEVBTN_HEADER,
93
+ OP_DEVBTN_MORE,
94
+ OP_DEVBTN_PAGE,
95
+ OP_DEVBTN_TAIL,
96
+ OP_FIND_REMOTE,
97
+ OP_FIND_REMOTE_X2,
98
+ OP_REMOTE_SYNC,
99
+ OP_SET_HUB_NAME,
100
+ OP_X2_REMOTE_LIST,
101
+ OP_X2_REMOTE_SYNC,
102
+ OP_INFO_BANNER,
103
+ OP_CREATE_DEVICE_HEAD,
104
+ OP_DEFINE_IP_CMD,
105
+ OP_DEFINE_IP_CMD_EXISTING,
106
+ OP_PREPARE_SAVE,
107
+ OP_FINALIZE_DEVICE,
108
+ OP_DEVICE_SAVE_HEAD,
109
+ OP_SAVE_COMMIT,
110
+ OP_KEYMAP_CONT,
111
+ OP_KEYMAP_TBL_A,
112
+ OP_KEYMAP_TBL_B,
113
+ OP_KEYMAP_TBL_C,
114
+ OP_KEYMAP_TBL_D,
115
+ OP_KEYMAP_TBL_E,
116
+ OP_KEYMAP_EXTRA,
117
+ OP_MACROS_A1,
118
+ OP_MACROS_A2,
119
+ OP_MACROS_B1,
120
+ OP_MACROS_B2,
121
+ OP_MARKER,
122
+ OP_PING2,
123
+ OP_SET_IDLE_BEHAVIOR,
124
+ OP_REQ_ACTIVITIES,
125
+ OP_REQ_ACTIVATE,
126
+ OP_REQ_IDLE_BEHAVIOR,
127
+ OP_REQ_ACTIVITY_MAP,
128
+ OP_REQ_BANNER,
129
+ OP_DELETE_DEVICE,
130
+ OP_STATUS_ACK,
131
+ OP_ACTIVITY_ASSIGN_FINALIZE,
132
+ OP_ACTIVITY_CONFIRM,
133
+ OP_REQ_BUTTONS,
134
+ OP_REQ_BLOB,
135
+ OP_REQ_COMMANDS,
136
+ OP_REQ_IPCMD_SYNC,
137
+ OP_REQ_DEVICES,
138
+ OP_REQ_MACRO_LABELS,
139
+ OP_IDLE_BEHAVIOR,
140
+ OP_ACTIVITY_DEVICE_CONFIRM,
141
+ OP_REQ_ACTIVITY_INPUTS,
142
+ OP_REQ_VERSION,
143
+ OP_WIFI_FW,
144
+ FAMILY_FAV_DELETE,
145
+ FAMILY_FAV_ORDER_REQ,
146
+ FAMILY_HUB_NAME_REPLY,
147
+ SYNC0,
148
+ SYNC1,
149
+ )
150
+ from .state_helpers import (
151
+ ActivityCache,
152
+ BurstScheduler,
153
+ normalize_device_entry,
154
+ )
155
+ from .deframer import Deframer
156
+ from .transport_bridge import TransportBridge
157
+ from .proxy_restore import RestoreMixin
158
+ from .proxy_wifi_device import WifiDeviceMixin
159
+ from .proxy_backup import CacheBackupMixin
160
+ from .proxy_backup_export import BackupExportMixin
161
+ from .proxy_activity_ops import ActivityOpsMixin
162
+ from .proxy_ack_waiters import AckWaitersMixin
163
+ from .proxy_catalog import CatalogMixin
164
+ from .proxy_frame_decode import FrameDecodeMixin, _hexdump
165
+ from .proxy_ir_blob import IrBlobMixin
166
+
167
+ # ============================================================================
168
+ # Utilities
169
+ # ============================================================================
170
+ log = logging.getLogger("x1proxy")
171
+
172
+ ACTIVITY_INCOMPLETE_RETRY_DELAY_S = 0.75
173
+ _HUB_MODEL_BY_CODE = {
174
+ 0x01: HUB_VERSION_X1,
175
+ 0x02: HUB_VERSION_X1S,
176
+ 0x03: HUB_VERSION_X2,
177
+ }
178
+
179
+
180
+ def _normalize_banner_model(value: Any) -> str | None:
181
+ text = str(value or "").strip().upper()
182
+ if text in _HUB_MODEL_BY_CODE.values():
183
+ return text
184
+ return None
185
+
186
+
187
+ def _sanitize_mdns_label_component(value: str) -> str:
188
+ text = re.sub(r"[^A-Za-z0-9-]+", "-", str(value or "").strip())
189
+ text = re.sub(r"-{2,}", "-", text).strip("-")
190
+ return text or "X1"
191
+
192
+
193
+ def _mac_suffix_for_instance(mdns_txt: Dict[str, str]) -> str:
194
+ raw_mac = str(mdns_txt.get("MAC") or mdns_txt.get("mac") or "").strip()
195
+ compact = re.sub(r"[^0-9A-Fa-f]", "", raw_mac).lower()
196
+ if len(compact) >= 6:
197
+ return compact[-6:]
198
+ return "000000"
199
+
200
+
201
+ def _mdns_instance_for_identity(model: str | None, mdns_txt: Dict[str, str]) -> str:
202
+ display_model = _sanitize_mdns_label_component(_normalize_banner_model(model) or HUB_VERSION_X1)
203
+ return f"{display_model}-HUB-{_mac_suffix_for_instance(mdns_txt)}"
204
+
205
+ def _sum8(b: bytes) -> int: return sum(b) & 0xFF
206
+ def to_export_view(entry: dict[str, Any]) -> dict[str, Any]:
207
+ """Return a JSON-safe shallow copy of a device / activity entry.
208
+
209
+ This is the *single* boundary at which ``raw_body`` is stripped.
210
+ Everywhere else (``state.devices``, ``state.activities``, the
211
+ snapshot helpers on :class:`~custom_components.sofabaton_x1s.hub.SofabatonHub`)
212
+ keeps the raw record body so the on-demand backup / restore paths
213
+ can decode the full schema without re-fetching. Pipe through this
214
+ helper before serialising to JSON (WS payloads, persistent cache,
215
+ diagnostic exports).
216
+ """
217
+
218
+ view = dict(entry)
219
+ view.pop("raw_body", None)
220
+ return view
221
+
222
+
223
+ def _input_create_step(
224
+ *, device_id: int, payload: bytes, label_suffix: str
225
+ ) -> CreateStep:
226
+ """Wrap a family-0x46 payload into a :class:`CreateStep`.
227
+
228
+ Local to the proxy so :mod:`lib.inputs` stays free of any
229
+ create-sequence dependency.
230
+ """
231
+
232
+ return CreateStep(
233
+ label=f"inputs dev=0x{device_id & 0xFF:02X} {label_suffix}",
234
+ family=FAMILY_INPUTS,
235
+ payload=payload,
236
+ ack_opcode=ACK_OPCODE_STATUS,
237
+ ack_first_byte=ACK_STATUS_BYTE_OK,
238
+ )
239
+
240
+
241
+ def _hex_to_bytes(raw_hex: str) -> bytes:
242
+ return bytes.fromhex(raw_hex)
243
+
244
+
245
+ def _ascii_padded(value: str, *, length: int) -> bytes:
246
+ return value.encode("ascii", errors="ignore")[:length].ljust(length, b"\x00")
247
+
248
+
249
+ def _to_dbc(value: str) -> str:
250
+ """Collapse full-width forms to the GB2312-friendly half-width variant."""
251
+
252
+ chars: list[str] = []
253
+ for ch in str(value or ""):
254
+ code = ord(ch)
255
+ if code == 0x3000:
256
+ chars.append(" ")
257
+ elif 0xFF01 <= code <= 0xFF5E:
258
+ chars.append(chr(code - 0xFEE0))
259
+ else:
260
+ chars.append(ch)
261
+ return "".join(chars)
262
+
263
+
264
+ def _encode_hub_name_wire(value: str) -> bytes:
265
+ return _to_dbc(value).encode("gb2312", errors="ignore")
266
+
267
+
268
+ def _decode_hub_name_wire(payload: bytes, *, hub_version: str | None) -> str:
269
+ raw = payload
270
+ if hub_version == HUB_VERSION_X2 and len(raw) >= 2:
271
+ raw = raw[2:]
272
+ return raw.decode("gb2312", errors="ignore").strip("\x00").strip()
273
+
274
+
275
+
276
+
277
+ # Position of the tail token block inside a CATALOG_ROW_ACTIVITY payload.
278
+ # See the activity-row schema comment in ``opcode_handlers`` for details.
279
+ _ACTIVITY_ROW_TAIL_OFFSET_IN_PAYLOAD = 152
280
+ _ACTIVITY_ROW_TAIL_LEN = 60
281
+
282
+
283
+ # ACTIVITY_INPUTS (family 0x46 / response 0x47) schema lives in
284
+ # :mod:`lib.inputs`; see its module docstring for the canonical
285
+ # per-variant entry stride and trailing-region layout. Parser and
286
+ # builder are exposed there as :func:`parse_inputs_burst` and
287
+ # :func:`build_inputs_write`.
288
+
289
+
290
+
291
+ def _normalize_mdns_instance(name: str) -> str:
292
+ """Return an mDNS-friendly instance name without whitespace."""
293
+
294
+ normalized = re.sub(r"\s+", "-", name.strip())
295
+ return normalized or "X1-HUB-PROXY"
296
+
297
+ def _route_local_ip(peer_ip: str) -> str:
298
+ try:
299
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
300
+ s.connect((peer_ip, 80))
301
+ return s.getsockname()[0]
302
+ except Exception:
303
+ return "127.0.0.1"
304
+ finally:
305
+ try: s.close()
306
+ except Exception: pass
307
+
308
+ def _pick_port_near(base: int, tries: int = 64) -> int:
309
+ for i in range(tries):
310
+ cand = base + i
311
+ try:
312
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
313
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
314
+ s.bind(("0.0.0.0", cand))
315
+ s.close()
316
+ return cand
317
+ except OSError:
318
+ continue
319
+ raise OSError("No free port near %d" % base)
320
+
321
+ def _enable_keepalive(sock: socket.socket, *, idle: int = 30, interval: int = 10, count: int = 3) -> None:
322
+ try: sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
323
+ except Exception: pass
324
+ try: # Linux
325
+ TCP_KEEPIDLE = getattr(socket, "TCP_KEEPIDLE", None)
326
+ TCP_KEEPINTVL = getattr(socket, "TCP_KEEPINTVL", None)
327
+ TCP_KEEPCNT = getattr(socket, "TCP_KEEPCNT", None)
328
+ if TCP_KEEPIDLE is not None: sock.setsockopt(socket.IPPROTO_TCP, TCP_KEEPIDLE, idle)
329
+ if TCP_KEEPINTVL is not None: sock.setsockopt(socket.IPPROTO_TCP, TCP_KEEPINTVL, interval)
330
+ if TCP_KEEPCNT is not None: sock.setsockopt(socket.IPPROTO_TCP, TCP_KEEPCNT, count)
331
+ except Exception: pass
332
+ try: # macOS/Windows approx
333
+ TCP_KEEPALIVE = getattr(socket, "TCP_KEEPALIVE", None)
334
+ if TCP_KEEPALIVE is not None: sock.setsockopt(socket.IPPROTO_TCP, TCP_KEEPALIVE, idle)
335
+ except Exception: pass
336
+
337
+
338
+
339
+ # Deframer moved to lib/deframer.py — re-exported above.
340
+
341
+ # ============================================================================
342
+ # Proxy
343
+ # ============================================================================
344
+ class X1Proxy(FrameDecodeMixin, IrBlobMixin, CatalogMixin, AckWaitersMixin, ActivityOpsMixin, CacheBackupMixin, WifiDeviceMixin, RestoreMixin, BackupExportMixin):
345
+ def __init__(
346
+ self,
347
+ real_hub_ip: str,
348
+ real_hub_udp_port: int = 8102,
349
+ proxy_udp_port: int = 8102,
350
+ hub_listen_base: int = 8200,
351
+ mdns_instance: str = "X1-HUB-PROXY",
352
+ mdns_host: Optional[str] = None,
353
+ mdns_txt: Optional[Dict[str, str]] = None,
354
+ proxy_id: Optional[str] = None,
355
+ proxy_enabled: bool = True,
356
+ diag_dump: bool = True,
357
+ diag_parse: bool = True,
358
+ ka_idle: int = 30,
359
+ ka_interval: int = 10,
360
+ ka_count: int = 3,
361
+ zeroconf=None,
362
+ hub_version: str | None = None,
363
+ ) -> None:
364
+ self.real_hub_ip = real_hub_ip
365
+ self.real_hub_udp_port = int(real_hub_udp_port)
366
+ self.proxy_udp_port = int(proxy_udp_port)
367
+ self.hub_listen_base = int(hub_listen_base)
368
+ self.mdns_instance = _normalize_mdns_instance(mdns_instance)
369
+ self.mdns_host = mdns_host or (self.mdns_instance + ".local")
370
+ self.mdns_txt = mdns_txt or {}
371
+ self.proxy_id = proxy_id or self.mdns_instance
372
+ self._log = get_hub_logger(log, self.proxy_id)
373
+ self.diag_dump = bool(diag_dump)
374
+ self.diag_parse = bool(diag_parse)
375
+ if hub_version:
376
+ self.hub_version = hub_version
377
+ else:
378
+ try:
379
+ self.hub_version = classify_hub_version(self.mdns_txt)
380
+ except ValueError:
381
+ # No mDNS classification available yet (manual entry,
382
+ # mid-handshake, or a synthetic test proxy). Keep the
383
+ # variant pinned to the narrow-line layout until the
384
+ # connect banner arrives and ``_apply_banner_identity``
385
+ # re-classifies. Anything that talks to a real hub is
386
+ # already classified by mDNS before construction, so
387
+ # this path only matters for transitional state.
388
+ self.hub_version = HUB_VERSION_X1
389
+ # deframers
390
+ self._df_h2a = Deframer()
391
+ self._df_a2h = Deframer()
392
+ self._adv_started = False
393
+
394
+ self.state = ActivityCache()
395
+ self._button_assembler = DeviceButtonAssembler()
396
+ self._command_assembler = DeviceCommandAssembler()
397
+ self._macro_assembler = MacroAssembler()
398
+ self._burst = BurstScheduler()
399
+ self._pending_button_requests: set[int] = set()
400
+ self._button_burst_expected_frames: dict[int, int] = {}
401
+ # Track pending command fetches per device, so multiple targeted
402
+ # lookups for the same device (different commands) can be queued.
403
+ self._pending_command_requests: dict[int, set[int]] = {}
404
+ self._ir_dump_lock = threading.Lock()
405
+ self._ir_dump_pending: dict[tuple[int, int], dict[str, Any]] = {}
406
+ self._commands_complete: set[int] = set()
407
+ self._pending_macro_requests: set[int] = set()
408
+ self._macros_complete: set[int] = set()
409
+ self._pending_activity_map_requests: set[int] = set()
410
+ self._activity_map_complete: set[int] = set()
411
+ self._activity_row_payloads: dict[int, bytes] = {}
412
+ self._device_request_serial = 0
413
+ self._device_request_inflight: int | None = None
414
+ self._devices_catalog_ready = False
415
+ self._device_pending_generation: int | None = None
416
+ self._device_pending_expected_rows: int | None = None
417
+ self._device_pending_rows: dict[int, dict[str, Any]] = {}
418
+ self._activity_request_serial = 0
419
+ self._activity_request_inflight: int | None = None
420
+ self._activities_catalog_ready = False
421
+ self._activity_retry_count = 0
422
+ self._activity_retry_due_at: float | None = None
423
+ self._activity_retry_send_pending = False
424
+ self._activity_pending_generation: int | None = None
425
+ self._activity_pending_expected_rows: int | None = None
426
+ self._activity_pending_rows: dict[int, dict[str, Any]] = {}
427
+ self._activity_pending_payloads: dict[int, bytes] = {}
428
+ self._activity_pending_hint: int | None = None
429
+ self._favorite_label_requests: dict[tuple[int, int], set[int]] = defaultdict(set)
430
+ self._keybinding_label_requests: dict[tuple[int, int], set[int]] = defaultdict(set)
431
+ self._activity_listeners: list[callable] = []
432
+ self._activity_list_update_listeners: list[Callable[[], None]] = []
433
+ self._hub_state_listeners: list[callable] = []
434
+ self._client_state_listeners: list[callable] = []
435
+ self._ota_update_listeners: list[callable] = []
436
+ self._activation_listeners: list[callable] = []
437
+ self._app_devices_deadline: float | None = None
438
+ self._app_devices_retry_sent = False
439
+ self._pending_virtual: dict[str, Any] | None = None
440
+ self._pending_virtual_event = threading.Event()
441
+ self._pending_virtual_lock = threading.Lock()
442
+ self._pending_assigned_device_id: int | None = None
443
+ self._pending_assigned_device_event = threading.Event()
444
+ self._pending_assigned_device_lock = threading.Lock()
445
+ self._ack_queue_lock = threading.Lock()
446
+ self._ack_queue: deque[tuple[int, bytes, float]] = deque()
447
+ self._ack_event = threading.Event()
448
+ self._x2_remote_sync_id_lock = threading.Lock()
449
+ self._x2_remote_sync_id: bytes | None = None
450
+ self._x2_remote_sync_id_event = threading.Event()
451
+ self._macro_payload_lock = threading.Lock()
452
+ self._macro_payload_events: dict[tuple[int, int], MacroRecord] = {}
453
+ self._macro_payload_event = threading.Event()
454
+ self._activity_inputs_lock = threading.Lock()
455
+ self._activity_inputs_seen = 0
456
+ self._activity_inputs_last_ts = 0.0
457
+ self._activity_inputs_event = threading.Event()
458
+ self._activity_inputs_payloads: list[bytes] = []
459
+ self._device_key_sort_lock = threading.Lock()
460
+ self._device_key_sort_pending: int | None = None
461
+ self._device_key_sort_expected_pages: int | None = None
462
+ self._device_key_sort_pages: dict[int, bytes] = {}
463
+ # Set while REQ_ACTIVITY_INPUTS is awaiting a reply. Lets the
464
+ # STATUS_ACK handler distinguish a hub rejection of *our* inputs
465
+ # request from unrelated 0x0103 acks.
466
+ self._activity_inputs_pending = False
467
+ # Latched by the STATUS_ACK handler when the hub answers our
468
+ # inputs request with a non-zero status (e.g. 0x07 "not
469
+ # configured"). Read and reset exclusively inside
470
+ # :meth:`wait_for_activity_inputs_burst`; never exposed to
471
+ # callers -- the outcome surfaces through
472
+ # :class:`InputsBurstResult`.
473
+ self._inputs_burst_reject_pending = False
474
+ self._banner_info_lock = threading.Lock()
475
+ self._banner_info_event = threading.Event()
476
+ self._banner_info: dict[str, Any] = {}
477
+ self._idle_behavior_lock = threading.Lock()
478
+ self._idle_behavior_events: dict[int, threading.Event] = {}
479
+ self._idle_behavior_values: dict[int, int] = {}
480
+
481
+ self.transport = TransportBridge(
482
+ real_hub_ip,
483
+ real_hub_udp_port,
484
+ proxy_udp_port,
485
+ hub_listen_base,
486
+ mdns_instance=self.mdns_instance,
487
+ mdns_txt=self.mdns_txt,
488
+ proxy_id=self.proxy_id,
489
+ ka_idle=ka_idle,
490
+ ka_interval=ka_interval,
491
+ ka_count=ka_count,
492
+ )
493
+ self._proxy_enabled = bool(proxy_enabled)
494
+ self.transport.on_hub_frame(self._handle_hub_frame)
495
+ self.transport.on_app_frame(self._handle_app_frame)
496
+ self.transport.on_hub_state(self._notify_hub_state)
497
+ self.transport.on_client_state(self._notify_client_state)
498
+ self.transport.on_idle(self._handle_idle)
499
+
500
+ self._burst.on_burst_end("buttons", self._on_buttons_burst_end)
501
+ self._burst.on_burst_end("commands", self._on_commands_burst_end)
502
+ self._burst.on_burst_end("ir_dump", self._on_ir_dump_burst_end)
503
+ self._burst.on_burst_end("macros", self._on_macros_burst_end)
504
+ self._burst.on_burst_end("activity_map", self._on_activity_map_burst_end)
505
+ self._burst.on_burst_end("activities", self._on_activities_burst_end)
506
+ self._burst.on_burst_end("devices", self._on_devices_burst_end)
507
+ self.on_burst_end("activities", self.handle_active_state)
508
+
509
+ self._hub_connected: bool = False
510
+ self._client_connected: bool = False
511
+
512
+ self._zc = zeroconf # type: ignore[assignment]
513
+ self._zc_owned = False
514
+ self._mdns_infos: list[Any] = []
515
+
516
+ # ---------------------------------------------------------------------
517
+ # Helpers
518
+ # ---------------------------------------------------------------------
519
+ def set_zeroconf(self, zc) -> None:
520
+ """Use an existing Zeroconf instance (e.g., Home Assistant shared)."""
521
+
522
+ self._zc = zc
523
+ self._zc_owned = False
524
+
525
+ def has_banner_identity(self) -> bool:
526
+ info = self.get_banner_info()
527
+ model = _normalize_banner_model(info.get("model"))
528
+ name = str(info.get("name") or "").strip()
529
+ firmware_version = info.get("firmware_version")
530
+ return bool(
531
+ model
532
+ and name
533
+ and isinstance(firmware_version, (int, float))
534
+ )
535
+
536
+ def update_discovery_identity(
537
+ self,
538
+ *,
539
+ mdns_txt: Dict[str, str],
540
+ hub_version: str | None,
541
+ ) -> bool:
542
+ changed = False
543
+ next_txt = dict(mdns_txt)
544
+ if self.mdns_txt != next_txt:
545
+ self.mdns_txt = next_txt
546
+ changed = True
547
+
548
+ next_version = _normalize_banner_model(hub_version)
549
+ if not next_version:
550
+ try:
551
+ next_version = classify_hub_version(self.mdns_txt)
552
+ except ValueError:
553
+ next_version = self.hub_version
554
+ if self.hub_version != next_version:
555
+ self.hub_version = next_version
556
+ changed = True
557
+
558
+ next_instance = _normalize_mdns_instance(
559
+ _mdns_instance_for_identity(self.hub_version, self.mdns_txt)
560
+ )
561
+ next_host = next_instance + ".local"
562
+ if self.mdns_instance != next_instance or self.mdns_host != next_host:
563
+ self.mdns_instance = next_instance
564
+ self.mdns_host = next_host
565
+ changed = True
566
+
567
+ self.transport.update_discovery_metadata(mdns_txt=self.mdns_txt)
568
+
569
+ if changed and self._adv_started:
570
+ self._stop_discovery()
571
+ if self.transport.is_hub_connected and self.has_banner_identity():
572
+ self._start_discovery()
573
+
574
+ return changed
575
+
576
+ # ---------------------------------------------------------------------
577
+ # Local command API
578
+ # ---------------------------------------------------------------------
579
+ def on_activity_list_update(self, cb: Callable[[], None]) -> None:
580
+ self._activity_list_update_listeners.append(cb)
581
+
582
+ def _notify_activity_list_update(self) -> None:
583
+ for cb in self._activity_list_update_listeners:
584
+ cb()
585
+
586
+ def handle_active_state(self, trigger: str) -> None:
587
+ new_id, old_id = self.state.update_activity_state()
588
+ if new_id != old_id:
589
+ if new_id is not None:
590
+ self._notify_activity_change(new_id & 0xFF, old_id & 0xFF if old_id is not None else None)
591
+ else:
592
+ self._notify_activity_change(None, old_id & 0xFF if old_id is not None else None)
593
+
594
+ def enable_proxy(self) -> None:
595
+ self._proxy_enabled = True
596
+ self.transport.enable_proxy()
597
+ if self.transport.is_hub_connected and not self._adv_started:
598
+ self._start_discovery()
599
+
600
+ def disable_proxy(self) -> None:
601
+ self._proxy_enabled = False
602
+ self.transport.disable_proxy()
603
+ self._stop_discovery()
604
+
605
+ def set_diag_dump(self, enable: bool) -> None:
606
+ self.diag_dump = bool(enable)
607
+ self._log.info("%s hex logging %s", LogTag.PROXY, "enabled" if enable else "disabled")
608
+
609
+ def can_issue_commands(self) -> bool:
610
+ return self.transport.can_issue_commands()
611
+
612
+ def _build_frame(self, opcode: int, payload: bytes = b"") -> bytes:
613
+ head = bytes([SYNC0, SYNC1, (opcode >> 8) & 0xFF, opcode & 0xFF])
614
+ frame = head + payload
615
+ return frame + bytes([_sum8(frame)])
616
+
617
+ def _send_family_frame(self, family: int, payload: bytes) -> None:
618
+ opcode = ((len(payload) & 0xFF) << 8) | (family & 0xFF)
619
+ self._log.debug(
620
+ "%s send family=0x%02X opcode=0x%04X payload=%dB",
621
+ LogTag.WIFI,
622
+ family,
623
+ opcode,
624
+ len(payload),
625
+ )
626
+ self._send_cmd_frame(opcode, payload)
627
+
628
+ def _send_step(
629
+ self,
630
+ *,
631
+ step_name: str,
632
+ family: int,
633
+ payload: bytes,
634
+ ack_opcode: int,
635
+ ack_first_byte: int | None = None,
636
+ ack_fallback_opcodes: tuple[int, ...] = (),
637
+ timeout: float = 5.0,
638
+ retries: int = 0,
639
+ retry_delay: float = 0.0,
640
+ ) -> SendStepResult:
641
+ """Send one create-flow step and classify the hub's response.
642
+
643
+ Returns a :class:`SendStepResult` whose ``outcome`` is one of
644
+ :class:`AckOutcome`. A ``STATUS_ACK`` (``0x0103``) reply whose
645
+ first payload byte is non-zero is classified as
646
+ :attr:`AckOutcome.rejected` even when the step asked for the
647
+ success byte; this matches the behaviour already present in
648
+ :func:`run_create_sequence` and lets multi-step orchestrations
649
+ fail fast on the first hub-side refusal instead of spinning out
650
+ the per-step timeout.
651
+ """
652
+
653
+ candidates: list[tuple[int, int | None]] = [(ack_opcode, ack_first_byte)]
654
+ candidates.extend((fallback_opcode, None) for fallback_opcode in ack_fallback_opcodes)
655
+
656
+ # STATUS_ACK reply slot. When the caller is waiting for the OK
657
+ # byte specifically, also wake on any other first byte so a
658
+ # rejection surfaces immediately rather than waiting out the
659
+ # timeout. The classifier below turns a non-zero answer into a
660
+ # ``rejected`` outcome.
661
+ wildcard_status_reject = (
662
+ ack_opcode == ACK_OPCODE_STATUS
663
+ and ack_first_byte == ACK_STATUS_BYTE_OK
664
+ )
665
+ if wildcard_status_reject:
666
+ candidates.append((ack_opcode, None))
667
+
668
+ total_attempts = max(1, int(retries) + 1)
669
+ for attempt in range(1, total_attempts + 1):
670
+ self._log.debug(
671
+ "%s[STEP] %s tx family=0x%02X expect_ack=0x%04X first_byte=%s attempt=%d/%d",
672
+ LogTag.WIFI,
673
+ step_name,
674
+ family,
675
+ ack_opcode,
676
+ f"0x{ack_first_byte:02X}" if ack_first_byte is not None else "*",
677
+ attempt,
678
+ total_attempts,
679
+ )
680
+ send_ts = time.monotonic()
681
+ self._send_family_frame(family, payload)
682
+
683
+ matched = self.wait_for_ack_any(
684
+ candidates,
685
+ timeout=timeout,
686
+ not_before=send_ts,
687
+ )
688
+ if matched is not None:
689
+ matched_opcode, matched_payload = matched
690
+ first_byte = matched_payload[0] if matched_payload else None
691
+ is_status_reject = (
692
+ matched_opcode == ACK_OPCODE_STATUS
693
+ and first_byte is not None
694
+ and first_byte != ACK_STATUS_BYTE_OK
695
+ )
696
+ if is_status_reject:
697
+ self._log.warning(
698
+ "%s[STEP] %s hub rejected status=0x%02X",
699
+ LogTag.WIFI,
700
+ step_name,
701
+ first_byte,
702
+ )
703
+ return SendStepResult(
704
+ outcome=AckOutcome.rejected,
705
+ ack_opcode=matched_opcode,
706
+ ack_payload=bytes(matched_payload),
707
+ )
708
+ if matched_opcode != ack_opcode:
709
+ self._log.warning(
710
+ "%s[STEP] %s matched fallback ack=0x%04X (expected=0x%04X)",
711
+ LogTag.WIFI,
712
+ step_name,
713
+ matched_opcode,
714
+ ack_opcode,
715
+ )
716
+ self._log.debug("%s[STEP] %s acked via 0x%04X", LogTag.WIFI, step_name, matched_opcode)
717
+ return SendStepResult(
718
+ outcome=AckOutcome.acked,
719
+ ack_opcode=matched_opcode,
720
+ ack_payload=bytes(matched_payload),
721
+ )
722
+
723
+ if attempt < total_attempts:
724
+ self._log.warning(
725
+ "%s[STEP] %s retrying after ack timeout (attempt %d/%d)",
726
+ LogTag.WIFI,
727
+ step_name,
728
+ attempt,
729
+ total_attempts,
730
+ )
731
+ if retry_delay > 0:
732
+ time.sleep(retry_delay)
733
+
734
+ self._log.warning(
735
+ "%s[STEP] %s timeout waiting ack=0x%04X first_byte=%s",
736
+ LogTag.WIFI,
737
+ step_name,
738
+ ack_opcode,
739
+ f"0x{ack_first_byte:02X}" if ack_first_byte is not None else "*",
740
+ )
741
+ return SendStepResult(outcome=AckOutcome.timeout)
742
+
743
+ def _utf16le_padded(self, text: str, *, length: int) -> bytes:
744
+ data = text.encode("utf-16le")
745
+ truncated = data[:length]
746
+ return truncated + b"\x00" * max(0, length - len(truncated))
747
+
748
+ def _encode_len_prefixed(self, blob: bytes, *, max_len: int = 255) -> bytes:
749
+ limited = blob[:max_len]
750
+ return bytes([len(limited)]) + limited
751
+
752
+ def _encode_headers(self, headers: dict[str, str]) -> bytes:
753
+ return "\r\n".join(f"{k}: {v}" for k, v in headers.items()).encode("utf-8")
754
+
755
+ def enqueue_cmd(
756
+ self,
757
+ opcode: int,
758
+ payload: bytes = b"",
759
+ *,
760
+ expects_burst: bool = False,
761
+ burst_kind: str | None = None,
762
+ ) -> bool:
763
+ sent = self._burst.queue_or_send(
764
+ opcode=opcode,
765
+ payload=payload,
766
+ expects_burst=expects_burst,
767
+ burst_kind=burst_kind,
768
+ can_issue=self.can_issue_commands,
769
+ sender=self._send_cmd_frame,
770
+ )
771
+ if sent:
772
+ self._log.debug("%s queued %s (0x%04X) %dB", LogTag.CMD, OPNAMES.get(opcode, f"OP_{opcode:04X}"), opcode, len(payload))
773
+ else:
774
+ self._log.debug(
775
+ "%s ignoring %s: proxy client is connected",
776
+ LogTag.CMD,
777
+ OPNAMES.get(opcode, f"OP_{opcode:04X}"),
778
+ )
779
+ return sent
780
+
781
+ def on_hub_state_change(self, cb) -> None:
782
+ """cb(connected: bool)"""
783
+ self._hub_state_listeners.append(cb)
784
+ cb(self._hub_connected)
785
+
786
+ def on_client_state_change(self, cb) -> None:
787
+ """cb(connected: bool)"""
788
+ self._client_state_listeners.append(cb)
789
+ cb(self._client_connected)
790
+
791
+ def on_ota_update(self, cb) -> None:
792
+ """cb() Fired when the hub announces an OTA firmware update (opcode 0x0167)."""
793
+ self._ota_update_listeners.append(cb)
794
+
795
+ def notify_ota_in_progress(self) -> None:
796
+ """Dispatch the OTA-in-progress event to registered listeners."""
797
+ self._log.warning("[OTA] hub announced firmware update — pausing reconnects")
798
+ for cb in self._ota_update_listeners:
799
+ try:
800
+ cb()
801
+ except Exception:
802
+ self._log.exception("ota update listener failed")
803
+
804
+ def on_activity_change(self, cb) -> None:
805
+ """cb(new_id: int | None, old_id: int | None, name: str | None)"""
806
+ self._activity_listeners.append(cb)
807
+
808
+ def on_app_activation(self, cb) -> None:
809
+ """cb(record: dict[str, Any])"""
810
+ self._activation_listeners.append(cb)
811
+
812
+ def on_burst_end(self, key: str, cb):
813
+ # key can be:
814
+ # "buttons" -> all buttons updates
815
+ # "buttons:101" -> just entity 101
816
+ # "commands" -> all commands updates
817
+ # "commands:101" -> just entity 101
818
+ self._burst.on_burst_end(key, cb)
819
+
820
+ def get_banner_info(self) -> dict[str, Any]:
821
+ with self._banner_info_lock:
822
+ return dict(self._banner_info)
823
+
824
+ def record_hub_name(self, name: str) -> bool:
825
+ """Update cached banner identity with a newly confirmed hub name."""
826
+
827
+ next_name = str(name or "").strip()
828
+ if not next_name:
829
+ return False
830
+
831
+ changed = False
832
+ with self._banner_info_lock:
833
+ next_info = dict(self._banner_info)
834
+ if str(next_info.get("name") or "").strip() != next_name:
835
+ next_info["name"] = next_name
836
+ self._banner_info = next_info
837
+ changed = True
838
+
839
+ self._banner_info_event.set()
840
+ if changed:
841
+ self._log.info("[HUB] cached hub name=%s", next_name)
842
+ return True
843
+
844
+ def request_banner_info(self) -> bool:
845
+ return self.enqueue_cmd(OP_REQ_BANNER)
846
+
847
+ def request_idle_behavior(self, device_id: int) -> bool:
848
+ """Request the current idle/power behavior for one device."""
849
+
850
+ return self.enqueue_cmd(OP_REQ_IDLE_BEHAVIOR, bytes([device_id & 0xFF]))
851
+
852
+ def set_idle_behavior(self, device_id: int, mode: int) -> bool:
853
+ """Set the idle/power behavior for one device."""
854
+
855
+ dev_lo = device_id & 0xFF
856
+ normalized_mode = int(mode) & 0xFF
857
+ ok = self.enqueue_cmd(
858
+ OP_SET_IDLE_BEHAVIOR,
859
+ bytes([dev_lo, normalized_mode]),
860
+ )
861
+ if ok:
862
+ self.record_idle_behavior_value(dev_lo, normalized_mode, source="local_set")
863
+ return ok
864
+
865
+ def get_idle_behavior(
866
+ self,
867
+ device_id: int,
868
+ *,
869
+ fetch_if_missing: bool = True,
870
+ ) -> tuple[int | None, bool]:
871
+ """Return cached idle behavior, optionally querying the hub if missing."""
872
+
873
+ dev_lo = device_id & 0xFF
874
+ cached = self.state.entities("device").get(dev_lo, {}).get("idle_behavior")
875
+ if isinstance(cached, int):
876
+ return (cached & 0xFF, True)
877
+
878
+ with self._idle_behavior_lock:
879
+ value = self._idle_behavior_values.get(dev_lo)
880
+
881
+ if value is not None:
882
+ return (value, True)
883
+
884
+ if fetch_if_missing and self.can_issue_commands():
885
+ self.request_idle_behavior(dev_lo)
886
+
887
+ return (None, False)
888
+
889
+ def fetch_idle_behavior(
890
+ self,
891
+ device_id: int,
892
+ *,
893
+ force_refresh: bool = True,
894
+ timeout: float = 2.0,
895
+ ) -> tuple[int | None, bool]:
896
+ """Fetch one device's idle behavior, using cached data when allowed."""
897
+
898
+ dev_lo = device_id & 0xFF
899
+ cached, ready = self.get_idle_behavior(dev_lo, fetch_if_missing=False)
900
+ if ready and not force_refresh:
901
+ return (cached, True)
902
+
903
+ if not self.can_issue_commands():
904
+ return (cached, ready)
905
+
906
+ with self._idle_behavior_lock:
907
+ event = self._idle_behavior_events.setdefault(dev_lo, threading.Event())
908
+ event.clear()
909
+
910
+ if not self.request_idle_behavior(dev_lo):
911
+ return (cached, ready)
912
+
913
+ event.wait(timeout)
914
+ refreshed, refreshed_ready = self.get_idle_behavior(dev_lo, fetch_if_missing=False)
915
+ return (refreshed, refreshed_ready)
916
+
917
+ def fetch_banner_info(
918
+ self,
919
+ *,
920
+ force_refresh: bool = True,
921
+ timeout: float = 2.0,
922
+ ) -> tuple[dict[str, Any], bool]:
923
+ cached = self.get_banner_info()
924
+ if cached and not force_refresh:
925
+ return (cached, True)
926
+
927
+ if not self.can_issue_commands():
928
+ return (cached, bool(cached))
929
+
930
+ self._banner_info_event.clear()
931
+ if not self.request_banner_info():
932
+ return (cached, bool(cached))
933
+
934
+ self._banner_info_event.wait(timeout)
935
+ info = self.get_banner_info()
936
+ return (info, bool(info))
937
+
938
+ def set_hub_name(
939
+ self,
940
+ name: str,
941
+ *,
942
+ timeout: float = 5.0,
943
+ ) -> bool:
944
+ next_name = str(name or "").strip()
945
+ if not next_name:
946
+ self._log.warning("[HUB] set_hub_name ignored: empty name")
947
+ return False
948
+ if not self.can_issue_commands():
949
+ self._log.warning("[HUB] set_hub_name ignored: transport not ready")
950
+ return False
951
+
952
+ payload = _encode_hub_name_wire(next_name)
953
+ if not payload:
954
+ self._log.warning("[HUB] set_hub_name ignored: name %r produced no encodable bytes", next_name)
955
+ return False
956
+
957
+ self.clear_ack_queue()
958
+ send_ts = time.monotonic()
959
+ self._log.info("[HUB] setting hub name=%s", next_name)
960
+ self._send_family_frame(OP_SET_HUB_NAME & 0xFF, payload)
961
+
962
+ matched = self.wait_for_ack_family_low(
963
+ FAMILY_HUB_NAME_REPLY,
964
+ timeout=timeout,
965
+ not_before=send_ts,
966
+ )
967
+ if matched is None:
968
+ self._log.warning("[HUB] timed out waiting for hub-name reply")
969
+ return False
970
+
971
+ _ack_opcode, ack_payload = matched
972
+ echoed_name = _decode_hub_name_wire(ack_payload, hub_version=self.hub_version)
973
+ if echoed_name and echoed_name != next_name:
974
+ self._log.info(
975
+ "[HUB] hub echoed normalized name=%s (requested=%s)",
976
+ echoed_name,
977
+ next_name,
978
+ )
979
+ self.record_hub_name(echoed_name or next_name)
980
+ return True
981
+
982
+ def record_banner_payload(self, opcode: int, payload: bytes) -> dict[str, Any] | None:
983
+ if opcode_family(opcode) != 0x02 or len(payload) < 15:
984
+ return None
985
+
986
+ model = _HUB_MODEL_BY_CODE.get(payload[7] & 0xFF)
987
+ trailer_flag = payload[13] & 0xFF
988
+ trailer_zero = payload[14] & 0xFF
989
+ if model is None or trailer_zero != 0x00 or trailer_flag not in (0x00, 0x01):
990
+ return None
991
+
992
+ batch = payload[8:12].hex()
993
+ firmware_version = payload[12] & 0xFF
994
+ banner_name = payload[15:].decode("utf-8", errors="ignore").strip("\x00").strip()
995
+ parsed = {
996
+ "model": model,
997
+ "production_batch": batch,
998
+ "firmware_version": firmware_version,
999
+ "name": banner_name,
1000
+ }
1001
+
1002
+ changed = False
1003
+ with self._banner_info_lock:
1004
+ if self._banner_info != parsed:
1005
+ self._banner_info = dict(parsed)
1006
+ changed = True
1007
+
1008
+ if changed:
1009
+ if self.hub_version != model:
1010
+ self._log.info(
1011
+ "[BANNER] model corrected from %s to %s",
1012
+ self.hub_version,
1013
+ model,
1014
+ )
1015
+ self.hub_version = model
1016
+ self._log.info(
1017
+ "[BANNER] model=%s batch=%s fw=%d name=%s",
1018
+ model,
1019
+ batch,
1020
+ firmware_version,
1021
+ banner_name or "<unknown>",
1022
+ )
1023
+
1024
+ self._banner_info_event.set()
1025
+ return parsed
1026
+
1027
+ def record_idle_behavior_value(
1028
+ self,
1029
+ device_id: int,
1030
+ mode: int,
1031
+ *,
1032
+ source: str = "hub_reply",
1033
+ ) -> int:
1034
+ """Cache one device's idle/power behavior and wake any waiter."""
1035
+
1036
+ dev_lo = device_id & 0xFF
1037
+ normalized_mode = int(mode) & 0xFF
1038
+
1039
+ with self._idle_behavior_lock:
1040
+ self._idle_behavior_values[dev_lo] = normalized_mode
1041
+ event = self._idle_behavior_events.get(dev_lo)
1042
+
1043
+ existing = dict(self.state.entities("device").get(dev_lo, {}))
1044
+ existing["idle_behavior"] = normalized_mode
1045
+ existing["power_mode"] = normalized_mode
1046
+ existing["power_model"] = normalized_mode
1047
+ self.state.devices[dev_lo] = normalize_device_entry(existing)
1048
+
1049
+ self._log.info(
1050
+ "%s idle %s dev=0x%02X mode=%d",
1051
+ LogTag.REMOTE,
1052
+ source,
1053
+ dev_lo,
1054
+ normalized_mode,
1055
+ )
1056
+
1057
+ if event is not None:
1058
+ event.set()
1059
+
1060
+ return normalized_mode
1061
+
1062
+ # High-level helpers
1063
+ def request_devices(self) -> bool: return self.enqueue_cmd(OP_REQ_DEVICES, expects_burst=True, burst_kind="devices")
1064
+ def request_activities(self, *, is_retry: bool = False) -> bool:
1065
+ self._activity_retry_send_pending = is_retry
1066
+ ok = self.enqueue_cmd(OP_REQ_ACTIVITIES, expects_burst=True, burst_kind="activities")
1067
+ if not ok:
1068
+ self._activity_retry_send_pending = False
1069
+ return ok
1070
+
1071
+ def request_buttons_for_entity(self, ent_id: int) -> bool:
1072
+ if not self.can_issue_commands():
1073
+ self._log.info("[CMD] request_buttons_for_entity ignored: proxy client is connected"); return False
1074
+
1075
+ ent_lo = ent_id & 0xFF
1076
+ if ent_lo in self._pending_button_requests:
1077
+ self._log.debug(
1078
+ "[CMD] request_buttons_for_entity ignored: burst already pending for 0x%02X",
1079
+ ent_lo,
1080
+ )
1081
+ return False
1082
+
1083
+ self._pending_button_requests.add(ent_lo)
1084
+ return self.enqueue_cmd(
1085
+ OP_REQ_BUTTONS,
1086
+ bytes([ent_lo, 0xFF]),
1087
+ expects_burst=True,
1088
+ burst_kind=f"buttons:{ent_lo}",
1089
+ )
1090
+
1091
+ def request_commands_for_entity(self, ent_id: int) -> bool:
1092
+ if not self.can_issue_commands():
1093
+ self._log.info("[CMD] request_commands_for_entity ignored: proxy client is connected"); return False
1094
+ ent_lo = ent_id & 0xFF
1095
+ if 0xFF in self._pending_command_requests.get(ent_lo, set()):
1096
+ self._log.debug(
1097
+ "[CMD] request_commands_for_entity ignored: burst already pending for 0x%02X",
1098
+ ent_lo,
1099
+ )
1100
+ return False
1101
+
1102
+ self._pending_command_requests.setdefault(ent_lo, set()).add(0xFF)
1103
+ self.enqueue_cmd(OP_REQ_COMMANDS, bytes([ent_lo, 0xFF]), expects_burst=True, burst_kind=f"commands:{ent_lo}")
1104
+ return True
1105
+
1106
+ def request_ir_command_dump(
1107
+ self,
1108
+ device_id: int,
1109
+ command_id: int | None = None,
1110
+ *,
1111
+ timeout: float = 10.0,
1112
+ ) -> dict[str, Any] | None:
1113
+ """Request the raw 0x020C [dev, item] blob dump for a device."""
1114
+
1115
+ if not self.can_issue_commands():
1116
+ self._log.info("[CMD] request_ir_command_dump ignored: proxy client is connected")
1117
+ return None
1118
+
1119
+ dev_lo = device_id & 0xFF
1120
+ cmd_lo = 0xFF if command_id is None else (command_id & 0xFF)
1121
+ request_key = (dev_lo, cmd_lo)
1122
+ event: threading.Event
1123
+ should_send = False
1124
+
1125
+ with self._ir_dump_lock:
1126
+ pending = self._ir_dump_pending.get(request_key)
1127
+ if pending is not None and not pending["event"].is_set():
1128
+ event = pending["event"]
1129
+ else:
1130
+ now = time.monotonic()
1131
+ event = threading.Event()
1132
+ self._ir_dump_pending[request_key] = {
1133
+ "event": event,
1134
+ "device_id": dev_lo,
1135
+ "requested_command_id": None if cmd_lo == 0xFF else cmd_lo,
1136
+ "total_commands": None,
1137
+ "commands": {},
1138
+ "response_index_to_command_id": {},
1139
+ "started_ts": now,
1140
+ "last_progress_ts": now,
1141
+ "burst_finished": False,
1142
+ }
1143
+ should_send = True
1144
+
1145
+ if should_send:
1146
+ ok = self.enqueue_cmd(
1147
+ OP_REQ_BLOB,
1148
+ bytes([dev_lo, cmd_lo]),
1149
+ expects_burst=True,
1150
+ burst_kind=f"ir_dump:{dev_lo}:{cmd_lo}",
1151
+ )
1152
+ if not ok:
1153
+ with self._ir_dump_lock:
1154
+ active = self._ir_dump_pending.get(request_key)
1155
+ if active is not None and active["event"] is event:
1156
+ self._ir_dump_pending.pop(request_key, None)
1157
+ return None
1158
+
1159
+ idle_timeout = max(float(timeout), 0.1)
1160
+ hard_timeout = 120.0 if cmd_lo == 0xFF else max(idle_timeout * 3.0, 30.0)
1161
+ hard_deadline = time.monotonic() + hard_timeout
1162
+
1163
+ while True:
1164
+ remaining = hard_deadline - time.monotonic()
1165
+ if remaining <= 0:
1166
+ break
1167
+ if event.wait(min(0.25, remaining)):
1168
+ break
1169
+
1170
+ with self._ir_dump_lock:
1171
+ live_pending = self._ir_dump_pending.get(request_key)
1172
+ if live_pending is None:
1173
+ break
1174
+ last_progress = float(
1175
+ live_pending.get("last_progress_ts", live_pending.get("started_ts", 0.0))
1176
+ )
1177
+
1178
+ if time.monotonic() - last_progress >= idle_timeout:
1179
+ break
1180
+
1181
+ with self._ir_dump_lock:
1182
+ pending = self._ir_dump_pending.pop(request_key, None)
1183
+
1184
+ if pending is None:
1185
+ return None
1186
+
1187
+ return self._build_ir_dump_result(pending)
1188
+
1189
+
1190
+ def get_app_activations(self) -> list[dict[str, Any]]:
1191
+ return self.state.get_app_activations()
1192
+
1193
+ def get_proxy_status(self) -> bool:
1194
+ return self._proxy_enabled
1195
+
1196
+ def send_command(self, ent_id: int, key_code: int) -> bool:
1197
+ if not self.can_issue_commands():
1198
+ self._log.info(
1199
+ "[CMD] send_command ignored: transport not ready "
1200
+ "(hub_connected=%s, client_connected=%s)",
1201
+ self.transport.is_hub_connected,
1202
+ self.transport.is_client_connected,
1203
+ )
1204
+ return False
1205
+
1206
+ if key_code == ButtonName.POWER_ON:
1207
+ self.state.set_hint(ent_id)
1208
+
1209
+ id_lo = ent_id & 0xFF
1210
+ return self.enqueue_cmd(OP_REQ_ACTIVATE, bytes([id_lo, key_code]))
1211
+
1212
+ def record_app_activation(
1213
+ self,
1214
+ *,
1215
+ ent_id: int,
1216
+ ent_kind: str,
1217
+ ent_name: str,
1218
+ command_id: int,
1219
+ command_label: str | None,
1220
+ button_label: str | None,
1221
+ direction: str,
1222
+ ) -> dict[str, Any]:
1223
+ record = self.state.record_app_activation(
1224
+ ent_id=ent_id,
1225
+ ent_kind=ent_kind,
1226
+ ent_name=ent_name,
1227
+ command_id=command_id,
1228
+ command_label=command_label,
1229
+ button_label=button_label,
1230
+ direction=direction,
1231
+ )
1232
+ self._notify_app_activation(record)
1233
+ return record
1234
+
1235
+ def find_remote(self, hub_version: str | None = None) -> bool:
1236
+ """Trigger the hub's "find my remote" feature."""
1237
+ version = hub_version or self.hub_version
1238
+ if not version:
1239
+ try:
1240
+ version = classify_hub_version(self.mdns_txt)
1241
+ except ValueError:
1242
+ self._log.warning(
1243
+ "%s find-remote: hub_version unknown; cannot pick opcode.", LogTag.REMOTE
1244
+ )
1245
+ return False
1246
+ self.hub_version = version
1247
+
1248
+ if version == HUB_VERSION_X2:
1249
+ return self.enqueue_cmd(OP_FIND_REMOTE_X2, b"\x00\x00\x08")
1250
+
1251
+ return self.enqueue_cmd(OP_FIND_REMOTE)
1252
+
1253
+ def update_x2_remote_sync_id(self, remote_id: bytes) -> None:
1254
+ with self._x2_remote_sync_id_lock:
1255
+ self._x2_remote_sync_id = bytes(remote_id[:3])
1256
+ self._x2_remote_sync_id_event.set()
1257
+
1258
+ def wait_for_x2_remote_sync_id(self, timeout: float = 2.0) -> bytes | None:
1259
+ self._x2_remote_sync_id_event.wait(timeout)
1260
+ with self._x2_remote_sync_id_lock:
1261
+ return self._x2_remote_sync_id
1262
+
1263
+ def resync_remote(self, hub_version: str | None = None) -> bool:
1264
+ """Force a physical remote sync with the hub."""
1265
+ version = hub_version or self.hub_version
1266
+ if not version:
1267
+ try:
1268
+ version = classify_hub_version(self.mdns_txt)
1269
+ except ValueError:
1270
+ self._log.warning(
1271
+ "%s sync: hub_version unknown; cannot pick opcode.", LogTag.REMOTE
1272
+ )
1273
+ return False
1274
+ self.hub_version = version
1275
+
1276
+ if version == HUB_VERSION_X2:
1277
+ with self._x2_remote_sync_id_lock:
1278
+ self._x2_remote_sync_id = None
1279
+ self._x2_remote_sync_id_event.clear()
1280
+
1281
+ if not self.enqueue_cmd(OP_X2_REMOTE_LIST, b"\x00"):
1282
+ return False
1283
+
1284
+ remote_id = self.wait_for_x2_remote_sync_id(timeout=2.0)
1285
+ if remote_id is None:
1286
+ self._log.warning("%s sync: timed out waiting for X2 remote list response", LogTag.REMOTE)
1287
+ return False
1288
+
1289
+ return self.enqueue_cmd(OP_X2_REMOTE_SYNC, remote_id + b"\x01")
1290
+
1291
+ return self.enqueue_cmd(OP_REMOTE_SYNC)
1292
+
1293
+ # ------------------------------------------------------------------
1294
+ # Virtual IP device/button creation
1295
+ # ------------------------------------------------------------------
1296
+ def start_virtual_device(
1297
+ self,
1298
+ *,
1299
+ device_name: str | None = None,
1300
+ button_name: str | None = None,
1301
+ method: str | None = None,
1302
+ url: str | None = None,
1303
+ headers: dict[str, str] | None = None,
1304
+ ) -> None:
1305
+ with self._pending_virtual_lock:
1306
+ self._pending_virtual_event.clear()
1307
+ self._pending_virtual = {
1308
+ "device_name": device_name or "",
1309
+ "button_name": button_name,
1310
+ "method": method,
1311
+ "url": url,
1312
+ "headers": headers or {},
1313
+ "device_id": None,
1314
+ "button_id": None,
1315
+ "status": "pending",
1316
+ }
1317
+
1318
+ def update_virtual_device(self, **kwargs) -> dict[str, Any]:
1319
+ with self._pending_virtual_lock:
1320
+ if self._pending_virtual is None:
1321
+ self._pending_virtual = {"headers": {}, "status": "pending"}
1322
+ if "headers" in kwargs and kwargs["headers"] is not None:
1323
+ merged = dict(self._pending_virtual.get("headers", {}))
1324
+ merged.update(kwargs["headers"])
1325
+ kwargs["headers"] = merged
1326
+ self._pending_virtual.update({k: v for k, v in kwargs.items() if v is not None or k == "status"})
1327
+ snapshot = dict(self._pending_virtual)
1328
+
1329
+ if snapshot.get("device_id") is not None and snapshot.get("device_name"):
1330
+ self.state.record_virtual_device(
1331
+ snapshot["device_id"],
1332
+ name=snapshot.get("device_name", ""),
1333
+ button_id=snapshot.get("button_id"),
1334
+ method=snapshot.get("method"),
1335
+ url=snapshot.get("url"),
1336
+ headers=snapshot.get("headers"),
1337
+ button_name=snapshot.get("button_name"),
1338
+ )
1339
+
1340
+ if kwargs.get("status") == "success" or kwargs.get("device_id") is not None:
1341
+ self._pending_virtual_event.set()
1342
+
1343
+ return snapshot
1344
+
1345
+ def wait_for_virtual_device(self, timeout: float = 5.0) -> dict[str, Any] | None:
1346
+ self._pending_virtual_event.wait(timeout)
1347
+ with self._pending_virtual_lock:
1348
+ if self._pending_virtual is None:
1349
+ return None
1350
+ snapshot = dict(self._pending_virtual)
1351
+ if snapshot.get("status") == "success":
1352
+ self._pending_virtual = None
1353
+ return snapshot
1354
+
1355
+ def _build_macro_record_entry(
1356
+ self,
1357
+ *,
1358
+ device_id: int,
1359
+ command_id: int,
1360
+ input_index: int = 0,
1361
+ ) -> MacroKeyEntry:
1362
+ return MacroKeyEntry(
1363
+ device_id=device_id & 0xFF,
1364
+ key_id=command_id & 0xFF,
1365
+ fid=0,
1366
+ duration=input_index & 0xFF,
1367
+ delay=0xFF,
1368
+ )
1369
+
1370
+
1371
+ def _build_paged_macro_save_payloads(self, payload: bytes) -> list[bytes]:
1372
+ """Split one family-0x12 macro-save body into app-shaped page payloads.
1373
+
1374
+ ``payload`` is the canonical ``[outer_marker][outer_seq_be] + inner_body``
1375
+ produced by :func:`build_macro_save_payload`. The inner body already
1376
+ carries the correct ``total_pages`` at ``body[1:3]`` and a checksum at
1377
+ ``body[-1]`` computed over the final byte values, so this function only
1378
+ chops the body into 247-byte chunks and prepends a fresh
1379
+ ``[0x01][seq_be]`` wrapper to each page.
1380
+ """
1381
+
1382
+ if len(payload) < 4:
1383
+ return [payload]
1384
+
1385
+ body = payload[3:]
1386
+ chunk_size = 247
1387
+ total_pages = max(1, (len(body) + chunk_size - 1) // chunk_size)
1388
+
1389
+ paged_payloads: list[bytes] = []
1390
+ for seq in range(1, total_pages + 1):
1391
+ chunk = body[(seq - 1) * chunk_size : seq * chunk_size]
1392
+ paged_payloads.append(bytes([0x01]) + seq.to_bytes(2, "big") + bytes(chunk))
1393
+ return paged_payloads
1394
+
1395
+ def _send_paged_macro_save(
1396
+ self,
1397
+ *,
1398
+ payload: bytes,
1399
+ macro_button: int,
1400
+ ack_timeout: float = 5.0,
1401
+ ) -> tuple[int, bytes] | None:
1402
+ """Send one macro save using paged family-0x12 write layout."""
1403
+
1404
+ paged_payloads = self._build_paged_macro_save_payloads(payload)
1405
+ self.clear_ack_queue()
1406
+
1407
+ last_ack: tuple[int, bytes] | None = None
1408
+ for seq, page_payload in enumerate(paged_payloads, start=1):
1409
+ page_opcode = ((len(page_payload) & 0xFF) << 8) | 0x12
1410
+ self._log.debug(
1411
+ "%s save macro page seq=%d/%d opcode=0x%04X payload=%dB",
1412
+ LogTag.ACTIVITY,
1413
+ seq,
1414
+ len(paged_payloads),
1415
+ page_opcode,
1416
+ len(page_payload),
1417
+ )
1418
+ if self.diag_dump:
1419
+ self._log.debug(
1420
+ "%s save macro page %d/%d payload %s",
1421
+ LogTag.WIRE,
1422
+ seq,
1423
+ len(paged_payloads),
1424
+ page_payload.hex(" "),
1425
+ )
1426
+
1427
+ send_ts = time.monotonic()
1428
+ self._send_family_frame(0x12, page_payload)
1429
+ if seq < len(paged_payloads):
1430
+ candidates = [(0x0103, None)]
1431
+ else:
1432
+ candidates = [(0x0112, macro_button), (0x0103, None)]
1433
+ last_ack = self.wait_for_ack_any(
1434
+ candidates,
1435
+ timeout=ack_timeout,
1436
+ not_before=send_ts,
1437
+ )
1438
+ if last_ack is None:
1439
+ self._log.warning(
1440
+ "%s missing ACK after macro save page seq=%d/%d button=0x%02X",
1441
+ LogTag.ACTIVITY,
1442
+ seq,
1443
+ len(paged_payloads),
1444
+ macro_button,
1445
+ )
1446
+ return None
1447
+
1448
+ ack_opcode, ack_payload = last_ack
1449
+ # 0x0103 carries the hub status in payload[0]: 0x00 = accept,
1450
+ # anything else (observed: 0x0c) = rejection. We can't trust a
1451
+ # rejected page as if it succeeded.
1452
+ if ack_opcode == 0x0103 and (not ack_payload or ack_payload[0] != 0x00):
1453
+ status = ack_payload[0] if ack_payload else None
1454
+ self._log.warning(
1455
+ "%s hub rejected macro save page seq=%d/%d button=0x%02X status=%s",
1456
+ LogTag.ACTIVITY,
1457
+ seq,
1458
+ len(paged_payloads),
1459
+ macro_button,
1460
+ f"0x{status:02X}" if status is not None else "?",
1461
+ )
1462
+ return None
1463
+
1464
+ return last_ack
1465
+
1466
+ def _build_macro_save_payload(
1467
+ self,
1468
+ source_record: MacroRecord,
1469
+ *,
1470
+ device_id: int,
1471
+ button_id: int,
1472
+ allowed_device_ids: set[int] | None = None,
1473
+ input_index: int = 0,
1474
+ ) -> bytes:
1475
+ """Build a power-macro save payload from a fetched MacroRecord.
1476
+
1477
+ The fetched ``MacroRecord`` comes from :class:`MacroAssembler` via the
1478
+ burst handler, so its ``key_sequence`` reflects the canonical
1479
+ schema (no need to re-scan for 0xFF separators, codec heuristics, or
1480
+ expanded-pair collapses).
1481
+
1482
+ We append, rather than dedup/reorder, to mirror the official app's
1483
+ in-memory model: the device list grows by one when a device is added
1484
+ and the new device's rows land at the end of the sequence.
1485
+ """
1486
+
1487
+ allowed: set[int] | None = None
1488
+ if allowed_device_ids is not None:
1489
+ allowed = {d & 0xFF for d in allowed_device_ids}
1490
+
1491
+ compact_entries: list[MacroKeyEntry] = []
1492
+ for entry in source_record.key_sequence:
1493
+ if entry.is_delay_only:
1494
+ compact_entries.append(entry)
1495
+ continue
1496
+ if allowed is not None and entry.device_id not in allowed:
1497
+ continue
1498
+ compact_entries.append(entry)
1499
+
1500
+ existing_pairs = {
1501
+ (entry.device_id, entry.key_id)
1502
+ for entry in compact_entries
1503
+ if not entry.is_delay_only
1504
+ }
1505
+
1506
+ new_dev = device_id & 0xFF
1507
+ power_key = button_id & 0xFF
1508
+ if (new_dev, power_key) not in existing_pairs:
1509
+ compact_entries.append(
1510
+ self._build_macro_record_entry(device_id=new_dev, command_id=power_key)
1511
+ )
1512
+
1513
+ # On POWER_ON the official app always emits a (dev, 0xC5) row for
1514
+ # the newly-added device (duration carries the input ordinal, or 0
1515
+ # when no input is selected). The hub uses the C5 row as the
1516
+ # paired entry to the C6 row even when no HDMI input is being
1517
+ # switched; omitting it produces a row-count mismatch and the
1518
+ # macro shows up corrupted in the app.
1519
+ if button_id == ButtonName.POWER_ON:
1520
+ input_entry = self._build_macro_record_entry(
1521
+ device_id=new_dev, command_id=0xC5, input_index=input_index
1522
+ )
1523
+ replaced = False
1524
+ for i, entry in enumerate(compact_entries):
1525
+ if entry.device_id == new_dev and entry.key_id == 0xC5:
1526
+ compact_entries[i] = input_entry
1527
+ replaced = True
1528
+ break
1529
+ if not replaced:
1530
+ compact_entries.append(input_entry)
1531
+
1532
+ return build_macro_save_payload(
1533
+ activity_id=source_record.activity_id,
1534
+ key_id=power_key,
1535
+ key_sequence=compact_entries,
1536
+ label="POWER_ON" if button_id == ButtonName.POWER_ON else "POWER_OFF",
1537
+ hub_version=self.hub_version,
1538
+ label_slot=source_record.raw_label_slot or None,
1539
+ )
1540
+
1541
+
1542
+ def get_routed_local_ip(self) -> str:
1543
+ """Return the local IPv4 address selected by OS routing toward the real hub."""
1544
+
1545
+ return _route_local_ip(self.real_hub_ip)
1546
+
1547
+
1548
+
1549
+
1550
+ # ---------------------------------------------------------------------
1551
+ # mDNS advertisement
1552
+ # ---------------------------------------------------------------------
1553
+ def _start_mdns(self) -> None:
1554
+ from zeroconf import BadTypeInNameException, IPVersion, NonUniqueNameException, ServiceInfo, Zeroconf
1555
+
1556
+ ip_bytes = socket.inet_aton(_route_local_ip(self.real_hub_ip))
1557
+ service_type = mdns_service_type_for_props(self.mdns_txt)
1558
+ instance = self.mdns_instance
1559
+ host = (self.mdns_host or instance) + "."
1560
+
1561
+ props = {k: v.encode("utf-8") for k, v in self.mdns_txt.items()}
1562
+ # Always self-mark the advertisement as a proxy so discovery
1563
+ # (ours or any consumer's) can tell it apart from a physical
1564
+ # hub. Callers may still pre-set the key via mdns_txt.
1565
+ props.setdefault(PROXY_TXT_KEY, PROXY_TXT_VALUE.encode("utf-8"))
1566
+
1567
+ # reset any previous registrations in case of restart
1568
+ self._mdns_infos = []
1569
+
1570
+ zc = self._zc
1571
+ if zc is None:
1572
+ zc = Zeroconf(ip_version=IPVersion.V4Only)
1573
+ self._zc_owned = True
1574
+ self._zc = zc
1575
+
1576
+ info = ServiceInfo(
1577
+ type_=service_type,
1578
+ name=f"{instance}.{service_type}",
1579
+ addresses=[ip_bytes],
1580
+ port=self.proxy_udp_port,
1581
+ properties=props,
1582
+ server=host,
1583
+ )
1584
+
1585
+ try:
1586
+ zc.register_service(info)
1587
+ except BadTypeInNameException:
1588
+ self._log.exception(
1589
+ "[MDNS] service type %s was rejected; advertisement will not be started",
1590
+ service_type,
1591
+ )
1592
+ return False
1593
+ except NonUniqueNameException:
1594
+ self._log.warning(
1595
+ "[MDNS] service name %s is already in use; advertisement will not be started",
1596
+ info.name,
1597
+ )
1598
+ return False
1599
+ self._mdns_infos.append(info)
1600
+ self._log.info(
1601
+ "[MDNS] registered %s on %s:%d (HVER=%s)",
1602
+ info.name,
1603
+ socket.inet_ntoa(ip_bytes),
1604
+ self.proxy_udp_port,
1605
+ self.mdns_txt.get("HVER", "unknown"),
1606
+ )
1607
+
1608
+ self._adv_started = True
1609
+ self._log.info("[MDNS] registration complete; verify via Zeroconf browser if available")
1610
+ return True
1611
+
1612
+ # ---------------------------------------------------------------------
1613
+ # Parsing helpers
1614
+ # ---------------------------------------------------------------------
1615
+
1616
+ def _notify_hub_state(self, connected: bool) -> None:
1617
+ self._hub_connected = connected
1618
+ for cb in self._hub_state_listeners:
1619
+ try:
1620
+ cb(connected)
1621
+ except Exception:
1622
+ self._log.exception("hub state listener failed")
1623
+
1624
+ def _notify_client_state(self, connected: bool) -> None:
1625
+ self._client_connected = connected
1626
+ for cb in self._client_state_listeners:
1627
+ try:
1628
+ cb(connected)
1629
+ except Exception:
1630
+ self._log.exception("client state listener failed")
1631
+ if not connected:
1632
+ self._clear_app_device_retry()
1633
+
1634
+
1635
+ def _notify_activity_change(self, new_id: int | None, old_id: int | None) -> None:
1636
+ name = None
1637
+ if new_id is not None:
1638
+ name = self.state.entities("activity").get(new_id & 0xFF, {}).get("name")
1639
+ for cb in self._activity_listeners:
1640
+ try:
1641
+ cb(new_id, old_id, name)
1642
+ except Exception:
1643
+ self._log.exception("activity listener failed")
1644
+
1645
+ def _notify_app_activation(self, record: dict[str, Any]) -> None:
1646
+ for cb in self._activation_listeners:
1647
+ try:
1648
+ cb(record)
1649
+ except Exception:
1650
+ self._log.exception("app activation listener failed")
1651
+
1652
+ def _on_commands_burst_end(self, key: str) -> None:
1653
+ parts = key.split(":")
1654
+ if len(parts) >= 2 and parts[0] == "commands":
1655
+ try:
1656
+ ent_lo = int(parts[1])
1657
+ except ValueError:
1658
+ self._pending_command_requests.clear(); return
1659
+
1660
+ pending = self._pending_command_requests.get(ent_lo)
1661
+ if pending is None:
1662
+ return
1663
+
1664
+ targeted_cmd: int | None = None
1665
+ if len(parts) >= 3:
1666
+ try:
1667
+ targeted_cmd = int(parts[2])
1668
+ except ValueError:
1669
+ targeted_cmd = None
1670
+
1671
+ if targeted_cmd is not None:
1672
+ pending.discard(targeted_cmd)
1673
+ elif 0xFF in pending:
1674
+ pending.discard(0xFF)
1675
+ self._commands_complete.add(ent_lo)
1676
+ else:
1677
+ pending.clear()
1678
+
1679
+ if not pending:
1680
+ self._pending_command_requests.pop(ent_lo, None)
1681
+ else:
1682
+ self._pending_command_requests.clear()
1683
+
1684
+ def _on_ir_dump_burst_end(self, key: str) -> None:
1685
+ parts = key.split(":")
1686
+ if len(parts) < 3 or parts[0] != "ir_dump":
1687
+ return
1688
+
1689
+ try:
1690
+ request_key = (int(parts[1]) & 0xFF, int(parts[2]) & 0xFF)
1691
+ except ValueError:
1692
+ return
1693
+
1694
+ with self._ir_dump_lock:
1695
+ pending = self._ir_dump_pending.get(request_key)
1696
+ if pending is None:
1697
+ return
1698
+ pending["burst_finished"] = True
1699
+ pending["event"].set()
1700
+
1701
+ def _on_macros_burst_end(self, key: str) -> None:
1702
+ parts = key.split(":")
1703
+ if len(parts) >= 2 and parts[0] == "macros":
1704
+ try:
1705
+ act_lo = int(parts[1])
1706
+ except ValueError:
1707
+ self._pending_macro_requests.clear()
1708
+ return
1709
+
1710
+ self._pending_macro_requests.discard(act_lo)
1711
+ self._macros_complete.add(act_lo)
1712
+ else:
1713
+ self._pending_macro_requests.clear()
1714
+
1715
+ def _on_activity_map_burst_end(self, key: str) -> None:
1716
+ parts = key.split(":")
1717
+ if len(parts) >= 2 and parts[0] == "activity_map":
1718
+ try:
1719
+ act_lo = int(parts[1])
1720
+ except ValueError:
1721
+ self._pending_activity_map_requests.clear()
1722
+ return
1723
+
1724
+ self._pending_activity_map_requests.discard(act_lo)
1725
+ self._activity_map_complete.add(act_lo)
1726
+ else:
1727
+ self._pending_activity_map_requests.clear()
1728
+
1729
+ def _on_buttons_burst_end(self, key: str) -> None:
1730
+ if ":" in key:
1731
+ try:
1732
+ ent_lo = int(key.split(":", 1)[1])
1733
+ self._pending_button_requests.discard(ent_lo)
1734
+ self._button_burst_expected_frames.pop(ent_lo, None)
1735
+ except ValueError:
1736
+ self._pending_button_requests.clear()
1737
+ self._button_burst_expected_frames.clear()
1738
+ else:
1739
+ self._pending_button_requests.clear()
1740
+ self._button_burst_expected_frames.clear()
1741
+
1742
+ def _handle_idle(self, now: float) -> None:
1743
+ self._burst.tick(now, can_issue=self.can_issue_commands, sender=self._send_cmd_frame)
1744
+ if (
1745
+ self._activity_retry_due_at is not None
1746
+ and now >= self._activity_retry_due_at
1747
+ and not self._burst.active
1748
+ and self.can_issue_commands()
1749
+ ):
1750
+ self._activity_retry_due_at = None
1751
+ self.request_activities(is_retry=True)
1752
+ self._maybe_retry_app_devices(now)
1753
+
1754
+ def _maybe_retry_app_devices(self, now: float) -> None:
1755
+ if self._app_devices_deadline is None:
1756
+ return
1757
+
1758
+ if now >= self._app_devices_deadline:
1759
+ if not self._app_devices_retry_sent:
1760
+ #self._log.info("[CMD] retrying app-sourced REQ_DEVICES after timeout")
1761
+ #self._send_cmd_frame(OP_REQ_DEVICES, b"")
1762
+ self._app_devices_retry_sent = True
1763
+ self._app_devices_deadline = None
1764
+
1765
+ def _send_cmd_frame(self, opcode: int, payload: bytes) -> None:
1766
+ frame = self._build_frame(opcode, payload)
1767
+ if opcode == OP_REQ_DEVICES:
1768
+ self._begin_device_request()
1769
+ if opcode == OP_REQ_ACTIVITIES:
1770
+ is_retry = self._activity_retry_send_pending
1771
+ self._activity_retry_send_pending = False
1772
+ self._begin_activity_request(is_retry=is_retry)
1773
+ self._log.debug(
1774
+ "%s hub %s (0x%04X) %dB",
1775
+ LogTag.SEND,
1776
+ OPNAMES.get(opcode, f"OP_{opcode:04X}"),
1777
+ opcode,
1778
+ len(payload),
1779
+ )
1780
+ self.transport.send_local(frame)
1781
+ if self.diag_dump:
1782
+ self._log.debug("%s A→H %s", LogTag.WIRE, _hexdump(frame))
1783
+
1784
+ # ---------------------------------------------------------------------
1785
+ # Lifecycle
1786
+ # ---------------------------------------------------------------------
1787
+
1788
+ def _start_discovery(self) -> None:
1789
+ if not self._proxy_enabled:
1790
+ return
1791
+ if self._adv_started:
1792
+ return
1793
+ if not self.has_banner_identity():
1794
+ self._log.debug("[MDNS] discovery deferred until banner identity is ready")
1795
+ return
1796
+
1797
+ self.proxy_udp_port = self.transport.proxy_udp_port
1798
+ if not self._start_mdns():
1799
+ return
1800
+ self.transport.start_notify_listener()
1801
+ self._adv_started = True
1802
+
1803
+ def _stop_discovery(self) -> None:
1804
+ self.transport.stop_notify_listener()
1805
+ # unregister mDNS
1806
+ if self._zc is not None and self._mdns_infos:
1807
+ try:
1808
+ for info in self._mdns_infos:
1809
+ try:
1810
+ self._zc.unregister_service(info)
1811
+ self._log.info("[MDNS] unregistered %s", info.name)
1812
+ except Exception:
1813
+ self._log.exception("[MDNS] failed to unregister service %s", info.name)
1814
+ finally:
1815
+ if self._zc_owned:
1816
+ self._zc.close()
1817
+ self._zc = None
1818
+ self._mdns_infos = []
1819
+
1820
+ self._adv_started = False
1821
+
1822
+ def start(self) -> None:
1823
+ self.transport.start()
1824
+ if self._proxy_enabled and self.transport.is_hub_connected and not self._adv_started:
1825
+ self._start_discovery()
1826
+
1827
+ def stop(self) -> None:
1828
+ self._stop_discovery()
1829
+ self.transport.stop()
1830
+ self._log.info("%s proxy stopped", LogTag.PROXY)
1831
+
1832
+
1833
+ from . import opcode_handlers # noqa: F401 # register frame handlers