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.
@@ -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,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-07-18
4
+
5
+ - Initial release, extracted from the [landbook-ha](https://github.com/zackwag/landbook-ha) Home Assistant integration (`api.py` and `mqtt_client.py`, previously bundled with the integration code).
@@ -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"]