casambi-bt-revamped 0.3.12.dev5__py3-none-any.whl → 0.3.12.dev7__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 +323 -175
- CasambiBt/_network.py +110 -13
- CasambiBt/_unit.py +2 -0
- CasambiBt/_version.py +10 -0
- {casambi_bt_revamped-0.3.12.dev5.dist-info → casambi_bt_revamped-0.3.12.dev7.dist-info}/METADATA +1 -1
- {casambi_bt_revamped-0.3.12.dev5.dist-info → casambi_bt_revamped-0.3.12.dev7.dist-info}/RECORD +10 -9
- {casambi_bt_revamped-0.3.12.dev5.dist-info → casambi_bt_revamped-0.3.12.dev7.dist-info}/WHEEL +0 -0
- {casambi_bt_revamped-0.3.12.dev5.dist-info → casambi_bt_revamped-0.3.12.dev7.dist-info}/licenses/LICENSE +0 -0
- {casambi_bt_revamped-0.3.12.dev5.dist-info → casambi_bt_revamped-0.3.12.dev7.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
|
|
|
@@ -107,28 +130,25 @@ class CasambiClient:
|
|
|
107
130
|
self._classicConnHash8: bytes | None = None
|
|
108
131
|
self._classicTxSeq: int = 0 # 16-bit sequence number (big endian on the wire)
|
|
109
132
|
self._classicCmdDiv: int = 0 # 8-bit per-command divider/id (matches u1.C1751c.b0)
|
|
110
|
-
|
|
111
|
-
#
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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()
|
|
119
146
|
|
|
120
147
|
@property
|
|
121
148
|
def protocolMode(self) -> ProtocolMode | None:
|
|
122
149
|
return self._protocolMode
|
|
123
150
|
|
|
124
151
|
def _checkProtocolVersion(self, version: int, *, source: str = "unknown") -> None:
|
|
125
|
-
strict = os.getenv("CASAMBI_BT_STRICT_PROTOCOL_VERSION", "").strip() in {
|
|
126
|
-
"1",
|
|
127
|
-
"true",
|
|
128
|
-
"TRUE",
|
|
129
|
-
"yes",
|
|
130
|
-
"YES",
|
|
131
|
-
}
|
|
132
152
|
if version < MIN_VERSION:
|
|
133
153
|
# Legacy protocol versions are intentionally allowed. We keep this check as a warning
|
|
134
154
|
# because packet layouts/handshakes may differ and we want actionable tester logs.
|
|
@@ -136,8 +156,6 @@ class CasambiClient:
|
|
|
136
156
|
f"Legacy protocol version detected ({source}={version}). "
|
|
137
157
|
f"Versions < {MIN_VERSION} are not fully verified; attempting to continue."
|
|
138
158
|
)
|
|
139
|
-
if strict:
|
|
140
|
-
raise UnsupportedProtocolVersion(msg)
|
|
141
159
|
self._logger.warning(msg)
|
|
142
160
|
return
|
|
143
161
|
if version > MAX_VERSION:
|
|
@@ -243,8 +261,8 @@ class CasambiClient:
|
|
|
243
261
|
|
|
244
262
|
def _log_probe_summary(mode: str) -> None:
|
|
245
263
|
# One stable, high-signal line for testers.
|
|
246
|
-
self._logger.
|
|
247
|
-
"[CASAMBI_PROTOCOL_PROBE] address=%s mode=%s cloud_protocol=%s
|
|
264
|
+
self._logger.warning(
|
|
265
|
+
"[CASAMBI_PROTOCOL_PROBE] address=%s mode=%s cloud_protocol=%s nodeinfo_b1=%s "
|
|
248
266
|
"data_uuid=%s classic_hash8_present=%s auth_read_prefix=%s ca51_read_prefix=%s ca51_read_error=%s auth_read_error=%s",
|
|
249
267
|
self.address,
|
|
250
268
|
mode,
|
|
@@ -275,16 +293,9 @@ class CasambiClient:
|
|
|
275
293
|
self._logger.debug("[CASAMBI_GATT_PROBE] read ca51 fail err=%s", ca51_err)
|
|
276
294
|
|
|
277
295
|
if classic_hash and len(classic_hash) >= 8:
|
|
278
|
-
if os.getenv("CASAMBI_BT_DISABLE_CLASSIC", "").strip() in {"1", "true", "TRUE", "yes", "YES"}:
|
|
279
|
-
raise ProtocolError("Classic protocol detected but disabled via CASAMBI_BT_DISABLE_CLASSIC=1")
|
|
280
|
-
|
|
281
|
-
if not self._network.hasClassicKeys():
|
|
282
|
-
raise ClassicKeysMissingError(
|
|
283
|
-
"Classic protocol detected but network has no visitorKey/managerKey."
|
|
284
|
-
)
|
|
285
|
-
|
|
286
296
|
self._protocolMode = ProtocolMode.CLASSIC
|
|
287
297
|
self._dataCharUuid = CASA_CLASSIC_DATA_CHAR_UUID
|
|
298
|
+
self._classicHeaderMode = "legacy"
|
|
288
299
|
|
|
289
300
|
# Read connection hash (first 8 bytes are used for CMAC signing).
|
|
290
301
|
raw_hash = classic_hash
|
|
@@ -368,7 +379,7 @@ class CasambiClient:
|
|
|
368
379
|
self._logger.debug("Failed to parse NodeInfo fields for logging.", exc_info=True)
|
|
369
380
|
|
|
370
381
|
self._logger.info(
|
|
371
|
-
"[CASAMBI_EVO_NODEINFO] cloud_protocol=%s
|
|
382
|
+
"[CASAMBI_EVO_NODEINFO] cloud_protocol=%s nodeinfo_b1=%s mtu=%s unit=%s flags=%s nonce_prefix=%s len=%d prefix=%s",
|
|
372
383
|
cloud_protocol,
|
|
373
384
|
device_nodeinfo_protocol,
|
|
374
385
|
mtu,
|
|
@@ -378,15 +389,9 @@ class CasambiClient:
|
|
|
378
389
|
len(first),
|
|
379
390
|
b2a(first[: min(len(first), 32)]),
|
|
380
391
|
)
|
|
381
|
-
if cloud_protocol is not None and device_nodeinfo_protocol != cloud_protocol:
|
|
382
|
-
self._logger.warning(
|
|
383
|
-
"[CASAMBI_EVO_NODEINFO_MISMATCH] cloud_protocol=%s device_protocol=%s",
|
|
384
|
-
cloud_protocol,
|
|
385
|
-
device_nodeinfo_protocol,
|
|
386
|
-
)
|
|
387
392
|
if len(first) < 23:
|
|
388
393
|
self._logger.warning(
|
|
389
|
-
"[CASAMBI_EVO_NODEINFO_SHORT] len=%d cloud_protocol=%s
|
|
394
|
+
"[CASAMBI_EVO_NODEINFO_SHORT] len=%d cloud_protocol=%s nodeinfo_b1=%s prefix=%s",
|
|
390
395
|
len(first),
|
|
391
396
|
cloud_protocol,
|
|
392
397
|
device_nodeinfo_protocol,
|
|
@@ -395,19 +400,13 @@ class CasambiClient:
|
|
|
395
400
|
|
|
396
401
|
self._protocolMode = ProtocolMode.EVO
|
|
397
402
|
self._dataCharUuid = CASA_AUTH_CHAR_UUID
|
|
398
|
-
self.
|
|
403
|
+
self._classicHeaderMode = None
|
|
399
404
|
self._logger.info("Protocol mode selected: EVO")
|
|
400
405
|
_log_probe_summary("EVO")
|
|
401
406
|
return
|
|
402
407
|
|
|
403
408
|
if first is not None:
|
|
404
409
|
# Otherwise, treat as Classic conformant: read provides connection hash.
|
|
405
|
-
if os.getenv("CASAMBI_BT_DISABLE_CLASSIC", "").strip() in {"1", "true", "TRUE", "yes", "YES"}:
|
|
406
|
-
raise ProtocolError("Classic protocol detected but disabled via CASAMBI_BT_DISABLE_CLASSIC=1")
|
|
407
|
-
if not self._network.hasClassicKeys():
|
|
408
|
-
raise ClassicKeysMissingError(
|
|
409
|
-
"Classic protocol detected but network has no visitorKey/managerKey."
|
|
410
|
-
)
|
|
411
410
|
if len(first) < 8:
|
|
412
411
|
raise ClassicHandshakeError(
|
|
413
412
|
f"Classic connection hash read failed/too short (len={len(first)})."
|
|
@@ -415,6 +414,7 @@ class CasambiClient:
|
|
|
415
414
|
|
|
416
415
|
self._protocolMode = ProtocolMode.CLASSIC
|
|
417
416
|
self._dataCharUuid = CASA_AUTH_CHAR_UUID
|
|
417
|
+
self._classicHeaderMode = "conformant"
|
|
418
418
|
self._classicConnHash8 = bytes(first[:8])
|
|
419
419
|
self._classicCmdDiv = int.from_bytes(os.urandom(1), "big") or 1
|
|
420
420
|
self._classicTxSeq = 0
|
|
@@ -440,6 +440,7 @@ class CasambiClient:
|
|
|
440
440
|
_log_probe_summary("CLASSIC")
|
|
441
441
|
return
|
|
442
442
|
|
|
443
|
+
_log_probe_summary("UNKNOWN")
|
|
443
444
|
raise ProtocolError(
|
|
444
445
|
"No supported Casambi characteristics found (Classic ca51/ca52 or EVO/Classic-conformant auth char)."
|
|
445
446
|
)
|
|
@@ -469,7 +470,6 @@ class CasambiClient:
|
|
|
469
470
|
)
|
|
470
471
|
|
|
471
472
|
cloud_protocol = getattr(self._network, "protocolVersion", None)
|
|
472
|
-
expected_protocol = self._deviceProtocolVersion or cloud_protocol
|
|
473
473
|
|
|
474
474
|
# EVO key exchange expects the NodeInfo packet (0x01 ...).
|
|
475
475
|
if len(firstResp) < 2 or firstResp[0] != 0x01:
|
|
@@ -482,27 +482,12 @@ class CasambiClient:
|
|
|
482
482
|
|
|
483
483
|
device_protocol = firstResp[1]
|
|
484
484
|
self._deviceProtocolVersion = device_protocol
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
if expected_protocol is not None and device_protocol != expected_protocol:
|
|
488
|
-
self._logger.warning(
|
|
489
|
-
"[CASAMBI_EVO_NODEINFO_MISMATCH] expected_protocol=%s cloud_protocol=%s device_protocol=%s",
|
|
490
|
-
expected_protocol,
|
|
491
|
-
cloud_protocol,
|
|
492
|
-
device_protocol,
|
|
493
|
-
)
|
|
494
|
-
elif cloud_protocol is not None and device_protocol != cloud_protocol:
|
|
495
|
-
# Keep this separate to catch cloud/device mismatches even if we didn't have an expected protocol set.
|
|
496
|
-
self._logger.warning(
|
|
497
|
-
"[CASAMBI_EVO_NODEINFO_MISMATCH] expected_protocol=%s cloud_protocol=%s device_protocol=%s",
|
|
498
|
-
expected_protocol,
|
|
499
|
-
cloud_protocol,
|
|
500
|
-
device_protocol,
|
|
501
|
-
)
|
|
485
|
+
# Do not interpret NodeInfo byte1 as "cloud protocolVersion".
|
|
486
|
+
# Some firmwares use a different numbering scheme, so mismatch warnings are misleading.
|
|
502
487
|
|
|
503
488
|
if len(firstResp) < 23:
|
|
504
489
|
self._logger.error(
|
|
505
|
-
"[CASAMBI_EVO_NODEINFO_SHORT] len=%d cloud_protocol=%s
|
|
490
|
+
"[CASAMBI_EVO_NODEINFO_SHORT] len=%d cloud_protocol=%s nodeinfo_b1=%s prefix=%s",
|
|
506
491
|
len(firstResp),
|
|
507
492
|
cloud_protocol,
|
|
508
493
|
device_protocol,
|
|
@@ -598,14 +583,6 @@ class CasambiClient:
|
|
|
598
583
|
def _callbackMulitplexer(
|
|
599
584
|
self, handle: BleakGATTCharacteristic, data: bytes
|
|
600
585
|
) -> None:
|
|
601
|
-
if self._logRawNotifies and self._logger.isEnabledFor(logging.DEBUG):
|
|
602
|
-
self._logger.debug(
|
|
603
|
-
"Callback on handle %s (%s): %s",
|
|
604
|
-
getattr(handle, "handle", "?"),
|
|
605
|
-
getattr(handle, "uuid", "?"),
|
|
606
|
-
b2a(data),
|
|
607
|
-
)
|
|
608
|
-
|
|
609
586
|
if self._connectionState == ConnectionState.CONNECTED:
|
|
610
587
|
self._exchNofityCallback(handle, data)
|
|
611
588
|
elif self._connectionState == ConnectionState.KEY_EXCHANGED:
|
|
@@ -747,7 +724,7 @@ class CasambiClient:
|
|
|
747
724
|
# EVO sends INVOCATION operations (packet type=0x07) inside the encrypted channel.
|
|
748
725
|
# Classic sends signed command frames on the CA52 channel.
|
|
749
726
|
if self._protocolMode == ProtocolMode.CLASSIC:
|
|
750
|
-
await self.
|
|
727
|
+
await self._sendClassic(packet)
|
|
751
728
|
return
|
|
752
729
|
|
|
753
730
|
self._checkState(ConnectionState.AUTHENTICATED)
|
|
@@ -843,7 +820,7 @@ class CasambiClient:
|
|
|
843
820
|
|
|
844
821
|
return bytes(b)
|
|
845
822
|
|
|
846
|
-
async def
|
|
823
|
+
async def _sendClassic(self, command_bytes: bytes) -> None:
|
|
847
824
|
self._checkState(ConnectionState.AUTHENTICATED)
|
|
848
825
|
if self._protocolMode != ProtocolMode.CLASSIC:
|
|
849
826
|
raise ProtocolError("Classic send called while not in Classic protocol mode.")
|
|
@@ -852,72 +829,85 @@ class CasambiClient:
|
|
|
852
829
|
if self._classicConnHash8 is None:
|
|
853
830
|
raise ClassicHandshakeError("Classic connection hash not available.")
|
|
854
831
|
|
|
855
|
-
# Decide whether to use visitor or manager key.
|
|
856
|
-
if use_manager is None:
|
|
857
|
-
use_manager = os.getenv("CASAMBI_BT_CLASSIC_USE_MANAGER", "").strip() in {
|
|
858
|
-
"1",
|
|
859
|
-
"true",
|
|
860
|
-
"TRUE",
|
|
861
|
-
"yes",
|
|
862
|
-
"YES",
|
|
863
|
-
}
|
|
864
|
-
|
|
865
832
|
visitor_key = self._network.classicVisitorKey()
|
|
866
833
|
manager_key = self._network.classicManagerKey()
|
|
867
834
|
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
key
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
# [..] 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
|
|
894
860
|
pkt = bytearray()
|
|
895
|
-
pkt.append(auth_level)
|
|
896
|
-
pkt.extend(b"\x00" * sig_len)
|
|
897
|
-
pkt.extend(b"\x00\x00")
|
|
898
|
-
pkt.extend(command_bytes)
|
|
899
|
-
|
|
900
|
-
seq_off = 1 + sig_len
|
|
901
|
-
pkt[seq_off] = (seq >> 8) & 0xFF
|
|
902
|
-
pkt[seq_off + 1] = seq & 0xFF
|
|
903
861
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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}")
|
|
907
897
|
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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,
|
|
911
904
|
key_name,
|
|
912
905
|
auth_level,
|
|
913
906
|
sig_len,
|
|
914
|
-
seq,
|
|
907
|
+
None if seq is None else f"0x{seq:04x}",
|
|
915
908
|
len(command_bytes),
|
|
916
909
|
len(pkt),
|
|
917
|
-
|
|
918
|
-
self._logger.debug(
|
|
919
|
-
"[CASAMBI_CLASSIC_TX_RAW] %s",
|
|
920
|
-
b2a(bytes(pkt[: min(len(pkt), 64)])) + (b"..." if len(pkt) > 64 else b""),
|
|
910
|
+
b2a(bytes(pkt[: min(len(pkt), 24)])),
|
|
921
911
|
)
|
|
922
912
|
|
|
923
913
|
# Classic packets can exceed 20 bytes when using a 16-byte manager signature.
|
|
@@ -1000,6 +990,7 @@ class CasambiClient:
|
|
|
1000
990
|
Ground truth: casambi-android `t1.P.o(...)`.
|
|
1001
991
|
"""
|
|
1002
992
|
self._inPacketCount += 1
|
|
993
|
+
self._classicRxFrames += 1
|
|
1003
994
|
|
|
1004
995
|
raw = bytes(data)
|
|
1005
996
|
if self._logger.isEnabledFor(logging.DEBUG):
|
|
@@ -1010,69 +1001,226 @@ class CasambiClient:
|
|
|
1010
1001
|
)
|
|
1011
1002
|
|
|
1012
1003
|
if self._classicConnHash8 is None:
|
|
1013
|
-
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))
|
|
1014
1006
|
return
|
|
1015
1007
|
|
|
1016
1008
|
visitor_key = self._network.classicVisitorKey()
|
|
1017
1009
|
manager_key = self._network.classicManagerKey()
|
|
1018
1010
|
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
|
1023
1051
|
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
("visitor", visitor_key, 4),
|
|
1028
|
-
("manager", manager_key, 16),
|
|
1029
|
-
]
|
|
1052
|
+
header_len = 1 + sig_len + 2
|
|
1053
|
+
if len(raw_bytes) < header_len:
|
|
1054
|
+
return None
|
|
1030
1055
|
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
if len(raw) < header_len:
|
|
1036
|
-
continue
|
|
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:]
|
|
1037
1060
|
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1061
|
+
verified: bool | None
|
|
1062
|
+
if key is None:
|
|
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
|
+
}
|
|
1041
1081
|
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
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
|
|
1046
1111
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
sig_len
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
name,
|
|
1057
|
-
auth_level,
|
|
1058
|
-
slen,
|
|
1059
|
-
seq,
|
|
1060
|
-
)
|
|
1061
|
-
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
|
+
}
|
|
1062
1121
|
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
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)
|
|
1068
1134
|
else:
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
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
|
|
1072
1152
|
|
|
1073
|
-
|
|
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
|
+
)
|
|
1074
1176
|
return
|
|
1075
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
|
+
|
|
1076
1224
|
# If the payload starts with a known EVO packet type, reuse existing parsers.
|
|
1077
1225
|
packet_type = payload[0]
|
|
1078
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
|
|
@@ -227,14 +262,16 @@ class Network:
|
|
|
227
262
|
getNetworkUrl = f"https://api.casambi.com/network/{self._id}/"
|
|
228
263
|
|
|
229
264
|
try:
|
|
265
|
+
payload = {
|
|
266
|
+
"formatVersion": 1,
|
|
267
|
+
"deviceName": DEVICE_NAME,
|
|
268
|
+
"revision": self._networkRevision,
|
|
269
|
+
}
|
|
270
|
+
|
|
230
271
|
# **SECURITY**: Do not set session header for client! This could leak the session with external clients.
|
|
231
272
|
res = await self._httpClient.put(
|
|
232
273
|
getNetworkUrl,
|
|
233
|
-
json=
|
|
234
|
-
"formatVersion": 1,
|
|
235
|
-
"deviceName": DEVICE_NAME,
|
|
236
|
-
"revision": self._networkRevision,
|
|
237
|
-
},
|
|
274
|
+
json=payload,
|
|
238
275
|
headers={"X-Casambi-Session": self._session.session}, # type: ignore[union-attr]
|
|
239
276
|
)
|
|
240
277
|
|
|
@@ -247,8 +284,28 @@ class Network:
|
|
|
247
284
|
)
|
|
248
285
|
await self._cache.invalidateCache()
|
|
249
286
|
|
|
287
|
+
if res.status_code == httpx.codes.BAD_REQUEST:
|
|
288
|
+
# Some backend variants may reject the minimal update payload.
|
|
289
|
+
# Retry once with Android-like fields (token/clientInfo) for diagnostics/testing.
|
|
290
|
+
self._logger.warning(
|
|
291
|
+
"[CASAMBI_CLOUD_UPDATE_RETRY] status=400 retry_with_token_clientInfo=true body_prefix=%r",
|
|
292
|
+
(res.text or "")[:200],
|
|
293
|
+
)
|
|
294
|
+
payload2 = dict(payload)
|
|
295
|
+
payload2["token"] = self._token
|
|
296
|
+
payload2["clientInfo"] = self._clientInfo
|
|
297
|
+
res = await self._httpClient.put(
|
|
298
|
+
getNetworkUrl,
|
|
299
|
+
json=payload2,
|
|
300
|
+
headers={"X-Casambi-Session": self._session.session}, # type: ignore[union-attr]
|
|
301
|
+
)
|
|
302
|
+
|
|
250
303
|
if res.status_code != httpx.codes.OK:
|
|
251
|
-
self._logger.error(
|
|
304
|
+
self._logger.error(
|
|
305
|
+
"Update failed: %s body_prefix=%r",
|
|
306
|
+
res.status_code,
|
|
307
|
+
(res.text or "")[:500],
|
|
308
|
+
)
|
|
252
309
|
raise NetworkUpdateError("Could not update network!")
|
|
253
310
|
|
|
254
311
|
self._logger.debug(f"Network: {res.text}")
|
|
@@ -302,15 +359,23 @@ class Network:
|
|
|
302
359
|
|
|
303
360
|
self._classicVisitorKey = _parse_hex_key(visitor_hex)
|
|
304
361
|
self._classicManagerKey = _parse_hex_key(manager_hex)
|
|
305
|
-
self.
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
362
|
+
if not (self._classicVisitorKey or self._classicManagerKey):
|
|
363
|
+
# Android still sends Classic frames even when keys are null (signature bytes remain zeros).
|
|
364
|
+
# We need this as a loud hint for testers when Classic control doesn't work yet.
|
|
365
|
+
self._logger.warning(
|
|
366
|
+
"[CASAMBI_CLASSIC_KEYS_MISSING] visitorKey=false managerKey=false"
|
|
367
|
+
)
|
|
368
|
+
else:
|
|
369
|
+
self._logger.info(
|
|
370
|
+
"Classic keys present: visitor=%s manager=%s",
|
|
371
|
+
bool(self._classicVisitorKey),
|
|
372
|
+
bool(self._classicManagerKey),
|
|
373
|
+
)
|
|
310
374
|
|
|
311
375
|
# Parse units
|
|
312
376
|
self.units = []
|
|
313
377
|
units = network["network"]["units"]
|
|
378
|
+
units_with_security_key = 0
|
|
314
379
|
for u in units:
|
|
315
380
|
uType = await self._fetchUnitInfo(u["type"])
|
|
316
381
|
if uType is None:
|
|
@@ -318,6 +383,23 @@ class Network:
|
|
|
318
383
|
"Failed to fetch type for unit %i. Skipping.", u["type"]
|
|
319
384
|
)
|
|
320
385
|
continue
|
|
386
|
+
|
|
387
|
+
security_key: bytes | None = None
|
|
388
|
+
sec_hex = u.get("securityKey")
|
|
389
|
+
if isinstance(sec_hex, str):
|
|
390
|
+
sec_hex = sec_hex.strip()
|
|
391
|
+
if sec_hex:
|
|
392
|
+
try:
|
|
393
|
+
security_key = bytes.fromhex(sec_hex)
|
|
394
|
+
except ValueError:
|
|
395
|
+
self._logger.debug(
|
|
396
|
+
"Invalid unit securityKey hex for unit %s (len=%d).",
|
|
397
|
+
u.get("deviceID"),
|
|
398
|
+
len(sec_hex),
|
|
399
|
+
)
|
|
400
|
+
if security_key is not None:
|
|
401
|
+
units_with_security_key += 1
|
|
402
|
+
|
|
321
403
|
uObj = Unit(
|
|
322
404
|
u["type"],
|
|
323
405
|
u["deviceID"],
|
|
@@ -326,9 +408,24 @@ class Network:
|
|
|
326
408
|
u["name"],
|
|
327
409
|
str(u["firmware"]),
|
|
328
410
|
uType,
|
|
411
|
+
securityKey=security_key,
|
|
329
412
|
)
|
|
330
413
|
self.units.append(uObj)
|
|
331
414
|
|
|
415
|
+
# One compact profile line to help interpret mixed/legacy networks from tester logs.
|
|
416
|
+
# Keep EVO networks at INFO to avoid noisy HA warnings; elevate legacy (<10) to WARNING.
|
|
417
|
+
level = logging.WARNING if self._protocolVersion < 10 else logging.INFO
|
|
418
|
+
self._logger.log(
|
|
419
|
+
level,
|
|
420
|
+
"[CASAMBI_NETWORK_PROFILE] uuid=%s id=%s protocolVersion=%s units=%d units_with_securityKey=%d keyStore=%s",
|
|
421
|
+
self._uuid,
|
|
422
|
+
self._id,
|
|
423
|
+
self._protocolVersion,
|
|
424
|
+
len(self.units),
|
|
425
|
+
units_with_security_key,
|
|
426
|
+
"keyStore" in network["network"],
|
|
427
|
+
)
|
|
428
|
+
|
|
332
429
|
# Parse cells
|
|
333
430
|
self.groups = []
|
|
334
431
|
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.dev7"
|
{casambi_bt_revamped-0.3.12.dev5.dist-info → casambi_bt_revamped-0.3.12.dev7.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.dev7
|
|
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.dev5.dist-info → casambi_bt_revamped-0.3.12.dev7.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=nB_pRB9dZL6P7THeuOce7ctWd0wXyCWF13h67SauZVQ,20714
|
|
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=eONTRjFMNE1oRTOTcoScFZ4eVqZj6WKN07jnjauq8Ao,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.dev7.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
|
|
19
|
+
casambi_bt_revamped-0.3.12.dev7.dist-info/METADATA,sha256=q4QM-1wpUKiobHPUoZXXqfwQlI_hf5iA_aYS4fBhPLE,5877
|
|
20
|
+
casambi_bt_revamped-0.3.12.dev7.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
21
|
+
casambi_bt_revamped-0.3.12.dev7.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
|
|
22
|
+
casambi_bt_revamped-0.3.12.dev7.dist-info/RECORD,,
|
{casambi_bt_revamped-0.3.12.dev5.dist-info → casambi_bt_revamped-0.3.12.dev7.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|