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/__init__.py +1 -0
- CasambiBt/_cache.py +9 -9
- CasambiBt/_casambi.py +411 -11
- CasambiBt/_classic_crypto.py +146 -0
- CasambiBt/_client.py +1915 -159
- CasambiBt/_constants.py +16 -0
- CasambiBt/_discover.py +3 -2
- CasambiBt/_invocation.py +116 -0
- CasambiBt/_network.py +189 -24
- 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.dev9.dist-info → casambi_bt_revamped-0.3.12.dev15.dist-info}/WHEEL +1 -1
- casambi_bt_revamped-0.3.7.dev9.dist-info/METADATA +0 -81
- casambi_bt_revamped-0.3.7.dev9.dist-info/RECORD +0 -18
- {casambi_bt_revamped-0.3.7.dev9.dist-info → casambi_bt_revamped-0.3.12.dev15.dist-info}/licenses/LICENSE +0 -0
- {casambi_bt_revamped-0.3.7.dev9.dist-info → casambi_bt_revamped-0.3.12.dev15.dist-info}/top_level.txt +0 -0
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
|
-
|
|
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
|
|
CasambiBt/_invocation.py
ADDED
|
@@ -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,
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|