casambi-bt-revamped 0.3.12.dev14__tar.gz → 0.3.12.dev16__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.dev14/src/casambi_bt_revamped.egg-info → casambi_bt_revamped-0.3.12.dev16}/PKG-INFO +1 -1
  2. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/setup.cfg +1 -1
  3. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/src/CasambiBt/_client.py +230 -51
  4. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/src/CasambiBt/_version.py +1 -1
  5. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16/src/casambi_bt_revamped.egg-info}/PKG-INFO +1 -1
  6. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/LICENSE +0 -0
  7. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/README.md +0 -0
  8. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/pyproject.toml +0 -0
  9. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/src/CasambiBt/__init__.py +0 -0
  10. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/src/CasambiBt/_cache.py +0 -0
  11. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/src/CasambiBt/_casambi.py +0 -0
  12. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/src/CasambiBt/_classic_crypto.py +0 -0
  13. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/src/CasambiBt/_constants.py +0 -0
  14. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/src/CasambiBt/_discover.py +0 -0
  15. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/src/CasambiBt/_encryption.py +0 -0
  16. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/src/CasambiBt/_invocation.py +0 -0
  17. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/src/CasambiBt/_keystore.py +0 -0
  18. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/src/CasambiBt/_network.py +0 -0
  19. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/src/CasambiBt/_operation.py +0 -0
  20. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/src/CasambiBt/_switch_events.py +0 -0
  21. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/src/CasambiBt/_unit.py +0 -0
  22. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/src/CasambiBt/errors.py +0 -0
  23. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/src/CasambiBt/py.typed +0 -0
  24. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/src/casambi_bt_revamped.egg-info/SOURCES.txt +0 -0
  25. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/src/casambi_bt_revamped.egg-info/dependency_links.txt +0 -0
  26. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/src/casambi_bt_revamped.egg-info/requires.txt +0 -0
  27. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/src/casambi_bt_revamped.egg-info/top_level.txt +0 -0
  28. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/tests/test_classic_protocol.py +0 -0
  29. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/tests/test_legacy_protocol_handling.py +0 -0
  30. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/tests/test_switch_event_logs.py +0 -0
  31. {casambi_bt_revamped-0.3.12.dev14 → casambi_bt_revamped-0.3.12.dev16}/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.dev14
3
+ Version: 0.3.12.dev16
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.dev14
3
+ version = 0.3.12.dev16
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
@@ -800,6 +800,11 @@ class CasambiClient:
800
800
  await self._activityLock.acquire()
801
801
  try:
802
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
+ )
803
808
  finally:
804
809
  self._callbackQueue.task_done()
805
810
  self._activityLock.release()
@@ -811,6 +816,10 @@ class CasambiClient:
811
816
  def _callbackMulitplexer(
812
817
  self, handle: BleakGATTCharacteristic, data: bytes
813
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
+ )
814
823
  if self._connectionState == ConnectionState.CONNECTED:
815
824
  self._exchNofityCallback(handle, data)
816
825
  elif self._connectionState == ConnectionState.KEY_EXCHANGED:
@@ -1081,7 +1090,14 @@ class CasambiClient:
1081
1090
  else:
1082
1091
  return bytes([counter, unit_id & 0xFF, 1, dimmer & 0xFF])
1083
1092
 
1084
- async def _sendClassic(self, command_bytes: bytes, *, target_uuid: str | None = None) -> 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:
1085
1101
  self._checkState(ConnectionState.AUTHENTICATED)
1086
1102
  if self._protocolMode != ProtocolMode.CLASSIC:
1087
1103
  raise ProtocolError("Classic send called while not in Classic protocol mode.")
@@ -1122,26 +1138,41 @@ class CasambiClient:
1122
1138
  # If parsing fails, keep fields as None.
1123
1139
  pass
1124
1140
 
1125
- # Key selection mirrors Android's intent:
1126
- # - Use manager key if our cloud session is manager and a managerKey exists.
1127
- # - Else use visitor key if present.
1128
- # - Else fall back to manager key if present.
1129
- # - 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.
1130
1149
  key_name = "none"
1131
- auth_level = 0x02 # visitor by default
1132
- key = None
1133
- if manager_key is not None and getattr(self._network, "isManager", lambda: False)():
1134
- key_name = "manager"
1135
- auth_level = 0x03
1136
- key = manager_key
1137
- elif visitor_key is not None:
1138
- key_name = "visitor"
1139
- auth_level = 0x02
1140
- key = visitor_key
1141
- elif manager_key is not None:
1142
- key_name = "manager"
1143
- auth_level = 0x03
1144
- 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
1145
1176
 
1146
1177
  header_mode = self._classicHeaderMode or "conformant"
1147
1178
 
@@ -1218,11 +1249,14 @@ class CasambiClient:
1218
1249
  b2a(bytes(pkt[: min(len(pkt), 24)])),
1219
1250
  )
1220
1251
 
1221
- # Classic packets can exceed 20 bytes when using a 16-byte manager signature.
1222
- # 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
1223
1257
  tx_result = "pending"
1224
1258
  try:
1225
- 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)
1226
1260
  tx_result = "ok"
1227
1261
  except Exception as e:
1228
1262
  tx_result = f"error: {type(e).__name__}: {e}"
@@ -1314,13 +1348,17 @@ class CasambiClient:
1314
1348
  )
1315
1349
 
1316
1350
  async def classicSendInit(self) -> None:
1317
- """Send Classic post-connection initialization (time-sync).
1351
+ """Send Classic post-connection initialization (version + time-sync).
1318
1352
 
1319
- Ground truth: casambi-android AbstractC1717h.X() (lines 254-345).
1320
- The Android app sends this as the first packet after Classic connection.
1321
- In EVO, the key exchange/auth handshake implicitly signals the device;
1322
- Classic has no such handshake, so an explicit init write is needed to
1323
- 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.
1324
1362
 
1325
1363
  The payload is sent raw via _sendClassic (NOT wrapped in buildClassicCommand).
1326
1364
  """
@@ -1328,6 +1366,33 @@ class CasambiClient:
1328
1366
  if self._protocolMode != ProtocolMode.CLASSIC:
1329
1367
  return
1330
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
+
1331
1396
  import datetime as _dt
1332
1397
 
1333
1398
  now = _dt.datetime.now()
@@ -1366,21 +1431,42 @@ class CasambiClient:
1366
1431
  # DST transition data and change minutes (0 = no DST info).
1367
1432
  payload.extend(struct.pack(">I", 0))
1368
1433
  payload.append(0)
1369
- # Classic extra bytes: timestamps, zero short, millis, trailing byte.
1434
+ # Classic extra bytes: lon/lat (fixed-point), zero short, millis, trailing lon high byte.
1370
1435
  # Android AbstractC1717h.X() lines 323-328: j() = 3-byte big-endian write
1371
1436
  # (Q2.t.java:59-63), NOT 4-byte. Plus trailing writeByte(iK0 >> 24).
1372
- ts1 = 0 # Q2.r.K0(network.V) — start with 0
1373
- ts2 = 0 # Q2.r.K0(network.W) start with 0
1374
- for ts in (ts1, ts2):
1375
- payload.append((ts >> 16) & 0xFF)
1376
- payload.append((ts >> 8) & 0xFF)
1377
- 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)
1378
1464
  payload.extend(struct.pack(">H", 0)) # writeShort(0)
1379
1465
  millis_val = now.microsecond // 1000 * 1000
1380
1466
  payload.append((millis_val >> 16) & 0xFF)
1381
1467
  payload.append((millis_val >> 8) & 0xFF)
1382
1468
  payload.append(millis_val & 0xFF)
1383
- payload.append((ts1 >> 24) & 0xFF) # writeByte(iK0 >> 24)
1469
+ payload.append((lon_fp32 >> 24) & 0xFF) # writeByte(lon >> 24)
1384
1470
 
1385
1471
  self._logger.warning(
1386
1472
  "[CASAMBI_CLASSIC_INIT] sending time-sync len=%d cmd=%d target_uuid=%s header_mode=%s hex=%s",
@@ -1392,7 +1478,14 @@ class CasambiClient:
1392
1478
  )
1393
1479
 
1394
1480
  try:
1395
- await self._sendClassic(bytes(payload), target_uuid=timesync_uuid)
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
+ )
1396
1489
  self._logger.warning("[CASAMBI_CLASSIC_INIT] time-sync sent successfully")
1397
1490
  except Exception:
1398
1491
  self._logger.warning(
@@ -1410,9 +1503,19 @@ class CasambiClient:
1410
1503
  except Exception:
1411
1504
  handle_uuid = ""
1412
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
+ )
1413
1511
  self._classicEstablishedNotifyCallback(handle, data)
1414
1512
  return
1415
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
+ )
1416
1519
  self._classicEstablishedNotifyCallback(handle, data)
1417
1520
  return
1418
1521
 
@@ -1521,6 +1624,39 @@ class CasambiClient:
1521
1624
  visitor_key = self._network.classicVisitorKey()
1522
1625
  manager_key = self._network.classicManagerKey()
1523
1626
 
1627
+ def _walk_classic_records(data: bytes) -> bool:
1628
+ """Check if data is a plausible Classic unit state record stream.
1629
+
1630
+ Walks the same record structure as _parseClassicUnitStates:
1631
+ - unit_id(1) + flags(1) + optional extras + state(state_len)
1632
+ - unit_id 0xF0 is a command response (no extras, state_len bytes consumed)
1633
+ Accepts if >=1 record parsed AND pos ends exactly at len(data).
1634
+ """
1635
+ pos = 0
1636
+ count = 0
1637
+ while pos + 3 <= len(data): # Match Android available()>=3
1638
+ unit_id = data[pos]
1639
+ flags = data[pos + 1]
1640
+ state_len = flags & 0x0F
1641
+ # 0 and 255 are Classic control bytes, not valid unit records
1642
+ # inside a record stream (handled at dispatch level).
1643
+ if unit_id == 0 or unit_id == 255:
1644
+ return False
1645
+ pos += 2
1646
+ if unit_id == 0xF0:
1647
+ # Command response: cmd_id(1) + seq(1) + payload(state_len-2).
1648
+ if state_len < 2:
1649
+ return False
1650
+ pos += state_len
1651
+ else:
1652
+ has_extra1 = (flags & 0x20) != 0
1653
+ has_extra2 = (flags & 0x40) != 0
1654
+ pos += int(has_extra1) + int(has_extra2) + state_len
1655
+ if pos > len(data):
1656
+ return False
1657
+ count += 1
1658
+ return count >= 1 and pos == len(data)
1659
+
1524
1660
  def _plausible_payload(payload: bytes) -> bool:
1525
1661
  if not payload:
1526
1662
  return False
@@ -1535,6 +1671,12 @@ class CasambiClient:
1535
1671
  rec_len = (payload[0] - 239) & 0xFF
1536
1672
  if 2 <= rec_len <= len(payload):
1537
1673
  return True
1674
+ # Classic unit state record stream or control byte.
1675
+ if self._protocolMode == ProtocolMode.CLASSIC:
1676
+ if payload[0] in (0, 255):
1677
+ return True
1678
+ if _walk_classic_records(payload):
1679
+ return True
1538
1680
  return False
1539
1681
 
1540
1682
  def _score(verified: bool | None, payload: bytes) -> int:
@@ -1632,6 +1774,25 @@ class CasambiClient:
1632
1774
  "payload": payload,
1633
1775
  }
1634
1776
 
1777
+ def _parse_raw(raw_bytes: bytes) -> dict[str, Any] | None:
1778
+ """Parse as raw (unsigned) Classic data.
1779
+
1780
+ Android classic gateway a1.c.V() receives raw bytes with NO CMAC
1781
+ header — byte 0 is the unit_id directly. Adding this as a candidate
1782
+ avoids silently dropping unsigned notifications.
1783
+ """
1784
+ if not raw_bytes:
1785
+ return None
1786
+ return {
1787
+ "mode": "raw",
1788
+ "auth_level": None,
1789
+ "sig_len": 0,
1790
+ "seq": None,
1791
+ "key_name": None,
1792
+ "verified": None, # raw = unverifiable
1793
+ "payload": raw_bytes,
1794
+ }
1795
+
1635
1796
  # Try the currently selected header mode first, then fall back.
1636
1797
  # Some mixed/legacy setups differ between CA52 (legacy) and auth-UUID (conformant).
1637
1798
  parsed_candidates: list[dict[str, Any]] = []
@@ -1653,6 +1814,12 @@ class CasambiClient:
1653
1814
  if r is not None:
1654
1815
  parsed_candidates.append(r)
1655
1816
 
1817
+ # Add a raw (unsigned) candidate — needed because Android classic
1818
+ # gateway receives raw bytes with no CMAC header.
1819
+ raw_candidate = _parse_raw(raw)
1820
+ if raw_candidate is not None:
1821
+ parsed_candidates.append(raw_candidate)
1822
+
1656
1823
  if not parsed_candidates:
1657
1824
  self._classicRxParseFail += 1
1658
1825
  if self._logLimiter.allow("classic_rx_parse_fail", burst=5, window_s=60.0):
@@ -1726,7 +1893,8 @@ class CasambiClient:
1726
1893
  )
1727
1894
 
1728
1895
  # Auto-correct header mode if the other format parses much better.
1729
- if best["mode"] != preferred:
1896
+ # Never switch to "raw" raw is not a header mode, only a fallback parse.
1897
+ if best["mode"] != preferred and best["mode"] in ("conformant", "legacy"):
1730
1898
  # Only switch if we got a stronger signal (verified or plausible payload with fewer assumptions).
1731
1899
  if best["score"] >= 50 and self._logLimiter.allow("classic_rx_mode_switch", burst=3, window_s=3600.0):
1732
1900
  self._logger.warning(
@@ -1928,11 +2096,11 @@ class CasambiClient:
1928
2096
  def _parseClassicUnitStates(self, data: bytes) -> None:
1929
2097
  """Parse Classic unit state records.
1930
2098
 
1931
- Ground truth: casambi-android C1751c.V() (line 301+).
2099
+ Ground truth: casambi-android a1.c.V() (line 226+).
1932
2100
  Format is completely different from EVO _parseUnitStates:
1933
2101
  - flags lower nibble = state_len (EVO uses a separate byte)
1934
- - flags bit 5 = extra1 present, bit 6 = extra2 present, bit 7 = offline
1935
- - unit_id 0xF0 = command response (skip)
2102
+ - flags bit 4 = priority 14, bit 5 = extra1, bit 6 = extra2, bit 7 = online
2103
+ - unit_id 0xF0 = command response (cmd_id + seq + payload)
1936
2104
  """
1937
2105
  self._logger.debug("Parsing Classic unit states...")
1938
2106
  if self._logger.isEnabledFor(logging.DEBUG):
@@ -1942,7 +2110,8 @@ class CasambiClient:
1942
2110
  old_pos = 0
1943
2111
  records_parsed = 0
1944
2112
  try:
1945
- while pos + 2 <= len(data):
2113
+ # Android uses fVar.available() >= 3 as loop guard.
2114
+ while pos + 3 <= len(data):
1946
2115
  unit_id = data[pos]
1947
2116
  flags = data[pos + 1]
1948
2117
  pos += 2
@@ -1950,10 +2119,21 @@ class CasambiClient:
1950
2119
  state_len = flags & 0x0F
1951
2120
  has_extra1 = (flags & 0x20) != 0
1952
2121
  has_extra2 = (flags & 0x40) != 0
1953
- is_offline = (flags & 0x80) != 0
2122
+ # Android a1.c.java:286: (b6 & 128) != 0 → online (NOT offline).
2123
+ # Confirmed by N1.java:1298 log "Set unit ONLINE=" + z6.
2124
+ online = (flags & 0x80) != 0
1954
2125
 
1955
- # 0xF0 = command response record, skip state_len bytes.
2126
+ # 0xF0 = command response record (Android a1.c.java:260-270).
2127
+ # Format: cmd_id(1) + seq(1) + payload(state_len - 2).
1956
2128
  if unit_id == 0xF0:
2129
+ cmd_id = data[pos] if pos < len(data) else None
2130
+ seq_byte = data[pos + 1] if pos + 1 < len(data) else None
2131
+ self._logger.debug(
2132
+ "[CASAMBI_CLASSIC_CMD_RESP] cmd_id=%s seq=%s state_len=%d",
2133
+ cmd_id,
2134
+ seq_byte,
2135
+ state_len,
2136
+ )
1957
2137
  pos += state_len
1958
2138
  continue
1959
2139
 
@@ -1982,17 +2162,15 @@ class CasambiClient:
1982
2162
  if records_parsed <= 10 or self._logger.isEnabledFor(logging.DEBUG):
1983
2163
  self._logger.warning(
1984
2164
  "[CASAMBI_CLASSIC_STATE_PARSED] unit=%d flags=0x%02x state_len=%d "
1985
- "offline=%s extra1=%d extra2=%d state=%s",
2165
+ "online=%s extra1=%d extra2=%d state=%s",
1986
2166
  unit_id,
1987
2167
  flags,
1988
2168
  state_len,
1989
- is_offline,
2169
+ online,
1990
2170
  extra1,
1991
2171
  extra2,
1992
2172
  b2a(state),
1993
2173
  )
1994
-
1995
- online = not is_offline
1996
2174
  # Let Unit.is_on derive actual on/off from state bytes (dimmer, onoff).
1997
2175
  on = True
1998
2176
 
@@ -2004,7 +2182,8 @@ class CasambiClient:
2004
2182
  "on": on,
2005
2183
  "state": state,
2006
2184
  "flags": flags,
2007
- "prio": 0,
2185
+ # Android a1.c.java:291: (b6 & 16) != 0 ? 14 : 0
2186
+ "prio": 14 if (flags & 0x10) else 0,
2008
2187
  "state_len": state_len,
2009
2188
  "padding_len": 0,
2010
2189
  "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.dev14"
10
+ __version__ = "0.3.12.dev16"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: casambi-bt-revamped
3
- Version: 0.3.12.dev14
3
+ Version: 0.3.12.dev16
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