casambi-bt-revamped 0.3.12.dev8__py3-none-any.whl → 0.3.12.dev10__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/_casambi.py +26 -9
- CasambiBt/_classic_crypto.py +123 -8
- CasambiBt/_client.py +339 -17
- CasambiBt/_constants.py +9 -0
- CasambiBt/_network.py +26 -19
- CasambiBt/_version.py +1 -1
- {casambi_bt_revamped-0.3.12.dev8.dist-info → casambi_bt_revamped-0.3.12.dev10.dist-info}/METADATA +1 -1
- casambi_bt_revamped-0.3.12.dev10.dist-info/RECORD +22 -0
- casambi_bt_revamped-0.3.12.dev8.dist-info/RECORD +0 -22
- {casambi_bt_revamped-0.3.12.dev8.dist-info → casambi_bt_revamped-0.3.12.dev10.dist-info}/WHEEL +0 -0
- {casambi_bt_revamped-0.3.12.dev8.dist-info → casambi_bt_revamped-0.3.12.dev10.dist-info}/licenses/LICENSE +0 -0
- {casambi_bt_revamped-0.3.12.dev8.dist-info → casambi_bt_revamped-0.3.12.dev10.dist-info}/top_level.txt +0 -0
CasambiBt/_casambi.py
CHANGED
|
@@ -206,16 +206,33 @@ class Casambi:
|
|
|
206
206
|
|
|
207
207
|
# Classic protocol uses signed command frames (u1.C1753e / u1.EnumC1754f).
|
|
208
208
|
if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
|
|
209
|
-
#
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
209
|
+
# Check if we should use the alternative simple format from BLE captures
|
|
210
|
+
import os
|
|
211
|
+
use_simple = os.environ.get("CASAMBI_BT_CLASSIC_FORMAT", "").lower() == "simple"
|
|
212
|
+
|
|
213
|
+
if use_simple:
|
|
214
|
+
# Simple format: [counter][unit_id][param_len][dimmer]
|
|
215
|
+
# For "all units", use unit_id=0xFF
|
|
216
|
+
if isinstance(target, Unit):
|
|
217
|
+
cmd = self._casaClient.buildClassicCommandSimple(target.deviceId, level)
|
|
218
|
+
elif isinstance(target, Group):
|
|
219
|
+
# Groups in simple format: not fully confirmed, try group ID
|
|
220
|
+
cmd = self._casaClient.buildClassicCommandSimple(target.groudId, level)
|
|
221
|
+
elif target is None:
|
|
222
|
+
cmd = self._casaClient.buildClassicCommandSimple(0xFF, level)
|
|
223
|
+
else:
|
|
224
|
+
raise TypeError(f"Unkown target type {type(target)}")
|
|
217
225
|
else:
|
|
218
|
-
|
|
226
|
+
# EnumC1754f ordinals (ground truth: casambi-android u1.EnumC1754f):
|
|
227
|
+
# - AllUnitsLevel=4, UnitLevel=7, GroupLevel=26
|
|
228
|
+
if isinstance(target, Unit):
|
|
229
|
+
cmd = self._casaClient.buildClassicCommand(7, payload, target_id=target.deviceId)
|
|
230
|
+
elif isinstance(target, Group):
|
|
231
|
+
cmd = self._casaClient.buildClassicCommand(26, payload, target_id=target.groudId)
|
|
232
|
+
elif target is None:
|
|
233
|
+
cmd = self._casaClient.buildClassicCommand(4, payload)
|
|
234
|
+
else:
|
|
235
|
+
raise TypeError(f"Unkown target type {type(target)}")
|
|
219
236
|
|
|
220
237
|
await self._casaClient.send(cmd)
|
|
221
238
|
return
|
CasambiBt/_classic_crypto.py
CHANGED
|
@@ -4,28 +4,143 @@ Ground truth:
|
|
|
4
4
|
- casambi-android `t1.P.o(...)` calculates a CMAC over:
|
|
5
5
|
connection_hash[0:8] + payload
|
|
6
6
|
and stores the CMAC (prefix) into the packet header.
|
|
7
|
+
|
|
8
|
+
The CMAC input structure from t1.P.o():
|
|
9
|
+
kVar.i(rVar.f16699L, 0, 8); // connection_hash[0:8]
|
|
10
|
+
kVar.i(b(), this.f16551t + i9, (c() - i9) - this.f16551t); // payload starting at seq offset
|
|
11
|
+
|
|
12
|
+
For "conformant" classic (rVar.Z=true):
|
|
13
|
+
CMAC input = conn_hash[0:8] || seq(2 bytes BE) || command_bytes
|
|
14
|
+
|
|
15
|
+
For "legacy" classic (rVar.Z=false):
|
|
16
|
+
CMAC input = conn_hash[0:8] || command_bytes
|
|
17
|
+
|
|
18
|
+
Test vectors based on community BLE captures (GitHub lkempf/casambi-bt#17):
|
|
19
|
+
|
|
20
|
+
Capture from sMauldaeschle:
|
|
21
|
+
Raw packet: 0215db43c40004040200b3
|
|
22
|
+
- Byte 0: 02 (auth level = visitor)
|
|
23
|
+
- Bytes 1-4: 15db43c4 (4-byte CMAC signature)
|
|
24
|
+
- Byte 5: 00 (padding/marker)
|
|
25
|
+
- Byte 6: 04 (counter/sequence)
|
|
26
|
+
- Byte 7: 04 (unit id)
|
|
27
|
+
- Byte 8: 02 (length of parameters)
|
|
28
|
+
- Byte 9: 00 (dimmer value)
|
|
29
|
+
- Byte 10: b3 (temperature/vertical value)
|
|
30
|
+
|
|
31
|
+
Capture from FliegenKLATSCH:
|
|
32
|
+
Raw packet: 0200 43A2 9600 0203 0254 FF
|
|
33
|
+
- Bytes 0-1: 0200 (auth 02, then...)
|
|
34
|
+
- Bytes 2-5: 43A2 9600 (first 4 bytes of CMAC)
|
|
35
|
+
- The CMAC input was: <8 bytes connection hash> 00 0203 0254 FF
|
|
7
36
|
"""
|
|
8
37
|
|
|
9
38
|
from __future__ import annotations
|
|
10
39
|
|
|
40
|
+
import logging
|
|
41
|
+
from typing import TYPE_CHECKING
|
|
42
|
+
|
|
11
43
|
from cryptography.hazmat.primitives.cmac import CMAC
|
|
12
44
|
from cryptography.hazmat.primitives.ciphers.algorithms import AES
|
|
13
45
|
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
_logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
14
51
|
|
|
15
|
-
def classic_cmac(key: bytes, conn_hash8: bytes, payload: bytes) -> bytes:
|
|
16
|
-
"""Compute the Classic CMAC (16 bytes) over connection hash + payload.
|
|
52
|
+
def classic_cmac(key: bytes, conn_hash8: bytes, payload: bytes, *, debug: bool = False) -> bytes:
|
|
53
|
+
"""Compute the Classic CMAC (16 bytes) over connection hash + payload.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
key: 16-byte AES key (visitor or manager key from cloud)
|
|
57
|
+
conn_hash8: First 8 bytes of the connection hash read from CA51/auth char
|
|
58
|
+
payload: The data to sign (for conformant: seq + command; for legacy: command only)
|
|
59
|
+
debug: If True, log CMAC input/output for debugging
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
16-byte CMAC
|
|
63
|
+
"""
|
|
17
64
|
if len(conn_hash8) != 8:
|
|
18
65
|
raise ValueError("conn_hash8 must be 8 bytes")
|
|
66
|
+
|
|
67
|
+
cmac_input = conn_hash8 + payload
|
|
19
68
|
cmac = CMAC(AES(key))
|
|
20
|
-
cmac.update(
|
|
21
|
-
cmac.
|
|
22
|
-
|
|
69
|
+
cmac.update(cmac_input)
|
|
70
|
+
result = cmac.finalize()
|
|
71
|
+
|
|
72
|
+
if debug:
|
|
73
|
+
_logger.warning(
|
|
74
|
+
"[CLASSIC_CMAC_DEBUG] key_len=%d conn_hash8=%s payload_len=%d payload_prefix=%s cmac=%s",
|
|
75
|
+
len(key),
|
|
76
|
+
conn_hash8.hex(),
|
|
77
|
+
len(payload),
|
|
78
|
+
payload[:16].hex() if len(payload) > 16 else payload.hex(),
|
|
79
|
+
result.hex(),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return result
|
|
23
83
|
|
|
24
84
|
|
|
25
85
|
def classic_cmac_prefix(
|
|
26
|
-
key: bytes, conn_hash8: bytes, payload: bytes, prefix_len: int
|
|
86
|
+
key: bytes, conn_hash8: bytes, payload: bytes, prefix_len: int, *, debug: bool = False
|
|
27
87
|
) -> bytes:
|
|
28
|
-
"""Return the prefix bytes that are embedded into the Classic packet header.
|
|
29
|
-
|
|
88
|
+
"""Return the prefix bytes that are embedded into the Classic packet header.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
key: 16-byte AES key
|
|
92
|
+
conn_hash8: First 8 bytes of connection hash
|
|
93
|
+
payload: Data to sign
|
|
94
|
+
prefix_len: Number of CMAC bytes to use (4 for visitor, 16 for manager)
|
|
95
|
+
debug: If True, log CMAC computation
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
First prefix_len bytes of the CMAC
|
|
99
|
+
"""
|
|
100
|
+
mac = classic_cmac(key, conn_hash8, payload, debug=debug)
|
|
30
101
|
return mac[:prefix_len]
|
|
31
102
|
|
|
103
|
+
|
|
104
|
+
def verify_classic_cmac(
|
|
105
|
+
key: bytes, conn_hash8: bytes, payload: bytes, expected_prefix: bytes
|
|
106
|
+
) -> bool:
|
|
107
|
+
"""Verify a Classic CMAC signature prefix.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
key: 16-byte AES key
|
|
111
|
+
conn_hash8: First 8 bytes of connection hash
|
|
112
|
+
payload: Data that was signed
|
|
113
|
+
expected_prefix: The CMAC prefix from the packet header
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
True if the computed CMAC prefix matches expected_prefix
|
|
117
|
+
"""
|
|
118
|
+
computed = classic_cmac_prefix(key, conn_hash8, payload, len(expected_prefix))
|
|
119
|
+
return computed == expected_prefix
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# Test vectors from RFC 4493 (AES-CMAC) - used in test_classic_protocol.py
|
|
123
|
+
# These confirm our CMAC implementation is correct.
|
|
124
|
+
RFC4493_TEST_KEY = bytes.fromhex("2b7e151628aed2a6abf7158809cf4f3c")
|
|
125
|
+
RFC4493_VECTORS = [
|
|
126
|
+
# (message, expected_cmac)
|
|
127
|
+
(bytes.fromhex("6bc1bee22e409f96e93d7e117393172a"),
|
|
128
|
+
bytes.fromhex("070a16b46b4d4144f79bdd9dd04a287c")),
|
|
129
|
+
(bytes.fromhex("6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e51"),
|
|
130
|
+
bytes.fromhex("ce0cbf1738f4df6428b1d93bf12081c9")),
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
# Placeholder for real Casambi test vectors once we have them from captures
|
|
134
|
+
# Format: (visitor_key, conn_hash8, payload_after_header, expected_cmac_prefix_4, description)
|
|
135
|
+
CASAMBI_CLASSIC_TEST_VECTORS: list[tuple[bytes, bytes, bytes, bytes, str]] = [
|
|
136
|
+
# These will be populated from real BLE captures
|
|
137
|
+
# Example structure (not verified yet):
|
|
138
|
+
# (
|
|
139
|
+
# bytes.fromhex("...16 byte key..."),
|
|
140
|
+
# bytes.fromhex("...8 byte conn hash..."),
|
|
141
|
+
# bytes.fromhex("...payload after auth byte..."),
|
|
142
|
+
# bytes.fromhex("...4 byte expected cmac..."),
|
|
143
|
+
# "Description of what this packet does"
|
|
144
|
+
# ),
|
|
145
|
+
]
|
|
146
|
+
|
CasambiBt/_client.py
CHANGED
|
@@ -25,7 +25,13 @@ from cryptography.exceptions import InvalidSignature
|
|
|
25
25
|
from cryptography.hazmat.primitives.asymmetric import ec
|
|
26
26
|
|
|
27
27
|
from ._constants import CASA_AUTH_CHAR_UUID, ConnectionState
|
|
28
|
-
from ._constants import
|
|
28
|
+
from ._constants import (
|
|
29
|
+
CASA_CLASSIC_CA53_CHAR_UUID,
|
|
30
|
+
CASA_CLASSIC_CONFORMANT_CA51_CHAR_UUID,
|
|
31
|
+
CASA_CLASSIC_CONFORMANT_CA53_CHAR_UUID,
|
|
32
|
+
CASA_CLASSIC_DATA_CHAR_UUID,
|
|
33
|
+
CASA_CLASSIC_HASH_CHAR_UUID,
|
|
34
|
+
)
|
|
29
35
|
from ._classic_crypto import classic_cmac_prefix
|
|
30
36
|
from ._encryption import Encryptor
|
|
31
37
|
from ._network import Network
|
|
@@ -135,6 +141,12 @@ class CasambiClient:
|
|
|
135
141
|
# - "legacy": [sig][payload]
|
|
136
142
|
# Ground truth: casambi-android `t1.P.n(...)` and `t1.P.o(...)`.
|
|
137
143
|
self._classicHeaderMode: str | None = None # "conformant" | "legacy"
|
|
144
|
+
# Classic transport diagnostics / channel selection.
|
|
145
|
+
self._classicTxCharUuid: str | None = None
|
|
146
|
+
self._classicNotifyCharUuids: set[str] = set()
|
|
147
|
+
self._classicHashSource: str | None = None # "ca51" | "ca52_0001" | None
|
|
148
|
+
self._classicFirstRxTs: float | None = None
|
|
149
|
+
self._classicNoRxTask: asyncio.Task[None] | None = None
|
|
138
150
|
|
|
139
151
|
# Rate limit WARNING logs (especially Classic RX) to keep HA usable.
|
|
140
152
|
self._logLimiter = _LogBurstLimiter()
|
|
@@ -151,6 +163,11 @@ class CasambiClient:
|
|
|
151
163
|
self._classicRxKindSamples: dict[str, int] = {}
|
|
152
164
|
self._classicRxLastStatsTs = time.monotonic()
|
|
153
165
|
|
|
166
|
+
# Classic diagnostic packet history (for dump_classic_diagnostics service)
|
|
167
|
+
self._classicTxHistory: list[dict[str, Any]] = []
|
|
168
|
+
self._classicRxHistory: list[dict[str, Any]] = []
|
|
169
|
+
self._classicDiagMaxHistory = 50 # Keep last 50 TX and RX packets
|
|
170
|
+
|
|
154
171
|
@property
|
|
155
172
|
def protocolMode(self) -> ProtocolMode | None:
|
|
156
173
|
return self._protocolMode
|
|
@@ -186,6 +203,23 @@ class CasambiClient:
|
|
|
186
203
|
self._outPacketCount = 2
|
|
187
204
|
self._inPacketCount = 1
|
|
188
205
|
|
|
206
|
+
# Reset protocol-specific state (important for reconnects).
|
|
207
|
+
self._protocolMode = None
|
|
208
|
+
self._dataCharUuid = None
|
|
209
|
+
self._deviceProtocolVersion = None
|
|
210
|
+
|
|
211
|
+
self._classicConnHash8 = None
|
|
212
|
+
self._classicTxSeq = 0
|
|
213
|
+
self._classicCmdDiv = 0
|
|
214
|
+
self._classicHeaderMode = None
|
|
215
|
+
self._classicTxCharUuid = None
|
|
216
|
+
self._classicNotifyCharUuids.clear()
|
|
217
|
+
self._classicHashSource = None
|
|
218
|
+
self._classicFirstRxTs = None
|
|
219
|
+
if self._classicNoRxTask is not None:
|
|
220
|
+
self._classicNoRxTask.cancel()
|
|
221
|
+
self._classicNoRxTask = None
|
|
222
|
+
|
|
189
223
|
# Reset callback queue
|
|
190
224
|
self._callbackQueue = asyncio.Queue()
|
|
191
225
|
self._callbackTask = asyncio.create_task(self._processCallbacks())
|
|
@@ -262,28 +296,43 @@ class CasambiClient:
|
|
|
262
296
|
cloud_protocol = getattr(self._network, "protocolVersion", None)
|
|
263
297
|
ca51_prefix: bytes | None = None
|
|
264
298
|
ca51_err: str | None = None
|
|
299
|
+
ca52_notify_err: str | None = None
|
|
300
|
+
ca53_notify_err: str | None = None
|
|
265
301
|
auth_prefix: bytes | None = None
|
|
266
302
|
auth_err: str | None = None
|
|
303
|
+
c0002_prefix: bytes | None = None
|
|
304
|
+
c0002_err: str | None = None
|
|
305
|
+
c0003_notify_err: str | None = None
|
|
267
306
|
device_nodeinfo_protocol: int | None = None
|
|
268
307
|
|
|
269
308
|
def _log_probe_summary(mode: str, *, classic_variant: str | None = None) -> None:
|
|
270
309
|
# One stable, high-signal line for testers.
|
|
271
310
|
self._logger.warning(
|
|
272
311
|
"[CASAMBI_PROTOCOL_PROBE] address=%s mode=%s cloud_protocol=%s nodeinfo_b1=%s data_uuid=%s "
|
|
273
|
-
"classic_variant=%s
|
|
274
|
-
"
|
|
312
|
+
"classic_variant=%s hash_source=%s classic_tx_uuid=%s classic_notify_uuids=%s "
|
|
313
|
+
"ca51_hash8_present=%s conn_hash8_ready=%s "
|
|
314
|
+
"auth_read_prefix=%s ca51_read_prefix=%s ca51_read_error=%s auth_read_error=%s "
|
|
315
|
+
"ca52_notify_error=%s ca53_notify_error=%s c0002_read_prefix=%s c0002_read_error=%s c0003_notify_error=%s",
|
|
275
316
|
self.address,
|
|
276
317
|
mode,
|
|
277
318
|
cloud_protocol,
|
|
278
319
|
device_nodeinfo_protocol,
|
|
279
320
|
self._dataCharUuid,
|
|
280
321
|
classic_variant,
|
|
322
|
+
self._classicHashSource,
|
|
323
|
+
self._classicTxCharUuid,
|
|
324
|
+
sorted(self._classicNotifyCharUuids) if self._classicNotifyCharUuids else None,
|
|
281
325
|
bool(classic_hash and len(classic_hash) >= 8),
|
|
282
326
|
self._classicConnHash8 is not None,
|
|
283
327
|
auth_prefix,
|
|
284
328
|
ca51_prefix,
|
|
285
329
|
ca51_err,
|
|
286
330
|
auth_err,
|
|
331
|
+
ca52_notify_err,
|
|
332
|
+
ca53_notify_err,
|
|
333
|
+
c0002_prefix,
|
|
334
|
+
c0002_err,
|
|
335
|
+
c0003_notify_err,
|
|
287
336
|
)
|
|
288
337
|
|
|
289
338
|
classic_hash: bytes | None = None
|
|
@@ -305,7 +354,9 @@ class CasambiClient:
|
|
|
305
354
|
if classic_hash and len(classic_hash) >= 8:
|
|
306
355
|
self._protocolMode = ProtocolMode.CLASSIC
|
|
307
356
|
self._dataCharUuid = CASA_CLASSIC_DATA_CHAR_UUID
|
|
357
|
+
self._classicTxCharUuid = CASA_CLASSIC_DATA_CHAR_UUID
|
|
308
358
|
self._classicHeaderMode = "legacy"
|
|
359
|
+
self._classicHashSource = "ca51"
|
|
309
360
|
|
|
310
361
|
# Read connection hash (first 8 bytes are used for CMAC signing).
|
|
311
362
|
raw_hash = classic_hash
|
|
@@ -330,6 +381,7 @@ class CasambiClient:
|
|
|
330
381
|
**notify_kwargs,
|
|
331
382
|
)
|
|
332
383
|
except Exception as e:
|
|
384
|
+
ca52_notify_err = type(e).__name__
|
|
333
385
|
# Some firmwares may expose Classic signing on the EVO UUID instead.
|
|
334
386
|
# Fall through to auth-char probing if CA52 isn't available.
|
|
335
387
|
if self._logger.isEnabledFor(logging.DEBUG):
|
|
@@ -341,8 +393,25 @@ class CasambiClient:
|
|
|
341
393
|
self._protocolMode = None
|
|
342
394
|
self._dataCharUuid = None
|
|
343
395
|
self._classicConnHash8 = None
|
|
396
|
+
self._classicTxCharUuid = None
|
|
397
|
+
self._classicNotifyCharUuids.clear()
|
|
398
|
+
self._classicHeaderMode = None
|
|
399
|
+
self._classicHashSource = None
|
|
344
400
|
# continue detection below
|
|
345
401
|
else:
|
|
402
|
+
self._classicNotifyCharUuids.add(CASA_CLASSIC_DATA_CHAR_UUID.lower())
|
|
403
|
+
# Some Classic firmwares also expose state/config notifications on CA53.
|
|
404
|
+
try:
|
|
405
|
+
await self._gattClient.start_notify(
|
|
406
|
+
CASA_CLASSIC_CA53_CHAR_UUID,
|
|
407
|
+
self._queueCallback,
|
|
408
|
+
**notify_kwargs,
|
|
409
|
+
)
|
|
410
|
+
except Exception as e:
|
|
411
|
+
ca53_notify_err = type(e).__name__
|
|
412
|
+
else:
|
|
413
|
+
self._classicNotifyCharUuids.add(CASA_CLASSIC_CA53_CHAR_UUID.lower())
|
|
414
|
+
|
|
346
415
|
# Classic has no EVO-style key exchange/auth; we can send immediately.
|
|
347
416
|
self._connectionState = ConnectionState.AUTHENTICATED
|
|
348
417
|
self._logger.info("Protocol mode selected: CLASSIC")
|
|
@@ -354,10 +423,11 @@ class CasambiClient:
|
|
|
354
423
|
b2a(self._classicConnHash8),
|
|
355
424
|
)
|
|
356
425
|
self._logger.warning(
|
|
357
|
-
"[CASAMBI_CLASSIC_SELECTED] address=%s variant=ca52_legacy data_uuid=%s
|
|
426
|
+
"[CASAMBI_CLASSIC_SELECTED] address=%s variant=ca52_legacy data_uuid=%s tx_uuid=%s notify_uuids=%s header_mode=%s conn_hash8_prefix=%s",
|
|
358
427
|
self.address,
|
|
359
428
|
self._dataCharUuid,
|
|
360
|
-
|
|
429
|
+
self._classicTxCharUuid,
|
|
430
|
+
sorted(self._classicNotifyCharUuids) if self._classicNotifyCharUuids else None,
|
|
361
431
|
self._classicHeaderMode,
|
|
362
432
|
b2a(self._classicConnHash8),
|
|
363
433
|
)
|
|
@@ -368,6 +438,8 @@ class CasambiClient:
|
|
|
368
438
|
getattr(self._network, "isManager", lambda: False)(),
|
|
369
439
|
)
|
|
370
440
|
_log_probe_summary("CLASSIC", classic_variant="ca52_legacy")
|
|
441
|
+
# Emit a warning if we never see Classic RX frames; this is a common failure mode.
|
|
442
|
+
self._classicNoRxTask = asyncio.create_task(self._classic_no_rx_watchdog(30.0))
|
|
371
443
|
return
|
|
372
444
|
|
|
373
445
|
# Conformant devices can expose the Classic signed channel on the EVO-style UUID too.
|
|
@@ -438,34 +510,79 @@ class CasambiClient:
|
|
|
438
510
|
|
|
439
511
|
self._protocolMode = ProtocolMode.CLASSIC
|
|
440
512
|
self._dataCharUuid = CASA_AUTH_CHAR_UUID
|
|
513
|
+
self._classicTxCharUuid = CASA_AUTH_CHAR_UUID
|
|
441
514
|
self._classicHeaderMode = "conformant"
|
|
515
|
+
self._classicHashSource = "ca52_0001"
|
|
442
516
|
self._classicConnHash8 = bytes(first[:8])
|
|
443
517
|
self._classicCmdDiv = int.from_bytes(os.urandom(1), "big") or 1
|
|
444
518
|
self._classicTxSeq = 0
|
|
445
519
|
|
|
520
|
+
# Probe mapped Classic CA51 (0002) for diagnostics; some firmwares use it for time/config.
|
|
521
|
+
try:
|
|
522
|
+
v = await self._gattClient.read_gatt_char(CASA_CLASSIC_CONFORMANT_CA51_CHAR_UUID)
|
|
523
|
+
c0002_prefix = b2a(v[:10]) if v else None
|
|
524
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
525
|
+
self._logger.debug(
|
|
526
|
+
"[CASAMBI_GATT_PROBE] read classic-0002 ok len=%d prefix=%s",
|
|
527
|
+
0 if v is None else len(v),
|
|
528
|
+
c0002_prefix,
|
|
529
|
+
)
|
|
530
|
+
except Exception as e:
|
|
531
|
+
c0002_err = type(e).__name__
|
|
532
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
533
|
+
self._logger.debug(
|
|
534
|
+
"[CASAMBI_GATT_PROBE] read classic-0002 fail err=%s",
|
|
535
|
+
c0002_err,
|
|
536
|
+
)
|
|
537
|
+
|
|
446
538
|
notify_kwargs: dict[str, Any] = {}
|
|
447
539
|
notify_params = inspect.signature(self._gattClient.start_notify).parameters
|
|
448
540
|
if "bluez" in notify_params:
|
|
449
541
|
notify_kwargs["bluez"] = {"use_start_notify": True}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
542
|
+
try:
|
|
543
|
+
await self._gattClient.start_notify(
|
|
544
|
+
CASA_AUTH_CHAR_UUID,
|
|
545
|
+
self._queueCallback,
|
|
546
|
+
**notify_kwargs,
|
|
547
|
+
)
|
|
548
|
+
except Exception as e:
|
|
549
|
+
ca52_notify_err = type(e).__name__
|
|
550
|
+
else:
|
|
551
|
+
self._classicNotifyCharUuids.add(CASA_AUTH_CHAR_UUID.lower())
|
|
552
|
+
|
|
553
|
+
# Probe mapped Classic CA53 (0003) notify: some firmwares may emit state/config here.
|
|
554
|
+
try:
|
|
555
|
+
await self._gattClient.start_notify(
|
|
556
|
+
CASA_CLASSIC_CONFORMANT_CA53_CHAR_UUID,
|
|
557
|
+
self._queueCallback,
|
|
558
|
+
**notify_kwargs,
|
|
559
|
+
)
|
|
560
|
+
except Exception as e:
|
|
561
|
+
c0003_notify_err = type(e).__name__
|
|
562
|
+
else:
|
|
563
|
+
self._classicNotifyCharUuids.add(CASA_CLASSIC_CONFORMANT_CA53_CHAR_UUID.lower())
|
|
564
|
+
|
|
455
565
|
self._connectionState = ConnectionState.AUTHENTICATED
|
|
456
566
|
self._logger.info("Protocol mode selected: CLASSIC")
|
|
457
567
|
if self._logger.isEnabledFor(logging.DEBUG):
|
|
458
|
-
|
|
568
|
+
if ca52_notify_err is None:
|
|
569
|
+
self._logger.debug("[CASAMBI_GATT_PROBE] start_notify auth ok (classic conformant)")
|
|
570
|
+
else:
|
|
571
|
+
self._logger.debug(
|
|
572
|
+
"[CASAMBI_GATT_PROBE] start_notify auth fail err=%s (classic conformant)",
|
|
573
|
+
ca52_notify_err,
|
|
574
|
+
)
|
|
459
575
|
self._logger.debug(
|
|
460
576
|
"[CASAMBI_CLASSIC_CONN_HASH] len=%d hash=%s",
|
|
461
577
|
len(self._classicConnHash8),
|
|
462
578
|
b2a(self._classicConnHash8),
|
|
463
579
|
)
|
|
464
580
|
self._logger.warning(
|
|
465
|
-
"[CASAMBI_CLASSIC_SELECTED] address=%s variant=auth_uuid_conformant data_uuid=%s
|
|
581
|
+
"[CASAMBI_CLASSIC_SELECTED] address=%s variant=auth_uuid_conformant data_uuid=%s tx_uuid=%s notify_uuids=%s header_mode=%s conn_hash8_prefix=%s",
|
|
466
582
|
self.address,
|
|
467
583
|
self._dataCharUuid,
|
|
468
|
-
|
|
584
|
+
self._classicTxCharUuid,
|
|
585
|
+
sorted(self._classicNotifyCharUuids) if self._classicNotifyCharUuids else None,
|
|
469
586
|
self._classicHeaderMode,
|
|
470
587
|
b2a(self._classicConnHash8),
|
|
471
588
|
)
|
|
@@ -476,6 +593,7 @@ class CasambiClient:
|
|
|
476
593
|
getattr(self._network, "isManager", lambda: False)(),
|
|
477
594
|
)
|
|
478
595
|
_log_probe_summary("CLASSIC", classic_variant="auth_uuid_conformant")
|
|
596
|
+
self._classicNoRxTask = asyncio.create_task(self._classic_no_rx_watchdog(30.0))
|
|
479
597
|
return
|
|
480
598
|
|
|
481
599
|
_log_probe_summary("UNKNOWN")
|
|
@@ -483,12 +601,45 @@ class CasambiClient:
|
|
|
483
601
|
"No supported Casambi characteristics found (Classic ca51/ca52 or EVO/Classic-conformant auth char)."
|
|
484
602
|
)
|
|
485
603
|
|
|
604
|
+
async def _classic_no_rx_watchdog(self, after_s: float) -> None:
|
|
605
|
+
"""Emit one high-signal log if Classic RX stays silent after connect.
|
|
606
|
+
|
|
607
|
+
This helps testers capture actionable logs when Classic control/updates don't work yet.
|
|
608
|
+
"""
|
|
609
|
+
try:
|
|
610
|
+
await asyncio.sleep(after_s)
|
|
611
|
+
if self._protocolMode != ProtocolMode.CLASSIC:
|
|
612
|
+
return
|
|
613
|
+
if self._classicFirstRxTs is not None:
|
|
614
|
+
return
|
|
615
|
+
|
|
616
|
+
self._logger.warning(
|
|
617
|
+
"[CASAMBI_CLASSIC_NO_RX] after_s=%s notify_uuids=%s tx_uuid=%s header_mode=%s "
|
|
618
|
+
"conn_hash8_prefix=%s visitor=%s manager=%s cloud_session_is_manager=%s",
|
|
619
|
+
after_s,
|
|
620
|
+
sorted(self._classicNotifyCharUuids) if self._classicNotifyCharUuids else None,
|
|
621
|
+
self._classicTxCharUuid,
|
|
622
|
+
self._classicHeaderMode,
|
|
623
|
+
None if self._classicConnHash8 is None else b2a(self._classicConnHash8),
|
|
624
|
+
self._network.classicVisitorKey() is not None,
|
|
625
|
+
self._network.classicManagerKey() is not None,
|
|
626
|
+
getattr(self._network, "isManager", lambda: False)(),
|
|
627
|
+
)
|
|
628
|
+
except asyncio.CancelledError:
|
|
629
|
+
return
|
|
630
|
+
except Exception:
|
|
631
|
+
# Never fail the connection because of diagnostics.
|
|
632
|
+
self._logger.debug("Classic no-RX watchdog failed.", exc_info=True)
|
|
633
|
+
|
|
486
634
|
def _on_disconnect(self, client: BleakClient) -> None:
|
|
487
635
|
if self._connectionState != ConnectionState.NONE:
|
|
488
636
|
self._logger.info(f"Received disconnect callback from {self.address}")
|
|
489
637
|
if self._connectionState == ConnectionState.AUTHENTICATED:
|
|
490
638
|
self._logger.debug("Executing disconnect callback.")
|
|
491
639
|
self._disconnectedCallback()
|
|
640
|
+
if self._classicNoRxTask is not None:
|
|
641
|
+
self._classicNoRxTask.cancel()
|
|
642
|
+
self._classicNoRxTask = None
|
|
492
643
|
self._connectionState = ConnectionState.NONE
|
|
493
644
|
|
|
494
645
|
async def exchangeKey(self) -> None:
|
|
@@ -858,12 +1009,46 @@ class CasambiClient:
|
|
|
858
1009
|
|
|
859
1010
|
return bytes(b)
|
|
860
1011
|
|
|
1012
|
+
def buildClassicCommandSimple(
|
|
1013
|
+
self,
|
|
1014
|
+
unit_id: int,
|
|
1015
|
+
dimmer: int,
|
|
1016
|
+
extra: int | None = None,
|
|
1017
|
+
) -> bytes:
|
|
1018
|
+
"""Build a Classic command using the simple format from BLE captures.
|
|
1019
|
+
|
|
1020
|
+
This alternative format was observed in real BLE captures and differs from
|
|
1021
|
+
the Android u1.C1753e command record format. Use with env variable
|
|
1022
|
+
CASAMBI_BT_CLASSIC_FORMAT=simple to experiment.
|
|
1023
|
+
|
|
1024
|
+
Format (before header added by _sendClassic):
|
|
1025
|
+
[counter:1][unit_id:1][param_len:1][dimmer:1][extra:1?]
|
|
1026
|
+
|
|
1027
|
+
The header (added by _sendClassic) is:
|
|
1028
|
+
- Conformant: [auth:1][cmac:4|16][seq:2]
|
|
1029
|
+
- Legacy: [cmac:4]
|
|
1030
|
+
|
|
1031
|
+
Args:
|
|
1032
|
+
unit_id: Target unit ID (0-255, use 0xFF for "all units")
|
|
1033
|
+
dimmer: Dimmer/level value (0-255)
|
|
1034
|
+
extra: Optional extra parameter (e.g., temperature/vertical value)
|
|
1035
|
+
|
|
1036
|
+
Returns:
|
|
1037
|
+
Command bytes to pass to _sendClassic
|
|
1038
|
+
"""
|
|
1039
|
+
counter = self._classic_next_div()
|
|
1040
|
+
if extra is not None:
|
|
1041
|
+
return bytes([counter, unit_id & 0xFF, 2, dimmer & 0xFF, extra & 0xFF])
|
|
1042
|
+
else:
|
|
1043
|
+
return bytes([counter, unit_id & 0xFF, 1, dimmer & 0xFF])
|
|
1044
|
+
|
|
861
1045
|
async def _sendClassic(self, command_bytes: bytes) -> None:
|
|
862
1046
|
self._checkState(ConnectionState.AUTHENTICATED)
|
|
863
1047
|
if self._protocolMode != ProtocolMode.CLASSIC:
|
|
864
1048
|
raise ProtocolError("Classic send called while not in Classic protocol mode.")
|
|
865
|
-
|
|
866
|
-
|
|
1049
|
+
tx_uuid = self._classicTxCharUuid or self._dataCharUuid
|
|
1050
|
+
if not tx_uuid:
|
|
1051
|
+
raise ProtocolError("Classic TX characteristic UUID not set.")
|
|
867
1052
|
if self._classicConnHash8 is None:
|
|
868
1053
|
raise ClassicHandshakeError("Classic connection hash not available.")
|
|
869
1054
|
|
|
@@ -974,12 +1159,13 @@ class CasambiClient:
|
|
|
974
1159
|
if self._logLimiter.allow("classic_tx", burst=50, window_s=60.0):
|
|
975
1160
|
auth_str = f"0x{auth_level:02x}" if header_mode == "conformant" else None
|
|
976
1161
|
self._logger.warning(
|
|
977
|
-
"[CASAMBI_CLASSIC_TX] header=%s key=%s signed=%s auth=%s sig_len=%d seq=%s "
|
|
1162
|
+
"[CASAMBI_CLASSIC_TX] header=%s key=%s signed=%s tx_uuid=%s auth=%s sig_len=%d seq=%s "
|
|
978
1163
|
"cmd_len=%d cmd_ord=%s target=%s div=%s lifetime=%s payload_len=%s "
|
|
979
1164
|
"total_len=%d prefix=%s",
|
|
980
1165
|
header_mode,
|
|
981
1166
|
key_name,
|
|
982
1167
|
signed,
|
|
1168
|
+
tx_uuid,
|
|
983
1169
|
auth_str,
|
|
984
1170
|
sig_len,
|
|
985
1171
|
None if seq is None else f"0x{seq:04x}",
|
|
@@ -995,11 +1181,59 @@ class CasambiClient:
|
|
|
995
1181
|
|
|
996
1182
|
# Classic packets can exceed 20 bytes when using a 16-byte manager signature.
|
|
997
1183
|
# Bleak needs a write-with-response for long writes on most backends.
|
|
998
|
-
|
|
1184
|
+
tx_result = "pending"
|
|
1185
|
+
try:
|
|
1186
|
+
await self._gattClient.write_gatt_char(tx_uuid, bytes(pkt), response=True)
|
|
1187
|
+
tx_result = "ok"
|
|
1188
|
+
except Exception as e:
|
|
1189
|
+
tx_result = f"error: {type(e).__name__}: {e}"
|
|
1190
|
+
raise
|
|
1191
|
+
finally:
|
|
1192
|
+
# Record TX in diagnostic history
|
|
1193
|
+
tx_entry = {
|
|
1194
|
+
"timestamp": time.monotonic(),
|
|
1195
|
+
"header_mode": header_mode,
|
|
1196
|
+
"key": key_name,
|
|
1197
|
+
"signed": signed,
|
|
1198
|
+
"tx_uuid": tx_uuid,
|
|
1199
|
+
"auth_level": auth_level if header_mode == "conformant" else None,
|
|
1200
|
+
"sig_len": sig_len,
|
|
1201
|
+
"seq": seq,
|
|
1202
|
+
"cmd_ordinal": cmd_ordinal,
|
|
1203
|
+
"cmd_target": cmd_target,
|
|
1204
|
+
"cmd_div": cmd_div,
|
|
1205
|
+
"cmd_lifetime": cmd_lifetime,
|
|
1206
|
+
"cmd_payload_len": cmd_payload_len,
|
|
1207
|
+
"total_len": len(pkt),
|
|
1208
|
+
"pre_sign_hex": b2a(command_bytes).decode("ascii"),
|
|
1209
|
+
"post_sign_hex": b2a(bytes(pkt)).decode("ascii"),
|
|
1210
|
+
"result": tx_result,
|
|
1211
|
+
}
|
|
1212
|
+
self._classicTxHistory.append(tx_entry)
|
|
1213
|
+
if len(self._classicTxHistory) > self._classicDiagMaxHistory:
|
|
1214
|
+
self._classicTxHistory = self._classicTxHistory[-self._classicDiagMaxHistory:]
|
|
1215
|
+
|
|
1216
|
+
# Enhanced TX diagnostic log
|
|
1217
|
+
self._logger.warning(
|
|
1218
|
+
"[CLASSIC_DIAG_TX_RESULT] result=%s header=%s seq=%s total_len=%d",
|
|
1219
|
+
tx_result,
|
|
1220
|
+
header_mode,
|
|
1221
|
+
None if seq is None else f"0x{seq:04x}",
|
|
1222
|
+
len(pkt),
|
|
1223
|
+
)
|
|
999
1224
|
|
|
1000
1225
|
def _establishedNofityCallback(
|
|
1001
1226
|
self, handle: BleakGATTCharacteristic, data: bytes
|
|
1002
1227
|
) -> None:
|
|
1228
|
+
# Route notifications based on characteristic UUID when available.
|
|
1229
|
+
# This helps with mixed/legacy setups where multiple Classic channels might be active.
|
|
1230
|
+
try:
|
|
1231
|
+
handle_uuid = str(getattr(handle, "uuid", "")).lower()
|
|
1232
|
+
except Exception:
|
|
1233
|
+
handle_uuid = ""
|
|
1234
|
+
if handle_uuid and handle_uuid in self._classicNotifyCharUuids:
|
|
1235
|
+
self._classicEstablishedNotifyCallback(handle, data)
|
|
1236
|
+
return
|
|
1003
1237
|
if self._protocolMode == ProtocolMode.CLASSIC:
|
|
1004
1238
|
self._classicEstablishedNotifyCallback(handle, data)
|
|
1005
1239
|
return
|
|
@@ -1074,8 +1308,26 @@ class CasambiClient:
|
|
|
1074
1308
|
"""
|
|
1075
1309
|
self._inPacketCount += 1
|
|
1076
1310
|
self._classicRxFrames += 1
|
|
1311
|
+
rx_ts = time.monotonic()
|
|
1312
|
+
if self._classicFirstRxTs is None:
|
|
1313
|
+
self._classicFirstRxTs = rx_ts
|
|
1077
1314
|
|
|
1078
1315
|
raw = bytes(data)
|
|
1316
|
+
|
|
1317
|
+
# Enhanced RX diagnostic logging
|
|
1318
|
+
try:
|
|
1319
|
+
handle_uuid = str(getattr(handle, "uuid", "unknown")).lower()
|
|
1320
|
+
except Exception:
|
|
1321
|
+
handle_uuid = "unknown"
|
|
1322
|
+
|
|
1323
|
+
self._logger.warning(
|
|
1324
|
+
"[CLASSIC_DIAG_RX] #%d handle=%s len=%d hex=%s",
|
|
1325
|
+
self._classicRxFrames,
|
|
1326
|
+
handle_uuid,
|
|
1327
|
+
len(raw),
|
|
1328
|
+
b2a(raw[: min(len(raw), 48)]).decode("ascii") + ("..." if len(raw) > 48 else ""),
|
|
1329
|
+
)
|
|
1330
|
+
|
|
1079
1331
|
if self._logger.isEnabledFor(logging.DEBUG):
|
|
1080
1332
|
self._logger.debug(
|
|
1081
1333
|
"[CASAMBI_CLASSIC_RX_RAW] len=%d hex=%s",
|
|
@@ -1265,6 +1517,36 @@ class CasambiClient:
|
|
|
1265
1517
|
elif verified is None:
|
|
1266
1518
|
self._classicRxUnverifiable += 1
|
|
1267
1519
|
|
|
1520
|
+
# Record RX in diagnostic history
|
|
1521
|
+
rx_entry = {
|
|
1522
|
+
"timestamp": rx_ts,
|
|
1523
|
+
"handle_uuid": handle_uuid,
|
|
1524
|
+
"header_mode": best["mode"],
|
|
1525
|
+
"verified": verified,
|
|
1526
|
+
"auth_level": best["auth_level"],
|
|
1527
|
+
"sig_len": best["sig_len"],
|
|
1528
|
+
"seq": best["seq"],
|
|
1529
|
+
"payload_len": len(payload),
|
|
1530
|
+
"raw_hex": b2a(raw).decode("ascii"),
|
|
1531
|
+
"payload_hex": b2a(payload).decode("ascii"),
|
|
1532
|
+
"score": best["score"],
|
|
1533
|
+
}
|
|
1534
|
+
self._classicRxHistory.append(rx_entry)
|
|
1535
|
+
if len(self._classicRxHistory) > self._classicDiagMaxHistory:
|
|
1536
|
+
self._classicRxHistory = self._classicRxHistory[-self._classicDiagMaxHistory:]
|
|
1537
|
+
|
|
1538
|
+
# Enhanced RX parse result log
|
|
1539
|
+
self._logger.warning(
|
|
1540
|
+
"[CLASSIC_DIAG_RX_PARSE] mode=%s verified=%s auth=%s sig_len=%d seq=%s score=%d payload_len=%d",
|
|
1541
|
+
best["mode"],
|
|
1542
|
+
verified,
|
|
1543
|
+
None if best["auth_level"] is None else f"0x{best['auth_level']:02x}",
|
|
1544
|
+
best["sig_len"],
|
|
1545
|
+
None if best["seq"] is None else f"0x{best['seq']:04x}",
|
|
1546
|
+
best["score"],
|
|
1547
|
+
len(payload),
|
|
1548
|
+
)
|
|
1549
|
+
|
|
1268
1550
|
# Auto-correct header mode if the other format parses much better.
|
|
1269
1551
|
if best["mode"] != preferred:
|
|
1270
1552
|
# Only switch if we got a stronger signal (verified or plausible payload with fewer assumptions).
|
|
@@ -1681,6 +1963,10 @@ class CasambiClient:
|
|
|
1681
1963
|
async def disconnect(self) -> None:
|
|
1682
1964
|
self._logger.info("Disconnecting...")
|
|
1683
1965
|
|
|
1966
|
+
if self._classicNoRxTask is not None:
|
|
1967
|
+
self._classicNoRxTask.cancel()
|
|
1968
|
+
self._classicNoRxTask = None
|
|
1969
|
+
|
|
1684
1970
|
if self._callbackTask is not None:
|
|
1685
1971
|
# Cancel and await the background callback task to avoid
|
|
1686
1972
|
# 'Task was destroyed but it is pending' warnings.
|
|
@@ -1702,3 +1988,39 @@ class CasambiClient:
|
|
|
1702
1988
|
|
|
1703
1989
|
self._connectionState = ConnectionState.NONE
|
|
1704
1990
|
self._logger.info("Disconnected.")
|
|
1991
|
+
|
|
1992
|
+
def getClassicDiagnostics(self) -> dict[str, Any]:
|
|
1993
|
+
"""Return Classic protocol diagnostic state for external services.
|
|
1994
|
+
|
|
1995
|
+
This method provides a snapshot of Classic protocol state including:
|
|
1996
|
+
- Connection parameters (hash, mode, UUIDs)
|
|
1997
|
+
- RX/TX statistics
|
|
1998
|
+
- Last N TX and RX packets
|
|
1999
|
+
- Any detected errors or anomalies
|
|
2000
|
+
|
|
2001
|
+
Safe to call from HA services for dump_classic_diagnostics.
|
|
2002
|
+
"""
|
|
2003
|
+
return {
|
|
2004
|
+
"protocol_mode": self._protocolMode.name if self._protocolMode else None,
|
|
2005
|
+
"classic_header_mode": self._classicHeaderMode,
|
|
2006
|
+
"classic_hash_source": self._classicHashSource,
|
|
2007
|
+
"classic_conn_hash8_hex": b2a(self._classicConnHash8).decode("ascii") if self._classicConnHash8 else None,
|
|
2008
|
+
"classic_tx_uuid": self._classicTxCharUuid,
|
|
2009
|
+
"classic_notify_uuids": sorted(self._classicNotifyCharUuids) if self._classicNotifyCharUuids else [],
|
|
2010
|
+
"classic_first_rx_ts": self._classicFirstRxTs,
|
|
2011
|
+
"classic_rx_stats": {
|
|
2012
|
+
"frames": self._classicRxFrames,
|
|
2013
|
+
"verified": self._classicRxVerified,
|
|
2014
|
+
"unverifiable": self._classicRxUnverifiable,
|
|
2015
|
+
"parse_fail": self._classicRxParseFail,
|
|
2016
|
+
"type6_unitstate": self._classicRxType6,
|
|
2017
|
+
"type7_switch": self._classicRxType7,
|
|
2018
|
+
"type9_netconf": self._classicRxType9,
|
|
2019
|
+
"cmdstream": self._classicRxCmdStream,
|
|
2020
|
+
"unknown": self._classicRxUnknown,
|
|
2021
|
+
},
|
|
2022
|
+
"classic_tx_count": len(self._classicTxHistory),
|
|
2023
|
+
"classic_rx_count": len(self._classicRxHistory),
|
|
2024
|
+
"classic_tx_history": self._classicTxHistory[-20:], # Last 20
|
|
2025
|
+
"classic_rx_history": self._classicRxHistory[-20:], # Last 20
|
|
2026
|
+
}
|
CasambiBt/_constants.py
CHANGED
|
@@ -12,6 +12,15 @@ CASA_AUTH_CHAR_UUID: Final = "c9ffde48-ca5a-0001-ab83-8f519b482f77"
|
|
|
12
12
|
CASA_UUID_CLASSIC: Final = "0000ca5a-0000-1000-8000-00805f9b34fb"
|
|
13
13
|
CASA_CLASSIC_HASH_CHAR_UUID: Final = "0000ca51-0000-1000-8000-00805f9b34fb"
|
|
14
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"
|
|
15
24
|
|
|
16
25
|
|
|
17
26
|
@unique
|
CasambiBt/_network.py
CHANGED
|
@@ -291,7 +291,7 @@ class Network:
|
|
|
291
291
|
"[CASAMBI_CLOUD_UPDATE_RETRY] status=400 retry_with_token_clientInfo=true body_prefix=%r",
|
|
292
292
|
(res.text or "")[:200],
|
|
293
293
|
)
|
|
294
|
-
payload2 = dict(payload)
|
|
294
|
+
payload2: dict[str, Any] = dict(payload)
|
|
295
295
|
payload2["token"] = self._token
|
|
296
296
|
payload2["clientInfo"] = self._clientInfo
|
|
297
297
|
res = await self._httpClient.put(
|
|
@@ -301,26 +301,33 @@ class Network:
|
|
|
301
301
|
)
|
|
302
302
|
|
|
303
303
|
if res.status_code != httpx.codes.OK:
|
|
304
|
-
|
|
305
|
-
|
|
304
|
+
body_prefix = (res.text or "")[:500]
|
|
305
|
+
# If we have cached network data, do not fail setup; continue offline.
|
|
306
|
+
# This is important for HA stability and for "cloud down / API changed" scenarios.
|
|
307
|
+
have_cache = bool(self._networkRevision and self._networkRevision > 0 and self._rawNetworkData)
|
|
308
|
+
self._logger.warning(
|
|
309
|
+
"[CASAMBI_CLOUD_UPDATE_FAILED] status=%s cached_revision=%s continuing_offline=%s body_prefix=%r",
|
|
306
310
|
res.status_code,
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
self._logger.debug(f"Network: {res.text}")
|
|
312
|
-
|
|
313
|
-
updateResult = res.json()
|
|
314
|
-
if updateResult["status"] != "UPTODATE":
|
|
315
|
-
self._networkRevision = updateResult["network"]["revision"]
|
|
316
|
-
self._rawNetworkData = updateResult
|
|
317
|
-
async with self._cache as cachePath:
|
|
318
|
-
cachedNetworkPah = cachePath / f"{self._id}.json"
|
|
319
|
-
await cachedNetworkPah.write_bytes(res.content)
|
|
320
|
-
network = updateResult
|
|
321
|
-
self._logger.info(
|
|
322
|
-
f"Fetched updated network with revision {self._networkRevision}"
|
|
311
|
+
self._networkRevision,
|
|
312
|
+
have_cache,
|
|
313
|
+
body_prefix,
|
|
323
314
|
)
|
|
315
|
+
if not have_cache:
|
|
316
|
+
raise NetworkUpdateError("Could not update network!")
|
|
317
|
+
else:
|
|
318
|
+
self._logger.debug(f"Network: {res.text}")
|
|
319
|
+
|
|
320
|
+
updateResult = res.json()
|
|
321
|
+
if updateResult["status"] != "UPTODATE":
|
|
322
|
+
self._networkRevision = updateResult["network"]["revision"]
|
|
323
|
+
self._rawNetworkData = updateResult
|
|
324
|
+
async with self._cache as cachePath:
|
|
325
|
+
cachedNetworkPah = cachePath / f"{self._id}.json"
|
|
326
|
+
await cachedNetworkPah.write_bytes(res.content)
|
|
327
|
+
network = updateResult
|
|
328
|
+
self._logger.info(
|
|
329
|
+
f"Fetched updated network with revision {self._networkRevision}"
|
|
330
|
+
)
|
|
324
331
|
except RequestError as err:
|
|
325
332
|
if self._networkRevision == 0:
|
|
326
333
|
raise NetworkUpdateError from err
|
CasambiBt/_version.py
CHANGED
{casambi_bt_revamped-0.3.12.dev8.dist-info → casambi_bt_revamped-0.3.12.dev10.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: casambi-bt-revamped
|
|
3
|
-
Version: 0.3.12.
|
|
3
|
+
Version: 0.3.12.dev10
|
|
4
4
|
Summary: Forked Casambi Bluetooth client library with switch event support, use original if no special need. https://github.com/lkempf/casambi-bt
|
|
5
5
|
Home-page: https://github.com/rankjie/casambi-bt
|
|
6
6
|
Author: rankjie
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
CasambiBt/__init__.py,sha256=iJdTF4oeXfj5d5gfGxQkacqUjtnQo0IW-zFPJvFjWWk,336
|
|
2
|
+
CasambiBt/_cache.py,sha256=3bQil8vhSy4f4sf9JusMfEdQC7d3cJuva9qHhyKro-0,3808
|
|
3
|
+
CasambiBt/_casambi.py,sha256=9pWTxR1ZBARK-IB91PSRAQrcRoswU40jI-9AfLPpvW0,41649
|
|
4
|
+
CasambiBt/_classic_crypto.py,sha256=XIp3JBaeY8hIUv5kB0ygVG_eRx9AgHHF4ts2--CFm78,4973
|
|
5
|
+
CasambiBt/_client.py,sha256=EuaCwb3t6D0sbQR6XA84UWDEtkc3n7WBRozsM2kFu-Y,83927
|
|
6
|
+
CasambiBt/_constants.py,sha256=86heoDdb5iPaRrPmK2DIIl-4uSxbFFcnCo9zlCvTLww,1290
|
|
7
|
+
CasambiBt/_discover.py,sha256=jLc6H69JddrCURgtANZEjws6_UbSzXJtvJkbKTaIUHY,1849
|
|
8
|
+
CasambiBt/_encryption.py,sha256=CLcoOOrggQqhJbnr_emBnEnkizpWDvb_0yFnitq4_FM,3831
|
|
9
|
+
CasambiBt/_invocation.py,sha256=fkG4R0Gv5_amFfD_P6DKuIEe3oKWZW0v8RSU8zDjPdI,2985
|
|
10
|
+
CasambiBt/_keystore.py,sha256=Jdiq0zMPDmhfpheSojKY6sTUpmVrvX_qOyO7yCYd3kw,2788
|
|
11
|
+
CasambiBt/_network.py,sha256=ai1o3EybsAhjyPohSOxeE0cWoFvEqdcc3PE3uFDaTfE,21346
|
|
12
|
+
CasambiBt/_operation.py,sha256=Q5UccsrtNp_B_wWqwH_3eLFW_yF6A55FMmfUKDk2WrI,1059
|
|
13
|
+
CasambiBt/_switch_events.py,sha256=S8OD0dBcw5T4J2C7qfmOQMnTJ7omIXRUYv4PqDOB87E,13137
|
|
14
|
+
CasambiBt/_unit.py,sha256=nxbg_8UCCVB9WI8dUS21g2JrGyPKcefqKMSusMOhLOo,18721
|
|
15
|
+
CasambiBt/_version.py,sha256=KfDHVZ0HvUoCJCQD90I4l0PCSgOKne4pUVo8Y_Hv5Xk,338
|
|
16
|
+
CasambiBt/errors.py,sha256=1L_Q8og_N_BRYEKizghAQXr6tihlHykFgtcCHUDcBas,1961
|
|
17
|
+
CasambiBt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
+
casambi_bt_revamped-0.3.12.dev10.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
|
|
19
|
+
casambi_bt_revamped-0.3.12.dev10.dist-info/METADATA,sha256=VYoGQSXLxJqbL3RSHlXZbuwsMydhxXPvfIPYQnGfhac,5878
|
|
20
|
+
casambi_bt_revamped-0.3.12.dev10.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
21
|
+
casambi_bt_revamped-0.3.12.dev10.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
|
|
22
|
+
casambi_bt_revamped-0.3.12.dev10.dist-info/RECORD,,
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
CasambiBt/__init__.py,sha256=iJdTF4oeXfj5d5gfGxQkacqUjtnQo0IW-zFPJvFjWWk,336
|
|
2
|
-
CasambiBt/_cache.py,sha256=3bQil8vhSy4f4sf9JusMfEdQC7d3cJuva9qHhyKro-0,3808
|
|
3
|
-
CasambiBt/_casambi.py,sha256=TN4ecgjm95nSJ4h9TsKayNn577Y82fdsGK4IGUZF23Q,40666
|
|
4
|
-
CasambiBt/_classic_crypto.py,sha256=6DcCOdjLQo7k2cOOutNdUKupykOG_E2TDDwg6fH-ODM,998
|
|
5
|
-
CasambiBt/_client.py,sha256=dG-VRlZ0n7Eng8ORc-Xk8rifCVAcXBexFroA4BLQ_w8,69657
|
|
6
|
-
CasambiBt/_constants.py,sha256=sbElg5W8eeQvvL1rHn_E0jhP1wOrrabc7dFLLnlDMsU,810
|
|
7
|
-
CasambiBt/_discover.py,sha256=jLc6H69JddrCURgtANZEjws6_UbSzXJtvJkbKTaIUHY,1849
|
|
8
|
-
CasambiBt/_encryption.py,sha256=CLcoOOrggQqhJbnr_emBnEnkizpWDvb_0yFnitq4_FM,3831
|
|
9
|
-
CasambiBt/_invocation.py,sha256=fkG4R0Gv5_amFfD_P6DKuIEe3oKWZW0v8RSU8zDjPdI,2985
|
|
10
|
-
CasambiBt/_keystore.py,sha256=Jdiq0zMPDmhfpheSojKY6sTUpmVrvX_qOyO7yCYd3kw,2788
|
|
11
|
-
CasambiBt/_network.py,sha256=nB_pRB9dZL6P7THeuOce7ctWd0wXyCWF13h67SauZVQ,20714
|
|
12
|
-
CasambiBt/_operation.py,sha256=Q5UccsrtNp_B_wWqwH_3eLFW_yF6A55FMmfUKDk2WrI,1059
|
|
13
|
-
CasambiBt/_switch_events.py,sha256=S8OD0dBcw5T4J2C7qfmOQMnTJ7omIXRUYv4PqDOB87E,13137
|
|
14
|
-
CasambiBt/_unit.py,sha256=nxbg_8UCCVB9WI8dUS21g2JrGyPKcefqKMSusMOhLOo,18721
|
|
15
|
-
CasambiBt/_version.py,sha256=RkpM6Fp6uH7xKTYzqUnnINOKTs0TrFqLrkU4nloEFrU,337
|
|
16
|
-
CasambiBt/errors.py,sha256=1L_Q8og_N_BRYEKizghAQXr6tihlHykFgtcCHUDcBas,1961
|
|
17
|
-
CasambiBt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
-
casambi_bt_revamped-0.3.12.dev8.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
|
|
19
|
-
casambi_bt_revamped-0.3.12.dev8.dist-info/METADATA,sha256=d0oJkqNgiNr_ACzBbE_6Z2i93Wsa1oG_gZi54xgNiJo,5877
|
|
20
|
-
casambi_bt_revamped-0.3.12.dev8.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
21
|
-
casambi_bt_revamped-0.3.12.dev8.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
|
|
22
|
-
casambi_bt_revamped-0.3.12.dev8.dist-info/RECORD,,
|
{casambi_bt_revamped-0.3.12.dev8.dist-info → casambi_bt_revamped-0.3.12.dev10.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|