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 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"
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.