landbook-api 0.1.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.
- landbook_api-0.1.0/.github/workflows/release.yml +83 -0
- landbook_api-0.1.0/.github/workflows/test.yml +20 -0
- landbook_api-0.1.0/.gitignore +11 -0
- landbook_api-0.1.0/CHANGELOG.md +5 -0
- landbook_api-0.1.0/LICENSE +21 -0
- landbook_api-0.1.0/PKG-INFO +87 -0
- landbook_api-0.1.0/README.md +65 -0
- landbook_api-0.1.0/landbook_api/__init__.py +41 -0
- landbook_api-0.1.0/landbook_api/api.py +199 -0
- landbook_api-0.1.0/landbook_api/const.py +36 -0
- landbook_api-0.1.0/landbook_api/mqtt_client.py +234 -0
- landbook_api-0.1.0/landbook_api/py.typed +0 -0
- landbook_api-0.1.0/pyproject.toml +34 -0
- landbook_api-0.1.0/tests/__init__.py +0 -0
- landbook_api-0.1.0/tests/test_api.py +41 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
inputs:
|
|
9
|
+
version:
|
|
10
|
+
description: "Version to release (e.g. 0.1.0), no leading v"
|
|
11
|
+
required: true
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
build:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: "3.12"
|
|
21
|
+
- run: pip install -e ".[test]"
|
|
22
|
+
- run: pytest
|
|
23
|
+
- run: pip install build
|
|
24
|
+
- run: python -m build
|
|
25
|
+
- uses: actions/upload-artifact@v4
|
|
26
|
+
with:
|
|
27
|
+
name: dist
|
|
28
|
+
path: dist/
|
|
29
|
+
|
|
30
|
+
publish-pypi:
|
|
31
|
+
needs: build
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
environment: pypi
|
|
34
|
+
permissions:
|
|
35
|
+
id-token: write
|
|
36
|
+
steps:
|
|
37
|
+
- uses: actions/download-artifact@v4
|
|
38
|
+
with:
|
|
39
|
+
name: dist
|
|
40
|
+
path: dist/
|
|
41
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
42
|
+
with:
|
|
43
|
+
skip-existing: true
|
|
44
|
+
|
|
45
|
+
github-release:
|
|
46
|
+
needs: publish-pypi
|
|
47
|
+
runs-on: ubuntu-latest
|
|
48
|
+
permissions:
|
|
49
|
+
contents: write
|
|
50
|
+
steps:
|
|
51
|
+
- uses: actions/checkout@v4
|
|
52
|
+
|
|
53
|
+
- name: Get version
|
|
54
|
+
id: version
|
|
55
|
+
run: |
|
|
56
|
+
if [ -n "${{ inputs.version }}" ]; then
|
|
57
|
+
echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT
|
|
58
|
+
else
|
|
59
|
+
echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
- name: Extract changelog entry
|
|
63
|
+
id: changelog
|
|
64
|
+
run: |
|
|
65
|
+
VERSION="${{ steps.version.outputs.version }}"
|
|
66
|
+
NOTES=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## /{exit} found{print}" CHANGELOG.md | sed '/^[[:space:]]*$/d')
|
|
67
|
+
{
|
|
68
|
+
echo "notes<<EOF"
|
|
69
|
+
echo "## What's Changed"
|
|
70
|
+
echo ""
|
|
71
|
+
echo "$NOTES"
|
|
72
|
+
echo ""
|
|
73
|
+
echo "**Full changelog:** https://github.com/zackwag/landbook-api/blob/main/CHANGELOG.md"
|
|
74
|
+
echo "EOF"
|
|
75
|
+
} >> $GITHUB_OUTPUT
|
|
76
|
+
|
|
77
|
+
- name: Create GitHub Release
|
|
78
|
+
uses: softprops/action-gh-release@v2
|
|
79
|
+
with:
|
|
80
|
+
tag_name: "v${{ steps.version.outputs.version }}"
|
|
81
|
+
name: "v${{ steps.version.outputs.version }}"
|
|
82
|
+
body: "${{ steps.changelog.outputs.notes }}"
|
|
83
|
+
make_latest: true
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: Test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python-version }}
|
|
19
|
+
- run: pip install -e ".[test]"
|
|
20
|
+
- run: pytest
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zack Wagner
|
|
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,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: landbook-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unofficial Python client for the Landbook (Netprisma/Landecia) smart home cloud API — REST auth/device discovery and MQTT pub/sub.
|
|
5
|
+
Project-URL: Homepage, https://github.com/zackwag/landbook-api
|
|
6
|
+
Project-URL: Issues, https://github.com/zackwag/landbook-api/issues
|
|
7
|
+
Author: Zack Wagner
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Home Automation
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: paho-mqtt>=2.0.0
|
|
18
|
+
Requires-Dist: pycryptodome>=3.0.0
|
|
19
|
+
Provides-Extra: test
|
|
20
|
+
Requires-Dist: pytest>=7.0; extra == 'test'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# landbook-api
|
|
24
|
+
|
|
25
|
+
Unofficial Python client for the Landbook smart home cloud API (Netprisma/Landecia), reverse-engineered from the Landbook iOS app. Covers:
|
|
26
|
+
|
|
27
|
+
- **REST**: email/password login, token refresh, device discovery, TSL (Thing Specification Language) model fetch, device attribute reads.
|
|
28
|
+
- **MQTT**: a persistent WebSocket/TLS pub-sub connection for real-time device control and state (`LandbookMQTTClient`).
|
|
29
|
+
|
|
30
|
+
This library powers the [landbook-ha](https://github.com/zackwag/landbook-ha) Home Assistant integration, but has no dependency on Home Assistant and can be used standalone.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install landbook-api
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from landbook_api import login, get_device_list, get_tsl, LandbookMQTTClient
|
|
42
|
+
|
|
43
|
+
bearer_token, uid, refresh_token = login("you@example.com", "your-password", region="us")
|
|
44
|
+
|
|
45
|
+
devices = get_device_list(bearer_token, region="us")
|
|
46
|
+
pk = devices[0]["productKey"]
|
|
47
|
+
dk = devices[0]["deviceKey"]
|
|
48
|
+
|
|
49
|
+
properties = get_tsl(bearer_token, pk, region="us")
|
|
50
|
+
|
|
51
|
+
client = LandbookMQTTClient(uid, bearer_token)
|
|
52
|
+
client.connect()
|
|
53
|
+
|
|
54
|
+
def on_message(topic_suffix: str, payload: dict) -> None:
|
|
55
|
+
print(topic_suffix, payload)
|
|
56
|
+
|
|
57
|
+
device_id = f"qd{pk}{dk}"
|
|
58
|
+
client.subscribe_device(device_id, on_message)
|
|
59
|
+
client.send_read(device_id, pk, dk, [p["code"] for p in properties])
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Async equivalents (`async_login`, `async_get_device_list`, `async_get_tsl`, `async_refresh_token`, `async_get_device_attributes`) are also exported — each wraps the sync call in a thread executor.
|
|
63
|
+
|
|
64
|
+
## Supported Regions
|
|
65
|
+
|
|
66
|
+
| Region | API |
|
|
67
|
+
|--------|-----|
|
|
68
|
+
| United States | `iot-api.quectelus.com` |
|
|
69
|
+
| Europe | `iot-api.quecteleu.com` |
|
|
70
|
+
| China | `iot-gateway.quectel.com` |
|
|
71
|
+
|
|
72
|
+
Pass `region="us"`, `"eu"`, or `"cn"` to any call (defaults to `"us"`). EU and CN are untested — reports welcome via issues.
|
|
73
|
+
|
|
74
|
+
## Session Tokens
|
|
75
|
+
|
|
76
|
+
`login()` returns a `(bearer_token, uid, refresh_token)` tuple. The access token (`bearer_token`) is valid for 2 hours; the refresh token is valid for 30 days and must be used to obtain a new pair via `refresh_token()` before the access token expires. Both values should be persisted by the caller — this library does not manage token storage.
|
|
77
|
+
|
|
78
|
+
## Development
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install -e ".[test]"
|
|
82
|
+
pytest
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Disclaimer
|
|
86
|
+
|
|
87
|
+
This is an unofficial, reverse-engineered client with no affiliation to Landbook, Netprisma, or Landecia. It may break if the upstream API changes.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# landbook-api
|
|
2
|
+
|
|
3
|
+
Unofficial Python client for the Landbook smart home cloud API (Netprisma/Landecia), reverse-engineered from the Landbook iOS app. Covers:
|
|
4
|
+
|
|
5
|
+
- **REST**: email/password login, token refresh, device discovery, TSL (Thing Specification Language) model fetch, device attribute reads.
|
|
6
|
+
- **MQTT**: a persistent WebSocket/TLS pub-sub connection for real-time device control and state (`LandbookMQTTClient`).
|
|
7
|
+
|
|
8
|
+
This library powers the [landbook-ha](https://github.com/zackwag/landbook-ha) Home Assistant integration, but has no dependency on Home Assistant and can be used standalone.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install landbook-api
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from landbook_api import login, get_device_list, get_tsl, LandbookMQTTClient
|
|
20
|
+
|
|
21
|
+
bearer_token, uid, refresh_token = login("you@example.com", "your-password", region="us")
|
|
22
|
+
|
|
23
|
+
devices = get_device_list(bearer_token, region="us")
|
|
24
|
+
pk = devices[0]["productKey"]
|
|
25
|
+
dk = devices[0]["deviceKey"]
|
|
26
|
+
|
|
27
|
+
properties = get_tsl(bearer_token, pk, region="us")
|
|
28
|
+
|
|
29
|
+
client = LandbookMQTTClient(uid, bearer_token)
|
|
30
|
+
client.connect()
|
|
31
|
+
|
|
32
|
+
def on_message(topic_suffix: str, payload: dict) -> None:
|
|
33
|
+
print(topic_suffix, payload)
|
|
34
|
+
|
|
35
|
+
device_id = f"qd{pk}{dk}"
|
|
36
|
+
client.subscribe_device(device_id, on_message)
|
|
37
|
+
client.send_read(device_id, pk, dk, [p["code"] for p in properties])
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Async equivalents (`async_login`, `async_get_device_list`, `async_get_tsl`, `async_refresh_token`, `async_get_device_attributes`) are also exported — each wraps the sync call in a thread executor.
|
|
41
|
+
|
|
42
|
+
## Supported Regions
|
|
43
|
+
|
|
44
|
+
| Region | API |
|
|
45
|
+
|--------|-----|
|
|
46
|
+
| United States | `iot-api.quectelus.com` |
|
|
47
|
+
| Europe | `iot-api.quecteleu.com` |
|
|
48
|
+
| China | `iot-gateway.quectel.com` |
|
|
49
|
+
|
|
50
|
+
Pass `region="us"`, `"eu"`, or `"cn"` to any call (defaults to `"us"`). EU and CN are untested — reports welcome via issues.
|
|
51
|
+
|
|
52
|
+
## Session Tokens
|
|
53
|
+
|
|
54
|
+
`login()` returns a `(bearer_token, uid, refresh_token)` tuple. The access token (`bearer_token`) is valid for 2 hours; the refresh token is valid for 30 days and must be used to obtain a new pair via `refresh_token()` before the access token expires. Both values should be persisted by the caller — this library does not manage token storage.
|
|
55
|
+
|
|
56
|
+
## Development
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install -e ".[test]"
|
|
60
|
+
pytest
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Disclaimer
|
|
64
|
+
|
|
65
|
+
This is an unofficial, reverse-engineered client with no affiliation to Landbook, Netprisma, or Landecia. It may break if the upstream API changes.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Unofficial Python client for the Landbook (Netprisma/Landecia) cloud API.
|
|
2
|
+
|
|
3
|
+
Covers REST auth/device discovery/TSL model fetch and the WebSocket/TLS MQTT
|
|
4
|
+
pub/sub channel used for real-time device control and state.
|
|
5
|
+
"""
|
|
6
|
+
from .api import (
|
|
7
|
+
LandbookAPIError,
|
|
8
|
+
LandbookAuthError,
|
|
9
|
+
async_get_device_attributes,
|
|
10
|
+
async_get_device_list,
|
|
11
|
+
async_get_tsl,
|
|
12
|
+
async_login,
|
|
13
|
+
async_refresh_token,
|
|
14
|
+
get_device_attributes,
|
|
15
|
+
get_device_list,
|
|
16
|
+
get_tsl,
|
|
17
|
+
login,
|
|
18
|
+
refresh_token,
|
|
19
|
+
)
|
|
20
|
+
from .const import DEFAULT_REGION, REGIONS
|
|
21
|
+
from .mqtt_client import LandbookMQTTClient
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"LandbookAPIError",
|
|
27
|
+
"LandbookAuthError",
|
|
28
|
+
"LandbookMQTTClient",
|
|
29
|
+
"DEFAULT_REGION",
|
|
30
|
+
"REGIONS",
|
|
31
|
+
"login",
|
|
32
|
+
"async_login",
|
|
33
|
+
"get_device_list",
|
|
34
|
+
"async_get_device_list",
|
|
35
|
+
"get_tsl",
|
|
36
|
+
"async_get_tsl",
|
|
37
|
+
"refresh_token",
|
|
38
|
+
"async_refresh_token",
|
|
39
|
+
"get_device_attributes",
|
|
40
|
+
"async_get_device_attributes",
|
|
41
|
+
]
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Landbook API client — auth, device discovery, TSL model fetch."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import random
|
|
9
|
+
import string
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from Crypto.Cipher import AES
|
|
15
|
+
from Crypto.Util.Padding import pad
|
|
16
|
+
|
|
17
|
+
from .const import (
|
|
18
|
+
APP_ID,
|
|
19
|
+
APP_SYSTEM_TYPE,
|
|
20
|
+
APP_VERSION,
|
|
21
|
+
DEFAULT_REGION,
|
|
22
|
+
REGIONS,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LandbookAuthError(Exception):
|
|
27
|
+
"""Raised when login fails."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LandbookAPIError(Exception):
|
|
31
|
+
"""Raised when an API call fails."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _region_cfg(region: str) -> dict:
|
|
35
|
+
return REGIONS.get(region, REGIONS[DEFAULT_REGION])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _encrypt_password(password: str) -> tuple[str, str]:
|
|
39
|
+
"""Return (pwd_b64, rand) — AES-128-CBC, matches the Landbook app logic."""
|
|
40
|
+
rand = "".join(random.choices(string.ascii_letters + string.digits, k=16))
|
|
41
|
+
md5_full = hashlib.md5(rand.encode()).hexdigest().upper()
|
|
42
|
+
hash_random = md5_full[8:24]
|
|
43
|
+
iv = hash_random[8:16] + hash_random[0:8]
|
|
44
|
+
|
|
45
|
+
cipher = AES.new(hash_random.encode(), AES.MODE_CBC, iv.encode())
|
|
46
|
+
encrypted = cipher.encrypt(pad(password.encode(), AES.block_size))
|
|
47
|
+
pwd = base64.b64encode(encrypted).decode()
|
|
48
|
+
return pwd, rand
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _default_headers() -> dict[str, str]:
|
|
52
|
+
return {
|
|
53
|
+
"appId": APP_ID,
|
|
54
|
+
"appVersion": APP_VERSION,
|
|
55
|
+
"appSystemType": APP_SYSTEM_TYPE,
|
|
56
|
+
"Accept": "application/json",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def login(email: str, password: str, region: str = DEFAULT_REGION) -> tuple[str, str, str]:
|
|
61
|
+
"""Authenticate and return (bearer_token, uid, refresh_token).
|
|
62
|
+
|
|
63
|
+
The access token is only valid for 2 hours; the refresh token (returned
|
|
64
|
+
alongside it) is what actually renews the session and is valid for 30
|
|
65
|
+
days. Both must be persisted — see refresh_token() below.
|
|
66
|
+
"""
|
|
67
|
+
cfg = _region_cfg(region)
|
|
68
|
+
pwd, rand = _encrypt_password(password)
|
|
69
|
+
|
|
70
|
+
sig = hashlib.sha256(
|
|
71
|
+
(email + pwd + rand + cfg["app_domain_key"]).encode()
|
|
72
|
+
).hexdigest()
|
|
73
|
+
|
|
74
|
+
data = urllib.parse.urlencode(
|
|
75
|
+
{
|
|
76
|
+
"email": email,
|
|
77
|
+
"pwd": pwd,
|
|
78
|
+
"random": rand,
|
|
79
|
+
"userDomain": cfg["user_domain"],
|
|
80
|
+
"signature": sig,
|
|
81
|
+
}
|
|
82
|
+
).encode()
|
|
83
|
+
|
|
84
|
+
login_url = cfg["api_base"] + "/v2/enduser/enduserapi/emailPwdLogin"
|
|
85
|
+
headers = {**_default_headers(), "Content-Type": "application/x-www-form-urlencoded"}
|
|
86
|
+
req = urllib.request.Request(login_url, data=data, headers=headers)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
resp = json.loads(urllib.request.urlopen(req).read())
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
raise LandbookAuthError(f"Login request failed: {exc}") from exc
|
|
92
|
+
|
|
93
|
+
if resp.get("code") != 200 or "data" not in resp:
|
|
94
|
+
raise LandbookAuthError(f"Login failed: {resp.get('msg', 'unknown error')}")
|
|
95
|
+
|
|
96
|
+
bearer_token: str = resp["data"]["accessToken"]["token"]
|
|
97
|
+
refresh_token_value: str = resp["data"]["refreshToken"]["token"]
|
|
98
|
+
rest_token = bearer_token.replace("Bearer ", "")
|
|
99
|
+
payload_b64 = rest_token.split(".")[1]
|
|
100
|
+
uid: str = json.loads(base64.b64decode(payload_b64 + "=="))["uid"]
|
|
101
|
+
|
|
102
|
+
return bearer_token, uid, refresh_token_value
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _api_get(bearer_token: str, api_base: str, path: str, params: dict | None = None) -> Any:
|
|
106
|
+
url = api_base + path
|
|
107
|
+
if params:
|
|
108
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
109
|
+
headers = {**_default_headers(), "Authorization": bearer_token}
|
|
110
|
+
req = urllib.request.Request(url, headers=headers)
|
|
111
|
+
try:
|
|
112
|
+
resp = json.loads(urllib.request.urlopen(req).read())
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
raise LandbookAPIError(f"API call to {path} failed: {exc}") from exc
|
|
115
|
+
if resp.get("code") != 200:
|
|
116
|
+
raise LandbookAPIError(f"API error on {path}: {resp.get('msg', 'unknown')}")
|
|
117
|
+
return resp["data"]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def get_device_list(bearer_token: str, region: str = DEFAULT_REGION) -> list[dict]:
|
|
121
|
+
api_base = _region_cfg(region)["api_base"]
|
|
122
|
+
data = _api_get(bearer_token, api_base, "/v2/binding/enduserapi/userDeviceList", {"page": 1, "pageSize": 50})
|
|
123
|
+
return data.get("list", [])
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_tsl(bearer_token: str, pk: str, region: str = DEFAULT_REGION) -> list[dict]:
|
|
127
|
+
api_base = _region_cfg(region)["api_base"]
|
|
128
|
+
data = _api_get(bearer_token, api_base, "/v2/binding/enduserapi/productTSL", {"pk": pk})
|
|
129
|
+
properties = [
|
|
130
|
+
p for p in data.get("properties", [])
|
|
131
|
+
if "W" in p.get("subType", "") and p["type"] == "PROPERTY"
|
|
132
|
+
]
|
|
133
|
+
properties.sort(key=lambda p: p.get("sort", 0))
|
|
134
|
+
return properties
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def refresh_token(bearer_token: str, refresh_token_value: str, region: str = DEFAULT_REGION) -> tuple[str, str]:
|
|
138
|
+
"""Exchange the refresh token for a new (access_token, refresh_token) pair.
|
|
139
|
+
|
|
140
|
+
The API requires BOTH the current access token as the Authorization
|
|
141
|
+
header AND the full refresh token (including its "Bearer " prefix) as a
|
|
142
|
+
form-encoded `refreshToken` body field — sending only one or the other
|
|
143
|
+
is rejected. The refresh token itself rotates on every successful call,
|
|
144
|
+
so the new one returned here must be persisted too, not just the access
|
|
145
|
+
token.
|
|
146
|
+
"""
|
|
147
|
+
api_base = _region_cfg(region)["api_base"]
|
|
148
|
+
headers = {
|
|
149
|
+
**_default_headers(),
|
|
150
|
+
"Authorization": bearer_token,
|
|
151
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
152
|
+
}
|
|
153
|
+
data = urllib.parse.urlencode({"refreshToken": refresh_token_value}).encode()
|
|
154
|
+
req = urllib.request.Request(
|
|
155
|
+
api_base + "/v2/enduser/enduserapi/refreshToken",
|
|
156
|
+
data=data,
|
|
157
|
+
headers=headers,
|
|
158
|
+
method="PUT",
|
|
159
|
+
)
|
|
160
|
+
try:
|
|
161
|
+
resp = json.loads(urllib.request.urlopen(req).read())
|
|
162
|
+
except Exception as exc:
|
|
163
|
+
raise LandbookAPIError(f"Token refresh request failed: {exc}") from exc
|
|
164
|
+
|
|
165
|
+
if resp.get("code") != 200 or "data" not in resp:
|
|
166
|
+
raise LandbookAuthError(f"Token refresh rejected: {resp.get('msg', 'unknown error')}")
|
|
167
|
+
return resp["data"]["accessToken"]["token"], resp["data"]["refreshToken"]["token"]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_device_attributes(bearer_token: str, pk: str, dk: str, region: str = DEFAULT_REGION) -> dict:
|
|
171
|
+
api_base = _region_cfg(region)["api_base"]
|
|
172
|
+
return _api_get(bearer_token, api_base, "/v2/binding/enduserapi/getDeviceBusinessAttributes", {"pk": pk, "dk": dk})
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def async_login(email: str, password: str, region: str = DEFAULT_REGION) -> tuple[str, str, str]:
|
|
176
|
+
loop = asyncio.get_event_loop()
|
|
177
|
+
return await loop.run_in_executor(None, login, email, password, region)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def async_get_device_list(bearer_token: str, region: str = DEFAULT_REGION) -> list[dict]:
|
|
181
|
+
loop = asyncio.get_event_loop()
|
|
182
|
+
return await loop.run_in_executor(None, get_device_list, bearer_token, region)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def async_get_tsl(bearer_token: str, pk: str, region: str = DEFAULT_REGION) -> list[dict]:
|
|
186
|
+
loop = asyncio.get_event_loop()
|
|
187
|
+
return await loop.run_in_executor(None, get_tsl, bearer_token, pk, region)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def async_refresh_token(
|
|
191
|
+
bearer_token: str, refresh_token_value: str, region: str = DEFAULT_REGION
|
|
192
|
+
) -> tuple[str, str]:
|
|
193
|
+
loop = asyncio.get_event_loop()
|
|
194
|
+
return await loop.run_in_executor(None, refresh_token, bearer_token, refresh_token_value, region)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def async_get_device_attributes(bearer_token: str, pk: str, dk: str, region: str = DEFAULT_REGION) -> dict:
|
|
198
|
+
loop = asyncio.get_event_loop()
|
|
199
|
+
return await loop.run_in_executor(None, get_device_attributes, bearer_token, pk, dk, region)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Protocol constants for the Landbook (Netprisma/Landecia) cloud API."""
|
|
2
|
+
|
|
3
|
+
APP_ID = "584"
|
|
4
|
+
APP_VERSION = "3.6.0"
|
|
5
|
+
APP_SYSTEM_TYPE = "ios"
|
|
6
|
+
|
|
7
|
+
MQTT_PORT = 8443
|
|
8
|
+
MQTT_WS_PATH = "/ws/v2"
|
|
9
|
+
MQTT_KEEPALIVE = 40
|
|
10
|
+
|
|
11
|
+
# Region definitions — sourced from ad0.java (i=0 CN, i=1 EU, i=2 US)
|
|
12
|
+
REGIONS: dict[str, dict] = {
|
|
13
|
+
"us": {
|
|
14
|
+
"label": "United States",
|
|
15
|
+
"api_base": "https://iot-api.quectelus.com",
|
|
16
|
+
"mqtt_host": "iot-south.landecia.com",
|
|
17
|
+
"user_domain": "U.SP.8589934603",
|
|
18
|
+
"app_domain_key": "pUTp5goB1bLinprRQMmK3EPiiuPiGrJtKUNptWRXVmP",
|
|
19
|
+
},
|
|
20
|
+
"eu": {
|
|
21
|
+
"label": "Europe",
|
|
22
|
+
"api_base": "https://iot-api.quecteleu.com",
|
|
23
|
+
"mqtt_host": "iot-south.quecteleu.com",
|
|
24
|
+
"user_domain": "E.SP.4294967410",
|
|
25
|
+
"app_domain_key": "3aRNUwWahjyANa7WfBK2wCCkxCexB6nXxKJwXxfePvzf",
|
|
26
|
+
},
|
|
27
|
+
"cn": {
|
|
28
|
+
"label": "China",
|
|
29
|
+
"api_base": "https://iot-gateway.quectel.com",
|
|
30
|
+
"mqtt_host": "iot-south.quectelcn.com",
|
|
31
|
+
"user_domain": "C.DM.5903.1",
|
|
32
|
+
"app_domain_key": "EufftRJSuWuVY7c6txzGifV9bJcfXHAFa7hXY5doXSn7",
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
DEFAULT_REGION = "us"
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Landbook MQTT client — WebSocket/TLS connection, pub/sub."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import paho.mqtt.client as mqtt
|
|
12
|
+
|
|
13
|
+
from .const import MQTT_KEEPALIVE, MQTT_PORT, MQTT_WS_PATH, REGIONS, DEFAULT_REGION
|
|
14
|
+
|
|
15
|
+
_LOGGER = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LandbookMQTTClient:
|
|
19
|
+
"""Manages a single persistent MQTT connection for one account."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
uid: str,
|
|
24
|
+
bearer_token: str,
|
|
25
|
+
mqtt_host: str | None = None,
|
|
26
|
+
token_refresher: Callable[[], str] | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
self._uid = uid
|
|
29
|
+
self._bearer_token = bearer_token
|
|
30
|
+
self._mqtt_host = mqtt_host or REGIONS[DEFAULT_REGION]["mqtt_host"]
|
|
31
|
+
self._token_refresher = token_refresher
|
|
32
|
+
self._client: mqtt.Client | None = None
|
|
33
|
+
self._connected = False
|
|
34
|
+
self._shutting_down = False
|
|
35
|
+
self._reauth_pending = False
|
|
36
|
+
self._msg_counter = 1000
|
|
37
|
+
self._reconnect_timer: threading.Timer | None = None
|
|
38
|
+
|
|
39
|
+
# device_id -> list of callbacks
|
|
40
|
+
self._listeners: dict[str, list[Callable[[str, Any], None]]] = {}
|
|
41
|
+
# called after (re)connect to refresh state
|
|
42
|
+
self._on_reconnect: Callable[[], None] | None = None
|
|
43
|
+
|
|
44
|
+
# ------------------------------------------------------------------
|
|
45
|
+
# Public API
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def connect(self) -> None:
|
|
49
|
+
"""Establish the WebSocket/TLS MQTT connection (blocking until connected or timeout)."""
|
|
50
|
+
client_id = f"qu_{self._uid}_{int(time.time() * 1000)}"
|
|
51
|
+
client = mqtt.Client(
|
|
52
|
+
mqtt.CallbackAPIVersion.VERSION2,
|
|
53
|
+
client_id=client_id,
|
|
54
|
+
transport="websockets",
|
|
55
|
+
protocol=mqtt.MQTTv311,
|
|
56
|
+
)
|
|
57
|
+
client.ws_set_options(path=MQTT_WS_PATH)
|
|
58
|
+
client.tls_set()
|
|
59
|
+
client.username_pw_set("", self._bearer_token)
|
|
60
|
+
client.on_connect = self._on_connect
|
|
61
|
+
client.on_message = self._on_message
|
|
62
|
+
client.on_disconnect = self._on_disconnect
|
|
63
|
+
|
|
64
|
+
client.connect(self._mqtt_host, MQTT_PORT, keepalive=MQTT_KEEPALIVE)
|
|
65
|
+
client.loop_start()
|
|
66
|
+
self._client = client
|
|
67
|
+
|
|
68
|
+
for _ in range(40):
|
|
69
|
+
if self._connected:
|
|
70
|
+
break
|
|
71
|
+
time.sleep(0.25)
|
|
72
|
+
|
|
73
|
+
if not self._connected:
|
|
74
|
+
raise ConnectionError("MQTT connection timed out")
|
|
75
|
+
|
|
76
|
+
def update_token(self, bearer_token: str) -> None:
|
|
77
|
+
"""Update the stored token (e.g. after a refresh)."""
|
|
78
|
+
self._bearer_token = bearer_token
|
|
79
|
+
|
|
80
|
+
def halt_reconnects(self) -> None:
|
|
81
|
+
"""Stop all reconnect attempts (e.g. while reauth is pending)."""
|
|
82
|
+
self._reauth_pending = True
|
|
83
|
+
if self._reconnect_timer:
|
|
84
|
+
self._reconnect_timer.cancel()
|
|
85
|
+
self._reconnect_timer = None
|
|
86
|
+
|
|
87
|
+
def disconnect(self) -> None:
|
|
88
|
+
self._shutting_down = True
|
|
89
|
+
if self._reconnect_timer:
|
|
90
|
+
self._reconnect_timer.cancel()
|
|
91
|
+
self._reconnect_timer = None
|
|
92
|
+
if self._client:
|
|
93
|
+
self._client.loop_stop()
|
|
94
|
+
self._client.disconnect()
|
|
95
|
+
self._client = None
|
|
96
|
+
self._connected = False
|
|
97
|
+
|
|
98
|
+
def subscribe_device(
|
|
99
|
+
self,
|
|
100
|
+
device_id: str,
|
|
101
|
+
callback: Callable[[str, Any], None],
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Subscribe to all topics for a device and register a callback.
|
|
104
|
+
|
|
105
|
+
callback(topic_suffix, payload_dict)
|
|
106
|
+
"""
|
|
107
|
+
if device_id not in self._listeners:
|
|
108
|
+
self._listeners[device_id] = []
|
|
109
|
+
if self._client and self._connected:
|
|
110
|
+
self._subscribe_topics(device_id)
|
|
111
|
+
|
|
112
|
+
self._listeners[device_id].append(callback)
|
|
113
|
+
|
|
114
|
+
def send_read(self, device_id: str, pk: str, dk: str, codes: list[str]) -> None:
|
|
115
|
+
"""Request current values for the given property codes (READ-ATTR)."""
|
|
116
|
+
if not self._client or not self._connected:
|
|
117
|
+
return
|
|
118
|
+
self._msg_counter = (self._msg_counter + 1) & 0xFFFF
|
|
119
|
+
payload = json.dumps(
|
|
120
|
+
{
|
|
121
|
+
"msgId": self._msg_counter,
|
|
122
|
+
"productKey": pk,
|
|
123
|
+
"deviceKey": dk,
|
|
124
|
+
"type": "READ-ATTR",
|
|
125
|
+
"kv": json.dumps(codes),
|
|
126
|
+
"cacheTime": 0,
|
|
127
|
+
"isCache": False,
|
|
128
|
+
"isCover": False,
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
self._client.publish(f"q/1/d/{device_id}/sys_", payload, qos=1)
|
|
132
|
+
_LOGGER.debug("send_read device=%s codes=%s", device_id, codes)
|
|
133
|
+
|
|
134
|
+
def send_write(self, device_id: str, pk: str, dk: str, props: dict) -> None:
|
|
135
|
+
"""Publish a WRITE-ATTR command."""
|
|
136
|
+
if not self._client or not self._connected:
|
|
137
|
+
raise ConnectionError("MQTT not connected")
|
|
138
|
+
|
|
139
|
+
self._msg_counter = (self._msg_counter + 1) & 0xFFFF
|
|
140
|
+
payload = json.dumps(
|
|
141
|
+
{
|
|
142
|
+
"msgId": self._msg_counter,
|
|
143
|
+
"productKey": pk,
|
|
144
|
+
"deviceKey": dk,
|
|
145
|
+
"type": "WRITE-ATTR",
|
|
146
|
+
"kv": json.dumps([props]),
|
|
147
|
+
"cacheTime": 0,
|
|
148
|
+
"isCache": False,
|
|
149
|
+
"isCover": False,
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
self._client.publish(f"q/1/d/{device_id}/sys_", payload, qos=1)
|
|
153
|
+
_LOGGER.debug("send_write device=%s props=%s", device_id, props)
|
|
154
|
+
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
# Internal
|
|
157
|
+
# ------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def _subscribe_topics(self, device_id: str) -> None:
|
|
160
|
+
assert self._client
|
|
161
|
+
# Subscribe to all known downstream topics for this device
|
|
162
|
+
for suffix in ("ack_", "bus_", "onl_", "ota_", "inf_", "loc_"):
|
|
163
|
+
self._client.subscribe(f"q/2/d/{device_id}/{suffix}", qos=1)
|
|
164
|
+
|
|
165
|
+
def _on_connect(self, client, userdata, flags, reason_code, properties):
|
|
166
|
+
if str(reason_code) in ("Success", "0") or reason_code == 0:
|
|
167
|
+
self._connected = True
|
|
168
|
+
for device_id in self._listeners:
|
|
169
|
+
self._subscribe_topics(device_id)
|
|
170
|
+
_LOGGER.info("Landbook MQTT connected")
|
|
171
|
+
if self._on_reconnect:
|
|
172
|
+
self._on_reconnect()
|
|
173
|
+
else:
|
|
174
|
+
_LOGGER.error("Landbook MQTT connect failed: %s", reason_code)
|
|
175
|
+
|
|
176
|
+
def _on_disconnect(self, client, userdata, disconnect_flags, reason_code, properties):
|
|
177
|
+
self._connected = False
|
|
178
|
+
if self._shutting_down:
|
|
179
|
+
_LOGGER.debug("Landbook MQTT disconnected cleanly (unload)")
|
|
180
|
+
return
|
|
181
|
+
if self._reauth_pending:
|
|
182
|
+
_LOGGER.debug("Landbook MQTT disconnected while reauth pending — not reconnecting")
|
|
183
|
+
return
|
|
184
|
+
_LOGGER.warning("Landbook MQTT disconnected: %s — scheduling reconnect", reason_code)
|
|
185
|
+
self._schedule_reconnect(delay=5)
|
|
186
|
+
|
|
187
|
+
def _schedule_reconnect(self, delay: float = 5) -> None:
|
|
188
|
+
if self._reconnect_timer:
|
|
189
|
+
self._reconnect_timer.cancel()
|
|
190
|
+
self._reconnect_timer = threading.Timer(delay, self._reconnect)
|
|
191
|
+
self._reconnect_timer.daemon = True
|
|
192
|
+
self._reconnect_timer.start()
|
|
193
|
+
|
|
194
|
+
def _reconnect(self) -> None:
|
|
195
|
+
if self._reauth_pending:
|
|
196
|
+
return
|
|
197
|
+
_LOGGER.info("Landbook MQTT attempting reconnect")
|
|
198
|
+
try:
|
|
199
|
+
if self._token_refresher:
|
|
200
|
+
self._bearer_token = self._token_refresher()
|
|
201
|
+
_LOGGER.debug("Token refreshed before reconnect")
|
|
202
|
+
except Exception as exc:
|
|
203
|
+
_LOGGER.warning("Token refresh failed, reconnecting with old token: %s", exc)
|
|
204
|
+
if self._reauth_pending:
|
|
205
|
+
return
|
|
206
|
+
try:
|
|
207
|
+
if self._client:
|
|
208
|
+
self._client.loop_stop()
|
|
209
|
+
self.connect()
|
|
210
|
+
except Exception as exc:
|
|
211
|
+
_LOGGER.warning("Reconnect failed: %s — will retry in 30s", exc)
|
|
212
|
+
self._schedule_reconnect(delay=30)
|
|
213
|
+
|
|
214
|
+
def _on_message(self, client, userdata, msg: mqtt.MQTTMessage) -> None:
|
|
215
|
+
parts = msg.topic.split("/")
|
|
216
|
+
# topic format: q/2/d/{device_id}/{suffix}
|
|
217
|
+
if len(parts) < 5:
|
|
218
|
+
return
|
|
219
|
+
device_id = parts[3]
|
|
220
|
+
suffix = parts[4]
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
payload = json.loads(msg.payload.decode())
|
|
224
|
+
except Exception:
|
|
225
|
+
_LOGGER.debug("Non-JSON MQTT payload on %s", msg.topic)
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
_LOGGER.debug("MQTT [%s] %s: %s", device_id, suffix, payload)
|
|
229
|
+
|
|
230
|
+
for cb in self._listeners.get(device_id, []):
|
|
231
|
+
try:
|
|
232
|
+
cb(suffix, payload)
|
|
233
|
+
except Exception:
|
|
234
|
+
_LOGGER.exception("Error in MQTT listener for %s", device_id)
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "landbook-api"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Unofficial Python client for the Landbook (Netprisma/Landecia) smart home cloud API — REST auth/device discovery and MQTT pub/sub."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [{ name = "Zack Wagner" }]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"paho-mqtt>=2.0.0",
|
|
15
|
+
"pycryptodome>=3.0.0",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Topic :: Home Automation",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/zackwag/landbook-api"
|
|
28
|
+
Issues = "https://github.com/zackwag/landbook-api/issues"
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
test = ["pytest>=7.0"]
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["landbook_api"]
|
|
File without changes
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
|
|
3
|
+
from landbook_api import DEFAULT_REGION, REGIONS, LandbookMQTTClient
|
|
4
|
+
from landbook_api.api import _encrypt_password, _region_cfg
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_regions_have_required_keys():
|
|
8
|
+
required = {"label", "api_base", "mqtt_host", "user_domain", "app_domain_key"}
|
|
9
|
+
for region, cfg in REGIONS.items():
|
|
10
|
+
assert required <= cfg.keys(), f"region {region} missing keys"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_default_region_is_valid():
|
|
14
|
+
assert DEFAULT_REGION in REGIONS
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_region_cfg_falls_back_to_default_for_unknown_region():
|
|
18
|
+
assert _region_cfg("does-not-exist") == REGIONS[DEFAULT_REGION]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_region_cfg_returns_requested_region():
|
|
22
|
+
assert _region_cfg("eu") == REGIONS["eu"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_encrypt_password_returns_base64_ciphertext_and_16_char_rand():
|
|
26
|
+
pwd_b64, rand = _encrypt_password("hunter2")
|
|
27
|
+
assert len(rand) == 16
|
|
28
|
+
# Should round-trip through base64 without error.
|
|
29
|
+
base64.b64decode(pwd_b64)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_encrypt_password_uses_random_salt_each_call():
|
|
33
|
+
pwd_a, rand_a = _encrypt_password("hunter2")
|
|
34
|
+
pwd_b, rand_b = _encrypt_password("hunter2")
|
|
35
|
+
assert rand_a != rand_b
|
|
36
|
+
assert pwd_a != pwd_b
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_mqtt_client_can_be_constructed_without_connecting():
|
|
40
|
+
client = LandbookMQTTClient(uid="u1", bearer_token="Bearer abc")
|
|
41
|
+
assert client._mqtt_host == REGIONS[DEFAULT_REGION]["mqtt_host"]
|