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 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
- # EnumC1754f ordinals (ground truth: casambi-android u1.EnumC1754f):
210
- # - AllUnitsLevel=4, UnitLevel=7, GroupLevel=26
211
- if isinstance(target, Unit):
212
- cmd = self._casaClient.buildClassicCommand(7, payload, target_id=target.deviceId)
213
- elif isinstance(target, Group):
214
- cmd = self._casaClient.buildClassicCommand(26, payload, target_id=target.groudId)
215
- elif target is None:
216
- cmd = self._casaClient.buildClassicCommand(4, payload)
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
- raise TypeError(f"Unkown target type {type(target)}")
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
@@ -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(conn_hash8)
21
- cmac.update(payload)
22
- return cmac.finalize()
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
- mac = classic_cmac(key, conn_hash8, payload)
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 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()
@@ -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 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",
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 start_notify_uuid=%s header_mode=%s conn_hash8_prefix=%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
- CASA_CLASSIC_DATA_CHAR_UUID,
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
- await self._gattClient.start_notify(
451
- CASA_AUTH_CHAR_UUID,
452
- self._queueCallback,
453
- **notify_kwargs,
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
- self._logger.debug("[CASAMBI_GATT_PROBE] start_notify auth ok (classic conformant)")
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 start_notify_uuid=%s header_mode=%s conn_hash8_prefix=%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
- CASA_AUTH_CHAR_UUID,
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
- if not self._dataCharUuid:
866
- raise ProtocolError("Classic data characteristic UUID not set.")
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
- await self._gattClient.write_gatt_char(self._dataCharUuid, bytes(pkt), response=True)
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
- 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.dev8"
10
+ __version__ = "0.3.12.dev10"
@@ -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.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,,