casambi-bt-revamped 0.3.7.dev3__py3-none-any.whl → 0.3.12.dev15__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/__init__.py +1 -0
- CasambiBt/_cache.py +9 -9
- CasambiBt/_casambi.py +421 -11
- CasambiBt/_classic_crypto.py +146 -0
- CasambiBt/_client.py +1916 -160
- CasambiBt/_constants.py +16 -0
- CasambiBt/_discover.py +3 -2
- CasambiBt/_invocation.py +116 -0
- CasambiBt/_network.py +195 -23
- CasambiBt/_operation.py +13 -2
- CasambiBt/_switch_events.py +329 -0
- CasambiBt/_unit.py +59 -3
- CasambiBt/_version.py +10 -0
- CasambiBt/errors.py +12 -0
- casambi_bt_revamped-0.3.12.dev15.dist-info/METADATA +135 -0
- casambi_bt_revamped-0.3.12.dev15.dist-info/RECORD +22 -0
- {casambi_bt_revamped-0.3.7.dev3.dist-info → casambi_bt_revamped-0.3.12.dev15.dist-info}/WHEEL +1 -1
- casambi_bt_revamped-0.3.7.dev3.dist-info/METADATA +0 -81
- casambi_bt_revamped-0.3.7.dev3.dist-info/RECORD +0 -18
- {casambi_bt_revamped-0.3.7.dev3.dist-info → casambi_bt_revamped-0.3.12.dev15.dist-info}/licenses/LICENSE +0 -0
- {casambi_bt_revamped-0.3.7.dev3.dist-info → casambi_bt_revamped-0.3.12.dev15.dist-info}/top_level.txt +0 -0
CasambiBt/__init__.py
CHANGED
CasambiBt/_cache.py
CHANGED
|
@@ -6,28 +6,28 @@ import shutil
|
|
|
6
6
|
from types import TracebackType
|
|
7
7
|
from typing import Final
|
|
8
8
|
|
|
9
|
-
from
|
|
9
|
+
from anyio import Path
|
|
10
10
|
|
|
11
11
|
_LOGGER = logging.getLogger(__name__)
|
|
12
12
|
|
|
13
|
-
CACHE_PATH_DEFAULT: Final =
|
|
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
|
|
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:
|
|
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:
|
|
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,
|
|
30
|
-
self._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) ->
|
|
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 =
|
|
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()
|
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
|
|
@@ -98,6 +98,16 @@ class Casambi:
|
|
|
98
98
|
and self._casaClient._connectionState == ConnectionState.AUTHENTICATED
|
|
99
99
|
)
|
|
100
100
|
|
|
101
|
+
@property
|
|
102
|
+
def rawNetworkData(self) -> dict | None:
|
|
103
|
+
"""Get the raw network configuration data if available.
|
|
104
|
+
|
|
105
|
+
:return: The raw network JSON data or None if not connected.
|
|
106
|
+
"""
|
|
107
|
+
if self._casaNetwork:
|
|
108
|
+
return self._casaNetwork.rawNetworkData
|
|
109
|
+
return None
|
|
110
|
+
|
|
101
111
|
async def connect(
|
|
102
112
|
self,
|
|
103
113
|
addr_or_device: str | BLEDevice,
|
|
@@ -159,8 +169,15 @@ class Casambi:
|
|
|
159
169
|
self._casaClient = cast(CasambiClient, self._casaClient)
|
|
160
170
|
await self._casaClient.connect()
|
|
161
171
|
try:
|
|
162
|
-
|
|
163
|
-
|
|
172
|
+
if self._casaClient.protocolMode == ProtocolMode.EVO:
|
|
173
|
+
# EVO requires key exchange + authenticate.
|
|
174
|
+
await self._casaClient.exchangeKey()
|
|
175
|
+
await self._casaClient.authenticate()
|
|
176
|
+
elif self._casaClient.protocolMode == ProtocolMode.CLASSIC:
|
|
177
|
+
# Classic needs an init write to trigger state broadcasts.
|
|
178
|
+
# In EVO the key exchange/auth handshake implicitly signals the
|
|
179
|
+
# device; Classic has no such handshake so we send a time-sync.
|
|
180
|
+
await self._casaClient.classicSendInit()
|
|
164
181
|
except ProtocolError as e:
|
|
165
182
|
await self._casaClient.disconnect()
|
|
166
183
|
raise e
|
|
@@ -191,6 +208,40 @@ class Casambi:
|
|
|
191
208
|
raise ValueError()
|
|
192
209
|
|
|
193
210
|
payload = level.to_bytes(1, byteorder="big", signed=False)
|
|
211
|
+
|
|
212
|
+
# Classic protocol uses signed command frames (u1.C1753e / u1.EnumC1754f).
|
|
213
|
+
if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
|
|
214
|
+
# Check if we should use the alternative simple format from BLE captures
|
|
215
|
+
import os
|
|
216
|
+
use_simple = os.environ.get("CASAMBI_BT_CLASSIC_FORMAT", "").lower() == "simple"
|
|
217
|
+
|
|
218
|
+
if use_simple:
|
|
219
|
+
# Simple format: [counter][unit_id][param_len][dimmer]
|
|
220
|
+
# For "all units", use unit_id=0xFF
|
|
221
|
+
if isinstance(target, Unit):
|
|
222
|
+
cmd = self._casaClient.buildClassicCommandSimple(target.deviceId, level)
|
|
223
|
+
elif isinstance(target, Group):
|
|
224
|
+
# Groups in simple format: not fully confirmed, try group ID
|
|
225
|
+
cmd = self._casaClient.buildClassicCommandSimple(target.groudId, level)
|
|
226
|
+
elif target is None:
|
|
227
|
+
cmd = self._casaClient.buildClassicCommandSimple(0xFF, level)
|
|
228
|
+
else:
|
|
229
|
+
raise TypeError(f"Unkown target type {type(target)}")
|
|
230
|
+
else:
|
|
231
|
+
# EnumC1754f ordinals (ground truth: casambi-android u1.EnumC1754f):
|
|
232
|
+
# - AllUnitsLevel=4, UnitLevel=7, GroupLevel=26
|
|
233
|
+
if isinstance(target, Unit):
|
|
234
|
+
cmd = self._casaClient.buildClassicCommand(7, payload, target_id=target.deviceId)
|
|
235
|
+
elif isinstance(target, Group):
|
|
236
|
+
cmd = self._casaClient.buildClassicCommand(26, payload, target_id=target.groudId)
|
|
237
|
+
elif target is None:
|
|
238
|
+
cmd = self._casaClient.buildClassicCommand(4, payload)
|
|
239
|
+
else:
|
|
240
|
+
raise TypeError(f"Unkown target type {type(target)}")
|
|
241
|
+
|
|
242
|
+
await self._casaClient.send(cmd)
|
|
243
|
+
return
|
|
244
|
+
|
|
194
245
|
await self._send(target, payload, OpCode.SetLevel)
|
|
195
246
|
|
|
196
247
|
async def setVertical(self, target: Unit | Group | None, vertical: int) -> None:
|
|
@@ -209,6 +260,21 @@ class Casambi:
|
|
|
209
260
|
raise ValueError()
|
|
210
261
|
|
|
211
262
|
payload = vertical.to_bytes(1, byteorder="big", signed=False)
|
|
263
|
+
|
|
264
|
+
if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
|
|
265
|
+
# EnumC1754f ordinals: AllUnitsVertical=22, UnitVertical=24, GroupVertical=29
|
|
266
|
+
if isinstance(target, Unit):
|
|
267
|
+
cmd = self._casaClient.buildClassicCommand(24, payload, target_id=target.deviceId)
|
|
268
|
+
elif isinstance(target, Group):
|
|
269
|
+
cmd = self._casaClient.buildClassicCommand(29, payload, target_id=target.groudId)
|
|
270
|
+
elif target is None:
|
|
271
|
+
cmd = self._casaClient.buildClassicCommand(22, payload)
|
|
272
|
+
else:
|
|
273
|
+
raise TypeError(f"Unkown target type {type(target)}")
|
|
274
|
+
|
|
275
|
+
await self._casaClient.send(cmd)
|
|
276
|
+
return
|
|
277
|
+
|
|
212
278
|
await self._send(target, payload, OpCode.SetVertical)
|
|
213
279
|
|
|
214
280
|
async def setSlider(self, target: Unit | Group | None, value: int) -> None:
|
|
@@ -245,6 +311,21 @@ class Casambi:
|
|
|
245
311
|
raise ValueError()
|
|
246
312
|
|
|
247
313
|
payload = level.to_bytes(1, byteorder="big", signed=False)
|
|
314
|
+
|
|
315
|
+
if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
|
|
316
|
+
# EnumC1754f ordinals: AllUnitsWhite=23, UnitWhite=25, GroupWhite=30
|
|
317
|
+
if isinstance(target, Unit):
|
|
318
|
+
cmd = self._casaClient.buildClassicCommand(25, payload, target_id=target.deviceId)
|
|
319
|
+
elif isinstance(target, Group):
|
|
320
|
+
cmd = self._casaClient.buildClassicCommand(30, payload, target_id=target.groudId)
|
|
321
|
+
elif target is None:
|
|
322
|
+
cmd = self._casaClient.buildClassicCommand(23, payload)
|
|
323
|
+
else:
|
|
324
|
+
raise TypeError(f"Unkown target type {type(target)}")
|
|
325
|
+
|
|
326
|
+
await self._casaClient.send(cmd)
|
|
327
|
+
return
|
|
328
|
+
|
|
248
329
|
await self._send(target, payload, OpCode.SetWhite)
|
|
249
330
|
|
|
250
331
|
async def setColor(
|
|
@@ -262,6 +343,27 @@ class Casambi:
|
|
|
262
343
|
:raises ValueError: The supplied rgbColor isn't in range
|
|
263
344
|
"""
|
|
264
345
|
|
|
346
|
+
if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
|
|
347
|
+
# Classic uses RGB payload (3 bytes) directly.
|
|
348
|
+
r, g, b = rgbColor
|
|
349
|
+
if not (0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255):
|
|
350
|
+
raise ValueError("rgbColor out of range.")
|
|
351
|
+
payload = bytes([r & 0xFF, g & 0xFF, b & 0xFF])
|
|
352
|
+
|
|
353
|
+
# EnumC1754f ordinals: AllUnitsColor=6, UnitColor=9, GroupColor=28
|
|
354
|
+
if isinstance(target, Unit):
|
|
355
|
+
cmd = self._casaClient.buildClassicCommand(9, payload, target_id=target.deviceId)
|
|
356
|
+
elif isinstance(target, Group):
|
|
357
|
+
cmd = self._casaClient.buildClassicCommand(28, payload, target_id=target.groudId)
|
|
358
|
+
elif target is None:
|
|
359
|
+
cmd = self._casaClient.buildClassicCommand(6, payload)
|
|
360
|
+
else:
|
|
361
|
+
raise TypeError(f"Unkown target type {type(target)}")
|
|
362
|
+
|
|
363
|
+
await self._casaClient.send(cmd)
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
# Evolution uses HS payload (hue 10-bit + sat 8-bit) for SetColor.
|
|
265
367
|
state = UnitState()
|
|
266
368
|
state.rgb = rgbColor
|
|
267
369
|
hs: tuple[float, float] = state.hs # type: ignore[assignment]
|
|
@@ -290,6 +392,21 @@ class Casambi:
|
|
|
290
392
|
|
|
291
393
|
temperature = int(temperature / 50)
|
|
292
394
|
payload = temperature.to_bytes(1, byteorder="big", signed=False)
|
|
395
|
+
|
|
396
|
+
if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
|
|
397
|
+
# EnumC1754f ordinals: AllUnitsTemperature=5, UnitTemperature=8, GroupTemperature=27
|
|
398
|
+
if isinstance(target, Unit):
|
|
399
|
+
cmd = self._casaClient.buildClassicCommand(8, payload, target_id=target.deviceId)
|
|
400
|
+
elif isinstance(target, Group):
|
|
401
|
+
cmd = self._casaClient.buildClassicCommand(27, payload, target_id=target.groudId)
|
|
402
|
+
elif target is None:
|
|
403
|
+
cmd = self._casaClient.buildClassicCommand(5, payload)
|
|
404
|
+
else:
|
|
405
|
+
raise TypeError(f"Unkown target type {type(target)}")
|
|
406
|
+
|
|
407
|
+
await self._casaClient.send(cmd)
|
|
408
|
+
return
|
|
409
|
+
|
|
293
410
|
await self._send(target, payload, OpCode.SetTemperature)
|
|
294
411
|
|
|
295
412
|
async def setColorXY(
|
|
@@ -307,6 +424,10 @@ class Casambi:
|
|
|
307
424
|
:raises ValueError: The supplied XYColor isn't in range or not supported by the supplied unit.
|
|
308
425
|
"""
|
|
309
426
|
|
|
427
|
+
if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
|
|
428
|
+
# Classic command set (u1.EnumC1754f) only exposes RGB color control.
|
|
429
|
+
raise ValueError("XY color control is not supported on Classic networks.")
|
|
430
|
+
|
|
310
431
|
if xyColor[0] < 0.0 or xyColor[0] > 1.0 or xyColor[1] < 0.0 or xyColor[1] > 1.0:
|
|
311
432
|
raise ValueError("Color out of range.")
|
|
312
433
|
|
|
@@ -335,6 +456,22 @@ class Casambi:
|
|
|
335
456
|
:return: Nothing is returned by this function. To get the new state register a change handler.
|
|
336
457
|
"""
|
|
337
458
|
|
|
459
|
+
if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
|
|
460
|
+
# Classic uses a longer payload for "restore last level" (ground truth: casambi-android u1.C1751c.o()).
|
|
461
|
+
payload = bytes([0xFF, 0x01, 0x00, 0x00, 0x01])
|
|
462
|
+
# EnumC1754f ordinals: AllUnitsLevel=4, UnitLevel=7, GroupLevel=26
|
|
463
|
+
if isinstance(target, Unit):
|
|
464
|
+
cmd = self._casaClient.buildClassicCommand(7, payload, target_id=target.deviceId)
|
|
465
|
+
elif isinstance(target, Group):
|
|
466
|
+
cmd = self._casaClient.buildClassicCommand(26, payload, target_id=target.groudId)
|
|
467
|
+
elif target is None:
|
|
468
|
+
cmd = self._casaClient.buildClassicCommand(4, payload)
|
|
469
|
+
else:
|
|
470
|
+
raise TypeError(f"Unkown target type {type(target)}")
|
|
471
|
+
|
|
472
|
+
await self._casaClient.send(cmd)
|
|
473
|
+
return
|
|
474
|
+
|
|
338
475
|
# Use -1 to indicate special packet format
|
|
339
476
|
# Use RestoreLastLevel flag (1) and UseFullTimeFlag (4).
|
|
340
477
|
# Not sure what UseFullTime does but this is what the app uses.
|
|
@@ -357,6 +494,11 @@ class Casambi:
|
|
|
357
494
|
ConnectionState.AUTHENTICATED,
|
|
358
495
|
ConnectionState.NONE,
|
|
359
496
|
)
|
|
497
|
+
if self._casaClient.protocolMode == ProtocolMode.CLASSIC:
|
|
498
|
+
# Classic uses a completely different command encoding (u1.C1753e/u1.EnumC1754f).
|
|
499
|
+
# Public APIs that support Classic handle it explicitly; anything reaching here would
|
|
500
|
+
# send an EVO INVOCATION packet which is not valid on Classic.
|
|
501
|
+
raise ProtocolError(f"Operation {opcode.name} is not supported on Classic networks via INVOCATION.")
|
|
360
502
|
|
|
361
503
|
targetCode = 0
|
|
362
504
|
if isinstance(target, Unit):
|
|
@@ -390,7 +532,7 @@ class Casambi:
|
|
|
390
532
|
def _dataCallback(
|
|
391
533
|
self, packetType: IncommingPacketType, data: dict[str, Any]
|
|
392
534
|
) -> None:
|
|
393
|
-
self._logger.
|
|
535
|
+
self._logger.debug("Incomming data callback of type %s", packetType)
|
|
394
536
|
if packetType == IncommingPacketType.UnitState:
|
|
395
537
|
self._logger.debug(
|
|
396
538
|
f"Handling changed state {b2a(data['state'])} for unit {data['id']}"
|
|
@@ -463,13 +605,17 @@ class Casambi:
|
|
|
463
605
|
"""Register a new handler for switch events.
|
|
464
606
|
|
|
465
607
|
This handler is called whenever a switch event is received.
|
|
466
|
-
The handler is supplied with a dictionary containing:
|
|
467
|
-
- unit_id:
|
|
468
|
-
- button:
|
|
469
|
-
- event:
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
-
|
|
608
|
+
The handler is supplied with a dictionary containing (at minimum):
|
|
609
|
+
- unit_id: target unit id (from INVOCATION target high byte)
|
|
610
|
+
- button: best-effort "label" (typically 1..4 for 4-gang switches)
|
|
611
|
+
- event: "button_press" | "button_release" | "input_event"
|
|
612
|
+
|
|
613
|
+
Switch events are parsed from decrypted packet type=7 (INVOCATION stream),
|
|
614
|
+
matching casambi-android `v1.C1775b.Q(Q2.h)`. Extra diagnostic keys include:
|
|
615
|
+
- invocation_flags, opcode, origin, target, target_type, age, origin_handle
|
|
616
|
+
- button_event_index (0..7), param_p, param_s
|
|
617
|
+
- input_index (0..7), input_code, input_b1, input_channel, input_value16, input_mapped_event
|
|
618
|
+
- packet_sequence, arrival_sequence, raw_packet, decrypted_data, payload_hex, frame_offset, event_id
|
|
473
619
|
|
|
474
620
|
:param handler: The method to call when a switch event is received.
|
|
475
621
|
"""
|
|
@@ -541,6 +687,270 @@ class Casambi:
|
|
|
541
687
|
exc_info=True,
|
|
542
688
|
)
|
|
543
689
|
|
|
690
|
+
async def setParameter(self, unitId: int, parameterTag: int, parameterData: bytes) -> None:
|
|
691
|
+
"""Send a SetParameter command to a unit.
|
|
692
|
+
|
|
693
|
+
Args:
|
|
694
|
+
unitId: The ID of the unit to send the command to
|
|
695
|
+
parameterTag: The parameter tag/ID to update
|
|
696
|
+
parameterData: The raw parameter data to send
|
|
697
|
+
"""
|
|
698
|
+
if not self._casaClient:
|
|
699
|
+
raise RuntimeError("Not connected to network")
|
|
700
|
+
|
|
701
|
+
# Build payload: [parameter_tag][parameter_data]
|
|
702
|
+
# Note: SetParameter supports max 31 bytes after the tag.
|
|
703
|
+
payload = bytes([parameterTag]) + parameterData[:31]
|
|
704
|
+
|
|
705
|
+
# Send using OpCode.SetParameter (26)
|
|
706
|
+
opPkt = self._opContext.prepareOperation(OpCode.SetParameter, (unitId << 8) | 0x01, payload)
|
|
707
|
+
await self._casaClient.send(opPkt)
|
|
708
|
+
|
|
709
|
+
async def send_ext_packet(self, unit_id: int, seq: int, chunk: bytes, *, lifetime: int = 9) -> None:
|
|
710
|
+
"""Send an extended packet (ExtPacketSend / opcode 43) to a unit.
|
|
711
|
+
|
|
712
|
+
Payload is framed as: [0x29][seq][chunk...]
|
|
713
|
+
|
|
714
|
+
Notes:
|
|
715
|
+
- Total payload must be <= 63 bytes, so len(chunk) <= 61.
|
|
716
|
+
- Lifetime defaults to 9 to mirror longer-running multi-chunk ops seen on Android.
|
|
717
|
+
"""
|
|
718
|
+
if not self._casaClient:
|
|
719
|
+
raise RuntimeError("Not connected to network")
|
|
720
|
+
|
|
721
|
+
if seq < 0 or seq > 255:
|
|
722
|
+
raise ValueError("seq must be 0..255")
|
|
723
|
+
if len(chunk) > 61:
|
|
724
|
+
raise ValueError("chunk too large; max 61 bytes to fit ExtPacket payload")
|
|
725
|
+
|
|
726
|
+
payload = bytes([0x29, seq & 0xFF]) + chunk
|
|
727
|
+
target = (int(unit_id) << 8) | 0x01
|
|
728
|
+
opPkt = self._opContext.prepareOperation(
|
|
729
|
+
OpCode.ExtPacketSend,
|
|
730
|
+
target,
|
|
731
|
+
payload,
|
|
732
|
+
lifetime=lifetime,
|
|
733
|
+
)
|
|
734
|
+
await self._casaClient.send(opPkt)
|
|
735
|
+
|
|
736
|
+
async def start_switch_session(self, unit_id: int, *, payload: bytes = b"", lifetime: int = 9) -> None:
|
|
737
|
+
"""Experimental: send AcquireSwitchSession (opcode 42).
|
|
738
|
+
|
|
739
|
+
Payload is device-specific and currently unknown for switchConfig; by default
|
|
740
|
+
sends an empty payload. Use for experimentation only until framing is confirmed.
|
|
741
|
+
"""
|
|
742
|
+
if not self._casaClient:
|
|
743
|
+
raise RuntimeError("Not connected to network")
|
|
744
|
+
if len(payload) > 63:
|
|
745
|
+
raise ValueError("payload too large; max 63 bytes")
|
|
746
|
+
target = (int(unit_id) << 8) | 0x01
|
|
747
|
+
opPkt = self._opContext.prepareOperation(
|
|
748
|
+
OpCode.AcquireSwitchSession,
|
|
749
|
+
target,
|
|
750
|
+
payload,
|
|
751
|
+
lifetime=lifetime,
|
|
752
|
+
)
|
|
753
|
+
await self._casaClient.send(opPkt)
|
|
754
|
+
|
|
755
|
+
async def apply_switch_config_ble(self, unit_id: int, *, parameter_tag: int | None = None) -> None:
|
|
756
|
+
"""Attempt to push the unit's switchConfig to the device over BLE.
|
|
757
|
+
|
|
758
|
+
This method currently supports only very small configurations that fit
|
|
759
|
+
into a single SetParameter payload (max 31 bytes after the tag).
|
|
760
|
+
|
|
761
|
+
Args:
|
|
762
|
+
unit_id: The ID of the unit to update.
|
|
763
|
+
parameter_tag: Optional override for the parameter tag; if not provided,
|
|
764
|
+
a default of 1 is used (subject to device variation).
|
|
765
|
+
|
|
766
|
+
Raises:
|
|
767
|
+
RuntimeError: If not connected or no raw network data.
|
|
768
|
+
ValueError: If the encoded switchConfig is too large for SetParameter.
|
|
769
|
+
"""
|
|
770
|
+
if not self._casaClient:
|
|
771
|
+
raise RuntimeError("Not connected to network")
|
|
772
|
+
if not self._casaNetwork or not self._casaNetwork.rawNetworkData:
|
|
773
|
+
raise RuntimeError("No raw network data available; connect and update first")
|
|
774
|
+
|
|
775
|
+
units = self._casaNetwork.rawNetworkData.get("network", {}).get("units", [])
|
|
776
|
+
unit_data = next((u for u in units if u.get("deviceID") == unit_id), None)
|
|
777
|
+
if not unit_data:
|
|
778
|
+
raise ValueError(f"Unit {unit_id} not found in raw network data")
|
|
779
|
+
|
|
780
|
+
switch_config = unit_data.get("switchConfig") or {}
|
|
781
|
+
|
|
782
|
+
import json
|
|
783
|
+
config_bytes = json.dumps(switch_config, separators=(",", ":")).encode("utf-8")
|
|
784
|
+
|
|
785
|
+
# Protocol restriction: SetParameter accepts max 31 bytes after tag.
|
|
786
|
+
if len(config_bytes) > 31:
|
|
787
|
+
raise ValueError(
|
|
788
|
+
"switchConfig exceeds 31 bytes; multi-packet BLE apply is required. "
|
|
789
|
+
"This library will need an extended writer (AcquireSwitchSession/ExtPacketSend) to support large configs."
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
tag = 1 if parameter_tag is None else int(parameter_tag)
|
|
793
|
+
await self.setParameter(unit_id, tag, config_bytes)
|
|
794
|
+
self._logger.info(
|
|
795
|
+
"Applied switchConfig via SetParameter (tag=%s, bytes=%s) for unit %s",
|
|
796
|
+
tag,
|
|
797
|
+
len(config_bytes),
|
|
798
|
+
unit_id,
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
async def apply_switch_config_ble_large(self, unit_id: int) -> None:
|
|
802
|
+
"""Experimental: attempt to push switchConfig using ExtPacketSend in chunks.
|
|
803
|
+
|
|
804
|
+
WARNING: Framing is not fully confirmed for switchConfig updates. This method
|
|
805
|
+
sends raw JSON bytes chunked as consecutive ExtPacket frames with header
|
|
806
|
+
[0x29][seq]. Use only for experimentation and logs; correctness is not guaranteed.
|
|
807
|
+
"""
|
|
808
|
+
if not self._casaClient:
|
|
809
|
+
raise RuntimeError("Not connected to network")
|
|
810
|
+
if not self._casaNetwork or not self._casaNetwork.rawNetworkData:
|
|
811
|
+
raise RuntimeError("No raw network data available; connect and update first")
|
|
812
|
+
|
|
813
|
+
units = self._casaNetwork.rawNetworkData.get("network", {}).get("units", [])
|
|
814
|
+
unit_data = next((u for u in units if u.get("deviceID") == unit_id), None)
|
|
815
|
+
if not unit_data:
|
|
816
|
+
raise ValueError(f"Unit {unit_id} not found in raw network data")
|
|
817
|
+
|
|
818
|
+
switch_config = unit_data.get("switchConfig") or {}
|
|
819
|
+
import json
|
|
820
|
+
data = json.dumps(switch_config, separators=(",", ":")).encode("utf-8")
|
|
821
|
+
|
|
822
|
+
# Attempt to start a switch session before sending chunks. Some devices
|
|
823
|
+
# expect an AcquireSwitchSession (opcode 42) handshake prior to ExtPacketSend.
|
|
824
|
+
try:
|
|
825
|
+
await self.start_switch_session(unit_id)
|
|
826
|
+
self._logger.info("Started switch session (AcquireSwitchSession) for unit %s", unit_id)
|
|
827
|
+
except Exception as e:
|
|
828
|
+
# Non-fatal: proceed without session if device doesn't require it.
|
|
829
|
+
self._logger.info(
|
|
830
|
+
"AcquireSwitchSession not acknowledged or unsupported for unit %s (%s); proceeding with ExtPacketSend only.",
|
|
831
|
+
unit_id,
|
|
832
|
+
e,
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
# Chunk into 61-byte pieces (ExtPacket payload allows 63 total minus 2-byte header)
|
|
836
|
+
max_chunk = 61
|
|
837
|
+
seq = 0
|
|
838
|
+
for off in range(0, len(data), max_chunk):
|
|
839
|
+
chunk = data[off : off + max_chunk]
|
|
840
|
+
await self.send_ext_packet(unit_id, seq, chunk, lifetime=9)
|
|
841
|
+
self._logger.debug(
|
|
842
|
+
"ExtPacket chunk sent: unit=%s seq=%s size=%s/%s", unit_id, seq, len(chunk), len(data)
|
|
843
|
+
)
|
|
844
|
+
seq = (seq + 1) & 0xFF
|
|
845
|
+
self._logger.info(
|
|
846
|
+
"ExtPacket switchConfig attempt complete (unit=%s, total_bytes=%s, chunks=%s)",
|
|
847
|
+
unit_id,
|
|
848
|
+
len(data),
|
|
849
|
+
(len(data) + max_chunk - 1) // max_chunk,
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
async def update_button_config(
|
|
853
|
+
self,
|
|
854
|
+
unit_id: int,
|
|
855
|
+
button_index: int,
|
|
856
|
+
action_type: str,
|
|
857
|
+
target_id: int | None = None,
|
|
858
|
+
*,
|
|
859
|
+
exclusive_scenes: bool | None = None,
|
|
860
|
+
long_press_all_off: bool | None = None,
|
|
861
|
+
toggle_disabled: bool | None = None,
|
|
862
|
+
) -> dict:
|
|
863
|
+
"""Update one button entry in a unit's switchConfig in the cached network data.
|
|
864
|
+
|
|
865
|
+
Android mapping for actions (EnumC0105q):
|
|
866
|
+
- none -> 0, target 0
|
|
867
|
+
- scene -> 1, target=sceneID
|
|
868
|
+
- control_unit -> 2, target=deviceID
|
|
869
|
+
- control_group -> 3, target=groupID
|
|
870
|
+
- all_units -> 4, target=255
|
|
871
|
+
- resume_automation -> 6, target=0
|
|
872
|
+
- resume_automation_group -> 7, target=groupID
|
|
873
|
+
|
|
874
|
+
Returns the updated switchConfig dict.
|
|
875
|
+
"""
|
|
876
|
+
if button_index < 0 or button_index > 7:
|
|
877
|
+
raise ValueError("button_index must be in range 0..7")
|
|
878
|
+
|
|
879
|
+
raw = self.rawNetworkData
|
|
880
|
+
if not raw:
|
|
881
|
+
raise RuntimeError("No raw network data loaded. Connect and update first.")
|
|
882
|
+
|
|
883
|
+
# Locate the unit's JSON entry
|
|
884
|
+
units = raw.get("network", {}).get("units", [])
|
|
885
|
+
unit_data = None
|
|
886
|
+
for u in units:
|
|
887
|
+
if u.get("deviceID") == unit_id:
|
|
888
|
+
unit_data = u
|
|
889
|
+
break
|
|
890
|
+
if not unit_data:
|
|
891
|
+
raise ValueError(f"Unit {unit_id} not found in raw network data")
|
|
892
|
+
|
|
893
|
+
switch_config = unit_data.get("switchConfig") or {}
|
|
894
|
+
buttons: list[dict] = switch_config.get("buttons") or []
|
|
895
|
+
|
|
896
|
+
action_map = {
|
|
897
|
+
"none": 0,
|
|
898
|
+
"scene": 1,
|
|
899
|
+
"control_unit": 2,
|
|
900
|
+
"control_group": 3,
|
|
901
|
+
"all_units": 4,
|
|
902
|
+
"resume_automation": 6,
|
|
903
|
+
"resume_automation_group": 7,
|
|
904
|
+
}
|
|
905
|
+
if action_type not in action_map:
|
|
906
|
+
raise ValueError(
|
|
907
|
+
"Unsupported action_type. Use one of: none, scene, control_unit, "
|
|
908
|
+
"control_group, all_units, resume_automation, resume_automation_group"
|
|
909
|
+
)
|
|
910
|
+
action_code = action_map[action_type]
|
|
911
|
+
|
|
912
|
+
if action_type == "all_units":
|
|
913
|
+
resolved_target = 255
|
|
914
|
+
elif action_type in ("none", "resume_automation"):
|
|
915
|
+
resolved_target = 0
|
|
916
|
+
else:
|
|
917
|
+
if target_id is None:
|
|
918
|
+
raise ValueError(f"target_id is required for action_type '{action_type}'")
|
|
919
|
+
resolved_target = int(target_id)
|
|
920
|
+
|
|
921
|
+
# Find or create entry for this button index
|
|
922
|
+
existing = None
|
|
923
|
+
for b in buttons:
|
|
924
|
+
if b.get("type") == button_index:
|
|
925
|
+
existing = b
|
|
926
|
+
break
|
|
927
|
+
entry = existing or {"type": button_index, "action": 0, "target": 0}
|
|
928
|
+
entry["action"] = action_code
|
|
929
|
+
entry["target"] = resolved_target
|
|
930
|
+
if not existing:
|
|
931
|
+
buttons.append(entry)
|
|
932
|
+
|
|
933
|
+
# Persist and apply optional flags
|
|
934
|
+
switch_config["buttons"] = buttons
|
|
935
|
+
if exclusive_scenes is not None:
|
|
936
|
+
switch_config["exclusiveScenes"] = bool(exclusive_scenes)
|
|
937
|
+
if long_press_all_off is not None:
|
|
938
|
+
switch_config["longPressAllOff"] = bool(long_press_all_off)
|
|
939
|
+
if toggle_disabled is not None:
|
|
940
|
+
switch_config["toggleDisabled"] = bool(toggle_disabled)
|
|
941
|
+
|
|
942
|
+
unit_data["switchConfig"] = switch_config
|
|
943
|
+
|
|
944
|
+
self._logger.info(
|
|
945
|
+
"Updated switchConfig (unit=%s, button=%s, action=%s, target=%s)",
|
|
946
|
+
unit_id,
|
|
947
|
+
button_index,
|
|
948
|
+
action_type,
|
|
949
|
+
resolved_target,
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
return switch_config
|
|
953
|
+
|
|
544
954
|
async def disconnect(self) -> None:
|
|
545
955
|
"""Disconnect from the network."""
|
|
546
956
|
if self._casaClient:
|