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 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 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()
@@ -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
- "data_uuid=%s classic_hash8_present=%s auth_read_prefix=%s ca51_read_prefix=%s ca51_read_error=%s auth_read_error=%s",
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
- _log_probe_summary("CLASSIC")
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
- await self._gattClient.start_notify(
427
- CASA_AUTH_CHAR_UUID,
428
- self._queueCallback,
429
- **notify_kwargs,
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
- 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
+ )
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
- _log_probe_summary("CLASSIC")
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
- if not self._dataCharUuid:
828
- 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.")
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 auth=0x%02x sig_len=%d seq=%s cmd_len=%d total_len=%d prefix=%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
- auth_level,
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(self._dataCharUuid, bytes(pkt), response=True)
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
- 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
CasambiBt/_version.py CHANGED
@@ -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.dev7"
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.dev7
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
@@ -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=PNYBwMdehh-YvSdxf8I-74bpn008VjNvwZyru5H_LuM,63618
6
- CasambiBt/_constants.py,sha256=sbElg5W8eeQvvL1rHn_E0jhP1wOrrabc7dFLLnlDMsU,810
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=nB_pRB9dZL6P7THeuOce7ctWd0wXyCWF13h67SauZVQ,20714
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=eONTRjFMNE1oRTOTcoScFZ4eVqZj6WKN07jnjauq8Ao,337
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.dev7.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
19
- casambi_bt_revamped-0.3.12.dev7.dist-info/METADATA,sha256=q4QM-1wpUKiobHPUoZXXqfwQlI_hf5iA_aYS4fBhPLE,5877
20
- casambi_bt_revamped-0.3.12.dev7.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
21
- casambi_bt_revamped-0.3.12.dev7.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
22
- casambi_bt_revamped-0.3.12.dev7.dist-info/RECORD,,
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,,