casambi-bt-revamped 0.3.12.dev9__tar.gz → 0.3.12.dev11__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 (33) hide show
  1. {casambi_bt_revamped-0.3.12.dev9/src/casambi_bt_revamped.egg-info → casambi_bt_revamped-0.3.12.dev11}/PKG-INFO +1 -1
  2. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/setup.cfg +1 -1
  3. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_casambi.py +32 -10
  4. casambi_bt_revamped-0.3.12.dev11/src/CasambiBt/_classic_crypto.py +146 -0
  5. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_client.py +381 -3
  6. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_version.py +1 -1
  7. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11/src/casambi_bt_revamped.egg-info}/PKG-INFO +1 -1
  8. casambi_bt_revamped-0.3.12.dev11/tests/test_classic_protocol.py +385 -0
  9. casambi_bt_revamped-0.3.12.dev9/src/CasambiBt/_classic_crypto.py +0 -31
  10. casambi_bt_revamped-0.3.12.dev9/tests/test_classic_protocol.py +0 -157
  11. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/LICENSE +0 -0
  12. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/README.md +0 -0
  13. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/pyproject.toml +0 -0
  14. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/__init__.py +0 -0
  15. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_cache.py +0 -0
  16. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_constants.py +0 -0
  17. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_discover.py +0 -0
  18. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_encryption.py +0 -0
  19. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_invocation.py +0 -0
  20. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_keystore.py +0 -0
  21. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_network.py +0 -0
  22. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_operation.py +0 -0
  23. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_switch_events.py +0 -0
  24. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_unit.py +0 -0
  25. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/errors.py +0 -0
  26. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/py.typed +0 -0
  27. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/casambi_bt_revamped.egg-info/SOURCES.txt +0 -0
  28. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/casambi_bt_revamped.egg-info/dependency_links.txt +0 -0
  29. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/casambi_bt_revamped.egg-info/requires.txt +0 -0
  30. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/casambi_bt_revamped.egg-info/top_level.txt +0 -0
  31. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/tests/test_legacy_protocol_handling.py +0 -0
  32. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/tests/test_switch_event_logs.py +0 -0
  33. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/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.dev9
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,6 +1,6 @@
1
1
  [metadata]
2
2
  name = casambi-bt-revamped
3
- version = 0.3.12.dev9
3
+ version = 0.3.12.dev11
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
@@ -206,16 +211,33 @@ class Casambi:
206
211
 
207
212
  # Classic protocol uses signed command frames (u1.C1753e / u1.EnumC1754f).
208
213
  if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
209
- # EnumC1754f ordinals (ground truth: casambi-android u1.EnumC1754f):
210
- # - AllUnitsLevel=4, UnitLevel=7, GroupLevel=26
211
- if isinstance(target, Unit):
212
- cmd = self._casaClient.buildClassicCommand(7, payload, target_id=target.deviceId)
213
- elif isinstance(target, Group):
214
- cmd = self._casaClient.buildClassicCommand(26, payload, target_id=target.groudId)
215
- elif target is None:
216
- cmd = self._casaClient.buildClassicCommand(4, payload)
214
+ # Check if we should use the alternative simple format from BLE captures
215
+ import os
216
+ use_simple = os.environ.get("CASAMBI_BT_CLASSIC_FORMAT", "").lower() == "simple"
217
+
218
+ if use_simple:
219
+ # Simple format: [counter][unit_id][param_len][dimmer]
220
+ # For "all units", use unit_id=0xFF
221
+ if isinstance(target, Unit):
222
+ cmd = self._casaClient.buildClassicCommandSimple(target.deviceId, level)
223
+ elif isinstance(target, Group):
224
+ # Groups in simple format: not fully confirmed, try group ID
225
+ cmd = self._casaClient.buildClassicCommandSimple(target.groudId, level)
226
+ elif target is None:
227
+ cmd = self._casaClient.buildClassicCommandSimple(0xFF, level)
228
+ else:
229
+ raise TypeError(f"Unkown target type {type(target)}")
217
230
  else:
218
- raise TypeError(f"Unkown target type {type(target)}")
231
+ # EnumC1754f ordinals (ground truth: casambi-android u1.EnumC1754f):
232
+ # - AllUnitsLevel=4, UnitLevel=7, GroupLevel=26
233
+ if isinstance(target, Unit):
234
+ cmd = self._casaClient.buildClassicCommand(7, payload, target_id=target.deviceId)
235
+ elif isinstance(target, Group):
236
+ cmd = self._casaClient.buildClassicCommand(26, payload, target_id=target.groudId)
237
+ elif target is None:
238
+ cmd = self._casaClient.buildClassicCommand(4, payload)
239
+ else:
240
+ raise TypeError(f"Unkown target type {type(target)}")
219
241
 
220
242
  await self._casaClient.send(cmd)
221
243
  return
@@ -0,0 +1,146 @@
1
+ """Classic Casambi protocol helpers (CMAC signing/verification).
2
+
3
+ Ground truth:
4
+ - casambi-android `t1.P.o(...)` calculates a CMAC over:
5
+ connection_hash[0:8] + payload
6
+ and stores the CMAC (prefix) into the packet header.
7
+
8
+ The CMAC input structure from t1.P.o():
9
+ kVar.i(rVar.f16699L, 0, 8); // connection_hash[0:8]
10
+ kVar.i(b(), this.f16551t + i9, (c() - i9) - this.f16551t); // payload starting at seq offset
11
+
12
+ For "conformant" classic (rVar.Z=true):
13
+ CMAC input = conn_hash[0:8] || seq(2 bytes BE) || command_bytes
14
+
15
+ For "legacy" classic (rVar.Z=false):
16
+ CMAC input = conn_hash[0:8] || command_bytes
17
+
18
+ Test vectors based on community BLE captures (GitHub lkempf/casambi-bt#17):
19
+
20
+ Capture from sMauldaeschle:
21
+ Raw packet: 0215db43c40004040200b3
22
+ - Byte 0: 02 (auth level = visitor)
23
+ - Bytes 1-4: 15db43c4 (4-byte CMAC signature)
24
+ - Byte 5: 00 (padding/marker)
25
+ - Byte 6: 04 (counter/sequence)
26
+ - Byte 7: 04 (unit id)
27
+ - Byte 8: 02 (length of parameters)
28
+ - Byte 9: 00 (dimmer value)
29
+ - Byte 10: b3 (temperature/vertical value)
30
+
31
+ Capture from FliegenKLATSCH:
32
+ Raw packet: 0200 43A2 9600 0203 0254 FF
33
+ - Bytes 0-1: 0200 (auth 02, then...)
34
+ - Bytes 2-5: 43A2 9600 (first 4 bytes of CMAC)
35
+ - The CMAC input was: <8 bytes connection hash> 00 0203 0254 FF
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import logging
41
+ from typing import TYPE_CHECKING
42
+
43
+ from cryptography.hazmat.primitives.cmac import CMAC
44
+ from cryptography.hazmat.primitives.ciphers.algorithms import AES
45
+
46
+ if TYPE_CHECKING:
47
+ pass
48
+
49
+ _logger = logging.getLogger(__name__)
50
+
51
+
52
+ def classic_cmac(key: bytes, conn_hash8: bytes, payload: bytes, *, debug: bool = False) -> bytes:
53
+ """Compute the Classic CMAC (16 bytes) over connection hash + payload.
54
+
55
+ Args:
56
+ key: 16-byte AES key (visitor or manager key from cloud)
57
+ conn_hash8: First 8 bytes of the connection hash read from CA51/auth char
58
+ payload: The data to sign (for conformant: seq + command; for legacy: command only)
59
+ debug: If True, log CMAC input/output for debugging
60
+
61
+ Returns:
62
+ 16-byte CMAC
63
+ """
64
+ if len(conn_hash8) != 8:
65
+ raise ValueError("conn_hash8 must be 8 bytes")
66
+
67
+ cmac_input = conn_hash8 + payload
68
+ cmac = CMAC(AES(key))
69
+ cmac.update(cmac_input)
70
+ result = cmac.finalize()
71
+
72
+ if debug:
73
+ _logger.warning(
74
+ "[CLASSIC_CMAC_DEBUG] key_len=%d conn_hash8=%s payload_len=%d payload_prefix=%s cmac=%s",
75
+ len(key),
76
+ conn_hash8.hex(),
77
+ len(payload),
78
+ payload[:16].hex() if len(payload) > 16 else payload.hex(),
79
+ result.hex(),
80
+ )
81
+
82
+ return result
83
+
84
+
85
+ def classic_cmac_prefix(
86
+ key: bytes, conn_hash8: bytes, payload: bytes, prefix_len: int, *, debug: bool = False
87
+ ) -> bytes:
88
+ """Return the prefix bytes that are embedded into the Classic packet header.
89
+
90
+ Args:
91
+ key: 16-byte AES key
92
+ conn_hash8: First 8 bytes of connection hash
93
+ payload: Data to sign
94
+ prefix_len: Number of CMAC bytes to use (4 for visitor, 16 for manager)
95
+ debug: If True, log CMAC computation
96
+
97
+ Returns:
98
+ First prefix_len bytes of the CMAC
99
+ """
100
+ mac = classic_cmac(key, conn_hash8, payload, debug=debug)
101
+ return mac[:prefix_len]
102
+
103
+
104
+ def verify_classic_cmac(
105
+ key: bytes, conn_hash8: bytes, payload: bytes, expected_prefix: bytes
106
+ ) -> bool:
107
+ """Verify a Classic CMAC signature prefix.
108
+
109
+ Args:
110
+ key: 16-byte AES key
111
+ conn_hash8: First 8 bytes of connection hash
112
+ payload: Data that was signed
113
+ expected_prefix: The CMAC prefix from the packet header
114
+
115
+ Returns:
116
+ True if the computed CMAC prefix matches expected_prefix
117
+ """
118
+ computed = classic_cmac_prefix(key, conn_hash8, payload, len(expected_prefix))
119
+ return computed == expected_prefix
120
+
121
+
122
+ # Test vectors from RFC 4493 (AES-CMAC) - used in test_classic_protocol.py
123
+ # These confirm our CMAC implementation is correct.
124
+ RFC4493_TEST_KEY = bytes.fromhex("2b7e151628aed2a6abf7158809cf4f3c")
125
+ RFC4493_VECTORS = [
126
+ # (message, expected_cmac)
127
+ (bytes.fromhex("6bc1bee22e409f96e93d7e117393172a"),
128
+ bytes.fromhex("070a16b46b4d4144f79bdd9dd04a287c")),
129
+ (bytes.fromhex("6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e51"),
130
+ bytes.fromhex("ce0cbf1738f4df6428b1d93bf12081c9")),
131
+ ]
132
+
133
+ # Placeholder for real Casambi test vectors once we have them from captures
134
+ # Format: (visitor_key, conn_hash8, payload_after_header, expected_cmac_prefix_4, description)
135
+ CASAMBI_CLASSIC_TEST_VECTORS: list[tuple[bytes, bytes, bytes, bytes, str]] = [
136
+ # These will be populated from real BLE captures
137
+ # Example structure (not verified yet):
138
+ # (
139
+ # bytes.fromhex("...16 byte key..."),
140
+ # bytes.fromhex("...8 byte conn hash..."),
141
+ # bytes.fromhex("...payload after auth byte..."),
142
+ # bytes.fromhex("...4 byte expected cmac..."),
143
+ # "Description of what this packet does"
144
+ # ),
145
+ ]
146
+
@@ -159,10 +159,16 @@ 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()
165
166
 
167
+ # Classic diagnostic packet history (for dump_classic_diagnostics service)
168
+ self._classicTxHistory: list[dict[str, Any]] = []
169
+ self._classicRxHistory: list[dict[str, Any]] = []
170
+ self._classicDiagMaxHistory = 50 # Keep last 50 TX and RX packets
171
+
166
172
  @property
167
173
  def protocolMode(self) -> ProtocolMode | None:
168
174
  return self._protocolMode
@@ -1004,6 +1010,39 @@ class CasambiClient:
1004
1010
 
1005
1011
  return bytes(b)
1006
1012
 
1013
+ def buildClassicCommandSimple(
1014
+ self,
1015
+ unit_id: int,
1016
+ dimmer: int,
1017
+ extra: int | None = None,
1018
+ ) -> bytes:
1019
+ """Build a Classic command using the simple format from BLE captures.
1020
+
1021
+ This alternative format was observed in real BLE captures and differs from
1022
+ the Android u1.C1753e command record format. Use with env variable
1023
+ CASAMBI_BT_CLASSIC_FORMAT=simple to experiment.
1024
+
1025
+ Format (before header added by _sendClassic):
1026
+ [counter:1][unit_id:1][param_len:1][dimmer:1][extra:1?]
1027
+
1028
+ The header (added by _sendClassic) is:
1029
+ - Conformant: [auth:1][cmac:4|16][seq:2]
1030
+ - Legacy: [cmac:4]
1031
+
1032
+ Args:
1033
+ unit_id: Target unit ID (0-255, use 0xFF for "all units")
1034
+ dimmer: Dimmer/level value (0-255)
1035
+ extra: Optional extra parameter (e.g., temperature/vertical value)
1036
+
1037
+ Returns:
1038
+ Command bytes to pass to _sendClassic
1039
+ """
1040
+ counter = self._classic_next_div()
1041
+ if extra is not None:
1042
+ return bytes([counter, unit_id & 0xFF, 2, dimmer & 0xFF, extra & 0xFF])
1043
+ else:
1044
+ return bytes([counter, unit_id & 0xFF, 1, dimmer & 0xFF])
1045
+
1007
1046
  async def _sendClassic(self, command_bytes: bytes) -> None:
1008
1047
  self._checkState(ConnectionState.AUTHENTICATED)
1009
1048
  if self._protocolMode != ProtocolMode.CLASSIC:
@@ -1143,7 +1182,115 @@ class CasambiClient:
1143
1182
 
1144
1183
  # Classic packets can exceed 20 bytes when using a 16-byte manager signature.
1145
1184
  # Bleak needs a write-with-response for long writes on most backends.
1146
- await self._gattClient.write_gatt_char(tx_uuid, bytes(pkt), response=True)
1185
+ tx_result = "pending"
1186
+ try:
1187
+ await self._gattClient.write_gatt_char(tx_uuid, bytes(pkt), response=True)
1188
+ tx_result = "ok"
1189
+ except Exception as e:
1190
+ tx_result = f"error: {type(e).__name__}: {e}"
1191
+ raise
1192
+ finally:
1193
+ # Record TX in diagnostic history
1194
+ tx_entry = {
1195
+ "timestamp": time.monotonic(),
1196
+ "header_mode": header_mode,
1197
+ "key": key_name,
1198
+ "signed": signed,
1199
+ "tx_uuid": tx_uuid,
1200
+ "auth_level": auth_level if header_mode == "conformant" else None,
1201
+ "sig_len": sig_len,
1202
+ "seq": seq,
1203
+ "cmd_ordinal": cmd_ordinal,
1204
+ "cmd_target": cmd_target,
1205
+ "cmd_div": cmd_div,
1206
+ "cmd_lifetime": cmd_lifetime,
1207
+ "cmd_payload_len": cmd_payload_len,
1208
+ "total_len": len(pkt),
1209
+ "pre_sign_hex": b2a(command_bytes).decode("ascii"),
1210
+ "post_sign_hex": b2a(bytes(pkt)).decode("ascii"),
1211
+ "result": tx_result,
1212
+ }
1213
+ self._classicTxHistory.append(tx_entry)
1214
+ if len(self._classicTxHistory) > self._classicDiagMaxHistory:
1215
+ self._classicTxHistory = self._classicTxHistory[-self._classicDiagMaxHistory:]
1216
+
1217
+ # Enhanced TX diagnostic log
1218
+ self._logger.warning(
1219
+ "[CLASSIC_DIAG_TX_RESULT] result=%s header=%s seq=%s total_len=%d",
1220
+ tx_result,
1221
+ header_mode,
1222
+ None if seq is None else f"0x{seq:04x}",
1223
+ len(pkt),
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
+ )
1147
1294
 
1148
1295
  def _establishedNofityCallback(
1149
1296
  self, handle: BleakGATTCharacteristic, data: bytes
@@ -1231,10 +1378,26 @@ class CasambiClient:
1231
1378
  """
1232
1379
  self._inPacketCount += 1
1233
1380
  self._classicRxFrames += 1
1381
+ rx_ts = time.monotonic()
1234
1382
  if self._classicFirstRxTs is None:
1235
- self._classicFirstRxTs = time.monotonic()
1383
+ self._classicFirstRxTs = rx_ts
1236
1384
 
1237
1385
  raw = bytes(data)
1386
+
1387
+ # Enhanced RX diagnostic logging
1388
+ try:
1389
+ handle_uuid = str(getattr(handle, "uuid", "unknown")).lower()
1390
+ except Exception:
1391
+ handle_uuid = "unknown"
1392
+
1393
+ self._logger.warning(
1394
+ "[CLASSIC_DIAG_RX] #%d handle=%s len=%d hex=%s",
1395
+ self._classicRxFrames,
1396
+ handle_uuid,
1397
+ len(raw),
1398
+ b2a(raw[: min(len(raw), 48)]).decode("ascii") + ("..." if len(raw) > 48 else ""),
1399
+ )
1400
+
1238
1401
  if self._logger.isEnabledFor(logging.DEBUG):
1239
1402
  self._logger.debug(
1240
1403
  "[CASAMBI_CLASSIC_RX_RAW] len=%d hex=%s",
@@ -1424,6 +1587,36 @@ class CasambiClient:
1424
1587
  elif verified is None:
1425
1588
  self._classicRxUnverifiable += 1
1426
1589
 
1590
+ # Record RX in diagnostic history
1591
+ rx_entry = {
1592
+ "timestamp": rx_ts,
1593
+ "handle_uuid": handle_uuid,
1594
+ "header_mode": best["mode"],
1595
+ "verified": verified,
1596
+ "auth_level": best["auth_level"],
1597
+ "sig_len": best["sig_len"],
1598
+ "seq": best["seq"],
1599
+ "payload_len": len(payload),
1600
+ "raw_hex": b2a(raw).decode("ascii"),
1601
+ "payload_hex": b2a(payload).decode("ascii"),
1602
+ "score": best["score"],
1603
+ }
1604
+ self._classicRxHistory.append(rx_entry)
1605
+ if len(self._classicRxHistory) > self._classicDiagMaxHistory:
1606
+ self._classicRxHistory = self._classicRxHistory[-self._classicDiagMaxHistory:]
1607
+
1608
+ # Enhanced RX parse result log
1609
+ self._logger.warning(
1610
+ "[CLASSIC_DIAG_RX_PARSE] mode=%s verified=%s auth=%s sig_len=%d seq=%s score=%d payload_len=%d",
1611
+ best["mode"],
1612
+ verified,
1613
+ None if best["auth_level"] is None else f"0x{best['auth_level']:02x}",
1614
+ best["sig_len"],
1615
+ None if best["seq"] is None else f"0x{best['seq']:04x}",
1616
+ best["score"],
1617
+ len(payload),
1618
+ )
1619
+
1427
1620
  # Auto-correct header mode if the other format parses much better.
1428
1621
  if best["mode"] != preferred:
1429
1622
  # Only switch if we got a stronger signal (verified or plausible payload with fewer assumptions).
@@ -1456,7 +1649,7 @@ class CasambiClient:
1456
1649
  self._classicRxLastStatsTs = now
1457
1650
  self._logger.warning(
1458
1651
  "[CASAMBI_CLASSIC_RX_STATS] frames=%d verified=%d unverifiable=%d parse_fail=%d header=%s "
1459
- "type6=%d type7=%d type9=%d cmdstream=%d unknown=%d",
1652
+ "type6=%d type7=%d type9=%d cmdstream=%d unknown=%d classic_states=%d",
1460
1653
  self._classicRxFrames,
1461
1654
  self._classicRxVerified,
1462
1655
  self._classicRxUnverifiable,
@@ -1467,8 +1660,17 @@ class CasambiClient:
1467
1660
  self._classicRxType9,
1468
1661
  self._classicRxCmdStream,
1469
1662
  self._classicRxUnknown,
1663
+ self._classicRxClassicStates,
1470
1664
  )
1471
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
+
1472
1674
  # If the payload starts with a known EVO packet type, reuse existing parsers.
1473
1675
  packet_type = payload[0]
1474
1676
  if packet_type in (IncommingPacketType.UnitState, IncommingPacketType.SwitchEvent, IncommingPacketType.NetworkConfig):
@@ -1579,6 +1781,146 @@ class CasambiClient:
1579
1781
  b2a(payload[pos:]),
1580
1782
  )
1581
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
+
1582
1924
  def _parseUnitStates(self, data: bytes) -> None:
1583
1925
  # Ground truth: casambi-android `v1.C1775b.V(Q2.h)` parses decrypted packet type=6
1584
1926
  # as a stream of unit state records. Records have optional bytes depending on flags.
@@ -1865,3 +2207,39 @@ class CasambiClient:
1865
2207
 
1866
2208
  self._connectionState = ConnectionState.NONE
1867
2209
  self._logger.info("Disconnected.")
2210
+
2211
+ def getClassicDiagnostics(self) -> dict[str, Any]:
2212
+ """Return Classic protocol diagnostic state for external services.
2213
+
2214
+ This method provides a snapshot of Classic protocol state including:
2215
+ - Connection parameters (hash, mode, UUIDs)
2216
+ - RX/TX statistics
2217
+ - Last N TX and RX packets
2218
+ - Any detected errors or anomalies
2219
+
2220
+ Safe to call from HA services for dump_classic_diagnostics.
2221
+ """
2222
+ return {
2223
+ "protocol_mode": self._protocolMode.name if self._protocolMode else None,
2224
+ "classic_header_mode": self._classicHeaderMode,
2225
+ "classic_hash_source": self._classicHashSource,
2226
+ "classic_conn_hash8_hex": b2a(self._classicConnHash8).decode("ascii") if self._classicConnHash8 else None,
2227
+ "classic_tx_uuid": self._classicTxCharUuid,
2228
+ "classic_notify_uuids": sorted(self._classicNotifyCharUuids) if self._classicNotifyCharUuids else [],
2229
+ "classic_first_rx_ts": self._classicFirstRxTs,
2230
+ "classic_rx_stats": {
2231
+ "frames": self._classicRxFrames,
2232
+ "verified": self._classicRxVerified,
2233
+ "unverifiable": self._classicRxUnverifiable,
2234
+ "parse_fail": self._classicRxParseFail,
2235
+ "type6_unitstate": self._classicRxType6,
2236
+ "type7_switch": self._classicRxType7,
2237
+ "type9_netconf": self._classicRxType9,
2238
+ "cmdstream": self._classicRxCmdStream,
2239
+ "unknown": self._classicRxUnknown,
2240
+ },
2241
+ "classic_tx_count": len(self._classicTxHistory),
2242
+ "classic_rx_count": len(self._classicRxHistory),
2243
+ "classic_tx_history": self._classicTxHistory[-20:], # Last 20
2244
+ "classic_rx_history": self._classicRxHistory[-20:], # Last 20
2245
+ }
@@ -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.dev9"
10
+ __version__ = "0.3.12.dev10"