casambi-bt-revamped 0.3.12.dev2__tar.gz → 0.3.12.dev4__tar.gz

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.
Files changed (30) hide show
  1. {casambi_bt_revamped-0.3.12.dev2/src/casambi_bt_revamped.egg-info → casambi_bt_revamped-0.3.12.dev4}/PKG-INFO +16 -1
  2. {casambi_bt_revamped-0.3.12.dev2 → casambi_bt_revamped-0.3.12.dev4}/README.md +15 -0
  3. {casambi_bt_revamped-0.3.12.dev2 → casambi_bt_revamped-0.3.12.dev4}/setup.cfg +1 -1
  4. {casambi_bt_revamped-0.3.12.dev2 → casambi_bt_revamped-0.3.12.dev4}/src/CasambiBt/_casambi.py +113 -3
  5. casambi_bt_revamped-0.3.12.dev4/src/CasambiBt/_classic_crypto.py +31 -0
  6. {casambi_bt_revamped-0.3.12.dev2 → casambi_bt_revamped-0.3.12.dev4}/src/CasambiBt/_client.py +477 -3
  7. casambi_bt_revamped-0.3.12.dev4/src/CasambiBt/_constants.py +23 -0
  8. {casambi_bt_revamped-0.3.12.dev2 → casambi_bt_revamped-0.3.12.dev4}/src/CasambiBt/_discover.py +3 -2
  9. {casambi_bt_revamped-0.3.12.dev2 → casambi_bt_revamped-0.3.12.dev4}/src/CasambiBt/_network.py +44 -2
  10. {casambi_bt_revamped-0.3.12.dev2 → casambi_bt_revamped-0.3.12.dev4}/src/CasambiBt/errors.py +12 -0
  11. {casambi_bt_revamped-0.3.12.dev2 → casambi_bt_revamped-0.3.12.dev4/src/casambi_bt_revamped.egg-info}/PKG-INFO +16 -1
  12. {casambi_bt_revamped-0.3.12.dev2 → casambi_bt_revamped-0.3.12.dev4}/src/casambi_bt_revamped.egg-info/SOURCES.txt +2 -0
  13. casambi_bt_revamped-0.3.12.dev4/tests/test_classic_protocol.py +84 -0
  14. casambi_bt_revamped-0.3.12.dev2/src/CasambiBt/_constants.py +0 -16
  15. {casambi_bt_revamped-0.3.12.dev2 → casambi_bt_revamped-0.3.12.dev4}/LICENSE +0 -0
  16. {casambi_bt_revamped-0.3.12.dev2 → casambi_bt_revamped-0.3.12.dev4}/pyproject.toml +0 -0
  17. {casambi_bt_revamped-0.3.12.dev2 → casambi_bt_revamped-0.3.12.dev4}/src/CasambiBt/__init__.py +0 -0
  18. {casambi_bt_revamped-0.3.12.dev2 → casambi_bt_revamped-0.3.12.dev4}/src/CasambiBt/_cache.py +0 -0
  19. {casambi_bt_revamped-0.3.12.dev2 → casambi_bt_revamped-0.3.12.dev4}/src/CasambiBt/_encryption.py +0 -0
  20. {casambi_bt_revamped-0.3.12.dev2 → casambi_bt_revamped-0.3.12.dev4}/src/CasambiBt/_invocation.py +0 -0
  21. {casambi_bt_revamped-0.3.12.dev2 → casambi_bt_revamped-0.3.12.dev4}/src/CasambiBt/_keystore.py +0 -0
  22. {casambi_bt_revamped-0.3.12.dev2 → casambi_bt_revamped-0.3.12.dev4}/src/CasambiBt/_operation.py +0 -0
  23. {casambi_bt_revamped-0.3.12.dev2 → casambi_bt_revamped-0.3.12.dev4}/src/CasambiBt/_switch_events.py +0 -0
  24. {casambi_bt_revamped-0.3.12.dev2 → casambi_bt_revamped-0.3.12.dev4}/src/CasambiBt/_unit.py +0 -0
  25. {casambi_bt_revamped-0.3.12.dev2 → casambi_bt_revamped-0.3.12.dev4}/src/CasambiBt/py.typed +0 -0
  26. {casambi_bt_revamped-0.3.12.dev2 → casambi_bt_revamped-0.3.12.dev4}/src/casambi_bt_revamped.egg-info/dependency_links.txt +0 -0
  27. {casambi_bt_revamped-0.3.12.dev2 → casambi_bt_revamped-0.3.12.dev4}/src/casambi_bt_revamped.egg-info/requires.txt +0 -0
  28. {casambi_bt_revamped-0.3.12.dev2 → casambi_bt_revamped-0.3.12.dev4}/src/casambi_bt_revamped.egg-info/top_level.txt +0 -0
  29. {casambi_bt_revamped-0.3.12.dev2 → casambi_bt_revamped-0.3.12.dev4}/tests/test_switch_event_logs.py +0 -0
  30. {casambi_bt_revamped-0.3.12.dev2 → casambi_bt_revamped-0.3.12.dev4}/tests/test_unit_state_logs.py +0 -0
@@ -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.dev4
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),
@@ -7,6 +7,7 @@ This is a customized fork of the original [casambi-bt](https://github.com/lkempf
7
7
 
8
8
  - **Switch event support** - Receive button press/release/hold events from Casambi switches (wired + wireless)
9
9
  - **Improved relay status handling** - Better support for relay units
10
+ - **Classic protocol (experimental)** - Basic unit control for Classic (legacy) firmware networks
10
11
  - **Bug fixes and improvements** - Various fixes based on real-world usage
11
12
 
12
13
  This library provides a bluetooth interface to Casambi-based lights. It is not associated with Casambi.
@@ -74,6 +75,20 @@ Notes:
74
75
 
75
76
  For the parsing details and field layout, see `doc/PROTOCOL_PARSING.md`.
76
77
 
78
+ ### Classic (Legacy Firmware) Support (Experimental)
79
+
80
+ This library can also connect to **Classic** Casambi networks and send **unit control** commands.
81
+
82
+ How it works (ground truth: the bundled Android app sources):
83
+ - Classic devices expose a CMAC-signed data channel (`ca51`/`ca52`) or a "Classic conformant" signed channel on the EVO UUID.
84
+ - The cloud network JSON exposes `visitorKey` / `managerKey` (hex strings) instead of an EVO `keyStore`.
85
+ - Commands are signed with AES-CMAC and sent as Classic "command records" (see `doc/PROTOCOL_PARSING.md`).
86
+
87
+ Environment flags:
88
+ - `CASAMBI_BT_DISABLE_CLASSIC=1` to refuse Classic connections (fail fast)
89
+ - `CASAMBI_BT_CLASSIC_USE_MANAGER=1` to sign with the 16-byte manager signature (default is visitor/4-byte prefix)
90
+ - `CASAMBI_BT_LOG_RAW_NOTIFIES=1` to enable very verbose per-notify hexdumps (mainly for Classic debugging)
91
+
77
92
  ### MacOS
78
93
 
79
94
  MacOS [does not expose the Bluetooth MAC address via their official API](https://github.com/hbldh/bleak/issues/140),
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = casambi-bt-revamped
3
- version = 0.3.12.dev2
3
+ version = 0.3.12.dev4
4
4
  author = rankjie
5
5
  author_email = rankjie@gmail.com
6
6
  description = Forked Casambi Bluetooth client library with switch event support, use original if no special need. https://github.com/lkempf/casambi-bt
@@ -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
+
@@ -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,133 @@ class CasambiClient:
177
207
  self._logger.info(f"Connected to {self.address}")
178
208
  self._connectionState = ConnectionState.CONNECTED
179
209
 
210
+ # Detect protocol mode.
211
+ #
212
+ # Important: Home Assistant wraps BleakClient (HaBleakClientWrapper) which does not implement
213
+ # `get_services()`. Therefore we use "try-read" probing instead of enumerating GATT services.
214
+ #
215
+ # Order:
216
+ # 1) Classic "non-conformant": CA51 (hash) + CA52 (data channel)
217
+ # 2) EVO: auth char read starts with 0x01 (NodeInfo)
218
+ # 3) Classic "conformant": auth char read returns connection hash (first 8 bytes used)
219
+
220
+ classic_hash: bytes | None = None
221
+ try:
222
+ classic_hash = await self._gattClient.read_gatt_char(CASA_CLASSIC_HASH_CHAR_UUID)
223
+ except Exception:
224
+ classic_hash = None
225
+
226
+ 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
+ self._protocolMode = ProtocolMode.CLASSIC
236
+ self._dataCharUuid = CASA_CLASSIC_DATA_CHAR_UUID
237
+
238
+ # Read connection hash (first 8 bytes are used for CMAC signing).
239
+ raw_hash = classic_hash
240
+ if raw_hash is None or len(raw_hash) < 8:
241
+ raise ClassicHandshakeError(
242
+ f"Classic connection hash read failed/too short (len={0 if raw_hash is None else len(raw_hash)})."
243
+ )
244
+ self._classicConnHash8 = bytes(raw_hash[:8])
245
+ # Android seeds the command divider with a random byte on startup (u1.C1751c).
246
+ self._classicCmdDiv = int.from_bytes(os.urandom(1), "big") or 1
247
+ self._classicTxSeq = 0
248
+
249
+ # Start notify on the data channel.
250
+ notify_kwargs: dict[str, Any] = {}
251
+ notify_params = inspect.signature(self._gattClient.start_notify).parameters
252
+ if "bluez" in notify_params:
253
+ notify_kwargs["bluez"] = {"use_start_notify": True}
254
+ try:
255
+ await self._gattClient.start_notify(
256
+ CASA_CLASSIC_DATA_CHAR_UUID,
257
+ self._queueCallback,
258
+ **notify_kwargs,
259
+ )
260
+ except Exception as e:
261
+ # Some firmwares may expose Classic signing on the EVO UUID instead.
262
+ # 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)
264
+ self._protocolMode = None
265
+ self._dataCharUuid = None
266
+ self._classicConnHash8 = None
267
+ # continue detection below
268
+ else:
269
+ # Classic has no EVO-style key exchange/auth; we can send immediately.
270
+ self._connectionState = ConnectionState.AUTHENTICATED
271
+ self._logger.info("Protocol mode selected: CLASSIC")
272
+ if self._logger.isEnabledFor(logging.DEBUG):
273
+ self._logger.debug(
274
+ "[CASAMBI_CLASSIC_CONN_HASH] len=%d hash=%s",
275
+ len(self._classicConnHash8),
276
+ b2a(self._classicConnHash8),
277
+ )
278
+ return
279
+
280
+ # Conformant devices can expose the Classic signed channel on the EVO-style UUID too.
281
+ first: bytes | None = None
282
+ try:
283
+ first = await self._gattClient.read_gatt_char(CASA_AUTH_CHAR_UUID)
284
+ except Exception:
285
+ first = None
286
+
287
+ if first and len(first) >= 2 and first[0] == 0x01:
288
+ # EVO NodeInfo packet starts with 0x01.
289
+ self._protocolMode = ProtocolMode.EVO
290
+ self._dataCharUuid = CASA_AUTH_CHAR_UUID
291
+ self._checkProtocolVersion(self._network.protocolVersion)
292
+ self._logger.info("Protocol mode selected: EVO")
293
+ return
294
+
295
+ if first is not None:
296
+ # 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
+ if len(first) < 8:
304
+ raise ClassicHandshakeError(
305
+ f"Classic connection hash read failed/too short (len={len(first)})."
306
+ )
307
+
308
+ self._protocolMode = ProtocolMode.CLASSIC
309
+ self._dataCharUuid = CASA_AUTH_CHAR_UUID
310
+ self._classicConnHash8 = bytes(first[:8])
311
+ self._classicCmdDiv = int.from_bytes(os.urandom(1), "big") or 1
312
+ self._classicTxSeq = 0
313
+
314
+ notify_kwargs: dict[str, Any] = {}
315
+ notify_params = inspect.signature(self._gattClient.start_notify).parameters
316
+ if "bluez" in notify_params:
317
+ notify_kwargs["bluez"] = {"use_start_notify": True}
318
+ await self._gattClient.start_notify(
319
+ CASA_AUTH_CHAR_UUID,
320
+ self._queueCallback,
321
+ **notify_kwargs,
322
+ )
323
+ self._connectionState = ConnectionState.AUTHENTICATED
324
+ self._logger.info("Protocol mode selected: CLASSIC")
325
+ if self._logger.isEnabledFor(logging.DEBUG):
326
+ self._logger.debug(
327
+ "[CASAMBI_CLASSIC_CONN_HASH] len=%d hash=%s",
328
+ len(self._classicConnHash8),
329
+ b2a(self._classicConnHash8),
330
+ )
331
+ return
332
+
333
+ raise ProtocolError(
334
+ "No supported Casambi characteristics found (Classic ca51/ca52 or EVO/Classic-conformant auth char)."
335
+ )
336
+
180
337
  def _on_disconnect(self, client: BleakClient) -> None:
181
338
  if self._connectionState != ConnectionState.NONE:
182
339
  self._logger.info(f"Received disconnect callback from {self.address}")
@@ -292,7 +449,13 @@ class CasambiClient:
292
449
  def _callbackMulitplexer(
293
450
  self, handle: BleakGATTCharacteristic, data: bytes
294
451
  ) -> None:
295
- self._logger.debug(f"Callback on handle {handle}: {b2a(data)}")
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
+ )
296
459
 
297
460
  if self._connectionState == ConnectionState.CONNECTED:
298
461
  self._exchNofityCallback(handle, data)
@@ -432,6 +595,12 @@ class CasambiClient:
432
595
  return self._nonce[:4] + id + self._nonce[8:]
433
596
 
434
597
  async def send(self, packet: bytes) -> None:
598
+ # EVO sends INVOCATION operations (packet type=0x07) inside the encrypted channel.
599
+ # Classic sends signed command frames on the CA52 channel.
600
+ if self._protocolMode == ProtocolMode.CLASSIC:
601
+ await self._sendClassicSigned(packet)
602
+ return
603
+
435
604
  self._checkState(ConnectionState.AUTHENTICATED)
436
605
 
437
606
  await self._activityLock.acquire()
@@ -452,9 +621,167 @@ class CasambiClient:
452
621
  finally:
453
622
  self._activityLock.release()
454
623
 
624
+ def _classic_next_seq(self) -> int:
625
+ # 16-bit sequence inserted in the header (big endian) and included in CMAC input.
626
+ self._classicTxSeq = (self._classicTxSeq + 1) & 0xFFFF
627
+ if self._classicTxSeq == 0:
628
+ self._classicTxSeq = 1
629
+ return self._classicTxSeq
630
+
631
+ def _classic_next_div(self) -> int:
632
+ # 8-bit command divider/id. Android uses a random start and increments 1..255.
633
+ self._classicCmdDiv += 1
634
+ if self._classicCmdDiv == 0 or self._classicCmdDiv > 255:
635
+ self._classicCmdDiv = 1
636
+ return self._classicCmdDiv
637
+
638
+ def buildClassicCommand(
639
+ self,
640
+ command_ordinal: int,
641
+ payload: bytes,
642
+ *,
643
+ target_id: int | None = None,
644
+ lifetime: int = 200,
645
+ div: int | None = None,
646
+ ) -> bytes:
647
+ """Build one Classic command record (u1.C1753e export format).
648
+
649
+ This is the message that follows the Classic signed header and 16-bit sequence.
650
+ """
651
+ if div is None:
652
+ div = self._classic_next_div()
653
+ if div < 0 or div > 255:
654
+ raise ValueError("div must fit in one byte")
655
+ if lifetime < 0 or lifetime > 255:
656
+ raise ValueError("lifetime must fit in one byte")
657
+ if target_id is not None and (target_id < 0 or target_id > 255):
658
+ raise ValueError("target_id must fit in one byte")
659
+
660
+ # Two leading bytes are patched after we know the final length:
661
+ # - byte0 = (len + 239) mod 256
662
+ # - byte1 = ordinal | 0x40 (div present) | 0x80 (target present)
663
+ b = bytearray()
664
+ b.append(0)
665
+ b.append(0)
666
+
667
+ type_flags = command_ordinal & 0x3F
668
+
669
+ # div present
670
+ b.append(div & 0xFF)
671
+ type_flags |= 0x40
672
+
673
+ if target_id is not None and target_id > 0:
674
+ b.append(target_id & 0xFF)
675
+ type_flags |= 0x80
676
+
677
+ b.append(lifetime & 0xFF)
678
+ b.extend(payload)
679
+
680
+ msg_len = len(b)
681
+ b[0] = (msg_len + 239) & 0xFF
682
+ b[1] = type_flags & 0xFF
683
+
684
+ if self._logger.isEnabledFor(logging.DEBUG):
685
+ self._logger.debug(
686
+ "[CASAMBI_CLASSIC_CMD_BUILD] ord=%d target=%s div=%d lifetime=%d len=%d payload=%s",
687
+ command_ordinal,
688
+ target_id,
689
+ div,
690
+ lifetime,
691
+ msg_len,
692
+ b2a(payload),
693
+ )
694
+
695
+ return bytes(b)
696
+
697
+ async def _sendClassicSigned(self, command_bytes: bytes, *, use_manager: bool | None = None) -> None:
698
+ self._checkState(ConnectionState.AUTHENTICATED)
699
+ if self._protocolMode != ProtocolMode.CLASSIC:
700
+ raise ProtocolError("Classic send called while not in Classic protocol mode.")
701
+ if not self._dataCharUuid:
702
+ raise ProtocolError("Classic data characteristic UUID not set.")
703
+ if self._classicConnHash8 is None:
704
+ raise ClassicHandshakeError("Classic connection hash not available.")
705
+
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
+ visitor_key = self._network.classicVisitorKey()
717
+ manager_key = self._network.classicManagerKey()
718
+
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
745
+ 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
+
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
758
+
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",
762
+ key_name,
763
+ auth_level,
764
+ sig_len,
765
+ seq,
766
+ len(command_bytes),
767
+ 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""),
772
+ )
773
+
774
+ # Classic packets can exceed 20 bytes when using a 16-byte manager signature.
775
+ # Bleak needs a write-with-response for long writes on most backends.
776
+ await self._gattClient.write_gatt_char(self._dataCharUuid, bytes(pkt), response=True)
777
+
455
778
  def _establishedNofityCallback(
456
779
  self, handle: BleakGATTCharacteristic, data: bytes
457
780
  ) -> None:
781
+ if self._protocolMode == ProtocolMode.CLASSIC:
782
+ self._classicEstablishedNotifyCallback(handle, data)
783
+ return
784
+
458
785
  # TODO: Check incoming counter and direction flag
459
786
  self._inPacketCount += 1
460
787
 
@@ -515,6 +842,153 @@ class CasambiClient:
515
842
  else:
516
843
  self._logger.debug("Packet type %d not implemented. Ignoring!", packetType)
517
844
 
845
+ def _classicEstablishedNotifyCallback(
846
+ self, handle: BleakGATTCharacteristic, data: bytes
847
+ ) -> None:
848
+ """Parse Classic notifications from the CA52 channel.
849
+
850
+ Classic packets are CMAC-signed (prefix embedded into the header).
851
+ Ground truth: casambi-android `t1.P.o(...)`.
852
+ """
853
+ self._inPacketCount += 1
854
+
855
+ raw = bytes(data)
856
+ if self._logger.isEnabledFor(logging.DEBUG):
857
+ self._logger.debug(
858
+ "[CASAMBI_CLASSIC_RX_RAW] len=%d hex=%s",
859
+ len(raw),
860
+ b2a(raw[: min(len(raw), 64)]) + (b"..." if len(raw) > 64 else b""),
861
+ )
862
+
863
+ if self._classicConnHash8 is None:
864
+ self._logger.debug("[CASAMBI_CLASSIC_RX] Missing connection hash; cannot verify CMAC.")
865
+ return
866
+
867
+ visitor_key = self._network.classicVisitorKey()
868
+ manager_key = self._network.classicManagerKey()
869
+
870
+ verified = False
871
+ key_name: str | None = None
872
+ sig_len: int | None = None
873
+ payload_with_seq: bytes | None = None
874
+
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
+ ]
881
+
882
+ for name, key, slen in candidates:
883
+ 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
892
+
893
+ try:
894
+ expected = classic_cmac_prefix(key, self._classicConnHash8, cmac_input, slen)
895
+ except Exception:
896
+ continue
897
+
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
913
+
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
919
+ 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:]
923
+
924
+ if not payload:
925
+ return
926
+
927
+ # If the payload starts with a known EVO packet type, reuse existing parsers.
928
+ packet_type = payload[0]
929
+ if packet_type in (IncommingPacketType.UnitState, IncommingPacketType.SwitchEvent, IncommingPacketType.NetworkConfig):
930
+ if self._logger.isEnabledFor(logging.DEBUG):
931
+ self._logger.debug(
932
+ "[CASAMBI_CLASSIC_RX_PAYLOAD] type=%d len=%d hex=%s",
933
+ packet_type,
934
+ len(payload),
935
+ b2a(payload[: min(len(payload), 64)])
936
+ + (b"..." if len(payload) > 64 else b""),
937
+ )
938
+ if packet_type == IncommingPacketType.UnitState:
939
+ self._parseUnitStates(payload[1:])
940
+ elif packet_type == IncommingPacketType.SwitchEvent:
941
+ self._parseSwitchEvent(payload[1:], None, raw)
942
+ else:
943
+ # ignore network config
944
+ pass
945
+ return
946
+
947
+ # Otherwise, attempt to parse a stream of Classic "command" records:
948
+ # record[0] = (len + 239) mod 256, so len = (b0 - 239) & 0xFF.
949
+ pos = 0
950
+ while pos + 2 <= len(payload):
951
+ enc_len = payload[pos]
952
+ rec_len = (enc_len - 239) & 0xFF
953
+ if rec_len < 2 or pos + rec_len > len(payload):
954
+ break
955
+ rec = payload[pos : pos + rec_len]
956
+ pos += rec_len
957
+
958
+ typ = rec[1]
959
+ ordinal = typ & 0x3F
960
+ has_div = (typ & 0x40) != 0
961
+ has_target = (typ & 0x80) != 0
962
+ p = 2
963
+ div = rec[p] if has_div and p < len(rec) else None
964
+ if has_div:
965
+ p += 1
966
+ target = rec[p] if has_target and p < len(rec) else None
967
+ if has_target:
968
+ p += 1
969
+ lifetime = rec[p] if p < len(rec) else None
970
+ if lifetime is not None:
971
+ p += 1
972
+ rec_payload = rec[p:] if p <= len(rec) else b""
973
+
974
+ if self._logger.isEnabledFor(logging.DEBUG):
975
+ self._logger.debug(
976
+ "[CASAMBI_CLASSIC_CMD] ord=%d div=%s target=%s lifetime=%s payload=%s",
977
+ ordinal,
978
+ div,
979
+ target,
980
+ lifetime,
981
+ b2a(rec_payload),
982
+ )
983
+
984
+ # Any trailing bytes that don't form a full record are logged for analysis.
985
+ if self._logger.isEnabledFor(logging.DEBUG) and pos < len(payload):
986
+ self._logger.debug(
987
+ "[CASAMBI_CLASSIC_CMD_TRAILING] len=%d hex=%s",
988
+ len(payload) - pos,
989
+ b2a(payload[pos:]),
990
+ )
991
+
518
992
  def _parseUnitStates(self, data: bytes) -> None:
519
993
  # Ground truth: casambi-android `v1.C1775b.V(Q2.h)` parses decrypted packet type=6
520
994
  # as a stream of unit state records. Records have optional bytes depending on flags.
@@ -0,0 +1,23 @@
1
+ from enum import IntEnum, unique
2
+ from typing import Final
3
+
4
+ DEVICE_NAME: Final = "Casambi BT Python"
5
+
6
+ CASA_UUID: Final = "0000fe4d-0000-1000-8000-00805f9b34fb"
7
+ CASA_AUTH_CHAR_UUID: Final = "c9ffde48-ca5a-0001-ab83-8f519b482f77"
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
+
16
+
17
+ @unique
18
+ class ConnectionState(IntEnum):
19
+ NONE = 0
20
+ CONNECTED = 1
21
+ KEY_EXCHANGED = 2
22
+ AUTHENTICATED = 3
23
+ ERROR = 99
@@ -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
 
@@ -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 = []
@@ -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.dev4
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),
@@ -5,6 +5,7 @@ setup.cfg
5
5
  src/CasambiBt/__init__.py
6
6
  src/CasambiBt/_cache.py
7
7
  src/CasambiBt/_casambi.py
8
+ src/CasambiBt/_classic_crypto.py
8
9
  src/CasambiBt/_client.py
9
10
  src/CasambiBt/_constants.py
10
11
  src/CasambiBt/_discover.py
@@ -22,5 +23,6 @@ src/casambi_bt_revamped.egg-info/SOURCES.txt
22
23
  src/casambi_bt_revamped.egg-info/dependency_links.txt
23
24
  src/casambi_bt_revamped.egg-info/requires.txt
24
25
  src/casambi_bt_revamped.egg-info/top_level.txt
26
+ tests/test_classic_protocol.py
25
27
  tests/test_switch_event_logs.py
26
28
  tests/test_unit_state_logs.py
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import unittest
5
+ from pathlib import Path
6
+
7
+ # Allow tests to run without installing the package.
8
+ ROOT = Path(__file__).resolve().parents[1]
9
+ sys.path.insert(0, str(ROOT / "src"))
10
+
11
+ from CasambiBt._classic_crypto import classic_cmac, classic_cmac_prefix # noqa: E402
12
+ from CasambiBt._client import CasambiClient, IncommingPacketType # noqa: E402
13
+
14
+
15
+ class _DummyNetwork:
16
+ protocolVersion = 10
17
+
18
+ def classicVisitorKey(self) -> bytes | None: # noqa: D401
19
+ return None
20
+
21
+ def classicManagerKey(self) -> bytes | None: # noqa: D401
22
+ return None
23
+
24
+ def hasClassicKeys(self) -> bool: # noqa: D401
25
+ return False
26
+
27
+
28
+ class TestClassicProtocolHelpers(unittest.TestCase):
29
+ def test_classic_cmac_matches_rfc4493_vectors(self) -> None:
30
+ # RFC 4493 test vectors (AES-CMAC).
31
+ key = bytes.fromhex("2b7e151628aed2a6abf7158809cf4f3c")
32
+
33
+ # Example 2 (16 bytes)
34
+ msg16 = bytes.fromhex("6bc1bee22e409f96e93d7e117393172a")
35
+ exp16 = bytes.fromhex("070a16b46b4d4144f79bdd9dd04a287c")
36
+
37
+ # Example 3 (32 bytes)
38
+ msg32 = bytes.fromhex(
39
+ "6bc1bee22e409f96e93d7e117393172a"
40
+ "ae2d8a571e03ac9c9eb76fac45af8e51"
41
+ )
42
+ exp32 = bytes.fromhex("ce0cbf1738f4df6428b1d93bf12081c9")
43
+
44
+ # Example 4 (48 bytes)
45
+ msg48 = bytes.fromhex(
46
+ "6bc1bee22e409f96e93d7e117393172a"
47
+ "ae2d8a571e03ac9c9eb76fac45af8e51"
48
+ "30c81c46a35ce411e5fbc1191a0a52ef"
49
+ )
50
+ exp48 = bytes.fromhex("c47c4d9d64588f67fb9de6fe745d7fbf")
51
+
52
+ for msg, expected in ((msg16, exp16), (msg32, exp32), (msg48, exp48)):
53
+ conn_hash8 = msg[:8]
54
+ payload = msg[8:]
55
+ mac = classic_cmac(key, conn_hash8, payload)
56
+ self.assertEqual(mac, expected)
57
+ self.assertEqual(classic_cmac_prefix(key, conn_hash8, payload, 4), expected[:4])
58
+ self.assertEqual(classic_cmac_prefix(key, conn_hash8, payload, 16), expected)
59
+
60
+ def test_classic_command_encoding_matches_android_layout(self) -> None:
61
+ # Ground truth: casambi-android `u1.C1753e.a(P)`:
62
+ # [len+239][ordinal|flags][div][target?][lifetime=200][payload...]
63
+ parsed: list[dict] = []
64
+
65
+ def cb(_: IncommingPacketType, data: dict) -> None:
66
+ parsed.append(data)
67
+
68
+ c = CasambiClient("00:00:00:00:00:00", cb, lambda: None, _DummyNetwork())
69
+
70
+ # Unit level command: ordinal=7, div present, target present, lifetime=200, payload=0x54
71
+ cmd = c.buildClassicCommand(7, bytes([0x54]), target_id=3, div=0x12, lifetime=200)
72
+ self.assertEqual(cmd.hex(), "f5c71203c854")
73
+
74
+ # All units level: ordinal=4, div present, no target, lifetime=200, payload=0xff
75
+ cmd2 = c.buildClassicCommand(4, bytes([0xFF]), target_id=None, div=0x01, lifetime=200)
76
+ self.assertEqual(cmd2.hex(), "f44401c8ff")
77
+
78
+ # target_id=0 is treated as "no target" (Android only writes target when > 0).
79
+ cmd3 = c.buildClassicCommand(4, bytes([0xFF]), target_id=0, div=0x01, lifetime=200)
80
+ self.assertEqual(cmd3.hex(), "f44401c8ff")
81
+
82
+
83
+ if __name__ == "__main__":
84
+ unittest.main()
@@ -1,16 +0,0 @@
1
- from enum import IntEnum, unique
2
- from typing import Final
3
-
4
- DEVICE_NAME: Final = "Casambi BT Python"
5
-
6
- CASA_UUID: Final = "0000fe4d-0000-1000-8000-00805f9b34fb"
7
- CASA_AUTH_CHAR_UUID: Final = "c9ffde48-ca5a-0001-ab83-8f519b482f77"
8
-
9
-
10
- @unique
11
- class ConnectionState(IntEnum):
12
- NONE = 0
13
- CONNECTED = 1
14
- KEY_EXCHANGED = 2
15
- AUTHENTICATED = 3
16
- ERROR = 99