pybluetti 0.1.0__py3-none-any.whl
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/__init__.py +31 -0
- pybluetti/client.py +105 -0
- pybluetti/const.py +18 -0
- pybluetti/exceptions.py +18 -0
- pybluetti/models.py +14 -0
- pybluetti/product_client.py +57 -0
- pybluetti/py.typed +0 -0
- pybluetti/unify_response.py +23 -0
- pybluetti/websocket.py +212 -0
- pybluetti-0.1.0.dist-info/METADATA +77 -0
- pybluetti-0.1.0.dist-info/RECORD +13 -0
- pybluetti-0.1.0.dist-info/WHEEL +4 -0
- pybluetti-0.1.0.dist-info/licenses/LICENSE +21 -0
pybluetti/__init__.py
ADDED
|
@@ -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
|
+
]
|
pybluetti/client.py
ADDED
|
@@ -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()
|
pybluetti/const.py
ADDED
|
@@ -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"
|
pybluetti/exceptions.py
ADDED
|
@@ -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)
|
pybluetti/models.py
ADDED
|
@@ -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
|
+
)
|
pybluetti/py.typed
ADDED
|
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
|
pybluetti/websocket.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""STOMP-over-websocket client for BLUETTI's real-time device push updates."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import warnings
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
|
|
11
|
+
# stomper's stompbuffer module has an invalid regex escape sequence that
|
|
12
|
+
# raises a SyntaxWarning on import (fixed in no released version as of
|
|
13
|
+
# 0.4.3); silence it here so it isn't misattributed to this package.
|
|
14
|
+
with warnings.catch_warnings():
|
|
15
|
+
warnings.simplefilter("ignore", SyntaxWarning)
|
|
16
|
+
import stomper
|
|
17
|
+
|
|
18
|
+
from .exceptions import ApplicationRuntimeException
|
|
19
|
+
|
|
20
|
+
__LOGGER__ = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class StompClient:
|
|
24
|
+
"""A STOMP client connected to the BLUETTI cloud's push-update websocket."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
session: aiohttp.ClientSession,
|
|
29
|
+
url: str,
|
|
30
|
+
access_token: str,
|
|
31
|
+
handler: Callable[[str], None] | None = None,
|
|
32
|
+
on_auth_expired: Callable[[], None] | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""
|
|
35
|
+
Initialize the client.
|
|
36
|
+
|
|
37
|
+
- session: the aiohttp session to open the websocket connection on.
|
|
38
|
+
- url: the websocket base URL (region-specific).
|
|
39
|
+
- access_token: the OAuth2 access token to authenticate the connection with.
|
|
40
|
+
- handler: called with each MESSAGE frame's body.
|
|
41
|
+
- on_auth_expired: called when the cloud reports the access token as
|
|
42
|
+
expired (msgCode 805), so the caller can react.
|
|
43
|
+
"""
|
|
44
|
+
self._session = session
|
|
45
|
+
self.__url = url + "/websocket"
|
|
46
|
+
self.__headers = {
|
|
47
|
+
"Host": self.__get_host(url),
|
|
48
|
+
"Authorization": access_token,
|
|
49
|
+
}
|
|
50
|
+
self.__handler = handler
|
|
51
|
+
self.on_auth_expired = on_auth_expired
|
|
52
|
+
self._ws: aiohttp.ClientWebSocketResponse | None = None
|
|
53
|
+
self.running = False
|
|
54
|
+
|
|
55
|
+
self._receive_task: asyncio.Task | None = None
|
|
56
|
+
self._heartbeat_task: asyncio.Task | None = None
|
|
57
|
+
self.heartbeat_interval = 10
|
|
58
|
+
|
|
59
|
+
self.reconnect_delay = 1 # initial reconnect delay (seconds)
|
|
60
|
+
self.max_reconnect_delay = 30 # max reconnect delay (seconds)
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def __get_host(connection_url: str) -> str:
|
|
64
|
+
host = connection_url.split("//")[1]
|
|
65
|
+
index = host.find("/")
|
|
66
|
+
host = host[0:index]
|
|
67
|
+
|
|
68
|
+
if host.find(":") > -1:
|
|
69
|
+
host = host.split(":")[0]
|
|
70
|
+
return host
|
|
71
|
+
|
|
72
|
+
async def connect(self) -> None:
|
|
73
|
+
"""Connect to the ws server and start the background receive/heartbeat tasks."""
|
|
74
|
+
__LOGGER__.info("Start to connect the BLUETTI WebSocket Server.")
|
|
75
|
+
self.running = True
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
self._ws = await self._session.ws_connect(self.__url, headers=self.__headers)
|
|
79
|
+
|
|
80
|
+
connect_frame = (
|
|
81
|
+
"CONNECT\n"
|
|
82
|
+
"accept-version:1.0,1.1,2.0\n"
|
|
83
|
+
"Host:" + self.__headers["Host"] + "\n"
|
|
84
|
+
"Authorization: " + self.__headers["Authorization"] + "\n"
|
|
85
|
+
"heart-beat:10000,10000\n"
|
|
86
|
+
"\n\x00\n"
|
|
87
|
+
)
|
|
88
|
+
await self._ws.send_str(connect_frame)
|
|
89
|
+
except Exception:
|
|
90
|
+
# Same resilience as a run-time disconnect: log and retry with
|
|
91
|
+
# backoff rather than letting a connection failure go silent.
|
|
92
|
+
__LOGGER__.exception("Failed to connect to the BLUETTI WebSocket Server")
|
|
93
|
+
await self.reconnect()
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
__LOGGER__.info("Connect the BLUETTI WebSocket Server successfully.")
|
|
97
|
+
|
|
98
|
+
self._receive_task = asyncio.ensure_future(self._run())
|
|
99
|
+
self._heartbeat_task = asyncio.ensure_future(self._heartbeat_loop())
|
|
100
|
+
|
|
101
|
+
async def disconnect(self) -> None:
|
|
102
|
+
"""Stop reconnecting, cancel background tasks, and close the connection."""
|
|
103
|
+
self.running = False
|
|
104
|
+
tasks = [t for t in (self._receive_task, self._heartbeat_task) if t is not None]
|
|
105
|
+
for task in tasks:
|
|
106
|
+
task.cancel()
|
|
107
|
+
if tasks:
|
|
108
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
109
|
+
if self._ws is not None:
|
|
110
|
+
await self._ws.close()
|
|
111
|
+
|
|
112
|
+
async def reconnect(self) -> None:
|
|
113
|
+
"""Reconnect with exponential backoff, if still running."""
|
|
114
|
+
__LOGGER__.info("Websocket reconnect")
|
|
115
|
+
if self.running:
|
|
116
|
+
await asyncio.sleep(self.reconnect_delay)
|
|
117
|
+
self.reconnect_delay = min(self.reconnect_delay * 2, self.max_reconnect_delay)
|
|
118
|
+
await self.connect()
|
|
119
|
+
else:
|
|
120
|
+
__LOGGER__.info("Websocket have stop do not reconnect")
|
|
121
|
+
|
|
122
|
+
async def _heartbeat_loop(self) -> None:
|
|
123
|
+
r"""Send a STOMP heartbeat ("\n") on the configured interval."""
|
|
124
|
+
while self.running:
|
|
125
|
+
await asyncio.sleep(self.heartbeat_interval)
|
|
126
|
+
if self._ws is None or self._ws.closed:
|
|
127
|
+
break
|
|
128
|
+
try:
|
|
129
|
+
await self._ws.send_str("\n")
|
|
130
|
+
__LOGGER__.debug("Sent STOMP heartbeat")
|
|
131
|
+
except Exception as e:
|
|
132
|
+
__LOGGER__.error("Failed to send heartbeat: %s", e)
|
|
133
|
+
break
|
|
134
|
+
|
|
135
|
+
async def _run(self) -> None:
|
|
136
|
+
"""Receive and handle STOMP frames until the connection closes."""
|
|
137
|
+
try:
|
|
138
|
+
async for msg in self._ws:
|
|
139
|
+
if msg.type == aiohttp.WSMsgType.TEXT:
|
|
140
|
+
await self._handle_frame(msg.data)
|
|
141
|
+
elif msg.type == aiohttp.WSMsgType.ERROR:
|
|
142
|
+
__LOGGER__.error("The BLUETTI WebSocket raised an error: %s", self._ws.exception())
|
|
143
|
+
break
|
|
144
|
+
elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED):
|
|
145
|
+
__LOGGER__.debug("WebSocket connection closed: %s", msg)
|
|
146
|
+
break
|
|
147
|
+
except Exception:
|
|
148
|
+
__LOGGER__.exception("BLUETTI WebSocket task crashed")
|
|
149
|
+
|
|
150
|
+
if self.running:
|
|
151
|
+
await self.reconnect()
|
|
152
|
+
|
|
153
|
+
async def _handle_frame(self, message: str) -> None:
|
|
154
|
+
"""Parse and handle one incoming STOMP frame."""
|
|
155
|
+
__LOGGER__.debug("Received the BLUETTI websocket message:\n %s", message)
|
|
156
|
+
|
|
157
|
+
if not message or message == "\n":
|
|
158
|
+
__LOGGER__.debug("Received heartbeat from server")
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
frame = stomper.Frame()
|
|
162
|
+
frame.unpack(message)
|
|
163
|
+
|
|
164
|
+
if frame.cmd == "ERROR":
|
|
165
|
+
await self._handle_error_frame(frame)
|
|
166
|
+
elif frame.cmd == "CONNECTED":
|
|
167
|
+
await self._handle_connected_frame(frame)
|
|
168
|
+
elif frame.cmd == "MESSAGE":
|
|
169
|
+
self._invoke_handler(frame.body)
|
|
170
|
+
|
|
171
|
+
async def _handle_error_frame(self, frame: stomper.Frame) -> None:
|
|
172
|
+
error = frame.headers["message"].replace("\\c", ":")
|
|
173
|
+
error = json.loads(error)
|
|
174
|
+
if error["msgCode"] == 805:
|
|
175
|
+
# Stop everything without cancelling our own currently-running
|
|
176
|
+
# task (this runs inside _run()'s receive loop): flip the
|
|
177
|
+
# running flag and close the socket so the loop exits on its
|
|
178
|
+
# own next iteration, then stop the (separate) heartbeat task.
|
|
179
|
+
self.running = False
|
|
180
|
+
if self._heartbeat_task is not None:
|
|
181
|
+
self._heartbeat_task.cancel()
|
|
182
|
+
if self._ws is not None:
|
|
183
|
+
await self._ws.close()
|
|
184
|
+
if self.on_auth_expired is not None:
|
|
185
|
+
self.on_auth_expired()
|
|
186
|
+
__LOGGER__.info("token have expired stop ws connect")
|
|
187
|
+
else:
|
|
188
|
+
raise ApplicationRuntimeException(msgCode=error["msgCode"], errMessage=error["message"])
|
|
189
|
+
|
|
190
|
+
async def _handle_connected_frame(self, frame: stomper.Frame) -> None:
|
|
191
|
+
heartbeat = frame.headers.get("heart-beat", "0,0")
|
|
192
|
+
server_send, server_receive = map(int, heartbeat.split(","))
|
|
193
|
+
__LOGGER__.info(
|
|
194
|
+
"Server heartbeat configuration: send=%s, receive=%s",
|
|
195
|
+
server_send, server_receive,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
user_name = frame.headers.get("user-name")
|
|
199
|
+
if not user_name:
|
|
200
|
+
__LOGGER__.error("CONNECTED frame missing 'user-name' header, cannot subscribe")
|
|
201
|
+
return
|
|
202
|
+
destination = f"/ws-subscribe/user/{user_name}/notify"
|
|
203
|
+
sub = stomper.subscribe(destination, "clientUniqueId", ack="auto")
|
|
204
|
+
await self._ws.send_str(sub)
|
|
205
|
+
|
|
206
|
+
def _invoke_handler(self, body: str) -> None:
|
|
207
|
+
if not self.__handler:
|
|
208
|
+
return
|
|
209
|
+
try:
|
|
210
|
+
self.__handler(body)
|
|
211
|
+
except Exception as e:
|
|
212
|
+
__LOGGER__.error("error from callback %s: %s", self.__handler, e)
|
|
@@ -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,13 @@
|
|
|
1
|
+
pybluetti/__init__.py,sha256=7PwyzoAgM4k75y1SmBsZP6hjOwRKBpOgQRCz5RyXI0U,909
|
|
2
|
+
pybluetti/client.py,sha256=tseKGBpb9t1PsJW5XgBFDxb5DP6BzfepziamQvWv1lk,3721
|
|
3
|
+
pybluetti/const.py,sha256=OubZ1kb16163EJAeDTDfBwkN19OcVpRtqGuar9xMLuY,295
|
|
4
|
+
pybluetti/exceptions.py,sha256=Zs1ZxrxG3y2TknADE5tRDkjGxacPi3Q_2fihtdp_4Uc,536
|
|
5
|
+
pybluetti/models.py,sha256=7B-bAQEsTBmwcXW83K7q-w8B8Gln9xcJvDJhsfAJHDM,319
|
|
6
|
+
pybluetti/product_client.py,sha256=a9U-y2H_M_xM6ukz5ma9RLPgxPH_n96uMrLu6MKnPbw,1828
|
|
7
|
+
pybluetti/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
pybluetti/unify_response.py,sha256=UnP1dKpYGyhH-caiMJdQDTrlzlc2xAzjwCp8mboPX6E,596
|
|
9
|
+
pybluetti/websocket.py,sha256=FsFhY3p0uojFzplAiDEUW0WKMFoc68bdClKTP8U2PUg,8375
|
|
10
|
+
pybluetti-0.1.0.dist-info/METADATA,sha256=e2vim-Jlz0jgLQ_li0fB5D6-PATNg-KuaKWYa83I1Vs,3335
|
|
11
|
+
pybluetti-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
12
|
+
pybluetti-0.1.0.dist-info/licenses/LICENSE,sha256=G3jdL6SdKnZnpGDLU6xdas4Vaqn0dw1cNgCc5ZuzTro,1063
|
|
13
|
+
pybluetti-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|