casambi-bt-revamped 0.3.12.dev13__tar.gz → 0.3.12.dev15__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.dev13/src/casambi_bt_revamped.egg-info → casambi_bt_revamped-0.3.12.dev15}/PKG-INFO +1 -1
  2. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/setup.cfg +1 -1
  3. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_client.py +244 -55
  4. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_version.py +1 -1
  5. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15/src/casambi_bt_revamped.egg-info}/PKG-INFO +1 -1
  6. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/LICENSE +0 -0
  7. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/README.md +0 -0
  8. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/pyproject.toml +0 -0
  9. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/__init__.py +0 -0
  10. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_cache.py +0 -0
  11. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_casambi.py +0 -0
  12. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_classic_crypto.py +0 -0
  13. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_constants.py +0 -0
  14. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_discover.py +0 -0
  15. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_encryption.py +0 -0
  16. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_invocation.py +0 -0
  17. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_keystore.py +0 -0
  18. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_network.py +0 -0
  19. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_operation.py +0 -0
  20. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_switch_events.py +0 -0
  21. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/_unit.py +0 -0
  22. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/errors.py +0 -0
  23. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/CasambiBt/py.typed +0 -0
  24. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/casambi_bt_revamped.egg-info/SOURCES.txt +0 -0
  25. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/casambi_bt_revamped.egg-info/dependency_links.txt +0 -0
  26. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/casambi_bt_revamped.egg-info/requires.txt +0 -0
  27. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/src/casambi_bt_revamped.egg-info/top_level.txt +0 -0
  28. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/tests/test_classic_protocol.py +0 -0
  29. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/tests/test_legacy_protocol_handling.py +0 -0
  30. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/tests/test_switch_event_logs.py +0 -0
  31. {casambi_bt_revamped-0.3.12.dev13 → casambi_bt_revamped-0.3.12.dev15}/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.dev13
3
+ Version: 0.3.12.dev15
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.dev13
3
+ version = 0.3.12.dev15
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
@@ -9,7 +9,7 @@ from binascii import b2a_hex as b2a
9
9
  from collections.abc import Callable
10
10
  from enum import Enum, IntEnum, auto, unique
11
11
  from hashlib import sha256
12
- from typing import Any, Final
12
+ from typing import Any, Final, Literal
13
13
 
14
14
  from bleak import BleakClient
15
15
  from bleak.backends.characteristic import BleakGATTCharacteristic
@@ -366,6 +366,24 @@ class CasambiClient:
366
366
  f"Classic connection hash read failed/too short (len={0 if raw_hash is None else len(raw_hash)})."
367
367
  )
368
368
  self._classicConnHash8 = bytes(raw_hash[:8])
369
+
370
+ # Parse Android's extended connection hash fields for diagnostics.
371
+ # Offset 8: unitId, 9: flags_lo, 10: MTU, 11: protocolVersion, 12: flags_hi
372
+ if len(raw_hash) >= 13:
373
+ ext_unit_id = raw_hash[8]
374
+ ext_flags_lo = raw_hash[9]
375
+ ext_mtu = raw_hash[10]
376
+ ext_proto_ver = raw_hash[11]
377
+ ext_flags_hi = raw_hash[12]
378
+ self._logger.warning(
379
+ "[CASAMBI_CLASSIC_CONN_HASH_EXT] variant=legacy unitId=%d flags=0x%04x mtu=%d protocolVersion=%d raw=%s",
380
+ ext_unit_id,
381
+ (ext_flags_hi << 8) | ext_flags_lo,
382
+ ext_mtu,
383
+ ext_proto_ver,
384
+ b2a(bytes(raw_hash[:min(len(raw_hash), 20)])),
385
+ )
386
+
369
387
  # Android seeds the command divider with a random byte on startup (u1.C1751c).
370
388
  self._classicCmdDiv = int.from_bytes(os.urandom(1), "big") or 1
371
389
  self._classicTxSeq = 0
@@ -516,6 +534,24 @@ class CasambiClient:
516
534
  self._classicHeaderMode = "conformant"
517
535
  self._classicHashSource = "ca52_0001"
518
536
  self._classicConnHash8 = bytes(first[:8])
537
+
538
+ # Parse Android's extended connection hash fields for diagnostics.
539
+ # Offset 8: unitId, 9: flags_lo, 10: MTU, 11: protocolVersion, 12: flags_hi
540
+ if len(first) >= 13:
541
+ ext_unit_id = first[8]
542
+ ext_flags_lo = first[9]
543
+ ext_mtu = first[10]
544
+ ext_proto_ver = first[11]
545
+ ext_flags_hi = first[12]
546
+ self._logger.warning(
547
+ "[CASAMBI_CLASSIC_CONN_HASH_EXT] variant=conformant unitId=%d flags=0x%04x mtu=%d protocolVersion=%d raw=%s",
548
+ ext_unit_id,
549
+ (ext_flags_hi << 8) | ext_flags_lo,
550
+ ext_mtu,
551
+ ext_proto_ver,
552
+ b2a(bytes(first[:min(len(first), 20)])),
553
+ )
554
+
519
555
  self._classicCmdDiv = int.from_bytes(os.urandom(1), "big") or 1
520
556
  self._classicTxSeq = 0
521
557
 
@@ -764,6 +800,11 @@ class CasambiClient:
764
800
  await self._activityLock.acquire()
765
801
  try:
766
802
  self._callbackMulitplexer(handle, data)
803
+ except Exception:
804
+ self._logger.warning(
805
+ "[CASAMBI_CALLBACK_ERROR] unhandled exception in callback multiplexer",
806
+ exc_info=True,
807
+ )
767
808
  finally:
768
809
  self._callbackQueue.task_done()
769
810
  self._activityLock.release()
@@ -775,6 +816,10 @@ class CasambiClient:
775
816
  def _callbackMulitplexer(
776
817
  self, handle: BleakGATTCharacteristic, data: bytes
777
818
  ) -> None:
819
+ if self._logger.isEnabledFor(logging.DEBUG):
820
+ self._logger.debug(
821
+ "[CASAMBI_MUX] state=%s len=%d", self._connectionState, len(data)
822
+ )
778
823
  if self._connectionState == ConnectionState.CONNECTED:
779
824
  self._exchNofityCallback(handle, data)
780
825
  elif self._connectionState == ConnectionState.KEY_EXCHANGED:
@@ -1045,11 +1090,18 @@ class CasambiClient:
1045
1090
  else:
1046
1091
  return bytes([counter, unit_id & 0xFF, 1, dimmer & 0xFF])
1047
1092
 
1048
- async def _sendClassic(self, command_bytes: bytes) -> None:
1093
+ async def _sendClassic(
1094
+ self,
1095
+ command_bytes: bytes,
1096
+ *,
1097
+ target_uuid: str | None = None,
1098
+ key_preference: Literal["auto", "visitor", "manager"] = "auto",
1099
+ response: bool | None = None,
1100
+ ) -> None:
1049
1101
  self._checkState(ConnectionState.AUTHENTICATED)
1050
1102
  if self._protocolMode != ProtocolMode.CLASSIC:
1051
1103
  raise ProtocolError("Classic send called while not in Classic protocol mode.")
1052
- tx_uuid = self._classicTxCharUuid or self._dataCharUuid
1104
+ tx_uuid = target_uuid or self._classicTxCharUuid or self._dataCharUuid
1053
1105
  if not tx_uuid:
1054
1106
  raise ProtocolError("Classic TX characteristic UUID not set.")
1055
1107
  if self._classicConnHash8 is None:
@@ -1086,26 +1138,41 @@ class CasambiClient:
1086
1138
  # If parsing fails, keep fields as None.
1087
1139
  pass
1088
1140
 
1089
- # Key selection mirrors Android's intent:
1090
- # - Use manager key if our cloud session is manager and a managerKey exists.
1091
- # - Else use visitor key if present.
1092
- # - Else fall back to manager key if present.
1093
- # - Else send an unsigned frame (signature bytes remain zeros), which Android does when keys are null.
1141
+ # Classic key selection:
1142
+ #
1143
+ # Android (v3.16) explicitly uses "visitor" signing (auth_level=2 / 4-byte sig)
1144
+ # for the Classic enable-notify bootstrap packets (sendVersion + sendTime), even
1145
+ # when a managerKey exists.
1146
+ #
1147
+ # For normal commands we keep the historical behavior ("auto" == prefer manager
1148
+ # when the cloud session is manager), but allow overrides so init can match Android.
1094
1149
  key_name = "none"
1095
- auth_level = 0x02 # visitor by default
1096
- key = None
1097
- if manager_key is not None and getattr(self._network, "isManager", lambda: False)():
1098
- key_name = "manager"
1099
- auth_level = 0x03
1100
- key = manager_key
1101
- elif visitor_key is not None:
1102
- key_name = "visitor"
1103
- auth_level = 0x02
1104
- key = visitor_key
1105
- elif manager_key is not None:
1106
- key_name = "manager"
1107
- auth_level = 0x03
1108
- key = manager_key
1150
+ auth_level = 0x02
1151
+ key: bytes | None = None
1152
+
1153
+ if key_preference == "visitor":
1154
+ if visitor_key is not None:
1155
+ key_name, auth_level, key = "visitor", 0x02, visitor_key
1156
+ elif manager_key is not None:
1157
+ # Fallback: some networks have managerKey only.
1158
+ key_name, auth_level, key = "manager", 0x03, manager_key
1159
+ else:
1160
+ key_name, auth_level, key = "none", 0x02, None
1161
+ elif key_preference == "manager":
1162
+ if manager_key is not None:
1163
+ key_name, auth_level, key = "manager", 0x03, manager_key
1164
+ elif visitor_key is not None:
1165
+ key_name, auth_level, key = "visitor", 0x02, visitor_key
1166
+ else:
1167
+ key_name, auth_level, key = "none", 0x03, None
1168
+ else:
1169
+ # "auto" (legacy behavior)
1170
+ if manager_key is not None and getattr(self._network, "isManager", lambda: False)():
1171
+ key_name, auth_level, key = "manager", 0x03, manager_key
1172
+ elif visitor_key is not None:
1173
+ key_name, auth_level, key = "visitor", 0x02, visitor_key
1174
+ elif manager_key is not None:
1175
+ key_name, auth_level, key = "manager", 0x03, manager_key
1109
1176
 
1110
1177
  header_mode = self._classicHeaderMode or "conformant"
1111
1178
 
@@ -1182,11 +1249,14 @@ class CasambiClient:
1182
1249
  b2a(bytes(pkt[: min(len(pkt), 24)])),
1183
1250
  )
1184
1251
 
1185
- # Classic packets can exceed 20 bytes when using a 16-byte manager signature.
1186
- # Bleak needs a write-with-response for long writes on most backends.
1252
+ # Android uses WRITE_TYPE_NO_RESPONSE (1) for version/state writes (n0 path)
1253
+ # and WRITE_TYPE_DEFAULT (2) = with-response for time-sync (X path).
1254
+ # If caller didn't specify, default to True for backward compatibility
1255
+ # (also needed for long writes with 16-byte manager signature).
1256
+ use_response = response if response is not None else True
1187
1257
  tx_result = "pending"
1188
1258
  try:
1189
- await self._gattClient.write_gatt_char(tx_uuid, bytes(pkt), response=True)
1259
+ await self._gattClient.write_gatt_char(tx_uuid, bytes(pkt), response=use_response)
1190
1260
  tx_result = "ok"
1191
1261
  except Exception as e:
1192
1262
  tx_result = f"error: {type(e).__name__}: {e}"
@@ -1278,13 +1348,17 @@ class CasambiClient:
1278
1348
  )
1279
1349
 
1280
1350
  async def classicSendInit(self) -> None:
1281
- """Send Classic post-connection initialization (time-sync).
1351
+ """Send Classic post-connection initialization (version + time-sync).
1282
1352
 
1283
- Ground truth: casambi-android AbstractC1717h.X() (lines 254-345).
1284
- The Android app sends this as the first packet after Classic connection.
1285
- In EVO, the key exchange/auth handshake implicitly signals the device;
1286
- Classic has no such handshake, so an explicit init write is needed to
1287
- trigger the device to start broadcasting state notifications.
1353
+ Ground truth (casambi-android v3.16):
1354
+ - Enable notify on CA52/0001 (CCCD) (handled by Bleak start_notify)
1355
+ - Send "version" on CA52/0001: bytes [0,1,11]
1356
+ (`Z0/AbstractC0151u.h0()` in Android)
1357
+ - Then send time-sync on CA51/0002 (cmd 10 legacy / 7 conformant)
1358
+ (`Z0/AbstractC0142k.X()` in Android)
1359
+
1360
+ Classic often stays silent until this bootstrap is completed, so we do it
1361
+ right after the BLE connection is established.
1288
1362
 
1289
1363
  The payload is sent raw via _sendClassic (NOT wrapped in buildClassicCommand).
1290
1364
  """
@@ -1292,6 +1366,33 @@ class CasambiClient:
1292
1366
  if self._protocolMode != ProtocolMode.CLASSIC:
1293
1367
  return
1294
1368
 
1369
+ # Ensure notify setup has a moment to settle (Android delays ~100ms after CCCD write).
1370
+ await asyncio.sleep(0.1)
1371
+
1372
+ # 1) Send Classic "version" packet on CA52/0001.
1373
+ version_uuid = self._classicTxCharUuid or self._dataCharUuid
1374
+ if version_uuid:
1375
+ try:
1376
+ self._logger.warning(
1377
+ "[CASAMBI_CLASSIC_INIT] sending version len=3 target_uuid=%s header_mode=%s",
1378
+ version_uuid,
1379
+ self._classicHeaderMode,
1380
+ )
1381
+ await self._sendClassic(
1382
+ b"\x00\x01\x0b",
1383
+ target_uuid=version_uuid,
1384
+ # Android uses visitor auth for this bootstrap packet.
1385
+ key_preference="visitor",
1386
+ # Android n0():998 uses WRITE_TYPE_NO_RESPONSE for classic.
1387
+ response=False,
1388
+ )
1389
+ self._logger.warning("[CASAMBI_CLASSIC_INIT] version sent successfully")
1390
+ except Exception:
1391
+ self._logger.warning(
1392
+ "[CASAMBI_CLASSIC_INIT] version send failed (continuing with time-sync)",
1393
+ exc_info=True,
1394
+ )
1395
+
1295
1396
  import datetime as _dt
1296
1397
 
1297
1398
  now = _dt.datetime.now()
@@ -1304,12 +1405,22 @@ class CasambiClient:
1304
1405
  if offset is not None:
1305
1406
  utc_offset_minutes = int(offset.total_seconds()) // 60
1306
1407
 
1408
+ # Determine time-sync target and command byte per Android AbstractC1717h.X():
1409
+ # - Non-conformant: write to CA51, command byte 10
1410
+ # - Conformant: write to 0002 (mapped CA51), command byte 7
1411
+ if self._classicHeaderMode == "conformant":
1412
+ timesync_uuid = CASA_CLASSIC_CONFORMANT_CA51_CHAR_UUID # 0002
1413
+ timesync_cmd = 7
1414
+ else:
1415
+ timesync_uuid = CASA_CLASSIC_HASH_CHAR_UUID # CA51
1416
+ timesync_cmd = 10
1417
+
1307
1418
  # Build the time-sync payload.
1308
- # Format: [10][year:2BE][month:1][day:1][hour:1][min:1][sec:1]
1419
+ # Format: [cmd][year:2BE][month:1][day:1][hour:1][min:1][sec:1]
1309
1420
  # [tz_offset:2BE signed][dst_transition:4BE][dst_change:1]
1310
1421
  # [timestamp1:3BE][timestamp2:3BE][zero:2][millis:3BE][extra:1]
1311
1422
  payload = bytearray()
1312
- payload.append(10) # Classic time-sync command byte
1423
+ payload.append(timesync_cmd)
1313
1424
  payload.extend(struct.pack(">H", now.year))
1314
1425
  payload.append(now.month)
1315
1426
  payload.append(now.day)
@@ -1320,30 +1431,61 @@ class CasambiClient:
1320
1431
  # DST transition data and change minutes (0 = no DST info).
1321
1432
  payload.extend(struct.pack(">I", 0))
1322
1433
  payload.append(0)
1323
- # Classic extra bytes: timestamps, zero short, millis, trailing byte.
1434
+ # Classic extra bytes: lon/lat (fixed-point), zero short, millis, trailing lon high byte.
1324
1435
  # Android AbstractC1717h.X() lines 323-328: j() = 3-byte big-endian write
1325
1436
  # (Q2.t.java:59-63), NOT 4-byte. Plus trailing writeByte(iK0 >> 24).
1326
- ts1 = 0 # Q2.r.K0(network.V) — start with 0
1327
- ts2 = 0 # Q2.r.K0(network.W) start with 0
1328
- for ts in (ts1, ts2):
1329
- payload.append((ts >> 16) & 0xFF)
1330
- payload.append((ts >> 8) & 0xFF)
1331
- payload.append(ts & 0xFF)
1437
+ #
1438
+ # In casambi-android v3.16 these values are derived from:
1439
+ # - longitude: round(longitude * 65536)
1440
+ # - latitude: round(latitude * 65536)
1441
+ # and sent as:
1442
+ # j(lon32) + j(lat32) + ... + writeByte(lon32 >> 24)
1443
+ lon_fp32 = 0
1444
+ lat_fp32 = 0
1445
+ try:
1446
+ raw = getattr(self._network, "rawNetworkData", None)
1447
+ net = raw.get("network") if isinstance(raw, dict) else None
1448
+ if isinstance(net, dict):
1449
+ lon = net.get("longitude")
1450
+ lat = net.get("latitude")
1451
+ if isinstance(lon, (int, float, str)):
1452
+ lon_fp32 = int(round(float(lon) * 65536.0))
1453
+ if isinstance(lat, (int, float, str)):
1454
+ lat_fp32 = int(round(float(lat) * 65536.0))
1455
+ except Exception:
1456
+ # Never fail init due to missing location; send zeros.
1457
+ lon_fp32 = 0
1458
+ lat_fp32 = 0
1459
+
1460
+ for v in (lon_fp32, lat_fp32):
1461
+ payload.append((v >> 16) & 0xFF)
1462
+ payload.append((v >> 8) & 0xFF)
1463
+ payload.append(v & 0xFF)
1332
1464
  payload.extend(struct.pack(">H", 0)) # writeShort(0)
1333
1465
  millis_val = now.microsecond // 1000 * 1000
1334
1466
  payload.append((millis_val >> 16) & 0xFF)
1335
1467
  payload.append((millis_val >> 8) & 0xFF)
1336
1468
  payload.append(millis_val & 0xFF)
1337
- payload.append((ts1 >> 24) & 0xFF) # writeByte(iK0 >> 24)
1469
+ payload.append((lon_fp32 >> 24) & 0xFF) # writeByte(lon >> 24)
1338
1470
 
1339
1471
  self._logger.warning(
1340
- "[CASAMBI_CLASSIC_INIT] sending time-sync len=%d hex=%s",
1472
+ "[CASAMBI_CLASSIC_INIT] sending time-sync len=%d cmd=%d target_uuid=%s header_mode=%s hex=%s",
1341
1473
  len(payload),
1474
+ timesync_cmd,
1475
+ timesync_uuid,
1476
+ self._classicHeaderMode,
1342
1477
  b2a(bytes(payload)),
1343
1478
  )
1344
1479
 
1345
1480
  try:
1346
- await self._sendClassic(bytes(payload))
1481
+ await self._sendClassic(
1482
+ bytes(payload),
1483
+ target_uuid=timesync_uuid,
1484
+ # Android uses visitor auth for this bootstrap packet.
1485
+ key_preference="visitor",
1486
+ # Android X():314 uses WRITE_TYPE_DEFAULT (2) = with-response.
1487
+ response=True,
1488
+ )
1347
1489
  self._logger.warning("[CASAMBI_CLASSIC_INIT] time-sync sent successfully")
1348
1490
  except Exception:
1349
1491
  self._logger.warning(
@@ -1361,9 +1503,19 @@ class CasambiClient:
1361
1503
  except Exception:
1362
1504
  handle_uuid = ""
1363
1505
  if handle_uuid and handle_uuid in self._classicNotifyCharUuids:
1506
+ self._logger.debug(
1507
+ "[CASAMBI_NOTIFY_ROUTE] classic_by_uuid uuid=%s len=%d",
1508
+ handle_uuid,
1509
+ len(data),
1510
+ )
1364
1511
  self._classicEstablishedNotifyCallback(handle, data)
1365
1512
  return
1366
1513
  if self._protocolMode == ProtocolMode.CLASSIC:
1514
+ self._logger.debug(
1515
+ "[CASAMBI_NOTIFY_ROUTE] classic_by_mode uuid=%s len=%d",
1516
+ handle_uuid,
1517
+ len(data),
1518
+ )
1367
1519
  self._classicEstablishedNotifyCallback(handle, data)
1368
1520
  return
1369
1521
 
@@ -1583,6 +1735,25 @@ class CasambiClient:
1583
1735
  "payload": payload,
1584
1736
  }
1585
1737
 
1738
+ def _parse_raw(raw_bytes: bytes) -> dict[str, Any] | None:
1739
+ """Parse as raw (unsigned) Classic data.
1740
+
1741
+ Android classic gateway a1.c.V() receives raw bytes with NO CMAC
1742
+ header — byte 0 is the unit_id directly. Adding this as a candidate
1743
+ avoids silently dropping unsigned notifications.
1744
+ """
1745
+ if not raw_bytes:
1746
+ return None
1747
+ return {
1748
+ "mode": "raw",
1749
+ "auth_level": None,
1750
+ "sig_len": 0,
1751
+ "seq": None,
1752
+ "key_name": None,
1753
+ "verified": None, # raw = unverifiable
1754
+ "payload": raw_bytes,
1755
+ }
1756
+
1586
1757
  # Try the currently selected header mode first, then fall back.
1587
1758
  # Some mixed/legacy setups differ between CA52 (legacy) and auth-UUID (conformant).
1588
1759
  parsed_candidates: list[dict[str, Any]] = []
@@ -1604,6 +1775,12 @@ class CasambiClient:
1604
1775
  if r is not None:
1605
1776
  parsed_candidates.append(r)
1606
1777
 
1778
+ # Add a raw (unsigned) candidate — needed because Android classic
1779
+ # gateway receives raw bytes with no CMAC header.
1780
+ raw_candidate = _parse_raw(raw)
1781
+ if raw_candidate is not None:
1782
+ parsed_candidates.append(raw_candidate)
1783
+
1607
1784
  if not parsed_candidates:
1608
1785
  self._classicRxParseFail += 1
1609
1786
  if self._logLimiter.allow("classic_rx_parse_fail", burst=5, window_s=60.0):
@@ -1677,7 +1854,8 @@ class CasambiClient:
1677
1854
  )
1678
1855
 
1679
1856
  # Auto-correct header mode if the other format parses much better.
1680
- if best["mode"] != preferred:
1857
+ # Never switch to "raw" raw is not a header mode, only a fallback parse.
1858
+ if best["mode"] != preferred and best["mode"] in ("conformant", "legacy"):
1681
1859
  # Only switch if we got a stronger signal (verified or plausible payload with fewer assumptions).
1682
1860
  if best["score"] >= 50 and self._logLimiter.allow("classic_rx_mode_switch", burst=3, window_s=3600.0):
1683
1861
  self._logger.warning(
@@ -1879,11 +2057,11 @@ class CasambiClient:
1879
2057
  def _parseClassicUnitStates(self, data: bytes) -> None:
1880
2058
  """Parse Classic unit state records.
1881
2059
 
1882
- Ground truth: casambi-android C1751c.V() (line 301+).
2060
+ Ground truth: casambi-android a1.c.V() (line 226+).
1883
2061
  Format is completely different from EVO _parseUnitStates:
1884
2062
  - flags lower nibble = state_len (EVO uses a separate byte)
1885
- - flags bit 5 = extra1 present, bit 6 = extra2 present, bit 7 = offline
1886
- - unit_id 0xF0 = command response (skip)
2063
+ - flags bit 4 = priority 14, bit 5 = extra1, bit 6 = extra2, bit 7 = online
2064
+ - unit_id 0xF0 = command response (cmd_id + seq + payload)
1887
2065
  """
1888
2066
  self._logger.debug("Parsing Classic unit states...")
1889
2067
  if self._logger.isEnabledFor(logging.DEBUG):
@@ -1893,7 +2071,8 @@ class CasambiClient:
1893
2071
  old_pos = 0
1894
2072
  records_parsed = 0
1895
2073
  try:
1896
- while pos + 2 <= len(data):
2074
+ # Android uses fVar.available() >= 3 as loop guard.
2075
+ while pos + 3 <= len(data):
1897
2076
  unit_id = data[pos]
1898
2077
  flags = data[pos + 1]
1899
2078
  pos += 2
@@ -1901,10 +2080,21 @@ class CasambiClient:
1901
2080
  state_len = flags & 0x0F
1902
2081
  has_extra1 = (flags & 0x20) != 0
1903
2082
  has_extra2 = (flags & 0x40) != 0
1904
- is_offline = (flags & 0x80) != 0
2083
+ # Android a1.c.java:286: (b6 & 128) != 0 → online (NOT offline).
2084
+ # Confirmed by N1.java:1298 log "Set unit ONLINE=" + z6.
2085
+ online = (flags & 0x80) != 0
1905
2086
 
1906
- # 0xF0 = command response record, skip state_len bytes.
2087
+ # 0xF0 = command response record (Android a1.c.java:260-270).
2088
+ # Format: cmd_id(1) + seq(1) + payload(state_len - 2).
1907
2089
  if unit_id == 0xF0:
2090
+ cmd_id = data[pos] if pos < len(data) else None
2091
+ seq_byte = data[pos + 1] if pos + 1 < len(data) else None
2092
+ self._logger.debug(
2093
+ "[CASAMBI_CLASSIC_CMD_RESP] cmd_id=%s seq=%s state_len=%d",
2094
+ cmd_id,
2095
+ seq_byte,
2096
+ state_len,
2097
+ )
1908
2098
  pos += state_len
1909
2099
  continue
1910
2100
 
@@ -1933,17 +2123,15 @@ class CasambiClient:
1933
2123
  if records_parsed <= 10 or self._logger.isEnabledFor(logging.DEBUG):
1934
2124
  self._logger.warning(
1935
2125
  "[CASAMBI_CLASSIC_STATE_PARSED] unit=%d flags=0x%02x state_len=%d "
1936
- "offline=%s extra1=%d extra2=%d state=%s",
2126
+ "online=%s extra1=%d extra2=%d state=%s",
1937
2127
  unit_id,
1938
2128
  flags,
1939
2129
  state_len,
1940
- is_offline,
2130
+ online,
1941
2131
  extra1,
1942
2132
  extra2,
1943
2133
  b2a(state),
1944
2134
  )
1945
-
1946
- online = not is_offline
1947
2135
  # Let Unit.is_on derive actual on/off from state bytes (dimmer, onoff).
1948
2136
  on = True
1949
2137
 
@@ -1955,7 +2143,8 @@ class CasambiClient:
1955
2143
  "on": on,
1956
2144
  "state": state,
1957
2145
  "flags": flags,
1958
- "prio": 0,
2146
+ # Android a1.c.java:291: (b6 & 16) != 0 ? 14 : 0
2147
+ "prio": 14 if (flags & 0x10) else 0,
1959
2148
  "state_len": state_len,
1960
2149
  "padding_len": 0,
1961
2150
  "con": None,
@@ -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.dev13"
10
+ __version__ = "0.3.12.dev15"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: casambi-bt-revamped
3
- Version: 0.3.12.dev13
3
+ Version: 0.3.12.dev15
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