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.
@@ -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,14 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .pytest_cache/
8
+ .coverage
9
+ .coverage.*
10
+ htmlcov/
11
+ .ruff_cache/
12
+ .venv/
13
+ venv/
14
+ .mypy_cache/
@@ -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.
@@ -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.
@@ -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,6 @@
1
+ #!/usr/bin/env bash
2
+ set -e
3
+
4
+ cd "$(dirname "$0")/.."
5
+
6
+ ruff check . --fix
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -e
3
+
4
+ cd "$(dirname "$0")/.."
5
+
6
+ python3 -m pip install --upgrade pip
7
+ python3 -m pip install --editable ".[test]"
8
+ python3 -m pip install ruff
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+ set -e
3
+
4
+ cd "$(dirname "$0")/.."
5
+
6
+ python3 -m pytest tests/ -q --cov=pybluetti --cov-report=term-missing --cov-fail-under=100
@@ -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