casambi-bt-revamped 0.3.12.dev3__py3-none-any.whl → 0.3.12.dev5__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
@@ -100,6 +100,8 @@ class CasambiClient:
100
100
  # Determined at runtime by inspecting GATT services/characteristics.
101
101
  self._protocolMode: ProtocolMode | None = None
102
102
  self._dataCharUuid: str | None = None
103
+ # EVO only: protocolVersion from the device-provided NodeInfo (byte1).
104
+ self._deviceProtocolVersion: int | None = None
103
105
 
104
106
  # Classic protocol state
105
107
  self._classicConnHash8: bytes | None = None
@@ -119,14 +121,29 @@ class CasambiClient:
119
121
  def protocolMode(self) -> ProtocolMode | None:
120
122
  return self._protocolMode
121
123
 
122
- def _checkProtocolVersion(self, version: int) -> None:
124
+ def _checkProtocolVersion(self, version: int, *, source: str = "unknown") -> None:
125
+ strict = os.getenv("CASAMBI_BT_STRICT_PROTOCOL_VERSION", "").strip() in {
126
+ "1",
127
+ "true",
128
+ "TRUE",
129
+ "yes",
130
+ "YES",
131
+ }
123
132
  if version < MIN_VERSION:
124
- raise UnsupportedProtocolVersion(
125
- f"Legacy version aren't supported currently. Your network version is {version}. Minimum version is {MIN_VERSION}."
133
+ # Legacy protocol versions are intentionally allowed. We keep this check as a warning
134
+ # because packet layouts/handshakes may differ and we want actionable tester logs.
135
+ msg = (
136
+ f"Legacy protocol version detected ({source}={version}). "
137
+ f"Versions < {MIN_VERSION} are not fully verified; attempting to continue."
126
138
  )
139
+ if strict:
140
+ raise UnsupportedProtocolVersion(msg)
141
+ self._logger.warning(msg)
142
+ return
127
143
  if version > MAX_VERSION:
128
144
  self._logger.warning(
129
- "Version too new. Your network version is %i. Highest supported version is %i. Continue at your own risk.",
145
+ "Version too new (%s=%i). Highest supported version is %i. Continue at your own risk.",
146
+ source,
130
147
  version,
131
148
  MAX_VERSION,
132
149
  )
@@ -207,19 +224,57 @@ class CasambiClient:
207
224
  self._logger.info(f"Connected to {self.address}")
208
225
  self._connectionState = ConnectionState.CONNECTED
209
226
 
210
- # Detect protocol mode by available characteristics.
211
- services = await self._gattClient.get_services()
227
+ # Detect protocol mode.
228
+ #
229
+ # Important: Home Assistant wraps BleakClient (HaBleakClientWrapper) which does not implement
230
+ # `get_services()`. Therefore we use "try-read" probing instead of enumerating GATT services.
231
+ #
232
+ # Order:
233
+ # 1) Classic "non-conformant": CA51 (hash) + CA52 (data channel)
234
+ # 2) EVO: auth char read starts with 0x01 (NodeInfo)
235
+ # 3) Classic "conformant": auth char read returns connection hash (first 8 bytes used)
236
+
237
+ cloud_protocol = getattr(self._network, "protocolVersion", None)
238
+ ca51_prefix: bytes | None = None
239
+ ca51_err: str | None = None
240
+ auth_prefix: bytes | None = None
241
+ auth_err: str | None = None
242
+ device_nodeinfo_protocol: int | None = None
243
+
244
+ def _log_probe_summary(mode: str) -> None:
245
+ # One stable, high-signal line for testers.
246
+ self._logger.info(
247
+ "[CASAMBI_PROTOCOL_PROBE] address=%s mode=%s cloud_protocol=%s device_nodeinfo_protocol=%s "
248
+ "data_uuid=%s classic_hash8_present=%s auth_read_prefix=%s ca51_read_prefix=%s ca51_read_error=%s auth_read_error=%s",
249
+ self.address,
250
+ mode,
251
+ cloud_protocol,
252
+ device_nodeinfo_protocol,
253
+ self._dataCharUuid,
254
+ bool(classic_hash and len(classic_hash) >= 8),
255
+ auth_prefix,
256
+ ca51_prefix,
257
+ ca51_err,
258
+ auth_err,
259
+ )
212
260
 
213
- def _has_char(uuid: str) -> bool:
214
- uuid_l = uuid.lower()
215
- for s in services:
216
- for c in s.characteristics:
217
- if c.uuid.lower() == uuid_l:
218
- return True
219
- return False
261
+ classic_hash: bytes | None = None
262
+ try:
263
+ classic_hash = await self._gattClient.read_gatt_char(CASA_CLASSIC_HASH_CHAR_UUID)
264
+ ca51_prefix = b2a(classic_hash[:10]) if classic_hash else None
265
+ if self._logger.isEnabledFor(logging.DEBUG):
266
+ self._logger.debug(
267
+ "[CASAMBI_GATT_PROBE] read ca51 ok len=%d prefix=%s",
268
+ 0 if classic_hash is None else len(classic_hash),
269
+ ca51_prefix,
270
+ )
271
+ except Exception as e:
272
+ classic_hash = None
273
+ ca51_err = type(e).__name__
274
+ if self._logger.isEnabledFor(logging.DEBUG):
275
+ self._logger.debug("[CASAMBI_GATT_PROBE] read ca51 fail err=%s", ca51_err)
220
276
 
221
- # Classic (non-conformant) uses CA51 (connection hash) + CA52 (data channel).
222
- if _has_char(CASA_CLASSIC_HASH_CHAR_UUID) and _has_char(CASA_CLASSIC_DATA_CHAR_UUID):
277
+ if classic_hash and len(classic_hash) >= 8:
223
278
  if os.getenv("CASAMBI_BT_DISABLE_CLASSIC", "").strip() in {"1", "true", "TRUE", "yes", "YES"}:
224
279
  raise ProtocolError("Classic protocol detected but disabled via CASAMBI_BT_DISABLE_CLASSIC=1")
225
280
 
@@ -232,7 +287,7 @@ class CasambiClient:
232
287
  self._dataCharUuid = CASA_CLASSIC_DATA_CHAR_UUID
233
288
 
234
289
  # Read connection hash (first 8 bytes are used for CMAC signing).
235
- raw_hash = await self._gattClient.read_gatt_char(CASA_CLASSIC_HASH_CHAR_UUID)
290
+ raw_hash = classic_hash
236
291
  if raw_hash is None or len(raw_hash) < 8:
237
292
  raise ClassicHandshakeError(
238
293
  f"Classic connection hash read failed/too short (len={0 if raw_hash is None else len(raw_hash)})."
@@ -247,34 +302,105 @@ class CasambiClient:
247
302
  notify_params = inspect.signature(self._gattClient.start_notify).parameters
248
303
  if "bluez" in notify_params:
249
304
  notify_kwargs["bluez"] = {"use_start_notify": True}
250
- await self._gattClient.start_notify(
251
- CASA_CLASSIC_DATA_CHAR_UUID,
252
- self._queueCallback,
253
- **notify_kwargs,
254
- )
305
+ try:
306
+ await self._gattClient.start_notify(
307
+ CASA_CLASSIC_DATA_CHAR_UUID,
308
+ self._queueCallback,
309
+ **notify_kwargs,
310
+ )
311
+ except Exception as e:
312
+ # Some firmwares may expose Classic signing on the EVO UUID instead.
313
+ # Fall through to auth-char probing if CA52 isn't available.
314
+ if self._logger.isEnabledFor(logging.DEBUG):
315
+ self._logger.debug(
316
+ "[CASAMBI_GATT_PROBE] start_notify ca52 fail err=%s; trying auth UUID probing.",
317
+ type(e).__name__,
318
+ exc_info=True,
319
+ )
320
+ self._protocolMode = None
321
+ self._dataCharUuid = None
322
+ self._classicConnHash8 = None
323
+ # continue detection below
324
+ else:
325
+ # Classic has no EVO-style key exchange/auth; we can send immediately.
326
+ self._connectionState = ConnectionState.AUTHENTICATED
327
+ self._logger.info("Protocol mode selected: CLASSIC")
328
+ if self._logger.isEnabledFor(logging.DEBUG):
329
+ self._logger.debug("[CASAMBI_GATT_PROBE] start_notify ca52 ok")
330
+ self._logger.debug(
331
+ "[CASAMBI_CLASSIC_CONN_HASH] len=%d hash=%s",
332
+ len(self._classicConnHash8),
333
+ b2a(self._classicConnHash8),
334
+ )
335
+ _log_probe_summary("CLASSIC")
336
+ return
255
337
 
256
- # Classic has no EVO-style key exchange/auth; we can send immediately.
257
- self._connectionState = ConnectionState.AUTHENTICATED
258
- self._logger.info("Protocol mode selected: CLASSIC")
338
+ # Conformant devices can expose the Classic signed channel on the EVO-style UUID too.
339
+ first: bytes | None = None
340
+ try:
341
+ first = await self._gattClient.read_gatt_char(CASA_AUTH_CHAR_UUID)
342
+ auth_prefix = b2a(first[:10]) if first else None
259
343
  if self._logger.isEnabledFor(logging.DEBUG):
260
344
  self._logger.debug(
261
- "[CASAMBI_CLASSIC_CONN_HASH] len=%d hash=%s",
262
- len(self._classicConnHash8),
263
- b2a(self._classicConnHash8),
345
+ "[CASAMBI_GATT_PROBE] read auth ok len=%d first_byte=%s prefix=%s",
346
+ 0 if first is None else len(first),
347
+ None if not first else f"0x{first[0]:02x}",
348
+ auth_prefix,
264
349
  )
265
- return
350
+ except Exception as e:
351
+ first = None
352
+ auth_err = type(e).__name__
353
+ if self._logger.isEnabledFor(logging.DEBUG):
354
+ self._logger.debug("[CASAMBI_GATT_PROBE] read auth fail err=%s", auth_err)
355
+
356
+ if first and len(first) >= 2 and first[0] == 0x01:
357
+ # EVO NodeInfo packet starts with 0x01.
358
+ device_nodeinfo_protocol = first[1]
359
+ self._deviceProtocolVersion = device_nodeinfo_protocol
360
+ mtu = unit = flags = None
361
+ nonce_prefix = None
362
+ if len(first) >= 23:
363
+ try:
364
+ mtu, unit, flags, nonce = struct.unpack_from(">BHH16s", first, 2)
365
+ nonce_prefix = b2a(nonce[:8])
366
+ except Exception:
367
+ if self._logger.isEnabledFor(logging.DEBUG):
368
+ self._logger.debug("Failed to parse NodeInfo fields for logging.", exc_info=True)
266
369
 
267
- # Conformant devices can expose the Classic signed channel on the EVO-style UUID too.
268
- if _has_char(CASA_AUTH_CHAR_UUID):
269
- first = await self._gattClient.read_gatt_char(CASA_AUTH_CHAR_UUID)
270
- if first and len(first) >= 2 and first[0] == 0x01:
271
- # EVO NodeInfo packet starts with 0x01.
272
- self._protocolMode = ProtocolMode.EVO
273
- self._dataCharUuid = CASA_AUTH_CHAR_UUID
274
- self._checkProtocolVersion(self._network.protocolVersion)
275
- self._logger.info("Protocol mode selected: EVO")
276
- return
370
+ self._logger.info(
371
+ "[CASAMBI_EVO_NODEINFO] cloud_protocol=%s device_protocol=%s mtu=%s unit=%s flags=%s nonce_prefix=%s len=%d prefix=%s",
372
+ cloud_protocol,
373
+ device_nodeinfo_protocol,
374
+ mtu,
375
+ unit,
376
+ None if flags is None else f"0x{flags:04x}",
377
+ nonce_prefix,
378
+ len(first),
379
+ b2a(first[: min(len(first), 32)]),
380
+ )
381
+ if cloud_protocol is not None and device_nodeinfo_protocol != cloud_protocol:
382
+ self._logger.warning(
383
+ "[CASAMBI_EVO_NODEINFO_MISMATCH] cloud_protocol=%s device_protocol=%s",
384
+ cloud_protocol,
385
+ device_nodeinfo_protocol,
386
+ )
387
+ if len(first) < 23:
388
+ self._logger.warning(
389
+ "[CASAMBI_EVO_NODEINFO_SHORT] len=%d cloud_protocol=%s device_protocol=%s prefix=%s",
390
+ len(first),
391
+ cloud_protocol,
392
+ device_nodeinfo_protocol,
393
+ b2a(first[: min(len(first), 32)]),
394
+ )
395
+
396
+ self._protocolMode = ProtocolMode.EVO
397
+ self._dataCharUuid = CASA_AUTH_CHAR_UUID
398
+ self._checkProtocolVersion(device_nodeinfo_protocol, source="device_nodeinfo")
399
+ self._logger.info("Protocol mode selected: EVO")
400
+ _log_probe_summary("EVO")
401
+ return
277
402
 
403
+ if first is not None:
278
404
  # Otherwise, treat as Classic conformant: read provides connection hash.
279
405
  if os.getenv("CASAMBI_BT_DISABLE_CLASSIC", "").strip() in {"1", "true", "TRUE", "yes", "YES"}:
280
406
  raise ProtocolError("Classic protocol detected but disabled via CASAMBI_BT_DISABLE_CLASSIC=1")
@@ -282,9 +408,9 @@ class CasambiClient:
282
408
  raise ClassicKeysMissingError(
283
409
  "Classic protocol detected but network has no visitorKey/managerKey."
284
410
  )
285
- if first is None or len(first) < 8:
411
+ if len(first) < 8:
286
412
  raise ClassicHandshakeError(
287
- f"Classic connection hash read failed/too short (len={0 if first is None else len(first)})."
413
+ f"Classic connection hash read failed/too short (len={len(first)})."
288
414
  )
289
415
 
290
416
  self._protocolMode = ProtocolMode.CLASSIC
@@ -305,15 +431,17 @@ class CasambiClient:
305
431
  self._connectionState = ConnectionState.AUTHENTICATED
306
432
  self._logger.info("Protocol mode selected: CLASSIC")
307
433
  if self._logger.isEnabledFor(logging.DEBUG):
434
+ self._logger.debug("[CASAMBI_GATT_PROBE] start_notify auth ok (classic conformant)")
308
435
  self._logger.debug(
309
436
  "[CASAMBI_CLASSIC_CONN_HASH] len=%d hash=%s",
310
437
  len(self._classicConnHash8),
311
438
  b2a(self._classicConnHash8),
312
439
  )
440
+ _log_probe_summary("CLASSIC")
313
441
  return
314
442
 
315
443
  raise ProtocolError(
316
- "No supported Casambi characteristics found (Classic ca51/ca52 or EVO/Classic conformant auth char)."
444
+ "No supported Casambi characteristics found (Classic ca51/ca52 or EVO/Classic-conformant auth char)."
317
445
  )
318
446
 
319
447
  def _on_disconnect(self, client: BleakClient) -> None:
@@ -333,15 +461,54 @@ class CasambiClient:
333
461
  try:
334
462
  # Initiate communication with device
335
463
  firstResp = await self._gattClient.read_gatt_char(CASA_AUTH_CHAR_UUID)
336
- self._logger.debug(f"Got {b2a(firstResp)}")
464
+ if self._logger.isEnabledFor(logging.DEBUG):
465
+ self._logger.debug(
466
+ "[CASAMBI_EVO_NODEINFO_RAW] len=%d prefix=%s",
467
+ len(firstResp),
468
+ b2a(firstResp[: min(len(firstResp), 32)]),
469
+ )
470
+
471
+ cloud_protocol = getattr(self._network, "protocolVersion", None)
472
+ expected_protocol = self._deviceProtocolVersion or cloud_protocol
473
+
474
+ # EVO key exchange expects the NodeInfo packet (0x01 ...).
475
+ if len(firstResp) < 2 or firstResp[0] != 0x01:
476
+ self._logger.error(
477
+ "[CASAMBI_EVO_NODEINFO_UNEXPECTED] expected_prefix=01 len=%d prefix=%s",
478
+ len(firstResp),
479
+ b2a(firstResp[: min(len(firstResp), 32)]),
480
+ )
481
+ raise ProtocolError("Unexpected NodeInfo response while starting key exchange.")
482
+
483
+ device_protocol = firstResp[1]
484
+ self._deviceProtocolVersion = device_protocol
485
+ self._checkProtocolVersion(device_protocol, source="device_nodeinfo")
486
+
487
+ if expected_protocol is not None and device_protocol != expected_protocol:
488
+ self._logger.warning(
489
+ "[CASAMBI_EVO_NODEINFO_MISMATCH] expected_protocol=%s cloud_protocol=%s device_protocol=%s",
490
+ expected_protocol,
491
+ cloud_protocol,
492
+ device_protocol,
493
+ )
494
+ elif cloud_protocol is not None and device_protocol != cloud_protocol:
495
+ # Keep this separate to catch cloud/device mismatches even if we didn't have an expected protocol set.
496
+ self._logger.warning(
497
+ "[CASAMBI_EVO_NODEINFO_MISMATCH] expected_protocol=%s cloud_protocol=%s device_protocol=%s",
498
+ expected_protocol,
499
+ cloud_protocol,
500
+ device_protocol,
501
+ )
337
502
 
338
- # Check type and protocol version
339
- if not (
340
- firstResp[0] == 0x1 and firstResp[1] == self._network.protocolVersion
341
- ):
503
+ if len(firstResp) < 23:
342
504
  self._logger.error(
343
- "Unexpected answer from device! Wrong device or protocol version? Trying to continue."
505
+ "[CASAMBI_EVO_NODEINFO_SHORT] len=%d cloud_protocol=%s device_protocol=%s prefix=%s",
506
+ len(firstResp),
507
+ cloud_protocol,
508
+ device_protocol,
509
+ b2a(firstResp[: min(len(firstResp), 32)]),
344
510
  )
511
+ raise ProtocolError("NodeInfo response too short while starting key exchange.")
345
512
 
346
513
  # Parse device info
347
514
  self._mtu, self._unit, self._flags, self._nonce = struct.unpack_from(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: casambi-bt-revamped
3
- Version: 0.3.12.dev3
3
+ Version: 0.3.12.dev5
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,7 +2,7 @@ CasambiBt/__init__.py,sha256=TW445xSu5PV3TyMjJfwaA1JoWvQQ8LXhZgGdDTfWf3s,302
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=nOOvs9qyQdc8D37DRejOY-15iTcSolK2EAlOOvpg_Xo,49990
5
+ CasambiBt/_client.py,sha256=AASUN9OvmTIg9IeYMEvLI8kBEYbV9FapIuyDXGZMpME,57883
6
6
  CasambiBt/_constants.py,sha256=sbElg5W8eeQvvL1rHn_E0jhP1wOrrabc7dFLLnlDMsU,810
7
7
  CasambiBt/_discover.py,sha256=jLc6H69JddrCURgtANZEjws6_UbSzXJtvJkbKTaIUHY,1849
8
8
  CasambiBt/_encryption.py,sha256=CLcoOOrggQqhJbnr_emBnEnkizpWDvb_0yFnitq4_FM,3831
@@ -14,8 +14,8 @@ CasambiBt/_switch_events.py,sha256=S8OD0dBcw5T4J2C7qfmOQMnTJ7omIXRUYv4PqDOB87E,1
14
14
  CasambiBt/_unit.py,sha256=KIpvUT_Wm-O2Lmb1JVnNO625-j5j7GqufmZzfTR-jW0,18587
15
15
  CasambiBt/errors.py,sha256=1L_Q8og_N_BRYEKizghAQXr6tihlHykFgtcCHUDcBas,1961
16
16
  CasambiBt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
- casambi_bt_revamped-0.3.12.dev3.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
18
- casambi_bt_revamped-0.3.12.dev3.dist-info/METADATA,sha256=6oEPqnaAaaI5RZpDRNoquX7c9uHW742-J81FmPe1zNI,5877
19
- casambi_bt_revamped-0.3.12.dev3.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
20
- casambi_bt_revamped-0.3.12.dev3.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
21
- casambi_bt_revamped-0.3.12.dev3.dist-info/RECORD,,
17
+ casambi_bt_revamped-0.3.12.dev5.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
18
+ casambi_bt_revamped-0.3.12.dev5.dist-info/METADATA,sha256=mNRrJjPdZBbSvEJp9RBAYkv7wU0-znKwBhpR5XEXtLo,5877
19
+ casambi_bt_revamped-0.3.12.dev5.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
20
+ casambi_bt_revamped-0.3.12.dev5.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
21
+ casambi_bt_revamped-0.3.12.dev5.dist-info/RECORD,,