casambi-bt-revamped 0.3.12.dev4__py3-none-any.whl → 0.3.12.dev6__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/_client.py +447 -150
- CasambiBt/_network.py +84 -7
- CasambiBt/_unit.py +2 -0
- CasambiBt/_version.py +10 -0
- {casambi_bt_revamped-0.3.12.dev4.dist-info → casambi_bt_revamped-0.3.12.dev6.dist-info}/METADATA +1 -1
- {casambi_bt_revamped-0.3.12.dev4.dist-info → casambi_bt_revamped-0.3.12.dev6.dist-info}/RECORD +10 -9
- {casambi_bt_revamped-0.3.12.dev4.dist-info → casambi_bt_revamped-0.3.12.dev6.dist-info}/WHEEL +0 -0
- {casambi_bt_revamped-0.3.12.dev4.dist-info → casambi_bt_revamped-0.3.12.dev6.dist-info}/licenses/LICENSE +0 -0
- {casambi_bt_revamped-0.3.12.dev4.dist-info → casambi_bt_revamped-0.3.12.dev6.dist-info}/top_level.txt +0 -0
CasambiBt/__init__.py
CHANGED
CasambiBt/_client.py
CHANGED
|
@@ -4,6 +4,7 @@ import logging
|
|
|
4
4
|
import os
|
|
5
5
|
import platform
|
|
6
6
|
import struct
|
|
7
|
+
import time
|
|
7
8
|
from binascii import b2a_hex as b2a
|
|
8
9
|
from collections.abc import Callable
|
|
9
10
|
from enum import Enum, IntEnum, auto, unique
|
|
@@ -54,6 +55,28 @@ class ProtocolMode(Enum):
|
|
|
54
55
|
CLASSIC = auto()
|
|
55
56
|
|
|
56
57
|
|
|
58
|
+
class _LogBurstLimiter:
|
|
59
|
+
"""Simple in-process log rate limiter (per key).
|
|
60
|
+
|
|
61
|
+
Home Assistant warns if a logger emits too many messages. We keep some high-signal
|
|
62
|
+
WARNING logs for Classic reverse engineering but avoid spamming.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self) -> None:
|
|
66
|
+
self._state: dict[str, tuple[float, int]] = {}
|
|
67
|
+
|
|
68
|
+
def allow(self, key: str, *, burst: int, window_s: float) -> bool:
|
|
69
|
+
now = time.monotonic()
|
|
70
|
+
start, count = self._state.get(key, (now, 0))
|
|
71
|
+
if (now - start) > window_s:
|
|
72
|
+
start, count = now, 0
|
|
73
|
+
if count >= burst:
|
|
74
|
+
self._state[key] = (start, count)
|
|
75
|
+
return False
|
|
76
|
+
self._state[key] = (start, count + 1)
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
|
|
57
80
|
MIN_VERSION: Final[int] = 10
|
|
58
81
|
MAX_VERSION: Final[int] = 11
|
|
59
82
|
|
|
@@ -100,33 +123,45 @@ class CasambiClient:
|
|
|
100
123
|
# Determined at runtime by inspecting GATT services/characteristics.
|
|
101
124
|
self._protocolMode: ProtocolMode | None = None
|
|
102
125
|
self._dataCharUuid: str | None = None
|
|
126
|
+
# EVO only: protocolVersion from the device-provided NodeInfo (byte1).
|
|
127
|
+
self._deviceProtocolVersion: int | None = None
|
|
103
128
|
|
|
104
129
|
# Classic protocol state
|
|
105
130
|
self._classicConnHash8: bytes | None = None
|
|
106
131
|
self._classicTxSeq: int = 0 # 16-bit sequence number (big endian on the wire)
|
|
107
132
|
self._classicCmdDiv: int = 0 # 8-bit per-command divider/id (matches u1.C1751c.b0)
|
|
108
|
-
|
|
109
|
-
#
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
133
|
+
# Classic header framing mode:
|
|
134
|
+
# - "conformant": [auth][sig][seq16][payload]
|
|
135
|
+
# - "legacy": [sig][payload]
|
|
136
|
+
# Ground truth: casambi-android `t1.P.n(...)` and `t1.P.o(...)`.
|
|
137
|
+
self._classicHeaderMode: str | None = None # "conformant" | "legacy"
|
|
138
|
+
|
|
139
|
+
# Rate limit WARNING logs (especially Classic RX) to keep HA usable.
|
|
140
|
+
self._logLimiter = _LogBurstLimiter()
|
|
141
|
+
self._classicRxFrames = 0
|
|
142
|
+
self._classicRxVerified = 0
|
|
143
|
+
self._classicRxUnverifiable = 0
|
|
144
|
+
self._classicRxParseFail = 0
|
|
145
|
+
self._classicRxLastStatsTs = time.monotonic()
|
|
117
146
|
|
|
118
147
|
@property
|
|
119
148
|
def protocolMode(self) -> ProtocolMode | None:
|
|
120
149
|
return self._protocolMode
|
|
121
150
|
|
|
122
|
-
def _checkProtocolVersion(self, version: int) -> None:
|
|
151
|
+
def _checkProtocolVersion(self, version: int, *, source: str = "unknown") -> None:
|
|
123
152
|
if version < MIN_VERSION:
|
|
124
|
-
|
|
125
|
-
|
|
153
|
+
# Legacy protocol versions are intentionally allowed. We keep this check as a warning
|
|
154
|
+
# because packet layouts/handshakes may differ and we want actionable tester logs.
|
|
155
|
+
msg = (
|
|
156
|
+
f"Legacy protocol version detected ({source}={version}). "
|
|
157
|
+
f"Versions < {MIN_VERSION} are not fully verified; attempting to continue."
|
|
126
158
|
)
|
|
159
|
+
self._logger.warning(msg)
|
|
160
|
+
return
|
|
127
161
|
if version > MAX_VERSION:
|
|
128
162
|
self._logger.warning(
|
|
129
|
-
"Version too new
|
|
163
|
+
"Version too new (%s=%i). Highest supported version is %i. Continue at your own risk.",
|
|
164
|
+
source,
|
|
130
165
|
version,
|
|
131
166
|
MAX_VERSION,
|
|
132
167
|
)
|
|
@@ -217,23 +252,50 @@ class CasambiClient:
|
|
|
217
252
|
# 2) EVO: auth char read starts with 0x01 (NodeInfo)
|
|
218
253
|
# 3) Classic "conformant": auth char read returns connection hash (first 8 bytes used)
|
|
219
254
|
|
|
255
|
+
cloud_protocol = getattr(self._network, "protocolVersion", None)
|
|
256
|
+
ca51_prefix: bytes | None = None
|
|
257
|
+
ca51_err: str | None = None
|
|
258
|
+
auth_prefix: bytes | None = None
|
|
259
|
+
auth_err: str | None = None
|
|
260
|
+
device_nodeinfo_protocol: int | None = None
|
|
261
|
+
|
|
262
|
+
def _log_probe_summary(mode: str) -> None:
|
|
263
|
+
# One stable, high-signal line for testers.
|
|
264
|
+
self._logger.warning(
|
|
265
|
+
"[CASAMBI_PROTOCOL_PROBE] address=%s mode=%s cloud_protocol=%s nodeinfo_b1=%s "
|
|
266
|
+
"data_uuid=%s classic_hash8_present=%s auth_read_prefix=%s ca51_read_prefix=%s ca51_read_error=%s auth_read_error=%s",
|
|
267
|
+
self.address,
|
|
268
|
+
mode,
|
|
269
|
+
cloud_protocol,
|
|
270
|
+
device_nodeinfo_protocol,
|
|
271
|
+
self._dataCharUuid,
|
|
272
|
+
bool(classic_hash and len(classic_hash) >= 8),
|
|
273
|
+
auth_prefix,
|
|
274
|
+
ca51_prefix,
|
|
275
|
+
ca51_err,
|
|
276
|
+
auth_err,
|
|
277
|
+
)
|
|
278
|
+
|
|
220
279
|
classic_hash: bytes | None = None
|
|
221
280
|
try:
|
|
222
281
|
classic_hash = await self._gattClient.read_gatt_char(CASA_CLASSIC_HASH_CHAR_UUID)
|
|
223
|
-
|
|
282
|
+
ca51_prefix = b2a(classic_hash[:10]) if classic_hash else None
|
|
283
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
284
|
+
self._logger.debug(
|
|
285
|
+
"[CASAMBI_GATT_PROBE] read ca51 ok len=%d prefix=%s",
|
|
286
|
+
0 if classic_hash is None else len(classic_hash),
|
|
287
|
+
ca51_prefix,
|
|
288
|
+
)
|
|
289
|
+
except Exception as e:
|
|
224
290
|
classic_hash = None
|
|
291
|
+
ca51_err = type(e).__name__
|
|
292
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
293
|
+
self._logger.debug("[CASAMBI_GATT_PROBE] read ca51 fail err=%s", ca51_err)
|
|
225
294
|
|
|
226
295
|
if classic_hash and len(classic_hash) >= 8:
|
|
227
|
-
if os.getenv("CASAMBI_BT_DISABLE_CLASSIC", "").strip() in {"1", "true", "TRUE", "yes", "YES"}:
|
|
228
|
-
raise ProtocolError("Classic protocol detected but disabled via CASAMBI_BT_DISABLE_CLASSIC=1")
|
|
229
|
-
|
|
230
|
-
if not self._network.hasClassicKeys():
|
|
231
|
-
raise ClassicKeysMissingError(
|
|
232
|
-
"Classic protocol detected but network has no visitorKey/managerKey."
|
|
233
|
-
)
|
|
234
|
-
|
|
235
296
|
self._protocolMode = ProtocolMode.CLASSIC
|
|
236
297
|
self._dataCharUuid = CASA_CLASSIC_DATA_CHAR_UUID
|
|
298
|
+
self._classicHeaderMode = "legacy"
|
|
237
299
|
|
|
238
300
|
# Read connection hash (first 8 bytes are used for CMAC signing).
|
|
239
301
|
raw_hash = classic_hash
|
|
@@ -260,7 +322,12 @@ class CasambiClient:
|
|
|
260
322
|
except Exception as e:
|
|
261
323
|
# Some firmwares may expose Classic signing on the EVO UUID instead.
|
|
262
324
|
# Fall through to auth-char probing if CA52 isn't available.
|
|
263
|
-
self._logger.
|
|
325
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
326
|
+
self._logger.debug(
|
|
327
|
+
"[CASAMBI_GATT_PROBE] start_notify ca52 fail err=%s; trying auth UUID probing.",
|
|
328
|
+
type(e).__name__,
|
|
329
|
+
exc_info=True,
|
|
330
|
+
)
|
|
264
331
|
self._protocolMode = None
|
|
265
332
|
self._dataCharUuid = None
|
|
266
333
|
self._classicConnHash8 = None
|
|
@@ -270,36 +337,76 @@ class CasambiClient:
|
|
|
270
337
|
self._connectionState = ConnectionState.AUTHENTICATED
|
|
271
338
|
self._logger.info("Protocol mode selected: CLASSIC")
|
|
272
339
|
if self._logger.isEnabledFor(logging.DEBUG):
|
|
340
|
+
self._logger.debug("[CASAMBI_GATT_PROBE] start_notify ca52 ok")
|
|
273
341
|
self._logger.debug(
|
|
274
342
|
"[CASAMBI_CLASSIC_CONN_HASH] len=%d hash=%s",
|
|
275
343
|
len(self._classicConnHash8),
|
|
276
344
|
b2a(self._classicConnHash8),
|
|
277
345
|
)
|
|
346
|
+
_log_probe_summary("CLASSIC")
|
|
278
347
|
return
|
|
279
348
|
|
|
280
349
|
# Conformant devices can expose the Classic signed channel on the EVO-style UUID too.
|
|
281
350
|
first: bytes | None = None
|
|
282
351
|
try:
|
|
283
352
|
first = await self._gattClient.read_gatt_char(CASA_AUTH_CHAR_UUID)
|
|
284
|
-
|
|
353
|
+
auth_prefix = b2a(first[:10]) if first else None
|
|
354
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
355
|
+
self._logger.debug(
|
|
356
|
+
"[CASAMBI_GATT_PROBE] read auth ok len=%d first_byte=%s prefix=%s",
|
|
357
|
+
0 if first is None else len(first),
|
|
358
|
+
None if not first else f"0x{first[0]:02x}",
|
|
359
|
+
auth_prefix,
|
|
360
|
+
)
|
|
361
|
+
except Exception as e:
|
|
285
362
|
first = None
|
|
363
|
+
auth_err = type(e).__name__
|
|
364
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
365
|
+
self._logger.debug("[CASAMBI_GATT_PROBE] read auth fail err=%s", auth_err)
|
|
286
366
|
|
|
287
367
|
if first and len(first) >= 2 and first[0] == 0x01:
|
|
288
368
|
# EVO NodeInfo packet starts with 0x01.
|
|
369
|
+
device_nodeinfo_protocol = first[1]
|
|
370
|
+
self._deviceProtocolVersion = device_nodeinfo_protocol
|
|
371
|
+
mtu = unit = flags = None
|
|
372
|
+
nonce_prefix = None
|
|
373
|
+
if len(first) >= 23:
|
|
374
|
+
try:
|
|
375
|
+
mtu, unit, flags, nonce = struct.unpack_from(">BHH16s", first, 2)
|
|
376
|
+
nonce_prefix = b2a(nonce[:8])
|
|
377
|
+
except Exception:
|
|
378
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
379
|
+
self._logger.debug("Failed to parse NodeInfo fields for logging.", exc_info=True)
|
|
380
|
+
|
|
381
|
+
self._logger.info(
|
|
382
|
+
"[CASAMBI_EVO_NODEINFO] cloud_protocol=%s nodeinfo_b1=%s mtu=%s unit=%s flags=%s nonce_prefix=%s len=%d prefix=%s",
|
|
383
|
+
cloud_protocol,
|
|
384
|
+
device_nodeinfo_protocol,
|
|
385
|
+
mtu,
|
|
386
|
+
unit,
|
|
387
|
+
None if flags is None else f"0x{flags:04x}",
|
|
388
|
+
nonce_prefix,
|
|
389
|
+
len(first),
|
|
390
|
+
b2a(first[: min(len(first), 32)]),
|
|
391
|
+
)
|
|
392
|
+
if len(first) < 23:
|
|
393
|
+
self._logger.warning(
|
|
394
|
+
"[CASAMBI_EVO_NODEINFO_SHORT] len=%d cloud_protocol=%s nodeinfo_b1=%s prefix=%s",
|
|
395
|
+
len(first),
|
|
396
|
+
cloud_protocol,
|
|
397
|
+
device_nodeinfo_protocol,
|
|
398
|
+
b2a(first[: min(len(first), 32)]),
|
|
399
|
+
)
|
|
400
|
+
|
|
289
401
|
self._protocolMode = ProtocolMode.EVO
|
|
290
402
|
self._dataCharUuid = CASA_AUTH_CHAR_UUID
|
|
291
|
-
self.
|
|
403
|
+
self._classicHeaderMode = None
|
|
292
404
|
self._logger.info("Protocol mode selected: EVO")
|
|
405
|
+
_log_probe_summary("EVO")
|
|
293
406
|
return
|
|
294
407
|
|
|
295
408
|
if first is not None:
|
|
296
409
|
# Otherwise, treat as Classic conformant: read provides connection hash.
|
|
297
|
-
if os.getenv("CASAMBI_BT_DISABLE_CLASSIC", "").strip() in {"1", "true", "TRUE", "yes", "YES"}:
|
|
298
|
-
raise ProtocolError("Classic protocol detected but disabled via CASAMBI_BT_DISABLE_CLASSIC=1")
|
|
299
|
-
if not self._network.hasClassicKeys():
|
|
300
|
-
raise ClassicKeysMissingError(
|
|
301
|
-
"Classic protocol detected but network has no visitorKey/managerKey."
|
|
302
|
-
)
|
|
303
410
|
if len(first) < 8:
|
|
304
411
|
raise ClassicHandshakeError(
|
|
305
412
|
f"Classic connection hash read failed/too short (len={len(first)})."
|
|
@@ -307,6 +414,7 @@ class CasambiClient:
|
|
|
307
414
|
|
|
308
415
|
self._protocolMode = ProtocolMode.CLASSIC
|
|
309
416
|
self._dataCharUuid = CASA_AUTH_CHAR_UUID
|
|
417
|
+
self._classicHeaderMode = "conformant"
|
|
310
418
|
self._classicConnHash8 = bytes(first[:8])
|
|
311
419
|
self._classicCmdDiv = int.from_bytes(os.urandom(1), "big") or 1
|
|
312
420
|
self._classicTxSeq = 0
|
|
@@ -323,13 +431,16 @@ class CasambiClient:
|
|
|
323
431
|
self._connectionState = ConnectionState.AUTHENTICATED
|
|
324
432
|
self._logger.info("Protocol mode selected: CLASSIC")
|
|
325
433
|
if self._logger.isEnabledFor(logging.DEBUG):
|
|
434
|
+
self._logger.debug("[CASAMBI_GATT_PROBE] start_notify auth ok (classic conformant)")
|
|
326
435
|
self._logger.debug(
|
|
327
436
|
"[CASAMBI_CLASSIC_CONN_HASH] len=%d hash=%s",
|
|
328
437
|
len(self._classicConnHash8),
|
|
329
438
|
b2a(self._classicConnHash8),
|
|
330
439
|
)
|
|
440
|
+
_log_probe_summary("CLASSIC")
|
|
331
441
|
return
|
|
332
442
|
|
|
443
|
+
_log_probe_summary("UNKNOWN")
|
|
333
444
|
raise ProtocolError(
|
|
334
445
|
"No supported Casambi characteristics found (Classic ca51/ca52 or EVO/Classic-conformant auth char)."
|
|
335
446
|
)
|
|
@@ -351,15 +462,38 @@ class CasambiClient:
|
|
|
351
462
|
try:
|
|
352
463
|
# Initiate communication with device
|
|
353
464
|
firstResp = await self._gattClient.read_gatt_char(CASA_AUTH_CHAR_UUID)
|
|
354
|
-
self._logger.
|
|
465
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
466
|
+
self._logger.debug(
|
|
467
|
+
"[CASAMBI_EVO_NODEINFO_RAW] len=%d prefix=%s",
|
|
468
|
+
len(firstResp),
|
|
469
|
+
b2a(firstResp[: min(len(firstResp), 32)]),
|
|
470
|
+
)
|
|
355
471
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
):
|
|
472
|
+
cloud_protocol = getattr(self._network, "protocolVersion", None)
|
|
473
|
+
|
|
474
|
+
# EVO key exchange expects the NodeInfo packet (0x01 ...).
|
|
475
|
+
if len(firstResp) < 2 or firstResp[0] != 0x01:
|
|
476
|
+
self._logger.error(
|
|
477
|
+
"[CASAMBI_EVO_NODEINFO_UNEXPECTED] expected_prefix=01 len=%d prefix=%s",
|
|
478
|
+
len(firstResp),
|
|
479
|
+
b2a(firstResp[: min(len(firstResp), 32)]),
|
|
480
|
+
)
|
|
481
|
+
raise ProtocolError("Unexpected NodeInfo response while starting key exchange.")
|
|
482
|
+
|
|
483
|
+
device_protocol = firstResp[1]
|
|
484
|
+
self._deviceProtocolVersion = device_protocol
|
|
485
|
+
# Do not interpret NodeInfo byte1 as "cloud protocolVersion".
|
|
486
|
+
# Some firmwares use a different numbering scheme, so mismatch warnings are misleading.
|
|
487
|
+
|
|
488
|
+
if len(firstResp) < 23:
|
|
360
489
|
self._logger.error(
|
|
361
|
-
"
|
|
490
|
+
"[CASAMBI_EVO_NODEINFO_SHORT] len=%d cloud_protocol=%s nodeinfo_b1=%s prefix=%s",
|
|
491
|
+
len(firstResp),
|
|
492
|
+
cloud_protocol,
|
|
493
|
+
device_protocol,
|
|
494
|
+
b2a(firstResp[: min(len(firstResp), 32)]),
|
|
362
495
|
)
|
|
496
|
+
raise ProtocolError("NodeInfo response too short while starting key exchange.")
|
|
363
497
|
|
|
364
498
|
# Parse device info
|
|
365
499
|
self._mtu, self._unit, self._flags, self._nonce = struct.unpack_from(
|
|
@@ -449,14 +583,6 @@ class CasambiClient:
|
|
|
449
583
|
def _callbackMulitplexer(
|
|
450
584
|
self, handle: BleakGATTCharacteristic, data: bytes
|
|
451
585
|
) -> None:
|
|
452
|
-
if self._logRawNotifies and self._logger.isEnabledFor(logging.DEBUG):
|
|
453
|
-
self._logger.debug(
|
|
454
|
-
"Callback on handle %s (%s): %s",
|
|
455
|
-
getattr(handle, "handle", "?"),
|
|
456
|
-
getattr(handle, "uuid", "?"),
|
|
457
|
-
b2a(data),
|
|
458
|
-
)
|
|
459
|
-
|
|
460
586
|
if self._connectionState == ConnectionState.CONNECTED:
|
|
461
587
|
self._exchNofityCallback(handle, data)
|
|
462
588
|
elif self._connectionState == ConnectionState.KEY_EXCHANGED:
|
|
@@ -598,7 +724,7 @@ class CasambiClient:
|
|
|
598
724
|
# EVO sends INVOCATION operations (packet type=0x07) inside the encrypted channel.
|
|
599
725
|
# Classic sends signed command frames on the CA52 channel.
|
|
600
726
|
if self._protocolMode == ProtocolMode.CLASSIC:
|
|
601
|
-
await self.
|
|
727
|
+
await self._sendClassic(packet)
|
|
602
728
|
return
|
|
603
729
|
|
|
604
730
|
self._checkState(ConnectionState.AUTHENTICATED)
|
|
@@ -694,7 +820,7 @@ class CasambiClient:
|
|
|
694
820
|
|
|
695
821
|
return bytes(b)
|
|
696
822
|
|
|
697
|
-
async def
|
|
823
|
+
async def _sendClassic(self, command_bytes: bytes) -> None:
|
|
698
824
|
self._checkState(ConnectionState.AUTHENTICATED)
|
|
699
825
|
if self._protocolMode != ProtocolMode.CLASSIC:
|
|
700
826
|
raise ProtocolError("Classic send called while not in Classic protocol mode.")
|
|
@@ -703,72 +829,85 @@ class CasambiClient:
|
|
|
703
829
|
if self._classicConnHash8 is None:
|
|
704
830
|
raise ClassicHandshakeError("Classic connection hash not available.")
|
|
705
831
|
|
|
706
|
-
# Decide whether to use visitor or manager key.
|
|
707
|
-
if use_manager is None:
|
|
708
|
-
use_manager = os.getenv("CASAMBI_BT_CLASSIC_USE_MANAGER", "").strip() in {
|
|
709
|
-
"1",
|
|
710
|
-
"true",
|
|
711
|
-
"TRUE",
|
|
712
|
-
"yes",
|
|
713
|
-
"YES",
|
|
714
|
-
}
|
|
715
|
-
|
|
716
832
|
visitor_key = self._network.classicVisitorKey()
|
|
717
833
|
manager_key = self._network.classicManagerKey()
|
|
718
834
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
key
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
# [..] command bytes
|
|
835
|
+
# Key selection mirrors Android's intent:
|
|
836
|
+
# - Use manager key if our cloud session is manager and a managerKey exists.
|
|
837
|
+
# - Else use visitor key if present.
|
|
838
|
+
# - Else fall back to manager key if present.
|
|
839
|
+
# - Else send an unsigned frame (signature bytes remain zeros), which Android does when keys are null.
|
|
840
|
+
key_name = "none"
|
|
841
|
+
auth_level = 0x02 # visitor by default
|
|
842
|
+
key = None
|
|
843
|
+
if manager_key is not None and getattr(self._network, "isManager", lambda: False)():
|
|
844
|
+
key_name = "manager"
|
|
845
|
+
auth_level = 0x03
|
|
846
|
+
key = manager_key
|
|
847
|
+
elif visitor_key is not None:
|
|
848
|
+
key_name = "visitor"
|
|
849
|
+
auth_level = 0x02
|
|
850
|
+
key = visitor_key
|
|
851
|
+
elif manager_key is not None:
|
|
852
|
+
key_name = "manager"
|
|
853
|
+
auth_level = 0x03
|
|
854
|
+
key = manager_key
|
|
855
|
+
|
|
856
|
+
header_mode = self._classicHeaderMode or "conformant"
|
|
857
|
+
|
|
858
|
+
seq: int | None = None
|
|
859
|
+
sig_len: int
|
|
745
860
|
pkt = bytearray()
|
|
746
|
-
pkt.append(auth_level)
|
|
747
|
-
pkt.extend(b"\x00" * sig_len)
|
|
748
|
-
pkt.extend(b"\x00\x00")
|
|
749
|
-
pkt.extend(command_bytes)
|
|
750
|
-
|
|
751
|
-
seq_off = 1 + sig_len
|
|
752
|
-
pkt[seq_off] = (seq >> 8) & 0xFF
|
|
753
|
-
pkt[seq_off + 1] = seq & 0xFF
|
|
754
861
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
862
|
+
if header_mode == "conformant":
|
|
863
|
+
sig_len = 16 if auth_level == 0x03 else 4
|
|
864
|
+
seq = self._classic_next_seq()
|
|
865
|
+
|
|
866
|
+
# Header layout (rVar.Z=true / "conformant" classic):
|
|
867
|
+
# [0] auth_level (2 visitor / 3 manager)
|
|
868
|
+
# [1..sig_len] CMAC prefix placeholder (filled after CMAC computation)
|
|
869
|
+
# [1+sig_len .. 1+sig_len+1] 16-bit sequence, big endian (included in CMAC input)
|
|
870
|
+
# [..] command bytes
|
|
871
|
+
pkt.append(auth_level)
|
|
872
|
+
pkt.extend(b"\x00" * sig_len)
|
|
873
|
+
pkt.extend(b"\x00\x00")
|
|
874
|
+
pkt.extend(command_bytes)
|
|
875
|
+
|
|
876
|
+
seq_off = 1 + sig_len
|
|
877
|
+
pkt[seq_off] = (seq >> 8) & 0xFF
|
|
878
|
+
pkt[seq_off + 1] = seq & 0xFF
|
|
879
|
+
|
|
880
|
+
if key is not None:
|
|
881
|
+
cmac_input = bytes(pkt[seq_off:]) # includes seq + command bytes
|
|
882
|
+
prefix = classic_cmac_prefix(key, self._classicConnHash8, cmac_input, sig_len)
|
|
883
|
+
pkt[1 : 1 + sig_len] = prefix
|
|
884
|
+
|
|
885
|
+
elif header_mode == "legacy":
|
|
886
|
+
# Legacy/non-conformant classic: only a 4-byte CMAC prefix, no auth byte, no seq.
|
|
887
|
+
sig_len = 4
|
|
888
|
+
pkt.extend(b"\x00" * sig_len)
|
|
889
|
+
pkt.extend(command_bytes)
|
|
890
|
+
|
|
891
|
+
if key is not None:
|
|
892
|
+
cmac_input = bytes(command_bytes)
|
|
893
|
+
prefix = classic_cmac_prefix(key, self._classicConnHash8, cmac_input, sig_len)
|
|
894
|
+
pkt[0:sig_len] = prefix
|
|
895
|
+
else:
|
|
896
|
+
raise ProtocolError(f"Unknown Classic header mode: {header_mode}")
|
|
758
897
|
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
898
|
+
# WARNING-level TX logs are intentional: they are needed for Classic reverse engineering.
|
|
899
|
+
# Keep payload logging minimal (prefix only).
|
|
900
|
+
if self._logLimiter.allow("classic_tx", burst=50, window_s=60.0):
|
|
901
|
+
self._logger.warning(
|
|
902
|
+
"[CASAMBI_CLASSIC_TX] header=%s key=%s auth=0x%02x sig_len=%d seq=%s cmd_len=%d total_len=%d prefix=%s",
|
|
903
|
+
header_mode,
|
|
762
904
|
key_name,
|
|
763
905
|
auth_level,
|
|
764
906
|
sig_len,
|
|
765
|
-
seq,
|
|
907
|
+
None if seq is None else f"0x{seq:04x}",
|
|
766
908
|
len(command_bytes),
|
|
767
909
|
len(pkt),
|
|
768
|
-
|
|
769
|
-
self._logger.debug(
|
|
770
|
-
"[CASAMBI_CLASSIC_TX_RAW] %s",
|
|
771
|
-
b2a(bytes(pkt[: min(len(pkt), 64)])) + (b"..." if len(pkt) > 64 else b""),
|
|
910
|
+
b2a(bytes(pkt[: min(len(pkt), 24)])),
|
|
772
911
|
)
|
|
773
912
|
|
|
774
913
|
# Classic packets can exceed 20 bytes when using a 16-byte manager signature.
|
|
@@ -851,6 +990,7 @@ class CasambiClient:
|
|
|
851
990
|
Ground truth: casambi-android `t1.P.o(...)`.
|
|
852
991
|
"""
|
|
853
992
|
self._inPacketCount += 1
|
|
993
|
+
self._classicRxFrames += 1
|
|
854
994
|
|
|
855
995
|
raw = bytes(data)
|
|
856
996
|
if self._logger.isEnabledFor(logging.DEBUG):
|
|
@@ -861,69 +1001,226 @@ class CasambiClient:
|
|
|
861
1001
|
)
|
|
862
1002
|
|
|
863
1003
|
if self._classicConnHash8 is None:
|
|
864
|
-
self.
|
|
1004
|
+
if self._logLimiter.allow("classic_rx_no_hash", burst=5, window_s=60.0):
|
|
1005
|
+
self._logger.warning("[CASAMBI_CLASSIC_RX] missing_connection_hash len=%d", len(raw))
|
|
865
1006
|
return
|
|
866
1007
|
|
|
867
1008
|
visitor_key = self._network.classicVisitorKey()
|
|
868
1009
|
manager_key = self._network.classicManagerKey()
|
|
869
1010
|
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
1011
|
+
def _plausible_payload(payload: bytes) -> bool:
|
|
1012
|
+
if not payload:
|
|
1013
|
+
return False
|
|
1014
|
+
if payload[0] in (
|
|
1015
|
+
IncommingPacketType.UnitState,
|
|
1016
|
+
IncommingPacketType.SwitchEvent,
|
|
1017
|
+
IncommingPacketType.NetworkConfig,
|
|
1018
|
+
):
|
|
1019
|
+
return True
|
|
1020
|
+
# Classic command record stream: record[0] = (len+239) mod 256
|
|
1021
|
+
if len(payload) >= 2:
|
|
1022
|
+
rec_len = (payload[0] - 239) & 0xFF
|
|
1023
|
+
if 2 <= rec_len <= len(payload):
|
|
1024
|
+
return True
|
|
1025
|
+
return False
|
|
1026
|
+
|
|
1027
|
+
def _score(verified: bool | None, payload: bytes) -> int:
|
|
1028
|
+
plausible = _plausible_payload(payload)
|
|
1029
|
+
if verified is True:
|
|
1030
|
+
return 100
|
|
1031
|
+
if plausible and verified is None:
|
|
1032
|
+
return 50
|
|
1033
|
+
if plausible and verified is False:
|
|
1034
|
+
return 20
|
|
1035
|
+
return 0
|
|
1036
|
+
|
|
1037
|
+
def _parse_conformant(raw_bytes: bytes) -> dict[str, Any] | None:
|
|
1038
|
+
if len(raw_bytes) < 1 + 4 + 2:
|
|
1039
|
+
return None
|
|
1040
|
+
auth_level = raw_bytes[0]
|
|
1041
|
+
if auth_level == 0x02:
|
|
1042
|
+
sig_len = 4
|
|
1043
|
+
key_name = "visitor"
|
|
1044
|
+
key = visitor_key
|
|
1045
|
+
elif auth_level == 0x03:
|
|
1046
|
+
sig_len = 16
|
|
1047
|
+
key_name = "manager"
|
|
1048
|
+
key = manager_key
|
|
1049
|
+
else:
|
|
1050
|
+
return None
|
|
1051
|
+
|
|
1052
|
+
header_len = 1 + sig_len + 2
|
|
1053
|
+
if len(raw_bytes) < header_len:
|
|
1054
|
+
return None
|
|
874
1055
|
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
("manager", manager_key, 16),
|
|
880
|
-
]
|
|
1056
|
+
sig = raw_bytes[1 : 1 + sig_len]
|
|
1057
|
+
cmac_input = raw_bytes[1 + sig_len :] # seq(2) + payload
|
|
1058
|
+
seq = int.from_bytes(cmac_input[:2], byteorder="big", signed=False)
|
|
1059
|
+
payload = cmac_input[2:]
|
|
881
1060
|
|
|
882
|
-
|
|
1061
|
+
verified: bool | None
|
|
883
1062
|
if key is None:
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1063
|
+
verified = None
|
|
1064
|
+
else:
|
|
1065
|
+
try:
|
|
1066
|
+
expected = classic_cmac_prefix(key, self._classicConnHash8, cmac_input, sig_len)
|
|
1067
|
+
except Exception:
|
|
1068
|
+
verified = False
|
|
1069
|
+
else:
|
|
1070
|
+
verified = expected == sig
|
|
1071
|
+
|
|
1072
|
+
return {
|
|
1073
|
+
"mode": "conformant",
|
|
1074
|
+
"auth_level": auth_level,
|
|
1075
|
+
"sig_len": sig_len,
|
|
1076
|
+
"seq": seq,
|
|
1077
|
+
"key_name": key_name if key is not None else None,
|
|
1078
|
+
"verified": verified,
|
|
1079
|
+
"payload": payload,
|
|
1080
|
+
}
|
|
892
1081
|
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
1082
|
+
def _parse_legacy(raw_bytes: bytes, *, sig_len: int) -> dict[str, Any] | None:
|
|
1083
|
+
if len(raw_bytes) < sig_len + 1:
|
|
1084
|
+
return None
|
|
1085
|
+
sig = raw_bytes[:sig_len]
|
|
1086
|
+
payload = raw_bytes[sig_len:]
|
|
1087
|
+
|
|
1088
|
+
# In non-conformant mode Android still selects visitor/manager key for CMAC,
|
|
1089
|
+
# but the header contains only the CMAC prefix (typically 4 bytes).
|
|
1090
|
+
verified: bool | None = None
|
|
1091
|
+
key_name: str | None = None
|
|
1092
|
+
|
|
1093
|
+
keys_to_try: list[tuple[str, bytes | None]] = [
|
|
1094
|
+
("visitor", visitor_key),
|
|
1095
|
+
("manager", manager_key),
|
|
1096
|
+
]
|
|
1097
|
+
any_key = any(k is not None for _, k in keys_to_try)
|
|
1098
|
+
if any_key:
|
|
1099
|
+
verified = False
|
|
1100
|
+
for nm, key in keys_to_try:
|
|
1101
|
+
if key is None:
|
|
1102
|
+
continue
|
|
1103
|
+
try:
|
|
1104
|
+
expected = classic_cmac_prefix(key, self._classicConnHash8, payload, sig_len)
|
|
1105
|
+
except Exception:
|
|
1106
|
+
continue
|
|
1107
|
+
if expected == sig:
|
|
1108
|
+
verified = True
|
|
1109
|
+
key_name = nm
|
|
1110
|
+
break
|
|
897
1111
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
sig_len
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
name,
|
|
908
|
-
auth_level,
|
|
909
|
-
slen,
|
|
910
|
-
seq,
|
|
911
|
-
)
|
|
912
|
-
break
|
|
1112
|
+
return {
|
|
1113
|
+
"mode": "legacy",
|
|
1114
|
+
"auth_level": None,
|
|
1115
|
+
"sig_len": sig_len,
|
|
1116
|
+
"seq": None,
|
|
1117
|
+
"key_name": key_name,
|
|
1118
|
+
"verified": verified,
|
|
1119
|
+
"payload": payload,
|
|
1120
|
+
}
|
|
913
1121
|
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
1122
|
+
# Try the currently selected header mode first, then fall back.
|
|
1123
|
+
# Some mixed/legacy setups differ between CA52 (legacy) and auth-UUID (conformant).
|
|
1124
|
+
parsed_candidates: list[dict[str, Any]] = []
|
|
1125
|
+
preferred = self._classicHeaderMode or "conformant"
|
|
1126
|
+
if preferred == "legacy":
|
|
1127
|
+
for sl in (4, 16):
|
|
1128
|
+
r = _parse_legacy(raw, sig_len=sl)
|
|
1129
|
+
if r is not None:
|
|
1130
|
+
parsed_candidates.append(r)
|
|
1131
|
+
r = _parse_conformant(raw)
|
|
1132
|
+
if r is not None:
|
|
1133
|
+
parsed_candidates.append(r)
|
|
919
1134
|
else:
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
1135
|
+
r = _parse_conformant(raw)
|
|
1136
|
+
if r is not None:
|
|
1137
|
+
parsed_candidates.append(r)
|
|
1138
|
+
for sl in (4, 16):
|
|
1139
|
+
r = _parse_legacy(raw, sig_len=sl)
|
|
1140
|
+
if r is not None:
|
|
1141
|
+
parsed_candidates.append(r)
|
|
1142
|
+
|
|
1143
|
+
if not parsed_candidates:
|
|
1144
|
+
self._classicRxParseFail += 1
|
|
1145
|
+
if self._logLimiter.allow("classic_rx_parse_fail", burst=5, window_s=60.0):
|
|
1146
|
+
self._logger.warning(
|
|
1147
|
+
"[CASAMBI_CLASSIC_RX_PARSE_FAIL] len=%d prefix=%s",
|
|
1148
|
+
len(raw),
|
|
1149
|
+
b2a(raw[: min(len(raw), 32)]),
|
|
1150
|
+
)
|
|
1151
|
+
return
|
|
923
1152
|
|
|
924
|
-
|
|
1153
|
+
# Choose best candidate by score; tie-breaker prefers current mode.
|
|
1154
|
+
for c in parsed_candidates:
|
|
1155
|
+
c["score"] = _score(c["verified"], c["payload"])
|
|
1156
|
+
|
|
1157
|
+
parsed_candidates.sort(
|
|
1158
|
+
key=lambda c: (
|
|
1159
|
+
c["score"],
|
|
1160
|
+
1 if c["mode"] == preferred else 0,
|
|
1161
|
+
-c["sig_len"],
|
|
1162
|
+
),
|
|
1163
|
+
reverse=True,
|
|
1164
|
+
)
|
|
1165
|
+
best = parsed_candidates[0]
|
|
1166
|
+
|
|
1167
|
+
if best["score"] == 0:
|
|
1168
|
+
self._classicRxParseFail += 1
|
|
1169
|
+
if self._logLimiter.allow("classic_rx_unplausible", burst=5, window_s=60.0):
|
|
1170
|
+
self._logger.warning(
|
|
1171
|
+
"[CASAMBI_CLASSIC_RX_UNPLAUSIBLE] preferred=%s len=%d prefix=%s",
|
|
1172
|
+
preferred,
|
|
1173
|
+
len(raw),
|
|
1174
|
+
b2a(raw[: min(len(raw), 32)]),
|
|
1175
|
+
)
|
|
925
1176
|
return
|
|
926
1177
|
|
|
1178
|
+
payload = best["payload"]
|
|
1179
|
+
verified = best["verified"]
|
|
1180
|
+
if verified is True:
|
|
1181
|
+
self._classicRxVerified += 1
|
|
1182
|
+
elif verified is None:
|
|
1183
|
+
self._classicRxUnverifiable += 1
|
|
1184
|
+
|
|
1185
|
+
# Auto-correct header mode if the other format parses much better.
|
|
1186
|
+
if best["mode"] != preferred:
|
|
1187
|
+
# Only switch if we got a stronger signal (verified or plausible payload with fewer assumptions).
|
|
1188
|
+
if best["score"] >= 50 and self._logLimiter.allow("classic_rx_mode_switch", burst=3, window_s=3600.0):
|
|
1189
|
+
self._logger.warning(
|
|
1190
|
+
"[CASAMBI_CLASSIC_RX_MODE] switching %s -> %s (score=%d verified=%s sig_len=%d)",
|
|
1191
|
+
preferred,
|
|
1192
|
+
best["mode"],
|
|
1193
|
+
best["score"],
|
|
1194
|
+
verified,
|
|
1195
|
+
best["sig_len"],
|
|
1196
|
+
)
|
|
1197
|
+
self._classicHeaderMode = best["mode"]
|
|
1198
|
+
|
|
1199
|
+
# Sample RX logs (limited) + periodic stats (limited).
|
|
1200
|
+
if self._logLimiter.allow("classic_rx_sample", burst=10, window_s=60.0):
|
|
1201
|
+
self._logger.warning(
|
|
1202
|
+
"[CASAMBI_CLASSIC_RX] header=%s verified=%s auth=%s sig_len=%d seq=%s payload_prefix=%s",
|
|
1203
|
+
best["mode"],
|
|
1204
|
+
verified,
|
|
1205
|
+
None if best["auth_level"] is None else f"0x{best['auth_level']:02x}",
|
|
1206
|
+
best["sig_len"],
|
|
1207
|
+
None if best["seq"] is None else f"0x{best['seq']:04x}",
|
|
1208
|
+
b2a(payload[: min(len(payload), 32)]),
|
|
1209
|
+
)
|
|
1210
|
+
now = time.monotonic()
|
|
1211
|
+
if (now - self._classicRxLastStatsTs) > 60.0 and self._logLimiter.allow(
|
|
1212
|
+
"classic_rx_stats", burst=2, window_s=60.0
|
|
1213
|
+
):
|
|
1214
|
+
self._classicRxLastStatsTs = now
|
|
1215
|
+
self._logger.warning(
|
|
1216
|
+
"[CASAMBI_CLASSIC_RX_STATS] frames=%d verified=%d unverifiable=%d parse_fail=%d header=%s",
|
|
1217
|
+
self._classicRxFrames,
|
|
1218
|
+
self._classicRxVerified,
|
|
1219
|
+
self._classicRxUnverifiable,
|
|
1220
|
+
self._classicRxParseFail,
|
|
1221
|
+
self._classicHeaderMode,
|
|
1222
|
+
)
|
|
1223
|
+
|
|
927
1224
|
# If the payload starts with a known EVO packet type, reuse existing parsers.
|
|
928
1225
|
packet_type = payload[0]
|
|
929
1226
|
if packet_type in (IncommingPacketType.UnitState, IncommingPacketType.SwitchEvent, IncommingPacketType.NetworkConfig):
|
CasambiBt/_network.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import logging
|
|
3
|
+
import platform
|
|
3
4
|
import pickle
|
|
4
5
|
from dataclasses import dataclass
|
|
5
6
|
from datetime import datetime, timedelta
|
|
6
|
-
from typing import Final, cast
|
|
7
|
+
from typing import Any, Final, cast
|
|
7
8
|
|
|
8
9
|
import httpx
|
|
9
10
|
from httpx import AsyncClient, RequestError
|
|
@@ -12,6 +13,7 @@ from ._cache import Cache
|
|
|
12
13
|
from ._constants import DEVICE_NAME
|
|
13
14
|
from ._keystore import KeyStore
|
|
14
15
|
from ._unit import Group, Scene, Unit, UnitControl, UnitControlType, UnitType
|
|
16
|
+
from ._version import __version__
|
|
15
17
|
from .errors import (
|
|
16
18
|
AuthenticationError,
|
|
17
19
|
NetworkNotFoundError,
|
|
@@ -64,6 +66,30 @@ class Network:
|
|
|
64
66
|
|
|
65
67
|
self._cache = cache
|
|
66
68
|
|
|
69
|
+
# Android always includes a "token" (and typically "clientInfo") in cloud requests.
|
|
70
|
+
# We keep these stable for the process lifetime to make tester logs comparable.
|
|
71
|
+
self._token: str = self._make_token()
|
|
72
|
+
self._clientInfo: dict[str, Any] = self._make_client_info()
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _make_token() -> str:
|
|
76
|
+
# Ground truth: casambi-android `w1.o.p(...)` sends `token` for session requests.
|
|
77
|
+
#
|
|
78
|
+
# Keep this structured (Android uses "brand/model/device/cpu/unknown") but avoid hostnames/PII.
|
|
79
|
+
sys = platform.system().lower() or "unknown"
|
|
80
|
+
machine = platform.machine().lower() or "unknown"
|
|
81
|
+
return f"python/{sys}/{machine}/unknown/unknown"
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def _make_client_info() -> dict[str, Any]:
|
|
85
|
+
# Ground truth: casambi-android `w1.o.g(...)` includes `clientInfo`.
|
|
86
|
+
return {
|
|
87
|
+
"name": "casambi-bt-revamped",
|
|
88
|
+
"version": __version__,
|
|
89
|
+
"python": platform.python_version(),
|
|
90
|
+
"platform": platform.platform(),
|
|
91
|
+
}
|
|
92
|
+
|
|
67
93
|
async def load(self) -> None:
|
|
68
94
|
self._keystore = KeyStore(self._cache)
|
|
69
95
|
await self._keystore.load()
|
|
@@ -150,6 +176,10 @@ class Network:
|
|
|
150
176
|
return False
|
|
151
177
|
return not self._session.expired()
|
|
152
178
|
|
|
179
|
+
def isManager(self) -> bool:
|
|
180
|
+
"""Whether the current cloud session has manager privileges."""
|
|
181
|
+
return bool(self._session and self._session.manager)
|
|
182
|
+
|
|
153
183
|
@property
|
|
154
184
|
def keyStore(self) -> KeyStore:
|
|
155
185
|
return self._keystore
|
|
@@ -186,7 +216,12 @@ class Network:
|
|
|
186
216
|
getSessionUrl = f"https://api.casambi.com/network/{self._id}/session"
|
|
187
217
|
|
|
188
218
|
res = await self._httpClient.post(
|
|
189
|
-
getSessionUrl,
|
|
219
|
+
getSessionUrl,
|
|
220
|
+
json={
|
|
221
|
+
"token": self._token,
|
|
222
|
+
"password": password,
|
|
223
|
+
"deviceName": DEVICE_NAME,
|
|
224
|
+
},
|
|
190
225
|
)
|
|
191
226
|
if res.status_code == httpx.codes.OK:
|
|
192
227
|
# Parse session
|
|
@@ -232,7 +267,9 @@ class Network:
|
|
|
232
267
|
getNetworkUrl,
|
|
233
268
|
json={
|
|
234
269
|
"formatVersion": 1,
|
|
270
|
+
"token": self._token,
|
|
235
271
|
"deviceName": DEVICE_NAME,
|
|
272
|
+
"clientInfo": self._clientInfo,
|
|
236
273
|
"revision": self._networkRevision,
|
|
237
274
|
},
|
|
238
275
|
headers={"X-Casambi-Session": self._session.session}, # type: ignore[union-attr]
|
|
@@ -302,15 +339,23 @@ class Network:
|
|
|
302
339
|
|
|
303
340
|
self._classicVisitorKey = _parse_hex_key(visitor_hex)
|
|
304
341
|
self._classicManagerKey = _parse_hex_key(manager_hex)
|
|
305
|
-
self.
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
342
|
+
if not (self._classicVisitorKey or self._classicManagerKey):
|
|
343
|
+
# Android still sends Classic frames even when keys are null (signature bytes remain zeros).
|
|
344
|
+
# We need this as a loud hint for testers when Classic control doesn't work yet.
|
|
345
|
+
self._logger.warning(
|
|
346
|
+
"[CASAMBI_CLASSIC_KEYS_MISSING] visitorKey=false managerKey=false"
|
|
347
|
+
)
|
|
348
|
+
else:
|
|
349
|
+
self._logger.info(
|
|
350
|
+
"Classic keys present: visitor=%s manager=%s",
|
|
351
|
+
bool(self._classicVisitorKey),
|
|
352
|
+
bool(self._classicManagerKey),
|
|
353
|
+
)
|
|
310
354
|
|
|
311
355
|
# Parse units
|
|
312
356
|
self.units = []
|
|
313
357
|
units = network["network"]["units"]
|
|
358
|
+
units_with_security_key = 0
|
|
314
359
|
for u in units:
|
|
315
360
|
uType = await self._fetchUnitInfo(u["type"])
|
|
316
361
|
if uType is None:
|
|
@@ -318,6 +363,23 @@ class Network:
|
|
|
318
363
|
"Failed to fetch type for unit %i. Skipping.", u["type"]
|
|
319
364
|
)
|
|
320
365
|
continue
|
|
366
|
+
|
|
367
|
+
security_key: bytes | None = None
|
|
368
|
+
sec_hex = u.get("securityKey")
|
|
369
|
+
if isinstance(sec_hex, str):
|
|
370
|
+
sec_hex = sec_hex.strip()
|
|
371
|
+
if sec_hex:
|
|
372
|
+
try:
|
|
373
|
+
security_key = bytes.fromhex(sec_hex)
|
|
374
|
+
except ValueError:
|
|
375
|
+
self._logger.debug(
|
|
376
|
+
"Invalid unit securityKey hex for unit %s (len=%d).",
|
|
377
|
+
u.get("deviceID"),
|
|
378
|
+
len(sec_hex),
|
|
379
|
+
)
|
|
380
|
+
if security_key is not None:
|
|
381
|
+
units_with_security_key += 1
|
|
382
|
+
|
|
321
383
|
uObj = Unit(
|
|
322
384
|
u["type"],
|
|
323
385
|
u["deviceID"],
|
|
@@ -326,9 +388,24 @@ class Network:
|
|
|
326
388
|
u["name"],
|
|
327
389
|
str(u["firmware"]),
|
|
328
390
|
uType,
|
|
391
|
+
securityKey=security_key,
|
|
329
392
|
)
|
|
330
393
|
self.units.append(uObj)
|
|
331
394
|
|
|
395
|
+
# One compact profile line to help interpret mixed/legacy networks from tester logs.
|
|
396
|
+
# Keep EVO networks at INFO to avoid noisy HA warnings; elevate legacy (<10) to WARNING.
|
|
397
|
+
level = logging.WARNING if self._protocolVersion < 10 else logging.INFO
|
|
398
|
+
self._logger.log(
|
|
399
|
+
level,
|
|
400
|
+
"[CASAMBI_NETWORK_PROFILE] uuid=%s id=%s protocolVersion=%s units=%d units_with_securityKey=%d keyStore=%s",
|
|
401
|
+
self._uuid,
|
|
402
|
+
self._id,
|
|
403
|
+
self._protocolVersion,
|
|
404
|
+
len(self.units),
|
|
405
|
+
units_with_security_key,
|
|
406
|
+
"keyStore" in network["network"],
|
|
407
|
+
)
|
|
408
|
+
|
|
332
409
|
# Parse cells
|
|
333
410
|
self.groups = []
|
|
334
411
|
cells = network["network"]["grid"]["cells"]
|
CasambiBt/_unit.py
CHANGED
|
@@ -330,6 +330,7 @@ class Unit:
|
|
|
330
330
|
:ivar firmwareVersion: Firmware version of the unit.
|
|
331
331
|
|
|
332
332
|
:ivar unitType: Type of the unit. Determines the capabilities.
|
|
333
|
+
:ivar securityKey: Optional per-unit key (seen on some legacy/mixed networks). Not used yet.
|
|
333
334
|
"""
|
|
334
335
|
|
|
335
336
|
_typeId: int
|
|
@@ -340,6 +341,7 @@ class Unit:
|
|
|
340
341
|
firmwareVersion: str
|
|
341
342
|
|
|
342
343
|
unitType: UnitType
|
|
344
|
+
securityKey: bytes | None = None
|
|
343
345
|
|
|
344
346
|
_state: UnitState | None = None
|
|
345
347
|
_on: bool = False
|
CasambiBt/_version.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Package version (kept in-sync with setup.cfg).
|
|
2
|
+
|
|
3
|
+
Home Assistant integrations sometimes run with strict event-loop blocking checks.
|
|
4
|
+
Avoid using importlib.metadata in hot paths by providing a static version string.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__all__ = ["__version__"]
|
|
8
|
+
|
|
9
|
+
# NOTE: Must match `casambi-bt/setup.cfg` [metadata] version.
|
|
10
|
+
__version__ = "0.3.12.dev6"
|
{casambi_bt_revamped-0.3.12.dev4.dist-info → casambi_bt_revamped-0.3.12.dev6.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: casambi-bt-revamped
|
|
3
|
-
Version: 0.3.12.
|
|
3
|
+
Version: 0.3.12.dev6
|
|
4
4
|
Summary: Forked Casambi Bluetooth client library with switch event support, use original if no special need. https://github.com/lkempf/casambi-bt
|
|
5
5
|
Home-page: https://github.com/rankjie/casambi-bt
|
|
6
6
|
Author: rankjie
|
{casambi_bt_revamped-0.3.12.dev4.dist-info → casambi_bt_revamped-0.3.12.dev6.dist-info}/RECORD
RENAMED
|
@@ -1,21 +1,22 @@
|
|
|
1
|
-
CasambiBt/__init__.py,sha256=
|
|
1
|
+
CasambiBt/__init__.py,sha256=iJdTF4oeXfj5d5gfGxQkacqUjtnQo0IW-zFPJvFjWWk,336
|
|
2
2
|
CasambiBt/_cache.py,sha256=3bQil8vhSy4f4sf9JusMfEdQC7d3cJuva9qHhyKro-0,3808
|
|
3
3
|
CasambiBt/_casambi.py,sha256=TN4ecgjm95nSJ4h9TsKayNn577Y82fdsGK4IGUZF23Q,40666
|
|
4
4
|
CasambiBt/_classic_crypto.py,sha256=6DcCOdjLQo7k2cOOutNdUKupykOG_E2TDDwg6fH-ODM,998
|
|
5
|
-
CasambiBt/_client.py,sha256=
|
|
5
|
+
CasambiBt/_client.py,sha256=PNYBwMdehh-YvSdxf8I-74bpn008VjNvwZyru5H_LuM,63618
|
|
6
6
|
CasambiBt/_constants.py,sha256=sbElg5W8eeQvvL1rHn_E0jhP1wOrrabc7dFLLnlDMsU,810
|
|
7
7
|
CasambiBt/_discover.py,sha256=jLc6H69JddrCURgtANZEjws6_UbSzXJtvJkbKTaIUHY,1849
|
|
8
8
|
CasambiBt/_encryption.py,sha256=CLcoOOrggQqhJbnr_emBnEnkizpWDvb_0yFnitq4_FM,3831
|
|
9
9
|
CasambiBt/_invocation.py,sha256=fkG4R0Gv5_amFfD_P6DKuIEe3oKWZW0v8RSU8zDjPdI,2985
|
|
10
10
|
CasambiBt/_keystore.py,sha256=Jdiq0zMPDmhfpheSojKY6sTUpmVrvX_qOyO7yCYd3kw,2788
|
|
11
|
-
CasambiBt/_network.py,sha256=
|
|
11
|
+
CasambiBt/_network.py,sha256=UMGpB-seAXtfwPS7pvXTieLf9ekFXgvy57tAfcc_cno,19779
|
|
12
12
|
CasambiBt/_operation.py,sha256=Q5UccsrtNp_B_wWqwH_3eLFW_yF6A55FMmfUKDk2WrI,1059
|
|
13
13
|
CasambiBt/_switch_events.py,sha256=S8OD0dBcw5T4J2C7qfmOQMnTJ7omIXRUYv4PqDOB87E,13137
|
|
14
|
-
CasambiBt/_unit.py,sha256=
|
|
14
|
+
CasambiBt/_unit.py,sha256=nxbg_8UCCVB9WI8dUS21g2JrGyPKcefqKMSusMOhLOo,18721
|
|
15
|
+
CasambiBt/_version.py,sha256=spRApATilqicOYCOi-3PEHxfpK9lOYP1fW1ufdiSN5Q,337
|
|
15
16
|
CasambiBt/errors.py,sha256=1L_Q8og_N_BRYEKizghAQXr6tihlHykFgtcCHUDcBas,1961
|
|
16
17
|
CasambiBt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
-
casambi_bt_revamped-0.3.12.
|
|
18
|
-
casambi_bt_revamped-0.3.12.
|
|
19
|
-
casambi_bt_revamped-0.3.12.
|
|
20
|
-
casambi_bt_revamped-0.3.12.
|
|
21
|
-
casambi_bt_revamped-0.3.12.
|
|
18
|
+
casambi_bt_revamped-0.3.12.dev6.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
|
|
19
|
+
casambi_bt_revamped-0.3.12.dev6.dist-info/METADATA,sha256=mwWxQMdafeUx5y1uwhn-n_CegEkuzS9uJ4hODJJ0RI8,5877
|
|
20
|
+
casambi_bt_revamped-0.3.12.dev6.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
21
|
+
casambi_bt_revamped-0.3.12.dev6.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
|
|
22
|
+
casambi_bt_revamped-0.3.12.dev6.dist-info/RECORD,,
|
{casambi_bt_revamped-0.3.12.dev4.dist-info → casambi_bt_revamped-0.3.12.dev6.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|