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/_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,11 @@ 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
52
+ self._rawNetworkData: dict | None = None
47
53
 
48
54
  self._unitTypes: dict[int, tuple[UnitType | None, datetime]] = {}
49
55
  self.units: list[Unit] = []
@@ -59,6 +65,50 @@ class Network:
59
65
 
60
66
  self._cache = cache
61
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
+
62
112
  async def load(self) -> None:
63
113
  self._keystore = KeyStore(self._cache)
64
114
  await self._keystore.load()
@@ -145,6 +195,10 @@ class Network:
145
195
  return False
146
196
  return not self._session.expired()
147
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
+
148
202
  @property
149
203
  def keyStore(self) -> KeyStore:
150
204
  return self._keystore
@@ -153,6 +207,23 @@ class Network:
153
207
  def protocolVersion(self) -> int:
154
208
  return self._protocolVersion
155
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
+
223
+ @property
224
+ def rawNetworkData(self) -> dict | None:
225
+ return self._rawNetworkData
226
+
156
227
  async def logIn(self, password: str, forceOffline: bool = False) -> None:
157
228
  await self.getNetworkId(forceOffline)
158
229
 
@@ -164,7 +235,12 @@ class Network:
164
235
  getSessionUrl = f"https://api.casambi.com/network/{self._id}/session"
165
236
 
166
237
  res = await self._httpClient.post(
167
- getSessionUrl, json={"password": password, "deviceName": DEVICE_NAME}
238
+ getSessionUrl,
239
+ json={
240
+ "token": self._token,
241
+ "password": password,
242
+ "deviceName": DEVICE_NAME,
243
+ },
168
244
  )
169
245
  if res.status_code == httpx.codes.OK:
170
246
  # Parse session
@@ -191,6 +267,7 @@ class Network:
191
267
  cachedNetworkPah = cachePath / f"{self._id}.json"
192
268
  if await cachedNetworkPah.exists():
193
269
  network = json.loads(await cachedNetworkPah.read_bytes())
270
+ self._rawNetworkData = network
194
271
  self._networkRevision = network["network"]["revision"]
195
272
  self._logger.info(
196
273
  f"Loaded cached network. Revision: {self._networkRevision}"
@@ -204,14 +281,16 @@ class Network:
204
281
  getNetworkUrl = f"https://api.casambi.com/network/{self._id}/"
205
282
 
206
283
  try:
284
+ payload = {
285
+ "formatVersion": 1,
286
+ "deviceName": DEVICE_NAME,
287
+ "revision": self._networkRevision,
288
+ }
289
+
207
290
  # **SECURITY**: Do not set session header for client! This could leak the session with external clients.
208
291
  res = await self._httpClient.put(
209
292
  getNetworkUrl,
210
- json={
211
- "formatVersion": 1,
212
- "deviceName": DEVICE_NAME,
213
- "revision": self._networkRevision,
214
- },
293
+ json=payload,
215
294
  headers={"X-Casambi-Session": self._session.session}, # type: ignore[union-attr]
216
295
  )
217
296
 
@@ -224,22 +303,50 @@ class Network:
224
303
  )
225
304
  await self._cache.invalidateCache()
226
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
+
227
322
  if res.status_code != httpx.codes.OK:
228
- self._logger.error(f"Update failed: {res.status_code}\n{res.text}")
229
- raise NetworkUpdateError("Could not update network!")
230
-
231
- self._logger.debug(f"Network: {res.text}")
232
-
233
- updateResult = res.json()
234
- if updateResult["status"] != "UPTODATE":
235
- self._networkRevision = updateResult["network"]["revision"]
236
- async with self._cache as cachePath:
237
- cachedNetworkPah = cachePath / f"{self._id}.json"
238
- await cachedNetworkPah.write_bytes(res.content)
239
- network = updateResult
240
- self._logger.info(
241
- 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,
242
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
+ )
243
350
  except RequestError as err:
244
351
  if self._networkRevision == 0:
245
352
  raise NetworkUpdateError from err
@@ -256,12 +363,45 @@ class Network:
256
363
  keys = network["network"]["keyStore"]["keys"]
257
364
  for k in keys:
258
365
  await self._keystore.addKey(k)
259
-
260
- # 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
+ )
261
400
 
262
401
  # Parse units
263
402
  self.units = []
264
403
  units = network["network"]["units"]
404
+ units_with_security_key = 0
265
405
  for u in units:
266
406
  uType = await self._fetchUnitInfo(u["type"])
267
407
  if uType is None:
@@ -269,6 +409,23 @@ class Network:
269
409
  "Failed to fetch type for unit %i. Skipping.", u["type"]
270
410
  )
271
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
+
272
429
  uObj = Unit(
273
430
  u["type"],
274
431
  u["deviceID"],
@@ -277,9 +434,24 @@ class Network:
277
434
  u["name"],
278
435
  str(u["firmware"]),
279
436
  uType,
437
+ securityKey=security_key,
280
438
  )
281
439
  self.units.append(uObj)
282
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
+
283
455
  # Parse cells
284
456
  self.groups = []
285
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.