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/_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,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,
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|