casambi-bt-revamped 0.3.12.dev10__py3-none-any.whl → 0.3.12.dev11__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
@@ -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
CasambiBt/_client.py CHANGED
@@ -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()
@@ -1222,6 +1223,75 @@ class CasambiClient:
1222
1223
  len(pkt),
1223
1224
  )
1224
1225
 
1226
+ async def classicSendInit(self) -> None:
1227
+ """Send Classic post-connection initialization (time-sync).
1228
+
1229
+ Ground truth: casambi-android AbstractC1717h.X() (lines 254-345).
1230
+ The Android app sends this as the first packet after Classic connection.
1231
+ In EVO, the key exchange/auth handshake implicitly signals the device;
1232
+ Classic has no such handshake, so an explicit init write is needed to
1233
+ trigger the device to start broadcasting state notifications.
1234
+
1235
+ The payload is sent raw via _sendClassic (NOT wrapped in buildClassicCommand).
1236
+ """
1237
+ self._checkState(ConnectionState.AUTHENTICATED)
1238
+ if self._protocolMode != ProtocolMode.CLASSIC:
1239
+ return
1240
+
1241
+ import datetime as _dt
1242
+
1243
+ now = _dt.datetime.now()
1244
+
1245
+ # Timezone offset in minutes from UTC.
1246
+ local_tz = _dt.datetime.now(_dt.timezone.utc).astimezone().tzinfo
1247
+ utc_offset_minutes = 0
1248
+ if local_tz is not None:
1249
+ offset = local_tz.utcoffset(now)
1250
+ if offset is not None:
1251
+ utc_offset_minutes = int(offset.total_seconds()) // 60
1252
+
1253
+ # Build the time-sync payload.
1254
+ # Format: [10][year:2BE][month:1][day:1][hour:1][min:1][sec:1]
1255
+ # [tz_offset:2BE signed][dst_transition:4BE][dst_change:1]
1256
+ # [timestamp1:4BE][timestamp2:4BE][zero:2][millis:3BE]
1257
+ payload = bytearray()
1258
+ payload.append(10) # Classic time-sync command byte
1259
+ payload.extend(struct.pack(">H", now.year))
1260
+ payload.append(now.month)
1261
+ payload.append(now.day)
1262
+ payload.append(now.hour)
1263
+ payload.append(now.minute)
1264
+ payload.append(now.second)
1265
+ payload.extend(struct.pack(">h", utc_offset_minutes))
1266
+ # DST transition data and change minutes (0 = no DST info).
1267
+ payload.extend(struct.pack(">I", 0))
1268
+ payload.append(0)
1269
+ # Classic extra bytes: timestamps, zero short, millis.
1270
+ # Start with zeros - refine after tester feedback if needed.
1271
+ payload.extend(struct.pack(">I", 0)) # timestamp1
1272
+ payload.extend(struct.pack(">I", 0)) # timestamp2
1273
+ payload.extend(struct.pack(">H", 0)) # zero
1274
+ # j() in Android is a 3-byte big-endian write.
1275
+ millis_val = now.microsecond // 1000 * 1000
1276
+ payload.append((millis_val >> 16) & 0xFF)
1277
+ payload.append((millis_val >> 8) & 0xFF)
1278
+ payload.append(millis_val & 0xFF)
1279
+
1280
+ self._logger.warning(
1281
+ "[CASAMBI_CLASSIC_INIT] sending time-sync len=%d hex=%s",
1282
+ len(payload),
1283
+ b2a(bytes(payload)),
1284
+ )
1285
+
1286
+ try:
1287
+ await self._sendClassic(bytes(payload))
1288
+ self._logger.warning("[CASAMBI_CLASSIC_INIT] time-sync sent successfully")
1289
+ except Exception:
1290
+ self._logger.warning(
1291
+ "[CASAMBI_CLASSIC_INIT] time-sync send failed",
1292
+ exc_info=True,
1293
+ )
1294
+
1225
1295
  def _establishedNofityCallback(
1226
1296
  self, handle: BleakGATTCharacteristic, data: bytes
1227
1297
  ) -> None:
@@ -1579,7 +1649,7 @@ class CasambiClient:
1579
1649
  self._classicRxLastStatsTs = now
1580
1650
  self._logger.warning(
1581
1651
  "[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",
1652
+ "type6=%d type7=%d type9=%d cmdstream=%d unknown=%d classic_states=%d",
1583
1653
  self._classicRxFrames,
1584
1654
  self._classicRxVerified,
1585
1655
  self._classicRxUnverifiable,
@@ -1590,8 +1660,17 @@ class CasambiClient:
1590
1660
  self._classicRxType9,
1591
1661
  self._classicRxCmdStream,
1592
1662
  self._classicRxUnknown,
1663
+ self._classicRxClassicStates,
1593
1664
  )
1594
1665
 
1666
+ # Classic payloads use a completely different format from EVO.
1667
+ # Classic: byte 0 is a type indicator (0=netconfig, 255=log, else=unit_id).
1668
+ # EVO: byte 0 is a packet type (6=UnitState, 7=Switch, 9=NetConfig).
1669
+ # Dispatch Classic through its own parser to avoid misinterpretation.
1670
+ if self._protocolMode == ProtocolMode.CLASSIC:
1671
+ self._dispatchClassicPayload(payload)
1672
+ return
1673
+
1595
1674
  # If the payload starts with a known EVO packet type, reuse existing parsers.
1596
1675
  packet_type = payload[0]
1597
1676
  if packet_type in (IncommingPacketType.UnitState, IncommingPacketType.SwitchEvent, IncommingPacketType.NetworkConfig):
@@ -1702,6 +1781,146 @@ class CasambiClient:
1702
1781
  b2a(payload[pos:]),
1703
1782
  )
1704
1783
 
1784
+ def _dispatchClassicPayload(self, payload: bytes) -> None:
1785
+ """Dispatch a verified Classic payload based on its type indicator.
1786
+
1787
+ Classic payloads (from C1751c.V()) use a different format from EVO:
1788
+ - byte 0 == 0: network config data
1789
+ - byte 0 == 255: log message
1790
+ - otherwise: unit state stream (byte 0 is the first unit_id)
1791
+ """
1792
+ if not payload:
1793
+ return
1794
+
1795
+ first_byte = payload[0]
1796
+
1797
+ # Log full payload for the first 10 Classic payloads regardless of type.
1798
+ if self._classicRxClassicStates < 10:
1799
+ self._logger.warning(
1800
+ "[CASAMBI_CLASSIC_DISPATCH] #%d type_byte=%d len=%d hex=%s",
1801
+ self._classicRxClassicStates,
1802
+ first_byte,
1803
+ len(payload),
1804
+ b2a(payload[: min(len(payload), 64)]).decode("ascii")
1805
+ + ("..." if len(payload) > 64 else ""),
1806
+ )
1807
+
1808
+ if first_byte == 0:
1809
+ self._logger.debug("[CASAMBI_CLASSIC_NETCONFIG] len=%d", len(payload))
1810
+ return
1811
+
1812
+ if first_byte == 255:
1813
+ self._logger.debug("[CASAMBI_CLASSIC_LOG] len=%d", len(payload))
1814
+ return
1815
+
1816
+ # Unit state stream: entire payload is passed (first byte is the first unit_id).
1817
+ self._classicRxClassicStates += 1
1818
+ self._parseClassicUnitStates(payload)
1819
+
1820
+ def _parseClassicUnitStates(self, data: bytes) -> None:
1821
+ """Parse Classic unit state records.
1822
+
1823
+ Ground truth: casambi-android C1751c.V() (line 301+).
1824
+ Format is completely different from EVO _parseUnitStates:
1825
+ - flags lower nibble = state_len (EVO uses a separate byte)
1826
+ - flags bit 5 = extra1 present, bit 6 = extra2 present, bit 7 = offline
1827
+ - unit_id 0xF0 = command response (skip)
1828
+ """
1829
+ self._logger.debug("Parsing Classic unit states...")
1830
+ if self._logger.isEnabledFor(logging.DEBUG):
1831
+ self._logger.debug("[CASAMBI_CLASSIC_STATES_RAW] len=%d hex=%s", len(data), b2a(data))
1832
+
1833
+ pos = 0
1834
+ old_pos = 0
1835
+ records_parsed = 0
1836
+ try:
1837
+ while pos + 2 <= len(data):
1838
+ unit_id = data[pos]
1839
+ flags = data[pos + 1]
1840
+ pos += 2
1841
+
1842
+ state_len = flags & 0x0F
1843
+ has_extra1 = (flags & 0x20) != 0
1844
+ has_extra2 = (flags & 0x40) != 0
1845
+ is_offline = (flags & 0x80) != 0
1846
+
1847
+ # 0xF0 = command response record, skip state_len bytes.
1848
+ if unit_id == 0xF0:
1849
+ pos += state_len
1850
+ continue
1851
+
1852
+ extra1 = 0
1853
+ if has_extra1:
1854
+ if pos >= len(data):
1855
+ break
1856
+ extra1 = data[pos]
1857
+ pos += 1
1858
+
1859
+ extra2 = 0
1860
+ if has_extra2:
1861
+ if pos >= len(data):
1862
+ break
1863
+ extra2 = data[pos]
1864
+ pos += 1
1865
+
1866
+ if pos + state_len > len(data):
1867
+ break
1868
+
1869
+ state = data[pos : pos + state_len]
1870
+ pos += state_len
1871
+ records_parsed += 1
1872
+
1873
+ # Log the first few parsed records at WARNING level for tester visibility.
1874
+ if records_parsed <= 10 or self._logger.isEnabledFor(logging.DEBUG):
1875
+ self._logger.warning(
1876
+ "[CASAMBI_CLASSIC_STATE_PARSED] unit=%d flags=0x%02x state_len=%d "
1877
+ "offline=%s extra1=%d extra2=%d state=%s",
1878
+ unit_id,
1879
+ flags,
1880
+ state_len,
1881
+ is_offline,
1882
+ extra1,
1883
+ extra2,
1884
+ b2a(state),
1885
+ )
1886
+
1887
+ online = not is_offline
1888
+ # Let Unit.is_on derive actual on/off from state bytes (dimmer, onoff).
1889
+ on = True
1890
+
1891
+ self._dataCallback(
1892
+ IncommingPacketType.UnitState,
1893
+ {
1894
+ "id": unit_id,
1895
+ "online": online,
1896
+ "on": on,
1897
+ "state": state,
1898
+ "flags": flags,
1899
+ "prio": 0,
1900
+ "state_len": state_len,
1901
+ "padding_len": 0,
1902
+ "con": None,
1903
+ "sid": None,
1904
+ "extra_byte": extra1,
1905
+ "extra_float": extra1 / 255.0 if extra1 else 0.0,
1906
+ },
1907
+ )
1908
+
1909
+ old_pos = pos
1910
+ except IndexError:
1911
+ self._logger.error(
1912
+ "Ran out of data while parsing Classic unit state! Remaining data %s in %s.",
1913
+ b2a(data[old_pos:]),
1914
+ b2a(data),
1915
+ )
1916
+
1917
+ if records_parsed > 0:
1918
+ self._logger.debug(
1919
+ "[CASAMBI_CLASSIC_STATES_DONE] records=%d remaining=%d",
1920
+ records_parsed,
1921
+ len(data) - pos,
1922
+ )
1923
+
1705
1924
  def _parseUnitStates(self, data: bytes) -> None:
1706
1925
  # Ground truth: casambi-android `v1.C1775b.V(Q2.h)` parses decrypted packet type=6
1707
1926
  # as a stream of unit state records. Records have optional bytes depending on flags.
@@ -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.dev11
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,8 +1,8 @@
1
1
  CasambiBt/__init__.py,sha256=iJdTF4oeXfj5d5gfGxQkacqUjtnQo0IW-zFPJvFjWWk,336
2
2
  CasambiBt/_cache.py,sha256=3bQil8vhSy4f4sf9JusMfEdQC7d3cJuva9qHhyKro-0,3808
3
- CasambiBt/_casambi.py,sha256=9pWTxR1ZBARK-IB91PSRAQrcRoswU40jI-9AfLPpvW0,41649
3
+ CasambiBt/_casambi.py,sha256=dAZZ0S2-t2ShLbW78AE9lOLBzOmhBOTTXJky-6khdkE,41981
4
4
  CasambiBt/_classic_crypto.py,sha256=XIp3JBaeY8hIUv5kB0ygVG_eRx9AgHHF4ts2--CFm78,4973
5
- CasambiBt/_client.py,sha256=EuaCwb3t6D0sbQR6XA84UWDEtkc3n7WBRozsM2kFu-Y,83927
5
+ CasambiBt/_client.py,sha256=-C1WfIa8IOTB5R9-nGSKJwE6yLtJC5Meo5FKUq9_XEw,92546
6
6
  CasambiBt/_constants.py,sha256=86heoDdb5iPaRrPmK2DIIl-4uSxbFFcnCo9zlCvTLww,1290
7
7
  CasambiBt/_discover.py,sha256=jLc6H69JddrCURgtANZEjws6_UbSzXJtvJkbKTaIUHY,1849
8
8
  CasambiBt/_encryption.py,sha256=CLcoOOrggQqhJbnr_emBnEnkizpWDvb_0yFnitq4_FM,3831
@@ -15,8 +15,8 @@ CasambiBt/_unit.py,sha256=nxbg_8UCCVB9WI8dUS21g2JrGyPKcefqKMSusMOhLOo,18721
15
15
  CasambiBt/_version.py,sha256=KfDHVZ0HvUoCJCQD90I4l0PCSgOKne4pUVo8Y_Hv5Xk,338
16
16
  CasambiBt/errors.py,sha256=1L_Q8og_N_BRYEKizghAQXr6tihlHykFgtcCHUDcBas,1961
17
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,,
18
+ casambi_bt_revamped-0.3.12.dev11.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
19
+ casambi_bt_revamped-0.3.12.dev11.dist-info/METADATA,sha256=fTNunmvIZDbzGByz_OJPXkDf2Jstu7y2Rp5DCxZ62IY,5878
20
+ casambi_bt_revamped-0.3.12.dev11.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
21
+ casambi_bt_revamped-0.3.12.dev11.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
22
+ casambi_bt_revamped-0.3.12.dev11.dist-info/RECORD,,