cync-lan 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cync_lan/__init__.py +1 -0
- cync_lan/ble_provision.py +462 -0
- cync_lan/cloud_api.py +773 -0
- cync_lan/const.py +231 -0
- cync_lan/devices.py +4910 -0
- cync_lan/metadata/__init__.py +0 -0
- cync_lan/metadata/model_info.py +1334 -0
- cync_lan/packet/__init__.py +2 -0
- cync_lan/packet/builder.py +268 -0
- cync_lan/packet/xlink_legacy.py +128 -0
- cync_lan/protocols.py +87 -0
- cync_lan/py.typed +0 -0
- cync_lan/server.py +431 -0
- cync_lan/structs.py +362 -0
- cync_lan/utils.py +471 -0
- cync_lan-0.1.0.dist-info/METADATA +72 -0
- cync_lan-0.1.0.dist-info/RECORD +22 -0
- cync_lan-0.1.0.dist-info/WHEEL +5 -0
- cync_lan-0.1.0.dist-info/entry_points.txt +2 -0
- cync_lan-0.1.0.dist-info/licenses/LICENSE +21 -0
- cync_lan-0.1.0.dist-info/licenses/LICENSE-3RD-PARTY +381 -0
- cync_lan-0.1.0.dist-info/top_level.txt +1 -0
cync_lan/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__: str = "0.1.0"
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""EXPERIMENTAL: BLE GATT provisioning for brand-new (factory-default,
|
|
2
|
+
never-provisioned) Telink-mesh Cync/C-by-GE devices - discovery, the
|
|
3
|
+
pairing/session-key handshake, and the mesh-credential handoff that gives
|
|
4
|
+
a device its permanent mesh name/password/LTK. See
|
|
5
|
+
docs/ble_provisioning_protocol.md for the full protocol research this
|
|
6
|
+
implements (service/characteristic UUIDs, the encryption algorithm, the
|
|
7
|
+
WiFi-credential handoff format for WiFi-capable devices).
|
|
8
|
+
|
|
9
|
+
UNTESTED AGAINST REAL HARDWARE as of this writing. Every byte here is
|
|
10
|
+
confirmed from decompiled Cync Android app source, and the pairing/
|
|
11
|
+
session-key/command-encryption primitives are independently
|
|
12
|
+
cross-validated against `python-dimond` (a real, working, unrelated
|
|
13
|
+
Telink-mesh BLE client) - but no live pairing attempt against real
|
|
14
|
+
hardware has been made from this module yet. Run this against a spare,
|
|
15
|
+
factory-reset device and report back what happens (success, or the exact
|
|
16
|
+
error/traceback) - see this module's own CLI --help.
|
|
17
|
+
|
|
18
|
+
This is a completely separate transport (BLE GATT, via `bleak`) from the
|
|
19
|
+
rest of cync-lan, which only ever intercepts the TCP relay of already-
|
|
20
|
+
provisioned devices - nothing here is used by, or shared with, the normal
|
|
21
|
+
TCP server. `bleak` is an optional dependency (`pip install cync_lan[ble]`)
|
|
22
|
+
so it isn't required just to run the everyday TCP relay server.
|
|
23
|
+
|
|
24
|
+
Only the CLI/BLE-I/O layer needs `bleak` - the crypto/framing functions
|
|
25
|
+
below are pure and import-safe without it, so they can be unit tested
|
|
26
|
+
without any BLE hardware or the `bleak` package installed.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import asyncio
|
|
33
|
+
import logging
|
|
34
|
+
import sys
|
|
35
|
+
|
|
36
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"PAIRING_SERVICE_UUID",
|
|
40
|
+
"PAIRING_CHAR_UUID",
|
|
41
|
+
"STATUS_CHAR_UUID",
|
|
42
|
+
"COMMAND_CHAR_UUID",
|
|
43
|
+
"FACTORY_MESH_NAME",
|
|
44
|
+
"FACTORY_MESH_PASSWORD",
|
|
45
|
+
"FACTORY_ADVERTISED_NAME",
|
|
46
|
+
"R_APP",
|
|
47
|
+
"FACTORY_DEFAULT_PAIRING_WRITE",
|
|
48
|
+
"DEFAULT_LTK",
|
|
49
|
+
"PAIR_CONFIRM_BYTE",
|
|
50
|
+
"TELINK_COMPANY_ID",
|
|
51
|
+
"key_encrypt",
|
|
52
|
+
"generate_sk",
|
|
53
|
+
"build_pairing_write",
|
|
54
|
+
"derive_session_key",
|
|
55
|
+
"verify_pairing_response",
|
|
56
|
+
"build_mesh_credential_write",
|
|
57
|
+
"PairingError",
|
|
58
|
+
"scan_for_unprovisioned_devices",
|
|
59
|
+
"provision_device",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
logger = logging.getLogger("cync_lan.ble_provision")
|
|
63
|
+
|
|
64
|
+
# Confirmed via com/gelighting/cbygekit/foundation/commands/Telink.java
|
|
65
|
+
# (Telink.f28872f/f28873g/f28874h/f28876j) - byte-for-byte identical to
|
|
66
|
+
# python-dimond's hardcoded UUIDs (dimond/__init__.py:114-116), independent
|
|
67
|
+
# confirmation this is standard Telink Mesh SDK behavior, not Cync-specific.
|
|
68
|
+
PAIRING_SERVICE_UUID = "00010203-0405-0607-0809-0a0b0c0d1910"
|
|
69
|
+
PAIRING_CHAR_UUID = "00010203-0405-0607-0809-0a0b0c0d1914"
|
|
70
|
+
STATUS_CHAR_UUID = "00010203-0405-0607-0809-0a0b0c0d1911"
|
|
71
|
+
COMMAND_CHAR_UUID = "00010203-0405-0607-0809-0a0b0c0d1912"
|
|
72
|
+
|
|
73
|
+
# Telink Semiconductor's registered Bluetooth SIG company ID - confirmed as
|
|
74
|
+
# both the BLE-advertisement manufacturer-data key used for new-device
|
|
75
|
+
# discovery (BleDeviceScanner.java) AND the vendor-ID bytes (0x11,0x02
|
|
76
|
+
# little-endian) already present in every cync-lan mesh command payload.
|
|
77
|
+
TELINK_COMPANY_ID = 0x0211
|
|
78
|
+
|
|
79
|
+
# Factory-default mesh identity + BLE advertised name for a brand-new,
|
|
80
|
+
# never-provisioned Telink device - confirmed via
|
|
81
|
+
# TelinkDeviceBleManager.m14334v ("authenticate")'s special-cased branch
|
|
82
|
+
# and BleDeviceScanner.java's "telink_mesh1" advertised-name check.
|
|
83
|
+
FACTORY_MESH_NAME = "telink_mesh1"
|
|
84
|
+
FACTORY_MESH_PASSWORD = "123"
|
|
85
|
+
FACTORY_ADVERTISED_NAME = "telink_mesh1"
|
|
86
|
+
|
|
87
|
+
# Confirmed via Telink.java's static initializer: f28877k = {0xA0..0xA7,
|
|
88
|
+
# zero-padded to 16}. NOT SecureRandom output despite superficially
|
|
89
|
+
# resembling one - it's a `final` field written once, used AS-IS at two
|
|
90
|
+
# independent real call sites (the initial pairing write, and the
|
|
91
|
+
# read-response callback that reconstructs the same value to derive the
|
|
92
|
+
# session key). The real Cync/GE app hardcodes this "R_app" contribution
|
|
93
|
+
# to a fixed constant on every single pairing attempt, unlike
|
|
94
|
+
# python-dimond (which generates 8 fresh random bytes per session, still
|
|
95
|
+
# protocol-compatible since the algorithm doesn't require freshness).
|
|
96
|
+
R_APP = bytes([0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7])
|
|
97
|
+
|
|
98
|
+
# Confirmed via Telink.java's static initializer: f28878l, a fully
|
|
99
|
+
# pre-baked 17-byte pairing-characteristic write used verbatim by
|
|
100
|
+
# TelinkDeviceBleManager.m14334v whenever the target mesh name/password
|
|
101
|
+
# are exactly the factory defaults above - i.e. this exact byte sequence
|
|
102
|
+
# is what the real app writes to bootstrap a brand-new device.
|
|
103
|
+
# build_pairing_write(FACTORY_MESH_NAME, FACTORY_MESH_PASSWORD) reproduces
|
|
104
|
+
# this exact value from the general formula (see its own docstring) - a
|
|
105
|
+
# genuine internal cross-check, not just an assumption the formula
|
|
106
|
+
# generalizes.
|
|
107
|
+
FACTORY_DEFAULT_PAIRING_WRITE = bytes(
|
|
108
|
+
[
|
|
109
|
+
0x0C,
|
|
110
|
+
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7,
|
|
111
|
+
0x8D, 0xB6, 0x74, 0x71, 0x1B, 0x85, 0x5A, 0x79,
|
|
112
|
+
]
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Confirmed via Telink.java's static initializer: f28879m, the default LTK
|
|
116
|
+
# (long-term key) TelinkDeviceBleManager$pairMesh$2.java hands a device
|
|
117
|
+
# when no custom LTK is supplied.
|
|
118
|
+
DEFAULT_LTK = bytes(
|
|
119
|
+
[
|
|
120
|
+
0xC0, 0xC1, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7,
|
|
121
|
+
0xD8, 0xD9, 0xDA, 0xDB, 0xDC, 0xDD, 0xDE, 0xDF,
|
|
122
|
+
]
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Confirmed via TelinkDeviceBleManager$pairMesh$2.java's literal opcode
|
|
126
|
+
# bytes (4/5/6) for the encrypted NAME/PASSWORD/LTK mesh-credential-handoff
|
|
127
|
+
# writes - see build_mesh_credential_write().
|
|
128
|
+
_PAIR_CREDENTIAL_OPCODES = {"name": 4, "password": 5, "ltk": 6}
|
|
129
|
+
|
|
130
|
+
# Confirmed via C2185e.java (the DataReceivedCallback registered on
|
|
131
|
+
# pairMesh$2's confirmation ReadRequest): the callback only sets its
|
|
132
|
+
# success flag when the response's first byte is LITERALLY 7 - any other
|
|
133
|
+
# value (including 0) leaves it false, i.e. "not confirmed". Not merely
|
|
134
|
+
# "nonzero means success" as a naive read might assume. This lines up
|
|
135
|
+
# with the same "literal = ordinal+1" pattern already confirmed for the
|
|
136
|
+
# NAME/PASSWORD/LTK opcodes above (4/5/6 = enum ordinals 3/4/5) -
|
|
137
|
+
# ordinal 6 is PAIR_CONFIRM, and 6+1=7, a semantically sensible match
|
|
138
|
+
# for "pairing confirmed" under that same hypothesis.
|
|
139
|
+
PAIR_CONFIRM_BYTE = 7
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _pad16(data: bytes) -> bytes:
|
|
143
|
+
"""Truncates/zero-pads to exactly 16 bytes - Telink's own UTF-8-encode-
|
|
144
|
+
then-pad-to-16 convention (Telink.m13406d) for both mesh name/password
|
|
145
|
+
strings and raw key/nonce material."""
|
|
146
|
+
return data[:16].ljust(16, b"\x00")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _aes_ecb_encrypt(key: bytes, data: bytes) -> bytes:
|
|
150
|
+
"""One AES-ECB block operation, WITH Telink's confirmed byte-reversal
|
|
151
|
+
quirk applied to both the key and the data before encrypting, and to
|
|
152
|
+
the result afterward: `AES(reversed(key)).encrypt(reversed(data))`,
|
|
153
|
+
then reversed back. Confirmed exactly via python-dimond's real,
|
|
154
|
+
working `encrypt()` (dimond/__init__.py:29-35) - this is real,
|
|
155
|
+
load-bearing Telink SDK behavior, not a decompiler artifact.
|
|
156
|
+
|
|
157
|
+
`key`/`data` must each be exactly 16 bytes already (every call site
|
|
158
|
+
pads via _pad16() first) - not re-validated here."""
|
|
159
|
+
cipher = Cipher(algorithms.AES(key[::-1]), modes.ECB())
|
|
160
|
+
encryptor = cipher.encryptor()
|
|
161
|
+
return encryptor.update(data[::-1])[::-1]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def key_encrypt(name: bytes, password: bytes, key: bytes) -> bytes:
|
|
165
|
+
"""`data = XOR(pad16(name), pad16(password))`; `return AES_ECB(key,
|
|
166
|
+
data)` - confirmed via python-dimond's real `key_encrypt()`
|
|
167
|
+
(dimond/__init__.py:45-49). Used both for the initial pairing write
|
|
168
|
+
(key=R_APP padded to 16) and, via generate_sk(), for session-key
|
|
169
|
+
derivation (key=XOR(meshName,meshPass) there instead - a different
|
|
170
|
+
role for the same two building blocks, not the same call)."""
|
|
171
|
+
xored = bytes(a ^ b for a, b in zip(_pad16(name), _pad16(password)))
|
|
172
|
+
return _aes_ecb_encrypt(key, xored)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def generate_sk(name: bytes, password: bytes, data1: bytes, data2: bytes) -> bytes:
|
|
176
|
+
"""`key = XOR(pad16(name), pad16(password))`; `data = data1[0:8] +
|
|
177
|
+
data2[0:8]`; `return AES_ECB(key, data)` - confirmed via python-dimond's
|
|
178
|
+
real `generate_sk()` (dimond/__init__.py:37-43). `data1`/`data2` are
|
|
179
|
+
R_app/R_dev (in that order) when deriving the session key."""
|
|
180
|
+
key = bytes(a ^ b for a, b in zip(_pad16(name), _pad16(password)))
|
|
181
|
+
data = (data1[:8] + data2[:8])[:16].ljust(16, b"\x00")
|
|
182
|
+
return _aes_ecb_encrypt(key, data)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def build_pairing_write(mesh_name: str, mesh_password: str) -> bytes:
|
|
186
|
+
"""Builds the 17-byte initial pairing-characteristic write:
|
|
187
|
+
`[0x0C] + R_APP + key_encrypt(mesh_name, mesh_password,
|
|
188
|
+
key=pad16(R_APP))[0:8]` - confirmed via
|
|
189
|
+
TelinkDeviceBleManager.m14334v ("authenticate")'s general-case branch.
|
|
190
|
+
|
|
191
|
+
`build_pairing_write(FACTORY_MESH_NAME, FACTORY_MESH_PASSWORD)`
|
|
192
|
+
reproduces `FACTORY_DEFAULT_PAIRING_WRITE` exactly - both are given
|
|
193
|
+
directly (not derived from each other) as a cross-check that this
|
|
194
|
+
formula generalizes correctly to the pre-baked factory-default
|
|
195
|
+
constant found separately in the decompiled source.
|
|
196
|
+
"""
|
|
197
|
+
ciphertext = key_encrypt(
|
|
198
|
+
mesh_name.encode("utf-8"), mesh_password.encode("utf-8"), _pad16(R_APP)
|
|
199
|
+
)[:8]
|
|
200
|
+
return bytes([0x0C]) + R_APP + ciphertext
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def derive_session_key(mesh_name: str, mesh_password: str, r_app: bytes, r_dev: bytes) -> bytes:
|
|
204
|
+
"""Derives the AES session key from both sides' random contributions -
|
|
205
|
+
confirmed via TelinkDeviceBleManager$..d (C2184d.mo14353a, the pairing-
|
|
206
|
+
characteristic read-response callback) and python-dimond's real
|
|
207
|
+
`connect()`. `r_dev` is bytes [1:9] of the device's response to the
|
|
208
|
+
`build_pairing_write()` write."""
|
|
209
|
+
return generate_sk(mesh_name.encode("utf-8"), mesh_password.encode("utf-8"), r_app, r_dev)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def verify_pairing_response(mesh_name: str, mesh_password: str, response: bytes) -> bool:
|
|
213
|
+
"""Real mutual-auth check the actual Cync app performs on the pairing
|
|
214
|
+
response, confirmed via C2184d.java's DataReceivedCallback -
|
|
215
|
+
contradicting this module's own earlier assumption (based only on
|
|
216
|
+
python-dimond, which skips it) that the real app performs no
|
|
217
|
+
verification at all. The device is expected to prove it derived the
|
|
218
|
+
same R_dev/key material by echoing back a proof value:
|
|
219
|
+
`response[9:17]` should equal `key_encrypt(mesh_name, mesh_password,
|
|
220
|
+
key=pad16(r_dev))[0:8]`, where `r_dev = response[1:9]`.
|
|
221
|
+
|
|
222
|
+
This is a CLIENT-side sanity check only - the device doesn't care
|
|
223
|
+
whether the phone/client verifies its response, so a failed/skipped
|
|
224
|
+
check does not by itself prevent provisioning from working (this is
|
|
225
|
+
exactly why python-dimond can skip it and still work). Treat a
|
|
226
|
+
verification failure as a strong signal something is misunderstood
|
|
227
|
+
about the response format (e.g. wrong mesh_name/mesh_password, or a
|
|
228
|
+
device that responds differently than expected) rather than a fatal
|
|
229
|
+
error - provision_device() logs it as a warning, not a raised
|
|
230
|
+
exception.
|
|
231
|
+
|
|
232
|
+
Returns False (rather than raising) if `response` is too short to
|
|
233
|
+
contain a proof value (< 17 bytes) - not every device/response variant
|
|
234
|
+
is guaranteed to include one.
|
|
235
|
+
"""
|
|
236
|
+
if len(response) < 17:
|
|
237
|
+
return False
|
|
238
|
+
r_dev = response[1:9]
|
|
239
|
+
expected_proof = key_encrypt(
|
|
240
|
+
mesh_name.encode("utf-8"), mesh_password.encode("utf-8"), _pad16(r_dev)
|
|
241
|
+
)[:8]
|
|
242
|
+
return response[9:17] == expected_proof
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def build_mesh_credential_write(kind: str, value: bytes, session_key: bytes) -> bytes:
|
|
246
|
+
"""Builds one of the 3 mesh-credential-handoff writes ("pairMesh") that
|
|
247
|
+
permanently assign a device its target mesh name/password/LTK, sent
|
|
248
|
+
after the session key above has been derived. Confirmed via
|
|
249
|
+
TelinkDeviceBleManager$pairMesh$2.java: for each of name/password/ltk,
|
|
250
|
+
UTF-8-encode + pad to 16 bytes, AES-ECB-encrypt with the session key,
|
|
251
|
+
take the first 8 bytes, prepend a literal opcode byte (4=name,
|
|
252
|
+
5=password, 6=ltk), then zero-pad the resulting 9 bytes to 17 for the
|
|
253
|
+
GATT write.
|
|
254
|
+
|
|
255
|
+
kind: one of "name", "password", "ltk". value: the raw UTF-8 mesh name/
|
|
256
|
+
password bytes, or DEFAULT_LTK/a custom 16-byte LTK for kind="ltk".
|
|
257
|
+
"""
|
|
258
|
+
if kind not in _PAIR_CREDENTIAL_OPCODES:
|
|
259
|
+
raise ValueError(f"kind must be one of {sorted(_PAIR_CREDENTIAL_OPCODES)}, got {kind!r}")
|
|
260
|
+
opcode = _PAIR_CREDENTIAL_OPCODES[kind]
|
|
261
|
+
ciphertext = _aes_ecb_encrypt(session_key, _pad16(value))[:8]
|
|
262
|
+
payload = bytes([opcode]) + ciphertext
|
|
263
|
+
return payload.ljust(17, b"\x00")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class PairingError(Exception):
|
|
267
|
+
"""Raised when a real BLE pairing/mesh-join attempt fails - a real
|
|
268
|
+
device response (or lack of one) was involved, not just a local
|
|
269
|
+
programming error. See the specific message for which step failed."""
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
async def scan_for_unprovisioned_devices(timeout: float = 10.0):
|
|
273
|
+
"""EXPERIMENTAL, UNTESTED: scans for nearby BLE advertisements and
|
|
274
|
+
returns the ones that look like brand-new, never-provisioned Telink
|
|
275
|
+
devices - confirmed filter logic (BleDeviceScanner.java): manufacturer
|
|
276
|
+
data keyed by TELINK_COMPANY_ID (0x0211) present, AND the advertised
|
|
277
|
+
local name equal to FACTORY_ADVERTISED_NAME ("telink_mesh1", Telink's
|
|
278
|
+
stock unprovisioned-node name). No BLE-level scan filter is used by
|
|
279
|
+
the real app either - everything is filtered in application code
|
|
280
|
+
after an unfiltered OS-level scan, so this does the same.
|
|
281
|
+
|
|
282
|
+
Looser than the real app's own filter: BleDeviceScanner.java also
|
|
283
|
+
validates a device-type byte within the manufacturer data against
|
|
284
|
+
the full GE/Cync product catalog, rejecting unrecognized types
|
|
285
|
+
outright - not replicated here, so this may surface non-Cync Telink
|
|
286
|
+
devices in scan results if any happen to be nearby (not incorrect,
|
|
287
|
+
just more permissive).
|
|
288
|
+
|
|
289
|
+
Returns a list of bleak `BLEDevice` objects. Requires the `bleak`
|
|
290
|
+
optional dependency (`pip install cync_lan[ble]`).
|
|
291
|
+
"""
|
|
292
|
+
try:
|
|
293
|
+
from bleak import BleakScanner
|
|
294
|
+
except ImportError as e:
|
|
295
|
+
raise RuntimeError(
|
|
296
|
+
"bleak is required for BLE provisioning - install it with "
|
|
297
|
+
"'pip install cync_lan[ble]'"
|
|
298
|
+
) from e
|
|
299
|
+
|
|
300
|
+
discovered = await BleakScanner.discover(timeout=timeout, return_adv=True)
|
|
301
|
+
found = []
|
|
302
|
+
for address, (device, adv) in discovered.items():
|
|
303
|
+
if TELINK_COMPANY_ID not in (adv.manufacturer_data or {}):
|
|
304
|
+
continue
|
|
305
|
+
if (adv.local_name or device.name) != FACTORY_ADVERTISED_NAME:
|
|
306
|
+
continue
|
|
307
|
+
found.append(device)
|
|
308
|
+
return found
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
async def provision_device(
|
|
312
|
+
address: str,
|
|
313
|
+
mesh_name: str,
|
|
314
|
+
mesh_password: str,
|
|
315
|
+
ltk: bytes = DEFAULT_LTK,
|
|
316
|
+
timeout: float = 15.0,
|
|
317
|
+
) -> None:
|
|
318
|
+
"""EXPERIMENTAL, UNTESTED AGAINST REAL HARDWARE: connects to a brand-
|
|
319
|
+
new/factory-default device at `address` (see
|
|
320
|
+
scan_for_unprovisioned_devices()) and hands it the given mesh
|
|
321
|
+
name/password/LTK, making it a permanent member of that mesh.
|
|
322
|
+
|
|
323
|
+
Confirmed flow (TelinkDeviceBleManager.m14334v + m14318B +
|
|
324
|
+
TelinkDeviceBleManager$pairMesh$2.java):
|
|
325
|
+
1. Connect, discover GATT services.
|
|
326
|
+
2. Write FACTORY_DEFAULT_PAIRING_WRITE to the pairing characteristic
|
|
327
|
+
(the pre-baked bootstrap handshake for a factory-default device).
|
|
328
|
+
3. Read the pairing characteristic's response; bytes [1:9] are the
|
|
329
|
+
device's own random contribution ("R_dev").
|
|
330
|
+
4. Derive the session key from R_APP + R_dev, using the FACTORY mesh
|
|
331
|
+
name/password (not the target mesh) as the key material - this is
|
|
332
|
+
the bootstrap session, not the final one.
|
|
333
|
+
5. Write the target mesh name, password, and LTK (3 separate writes,
|
|
334
|
+
each confirmed by build_mesh_credential_write()) using that session
|
|
335
|
+
key, queued together.
|
|
336
|
+
6. Read the pairing characteristic once more to confirm the device
|
|
337
|
+
accepted the new credentials.
|
|
338
|
+
|
|
339
|
+
Raises PairingError if the device doesn't respond as expected at any
|
|
340
|
+
step. Does NOT hand the device WiFi credentials (a separate step, see
|
|
341
|
+
docs/ble_provisioning_protocol.md's "WiFi credential handoff" section -
|
|
342
|
+
only relevant for WiFi-capable device types, not implemented here yet).
|
|
343
|
+
|
|
344
|
+
Requires the `bleak` optional dependency (`pip install cync_lan[ble]`).
|
|
345
|
+
"""
|
|
346
|
+
try:
|
|
347
|
+
from bleak import BleakClient
|
|
348
|
+
except ImportError as e:
|
|
349
|
+
raise RuntimeError(
|
|
350
|
+
"bleak is required for BLE provisioning - install it with "
|
|
351
|
+
"'pip install cync_lan[ble]'"
|
|
352
|
+
) from e
|
|
353
|
+
|
|
354
|
+
lp = f"provision_device[{address}]:"
|
|
355
|
+
logger.warning(
|
|
356
|
+
f"{lp} EXPERIMENTAL, untested against real hardware - please report success "
|
|
357
|
+
f"or failure (with the exact error/traceback) regardless of outcome."
|
|
358
|
+
)
|
|
359
|
+
async with BleakClient(address, timeout=timeout) as client:
|
|
360
|
+
logger.info(f"{lp} connected, writing factory-default pairing handshake")
|
|
361
|
+
await client.write_gatt_char(
|
|
362
|
+
PAIRING_CHAR_UUID, FACTORY_DEFAULT_PAIRING_WRITE, response=True
|
|
363
|
+
)
|
|
364
|
+
response = await client.read_gatt_char(PAIRING_CHAR_UUID)
|
|
365
|
+
if len(response) < 9:
|
|
366
|
+
raise PairingError(
|
|
367
|
+
f"{lp} pairing response too short ({len(response)} bytes, need >= 9): "
|
|
368
|
+
f"{response.hex()}"
|
|
369
|
+
)
|
|
370
|
+
r_dev = response[1:9]
|
|
371
|
+
if not verify_pairing_response(FACTORY_MESH_NAME, FACTORY_MESH_PASSWORD, response):
|
|
372
|
+
logger.warning(
|
|
373
|
+
f"{lp} device's pairing response did not pass the mutual-auth proof "
|
|
374
|
+
f"check the real app performs (see verify_pairing_response()'s docstring) - "
|
|
375
|
+
f"continuing anyway, since this doesn't block the device from accepting "
|
|
376
|
+
f"pairing, but it's a signal something may be misunderstood if the "
|
|
377
|
+
f"following steps fail: {response.hex()}"
|
|
378
|
+
)
|
|
379
|
+
session_key = derive_session_key(
|
|
380
|
+
FACTORY_MESH_NAME, FACTORY_MESH_PASSWORD, R_APP, r_dev
|
|
381
|
+
)
|
|
382
|
+
logger.info(f"{lp} session key derived, handing off target mesh credentials")
|
|
383
|
+
|
|
384
|
+
name_write = build_mesh_credential_write(
|
|
385
|
+
"name", mesh_name.encode("utf-8"), session_key
|
|
386
|
+
)
|
|
387
|
+
password_write = build_mesh_credential_write(
|
|
388
|
+
"password", mesh_password.encode("utf-8"), session_key
|
|
389
|
+
)
|
|
390
|
+
ltk_write = build_mesh_credential_write("ltk", ltk, session_key)
|
|
391
|
+
for payload in (name_write, password_write, ltk_write):
|
|
392
|
+
await client.write_gatt_char(PAIRING_CHAR_UUID, payload, response=True)
|
|
393
|
+
|
|
394
|
+
confirm = await client.read_gatt_char(PAIRING_CHAR_UUID)
|
|
395
|
+
if not confirm or confirm[0] != PAIR_CONFIRM_BYTE:
|
|
396
|
+
raise PairingError(
|
|
397
|
+
f"{lp} device did not confirm the new mesh credentials "
|
|
398
|
+
f"(expected first byte {PAIR_CONFIRM_BYTE}): {confirm.hex() if confirm else '(empty)'}"
|
|
399
|
+
)
|
|
400
|
+
logger.info(f"{lp} provisioned successfully onto mesh {mesh_name!r}")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _parse_cli(argv=None):
|
|
404
|
+
parser = argparse.ArgumentParser(
|
|
405
|
+
prog="cync-lan-ble-provision",
|
|
406
|
+
description=(
|
|
407
|
+
"EXPERIMENTAL, untested against real hardware - BLE provisioning "
|
|
408
|
+
"for brand-new (factory-default) Cync/C-by-GE devices. See "
|
|
409
|
+
"docs/ble_provisioning_protocol.md."
|
|
410
|
+
),
|
|
411
|
+
)
|
|
412
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
413
|
+
|
|
414
|
+
scan_parser = sub.add_parser("scan", help="Scan for nearby unprovisioned devices")
|
|
415
|
+
scan_parser.add_argument(
|
|
416
|
+
"--timeout", type=float, default=10.0, help="Scan duration in seconds"
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
provision_parser = sub.add_parser(
|
|
420
|
+
"provision", help="Provision a device onto a target mesh"
|
|
421
|
+
)
|
|
422
|
+
provision_parser.add_argument("address", help="BLE address of the device to provision")
|
|
423
|
+
provision_parser.add_argument("mesh_name", help="Target mesh name")
|
|
424
|
+
provision_parser.add_argument("mesh_password", help="Target mesh password")
|
|
425
|
+
provision_parser.add_argument(
|
|
426
|
+
"--timeout", type=float, default=15.0, help="Connection timeout in seconds"
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
return parser.parse_args(argv)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
async def _async_main(args) -> int:
|
|
433
|
+
if args.command == "scan":
|
|
434
|
+
devices = await scan_for_unprovisioned_devices(timeout=args.timeout)
|
|
435
|
+
if not devices:
|
|
436
|
+
print("No unprovisioned devices found.")
|
|
437
|
+
return 0
|
|
438
|
+
print(f"Found {len(devices)} unprovisioned device(s):")
|
|
439
|
+
for device in devices:
|
|
440
|
+
print(f" {device.address} {device.name}")
|
|
441
|
+
return 0
|
|
442
|
+
if args.command == "provision":
|
|
443
|
+
try:
|
|
444
|
+
await provision_device(
|
|
445
|
+
args.address, args.mesh_name, args.mesh_password, timeout=args.timeout
|
|
446
|
+
)
|
|
447
|
+
except PairingError as e:
|
|
448
|
+
print(f"Provisioning failed: {e}", file=sys.stderr)
|
|
449
|
+
return 1
|
|
450
|
+
print("Provisioned successfully.")
|
|
451
|
+
return 0
|
|
452
|
+
return 1
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def main(argv=None) -> None:
|
|
456
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
457
|
+
args = _parse_cli(argv)
|
|
458
|
+
sys.exit(asyncio.run(_async_main(args)))
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
if __name__ == "__main__":
|
|
462
|
+
main()
|