pybluetti 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.
- pybluetti-0.1.0/.github/workflows/publish.yml +53 -0
- pybluetti-0.1.0/.github/workflows/tests.yml +55 -0
- pybluetti-0.1.0/.gitignore +14 -0
- pybluetti-0.1.0/CHANGELOG.md +5 -0
- pybluetti-0.1.0/LICENSE +21 -0
- pybluetti-0.1.0/PKG-INFO +77 -0
- pybluetti-0.1.0/README.md +49 -0
- pybluetti-0.1.0/pyproject.toml +93 -0
- pybluetti-0.1.0/scripts/lint +6 -0
- pybluetti-0.1.0/scripts/setup +8 -0
- pybluetti-0.1.0/scripts/test +6 -0
- pybluetti-0.1.0/src/pybluetti/__init__.py +31 -0
- pybluetti-0.1.0/src/pybluetti/client.py +105 -0
- pybluetti-0.1.0/src/pybluetti/const.py +18 -0
- pybluetti-0.1.0/src/pybluetti/exceptions.py +18 -0
- pybluetti-0.1.0/src/pybluetti/models.py +14 -0
- pybluetti-0.1.0/src/pybluetti/product_client.py +57 -0
- pybluetti-0.1.0/src/pybluetti/py.typed +0 -0
- pybluetti-0.1.0/src/pybluetti/unify_response.py +23 -0
- pybluetti-0.1.0/src/pybluetti/websocket.py +212 -0
- pybluetti-0.1.0/tests/__init__.py +0 -0
- pybluetti-0.1.0/tests/test_const.py +9 -0
- pybluetti-0.1.0/tests/test_package.py +7 -0
- pybluetti-0.1.0/tests/test_product_client.py +142 -0
- pybluetti-0.1.0/tests/test_unify_response.py +28 -0
- pybluetti-0.1.0/tests/test_websocket.py +358 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions: {}
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
build:
|
|
11
|
+
name: Build distribution
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
permissions:
|
|
14
|
+
contents: read
|
|
15
|
+
steps:
|
|
16
|
+
- name: Checkout the repository
|
|
17
|
+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
18
|
+
with:
|
|
19
|
+
persist-credentials: false
|
|
20
|
+
|
|
21
|
+
- name: Set up Python
|
|
22
|
+
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
|
23
|
+
with:
|
|
24
|
+
python-version: "3.13"
|
|
25
|
+
|
|
26
|
+
- name: Install the build tool
|
|
27
|
+
run: pip install build
|
|
28
|
+
|
|
29
|
+
- name: Build sdist and wheel
|
|
30
|
+
run: python -m build
|
|
31
|
+
|
|
32
|
+
- name: Upload the built distribution
|
|
33
|
+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
34
|
+
with:
|
|
35
|
+
name: dist
|
|
36
|
+
path: dist/
|
|
37
|
+
|
|
38
|
+
publish:
|
|
39
|
+
name: Publish to PyPI (trusted publishing)
|
|
40
|
+
needs: build
|
|
41
|
+
runs-on: ubuntu-latest
|
|
42
|
+
environment: pypi
|
|
43
|
+
permissions:
|
|
44
|
+
id-token: write # required for PyPI trusted publishing (OIDC) - no API token stored anywhere
|
|
45
|
+
steps:
|
|
46
|
+
- name: Download the built distribution
|
|
47
|
+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
48
|
+
with:
|
|
49
|
+
name: dist
|
|
50
|
+
path: dist/
|
|
51
|
+
|
|
52
|
+
- name: Publish to PyPI
|
|
53
|
+
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
pull_request:
|
|
8
|
+
branches:
|
|
9
|
+
- main
|
|
10
|
+
|
|
11
|
+
permissions: {}
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
pytest:
|
|
15
|
+
name: Run test suite
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
strategy:
|
|
18
|
+
matrix:
|
|
19
|
+
python-version: ["3.12", "3.13"]
|
|
20
|
+
steps:
|
|
21
|
+
- name: Checkout the repository
|
|
22
|
+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
23
|
+
with:
|
|
24
|
+
persist-credentials: false
|
|
25
|
+
|
|
26
|
+
- name: Set up Python
|
|
27
|
+
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
|
28
|
+
with:
|
|
29
|
+
python-version: ${{ matrix.python-version }}
|
|
30
|
+
|
|
31
|
+
- name: Install dependencies
|
|
32
|
+
run: scripts/setup
|
|
33
|
+
|
|
34
|
+
- name: Run pytest with coverage
|
|
35
|
+
run: scripts/test
|
|
36
|
+
|
|
37
|
+
lint:
|
|
38
|
+
name: Run ruff
|
|
39
|
+
runs-on: ubuntu-latest
|
|
40
|
+
steps:
|
|
41
|
+
- name: Checkout the repository
|
|
42
|
+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
43
|
+
with:
|
|
44
|
+
persist-credentials: false
|
|
45
|
+
|
|
46
|
+
- name: Set up Python
|
|
47
|
+
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
|
48
|
+
with:
|
|
49
|
+
python-version: "3.13"
|
|
50
|
+
|
|
51
|
+
- name: Install ruff
|
|
52
|
+
run: pip install ruff
|
|
53
|
+
|
|
54
|
+
- name: Run ruff
|
|
55
|
+
run: ruff check .
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Unreleased
|
|
2
|
+
|
|
3
|
+
- Replaced `StompClient`'s blocking `websocket-client` transport (a dedicated daemon thread plus a second dedicated heartbeat thread) with `aiohttp`'s native async websocket client (`ClientSession.ws_connect`). `connect()`/`disconnect()`/`reconnect()` are now coroutines; the receive loop and heartbeat run as `asyncio.Task`s instead of threads. `StompListener` is folded into `StompClient` (no more callback registration to justify a separate object). STOMP protocol framing (`stomper`) and all frame-handling logic/log messages are unchanged. `websocket-client` dropped from dependencies.
|
|
4
|
+
- Migrated the BLUETTI cloud API client from `bluetti-home-assistant`'s `custom_components/bluetti/api/` (plus `model/product.py` and `application_exception.py`): `Bluetti`/`ProductClient` (HTTP), `StompClient`/`StompListener` (websocket push updates), `UserProduct`, `UnifyResponse`, `ApplicationRuntimeException`. Decoupled from Home Assistant - server URLs and an `on_auth_expired` callback are now plain constructor arguments instead of a `hass` object. The websocket transport itself (`websocket-client` on a dedicated thread) is unchanged in this step.
|
|
5
|
+
- Initial repository scaffold: packaging (`pyproject.toml`, `hatchling`), test/lint scripts, CI, MIT license.
|
pybluetti-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 chpego
|
|
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.
|
pybluetti-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pybluetti
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Python client for the BLUETTI cloud API - device discovery, state, and control.
|
|
5
|
+
Project-URL: Homepage, https://github.com/chpego/pybluetti
|
|
6
|
+
Project-URL: Used by, https://github.com/bluetti-official/bluetti-home-assistant
|
|
7
|
+
Project-URL: Issues, https://github.com/chpego/pybluetti/issues
|
|
8
|
+
Author: chpego
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Home Automation
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Requires-Dist: aiohttp>=3.9
|
|
20
|
+
Requires-Dist: pydantic>=2.0
|
|
21
|
+
Requires-Dist: stomper>=0.4
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: aioresponses; extra == 'test'
|
|
24
|
+
Requires-Dist: pytest; extra == 'test'
|
|
25
|
+
Requires-Dist: pytest-asyncio; extra == 'test'
|
|
26
|
+
Requires-Dist: pytest-cov; extra == 'test'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# pybluetti
|
|
30
|
+
|
|
31
|
+
Async Python client for the BLUETTI cloud API - device discovery, state, and control.
|
|
32
|
+
|
|
33
|
+
## Status: fully async, not yet wired up or published
|
|
34
|
+
|
|
35
|
+
This repository is the extraction target for the API client code that used to
|
|
36
|
+
live in
|
|
37
|
+
[`bluetti-home-assistant`](https://github.com/bluetti-official/bluetti-home-assistant)'s
|
|
38
|
+
`custom_components/bluetti/api/`, following the same pattern
|
|
39
|
+
[`pyenphase`](https://github.com/pyenphase/pyenphase) uses for the `enphase_envoy`
|
|
40
|
+
Home Assistant integration: a standalone, independently testable and
|
|
41
|
+
versionable library, decoupled from Home Assistant's own release cycle.
|
|
42
|
+
|
|
43
|
+
The extraction is happening in three steps:
|
|
44
|
+
|
|
45
|
+
1. **Done.** Move the client code here mechanically, decoupled from `hass`
|
|
46
|
+
(server URLs and an `on_auth_expired` callback are passed in as plain
|
|
47
|
+
constructor arguments instead - see `src/pybluetti/`).
|
|
48
|
+
2. **Done.** Replace the blocking `websocket-client` transport (previously
|
|
49
|
+
run on a dedicated thread to keep it out of Home Assistant's event loop)
|
|
50
|
+
with `aiohttp`'s native async websocket client - `src/pybluetti/websocket.py`
|
|
51
|
+
has no threads left. STOMP protocol framing (`stomper`) is unchanged.
|
|
52
|
+
3. *Not started.* Publish to PyPI, and switch `bluetti-home-assistant`'s
|
|
53
|
+
`manifest.json`/imports to depend on this package instead of its own
|
|
54
|
+
in-tree copy.
|
|
55
|
+
|
|
56
|
+
## Why extract it
|
|
57
|
+
|
|
58
|
+
- **Independent testing and versioning**, not tied to Home Assistant's release cadence.
|
|
59
|
+
- **A step toward Home Assistant core inclusion** - core integrations are expected to depend on
|
|
60
|
+
an external library for the actual device/API communication, not embed raw HTTP/websocket
|
|
61
|
+
calls directly in the integration.
|
|
62
|
+
- **Fixed a known gap along the way**: the embedded client used to run the blocking
|
|
63
|
+
`websocket-client` library on a dedicated thread to keep it out of Home Assistant's event
|
|
64
|
+
loop. `pybluetti` is fully async instead, matching `bluetti-home-assistant`'s own
|
|
65
|
+
`quality_scale.yaml` `async-dependency` goal once step 3 wires it up.
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
scripts/setup # install runtime + test dependencies
|
|
71
|
+
scripts/test # run the test suite (100% line coverage enforced)
|
|
72
|
+
scripts/lint # run ruff, auto-fixing what it safely can
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT - see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# pybluetti
|
|
2
|
+
|
|
3
|
+
Async Python client for the BLUETTI cloud API - device discovery, state, and control.
|
|
4
|
+
|
|
5
|
+
## Status: fully async, not yet wired up or published
|
|
6
|
+
|
|
7
|
+
This repository is the extraction target for the API client code that used to
|
|
8
|
+
live in
|
|
9
|
+
[`bluetti-home-assistant`](https://github.com/bluetti-official/bluetti-home-assistant)'s
|
|
10
|
+
`custom_components/bluetti/api/`, following the same pattern
|
|
11
|
+
[`pyenphase`](https://github.com/pyenphase/pyenphase) uses for the `enphase_envoy`
|
|
12
|
+
Home Assistant integration: a standalone, independently testable and
|
|
13
|
+
versionable library, decoupled from Home Assistant's own release cycle.
|
|
14
|
+
|
|
15
|
+
The extraction is happening in three steps:
|
|
16
|
+
|
|
17
|
+
1. **Done.** Move the client code here mechanically, decoupled from `hass`
|
|
18
|
+
(server URLs and an `on_auth_expired` callback are passed in as plain
|
|
19
|
+
constructor arguments instead - see `src/pybluetti/`).
|
|
20
|
+
2. **Done.** Replace the blocking `websocket-client` transport (previously
|
|
21
|
+
run on a dedicated thread to keep it out of Home Assistant's event loop)
|
|
22
|
+
with `aiohttp`'s native async websocket client - `src/pybluetti/websocket.py`
|
|
23
|
+
has no threads left. STOMP protocol framing (`stomper`) is unchanged.
|
|
24
|
+
3. *Not started.* Publish to PyPI, and switch `bluetti-home-assistant`'s
|
|
25
|
+
`manifest.json`/imports to depend on this package instead of its own
|
|
26
|
+
in-tree copy.
|
|
27
|
+
|
|
28
|
+
## Why extract it
|
|
29
|
+
|
|
30
|
+
- **Independent testing and versioning**, not tied to Home Assistant's release cadence.
|
|
31
|
+
- **A step toward Home Assistant core inclusion** - core integrations are expected to depend on
|
|
32
|
+
an external library for the actual device/API communication, not embed raw HTTP/websocket
|
|
33
|
+
calls directly in the integration.
|
|
34
|
+
- **Fixed a known gap along the way**: the embedded client used to run the blocking
|
|
35
|
+
`websocket-client` library on a dedicated thread to keep it out of Home Assistant's event
|
|
36
|
+
loop. `pybluetti` is fully async instead, matching `bluetti-home-assistant`'s own
|
|
37
|
+
`quality_scale.yaml` `async-dependency` goal once step 3 wires it up.
|
|
38
|
+
|
|
39
|
+
## Development
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
scripts/setup # install runtime + test dependencies
|
|
43
|
+
scripts/test # run the test suite (100% line coverage enforced)
|
|
44
|
+
scripts/lint # run ruff, auto-fixing what it safely can
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## License
|
|
48
|
+
|
|
49
|
+
MIT - see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pybluetti"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Async Python client for the BLUETTI cloud API - device discovery, state, and control."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
authors = [{ name = "chpego" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Topic :: Home Automation",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"aiohttp>=3.9",
|
|
24
|
+
"pydantic>=2.0",
|
|
25
|
+
"stomper>=0.4",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/chpego/pybluetti"
|
|
30
|
+
"Used by" = "https://github.com/bluetti-official/bluetti-home-assistant"
|
|
31
|
+
Issues = "https://github.com/chpego/pybluetti/issues"
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
test = [
|
|
35
|
+
"pytest",
|
|
36
|
+
"pytest-asyncio",
|
|
37
|
+
"pytest-cov",
|
|
38
|
+
"aioresponses",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/pybluetti"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
asyncio_mode = "auto"
|
|
46
|
+
|
|
47
|
+
[tool.ruff]
|
|
48
|
+
target-version = "py312"
|
|
49
|
+
|
|
50
|
+
[tool.ruff.lint]
|
|
51
|
+
select = ["ALL"]
|
|
52
|
+
ignore = [
|
|
53
|
+
# Formatter-incompatible / removed rules.
|
|
54
|
+
"D203", # no-blank-line-before-class
|
|
55
|
+
"D212", # multi-line-summary-first-line
|
|
56
|
+
"COM812", # incompatible with formatter
|
|
57
|
+
"ISC001", # incompatible with formatter
|
|
58
|
+
|
|
59
|
+
"D1", # undocumented-* - matches "no comment unless the why is non-obvious"
|
|
60
|
+
"CPY001", # missing-copyright-notice - a top-level LICENSE file covers this
|
|
61
|
+
|
|
62
|
+
# Deliberately NOT bootstrapped away like bluetti-home-assistant's ANN
|
|
63
|
+
# ignores: that codebase predates strict typing and ratchets up over
|
|
64
|
+
# time, but this one starts from zero, and reaching HA core eventually
|
|
65
|
+
# needs "strict-typing: done" - so annotations are required on src/ from
|
|
66
|
+
# the first commit instead of being deferred (see per-file-ignores below
|
|
67
|
+
# for why tests/ is exempt).
|
|
68
|
+
|
|
69
|
+
# The following are carried over verbatim from bluetti-home-assistant's
|
|
70
|
+
# own ruff.toml, for the exact same reason it lists there: client.py,
|
|
71
|
+
# product_client.py, and websocket.py are a mechanical port of that
|
|
72
|
+
# repo's api/ package (see pybluetti's README), so they inherit its
|
|
73
|
+
# patterns rather than introducing new laxness here.
|
|
74
|
+
"E501", # line-too-long
|
|
75
|
+
"N803", "N815", # non-lowercase naming - mirrors the cloud API's own field/param casing
|
|
76
|
+
"N818", # error-suffix-on-exception-name
|
|
77
|
+
"BLE001", # blind-except
|
|
78
|
+
"PLR2004", # magic-value-comparison
|
|
79
|
+
"ANN401", # Any is inherent to _request()'s generic response-type parameter
|
|
80
|
+
"ARG002", "ARG004", # unused arguments - expected for async context-manager/callback protocol signatures
|
|
81
|
+
"UP042", "UP046", # typing modernization (PEP 695 generics, StrEnum) not worth the diff in a direct port
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
[tool.ruff.lint.per-file-ignores]
|
|
85
|
+
"tests/**" = [
|
|
86
|
+
"S101", # assert is the normal pytest idiom, not a smell, here.
|
|
87
|
+
"SLF001", # the ported websocket tests deliberately reach into name-mangled
|
|
88
|
+
# privates (_StompClient__on_open etc.), by design - see
|
|
89
|
+
# bluetti-home-assistant's own identical ignore.
|
|
90
|
+
"ANN001", "ANN002", "ANN003", "ANN201", "ANN202", "ANN204", # test functions and
|
|
91
|
+
# fixture/mock helpers aren't part of the library's public surface,
|
|
92
|
+
# so the "annotations required" rule above doesn't apply to them.
|
|
93
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async Python client for the BLUETTI cloud API.
|
|
3
|
+
|
|
4
|
+
Extracted from
|
|
5
|
+
https://github.com/bluetti-official/bluetti-home-assistant's
|
|
6
|
+
custom_components/bluetti/api/, the same way
|
|
7
|
+
https://github.com/pyenphase/pyenphase backs the enphase_envoy Home Assistant
|
|
8
|
+
integration. Fully async, including the websocket push-update transport
|
|
9
|
+
(`aiohttp`'s native websocket client, no dedicated threads). Wiring
|
|
10
|
+
bluetti-home-assistant to depend on this package is a separate follow-up.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .client import Bluetti
|
|
14
|
+
from .const import Method
|
|
15
|
+
from .exceptions import ApplicationRuntimeException
|
|
16
|
+
from .models import UserProduct
|
|
17
|
+
from .product_client import ProductClient
|
|
18
|
+
from .unify_response import UnifyResponse
|
|
19
|
+
from .websocket import StompClient
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"ApplicationRuntimeException",
|
|
25
|
+
"Bluetti",
|
|
26
|
+
"Method",
|
|
27
|
+
"ProductClient",
|
|
28
|
+
"StompClient",
|
|
29
|
+
"UnifyResponse",
|
|
30
|
+
"UserProduct",
|
|
31
|
+
]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Base BLUETTI cloud API client."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from abc import abstractmethod
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from json import dumps
|
|
7
|
+
from typing import Any, Generic, TypeVar
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
from pydantic import TypeAdapter
|
|
11
|
+
|
|
12
|
+
from .const import Method
|
|
13
|
+
from .exceptions import ApplicationRuntimeException
|
|
14
|
+
from .unify_response import UnifyResponse
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Bluetti(Generic[T]):
|
|
20
|
+
"""Base class describing interactions with the BLUETTI cloud service."""
|
|
21
|
+
|
|
22
|
+
_accessToken: str | None = None
|
|
23
|
+
_httpSession: aiohttp.ClientSession
|
|
24
|
+
_gateway_url: str
|
|
25
|
+
_on_auth_expired: Callable[[], None] | None
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def logger(self) -> logging.Logger:
|
|
30
|
+
"""The subclass's logger."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
httpSession: aiohttp.ClientSession,
|
|
35
|
+
gateway_url: str,
|
|
36
|
+
accessToken: str | None = None,
|
|
37
|
+
on_auth_expired: Callable[[], None] | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""
|
|
40
|
+
Initialize the client.
|
|
41
|
+
|
|
42
|
+
- httpSession: the aiohttp session to issue requests on.
|
|
43
|
+
- gateway_url: the BLUETTI cloud gateway base URL (region-specific).
|
|
44
|
+
- accessToken: the OAuth2 access token to authenticate requests with.
|
|
45
|
+
- on_auth_expired: called when the cloud reports the access token as
|
|
46
|
+
expired (msgCode 805), so the caller can react (e.g. trigger a
|
|
47
|
+
refresh or re-authentication flow).
|
|
48
|
+
"""
|
|
49
|
+
self._httpSession = httpSession
|
|
50
|
+
self._gateway_url = gateway_url
|
|
51
|
+
self._accessToken = accessToken
|
|
52
|
+
self._on_auth_expired = on_auth_expired
|
|
53
|
+
|
|
54
|
+
async def _request(
|
|
55
|
+
self,
|
|
56
|
+
responseType: Any,
|
|
57
|
+
method: Method,
|
|
58
|
+
path: str,
|
|
59
|
+
params: dict[str, Any] | None = None,
|
|
60
|
+
body: dict[str, Any] | None = None,
|
|
61
|
+
) -> UnifyResponse[T] | str:
|
|
62
|
+
"""
|
|
63
|
+
Send a request to the server.
|
|
64
|
+
|
|
65
|
+
- responseType: the type of response data, without the UnifyResponse wrapper.
|
|
66
|
+
- method: the HTTP method.
|
|
67
|
+
"""
|
|
68
|
+
# when the method is 'GET', the request body must be null.
|
|
69
|
+
if method == Method.GET:
|
|
70
|
+
body = None
|
|
71
|
+
|
|
72
|
+
headers = {
|
|
73
|
+
"Authorization": f"{self._accessToken}",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Remove None values from params and json
|
|
77
|
+
if params:
|
|
78
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
79
|
+
self.logger.debug("======> Client request parameters: %s", params)
|
|
80
|
+
if body:
|
|
81
|
+
body = {k: v for k, v in body.items() if v is not None}
|
|
82
|
+
self.logger.debug("======> Client request body: %s", dumps(body))
|
|
83
|
+
headers["Content-Type"] = "application/json"
|
|
84
|
+
|
|
85
|
+
async with self._httpSession.request(
|
|
86
|
+
method,
|
|
87
|
+
f"{self._gateway_url}{path}",
|
|
88
|
+
headers=headers,
|
|
89
|
+
json=body,
|
|
90
|
+
params=params,
|
|
91
|
+
) as response:
|
|
92
|
+
self.logger.debug("<====== Server response status %s from %s", response.status, response.url)
|
|
93
|
+
self.logger.debug("<====== Server response type is: %s", response.content_type)
|
|
94
|
+
|
|
95
|
+
if not response.ok:
|
|
96
|
+
raise ApplicationRuntimeException(msgCode=response.status, data=await response.text())
|
|
97
|
+
|
|
98
|
+
if response.content_type.lower().startswith("application/json"):
|
|
99
|
+
data = await response.json() # read response body to JSON
|
|
100
|
+
unify_response = TypeAdapter(UnifyResponse[responseType]).validate_python(data)
|
|
101
|
+
if data.get("msgCode") == 805 and self._on_auth_expired is not None:
|
|
102
|
+
self._on_auth_expired()
|
|
103
|
+
self.logger.info("token have expired")
|
|
104
|
+
return unify_response
|
|
105
|
+
return await response.text()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Shared constants for pybluetti."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class StringEnum(str, Enum):
|
|
7
|
+
"""String Enum define."""
|
|
8
|
+
|
|
9
|
+
def __str__(self) -> str:
|
|
10
|
+
return self.value
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Method(StringEnum):
|
|
14
|
+
"""HTTP Methods define."""
|
|
15
|
+
|
|
16
|
+
GET = "GET"
|
|
17
|
+
POST = "POST"
|
|
18
|
+
DELETE = "DELETE"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Exceptions raised by the pybluetti client."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ApplicationRuntimeException(Exception):
|
|
5
|
+
"""Raised when a BLUETTI cloud API call fails."""
|
|
6
|
+
|
|
7
|
+
message: str = "An unknown error has occurred."
|
|
8
|
+
msgCode: int
|
|
9
|
+
data: dict | str | None = None
|
|
10
|
+
|
|
11
|
+
def __init__(self, msgCode: int, data: dict | str | None = None, errMessage: str | None = None) -> None:
|
|
12
|
+
self.msgCode = msgCode
|
|
13
|
+
self.data = data
|
|
14
|
+
|
|
15
|
+
if errMessage is not None:
|
|
16
|
+
self.message = errMessage
|
|
17
|
+
|
|
18
|
+
super().__init__(self.message)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Response models for the BLUETTI cloud API."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class UserProduct(BaseModel):
|
|
7
|
+
"""A device/power station bound to a BLUETTI account."""
|
|
8
|
+
|
|
9
|
+
sn: str
|
|
10
|
+
stateList: list
|
|
11
|
+
online: str
|
|
12
|
+
model: str | None = None
|
|
13
|
+
name: str | None = None
|
|
14
|
+
isBindByCurUser: str | None = None
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Client for the BLUETTI product/device endpoints."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from .client import Bluetti
|
|
6
|
+
from .const import Method
|
|
7
|
+
from .models import UserProduct
|
|
8
|
+
from .unify_response import UnifyResponse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ProductClient(Bluetti):
|
|
12
|
+
"""Class describing for the BLUETTI products."""
|
|
13
|
+
|
|
14
|
+
__LOGGER__ = None
|
|
15
|
+
"""The api client logger."""
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def logger(self) -> logging.Logger:
|
|
19
|
+
"""Get the api client logger."""
|
|
20
|
+
if self.__LOGGER__ is None:
|
|
21
|
+
self.__LOGGER__ = logging.getLogger(__name__ + "." + __class__.__name__)
|
|
22
|
+
return self.__LOGGER__
|
|
23
|
+
|
|
24
|
+
async def get_user_products(self) -> UnifyResponse[list[UserProduct]]:
|
|
25
|
+
"""Get the devices/power stations bound to the account."""
|
|
26
|
+
return await self._request(
|
|
27
|
+
list[UserProduct],
|
|
28
|
+
Method.GET,
|
|
29
|
+
"/api/bluiotdata/ha/v1/devices",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
async def get_device_status(self, sns: str | None = None) -> UnifyResponse[list[UserProduct]]:
|
|
33
|
+
"""Poll device state."""
|
|
34
|
+
return await self._request(
|
|
35
|
+
list[UserProduct],
|
|
36
|
+
Method.GET,
|
|
37
|
+
"/api/bluiotdata/ha/v1/deviceStates",
|
|
38
|
+
params={"sns": sns},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
async def control_device(self, payload: dict | None = None) -> UnifyResponse[dict] | str:
|
|
42
|
+
"""Send a control command to a device."""
|
|
43
|
+
return await self._request(
|
|
44
|
+
dict,
|
|
45
|
+
method=Method.POST,
|
|
46
|
+
path="/api/bluiotdata/ha/v1/fulfillment",
|
|
47
|
+
body=payload,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
async def bind_devices(self, payload: dict | None = None) -> UnifyResponse[dict] | str:
|
|
51
|
+
"""Bind devices to the account."""
|
|
52
|
+
return await self._request(
|
|
53
|
+
dict,
|
|
54
|
+
method=Method.POST,
|
|
55
|
+
path="/api/bluiotdata/ha/v1/bindDevices",
|
|
56
|
+
body=payload,
|
|
57
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""The envelope every BLUETTI cloud API response is wrapped in."""
|
|
2
|
+
|
|
3
|
+
from typing import Generic, TypeVar
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UnifyResponse(BaseModel, Generic[T]):
|
|
11
|
+
"""The Unify Server Response class."""
|
|
12
|
+
|
|
13
|
+
msgId: str
|
|
14
|
+
msgCode: int
|
|
15
|
+
data: T | None = None
|
|
16
|
+
|
|
17
|
+
def is_ok(self) -> bool:
|
|
18
|
+
"""Return true if the server response is success."""
|
|
19
|
+
return self.msgCode == 0
|
|
20
|
+
|
|
21
|
+
def has_data(self) -> bool:
|
|
22
|
+
"""Return true if the server response is success and has response data."""
|
|
23
|
+
return self.is_ok() and self.data is not None
|