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.
- {casambi_bt_revamped-0.3.12.dev9/src/casambi_bt_revamped.egg-info → casambi_bt_revamped-0.3.12.dev11}/PKG-INFO +1 -1
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/setup.cfg +1 -1
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_casambi.py +32 -10
- casambi_bt_revamped-0.3.12.dev11/src/CasambiBt/_classic_crypto.py +146 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_client.py +381 -3
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_version.py +1 -1
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11/src/casambi_bt_revamped.egg-info}/PKG-INFO +1 -1
- casambi_bt_revamped-0.3.12.dev11/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.dev11}/LICENSE +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/README.md +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/pyproject.toml +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/__init__.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_cache.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_constants.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_discover.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_encryption.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_invocation.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_keystore.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_network.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_operation.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_switch_events.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_unit.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/errors.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/py.typed +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/casambi_bt_revamped.egg-info/SOURCES.txt +0 -0
- {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
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/casambi_bt_revamped.egg-info/requires.txt +0 -0
- {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
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/tests/test_legacy_protocol_handling.py +0 -0
- {casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/tests/test_switch_event_logs.py +0 -0
- {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.
|
|
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.
|
|
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
|
{casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_casambi.py
RENAMED
|
@@ -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
|
-
#
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
+
|
{casambi_bt_revamped-0.3.12.dev9 → casambi_bt_revamped-0.3.12.dev11}/src/CasambiBt/_client.py
RENAMED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
+
}
|