casambi-bt-revamped 0.3.12.dev9__tar.gz → 0.3.12.dev10__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.dev10}/PKG-INFO +1 -1
  2. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/setup.cfg +1 -1
  3. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_casambi.py +26 -9
  4. casambi_bt_revamped-0.3.12.dev10/src/CasambiBt/_classic_crypto.py +146 -0
  5. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_client.py +161 -2
  6. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_version.py +1 -1
  7. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10/src/casambi_bt_revamped.egg-info}/PKG-INFO +1 -1
  8. casambi_bt_revamped-0.3.12.dev10/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.dev10}/LICENSE +0 -0
  12. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/README.md +0 -0
  13. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/pyproject.toml +0 -0
  14. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/__init__.py +0 -0
  15. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_cache.py +0 -0
  16. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_constants.py +0 -0
  17. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_discover.py +0 -0
  18. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_encryption.py +0 -0
  19. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_invocation.py +0 -0
  20. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_keystore.py +0 -0
  21. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_network.py +0 -0
  22. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_operation.py +0 -0
  23. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_switch_events.py +0 -0
  24. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_unit.py +0 -0
  25. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/errors.py +0 -0
  26. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/py.typed +0 -0
  27. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/casambi_bt_revamped.egg-info/SOURCES.txt +0 -0
  28. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/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.dev10}/src/casambi_bt_revamped.egg-info/requires.txt +0 -0
  30. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/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.dev10}/tests/test_legacy_protocol_handling.py +0 -0
  32. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/tests/test_switch_event_logs.py +0 -0
  33. {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/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.dev10
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.dev10
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
@@ -206,16 +206,33 @@ class Casambi:
206
206
 
207
207
  # Classic protocol uses signed command frames (u1.C1753e / u1.EnumC1754f).
208
208
  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)
209
+ # Check if we should use the alternative simple format from BLE captures
210
+ import os
211
+ use_simple = os.environ.get("CASAMBI_BT_CLASSIC_FORMAT", "").lower() == "simple"
212
+
213
+ if use_simple:
214
+ # Simple format: [counter][unit_id][param_len][dimmer]
215
+ # For "all units", use unit_id=0xFF
216
+ if isinstance(target, Unit):
217
+ cmd = self._casaClient.buildClassicCommandSimple(target.deviceId, level)
218
+ elif isinstance(target, Group):
219
+ # Groups in simple format: not fully confirmed, try group ID
220
+ cmd = self._casaClient.buildClassicCommandSimple(target.groudId, level)
221
+ elif target is None:
222
+ cmd = self._casaClient.buildClassicCommandSimple(0xFF, level)
223
+ else:
224
+ raise TypeError(f"Unkown target type {type(target)}")
217
225
  else:
218
- raise TypeError(f"Unkown target type {type(target)}")
226
+ # EnumC1754f ordinals (ground truth: casambi-android u1.EnumC1754f):
227
+ # - AllUnitsLevel=4, UnitLevel=7, GroupLevel=26
228
+ if isinstance(target, Unit):
229
+ cmd = self._casaClient.buildClassicCommand(7, payload, target_id=target.deviceId)
230
+ elif isinstance(target, Group):
231
+ cmd = self._casaClient.buildClassicCommand(26, payload, target_id=target.groudId)
232
+ elif target is None:
233
+ cmd = self._casaClient.buildClassicCommand(4, payload)
234
+ else:
235
+ raise TypeError(f"Unkown target type {type(target)}")
219
236
 
220
237
  await self._casaClient.send(cmd)
221
238
  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
+
@@ -163,6 +163,11 @@ class CasambiClient:
163
163
  self._classicRxKindSamples: dict[str, int] = {}
164
164
  self._classicRxLastStatsTs = time.monotonic()
165
165
 
166
+ # Classic diagnostic packet history (for dump_classic_diagnostics service)
167
+ self._classicTxHistory: list[dict[str, Any]] = []
168
+ self._classicRxHistory: list[dict[str, Any]] = []
169
+ self._classicDiagMaxHistory = 50 # Keep last 50 TX and RX packets
170
+
166
171
  @property
167
172
  def protocolMode(self) -> ProtocolMode | None:
168
173
  return self._protocolMode
@@ -1004,6 +1009,39 @@ class CasambiClient:
1004
1009
 
1005
1010
  return bytes(b)
1006
1011
 
1012
+ def buildClassicCommandSimple(
1013
+ self,
1014
+ unit_id: int,
1015
+ dimmer: int,
1016
+ extra: int | None = None,
1017
+ ) -> bytes:
1018
+ """Build a Classic command using the simple format from BLE captures.
1019
+
1020
+ This alternative format was observed in real BLE captures and differs from
1021
+ the Android u1.C1753e command record format. Use with env variable
1022
+ CASAMBI_BT_CLASSIC_FORMAT=simple to experiment.
1023
+
1024
+ Format (before header added by _sendClassic):
1025
+ [counter:1][unit_id:1][param_len:1][dimmer:1][extra:1?]
1026
+
1027
+ The header (added by _sendClassic) is:
1028
+ - Conformant: [auth:1][cmac:4|16][seq:2]
1029
+ - Legacy: [cmac:4]
1030
+
1031
+ Args:
1032
+ unit_id: Target unit ID (0-255, use 0xFF for "all units")
1033
+ dimmer: Dimmer/level value (0-255)
1034
+ extra: Optional extra parameter (e.g., temperature/vertical value)
1035
+
1036
+ Returns:
1037
+ Command bytes to pass to _sendClassic
1038
+ """
1039
+ counter = self._classic_next_div()
1040
+ if extra is not None:
1041
+ return bytes([counter, unit_id & 0xFF, 2, dimmer & 0xFF, extra & 0xFF])
1042
+ else:
1043
+ return bytes([counter, unit_id & 0xFF, 1, dimmer & 0xFF])
1044
+
1007
1045
  async def _sendClassic(self, command_bytes: bytes) -> None:
1008
1046
  self._checkState(ConnectionState.AUTHENTICATED)
1009
1047
  if self._protocolMode != ProtocolMode.CLASSIC:
@@ -1143,7 +1181,46 @@ class CasambiClient:
1143
1181
 
1144
1182
  # Classic packets can exceed 20 bytes when using a 16-byte manager signature.
1145
1183
  # 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)
1184
+ tx_result = "pending"
1185
+ try:
1186
+ await self._gattClient.write_gatt_char(tx_uuid, bytes(pkt), response=True)
1187
+ tx_result = "ok"
1188
+ except Exception as e:
1189
+ tx_result = f"error: {type(e).__name__}: {e}"
1190
+ raise
1191
+ finally:
1192
+ # Record TX in diagnostic history
1193
+ tx_entry = {
1194
+ "timestamp": time.monotonic(),
1195
+ "header_mode": header_mode,
1196
+ "key": key_name,
1197
+ "signed": signed,
1198
+ "tx_uuid": tx_uuid,
1199
+ "auth_level": auth_level if header_mode == "conformant" else None,
1200
+ "sig_len": sig_len,
1201
+ "seq": seq,
1202
+ "cmd_ordinal": cmd_ordinal,
1203
+ "cmd_target": cmd_target,
1204
+ "cmd_div": cmd_div,
1205
+ "cmd_lifetime": cmd_lifetime,
1206
+ "cmd_payload_len": cmd_payload_len,
1207
+ "total_len": len(pkt),
1208
+ "pre_sign_hex": b2a(command_bytes).decode("ascii"),
1209
+ "post_sign_hex": b2a(bytes(pkt)).decode("ascii"),
1210
+ "result": tx_result,
1211
+ }
1212
+ self._classicTxHistory.append(tx_entry)
1213
+ if len(self._classicTxHistory) > self._classicDiagMaxHistory:
1214
+ self._classicTxHistory = self._classicTxHistory[-self._classicDiagMaxHistory:]
1215
+
1216
+ # Enhanced TX diagnostic log
1217
+ self._logger.warning(
1218
+ "[CLASSIC_DIAG_TX_RESULT] result=%s header=%s seq=%s total_len=%d",
1219
+ tx_result,
1220
+ header_mode,
1221
+ None if seq is None else f"0x{seq:04x}",
1222
+ len(pkt),
1223
+ )
1147
1224
 
1148
1225
  def _establishedNofityCallback(
1149
1226
  self, handle: BleakGATTCharacteristic, data: bytes
@@ -1231,10 +1308,26 @@ class CasambiClient:
1231
1308
  """
1232
1309
  self._inPacketCount += 1
1233
1310
  self._classicRxFrames += 1
1311
+ rx_ts = time.monotonic()
1234
1312
  if self._classicFirstRxTs is None:
1235
- self._classicFirstRxTs = time.monotonic()
1313
+ self._classicFirstRxTs = rx_ts
1236
1314
 
1237
1315
  raw = bytes(data)
1316
+
1317
+ # Enhanced RX diagnostic logging
1318
+ try:
1319
+ handle_uuid = str(getattr(handle, "uuid", "unknown")).lower()
1320
+ except Exception:
1321
+ handle_uuid = "unknown"
1322
+
1323
+ self._logger.warning(
1324
+ "[CLASSIC_DIAG_RX] #%d handle=%s len=%d hex=%s",
1325
+ self._classicRxFrames,
1326
+ handle_uuid,
1327
+ len(raw),
1328
+ b2a(raw[: min(len(raw), 48)]).decode("ascii") + ("..." if len(raw) > 48 else ""),
1329
+ )
1330
+
1238
1331
  if self._logger.isEnabledFor(logging.DEBUG):
1239
1332
  self._logger.debug(
1240
1333
  "[CASAMBI_CLASSIC_RX_RAW] len=%d hex=%s",
@@ -1424,6 +1517,36 @@ class CasambiClient:
1424
1517
  elif verified is None:
1425
1518
  self._classicRxUnverifiable += 1
1426
1519
 
1520
+ # Record RX in diagnostic history
1521
+ rx_entry = {
1522
+ "timestamp": rx_ts,
1523
+ "handle_uuid": handle_uuid,
1524
+ "header_mode": best["mode"],
1525
+ "verified": verified,
1526
+ "auth_level": best["auth_level"],
1527
+ "sig_len": best["sig_len"],
1528
+ "seq": best["seq"],
1529
+ "payload_len": len(payload),
1530
+ "raw_hex": b2a(raw).decode("ascii"),
1531
+ "payload_hex": b2a(payload).decode("ascii"),
1532
+ "score": best["score"],
1533
+ }
1534
+ self._classicRxHistory.append(rx_entry)
1535
+ if len(self._classicRxHistory) > self._classicDiagMaxHistory:
1536
+ self._classicRxHistory = self._classicRxHistory[-self._classicDiagMaxHistory:]
1537
+
1538
+ # Enhanced RX parse result log
1539
+ self._logger.warning(
1540
+ "[CLASSIC_DIAG_RX_PARSE] mode=%s verified=%s auth=%s sig_len=%d seq=%s score=%d payload_len=%d",
1541
+ best["mode"],
1542
+ verified,
1543
+ None if best["auth_level"] is None else f"0x{best['auth_level']:02x}",
1544
+ best["sig_len"],
1545
+ None if best["seq"] is None else f"0x{best['seq']:04x}",
1546
+ best["score"],
1547
+ len(payload),
1548
+ )
1549
+
1427
1550
  # Auto-correct header mode if the other format parses much better.
1428
1551
  if best["mode"] != preferred:
1429
1552
  # Only switch if we got a stronger signal (verified or plausible payload with fewer assumptions).
@@ -1865,3 +1988,39 @@ class CasambiClient:
1865
1988
 
1866
1989
  self._connectionState = ConnectionState.NONE
1867
1990
  self._logger.info("Disconnected.")
1991
+
1992
+ def getClassicDiagnostics(self) -> dict[str, Any]:
1993
+ """Return Classic protocol diagnostic state for external services.
1994
+
1995
+ This method provides a snapshot of Classic protocol state including:
1996
+ - Connection parameters (hash, mode, UUIDs)
1997
+ - RX/TX statistics
1998
+ - Last N TX and RX packets
1999
+ - Any detected errors or anomalies
2000
+
2001
+ Safe to call from HA services for dump_classic_diagnostics.
2002
+ """
2003
+ return {
2004
+ "protocol_mode": self._protocolMode.name if self._protocolMode else None,
2005
+ "classic_header_mode": self._classicHeaderMode,
2006
+ "classic_hash_source": self._classicHashSource,
2007
+ "classic_conn_hash8_hex": b2a(self._classicConnHash8).decode("ascii") if self._classicConnHash8 else None,
2008
+ "classic_tx_uuid": self._classicTxCharUuid,
2009
+ "classic_notify_uuids": sorted(self._classicNotifyCharUuids) if self._classicNotifyCharUuids else [],
2010
+ "classic_first_rx_ts": self._classicFirstRxTs,
2011
+ "classic_rx_stats": {
2012
+ "frames": self._classicRxFrames,
2013
+ "verified": self._classicRxVerified,
2014
+ "unverifiable": self._classicRxUnverifiable,
2015
+ "parse_fail": self._classicRxParseFail,
2016
+ "type6_unitstate": self._classicRxType6,
2017
+ "type7_switch": self._classicRxType7,
2018
+ "type9_netconf": self._classicRxType9,
2019
+ "cmdstream": self._classicRxCmdStream,
2020
+ "unknown": self._classicRxUnknown,
2021
+ },
2022
+ "classic_tx_count": len(self._classicTxHistory),
2023
+ "classic_rx_count": len(self._classicRxHistory),
2024
+ "classic_tx_history": self._classicTxHistory[-20:], # Last 20
2025
+ "classic_rx_history": self._classicRxHistory[-20:], # Last 20
2026
+ }
@@ -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"
@@ -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.dev10
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
@@ -0,0 +1,385 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import unittest
5
+ from pathlib import Path
6
+
7
+ # Allow tests to run without installing the package.
8
+ ROOT = Path(__file__).resolve().parents[1]
9
+ sys.path.insert(0, str(ROOT / "src"))
10
+
11
+ from CasambiBt._classic_crypto import ( # noqa: E402
12
+ classic_cmac,
13
+ classic_cmac_prefix,
14
+ verify_classic_cmac,
15
+ RFC4493_TEST_KEY,
16
+ RFC4493_VECTORS,
17
+ )
18
+ from CasambiBt._client import ( # noqa: E402
19
+ CasambiClient,
20
+ ConnectionState,
21
+ IncommingPacketType,
22
+ ProtocolMode,
23
+ )
24
+
25
+
26
+ class _DummyNetwork:
27
+ protocolVersion = 10
28
+
29
+ def classicVisitorKey(self) -> bytes | None: # noqa: D401
30
+ return None
31
+
32
+ def classicManagerKey(self) -> bytes | None: # noqa: D401
33
+ return None
34
+
35
+ def hasClassicKeys(self) -> bool: # noqa: D401
36
+ return False
37
+
38
+ def isManager(self) -> bool: # noqa: D401
39
+ return False
40
+
41
+
42
+ class _DummyNetworkWithKeys:
43
+ protocolVersion = 5 # Classic protocol version
44
+
45
+ def __init__(self, visitor_key: bytes | None = None, manager_key: bytes | None = None) -> None:
46
+ self._visitor = visitor_key
47
+ self._manager = manager_key
48
+
49
+ def classicVisitorKey(self) -> bytes | None: # noqa: D401
50
+ return self._visitor
51
+
52
+ def classicManagerKey(self) -> bytes | None: # noqa: D401
53
+ return self._manager
54
+
55
+ def hasClassicKeys(self) -> bool: # noqa: D401
56
+ return bool(self._visitor or self._manager)
57
+
58
+ def isManager(self) -> bool: # noqa: D401
59
+ return self._manager is not None
60
+
61
+
62
+ class _StubGattClient:
63
+ def __init__(self) -> None:
64
+ self.writes: list[tuple[str, bytes, bool]] = []
65
+
66
+ async def write_gatt_char(self, uuid: str, data: bytes, response: bool = False) -> None:
67
+ self.writes.append((uuid, bytes(data), bool(response)))
68
+
69
+
70
+ class TestClassicProtocolHelpers(unittest.TestCase):
71
+ def test_classic_cmac_matches_rfc4493_vectors(self) -> None:
72
+ # RFC 4493 test vectors (AES-CMAC).
73
+ key = bytes.fromhex("2b7e151628aed2a6abf7158809cf4f3c")
74
+
75
+ # Example 2 (16 bytes)
76
+ msg16 = bytes.fromhex("6bc1bee22e409f96e93d7e117393172a")
77
+ exp16 = bytes.fromhex("070a16b46b4d4144f79bdd9dd04a287c")
78
+
79
+ # Example 3 (32 bytes)
80
+ msg32 = bytes.fromhex(
81
+ "6bc1bee22e409f96e93d7e117393172a"
82
+ "ae2d8a571e03ac9c9eb76fac45af8e51"
83
+ )
84
+ exp32 = bytes.fromhex("ce0cbf1738f4df6428b1d93bf12081c9")
85
+
86
+ # Example 4 (48 bytes)
87
+ msg48 = bytes.fromhex(
88
+ "6bc1bee22e409f96e93d7e117393172a"
89
+ "ae2d8a571e03ac9c9eb76fac45af8e51"
90
+ "30c81c46a35ce411e5fbc1191a0a52ef"
91
+ )
92
+ exp48 = bytes.fromhex("c47c4d9d64588f67fb9de6fe745d7fbf")
93
+
94
+ for msg, expected in ((msg16, exp16), (msg32, exp32), (msg48, exp48)):
95
+ conn_hash8 = msg[:8]
96
+ payload = msg[8:]
97
+ mac = classic_cmac(key, conn_hash8, payload)
98
+ self.assertEqual(mac, expected)
99
+ self.assertEqual(classic_cmac_prefix(key, conn_hash8, payload, 4), expected[:4])
100
+ self.assertEqual(classic_cmac_prefix(key, conn_hash8, payload, 16), expected)
101
+
102
+ def test_rfc4493_vectors_from_module(self) -> None:
103
+ """Test using the vectors exported from _classic_crypto module."""
104
+ for msg, expected in RFC4493_VECTORS:
105
+ conn_hash8 = msg[:8]
106
+ payload = msg[8:]
107
+ mac = classic_cmac(RFC4493_TEST_KEY, conn_hash8, payload)
108
+ self.assertEqual(mac, expected)
109
+
110
+ def test_verify_classic_cmac(self) -> None:
111
+ """Test the verify_classic_cmac helper."""
112
+ key = bytes.fromhex("2b7e151628aed2a6abf7158809cf4f3c")
113
+ msg = bytes.fromhex("6bc1bee22e409f96e93d7e117393172a")
114
+ expected = bytes.fromhex("070a16b46b4d4144f79bdd9dd04a287c")
115
+
116
+ conn_hash8 = msg[:8]
117
+ payload = msg[8:]
118
+
119
+ # Correct prefix should verify
120
+ self.assertTrue(verify_classic_cmac(key, conn_hash8, payload, expected[:4]))
121
+ self.assertTrue(verify_classic_cmac(key, conn_hash8, payload, expected[:16]))
122
+
123
+ # Wrong prefix should not verify
124
+ wrong_prefix = bytes.fromhex("00000000")
125
+ self.assertFalse(verify_classic_cmac(key, conn_hash8, payload, wrong_prefix))
126
+
127
+ def test_classic_command_encoding_matches_android_layout(self) -> None:
128
+ # Ground truth: casambi-android `u1.C1753e.a(P)`:
129
+ # [len+239][ordinal|flags][div][target?][lifetime=200][payload...]
130
+ parsed: list[dict] = []
131
+
132
+ def cb(_: IncommingPacketType, data: dict) -> None:
133
+ parsed.append(data)
134
+
135
+ c = CasambiClient("00:00:00:00:00:00", cb, lambda: None, _DummyNetwork())
136
+
137
+ # Unit level command: ordinal=7, div present, target present, lifetime=200, payload=0x54
138
+ cmd = c.buildClassicCommand(7, bytes([0x54]), target_id=3, div=0x12, lifetime=200)
139
+ self.assertEqual(cmd.hex(), "f5c71203c854")
140
+
141
+ # All units level: ordinal=4, div present, no target, lifetime=200, payload=0xff
142
+ cmd2 = c.buildClassicCommand(4, bytes([0xFF]), target_id=None, div=0x01, lifetime=200)
143
+ self.assertEqual(cmd2.hex(), "f44401c8ff")
144
+
145
+ # target_id=0 is treated as "no target" (Android only writes target when > 0).
146
+ cmd3 = c.buildClassicCommand(4, bytes([0xFF]), target_id=0, div=0x01, lifetime=200)
147
+ self.assertEqual(cmd3.hex(), "f44401c8ff")
148
+
149
+
150
+ class TestClassicSimpleCommandFormat(unittest.TestCase):
151
+ """Test the alternative simple command format from BLE captures."""
152
+
153
+ def test_simple_command_format_all_units(self) -> None:
154
+ """Test simple command format for all units."""
155
+ def cb(_: IncommingPacketType, data: dict) -> None:
156
+ pass
157
+
158
+ c = CasambiClient("00:00:00:00:00:00", cb, lambda: None, _DummyNetwork())
159
+ c._classicCmdDiv = 0x03 # Set known counter
160
+
161
+ # All units (0xFF), level 128
162
+ cmd = c.buildClassicCommandSimple(0xFF, 128)
163
+ # Expected: [counter=4][unit_id=0xFF][param_len=1][dimmer=128]
164
+ self.assertEqual(cmd.hex(), "04ff0180")
165
+
166
+ def test_simple_command_format_single_unit(self) -> None:
167
+ """Test simple command format for single unit."""
168
+ def cb(_: IncommingPacketType, data: dict) -> None:
169
+ pass
170
+
171
+ c = CasambiClient("00:00:00:00:00:00", cb, lambda: None, _DummyNetwork())
172
+ c._classicCmdDiv = 0x03
173
+
174
+ # Unit 4, level 0
175
+ cmd = c.buildClassicCommandSimple(4, 0)
176
+ # Expected: [counter=4][unit_id=4][param_len=1][dimmer=0]
177
+ self.assertEqual(cmd.hex(), "04040100")
178
+
179
+ def test_simple_command_format_with_extra(self) -> None:
180
+ """Test simple command format with extra parameter (temperature/vertical)."""
181
+ def cb(_: IncommingPacketType, data: dict) -> None:
182
+ pass
183
+
184
+ c = CasambiClient("00:00:00:00:00:00", cb, lambda: None, _DummyNetwork())
185
+ c._classicCmdDiv = 0x03
186
+
187
+ # Unit 4, level 0, extra 0xB3 (temperature)
188
+ cmd = c.buildClassicCommandSimple(4, 0, extra=0xB3)
189
+ # Expected: [counter=4][unit_id=4][param_len=2][dimmer=0][extra=0xB3]
190
+ self.assertEqual(cmd.hex(), "040402" "00b3")
191
+
192
+
193
+ class TestClassicSendWithoutKeys(unittest.IsolatedAsyncioTestCase):
194
+ async def test_classic_send_conformant_without_keys_has_zero_sig_and_seq(self) -> None:
195
+ sent: list[tuple[str, bytes, bool]] = []
196
+
197
+ def cb(_: IncommingPacketType, __: dict) -> None:
198
+ return
199
+
200
+ c = CasambiClient("00:00:00:00:00:00", cb, lambda: None, _DummyNetwork())
201
+ c._gattClient = _StubGattClient()
202
+ c._connectionState = ConnectionState.AUTHENTICATED
203
+ c._protocolMode = ProtocolMode.CLASSIC
204
+ c._dataCharUuid = "dummy"
205
+ c._classicConnHash8 = b"\x11" * 8
206
+ c._classicHeaderMode = "conformant"
207
+ c._classicTxSeq = 0
208
+
209
+ cmd = c.buildClassicCommand(4, bytes([0xFF]), div=0x01, lifetime=200)
210
+ await c.send(cmd)
211
+
212
+ stub = c._gattClient
213
+ assert isinstance(stub, _StubGattClient)
214
+ self.assertEqual(len(stub.writes), 1)
215
+ _uuid, pkt, response = stub.writes[0]
216
+ self.assertTrue(response)
217
+
218
+ # [auth=0x02][sig(4x00)][seq=0x0001][cmd...]
219
+ self.assertEqual(pkt[0], 0x02)
220
+ self.assertEqual(pkt[1:5], b"\x00" * 4)
221
+ self.assertEqual(pkt[5:7], b"\x00\x01")
222
+ self.assertEqual(pkt[7:], cmd)
223
+
224
+ async def test_classic_send_legacy_without_keys_has_zero_sig(self) -> None:
225
+ def cb(_: IncommingPacketType, __: dict) -> None:
226
+ return
227
+
228
+ c = CasambiClient("00:00:00:00:00:00", cb, lambda: None, _DummyNetwork())
229
+ c._gattClient = _StubGattClient()
230
+ c._connectionState = ConnectionState.AUTHENTICATED
231
+ c._protocolMode = ProtocolMode.CLASSIC
232
+ c._dataCharUuid = "dummy"
233
+ c._classicConnHash8 = b"\x11" * 8
234
+ c._classicHeaderMode = "legacy"
235
+
236
+ cmd = c.buildClassicCommand(4, bytes([0xFF]), div=0x01, lifetime=200)
237
+ await c.send(cmd)
238
+
239
+ stub = c._gattClient
240
+ assert isinstance(stub, _StubGattClient)
241
+ self.assertEqual(len(stub.writes), 1)
242
+ _uuid, pkt, response = stub.writes[0]
243
+ self.assertTrue(response)
244
+
245
+ # [sig(4x00)][cmd...]
246
+ self.assertEqual(pkt[:4], b"\x00" * 4)
247
+ self.assertEqual(pkt[4:], cmd)
248
+
249
+
250
+ class TestClassicSendWithKeys(unittest.IsolatedAsyncioTestCase):
251
+ """Test Classic send with actual keys for CMAC signing."""
252
+
253
+ async def test_classic_send_conformant_with_visitor_key(self) -> None:
254
+ """Test conformant mode with visitor key produces signed packet."""
255
+ visitor_key = bytes.fromhex("2b7e151628aed2a6abf7158809cf4f3c")
256
+
257
+ def cb(_: IncommingPacketType, __: dict) -> None:
258
+ return
259
+
260
+ network = _DummyNetworkWithKeys(visitor_key=visitor_key)
261
+ c = CasambiClient("00:00:00:00:00:00", cb, lambda: None, network)
262
+ c._gattClient = _StubGattClient()
263
+ c._connectionState = ConnectionState.AUTHENTICATED
264
+ c._protocolMode = ProtocolMode.CLASSIC
265
+ c._dataCharUuid = "dummy"
266
+ c._classicConnHash8 = bytes.fromhex("1122334455667788")
267
+ c._classicHeaderMode = "conformant"
268
+ c._classicTxSeq = 0
269
+
270
+ cmd = c.buildClassicCommand(4, bytes([0xFF]), div=0x01, lifetime=200)
271
+ await c.send(cmd)
272
+
273
+ stub = c._gattClient
274
+ assert isinstance(stub, _StubGattClient)
275
+ self.assertEqual(len(stub.writes), 1)
276
+ _uuid, pkt, response = stub.writes[0]
277
+ self.assertTrue(response)
278
+
279
+ # [auth=0x02][sig(4 bytes, NOT zero)][seq=0x0001][cmd...]
280
+ self.assertEqual(pkt[0], 0x02)
281
+ # Signature should NOT be all zeros (it's computed with the key)
282
+ self.assertNotEqual(pkt[1:5], b"\x00" * 4)
283
+ self.assertEqual(pkt[5:7], b"\x00\x01") # seq = 1
284
+ self.assertEqual(pkt[7:], cmd)
285
+
286
+ # Verify the CMAC is correct
287
+ cmac_input = pkt[5:] # seq + cmd
288
+ expected_sig = classic_cmac_prefix(visitor_key, c._classicConnHash8, cmac_input, 4)
289
+ self.assertEqual(pkt[1:5], expected_sig)
290
+
291
+ async def test_classic_send_legacy_with_visitor_key(self) -> None:
292
+ """Test legacy mode with visitor key produces signed packet."""
293
+ visitor_key = bytes.fromhex("2b7e151628aed2a6abf7158809cf4f3c")
294
+
295
+ def cb(_: IncommingPacketType, __: dict) -> None:
296
+ return
297
+
298
+ network = _DummyNetworkWithKeys(visitor_key=visitor_key)
299
+ c = CasambiClient("00:00:00:00:00:00", cb, lambda: None, network)
300
+ c._gattClient = _StubGattClient()
301
+ c._connectionState = ConnectionState.AUTHENTICATED
302
+ c._protocolMode = ProtocolMode.CLASSIC
303
+ c._dataCharUuid = "dummy"
304
+ c._classicConnHash8 = bytes.fromhex("1122334455667788")
305
+ c._classicHeaderMode = "legacy"
306
+
307
+ cmd = c.buildClassicCommand(4, bytes([0xFF]), div=0x01, lifetime=200)
308
+ await c.send(cmd)
309
+
310
+ stub = c._gattClient
311
+ assert isinstance(stub, _StubGattClient)
312
+ self.assertEqual(len(stub.writes), 1)
313
+ _uuid, pkt, response = stub.writes[0]
314
+ self.assertTrue(response)
315
+
316
+ # [sig(4 bytes, NOT zero)][cmd...]
317
+ self.assertNotEqual(pkt[:4], b"\x00" * 4)
318
+ self.assertEqual(pkt[4:], cmd)
319
+
320
+ # Verify the CMAC is correct
321
+ expected_sig = classic_cmac_prefix(visitor_key, c._classicConnHash8, cmd, 4)
322
+ self.assertEqual(pkt[:4], expected_sig)
323
+
324
+
325
+ class TestClassicDiagnosticHistory(unittest.IsolatedAsyncioTestCase):
326
+ """Test that TX/RX packets are recorded in diagnostic history."""
327
+
328
+ async def test_tx_history_recorded(self) -> None:
329
+ """Test that TX packets are recorded in history."""
330
+ def cb(_: IncommingPacketType, __: dict) -> None:
331
+ return
332
+
333
+ c = CasambiClient("00:00:00:00:00:00", cb, lambda: None, _DummyNetwork())
334
+ c._gattClient = _StubGattClient()
335
+ c._connectionState = ConnectionState.AUTHENTICATED
336
+ c._protocolMode = ProtocolMode.CLASSIC
337
+ c._dataCharUuid = "dummy"
338
+ c._classicConnHash8 = b"\x11" * 8
339
+ c._classicHeaderMode = "conformant"
340
+ c._classicTxSeq = 0
341
+
342
+ # Initially empty
343
+ self.assertEqual(len(c._classicTxHistory), 0)
344
+
345
+ # Send a command
346
+ cmd = c.buildClassicCommand(4, bytes([0xFF]), div=0x01, lifetime=200)
347
+ await c.send(cmd)
348
+
349
+ # Should have one entry in history
350
+ self.assertEqual(len(c._classicTxHistory), 1)
351
+ entry = c._classicTxHistory[0]
352
+ self.assertIn("timestamp", entry)
353
+ self.assertIn("header_mode", entry)
354
+ self.assertIn("post_sign_hex", entry)
355
+ self.assertEqual(entry["result"], "ok")
356
+
357
+ async def test_getClassicDiagnostics_returns_data(self) -> None:
358
+ """Test that getClassicDiagnostics returns structured data."""
359
+ def cb(_: IncommingPacketType, __: dict) -> None:
360
+ return
361
+
362
+ c = CasambiClient("00:00:00:00:00:00", cb, lambda: None, _DummyNetwork())
363
+ c._gattClient = _StubGattClient()
364
+ c._connectionState = ConnectionState.AUTHENTICATED
365
+ c._protocolMode = ProtocolMode.CLASSIC
366
+ c._dataCharUuid = "dummy"
367
+ c._classicConnHash8 = b"\x11" * 8
368
+ c._classicHeaderMode = "conformant"
369
+ c._classicTxSeq = 0
370
+
371
+ # Send a command
372
+ cmd = c.buildClassicCommand(4, bytes([0xFF]), div=0x01, lifetime=200)
373
+ await c.send(cmd)
374
+
375
+ # Get diagnostics
376
+ diag = c.getClassicDiagnostics()
377
+ self.assertEqual(diag["protocol_mode"], "CLASSIC")
378
+ self.assertEqual(diag["classic_header_mode"], "conformant")
379
+ self.assertEqual(diag["classic_tx_count"], 1)
380
+ self.assertIn("classic_tx_history", diag)
381
+ self.assertIn("classic_rx_stats", diag)
382
+
383
+
384
+ if __name__ == "__main__":
385
+ unittest.main()
@@ -1,31 +0,0 @@
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
-
9
- from __future__ import annotations
10
-
11
- from cryptography.hazmat.primitives.cmac import CMAC
12
- from cryptography.hazmat.primitives.ciphers.algorithms import AES
13
-
14
-
15
- def classic_cmac(key: bytes, conn_hash8: bytes, payload: bytes) -> bytes:
16
- """Compute the Classic CMAC (16 bytes) over connection hash + payload."""
17
- if len(conn_hash8) != 8:
18
- raise ValueError("conn_hash8 must be 8 bytes")
19
- cmac = CMAC(AES(key))
20
- cmac.update(conn_hash8)
21
- cmac.update(payload)
22
- return cmac.finalize()
23
-
24
-
25
- def classic_cmac_prefix(
26
- key: bytes, conn_hash8: bytes, payload: bytes, prefix_len: int
27
- ) -> bytes:
28
- """Return the prefix bytes that are embedded into the Classic packet header."""
29
- mac = classic_cmac(key, conn_hash8, payload)
30
- return mac[:prefix_len]
31
-
@@ -1,157 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import sys
4
- import unittest
5
- from pathlib import Path
6
-
7
- # Allow tests to run without installing the package.
8
- ROOT = Path(__file__).resolve().parents[1]
9
- sys.path.insert(0, str(ROOT / "src"))
10
-
11
- from CasambiBt._classic_crypto import classic_cmac, classic_cmac_prefix # noqa: E402
12
- from CasambiBt._client import ( # noqa: E402
13
- CasambiClient,
14
- ConnectionState,
15
- IncommingPacketType,
16
- ProtocolMode,
17
- )
18
-
19
-
20
- class _DummyNetwork:
21
- protocolVersion = 10
22
-
23
- def classicVisitorKey(self) -> bytes | None: # noqa: D401
24
- return None
25
-
26
- def classicManagerKey(self) -> bytes | None: # noqa: D401
27
- return None
28
-
29
- def hasClassicKeys(self) -> bool: # noqa: D401
30
- return False
31
-
32
- def isManager(self) -> bool: # noqa: D401
33
- return False
34
-
35
-
36
- class _StubGattClient:
37
- def __init__(self) -> None:
38
- self.writes: list[tuple[str, bytes, bool]] = []
39
-
40
- async def write_gatt_char(self, uuid: str, data: bytes, response: bool = False) -> None:
41
- self.writes.append((uuid, bytes(data), bool(response)))
42
-
43
-
44
- class TestClassicProtocolHelpers(unittest.TestCase):
45
- def test_classic_cmac_matches_rfc4493_vectors(self) -> None:
46
- # RFC 4493 test vectors (AES-CMAC).
47
- key = bytes.fromhex("2b7e151628aed2a6abf7158809cf4f3c")
48
-
49
- # Example 2 (16 bytes)
50
- msg16 = bytes.fromhex("6bc1bee22e409f96e93d7e117393172a")
51
- exp16 = bytes.fromhex("070a16b46b4d4144f79bdd9dd04a287c")
52
-
53
- # Example 3 (32 bytes)
54
- msg32 = bytes.fromhex(
55
- "6bc1bee22e409f96e93d7e117393172a"
56
- "ae2d8a571e03ac9c9eb76fac45af8e51"
57
- )
58
- exp32 = bytes.fromhex("ce0cbf1738f4df6428b1d93bf12081c9")
59
-
60
- # Example 4 (48 bytes)
61
- msg48 = bytes.fromhex(
62
- "6bc1bee22e409f96e93d7e117393172a"
63
- "ae2d8a571e03ac9c9eb76fac45af8e51"
64
- "30c81c46a35ce411e5fbc1191a0a52ef"
65
- )
66
- exp48 = bytes.fromhex("c47c4d9d64588f67fb9de6fe745d7fbf")
67
-
68
- for msg, expected in ((msg16, exp16), (msg32, exp32), (msg48, exp48)):
69
- conn_hash8 = msg[:8]
70
- payload = msg[8:]
71
- mac = classic_cmac(key, conn_hash8, payload)
72
- self.assertEqual(mac, expected)
73
- self.assertEqual(classic_cmac_prefix(key, conn_hash8, payload, 4), expected[:4])
74
- self.assertEqual(classic_cmac_prefix(key, conn_hash8, payload, 16), expected)
75
-
76
- def test_classic_command_encoding_matches_android_layout(self) -> None:
77
- # Ground truth: casambi-android `u1.C1753e.a(P)`:
78
- # [len+239][ordinal|flags][div][target?][lifetime=200][payload...]
79
- parsed: list[dict] = []
80
-
81
- def cb(_: IncommingPacketType, data: dict) -> None:
82
- parsed.append(data)
83
-
84
- c = CasambiClient("00:00:00:00:00:00", cb, lambda: None, _DummyNetwork())
85
-
86
- # Unit level command: ordinal=7, div present, target present, lifetime=200, payload=0x54
87
- cmd = c.buildClassicCommand(7, bytes([0x54]), target_id=3, div=0x12, lifetime=200)
88
- self.assertEqual(cmd.hex(), "f5c71203c854")
89
-
90
- # All units level: ordinal=4, div present, no target, lifetime=200, payload=0xff
91
- cmd2 = c.buildClassicCommand(4, bytes([0xFF]), target_id=None, div=0x01, lifetime=200)
92
- self.assertEqual(cmd2.hex(), "f44401c8ff")
93
-
94
- # target_id=0 is treated as "no target" (Android only writes target when > 0).
95
- cmd3 = c.buildClassicCommand(4, bytes([0xFF]), target_id=0, div=0x01, lifetime=200)
96
- self.assertEqual(cmd3.hex(), "f44401c8ff")
97
-
98
-
99
- class TestClassicSendWithoutKeys(unittest.IsolatedAsyncioTestCase):
100
- async def test_classic_send_conformant_without_keys_has_zero_sig_and_seq(self) -> None:
101
- sent: list[tuple[str, bytes, bool]] = []
102
-
103
- def cb(_: IncommingPacketType, __: dict) -> None:
104
- return
105
-
106
- c = CasambiClient("00:00:00:00:00:00", cb, lambda: None, _DummyNetwork())
107
- c._gattClient = _StubGattClient()
108
- c._connectionState = ConnectionState.AUTHENTICATED
109
- c._protocolMode = ProtocolMode.CLASSIC
110
- c._dataCharUuid = "dummy"
111
- c._classicConnHash8 = b"\x11" * 8
112
- c._classicHeaderMode = "conformant"
113
- c._classicTxSeq = 0
114
-
115
- cmd = c.buildClassicCommand(4, bytes([0xFF]), div=0x01, lifetime=200)
116
- await c.send(cmd)
117
-
118
- stub = c._gattClient
119
- assert isinstance(stub, _StubGattClient)
120
- self.assertEqual(len(stub.writes), 1)
121
- _uuid, pkt, response = stub.writes[0]
122
- self.assertTrue(response)
123
-
124
- # [auth=0x02][sig(4x00)][seq=0x0001][cmd...]
125
- self.assertEqual(pkt[0], 0x02)
126
- self.assertEqual(pkt[1:5], b"\x00" * 4)
127
- self.assertEqual(pkt[5:7], b"\x00\x01")
128
- self.assertEqual(pkt[7:], cmd)
129
-
130
- async def test_classic_send_legacy_without_keys_has_zero_sig(self) -> None:
131
- def cb(_: IncommingPacketType, __: dict) -> None:
132
- return
133
-
134
- c = CasambiClient("00:00:00:00:00:00", cb, lambda: None, _DummyNetwork())
135
- c._gattClient = _StubGattClient()
136
- c._connectionState = ConnectionState.AUTHENTICATED
137
- c._protocolMode = ProtocolMode.CLASSIC
138
- c._dataCharUuid = "dummy"
139
- c._classicConnHash8 = b"\x11" * 8
140
- c._classicHeaderMode = "legacy"
141
-
142
- cmd = c.buildClassicCommand(4, bytes([0xFF]), div=0x01, lifetime=200)
143
- await c.send(cmd)
144
-
145
- stub = c._gattClient
146
- assert isinstance(stub, _StubGattClient)
147
- self.assertEqual(len(stub.writes), 1)
148
- _uuid, pkt, response = stub.writes[0]
149
- self.assertTrue(response)
150
-
151
- # [sig(4x00)][cmd...]
152
- self.assertEqual(pkt[:4], b"\x00" * 4)
153
- self.assertEqual(pkt[4:], cmd)
154
-
155
-
156
- if __name__ == "__main__":
157
- unittest.main()