casambi-bt-revamped 0.3.12.dev10__tar.gz → 0.3.12.dev12__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.dev10/src/casambi_bt_revamped.egg-info → casambi_bt_revamped-0.3.12.dev12}/PKG-INFO +1 -1
  2. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/setup.cfg +1 -1
  3. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/src/CasambiBt/_casambi.py +6 -1
  4. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/src/CasambiBt/_client.py +279 -1
  5. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/src/CasambiBt/_version.py +1 -1
  6. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12/src/casambi_bt_revamped.egg-info}/PKG-INFO +1 -1
  7. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/LICENSE +0 -0
  8. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/README.md +0 -0
  9. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/pyproject.toml +0 -0
  10. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/src/CasambiBt/__init__.py +0 -0
  11. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/src/CasambiBt/_cache.py +0 -0
  12. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/src/CasambiBt/_classic_crypto.py +0 -0
  13. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/src/CasambiBt/_constants.py +0 -0
  14. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/src/CasambiBt/_discover.py +0 -0
  15. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/src/CasambiBt/_encryption.py +0 -0
  16. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/src/CasambiBt/_invocation.py +0 -0
  17. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/src/CasambiBt/_keystore.py +0 -0
  18. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/src/CasambiBt/_network.py +0 -0
  19. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/src/CasambiBt/_operation.py +0 -0
  20. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/src/CasambiBt/_switch_events.py +0 -0
  21. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/src/CasambiBt/_unit.py +0 -0
  22. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/src/CasambiBt/errors.py +0 -0
  23. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/src/CasambiBt/py.typed +0 -0
  24. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/src/casambi_bt_revamped.egg-info/SOURCES.txt +0 -0
  25. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/src/casambi_bt_revamped.egg-info/dependency_links.txt +0 -0
  26. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/src/casambi_bt_revamped.egg-info/requires.txt +0 -0
  27. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/src/casambi_bt_revamped.egg-info/top_level.txt +0 -0
  28. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/tests/test_classic_protocol.py +0 -0
  29. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/tests/test_legacy_protocol_handling.py +0 -0
  30. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/tests/test_switch_event_logs.py +0 -0
  31. {casambi_bt_revamped-0.3.12.dev10 → casambi_bt_revamped-0.3.12.dev12}/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.dev10
3
+ Version: 0.3.12.dev12
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.dev10
3
+ version = 0.3.12.dev12
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
@@ -169,10 +169,15 @@ class Casambi:
169
169
  self._casaClient = cast(CasambiClient, self._casaClient)
170
170
  await self._casaClient.connect()
171
171
  try:
172
- # EVO requires key exchange + authenticate; Classic is ready after `connect()`.
173
172
  if self._casaClient.protocolMode == ProtocolMode.EVO:
173
+ # EVO requires key exchange + authenticate.
174
174
  await self._casaClient.exchangeKey()
175
175
  await self._casaClient.authenticate()
176
+ elif self._casaClient.protocolMode == ProtocolMode.CLASSIC:
177
+ # Classic needs an init write to trigger state broadcasts.
178
+ # In EVO the key exchange/auth handshake implicitly signals the
179
+ # device; Classic has no such handshake so we send a time-sync.
180
+ await self._casaClient.classicSendInit()
176
181
  except ProtocolError as e:
177
182
  await self._casaClient.disconnect()
178
183
  raise e
@@ -159,6 +159,7 @@ class CasambiClient:
159
159
  self._classicRxType9 = 0
160
160
  self._classicRxCmdStream = 0
161
161
  self._classicRxUnknown = 0
162
+ self._classicRxClassicStates = 0
162
163
  # Per-kind sample counters to ensure we emit at least a few examples for reverse engineering.
163
164
  self._classicRxKindSamples: dict[str, int] = {}
164
165
  self._classicRxLastStatsTs = time.monotonic()
@@ -437,6 +438,7 @@ class CasambiClient:
437
438
  self._network.classicManagerKey() is not None,
438
439
  getattr(self._network, "isManager", lambda: False)(),
439
440
  )
441
+ await self._classicEnumerateAndSubscribeGatt(notify_kwargs)
440
442
  _log_probe_summary("CLASSIC", classic_variant="ca52_legacy")
441
443
  # Emit a warning if we never see Classic RX frames; this is a common failure mode.
442
444
  self._classicNoRxTask = asyncio.create_task(self._classic_no_rx_watchdog(30.0))
@@ -592,6 +594,7 @@ class CasambiClient:
592
594
  self._network.classicManagerKey() is not None,
593
595
  getattr(self._network, "isManager", lambda: False)(),
594
596
  )
597
+ await self._classicEnumerateAndSubscribeGatt(notify_kwargs)
595
598
  _log_probe_summary("CLASSIC", classic_variant="auth_uuid_conformant")
596
599
  self._classicNoRxTask = asyncio.create_task(self._classic_no_rx_watchdog(30.0))
597
600
  return
@@ -1222,6 +1225,132 @@ class CasambiClient:
1222
1225
  len(pkt),
1223
1226
  )
1224
1227
 
1228
+ async def _classicEnumerateAndSubscribeGatt(
1229
+ self, notify_kwargs: dict[str, Any]
1230
+ ) -> None:
1231
+ """Enumerate all GATT characteristics and subscribe to any notifiable ones.
1232
+
1233
+ This discovers characteristics beyond the manually-probed CA51/CA52/CA53
1234
+ UUIDs and subscribes to any that support notify or indicate, which may be
1235
+ needed for receiving Classic state/config notifications.
1236
+ """
1237
+ try:
1238
+ total_chars = 0
1239
+ for svc in self._gattClient.services:
1240
+ for char in svc.characteristics:
1241
+ total_chars += 1
1242
+ char_uuid = str(char.uuid).lower()
1243
+ props = char.properties
1244
+ self._logger.warning(
1245
+ "[CASAMBI_CLASSIC_GATT_CHAR] uuid=%s props=%s handle=%d",
1246
+ char_uuid,
1247
+ props,
1248
+ char.handle,
1249
+ )
1250
+ if char_uuid not in self._classicNotifyCharUuids:
1251
+ if "notify" in props or "indicate" in props:
1252
+ try:
1253
+ await self._gattClient.start_notify(
1254
+ char.uuid,
1255
+ self._queueCallback,
1256
+ **notify_kwargs,
1257
+ )
1258
+ self._classicNotifyCharUuids.add(char_uuid)
1259
+ self._logger.warning(
1260
+ "[CASAMBI_CLASSIC_GATT_SUB] subscribed uuid=%s",
1261
+ char_uuid,
1262
+ )
1263
+ except Exception as e:
1264
+ self._logger.warning(
1265
+ "[CASAMBI_CLASSIC_GATT_SUB] failed uuid=%s err=%s",
1266
+ char_uuid,
1267
+ type(e).__name__,
1268
+ )
1269
+ self._logger.warning(
1270
+ "[CASAMBI_CLASSIC_GATT_ENUM] total_chars=%d subscribed_uuids=%s",
1271
+ total_chars,
1272
+ sorted(self._classicNotifyCharUuids),
1273
+ )
1274
+ except Exception as e:
1275
+ self._logger.warning(
1276
+ "[CASAMBI_CLASSIC_GATT_ENUM] services enumeration unavailable: %s",
1277
+ type(e).__name__,
1278
+ )
1279
+
1280
+ async def classicSendInit(self) -> None:
1281
+ """Send Classic post-connection initialization (time-sync).
1282
+
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.
1288
+
1289
+ The payload is sent raw via _sendClassic (NOT wrapped in buildClassicCommand).
1290
+ """
1291
+ self._checkState(ConnectionState.AUTHENTICATED)
1292
+ if self._protocolMode != ProtocolMode.CLASSIC:
1293
+ return
1294
+
1295
+ import datetime as _dt
1296
+
1297
+ now = _dt.datetime.now()
1298
+
1299
+ # Timezone offset in minutes from UTC.
1300
+ local_tz = _dt.datetime.now(_dt.timezone.utc).astimezone().tzinfo
1301
+ utc_offset_minutes = 0
1302
+ if local_tz is not None:
1303
+ offset = local_tz.utcoffset(now)
1304
+ if offset is not None:
1305
+ utc_offset_minutes = int(offset.total_seconds()) // 60
1306
+
1307
+ # Build the time-sync payload.
1308
+ # Format: [10][year:2BE][month:1][day:1][hour:1][min:1][sec:1]
1309
+ # [tz_offset:2BE signed][dst_transition:4BE][dst_change:1]
1310
+ # [timestamp1:3BE][timestamp2:3BE][zero:2][millis:3BE][extra:1]
1311
+ payload = bytearray()
1312
+ payload.append(10) # Classic time-sync command byte
1313
+ payload.extend(struct.pack(">H", now.year))
1314
+ payload.append(now.month)
1315
+ payload.append(now.day)
1316
+ payload.append(now.hour)
1317
+ payload.append(now.minute)
1318
+ payload.append(now.second)
1319
+ payload.extend(struct.pack(">h", utc_offset_minutes))
1320
+ # DST transition data and change minutes (0 = no DST info).
1321
+ payload.extend(struct.pack(">I", 0))
1322
+ payload.append(0)
1323
+ # Classic extra bytes: timestamps, zero short, millis, trailing byte.
1324
+ # Android AbstractC1717h.X() lines 323-328: j() = 3-byte big-endian write
1325
+ # (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)
1332
+ payload.extend(struct.pack(">H", 0)) # writeShort(0)
1333
+ millis_val = now.microsecond // 1000 * 1000
1334
+ payload.append((millis_val >> 16) & 0xFF)
1335
+ payload.append((millis_val >> 8) & 0xFF)
1336
+ payload.append(millis_val & 0xFF)
1337
+ payload.append((ts1 >> 24) & 0xFF) # writeByte(iK0 >> 24)
1338
+
1339
+ self._logger.warning(
1340
+ "[CASAMBI_CLASSIC_INIT] sending time-sync len=%d hex=%s",
1341
+ len(payload),
1342
+ b2a(bytes(payload)),
1343
+ )
1344
+
1345
+ try:
1346
+ await self._sendClassic(bytes(payload))
1347
+ self._logger.warning("[CASAMBI_CLASSIC_INIT] time-sync sent successfully")
1348
+ except Exception:
1349
+ self._logger.warning(
1350
+ "[CASAMBI_CLASSIC_INIT] time-sync send failed",
1351
+ exc_info=True,
1352
+ )
1353
+
1225
1354
  def _establishedNofityCallback(
1226
1355
  self, handle: BleakGATTCharacteristic, data: bytes
1227
1356
  ) -> None:
@@ -1579,7 +1708,7 @@ class CasambiClient:
1579
1708
  self._classicRxLastStatsTs = now
1580
1709
  self._logger.warning(
1581
1710
  "[CASAMBI_CLASSIC_RX_STATS] frames=%d verified=%d unverifiable=%d parse_fail=%d header=%s "
1582
- "type6=%d type7=%d type9=%d cmdstream=%d unknown=%d",
1711
+ "type6=%d type7=%d type9=%d cmdstream=%d unknown=%d classic_states=%d",
1583
1712
  self._classicRxFrames,
1584
1713
  self._classicRxVerified,
1585
1714
  self._classicRxUnverifiable,
@@ -1590,8 +1719,17 @@ class CasambiClient:
1590
1719
  self._classicRxType9,
1591
1720
  self._classicRxCmdStream,
1592
1721
  self._classicRxUnknown,
1722
+ self._classicRxClassicStates,
1593
1723
  )
1594
1724
 
1725
+ # Classic payloads use a completely different format from EVO.
1726
+ # Classic: byte 0 is a type indicator (0=netconfig, 255=log, else=unit_id).
1727
+ # EVO: byte 0 is a packet type (6=UnitState, 7=Switch, 9=NetConfig).
1728
+ # Dispatch Classic through its own parser to avoid misinterpretation.
1729
+ if self._protocolMode == ProtocolMode.CLASSIC:
1730
+ self._dispatchClassicPayload(payload)
1731
+ return
1732
+
1595
1733
  # If the payload starts with a known EVO packet type, reuse existing parsers.
1596
1734
  packet_type = payload[0]
1597
1735
  if packet_type in (IncommingPacketType.UnitState, IncommingPacketType.SwitchEvent, IncommingPacketType.NetworkConfig):
@@ -1702,6 +1840,146 @@ class CasambiClient:
1702
1840
  b2a(payload[pos:]),
1703
1841
  )
1704
1842
 
1843
+ def _dispatchClassicPayload(self, payload: bytes) -> None:
1844
+ """Dispatch a verified Classic payload based on its type indicator.
1845
+
1846
+ Classic payloads (from C1751c.V()) use a different format from EVO:
1847
+ - byte 0 == 0: network config data
1848
+ - byte 0 == 255: log message
1849
+ - otherwise: unit state stream (byte 0 is the first unit_id)
1850
+ """
1851
+ if not payload:
1852
+ return
1853
+
1854
+ first_byte = payload[0]
1855
+
1856
+ # Log full payload for the first 10 Classic payloads regardless of type.
1857
+ if self._classicRxClassicStates < 10:
1858
+ self._logger.warning(
1859
+ "[CASAMBI_CLASSIC_DISPATCH] #%d type_byte=%d len=%d hex=%s",
1860
+ self._classicRxClassicStates,
1861
+ first_byte,
1862
+ len(payload),
1863
+ b2a(payload[: min(len(payload), 64)]).decode("ascii")
1864
+ + ("..." if len(payload) > 64 else ""),
1865
+ )
1866
+
1867
+ if first_byte == 0:
1868
+ self._logger.debug("[CASAMBI_CLASSIC_NETCONFIG] len=%d", len(payload))
1869
+ return
1870
+
1871
+ if first_byte == 255:
1872
+ self._logger.debug("[CASAMBI_CLASSIC_LOG] len=%d", len(payload))
1873
+ return
1874
+
1875
+ # Unit state stream: entire payload is passed (first byte is the first unit_id).
1876
+ self._classicRxClassicStates += 1
1877
+ self._parseClassicUnitStates(payload)
1878
+
1879
+ def _parseClassicUnitStates(self, data: bytes) -> None:
1880
+ """Parse Classic unit state records.
1881
+
1882
+ Ground truth: casambi-android C1751c.V() (line 301+).
1883
+ Format is completely different from EVO _parseUnitStates:
1884
+ - 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)
1887
+ """
1888
+ self._logger.debug("Parsing Classic unit states...")
1889
+ if self._logger.isEnabledFor(logging.DEBUG):
1890
+ self._logger.debug("[CASAMBI_CLASSIC_STATES_RAW] len=%d hex=%s", len(data), b2a(data))
1891
+
1892
+ pos = 0
1893
+ old_pos = 0
1894
+ records_parsed = 0
1895
+ try:
1896
+ while pos + 2 <= len(data):
1897
+ unit_id = data[pos]
1898
+ flags = data[pos + 1]
1899
+ pos += 2
1900
+
1901
+ state_len = flags & 0x0F
1902
+ has_extra1 = (flags & 0x20) != 0
1903
+ has_extra2 = (flags & 0x40) != 0
1904
+ is_offline = (flags & 0x80) != 0
1905
+
1906
+ # 0xF0 = command response record, skip state_len bytes.
1907
+ if unit_id == 0xF0:
1908
+ pos += state_len
1909
+ continue
1910
+
1911
+ extra1 = 0
1912
+ if has_extra1:
1913
+ if pos >= len(data):
1914
+ break
1915
+ extra1 = data[pos]
1916
+ pos += 1
1917
+
1918
+ extra2 = 0
1919
+ if has_extra2:
1920
+ if pos >= len(data):
1921
+ break
1922
+ extra2 = data[pos]
1923
+ pos += 1
1924
+
1925
+ if pos + state_len > len(data):
1926
+ break
1927
+
1928
+ state = data[pos : pos + state_len]
1929
+ pos += state_len
1930
+ records_parsed += 1
1931
+
1932
+ # Log the first few parsed records at WARNING level for tester visibility.
1933
+ if records_parsed <= 10 or self._logger.isEnabledFor(logging.DEBUG):
1934
+ self._logger.warning(
1935
+ "[CASAMBI_CLASSIC_STATE_PARSED] unit=%d flags=0x%02x state_len=%d "
1936
+ "offline=%s extra1=%d extra2=%d state=%s",
1937
+ unit_id,
1938
+ flags,
1939
+ state_len,
1940
+ is_offline,
1941
+ extra1,
1942
+ extra2,
1943
+ b2a(state),
1944
+ )
1945
+
1946
+ online = not is_offline
1947
+ # Let Unit.is_on derive actual on/off from state bytes (dimmer, onoff).
1948
+ on = True
1949
+
1950
+ self._dataCallback(
1951
+ IncommingPacketType.UnitState,
1952
+ {
1953
+ "id": unit_id,
1954
+ "online": online,
1955
+ "on": on,
1956
+ "state": state,
1957
+ "flags": flags,
1958
+ "prio": 0,
1959
+ "state_len": state_len,
1960
+ "padding_len": 0,
1961
+ "con": None,
1962
+ "sid": None,
1963
+ "extra_byte": extra1,
1964
+ "extra_float": extra1 / 255.0 if extra1 else 0.0,
1965
+ },
1966
+ )
1967
+
1968
+ old_pos = pos
1969
+ except IndexError:
1970
+ self._logger.error(
1971
+ "Ran out of data while parsing Classic unit state! Remaining data %s in %s.",
1972
+ b2a(data[old_pos:]),
1973
+ b2a(data),
1974
+ )
1975
+
1976
+ if records_parsed > 0:
1977
+ self._logger.debug(
1978
+ "[CASAMBI_CLASSIC_STATES_DONE] records=%d remaining=%d",
1979
+ records_parsed,
1980
+ len(data) - pos,
1981
+ )
1982
+
1705
1983
  def _parseUnitStates(self, data: bytes) -> None:
1706
1984
  # Ground truth: casambi-android `v1.C1775b.V(Q2.h)` parses decrypted packet type=6
1707
1985
  # as a stream of unit state records. Records have optional bytes depending on flags.
@@ -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.dev10"
10
+ __version__ = "0.3.12.dev12"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: casambi-bt-revamped
3
- Version: 0.3.12.dev10
3
+ Version: 0.3.12.dev12
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