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.
- {casambi_bt_revamped-0.3.12.dev9/src/casambi_bt_revamped.egg-info → casambi_bt_revamped-0.3.12.dev10}/PKG-INFO +1 -1
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/setup.cfg +1 -1
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_casambi.py +26 -9
- casambi_bt_revamped-0.3.12.dev10/src/CasambiBt/_classic_crypto.py +146 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_client.py +161 -2
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_version.py +1 -1
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10/src/casambi_bt_revamped.egg-info}/PKG-INFO +1 -1
- casambi_bt_revamped-0.3.12.dev10/tests/test_classic_protocol.py +385 -0
- casambi_bt_revamped-0.3.12.dev9/src/CasambiBt/_classic_crypto.py +0 -31
- casambi_bt_revamped-0.3.12.dev9/tests/test_classic_protocol.py +0 -157
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/LICENSE +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/README.md +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/pyproject.toml +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/__init__.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_cache.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_constants.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_discover.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_encryption.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_invocation.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_keystore.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_network.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_operation.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_switch_events.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_unit.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/errors.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/py.typed +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/casambi_bt_revamped.egg-info/SOURCES.txt +0 -0
- {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
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/casambi_bt_revamped.egg-info/requires.txt +0 -0
- {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
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/tests/test_legacy_protocol_handling.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/tests/test_switch_event_logs.py +0 -0
- {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.
|
|
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.
|
|
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
|
{casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_casambi.py
RENAMED
|
@@ -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
|
-
#
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
+
|
{casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_client.py
RENAMED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: casambi-bt-revamped
|
|
3
|
-
Version: 0.3.12.
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/__init__.py
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_cache.py
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_constants.py
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_discover.py
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_encryption.py
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_invocation.py
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_keystore.py
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_network.py
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_operation.py
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/_switch_events.py
RENAMED
|
File without changes
|
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/src/CasambiBt/errors.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/tests/test_switch_event_logs.py
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev10}/tests/test_unit_state_logs.py
RENAMED
|
File without changes
|