casambi-bt-revamped 0.3.12.dev8__tar.gz → 0.3.12.dev9__tar.gz

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.
Files changed (31) hide show
  1. {casambi_bt_revamped-0.3.12.dev8/src/casambi_bt_revamped.egg-info → casambi_bt_revamped-0.3.12.dev9}/PKG-INFO +1 -1
  2. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/setup.cfg +1 -1
  3. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/src/CasambiBt/_client.py +180 -17
  4. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/src/CasambiBt/_constants.py +9 -0
  5. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/src/CasambiBt/_network.py +26 -19
  6. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/src/CasambiBt/_version.py +1 -1
  7. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9/src/casambi_bt_revamped.egg-info}/PKG-INFO +1 -1
  8. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/LICENSE +0 -0
  9. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/README.md +0 -0
  10. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/pyproject.toml +0 -0
  11. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/src/CasambiBt/__init__.py +0 -0
  12. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/src/CasambiBt/_cache.py +0 -0
  13. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/src/CasambiBt/_casambi.py +0 -0
  14. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/src/CasambiBt/_classic_crypto.py +0 -0
  15. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/src/CasambiBt/_discover.py +0 -0
  16. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/src/CasambiBt/_encryption.py +0 -0
  17. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/src/CasambiBt/_invocation.py +0 -0
  18. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/src/CasambiBt/_keystore.py +0 -0
  19. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/src/CasambiBt/_operation.py +0 -0
  20. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/src/CasambiBt/_switch_events.py +0 -0
  21. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/src/CasambiBt/_unit.py +0 -0
  22. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/src/CasambiBt/errors.py +0 -0
  23. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/src/CasambiBt/py.typed +0 -0
  24. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/src/casambi_bt_revamped.egg-info/SOURCES.txt +0 -0
  25. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/src/casambi_bt_revamped.egg-info/dependency_links.txt +0 -0
  26. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/src/casambi_bt_revamped.egg-info/requires.txt +0 -0
  27. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/src/casambi_bt_revamped.egg-info/top_level.txt +0 -0
  28. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/tests/test_classic_protocol.py +0 -0
  29. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/tests/test_legacy_protocol_handling.py +0 -0
  30. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/tests/test_switch_event_logs.py +0 -0
  31. {casambi_bt_revamped-0.3.12.dev8 → casambi_bt_revamped-0.3.12.dev9}/tests/test_unit_state_logs.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: casambi-bt-revamped
3
- Version: 0.3.12.dev8
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
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = casambi-bt-revamped
3
- version = 0.3.12.dev8
3
+ version = 0.3.12.dev9
4
4
  author = rankjie
5
5
  author_email = rankjie@gmail.com
6
6
  description = Forked Casambi Bluetooth client library with switch event support, use original if no special need. https://github.com/lkempf/casambi-bt
@@ -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 CASA_CLASSIC_DATA_CHAR_UUID, CASA_CLASSIC_HASH_CHAR_UUID
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()
@@ -186,6 +198,23 @@ class CasambiClient:
186
198
  self._outPacketCount = 2
187
199
  self._inPacketCount = 1
188
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
+
189
218
  # Reset callback queue
190
219
  self._callbackQueue = asyncio.Queue()
191
220
  self._callbackTask = asyncio.create_task(self._processCallbacks())
@@ -262,28 +291,43 @@ class CasambiClient:
262
291
  cloud_protocol = getattr(self._network, "protocolVersion", None)
263
292
  ca51_prefix: bytes | None = None
264
293
  ca51_err: str | None = None
294
+ ca52_notify_err: str | None = None
295
+ ca53_notify_err: str | None = None
265
296
  auth_prefix: bytes | None = None
266
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
267
301
  device_nodeinfo_protocol: int | None = None
268
302
 
269
303
  def _log_probe_summary(mode: str, *, classic_variant: str | None = None) -> None:
270
304
  # One stable, high-signal line for testers.
271
305
  self._logger.warning(
272
306
  "[CASAMBI_PROTOCOL_PROBE] address=%s mode=%s cloud_protocol=%s nodeinfo_b1=%s data_uuid=%s "
273
- "classic_variant=%s ca51_hash8_present=%s conn_hash8_ready=%s "
274
- "auth_read_prefix=%s ca51_read_prefix=%s ca51_read_error=%s auth_read_error=%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",
275
311
  self.address,
276
312
  mode,
277
313
  cloud_protocol,
278
314
  device_nodeinfo_protocol,
279
315
  self._dataCharUuid,
280
316
  classic_variant,
317
+ self._classicHashSource,
318
+ self._classicTxCharUuid,
319
+ sorted(self._classicNotifyCharUuids) if self._classicNotifyCharUuids else None,
281
320
  bool(classic_hash and len(classic_hash) >= 8),
282
321
  self._classicConnHash8 is not None,
283
322
  auth_prefix,
284
323
  ca51_prefix,
285
324
  ca51_err,
286
325
  auth_err,
326
+ ca52_notify_err,
327
+ ca53_notify_err,
328
+ c0002_prefix,
329
+ c0002_err,
330
+ c0003_notify_err,
287
331
  )
288
332
 
289
333
  classic_hash: bytes | None = None
@@ -305,7 +349,9 @@ class CasambiClient:
305
349
  if classic_hash and len(classic_hash) >= 8:
306
350
  self._protocolMode = ProtocolMode.CLASSIC
307
351
  self._dataCharUuid = CASA_CLASSIC_DATA_CHAR_UUID
352
+ self._classicTxCharUuid = CASA_CLASSIC_DATA_CHAR_UUID
308
353
  self._classicHeaderMode = "legacy"
354
+ self._classicHashSource = "ca51"
309
355
 
310
356
  # Read connection hash (first 8 bytes are used for CMAC signing).
311
357
  raw_hash = classic_hash
@@ -330,6 +376,7 @@ class CasambiClient:
330
376
  **notify_kwargs,
331
377
  )
332
378
  except Exception as e:
379
+ ca52_notify_err = type(e).__name__
333
380
  # Some firmwares may expose Classic signing on the EVO UUID instead.
334
381
  # Fall through to auth-char probing if CA52 isn't available.
335
382
  if self._logger.isEnabledFor(logging.DEBUG):
@@ -341,8 +388,25 @@ class CasambiClient:
341
388
  self._protocolMode = None
342
389
  self._dataCharUuid = None
343
390
  self._classicConnHash8 = None
391
+ self._classicTxCharUuid = None
392
+ self._classicNotifyCharUuids.clear()
393
+ self._classicHeaderMode = None
394
+ self._classicHashSource = None
344
395
  # continue detection below
345
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
+
346
410
  # Classic has no EVO-style key exchange/auth; we can send immediately.
347
411
  self._connectionState = ConnectionState.AUTHENTICATED
348
412
  self._logger.info("Protocol mode selected: CLASSIC")
@@ -354,10 +418,11 @@ class CasambiClient:
354
418
  b2a(self._classicConnHash8),
355
419
  )
356
420
  self._logger.warning(
357
- "[CASAMBI_CLASSIC_SELECTED] address=%s variant=ca52_legacy data_uuid=%s start_notify_uuid=%s header_mode=%s conn_hash8_prefix=%s",
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",
358
422
  self.address,
359
423
  self._dataCharUuid,
360
- CASA_CLASSIC_DATA_CHAR_UUID,
424
+ self._classicTxCharUuid,
425
+ sorted(self._classicNotifyCharUuids) if self._classicNotifyCharUuids else None,
361
426
  self._classicHeaderMode,
362
427
  b2a(self._classicConnHash8),
363
428
  )
@@ -368,6 +433,8 @@ class CasambiClient:
368
433
  getattr(self._network, "isManager", lambda: False)(),
369
434
  )
370
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))
371
438
  return
372
439
 
373
440
  # Conformant devices can expose the Classic signed channel on the EVO-style UUID too.
@@ -438,34 +505,79 @@ class CasambiClient:
438
505
 
439
506
  self._protocolMode = ProtocolMode.CLASSIC
440
507
  self._dataCharUuid = CASA_AUTH_CHAR_UUID
508
+ self._classicTxCharUuid = CASA_AUTH_CHAR_UUID
441
509
  self._classicHeaderMode = "conformant"
510
+ self._classicHashSource = "ca52_0001"
442
511
  self._classicConnHash8 = bytes(first[:8])
443
512
  self._classicCmdDiv = int.from_bytes(os.urandom(1), "big") or 1
444
513
  self._classicTxSeq = 0
445
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
+
446
533
  notify_kwargs: dict[str, Any] = {}
447
534
  notify_params = inspect.signature(self._gattClient.start_notify).parameters
448
535
  if "bluez" in notify_params:
449
536
  notify_kwargs["bluez"] = {"use_start_notify": True}
450
- await self._gattClient.start_notify(
451
- CASA_AUTH_CHAR_UUID,
452
- self._queueCallback,
453
- **notify_kwargs,
454
- )
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
+
455
560
  self._connectionState = ConnectionState.AUTHENTICATED
456
561
  self._logger.info("Protocol mode selected: CLASSIC")
457
562
  if self._logger.isEnabledFor(logging.DEBUG):
458
- self._logger.debug("[CASAMBI_GATT_PROBE] start_notify auth ok (classic conformant)")
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
+ )
459
570
  self._logger.debug(
460
571
  "[CASAMBI_CLASSIC_CONN_HASH] len=%d hash=%s",
461
572
  len(self._classicConnHash8),
462
573
  b2a(self._classicConnHash8),
463
574
  )
464
575
  self._logger.warning(
465
- "[CASAMBI_CLASSIC_SELECTED] address=%s variant=auth_uuid_conformant data_uuid=%s start_notify_uuid=%s header_mode=%s conn_hash8_prefix=%s",
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",
466
577
  self.address,
467
578
  self._dataCharUuid,
468
- CASA_AUTH_CHAR_UUID,
579
+ self._classicTxCharUuid,
580
+ sorted(self._classicNotifyCharUuids) if self._classicNotifyCharUuids else None,
469
581
  self._classicHeaderMode,
470
582
  b2a(self._classicConnHash8),
471
583
  )
@@ -476,6 +588,7 @@ class CasambiClient:
476
588
  getattr(self._network, "isManager", lambda: False)(),
477
589
  )
478
590
  _log_probe_summary("CLASSIC", classic_variant="auth_uuid_conformant")
591
+ self._classicNoRxTask = asyncio.create_task(self._classic_no_rx_watchdog(30.0))
479
592
  return
480
593
 
481
594
  _log_probe_summary("UNKNOWN")
@@ -483,12 +596,45 @@ class CasambiClient:
483
596
  "No supported Casambi characteristics found (Classic ca51/ca52 or EVO/Classic-conformant auth char)."
484
597
  )
485
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
+
486
629
  def _on_disconnect(self, client: BleakClient) -> None:
487
630
  if self._connectionState != ConnectionState.NONE:
488
631
  self._logger.info(f"Received disconnect callback from {self.address}")
489
632
  if self._connectionState == ConnectionState.AUTHENTICATED:
490
633
  self._logger.debug("Executing disconnect callback.")
491
634
  self._disconnectedCallback()
635
+ if self._classicNoRxTask is not None:
636
+ self._classicNoRxTask.cancel()
637
+ self._classicNoRxTask = None
492
638
  self._connectionState = ConnectionState.NONE
493
639
 
494
640
  async def exchangeKey(self) -> None:
@@ -862,8 +1008,9 @@ class CasambiClient:
862
1008
  self._checkState(ConnectionState.AUTHENTICATED)
863
1009
  if self._protocolMode != ProtocolMode.CLASSIC:
864
1010
  raise ProtocolError("Classic send called while not in Classic protocol mode.")
865
- if not self._dataCharUuid:
866
- raise ProtocolError("Classic data characteristic UUID not set.")
1011
+ tx_uuid = self._classicTxCharUuid or self._dataCharUuid
1012
+ if not tx_uuid:
1013
+ raise ProtocolError("Classic TX characteristic UUID not set.")
867
1014
  if self._classicConnHash8 is None:
868
1015
  raise ClassicHandshakeError("Classic connection hash not available.")
869
1016
 
@@ -974,12 +1121,13 @@ class CasambiClient:
974
1121
  if self._logLimiter.allow("classic_tx", burst=50, window_s=60.0):
975
1122
  auth_str = f"0x{auth_level:02x}" if header_mode == "conformant" else None
976
1123
  self._logger.warning(
977
- "[CASAMBI_CLASSIC_TX] header=%s key=%s signed=%s auth=%s sig_len=%d seq=%s "
1124
+ "[CASAMBI_CLASSIC_TX] header=%s key=%s signed=%s tx_uuid=%s auth=%s sig_len=%d seq=%s "
978
1125
  "cmd_len=%d cmd_ord=%s target=%s div=%s lifetime=%s payload_len=%s "
979
1126
  "total_len=%d prefix=%s",
980
1127
  header_mode,
981
1128
  key_name,
982
1129
  signed,
1130
+ tx_uuid,
983
1131
  auth_str,
984
1132
  sig_len,
985
1133
  None if seq is None else f"0x{seq:04x}",
@@ -995,11 +1143,20 @@ class CasambiClient:
995
1143
 
996
1144
  # Classic packets can exceed 20 bytes when using a 16-byte manager signature.
997
1145
  # Bleak needs a write-with-response for long writes on most backends.
998
- await self._gattClient.write_gatt_char(self._dataCharUuid, bytes(pkt), response=True)
1146
+ await self._gattClient.write_gatt_char(tx_uuid, bytes(pkt), response=True)
999
1147
 
1000
1148
  def _establishedNofityCallback(
1001
1149
  self, handle: BleakGATTCharacteristic, data: bytes
1002
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
1003
1160
  if self._protocolMode == ProtocolMode.CLASSIC:
1004
1161
  self._classicEstablishedNotifyCallback(handle, data)
1005
1162
  return
@@ -1074,6 +1231,8 @@ class CasambiClient:
1074
1231
  """
1075
1232
  self._inPacketCount += 1
1076
1233
  self._classicRxFrames += 1
1234
+ if self._classicFirstRxTs is None:
1235
+ self._classicFirstRxTs = time.monotonic()
1077
1236
 
1078
1237
  raw = bytes(data)
1079
1238
  if self._logger.isEnabledFor(logging.DEBUG):
@@ -1681,6 +1840,10 @@ class CasambiClient:
1681
1840
  async def disconnect(self) -> None:
1682
1841
  self._logger.info("Disconnecting...")
1683
1842
 
1843
+ if self._classicNoRxTask is not None:
1844
+ self._classicNoRxTask.cancel()
1845
+ self._classicNoRxTask = None
1846
+
1684
1847
  if self._callbackTask is not None:
1685
1848
  # Cancel and await the background callback task to avoid
1686
1849
  # 'Task was destroyed but it is pending' warnings.
@@ -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
@@ -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
- self._logger.error(
305
- "Update failed: %s body_prefix=%r",
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
- (res.text or "")[:500],
308
- )
309
- raise NetworkUpdateError("Could not update network!")
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
@@ -7,4 +7,4 @@ Avoid using importlib.metadata in hot paths by providing a static version string
7
7
  __all__ = ["__version__"]
8
8
 
9
9
  # NOTE: Must match `casambi-bt/setup.cfg` [metadata] version.
10
- __version__ = "0.3.12.dev8"
10
+ __version__ = "0.3.12.dev9"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: casambi-bt-revamped
3
- Version: 0.3.12.dev8
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