casambi-bt-revamped 0.3.12.dev2__py3-none-any.whl → 0.3.12.dev4__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 +113 -3
- CasambiBt/_classic_crypto.py +31 -0
- CasambiBt/_client.py +477 -3
- CasambiBt/_constants.py +7 -0
- CasambiBt/_discover.py +3 -2
- CasambiBt/_network.py +44 -2
- CasambiBt/errors.py +12 -0
- {casambi_bt_revamped-0.3.12.dev2.dist-info → casambi_bt_revamped-0.3.12.dev4.dist-info}/METADATA +16 -1
- casambi_bt_revamped-0.3.12.dev4.dist-info/RECORD +21 -0
- casambi_bt_revamped-0.3.12.dev2.dist-info/RECORD +0 -20
- {casambi_bt_revamped-0.3.12.dev2.dist-info → casambi_bt_revamped-0.3.12.dev4.dist-info}/WHEEL +0 -0
- {casambi_bt_revamped-0.3.12.dev2.dist-info → casambi_bt_revamped-0.3.12.dev4.dist-info}/licenses/LICENSE +0 -0
- {casambi_bt_revamped-0.3.12.dev2.dist-info → casambi_bt_revamped-0.3.12.dev4.dist-info}/top_level.txt +0 -0
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
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
|
-
|
|
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
|
-
|
|
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
|
{casambi_bt_revamped-0.3.12.dev2.dist-info → casambi_bt_revamped-0.3.12.dev4.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: casambi-bt-revamped
|
|
3
|
-
Version: 0.3.12.
|
|
3
|
+
Version: 0.3.12.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),
|
|
@@ -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=yTSuAeJhBXp5Zs3jU-RvHFEpI-quRNwlB3HWGl7q_yY,50730
|
|
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.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,,
|
|
@@ -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,,
|
{casambi_bt_revamped-0.3.12.dev2.dist-info → casambi_bt_revamped-0.3.12.dev4.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|