hyxi-cloud-api 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.
- hyxi_cloud_api-0.1.0/LICENSE +21 -0
- hyxi_cloud_api-0.1.0/PKG-INFO +67 -0
- hyxi_cloud_api-0.1.0/README.md +56 -0
- hyxi_cloud_api-0.1.0/pyproject.toml +16 -0
- hyxi_cloud_api-0.1.0/setup.cfg +4 -0
- hyxi_cloud_api-0.1.0/src/hyxi_cloud_api/__init__.py +1 -0
- hyxi_cloud_api-0.1.0/src/hyxi_cloud_api/api.py +423 -0
- hyxi_cloud_api-0.1.0/src/hyxi_cloud_api.egg-info/PKG-INFO +67 -0
- hyxi_cloud_api-0.1.0/src/hyxi_cloud_api.egg-info/SOURCES.txt +10 -0
- hyxi_cloud_api-0.1.0/src/hyxi_cloud_api.egg-info/dependency_links.txt +1 -0
- hyxi_cloud_api-0.1.0/src/hyxi_cloud_api.egg-info/requires.txt +1 -0
- hyxi_cloud_api-0.1.0/src/hyxi_cloud_api.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Veldkornet
|
|
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,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hyxi-cloud-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An async API client for HYXi Cloud.
|
|
5
|
+
Author-email: Veldkornet <veldkornet@outlook.com>
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: aiohttp>=3.8.0
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# hyxi-cloud-api
|
|
13
|
+
|
|
14
|
+
An asynchronous Python client for interacting with the HYXi Cloud API.
|
|
15
|
+
|
|
16
|
+
This library was primarily built to power the [HYXi Cloud Home Assistant Integration](LINK_TO_YOUR_HA_REPO_HERE), but it can be used in any Python 3.11+ project to fetch telemetry data from HYXi solar inverters and battery systems.
|
|
17
|
+
|
|
18
|
+
## 📦 Installation
|
|
19
|
+
|
|
20
|
+
You can install the package directly from PyPI:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install hyxi-cloud-api
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## 🚀 Quick Start
|
|
27
|
+
|
|
28
|
+
This library uses `aiohttp` for non-blocking network requests. You will need to provide your HYXi Cloud Access Key and Secret Key, along with an active `aiohttp.ClientSession`.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import asyncio
|
|
32
|
+
import aiohttp
|
|
33
|
+
from hyxi_cloud_api import HyxiApiClient
|
|
34
|
+
|
|
35
|
+
async def main():
|
|
36
|
+
# Replace with your actual HYXi Cloud credentials
|
|
37
|
+
ACCESS_KEY = "your_access_key"
|
|
38
|
+
SECRET_KEY = "your_secret_key"
|
|
39
|
+
BASE_URL = "[https://open.hyxicloud.com](https://open.hyxicloud.com)"
|
|
40
|
+
|
|
41
|
+
async with aiohttp.ClientSession() as session:
|
|
42
|
+
# 1. Initialize the client
|
|
43
|
+
client = HyxiApiClient(
|
|
44
|
+
access_key=ACCESS_KEY,
|
|
45
|
+
secret_key=SECRET_KEY,
|
|
46
|
+
base_url=BASE_URL,
|
|
47
|
+
session=session
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# 2. Fetch device data
|
|
51
|
+
try:
|
|
52
|
+
device_data = await client.get_all_device_data()
|
|
53
|
+
print("Successfully fetched HYXi data:")
|
|
54
|
+
print(device_data)
|
|
55
|
+
except Exception as e:
|
|
56
|
+
print(f"Error communicating with HYXi Cloud: {e}")
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
asyncio.run(main())
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## 🛠️ Requirements
|
|
63
|
+
* Python 3.11 or newer
|
|
64
|
+
* `aiohttp` >= 3.8.0
|
|
65
|
+
|
|
66
|
+
## ⚠️ Disclaimer
|
|
67
|
+
This is an unofficial, community-driven project. It is not affiliated with, endorsed by, or connected to HYXiPower in any official capacity. Use this software at your own risk.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# hyxi-cloud-api
|
|
2
|
+
|
|
3
|
+
An asynchronous Python client for interacting with the HYXi Cloud API.
|
|
4
|
+
|
|
5
|
+
This library was primarily built to power the [HYXi Cloud Home Assistant Integration](LINK_TO_YOUR_HA_REPO_HERE), but it can be used in any Python 3.11+ project to fetch telemetry data from HYXi solar inverters and battery systems.
|
|
6
|
+
|
|
7
|
+
## 📦 Installation
|
|
8
|
+
|
|
9
|
+
You can install the package directly from PyPI:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install hyxi-cloud-api
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## 🚀 Quick Start
|
|
16
|
+
|
|
17
|
+
This library uses `aiohttp` for non-blocking network requests. You will need to provide your HYXi Cloud Access Key and Secret Key, along with an active `aiohttp.ClientSession`.
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import asyncio
|
|
21
|
+
import aiohttp
|
|
22
|
+
from hyxi_cloud_api import HyxiApiClient
|
|
23
|
+
|
|
24
|
+
async def main():
|
|
25
|
+
# Replace with your actual HYXi Cloud credentials
|
|
26
|
+
ACCESS_KEY = "your_access_key"
|
|
27
|
+
SECRET_KEY = "your_secret_key"
|
|
28
|
+
BASE_URL = "[https://open.hyxicloud.com](https://open.hyxicloud.com)"
|
|
29
|
+
|
|
30
|
+
async with aiohttp.ClientSession() as session:
|
|
31
|
+
# 1. Initialize the client
|
|
32
|
+
client = HyxiApiClient(
|
|
33
|
+
access_key=ACCESS_KEY,
|
|
34
|
+
secret_key=SECRET_KEY,
|
|
35
|
+
base_url=BASE_URL,
|
|
36
|
+
session=session
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# 2. Fetch device data
|
|
40
|
+
try:
|
|
41
|
+
device_data = await client.get_all_device_data()
|
|
42
|
+
print("Successfully fetched HYXi data:")
|
|
43
|
+
print(device_data)
|
|
44
|
+
except Exception as e:
|
|
45
|
+
print(f"Error communicating with HYXi Cloud: {e}")
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
asyncio.run(main())
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## 🛠️ Requirements
|
|
52
|
+
* Python 3.11 or newer
|
|
53
|
+
* `aiohttp` >= 3.8.0
|
|
54
|
+
|
|
55
|
+
## ⚠️ Disclaimer
|
|
56
|
+
This is an unofficial, community-driven project. It is not affiliated with, endorsed by, or connected to HYXiPower in any official capacity. Use this software at your own risk.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hyxi-cloud-api"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name="Veldkornet", email="veldkornet@outlook.com" },
|
|
10
|
+
]
|
|
11
|
+
description = "An async API client for HYXi Cloud."
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.11"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"aiohttp>=3.8.0",
|
|
16
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .api import HyxiApiClient
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from datetime import UTC
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
import aiohttp
|
|
13
|
+
|
|
14
|
+
_LOGGER = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# Retry configuration
|
|
17
|
+
MAX_RETRIES = 3
|
|
18
|
+
RETRY_DELAY = 2 # Seconds to wait between retries (multiplied by attempt number)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HyxiApiClient:
|
|
22
|
+
def __init__(
|
|
23
|
+
self, access_key, secret_key, base_url, session: aiohttp.ClientSession
|
|
24
|
+
):
|
|
25
|
+
self.access_key = access_key
|
|
26
|
+
self.secret_key = secret_key
|
|
27
|
+
self.base_url = base_url.rstrip("/")
|
|
28
|
+
self.session = session
|
|
29
|
+
self.token = None
|
|
30
|
+
self.token_expires_at = 0
|
|
31
|
+
|
|
32
|
+
def _generate_headers(self, path, method, is_token_request=False):
|
|
33
|
+
"""Generates headers matching HYXi's official Java SDK implementation."""
|
|
34
|
+
now_ms = int(time.time() * 1000)
|
|
35
|
+
timestamp = str(now_ms)
|
|
36
|
+
|
|
37
|
+
# 🚀 Generate a truly unique Nonce for concurrent requests
|
|
38
|
+
nonce = os.urandom(4).hex()
|
|
39
|
+
|
|
40
|
+
content_str = "grantType:1" if is_token_request else ""
|
|
41
|
+
hex_hash = hashlib.sha512(content_str.encode("utf-8")).hexdigest()
|
|
42
|
+
|
|
43
|
+
string_to_sign = f"{path}\n{method.upper()}\n{hex_hash}\n"
|
|
44
|
+
|
|
45
|
+
# 🚀 Do not poison the signature with an expired token!
|
|
46
|
+
if is_token_request:
|
|
47
|
+
token_str = ""
|
|
48
|
+
else:
|
|
49
|
+
token_str = self.token if self.token else ""
|
|
50
|
+
|
|
51
|
+
# Build the final string
|
|
52
|
+
sign_string = f"{self.access_key}{token_str}{timestamp}{nonce}{string_to_sign}"
|
|
53
|
+
hmac_bytes = hmac.new(
|
|
54
|
+
self.secret_key.encode("utf-8"), sign_string.encode("utf-8"), hashlib.sha512
|
|
55
|
+
).digest()
|
|
56
|
+
signature = base64.b64encode(hmac_bytes).decode("utf-8")
|
|
57
|
+
|
|
58
|
+
headers = {
|
|
59
|
+
"accessKey": self.access_key,
|
|
60
|
+
"timestamp": timestamp,
|
|
61
|
+
"nonce": nonce,
|
|
62
|
+
"sign": signature,
|
|
63
|
+
"Content-Type": "application/json",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if is_token_request:
|
|
67
|
+
headers["sign-headers"] = "grantType"
|
|
68
|
+
elif token_str:
|
|
69
|
+
headers["Authorization"] = token_str
|
|
70
|
+
|
|
71
|
+
return headers
|
|
72
|
+
|
|
73
|
+
async def _refresh_token(self):
|
|
74
|
+
"""Async version of token refresh."""
|
|
75
|
+
if self.token and time.time() < self.token_expires_at:
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
path = "/api/authorization/v1/token"
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
async with self.session.post(
|
|
82
|
+
f"{self.base_url}{path}",
|
|
83
|
+
json={"grantType": 1},
|
|
84
|
+
headers=self._generate_headers(path, "POST", is_token_request=True),
|
|
85
|
+
timeout=15,
|
|
86
|
+
) as response:
|
|
87
|
+
if response.status in [401, 403]:
|
|
88
|
+
_LOGGER.error("HYXi API: Token request unauthorized (401/403)")
|
|
89
|
+
return "auth_failed"
|
|
90
|
+
|
|
91
|
+
response.raise_for_status()
|
|
92
|
+
res = await response.json()
|
|
93
|
+
|
|
94
|
+
if not res.get("success"):
|
|
95
|
+
_LOGGER.error("HYXi API Token Rejected: %s", res)
|
|
96
|
+
if res.get("code") in [401, 403, "401", "403"]:
|
|
97
|
+
return "auth_failed"
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
data = res.get("data", {})
|
|
101
|
+
token_val = data.get("token") or data.get("access_token")
|
|
102
|
+
|
|
103
|
+
if token_val:
|
|
104
|
+
self.token = f"Bearer {token_val}"
|
|
105
|
+
|
|
106
|
+
# 1. Grab the raw expiration value exactly as the API sent it
|
|
107
|
+
raw_expires_in = data.get("expiresIn") or data.get("expires_in")
|
|
108
|
+
_LOGGER.debug(
|
|
109
|
+
"HYXi API returned raw token expiration: %s seconds",
|
|
110
|
+
raw_expires_in,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# 2. Default to 6600 if the API didn't provide one
|
|
114
|
+
expires_in = raw_expires_in or 6600
|
|
115
|
+
|
|
116
|
+
# 3. Apply the 5-minute (300s) safety buffer
|
|
117
|
+
buffer_secs = 300
|
|
118
|
+
self.token_expires_at = time.time() + int(expires_in) - buffer_secs
|
|
119
|
+
|
|
120
|
+
# 4. Log the actual scheduled refresh time
|
|
121
|
+
refresh_time_str = datetime.fromtimestamp(
|
|
122
|
+
self.token_expires_at
|
|
123
|
+
).strftime("%Y-%m-%d %H:%M:%S")
|
|
124
|
+
_LOGGER.debug(
|
|
125
|
+
"HYXi Token proactive refresh scheduled in %s seconds (at %s)",
|
|
126
|
+
int(expires_in) - buffer_secs,
|
|
127
|
+
refresh_time_str,
|
|
128
|
+
)
|
|
129
|
+
return True
|
|
130
|
+
except Exception as e:
|
|
131
|
+
_LOGGER.error("HYXi Token Request Failed: %s", e)
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
async def _fetch_device_metrics(self, sn, entry):
|
|
135
|
+
"""Helper to fetch detailed metrics for a single device."""
|
|
136
|
+
q_path = "/api/device/v1/queryDeviceData"
|
|
137
|
+
try:
|
|
138
|
+
async with self.session.get(
|
|
139
|
+
f"{self.base_url}{q_path}?deviceSn={sn}",
|
|
140
|
+
headers=self._generate_headers(q_path, "GET"),
|
|
141
|
+
timeout=15,
|
|
142
|
+
) as resp_q:
|
|
143
|
+
resp_q.raise_for_status()
|
|
144
|
+
res_q = await resp_q.json()
|
|
145
|
+
|
|
146
|
+
if res_q.get("success"):
|
|
147
|
+
data_list = res_q.get("data", [])
|
|
148
|
+
m_raw = {
|
|
149
|
+
item.get("dataKey"): item.get("dataValue")
|
|
150
|
+
for item in data_list
|
|
151
|
+
if isinstance(item, dict) and item.get("dataKey")
|
|
152
|
+
}
|
|
153
|
+
_LOGGER.debug(
|
|
154
|
+
"HYXi Raw Metrics for %s (%s): %s",
|
|
155
|
+
sn,
|
|
156
|
+
entry.get("device_type_code"),
|
|
157
|
+
m_raw,
|
|
158
|
+
)
|
|
159
|
+
entry["metrics"].update(m_raw)
|
|
160
|
+
|
|
161
|
+
def get_f(key, data_map, mult=1.0):
|
|
162
|
+
try:
|
|
163
|
+
val = data_map.get(key)
|
|
164
|
+
if val is None or val == "":
|
|
165
|
+
return 0.0
|
|
166
|
+
return round(float(val) * mult, 2)
|
|
167
|
+
except (ValueError, TypeError):
|
|
168
|
+
return 0.0
|
|
169
|
+
|
|
170
|
+
if "gridP" in m_raw or "pbat" in m_raw:
|
|
171
|
+
grid = get_f("gridP", m_raw, 1000.0)
|
|
172
|
+
pbat = get_f("pbat", m_raw)
|
|
173
|
+
|
|
174
|
+
entry["metrics"].update(
|
|
175
|
+
{
|
|
176
|
+
"home_load": get_f("ph1Loadp", m_raw)
|
|
177
|
+
+ get_f("ph2Loadp", m_raw)
|
|
178
|
+
+ get_f("ph3Loadp", m_raw),
|
|
179
|
+
"grid_import": abs(grid) if grid < 0 else 0,
|
|
180
|
+
"grid_export": grid if grid > 0 else 0,
|
|
181
|
+
"bat_charging": abs(pbat) if pbat < 0 else 0,
|
|
182
|
+
"bat_discharging": pbat if pbat > 0 else 0,
|
|
183
|
+
"bat_charge_total": get_f("batCharge", m_raw),
|
|
184
|
+
"bat_discharge_total": get_f("batDisCharge", m_raw),
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
else:
|
|
188
|
+
_LOGGER.warning(
|
|
189
|
+
"HYXi API metrics rejected for %s: %s", sn, res_q.get("message")
|
|
190
|
+
)
|
|
191
|
+
except Exception as e:
|
|
192
|
+
_LOGGER.error("Error fetching metrics for %s: %s", sn, e)
|
|
193
|
+
|
|
194
|
+
async def _fetch_device_info(self, sn, entry):
|
|
195
|
+
"""Helper to fetch static device info (firmware, capacity, limits)."""
|
|
196
|
+
i_path = "/api/device/v1/queryDeviceInfo"
|
|
197
|
+
try:
|
|
198
|
+
async with self.session.get(
|
|
199
|
+
f"{self.base_url}{i_path}?deviceSn={sn}",
|
|
200
|
+
headers=self._generate_headers(i_path, "GET"),
|
|
201
|
+
timeout=15,
|
|
202
|
+
) as resp_i:
|
|
203
|
+
res_i = await resp_i.json()
|
|
204
|
+
|
|
205
|
+
if res_i.get("success"):
|
|
206
|
+
data_list = res_i.get("data", [])
|
|
207
|
+
i_raw = {
|
|
208
|
+
item.get("dataKey"): item.get("dataValue")
|
|
209
|
+
for item in data_list
|
|
210
|
+
if isinstance(item, dict) and item.get("dataKey")
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
# 👇 This will dump the EXACT info the cloud sends back
|
|
214
|
+
_LOGGER.debug("HYXi Raw INFO for %s: %s", sn, i_raw)
|
|
215
|
+
|
|
216
|
+
# Smart Firmware Finder
|
|
217
|
+
sw_ver = (
|
|
218
|
+
i_raw.get("swVerSys")
|
|
219
|
+
or i_raw.get("swVerMaster")
|
|
220
|
+
or i_raw.get("swVer")
|
|
221
|
+
)
|
|
222
|
+
if sw_ver:
|
|
223
|
+
entry["sw_version"] = sw_ver
|
|
224
|
+
|
|
225
|
+
# Merge static info into metrics
|
|
226
|
+
entry["metrics"].update(
|
|
227
|
+
{
|
|
228
|
+
"signalIntensity": i_raw.get("signalIntensity"),
|
|
229
|
+
"signalVal": i_raw.get("signalVal"),
|
|
230
|
+
"wifiVer": i_raw.get("wifiVer"),
|
|
231
|
+
"comMode": i_raw.get("comMode"),
|
|
232
|
+
"batCap": i_raw.get("batCap"),
|
|
233
|
+
"maxChargePower": i_raw.get("maxChargePower")
|
|
234
|
+
or i_raw.get("maxChargingDischargingPower"),
|
|
235
|
+
"maxDischargePower": i_raw.get("maxDischargePower")
|
|
236
|
+
or i_raw.get("maxChargingDischargingPower"),
|
|
237
|
+
}
|
|
238
|
+
)
|
|
239
|
+
else:
|
|
240
|
+
_LOGGER.warning(
|
|
241
|
+
"HYXi INFO API Rejected for %s: %s", sn, res_i.get("message")
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
except Exception as e:
|
|
245
|
+
_LOGGER.error("Error fetching device info for %s: %s", sn, e)
|
|
246
|
+
|
|
247
|
+
async def _fetch_all_for_device(self, sn, entry, dev_type):
|
|
248
|
+
"""Fires off concurrent requests for Data and Info, merging the results."""
|
|
249
|
+
tasks = [self._fetch_device_info(sn, entry)]
|
|
250
|
+
|
|
251
|
+
# Only fetch metrics for devices that actually generate live power data
|
|
252
|
+
if dev_type != "COLLECTOR":
|
|
253
|
+
tasks.append(self._fetch_device_metrics(sn, entry))
|
|
254
|
+
|
|
255
|
+
await asyncio.gather(*tasks)
|
|
256
|
+
return sn, entry
|
|
257
|
+
|
|
258
|
+
async def get_all_device_data(self):
|
|
259
|
+
"""Fetches data with built-in retry logic and returns attempt count."""
|
|
260
|
+
|
|
261
|
+
for attempt in range(1, MAX_RETRIES + 1):
|
|
262
|
+
try:
|
|
263
|
+
data = await self._execute_fetch_all()
|
|
264
|
+
if data == "auth_failed":
|
|
265
|
+
return None # Hard fail, don't retry bad credentials
|
|
266
|
+
if data:
|
|
267
|
+
# ✅ Success
|
|
268
|
+
return {"data": data, "attempts": attempt}
|
|
269
|
+
|
|
270
|
+
# If we get here, data was None (soft failure). Trigger a retry manually.
|
|
271
|
+
raise aiohttp.ClientError("Fetch returned None, triggering retry.")
|
|
272
|
+
|
|
273
|
+
except (aiohttp.ClientError, TimeoutError) as err:
|
|
274
|
+
if attempt < MAX_RETRIES:
|
|
275
|
+
wait_time = attempt * RETRY_DELAY
|
|
276
|
+
_LOGGER.debug(
|
|
277
|
+
"HYXi Connection attempt %s/%s failed. Retrying in %ss... (Error: %s)",
|
|
278
|
+
attempt,
|
|
279
|
+
MAX_RETRIES,
|
|
280
|
+
wait_time,
|
|
281
|
+
err,
|
|
282
|
+
)
|
|
283
|
+
await asyncio.sleep(wait_time)
|
|
284
|
+
else:
|
|
285
|
+
_LOGGER.error(
|
|
286
|
+
"HYXi Cloud connection failed after %s attempts: %s",
|
|
287
|
+
MAX_RETRIES,
|
|
288
|
+
err,
|
|
289
|
+
)
|
|
290
|
+
except Exception:
|
|
291
|
+
_LOGGER.exception("HYXi Unexpected Code Crash:")
|
|
292
|
+
break
|
|
293
|
+
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
async def _execute_fetch_all(self):
|
|
297
|
+
"""The actual fetching logic moved to a private method for the retry loop."""
|
|
298
|
+
|
|
299
|
+
# 🧪 MOCK OVERRIDE START
|
|
300
|
+
mock_file = os.path.join(os.path.dirname(__file__), "mock_data.json")
|
|
301
|
+
|
|
302
|
+
# Helper function to read the file synchronously
|
|
303
|
+
def load_mock():
|
|
304
|
+
if os.path.exists(mock_file):
|
|
305
|
+
with open(mock_file, encoding="utf-8") as f:
|
|
306
|
+
return json.load(f)
|
|
307
|
+
return "NOT_FOUND"
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
mock_data = await asyncio.to_thread(load_mock)
|
|
311
|
+
if mock_data != "NOT_FOUND":
|
|
312
|
+
_LOGGER.warning(
|
|
313
|
+
"HYXi API 🧪: MOCK MODE ACTIVE - Successfully loaded %s", mock_file
|
|
314
|
+
)
|
|
315
|
+
return mock_data
|
|
316
|
+
except json.JSONDecodeError as e:
|
|
317
|
+
_LOGGER.error(
|
|
318
|
+
"HYXi API 🧪: MOCK FILE FOUND, BUT JSON IS INVALID! Error: %s", e
|
|
319
|
+
)
|
|
320
|
+
return None
|
|
321
|
+
except Exception as e:
|
|
322
|
+
_LOGGER.error("HYXi API 🧪: Unexpected error reading mock file: %s", e)
|
|
323
|
+
return None
|
|
324
|
+
# 🧪 MOCK OVERRIDE END
|
|
325
|
+
|
|
326
|
+
token_status = await self._refresh_token()
|
|
327
|
+
|
|
328
|
+
if token_status == "auth_failed":
|
|
329
|
+
return "auth_failed"
|
|
330
|
+
if not token_status:
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
results = {}
|
|
334
|
+
now = datetime.now(UTC).isoformat()
|
|
335
|
+
|
|
336
|
+
# 1. Get Plants
|
|
337
|
+
p_path = "/api/plant/v1/page"
|
|
338
|
+
async with self.session.post(
|
|
339
|
+
f"{self.base_url}{p_path}",
|
|
340
|
+
json={"pageSize": 10, "currentPage": 1},
|
|
341
|
+
headers=self._generate_headers(p_path, "POST"),
|
|
342
|
+
timeout=15,
|
|
343
|
+
) as resp_p:
|
|
344
|
+
resp_p.raise_for_status()
|
|
345
|
+
res_p = await resp_p.json()
|
|
346
|
+
|
|
347
|
+
if not res_p.get("success"):
|
|
348
|
+
# 🚀 If the server rejects the token, wipe it and force a retry!
|
|
349
|
+
if res_p.get("code") in ["A000002", "A000005"]:
|
|
350
|
+
_LOGGER.debug(
|
|
351
|
+
"HYXi Server rejected our token (A000002/A000005). Forcing immediate token refresh..."
|
|
352
|
+
)
|
|
353
|
+
self.token = None
|
|
354
|
+
self.token_expires_at = 0
|
|
355
|
+
# Raising this error kicks it back up to the retry loop
|
|
356
|
+
raise aiohttp.ClientError("Server rejected token")
|
|
357
|
+
|
|
358
|
+
_LOGGER.error("HYXi API Plant Fetch Rejected: %s", res_p)
|
|
359
|
+
return None
|
|
360
|
+
|
|
361
|
+
data_p = res_p.get("data", {})
|
|
362
|
+
plants = data_p.get("list", []) if isinstance(data_p, dict) else []
|
|
363
|
+
metric_tasks = []
|
|
364
|
+
|
|
365
|
+
for p in plants:
|
|
366
|
+
plant_id = p.get("plantId")
|
|
367
|
+
if not plant_id:
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
# 2. Get Devices
|
|
371
|
+
d_path = "/api/plant/v1/devicePage"
|
|
372
|
+
async with self.session.post(
|
|
373
|
+
f"{self.base_url}{d_path}",
|
|
374
|
+
json={"plantId": plant_id, "pageSize": 50, "currentPage": 1},
|
|
375
|
+
headers=self._generate_headers(d_path, "POST"),
|
|
376
|
+
timeout=15,
|
|
377
|
+
) as resp_d:
|
|
378
|
+
resp_d.raise_for_status()
|
|
379
|
+
res_d = await resp_d.json()
|
|
380
|
+
|
|
381
|
+
if not res_d.get("success"):
|
|
382
|
+
_LOGGER.error(
|
|
383
|
+
"HYXi API Device Fetch Rejected for Plant %s: %s", plant_id, res_d
|
|
384
|
+
)
|
|
385
|
+
continue
|
|
386
|
+
|
|
387
|
+
data_val = res_d.get("data", {})
|
|
388
|
+
devices = (
|
|
389
|
+
data_val
|
|
390
|
+
if isinstance(data_val, list)
|
|
391
|
+
else data_val.get("deviceList", [])
|
|
392
|
+
if isinstance(data_val, dict)
|
|
393
|
+
else []
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
for d in devices:
|
|
397
|
+
sn = d.get("deviceSn")
|
|
398
|
+
if not sn:
|
|
399
|
+
continue
|
|
400
|
+
|
|
401
|
+
dev_type = d.get("deviceType") or "UNKNOWN"
|
|
402
|
+
friendly_name = dev_type.replace("_", " ").title()
|
|
403
|
+
|
|
404
|
+
entry = {
|
|
405
|
+
"sn": sn,
|
|
406
|
+
"device_name": d.get("deviceName") or f"{friendly_name} {sn}",
|
|
407
|
+
"model": friendly_name,
|
|
408
|
+
"device_type_code": dev_type,
|
|
409
|
+
"sw_version": d.get("swVer"),
|
|
410
|
+
"hw_version": d.get("hwVer"),
|
|
411
|
+
"metrics": {"last_seen": now},
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
metric_tasks.append(self._fetch_all_for_device(sn, entry, dev_type))
|
|
415
|
+
|
|
416
|
+
# 3. Concurrent Metrics
|
|
417
|
+
if metric_tasks:
|
|
418
|
+
updated_entries = await asyncio.gather(*metric_tasks)
|
|
419
|
+
for sn, entry in updated_entries:
|
|
420
|
+
if sn:
|
|
421
|
+
results[sn] = entry
|
|
422
|
+
|
|
423
|
+
return results
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hyxi-cloud-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An async API client for HYXi Cloud.
|
|
5
|
+
Author-email: Veldkornet <veldkornet@outlook.com>
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: aiohttp>=3.8.0
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# hyxi-cloud-api
|
|
13
|
+
|
|
14
|
+
An asynchronous Python client for interacting with the HYXi Cloud API.
|
|
15
|
+
|
|
16
|
+
This library was primarily built to power the [HYXi Cloud Home Assistant Integration](LINK_TO_YOUR_HA_REPO_HERE), but it can be used in any Python 3.11+ project to fetch telemetry data from HYXi solar inverters and battery systems.
|
|
17
|
+
|
|
18
|
+
## 📦 Installation
|
|
19
|
+
|
|
20
|
+
You can install the package directly from PyPI:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install hyxi-cloud-api
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## 🚀 Quick Start
|
|
27
|
+
|
|
28
|
+
This library uses `aiohttp` for non-blocking network requests. You will need to provide your HYXi Cloud Access Key and Secret Key, along with an active `aiohttp.ClientSession`.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import asyncio
|
|
32
|
+
import aiohttp
|
|
33
|
+
from hyxi_cloud_api import HyxiApiClient
|
|
34
|
+
|
|
35
|
+
async def main():
|
|
36
|
+
# Replace with your actual HYXi Cloud credentials
|
|
37
|
+
ACCESS_KEY = "your_access_key"
|
|
38
|
+
SECRET_KEY = "your_secret_key"
|
|
39
|
+
BASE_URL = "[https://open.hyxicloud.com](https://open.hyxicloud.com)"
|
|
40
|
+
|
|
41
|
+
async with aiohttp.ClientSession() as session:
|
|
42
|
+
# 1. Initialize the client
|
|
43
|
+
client = HyxiApiClient(
|
|
44
|
+
access_key=ACCESS_KEY,
|
|
45
|
+
secret_key=SECRET_KEY,
|
|
46
|
+
base_url=BASE_URL,
|
|
47
|
+
session=session
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# 2. Fetch device data
|
|
51
|
+
try:
|
|
52
|
+
device_data = await client.get_all_device_data()
|
|
53
|
+
print("Successfully fetched HYXi data:")
|
|
54
|
+
print(device_data)
|
|
55
|
+
except Exception as e:
|
|
56
|
+
print(f"Error communicating with HYXi Cloud: {e}")
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
asyncio.run(main())
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## 🛠️ Requirements
|
|
63
|
+
* Python 3.11 or newer
|
|
64
|
+
* `aiohttp` >= 3.8.0
|
|
65
|
+
|
|
66
|
+
## ⚠️ Disclaimer
|
|
67
|
+
This is an unofficial, community-driven project. It is not affiliated with, endorsed by, or connected to HYXiPower in any official capacity. Use this software at your own risk.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/hyxi_cloud_api/__init__.py
|
|
5
|
+
src/hyxi_cloud_api/api.py
|
|
6
|
+
src/hyxi_cloud_api.egg-info/PKG-INFO
|
|
7
|
+
src/hyxi_cloud_api.egg-info/SOURCES.txt
|
|
8
|
+
src/hyxi_cloud_api.egg-info/dependency_links.txt
|
|
9
|
+
src/hyxi_cloud_api.egg-info/requires.txt
|
|
10
|
+
src/hyxi_cloud_api.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aiohttp>=3.8.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hyxi_cloud_api
|