tuya-ble-sdk 0.1.1__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.
- tuya_ble_sdk-0.1.1/.github/dependabot.yml +33 -0
- tuya_ble_sdk-0.1.1/.github/workflows/ci.yml +29 -0
- tuya_ble_sdk-0.1.1/.github/workflows/codeql.yml +18 -0
- tuya_ble_sdk-0.1.1/.github/workflows/release.yml +62 -0
- tuya_ble_sdk-0.1.1/.gitignore +10 -0
- tuya_ble_sdk-0.1.1/.pre-commit-config.yaml +26 -0
- tuya_ble_sdk-0.1.1/.release-please-manifest.json +3 -0
- tuya_ble_sdk-0.1.1/CHANGELOG.md +33 -0
- tuya_ble_sdk-0.1.1/LICENSE +21 -0
- tuya_ble_sdk-0.1.1/PKG-INFO +111 -0
- tuya_ble_sdk-0.1.1/README.md +84 -0
- tuya_ble_sdk-0.1.1/pyproject.toml +140 -0
- tuya_ble_sdk-0.1.1/release-please-config.json +67 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/__init__.py +45 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/advertisement.py +75 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/cli.py +160 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/client.py +353 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/commands/__init__.py +18 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/commands/data_points.py +111 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/commands/device_info.py +27 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/commands/pair.py +51 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/commands/time_reply.py +38 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/crypto.py +87 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/exceptions/__init__.py +17 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/exceptions/tuya_ble_authentication_error.py +14 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/exceptions/tuya_ble_connection_error.py +9 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/exceptions/tuya_ble_error.py +12 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/exceptions/tuya_ble_handshake_timeout_error.py +18 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/exceptions/tuya_ble_protocol_error.py +14 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/models/__init__.py +16 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/models/advertisement_info.py +22 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/models/data_point.py +26 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/models/device_info.py +25 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/models/tuya_ble_credentials.py +21 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/protocol/__init__.py +50 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/protocol/command_code.py +36 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/protocol/constants.py +23 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/protocol/data_point_type.py +16 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/protocol/frame.py +154 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/protocol/reassembler.py +83 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/protocol/varint.py +39 -0
- tuya_ble_sdk-0.1.1/src/tuya_ble_sdk/py.typed +0 -0
- tuya_ble_sdk-0.1.1/uv.lock +1228 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
|
2
|
+
version: 2
|
|
3
|
+
updates:
|
|
4
|
+
- package-ecosystem: "github-actions"
|
|
5
|
+
directory: "/"
|
|
6
|
+
schedule:
|
|
7
|
+
interval: "weekly"
|
|
8
|
+
day: "monday"
|
|
9
|
+
time: "09:00"
|
|
10
|
+
timezone: "America/Sao_Paulo"
|
|
11
|
+
groups:
|
|
12
|
+
github-actions:
|
|
13
|
+
patterns:
|
|
14
|
+
- "*"
|
|
15
|
+
commit-message:
|
|
16
|
+
prefix: "deps"
|
|
17
|
+
include: "scope"
|
|
18
|
+
|
|
19
|
+
- package-ecosystem: "uv"
|
|
20
|
+
directory: "/"
|
|
21
|
+
schedule:
|
|
22
|
+
interval: "weekly"
|
|
23
|
+
day: "monday"
|
|
24
|
+
time: "09:00"
|
|
25
|
+
timezone: "America/Sao_Paulo"
|
|
26
|
+
groups:
|
|
27
|
+
python-deps:
|
|
28
|
+
patterns:
|
|
29
|
+
- "*"
|
|
30
|
+
commit-message:
|
|
31
|
+
prefix: "deps"
|
|
32
|
+
prefix-development: "deps-dev"
|
|
33
|
+
include: "scope"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: ci-${{ github.ref }}
|
|
10
|
+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
lint:
|
|
17
|
+
uses: roquerodrigo/workflows/.github/workflows/python-lint.yml@main
|
|
18
|
+
|
|
19
|
+
tests:
|
|
20
|
+
needs: lint
|
|
21
|
+
uses: roquerodrigo/workflows/.github/workflows/python-test.yml@main
|
|
22
|
+
|
|
23
|
+
update-pr-branch:
|
|
24
|
+
if: github.event_name == 'pull_request'
|
|
25
|
+
needs: [lint, tests]
|
|
26
|
+
permissions:
|
|
27
|
+
contents: write
|
|
28
|
+
pull-requests: write
|
|
29
|
+
uses: roquerodrigo/workflows/.github/workflows/update-pr-branch.yml@main
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: CodeQL
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
schedule:
|
|
8
|
+
- cron: "0 0 * * 0"
|
|
9
|
+
|
|
10
|
+
permissions: {}
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
codeql:
|
|
14
|
+
permissions:
|
|
15
|
+
actions: read
|
|
16
|
+
contents: read
|
|
17
|
+
security-events: write
|
|
18
|
+
uses: roquerodrigo/workflows/.github/workflows/codeql.yml@main
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# Three composable stages: release-please grooms the release pull request and
|
|
4
|
+
# tags it, the lock is refreshed on the release branch so it lands carrying the
|
|
5
|
+
# same version, and the tag is published to PyPI.
|
|
6
|
+
#
|
|
7
|
+
# The `workflow_run.event == 'push'` condition is what stops a pull request with
|
|
8
|
+
# green CI from cutting a release.
|
|
9
|
+
|
|
10
|
+
on:
|
|
11
|
+
workflow_run:
|
|
12
|
+
workflows: [CI]
|
|
13
|
+
types: [completed]
|
|
14
|
+
branches: [main]
|
|
15
|
+
workflow_dispatch:
|
|
16
|
+
inputs:
|
|
17
|
+
tag:
|
|
18
|
+
description: "Tag to republish to PyPI, for example v1.1.0. Skips release-please."
|
|
19
|
+
required: true
|
|
20
|
+
type: string
|
|
21
|
+
|
|
22
|
+
permissions: {}
|
|
23
|
+
|
|
24
|
+
jobs:
|
|
25
|
+
release:
|
|
26
|
+
if: >-
|
|
27
|
+
github.event_name == 'workflow_run' &&
|
|
28
|
+
github.event.workflow_run.event == 'push' &&
|
|
29
|
+
github.event.workflow_run.conclusion == 'success'
|
|
30
|
+
permissions:
|
|
31
|
+
contents: write
|
|
32
|
+
pull-requests: write
|
|
33
|
+
uses: roquerodrigo/workflows/.github/workflows/release-please.yml@main
|
|
34
|
+
secrets:
|
|
35
|
+
release-token: ${{ secrets.RELEASE_PLEASE_PAT }}
|
|
36
|
+
|
|
37
|
+
sync-uv-lock:
|
|
38
|
+
needs: release
|
|
39
|
+
if: needs.release.outputs.release-pr != ''
|
|
40
|
+
permissions:
|
|
41
|
+
contents: read
|
|
42
|
+
uses: roquerodrigo/workflows/.github/workflows/sync-uv-lock.yml@main
|
|
43
|
+
with:
|
|
44
|
+
release-pr: ${{ needs.release.outputs.release-pr }}
|
|
45
|
+
secrets:
|
|
46
|
+
release-token: ${{ secrets.RELEASE_PLEASE_PAT }}
|
|
47
|
+
|
|
48
|
+
publish:
|
|
49
|
+
needs: release
|
|
50
|
+
if: >-
|
|
51
|
+
always() && (
|
|
52
|
+
github.event_name == 'workflow_dispatch' ||
|
|
53
|
+
needs.release.outputs.release-created == 'true'
|
|
54
|
+
)
|
|
55
|
+
permissions:
|
|
56
|
+
contents: read
|
|
57
|
+
uses: roquerodrigo/workflows/.github/workflows/publish-pypi.yml@main
|
|
58
|
+
with:
|
|
59
|
+
package: tuya-ble-sdk
|
|
60
|
+
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || needs.release.outputs.tag-name }}
|
|
61
|
+
secrets:
|
|
62
|
+
pypi-token: ${{ secrets.PYPI_API_TOKEN }}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Hooks run through `uv run`, so they use the exact ruff and mypy versions
|
|
2
|
+
# pinned in the lint dependency group. A hook that installs its own copy drifts
|
|
3
|
+
# from CI the moment the pin moves.
|
|
4
|
+
repos:
|
|
5
|
+
- repo: local
|
|
6
|
+
hooks:
|
|
7
|
+
- id: ruff-format
|
|
8
|
+
name: ruff format
|
|
9
|
+
entry: uv run ruff format
|
|
10
|
+
language: system
|
|
11
|
+
types_or: [python, pyi]
|
|
12
|
+
require_serial: true
|
|
13
|
+
|
|
14
|
+
- id: ruff-check
|
|
15
|
+
name: ruff check
|
|
16
|
+
entry: uv run ruff check --fix
|
|
17
|
+
language: system
|
|
18
|
+
types_or: [python, pyi]
|
|
19
|
+
require_serial: true
|
|
20
|
+
|
|
21
|
+
- id: mypy
|
|
22
|
+
name: mypy
|
|
23
|
+
entry: uv run mypy
|
|
24
|
+
language: system
|
|
25
|
+
pass_filenames: false
|
|
26
|
+
types: [python]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.1](https://github.com/roquerodrigo/tuya-ble-sdk/compare/v0.1.0...v0.1.1) (2026-08-19)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* implement the Tuya BLE protocol ([f309745](https://github.com/roquerodrigo/tuya-ble-sdk/commit/f309745b2a62b67af4cace38ae7ecfca2b8cf7a4))
|
|
9
|
+
* scaffold the Tuya BLE SDK package ([8242a3e](https://github.com/roquerodrigo/tuya-ble-sdk/commit/8242a3e7bcdccdf62f7b4385aee9d6fa2ca7d757))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
* connect at the first sighting instead of after the whole scan ([a612982](https://github.com/roquerodrigo/tuya-ble-sdk/commit/a612982a51b0b3ca0fe5c2ac7dbfa345215e3993))
|
|
15
|
+
* connect at the first sighting instead of after the whole scan ([b81dd11](https://github.com/roquerodrigo/tuya-ble-sdk/commit/b81dd1180359b44832d506f8071bd85621221b55))
|
|
16
|
+
* hash the advertised product record as broadcast ([be4c892](https://github.com/roquerodrigo/tuya-ble-sdk/commit/be4c892439a50ec4c163f61044a2d8abd811d085))
|
|
17
|
+
* reset the framing state between two sessions of one client ([57059c2](https://github.com/roquerodrigo/tuya-ble-sdk/commit/57059c2b1a23b0517b31704108efcff9b8ac1fe9))
|
|
18
|
+
* treat a report with no datapoint as an answer ([445ad3a](https://github.com/roquerodrigo/tuya-ble-sdk/commit/445ad3ac31905679a2373c89c0b461aac0883e8e))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Documentation
|
|
22
|
+
|
|
23
|
+
* record the unparsed signed datapoint commands ([93db2a9](https://github.com/roquerodrigo/tuya-ble-sdk/commit/93db2a9cfa1530c1ea9e74d6fd6dee1e1df08c94))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Tests
|
|
27
|
+
|
|
28
|
+
* freeze the wire bytes of the handshake commands ([18c6e86](https://github.com/roquerodrigo/tuya-ble-sdk/commit/18c6e863cbeef7d372eb4d33ea2aacd15a774484))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
### Miscellaneous Chores
|
|
32
|
+
|
|
33
|
+
* let release-please own the version ([2f0a12a](https://github.com/roquerodrigo/tuya-ble-sdk/commit/2f0a12a8b9de35f55a59fc8fb119aaf5db4958c0))
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rodrigo Roque @roquerodrigo
|
|
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,111 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tuya-ble-sdk
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Python SDK for Tuya Bluetooth Low Energy devices
|
|
5
|
+
Project-URL: Homepage, https://github.com/roquerodrigo/tuya-ble-sdk
|
|
6
|
+
Project-URL: Repository, https://github.com/roquerodrigo/tuya-ble-sdk
|
|
7
|
+
Project-URL: Issues, https://github.com/roquerodrigo/tuya-ble-sdk/issues
|
|
8
|
+
Author-email: Rodrigo Roque <rodrigogoncalvesroque@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ble,bluetooth,home-automation,sdk,tuya
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Home Automation
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Requires-Dist: bleak-retry-connector>=4.4.0
|
|
22
|
+
Requires-Dist: bleak>=3.0.0
|
|
23
|
+
Requires-Dist: cryptography>=44.0.0
|
|
24
|
+
Provides-Extra: cli
|
|
25
|
+
Requires-Dist: typer>=0.12.0; extra == 'cli'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# tuya-ble-sdk
|
|
29
|
+
|
|
30
|
+
Python SDK for **Tuya Bluetooth Low Energy devices**. It speaks the Tuya BLE
|
|
31
|
+
GATT protocol directly — handshake, session key, encrypted frames and
|
|
32
|
+
datapoints — and knows nothing about Home Assistant.
|
|
33
|
+
|
|
34
|
+
Consumed by the [`ha-tuya-ble`](https://github.com/roquerodrigo/ha-tuya-ble)
|
|
35
|
+
integration, which pins it from `manifest.json`.
|
|
36
|
+
|
|
37
|
+
## What it does
|
|
38
|
+
|
|
39
|
+
One read is one whole session: the client connects, performs the handshake,
|
|
40
|
+
collects the datapoint report and disconnects. Tuya BLE sensors are battery
|
|
41
|
+
powered and only listen for a moment after they advertise, so holding a
|
|
42
|
+
connection open would drain them and occupy a proxy slot for nothing.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from tuya_ble_sdk import TuyaBleClient, TuyaBleCredentials, parse_advertisement
|
|
46
|
+
|
|
47
|
+
info = parse_advertisement(service_data, manufacturer_data)
|
|
48
|
+
client = TuyaBleClient(
|
|
49
|
+
ble_device,
|
|
50
|
+
TuyaBleCredentials(uuid=info.uuid, device_id=device_id, local_key=local_key),
|
|
51
|
+
)
|
|
52
|
+
data_points = await client.async_read_data_points()
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Discovery belongs to the caller: the client takes an already-resolved
|
|
56
|
+
`BLEDevice`, which is what lets Home Assistant hand over a device seen through
|
|
57
|
+
a Bluetooth proxy.
|
|
58
|
+
|
|
59
|
+
`parse_advertisement` reads what the advertisement discloses — every field of
|
|
60
|
+
the result is optional. The uuid is encrypted with the product-id record
|
|
61
|
+
broadcast beside it, so no cloud call is needed to learn it; the readable
|
|
62
|
+
product id, however, is only there on an **unbound** device. One bound to a
|
|
63
|
+
Tuya account broadcasts an obfuscated value in its place: those bytes still
|
|
64
|
+
decrypt the uuid, but they name no product, and the caller has to learn what
|
|
65
|
+
the device is some other way.
|
|
66
|
+
|
|
67
|
+
## Command line
|
|
68
|
+
|
|
69
|
+
The optional `cli` extra installs a `tuya-ble` command:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
uv run --extra cli tuya-ble scan
|
|
73
|
+
uv run --extra cli tuya-ble read \
|
|
74
|
+
--address AA:BB:CC:DD:EE:FF --device-id <id> --local-key <key>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`scan` lists every nearby Tuya BLE device with its product id and uuid; `read`
|
|
78
|
+
runs one session and prints the datapoints it reported.
|
|
79
|
+
|
|
80
|
+
## Not implemented
|
|
81
|
+
|
|
82
|
+
The device may report datapoints in a *signed* form (`0x8004` / `0x8005`)
|
|
83
|
+
instead of the plain one this SDK reads. Those two commands are recognised and
|
|
84
|
+
logged, not parsed: the reference implementation disagrees with itself about
|
|
85
|
+
where the records start inside them, and no device was available to settle it.
|
|
86
|
+
A device that uses them shows up as a read that reports no datapoint, with the
|
|
87
|
+
command name in the debug log.
|
|
88
|
+
|
|
89
|
+
## Development
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
uv sync # create .venv and install dependencies
|
|
93
|
+
uv run ruff format --check .
|
|
94
|
+
uv run ruff check .
|
|
95
|
+
uv run mypy src
|
|
96
|
+
uv run pytest # 90 % coverage gate
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Runtime dependencies carry a `>=` floor and nothing else: Home Assistant pins its
|
|
100
|
+
own transitive dependencies exactly, so an `==` pin here eventually contradicts
|
|
101
|
+
HA's pin and the integration stops installing.
|
|
102
|
+
|
|
103
|
+
## Credits
|
|
104
|
+
|
|
105
|
+
The protocol implementation is derived from
|
|
106
|
+
[`PlusPlus-ua/ha_tuya_ble`](https://github.com/PlusPlus-ua/ha_tuya_ble) (MIT),
|
|
107
|
+
itself based on [`redphx/poc-tuya-ble-fingerbot`](https://github.com/redphx/poc-tuya-ble-fingerbot).
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# tuya-ble-sdk
|
|
2
|
+
|
|
3
|
+
Python SDK for **Tuya Bluetooth Low Energy devices**. It speaks the Tuya BLE
|
|
4
|
+
GATT protocol directly — handshake, session key, encrypted frames and
|
|
5
|
+
datapoints — and knows nothing about Home Assistant.
|
|
6
|
+
|
|
7
|
+
Consumed by the [`ha-tuya-ble`](https://github.com/roquerodrigo/ha-tuya-ble)
|
|
8
|
+
integration, which pins it from `manifest.json`.
|
|
9
|
+
|
|
10
|
+
## What it does
|
|
11
|
+
|
|
12
|
+
One read is one whole session: the client connects, performs the handshake,
|
|
13
|
+
collects the datapoint report and disconnects. Tuya BLE sensors are battery
|
|
14
|
+
powered and only listen for a moment after they advertise, so holding a
|
|
15
|
+
connection open would drain them and occupy a proxy slot for nothing.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from tuya_ble_sdk import TuyaBleClient, TuyaBleCredentials, parse_advertisement
|
|
19
|
+
|
|
20
|
+
info = parse_advertisement(service_data, manufacturer_data)
|
|
21
|
+
client = TuyaBleClient(
|
|
22
|
+
ble_device,
|
|
23
|
+
TuyaBleCredentials(uuid=info.uuid, device_id=device_id, local_key=local_key),
|
|
24
|
+
)
|
|
25
|
+
data_points = await client.async_read_data_points()
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Discovery belongs to the caller: the client takes an already-resolved
|
|
29
|
+
`BLEDevice`, which is what lets Home Assistant hand over a device seen through
|
|
30
|
+
a Bluetooth proxy.
|
|
31
|
+
|
|
32
|
+
`parse_advertisement` reads what the advertisement discloses — every field of
|
|
33
|
+
the result is optional. The uuid is encrypted with the product-id record
|
|
34
|
+
broadcast beside it, so no cloud call is needed to learn it; the readable
|
|
35
|
+
product id, however, is only there on an **unbound** device. One bound to a
|
|
36
|
+
Tuya account broadcasts an obfuscated value in its place: those bytes still
|
|
37
|
+
decrypt the uuid, but they name no product, and the caller has to learn what
|
|
38
|
+
the device is some other way.
|
|
39
|
+
|
|
40
|
+
## Command line
|
|
41
|
+
|
|
42
|
+
The optional `cli` extra installs a `tuya-ble` command:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv run --extra cli tuya-ble scan
|
|
46
|
+
uv run --extra cli tuya-ble read \
|
|
47
|
+
--address AA:BB:CC:DD:EE:FF --device-id <id> --local-key <key>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`scan` lists every nearby Tuya BLE device with its product id and uuid; `read`
|
|
51
|
+
runs one session and prints the datapoints it reported.
|
|
52
|
+
|
|
53
|
+
## Not implemented
|
|
54
|
+
|
|
55
|
+
The device may report datapoints in a *signed* form (`0x8004` / `0x8005`)
|
|
56
|
+
instead of the plain one this SDK reads. Those two commands are recognised and
|
|
57
|
+
logged, not parsed: the reference implementation disagrees with itself about
|
|
58
|
+
where the records start inside them, and no device was available to settle it.
|
|
59
|
+
A device that uses them shows up as a read that reports no datapoint, with the
|
|
60
|
+
command name in the debug log.
|
|
61
|
+
|
|
62
|
+
## Development
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
uv sync # create .venv and install dependencies
|
|
66
|
+
uv run ruff format --check .
|
|
67
|
+
uv run ruff check .
|
|
68
|
+
uv run mypy src
|
|
69
|
+
uv run pytest # 90 % coverage gate
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Runtime dependencies carry a `>=` floor and nothing else: Home Assistant pins its
|
|
73
|
+
own transitive dependencies exactly, so an `==` pin here eventually contradicts
|
|
74
|
+
HA's pin and the integration stops installing.
|
|
75
|
+
|
|
76
|
+
## Credits
|
|
77
|
+
|
|
78
|
+
The protocol implementation is derived from
|
|
79
|
+
[`PlusPlus-ua/ha_tuya_ble`](https://github.com/PlusPlus-ua/ha_tuya_ble) (MIT),
|
|
80
|
+
itself based on [`redphx/poc-tuya-ble-fingerbot`](https://github.com/redphx/poc-tuya-ble-fingerbot).
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tuya-ble-sdk"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Python SDK for Tuya Bluetooth Low Energy devices"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Rodrigo Roque", email = "rodrigogoncalvesroque@gmail.com" }]
|
|
9
|
+
keywords = ["tuya", "bluetooth", "ble", "sdk", "home-automation"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Programming Language :: Python :: 3.13",
|
|
17
|
+
"Programming Language :: Python :: 3.14",
|
|
18
|
+
"Topic :: Home Automation",
|
|
19
|
+
]
|
|
20
|
+
# Runtime dependencies carry a floor and nothing else. Home Assistant pins its
|
|
21
|
+
# own transitive dependencies exactly, so an `==` pin — or an upper bound —
|
|
22
|
+
# eventually contradicts HA's pin and the consuming integration stops
|
|
23
|
+
# installing. Exact versions belong in the dependency groups below and in
|
|
24
|
+
# uv.lock, neither of which reaches the consumer.
|
|
25
|
+
dependencies = [
|
|
26
|
+
"bleak>=3.0.0",
|
|
27
|
+
"bleak-retry-connector>=4.4.0",
|
|
28
|
+
"cryptography>=44.0.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
cli = [
|
|
33
|
+
"typer>=0.12.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
tuya-ble = "tuya_ble_sdk.cli:app"
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://github.com/roquerodrigo/tuya-ble-sdk"
|
|
41
|
+
Repository = "https://github.com/roquerodrigo/tuya-ble-sdk"
|
|
42
|
+
Issues = "https://github.com/roquerodrigo/tuya-ble-sdk/issues"
|
|
43
|
+
|
|
44
|
+
[build-system]
|
|
45
|
+
requires = ["hatchling"]
|
|
46
|
+
build-backend = "hatchling.build"
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.wheel]
|
|
49
|
+
packages = ["src/tuya_ble_sdk"]
|
|
50
|
+
|
|
51
|
+
[tool.hatch.build.targets.sdist]
|
|
52
|
+
exclude = ["tests"]
|
|
53
|
+
|
|
54
|
+
[dependency-groups]
|
|
55
|
+
dev = [
|
|
56
|
+
"typer==0.20.0",
|
|
57
|
+
"pytest==9.0.3",
|
|
58
|
+
"pytest-asyncio==1.4.0",
|
|
59
|
+
"pytest-cov==7.1.0",
|
|
60
|
+
"pre-commit==4.6.1",
|
|
61
|
+
]
|
|
62
|
+
lint = [
|
|
63
|
+
"ruff==0.16.1",
|
|
64
|
+
"mypy==2.3.0",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
[tool.uv]
|
|
68
|
+
default-groups = ["dev", "lint"]
|
|
69
|
+
|
|
70
|
+
[tool.pytest.ini_options]
|
|
71
|
+
asyncio_mode = "auto"
|
|
72
|
+
testpaths = ["tests"]
|
|
73
|
+
addopts = [
|
|
74
|
+
"--strict-markers",
|
|
75
|
+
"--cov=src/tuya_ble_sdk",
|
|
76
|
+
"--cov-report=term-missing",
|
|
77
|
+
"--cov-fail-under=90",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
[tool.mypy]
|
|
81
|
+
python_version = "3.12"
|
|
82
|
+
files = ["src"]
|
|
83
|
+
strict = true
|
|
84
|
+
warn_unreachable = true
|
|
85
|
+
|
|
86
|
+
[[tool.mypy.overrides]]
|
|
87
|
+
module = "tests.*"
|
|
88
|
+
disallow_untyped_defs = false
|
|
89
|
+
check_untyped_defs = false
|
|
90
|
+
|
|
91
|
+
[tool.ruff]
|
|
92
|
+
target-version = "py312"
|
|
93
|
+
src = ["src", "tests"]
|
|
94
|
+
|
|
95
|
+
[tool.ruff.format]
|
|
96
|
+
exclude = ["*.md"] # ruff 0.16 formats Python blocks in Markdown; docs keep their own layout
|
|
97
|
+
|
|
98
|
+
[tool.ruff.lint]
|
|
99
|
+
select = ["ALL"]
|
|
100
|
+
ignore = [
|
|
101
|
+
"ANN401", # Dynamically typed expressions (typing.Any) are disallowed
|
|
102
|
+
"D203", # no-blank-line-before-class (incompatible with formatter)
|
|
103
|
+
"D212", # multi-line-summary-first-line (incompatible with formatter)
|
|
104
|
+
"COM812", # incompatible with formatter
|
|
105
|
+
"ISC001", # incompatible with formatter
|
|
106
|
+
"CPY001", # copyright headers are not used in this project
|
|
107
|
+
"TID252", # relative imports between same-package siblings are the house style
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
[tool.ruff.lint.flake8-pytest-style]
|
|
111
|
+
fixture-parentheses = false
|
|
112
|
+
|
|
113
|
+
[tool.ruff.lint.pyupgrade]
|
|
114
|
+
keep-runtime-typing = true
|
|
115
|
+
|
|
116
|
+
[tool.ruff.lint.per-file-ignores]
|
|
117
|
+
"src/tuya_ble_sdk/cli.py" = [
|
|
118
|
+
"FBT001", # a boolean-positional flag is the typer idiom
|
|
119
|
+
"FBT003", # so is the boolean positional passed to typer.Option
|
|
120
|
+
"PLR0913", # a command's options are its signature
|
|
121
|
+
"PLR0917",
|
|
122
|
+
]
|
|
123
|
+
"tests/**" = [
|
|
124
|
+
"ARG002", # the fake peripheral mirrors bleak's signature, unused arguments included
|
|
125
|
+
"EM101", # a literal message is fine in a throwaway test double
|
|
126
|
+
"FBT001", # so is a boolean flag mirroring bleak's own signature
|
|
127
|
+
"FBT002",
|
|
128
|
+
"PLC0415", # a local import keeps a helper's dependency next to its only use
|
|
129
|
+
"PT018", # a composite assertion reads better than splitting one fact in two
|
|
130
|
+
"S324", # tests recompute the MD5 the protocol specifies
|
|
131
|
+
"TRY003", # a literal message is fine in a throwaway test double
|
|
132
|
+
"S101", # assert is normal in pytest
|
|
133
|
+
"S105", # hardcoded-password-string is fine for fake test credentials
|
|
134
|
+
"S106", # hardcoded-password-func-arg is fine for fake test credentials
|
|
135
|
+
"D", # no docstrings required in tests
|
|
136
|
+
"ANN", # no type annotations required in tests
|
|
137
|
+
"ARG001", # fixtures are used implicitly by pytest
|
|
138
|
+
"SLF001", # testing internal methods is intentional
|
|
139
|
+
"PLR2004", # magic numbers in test assertions are fine
|
|
140
|
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
|
|
3
|
+
"release-type": "python",
|
|
4
|
+
"include-component-in-tag": false,
|
|
5
|
+
"include-v-in-tag": true,
|
|
6
|
+
"bump-minor-pre-major": true,
|
|
7
|
+
"bump-patch-for-minor-pre-major": true,
|
|
8
|
+
"packages": {
|
|
9
|
+
".": {
|
|
10
|
+
"package-name": "tuya-ble-sdk",
|
|
11
|
+
"changelog-sections": [
|
|
12
|
+
{
|
|
13
|
+
"type": "feat",
|
|
14
|
+
"section": "Features"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"type": "fix",
|
|
18
|
+
"section": "Bug Fixes"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"type": "perf",
|
|
22
|
+
"section": "Performance Improvements"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"type": "revert",
|
|
26
|
+
"section": "Reverts"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"type": "refactor",
|
|
30
|
+
"section": "Code Refactoring"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"type": "deps",
|
|
34
|
+
"section": "Dependencies"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"type": "deps-dev",
|
|
38
|
+
"section": "Development Dependencies"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"type": "docs",
|
|
42
|
+
"section": "Documentation"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"type": "build",
|
|
46
|
+
"section": "Build System"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"type": "ci",
|
|
50
|
+
"section": "Continuous Integration"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"type": "test",
|
|
54
|
+
"section": "Tests"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"type": "style",
|
|
58
|
+
"section": "Styles"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"type": "chore",
|
|
62
|
+
"section": "Miscellaneous Chores"
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|