sofapython 0.0.1rc1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sofapython/__init__.py +136 -0
- sofapython/ack.py +79 -0
- sofapython/aio.py +552 -0
- sofapython/backup_export.py +507 -0
- sofapython/blob_decoders.py +806 -0
- sofapython/cli.py +447 -0
- sofapython/commands.py +1273 -0
- sofapython/deframer.py +73 -0
- sofapython/device_create.py +1174 -0
- sofapython/devices.py +534 -0
- sofapython/discovery.py +315 -0
- sofapython/frame_handlers.py +131 -0
- sofapython/hub_listener.py +242 -0
- sofapython/hub_logging.py +152 -0
- sofapython/hub_versions.py +112 -0
- sofapython/inputs.py +501 -0
- sofapython/macros.py +669 -0
- sofapython/notify_demuxer.py +434 -0
- sofapython/opcode_handlers.py +1655 -0
- sofapython/protocol_const.py +633 -0
- sofapython/proxy_ack_waiters.py +660 -0
- sofapython/proxy_activity_ops.py +943 -0
- sofapython/proxy_backup.py +504 -0
- sofapython/proxy_backup_export.py +486 -0
- sofapython/proxy_catalog.py +915 -0
- sofapython/proxy_frame_decode.py +227 -0
- sofapython/proxy_ir_blob.py +676 -0
- sofapython/proxy_restore.py +2004 -0
- sofapython/proxy_wifi_device.py +1101 -0
- sofapython/state_helpers.py +713 -0
- sofapython/transport_bridge.py +876 -0
- sofapython/version.py +4 -0
- sofapython/wire_schema.py +164 -0
- sofapython/x1_proxy.py +1833 -0
- sofapython-0.0.1rc1.dist-info/METADATA +162 -0
- sofapython-0.0.1rc1.dist-info/RECORD +39 -0
- sofapython-0.0.1rc1.dist-info/WHEEL +4 -0
- sofapython-0.0.1rc1.dist-info/entry_points.txt +2 -0
- sofapython-0.0.1rc1.dist-info/licenses/LICENSE +21 -0
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
|