casambi-bt-revamped 0.3.7.dev9__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/_constants.py CHANGED
@@ -6,6 +6,22 @@ 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
+ CASA_CLASSIC_CA53_CHAR_UUID: Final = "0000ca53-0000-1000-8000-00805f9b34fb"
16
+
17
+ # Classic "conformant" firmware maps the legacy CA5A/CA5x UUIDs onto the FE4D service.
18
+ # Ground truth: casambi-android `t1.C1713d.e(UUID)` mapping:
19
+ # - CA52 -> 0001 (same as CASA_AUTH_CHAR_UUID)
20
+ # - CA51 -> 0002
21
+ # - CA53 -> 0003
22
+ CASA_CLASSIC_CONFORMANT_CA51_CHAR_UUID: Final = "c9ffde48-ca5a-0002-ab83-8f519b482f77"
23
+ CASA_CLASSIC_CONFORMANT_CA53_CHAR_UUID: Final = "c9ffde48-ca5a-0003-ab83-8f519b482f77"
24
+
9
25
 
10
26
  @unique
11
27
  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
- if CASA_UUID in advertisement.service_uuids:
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
 
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from typing import Final
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class InvocationFrame:
10
+ """One INVOCATION frame.
11
+
12
+ Ground truth: casambi-android `v1.C1775b.Q(Q2.h)` parses:
13
+ - flags:u16 (big-endian)
14
+ - opcode:u8
15
+ - origin:u16
16
+ - target:u16
17
+ - age:u16
18
+ - origin_handle?:u8 (if flags & 0x0200)
19
+ - payload: flags & 0x3f bytes
20
+ """
21
+
22
+ flags: int
23
+ opcode: int
24
+ origin: int
25
+ target: int
26
+ age: int
27
+ origin_handle: int | None
28
+ payload: bytes
29
+ offset: int # start offset of this frame in the decrypted type=7 payload
30
+
31
+ @property
32
+ def payload_len(self) -> int:
33
+ return self.flags & 0x3F
34
+
35
+
36
+ _FLAG_HAS_ORIGIN_HANDLE: Final[int] = 0x0200
37
+
38
+
39
+ def parse_invocation_stream(
40
+ data: bytes, *, logger: logging.Logger | None = None
41
+ ) -> list[InvocationFrame]:
42
+ """Parse decrypted packet type=7 payload into INVOCATION frames."""
43
+
44
+ frames: list[InvocationFrame] = []
45
+ pos = 0
46
+
47
+ # Android bails out if < 9 bytes remain.
48
+ while len(data) - pos >= 9:
49
+ frame_offset = pos
50
+
51
+ flags = int.from_bytes(data[pos : pos + 2], "big")
52
+ pos += 2
53
+
54
+ opcode = data[pos]
55
+ pos += 1
56
+
57
+ origin = int.from_bytes(data[pos : pos + 2], "big")
58
+ pos += 2
59
+
60
+ target = int.from_bytes(data[pos : pos + 2], "big")
61
+ pos += 2
62
+
63
+ age = int.from_bytes(data[pos : pos + 2], "big")
64
+ pos += 2
65
+
66
+ origin_handle: int | None = None
67
+ if flags & _FLAG_HAS_ORIGIN_HANDLE:
68
+ if pos >= len(data):
69
+ if logger:
70
+ logger.debug(
71
+ "INVOCATION frame truncated at origin_handle (offset=%d flags=0x%04x).",
72
+ frame_offset,
73
+ flags,
74
+ )
75
+ break
76
+ origin_handle = data[pos]
77
+ pos += 1
78
+
79
+ payload_len = flags & 0x3F
80
+ if pos + payload_len > len(data):
81
+ if logger:
82
+ logger.debug(
83
+ "INVOCATION frame truncated at payload (offset=%d flags=0x%04x payload_len=%d remaining=%d).",
84
+ frame_offset,
85
+ flags,
86
+ payload_len,
87
+ len(data) - pos,
88
+ )
89
+ break
90
+
91
+ payload = data[pos : pos + payload_len]
92
+ pos += payload_len
93
+
94
+ frames.append(
95
+ InvocationFrame(
96
+ flags=flags,
97
+ opcode=opcode,
98
+ origin=origin,
99
+ target=target,
100
+ age=age,
101
+ origin_handle=origin_handle,
102
+ payload=payload,
103
+ offset=frame_offset,
104
+ )
105
+ )
106
+
107
+ if logger and pos != len(data):
108
+ logger.debug(
109
+ "INVOCATION stream has %d trailing bytes (parsed=%d total=%d).",
110
+ len(data) - pos,
111
+ pos,
112
+ len(data),
113
+ )
114
+
115
+ return frames
116
+
CasambiBt/_network.py CHANGED
@@ -1,9 +1,10 @@
1
1
  import json
2
2
  import logging
3
3
  import pickle
4
+ import random
4
5
  from dataclasses import dataclass
5
6
  from datetime import datetime, timedelta
6
- from typing import Final, cast
7
+ from typing import Any, Final, cast
7
8
 
8
9
  import httpx
9
10
  from httpx import AsyncClient, RequestError
@@ -44,6 +45,10 @@ class Network:
44
45
  self._networkName: str | None = None
45
46
  self._networkRevision: int | None = None
46
47
  self._protocolVersion: int = -1
48
+ # Classic networks do not have a `keyStore`; instead they expose visitor/manager keys.
49
+ # Ground truth: casambi-android `D1.Z0` exports `visitorKey`/`managerKey`.
50
+ self._classicVisitorKey: bytes | None = None
51
+ self._classicManagerKey: bytes | None = None
47
52
  self._rawNetworkData: dict | None = None
48
53
 
49
54
  self._unitTypes: dict[int, tuple[UnitType | None, datetime]] = {}
@@ -60,6 +65,50 @@ class Network:
60
65
 
61
66
  self._cache = cache
62
67
 
68
+ # Android sends "fb:<FCM_token>" — we have no push token, use empty string
69
+ # to match the field type without leaking platform info.
70
+ self._token: str = ""
71
+ # Android: "{flavor}/{version} {manufacturer}_{model}/{os_release}"
72
+ _app_version = random.choice((
73
+ "3.19.0", "3.18.2", "3.18.1", "3.18.0",
74
+ "3.17.4", "3.17.3", "3.17.2", "3.17.1", "3.17.0",
75
+ "3.16.5", "3.16.4", "3.16.3", "3.16.1", "3.16.0",
76
+ "3.15.3", "3.15.2", "3.15.1", "3.15.0",
77
+ "3.14.2", "3.14.1", "3.14.0",
78
+ "3.13.2", "3.13.1", "3.13.0",
79
+ "3.12.4", "3.12.3", "3.12.1", "3.12.0",
80
+ "3.11.2", "3.11.1",
81
+ ))
82
+ _device = random.choice((
83
+ # Samsung Galaxy S series
84
+ "samsung_SM-S928B/15", # S24 Ultra
85
+ "samsung_SM-S926B/15", # S24+
86
+ "samsung_SM-S921B/15", # S24
87
+ "samsung_SM-S918B/14", # S23 Ultra
88
+ "samsung_SM-S916B/14", # S23+
89
+ "samsung_SM-S911B/14", # S23
90
+ "samsung_SM-S908B/14", # S22 Ultra
91
+ "samsung_SM-S906B/14", # S22+
92
+ "samsung_SM-S901B/14", # S22
93
+ "samsung_SM-G998B/13", # S21 Ultra
94
+ "samsung_SM-G996B/13", # S21+
95
+ "samsung_SM-G991B/13", # S21
96
+ # Samsung Galaxy A series
97
+ "samsung_SM-A556B/14", # A55
98
+ "samsung_SM-A546B/14", # A54
99
+ "samsung_SM-A346B/14", # A34
100
+ "samsung_SM-A536B/13", # A53
101
+ # Google Pixel
102
+ "Google_Pixel 8 Pro/14",
103
+ "Google_Pixel 8/14",
104
+ "Google_Pixel 7 Pro/14",
105
+ "Google_Pixel 7/14",
106
+ # OnePlus
107
+ "OnePlus_IN2023/14", # 12
108
+ "OnePlus_CPH2449/14", # 11
109
+ ))
110
+ self._clientInfo: str = f"Casambi/{_app_version} {_device}"
111
+
63
112
  async def load(self) -> None:
64
113
  self._keystore = KeyStore(self._cache)
65
114
  await self._keystore.load()
@@ -146,6 +195,10 @@ class Network:
146
195
  return False
147
196
  return not self._session.expired()
148
197
 
198
+ def isManager(self) -> bool:
199
+ """Whether the current cloud session has manager privileges."""
200
+ return bool(self._session and self._session.manager)
201
+
149
202
  @property
150
203
  def keyStore(self) -> KeyStore:
151
204
  return self._keystore
@@ -154,6 +207,19 @@ class Network:
154
207
  def protocolVersion(self) -> int:
155
208
  return self._protocolVersion
156
209
 
210
+ def classicVisitorKey(self) -> bytes | None:
211
+ return self._classicVisitorKey
212
+
213
+ def classicManagerKey(self) -> bytes | None:
214
+ return self._classicManagerKey
215
+
216
+ def classicBestKey(self) -> bytes | None:
217
+ # Prefer manager key if present, otherwise visitor key.
218
+ return self._classicManagerKey or self._classicVisitorKey
219
+
220
+ def hasClassicKeys(self) -> bool:
221
+ return bool(self._classicVisitorKey or self._classicManagerKey)
222
+
157
223
  @property
158
224
  def rawNetworkData(self) -> dict | None:
159
225
  return self._rawNetworkData
@@ -169,7 +235,12 @@ class Network:
169
235
  getSessionUrl = f"https://api.casambi.com/network/{self._id}/session"
170
236
 
171
237
  res = await self._httpClient.post(
172
- getSessionUrl, json={"password": password, "deviceName": DEVICE_NAME}
238
+ getSessionUrl,
239
+ json={
240
+ "token": self._token,
241
+ "password": password,
242
+ "deviceName": DEVICE_NAME,
243
+ },
173
244
  )
174
245
  if res.status_code == httpx.codes.OK:
175
246
  # Parse session
@@ -210,14 +281,16 @@ class Network:
210
281
  getNetworkUrl = f"https://api.casambi.com/network/{self._id}/"
211
282
 
212
283
  try:
284
+ payload = {
285
+ "formatVersion": 1,
286
+ "deviceName": DEVICE_NAME,
287
+ "revision": self._networkRevision,
288
+ }
289
+
213
290
  # **SECURITY**: Do not set session header for client! This could leak the session with external clients.
214
291
  res = await self._httpClient.put(
215
292
  getNetworkUrl,
216
- json={
217
- "formatVersion": 1,
218
- "deviceName": DEVICE_NAME,
219
- "revision": self._networkRevision,
220
- },
293
+ json=payload,
221
294
  headers={"X-Casambi-Session": self._session.session}, # type: ignore[union-attr]
222
295
  )
223
296
 
@@ -230,23 +303,50 @@ class Network:
230
303
  )
231
304
  await self._cache.invalidateCache()
232
305
 
306
+ if res.status_code == httpx.codes.BAD_REQUEST:
307
+ # Some backend variants may reject the minimal update payload.
308
+ # Retry once with Android-like fields (token/clientInfo) for diagnostics/testing.
309
+ self._logger.warning(
310
+ "[CASAMBI_CLOUD_UPDATE_RETRY] status=400 retry_with_token_clientInfo=true body_prefix=%r",
311
+ (res.text or "")[:200],
312
+ )
313
+ payload2: dict[str, Any] = dict(payload)
314
+ payload2["token"] = self._token
315
+ payload2["clientInfo"] = self._clientInfo
316
+ res = await self._httpClient.put(
317
+ getNetworkUrl,
318
+ json=payload2,
319
+ headers={"X-Casambi-Session": self._session.session}, # type: ignore[union-attr]
320
+ )
321
+
233
322
  if res.status_code != httpx.codes.OK:
234
- self._logger.error(f"Update failed: {res.status_code}\n{res.text}")
235
- raise NetworkUpdateError("Could not update network!")
236
-
237
- self._logger.debug(f"Network: {res.text}")
238
-
239
- updateResult = res.json()
240
- if updateResult["status"] != "UPTODATE":
241
- self._networkRevision = updateResult["network"]["revision"]
242
- self._rawNetworkData = updateResult
243
- async with self._cache as cachePath:
244
- cachedNetworkPah = cachePath / f"{self._id}.json"
245
- await cachedNetworkPah.write_bytes(res.content)
246
- network = updateResult
247
- self._logger.info(
248
- f"Fetched updated network with revision {self._networkRevision}"
323
+ body_prefix = (res.text or "")[:500]
324
+ # If we have cached network data, do not fail setup; continue offline.
325
+ # This is important for HA stability and for "cloud down / API changed" scenarios.
326
+ have_cache = bool(self._networkRevision and self._networkRevision > 0 and self._rawNetworkData)
327
+ self._logger.warning(
328
+ "[CASAMBI_CLOUD_UPDATE_FAILED] status=%s cached_revision=%s continuing_offline=%s body_prefix=%r",
329
+ res.status_code,
330
+ self._networkRevision,
331
+ have_cache,
332
+ body_prefix,
249
333
  )
334
+ if not have_cache:
335
+ raise NetworkUpdateError("Could not update network!")
336
+ else:
337
+ self._logger.debug(f"Network: {res.text}")
338
+
339
+ updateResult = res.json()
340
+ if updateResult["status"] != "UPTODATE":
341
+ self._networkRevision = updateResult["network"]["revision"]
342
+ self._rawNetworkData = updateResult
343
+ async with self._cache as cachePath:
344
+ cachedNetworkPah = cachePath / f"{self._id}.json"
345
+ await cachedNetworkPah.write_bytes(res.content)
346
+ network = updateResult
347
+ self._logger.info(
348
+ f"Fetched updated network with revision {self._networkRevision}"
349
+ )
250
350
  except RequestError as err:
251
351
  if self._networkRevision == 0:
252
352
  raise NetworkUpdateError from err
@@ -263,12 +363,45 @@ class Network:
263
363
  keys = network["network"]["keyStore"]["keys"]
264
364
  for k in keys:
265
365
  await self._keystore.addKey(k)
266
-
267
- # TODO: Parse managerKey and visitorKey for classic networks.
366
+ # Evolution network: classic keys not used
367
+ self._classicVisitorKey = None
368
+ self._classicManagerKey = None
369
+ else:
370
+ # Classic network: parse visitorKey / managerKey (hex strings).
371
+ # Ground truth: casambi-android `D1.Z0` exports these fields.
372
+ visitor_hex = network["network"].get("visitorKey")
373
+ manager_hex = network["network"].get("managerKey")
374
+
375
+ def _parse_hex_key(v: object) -> bytes | None:
376
+ if not isinstance(v, str):
377
+ return None
378
+ v = v.strip()
379
+ if not v:
380
+ return None
381
+ try:
382
+ return bytes.fromhex(v)
383
+ except ValueError:
384
+ return None
385
+
386
+ self._classicVisitorKey = _parse_hex_key(visitor_hex)
387
+ self._classicManagerKey = _parse_hex_key(manager_hex)
388
+ if not (self._classicVisitorKey or self._classicManagerKey):
389
+ # Android still sends Classic frames even when keys are null (signature bytes remain zeros).
390
+ # We need this as a loud hint for testers when Classic control doesn't work yet.
391
+ self._logger.warning(
392
+ "[CASAMBI_CLASSIC_KEYS_MISSING] visitorKey=false managerKey=false"
393
+ )
394
+ else:
395
+ self._logger.info(
396
+ "Classic keys present: visitor=%s manager=%s",
397
+ bool(self._classicVisitorKey),
398
+ bool(self._classicManagerKey),
399
+ )
268
400
 
269
401
  # Parse units
270
402
  self.units = []
271
403
  units = network["network"]["units"]
404
+ units_with_security_key = 0
272
405
  for u in units:
273
406
  uType = await self._fetchUnitInfo(u["type"])
274
407
  if uType is None:
@@ -276,6 +409,23 @@ class Network:
276
409
  "Failed to fetch type for unit %i. Skipping.", u["type"]
277
410
  )
278
411
  continue
412
+
413
+ security_key: bytes | None = None
414
+ sec_hex = u.get("securityKey")
415
+ if isinstance(sec_hex, str):
416
+ sec_hex = sec_hex.strip()
417
+ if sec_hex:
418
+ try:
419
+ security_key = bytes.fromhex(sec_hex)
420
+ except ValueError:
421
+ self._logger.debug(
422
+ "Invalid unit securityKey hex for unit %s (len=%d).",
423
+ u.get("deviceID"),
424
+ len(sec_hex),
425
+ )
426
+ if security_key is not None:
427
+ units_with_security_key += 1
428
+
279
429
  uObj = Unit(
280
430
  u["type"],
281
431
  u["deviceID"],
@@ -284,9 +434,24 @@ class Network:
284
434
  u["name"],
285
435
  str(u["firmware"]),
286
436
  uType,
437
+ securityKey=security_key,
287
438
  )
288
439
  self.units.append(uObj)
289
440
 
441
+ # One compact profile line to help interpret mixed/legacy networks from tester logs.
442
+ # Keep EVO networks at INFO to avoid noisy HA warnings; elevate legacy (<10) to WARNING.
443
+ level = logging.WARNING if self._protocolVersion < 10 else logging.INFO
444
+ self._logger.log(
445
+ level,
446
+ "[CASAMBI_NETWORK_PROFILE] uuid=%s id=%s protocolVersion=%s units=%d units_with_securityKey=%d keyStore=%s",
447
+ self._uuid,
448
+ self._id,
449
+ self._protocolVersion,
450
+ len(self.units),
451
+ units_with_security_key,
452
+ "keyStore" in network["network"],
453
+ )
454
+
290
455
  # Parse cells
291
456
  self.groups = []
292
457
  cells = network["network"]["grid"]["cells"]
CasambiBt/_operation.py CHANGED
@@ -11,6 +11,9 @@ class OpCode(IntEnum):
11
11
  SetWhite = 5
12
12
  SetColor = 7
13
13
  SetSlider = 12
14
+ SetParameter = 26
15
+ AcquireSwitchSession = 42
16
+ ExtPacketSend = 43
14
17
  SetState = 48
15
18
  SetColorXY = 54
16
19
 
@@ -20,11 +23,19 @@ class OperationsContext:
20
23
  self.origin: int = 1
21
24
  self.lifetime: int = 5
22
25
 
23
- def prepareOperation(self, op: OpCode, target: int, payload: bytes) -> bytes:
26
+ def prepareOperation(
27
+ self,
28
+ op: OpCode,
29
+ target: int,
30
+ payload: bytes,
31
+ *,
32
+ lifetime: int | None = None,
33
+ ) -> bytes:
24
34
  if len(payload) > 63:
25
35
  raise ValueError("Payload too long")
26
36
 
27
- flags = (self.lifetime & 15) << 11 | len(payload)
37
+ lt = self.lifetime if lifetime is None else int(lifetime)
38
+ flags = (lt & 15) << 11 | len(payload)
28
39
 
29
40
  # Ensure that origin can't overflow.
30
41
  # TODO: Check that unsigned is actually correct here.