casambi-bt-revamped 0.3.12.dev7__py3-none-any.whl → 0.3.12.dev9__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/_client.py +315 -18
- CasambiBt/_constants.py +9 -0
- CasambiBt/_network.py +26 -19
- CasambiBt/_version.py +1 -1
- {casambi_bt_revamped-0.3.12.dev7.dist-info → casambi_bt_revamped-0.3.12.dev9.dist-info}/METADATA +1 -1
- {casambi_bt_revamped-0.3.12.dev7.dist-info → casambi_bt_revamped-0.3.12.dev9.dist-info}/RECORD +9 -9
- {casambi_bt_revamped-0.3.12.dev7.dist-info → casambi_bt_revamped-0.3.12.dev9.dist-info}/WHEEL +0 -0
- {casambi_bt_revamped-0.3.12.dev7.dist-info → casambi_bt_revamped-0.3.12.dev9.dist-info}/licenses/LICENSE +0 -0
- {casambi_bt_revamped-0.3.12.dev7.dist-info → casambi_bt_revamped-0.3.12.dev9.dist-info}/top_level.txt +0 -0
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()
|
|
@@ -142,6 +154,13 @@ class CasambiClient:
|
|
|
142
154
|
self._classicRxVerified = 0
|
|
143
155
|
self._classicRxUnverifiable = 0
|
|
144
156
|
self._classicRxParseFail = 0
|
|
157
|
+
self._classicRxType6 = 0
|
|
158
|
+
self._classicRxType7 = 0
|
|
159
|
+
self._classicRxType9 = 0
|
|
160
|
+
self._classicRxCmdStream = 0
|
|
161
|
+
self._classicRxUnknown = 0
|
|
162
|
+
# Per-kind sample counters to ensure we emit at least a few examples for reverse engineering.
|
|
163
|
+
self._classicRxKindSamples: dict[str, int] = {}
|
|
145
164
|
self._classicRxLastStatsTs = time.monotonic()
|
|
146
165
|
|
|
147
166
|
@property
|
|
@@ -179,6 +198,23 @@ class CasambiClient:
|
|
|
179
198
|
self._outPacketCount = 2
|
|
180
199
|
self._inPacketCount = 1
|
|
181
200
|
|
|
201
|
+
# Reset protocol-specific state (important for reconnects).
|
|
202
|
+
self._protocolMode = None
|
|
203
|
+
self._dataCharUuid = None
|
|
204
|
+
self._deviceProtocolVersion = None
|
|
205
|
+
|
|
206
|
+
self._classicConnHash8 = None
|
|
207
|
+
self._classicTxSeq = 0
|
|
208
|
+
self._classicCmdDiv = 0
|
|
209
|
+
self._classicHeaderMode = None
|
|
210
|
+
self._classicTxCharUuid = None
|
|
211
|
+
self._classicNotifyCharUuids.clear()
|
|
212
|
+
self._classicHashSource = None
|
|
213
|
+
self._classicFirstRxTs = None
|
|
214
|
+
if self._classicNoRxTask is not None:
|
|
215
|
+
self._classicNoRxTask.cancel()
|
|
216
|
+
self._classicNoRxTask = None
|
|
217
|
+
|
|
182
218
|
# Reset callback queue
|
|
183
219
|
self._callbackQueue = asyncio.Queue()
|
|
184
220
|
self._callbackTask = asyncio.create_task(self._processCallbacks())
|
|
@@ -255,25 +291,43 @@ class CasambiClient:
|
|
|
255
291
|
cloud_protocol = getattr(self._network, "protocolVersion", None)
|
|
256
292
|
ca51_prefix: bytes | None = None
|
|
257
293
|
ca51_err: str | None = None
|
|
294
|
+
ca52_notify_err: str | None = None
|
|
295
|
+
ca53_notify_err: str | None = None
|
|
258
296
|
auth_prefix: bytes | None = None
|
|
259
297
|
auth_err: str | None = None
|
|
298
|
+
c0002_prefix: bytes | None = None
|
|
299
|
+
c0002_err: str | None = None
|
|
300
|
+
c0003_notify_err: str | None = None
|
|
260
301
|
device_nodeinfo_protocol: int | None = None
|
|
261
302
|
|
|
262
|
-
def _log_probe_summary(mode: str) -> None:
|
|
303
|
+
def _log_probe_summary(mode: str, *, classic_variant: str | None = None) -> None:
|
|
263
304
|
# One stable, high-signal line for testers.
|
|
264
305
|
self._logger.warning(
|
|
265
|
-
"[CASAMBI_PROTOCOL_PROBE] address=%s mode=%s cloud_protocol=%s nodeinfo_b1=%s "
|
|
266
|
-
"
|
|
306
|
+
"[CASAMBI_PROTOCOL_PROBE] address=%s mode=%s cloud_protocol=%s nodeinfo_b1=%s data_uuid=%s "
|
|
307
|
+
"classic_variant=%s hash_source=%s classic_tx_uuid=%s classic_notify_uuids=%s "
|
|
308
|
+
"ca51_hash8_present=%s conn_hash8_ready=%s "
|
|
309
|
+
"auth_read_prefix=%s ca51_read_prefix=%s ca51_read_error=%s auth_read_error=%s "
|
|
310
|
+
"ca52_notify_error=%s ca53_notify_error=%s c0002_read_prefix=%s c0002_read_error=%s c0003_notify_error=%s",
|
|
267
311
|
self.address,
|
|
268
312
|
mode,
|
|
269
313
|
cloud_protocol,
|
|
270
314
|
device_nodeinfo_protocol,
|
|
271
315
|
self._dataCharUuid,
|
|
316
|
+
classic_variant,
|
|
317
|
+
self._classicHashSource,
|
|
318
|
+
self._classicTxCharUuid,
|
|
319
|
+
sorted(self._classicNotifyCharUuids) if self._classicNotifyCharUuids else None,
|
|
272
320
|
bool(classic_hash and len(classic_hash) >= 8),
|
|
321
|
+
self._classicConnHash8 is not None,
|
|
273
322
|
auth_prefix,
|
|
274
323
|
ca51_prefix,
|
|
275
324
|
ca51_err,
|
|
276
325
|
auth_err,
|
|
326
|
+
ca52_notify_err,
|
|
327
|
+
ca53_notify_err,
|
|
328
|
+
c0002_prefix,
|
|
329
|
+
c0002_err,
|
|
330
|
+
c0003_notify_err,
|
|
277
331
|
)
|
|
278
332
|
|
|
279
333
|
classic_hash: bytes | None = None
|
|
@@ -295,7 +349,9 @@ class CasambiClient:
|
|
|
295
349
|
if classic_hash and len(classic_hash) >= 8:
|
|
296
350
|
self._protocolMode = ProtocolMode.CLASSIC
|
|
297
351
|
self._dataCharUuid = CASA_CLASSIC_DATA_CHAR_UUID
|
|
352
|
+
self._classicTxCharUuid = CASA_CLASSIC_DATA_CHAR_UUID
|
|
298
353
|
self._classicHeaderMode = "legacy"
|
|
354
|
+
self._classicHashSource = "ca51"
|
|
299
355
|
|
|
300
356
|
# Read connection hash (first 8 bytes are used for CMAC signing).
|
|
301
357
|
raw_hash = classic_hash
|
|
@@ -320,6 +376,7 @@ class CasambiClient:
|
|
|
320
376
|
**notify_kwargs,
|
|
321
377
|
)
|
|
322
378
|
except Exception as e:
|
|
379
|
+
ca52_notify_err = type(e).__name__
|
|
323
380
|
# Some firmwares may expose Classic signing on the EVO UUID instead.
|
|
324
381
|
# Fall through to auth-char probing if CA52 isn't available.
|
|
325
382
|
if self._logger.isEnabledFor(logging.DEBUG):
|
|
@@ -331,8 +388,25 @@ class CasambiClient:
|
|
|
331
388
|
self._protocolMode = None
|
|
332
389
|
self._dataCharUuid = None
|
|
333
390
|
self._classicConnHash8 = None
|
|
391
|
+
self._classicTxCharUuid = None
|
|
392
|
+
self._classicNotifyCharUuids.clear()
|
|
393
|
+
self._classicHeaderMode = None
|
|
394
|
+
self._classicHashSource = None
|
|
334
395
|
# continue detection below
|
|
335
396
|
else:
|
|
397
|
+
self._classicNotifyCharUuids.add(CASA_CLASSIC_DATA_CHAR_UUID.lower())
|
|
398
|
+
# Some Classic firmwares also expose state/config notifications on CA53.
|
|
399
|
+
try:
|
|
400
|
+
await self._gattClient.start_notify(
|
|
401
|
+
CASA_CLASSIC_CA53_CHAR_UUID,
|
|
402
|
+
self._queueCallback,
|
|
403
|
+
**notify_kwargs,
|
|
404
|
+
)
|
|
405
|
+
except Exception as e:
|
|
406
|
+
ca53_notify_err = type(e).__name__
|
|
407
|
+
else:
|
|
408
|
+
self._classicNotifyCharUuids.add(CASA_CLASSIC_CA53_CHAR_UUID.lower())
|
|
409
|
+
|
|
336
410
|
# Classic has no EVO-style key exchange/auth; we can send immediately.
|
|
337
411
|
self._connectionState = ConnectionState.AUTHENTICATED
|
|
338
412
|
self._logger.info("Protocol mode selected: CLASSIC")
|
|
@@ -343,7 +417,24 @@ class CasambiClient:
|
|
|
343
417
|
len(self._classicConnHash8),
|
|
344
418
|
b2a(self._classicConnHash8),
|
|
345
419
|
)
|
|
346
|
-
|
|
420
|
+
self._logger.warning(
|
|
421
|
+
"[CASAMBI_CLASSIC_SELECTED] address=%s variant=ca52_legacy data_uuid=%s tx_uuid=%s notify_uuids=%s header_mode=%s conn_hash8_prefix=%s",
|
|
422
|
+
self.address,
|
|
423
|
+
self._dataCharUuid,
|
|
424
|
+
self._classicTxCharUuid,
|
|
425
|
+
sorted(self._classicNotifyCharUuids) if self._classicNotifyCharUuids else None,
|
|
426
|
+
self._classicHeaderMode,
|
|
427
|
+
b2a(self._classicConnHash8),
|
|
428
|
+
)
|
|
429
|
+
self._logger.warning(
|
|
430
|
+
"[CASAMBI_CLASSIC_KEYS] visitor=%s manager=%s cloud_session_is_manager=%s",
|
|
431
|
+
self._network.classicVisitorKey() is not None,
|
|
432
|
+
self._network.classicManagerKey() is not None,
|
|
433
|
+
getattr(self._network, "isManager", lambda: False)(),
|
|
434
|
+
)
|
|
435
|
+
_log_probe_summary("CLASSIC", classic_variant="ca52_legacy")
|
|
436
|
+
# Emit a warning if we never see Classic RX frames; this is a common failure mode.
|
|
437
|
+
self._classicNoRxTask = asyncio.create_task(self._classic_no_rx_watchdog(30.0))
|
|
347
438
|
return
|
|
348
439
|
|
|
349
440
|
# Conformant devices can expose the Classic signed channel on the EVO-style UUID too.
|
|
@@ -414,30 +505,90 @@ class CasambiClient:
|
|
|
414
505
|
|
|
415
506
|
self._protocolMode = ProtocolMode.CLASSIC
|
|
416
507
|
self._dataCharUuid = CASA_AUTH_CHAR_UUID
|
|
508
|
+
self._classicTxCharUuid = CASA_AUTH_CHAR_UUID
|
|
417
509
|
self._classicHeaderMode = "conformant"
|
|
510
|
+
self._classicHashSource = "ca52_0001"
|
|
418
511
|
self._classicConnHash8 = bytes(first[:8])
|
|
419
512
|
self._classicCmdDiv = int.from_bytes(os.urandom(1), "big") or 1
|
|
420
513
|
self._classicTxSeq = 0
|
|
421
514
|
|
|
515
|
+
# Probe mapped Classic CA51 (0002) for diagnostics; some firmwares use it for time/config.
|
|
516
|
+
try:
|
|
517
|
+
v = await self._gattClient.read_gatt_char(CASA_CLASSIC_CONFORMANT_CA51_CHAR_UUID)
|
|
518
|
+
c0002_prefix = b2a(v[:10]) if v else None
|
|
519
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
520
|
+
self._logger.debug(
|
|
521
|
+
"[CASAMBI_GATT_PROBE] read classic-0002 ok len=%d prefix=%s",
|
|
522
|
+
0 if v is None else len(v),
|
|
523
|
+
c0002_prefix,
|
|
524
|
+
)
|
|
525
|
+
except Exception as e:
|
|
526
|
+
c0002_err = type(e).__name__
|
|
527
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
528
|
+
self._logger.debug(
|
|
529
|
+
"[CASAMBI_GATT_PROBE] read classic-0002 fail err=%s",
|
|
530
|
+
c0002_err,
|
|
531
|
+
)
|
|
532
|
+
|
|
422
533
|
notify_kwargs: dict[str, Any] = {}
|
|
423
534
|
notify_params = inspect.signature(self._gattClient.start_notify).parameters
|
|
424
535
|
if "bluez" in notify_params:
|
|
425
536
|
notify_kwargs["bluez"] = {"use_start_notify": True}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
537
|
+
try:
|
|
538
|
+
await self._gattClient.start_notify(
|
|
539
|
+
CASA_AUTH_CHAR_UUID,
|
|
540
|
+
self._queueCallback,
|
|
541
|
+
**notify_kwargs,
|
|
542
|
+
)
|
|
543
|
+
except Exception as e:
|
|
544
|
+
ca52_notify_err = type(e).__name__
|
|
545
|
+
else:
|
|
546
|
+
self._classicNotifyCharUuids.add(CASA_AUTH_CHAR_UUID.lower())
|
|
547
|
+
|
|
548
|
+
# Probe mapped Classic CA53 (0003) notify: some firmwares may emit state/config here.
|
|
549
|
+
try:
|
|
550
|
+
await self._gattClient.start_notify(
|
|
551
|
+
CASA_CLASSIC_CONFORMANT_CA53_CHAR_UUID,
|
|
552
|
+
self._queueCallback,
|
|
553
|
+
**notify_kwargs,
|
|
554
|
+
)
|
|
555
|
+
except Exception as e:
|
|
556
|
+
c0003_notify_err = type(e).__name__
|
|
557
|
+
else:
|
|
558
|
+
self._classicNotifyCharUuids.add(CASA_CLASSIC_CONFORMANT_CA53_CHAR_UUID.lower())
|
|
559
|
+
|
|
431
560
|
self._connectionState = ConnectionState.AUTHENTICATED
|
|
432
561
|
self._logger.info("Protocol mode selected: CLASSIC")
|
|
433
562
|
if self._logger.isEnabledFor(logging.DEBUG):
|
|
434
|
-
|
|
563
|
+
if ca52_notify_err is None:
|
|
564
|
+
self._logger.debug("[CASAMBI_GATT_PROBE] start_notify auth ok (classic conformant)")
|
|
565
|
+
else:
|
|
566
|
+
self._logger.debug(
|
|
567
|
+
"[CASAMBI_GATT_PROBE] start_notify auth fail err=%s (classic conformant)",
|
|
568
|
+
ca52_notify_err,
|
|
569
|
+
)
|
|
435
570
|
self._logger.debug(
|
|
436
571
|
"[CASAMBI_CLASSIC_CONN_HASH] len=%d hash=%s",
|
|
437
572
|
len(self._classicConnHash8),
|
|
438
573
|
b2a(self._classicConnHash8),
|
|
439
574
|
)
|
|
440
|
-
|
|
575
|
+
self._logger.warning(
|
|
576
|
+
"[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",
|
|
577
|
+
self.address,
|
|
578
|
+
self._dataCharUuid,
|
|
579
|
+
self._classicTxCharUuid,
|
|
580
|
+
sorted(self._classicNotifyCharUuids) if self._classicNotifyCharUuids else None,
|
|
581
|
+
self._classicHeaderMode,
|
|
582
|
+
b2a(self._classicConnHash8),
|
|
583
|
+
)
|
|
584
|
+
self._logger.warning(
|
|
585
|
+
"[CASAMBI_CLASSIC_KEYS] visitor=%s manager=%s cloud_session_is_manager=%s",
|
|
586
|
+
self._network.classicVisitorKey() is not None,
|
|
587
|
+
self._network.classicManagerKey() is not None,
|
|
588
|
+
getattr(self._network, "isManager", lambda: False)(),
|
|
589
|
+
)
|
|
590
|
+
_log_probe_summary("CLASSIC", classic_variant="auth_uuid_conformant")
|
|
591
|
+
self._classicNoRxTask = asyncio.create_task(self._classic_no_rx_watchdog(30.0))
|
|
441
592
|
return
|
|
442
593
|
|
|
443
594
|
_log_probe_summary("UNKNOWN")
|
|
@@ -445,12 +596,45 @@ class CasambiClient:
|
|
|
445
596
|
"No supported Casambi characteristics found (Classic ca51/ca52 or EVO/Classic-conformant auth char)."
|
|
446
597
|
)
|
|
447
598
|
|
|
599
|
+
async def _classic_no_rx_watchdog(self, after_s: float) -> None:
|
|
600
|
+
"""Emit one high-signal log if Classic RX stays silent after connect.
|
|
601
|
+
|
|
602
|
+
This helps testers capture actionable logs when Classic control/updates don't work yet.
|
|
603
|
+
"""
|
|
604
|
+
try:
|
|
605
|
+
await asyncio.sleep(after_s)
|
|
606
|
+
if self._protocolMode != ProtocolMode.CLASSIC:
|
|
607
|
+
return
|
|
608
|
+
if self._classicFirstRxTs is not None:
|
|
609
|
+
return
|
|
610
|
+
|
|
611
|
+
self._logger.warning(
|
|
612
|
+
"[CASAMBI_CLASSIC_NO_RX] after_s=%s notify_uuids=%s tx_uuid=%s header_mode=%s "
|
|
613
|
+
"conn_hash8_prefix=%s visitor=%s manager=%s cloud_session_is_manager=%s",
|
|
614
|
+
after_s,
|
|
615
|
+
sorted(self._classicNotifyCharUuids) if self._classicNotifyCharUuids else None,
|
|
616
|
+
self._classicTxCharUuid,
|
|
617
|
+
self._classicHeaderMode,
|
|
618
|
+
None if self._classicConnHash8 is None else b2a(self._classicConnHash8),
|
|
619
|
+
self._network.classicVisitorKey() is not None,
|
|
620
|
+
self._network.classicManagerKey() is not None,
|
|
621
|
+
getattr(self._network, "isManager", lambda: False)(),
|
|
622
|
+
)
|
|
623
|
+
except asyncio.CancelledError:
|
|
624
|
+
return
|
|
625
|
+
except Exception:
|
|
626
|
+
# Never fail the connection because of diagnostics.
|
|
627
|
+
self._logger.debug("Classic no-RX watchdog failed.", exc_info=True)
|
|
628
|
+
|
|
448
629
|
def _on_disconnect(self, client: BleakClient) -> None:
|
|
449
630
|
if self._connectionState != ConnectionState.NONE:
|
|
450
631
|
self._logger.info(f"Received disconnect callback from {self.address}")
|
|
451
632
|
if self._connectionState == ConnectionState.AUTHENTICATED:
|
|
452
633
|
self._logger.debug("Executing disconnect callback.")
|
|
453
634
|
self._disconnectedCallback()
|
|
635
|
+
if self._classicNoRxTask is not None:
|
|
636
|
+
self._classicNoRxTask.cancel()
|
|
637
|
+
self._classicNoRxTask = None
|
|
454
638
|
self._connectionState = ConnectionState.NONE
|
|
455
639
|
|
|
456
640
|
async def exchangeKey(self) -> None:
|
|
@@ -824,14 +1008,43 @@ class CasambiClient:
|
|
|
824
1008
|
self._checkState(ConnectionState.AUTHENTICATED)
|
|
825
1009
|
if self._protocolMode != ProtocolMode.CLASSIC:
|
|
826
1010
|
raise ProtocolError("Classic send called while not in Classic protocol mode.")
|
|
827
|
-
|
|
828
|
-
|
|
1011
|
+
tx_uuid = self._classicTxCharUuid or self._dataCharUuid
|
|
1012
|
+
if not tx_uuid:
|
|
1013
|
+
raise ProtocolError("Classic TX characteristic UUID not set.")
|
|
829
1014
|
if self._classicConnHash8 is None:
|
|
830
1015
|
raise ClassicHandshakeError("Classic connection hash not available.")
|
|
831
1016
|
|
|
832
1017
|
visitor_key = self._network.classicVisitorKey()
|
|
833
1018
|
manager_key = self._network.classicManagerKey()
|
|
834
1019
|
|
|
1020
|
+
# Parse the command record for logs (u1.C1753e export format).
|
|
1021
|
+
cmd_ordinal: int | None = None
|
|
1022
|
+
cmd_div: int | None = None
|
|
1023
|
+
cmd_target: int | None = None
|
|
1024
|
+
cmd_lifetime: int | None = None
|
|
1025
|
+
cmd_payload_len: int | None = None
|
|
1026
|
+
try:
|
|
1027
|
+
if len(command_bytes) >= 2:
|
|
1028
|
+
typ = command_bytes[1]
|
|
1029
|
+
cmd_ordinal = typ & 0x3F
|
|
1030
|
+
has_div = (typ & 0x40) != 0
|
|
1031
|
+
has_target = (typ & 0x80) != 0
|
|
1032
|
+
p = 2
|
|
1033
|
+
if has_div and p < len(command_bytes):
|
|
1034
|
+
cmd_div = command_bytes[p]
|
|
1035
|
+
p += 1
|
|
1036
|
+
if has_target and p < len(command_bytes):
|
|
1037
|
+
cmd_target = command_bytes[p]
|
|
1038
|
+
p += 1
|
|
1039
|
+
if p < len(command_bytes):
|
|
1040
|
+
cmd_lifetime = command_bytes[p]
|
|
1041
|
+
p += 1
|
|
1042
|
+
if p <= len(command_bytes):
|
|
1043
|
+
cmd_payload_len = len(command_bytes) - p
|
|
1044
|
+
except Exception:
|
|
1045
|
+
# If parsing fails, keep fields as None.
|
|
1046
|
+
pass
|
|
1047
|
+
|
|
835
1048
|
# Key selection mirrors Android's intent:
|
|
836
1049
|
# - Use manager key if our cloud session is manager and a managerKey exists.
|
|
837
1050
|
# - Else use visitor key if present.
|
|
@@ -895,28 +1108,55 @@ class CasambiClient:
|
|
|
895
1108
|
else:
|
|
896
1109
|
raise ProtocolError(f"Unknown Classic header mode: {header_mode}")
|
|
897
1110
|
|
|
1111
|
+
signed = key is not None
|
|
1112
|
+
if not signed and self._logLimiter.allow("classic_tx_unsigned", burst=10, window_s=300.0):
|
|
1113
|
+
self._logger.warning(
|
|
1114
|
+
"[CASAMBI_CLASSIC_TX_UNSIGNED] reason=keys_missing visitor=%s manager=%s",
|
|
1115
|
+
visitor_key is not None,
|
|
1116
|
+
manager_key is not None,
|
|
1117
|
+
)
|
|
1118
|
+
|
|
898
1119
|
# WARNING-level TX logs are intentional: they are needed for Classic reverse engineering.
|
|
899
1120
|
# Keep payload logging minimal (prefix only).
|
|
900
1121
|
if self._logLimiter.allow("classic_tx", burst=50, window_s=60.0):
|
|
1122
|
+
auth_str = f"0x{auth_level:02x}" if header_mode == "conformant" else None
|
|
901
1123
|
self._logger.warning(
|
|
902
|
-
"[CASAMBI_CLASSIC_TX] header=%s key=%s
|
|
1124
|
+
"[CASAMBI_CLASSIC_TX] header=%s key=%s signed=%s tx_uuid=%s auth=%s sig_len=%d seq=%s "
|
|
1125
|
+
"cmd_len=%d cmd_ord=%s target=%s div=%s lifetime=%s payload_len=%s "
|
|
1126
|
+
"total_len=%d prefix=%s",
|
|
903
1127
|
header_mode,
|
|
904
1128
|
key_name,
|
|
905
|
-
|
|
1129
|
+
signed,
|
|
1130
|
+
tx_uuid,
|
|
1131
|
+
auth_str,
|
|
906
1132
|
sig_len,
|
|
907
1133
|
None if seq is None else f"0x{seq:04x}",
|
|
908
1134
|
len(command_bytes),
|
|
1135
|
+
cmd_ordinal,
|
|
1136
|
+
cmd_target,
|
|
1137
|
+
cmd_div,
|
|
1138
|
+
cmd_lifetime,
|
|
1139
|
+
cmd_payload_len,
|
|
909
1140
|
len(pkt),
|
|
910
1141
|
b2a(bytes(pkt[: min(len(pkt), 24)])),
|
|
911
1142
|
)
|
|
912
1143
|
|
|
913
1144
|
# Classic packets can exceed 20 bytes when using a 16-byte manager signature.
|
|
914
1145
|
# Bleak needs a write-with-response for long writes on most backends.
|
|
915
|
-
await self._gattClient.write_gatt_char(
|
|
1146
|
+
await self._gattClient.write_gatt_char(tx_uuid, bytes(pkt), response=True)
|
|
916
1147
|
|
|
917
1148
|
def _establishedNofityCallback(
|
|
918
1149
|
self, handle: BleakGATTCharacteristic, data: bytes
|
|
919
1150
|
) -> None:
|
|
1151
|
+
# Route notifications based on characteristic UUID when available.
|
|
1152
|
+
# This helps with mixed/legacy setups where multiple Classic channels might be active.
|
|
1153
|
+
try:
|
|
1154
|
+
handle_uuid = str(getattr(handle, "uuid", "")).lower()
|
|
1155
|
+
except Exception:
|
|
1156
|
+
handle_uuid = ""
|
|
1157
|
+
if handle_uuid and handle_uuid in self._classicNotifyCharUuids:
|
|
1158
|
+
self._classicEstablishedNotifyCallback(handle, data)
|
|
1159
|
+
return
|
|
920
1160
|
if self._protocolMode == ProtocolMode.CLASSIC:
|
|
921
1161
|
self._classicEstablishedNotifyCallback(handle, data)
|
|
922
1162
|
return
|
|
@@ -991,6 +1231,8 @@ class CasambiClient:
|
|
|
991
1231
|
"""
|
|
992
1232
|
self._inPacketCount += 1
|
|
993
1233
|
self._classicRxFrames += 1
|
|
1234
|
+
if self._classicFirstRxTs is None:
|
|
1235
|
+
self._classicFirstRxTs = time.monotonic()
|
|
994
1236
|
|
|
995
1237
|
raw = bytes(data)
|
|
996
1238
|
if self._logger.isEnabledFor(logging.DEBUG):
|
|
@@ -1213,17 +1455,47 @@ class CasambiClient:
|
|
|
1213
1455
|
):
|
|
1214
1456
|
self._classicRxLastStatsTs = now
|
|
1215
1457
|
self._logger.warning(
|
|
1216
|
-
"[CASAMBI_CLASSIC_RX_STATS] frames=%d verified=%d unverifiable=%d parse_fail=%d header=%s"
|
|
1458
|
+
"[CASAMBI_CLASSIC_RX_STATS] frames=%d verified=%d unverifiable=%d parse_fail=%d header=%s "
|
|
1459
|
+
"type6=%d type7=%d type9=%d cmdstream=%d unknown=%d",
|
|
1217
1460
|
self._classicRxFrames,
|
|
1218
1461
|
self._classicRxVerified,
|
|
1219
1462
|
self._classicRxUnverifiable,
|
|
1220
1463
|
self._classicRxParseFail,
|
|
1221
1464
|
self._classicHeaderMode,
|
|
1465
|
+
self._classicRxType6,
|
|
1466
|
+
self._classicRxType7,
|
|
1467
|
+
self._classicRxType9,
|
|
1468
|
+
self._classicRxCmdStream,
|
|
1469
|
+
self._classicRxUnknown,
|
|
1222
1470
|
)
|
|
1223
1471
|
|
|
1224
1472
|
# If the payload starts with a known EVO packet type, reuse existing parsers.
|
|
1225
1473
|
packet_type = payload[0]
|
|
1226
1474
|
if packet_type in (IncommingPacketType.UnitState, IncommingPacketType.SwitchEvent, IncommingPacketType.NetworkConfig):
|
|
1475
|
+
kind = f"type{int(packet_type)}"
|
|
1476
|
+
if packet_type == IncommingPacketType.UnitState:
|
|
1477
|
+
self._classicRxType6 += 1
|
|
1478
|
+
kind = "type6_unitstate"
|
|
1479
|
+
elif packet_type == IncommingPacketType.SwitchEvent:
|
|
1480
|
+
self._classicRxType7 += 1
|
|
1481
|
+
kind = "type7_switch"
|
|
1482
|
+
else:
|
|
1483
|
+
self._classicRxType9 += 1
|
|
1484
|
+
kind = "type9_netconf"
|
|
1485
|
+
|
|
1486
|
+
# Emit a few per-kind examples for reverse engineering.
|
|
1487
|
+
if self._classicRxKindSamples.get(kind, 0) < 3:
|
|
1488
|
+
self._classicRxKindSamples[kind] = self._classicRxKindSamples.get(kind, 0) + 1
|
|
1489
|
+
self._logger.warning(
|
|
1490
|
+
"[CASAMBI_CLASSIC_RX_KIND] kind=%s header=%s verified=%s sig_len=%d seq=%s payload_prefix=%s",
|
|
1491
|
+
kind,
|
|
1492
|
+
best["mode"],
|
|
1493
|
+
verified,
|
|
1494
|
+
best["sig_len"],
|
|
1495
|
+
None if best["seq"] is None else f"0x{best['seq']:04x}",
|
|
1496
|
+
b2a(payload[: min(len(payload), 32)]),
|
|
1497
|
+
)
|
|
1498
|
+
|
|
1227
1499
|
if self._logger.isEnabledFor(logging.DEBUG):
|
|
1228
1500
|
self._logger.debug(
|
|
1229
1501
|
"[CASAMBI_CLASSIC_RX_PAYLOAD] type=%d len=%d hex=%s",
|
|
@@ -1244,6 +1516,7 @@ class CasambiClient:
|
|
|
1244
1516
|
# Otherwise, attempt to parse a stream of Classic "command" records:
|
|
1245
1517
|
# record[0] = (len + 239) mod 256, so len = (b0 - 239) & 0xFF.
|
|
1246
1518
|
pos = 0
|
|
1519
|
+
parsed_any = False
|
|
1247
1520
|
while pos + 2 <= len(payload):
|
|
1248
1521
|
enc_len = payload[pos]
|
|
1249
1522
|
rec_len = (enc_len - 239) & 0xFF
|
|
@@ -1251,6 +1524,7 @@ class CasambiClient:
|
|
|
1251
1524
|
break
|
|
1252
1525
|
rec = payload[pos : pos + rec_len]
|
|
1253
1526
|
pos += rec_len
|
|
1527
|
+
parsed_any = True
|
|
1254
1528
|
|
|
1255
1529
|
typ = rec[1]
|
|
1256
1530
|
ordinal = typ & 0x3F
|
|
@@ -1278,6 +1552,25 @@ class CasambiClient:
|
|
|
1278
1552
|
b2a(rec_payload),
|
|
1279
1553
|
)
|
|
1280
1554
|
|
|
1555
|
+
if parsed_any:
|
|
1556
|
+
self._classicRxCmdStream += 1
|
|
1557
|
+
kind = "cmdstream"
|
|
1558
|
+
else:
|
|
1559
|
+
self._classicRxUnknown += 1
|
|
1560
|
+
kind = "unknown"
|
|
1561
|
+
|
|
1562
|
+
if self._classicRxKindSamples.get(kind, 0) < 3:
|
|
1563
|
+
self._classicRxKindSamples[kind] = self._classicRxKindSamples.get(kind, 0) + 1
|
|
1564
|
+
self._logger.warning(
|
|
1565
|
+
"[CASAMBI_CLASSIC_RX_KIND] kind=%s header=%s verified=%s sig_len=%d seq=%s payload_prefix=%s",
|
|
1566
|
+
kind,
|
|
1567
|
+
best["mode"],
|
|
1568
|
+
verified,
|
|
1569
|
+
best["sig_len"],
|
|
1570
|
+
None if best["seq"] is None else f"0x{best['seq']:04x}",
|
|
1571
|
+
b2a(payload[: min(len(payload), 32)]),
|
|
1572
|
+
)
|
|
1573
|
+
|
|
1281
1574
|
# Any trailing bytes that don't form a full record are logged for analysis.
|
|
1282
1575
|
if self._logger.isEnabledFor(logging.DEBUG) and pos < len(payload):
|
|
1283
1576
|
self._logger.debug(
|
|
@@ -1547,6 +1840,10 @@ class CasambiClient:
|
|
|
1547
1840
|
async def disconnect(self) -> None:
|
|
1548
1841
|
self._logger.info("Disconnecting...")
|
|
1549
1842
|
|
|
1843
|
+
if self._classicNoRxTask is not None:
|
|
1844
|
+
self._classicNoRxTask.cancel()
|
|
1845
|
+
self._classicNoRxTask = None
|
|
1846
|
+
|
|
1550
1847
|
if self._callbackTask is not None:
|
|
1551
1848
|
# Cancel and await the background callback task to avoid
|
|
1552
1849
|
# 'Task was destroyed but it is pending' warnings.
|
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.dev7.dist-info → casambi_bt_revamped-0.3.12.dev9.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.dev9
|
|
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
|
{casambi_bt_revamped-0.3.12.dev7.dist-info → casambi_bt_revamped-0.3.12.dev9.dist-info}/RECORD
RENAMED
|
@@ -2,21 +2,21 @@ CasambiBt/__init__.py,sha256=iJdTF4oeXfj5d5gfGxQkacqUjtnQo0IW-zFPJvFjWWk,336
|
|
|
2
2
|
CasambiBt/_cache.py,sha256=3bQil8vhSy4f4sf9JusMfEdQC7d3cJuva9qHhyKro-0,3808
|
|
3
3
|
CasambiBt/_casambi.py,sha256=TN4ecgjm95nSJ4h9TsKayNn577Y82fdsGK4IGUZF23Q,40666
|
|
4
4
|
CasambiBt/_classic_crypto.py,sha256=6DcCOdjLQo7k2cOOutNdUKupykOG_E2TDDwg6fH-ODM,998
|
|
5
|
-
CasambiBt/_client.py,sha256=
|
|
6
|
-
CasambiBt/_constants.py,sha256=
|
|
5
|
+
CasambiBt/_client.py,sha256=yn6DckDCKap1YJ0xyDhk5wlvE6wvPIUGPe9M1veRDVs,77317
|
|
6
|
+
CasambiBt/_constants.py,sha256=86heoDdb5iPaRrPmK2DIIl-4uSxbFFcnCo9zlCvTLww,1290
|
|
7
7
|
CasambiBt/_discover.py,sha256=jLc6H69JddrCURgtANZEjws6_UbSzXJtvJkbKTaIUHY,1849
|
|
8
8
|
CasambiBt/_encryption.py,sha256=CLcoOOrggQqhJbnr_emBnEnkizpWDvb_0yFnitq4_FM,3831
|
|
9
9
|
CasambiBt/_invocation.py,sha256=fkG4R0Gv5_amFfD_P6DKuIEe3oKWZW0v8RSU8zDjPdI,2985
|
|
10
10
|
CasambiBt/_keystore.py,sha256=Jdiq0zMPDmhfpheSojKY6sTUpmVrvX_qOyO7yCYd3kw,2788
|
|
11
|
-
CasambiBt/_network.py,sha256=
|
|
11
|
+
CasambiBt/_network.py,sha256=ai1o3EybsAhjyPohSOxeE0cWoFvEqdcc3PE3uFDaTfE,21346
|
|
12
12
|
CasambiBt/_operation.py,sha256=Q5UccsrtNp_B_wWqwH_3eLFW_yF6A55FMmfUKDk2WrI,1059
|
|
13
13
|
CasambiBt/_switch_events.py,sha256=S8OD0dBcw5T4J2C7qfmOQMnTJ7omIXRUYv4PqDOB87E,13137
|
|
14
14
|
CasambiBt/_unit.py,sha256=nxbg_8UCCVB9WI8dUS21g2JrGyPKcefqKMSusMOhLOo,18721
|
|
15
|
-
CasambiBt/_version.py,sha256=
|
|
15
|
+
CasambiBt/_version.py,sha256=VtWwYBijhWhHtBHLmr-n1eqgiJjNkNATaUweLPXTAo4,337
|
|
16
16
|
CasambiBt/errors.py,sha256=1L_Q8og_N_BRYEKizghAQXr6tihlHykFgtcCHUDcBas,1961
|
|
17
17
|
CasambiBt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
-
casambi_bt_revamped-0.3.12.
|
|
19
|
-
casambi_bt_revamped-0.3.12.
|
|
20
|
-
casambi_bt_revamped-0.3.12.
|
|
21
|
-
casambi_bt_revamped-0.3.12.
|
|
22
|
-
casambi_bt_revamped-0.3.12.
|
|
18
|
+
casambi_bt_revamped-0.3.12.dev9.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
|
|
19
|
+
casambi_bt_revamped-0.3.12.dev9.dist-info/METADATA,sha256=R31XEvi4eCtF_CYH_DfCnN9lvMmhDS8zCYJlqFAuu2U,5877
|
|
20
|
+
casambi_bt_revamped-0.3.12.dev9.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
21
|
+
casambi_bt_revamped-0.3.12.dev9.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
|
|
22
|
+
casambi_bt_revamped-0.3.12.dev9.dist-info/RECORD,,
|
{casambi_bt_revamped-0.3.12.dev7.dist-info → casambi_bt_revamped-0.3.12.dev9.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|