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 CHANGED
@@ -10,7 +10,7 @@ from bleak.backends.device import BLEDevice
10
10
  from httpx import AsyncClient, RequestError
11
11
 
12
12
  from ._cache import Cache
13
- from ._client import CasambiClient, ConnectionState, IncommingPacketType
13
+ from ._client import CasambiClient, ConnectionState, IncommingPacketType, ProtocolMode
14
14
  from ._network import Network
15
15
  from ._operation import OpCode, OperationsContext
16
16
  from ._unit import Group, Scene, Unit, UnitControlType, UnitState
@@ -169,8 +169,10 @@ class Casambi:
169
169
  self._casaClient = cast(CasambiClient, self._casaClient)
170
170
  await self._casaClient.connect()
171
171
  try:
172
- await self._casaClient.exchangeKey()
173
- await self._casaClient.authenticate()
172
+ # EVO requires key exchange + authenticate; Classic is ready after `connect()`.
173
+ if self._casaClient.protocolMode == ProtocolMode.EVO:
174
+ await self._casaClient.exchangeKey()
175
+ await self._casaClient.authenticate()
174
176
  except ProtocolError as e:
175
177
  await self._casaClient.disconnect()
176
178
  raise e
@@ -201,6 +203,23 @@ class Casambi:
201
203
  raise ValueError()
202
204
 
203
205
  payload = level.to_bytes(1, byteorder="big", signed=False)
206
+
207
+ # Classic protocol uses signed command frames (u1.C1753e / u1.EnumC1754f).
208
+ if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
209
+ # EnumC1754f ordinals (ground truth: casambi-android u1.EnumC1754f):
210
+ # - AllUnitsLevel=4, UnitLevel=7, GroupLevel=26
211
+ if isinstance(target, Unit):
212
+ cmd = self._casaClient.buildClassicCommand(7, payload, target_id=target.deviceId)
213
+ elif isinstance(target, Group):
214
+ cmd = self._casaClient.buildClassicCommand(26, payload, target_id=target.groudId)
215
+ elif target is None:
216
+ cmd = self._casaClient.buildClassicCommand(4, payload)
217
+ else:
218
+ raise TypeError(f"Unkown target type {type(target)}")
219
+
220
+ await self._casaClient.send(cmd)
221
+ return
222
+
204
223
  await self._send(target, payload, OpCode.SetLevel)
205
224
 
206
225
  async def setVertical(self, target: Unit | Group | None, vertical: int) -> None:
@@ -219,6 +238,21 @@ class Casambi:
219
238
  raise ValueError()
220
239
 
221
240
  payload = vertical.to_bytes(1, byteorder="big", signed=False)
241
+
242
+ if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
243
+ # EnumC1754f ordinals: AllUnitsVertical=22, UnitVertical=24, GroupVertical=29
244
+ if isinstance(target, Unit):
245
+ cmd = self._casaClient.buildClassicCommand(24, payload, target_id=target.deviceId)
246
+ elif isinstance(target, Group):
247
+ cmd = self._casaClient.buildClassicCommand(29, payload, target_id=target.groudId)
248
+ elif target is None:
249
+ cmd = self._casaClient.buildClassicCommand(22, payload)
250
+ else:
251
+ raise TypeError(f"Unkown target type {type(target)}")
252
+
253
+ await self._casaClient.send(cmd)
254
+ return
255
+
222
256
  await self._send(target, payload, OpCode.SetVertical)
223
257
 
224
258
  async def setSlider(self, target: Unit | Group | None, value: int) -> None:
@@ -255,6 +289,21 @@ class Casambi:
255
289
  raise ValueError()
256
290
 
257
291
  payload = level.to_bytes(1, byteorder="big", signed=False)
292
+
293
+ if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
294
+ # EnumC1754f ordinals: AllUnitsWhite=23, UnitWhite=25, GroupWhite=30
295
+ if isinstance(target, Unit):
296
+ cmd = self._casaClient.buildClassicCommand(25, payload, target_id=target.deviceId)
297
+ elif isinstance(target, Group):
298
+ cmd = self._casaClient.buildClassicCommand(30, payload, target_id=target.groudId)
299
+ elif target is None:
300
+ cmd = self._casaClient.buildClassicCommand(23, payload)
301
+ else:
302
+ raise TypeError(f"Unkown target type {type(target)}")
303
+
304
+ await self._casaClient.send(cmd)
305
+ return
306
+
258
307
  await self._send(target, payload, OpCode.SetWhite)
259
308
 
260
309
  async def setColor(
@@ -272,6 +321,27 @@ class Casambi:
272
321
  :raises ValueError: The supplied rgbColor isn't in range
273
322
  """
274
323
 
324
+ if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
325
+ # Classic uses RGB payload (3 bytes) directly.
326
+ r, g, b = rgbColor
327
+ if not (0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255):
328
+ raise ValueError("rgbColor out of range.")
329
+ payload = bytes([r & 0xFF, g & 0xFF, b & 0xFF])
330
+
331
+ # EnumC1754f ordinals: AllUnitsColor=6, UnitColor=9, GroupColor=28
332
+ if isinstance(target, Unit):
333
+ cmd = self._casaClient.buildClassicCommand(9, payload, target_id=target.deviceId)
334
+ elif isinstance(target, Group):
335
+ cmd = self._casaClient.buildClassicCommand(28, payload, target_id=target.groudId)
336
+ elif target is None:
337
+ cmd = self._casaClient.buildClassicCommand(6, payload)
338
+ else:
339
+ raise TypeError(f"Unkown target type {type(target)}")
340
+
341
+ await self._casaClient.send(cmd)
342
+ return
343
+
344
+ # Evolution uses HS payload (hue 10-bit + sat 8-bit) for SetColor.
275
345
  state = UnitState()
276
346
  state.rgb = rgbColor
277
347
  hs: tuple[float, float] = state.hs # type: ignore[assignment]
@@ -300,6 +370,21 @@ class Casambi:
300
370
 
301
371
  temperature = int(temperature / 50)
302
372
  payload = temperature.to_bytes(1, byteorder="big", signed=False)
373
+
374
+ if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
375
+ # EnumC1754f ordinals: AllUnitsTemperature=5, UnitTemperature=8, GroupTemperature=27
376
+ if isinstance(target, Unit):
377
+ cmd = self._casaClient.buildClassicCommand(8, payload, target_id=target.deviceId)
378
+ elif isinstance(target, Group):
379
+ cmd = self._casaClient.buildClassicCommand(27, payload, target_id=target.groudId)
380
+ elif target is None:
381
+ cmd = self._casaClient.buildClassicCommand(5, payload)
382
+ else:
383
+ raise TypeError(f"Unkown target type {type(target)}")
384
+
385
+ await self._casaClient.send(cmd)
386
+ return
387
+
303
388
  await self._send(target, payload, OpCode.SetTemperature)
304
389
 
305
390
  async def setColorXY(
@@ -317,6 +402,10 @@ class Casambi:
317
402
  :raises ValueError: The supplied XYColor isn't in range or not supported by the supplied unit.
318
403
  """
319
404
 
405
+ if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
406
+ # Classic command set (u1.EnumC1754f) only exposes RGB color control.
407
+ raise ValueError("XY color control is not supported on Classic networks.")
408
+
320
409
  if xyColor[0] < 0.0 or xyColor[0] > 1.0 or xyColor[1] < 0.0 or xyColor[1] > 1.0:
321
410
  raise ValueError("Color out of range.")
322
411
 
@@ -345,6 +434,22 @@ class Casambi:
345
434
  :return: Nothing is returned by this function. To get the new state register a change handler.
346
435
  """
347
436
 
437
+ if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
438
+ # Classic uses a longer payload for "restore last level" (ground truth: casambi-android u1.C1751c.o()).
439
+ payload = bytes([0xFF, 0x01, 0x00, 0x00, 0x01])
440
+ # EnumC1754f ordinals: AllUnitsLevel=4, UnitLevel=7, GroupLevel=26
441
+ if isinstance(target, Unit):
442
+ cmd = self._casaClient.buildClassicCommand(7, payload, target_id=target.deviceId)
443
+ elif isinstance(target, Group):
444
+ cmd = self._casaClient.buildClassicCommand(26, payload, target_id=target.groudId)
445
+ elif target is None:
446
+ cmd = self._casaClient.buildClassicCommand(4, payload)
447
+ else:
448
+ raise TypeError(f"Unkown target type {type(target)}")
449
+
450
+ await self._casaClient.send(cmd)
451
+ return
452
+
348
453
  # Use -1 to indicate special packet format
349
454
  # Use RestoreLastLevel flag (1) and UseFullTimeFlag (4).
350
455
  # Not sure what UseFullTime does but this is what the app uses.
@@ -367,6 +472,11 @@ class Casambi:
367
472
  ConnectionState.AUTHENTICATED,
368
473
  ConnectionState.NONE,
369
474
  )
475
+ if self._casaClient.protocolMode == ProtocolMode.CLASSIC:
476
+ # Classic uses a completely different command encoding (u1.C1753e/u1.EnumC1754f).
477
+ # Public APIs that support Classic handle it explicitly; anything reaching here would
478
+ # send an EVO INVOCATION packet which is not valid on Classic.
479
+ raise ProtocolError(f"Operation {opcode.name} is not supported on Classic networks via INVOCATION.")
370
480
 
371
481
  targetCode = 0
372
482
  if isinstance(target, Unit):
@@ -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.info(f"Incomming data callback of type {packetType}")
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: The ID of the switch unit
478
- - button: The button number that was pressed/released
479
- - event: Either "button_press" or "button_release"
480
- - message_type: The raw message type (0x08 or 0x10)
481
- - flags: Additional flags from the message
482
- - extra_data: Any additional data from the message
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
+