casambi-bt-revamped 0.3.7.dev13__tar.gz → 0.3.12.dev3__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 (34) hide show
  1. casambi_bt_revamped-0.3.12.dev3/PKG-INFO +135 -0
  2. casambi_bt_revamped-0.3.12.dev3/README.md +114 -0
  3. {casambi_bt_revamped-0.3.7.dev13 → casambi_bt_revamped-0.3.12.dev3}/setup.cfg +4 -4
  4. {casambi_bt_revamped-0.3.7.dev13 → casambi_bt_revamped-0.3.12.dev3}/src/CasambiBt/_cache.py +9 -9
  5. {casambi_bt_revamped-0.3.7.dev13 → casambi_bt_revamped-0.3.12.dev3}/src/CasambiBt/_casambi.py +125 -11
  6. casambi_bt_revamped-0.3.12.dev3/src/CasambiBt/_classic_crypto.py +31 -0
  7. casambi_bt_revamped-0.3.12.dev3/src/CasambiBt/_client.py +1255 -0
  8. casambi_bt_revamped-0.3.12.dev3/src/CasambiBt/_constants.py +23 -0
  9. {casambi_bt_revamped-0.3.7.dev13 → casambi_bt_revamped-0.3.12.dev3}/src/CasambiBt/_discover.py +3 -2
  10. casambi_bt_revamped-0.3.12.dev3/src/CasambiBt/_invocation.py +116 -0
  11. {casambi_bt_revamped-0.3.7.dev13 → casambi_bt_revamped-0.3.12.dev3}/src/CasambiBt/_network.py +44 -2
  12. casambi_bt_revamped-0.3.12.dev3/src/CasambiBt/_switch_events.py +329 -0
  13. {casambi_bt_revamped-0.3.7.dev13 → casambi_bt_revamped-0.3.12.dev3}/src/CasambiBt/_unit.py +57 -3
  14. {casambi_bt_revamped-0.3.7.dev13 → casambi_bt_revamped-0.3.12.dev3}/src/CasambiBt/errors.py +12 -0
  15. casambi_bt_revamped-0.3.12.dev3/src/casambi_bt_revamped.egg-info/PKG-INFO +135 -0
  16. {casambi_bt_revamped-0.3.7.dev13 → casambi_bt_revamped-0.3.12.dev3}/src/casambi_bt_revamped.egg-info/SOURCES.txt +7 -1
  17. {casambi_bt_revamped-0.3.7.dev13 → casambi_bt_revamped-0.3.12.dev3}/src/casambi_bt_revamped.egg-info/requires.txt +2 -2
  18. casambi_bt_revamped-0.3.12.dev3/tests/test_classic_protocol.py +84 -0
  19. casambi_bt_revamped-0.3.12.dev3/tests/test_switch_event_logs.py +205 -0
  20. casambi_bt_revamped-0.3.12.dev3/tests/test_unit_state_logs.py +124 -0
  21. casambi_bt_revamped-0.3.7.dev13/PKG-INFO +0 -81
  22. casambi_bt_revamped-0.3.7.dev13/README.md +0 -60
  23. casambi_bt_revamped-0.3.7.dev13/src/CasambiBt/_client.py +0 -758
  24. casambi_bt_revamped-0.3.7.dev13/src/CasambiBt/_constants.py +0 -16
  25. casambi_bt_revamped-0.3.7.dev13/src/casambi_bt_revamped.egg-info/PKG-INFO +0 -81
  26. {casambi_bt_revamped-0.3.7.dev13 → casambi_bt_revamped-0.3.12.dev3}/LICENSE +0 -0
  27. {casambi_bt_revamped-0.3.7.dev13 → casambi_bt_revamped-0.3.12.dev3}/pyproject.toml +0 -0
  28. {casambi_bt_revamped-0.3.7.dev13 → casambi_bt_revamped-0.3.12.dev3}/src/CasambiBt/__init__.py +0 -0
  29. {casambi_bt_revamped-0.3.7.dev13 → casambi_bt_revamped-0.3.12.dev3}/src/CasambiBt/_encryption.py +0 -0
  30. {casambi_bt_revamped-0.3.7.dev13 → casambi_bt_revamped-0.3.12.dev3}/src/CasambiBt/_keystore.py +0 -0
  31. {casambi_bt_revamped-0.3.7.dev13 → casambi_bt_revamped-0.3.12.dev3}/src/CasambiBt/_operation.py +0 -0
  32. {casambi_bt_revamped-0.3.7.dev13 → casambi_bt_revamped-0.3.12.dev3}/src/CasambiBt/py.typed +0 -0
  33. {casambi_bt_revamped-0.3.7.dev13 → casambi_bt_revamped-0.3.12.dev3}/src/casambi_bt_revamped.egg-info/dependency_links.txt +0 -0
  34. {casambi_bt_revamped-0.3.7.dev13 → casambi_bt_revamped-0.3.12.dev3}/src/casambi_bt_revamped.egg-info/top_level.txt +0 -0
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.4
2
+ Name: casambi-bt-revamped
3
+ Version: 0.3.12.dev3
4
+ Summary: Forked Casambi Bluetooth client library with switch event support, use original if no special need. https://github.com/lkempf/casambi-bt
5
+ Home-page: https://github.com/rankjie/casambi-bt
6
+ Author: rankjie
7
+ Author-email: rankjie@gmail.com
8
+ Classifier: Programming Language :: Python :: 3.12
9
+ Classifier: License :: OSI Approved :: Apache Software License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Development Status :: 4 - Beta
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: bleak!=2.0.0,>=0.22
16
+ Requires-Dist: cryptography>=40.0.0
17
+ Requires-Dist: httpx>=0.25
18
+ Requires-Dist: bleak_retry_connector>=3.6.0
19
+ Requires-Dist: anyio>=4.10.0
20
+ Dynamic: license-file
21
+
22
+ ![PyPI](https://img.shields.io/pypi/v/casambi-bt-revamped)
23
+ [![Discord](https://img.shields.io/discord/1186445089317326888)](https://discord.gg/jgZVugfx)
24
+
25
+ # Casambi Bluetooth Revamped - Python library for Casambi networks
26
+
27
+ This is a customized fork of the original [casambi-bt](https://github.com/lkempf/casambi-bt) library with additional features and should only be used for special needs:
28
+
29
+ - **Switch event support** - Receive button press/release/hold events from Casambi switches (wired + wireless)
30
+ - **Improved relay status handling** - Better support for relay units
31
+ - **Classic protocol (experimental)** - Basic unit control for Classic (legacy) firmware networks
32
+ - **Bug fixes and improvements** - Various fixes based on real-world usage
33
+
34
+ This library provides a bluetooth interface to Casambi-based lights. It is not associated with Casambi.
35
+
36
+ For Home Assistant integration using this library, see [casambi-bt-hass](https://github.com/rankjie/casambi-bt-hass).
37
+
38
+ ## Getting started
39
+
40
+ This library is available on PyPi:
41
+
42
+ ```
43
+ pip install casambi-bt-revamped
44
+ ```
45
+
46
+ Have a look at `demo.py` for a small example.
47
+
48
+ ### Switch Event Support
49
+
50
+ This library supports receiving physical switch events as a decoded stream of INVOCATION frames (ground truth from the official Android app).
51
+
52
+ Event types you can expect:
53
+ - `button_press`
54
+ - `button_release`
55
+ - `button_hold`
56
+ - `button_release_after_hold`
57
+ - `input_event` (raw NotifyInput frame that may accompany presses/holds; useful for diagnostics and some wired devices)
58
+
59
+ ```python
60
+ from CasambiBt import Casambi
61
+
62
+ def handle_switch_event(event_data):
63
+ print(
64
+ "Switch event:",
65
+ {
66
+ "unit_id": event_data.get("unit_id"),
67
+ "button": event_data.get("button"),
68
+ "event": event_data.get("event"),
69
+ # INVOCATION metadata (useful for debugging/correlation)
70
+ "event_id": event_data.get("event_id"),
71
+ "opcode": event_data.get("opcode"),
72
+ "target_type": event_data.get("target_type"),
73
+ "origin": event_data.get("origin"),
74
+ "age": event_data.get("age"),
75
+ # NotifyInput fields (target_type=0x12)
76
+ "input_code": event_data.get("input_code"),
77
+ "input_channel": event_data.get("input_channel"),
78
+ "input_value16": event_data.get("input_value16"),
79
+ "input_mapped_event": event_data.get("input_mapped_event"),
80
+ },
81
+ )
82
+
83
+ casa = Casambi()
84
+ # ... connect to network ...
85
+
86
+ # Register switch event handler
87
+ casa.registerSwitchEventHandler(handle_switch_event)
88
+
89
+ # Events will be received when buttons are pressed/released
90
+ ```
91
+
92
+ Notes:
93
+ - Wireless (battery) switches typically send a "button stream" (target_type `0x06`) for press/release, and a NotifyInput stream (target_type `0x12`) for hold/release-after-hold.
94
+ - Wired switches often only send NotifyInput (target_type `0x12`), so `input_code` is mapped into `button_press/button_release/...` when appropriate.
95
+ - The library suppresses same-state retransmits at the protocol layer (edge detection), so Home Assistant-style time-window deduplication should generally not be necessary.
96
+
97
+ For the parsing details and field layout, see `doc/PROTOCOL_PARSING.md`.
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
+
113
+ ### MacOS
114
+
115
+ MacOS [does not expose the Bluetooth MAC address via their official API](https://github.com/hbldh/bleak/issues/140),
116
+ if you're running this library on MacOS, it will use an undocumented IOBluetooth API to get the MAC Address.
117
+ Without the real MAC address the integration with Casambi will not work.
118
+ If you're running into problems fetching the MAC address on MacOS, try it on a Raspberry Pi.
119
+
120
+ ### Casambi network setup
121
+
122
+ If you have problems connecting to the network please check that your network is configured appropriately before creating an issue. The network I test this with uses the **Evoultion firmware** and is configured as follows (screenshots are for the iOS app but the Android app should look very similar):
123
+
124
+ ![Gateway settings](/doc/img/gateway.png)
125
+ ![Network settings](/doc/img/network.png)
126
+ ![Performance settings](/doc/img/perf.png)
127
+
128
+ ## Development / Offline Testing
129
+
130
+ This repo includes log-driven unit tests for switch parsing:
131
+
132
+ ```bash
133
+ cd casambi-bt
134
+ python -m unittest -v
135
+ ```
@@ -0,0 +1,114 @@
1
+ ![PyPI](https://img.shields.io/pypi/v/casambi-bt-revamped)
2
+ [![Discord](https://img.shields.io/discord/1186445089317326888)](https://discord.gg/jgZVugfx)
3
+
4
+ # Casambi Bluetooth Revamped - Python library for Casambi networks
5
+
6
+ This is a customized fork of the original [casambi-bt](https://github.com/lkempf/casambi-bt) library with additional features and should only be used for special needs:
7
+
8
+ - **Switch event support** - Receive button press/release/hold events from Casambi switches (wired + wireless)
9
+ - **Improved relay status handling** - Better support for relay units
10
+ - **Classic protocol (experimental)** - Basic unit control for Classic (legacy) firmware networks
11
+ - **Bug fixes and improvements** - Various fixes based on real-world usage
12
+
13
+ This library provides a bluetooth interface to Casambi-based lights. It is not associated with Casambi.
14
+
15
+ For Home Assistant integration using this library, see [casambi-bt-hass](https://github.com/rankjie/casambi-bt-hass).
16
+
17
+ ## Getting started
18
+
19
+ This library is available on PyPi:
20
+
21
+ ```
22
+ pip install casambi-bt-revamped
23
+ ```
24
+
25
+ Have a look at `demo.py` for a small example.
26
+
27
+ ### Switch Event Support
28
+
29
+ This library supports receiving physical switch events as a decoded stream of INVOCATION frames (ground truth from the official Android app).
30
+
31
+ Event types you can expect:
32
+ - `button_press`
33
+ - `button_release`
34
+ - `button_hold`
35
+ - `button_release_after_hold`
36
+ - `input_event` (raw NotifyInput frame that may accompany presses/holds; useful for diagnostics and some wired devices)
37
+
38
+ ```python
39
+ from CasambiBt import Casambi
40
+
41
+ def handle_switch_event(event_data):
42
+ print(
43
+ "Switch event:",
44
+ {
45
+ "unit_id": event_data.get("unit_id"),
46
+ "button": event_data.get("button"),
47
+ "event": event_data.get("event"),
48
+ # INVOCATION metadata (useful for debugging/correlation)
49
+ "event_id": event_data.get("event_id"),
50
+ "opcode": event_data.get("opcode"),
51
+ "target_type": event_data.get("target_type"),
52
+ "origin": event_data.get("origin"),
53
+ "age": event_data.get("age"),
54
+ # NotifyInput fields (target_type=0x12)
55
+ "input_code": event_data.get("input_code"),
56
+ "input_channel": event_data.get("input_channel"),
57
+ "input_value16": event_data.get("input_value16"),
58
+ "input_mapped_event": event_data.get("input_mapped_event"),
59
+ },
60
+ )
61
+
62
+ casa = Casambi()
63
+ # ... connect to network ...
64
+
65
+ # Register switch event handler
66
+ casa.registerSwitchEventHandler(handle_switch_event)
67
+
68
+ # Events will be received when buttons are pressed/released
69
+ ```
70
+
71
+ Notes:
72
+ - Wireless (battery) switches typically send a "button stream" (target_type `0x06`) for press/release, and a NotifyInput stream (target_type `0x12`) for hold/release-after-hold.
73
+ - Wired switches often only send NotifyInput (target_type `0x12`), so `input_code` is mapped into `button_press/button_release/...` when appropriate.
74
+ - The library suppresses same-state retransmits at the protocol layer (edge detection), so Home Assistant-style time-window deduplication should generally not be necessary.
75
+
76
+ For the parsing details and field layout, see `doc/PROTOCOL_PARSING.md`.
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
+
92
+ ### MacOS
93
+
94
+ MacOS [does not expose the Bluetooth MAC address via their official API](https://github.com/hbldh/bleak/issues/140),
95
+ if you're running this library on MacOS, it will use an undocumented IOBluetooth API to get the MAC Address.
96
+ Without the real MAC address the integration with Casambi will not work.
97
+ If you're running into problems fetching the MAC address on MacOS, try it on a Raspberry Pi.
98
+
99
+ ### Casambi network setup
100
+
101
+ If you have problems connecting to the network please check that your network is configured appropriately before creating an issue. The network I test this with uses the **Evoultion firmware** and is configured as follows (screenshots are for the iOS app but the Android app should look very similar):
102
+
103
+ ![Gateway settings](/doc/img/gateway.png)
104
+ ![Network settings](/doc/img/network.png)
105
+ ![Performance settings](/doc/img/perf.png)
106
+
107
+ ## Development / Offline Testing
108
+
109
+ This repo includes log-driven unit tests for switch parsing:
110
+
111
+ ```bash
112
+ cd casambi-bt
113
+ python -m unittest -v
114
+ ```
@@ -1,9 +1,9 @@
1
1
  [metadata]
2
2
  name = casambi-bt-revamped
3
- version = 0.3.7.dev13
3
+ version = 0.3.12.dev3
4
4
  author = rankjie
5
5
  author_email = rankjie@gmail.com
6
- description = Enhanced Casambi Bluetooth client library with switch event support
6
+ description = Forked Casambi Bluetooth client library with switch event support, use original if no special need. https://github.com/lkempf/casambi-bt
7
7
  long_description = file: README.md
8
8
  long_description_content_type = text/markdown
9
9
  url = https://github.com/rankjie/casambi-bt
@@ -19,11 +19,11 @@ package_dir =
19
19
  packages = find:
20
20
  python_requires = >=3.11
21
21
  install_requires =
22
- bleak>=0.22
22
+ bleak>=0.22,!=2.0.0
23
23
  cryptography>=40.0.0
24
24
  httpx>=0.25
25
25
  bleak_retry_connector >= 3.6.0
26
- aiopath==0.7.*
26
+ anyio>=4.10.0
27
27
 
28
28
  [options.packages.find]
29
29
  where = src
@@ -6,28 +6,28 @@ import shutil
6
6
  from types import TracebackType
7
7
  from typing import Final
8
8
 
9
- from aiopath import AsyncPath # type: ignore
9
+ from anyio import Path
10
10
 
11
11
  _LOGGER = logging.getLogger(__name__)
12
12
 
13
- CACHE_PATH_DEFAULT: Final = AsyncPath(os.getcwd()) / "casambi-bt-store"
13
+ CACHE_PATH_DEFAULT: Final = Path(os.getcwd()) / "casambi-bt-store"
14
14
  CACHE_VERSION: Final = 2
15
15
 
16
- # We need a global lock since there could be multiple Caambi instances
16
+ # We need a global lock since there could be multiple Casambi instances
17
17
  # with their own cache instances pointing to the same folder.
18
18
  _cacheLock = asyncio.Lock()
19
19
 
20
20
 
21
- def _blocking_delete(path: AsyncPath) -> None:
21
+ def _blocking_delete(path: Path) -> None:
22
22
  shutil.rmtree(pathlib.Path(path))
23
23
 
24
24
 
25
25
  class Cache:
26
- def __init__(self, cachePath: AsyncPath | pathlib.Path | None) -> None:
26
+ def __init__(self, cachePath: Path | pathlib.Path | None) -> None:
27
27
  if cachePath is None:
28
28
  self._cachePath = CACHE_PATH_DEFAULT
29
- elif not isinstance(cachePath, AsyncPath):
30
- self._cachePath = AsyncPath(cachePath)
29
+ elif not isinstance(cachePath, Path):
30
+ self._cachePath = Path(cachePath)
31
31
  else:
32
32
  self._cachePath = cachePath
33
33
 
@@ -69,7 +69,7 @@ class Cache:
69
69
  await self._cachePath.mkdir(mode=0o700)
70
70
  await self._cacheVersionFile.write_text(str(CACHE_VERSION))
71
71
 
72
- async def __aenter__(self) -> AsyncPath:
72
+ async def __aenter__(self) -> Path:
73
73
  await _cacheLock.acquire()
74
74
 
75
75
  if self._uuid is None:
@@ -78,7 +78,7 @@ class Cache:
78
78
  try:
79
79
  await self._ensureCacheValid()
80
80
 
81
- cacheDir = AsyncPath(self._cachePath / self._uuid)
81
+ cacheDir = Path(self._cachePath / self._uuid)
82
82
  if not await cacheDir.exists():
83
83
  _LOGGER.debug("Creating cache entry for id %s", self._uuid)
84
84
  await cacheDir.mkdir()
@@ -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
+