aqara-ble 1.0.0__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.
- aqara_ble-1.0.0/LICENSE +21 -0
- aqara_ble-1.0.0/PKG-INFO +116 -0
- aqara_ble-1.0.0/README.md +78 -0
- aqara_ble-1.0.0/aqara_ble/__init__.py +206 -0
- aqara_ble-1.0.0/aqara_ble/auth.py +140 -0
- aqara_ble-1.0.0/aqara_ble/bumble_transport.py +141 -0
- aqara_ble-1.0.0/aqara_ble/cli.py +269 -0
- aqara_ble-1.0.0/aqara_ble/client.py +350 -0
- aqara_ble-1.0.0/aqara_ble/cloud_crypto.py +289 -0
- aqara_ble-1.0.0/aqara_ble/control_codec.py +44 -0
- aqara_ble-1.0.0/aqara_ble/errors.py +56 -0
- aqara_ble-1.0.0/aqara_ble/framing.py +144 -0
- aqara_ble-1.0.0/aqara_ble/gatt.py +100 -0
- aqara_ble-1.0.0/aqara_ble/gatt_uuids.py +61 -0
- aqara_ble-1.0.0/aqara_ble/kdf.py +585 -0
- aqara_ble-1.0.0/aqara_ble/lock_ops.py +205 -0
- aqara_ble-1.0.0/aqara_ble/lock_state.py +79 -0
- aqara_ble-1.0.0/aqara_ble/models.py +45 -0
- aqara_ble-1.0.0/aqara_ble/operations_catalog.py +339 -0
- aqara_ble-1.0.0/aqara_ble/protocol.py +45 -0
- aqara_ble-1.0.0/aqara_ble/py.typed +0 -0
- aqara_ble-1.0.0/aqara_ble/scanner.py +81 -0
- aqara_ble-1.0.0/aqara_ble/session.py +698 -0
- aqara_ble-1.0.0/aqara_ble/transport.py +440 -0
- aqara_ble-1.0.0/aqara_ble/volume.py +96 -0
- aqara_ble-1.0.0/aqara_ble.egg-info/PKG-INFO +116 -0
- aqara_ble-1.0.0/aqara_ble.egg-info/SOURCES.txt +52 -0
- aqara_ble-1.0.0/aqara_ble.egg-info/dependency_links.txt +1 -0
- aqara_ble-1.0.0/aqara_ble.egg-info/entry_points.txt +2 -0
- aqara_ble-1.0.0/aqara_ble.egg-info/requires.txt +13 -0
- aqara_ble-1.0.0/aqara_ble.egg-info/top_level.txt +1 -0
- aqara_ble-1.0.0/pyproject.toml +140 -0
- aqara_ble-1.0.0/setup.cfg +4 -0
- aqara_ble-1.0.0/tests/test_async_cloud_boundary.py +637 -0
- aqara_ble-1.0.0/tests/test_auto_login_flow.py +400 -0
- aqara_ble-1.0.0/tests/test_cli.py +220 -0
- aqara_ble-1.0.0/tests/test_client_facade.py +273 -0
- aqara_ble-1.0.0/tests/test_device_identity.py +51 -0
- aqara_ble-1.0.0/tests/test_gatt_abstraction.py +283 -0
- aqara_ble-1.0.0/tests/test_kdf.py +432 -0
- aqara_ble-1.0.0/tests/test_listen_window.py +56 -0
- aqara_ble-1.0.0/tests/test_lock_ops.py +111 -0
- aqara_ble-1.0.0/tests/test_lock_state.py +39 -0
- aqara_ble-1.0.0/tests/test_operations_catalog.py +94 -0
- aqara_ble-1.0.0/tests/test_package_api.py +48 -0
- aqara_ble-1.0.0/tests/test_protocol.py +85 -0
- aqara_ble-1.0.0/tests/test_scanner_identify.py +156 -0
- aqara_ble-1.0.0/tests/test_secret_hygiene.py +98 -0
- aqara_ble-1.0.0/tests/test_session.py +94 -0
- aqara_ble-1.0.0/tests/test_session_flow.py +440 -0
- aqara_ble-1.0.0/tests/test_status_query.py +73 -0
- aqara_ble-1.0.0/tests/test_transport.py +67 -0
- aqara_ble-1.0.0/tests/test_transport_contract.py +283 -0
- aqara_ble-1.0.0/tests/test_volume.py +55 -0
aqara_ble-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dani811
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
aqara_ble-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aqara-ble
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Autonomous BLE control library for the Aqara U200 smart lock — cloud KDF, auth handshake and AES-CCM control channel, reimplemented in pure Python.
|
|
5
|
+
Author: dani811
|
|
6
|
+
Maintainer: dani811
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/dani811/Aqara
|
|
9
|
+
Project-URL: Repository, https://github.com/dani811/Aqara
|
|
10
|
+
Project-URL: Issues, https://github.com/dani811/Aqara/issues
|
|
11
|
+
Keywords: aqara,u200,smart-lock,bluetooth,ble,reverse-engineering,home-automation
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Home Automation
|
|
22
|
+
Classifier: Topic :: Security :: Cryptography
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: cryptography>=43.0.0
|
|
28
|
+
Provides-Extra: ble
|
|
29
|
+
Requires-Dist: bleak>=0.22; extra == "ble"
|
|
30
|
+
Provides-Extra: bumble
|
|
31
|
+
Requires-Dist: bumble>=0.0.200; extra == "bumble"
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
34
|
+
Requires-Dist: mypy>=1.11; extra == "dev"
|
|
35
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
36
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
37
|
+
Dynamic: license-file
|
|
38
|
+
|
|
39
|
+
# Aqara BLE
|
|
40
|
+
|
|
41
|
+
Autonomous control of Aqara Bluetooth Low Energy locks from Python — no app, no
|
|
42
|
+
phone — reconstructed by reverse-engineering the official application's observed
|
|
43
|
+
behaviour. The **U200** is the fully solved reference device.
|
|
44
|
+
|
|
45
|
+
## Goals
|
|
46
|
+
|
|
47
|
+
- Document the Aqara BLE + cloud protocol precisely enough to reproduce it.
|
|
48
|
+
- Provide a cross-platform Python library that exposes the lock's full operation
|
|
49
|
+
surface.
|
|
50
|
+
- Enable a native Home Assistant integration (the primary target).
|
|
51
|
+
- Make porting to other Aqara-family devices a methodical, repeatable process.
|
|
52
|
+
|
|
53
|
+
## Quick start
|
|
54
|
+
|
|
55
|
+
Two ways in — a terminal command and an importable API. **Integrations couple to
|
|
56
|
+
the API**; the CLI is just a thin adapter over that same API.
|
|
57
|
+
|
|
58
|
+
### Terminal — the `aqara` command
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install -e . # puts `aqara` on your PATH
|
|
62
|
+
aqara login # account login only (no radio)
|
|
63
|
+
aqara scan --transport bleak
|
|
64
|
+
aqara lock --transport bumble --port serial:/dev/cu.usbmodemNNNN,115200
|
|
65
|
+
aqara unlock ; aqara operate keepalive
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Credentials come from `--account/--password` or the environment/`.env`
|
|
69
|
+
(`AQARA_ACCOUNT`, `AQARA_PASSWORD`, `AQARA_APPID`, `AQARA_APPKEY`,
|
|
70
|
+
`AQARA_CLIENT_ID`, `AQARA_PHONE_ID`, `AQARA_DEVICE_ID`).
|
|
71
|
+
|
|
72
|
+
### Library — the coupling surface for integrations
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from aqara_ble import BleakTransport, CloudAuthManager, U200Client
|
|
76
|
+
|
|
77
|
+
auth = CloudAuthManager(
|
|
78
|
+
account=..., password=..., appid=..., appkey=..., client_id=..., phone_id=...
|
|
79
|
+
)
|
|
80
|
+
async with await U200Client.connect(
|
|
81
|
+
auth=auth, transport=BleakTransport(), device_id="lumi1.xxxx"
|
|
82
|
+
) as lock:
|
|
83
|
+
await lock.lock()
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The facade logs in (and re-authenticates on token expiry), scans and identifies
|
|
87
|
+
the lock by what it advertises, connects, discovers services and runs the
|
|
88
|
+
authenticated operation. Swap `BleakTransport()` for
|
|
89
|
+
`BumbleTransport("serial:/dev/…")` to drive an ESP32‑S3 controller
|
|
90
|
+
([firmware](tools/esp32s3_hci_usb/README.md)). Shell equivalent:
|
|
91
|
+
`python examples/lock_cli.py --transport bleak lock`.
|
|
92
|
+
|
|
93
|
+
## Start here
|
|
94
|
+
|
|
95
|
+
- **[docs/](docs/README.md)** — the documentation entry point (understand · port ·
|
|
96
|
+
diagnose).
|
|
97
|
+
- **[docs/devices/u200/validation.md](docs/devices/u200/validation.md)** — run it
|
|
98
|
+
against a real U200 (facade, transports, troubleshooting).
|
|
99
|
+
- **[docs/architecture.md](docs/architecture.md)** — how it works end to end and
|
|
100
|
+
the transversal-vs-device Layer Map.
|
|
101
|
+
- **[docs/porting-guide.md](docs/porting-guide.md)** — the numbered process to
|
|
102
|
+
bring a new device online, with the CRC and login obstacles solved up front.
|
|
103
|
+
- **[CONTRIBUTING.md](CONTRIBUTING.md)** — Spec-Driven Development workflow and the
|
|
104
|
+
secret-hygiene rules.
|
|
105
|
+
|
|
106
|
+
## Secrets — non-negotiable
|
|
107
|
+
|
|
108
|
+
No real secret, capture, or app source ever enters this repository. Credentials
|
|
109
|
+
and device identifiers live only in a local, git-ignored `.env` (see
|
|
110
|
+
[`.env.example`](.env.example)); raw captures live under a git-ignored `captures/`
|
|
111
|
+
tree. See Constitution Principle I in
|
|
112
|
+
[`.specify/memory/constitution.md`](.specify/memory/constitution.md).
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Aqara BLE
|
|
2
|
+
|
|
3
|
+
Autonomous control of Aqara Bluetooth Low Energy locks from Python — no app, no
|
|
4
|
+
phone — reconstructed by reverse-engineering the official application's observed
|
|
5
|
+
behaviour. The **U200** is the fully solved reference device.
|
|
6
|
+
|
|
7
|
+
## Goals
|
|
8
|
+
|
|
9
|
+
- Document the Aqara BLE + cloud protocol precisely enough to reproduce it.
|
|
10
|
+
- Provide a cross-platform Python library that exposes the lock's full operation
|
|
11
|
+
surface.
|
|
12
|
+
- Enable a native Home Assistant integration (the primary target).
|
|
13
|
+
- Make porting to other Aqara-family devices a methodical, repeatable process.
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
Two ways in — a terminal command and an importable API. **Integrations couple to
|
|
18
|
+
the API**; the CLI is just a thin adapter over that same API.
|
|
19
|
+
|
|
20
|
+
### Terminal — the `aqara` command
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install -e . # puts `aqara` on your PATH
|
|
24
|
+
aqara login # account login only (no radio)
|
|
25
|
+
aqara scan --transport bleak
|
|
26
|
+
aqara lock --transport bumble --port serial:/dev/cu.usbmodemNNNN,115200
|
|
27
|
+
aqara unlock ; aqara operate keepalive
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Credentials come from `--account/--password` or the environment/`.env`
|
|
31
|
+
(`AQARA_ACCOUNT`, `AQARA_PASSWORD`, `AQARA_APPID`, `AQARA_APPKEY`,
|
|
32
|
+
`AQARA_CLIENT_ID`, `AQARA_PHONE_ID`, `AQARA_DEVICE_ID`).
|
|
33
|
+
|
|
34
|
+
### Library — the coupling surface for integrations
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from aqara_ble import BleakTransport, CloudAuthManager, U200Client
|
|
38
|
+
|
|
39
|
+
auth = CloudAuthManager(
|
|
40
|
+
account=..., password=..., appid=..., appkey=..., client_id=..., phone_id=...
|
|
41
|
+
)
|
|
42
|
+
async with await U200Client.connect(
|
|
43
|
+
auth=auth, transport=BleakTransport(), device_id="lumi1.xxxx"
|
|
44
|
+
) as lock:
|
|
45
|
+
await lock.lock()
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The facade logs in (and re-authenticates on token expiry), scans and identifies
|
|
49
|
+
the lock by what it advertises, connects, discovers services and runs the
|
|
50
|
+
authenticated operation. Swap `BleakTransport()` for
|
|
51
|
+
`BumbleTransport("serial:/dev/…")` to drive an ESP32‑S3 controller
|
|
52
|
+
([firmware](tools/esp32s3_hci_usb/README.md)). Shell equivalent:
|
|
53
|
+
`python examples/lock_cli.py --transport bleak lock`.
|
|
54
|
+
|
|
55
|
+
## Start here
|
|
56
|
+
|
|
57
|
+
- **[docs/](docs/README.md)** — the documentation entry point (understand · port ·
|
|
58
|
+
diagnose).
|
|
59
|
+
- **[docs/devices/u200/validation.md](docs/devices/u200/validation.md)** — run it
|
|
60
|
+
against a real U200 (facade, transports, troubleshooting).
|
|
61
|
+
- **[docs/architecture.md](docs/architecture.md)** — how it works end to end and
|
|
62
|
+
the transversal-vs-device Layer Map.
|
|
63
|
+
- **[docs/porting-guide.md](docs/porting-guide.md)** — the numbered process to
|
|
64
|
+
bring a new device online, with the CRC and login obstacles solved up front.
|
|
65
|
+
- **[CONTRIBUTING.md](CONTRIBUTING.md)** — Spec-Driven Development workflow and the
|
|
66
|
+
secret-hygiene rules.
|
|
67
|
+
|
|
68
|
+
## Secrets — non-negotiable
|
|
69
|
+
|
|
70
|
+
No real secret, capture, or app source ever enters this repository. Credentials
|
|
71
|
+
and device identifiers live only in a local, git-ignored `.env` (see
|
|
72
|
+
[`.env.example`](.env.example)); raw captures live under a git-ignored `captures/`
|
|
73
|
+
tree. See Constitution Principle I in
|
|
74
|
+
[`.specify/memory/constitution.md`](.specify/memory/constitution.md).
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Autonomous BLE control for the Aqara U200 smart lock.
|
|
2
|
+
|
|
3
|
+
Pure-Python reimplementation of the cloud KDF, the BLE authentication
|
|
4
|
+
handshake (frames ``0610``/``0710``) and the AES-CCM control channel. See
|
|
5
|
+
``docs/`` for the reverse-engineered protocol.
|
|
6
|
+
|
|
7
|
+
This package is assembled incrementally, one Spec Kit feature at a time. The
|
|
8
|
+
recommended entry point is the **feature 015 facade**: ``U200Client`` +
|
|
9
|
+
a ``Transport`` (``BleakTransport`` for the host radio, ``BumbleTransport`` for
|
|
10
|
+
an external ESP32-S3 HCI controller) + ``CloudAuthManager`` (account login with
|
|
11
|
+
automatic token refresh). The lower-level pieces (KDF, framing, session flow,
|
|
12
|
+
operations catalogue) remain public for advanced consumers.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
18
|
+
|
|
19
|
+
from .auth import CloudAuthManager
|
|
20
|
+
from .bumble_transport import BumbleGattAdapter
|
|
21
|
+
from .client import OperationResult, U200Client
|
|
22
|
+
from .cloud_crypto import (
|
|
23
|
+
aes128gcm_decrypt_body,
|
|
24
|
+
aes128gcm_encrypt_body,
|
|
25
|
+
compute_nonce,
|
|
26
|
+
compute_sign,
|
|
27
|
+
encrypt_login_password,
|
|
28
|
+
hkdf_expand,
|
|
29
|
+
hkdf_extract,
|
|
30
|
+
hkdf_sha256,
|
|
31
|
+
make_local_signer,
|
|
32
|
+
)
|
|
33
|
+
from .control_codec import decrypt_control_payload, encrypt_control_payload
|
|
34
|
+
from .errors import AmbiguousDeviceError, FlowPhase, NoDeviceFoundError, U200ClientError
|
|
35
|
+
from .framing import (
|
|
36
|
+
assemble_auth_fragments,
|
|
37
|
+
build_auth_message,
|
|
38
|
+
crc16_aqara,
|
|
39
|
+
fragment_auth_message,
|
|
40
|
+
parse_auth_message,
|
|
41
|
+
)
|
|
42
|
+
from .gatt import GattClient
|
|
43
|
+
from .gatt_uuids import (
|
|
44
|
+
AUTH_NOTIFY_UUID,
|
|
45
|
+
AUTH_SERVICE_UUID,
|
|
46
|
+
AUTH_WRITE_UUID,
|
|
47
|
+
AUX_NOTIFY_UUID,
|
|
48
|
+
AUX_SERVICE_UUID,
|
|
49
|
+
CONTROL_NOTIFY2_UUID,
|
|
50
|
+
CONTROL_NOTIFY_UUID,
|
|
51
|
+
CONTROL_SERVICE_UUID,
|
|
52
|
+
CONTROL_WRITE_UUID,
|
|
53
|
+
GATT_CACHING_PREAMBLE_UUID16,
|
|
54
|
+
PRE_AUTH_NOTIFY_ORDER,
|
|
55
|
+
U200_SERVICE_UUIDS,
|
|
56
|
+
)
|
|
57
|
+
from .kdf import (
|
|
58
|
+
CloudServiceError,
|
|
59
|
+
build_cloud_auth_headers,
|
|
60
|
+
cloud_get_public_key,
|
|
61
|
+
cloud_verify,
|
|
62
|
+
get_session_material,
|
|
63
|
+
login,
|
|
64
|
+
prepare_runtime_cloud_auth_headers,
|
|
65
|
+
)
|
|
66
|
+
from .lock_ops import (
|
|
67
|
+
LockOperation,
|
|
68
|
+
LockOperationWrite,
|
|
69
|
+
build_control_frame,
|
|
70
|
+
build_control_query_write,
|
|
71
|
+
build_lock_operation_write,
|
|
72
|
+
build_operate_frame,
|
|
73
|
+
normalize_lock_operation,
|
|
74
|
+
send_lock_operation,
|
|
75
|
+
)
|
|
76
|
+
from .lock_state import LockState, decode_lock_state
|
|
77
|
+
from .models import MODEL_BY_PRODUCT_ID, decode_manufacturer_payload
|
|
78
|
+
from .operations_catalog import (
|
|
79
|
+
OPERATIONS_CATALOG,
|
|
80
|
+
CommandFamily,
|
|
81
|
+
OperationEntry,
|
|
82
|
+
OperationStatus,
|
|
83
|
+
find_operation,
|
|
84
|
+
operations_in_family,
|
|
85
|
+
)
|
|
86
|
+
from .protocol import (
|
|
87
|
+
ControlRequest,
|
|
88
|
+
control_command_name,
|
|
89
|
+
parse_control_request,
|
|
90
|
+
valid_crc,
|
|
91
|
+
)
|
|
92
|
+
from .scanner import identify_candidate, scan, select_preferred
|
|
93
|
+
from .session import (
|
|
94
|
+
OperationInProgressError,
|
|
95
|
+
SessionMaterial,
|
|
96
|
+
run_authenticated_lock_operation,
|
|
97
|
+
)
|
|
98
|
+
from .transport import (
|
|
99
|
+
AQARA_COMPANY_ID,
|
|
100
|
+
EXPECTED_NAME,
|
|
101
|
+
BleakTransport,
|
|
102
|
+
BumbleTransport,
|
|
103
|
+
ScanCandidate,
|
|
104
|
+
Transport,
|
|
105
|
+
)
|
|
106
|
+
from .volume import (
|
|
107
|
+
VoiceVolumePreset,
|
|
108
|
+
VoiceVolumeWrite,
|
|
109
|
+
build_voice_volume_write,
|
|
110
|
+
normalize_voice_volume_preset,
|
|
111
|
+
set_voice_volume,
|
|
112
|
+
write_voice_volume,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
__version__ = version("aqara-ble")
|
|
117
|
+
except PackageNotFoundError: # pragma: no cover - running from a source checkout
|
|
118
|
+
__version__ = "0.0.0+unknown"
|
|
119
|
+
|
|
120
|
+
__all__ = [
|
|
121
|
+
"AQARA_COMPANY_ID",
|
|
122
|
+
"AUTH_NOTIFY_UUID",
|
|
123
|
+
"AUTH_SERVICE_UUID",
|
|
124
|
+
"AUTH_WRITE_UUID",
|
|
125
|
+
"AUX_NOTIFY_UUID",
|
|
126
|
+
"AUX_SERVICE_UUID",
|
|
127
|
+
"CONTROL_NOTIFY2_UUID",
|
|
128
|
+
"CONTROL_NOTIFY_UUID",
|
|
129
|
+
"CONTROL_SERVICE_UUID",
|
|
130
|
+
"CONTROL_WRITE_UUID",
|
|
131
|
+
"EXPECTED_NAME",
|
|
132
|
+
"GATT_CACHING_PREAMBLE_UUID16",
|
|
133
|
+
"MODEL_BY_PRODUCT_ID",
|
|
134
|
+
"OPERATIONS_CATALOG",
|
|
135
|
+
"PRE_AUTH_NOTIFY_ORDER",
|
|
136
|
+
"U200_SERVICE_UUIDS",
|
|
137
|
+
"AmbiguousDeviceError",
|
|
138
|
+
"BleakTransport",
|
|
139
|
+
"BumbleGattAdapter",
|
|
140
|
+
"BumbleTransport",
|
|
141
|
+
"CloudAuthManager",
|
|
142
|
+
"CloudServiceError",
|
|
143
|
+
"CommandFamily",
|
|
144
|
+
"ControlRequest",
|
|
145
|
+
"FlowPhase",
|
|
146
|
+
"GattClient",
|
|
147
|
+
"LockOperation",
|
|
148
|
+
"LockOperationWrite",
|
|
149
|
+
"LockState",
|
|
150
|
+
"NoDeviceFoundError",
|
|
151
|
+
"OperationEntry",
|
|
152
|
+
"OperationInProgressError",
|
|
153
|
+
"OperationResult",
|
|
154
|
+
"OperationStatus",
|
|
155
|
+
"ScanCandidate",
|
|
156
|
+
"SessionMaterial",
|
|
157
|
+
"Transport",
|
|
158
|
+
"U200Client",
|
|
159
|
+
"U200ClientError",
|
|
160
|
+
"VoiceVolumePreset",
|
|
161
|
+
"VoiceVolumeWrite",
|
|
162
|
+
"__version__",
|
|
163
|
+
"aes128gcm_decrypt_body",
|
|
164
|
+
"aes128gcm_encrypt_body",
|
|
165
|
+
"assemble_auth_fragments",
|
|
166
|
+
"build_auth_message",
|
|
167
|
+
"build_cloud_auth_headers",
|
|
168
|
+
"build_control_frame",
|
|
169
|
+
"build_control_query_write",
|
|
170
|
+
"build_lock_operation_write",
|
|
171
|
+
"build_operate_frame",
|
|
172
|
+
"build_voice_volume_write",
|
|
173
|
+
"cloud_get_public_key",
|
|
174
|
+
"cloud_verify",
|
|
175
|
+
"compute_nonce",
|
|
176
|
+
"compute_sign",
|
|
177
|
+
"control_command_name",
|
|
178
|
+
"crc16_aqara",
|
|
179
|
+
"decode_lock_state",
|
|
180
|
+
"decode_manufacturer_payload",
|
|
181
|
+
"decrypt_control_payload",
|
|
182
|
+
"encrypt_control_payload",
|
|
183
|
+
"encrypt_login_password",
|
|
184
|
+
"find_operation",
|
|
185
|
+
"fragment_auth_message",
|
|
186
|
+
"get_session_material",
|
|
187
|
+
"hkdf_expand",
|
|
188
|
+
"hkdf_extract",
|
|
189
|
+
"hkdf_sha256",
|
|
190
|
+
"identify_candidate",
|
|
191
|
+
"login",
|
|
192
|
+
"make_local_signer",
|
|
193
|
+
"normalize_lock_operation",
|
|
194
|
+
"normalize_voice_volume_preset",
|
|
195
|
+
"operations_in_family",
|
|
196
|
+
"parse_auth_message",
|
|
197
|
+
"parse_control_request",
|
|
198
|
+
"prepare_runtime_cloud_auth_headers",
|
|
199
|
+
"run_authenticated_lock_operation",
|
|
200
|
+
"scan",
|
|
201
|
+
"select_preferred",
|
|
202
|
+
"send_lock_operation",
|
|
203
|
+
"set_voice_volume",
|
|
204
|
+
"valid_crc",
|
|
205
|
+
"write_voice_volume",
|
|
206
|
+
]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Automatic token management for cloud authentication.
|
|
3
|
+
|
|
4
|
+
Handles login, token refresh on 108 errors, and transparent credential loading.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
from .cloud_crypto import Signer, make_local_signer
|
|
12
|
+
from .kdf import CloudServiceError, login
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CloudAuthManager:
|
|
18
|
+
"""Manages Aqara cloud authentication with automatic token refresh.
|
|
19
|
+
|
|
20
|
+
Features:
|
|
21
|
+
- Load credentials from environment or .env files
|
|
22
|
+
- Automatic login with account/password (no manual token required)
|
|
23
|
+
- Transparent token refresh on expiration (code 108)
|
|
24
|
+
- Thread-safe caching of valid tokens
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
account: str,
|
|
31
|
+
password: str,
|
|
32
|
+
appid: str,
|
|
33
|
+
appkey: str,
|
|
34
|
+
client_id: str,
|
|
35
|
+
phone_id: str,
|
|
36
|
+
region: str = "EU",
|
|
37
|
+
district: str = "ES",
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Initialize auth manager with account credentials.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
account: Aqara account (email or phone)
|
|
43
|
+
password: Aqara account password
|
|
44
|
+
appid: Aqara app ID
|
|
45
|
+
appkey: Aqara app key
|
|
46
|
+
client_id: Firebase Cloud Messaging client ID
|
|
47
|
+
phone_id: Device phone ID
|
|
48
|
+
region: Aqara region (EU, CN, etc.)
|
|
49
|
+
district: Aqara district (ES, CN, etc.)
|
|
50
|
+
"""
|
|
51
|
+
self.account = account
|
|
52
|
+
self.password = password
|
|
53
|
+
self.appid = appid
|
|
54
|
+
self.appkey = appkey
|
|
55
|
+
self.client_id = client_id
|
|
56
|
+
self.phone_id = phone_id
|
|
57
|
+
self.region = region
|
|
58
|
+
self.district = district
|
|
59
|
+
|
|
60
|
+
self._token: str | None = None
|
|
61
|
+
self._user_id: str | None = None
|
|
62
|
+
|
|
63
|
+
def _login(self) -> tuple[str, str]:
|
|
64
|
+
"""Perform login and return (token, user_id).
|
|
65
|
+
|
|
66
|
+
A ``code 810`` (wrong password / unregistered account) is translated into
|
|
67
|
+
a clear, **non-retryable** error so the caller never loops on it — it is
|
|
68
|
+
not a token expiry. No credential is logged.
|
|
69
|
+
"""
|
|
70
|
+
logger.debug("cloud login: starting")
|
|
71
|
+
try:
|
|
72
|
+
result = login(
|
|
73
|
+
self.account,
|
|
74
|
+
self.password,
|
|
75
|
+
appid=self.appid,
|
|
76
|
+
appkey=self.appkey,
|
|
77
|
+
client_id=self.client_id,
|
|
78
|
+
phone_id=self.phone_id,
|
|
79
|
+
region=self.region,
|
|
80
|
+
district=self.district,
|
|
81
|
+
)
|
|
82
|
+
except CloudServiceError as exc:
|
|
83
|
+
if exc.is_code(810):
|
|
84
|
+
raise RuntimeError(
|
|
85
|
+
"Aqara login rejected the credentials (code 810): wrong "
|
|
86
|
+
"password, or the account is not registered in this region. "
|
|
87
|
+
"This is not a token expiry and is not retried."
|
|
88
|
+
) from exc
|
|
89
|
+
raise
|
|
90
|
+
token = result.get("token")
|
|
91
|
+
user_id = result.get("userId") or result.get("uid")
|
|
92
|
+
if not token:
|
|
93
|
+
raise RuntimeError(f"Login failed: no token in result {sorted(result)}")
|
|
94
|
+
logger.debug("cloud login: succeeded")
|
|
95
|
+
return token, user_id or ""
|
|
96
|
+
|
|
97
|
+
def build_signer(self, *, force_refresh: bool = False) -> Signer:
|
|
98
|
+
"""Build a cloud request signer bound to a valid token (logging in if
|
|
99
|
+
needed). Used by the operation flow to sign cloud calls and to rebuild
|
|
100
|
+
the signer after a token refresh."""
|
|
101
|
+
token = self.get_token(force_refresh=force_refresh)
|
|
102
|
+
return make_local_signer(
|
|
103
|
+
appid=self.appid,
|
|
104
|
+
appkey=self.appkey,
|
|
105
|
+
token=token,
|
|
106
|
+
user_id=self._user_id or "",
|
|
107
|
+
client_id=self.client_id,
|
|
108
|
+
phone_id=self.phone_id,
|
|
109
|
+
area=self.region,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def get_token(self, *, force_refresh: bool = False) -> str:
|
|
113
|
+
"""Get valid token, logging in if needed.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
force_refresh: Force new login even if cached token exists
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Valid JWT token for cloud API calls
|
|
120
|
+
"""
|
|
121
|
+
if self._token and not force_refresh:
|
|
122
|
+
return self._token
|
|
123
|
+
|
|
124
|
+
token, user_id = self._login()
|
|
125
|
+
self._token = token
|
|
126
|
+
self._user_id = user_id
|
|
127
|
+
return token
|
|
128
|
+
|
|
129
|
+
def handle_expired_token(self) -> str:
|
|
130
|
+
"""Handle a ``code=108`` (token expired) by forcing a fresh login.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
New valid token
|
|
134
|
+
"""
|
|
135
|
+
logger.debug("cloud token: refreshing (expired)")
|
|
136
|
+
return self.get_token(force_refresh=True)
|
|
137
|
+
|
|
138
|
+
# NOTE: environment-based construction lives OUTSIDE the library, in
|
|
139
|
+
# examples/auth_from_env.py (Feature 014). The library never reads the
|
|
140
|
+
# environment: the consumer (e.g. Home Assistant) injects credentials.
|