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 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()