casambi-bt-revamped 0.3.7.dev9__py3-none-any.whl → 0.3.12.dev15__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.
- CasambiBt/__init__.py +1 -0
- CasambiBt/_cache.py +9 -9
- CasambiBt/_casambi.py +411 -11
- CasambiBt/_classic_crypto.py +146 -0
- CasambiBt/_client.py +1915 -159
- CasambiBt/_constants.py +16 -0
- CasambiBt/_discover.py +3 -2
- CasambiBt/_invocation.py +116 -0
- CasambiBt/_network.py +189 -24
- CasambiBt/_operation.py +13 -2
- CasambiBt/_switch_events.py +329 -0
- CasambiBt/_unit.py +59 -3
- CasambiBt/_version.py +10 -0
- CasambiBt/errors.py +12 -0
- casambi_bt_revamped-0.3.12.dev15.dist-info/METADATA +135 -0
- casambi_bt_revamped-0.3.12.dev15.dist-info/RECORD +22 -0
- {casambi_bt_revamped-0.3.7.dev9.dist-info → casambi_bt_revamped-0.3.12.dev15.dist-info}/WHEEL +1 -1
- casambi_bt_revamped-0.3.7.dev9.dist-info/METADATA +0 -81
- casambi_bt_revamped-0.3.7.dev9.dist-info/RECORD +0 -18
- {casambi_bt_revamped-0.3.7.dev9.dist-info → casambi_bt_revamped-0.3.12.dev15.dist-info}/licenses/LICENSE +0 -0
- {casambi_bt_revamped-0.3.7.dev9.dist-info → casambi_bt_revamped-0.3.12.dev15.dist-info}/top_level.txt +0 -0
CasambiBt/_client.py
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import inspect
|
|
2
3
|
import logging
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
3
6
|
import struct
|
|
7
|
+
import time
|
|
4
8
|
from binascii import b2a_hex as b2a
|
|
5
9
|
from collections.abc import Callable
|
|
6
|
-
from enum import IntEnum, unique
|
|
10
|
+
from enum import Enum, IntEnum, auto, unique
|
|
7
11
|
from hashlib import sha256
|
|
8
|
-
from typing import Any, Final
|
|
12
|
+
from typing import Any, Final, Literal
|
|
9
13
|
|
|
10
14
|
from bleak import BleakClient
|
|
11
15
|
from bleak.backends.characteristic import BleakGATTCharacteristic
|
|
@@ -21,13 +25,24 @@ from cryptography.exceptions import InvalidSignature
|
|
|
21
25
|
from cryptography.hazmat.primitives.asymmetric import ec
|
|
22
26
|
|
|
23
27
|
from ._constants import CASA_AUTH_CHAR_UUID, ConnectionState
|
|
28
|
+
from ._constants import (
|
|
29
|
+
CASA_CLASSIC_CA53_CHAR_UUID,
|
|
30
|
+
CASA_CLASSIC_CONFORMANT_CA51_CHAR_UUID,
|
|
31
|
+
CASA_CLASSIC_CONFORMANT_CA53_CHAR_UUID,
|
|
32
|
+
CASA_CLASSIC_DATA_CHAR_UUID,
|
|
33
|
+
CASA_CLASSIC_HASH_CHAR_UUID,
|
|
34
|
+
)
|
|
35
|
+
from ._classic_crypto import classic_cmac_prefix
|
|
24
36
|
from ._encryption import Encryptor
|
|
25
37
|
from ._network import Network
|
|
38
|
+
from ._switch_events import SwitchEventStreamDecoder
|
|
26
39
|
|
|
27
40
|
# We need to move these imports here to prevent a cycle.
|
|
28
41
|
from .errors import ( # noqa: E402
|
|
29
42
|
BluetoothError,
|
|
30
43
|
ConnectionStateError,
|
|
44
|
+
ClassicHandshakeError,
|
|
45
|
+
ClassicKeysMissingError,
|
|
31
46
|
NetworkNotFoundError,
|
|
32
47
|
ProtocolError,
|
|
33
48
|
UnsupportedProtocolVersion,
|
|
@@ -41,8 +56,35 @@ class IncommingPacketType(IntEnum):
|
|
|
41
56
|
NetworkConfig = 9
|
|
42
57
|
|
|
43
58
|
|
|
59
|
+
class ProtocolMode(Enum):
|
|
60
|
+
EVO = auto()
|
|
61
|
+
CLASSIC = auto()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _LogBurstLimiter:
|
|
65
|
+
"""Simple in-process log rate limiter (per key).
|
|
66
|
+
|
|
67
|
+
Home Assistant warns if a logger emits too many messages. We keep some high-signal
|
|
68
|
+
WARNING logs for Classic reverse engineering but avoid spamming.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self) -> None:
|
|
72
|
+
self._state: dict[str, tuple[float, int]] = {}
|
|
73
|
+
|
|
74
|
+
def allow(self, key: str, *, burst: int, window_s: float) -> bool:
|
|
75
|
+
now = time.monotonic()
|
|
76
|
+
start, count = self._state.get(key, (now, 0))
|
|
77
|
+
if (now - start) > window_s:
|
|
78
|
+
start, count = now, 0
|
|
79
|
+
if count >= burst:
|
|
80
|
+
self._state[key] = (start, count)
|
|
81
|
+
return False
|
|
82
|
+
self._state[key] = (start, count + 1)
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
|
|
44
86
|
MIN_VERSION: Final[int] = 10
|
|
45
|
-
MAX_VERSION: Final[int] =
|
|
87
|
+
MAX_VERSION: Final[int] = 11
|
|
46
88
|
|
|
47
89
|
|
|
48
90
|
class CasambiClient:
|
|
@@ -78,21 +120,73 @@ class CasambiClient:
|
|
|
78
120
|
else address_or_device
|
|
79
121
|
)
|
|
80
122
|
self._logger = logging.getLogger(__name__)
|
|
123
|
+
self._switchDecoder = SwitchEventStreamDecoder(self._logger)
|
|
81
124
|
self._connectionState: ConnectionState = ConnectionState.NONE
|
|
82
125
|
self._dataCallback = dataCallback
|
|
83
126
|
self._disconnectedCallback = disonnectedCallback
|
|
84
127
|
self._activityLock = asyncio.Lock()
|
|
85
128
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
129
|
+
# Determined at runtime by inspecting GATT services/characteristics.
|
|
130
|
+
self._protocolMode: ProtocolMode | None = None
|
|
131
|
+
self._dataCharUuid: str | None = None
|
|
132
|
+
# EVO only: protocolVersion from the device-provided NodeInfo (byte1).
|
|
133
|
+
self._deviceProtocolVersion: int | None = None
|
|
134
|
+
|
|
135
|
+
# Classic protocol state
|
|
136
|
+
self._classicConnHash8: bytes | None = None
|
|
137
|
+
self._classicTxSeq: int = 0 # 16-bit sequence number (big endian on the wire)
|
|
138
|
+
self._classicCmdDiv: int = 0 # 8-bit per-command divider/id (matches u1.C1751c.b0)
|
|
139
|
+
# Classic header framing mode:
|
|
140
|
+
# - "conformant": [auth][sig][seq16][payload]
|
|
141
|
+
# - "legacy": [sig][payload]
|
|
142
|
+
# Ground truth: casambi-android `t1.P.n(...)` and `t1.P.o(...)`.
|
|
143
|
+
self._classicHeaderMode: str | None = None # "conformant" | "legacy"
|
|
144
|
+
# Classic transport diagnostics / channel selection.
|
|
145
|
+
self._classicTxCharUuid: str | None = None
|
|
146
|
+
self._classicNotifyCharUuids: set[str] = set()
|
|
147
|
+
self._classicHashSource: str | None = None # "ca51" | "ca52_0001" | None
|
|
148
|
+
self._classicFirstRxTs: float | None = None
|
|
149
|
+
self._classicNoRxTask: asyncio.Task[None] | None = None
|
|
150
|
+
|
|
151
|
+
# Rate limit WARNING logs (especially Classic RX) to keep HA usable.
|
|
152
|
+
self._logLimiter = _LogBurstLimiter()
|
|
153
|
+
self._classicRxFrames = 0
|
|
154
|
+
self._classicRxVerified = 0
|
|
155
|
+
self._classicRxUnverifiable = 0
|
|
156
|
+
self._classicRxParseFail = 0
|
|
157
|
+
self._classicRxType6 = 0
|
|
158
|
+
self._classicRxType7 = 0
|
|
159
|
+
self._classicRxType9 = 0
|
|
160
|
+
self._classicRxCmdStream = 0
|
|
161
|
+
self._classicRxUnknown = 0
|
|
162
|
+
self._classicRxClassicStates = 0
|
|
163
|
+
# Per-kind sample counters to ensure we emit at least a few examples for reverse engineering.
|
|
164
|
+
self._classicRxKindSamples: dict[str, int] = {}
|
|
165
|
+
self._classicRxLastStatsTs = time.monotonic()
|
|
166
|
+
|
|
167
|
+
# Classic diagnostic packet history (for dump_classic_diagnostics service)
|
|
168
|
+
self._classicTxHistory: list[dict[str, Any]] = []
|
|
169
|
+
self._classicRxHistory: list[dict[str, Any]] = []
|
|
170
|
+
self._classicDiagMaxHistory = 50 # Keep last 50 TX and RX packets
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def protocolMode(self) -> ProtocolMode | None:
|
|
174
|
+
return self._protocolMode
|
|
175
|
+
|
|
176
|
+
def _checkProtocolVersion(self, version: int, *, source: str = "unknown") -> None:
|
|
89
177
|
if version < MIN_VERSION:
|
|
90
|
-
|
|
91
|
-
|
|
178
|
+
# Legacy protocol versions are intentionally allowed. We keep this check as a warning
|
|
179
|
+
# because packet layouts/handshakes may differ and we want actionable tester logs.
|
|
180
|
+
msg = (
|
|
181
|
+
f"Legacy protocol version detected ({source}={version}). "
|
|
182
|
+
f"Versions < {MIN_VERSION} are not fully verified; attempting to continue."
|
|
92
183
|
)
|
|
184
|
+
self._logger.warning(msg)
|
|
185
|
+
return
|
|
93
186
|
if version > MAX_VERSION:
|
|
94
187
|
self._logger.warning(
|
|
95
|
-
"Version too new
|
|
188
|
+
"Version too new (%s=%i). Highest supported version is %i. Continue at your own risk.",
|
|
189
|
+
source,
|
|
96
190
|
version,
|
|
97
191
|
MAX_VERSION,
|
|
98
192
|
)
|
|
@@ -110,6 +204,23 @@ class CasambiClient:
|
|
|
110
204
|
self._outPacketCount = 2
|
|
111
205
|
self._inPacketCount = 1
|
|
112
206
|
|
|
207
|
+
# Reset protocol-specific state (important for reconnects).
|
|
208
|
+
self._protocolMode = None
|
|
209
|
+
self._dataCharUuid = None
|
|
210
|
+
self._deviceProtocolVersion = None
|
|
211
|
+
|
|
212
|
+
self._classicConnHash8 = None
|
|
213
|
+
self._classicTxSeq = 0
|
|
214
|
+
self._classicCmdDiv = 0
|
|
215
|
+
self._classicHeaderMode = None
|
|
216
|
+
self._classicTxCharUuid = None
|
|
217
|
+
self._classicNotifyCharUuids.clear()
|
|
218
|
+
self._classicHashSource = None
|
|
219
|
+
self._classicFirstRxTs = None
|
|
220
|
+
if self._classicNoRxTask is not None:
|
|
221
|
+
self._classicNoRxTask.cancel()
|
|
222
|
+
self._classicNoRxTask = None
|
|
223
|
+
|
|
113
224
|
# Reset callback queue
|
|
114
225
|
self._callbackQueue = asyncio.Queue()
|
|
115
226
|
self._callbackTask = asyncio.create_task(self._processCallbacks())
|
|
@@ -121,6 +232,33 @@ class CasambiClient:
|
|
|
121
232
|
else await get_device(self.address)
|
|
122
233
|
)
|
|
123
234
|
|
|
235
|
+
if not device and isinstance(self._address_or_devive, str) and platform.system() == "Darwin":
|
|
236
|
+
# macOS CoreBluetooth typically reports random per-device identifiers as addresses
|
|
237
|
+
# unless `use_bdaddr` is enabled. Our `discover()` uses that flag so try it here.
|
|
238
|
+
try:
|
|
239
|
+
from ._discover import discover as discover_networks # local import to avoid cycles
|
|
240
|
+
|
|
241
|
+
networks = await discover_networks()
|
|
242
|
+
wanted = self.address.replace(":", "").lower()
|
|
243
|
+
for d in networks:
|
|
244
|
+
if d.address.replace(":", "").lower() == wanted:
|
|
245
|
+
device = d
|
|
246
|
+
break
|
|
247
|
+
|
|
248
|
+
if not device:
|
|
249
|
+
self._logger.warning(
|
|
250
|
+
"macOS BLE lookup by address failed. Discovered %d Casambi networks, but none match %s. Discovered=%s",
|
|
251
|
+
len(networks),
|
|
252
|
+
self.address,
|
|
253
|
+
[d.address for d in networks[:10]],
|
|
254
|
+
)
|
|
255
|
+
except Exception:
|
|
256
|
+
self._logger.debug(
|
|
257
|
+
"macOS fallback discovery failed while trying to find %s.",
|
|
258
|
+
self.address,
|
|
259
|
+
exc_info=True,
|
|
260
|
+
)
|
|
261
|
+
|
|
124
262
|
if not device:
|
|
125
263
|
self._logger.error("Failed to discover client.")
|
|
126
264
|
raise NetworkNotFoundError
|
|
@@ -146,12 +284,401 @@ class CasambiClient:
|
|
|
146
284
|
self._logger.info(f"Connected to {self.address}")
|
|
147
285
|
self._connectionState = ConnectionState.CONNECTED
|
|
148
286
|
|
|
287
|
+
# Detect protocol mode.
|
|
288
|
+
#
|
|
289
|
+
# Important: Home Assistant wraps BleakClient (HaBleakClientWrapper) which does not implement
|
|
290
|
+
# `get_services()`. Therefore we use "try-read" probing instead of enumerating GATT services.
|
|
291
|
+
#
|
|
292
|
+
# Order:
|
|
293
|
+
# 1) Classic "non-conformant": CA51 (hash) + CA52 (data channel)
|
|
294
|
+
# 2) EVO: auth char read starts with 0x01 (NodeInfo)
|
|
295
|
+
# 3) Classic "conformant": auth char read returns connection hash (first 8 bytes used)
|
|
296
|
+
|
|
297
|
+
cloud_protocol = getattr(self._network, "protocolVersion", None)
|
|
298
|
+
ca51_prefix: bytes | None = None
|
|
299
|
+
ca51_err: str | None = None
|
|
300
|
+
ca52_notify_err: str | None = None
|
|
301
|
+
ca53_notify_err: str | None = None
|
|
302
|
+
auth_prefix: bytes | None = None
|
|
303
|
+
auth_err: str | None = None
|
|
304
|
+
c0002_prefix: bytes | None = None
|
|
305
|
+
c0002_err: str | None = None
|
|
306
|
+
c0003_notify_err: str | None = None
|
|
307
|
+
device_nodeinfo_protocol: int | None = None
|
|
308
|
+
|
|
309
|
+
def _log_probe_summary(mode: str, *, classic_variant: str | None = None) -> None:
|
|
310
|
+
# One stable, high-signal line for testers.
|
|
311
|
+
self._logger.warning(
|
|
312
|
+
"[CASAMBI_PROTOCOL_PROBE] address=%s mode=%s cloud_protocol=%s nodeinfo_b1=%s data_uuid=%s "
|
|
313
|
+
"classic_variant=%s hash_source=%s classic_tx_uuid=%s classic_notify_uuids=%s "
|
|
314
|
+
"ca51_hash8_present=%s conn_hash8_ready=%s "
|
|
315
|
+
"auth_read_prefix=%s ca51_read_prefix=%s ca51_read_error=%s auth_read_error=%s "
|
|
316
|
+
"ca52_notify_error=%s ca53_notify_error=%s c0002_read_prefix=%s c0002_read_error=%s c0003_notify_error=%s",
|
|
317
|
+
self.address,
|
|
318
|
+
mode,
|
|
319
|
+
cloud_protocol,
|
|
320
|
+
device_nodeinfo_protocol,
|
|
321
|
+
self._dataCharUuid,
|
|
322
|
+
classic_variant,
|
|
323
|
+
self._classicHashSource,
|
|
324
|
+
self._classicTxCharUuid,
|
|
325
|
+
sorted(self._classicNotifyCharUuids) if self._classicNotifyCharUuids else None,
|
|
326
|
+
bool(classic_hash and len(classic_hash) >= 8),
|
|
327
|
+
self._classicConnHash8 is not None,
|
|
328
|
+
auth_prefix,
|
|
329
|
+
ca51_prefix,
|
|
330
|
+
ca51_err,
|
|
331
|
+
auth_err,
|
|
332
|
+
ca52_notify_err,
|
|
333
|
+
ca53_notify_err,
|
|
334
|
+
c0002_prefix,
|
|
335
|
+
c0002_err,
|
|
336
|
+
c0003_notify_err,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
classic_hash: bytes | None = None
|
|
340
|
+
try:
|
|
341
|
+
classic_hash = await self._gattClient.read_gatt_char(CASA_CLASSIC_HASH_CHAR_UUID)
|
|
342
|
+
ca51_prefix = b2a(classic_hash[:10]) if classic_hash else None
|
|
343
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
344
|
+
self._logger.debug(
|
|
345
|
+
"[CASAMBI_GATT_PROBE] read ca51 ok len=%d prefix=%s",
|
|
346
|
+
0 if classic_hash is None else len(classic_hash),
|
|
347
|
+
ca51_prefix,
|
|
348
|
+
)
|
|
349
|
+
except Exception as e:
|
|
350
|
+
classic_hash = None
|
|
351
|
+
ca51_err = type(e).__name__
|
|
352
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
353
|
+
self._logger.debug("[CASAMBI_GATT_PROBE] read ca51 fail err=%s", ca51_err)
|
|
354
|
+
|
|
355
|
+
if classic_hash and len(classic_hash) >= 8:
|
|
356
|
+
self._protocolMode = ProtocolMode.CLASSIC
|
|
357
|
+
self._dataCharUuid = CASA_CLASSIC_DATA_CHAR_UUID
|
|
358
|
+
self._classicTxCharUuid = CASA_CLASSIC_DATA_CHAR_UUID
|
|
359
|
+
self._classicHeaderMode = "legacy"
|
|
360
|
+
self._classicHashSource = "ca51"
|
|
361
|
+
|
|
362
|
+
# Read connection hash (first 8 bytes are used for CMAC signing).
|
|
363
|
+
raw_hash = classic_hash
|
|
364
|
+
if raw_hash is None or len(raw_hash) < 8:
|
|
365
|
+
raise ClassicHandshakeError(
|
|
366
|
+
f"Classic connection hash read failed/too short (len={0 if raw_hash is None else len(raw_hash)})."
|
|
367
|
+
)
|
|
368
|
+
self._classicConnHash8 = bytes(raw_hash[:8])
|
|
369
|
+
|
|
370
|
+
# Parse Android's extended connection hash fields for diagnostics.
|
|
371
|
+
# Offset 8: unitId, 9: flags_lo, 10: MTU, 11: protocolVersion, 12: flags_hi
|
|
372
|
+
if len(raw_hash) >= 13:
|
|
373
|
+
ext_unit_id = raw_hash[8]
|
|
374
|
+
ext_flags_lo = raw_hash[9]
|
|
375
|
+
ext_mtu = raw_hash[10]
|
|
376
|
+
ext_proto_ver = raw_hash[11]
|
|
377
|
+
ext_flags_hi = raw_hash[12]
|
|
378
|
+
self._logger.warning(
|
|
379
|
+
"[CASAMBI_CLASSIC_CONN_HASH_EXT] variant=legacy unitId=%d flags=0x%04x mtu=%d protocolVersion=%d raw=%s",
|
|
380
|
+
ext_unit_id,
|
|
381
|
+
(ext_flags_hi << 8) | ext_flags_lo,
|
|
382
|
+
ext_mtu,
|
|
383
|
+
ext_proto_ver,
|
|
384
|
+
b2a(bytes(raw_hash[:min(len(raw_hash), 20)])),
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# Android seeds the command divider with a random byte on startup (u1.C1751c).
|
|
388
|
+
self._classicCmdDiv = int.from_bytes(os.urandom(1), "big") or 1
|
|
389
|
+
self._classicTxSeq = 0
|
|
390
|
+
|
|
391
|
+
# Start notify on the data channel.
|
|
392
|
+
notify_kwargs: dict[str, Any] = {}
|
|
393
|
+
notify_params = inspect.signature(self._gattClient.start_notify).parameters
|
|
394
|
+
if "bluez" in notify_params:
|
|
395
|
+
notify_kwargs["bluez"] = {"use_start_notify": True}
|
|
396
|
+
try:
|
|
397
|
+
await self._gattClient.start_notify(
|
|
398
|
+
CASA_CLASSIC_DATA_CHAR_UUID,
|
|
399
|
+
self._queueCallback,
|
|
400
|
+
**notify_kwargs,
|
|
401
|
+
)
|
|
402
|
+
except Exception as e:
|
|
403
|
+
ca52_notify_err = type(e).__name__
|
|
404
|
+
# Some firmwares may expose Classic signing on the EVO UUID instead.
|
|
405
|
+
# Fall through to auth-char probing if CA52 isn't available.
|
|
406
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
407
|
+
self._logger.debug(
|
|
408
|
+
"[CASAMBI_GATT_PROBE] start_notify ca52 fail err=%s; trying auth UUID probing.",
|
|
409
|
+
type(e).__name__,
|
|
410
|
+
exc_info=True,
|
|
411
|
+
)
|
|
412
|
+
self._protocolMode = None
|
|
413
|
+
self._dataCharUuid = None
|
|
414
|
+
self._classicConnHash8 = None
|
|
415
|
+
self._classicTxCharUuid = None
|
|
416
|
+
self._classicNotifyCharUuids.clear()
|
|
417
|
+
self._classicHeaderMode = None
|
|
418
|
+
self._classicHashSource = None
|
|
419
|
+
# continue detection below
|
|
420
|
+
else:
|
|
421
|
+
self._classicNotifyCharUuids.add(CASA_CLASSIC_DATA_CHAR_UUID.lower())
|
|
422
|
+
# Some Classic firmwares also expose state/config notifications on CA53.
|
|
423
|
+
try:
|
|
424
|
+
await self._gattClient.start_notify(
|
|
425
|
+
CASA_CLASSIC_CA53_CHAR_UUID,
|
|
426
|
+
self._queueCallback,
|
|
427
|
+
**notify_kwargs,
|
|
428
|
+
)
|
|
429
|
+
except Exception as e:
|
|
430
|
+
ca53_notify_err = type(e).__name__
|
|
431
|
+
else:
|
|
432
|
+
self._classicNotifyCharUuids.add(CASA_CLASSIC_CA53_CHAR_UUID.lower())
|
|
433
|
+
|
|
434
|
+
# Classic has no EVO-style key exchange/auth; we can send immediately.
|
|
435
|
+
self._connectionState = ConnectionState.AUTHENTICATED
|
|
436
|
+
self._logger.info("Protocol mode selected: CLASSIC")
|
|
437
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
438
|
+
self._logger.debug("[CASAMBI_GATT_PROBE] start_notify ca52 ok")
|
|
439
|
+
self._logger.debug(
|
|
440
|
+
"[CASAMBI_CLASSIC_CONN_HASH] len=%d hash=%s",
|
|
441
|
+
len(self._classicConnHash8),
|
|
442
|
+
b2a(self._classicConnHash8),
|
|
443
|
+
)
|
|
444
|
+
self._logger.warning(
|
|
445
|
+
"[CASAMBI_CLASSIC_SELECTED] address=%s variant=ca52_legacy data_uuid=%s tx_uuid=%s notify_uuids=%s header_mode=%s conn_hash8_prefix=%s",
|
|
446
|
+
self.address,
|
|
447
|
+
self._dataCharUuid,
|
|
448
|
+
self._classicTxCharUuid,
|
|
449
|
+
sorted(self._classicNotifyCharUuids) if self._classicNotifyCharUuids else None,
|
|
450
|
+
self._classicHeaderMode,
|
|
451
|
+
b2a(self._classicConnHash8),
|
|
452
|
+
)
|
|
453
|
+
self._logger.warning(
|
|
454
|
+
"[CASAMBI_CLASSIC_KEYS] visitor=%s manager=%s cloud_session_is_manager=%s",
|
|
455
|
+
self._network.classicVisitorKey() is not None,
|
|
456
|
+
self._network.classicManagerKey() is not None,
|
|
457
|
+
getattr(self._network, "isManager", lambda: False)(),
|
|
458
|
+
)
|
|
459
|
+
await self._classicEnumerateAndSubscribeGatt(notify_kwargs)
|
|
460
|
+
_log_probe_summary("CLASSIC", classic_variant="ca52_legacy")
|
|
461
|
+
# Emit a warning if we never see Classic RX frames; this is a common failure mode.
|
|
462
|
+
self._classicNoRxTask = asyncio.create_task(self._classic_no_rx_watchdog(30.0))
|
|
463
|
+
return
|
|
464
|
+
|
|
465
|
+
# Conformant devices can expose the Classic signed channel on the EVO-style UUID too.
|
|
466
|
+
first: bytes | None = None
|
|
467
|
+
try:
|
|
468
|
+
first = await self._gattClient.read_gatt_char(CASA_AUTH_CHAR_UUID)
|
|
469
|
+
auth_prefix = b2a(first[:10]) if first else None
|
|
470
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
471
|
+
self._logger.debug(
|
|
472
|
+
"[CASAMBI_GATT_PROBE] read auth ok len=%d first_byte=%s prefix=%s",
|
|
473
|
+
0 if first is None else len(first),
|
|
474
|
+
None if not first else f"0x{first[0]:02x}",
|
|
475
|
+
auth_prefix,
|
|
476
|
+
)
|
|
477
|
+
except Exception as e:
|
|
478
|
+
first = None
|
|
479
|
+
auth_err = type(e).__name__
|
|
480
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
481
|
+
self._logger.debug("[CASAMBI_GATT_PROBE] read auth fail err=%s", auth_err)
|
|
482
|
+
|
|
483
|
+
if first and len(first) >= 2 and first[0] == 0x01:
|
|
484
|
+
# EVO NodeInfo packet starts with 0x01.
|
|
485
|
+
device_nodeinfo_protocol = first[1]
|
|
486
|
+
self._deviceProtocolVersion = device_nodeinfo_protocol
|
|
487
|
+
mtu = unit = flags = None
|
|
488
|
+
nonce_prefix = None
|
|
489
|
+
if len(first) >= 23:
|
|
490
|
+
try:
|
|
491
|
+
mtu, unit, flags, nonce = struct.unpack_from(">BHH16s", first, 2)
|
|
492
|
+
nonce_prefix = b2a(nonce[:8])
|
|
493
|
+
except Exception:
|
|
494
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
495
|
+
self._logger.debug("Failed to parse NodeInfo fields for logging.", exc_info=True)
|
|
496
|
+
|
|
497
|
+
self._logger.info(
|
|
498
|
+
"[CASAMBI_EVO_NODEINFO] cloud_protocol=%s nodeinfo_b1=%s mtu=%s unit=%s flags=%s nonce_prefix=%s len=%d prefix=%s",
|
|
499
|
+
cloud_protocol,
|
|
500
|
+
device_nodeinfo_protocol,
|
|
501
|
+
mtu,
|
|
502
|
+
unit,
|
|
503
|
+
None if flags is None else f"0x{flags:04x}",
|
|
504
|
+
nonce_prefix,
|
|
505
|
+
len(first),
|
|
506
|
+
b2a(first[: min(len(first), 32)]),
|
|
507
|
+
)
|
|
508
|
+
if len(first) < 23:
|
|
509
|
+
self._logger.warning(
|
|
510
|
+
"[CASAMBI_EVO_NODEINFO_SHORT] len=%d cloud_protocol=%s nodeinfo_b1=%s prefix=%s",
|
|
511
|
+
len(first),
|
|
512
|
+
cloud_protocol,
|
|
513
|
+
device_nodeinfo_protocol,
|
|
514
|
+
b2a(first[: min(len(first), 32)]),
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
self._protocolMode = ProtocolMode.EVO
|
|
518
|
+
self._dataCharUuid = CASA_AUTH_CHAR_UUID
|
|
519
|
+
self._classicHeaderMode = None
|
|
520
|
+
self._logger.info("Protocol mode selected: EVO")
|
|
521
|
+
_log_probe_summary("EVO")
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
if first is not None:
|
|
525
|
+
# Otherwise, treat as Classic conformant: read provides connection hash.
|
|
526
|
+
if len(first) < 8:
|
|
527
|
+
raise ClassicHandshakeError(
|
|
528
|
+
f"Classic connection hash read failed/too short (len={len(first)})."
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
self._protocolMode = ProtocolMode.CLASSIC
|
|
532
|
+
self._dataCharUuid = CASA_AUTH_CHAR_UUID
|
|
533
|
+
self._classicTxCharUuid = CASA_AUTH_CHAR_UUID
|
|
534
|
+
self._classicHeaderMode = "conformant"
|
|
535
|
+
self._classicHashSource = "ca52_0001"
|
|
536
|
+
self._classicConnHash8 = bytes(first[:8])
|
|
537
|
+
|
|
538
|
+
# Parse Android's extended connection hash fields for diagnostics.
|
|
539
|
+
# Offset 8: unitId, 9: flags_lo, 10: MTU, 11: protocolVersion, 12: flags_hi
|
|
540
|
+
if len(first) >= 13:
|
|
541
|
+
ext_unit_id = first[8]
|
|
542
|
+
ext_flags_lo = first[9]
|
|
543
|
+
ext_mtu = first[10]
|
|
544
|
+
ext_proto_ver = first[11]
|
|
545
|
+
ext_flags_hi = first[12]
|
|
546
|
+
self._logger.warning(
|
|
547
|
+
"[CASAMBI_CLASSIC_CONN_HASH_EXT] variant=conformant unitId=%d flags=0x%04x mtu=%d protocolVersion=%d raw=%s",
|
|
548
|
+
ext_unit_id,
|
|
549
|
+
(ext_flags_hi << 8) | ext_flags_lo,
|
|
550
|
+
ext_mtu,
|
|
551
|
+
ext_proto_ver,
|
|
552
|
+
b2a(bytes(first[:min(len(first), 20)])),
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
self._classicCmdDiv = int.from_bytes(os.urandom(1), "big") or 1
|
|
556
|
+
self._classicTxSeq = 0
|
|
557
|
+
|
|
558
|
+
# Probe mapped Classic CA51 (0002) for diagnostics; some firmwares use it for time/config.
|
|
559
|
+
try:
|
|
560
|
+
v = await self._gattClient.read_gatt_char(CASA_CLASSIC_CONFORMANT_CA51_CHAR_UUID)
|
|
561
|
+
c0002_prefix = b2a(v[:10]) if v else None
|
|
562
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
563
|
+
self._logger.debug(
|
|
564
|
+
"[CASAMBI_GATT_PROBE] read classic-0002 ok len=%d prefix=%s",
|
|
565
|
+
0 if v is None else len(v),
|
|
566
|
+
c0002_prefix,
|
|
567
|
+
)
|
|
568
|
+
except Exception as e:
|
|
569
|
+
c0002_err = type(e).__name__
|
|
570
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
571
|
+
self._logger.debug(
|
|
572
|
+
"[CASAMBI_GATT_PROBE] read classic-0002 fail err=%s",
|
|
573
|
+
c0002_err,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
notify_kwargs: dict[str, Any] = {}
|
|
577
|
+
notify_params = inspect.signature(self._gattClient.start_notify).parameters
|
|
578
|
+
if "bluez" in notify_params:
|
|
579
|
+
notify_kwargs["bluez"] = {"use_start_notify": True}
|
|
580
|
+
try:
|
|
581
|
+
await self._gattClient.start_notify(
|
|
582
|
+
CASA_AUTH_CHAR_UUID,
|
|
583
|
+
self._queueCallback,
|
|
584
|
+
**notify_kwargs,
|
|
585
|
+
)
|
|
586
|
+
except Exception as e:
|
|
587
|
+
ca52_notify_err = type(e).__name__
|
|
588
|
+
else:
|
|
589
|
+
self._classicNotifyCharUuids.add(CASA_AUTH_CHAR_UUID.lower())
|
|
590
|
+
|
|
591
|
+
# Probe mapped Classic CA53 (0003) notify: some firmwares may emit state/config here.
|
|
592
|
+
try:
|
|
593
|
+
await self._gattClient.start_notify(
|
|
594
|
+
CASA_CLASSIC_CONFORMANT_CA53_CHAR_UUID,
|
|
595
|
+
self._queueCallback,
|
|
596
|
+
**notify_kwargs,
|
|
597
|
+
)
|
|
598
|
+
except Exception as e:
|
|
599
|
+
c0003_notify_err = type(e).__name__
|
|
600
|
+
else:
|
|
601
|
+
self._classicNotifyCharUuids.add(CASA_CLASSIC_CONFORMANT_CA53_CHAR_UUID.lower())
|
|
602
|
+
|
|
603
|
+
self._connectionState = ConnectionState.AUTHENTICATED
|
|
604
|
+
self._logger.info("Protocol mode selected: CLASSIC")
|
|
605
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
606
|
+
if ca52_notify_err is None:
|
|
607
|
+
self._logger.debug("[CASAMBI_GATT_PROBE] start_notify auth ok (classic conformant)")
|
|
608
|
+
else:
|
|
609
|
+
self._logger.debug(
|
|
610
|
+
"[CASAMBI_GATT_PROBE] start_notify auth fail err=%s (classic conformant)",
|
|
611
|
+
ca52_notify_err,
|
|
612
|
+
)
|
|
613
|
+
self._logger.debug(
|
|
614
|
+
"[CASAMBI_CLASSIC_CONN_HASH] len=%d hash=%s",
|
|
615
|
+
len(self._classicConnHash8),
|
|
616
|
+
b2a(self._classicConnHash8),
|
|
617
|
+
)
|
|
618
|
+
self._logger.warning(
|
|
619
|
+
"[CASAMBI_CLASSIC_SELECTED] address=%s variant=auth_uuid_conformant data_uuid=%s tx_uuid=%s notify_uuids=%s header_mode=%s conn_hash8_prefix=%s",
|
|
620
|
+
self.address,
|
|
621
|
+
self._dataCharUuid,
|
|
622
|
+
self._classicTxCharUuid,
|
|
623
|
+
sorted(self._classicNotifyCharUuids) if self._classicNotifyCharUuids else None,
|
|
624
|
+
self._classicHeaderMode,
|
|
625
|
+
b2a(self._classicConnHash8),
|
|
626
|
+
)
|
|
627
|
+
self._logger.warning(
|
|
628
|
+
"[CASAMBI_CLASSIC_KEYS] visitor=%s manager=%s cloud_session_is_manager=%s",
|
|
629
|
+
self._network.classicVisitorKey() is not None,
|
|
630
|
+
self._network.classicManagerKey() is not None,
|
|
631
|
+
getattr(self._network, "isManager", lambda: False)(),
|
|
632
|
+
)
|
|
633
|
+
await self._classicEnumerateAndSubscribeGatt(notify_kwargs)
|
|
634
|
+
_log_probe_summary("CLASSIC", classic_variant="auth_uuid_conformant")
|
|
635
|
+
self._classicNoRxTask = asyncio.create_task(self._classic_no_rx_watchdog(30.0))
|
|
636
|
+
return
|
|
637
|
+
|
|
638
|
+
_log_probe_summary("UNKNOWN")
|
|
639
|
+
raise ProtocolError(
|
|
640
|
+
"No supported Casambi characteristics found (Classic ca51/ca52 or EVO/Classic-conformant auth char)."
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
async def _classic_no_rx_watchdog(self, after_s: float) -> None:
|
|
644
|
+
"""Emit one high-signal log if Classic RX stays silent after connect.
|
|
645
|
+
|
|
646
|
+
This helps testers capture actionable logs when Classic control/updates don't work yet.
|
|
647
|
+
"""
|
|
648
|
+
try:
|
|
649
|
+
await asyncio.sleep(after_s)
|
|
650
|
+
if self._protocolMode != ProtocolMode.CLASSIC:
|
|
651
|
+
return
|
|
652
|
+
if self._classicFirstRxTs is not None:
|
|
653
|
+
return
|
|
654
|
+
|
|
655
|
+
self._logger.warning(
|
|
656
|
+
"[CASAMBI_CLASSIC_NO_RX] after_s=%s notify_uuids=%s tx_uuid=%s header_mode=%s "
|
|
657
|
+
"conn_hash8_prefix=%s visitor=%s manager=%s cloud_session_is_manager=%s",
|
|
658
|
+
after_s,
|
|
659
|
+
sorted(self._classicNotifyCharUuids) if self._classicNotifyCharUuids else None,
|
|
660
|
+
self._classicTxCharUuid,
|
|
661
|
+
self._classicHeaderMode,
|
|
662
|
+
None if self._classicConnHash8 is None else b2a(self._classicConnHash8),
|
|
663
|
+
self._network.classicVisitorKey() is not None,
|
|
664
|
+
self._network.classicManagerKey() is not None,
|
|
665
|
+
getattr(self._network, "isManager", lambda: False)(),
|
|
666
|
+
)
|
|
667
|
+
except asyncio.CancelledError:
|
|
668
|
+
return
|
|
669
|
+
except Exception:
|
|
670
|
+
# Never fail the connection because of diagnostics.
|
|
671
|
+
self._logger.debug("Classic no-RX watchdog failed.", exc_info=True)
|
|
672
|
+
|
|
149
673
|
def _on_disconnect(self, client: BleakClient) -> None:
|
|
150
674
|
if self._connectionState != ConnectionState.NONE:
|
|
151
675
|
self._logger.info(f"Received disconnect callback from {self.address}")
|
|
152
676
|
if self._connectionState == ConnectionState.AUTHENTICATED:
|
|
153
677
|
self._logger.debug("Executing disconnect callback.")
|
|
154
678
|
self._disconnectedCallback()
|
|
679
|
+
if self._classicNoRxTask is not None:
|
|
680
|
+
self._classicNoRxTask.cancel()
|
|
681
|
+
self._classicNoRxTask = None
|
|
155
682
|
self._connectionState = ConnectionState.NONE
|
|
156
683
|
|
|
157
684
|
async def exchangeKey(self) -> None:
|
|
@@ -163,15 +690,38 @@ class CasambiClient:
|
|
|
163
690
|
try:
|
|
164
691
|
# Initiate communication with device
|
|
165
692
|
firstResp = await self._gattClient.read_gatt_char(CASA_AUTH_CHAR_UUID)
|
|
166
|
-
self._logger.
|
|
693
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
694
|
+
self._logger.debug(
|
|
695
|
+
"[CASAMBI_EVO_NODEINFO_RAW] len=%d prefix=%s",
|
|
696
|
+
len(firstResp),
|
|
697
|
+
b2a(firstResp[: min(len(firstResp), 32)]),
|
|
698
|
+
)
|
|
167
699
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
):
|
|
700
|
+
cloud_protocol = getattr(self._network, "protocolVersion", None)
|
|
701
|
+
|
|
702
|
+
# EVO key exchange expects the NodeInfo packet (0x01 ...).
|
|
703
|
+
if len(firstResp) < 2 or firstResp[0] != 0x01:
|
|
172
704
|
self._logger.error(
|
|
173
|
-
"
|
|
705
|
+
"[CASAMBI_EVO_NODEINFO_UNEXPECTED] expected_prefix=01 len=%d prefix=%s",
|
|
706
|
+
len(firstResp),
|
|
707
|
+
b2a(firstResp[: min(len(firstResp), 32)]),
|
|
174
708
|
)
|
|
709
|
+
raise ProtocolError("Unexpected NodeInfo response while starting key exchange.")
|
|
710
|
+
|
|
711
|
+
device_protocol = firstResp[1]
|
|
712
|
+
self._deviceProtocolVersion = device_protocol
|
|
713
|
+
# Do not interpret NodeInfo byte1 as "cloud protocolVersion".
|
|
714
|
+
# Some firmwares use a different numbering scheme, so mismatch warnings are misleading.
|
|
715
|
+
|
|
716
|
+
if len(firstResp) < 23:
|
|
717
|
+
self._logger.error(
|
|
718
|
+
"[CASAMBI_EVO_NODEINFO_SHORT] len=%d cloud_protocol=%s nodeinfo_b1=%s prefix=%s",
|
|
719
|
+
len(firstResp),
|
|
720
|
+
cloud_protocol,
|
|
721
|
+
device_protocol,
|
|
722
|
+
b2a(firstResp[: min(len(firstResp), 32)]),
|
|
723
|
+
)
|
|
724
|
+
raise ProtocolError("NodeInfo response too short while starting key exchange.")
|
|
175
725
|
|
|
176
726
|
# Parse device info
|
|
177
727
|
self._mtu, self._unit, self._flags, self._nonce = struct.unpack_from(
|
|
@@ -183,8 +733,15 @@ class CasambiClient:
|
|
|
183
733
|
|
|
184
734
|
# Device will initiate key exchange, so listen for that
|
|
185
735
|
self._logger.debug("Starting notify")
|
|
736
|
+
notify_kwargs: dict[str, Any] = {}
|
|
737
|
+
notify_params = inspect.signature(self._gattClient.start_notify).parameters
|
|
738
|
+
if "bluez" in notify_params:
|
|
739
|
+
notify_kwargs["bluez"] = {"use_start_notify": True}
|
|
740
|
+
|
|
186
741
|
await self._gattClient.start_notify(
|
|
187
|
-
CASA_AUTH_CHAR_UUID,
|
|
742
|
+
CASA_AUTH_CHAR_UUID,
|
|
743
|
+
self._queueCallback,
|
|
744
|
+
**notify_kwargs,
|
|
188
745
|
)
|
|
189
746
|
finally:
|
|
190
747
|
self._activityLock.release()
|
|
@@ -233,24 +790,36 @@ class CasambiClient:
|
|
|
233
790
|
self._callbackQueue.put_nowait((handle, data))
|
|
234
791
|
|
|
235
792
|
async def _processCallbacks(self) -> None:
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
793
|
+
try:
|
|
794
|
+
while True:
|
|
795
|
+
handle, data = await self._callbackQueue.get()
|
|
796
|
+
|
|
797
|
+
# Try to loose any races here.
|
|
798
|
+
# Otherwise a state change caused by the last packet might not have been handled yet
|
|
799
|
+
await asyncio.sleep(0.001)
|
|
800
|
+
await self._activityLock.acquire()
|
|
801
|
+
try:
|
|
802
|
+
self._callbackMulitplexer(handle, data)
|
|
803
|
+
except Exception:
|
|
804
|
+
self._logger.warning(
|
|
805
|
+
"[CASAMBI_CALLBACK_ERROR] unhandled exception in callback multiplexer",
|
|
806
|
+
exc_info=True,
|
|
807
|
+
)
|
|
808
|
+
finally:
|
|
809
|
+
self._callbackQueue.task_done()
|
|
810
|
+
self._activityLock.release()
|
|
811
|
+
except asyncio.CancelledError:
|
|
812
|
+
# Task cancelled during shutdown; log at debug and exit cleanly.
|
|
813
|
+
self._logger.debug("Callback processing task cancelled during shutdown.")
|
|
814
|
+
raise
|
|
248
815
|
|
|
249
816
|
def _callbackMulitplexer(
|
|
250
817
|
self, handle: BleakGATTCharacteristic, data: bytes
|
|
251
818
|
) -> None:
|
|
252
|
-
self._logger.
|
|
253
|
-
|
|
819
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
820
|
+
self._logger.debug(
|
|
821
|
+
"[CASAMBI_MUX] state=%s len=%d", self._connectionState, len(data)
|
|
822
|
+
)
|
|
254
823
|
if self._connectionState == ConnectionState.CONNECTED:
|
|
255
824
|
self._exchNofityCallback(handle, data)
|
|
256
825
|
elif self._connectionState == ConnectionState.KEY_EXCHANGED:
|
|
@@ -389,6 +958,12 @@ class CasambiClient:
|
|
|
389
958
|
return self._nonce[:4] + id + self._nonce[8:]
|
|
390
959
|
|
|
391
960
|
async def send(self, packet: bytes) -> None:
|
|
961
|
+
# EVO sends INVOCATION operations (packet type=0x07) inside the encrypted channel.
|
|
962
|
+
# Classic sends signed command frames on the CA52 channel.
|
|
963
|
+
if self._protocolMode == ProtocolMode.CLASSIC:
|
|
964
|
+
await self._sendClassic(packet)
|
|
965
|
+
return
|
|
966
|
+
|
|
392
967
|
self._checkState(ConnectionState.AUTHENTICATED)
|
|
393
968
|
|
|
394
969
|
await self._activityLock.acquire()
|
|
@@ -409,15 +984,554 @@ class CasambiClient:
|
|
|
409
984
|
finally:
|
|
410
985
|
self._activityLock.release()
|
|
411
986
|
|
|
987
|
+
def _classic_next_seq(self) -> int:
|
|
988
|
+
# 16-bit sequence inserted in the header (big endian) and included in CMAC input.
|
|
989
|
+
self._classicTxSeq = (self._classicTxSeq + 1) & 0xFFFF
|
|
990
|
+
if self._classicTxSeq == 0:
|
|
991
|
+
self._classicTxSeq = 1
|
|
992
|
+
return self._classicTxSeq
|
|
993
|
+
|
|
994
|
+
def _classic_next_div(self) -> int:
|
|
995
|
+
# 8-bit command divider/id. Android uses a random start and increments 1..255.
|
|
996
|
+
self._classicCmdDiv += 1
|
|
997
|
+
if self._classicCmdDiv == 0 or self._classicCmdDiv > 255:
|
|
998
|
+
self._classicCmdDiv = 1
|
|
999
|
+
return self._classicCmdDiv
|
|
1000
|
+
|
|
1001
|
+
def buildClassicCommand(
|
|
1002
|
+
self,
|
|
1003
|
+
command_ordinal: int,
|
|
1004
|
+
payload: bytes,
|
|
1005
|
+
*,
|
|
1006
|
+
target_id: int | None = None,
|
|
1007
|
+
lifetime: int = 200,
|
|
1008
|
+
div: int | None = None,
|
|
1009
|
+
) -> bytes:
|
|
1010
|
+
"""Build one Classic command record (u1.C1753e export format).
|
|
1011
|
+
|
|
1012
|
+
This is the message that follows the Classic signed header and 16-bit sequence.
|
|
1013
|
+
"""
|
|
1014
|
+
if div is None:
|
|
1015
|
+
div = self._classic_next_div()
|
|
1016
|
+
if div < 0 or div > 255:
|
|
1017
|
+
raise ValueError("div must fit in one byte")
|
|
1018
|
+
if lifetime < 0 or lifetime > 255:
|
|
1019
|
+
raise ValueError("lifetime must fit in one byte")
|
|
1020
|
+
if target_id is not None and (target_id < 0 or target_id > 255):
|
|
1021
|
+
raise ValueError("target_id must fit in one byte")
|
|
1022
|
+
|
|
1023
|
+
# Two leading bytes are patched after we know the final length:
|
|
1024
|
+
# - byte0 = (len + 239) mod 256
|
|
1025
|
+
# - byte1 = ordinal | 0x40 (div present) | 0x80 (target present)
|
|
1026
|
+
b = bytearray()
|
|
1027
|
+
b.append(0)
|
|
1028
|
+
b.append(0)
|
|
1029
|
+
|
|
1030
|
+
type_flags = command_ordinal & 0x3F
|
|
1031
|
+
|
|
1032
|
+
# div present
|
|
1033
|
+
b.append(div & 0xFF)
|
|
1034
|
+
type_flags |= 0x40
|
|
1035
|
+
|
|
1036
|
+
if target_id is not None and target_id > 0:
|
|
1037
|
+
b.append(target_id & 0xFF)
|
|
1038
|
+
type_flags |= 0x80
|
|
1039
|
+
|
|
1040
|
+
b.append(lifetime & 0xFF)
|
|
1041
|
+
b.extend(payload)
|
|
1042
|
+
|
|
1043
|
+
msg_len = len(b)
|
|
1044
|
+
b[0] = (msg_len + 239) & 0xFF
|
|
1045
|
+
b[1] = type_flags & 0xFF
|
|
1046
|
+
|
|
1047
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
1048
|
+
self._logger.debug(
|
|
1049
|
+
"[CASAMBI_CLASSIC_CMD_BUILD] ord=%d target=%s div=%d lifetime=%d len=%d payload=%s",
|
|
1050
|
+
command_ordinal,
|
|
1051
|
+
target_id,
|
|
1052
|
+
div,
|
|
1053
|
+
lifetime,
|
|
1054
|
+
msg_len,
|
|
1055
|
+
b2a(payload),
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
return bytes(b)
|
|
1059
|
+
|
|
1060
|
+
def buildClassicCommandSimple(
|
|
1061
|
+
self,
|
|
1062
|
+
unit_id: int,
|
|
1063
|
+
dimmer: int,
|
|
1064
|
+
extra: int | None = None,
|
|
1065
|
+
) -> bytes:
|
|
1066
|
+
"""Build a Classic command using the simple format from BLE captures.
|
|
1067
|
+
|
|
1068
|
+
This alternative format was observed in real BLE captures and differs from
|
|
1069
|
+
the Android u1.C1753e command record format. Use with env variable
|
|
1070
|
+
CASAMBI_BT_CLASSIC_FORMAT=simple to experiment.
|
|
1071
|
+
|
|
1072
|
+
Format (before header added by _sendClassic):
|
|
1073
|
+
[counter:1][unit_id:1][param_len:1][dimmer:1][extra:1?]
|
|
1074
|
+
|
|
1075
|
+
The header (added by _sendClassic) is:
|
|
1076
|
+
- Conformant: [auth:1][cmac:4|16][seq:2]
|
|
1077
|
+
- Legacy: [cmac:4]
|
|
1078
|
+
|
|
1079
|
+
Args:
|
|
1080
|
+
unit_id: Target unit ID (0-255, use 0xFF for "all units")
|
|
1081
|
+
dimmer: Dimmer/level value (0-255)
|
|
1082
|
+
extra: Optional extra parameter (e.g., temperature/vertical value)
|
|
1083
|
+
|
|
1084
|
+
Returns:
|
|
1085
|
+
Command bytes to pass to _sendClassic
|
|
1086
|
+
"""
|
|
1087
|
+
counter = self._classic_next_div()
|
|
1088
|
+
if extra is not None:
|
|
1089
|
+
return bytes([counter, unit_id & 0xFF, 2, dimmer & 0xFF, extra & 0xFF])
|
|
1090
|
+
else:
|
|
1091
|
+
return bytes([counter, unit_id & 0xFF, 1, dimmer & 0xFF])
|
|
1092
|
+
|
|
1093
|
+
async def _sendClassic(
|
|
1094
|
+
self,
|
|
1095
|
+
command_bytes: bytes,
|
|
1096
|
+
*,
|
|
1097
|
+
target_uuid: str | None = None,
|
|
1098
|
+
key_preference: Literal["auto", "visitor", "manager"] = "auto",
|
|
1099
|
+
response: bool | None = None,
|
|
1100
|
+
) -> None:
|
|
1101
|
+
self._checkState(ConnectionState.AUTHENTICATED)
|
|
1102
|
+
if self._protocolMode != ProtocolMode.CLASSIC:
|
|
1103
|
+
raise ProtocolError("Classic send called while not in Classic protocol mode.")
|
|
1104
|
+
tx_uuid = target_uuid or self._classicTxCharUuid or self._dataCharUuid
|
|
1105
|
+
if not tx_uuid:
|
|
1106
|
+
raise ProtocolError("Classic TX characteristic UUID not set.")
|
|
1107
|
+
if self._classicConnHash8 is None:
|
|
1108
|
+
raise ClassicHandshakeError("Classic connection hash not available.")
|
|
1109
|
+
|
|
1110
|
+
visitor_key = self._network.classicVisitorKey()
|
|
1111
|
+
manager_key = self._network.classicManagerKey()
|
|
1112
|
+
|
|
1113
|
+
# Parse the command record for logs (u1.C1753e export format).
|
|
1114
|
+
cmd_ordinal: int | None = None
|
|
1115
|
+
cmd_div: int | None = None
|
|
1116
|
+
cmd_target: int | None = None
|
|
1117
|
+
cmd_lifetime: int | None = None
|
|
1118
|
+
cmd_payload_len: int | None = None
|
|
1119
|
+
try:
|
|
1120
|
+
if len(command_bytes) >= 2:
|
|
1121
|
+
typ = command_bytes[1]
|
|
1122
|
+
cmd_ordinal = typ & 0x3F
|
|
1123
|
+
has_div = (typ & 0x40) != 0
|
|
1124
|
+
has_target = (typ & 0x80) != 0
|
|
1125
|
+
p = 2
|
|
1126
|
+
if has_div and p < len(command_bytes):
|
|
1127
|
+
cmd_div = command_bytes[p]
|
|
1128
|
+
p += 1
|
|
1129
|
+
if has_target and p < len(command_bytes):
|
|
1130
|
+
cmd_target = command_bytes[p]
|
|
1131
|
+
p += 1
|
|
1132
|
+
if p < len(command_bytes):
|
|
1133
|
+
cmd_lifetime = command_bytes[p]
|
|
1134
|
+
p += 1
|
|
1135
|
+
if p <= len(command_bytes):
|
|
1136
|
+
cmd_payload_len = len(command_bytes) - p
|
|
1137
|
+
except Exception:
|
|
1138
|
+
# If parsing fails, keep fields as None.
|
|
1139
|
+
pass
|
|
1140
|
+
|
|
1141
|
+
# Classic key selection:
|
|
1142
|
+
#
|
|
1143
|
+
# Android (v3.16) explicitly uses "visitor" signing (auth_level=2 / 4-byte sig)
|
|
1144
|
+
# for the Classic enable-notify bootstrap packets (sendVersion + sendTime), even
|
|
1145
|
+
# when a managerKey exists.
|
|
1146
|
+
#
|
|
1147
|
+
# For normal commands we keep the historical behavior ("auto" == prefer manager
|
|
1148
|
+
# when the cloud session is manager), but allow overrides so init can match Android.
|
|
1149
|
+
key_name = "none"
|
|
1150
|
+
auth_level = 0x02
|
|
1151
|
+
key: bytes | None = None
|
|
1152
|
+
|
|
1153
|
+
if key_preference == "visitor":
|
|
1154
|
+
if visitor_key is not None:
|
|
1155
|
+
key_name, auth_level, key = "visitor", 0x02, visitor_key
|
|
1156
|
+
elif manager_key is not None:
|
|
1157
|
+
# Fallback: some networks have managerKey only.
|
|
1158
|
+
key_name, auth_level, key = "manager", 0x03, manager_key
|
|
1159
|
+
else:
|
|
1160
|
+
key_name, auth_level, key = "none", 0x02, None
|
|
1161
|
+
elif key_preference == "manager":
|
|
1162
|
+
if manager_key is not None:
|
|
1163
|
+
key_name, auth_level, key = "manager", 0x03, manager_key
|
|
1164
|
+
elif visitor_key is not None:
|
|
1165
|
+
key_name, auth_level, key = "visitor", 0x02, visitor_key
|
|
1166
|
+
else:
|
|
1167
|
+
key_name, auth_level, key = "none", 0x03, None
|
|
1168
|
+
else:
|
|
1169
|
+
# "auto" (legacy behavior)
|
|
1170
|
+
if manager_key is not None and getattr(self._network, "isManager", lambda: False)():
|
|
1171
|
+
key_name, auth_level, key = "manager", 0x03, manager_key
|
|
1172
|
+
elif visitor_key is not None:
|
|
1173
|
+
key_name, auth_level, key = "visitor", 0x02, visitor_key
|
|
1174
|
+
elif manager_key is not None:
|
|
1175
|
+
key_name, auth_level, key = "manager", 0x03, manager_key
|
|
1176
|
+
|
|
1177
|
+
header_mode = self._classicHeaderMode or "conformant"
|
|
1178
|
+
|
|
1179
|
+
seq: int | None = None
|
|
1180
|
+
sig_len: int
|
|
1181
|
+
pkt = bytearray()
|
|
1182
|
+
|
|
1183
|
+
if header_mode == "conformant":
|
|
1184
|
+
sig_len = 16 if auth_level == 0x03 else 4
|
|
1185
|
+
seq = self._classic_next_seq()
|
|
1186
|
+
|
|
1187
|
+
# Header layout (rVar.Z=true / "conformant" classic):
|
|
1188
|
+
# [0] auth_level (2 visitor / 3 manager)
|
|
1189
|
+
# [1..sig_len] CMAC prefix placeholder (filled after CMAC computation)
|
|
1190
|
+
# [1+sig_len .. 1+sig_len+1] 16-bit sequence, big endian (included in CMAC input)
|
|
1191
|
+
# [..] command bytes
|
|
1192
|
+
pkt.append(auth_level)
|
|
1193
|
+
pkt.extend(b"\x00" * sig_len)
|
|
1194
|
+
pkt.extend(b"\x00\x00")
|
|
1195
|
+
pkt.extend(command_bytes)
|
|
1196
|
+
|
|
1197
|
+
seq_off = 1 + sig_len
|
|
1198
|
+
pkt[seq_off] = (seq >> 8) & 0xFF
|
|
1199
|
+
pkt[seq_off + 1] = seq & 0xFF
|
|
1200
|
+
|
|
1201
|
+
if key is not None:
|
|
1202
|
+
cmac_input = bytes(pkt[seq_off:]) # includes seq + command bytes
|
|
1203
|
+
prefix = classic_cmac_prefix(key, self._classicConnHash8, cmac_input, sig_len)
|
|
1204
|
+
pkt[1 : 1 + sig_len] = prefix
|
|
1205
|
+
|
|
1206
|
+
elif header_mode == "legacy":
|
|
1207
|
+
# Legacy/non-conformant classic: only a 4-byte CMAC prefix, no auth byte, no seq.
|
|
1208
|
+
sig_len = 4
|
|
1209
|
+
pkt.extend(b"\x00" * sig_len)
|
|
1210
|
+
pkt.extend(command_bytes)
|
|
1211
|
+
|
|
1212
|
+
if key is not None:
|
|
1213
|
+
cmac_input = bytes(command_bytes)
|
|
1214
|
+
prefix = classic_cmac_prefix(key, self._classicConnHash8, cmac_input, sig_len)
|
|
1215
|
+
pkt[0:sig_len] = prefix
|
|
1216
|
+
else:
|
|
1217
|
+
raise ProtocolError(f"Unknown Classic header mode: {header_mode}")
|
|
1218
|
+
|
|
1219
|
+
signed = key is not None
|
|
1220
|
+
if not signed and self._logLimiter.allow("classic_tx_unsigned", burst=10, window_s=300.0):
|
|
1221
|
+
self._logger.warning(
|
|
1222
|
+
"[CASAMBI_CLASSIC_TX_UNSIGNED] reason=keys_missing visitor=%s manager=%s",
|
|
1223
|
+
visitor_key is not None,
|
|
1224
|
+
manager_key is not None,
|
|
1225
|
+
)
|
|
1226
|
+
|
|
1227
|
+
# WARNING-level TX logs are intentional: they are needed for Classic reverse engineering.
|
|
1228
|
+
# Keep payload logging minimal (prefix only).
|
|
1229
|
+
if self._logLimiter.allow("classic_tx", burst=50, window_s=60.0):
|
|
1230
|
+
auth_str = f"0x{auth_level:02x}" if header_mode == "conformant" else None
|
|
1231
|
+
self._logger.warning(
|
|
1232
|
+
"[CASAMBI_CLASSIC_TX] header=%s key=%s signed=%s tx_uuid=%s auth=%s sig_len=%d seq=%s "
|
|
1233
|
+
"cmd_len=%d cmd_ord=%s target=%s div=%s lifetime=%s payload_len=%s "
|
|
1234
|
+
"total_len=%d prefix=%s",
|
|
1235
|
+
header_mode,
|
|
1236
|
+
key_name,
|
|
1237
|
+
signed,
|
|
1238
|
+
tx_uuid,
|
|
1239
|
+
auth_str,
|
|
1240
|
+
sig_len,
|
|
1241
|
+
None if seq is None else f"0x{seq:04x}",
|
|
1242
|
+
len(command_bytes),
|
|
1243
|
+
cmd_ordinal,
|
|
1244
|
+
cmd_target,
|
|
1245
|
+
cmd_div,
|
|
1246
|
+
cmd_lifetime,
|
|
1247
|
+
cmd_payload_len,
|
|
1248
|
+
len(pkt),
|
|
1249
|
+
b2a(bytes(pkt[: min(len(pkt), 24)])),
|
|
1250
|
+
)
|
|
1251
|
+
|
|
1252
|
+
# Android uses WRITE_TYPE_NO_RESPONSE (1) for version/state writes (n0 path)
|
|
1253
|
+
# and WRITE_TYPE_DEFAULT (2) = with-response for time-sync (X path).
|
|
1254
|
+
# If caller didn't specify, default to True for backward compatibility
|
|
1255
|
+
# (also needed for long writes with 16-byte manager signature).
|
|
1256
|
+
use_response = response if response is not None else True
|
|
1257
|
+
tx_result = "pending"
|
|
1258
|
+
try:
|
|
1259
|
+
await self._gattClient.write_gatt_char(tx_uuid, bytes(pkt), response=use_response)
|
|
1260
|
+
tx_result = "ok"
|
|
1261
|
+
except Exception as e:
|
|
1262
|
+
tx_result = f"error: {type(e).__name__}: {e}"
|
|
1263
|
+
raise
|
|
1264
|
+
finally:
|
|
1265
|
+
# Record TX in diagnostic history
|
|
1266
|
+
tx_entry = {
|
|
1267
|
+
"timestamp": time.monotonic(),
|
|
1268
|
+
"header_mode": header_mode,
|
|
1269
|
+
"key": key_name,
|
|
1270
|
+
"signed": signed,
|
|
1271
|
+
"tx_uuid": tx_uuid,
|
|
1272
|
+
"auth_level": auth_level if header_mode == "conformant" else None,
|
|
1273
|
+
"sig_len": sig_len,
|
|
1274
|
+
"seq": seq,
|
|
1275
|
+
"cmd_ordinal": cmd_ordinal,
|
|
1276
|
+
"cmd_target": cmd_target,
|
|
1277
|
+
"cmd_div": cmd_div,
|
|
1278
|
+
"cmd_lifetime": cmd_lifetime,
|
|
1279
|
+
"cmd_payload_len": cmd_payload_len,
|
|
1280
|
+
"total_len": len(pkt),
|
|
1281
|
+
"pre_sign_hex": b2a(command_bytes).decode("ascii"),
|
|
1282
|
+
"post_sign_hex": b2a(bytes(pkt)).decode("ascii"),
|
|
1283
|
+
"result": tx_result,
|
|
1284
|
+
}
|
|
1285
|
+
self._classicTxHistory.append(tx_entry)
|
|
1286
|
+
if len(self._classicTxHistory) > self._classicDiagMaxHistory:
|
|
1287
|
+
self._classicTxHistory = self._classicTxHistory[-self._classicDiagMaxHistory:]
|
|
1288
|
+
|
|
1289
|
+
# Enhanced TX diagnostic log
|
|
1290
|
+
self._logger.warning(
|
|
1291
|
+
"[CLASSIC_DIAG_TX_RESULT] result=%s header=%s seq=%s total_len=%d",
|
|
1292
|
+
tx_result,
|
|
1293
|
+
header_mode,
|
|
1294
|
+
None if seq is None else f"0x{seq:04x}",
|
|
1295
|
+
len(pkt),
|
|
1296
|
+
)
|
|
1297
|
+
|
|
1298
|
+
async def _classicEnumerateAndSubscribeGatt(
|
|
1299
|
+
self, notify_kwargs: dict[str, Any]
|
|
1300
|
+
) -> None:
|
|
1301
|
+
"""Enumerate all GATT characteristics and subscribe to any notifiable ones.
|
|
1302
|
+
|
|
1303
|
+
This discovers characteristics beyond the manually-probed CA51/CA52/CA53
|
|
1304
|
+
UUIDs and subscribes to any that support notify or indicate, which may be
|
|
1305
|
+
needed for receiving Classic state/config notifications.
|
|
1306
|
+
"""
|
|
1307
|
+
try:
|
|
1308
|
+
total_chars = 0
|
|
1309
|
+
for svc in self._gattClient.services:
|
|
1310
|
+
for char in svc.characteristics:
|
|
1311
|
+
total_chars += 1
|
|
1312
|
+
char_uuid = str(char.uuid).lower()
|
|
1313
|
+
props = char.properties
|
|
1314
|
+
self._logger.warning(
|
|
1315
|
+
"[CASAMBI_CLASSIC_GATT_CHAR] uuid=%s props=%s handle=%d",
|
|
1316
|
+
char_uuid,
|
|
1317
|
+
props,
|
|
1318
|
+
char.handle,
|
|
1319
|
+
)
|
|
1320
|
+
if char_uuid not in self._classicNotifyCharUuids:
|
|
1321
|
+
if "notify" in props or "indicate" in props:
|
|
1322
|
+
try:
|
|
1323
|
+
await self._gattClient.start_notify(
|
|
1324
|
+
char.uuid,
|
|
1325
|
+
self._queueCallback,
|
|
1326
|
+
**notify_kwargs,
|
|
1327
|
+
)
|
|
1328
|
+
self._classicNotifyCharUuids.add(char_uuid)
|
|
1329
|
+
self._logger.warning(
|
|
1330
|
+
"[CASAMBI_CLASSIC_GATT_SUB] subscribed uuid=%s",
|
|
1331
|
+
char_uuid,
|
|
1332
|
+
)
|
|
1333
|
+
except Exception as e:
|
|
1334
|
+
self._logger.warning(
|
|
1335
|
+
"[CASAMBI_CLASSIC_GATT_SUB] failed uuid=%s err=%s",
|
|
1336
|
+
char_uuid,
|
|
1337
|
+
type(e).__name__,
|
|
1338
|
+
)
|
|
1339
|
+
self._logger.warning(
|
|
1340
|
+
"[CASAMBI_CLASSIC_GATT_ENUM] total_chars=%d subscribed_uuids=%s",
|
|
1341
|
+
total_chars,
|
|
1342
|
+
sorted(self._classicNotifyCharUuids),
|
|
1343
|
+
)
|
|
1344
|
+
except Exception as e:
|
|
1345
|
+
self._logger.warning(
|
|
1346
|
+
"[CASAMBI_CLASSIC_GATT_ENUM] services enumeration unavailable: %s",
|
|
1347
|
+
type(e).__name__,
|
|
1348
|
+
)
|
|
1349
|
+
|
|
1350
|
+
async def classicSendInit(self) -> None:
|
|
1351
|
+
"""Send Classic post-connection initialization (version + time-sync).
|
|
1352
|
+
|
|
1353
|
+
Ground truth (casambi-android v3.16):
|
|
1354
|
+
- Enable notify on CA52/0001 (CCCD) (handled by Bleak start_notify)
|
|
1355
|
+
- Send "version" on CA52/0001: bytes [0,1,11]
|
|
1356
|
+
(`Z0/AbstractC0151u.h0()` in Android)
|
|
1357
|
+
- Then send time-sync on CA51/0002 (cmd 10 legacy / 7 conformant)
|
|
1358
|
+
(`Z0/AbstractC0142k.X()` in Android)
|
|
1359
|
+
|
|
1360
|
+
Classic often stays silent until this bootstrap is completed, so we do it
|
|
1361
|
+
right after the BLE connection is established.
|
|
1362
|
+
|
|
1363
|
+
The payload is sent raw via _sendClassic (NOT wrapped in buildClassicCommand).
|
|
1364
|
+
"""
|
|
1365
|
+
self._checkState(ConnectionState.AUTHENTICATED)
|
|
1366
|
+
if self._protocolMode != ProtocolMode.CLASSIC:
|
|
1367
|
+
return
|
|
1368
|
+
|
|
1369
|
+
# Ensure notify setup has a moment to settle (Android delays ~100ms after CCCD write).
|
|
1370
|
+
await asyncio.sleep(0.1)
|
|
1371
|
+
|
|
1372
|
+
# 1) Send Classic "version" packet on CA52/0001.
|
|
1373
|
+
version_uuid = self._classicTxCharUuid or self._dataCharUuid
|
|
1374
|
+
if version_uuid:
|
|
1375
|
+
try:
|
|
1376
|
+
self._logger.warning(
|
|
1377
|
+
"[CASAMBI_CLASSIC_INIT] sending version len=3 target_uuid=%s header_mode=%s",
|
|
1378
|
+
version_uuid,
|
|
1379
|
+
self._classicHeaderMode,
|
|
1380
|
+
)
|
|
1381
|
+
await self._sendClassic(
|
|
1382
|
+
b"\x00\x01\x0b",
|
|
1383
|
+
target_uuid=version_uuid,
|
|
1384
|
+
# Android uses visitor auth for this bootstrap packet.
|
|
1385
|
+
key_preference="visitor",
|
|
1386
|
+
# Android n0():998 uses WRITE_TYPE_NO_RESPONSE for classic.
|
|
1387
|
+
response=False,
|
|
1388
|
+
)
|
|
1389
|
+
self._logger.warning("[CASAMBI_CLASSIC_INIT] version sent successfully")
|
|
1390
|
+
except Exception:
|
|
1391
|
+
self._logger.warning(
|
|
1392
|
+
"[CASAMBI_CLASSIC_INIT] version send failed (continuing with time-sync)",
|
|
1393
|
+
exc_info=True,
|
|
1394
|
+
)
|
|
1395
|
+
|
|
1396
|
+
import datetime as _dt
|
|
1397
|
+
|
|
1398
|
+
now = _dt.datetime.now()
|
|
1399
|
+
|
|
1400
|
+
# Timezone offset in minutes from UTC.
|
|
1401
|
+
local_tz = _dt.datetime.now(_dt.timezone.utc).astimezone().tzinfo
|
|
1402
|
+
utc_offset_minutes = 0
|
|
1403
|
+
if local_tz is not None:
|
|
1404
|
+
offset = local_tz.utcoffset(now)
|
|
1405
|
+
if offset is not None:
|
|
1406
|
+
utc_offset_minutes = int(offset.total_seconds()) // 60
|
|
1407
|
+
|
|
1408
|
+
# Determine time-sync target and command byte per Android AbstractC1717h.X():
|
|
1409
|
+
# - Non-conformant: write to CA51, command byte 10
|
|
1410
|
+
# - Conformant: write to 0002 (mapped CA51), command byte 7
|
|
1411
|
+
if self._classicHeaderMode == "conformant":
|
|
1412
|
+
timesync_uuid = CASA_CLASSIC_CONFORMANT_CA51_CHAR_UUID # 0002
|
|
1413
|
+
timesync_cmd = 7
|
|
1414
|
+
else:
|
|
1415
|
+
timesync_uuid = CASA_CLASSIC_HASH_CHAR_UUID # CA51
|
|
1416
|
+
timesync_cmd = 10
|
|
1417
|
+
|
|
1418
|
+
# Build the time-sync payload.
|
|
1419
|
+
# Format: [cmd][year:2BE][month:1][day:1][hour:1][min:1][sec:1]
|
|
1420
|
+
# [tz_offset:2BE signed][dst_transition:4BE][dst_change:1]
|
|
1421
|
+
# [timestamp1:3BE][timestamp2:3BE][zero:2][millis:3BE][extra:1]
|
|
1422
|
+
payload = bytearray()
|
|
1423
|
+
payload.append(timesync_cmd)
|
|
1424
|
+
payload.extend(struct.pack(">H", now.year))
|
|
1425
|
+
payload.append(now.month)
|
|
1426
|
+
payload.append(now.day)
|
|
1427
|
+
payload.append(now.hour)
|
|
1428
|
+
payload.append(now.minute)
|
|
1429
|
+
payload.append(now.second)
|
|
1430
|
+
payload.extend(struct.pack(">h", utc_offset_minutes))
|
|
1431
|
+
# DST transition data and change minutes (0 = no DST info).
|
|
1432
|
+
payload.extend(struct.pack(">I", 0))
|
|
1433
|
+
payload.append(0)
|
|
1434
|
+
# Classic extra bytes: lon/lat (fixed-point), zero short, millis, trailing lon high byte.
|
|
1435
|
+
# Android AbstractC1717h.X() lines 323-328: j() = 3-byte big-endian write
|
|
1436
|
+
# (Q2.t.java:59-63), NOT 4-byte. Plus trailing writeByte(iK0 >> 24).
|
|
1437
|
+
#
|
|
1438
|
+
# In casambi-android v3.16 these values are derived from:
|
|
1439
|
+
# - longitude: round(longitude * 65536)
|
|
1440
|
+
# - latitude: round(latitude * 65536)
|
|
1441
|
+
# and sent as:
|
|
1442
|
+
# j(lon32) + j(lat32) + ... + writeByte(lon32 >> 24)
|
|
1443
|
+
lon_fp32 = 0
|
|
1444
|
+
lat_fp32 = 0
|
|
1445
|
+
try:
|
|
1446
|
+
raw = getattr(self._network, "rawNetworkData", None)
|
|
1447
|
+
net = raw.get("network") if isinstance(raw, dict) else None
|
|
1448
|
+
if isinstance(net, dict):
|
|
1449
|
+
lon = net.get("longitude")
|
|
1450
|
+
lat = net.get("latitude")
|
|
1451
|
+
if isinstance(lon, (int, float, str)):
|
|
1452
|
+
lon_fp32 = int(round(float(lon) * 65536.0))
|
|
1453
|
+
if isinstance(lat, (int, float, str)):
|
|
1454
|
+
lat_fp32 = int(round(float(lat) * 65536.0))
|
|
1455
|
+
except Exception:
|
|
1456
|
+
# Never fail init due to missing location; send zeros.
|
|
1457
|
+
lon_fp32 = 0
|
|
1458
|
+
lat_fp32 = 0
|
|
1459
|
+
|
|
1460
|
+
for v in (lon_fp32, lat_fp32):
|
|
1461
|
+
payload.append((v >> 16) & 0xFF)
|
|
1462
|
+
payload.append((v >> 8) & 0xFF)
|
|
1463
|
+
payload.append(v & 0xFF)
|
|
1464
|
+
payload.extend(struct.pack(">H", 0)) # writeShort(0)
|
|
1465
|
+
millis_val = now.microsecond // 1000 * 1000
|
|
1466
|
+
payload.append((millis_val >> 16) & 0xFF)
|
|
1467
|
+
payload.append((millis_val >> 8) & 0xFF)
|
|
1468
|
+
payload.append(millis_val & 0xFF)
|
|
1469
|
+
payload.append((lon_fp32 >> 24) & 0xFF) # writeByte(lon >> 24)
|
|
1470
|
+
|
|
1471
|
+
self._logger.warning(
|
|
1472
|
+
"[CASAMBI_CLASSIC_INIT] sending time-sync len=%d cmd=%d target_uuid=%s header_mode=%s hex=%s",
|
|
1473
|
+
len(payload),
|
|
1474
|
+
timesync_cmd,
|
|
1475
|
+
timesync_uuid,
|
|
1476
|
+
self._classicHeaderMode,
|
|
1477
|
+
b2a(bytes(payload)),
|
|
1478
|
+
)
|
|
1479
|
+
|
|
1480
|
+
try:
|
|
1481
|
+
await self._sendClassic(
|
|
1482
|
+
bytes(payload),
|
|
1483
|
+
target_uuid=timesync_uuid,
|
|
1484
|
+
# Android uses visitor auth for this bootstrap packet.
|
|
1485
|
+
key_preference="visitor",
|
|
1486
|
+
# Android X():314 uses WRITE_TYPE_DEFAULT (2) = with-response.
|
|
1487
|
+
response=True,
|
|
1488
|
+
)
|
|
1489
|
+
self._logger.warning("[CASAMBI_CLASSIC_INIT] time-sync sent successfully")
|
|
1490
|
+
except Exception:
|
|
1491
|
+
self._logger.warning(
|
|
1492
|
+
"[CASAMBI_CLASSIC_INIT] time-sync send failed",
|
|
1493
|
+
exc_info=True,
|
|
1494
|
+
)
|
|
1495
|
+
|
|
412
1496
|
def _establishedNofityCallback(
|
|
413
1497
|
self, handle: BleakGATTCharacteristic, data: bytes
|
|
414
1498
|
) -> None:
|
|
1499
|
+
# Route notifications based on characteristic UUID when available.
|
|
1500
|
+
# This helps with mixed/legacy setups where multiple Classic channels might be active.
|
|
1501
|
+
try:
|
|
1502
|
+
handle_uuid = str(getattr(handle, "uuid", "")).lower()
|
|
1503
|
+
except Exception:
|
|
1504
|
+
handle_uuid = ""
|
|
1505
|
+
if handle_uuid and handle_uuid in self._classicNotifyCharUuids:
|
|
1506
|
+
self._logger.debug(
|
|
1507
|
+
"[CASAMBI_NOTIFY_ROUTE] classic_by_uuid uuid=%s len=%d",
|
|
1508
|
+
handle_uuid,
|
|
1509
|
+
len(data),
|
|
1510
|
+
)
|
|
1511
|
+
self._classicEstablishedNotifyCallback(handle, data)
|
|
1512
|
+
return
|
|
1513
|
+
if self._protocolMode == ProtocolMode.CLASSIC:
|
|
1514
|
+
self._logger.debug(
|
|
1515
|
+
"[CASAMBI_NOTIFY_ROUTE] classic_by_mode uuid=%s len=%d",
|
|
1516
|
+
handle_uuid,
|
|
1517
|
+
len(data),
|
|
1518
|
+
)
|
|
1519
|
+
self._classicEstablishedNotifyCallback(handle, data)
|
|
1520
|
+
return
|
|
1521
|
+
|
|
415
1522
|
# TODO: Check incoming counter and direction flag
|
|
416
1523
|
self._inPacketCount += 1
|
|
417
1524
|
|
|
418
1525
|
# Store raw encrypted packet for reference
|
|
419
1526
|
raw_encrypted_packet = data[:]
|
|
420
1527
|
|
|
1528
|
+
# Extract the device-provided 4-byte little-endian counter from the
|
|
1529
|
+
# encrypted header. This is the true per-session packet sequence.
|
|
1530
|
+
try:
|
|
1531
|
+
device_sequence = int.from_bytes(data[:4], byteorder="little", signed=False)
|
|
1532
|
+
except Exception:
|
|
1533
|
+
device_sequence = None
|
|
1534
|
+
|
|
421
1535
|
try:
|
|
422
1536
|
decrypted_data = self._encryptor.decryptAndVerify(
|
|
423
1537
|
data, data[:4] + self._nonce[4:]
|
|
@@ -428,13 +1542,33 @@ class CasambiClient:
|
|
|
428
1542
|
return
|
|
429
1543
|
|
|
430
1544
|
packetType = decrypted_data[0]
|
|
431
|
-
self._logger.
|
|
1545
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
1546
|
+
self._logger.debug(
|
|
1547
|
+
"Incoming data of type %d: %s", packetType, b2a(decrypted_data)
|
|
1548
|
+
)
|
|
432
1549
|
|
|
433
1550
|
if packetType == IncommingPacketType.UnitState:
|
|
434
1551
|
self._parseUnitStates(decrypted_data[1:])
|
|
435
1552
|
elif packetType == IncommingPacketType.SwitchEvent:
|
|
1553
|
+
# Stable logs for offline analysis: packet seq + encrypted + decrypted.
|
|
1554
|
+
# (Decrypted data includes the leading packet type byte.)
|
|
1555
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
1556
|
+
self._logger.debug(
|
|
1557
|
+
"[CASAMBI_RAW_PACKET] Encrypted #%s: %s",
|
|
1558
|
+
device_sequence,
|
|
1559
|
+
b2a(raw_encrypted_packet),
|
|
1560
|
+
)
|
|
1561
|
+
self._logger.debug(
|
|
1562
|
+
"[CASAMBI_DECRYPTED] Type=%d #%s: %s",
|
|
1563
|
+
packetType,
|
|
1564
|
+
device_sequence,
|
|
1565
|
+
b2a(decrypted_data),
|
|
1566
|
+
)
|
|
1567
|
+
# Pass the device sequence as the packet sequence for consumers,
|
|
1568
|
+
# and still include the raw encrypted packet for diagnostics.
|
|
1569
|
+
seq_for_consumer = device_sequence if device_sequence is not None else self._inPacketCount
|
|
436
1570
|
self._parseSwitchEvent(
|
|
437
|
-
decrypted_data[1:],
|
|
1571
|
+
decrypted_data[1:], seq_for_consumer, raw_encrypted_packet
|
|
438
1572
|
)
|
|
439
1573
|
elif packetType == IncommingPacketType.NetworkConfig:
|
|
440
1574
|
# We don't care about the config the network thinks it has.
|
|
@@ -443,165 +1577,735 @@ class CasambiClient:
|
|
|
443
1577
|
# In the future we might want to parse the revision and issue a warning if there is a mismatch.
|
|
444
1578
|
pass
|
|
445
1579
|
else:
|
|
446
|
-
self._logger.
|
|
1580
|
+
self._logger.debug("Packet type %d not implemented. Ignoring!", packetType)
|
|
447
1581
|
|
|
448
|
-
def
|
|
449
|
-
self
|
|
450
|
-
|
|
1582
|
+
def _classicEstablishedNotifyCallback(
|
|
1583
|
+
self, handle: BleakGATTCharacteristic, data: bytes
|
|
1584
|
+
) -> None:
|
|
1585
|
+
"""Parse Classic notifications from the CA52 channel.
|
|
451
1586
|
|
|
452
|
-
|
|
453
|
-
|
|
1587
|
+
Classic packets are CMAC-signed (prefix embedded into the header).
|
|
1588
|
+
Ground truth: casambi-android `t1.P.o(...)`.
|
|
1589
|
+
"""
|
|
1590
|
+
self._inPacketCount += 1
|
|
1591
|
+
self._classicRxFrames += 1
|
|
1592
|
+
rx_ts = time.monotonic()
|
|
1593
|
+
if self._classicFirstRxTs is None:
|
|
1594
|
+
self._classicFirstRxTs = rx_ts
|
|
1595
|
+
|
|
1596
|
+
raw = bytes(data)
|
|
1597
|
+
|
|
1598
|
+
# Enhanced RX diagnostic logging
|
|
454
1599
|
try:
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
1600
|
+
handle_uuid = str(getattr(handle, "uuid", "unknown")).lower()
|
|
1601
|
+
except Exception:
|
|
1602
|
+
handle_uuid = "unknown"
|
|
1603
|
+
|
|
1604
|
+
self._logger.warning(
|
|
1605
|
+
"[CLASSIC_DIAG_RX] #%d handle=%s len=%d hex=%s",
|
|
1606
|
+
self._classicRxFrames,
|
|
1607
|
+
handle_uuid,
|
|
1608
|
+
len(raw),
|
|
1609
|
+
b2a(raw[: min(len(raw), 48)]).decode("ascii") + ("..." if len(raw) > 48 else ""),
|
|
1610
|
+
)
|
|
461
1611
|
|
|
462
|
-
|
|
463
|
-
|
|
1612
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
1613
|
+
self._logger.debug(
|
|
1614
|
+
"[CASAMBI_CLASSIC_RX_RAW] len=%d hex=%s",
|
|
1615
|
+
len(raw),
|
|
1616
|
+
b2a(raw[: min(len(raw), 64)]) + (b"..." if len(raw) > 64 else b""),
|
|
1617
|
+
)
|
|
464
1618
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
if flags & 16:
|
|
470
|
-
pos += 1 # Unkown value
|
|
1619
|
+
if self._classicConnHash8 is None:
|
|
1620
|
+
if self._logLimiter.allow("classic_rx_no_hash", burst=5, window_s=60.0):
|
|
1621
|
+
self._logger.warning("[CASAMBI_CLASSIC_RX] missing_connection_hash len=%d", len(raw))
|
|
1622
|
+
return
|
|
471
1623
|
|
|
472
|
-
|
|
473
|
-
|
|
1624
|
+
visitor_key = self._network.classicVisitorKey()
|
|
1625
|
+
manager_key = self._network.classicManagerKey()
|
|
474
1626
|
|
|
475
|
-
|
|
1627
|
+
def _plausible_payload(payload: bytes) -> bool:
|
|
1628
|
+
if not payload:
|
|
1629
|
+
return False
|
|
1630
|
+
if payload[0] in (
|
|
1631
|
+
IncommingPacketType.UnitState,
|
|
1632
|
+
IncommingPacketType.SwitchEvent,
|
|
1633
|
+
IncommingPacketType.NetworkConfig,
|
|
1634
|
+
):
|
|
1635
|
+
return True
|
|
1636
|
+
# Classic command record stream: record[0] = (len+239) mod 256
|
|
1637
|
+
if len(payload) >= 2:
|
|
1638
|
+
rec_len = (payload[0] - 239) & 0xFF
|
|
1639
|
+
if 2 <= rec_len <= len(payload):
|
|
1640
|
+
return True
|
|
1641
|
+
return False
|
|
1642
|
+
|
|
1643
|
+
def _score(verified: bool | None, payload: bytes) -> int:
|
|
1644
|
+
plausible = _plausible_payload(payload)
|
|
1645
|
+
if verified is True:
|
|
1646
|
+
return 100
|
|
1647
|
+
if plausible and verified is None:
|
|
1648
|
+
return 50
|
|
1649
|
+
if plausible and verified is False:
|
|
1650
|
+
return 20
|
|
1651
|
+
return 0
|
|
1652
|
+
|
|
1653
|
+
def _parse_conformant(raw_bytes: bytes) -> dict[str, Any] | None:
|
|
1654
|
+
if len(raw_bytes) < 1 + 4 + 2:
|
|
1655
|
+
return None
|
|
1656
|
+
auth_level = raw_bytes[0]
|
|
1657
|
+
if auth_level == 0x02:
|
|
1658
|
+
sig_len = 4
|
|
1659
|
+
key_name = "visitor"
|
|
1660
|
+
key = visitor_key
|
|
1661
|
+
elif auth_level == 0x03:
|
|
1662
|
+
sig_len = 16
|
|
1663
|
+
key_name = "manager"
|
|
1664
|
+
key = manager_key
|
|
1665
|
+
else:
|
|
1666
|
+
return None
|
|
476
1667
|
|
|
477
|
-
|
|
478
|
-
|
|
1668
|
+
header_len = 1 + sig_len + 2
|
|
1669
|
+
if len(raw_bytes) < header_len:
|
|
1670
|
+
return None
|
|
1671
|
+
|
|
1672
|
+
sig = raw_bytes[1 : 1 + sig_len]
|
|
1673
|
+
cmac_input = raw_bytes[1 + sig_len :] # seq(2) + payload
|
|
1674
|
+
seq = int.from_bytes(cmac_input[:2], byteorder="big", signed=False)
|
|
1675
|
+
payload = cmac_input[2:]
|
|
1676
|
+
|
|
1677
|
+
verified: bool | None
|
|
1678
|
+
if key is None:
|
|
1679
|
+
verified = None
|
|
1680
|
+
else:
|
|
1681
|
+
try:
|
|
1682
|
+
expected = classic_cmac_prefix(key, self._classicConnHash8, cmac_input, sig_len)
|
|
1683
|
+
except Exception:
|
|
1684
|
+
verified = False
|
|
1685
|
+
else:
|
|
1686
|
+
verified = expected == sig
|
|
1687
|
+
|
|
1688
|
+
return {
|
|
1689
|
+
"mode": "conformant",
|
|
1690
|
+
"auth_level": auth_level,
|
|
1691
|
+
"sig_len": sig_len,
|
|
1692
|
+
"seq": seq,
|
|
1693
|
+
"key_name": key_name if key is not None else None,
|
|
1694
|
+
"verified": verified,
|
|
1695
|
+
"payload": payload,
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
def _parse_legacy(raw_bytes: bytes, *, sig_len: int) -> dict[str, Any] | None:
|
|
1699
|
+
if len(raw_bytes) < sig_len + 1:
|
|
1700
|
+
return None
|
|
1701
|
+
sig = raw_bytes[:sig_len]
|
|
1702
|
+
payload = raw_bytes[sig_len:]
|
|
1703
|
+
|
|
1704
|
+
# In non-conformant mode Android still selects visitor/manager key for CMAC,
|
|
1705
|
+
# but the header contains only the CMAC prefix (typically 4 bytes).
|
|
1706
|
+
verified: bool | None = None
|
|
1707
|
+
key_name: str | None = None
|
|
1708
|
+
|
|
1709
|
+
keys_to_try: list[tuple[str, bytes | None]] = [
|
|
1710
|
+
("visitor", visitor_key),
|
|
1711
|
+
("manager", manager_key),
|
|
1712
|
+
]
|
|
1713
|
+
any_key = any(k is not None for _, k in keys_to_try)
|
|
1714
|
+
if any_key:
|
|
1715
|
+
verified = False
|
|
1716
|
+
for nm, key in keys_to_try:
|
|
1717
|
+
if key is None:
|
|
1718
|
+
continue
|
|
1719
|
+
try:
|
|
1720
|
+
expected = classic_cmac_prefix(key, self._classicConnHash8, payload, sig_len)
|
|
1721
|
+
except Exception:
|
|
1722
|
+
continue
|
|
1723
|
+
if expected == sig:
|
|
1724
|
+
verified = True
|
|
1725
|
+
key_name = nm
|
|
1726
|
+
break
|
|
1727
|
+
|
|
1728
|
+
return {
|
|
1729
|
+
"mode": "legacy",
|
|
1730
|
+
"auth_level": None,
|
|
1731
|
+
"sig_len": sig_len,
|
|
1732
|
+
"seq": None,
|
|
1733
|
+
"key_name": key_name,
|
|
1734
|
+
"verified": verified,
|
|
1735
|
+
"payload": payload,
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
def _parse_raw(raw_bytes: bytes) -> dict[str, Any] | None:
|
|
1739
|
+
"""Parse as raw (unsigned) Classic data.
|
|
1740
|
+
|
|
1741
|
+
Android classic gateway a1.c.V() receives raw bytes with NO CMAC
|
|
1742
|
+
header — byte 0 is the unit_id directly. Adding this as a candidate
|
|
1743
|
+
avoids silently dropping unsigned notifications.
|
|
1744
|
+
"""
|
|
1745
|
+
if not raw_bytes:
|
|
1746
|
+
return None
|
|
1747
|
+
return {
|
|
1748
|
+
"mode": "raw",
|
|
1749
|
+
"auth_level": None,
|
|
1750
|
+
"sig_len": 0,
|
|
1751
|
+
"seq": None,
|
|
1752
|
+
"key_name": None,
|
|
1753
|
+
"verified": None, # raw = unverifiable
|
|
1754
|
+
"payload": raw_bytes,
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
# Try the currently selected header mode first, then fall back.
|
|
1758
|
+
# Some mixed/legacy setups differ between CA52 (legacy) and auth-UUID (conformant).
|
|
1759
|
+
parsed_candidates: list[dict[str, Any]] = []
|
|
1760
|
+
preferred = self._classicHeaderMode or "conformant"
|
|
1761
|
+
if preferred == "legacy":
|
|
1762
|
+
for sl in (4, 16):
|
|
1763
|
+
r = _parse_legacy(raw, sig_len=sl)
|
|
1764
|
+
if r is not None:
|
|
1765
|
+
parsed_candidates.append(r)
|
|
1766
|
+
r = _parse_conformant(raw)
|
|
1767
|
+
if r is not None:
|
|
1768
|
+
parsed_candidates.append(r)
|
|
1769
|
+
else:
|
|
1770
|
+
r = _parse_conformant(raw)
|
|
1771
|
+
if r is not None:
|
|
1772
|
+
parsed_candidates.append(r)
|
|
1773
|
+
for sl in (4, 16):
|
|
1774
|
+
r = _parse_legacy(raw, sig_len=sl)
|
|
1775
|
+
if r is not None:
|
|
1776
|
+
parsed_candidates.append(r)
|
|
1777
|
+
|
|
1778
|
+
# Add a raw (unsigned) candidate — needed because Android classic
|
|
1779
|
+
# gateway receives raw bytes with no CMAC header.
|
|
1780
|
+
raw_candidate = _parse_raw(raw)
|
|
1781
|
+
if raw_candidate is not None:
|
|
1782
|
+
parsed_candidates.append(raw_candidate)
|
|
1783
|
+
|
|
1784
|
+
if not parsed_candidates:
|
|
1785
|
+
self._classicRxParseFail += 1
|
|
1786
|
+
if self._logLimiter.allow("classic_rx_parse_fail", burst=5, window_s=60.0):
|
|
1787
|
+
self._logger.warning(
|
|
1788
|
+
"[CASAMBI_CLASSIC_RX_PARSE_FAIL] len=%d prefix=%s",
|
|
1789
|
+
len(raw),
|
|
1790
|
+
b2a(raw[: min(len(raw), 32)]),
|
|
479
1791
|
)
|
|
1792
|
+
return
|
|
480
1793
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
1794
|
+
# Choose best candidate by score; tie-breaker prefers current mode.
|
|
1795
|
+
for c in parsed_candidates:
|
|
1796
|
+
c["score"] = _score(c["verified"], c["payload"])
|
|
1797
|
+
|
|
1798
|
+
parsed_candidates.sort(
|
|
1799
|
+
key=lambda c: (
|
|
1800
|
+
c["score"],
|
|
1801
|
+
1 if c["mode"] == preferred else 0,
|
|
1802
|
+
-c["sig_len"],
|
|
1803
|
+
),
|
|
1804
|
+
reverse=True,
|
|
1805
|
+
)
|
|
1806
|
+
best = parsed_candidates[0]
|
|
1807
|
+
|
|
1808
|
+
if best["score"] == 0:
|
|
1809
|
+
self._classicRxParseFail += 1
|
|
1810
|
+
if self._logLimiter.allow("classic_rx_unplausible", burst=5, window_s=60.0):
|
|
1811
|
+
self._logger.warning(
|
|
1812
|
+
"[CASAMBI_CLASSIC_RX_UNPLAUSIBLE] preferred=%s len=%d prefix=%s",
|
|
1813
|
+
preferred,
|
|
1814
|
+
len(raw),
|
|
1815
|
+
b2a(raw[: min(len(raw), 32)]),
|
|
484
1816
|
)
|
|
1817
|
+
return
|
|
485
1818
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
1819
|
+
payload = best["payload"]
|
|
1820
|
+
verified = best["verified"]
|
|
1821
|
+
if verified is True:
|
|
1822
|
+
self._classicRxVerified += 1
|
|
1823
|
+
elif verified is None:
|
|
1824
|
+
self._classicRxUnverifiable += 1
|
|
1825
|
+
|
|
1826
|
+
# Record RX in diagnostic history
|
|
1827
|
+
rx_entry = {
|
|
1828
|
+
"timestamp": rx_ts,
|
|
1829
|
+
"handle_uuid": handle_uuid,
|
|
1830
|
+
"header_mode": best["mode"],
|
|
1831
|
+
"verified": verified,
|
|
1832
|
+
"auth_level": best["auth_level"],
|
|
1833
|
+
"sig_len": best["sig_len"],
|
|
1834
|
+
"seq": best["seq"],
|
|
1835
|
+
"payload_len": len(payload),
|
|
1836
|
+
"raw_hex": b2a(raw).decode("ascii"),
|
|
1837
|
+
"payload_hex": b2a(payload).decode("ascii"),
|
|
1838
|
+
"score": best["score"],
|
|
1839
|
+
}
|
|
1840
|
+
self._classicRxHistory.append(rx_entry)
|
|
1841
|
+
if len(self._classicRxHistory) > self._classicDiagMaxHistory:
|
|
1842
|
+
self._classicRxHistory = self._classicRxHistory[-self._classicDiagMaxHistory:]
|
|
1843
|
+
|
|
1844
|
+
# Enhanced RX parse result log
|
|
1845
|
+
self._logger.warning(
|
|
1846
|
+
"[CLASSIC_DIAG_RX_PARSE] mode=%s verified=%s auth=%s sig_len=%d seq=%s score=%d payload_len=%d",
|
|
1847
|
+
best["mode"],
|
|
1848
|
+
verified,
|
|
1849
|
+
None if best["auth_level"] is None else f"0x{best['auth_level']:02x}",
|
|
1850
|
+
best["sig_len"],
|
|
1851
|
+
None if best["seq"] is None else f"0x{best['seq']:04x}",
|
|
1852
|
+
best["score"],
|
|
1853
|
+
len(payload),
|
|
1854
|
+
)
|
|
1855
|
+
|
|
1856
|
+
# Auto-correct header mode if the other format parses much better.
|
|
1857
|
+
# Never switch to "raw" — raw is not a header mode, only a fallback parse.
|
|
1858
|
+
if best["mode"] != preferred and best["mode"] in ("conformant", "legacy"):
|
|
1859
|
+
# Only switch if we got a stronger signal (verified or plausible payload with fewer assumptions).
|
|
1860
|
+
if best["score"] >= 50 and self._logLimiter.allow("classic_rx_mode_switch", burst=3, window_s=3600.0):
|
|
1861
|
+
self._logger.warning(
|
|
1862
|
+
"[CASAMBI_CLASSIC_RX_MODE] switching %s -> %s (score=%d verified=%s sig_len=%d)",
|
|
1863
|
+
preferred,
|
|
1864
|
+
best["mode"],
|
|
1865
|
+
best["score"],
|
|
1866
|
+
verified,
|
|
1867
|
+
best["sig_len"],
|
|
1868
|
+
)
|
|
1869
|
+
self._classicHeaderMode = best["mode"]
|
|
1870
|
+
|
|
1871
|
+
# Sample RX logs (limited) + periodic stats (limited).
|
|
1872
|
+
if self._logLimiter.allow("classic_rx_sample", burst=10, window_s=60.0):
|
|
1873
|
+
self._logger.warning(
|
|
1874
|
+
"[CASAMBI_CLASSIC_RX] header=%s verified=%s auth=%s sig_len=%d seq=%s payload_prefix=%s",
|
|
1875
|
+
best["mode"],
|
|
1876
|
+
verified,
|
|
1877
|
+
None if best["auth_level"] is None else f"0x{best['auth_level']:02x}",
|
|
1878
|
+
best["sig_len"],
|
|
1879
|
+
None if best["seq"] is None else f"0x{best['seq']:04x}",
|
|
1880
|
+
b2a(payload[: min(len(payload), 32)]),
|
|
1881
|
+
)
|
|
1882
|
+
now = time.monotonic()
|
|
1883
|
+
if (now - self._classicRxLastStatsTs) > 60.0 and self._logLimiter.allow(
|
|
1884
|
+
"classic_rx_stats", burst=2, window_s=60.0
|
|
1885
|
+
):
|
|
1886
|
+
self._classicRxLastStatsTs = now
|
|
1887
|
+
self._logger.warning(
|
|
1888
|
+
"[CASAMBI_CLASSIC_RX_STATS] frames=%d verified=%d unverifiable=%d parse_fail=%d header=%s "
|
|
1889
|
+
"type6=%d type7=%d type9=%d cmdstream=%d unknown=%d classic_states=%d",
|
|
1890
|
+
self._classicRxFrames,
|
|
1891
|
+
self._classicRxVerified,
|
|
1892
|
+
self._classicRxUnverifiable,
|
|
1893
|
+
self._classicRxParseFail,
|
|
1894
|
+
self._classicHeaderMode,
|
|
1895
|
+
self._classicRxType6,
|
|
1896
|
+
self._classicRxType7,
|
|
1897
|
+
self._classicRxType9,
|
|
1898
|
+
self._classicRxCmdStream,
|
|
1899
|
+
self._classicRxUnknown,
|
|
1900
|
+
self._classicRxClassicStates,
|
|
490
1901
|
)
|
|
491
1902
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
self.
|
|
497
|
-
|
|
498
|
-
|
|
1903
|
+
# Classic payloads use a completely different format from EVO.
|
|
1904
|
+
# Classic: byte 0 is a type indicator (0=netconfig, 255=log, else=unit_id).
|
|
1905
|
+
# EVO: byte 0 is a packet type (6=UnitState, 7=Switch, 9=NetConfig).
|
|
1906
|
+
# Dispatch Classic through its own parser to avoid misinterpretation.
|
|
1907
|
+
if self._protocolMode == ProtocolMode.CLASSIC:
|
|
1908
|
+
self._dispatchClassicPayload(payload)
|
|
1909
|
+
return
|
|
1910
|
+
|
|
1911
|
+
# If the payload starts with a known EVO packet type, reuse existing parsers.
|
|
1912
|
+
packet_type = payload[0]
|
|
1913
|
+
if packet_type in (IncommingPacketType.UnitState, IncommingPacketType.SwitchEvent, IncommingPacketType.NetworkConfig):
|
|
1914
|
+
kind = f"type{int(packet_type)}"
|
|
1915
|
+
if packet_type == IncommingPacketType.UnitState:
|
|
1916
|
+
self._classicRxType6 += 1
|
|
1917
|
+
kind = "type6_unitstate"
|
|
1918
|
+
elif packet_type == IncommingPacketType.SwitchEvent:
|
|
1919
|
+
self._classicRxType7 += 1
|
|
1920
|
+
kind = "type7_switch"
|
|
1921
|
+
else:
|
|
1922
|
+
self._classicRxType9 += 1
|
|
1923
|
+
kind = "type9_netconf"
|
|
1924
|
+
|
|
1925
|
+
# Emit a few per-kind examples for reverse engineering.
|
|
1926
|
+
if self._classicRxKindSamples.get(kind, 0) < 3:
|
|
1927
|
+
self._classicRxKindSamples[kind] = self._classicRxKindSamples.get(kind, 0) + 1
|
|
1928
|
+
self._logger.warning(
|
|
1929
|
+
"[CASAMBI_CLASSIC_RX_KIND] kind=%s header=%s verified=%s sig_len=%d seq=%s payload_prefix=%s",
|
|
1930
|
+
kind,
|
|
1931
|
+
best["mode"],
|
|
1932
|
+
verified,
|
|
1933
|
+
best["sig_len"],
|
|
1934
|
+
None if best["seq"] is None else f"0x{best['seq']:04x}",
|
|
1935
|
+
b2a(payload[: min(len(payload), 32)]),
|
|
1936
|
+
)
|
|
1937
|
+
|
|
1938
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
1939
|
+
self._logger.debug(
|
|
1940
|
+
"[CASAMBI_CLASSIC_RX_PAYLOAD] type=%d len=%d hex=%s",
|
|
1941
|
+
packet_type,
|
|
1942
|
+
len(payload),
|
|
1943
|
+
b2a(payload[: min(len(payload), 64)])
|
|
1944
|
+
+ (b"..." if len(payload) > 64 else b""),
|
|
1945
|
+
)
|
|
1946
|
+
if packet_type == IncommingPacketType.UnitState:
|
|
1947
|
+
self._parseUnitStates(payload[1:])
|
|
1948
|
+
elif packet_type == IncommingPacketType.SwitchEvent:
|
|
1949
|
+
self._parseSwitchEvent(payload[1:], None, raw)
|
|
1950
|
+
else:
|
|
1951
|
+
# ignore network config
|
|
1952
|
+
pass
|
|
1953
|
+
return
|
|
1954
|
+
|
|
1955
|
+
# Otherwise, attempt to parse a stream of Classic "command" records:
|
|
1956
|
+
# record[0] = (len + 239) mod 256, so len = (b0 - 239) & 0xFF.
|
|
1957
|
+
pos = 0
|
|
1958
|
+
parsed_any = False
|
|
1959
|
+
while pos + 2 <= len(payload):
|
|
1960
|
+
enc_len = payload[pos]
|
|
1961
|
+
rec_len = (enc_len - 239) & 0xFF
|
|
1962
|
+
if rec_len < 2 or pos + rec_len > len(payload):
|
|
1963
|
+
break
|
|
1964
|
+
rec = payload[pos : pos + rec_len]
|
|
1965
|
+
pos += rec_len
|
|
1966
|
+
parsed_any = True
|
|
1967
|
+
|
|
1968
|
+
typ = rec[1]
|
|
1969
|
+
ordinal = typ & 0x3F
|
|
1970
|
+
has_div = (typ & 0x40) != 0
|
|
1971
|
+
has_target = (typ & 0x80) != 0
|
|
1972
|
+
p = 2
|
|
1973
|
+
div = rec[p] if has_div and p < len(rec) else None
|
|
1974
|
+
if has_div:
|
|
1975
|
+
p += 1
|
|
1976
|
+
target = rec[p] if has_target and p < len(rec) else None
|
|
1977
|
+
if has_target:
|
|
1978
|
+
p += 1
|
|
1979
|
+
lifetime = rec[p] if p < len(rec) else None
|
|
1980
|
+
if lifetime is not None:
|
|
1981
|
+
p += 1
|
|
1982
|
+
rec_payload = rec[p:] if p <= len(rec) else b""
|
|
1983
|
+
|
|
1984
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
1985
|
+
self._logger.debug(
|
|
1986
|
+
"[CASAMBI_CLASSIC_CMD] ord=%d div=%s target=%s lifetime=%s payload=%s",
|
|
1987
|
+
ordinal,
|
|
1988
|
+
div,
|
|
1989
|
+
target,
|
|
1990
|
+
lifetime,
|
|
1991
|
+
b2a(rec_payload),
|
|
1992
|
+
)
|
|
1993
|
+
|
|
1994
|
+
if parsed_any:
|
|
1995
|
+
self._classicRxCmdStream += 1
|
|
1996
|
+
kind = "cmdstream"
|
|
1997
|
+
else:
|
|
1998
|
+
self._classicRxUnknown += 1
|
|
1999
|
+
kind = "unknown"
|
|
499
2000
|
|
|
500
|
-
|
|
501
|
-
|
|
2001
|
+
if self._classicRxKindSamples.get(kind, 0) < 3:
|
|
2002
|
+
self._classicRxKindSamples[kind] = self._classicRxKindSamples.get(kind, 0) + 1
|
|
2003
|
+
self._logger.warning(
|
|
2004
|
+
"[CASAMBI_CLASSIC_RX_KIND] kind=%s header=%s verified=%s sig_len=%d seq=%s payload_prefix=%s",
|
|
2005
|
+
kind,
|
|
2006
|
+
best["mode"],
|
|
2007
|
+
verified,
|
|
2008
|
+
best["sig_len"],
|
|
2009
|
+
None if best["seq"] is None else f"0x{best['seq']:04x}",
|
|
2010
|
+
b2a(payload[: min(len(payload), 32)]),
|
|
2011
|
+
)
|
|
2012
|
+
|
|
2013
|
+
# Any trailing bytes that don't form a full record are logged for analysis.
|
|
2014
|
+
if self._logger.isEnabledFor(logging.DEBUG) and pos < len(payload):
|
|
502
2015
|
self._logger.debug(
|
|
503
|
-
|
|
2016
|
+
"[CASAMBI_CLASSIC_CMD_TRAILING] len=%d hex=%s",
|
|
2017
|
+
len(payload) - pos,
|
|
2018
|
+
b2a(payload[pos:]),
|
|
504
2019
|
)
|
|
2020
|
+
|
|
2021
|
+
def _dispatchClassicPayload(self, payload: bytes) -> None:
|
|
2022
|
+
"""Dispatch a verified Classic payload based on its type indicator.
|
|
2023
|
+
|
|
2024
|
+
Classic payloads (from C1751c.V()) use a different format from EVO:
|
|
2025
|
+
- byte 0 == 0: network config data
|
|
2026
|
+
- byte 0 == 255: log message
|
|
2027
|
+
- otherwise: unit state stream (byte 0 is the first unit_id)
|
|
2028
|
+
"""
|
|
2029
|
+
if not payload:
|
|
505
2030
|
return
|
|
506
2031
|
|
|
507
|
-
|
|
508
|
-
oldPos = 0
|
|
509
|
-
switch_events_found = 0
|
|
2032
|
+
first_byte = payload[0]
|
|
510
2033
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
2034
|
+
# Log full payload for the first 10 Classic payloads regardless of type.
|
|
2035
|
+
if self._classicRxClassicStates < 10:
|
|
2036
|
+
self._logger.warning(
|
|
2037
|
+
"[CASAMBI_CLASSIC_DISPATCH] #%d type_byte=%d len=%d hex=%s",
|
|
2038
|
+
self._classicRxClassicStates,
|
|
2039
|
+
first_byte,
|
|
2040
|
+
len(payload),
|
|
2041
|
+
b2a(payload[: min(len(payload), 64)]).decode("ascii")
|
|
2042
|
+
+ ("..." if len(payload) > 64 else ""),
|
|
2043
|
+
)
|
|
514
2044
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
2045
|
+
if first_byte == 0:
|
|
2046
|
+
self._logger.debug("[CASAMBI_CLASSIC_NETCONFIG] len=%d", len(payload))
|
|
2047
|
+
return
|
|
2048
|
+
|
|
2049
|
+
if first_byte == 255:
|
|
2050
|
+
self._logger.debug("[CASAMBI_CLASSIC_LOG] len=%d", len(payload))
|
|
2051
|
+
return
|
|
2052
|
+
|
|
2053
|
+
# Unit state stream: entire payload is passed (first byte is the first unit_id).
|
|
2054
|
+
self._classicRxClassicStates += 1
|
|
2055
|
+
self._parseClassicUnitStates(payload)
|
|
2056
|
+
|
|
2057
|
+
def _parseClassicUnitStates(self, data: bytes) -> None:
|
|
2058
|
+
"""Parse Classic unit state records.
|
|
521
2059
|
|
|
522
|
-
|
|
523
|
-
|
|
2060
|
+
Ground truth: casambi-android a1.c.V() (line 226+).
|
|
2061
|
+
Format is completely different from EVO _parseUnitStates:
|
|
2062
|
+
- flags lower nibble = state_len (EVO uses a separate byte)
|
|
2063
|
+
- flags bit 4 = priority 14, bit 5 = extra1, bit 6 = extra2, bit 7 = online
|
|
2064
|
+
- unit_id 0xF0 = command response (cmd_id + seq + payload)
|
|
2065
|
+
"""
|
|
2066
|
+
self._logger.debug("Parsing Classic unit states...")
|
|
2067
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
2068
|
+
self._logger.debug("[CASAMBI_CLASSIC_STATES_RAW] len=%d hex=%s", len(data), b2a(data))
|
|
2069
|
+
|
|
2070
|
+
pos = 0
|
|
2071
|
+
old_pos = 0
|
|
2072
|
+
records_parsed = 0
|
|
2073
|
+
try:
|
|
2074
|
+
# Android uses fVar.available() >= 3 as loop guard.
|
|
2075
|
+
while pos + 3 <= len(data):
|
|
2076
|
+
unit_id = data[pos]
|
|
2077
|
+
flags = data[pos + 1]
|
|
2078
|
+
pos += 2
|
|
2079
|
+
|
|
2080
|
+
state_len = flags & 0x0F
|
|
2081
|
+
has_extra1 = (flags & 0x20) != 0
|
|
2082
|
+
has_extra2 = (flags & 0x40) != 0
|
|
2083
|
+
# Android a1.c.java:286: (b6 & 128) != 0 → online (NOT offline).
|
|
2084
|
+
# Confirmed by N1.java:1298 log "Set unit ONLINE=" + z6.
|
|
2085
|
+
online = (flags & 0x80) != 0
|
|
2086
|
+
|
|
2087
|
+
# 0xF0 = command response record (Android a1.c.java:260-270).
|
|
2088
|
+
# Format: cmd_id(1) + seq(1) + payload(state_len - 2).
|
|
2089
|
+
if unit_id == 0xF0:
|
|
2090
|
+
cmd_id = data[pos] if pos < len(data) else None
|
|
2091
|
+
seq_byte = data[pos + 1] if pos + 1 < len(data) else None
|
|
524
2092
|
self._logger.debug(
|
|
525
|
-
|
|
2093
|
+
"[CASAMBI_CLASSIC_CMD_RESP] cmd_id=%s seq=%s state_len=%d",
|
|
2094
|
+
cmd_id,
|
|
2095
|
+
seq_byte,
|
|
2096
|
+
state_len,
|
|
526
2097
|
)
|
|
527
|
-
|
|
528
|
-
pos = oldPos + 1
|
|
2098
|
+
pos += state_len
|
|
529
2099
|
continue
|
|
530
2100
|
|
|
531
|
-
|
|
532
|
-
if
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
2101
|
+
extra1 = 0
|
|
2102
|
+
if has_extra1:
|
|
2103
|
+
if pos >= len(data):
|
|
2104
|
+
break
|
|
2105
|
+
extra1 = data[pos]
|
|
2106
|
+
pos += 1
|
|
2107
|
+
|
|
2108
|
+
extra2 = 0
|
|
2109
|
+
if has_extra2:
|
|
2110
|
+
if pos >= len(data):
|
|
2111
|
+
break
|
|
2112
|
+
extra2 = data[pos]
|
|
2113
|
+
pos += 1
|
|
2114
|
+
|
|
2115
|
+
if pos + state_len > len(data):
|
|
537
2116
|
break
|
|
538
2117
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
#
|
|
544
|
-
if
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
# Use upper 4 bits if lower 4 bits are 0, otherwise use lower 4 bits
|
|
551
|
-
if button_lower == 0 and button_upper != 0:
|
|
552
|
-
button = button_upper
|
|
553
|
-
self._logger.debug(
|
|
554
|
-
f"EVO button extraction: parameter=0x{parameter:02x}, using upper nibble, button={button}"
|
|
555
|
-
)
|
|
556
|
-
else:
|
|
557
|
-
button = button_lower
|
|
558
|
-
self._logger.debug(
|
|
559
|
-
f"EVO button extraction: parameter=0x{parameter:02x}, using lower nibble, button={button}"
|
|
560
|
-
)
|
|
561
|
-
|
|
562
|
-
# For type 0x10 messages, we need to pass additional data beyond the declared payload
|
|
563
|
-
if message_type == 0x10:
|
|
564
|
-
# Extend to include at least 10 bytes from message start for state byte
|
|
565
|
-
extended_end = min(oldPos + 11, len(data))
|
|
566
|
-
full_message_data = data[oldPos:extended_end]
|
|
567
|
-
else:
|
|
568
|
-
full_message_data = data
|
|
569
|
-
self._processSwitchMessage(
|
|
570
|
-
message_type,
|
|
2118
|
+
state = data[pos : pos + state_len]
|
|
2119
|
+
pos += state_len
|
|
2120
|
+
records_parsed += 1
|
|
2121
|
+
|
|
2122
|
+
# Log the first few parsed records at WARNING level for tester visibility.
|
|
2123
|
+
if records_parsed <= 10 or self._logger.isEnabledFor(logging.DEBUG):
|
|
2124
|
+
self._logger.warning(
|
|
2125
|
+
"[CASAMBI_CLASSIC_STATE_PARSED] unit=%d flags=0x%02x state_len=%d "
|
|
2126
|
+
"online=%s extra1=%d extra2=%d state=%s",
|
|
2127
|
+
unit_id,
|
|
571
2128
|
flags,
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
raw_packet,
|
|
578
|
-
)
|
|
579
|
-
elif message_type == 0x29:
|
|
580
|
-
# This shouldn't happen due to check above, but just in case
|
|
581
|
-
self._logger.debug("Ignoring embedded type 0x29 message")
|
|
582
|
-
elif message_type in [0x00, 0x06, 0x09, 0x1F, 0x2A]:
|
|
583
|
-
# Known non-switch message types - log at debug level
|
|
584
|
-
self._logger.debug(
|
|
585
|
-
f"Non-switch message type 0x{message_type:02x}: flags=0x{flags:02x}, "
|
|
586
|
-
f"param={parameter}, payload={b2a(payload)}"
|
|
2129
|
+
state_len,
|
|
2130
|
+
online,
|
|
2131
|
+
extra1,
|
|
2132
|
+
extra2,
|
|
2133
|
+
b2a(state),
|
|
587
2134
|
)
|
|
2135
|
+
# Let Unit.is_on derive actual on/off from state bytes (dimmer, onoff).
|
|
2136
|
+
on = True
|
|
2137
|
+
|
|
2138
|
+
self._dataCallback(
|
|
2139
|
+
IncommingPacketType.UnitState,
|
|
2140
|
+
{
|
|
2141
|
+
"id": unit_id,
|
|
2142
|
+
"online": online,
|
|
2143
|
+
"on": on,
|
|
2144
|
+
"state": state,
|
|
2145
|
+
"flags": flags,
|
|
2146
|
+
# Android a1.c.java:291: (b6 & 16) != 0 ? 14 : 0
|
|
2147
|
+
"prio": 14 if (flags & 0x10) else 0,
|
|
2148
|
+
"state_len": state_len,
|
|
2149
|
+
"padding_len": 0,
|
|
2150
|
+
"con": None,
|
|
2151
|
+
"sid": None,
|
|
2152
|
+
"extra_byte": extra1,
|
|
2153
|
+
"extra_float": extra1 / 255.0 if extra1 else 0.0,
|
|
2154
|
+
},
|
|
2155
|
+
)
|
|
2156
|
+
|
|
2157
|
+
old_pos = pos
|
|
2158
|
+
except IndexError:
|
|
2159
|
+
self._logger.error(
|
|
2160
|
+
"Ran out of data while parsing Classic unit state! Remaining data %s in %s.",
|
|
2161
|
+
b2a(data[old_pos:]),
|
|
2162
|
+
b2a(data),
|
|
2163
|
+
)
|
|
2164
|
+
|
|
2165
|
+
if records_parsed > 0:
|
|
2166
|
+
self._logger.debug(
|
|
2167
|
+
"[CASAMBI_CLASSIC_STATES_DONE] records=%d remaining=%d",
|
|
2168
|
+
records_parsed,
|
|
2169
|
+
len(data) - pos,
|
|
2170
|
+
)
|
|
2171
|
+
|
|
2172
|
+
def _parseUnitStates(self, data: bytes) -> None:
|
|
2173
|
+
# Ground truth: casambi-android `v1.C1775b.V(Q2.h)` parses decrypted packet type=6
|
|
2174
|
+
# as a stream of unit state records. Records have optional bytes depending on flags.
|
|
2175
|
+
self._logger.debug("Parsing incoming unit states...")
|
|
2176
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
2177
|
+
self._logger.debug("Incoming unit state: %s", b2a(data))
|
|
2178
|
+
|
|
2179
|
+
pos = 0
|
|
2180
|
+
oldPos = 0
|
|
2181
|
+
try:
|
|
2182
|
+
# Android uses `while (available() >= 4)` as the loop condition.
|
|
2183
|
+
while pos <= len(data) - 4:
|
|
2184
|
+
unit_id = data[pos]
|
|
2185
|
+
flags = data[pos + 1]
|
|
2186
|
+
b8 = data[pos + 2]
|
|
2187
|
+
state_len = ((b8 >> 4) & 0x0F) + 1
|
|
2188
|
+
prio = b8 & 0x0F
|
|
2189
|
+
pos += 3
|
|
2190
|
+
|
|
2191
|
+
online = (flags & 0x02) != 0
|
|
2192
|
+
on = (flags & 0x01) != 0
|
|
2193
|
+
|
|
2194
|
+
con: int | None = None
|
|
2195
|
+
sid: int | None = None
|
|
2196
|
+
|
|
2197
|
+
# Optional bytes, matching Android:
|
|
2198
|
+
# - flags&0x04: con (1 byte)
|
|
2199
|
+
# - flags&0x08: sid (1 byte)
|
|
2200
|
+
# - flags&0x10: extra byte; if missing Android uses 0xFF
|
|
2201
|
+
if flags & 0x04:
|
|
2202
|
+
con = data[pos]
|
|
2203
|
+
pos += 1
|
|
2204
|
+
if flags & 0x08:
|
|
2205
|
+
sid = data[pos]
|
|
2206
|
+
pos += 1
|
|
2207
|
+
|
|
2208
|
+
if flags & 0x10:
|
|
2209
|
+
extra_byte = data[pos]
|
|
2210
|
+
pos += 1
|
|
588
2211
|
else:
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
2212
|
+
extra_byte = 0xFF
|
|
2213
|
+
|
|
2214
|
+
state = data[pos : pos + state_len]
|
|
2215
|
+
pos += state_len
|
|
2216
|
+
|
|
2217
|
+
padding_len = (flags >> 6) & 0x03
|
|
2218
|
+
padding = data[pos : pos + padding_len] if padding_len else b""
|
|
2219
|
+
pos += padding_len
|
|
2220
|
+
|
|
2221
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
2222
|
+
self._logger.debug(
|
|
2223
|
+
"[CASAMBI_UNITSTATE_PARSED] unit=%d flags=0x%02x prio=%d online=%s on=%s con=%s sid=%s extra_byte=%d state=%s padding=%s",
|
|
2224
|
+
unit_id,
|
|
2225
|
+
flags,
|
|
2226
|
+
prio,
|
|
2227
|
+
online,
|
|
2228
|
+
on,
|
|
2229
|
+
con,
|
|
2230
|
+
sid,
|
|
2231
|
+
extra_byte,
|
|
2232
|
+
b2a(state),
|
|
2233
|
+
b2a(padding),
|
|
593
2234
|
)
|
|
594
2235
|
|
|
595
|
-
|
|
2236
|
+
self._dataCallback(
|
|
2237
|
+
IncommingPacketType.UnitState,
|
|
2238
|
+
{
|
|
2239
|
+
"id": unit_id,
|
|
2240
|
+
"online": online,
|
|
2241
|
+
"on": on,
|
|
2242
|
+
"state": state,
|
|
2243
|
+
# Additional fields for diagnostics/analysis
|
|
2244
|
+
"flags": flags,
|
|
2245
|
+
"prio": prio,
|
|
2246
|
+
"state_len": state_len,
|
|
2247
|
+
"padding_len": padding_len,
|
|
2248
|
+
"con": con,
|
|
2249
|
+
"sid": sid,
|
|
2250
|
+
"extra_byte": extra_byte,
|
|
2251
|
+
"extra_float": extra_byte / 255.0,
|
|
2252
|
+
},
|
|
2253
|
+
)
|
|
596
2254
|
|
|
2255
|
+
oldPos = pos
|
|
597
2256
|
except IndexError:
|
|
598
2257
|
self._logger.error(
|
|
599
|
-
|
|
600
|
-
|
|
2258
|
+
"Ran out of data while parsing unit state! Remaining data %s in %s.",
|
|
2259
|
+
b2a(data[oldPos:]),
|
|
2260
|
+
b2a(data),
|
|
2261
|
+
)
|
|
2262
|
+
|
|
2263
|
+
def _parseSwitchEvent(
|
|
2264
|
+
self, data: bytes, packet_seq: int = None, raw_packet: bytes = None
|
|
2265
|
+
) -> None:
|
|
2266
|
+
"""Parse decrypted packet type=7 payload (INVOCATION stream).
|
|
2267
|
+
|
|
2268
|
+
Ground truth: casambi-android `v1.C1775b.Q(Q2.h)` parses decrypted packet type=7
|
|
2269
|
+
as a stream of INVOCATION frames. Switch button events are INVOCATIONs.
|
|
2270
|
+
"""
|
|
2271
|
+
|
|
2272
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
2273
|
+
data_hex = b2a(data)
|
|
2274
|
+
self._logger.debug(
|
|
2275
|
+
"Parsing incoming switch event packet #%s... Data: %s",
|
|
2276
|
+
packet_seq,
|
|
2277
|
+
data_hex,
|
|
2278
|
+
)
|
|
2279
|
+
self._logger.debug(
|
|
2280
|
+
"[CASAMBI_SWITCH_PACKET] Full data #%s: hex=%s len=%d",
|
|
2281
|
+
packet_seq,
|
|
2282
|
+
data_hex,
|
|
2283
|
+
len(data),
|
|
601
2284
|
)
|
|
602
2285
|
|
|
603
|
-
|
|
604
|
-
|
|
2286
|
+
events, stats = self._switchDecoder.decode(
|
|
2287
|
+
data,
|
|
2288
|
+
packet_seq=packet_seq,
|
|
2289
|
+
raw_packet=raw_packet,
|
|
2290
|
+
arrival_sequence=self._inPacketCount,
|
|
2291
|
+
)
|
|
2292
|
+
|
|
2293
|
+
self._logger.debug(
|
|
2294
|
+
"[CASAMBI_SWITCH_SUMMARY] packet=%s frames=%d button_frames=%d input_frames=%d ignored=%d emitted=%d suppressed_same_state=%d",
|
|
2295
|
+
packet_seq,
|
|
2296
|
+
stats.frames_total,
|
|
2297
|
+
stats.frames_button,
|
|
2298
|
+
stats.frames_input,
|
|
2299
|
+
stats.frames_ignored,
|
|
2300
|
+
stats.events_emitted,
|
|
2301
|
+
stats.events_suppressed_same_state,
|
|
2302
|
+
)
|
|
2303
|
+
|
|
2304
|
+
for ev in events:
|
|
2305
|
+
# Back-compat alias: older consumers looked for 'flags'
|
|
2306
|
+
if "flags" not in ev:
|
|
2307
|
+
ev["flags"] = ev.get("invocation_flags")
|
|
2308
|
+
self._dataCallback(IncommingPacketType.SwitchEvent, ev)
|
|
605
2309
|
|
|
606
2310
|
def _processSwitchMessage(
|
|
607
2311
|
self,
|
|
@@ -694,13 +2398,12 @@ class CasambiClient:
|
|
|
694
2398
|
f"action={action_display} ({event_string}), flags=0x{flags:02x}"
|
|
695
2399
|
)
|
|
696
2400
|
|
|
697
|
-
#
|
|
2401
|
+
# Log detailed info about type 0x08 messages (now processed, not filtered)
|
|
698
2402
|
if message_type == 0x08:
|
|
699
|
-
self._logger.
|
|
700
|
-
f"
|
|
701
|
-
f"action={action_display}, flags=0x{flags:02x}"
|
|
2403
|
+
self._logger.info(
|
|
2404
|
+
f"Type 0x08 event processed: button={button}, unit_id={unit_id}, "
|
|
2405
|
+
f"action={action_display}, event={event_string}, flags=0x{flags:02x}"
|
|
702
2406
|
)
|
|
703
|
-
return
|
|
704
2407
|
|
|
705
2408
|
self._dataCallback(
|
|
706
2409
|
IncommingPacketType.SwitchEvent,
|
|
@@ -712,7 +2415,11 @@ class CasambiClient:
|
|
|
712
2415
|
"event": event_string,
|
|
713
2416
|
"flags": flags,
|
|
714
2417
|
"extra_data": extra_data,
|
|
2418
|
+
# packet_sequence is the device-provided sequence number when available
|
|
2419
|
+
# (true 32-bit counter from the BLE header), otherwise the local arrival index.
|
|
715
2420
|
"packet_sequence": packet_seq,
|
|
2421
|
+
# Include the local arrival index for debugging and correlation.
|
|
2422
|
+
"arrival_sequence": self._inPacketCount,
|
|
716
2423
|
"raw_packet": b2a(raw_packet) if raw_packet else None,
|
|
717
2424
|
"decrypted_data": b2a(full_data),
|
|
718
2425
|
"message_position": start_pos,
|
|
@@ -723,9 +2430,22 @@ class CasambiClient:
|
|
|
723
2430
|
async def disconnect(self) -> None:
|
|
724
2431
|
self._logger.info("Disconnecting...")
|
|
725
2432
|
|
|
2433
|
+
if self._classicNoRxTask is not None:
|
|
2434
|
+
self._classicNoRxTask.cancel()
|
|
2435
|
+
self._classicNoRxTask = None
|
|
2436
|
+
|
|
726
2437
|
if self._callbackTask is not None:
|
|
2438
|
+
# Cancel and await the background callback task to avoid
|
|
2439
|
+
# 'Task was destroyed but it is pending' warnings.
|
|
727
2440
|
self._callbackTask.cancel()
|
|
728
|
-
|
|
2441
|
+
try:
|
|
2442
|
+
await self._callbackTask
|
|
2443
|
+
except asyncio.CancelledError:
|
|
2444
|
+
pass
|
|
2445
|
+
except Exception:
|
|
2446
|
+
self._logger.debug("Callback task finished with exception during disconnect.", exc_info=True)
|
|
2447
|
+
finally:
|
|
2448
|
+
self._callbackTask = None
|
|
729
2449
|
|
|
730
2450
|
if self._gattClient is not None and self._gattClient.is_connected:
|
|
731
2451
|
try:
|
|
@@ -735,3 +2455,39 @@ class CasambiClient:
|
|
|
735
2455
|
|
|
736
2456
|
self._connectionState = ConnectionState.NONE
|
|
737
2457
|
self._logger.info("Disconnected.")
|
|
2458
|
+
|
|
2459
|
+
def getClassicDiagnostics(self) -> dict[str, Any]:
|
|
2460
|
+
"""Return Classic protocol diagnostic state for external services.
|
|
2461
|
+
|
|
2462
|
+
This method provides a snapshot of Classic protocol state including:
|
|
2463
|
+
- Connection parameters (hash, mode, UUIDs)
|
|
2464
|
+
- RX/TX statistics
|
|
2465
|
+
- Last N TX and RX packets
|
|
2466
|
+
- Any detected errors or anomalies
|
|
2467
|
+
|
|
2468
|
+
Safe to call from HA services for dump_classic_diagnostics.
|
|
2469
|
+
"""
|
|
2470
|
+
return {
|
|
2471
|
+
"protocol_mode": self._protocolMode.name if self._protocolMode else None,
|
|
2472
|
+
"classic_header_mode": self._classicHeaderMode,
|
|
2473
|
+
"classic_hash_source": self._classicHashSource,
|
|
2474
|
+
"classic_conn_hash8_hex": b2a(self._classicConnHash8).decode("ascii") if self._classicConnHash8 else None,
|
|
2475
|
+
"classic_tx_uuid": self._classicTxCharUuid,
|
|
2476
|
+
"classic_notify_uuids": sorted(self._classicNotifyCharUuids) if self._classicNotifyCharUuids else [],
|
|
2477
|
+
"classic_first_rx_ts": self._classicFirstRxTs,
|
|
2478
|
+
"classic_rx_stats": {
|
|
2479
|
+
"frames": self._classicRxFrames,
|
|
2480
|
+
"verified": self._classicRxVerified,
|
|
2481
|
+
"unverifiable": self._classicRxUnverifiable,
|
|
2482
|
+
"parse_fail": self._classicRxParseFail,
|
|
2483
|
+
"type6_unitstate": self._classicRxType6,
|
|
2484
|
+
"type7_switch": self._classicRxType7,
|
|
2485
|
+
"type9_netconf": self._classicRxType9,
|
|
2486
|
+
"cmdstream": self._classicRxCmdStream,
|
|
2487
|
+
"unknown": self._classicRxUnknown,
|
|
2488
|
+
},
|
|
2489
|
+
"classic_tx_count": len(self._classicTxHistory),
|
|
2490
|
+
"classic_rx_count": len(self._classicRxHistory),
|
|
2491
|
+
"classic_tx_history": self._classicTxHistory[-20:], # Last 20
|
|
2492
|
+
"classic_rx_history": self._classicRxHistory[-20:], # Last 20
|
|
2493
|
+
}
|