casambi-bt-revamped 0.3.12.dev2__py3-none-any.whl → 0.3.12.dev3__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/_casambi.py CHANGED
@@ -10,7 +10,7 @@ from bleak.backends.device import BLEDevice
10
10
  from httpx import AsyncClient, RequestError
11
11
 
12
12
  from ._cache import Cache
13
- from ._client import CasambiClient, ConnectionState, IncommingPacketType
13
+ from ._client import CasambiClient, ConnectionState, IncommingPacketType, ProtocolMode
14
14
  from ._network import Network
15
15
  from ._operation import OpCode, OperationsContext
16
16
  from ._unit import Group, Scene, Unit, UnitControlType, UnitState
@@ -169,8 +169,10 @@ class Casambi:
169
169
  self._casaClient = cast(CasambiClient, self._casaClient)
170
170
  await self._casaClient.connect()
171
171
  try:
172
- await self._casaClient.exchangeKey()
173
- await self._casaClient.authenticate()
172
+ # EVO requires key exchange + authenticate; Classic is ready after `connect()`.
173
+ if self._casaClient.protocolMode == ProtocolMode.EVO:
174
+ await self._casaClient.exchangeKey()
175
+ await self._casaClient.authenticate()
174
176
  except ProtocolError as e:
175
177
  await self._casaClient.disconnect()
176
178
  raise e
@@ -201,6 +203,23 @@ class Casambi:
201
203
  raise ValueError()
202
204
 
203
205
  payload = level.to_bytes(1, byteorder="big", signed=False)
206
+
207
+ # Classic protocol uses signed command frames (u1.C1753e / u1.EnumC1754f).
208
+ if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
209
+ # EnumC1754f ordinals (ground truth: casambi-android u1.EnumC1754f):
210
+ # - AllUnitsLevel=4, UnitLevel=7, GroupLevel=26
211
+ if isinstance(target, Unit):
212
+ cmd = self._casaClient.buildClassicCommand(7, payload, target_id=target.deviceId)
213
+ elif isinstance(target, Group):
214
+ cmd = self._casaClient.buildClassicCommand(26, payload, target_id=target.groudId)
215
+ elif target is None:
216
+ cmd = self._casaClient.buildClassicCommand(4, payload)
217
+ else:
218
+ raise TypeError(f"Unkown target type {type(target)}")
219
+
220
+ await self._casaClient.send(cmd)
221
+ return
222
+
204
223
  await self._send(target, payload, OpCode.SetLevel)
205
224
 
206
225
  async def setVertical(self, target: Unit | Group | None, vertical: int) -> None:
@@ -219,6 +238,21 @@ class Casambi:
219
238
  raise ValueError()
220
239
 
221
240
  payload = vertical.to_bytes(1, byteorder="big", signed=False)
241
+
242
+ if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
243
+ # EnumC1754f ordinals: AllUnitsVertical=22, UnitVertical=24, GroupVertical=29
244
+ if isinstance(target, Unit):
245
+ cmd = self._casaClient.buildClassicCommand(24, payload, target_id=target.deviceId)
246
+ elif isinstance(target, Group):
247
+ cmd = self._casaClient.buildClassicCommand(29, payload, target_id=target.groudId)
248
+ elif target is None:
249
+ cmd = self._casaClient.buildClassicCommand(22, payload)
250
+ else:
251
+ raise TypeError(f"Unkown target type {type(target)}")
252
+
253
+ await self._casaClient.send(cmd)
254
+ return
255
+
222
256
  await self._send(target, payload, OpCode.SetVertical)
223
257
 
224
258
  async def setSlider(self, target: Unit | Group | None, value: int) -> None:
@@ -255,6 +289,21 @@ class Casambi:
255
289
  raise ValueError()
256
290
 
257
291
  payload = level.to_bytes(1, byteorder="big", signed=False)
292
+
293
+ if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
294
+ # EnumC1754f ordinals: AllUnitsWhite=23, UnitWhite=25, GroupWhite=30
295
+ if isinstance(target, Unit):
296
+ cmd = self._casaClient.buildClassicCommand(25, payload, target_id=target.deviceId)
297
+ elif isinstance(target, Group):
298
+ cmd = self._casaClient.buildClassicCommand(30, payload, target_id=target.groudId)
299
+ elif target is None:
300
+ cmd = self._casaClient.buildClassicCommand(23, payload)
301
+ else:
302
+ raise TypeError(f"Unkown target type {type(target)}")
303
+
304
+ await self._casaClient.send(cmd)
305
+ return
306
+
258
307
  await self._send(target, payload, OpCode.SetWhite)
259
308
 
260
309
  async def setColor(
@@ -272,6 +321,27 @@ class Casambi:
272
321
  :raises ValueError: The supplied rgbColor isn't in range
273
322
  """
274
323
 
324
+ if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
325
+ # Classic uses RGB payload (3 bytes) directly.
326
+ r, g, b = rgbColor
327
+ if not (0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255):
328
+ raise ValueError("rgbColor out of range.")
329
+ payload = bytes([r & 0xFF, g & 0xFF, b & 0xFF])
330
+
331
+ # EnumC1754f ordinals: AllUnitsColor=6, UnitColor=9, GroupColor=28
332
+ if isinstance(target, Unit):
333
+ cmd = self._casaClient.buildClassicCommand(9, payload, target_id=target.deviceId)
334
+ elif isinstance(target, Group):
335
+ cmd = self._casaClient.buildClassicCommand(28, payload, target_id=target.groudId)
336
+ elif target is None:
337
+ cmd = self._casaClient.buildClassicCommand(6, payload)
338
+ else:
339
+ raise TypeError(f"Unkown target type {type(target)}")
340
+
341
+ await self._casaClient.send(cmd)
342
+ return
343
+
344
+ # Evolution uses HS payload (hue 10-bit + sat 8-bit) for SetColor.
275
345
  state = UnitState()
276
346
  state.rgb = rgbColor
277
347
  hs: tuple[float, float] = state.hs # type: ignore[assignment]
@@ -300,6 +370,21 @@ class Casambi:
300
370
 
301
371
  temperature = int(temperature / 50)
302
372
  payload = temperature.to_bytes(1, byteorder="big", signed=False)
373
+
374
+ if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
375
+ # EnumC1754f ordinals: AllUnitsTemperature=5, UnitTemperature=8, GroupTemperature=27
376
+ if isinstance(target, Unit):
377
+ cmd = self._casaClient.buildClassicCommand(8, payload, target_id=target.deviceId)
378
+ elif isinstance(target, Group):
379
+ cmd = self._casaClient.buildClassicCommand(27, payload, target_id=target.groudId)
380
+ elif target is None:
381
+ cmd = self._casaClient.buildClassicCommand(5, payload)
382
+ else:
383
+ raise TypeError(f"Unkown target type {type(target)}")
384
+
385
+ await self._casaClient.send(cmd)
386
+ return
387
+
303
388
  await self._send(target, payload, OpCode.SetTemperature)
304
389
 
305
390
  async def setColorXY(
@@ -317,6 +402,10 @@ class Casambi:
317
402
  :raises ValueError: The supplied XYColor isn't in range or not supported by the supplied unit.
318
403
  """
319
404
 
405
+ if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
406
+ # Classic command set (u1.EnumC1754f) only exposes RGB color control.
407
+ raise ValueError("XY color control is not supported on Classic networks.")
408
+
320
409
  if xyColor[0] < 0.0 or xyColor[0] > 1.0 or xyColor[1] < 0.0 or xyColor[1] > 1.0:
321
410
  raise ValueError("Color out of range.")
322
411
 
@@ -345,6 +434,22 @@ class Casambi:
345
434
  :return: Nothing is returned by this function. To get the new state register a change handler.
346
435
  """
347
436
 
437
+ if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
438
+ # Classic uses a longer payload for "restore last level" (ground truth: casambi-android u1.C1751c.o()).
439
+ payload = bytes([0xFF, 0x01, 0x00, 0x00, 0x01])
440
+ # EnumC1754f ordinals: AllUnitsLevel=4, UnitLevel=7, GroupLevel=26
441
+ if isinstance(target, Unit):
442
+ cmd = self._casaClient.buildClassicCommand(7, payload, target_id=target.deviceId)
443
+ elif isinstance(target, Group):
444
+ cmd = self._casaClient.buildClassicCommand(26, payload, target_id=target.groudId)
445
+ elif target is None:
446
+ cmd = self._casaClient.buildClassicCommand(4, payload)
447
+ else:
448
+ raise TypeError(f"Unkown target type {type(target)}")
449
+
450
+ await self._casaClient.send(cmd)
451
+ return
452
+
348
453
  # Use -1 to indicate special packet format
349
454
  # Use RestoreLastLevel flag (1) and UseFullTimeFlag (4).
350
455
  # Not sure what UseFullTime does but this is what the app uses.
@@ -367,6 +472,11 @@ class Casambi:
367
472
  ConnectionState.AUTHENTICATED,
368
473
  ConnectionState.NONE,
369
474
  )
475
+ if self._casaClient.protocolMode == ProtocolMode.CLASSIC:
476
+ # Classic uses a completely different command encoding (u1.C1753e/u1.EnumC1754f).
477
+ # Public APIs that support Classic handle it explicitly; anything reaching here would
478
+ # send an EVO INVOCATION packet which is not valid on Classic.
479
+ raise ProtocolError(f"Operation {opcode.name} is not supported on Classic networks via INVOCATION.")
370
480
 
371
481
  targetCode = 0
372
482
  if isinstance(target, Unit):
@@ -0,0 +1,31 @@
1
+ """Classic Casambi protocol helpers (CMAC signing/verification).
2
+
3
+ Ground truth:
4
+ - casambi-android `t1.P.o(...)` calculates a CMAC over:
5
+ connection_hash[0:8] + payload
6
+ and stores the CMAC (prefix) into the packet header.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from cryptography.hazmat.primitives.cmac import CMAC
12
+ from cryptography.hazmat.primitives.ciphers.algorithms import AES
13
+
14
+
15
+ def classic_cmac(key: bytes, conn_hash8: bytes, payload: bytes) -> bytes:
16
+ """Compute the Classic CMAC (16 bytes) over connection hash + payload."""
17
+ if len(conn_hash8) != 8:
18
+ raise ValueError("conn_hash8 must be 8 bytes")
19
+ cmac = CMAC(AES(key))
20
+ cmac.update(conn_hash8)
21
+ cmac.update(payload)
22
+ return cmac.finalize()
23
+
24
+
25
+ def classic_cmac_prefix(
26
+ key: bytes, conn_hash8: bytes, payload: bytes, prefix_len: int
27
+ ) -> bytes:
28
+ """Return the prefix bytes that are embedded into the Classic packet header."""
29
+ mac = classic_cmac(key, conn_hash8, payload)
30
+ return mac[:prefix_len]
31
+
CasambiBt/_client.py CHANGED
@@ -1,11 +1,12 @@
1
1
  import asyncio
2
2
  import inspect
3
3
  import logging
4
+ import os
4
5
  import platform
5
6
  import struct
6
7
  from binascii import b2a_hex as b2a
7
8
  from collections.abc import Callable
8
- from enum import IntEnum, unique
9
+ from enum import Enum, IntEnum, auto, unique
9
10
  from hashlib import sha256
10
11
  from typing import Any, Final
11
12
 
@@ -23,6 +24,8 @@ from cryptography.exceptions import InvalidSignature
23
24
  from cryptography.hazmat.primitives.asymmetric import ec
24
25
 
25
26
  from ._constants import CASA_AUTH_CHAR_UUID, ConnectionState
27
+ from ._constants import CASA_CLASSIC_DATA_CHAR_UUID, CASA_CLASSIC_HASH_CHAR_UUID
28
+ from ._classic_crypto import classic_cmac_prefix
26
29
  from ._encryption import Encryptor
27
30
  from ._network import Network
28
31
  from ._switch_events import SwitchEventStreamDecoder
@@ -31,6 +34,8 @@ from ._switch_events import SwitchEventStreamDecoder
31
34
  from .errors import ( # noqa: E402
32
35
  BluetoothError,
33
36
  ConnectionStateError,
37
+ ClassicHandshakeError,
38
+ ClassicKeysMissingError,
34
39
  NetworkNotFoundError,
35
40
  ProtocolError,
36
41
  UnsupportedProtocolVersion,
@@ -44,6 +49,11 @@ class IncommingPacketType(IntEnum):
44
49
  NetworkConfig = 9
45
50
 
46
51
 
52
+ class ProtocolMode(Enum):
53
+ EVO = auto()
54
+ CLASSIC = auto()
55
+
56
+
47
57
  MIN_VERSION: Final[int] = 10
48
58
  MAX_VERSION: Final[int] = 11
49
59
 
@@ -87,7 +97,27 @@ class CasambiClient:
87
97
  self._disconnectedCallback = disonnectedCallback
88
98
  self._activityLock = asyncio.Lock()
89
99
 
90
- self._checkProtocolVersion(network.protocolVersion)
100
+ # Determined at runtime by inspecting GATT services/characteristics.
101
+ self._protocolMode: ProtocolMode | None = None
102
+ self._dataCharUuid: str | None = None
103
+
104
+ # Classic protocol state
105
+ self._classicConnHash8: bytes | None = None
106
+ self._classicTxSeq: int = 0 # 16-bit sequence number (big endian on the wire)
107
+ 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
+ }
117
+
118
+ @property
119
+ def protocolMode(self) -> ProtocolMode | None:
120
+ return self._protocolMode
91
121
 
92
122
  def _checkProtocolVersion(self, version: int) -> None:
93
123
  if version < MIN_VERSION:
@@ -177,6 +207,115 @@ class CasambiClient:
177
207
  self._logger.info(f"Connected to {self.address}")
178
208
  self._connectionState = ConnectionState.CONNECTED
179
209
 
210
+ # Detect protocol mode by available characteristics.
211
+ services = await self._gattClient.get_services()
212
+
213
+ def _has_char(uuid: str) -> bool:
214
+ uuid_l = uuid.lower()
215
+ for s in services:
216
+ for c in s.characteristics:
217
+ if c.uuid.lower() == uuid_l:
218
+ return True
219
+ return False
220
+
221
+ # Classic (non-conformant) uses CA51 (connection hash) + CA52 (data channel).
222
+ if _has_char(CASA_CLASSIC_HASH_CHAR_UUID) and _has_char(CASA_CLASSIC_DATA_CHAR_UUID):
223
+ if os.getenv("CASAMBI_BT_DISABLE_CLASSIC", "").strip() in {"1", "true", "TRUE", "yes", "YES"}:
224
+ raise ProtocolError("Classic protocol detected but disabled via CASAMBI_BT_DISABLE_CLASSIC=1")
225
+
226
+ if not self._network.hasClassicKeys():
227
+ raise ClassicKeysMissingError(
228
+ "Classic protocol detected but network has no visitorKey/managerKey."
229
+ )
230
+
231
+ self._protocolMode = ProtocolMode.CLASSIC
232
+ self._dataCharUuid = CASA_CLASSIC_DATA_CHAR_UUID
233
+
234
+ # Read connection hash (first 8 bytes are used for CMAC signing).
235
+ raw_hash = await self._gattClient.read_gatt_char(CASA_CLASSIC_HASH_CHAR_UUID)
236
+ if raw_hash is None or len(raw_hash) < 8:
237
+ raise ClassicHandshakeError(
238
+ f"Classic connection hash read failed/too short (len={0 if raw_hash is None else len(raw_hash)})."
239
+ )
240
+ self._classicConnHash8 = bytes(raw_hash[:8])
241
+ # Android seeds the command divider with a random byte on startup (u1.C1751c).
242
+ self._classicCmdDiv = int.from_bytes(os.urandom(1), "big") or 1
243
+ self._classicTxSeq = 0
244
+
245
+ # Start notify on the data channel.
246
+ notify_kwargs: dict[str, Any] = {}
247
+ notify_params = inspect.signature(self._gattClient.start_notify).parameters
248
+ if "bluez" in notify_params:
249
+ notify_kwargs["bluez"] = {"use_start_notify": True}
250
+ await self._gattClient.start_notify(
251
+ CASA_CLASSIC_DATA_CHAR_UUID,
252
+ self._queueCallback,
253
+ **notify_kwargs,
254
+ )
255
+
256
+ # Classic has no EVO-style key exchange/auth; we can send immediately.
257
+ self._connectionState = ConnectionState.AUTHENTICATED
258
+ self._logger.info("Protocol mode selected: CLASSIC")
259
+ if self._logger.isEnabledFor(logging.DEBUG):
260
+ self._logger.debug(
261
+ "[CASAMBI_CLASSIC_CONN_HASH] len=%d hash=%s",
262
+ len(self._classicConnHash8),
263
+ b2a(self._classicConnHash8),
264
+ )
265
+ return
266
+
267
+ # Conformant devices can expose the Classic signed channel on the EVO-style UUID too.
268
+ if _has_char(CASA_AUTH_CHAR_UUID):
269
+ first = await self._gattClient.read_gatt_char(CASA_AUTH_CHAR_UUID)
270
+ if first and len(first) >= 2 and first[0] == 0x01:
271
+ # EVO NodeInfo packet starts with 0x01.
272
+ self._protocolMode = ProtocolMode.EVO
273
+ self._dataCharUuid = CASA_AUTH_CHAR_UUID
274
+ self._checkProtocolVersion(self._network.protocolVersion)
275
+ self._logger.info("Protocol mode selected: EVO")
276
+ return
277
+
278
+ # Otherwise, treat as Classic conformant: read provides connection hash.
279
+ if os.getenv("CASAMBI_BT_DISABLE_CLASSIC", "").strip() in {"1", "true", "TRUE", "yes", "YES"}:
280
+ raise ProtocolError("Classic protocol detected but disabled via CASAMBI_BT_DISABLE_CLASSIC=1")
281
+ if not self._network.hasClassicKeys():
282
+ raise ClassicKeysMissingError(
283
+ "Classic protocol detected but network has no visitorKey/managerKey."
284
+ )
285
+ if first is None or len(first) < 8:
286
+ raise ClassicHandshakeError(
287
+ f"Classic connection hash read failed/too short (len={0 if first is None else len(first)})."
288
+ )
289
+
290
+ self._protocolMode = ProtocolMode.CLASSIC
291
+ self._dataCharUuid = CASA_AUTH_CHAR_UUID
292
+ self._classicConnHash8 = bytes(first[:8])
293
+ self._classicCmdDiv = int.from_bytes(os.urandom(1), "big") or 1
294
+ self._classicTxSeq = 0
295
+
296
+ notify_kwargs: dict[str, Any] = {}
297
+ notify_params = inspect.signature(self._gattClient.start_notify).parameters
298
+ if "bluez" in notify_params:
299
+ notify_kwargs["bluez"] = {"use_start_notify": True}
300
+ await self._gattClient.start_notify(
301
+ CASA_AUTH_CHAR_UUID,
302
+ self._queueCallback,
303
+ **notify_kwargs,
304
+ )
305
+ self._connectionState = ConnectionState.AUTHENTICATED
306
+ self._logger.info("Protocol mode selected: CLASSIC")
307
+ if self._logger.isEnabledFor(logging.DEBUG):
308
+ self._logger.debug(
309
+ "[CASAMBI_CLASSIC_CONN_HASH] len=%d hash=%s",
310
+ len(self._classicConnHash8),
311
+ b2a(self._classicConnHash8),
312
+ )
313
+ return
314
+
315
+ raise ProtocolError(
316
+ "No supported Casambi characteristics found (Classic ca51/ca52 or EVO/Classic conformant auth char)."
317
+ )
318
+
180
319
  def _on_disconnect(self, client: BleakClient) -> None:
181
320
  if self._connectionState != ConnectionState.NONE:
182
321
  self._logger.info(f"Received disconnect callback from {self.address}")
@@ -292,7 +431,13 @@ class CasambiClient:
292
431
  def _callbackMulitplexer(
293
432
  self, handle: BleakGATTCharacteristic, data: bytes
294
433
  ) -> None:
295
- self._logger.debug(f"Callback on handle {handle}: {b2a(data)}")
434
+ if self._logRawNotifies and self._logger.isEnabledFor(logging.DEBUG):
435
+ self._logger.debug(
436
+ "Callback on handle %s (%s): %s",
437
+ getattr(handle, "handle", "?"),
438
+ getattr(handle, "uuid", "?"),
439
+ b2a(data),
440
+ )
296
441
 
297
442
  if self._connectionState == ConnectionState.CONNECTED:
298
443
  self._exchNofityCallback(handle, data)
@@ -432,6 +577,12 @@ class CasambiClient:
432
577
  return self._nonce[:4] + id + self._nonce[8:]
433
578
 
434
579
  async def send(self, packet: bytes) -> None:
580
+ # EVO sends INVOCATION operations (packet type=0x07) inside the encrypted channel.
581
+ # Classic sends signed command frames on the CA52 channel.
582
+ if self._protocolMode == ProtocolMode.CLASSIC:
583
+ await self._sendClassicSigned(packet)
584
+ return
585
+
435
586
  self._checkState(ConnectionState.AUTHENTICATED)
436
587
 
437
588
  await self._activityLock.acquire()
@@ -452,9 +603,167 @@ class CasambiClient:
452
603
  finally:
453
604
  self._activityLock.release()
454
605
 
606
+ def _classic_next_seq(self) -> int:
607
+ # 16-bit sequence inserted in the header (big endian) and included in CMAC input.
608
+ self._classicTxSeq = (self._classicTxSeq + 1) & 0xFFFF
609
+ if self._classicTxSeq == 0:
610
+ self._classicTxSeq = 1
611
+ return self._classicTxSeq
612
+
613
+ def _classic_next_div(self) -> int:
614
+ # 8-bit command divider/id. Android uses a random start and increments 1..255.
615
+ self._classicCmdDiv += 1
616
+ if self._classicCmdDiv == 0 or self._classicCmdDiv > 255:
617
+ self._classicCmdDiv = 1
618
+ return self._classicCmdDiv
619
+
620
+ def buildClassicCommand(
621
+ self,
622
+ command_ordinal: int,
623
+ payload: bytes,
624
+ *,
625
+ target_id: int | None = None,
626
+ lifetime: int = 200,
627
+ div: int | None = None,
628
+ ) -> bytes:
629
+ """Build one Classic command record (u1.C1753e export format).
630
+
631
+ This is the message that follows the Classic signed header and 16-bit sequence.
632
+ """
633
+ if div is None:
634
+ div = self._classic_next_div()
635
+ if div < 0 or div > 255:
636
+ raise ValueError("div must fit in one byte")
637
+ if lifetime < 0 or lifetime > 255:
638
+ raise ValueError("lifetime must fit in one byte")
639
+ if target_id is not None and (target_id < 0 or target_id > 255):
640
+ raise ValueError("target_id must fit in one byte")
641
+
642
+ # Two leading bytes are patched after we know the final length:
643
+ # - byte0 = (len + 239) mod 256
644
+ # - byte1 = ordinal | 0x40 (div present) | 0x80 (target present)
645
+ b = bytearray()
646
+ b.append(0)
647
+ b.append(0)
648
+
649
+ type_flags = command_ordinal & 0x3F
650
+
651
+ # div present
652
+ b.append(div & 0xFF)
653
+ type_flags |= 0x40
654
+
655
+ if target_id is not None and target_id > 0:
656
+ b.append(target_id & 0xFF)
657
+ type_flags |= 0x80
658
+
659
+ b.append(lifetime & 0xFF)
660
+ b.extend(payload)
661
+
662
+ msg_len = len(b)
663
+ b[0] = (msg_len + 239) & 0xFF
664
+ b[1] = type_flags & 0xFF
665
+
666
+ if self._logger.isEnabledFor(logging.DEBUG):
667
+ self._logger.debug(
668
+ "[CASAMBI_CLASSIC_CMD_BUILD] ord=%d target=%s div=%d lifetime=%d len=%d payload=%s",
669
+ command_ordinal,
670
+ target_id,
671
+ div,
672
+ lifetime,
673
+ msg_len,
674
+ b2a(payload),
675
+ )
676
+
677
+ return bytes(b)
678
+
679
+ async def _sendClassicSigned(self, command_bytes: bytes, *, use_manager: bool | None = None) -> None:
680
+ self._checkState(ConnectionState.AUTHENTICATED)
681
+ if self._protocolMode != ProtocolMode.CLASSIC:
682
+ raise ProtocolError("Classic send called while not in Classic protocol mode.")
683
+ if not self._dataCharUuid:
684
+ raise ProtocolError("Classic data characteristic UUID not set.")
685
+ if self._classicConnHash8 is None:
686
+ raise ClassicHandshakeError("Classic connection hash not available.")
687
+
688
+ # Decide whether to use visitor or manager key.
689
+ if use_manager is None:
690
+ use_manager = os.getenv("CASAMBI_BT_CLASSIC_USE_MANAGER", "").strip() in {
691
+ "1",
692
+ "true",
693
+ "TRUE",
694
+ "yes",
695
+ "YES",
696
+ }
697
+
698
+ visitor_key = self._network.classicVisitorKey()
699
+ manager_key = self._network.classicManagerKey()
700
+
701
+ key_name = "visitor"
702
+ auth_level = 0x02
703
+ sig_len = 4
704
+ key = visitor_key
705
+
706
+ if use_manager or key is None:
707
+ if manager_key is None:
708
+ # If we were forced to use manager but don't have one, fall back to visitor if present.
709
+ if visitor_key is None:
710
+ raise ClassicKeysMissingError(
711
+ "Classic network has no visitorKey/managerKey available."
712
+ )
713
+ key = visitor_key
714
+ else:
715
+ key_name = "manager"
716
+ auth_level = 0x03
717
+ sig_len = 16
718
+ key = manager_key
719
+
720
+ seq = self._classic_next_seq()
721
+
722
+ # Header layout (rVar.Z=true / "conformant" classic):
723
+ # [0] auth_level (2 visitor / 3 manager)
724
+ # [1..sig_len] CMAC prefix placeholder (filled after CMAC computation)
725
+ # [1+sig_len .. 1+sig_len+1] 16-bit sequence, big endian (included in CMAC input)
726
+ # [..] command bytes
727
+ pkt = bytearray()
728
+ pkt.append(auth_level)
729
+ pkt.extend(b"\x00" * sig_len)
730
+ pkt.extend(b"\x00\x00")
731
+ pkt.extend(command_bytes)
732
+
733
+ seq_off = 1 + sig_len
734
+ pkt[seq_off] = (seq >> 8) & 0xFF
735
+ pkt[seq_off + 1] = seq & 0xFF
736
+
737
+ cmac_input = bytes(pkt[seq_off:]) # includes seq + command bytes
738
+ prefix = classic_cmac_prefix(key, self._classicConnHash8, cmac_input, sig_len)
739
+ pkt[1 : 1 + sig_len] = prefix
740
+
741
+ if self._logger.isEnabledFor(logging.DEBUG):
742
+ self._logger.debug(
743
+ "[CASAMBI_CLASSIC_TX] key=%s auth=0x%02x sig_len=%d seq=0x%04x cmd_len=%d total_len=%d",
744
+ key_name,
745
+ auth_level,
746
+ sig_len,
747
+ seq,
748
+ len(command_bytes),
749
+ len(pkt),
750
+ )
751
+ self._logger.debug(
752
+ "[CASAMBI_CLASSIC_TX_RAW] %s",
753
+ b2a(bytes(pkt[: min(len(pkt), 64)])) + (b"..." if len(pkt) > 64 else b""),
754
+ )
755
+
756
+ # Classic packets can exceed 20 bytes when using a 16-byte manager signature.
757
+ # Bleak needs a write-with-response for long writes on most backends.
758
+ await self._gattClient.write_gatt_char(self._dataCharUuid, bytes(pkt), response=True)
759
+
455
760
  def _establishedNofityCallback(
456
761
  self, handle: BleakGATTCharacteristic, data: bytes
457
762
  ) -> None:
763
+ if self._protocolMode == ProtocolMode.CLASSIC:
764
+ self._classicEstablishedNotifyCallback(handle, data)
765
+ return
766
+
458
767
  # TODO: Check incoming counter and direction flag
459
768
  self._inPacketCount += 1
460
769
 
@@ -515,6 +824,153 @@ class CasambiClient:
515
824
  else:
516
825
  self._logger.debug("Packet type %d not implemented. Ignoring!", packetType)
517
826
 
827
+ def _classicEstablishedNotifyCallback(
828
+ self, handle: BleakGATTCharacteristic, data: bytes
829
+ ) -> None:
830
+ """Parse Classic notifications from the CA52 channel.
831
+
832
+ Classic packets are CMAC-signed (prefix embedded into the header).
833
+ Ground truth: casambi-android `t1.P.o(...)`.
834
+ """
835
+ self._inPacketCount += 1
836
+
837
+ raw = bytes(data)
838
+ if self._logger.isEnabledFor(logging.DEBUG):
839
+ self._logger.debug(
840
+ "[CASAMBI_CLASSIC_RX_RAW] len=%d hex=%s",
841
+ len(raw),
842
+ b2a(raw[: min(len(raw), 64)]) + (b"..." if len(raw) > 64 else b""),
843
+ )
844
+
845
+ if self._classicConnHash8 is None:
846
+ self._logger.debug("[CASAMBI_CLASSIC_RX] Missing connection hash; cannot verify CMAC.")
847
+ return
848
+
849
+ visitor_key = self._network.classicVisitorKey()
850
+ manager_key = self._network.classicManagerKey()
851
+
852
+ verified = False
853
+ key_name: str | None = None
854
+ sig_len: int | None = None
855
+ payload_with_seq: bytes | None = None
856
+
857
+ # Try visitor (4-byte prefix) first, then manager (16-byte prefix).
858
+ # Some frames may be unsigned; in that case verification will fail and we'll fall back.
859
+ candidates: list[tuple[str, bytes | None, int]] = [
860
+ ("visitor", visitor_key, 4),
861
+ ("manager", manager_key, 16),
862
+ ]
863
+
864
+ for name, key, slen in candidates:
865
+ if key is None:
866
+ continue
867
+ header_len = 1 + slen + 2
868
+ if len(raw) < header_len:
869
+ continue
870
+
871
+ auth_level = raw[0]
872
+ sig = raw[1 : 1 + slen]
873
+ cmac_input = raw[1 + slen :] # seq(2) + payload
874
+
875
+ try:
876
+ expected = classic_cmac_prefix(key, self._classicConnHash8, cmac_input, slen)
877
+ except Exception:
878
+ continue
879
+
880
+ if expected == sig:
881
+ verified = True
882
+ key_name = name
883
+ sig_len = slen
884
+ payload_with_seq = cmac_input
885
+ if self._logger.isEnabledFor(logging.DEBUG):
886
+ seq = int.from_bytes(cmac_input[:2], byteorder="big", signed=False)
887
+ self._logger.debug(
888
+ "[CASAMBI_CLASSIC_RX_VERIFY] ok key=%s auth=0x%02x sig_len=%d seq=0x%04x",
889
+ name,
890
+ auth_level,
891
+ slen,
892
+ seq,
893
+ )
894
+ break
895
+
896
+ if not verified:
897
+ if self._logger.isEnabledFor(logging.DEBUG):
898
+ self._logger.debug("[CASAMBI_CLASSIC_RX_VERIFY] failed (no matching CMAC prefix)")
899
+ # Best-effort: treat raw bytes as payload.
900
+ payload = raw
901
+ else:
902
+ assert payload_with_seq is not None
903
+ # Drop the 16-bit sequence from the payload for higher-level parsing.
904
+ payload = payload_with_seq[2:]
905
+
906
+ if not payload:
907
+ return
908
+
909
+ # If the payload starts with a known EVO packet type, reuse existing parsers.
910
+ packet_type = payload[0]
911
+ if packet_type in (IncommingPacketType.UnitState, IncommingPacketType.SwitchEvent, IncommingPacketType.NetworkConfig):
912
+ if self._logger.isEnabledFor(logging.DEBUG):
913
+ self._logger.debug(
914
+ "[CASAMBI_CLASSIC_RX_PAYLOAD] type=%d len=%d hex=%s",
915
+ packet_type,
916
+ len(payload),
917
+ b2a(payload[: min(len(payload), 64)])
918
+ + (b"..." if len(payload) > 64 else b""),
919
+ )
920
+ if packet_type == IncommingPacketType.UnitState:
921
+ self._parseUnitStates(payload[1:])
922
+ elif packet_type == IncommingPacketType.SwitchEvent:
923
+ self._parseSwitchEvent(payload[1:], None, raw)
924
+ else:
925
+ # ignore network config
926
+ pass
927
+ return
928
+
929
+ # Otherwise, attempt to parse a stream of Classic "command" records:
930
+ # record[0] = (len + 239) mod 256, so len = (b0 - 239) & 0xFF.
931
+ pos = 0
932
+ while pos + 2 <= len(payload):
933
+ enc_len = payload[pos]
934
+ rec_len = (enc_len - 239) & 0xFF
935
+ if rec_len < 2 or pos + rec_len > len(payload):
936
+ break
937
+ rec = payload[pos : pos + rec_len]
938
+ pos += rec_len
939
+
940
+ typ = rec[1]
941
+ ordinal = typ & 0x3F
942
+ has_div = (typ & 0x40) != 0
943
+ has_target = (typ & 0x80) != 0
944
+ p = 2
945
+ div = rec[p] if has_div and p < len(rec) else None
946
+ if has_div:
947
+ p += 1
948
+ target = rec[p] if has_target and p < len(rec) else None
949
+ if has_target:
950
+ p += 1
951
+ lifetime = rec[p] if p < len(rec) else None
952
+ if lifetime is not None:
953
+ p += 1
954
+ rec_payload = rec[p:] if p <= len(rec) else b""
955
+
956
+ if self._logger.isEnabledFor(logging.DEBUG):
957
+ self._logger.debug(
958
+ "[CASAMBI_CLASSIC_CMD] ord=%d div=%s target=%s lifetime=%s payload=%s",
959
+ ordinal,
960
+ div,
961
+ target,
962
+ lifetime,
963
+ b2a(rec_payload),
964
+ )
965
+
966
+ # Any trailing bytes that don't form a full record are logged for analysis.
967
+ if self._logger.isEnabledFor(logging.DEBUG) and pos < len(payload):
968
+ self._logger.debug(
969
+ "[CASAMBI_CLASSIC_CMD_TRAILING] len=%d hex=%s",
970
+ len(payload) - pos,
971
+ b2a(payload[pos:]),
972
+ )
973
+
518
974
  def _parseUnitStates(self, data: bytes) -> None:
519
975
  # Ground truth: casambi-android `v1.C1775b.V(Q2.h)` parses decrypted packet type=6
520
976
  # as a stream of unit state records. Records have optional bytes depending on flags.
CasambiBt/_constants.py CHANGED
@@ -6,6 +6,13 @@ DEVICE_NAME: Final = "Casambi BT Python"
6
6
  CASA_UUID: Final = "0000fe4d-0000-1000-8000-00805f9b34fb"
7
7
  CASA_AUTH_CHAR_UUID: Final = "c9ffde48-ca5a-0001-ab83-8f519b482f77"
8
8
 
9
+ # Classic firmware/protocol uses different GATT characteristics (see casambi-android t1.C1713d):
10
+ # - 0000ca51-...: connection hash (first 8 bytes are used as CMAC input prefix)
11
+ # - 0000ca52-...: signed data channel (write + notify)
12
+ CASA_UUID_CLASSIC: Final = "0000ca5a-0000-1000-8000-00805f9b34fb"
13
+ CASA_CLASSIC_HASH_CHAR_UUID: Final = "0000ca51-0000-1000-8000-00805f9b34fb"
14
+ CASA_CLASSIC_DATA_CHAR_UUID: Final = "0000ca52-0000-1000-8000-00805f9b34fb"
15
+
9
16
 
10
17
  @unique
11
18
  class ConnectionState(IntEnum):
CasambiBt/_discover.py CHANGED
@@ -5,7 +5,7 @@ from bleak import BleakScanner
5
5
  from bleak.backends.client import BLEDevice
6
6
  from bleak.exc import BleakDBusError, BleakError
7
7
 
8
- from ._constants import CASA_UUID
8
+ from ._constants import CASA_UUID, CASA_UUID_CLASSIC
9
9
  from .errors import BluetoothError
10
10
 
11
11
  _LOGGER = logging.getLogger(__name__)
@@ -39,7 +39,8 @@ async def discover() -> list[BLEDevice]:
39
39
  discovered = []
40
40
  for _, (d, advertisement) in devices_and_advertisements.items():
41
41
  if 963 in advertisement.manufacturer_data:
42
- if CASA_UUID in advertisement.service_uuids:
42
+ # Evolution networks advertise FE4D; Classic networks advertise CA5A.
43
+ if CASA_UUID in advertisement.service_uuids or CASA_UUID_CLASSIC in advertisement.service_uuids:
43
44
  _LOGGER.debug(f"Discovered network at {d.address}")
44
45
  discovered.append(d)
45
46
 
CasambiBt/_network.py CHANGED
@@ -44,6 +44,10 @@ class Network:
44
44
  self._networkName: str | None = None
45
45
  self._networkRevision: int | None = None
46
46
  self._protocolVersion: int = -1
47
+ # Classic networks do not have a `keyStore`; instead they expose visitor/manager keys.
48
+ # Ground truth: casambi-android `D1.Z0` exports `visitorKey`/`managerKey`.
49
+ self._classicVisitorKey: bytes | None = None
50
+ self._classicManagerKey: bytes | None = None
47
51
  self._rawNetworkData: dict | None = None
48
52
 
49
53
  self._unitTypes: dict[int, tuple[UnitType | None, datetime]] = {}
@@ -154,6 +158,19 @@ class Network:
154
158
  def protocolVersion(self) -> int:
155
159
  return self._protocolVersion
156
160
 
161
+ def classicVisitorKey(self) -> bytes | None:
162
+ return self._classicVisitorKey
163
+
164
+ def classicManagerKey(self) -> bytes | None:
165
+ return self._classicManagerKey
166
+
167
+ def classicBestKey(self) -> bytes | None:
168
+ # Prefer manager key if present, otherwise visitor key.
169
+ return self._classicManagerKey or self._classicVisitorKey
170
+
171
+ def hasClassicKeys(self) -> bool:
172
+ return bool(self._classicVisitorKey or self._classicManagerKey)
173
+
157
174
  @property
158
175
  def rawNetworkData(self) -> dict | None:
159
176
  return self._rawNetworkData
@@ -263,8 +280,33 @@ class Network:
263
280
  keys = network["network"]["keyStore"]["keys"]
264
281
  for k in keys:
265
282
  await self._keystore.addKey(k)
266
-
267
- # TODO: Parse managerKey and visitorKey for classic networks.
283
+ # Evolution network: classic keys not used
284
+ self._classicVisitorKey = None
285
+ self._classicManagerKey = None
286
+ else:
287
+ # Classic network: parse visitorKey / managerKey (hex strings).
288
+ # Ground truth: casambi-android `D1.Z0` exports these fields.
289
+ visitor_hex = network["network"].get("visitorKey")
290
+ manager_hex = network["network"].get("managerKey")
291
+
292
+ def _parse_hex_key(v: object) -> bytes | None:
293
+ if not isinstance(v, str):
294
+ return None
295
+ v = v.strip()
296
+ if not v:
297
+ return None
298
+ try:
299
+ return bytes.fromhex(v)
300
+ except ValueError:
301
+ return None
302
+
303
+ self._classicVisitorKey = _parse_hex_key(visitor_hex)
304
+ 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
+ )
268
310
 
269
311
  # Parse units
270
312
  self.units = []
CasambiBt/errors.py CHANGED
@@ -69,3 +69,15 @@ class UnsupportedProtocolVersion(CasambiBtError):
69
69
  """Exception that is raised when the network has an unsupported version."""
70
70
 
71
71
  pass
72
+
73
+
74
+ class ClassicKeysMissingError(ProtocolError):
75
+ """Classic network is missing visitorKey/managerKey required for signing packets."""
76
+
77
+ pass
78
+
79
+
80
+ class ClassicHandshakeError(ProtocolError):
81
+ """Classic network handshake/initialization failed (e.g. connection hash unavailable)."""
82
+
83
+ pass
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: casambi-bt-revamped
3
- Version: 0.3.12.dev2
3
+ Version: 0.3.12.dev3
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
@@ -28,6 +28,7 @@ This is a customized fork of the original [casambi-bt](https://github.com/lkempf
28
28
 
29
29
  - **Switch event support** - Receive button press/release/hold events from Casambi switches (wired + wireless)
30
30
  - **Improved relay status handling** - Better support for relay units
31
+ - **Classic protocol (experimental)** - Basic unit control for Classic (legacy) firmware networks
31
32
  - **Bug fixes and improvements** - Various fixes based on real-world usage
32
33
 
33
34
  This library provides a bluetooth interface to Casambi-based lights. It is not associated with Casambi.
@@ -95,6 +96,20 @@ Notes:
95
96
 
96
97
  For the parsing details and field layout, see `doc/PROTOCOL_PARSING.md`.
97
98
 
99
+ ### Classic (Legacy Firmware) Support (Experimental)
100
+
101
+ This library can also connect to **Classic** Casambi networks and send **unit control** commands.
102
+
103
+ How it works (ground truth: the bundled Android app sources):
104
+ - Classic devices expose a CMAC-signed data channel (`ca51`/`ca52`) or a "Classic conformant" signed channel on the EVO UUID.
105
+ - The cloud network JSON exposes `visitorKey` / `managerKey` (hex strings) instead of an EVO `keyStore`.
106
+ - Commands are signed with AES-CMAC and sent as Classic "command records" (see `doc/PROTOCOL_PARSING.md`).
107
+
108
+ Environment flags:
109
+ - `CASAMBI_BT_DISABLE_CLASSIC=1` to refuse Classic connections (fail fast)
110
+ - `CASAMBI_BT_CLASSIC_USE_MANAGER=1` to sign with the 16-byte manager signature (default is visitor/4-byte prefix)
111
+ - `CASAMBI_BT_LOG_RAW_NOTIFIES=1` to enable very verbose per-notify hexdumps (mainly for Classic debugging)
112
+
98
113
  ### MacOS
99
114
 
100
115
  MacOS [does not expose the Bluetooth MAC address via their official API](https://github.com/hbldh/bleak/issues/140),
@@ -0,0 +1,21 @@
1
+ CasambiBt/__init__.py,sha256=TW445xSu5PV3TyMjJfwaA1JoWvQQ8LXhZgGdDTfWf3s,302
2
+ CasambiBt/_cache.py,sha256=3bQil8vhSy4f4sf9JusMfEdQC7d3cJuva9qHhyKro-0,3808
3
+ CasambiBt/_casambi.py,sha256=TN4ecgjm95nSJ4h9TsKayNn577Y82fdsGK4IGUZF23Q,40666
4
+ CasambiBt/_classic_crypto.py,sha256=6DcCOdjLQo7k2cOOutNdUKupykOG_E2TDDwg6fH-ODM,998
5
+ CasambiBt/_client.py,sha256=nOOvs9qyQdc8D37DRejOY-15iTcSolK2EAlOOvpg_Xo,49990
6
+ CasambiBt/_constants.py,sha256=sbElg5W8eeQvvL1rHn_E0jhP1wOrrabc7dFLLnlDMsU,810
7
+ CasambiBt/_discover.py,sha256=jLc6H69JddrCURgtANZEjws6_UbSzXJtvJkbKTaIUHY,1849
8
+ CasambiBt/_encryption.py,sha256=CLcoOOrggQqhJbnr_emBnEnkizpWDvb_0yFnitq4_FM,3831
9
+ CasambiBt/_invocation.py,sha256=fkG4R0Gv5_amFfD_P6DKuIEe3oKWZW0v8RSU8zDjPdI,2985
10
+ CasambiBt/_keystore.py,sha256=Jdiq0zMPDmhfpheSojKY6sTUpmVrvX_qOyO7yCYd3kw,2788
11
+ CasambiBt/_network.py,sha256=DdUSWWFgifc-PhjGbBxSzBntu8CJrsbp6aMYuD1D-Gg,16465
12
+ CasambiBt/_operation.py,sha256=Q5UccsrtNp_B_wWqwH_3eLFW_yF6A55FMmfUKDk2WrI,1059
13
+ CasambiBt/_switch_events.py,sha256=S8OD0dBcw5T4J2C7qfmOQMnTJ7omIXRUYv4PqDOB87E,13137
14
+ CasambiBt/_unit.py,sha256=KIpvUT_Wm-O2Lmb1JVnNO625-j5j7GqufmZzfTR-jW0,18587
15
+ CasambiBt/errors.py,sha256=1L_Q8og_N_BRYEKizghAQXr6tihlHykFgtcCHUDcBas,1961
16
+ CasambiBt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
+ casambi_bt_revamped-0.3.12.dev3.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
18
+ casambi_bt_revamped-0.3.12.dev3.dist-info/METADATA,sha256=6oEPqnaAaaI5RZpDRNoquX7c9uHW742-J81FmPe1zNI,5877
19
+ casambi_bt_revamped-0.3.12.dev3.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
20
+ casambi_bt_revamped-0.3.12.dev3.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
21
+ casambi_bt_revamped-0.3.12.dev3.dist-info/RECORD,,
@@ -1,20 +0,0 @@
1
- CasambiBt/__init__.py,sha256=TW445xSu5PV3TyMjJfwaA1JoWvQQ8LXhZgGdDTfWf3s,302
2
- CasambiBt/_cache.py,sha256=3bQil8vhSy4f4sf9JusMfEdQC7d3cJuva9qHhyKro-0,3808
3
- CasambiBt/_casambi.py,sha256=i-60A7zDblZwOVb4UfXz9EEwsbWEFPnrMvVkKMl6amY,34752
4
- CasambiBt/_client.py,sha256=z3AnTQrZMVLkQTXdyLOZyNkNonU3arMFtEHMxUF59Ig,31581
5
- CasambiBt/_constants.py,sha256=_AxkG7Btxl4VeS6mO7GJW5Kc9dFs3s9sDmtJ83ZEKNw,359
6
- CasambiBt/_discover.py,sha256=H7HpiFYIy9ELvmPXXd_ck-5O5invJf15dDIRk-vO5IE,1696
7
- CasambiBt/_encryption.py,sha256=CLcoOOrggQqhJbnr_emBnEnkizpWDvb_0yFnitq4_FM,3831
8
- CasambiBt/_invocation.py,sha256=fkG4R0Gv5_amFfD_P6DKuIEe3oKWZW0v8RSU8zDjPdI,2985
9
- CasambiBt/_keystore.py,sha256=Jdiq0zMPDmhfpheSojKY6sTUpmVrvX_qOyO7yCYd3kw,2788
10
- CasambiBt/_network.py,sha256=Gh0n3FEcOUHUMuBXALwcb3tws-AofpYLegKIquqtZl4,14665
11
- CasambiBt/_operation.py,sha256=Q5UccsrtNp_B_wWqwH_3eLFW_yF6A55FMmfUKDk2WrI,1059
12
- CasambiBt/_switch_events.py,sha256=S8OD0dBcw5T4J2C7qfmOQMnTJ7omIXRUYv4PqDOB87E,13137
13
- CasambiBt/_unit.py,sha256=KIpvUT_Wm-O2Lmb1JVnNO625-j5j7GqufmZzfTR-jW0,18587
14
- CasambiBt/errors.py,sha256=0JgDjaKlAKDes0poWzA8nrTUYQ8qdNfBb8dfaqqzCRA,1664
15
- CasambiBt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
- casambi_bt_revamped-0.3.12.dev2.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
17
- casambi_bt_revamped-0.3.12.dev2.dist-info/METADATA,sha256=nMUsaWF7HLmwMjYQFq_uJM3N_CGyP7SPmUKaGjKWdQk,4907
18
- casambi_bt_revamped-0.3.12.dev2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
19
- casambi_bt_revamped-0.3.12.dev2.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
20
- casambi_bt_revamped-0.3.12.dev2.dist-info/RECORD,,