casambi-bt-revamped 0.3.12.dev5__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 CHANGED
@@ -3,6 +3,7 @@
3
3
  # Import everything that should be public
4
4
  # ruff: noqa: F401
5
5
 
6
+ from ._version import __version__
6
7
  from ._casambi import Casambi
7
8
  from ._discover import discover
8
9
  from ._unit import (
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
- # Avoid log spam in Home Assistant: raw notify hexdumps are opt-in.
112
- self._logRawNotifies: bool = os.getenv("CASAMBI_BT_LOG_RAW_NOTIFIES", "").strip() in {
113
- "1",
114
- "true",
115
- "TRUE",
116
- "yes",
117
- "YES",
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.info(
247
- "[CASAMBI_PROTOCOL_PROBE] address=%s mode=%s cloud_protocol=%s device_nodeinfo_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 device_protocol=%s mtu=%s unit=%s flags=%s nonce_prefix=%s len=%d prefix=%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 device_protocol=%s prefix=%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._checkProtocolVersion(device_nodeinfo_protocol, source="device_nodeinfo")
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
- self._checkProtocolVersion(device_protocol, source="device_nodeinfo")
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 device_protocol=%s prefix=%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._sendClassicSigned(packet)
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 _sendClassicSigned(self, command_bytes: bytes, *, use_manager: bool | None = None) -> None:
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
- key_name = "visitor"
869
- auth_level = 0x02
870
- sig_len = 4
871
- key = visitor_key
872
-
873
- if use_manager or key is None:
874
- if manager_key is None:
875
- # If we were forced to use manager but don't have one, fall back to visitor if present.
876
- if visitor_key is None:
877
- raise ClassicKeysMissingError(
878
- "Classic network has no visitorKey/managerKey available."
879
- )
880
- key = visitor_key
881
- else:
882
- key_name = "manager"
883
- auth_level = 0x03
884
- sig_len = 16
885
- key = manager_key
886
-
887
- seq = self._classic_next_seq()
888
-
889
- # Header layout (rVar.Z=true / "conformant" classic):
890
- # [0] auth_level (2 visitor / 3 manager)
891
- # [1..sig_len] CMAC prefix placeholder (filled after CMAC computation)
892
- # [1+sig_len .. 1+sig_len+1] 16-bit sequence, big endian (included in CMAC input)
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
- cmac_input = bytes(pkt[seq_off:]) # includes seq + command bytes
905
- prefix = classic_cmac_prefix(key, self._classicConnHash8, cmac_input, sig_len)
906
- pkt[1 : 1 + sig_len] = prefix
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
- if self._logger.isEnabledFor(logging.DEBUG):
909
- self._logger.debug(
910
- "[CASAMBI_CLASSIC_TX] key=%s auth=0x%02x sig_len=%d seq=0x%04x cmd_len=%d total_len=%d",
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._logger.debug("[CASAMBI_CLASSIC_RX] Missing connection hash; cannot verify CMAC.")
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
- verified = False
1020
- key_name: str | None = None
1021
- sig_len: int | None = None
1022
- payload_with_seq: bytes | None = None
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
- # Try visitor (4-byte prefix) first, then manager (16-byte prefix).
1025
- # Some frames may be unsigned; in that case verification will fail and we'll fall back.
1026
- candidates: list[tuple[str, bytes | None, int]] = [
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
- for name, key, slen in candidates:
1032
- if key is None:
1033
- continue
1034
- header_len = 1 + slen + 2
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
- auth_level = raw[0]
1039
- sig = raw[1 : 1 + slen]
1040
- cmac_input = raw[1 + slen :] # seq(2) + payload
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
- try:
1043
- expected = classic_cmac_prefix(key, self._classicConnHash8, cmac_input, slen)
1044
- except Exception:
1045
- continue
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
- if expected == sig:
1048
- verified = True
1049
- key_name = name
1050
- sig_len = slen
1051
- payload_with_seq = cmac_input
1052
- if self._logger.isEnabledFor(logging.DEBUG):
1053
- seq = int.from_bytes(cmac_input[:2], byteorder="big", signed=False)
1054
- self._logger.debug(
1055
- "[CASAMBI_CLASSIC_RX_VERIFY] ok key=%s auth=0x%02x sig_len=%d seq=0x%04x",
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
- if not verified:
1064
- if self._logger.isEnabledFor(logging.DEBUG):
1065
- self._logger.debug("[CASAMBI_CLASSIC_RX_VERIFY] failed (no matching CMAC prefix)")
1066
- # Best-effort: treat raw bytes as payload.
1067
- payload = raw
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
- assert payload_with_seq is not None
1070
- # Drop the 16-bit sequence from the payload for higher-level parsing.
1071
- payload = payload_with_seq[2:]
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
- if not payload:
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, json={"password": password, "deviceName": DEVICE_NAME}
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._logger.info(
306
- "Classic keys present: visitor=%s manager=%s",
307
- bool(self._classicVisitorKey),
308
- bool(self._classicManagerKey),
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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: casambi-bt-revamped
3
- Version: 0.3.12.dev5
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
@@ -1,21 +1,22 @@
1
- CasambiBt/__init__.py,sha256=TW445xSu5PV3TyMjJfwaA1JoWvQQ8LXhZgGdDTfWf3s,302
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=AASUN9OvmTIg9IeYMEvLI8kBEYbV9FapIuyDXGZMpME,57883
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=DdUSWWFgifc-PhjGbBxSzBntu8CJrsbp6aMYuD1D-Gg,16465
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=KIpvUT_Wm-O2Lmb1JVnNO625-j5j7GqufmZzfTR-jW0,18587
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.dev5.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
18
- casambi_bt_revamped-0.3.12.dev5.dist-info/METADATA,sha256=mNRrJjPdZBbSvEJp9RBAYkv7wU0-znKwBhpR5XEXtLo,5877
19
- casambi_bt_revamped-0.3.12.dev5.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
20
- casambi_bt_revamped-0.3.12.dev5.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
21
- casambi_bt_revamped-0.3.12.dev5.dist-info/RECORD,,
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,,