casambi-bt-revamped 0.3.11__py3-none-any.whl → 0.3.12.dev3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- CasambiBt/_casambi.py +125 -11
- CasambiBt/_classic_crypto.py +31 -0
- CasambiBt/_client.py +616 -155
- CasambiBt/_constants.py +7 -0
- CasambiBt/_discover.py +3 -2
- CasambiBt/_invocation.py +116 -0
- CasambiBt/_network.py +44 -2
- CasambiBt/_switch_events.py +329 -0
- CasambiBt/_unit.py +37 -1
- CasambiBt/errors.py +12 -0
- casambi_bt_revamped-0.3.12.dev3.dist-info/METADATA +135 -0
- casambi_bt_revamped-0.3.12.dev3.dist-info/RECORD +21 -0
- casambi_bt_revamped-0.3.11.dist-info/METADATA +0 -81
- casambi_bt_revamped-0.3.11.dist-info/RECORD +0 -18
- {casambi_bt_revamped-0.3.11.dist-info → casambi_bt_revamped-0.3.12.dev3.dist-info}/WHEEL +0 -0
- {casambi_bt_revamped-0.3.11.dist-info → casambi_bt_revamped-0.3.12.dev3.dist-info}/licenses/LICENSE +0 -0
- {casambi_bt_revamped-0.3.11.dist-info → casambi_bt_revamped-0.3.12.dev3.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):
|
|
@@ -400,7 +510,7 @@ class Casambi:
|
|
|
400
510
|
def _dataCallback(
|
|
401
511
|
self, packetType: IncommingPacketType, data: dict[str, Any]
|
|
402
512
|
) -> None:
|
|
403
|
-
self._logger.
|
|
513
|
+
self._logger.debug("Incomming data callback of type %s", packetType)
|
|
404
514
|
if packetType == IncommingPacketType.UnitState:
|
|
405
515
|
self._logger.debug(
|
|
406
516
|
f"Handling changed state {b2a(data['state'])} for unit {data['id']}"
|
|
@@ -473,13 +583,17 @@ class Casambi:
|
|
|
473
583
|
"""Register a new handler for switch events.
|
|
474
584
|
|
|
475
585
|
This handler is called whenever a switch event is received.
|
|
476
|
-
The handler is supplied with a dictionary containing:
|
|
477
|
-
- unit_id:
|
|
478
|
-
- button:
|
|
479
|
-
- event:
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
-
|
|
586
|
+
The handler is supplied with a dictionary containing (at minimum):
|
|
587
|
+
- unit_id: target unit id (from INVOCATION target high byte)
|
|
588
|
+
- button: best-effort "label" (typically 1..4 for 4-gang switches)
|
|
589
|
+
- event: "button_press" | "button_release" | "input_event"
|
|
590
|
+
|
|
591
|
+
Switch events are parsed from decrypted packet type=7 (INVOCATION stream),
|
|
592
|
+
matching casambi-android `v1.C1775b.Q(Q2.h)`. Extra diagnostic keys include:
|
|
593
|
+
- invocation_flags, opcode, origin, target, target_type, age, origin_handle
|
|
594
|
+
- button_event_index (0..7), param_p, param_s
|
|
595
|
+
- input_index (0..7), input_code, input_b1, input_channel, input_value16, input_mapped_event
|
|
596
|
+
- packet_sequence, arrival_sequence, raw_packet, decrypted_data, payload_hex, frame_offset, event_id
|
|
483
597
|
|
|
484
598
|
:param handler: The method to call when a switch event is received.
|
|
485
599
|
"""
|
|
@@ -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
|
+
|