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.
Files changed (54) hide show
  1. aqara_ble-1.0.0/LICENSE +21 -0
  2. aqara_ble-1.0.0/PKG-INFO +116 -0
  3. aqara_ble-1.0.0/README.md +78 -0
  4. aqara_ble-1.0.0/aqara_ble/__init__.py +206 -0
  5. aqara_ble-1.0.0/aqara_ble/auth.py +140 -0
  6. aqara_ble-1.0.0/aqara_ble/bumble_transport.py +141 -0
  7. aqara_ble-1.0.0/aqara_ble/cli.py +269 -0
  8. aqara_ble-1.0.0/aqara_ble/client.py +350 -0
  9. aqara_ble-1.0.0/aqara_ble/cloud_crypto.py +289 -0
  10. aqara_ble-1.0.0/aqara_ble/control_codec.py +44 -0
  11. aqara_ble-1.0.0/aqara_ble/errors.py +56 -0
  12. aqara_ble-1.0.0/aqara_ble/framing.py +144 -0
  13. aqara_ble-1.0.0/aqara_ble/gatt.py +100 -0
  14. aqara_ble-1.0.0/aqara_ble/gatt_uuids.py +61 -0
  15. aqara_ble-1.0.0/aqara_ble/kdf.py +585 -0
  16. aqara_ble-1.0.0/aqara_ble/lock_ops.py +205 -0
  17. aqara_ble-1.0.0/aqara_ble/lock_state.py +79 -0
  18. aqara_ble-1.0.0/aqara_ble/models.py +45 -0
  19. aqara_ble-1.0.0/aqara_ble/operations_catalog.py +339 -0
  20. aqara_ble-1.0.0/aqara_ble/protocol.py +45 -0
  21. aqara_ble-1.0.0/aqara_ble/py.typed +0 -0
  22. aqara_ble-1.0.0/aqara_ble/scanner.py +81 -0
  23. aqara_ble-1.0.0/aqara_ble/session.py +698 -0
  24. aqara_ble-1.0.0/aqara_ble/transport.py +440 -0
  25. aqara_ble-1.0.0/aqara_ble/volume.py +96 -0
  26. aqara_ble-1.0.0/aqara_ble.egg-info/PKG-INFO +116 -0
  27. aqara_ble-1.0.0/aqara_ble.egg-info/SOURCES.txt +52 -0
  28. aqara_ble-1.0.0/aqara_ble.egg-info/dependency_links.txt +1 -0
  29. aqara_ble-1.0.0/aqara_ble.egg-info/entry_points.txt +2 -0
  30. aqara_ble-1.0.0/aqara_ble.egg-info/requires.txt +13 -0
  31. aqara_ble-1.0.0/aqara_ble.egg-info/top_level.txt +1 -0
  32. aqara_ble-1.0.0/pyproject.toml +140 -0
  33. aqara_ble-1.0.0/setup.cfg +4 -0
  34. aqara_ble-1.0.0/tests/test_async_cloud_boundary.py +637 -0
  35. aqara_ble-1.0.0/tests/test_auto_login_flow.py +400 -0
  36. aqara_ble-1.0.0/tests/test_cli.py +220 -0
  37. aqara_ble-1.0.0/tests/test_client_facade.py +273 -0
  38. aqara_ble-1.0.0/tests/test_device_identity.py +51 -0
  39. aqara_ble-1.0.0/tests/test_gatt_abstraction.py +283 -0
  40. aqara_ble-1.0.0/tests/test_kdf.py +432 -0
  41. aqara_ble-1.0.0/tests/test_listen_window.py +56 -0
  42. aqara_ble-1.0.0/tests/test_lock_ops.py +111 -0
  43. aqara_ble-1.0.0/tests/test_lock_state.py +39 -0
  44. aqara_ble-1.0.0/tests/test_operations_catalog.py +94 -0
  45. aqara_ble-1.0.0/tests/test_package_api.py +48 -0
  46. aqara_ble-1.0.0/tests/test_protocol.py +85 -0
  47. aqara_ble-1.0.0/tests/test_scanner_identify.py +156 -0
  48. aqara_ble-1.0.0/tests/test_secret_hygiene.py +98 -0
  49. aqara_ble-1.0.0/tests/test_session.py +94 -0
  50. aqara_ble-1.0.0/tests/test_session_flow.py +440 -0
  51. aqara_ble-1.0.0/tests/test_status_query.py +73 -0
  52. aqara_ble-1.0.0/tests/test_transport.py +67 -0
  53. aqara_ble-1.0.0/tests/test_transport_contract.py +283 -0
  54. aqara_ble-1.0.0/tests/test_volume.py +55 -0
@@ -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.
@@ -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.