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 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
 
@@ -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
- # Avoid log spam in Home Assistant: raw notify hexdumps are opt-in.
110
- self._logRawNotifies: bool = os.getenv("CASAMBI_BT_LOG_RAW_NOTIFIES", "").strip() in {
111
- "1",
112
- "true",
113
- "TRUE",
114
- "yes",
115
- "YES",
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
- raise UnsupportedProtocolVersion(
125
- f"Legacy version aren't supported currently. Your network version is {version}. Minimum version is {MIN_VERSION}."
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. Your network version is %i. Highest supported version is %i. Continue at your own risk.",
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
- except Exception:
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.debug("Classic CA52 notify failed; trying auth UUID probing.", exc_info=True)
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
- except Exception:
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._checkProtocolVersion(self._network.protocolVersion)
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.debug(f"Got {b2a(firstResp)}")
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
- # Check type and protocol version
357
- if not (
358
- firstResp[0] == 0x1 and firstResp[1] == self._network.protocolVersion
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
- "Unexpected answer from device! Wrong device or protocol version? Trying to continue."
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._sendClassicSigned(packet)
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 _sendClassicSigned(self, command_bytes: bytes, *, use_manager: bool | None = None) -> None:
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
- key_name = "visitor"
720
- auth_level = 0x02
721
- sig_len = 4
722
- key = visitor_key
723
-
724
- if use_manager or key is None:
725
- if manager_key is None:
726
- # If we were forced to use manager but don't have one, fall back to visitor if present.
727
- if visitor_key is None:
728
- raise ClassicKeysMissingError(
729
- "Classic network has no visitorKey/managerKey available."
730
- )
731
- key = visitor_key
732
- else:
733
- key_name = "manager"
734
- auth_level = 0x03
735
- sig_len = 16
736
- key = manager_key
737
-
738
- seq = self._classic_next_seq()
739
-
740
- # Header layout (rVar.Z=true / "conformant" classic):
741
- # [0] auth_level (2 visitor / 3 manager)
742
- # [1..sig_len] CMAC prefix placeholder (filled after CMAC computation)
743
- # [1+sig_len .. 1+sig_len+1] 16-bit sequence, big endian (included in CMAC input)
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
- cmac_input = bytes(pkt[seq_off:]) # includes seq + command bytes
756
- prefix = classic_cmac_prefix(key, self._classicConnHash8, cmac_input, sig_len)
757
- 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}")
758
897
 
759
- if self._logger.isEnabledFor(logging.DEBUG):
760
- self._logger.debug(
761
- "[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,
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._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))
865
1006
  return
866
1007
 
867
1008
  visitor_key = self._network.classicVisitorKey()
868
1009
  manager_key = self._network.classicManagerKey()
869
1010
 
870
- verified = False
871
- key_name: str | None = None
872
- sig_len: int | None = None
873
- 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
1051
+
1052
+ header_len = 1 + sig_len + 2
1053
+ if len(raw_bytes) < header_len:
1054
+ return None
874
1055
 
875
- # Try visitor (4-byte prefix) first, then manager (16-byte prefix).
876
- # Some frames may be unsigned; in that case verification will fail and we'll fall back.
877
- candidates: list[tuple[str, bytes | None, int]] = [
878
- ("visitor", visitor_key, 4),
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
- for name, key, slen in candidates:
1061
+ verified: bool | None
883
1062
  if key is None:
884
- continue
885
- header_len = 1 + slen + 2
886
- if len(raw) < header_len:
887
- continue
888
-
889
- auth_level = raw[0]
890
- sig = raw[1 : 1 + slen]
891
- cmac_input = raw[1 + slen :] # seq(2) + payload
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
- try:
894
- expected = classic_cmac_prefix(key, self._classicConnHash8, cmac_input, slen)
895
- except Exception:
896
- 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
897
1111
 
898
- if expected == sig:
899
- verified = True
900
- key_name = name
901
- sig_len = slen
902
- payload_with_seq = cmac_input
903
- if self._logger.isEnabledFor(logging.DEBUG):
904
- seq = int.from_bytes(cmac_input[:2], byteorder="big", signed=False)
905
- self._logger.debug(
906
- "[CASAMBI_CLASSIC_RX_VERIFY] ok key=%s auth=0x%02x sig_len=%d seq=0x%04x",
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
- if not verified:
915
- if self._logger.isEnabledFor(logging.DEBUG):
916
- self._logger.debug("[CASAMBI_CLASSIC_RX_VERIFY] failed (no matching CMAC prefix)")
917
- # Best-effort: treat raw bytes as payload.
918
- 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)
919
1134
  else:
920
- assert payload_with_seq is not None
921
- # Drop the 16-bit sequence from the payload for higher-level parsing.
922
- 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
923
1152
 
924
- 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
+ )
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, 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.dev4
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=yTSuAeJhBXp5Zs3jU-RvHFEpI-quRNwlB3HWGl7q_yY,50730
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.dev4.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
18
- casambi_bt_revamped-0.3.12.dev4.dist-info/METADATA,sha256=DKE1xb6Jg8lORTpoWyiM8qaSBOXOb5V_l7phDqWHGBA,5877
19
- casambi_bt_revamped-0.3.12.dev4.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
20
- casambi_bt_revamped-0.3.12.dev4.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
21
- casambi_bt_revamped-0.3.12.dev4.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,,