hyxi-cloud-api 1.2.6__tar.gz → 1.2.7__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-1.2.6/src/hyxi_cloud_api.egg-info → hyxi_cloud_api-1.2.7}/PKG-INFO +43 -1
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/README.md +42 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/pyproject.toml +1 -1
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/src/hyxi_cloud_api/__init__.py +1 -1
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/src/hyxi_cloud_api/api.py +145 -5
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7/src/hyxi_cloud_api.egg-info}/PKG-INFO +43 -1
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/src/hyxi_cloud_api.egg-info/SOURCES.txt +1 -0
- hyxi_cloud_api-1.2.7/tests/test_subscriptions.py +163 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/LICENSE +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/setup.cfg +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/src/hyxi_cloud_api.egg-info/dependency_links.txt +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/src/hyxi_cloud_api.egg-info/requires.txt +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/src/hyxi_cloud_api.egg-info/top_level.txt +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_all_in_one.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_api.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_build_plant_tasks.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_caching.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_compute_derived_metrics.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_device_control.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_device_entry.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_devices_errors.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_discovery.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_execute_device_tasks.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_execute_metric_tasks.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_execute_metrics_and_map_alarms.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_extract_battery_info.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_extract_device_info_metadata.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_fetch_and_process_alarms.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_fetch_device_list_for_plant.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_fetch_plants.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_fetch_state.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_fetch_sub_device_list.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_filter_metrics.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_fuzz_parser.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_get_f.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_handle_back_discovery_alarm.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_info_errors.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_mask_id.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_metrics_errors.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_parse_data_list.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_parse_ems_kv.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_parser.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_process_alarms_and_back_discovery.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_process_devices_for_plant.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_sanitize_dict.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_sanitize_list.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_security_fix.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_token_errors.py +0 -0
- {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_token_handling.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hyxi-cloud-api
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.7
|
|
4
4
|
Summary: An async API client for HYXi Cloud.
|
|
5
5
|
Author-email: Veldkornet <Veldkornet@users.noreply.github.com>
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -81,6 +81,15 @@ if __name__ == "__main__":
|
|
|
81
81
|
asyncio.run(main())
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
+
HYXI Open API base URLs vary by region. The examples in this README use the
|
|
85
|
+
Europe endpoint by default.
|
|
86
|
+
|
|
87
|
+
| Node | Request Address |
|
|
88
|
+
| :--- | :--- |
|
|
89
|
+
| China | `https://open-cn.hyxicloud.com` |
|
|
90
|
+
| Europe (default) | `https://open.hyxicloud.com` |
|
|
91
|
+
| North America | `https://open-or.hyxicloud.com` |
|
|
92
|
+
|
|
84
93
|
## 🔧 Device Control
|
|
85
94
|
|
|
86
95
|
You can control inverter operating modes directly through the API. This requires a device serial number, which you can obtain from the device data response above.
|
|
@@ -109,6 +118,39 @@ except client.ControlError as e:
|
|
|
109
118
|
print(f"Control command failed: {e}")
|
|
110
119
|
```
|
|
111
120
|
|
|
121
|
+
## 🔔 Subscriptions
|
|
122
|
+
|
|
123
|
+
You can subscribe a callback URL to HYXI push notifications for real-time data,
|
|
124
|
+
alarms, and FCAS/frequency-modulation real-time data.
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
async def subscription_example(client):
|
|
128
|
+
callback_url = "https://your-public-callback-host/hyxi/callback"
|
|
129
|
+
device_sns = ["60700000000001", "60700000000002"]
|
|
130
|
+
|
|
131
|
+
real_time = await client.subscribe_real_time_data(
|
|
132
|
+
callback_url,
|
|
133
|
+
device_sns,
|
|
134
|
+
post_rate=60000, # milliseconds, 5000-3600000
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
alarm = await client.subscribe_alarm(
|
|
138
|
+
callback_url,
|
|
139
|
+
device_sns,
|
|
140
|
+
post_rate=60000, # milliseconds, 5000-3600000
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
fcas = await client.subscribe_fm_real_time_data(
|
|
144
|
+
callback_url,
|
|
145
|
+
device_sns,
|
|
146
|
+
post_rate=1, # hours, 1-6
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
await client.cancel_subscription(real_time["data"]["subscribeCode"])
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Subscription failures raise `HyxiApiClient.SubscriptionError`.
|
|
153
|
+
|
|
112
154
|
## 🛠️ Requirements
|
|
113
155
|
* Python 3.14 or newer
|
|
114
156
|
* `aiohttp` >= 3.13.3
|
|
@@ -57,6 +57,15 @@ if __name__ == "__main__":
|
|
|
57
57
|
asyncio.run(main())
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
+
HYXI Open API base URLs vary by region. The examples in this README use the
|
|
61
|
+
Europe endpoint by default.
|
|
62
|
+
|
|
63
|
+
| Node | Request Address |
|
|
64
|
+
| :--- | :--- |
|
|
65
|
+
| China | `https://open-cn.hyxicloud.com` |
|
|
66
|
+
| Europe (default) | `https://open.hyxicloud.com` |
|
|
67
|
+
| North America | `https://open-or.hyxicloud.com` |
|
|
68
|
+
|
|
60
69
|
## 🔧 Device Control
|
|
61
70
|
|
|
62
71
|
You can control inverter operating modes directly through the API. This requires a device serial number, which you can obtain from the device data response above.
|
|
@@ -85,6 +94,39 @@ except client.ControlError as e:
|
|
|
85
94
|
print(f"Control command failed: {e}")
|
|
86
95
|
```
|
|
87
96
|
|
|
97
|
+
## 🔔 Subscriptions
|
|
98
|
+
|
|
99
|
+
You can subscribe a callback URL to HYXI push notifications for real-time data,
|
|
100
|
+
alarms, and FCAS/frequency-modulation real-time data.
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
async def subscription_example(client):
|
|
104
|
+
callback_url = "https://your-public-callback-host/hyxi/callback"
|
|
105
|
+
device_sns = ["60700000000001", "60700000000002"]
|
|
106
|
+
|
|
107
|
+
real_time = await client.subscribe_real_time_data(
|
|
108
|
+
callback_url,
|
|
109
|
+
device_sns,
|
|
110
|
+
post_rate=60000, # milliseconds, 5000-3600000
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
alarm = await client.subscribe_alarm(
|
|
114
|
+
callback_url,
|
|
115
|
+
device_sns,
|
|
116
|
+
post_rate=60000, # milliseconds, 5000-3600000
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
fcas = await client.subscribe_fm_real_time_data(
|
|
120
|
+
callback_url,
|
|
121
|
+
device_sns,
|
|
122
|
+
post_rate=1, # hours, 1-6
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
await client.cancel_subscription(real_time["data"]["subscribeCode"])
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Subscription failures raise `HyxiApiClient.SubscriptionError`.
|
|
129
|
+
|
|
88
130
|
## 🛠️ Requirements
|
|
89
131
|
* Python 3.14 or newer
|
|
90
132
|
* `aiohttp` >= 3.13.3
|
|
@@ -5,5 +5,5 @@ from .api import HyxiApiClient
|
|
|
5
5
|
# Module-level alias so callers can do: from hyxi_cloud_api import VPP_ACTIVE_MODES
|
|
6
6
|
VPP_ACTIVE_MODES: frozenset[str] = HyxiApiClient.VPP_ACTIVE_MODES
|
|
7
7
|
|
|
8
|
-
__version__ = "1.2.
|
|
8
|
+
__version__ = "1.2.7"
|
|
9
9
|
__all__ = ["VPP_ACTIVE_MODES", "HyxiApiClient"]
|
|
@@ -19,6 +19,7 @@ import time
|
|
|
19
19
|
from collections import defaultdict
|
|
20
20
|
from dataclasses import dataclass, field
|
|
21
21
|
from typing import Any
|
|
22
|
+
from urllib.parse import urlparse
|
|
22
23
|
|
|
23
24
|
try:
|
|
24
25
|
from datetime import UTC, datetime
|
|
@@ -776,6 +777,9 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
776
777
|
class ControlError(Exception):
|
|
777
778
|
"""Raised when a device control command fails."""
|
|
778
779
|
|
|
780
|
+
class SubscriptionError(Exception):
|
|
781
|
+
"""Raised when a subscription request fails."""
|
|
782
|
+
|
|
779
783
|
def __init__(
|
|
780
784
|
self, access_key, secret_key, base_url, session: aiohttp.ClientSession
|
|
781
785
|
):
|
|
@@ -931,6 +935,14 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
931
935
|
_LOGGER.error("HYXI Token Request Failed: %s", e)
|
|
932
936
|
return False
|
|
933
937
|
|
|
938
|
+
async def _ensure_authenticated(self, error_cls: type[Exception]) -> None:
|
|
939
|
+
"""Refresh the API token or raise the provided domain error."""
|
|
940
|
+
token_status = await self._refresh_token()
|
|
941
|
+
if token_status == "auth_failed":
|
|
942
|
+
raise error_cls("Authentication failed")
|
|
943
|
+
if not token_status:
|
|
944
|
+
raise error_cls("Could not obtain API token")
|
|
945
|
+
|
|
934
946
|
async def _fetch_device_metrics(self, sn, entry):
|
|
935
947
|
"""Helper to fetch detailed metrics for a single device."""
|
|
936
948
|
q_path = "/api/device/v1/queryDeviceData"
|
|
@@ -1614,11 +1626,7 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
1614
1626
|
Values are strings per the developer docs ('' for idle/self-consumption,
|
|
1615
1627
|
a wattage like '100' for 1063/1064, '0'/'1' for switches).
|
|
1616
1628
|
"""
|
|
1617
|
-
|
|
1618
|
-
if token_status == "auth_failed":
|
|
1619
|
-
raise self.ControlError("Authentication failed")
|
|
1620
|
-
if not token_status:
|
|
1621
|
-
raise self.ControlError("Could not obtain API token")
|
|
1629
|
+
await self._ensure_authenticated(self.ControlError)
|
|
1622
1630
|
|
|
1623
1631
|
path = "/api/device/v2/control"
|
|
1624
1632
|
body = {
|
|
@@ -1641,6 +1649,133 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
1641
1649
|
)
|
|
1642
1650
|
return res
|
|
1643
1651
|
|
|
1652
|
+
# ── Subscription API ────────────────────────────────────────────────
|
|
1653
|
+
|
|
1654
|
+
async def _post_subscription(self, path: str, body: dict) -> dict:
|
|
1655
|
+
"""Send an authenticated subscription request."""
|
|
1656
|
+
await self._ensure_authenticated(self.SubscriptionError)
|
|
1657
|
+
|
|
1658
|
+
_LOGGER.debug("HYXI subscription request to %s", path)
|
|
1659
|
+
_, res = await self._request("POST", path, json=body)
|
|
1660
|
+
if res is None or not res.get("success"):
|
|
1661
|
+
code = res.get("code", "unknown") if res else "no_response"
|
|
1662
|
+
msg = res.get("msg", "") if res else ""
|
|
1663
|
+
raise self.SubscriptionError(
|
|
1664
|
+
f"subscription request failed (code={code}): {msg}"
|
|
1665
|
+
)
|
|
1666
|
+
return res
|
|
1667
|
+
|
|
1668
|
+
@staticmethod
|
|
1669
|
+
def _validate_subscription_device_sns(device_sn_list: list[str]) -> None:
|
|
1670
|
+
"""Validate subscription device SN list constraints."""
|
|
1671
|
+
if not device_sn_list:
|
|
1672
|
+
raise ValueError("device_sn_list must contain at least one device SN")
|
|
1673
|
+
if len(device_sn_list) > 1000:
|
|
1674
|
+
raise ValueError("device_sn_list cannot contain more than 1000 device SNs")
|
|
1675
|
+
|
|
1676
|
+
@staticmethod
|
|
1677
|
+
def _validate_callback_url(callback_url: str) -> None:
|
|
1678
|
+
"""Validate the subscriber callback URL."""
|
|
1679
|
+
if not callback_url or not callback_url.strip():
|
|
1680
|
+
raise ValueError("callback_url must be a non-empty string")
|
|
1681
|
+
parsed = urlparse(callback_url.strip())
|
|
1682
|
+
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
|
1683
|
+
raise ValueError("callback_url must be a valid http/https URL")
|
|
1684
|
+
|
|
1685
|
+
@staticmethod
|
|
1686
|
+
def _validate_post_rate_ms(post_rate: int) -> None:
|
|
1687
|
+
"""Validate millisecond subscription push rate."""
|
|
1688
|
+
if not 5000 <= post_rate <= 3600000:
|
|
1689
|
+
raise ValueError("post_rate must be between 5000 and 3600000 milliseconds")
|
|
1690
|
+
|
|
1691
|
+
async def subscribe_real_time_data(
|
|
1692
|
+
self,
|
|
1693
|
+
callback_url: str,
|
|
1694
|
+
device_sn_list: list[str],
|
|
1695
|
+
post_rate: int,
|
|
1696
|
+
data_code_list: list[str] | None = None,
|
|
1697
|
+
) -> dict:
|
|
1698
|
+
"""Subscribe to real-time device data notifications.
|
|
1699
|
+
|
|
1700
|
+
Endpoint: POST /api/subscribe/v1/realTimeData
|
|
1701
|
+
"""
|
|
1702
|
+
self._validate_callback_url(callback_url)
|
|
1703
|
+
self._validate_subscription_device_sns(device_sn_list)
|
|
1704
|
+
self._validate_post_rate_ms(post_rate)
|
|
1705
|
+
|
|
1706
|
+
body: dict[str, Any] = {
|
|
1707
|
+
"callBackUrl": callback_url,
|
|
1708
|
+
"deviceSnList": device_sn_list,
|
|
1709
|
+
"postRate": int(post_rate),
|
|
1710
|
+
}
|
|
1711
|
+
if data_code_list is not None:
|
|
1712
|
+
body["dataCodeList"] = data_code_list
|
|
1713
|
+
|
|
1714
|
+
return await self._post_subscription("/api/subscribe/v1/realTimeData", body)
|
|
1715
|
+
|
|
1716
|
+
async def subscribe_alarm(
|
|
1717
|
+
self,
|
|
1718
|
+
callback_url: str,
|
|
1719
|
+
device_sn_list: list[str],
|
|
1720
|
+
post_rate: int,
|
|
1721
|
+
alarm_code_list: list[str] | None = None,
|
|
1722
|
+
) -> dict:
|
|
1723
|
+
"""Subscribe to device alarm notifications.
|
|
1724
|
+
|
|
1725
|
+
Endpoint: POST /api/subscribe/v1/alarm
|
|
1726
|
+
"""
|
|
1727
|
+
self._validate_callback_url(callback_url)
|
|
1728
|
+
self._validate_subscription_device_sns(device_sn_list)
|
|
1729
|
+
self._validate_post_rate_ms(post_rate)
|
|
1730
|
+
|
|
1731
|
+
body: dict[str, Any] = {
|
|
1732
|
+
"callBackUrl": callback_url,
|
|
1733
|
+
"deviceSnList": device_sn_list,
|
|
1734
|
+
"postRate": int(post_rate),
|
|
1735
|
+
}
|
|
1736
|
+
if alarm_code_list is not None:
|
|
1737
|
+
body["alarmCodeList"] = alarm_code_list
|
|
1738
|
+
|
|
1739
|
+
return await self._post_subscription("/api/subscribe/v1/alarm", body)
|
|
1740
|
+
|
|
1741
|
+
async def subscribe_fm_real_time_data(
|
|
1742
|
+
self,
|
|
1743
|
+
callback_url: str,
|
|
1744
|
+
device_sn_list: list[str],
|
|
1745
|
+
post_rate: int,
|
|
1746
|
+
) -> dict:
|
|
1747
|
+
"""Subscribe to FCAS/frequency modulation real-time device data.
|
|
1748
|
+
|
|
1749
|
+
Endpoint: POST /api/subscribe/v1/FMRealTimeData
|
|
1750
|
+
|
|
1751
|
+
Args:
|
|
1752
|
+
post_rate: Push rate in hours. Must be between 1 and 6.
|
|
1753
|
+
"""
|
|
1754
|
+
self._validate_callback_url(callback_url)
|
|
1755
|
+
self._validate_subscription_device_sns(device_sn_list)
|
|
1756
|
+
if not 1 <= post_rate <= 6:
|
|
1757
|
+
raise ValueError("post_rate must be between 1 and 6 hours")
|
|
1758
|
+
|
|
1759
|
+
body = {
|
|
1760
|
+
"callBackUrl": callback_url,
|
|
1761
|
+
"deviceSnList": device_sn_list,
|
|
1762
|
+
"postRate": int(post_rate),
|
|
1763
|
+
}
|
|
1764
|
+
return await self._post_subscription("/api/subscribe/v1/FMRealTimeData", body)
|
|
1765
|
+
|
|
1766
|
+
async def cancel_subscription(self, subscribe_code: str) -> dict:
|
|
1767
|
+
"""Cancel a subscription by subscription code.
|
|
1768
|
+
|
|
1769
|
+
Endpoint: POST /api/subscribe/v1/cancel
|
|
1770
|
+
"""
|
|
1771
|
+
if not subscribe_code or not subscribe_code.strip():
|
|
1772
|
+
raise ValueError("subscribe_code must be a non-empty string")
|
|
1773
|
+
|
|
1774
|
+
return await self._post_subscription(
|
|
1775
|
+
"/api/subscribe/v1/cancel",
|
|
1776
|
+
{"subscribeCode": subscribe_code.strip()},
|
|
1777
|
+
)
|
|
1778
|
+
|
|
1644
1779
|
async def set_mode_idle(self, device_sn: str) -> dict:
|
|
1645
1780
|
"""Set inverter to Idle mode (controlId 1062).
|
|
1646
1781
|
|
|
@@ -1779,3 +1914,8 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
1779
1914
|
res.get("success"),
|
|
1780
1915
|
)
|
|
1781
1916
|
return res
|
|
1917
|
+
|
|
1918
|
+
@staticmethod
|
|
1919
|
+
def compute_derived_metrics(m_raw: dict, device_type: str = "") -> dict:
|
|
1920
|
+
"""Calculate derived metrics (grid import/export, bat charging/discharging, etc.) from raw metrics."""
|
|
1921
|
+
return _compute_derived_metrics(m_raw, device_type)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hyxi-cloud-api
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.7
|
|
4
4
|
Summary: An async API client for HYXi Cloud.
|
|
5
5
|
Author-email: Veldkornet <Veldkornet@users.noreply.github.com>
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -81,6 +81,15 @@ if __name__ == "__main__":
|
|
|
81
81
|
asyncio.run(main())
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
+
HYXI Open API base URLs vary by region. The examples in this README use the
|
|
85
|
+
Europe endpoint by default.
|
|
86
|
+
|
|
87
|
+
| Node | Request Address |
|
|
88
|
+
| :--- | :--- |
|
|
89
|
+
| China | `https://open-cn.hyxicloud.com` |
|
|
90
|
+
| Europe (default) | `https://open.hyxicloud.com` |
|
|
91
|
+
| North America | `https://open-or.hyxicloud.com` |
|
|
92
|
+
|
|
84
93
|
## 🔧 Device Control
|
|
85
94
|
|
|
86
95
|
You can control inverter operating modes directly through the API. This requires a device serial number, which you can obtain from the device data response above.
|
|
@@ -109,6 +118,39 @@ except client.ControlError as e:
|
|
|
109
118
|
print(f"Control command failed: {e}")
|
|
110
119
|
```
|
|
111
120
|
|
|
121
|
+
## 🔔 Subscriptions
|
|
122
|
+
|
|
123
|
+
You can subscribe a callback URL to HYXI push notifications for real-time data,
|
|
124
|
+
alarms, and FCAS/frequency-modulation real-time data.
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
async def subscription_example(client):
|
|
128
|
+
callback_url = "https://your-public-callback-host/hyxi/callback"
|
|
129
|
+
device_sns = ["60700000000001", "60700000000002"]
|
|
130
|
+
|
|
131
|
+
real_time = await client.subscribe_real_time_data(
|
|
132
|
+
callback_url,
|
|
133
|
+
device_sns,
|
|
134
|
+
post_rate=60000, # milliseconds, 5000-3600000
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
alarm = await client.subscribe_alarm(
|
|
138
|
+
callback_url,
|
|
139
|
+
device_sns,
|
|
140
|
+
post_rate=60000, # milliseconds, 5000-3600000
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
fcas = await client.subscribe_fm_real_time_data(
|
|
144
|
+
callback_url,
|
|
145
|
+
device_sns,
|
|
146
|
+
post_rate=1, # hours, 1-6
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
await client.cancel_subscription(real_time["data"]["subscribeCode"])
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Subscription failures raise `HyxiApiClient.SubscriptionError`.
|
|
153
|
+
|
|
112
154
|
## 🛠️ Requirements
|
|
113
155
|
* Python 3.14 or newer
|
|
114
156
|
* `aiohttp` >= 3.13.3
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Tests for the HYXI subscription API methods."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
if "aiohttp" not in sys.modules or not hasattr(sys.modules["aiohttp"], "ClientError"):
|
|
9
|
+
m = MagicMock()
|
|
10
|
+
m.ClientError = Exception
|
|
11
|
+
m.ClientResponseError = type("ClientResponseError", (Exception,), {})
|
|
12
|
+
m.ContentTypeError = type("ContentTypeError", (Exception,), {})
|
|
13
|
+
sys.modules["aiohttp"] = m
|
|
14
|
+
|
|
15
|
+
from src.hyxi_cloud_api.api import HyxiApiClient
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _client() -> HyxiApiClient:
|
|
19
|
+
api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
|
|
20
|
+
api._refresh_token = AsyncMock(return_value=True)
|
|
21
|
+
api._request = AsyncMock(
|
|
22
|
+
return_value=(
|
|
23
|
+
200,
|
|
24
|
+
{
|
|
25
|
+
"code": "0",
|
|
26
|
+
"msg": "Success",
|
|
27
|
+
"data": {"subscribeCode": "sub-code"},
|
|
28
|
+
"success": True,
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
return api
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.mark.asyncio
|
|
36
|
+
async def test_subscribe_real_time_data():
|
|
37
|
+
"""Test real-time data subscription payload."""
|
|
38
|
+
api = _client()
|
|
39
|
+
|
|
40
|
+
result = await api.subscribe_real_time_data(
|
|
41
|
+
"https://example.com/hyxi",
|
|
42
|
+
["SN1", "SN2"],
|
|
43
|
+
60000,
|
|
44
|
+
data_code_list=["pv1p"],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
assert result["data"]["subscribeCode"] == "sub-code"
|
|
48
|
+
api._request.assert_called_once()
|
|
49
|
+
call_args = api._request.call_args
|
|
50
|
+
assert call_args.args[:2] == ("POST", "/api/subscribe/v1/realTimeData")
|
|
51
|
+
body = call_args.kwargs["json"]
|
|
52
|
+
assert body == {
|
|
53
|
+
"callBackUrl": "https://example.com/hyxi",
|
|
54
|
+
"deviceSnList": ["SN1", "SN2"],
|
|
55
|
+
"postRate": 60000,
|
|
56
|
+
"dataCodeList": ["pv1p"],
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.mark.asyncio
|
|
61
|
+
async def test_subscribe_alarm():
|
|
62
|
+
"""Test alarm subscription payload."""
|
|
63
|
+
api = _client()
|
|
64
|
+
|
|
65
|
+
await api.subscribe_alarm(
|
|
66
|
+
"https://example.com/hyxi",
|
|
67
|
+
["SN1"],
|
|
68
|
+
5000,
|
|
69
|
+
alarm_code_list=["704"],
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
call_args = api._request.call_args
|
|
73
|
+
assert call_args.args[:2] == ("POST", "/api/subscribe/v1/alarm")
|
|
74
|
+
body = call_args.kwargs["json"]
|
|
75
|
+
assert body == {
|
|
76
|
+
"callBackUrl": "https://example.com/hyxi",
|
|
77
|
+
"deviceSnList": ["SN1"],
|
|
78
|
+
"postRate": 5000,
|
|
79
|
+
"alarmCodeList": ["704"],
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.mark.asyncio
|
|
84
|
+
async def test_subscribe_fm_real_time_data():
|
|
85
|
+
"""Test FCAS real-time data subscription payload."""
|
|
86
|
+
api = _client()
|
|
87
|
+
|
|
88
|
+
await api.subscribe_fm_real_time_data("https://example.com/hyxi", ["SN1"], 1)
|
|
89
|
+
|
|
90
|
+
call_args = api._request.call_args
|
|
91
|
+
assert call_args.args[:2] == ("POST", "/api/subscribe/v1/FMRealTimeData")
|
|
92
|
+
body = call_args.kwargs["json"]
|
|
93
|
+
assert body == {
|
|
94
|
+
"callBackUrl": "https://example.com/hyxi",
|
|
95
|
+
"deviceSnList": ["SN1"],
|
|
96
|
+
"postRate": 1,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.mark.asyncio
|
|
101
|
+
async def test_cancel_subscription():
|
|
102
|
+
"""Test subscription cancellation payload."""
|
|
103
|
+
api = _client()
|
|
104
|
+
|
|
105
|
+
await api.cancel_subscription(" sub-code ")
|
|
106
|
+
|
|
107
|
+
call_args = api._request.call_args
|
|
108
|
+
assert call_args.args[:2] == ("POST", "/api/subscribe/v1/cancel")
|
|
109
|
+
assert call_args.kwargs["json"] == {"subscribeCode": "sub-code"}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@pytest.mark.asyncio
|
|
113
|
+
async def test_subscription_error_on_auth_failed():
|
|
114
|
+
"""Test SubscriptionError is raised when authentication fails."""
|
|
115
|
+
api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
|
|
116
|
+
api._refresh_token = AsyncMock(return_value="auth_failed")
|
|
117
|
+
|
|
118
|
+
with pytest.raises(api.SubscriptionError, match="Authentication failed"):
|
|
119
|
+
await api.subscribe_alarm("https://example.com/hyxi", ["SN1"], 60000)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@pytest.mark.asyncio
|
|
123
|
+
async def test_subscription_error_on_api_failure():
|
|
124
|
+
"""Test SubscriptionError is raised when API returns success=False."""
|
|
125
|
+
api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
|
|
126
|
+
api._refresh_token = AsyncMock(return_value=True)
|
|
127
|
+
api._request = AsyncMock(
|
|
128
|
+
return_value=(
|
|
129
|
+
200,
|
|
130
|
+
{"success": False, "code": "C000001", "msg": "Parameter error"},
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
with pytest.raises(api.SubscriptionError, match="subscription request failed"):
|
|
135
|
+
await api.subscribe_real_time_data("https://example.com/hyxi", ["SN1"], 60000)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@pytest.mark.asyncio
|
|
139
|
+
async def test_subscription_validation():
|
|
140
|
+
"""Test subscription input validation."""
|
|
141
|
+
api = _client()
|
|
142
|
+
|
|
143
|
+
with pytest.raises(ValueError, match="callback_url must be a non-empty string"):
|
|
144
|
+
await api.subscribe_alarm("", ["SN1"], 60000)
|
|
145
|
+
|
|
146
|
+
with pytest.raises(ValueError, match="at least one device SN"):
|
|
147
|
+
await api.subscribe_alarm("https://example.com/hyxi", [], 60000)
|
|
148
|
+
|
|
149
|
+
with pytest.raises(ValueError, match="more than 1000"):
|
|
150
|
+
await api.subscribe_alarm(
|
|
151
|
+
"https://example.com/hyxi",
|
|
152
|
+
[f"SN{i}" for i in range(1001)],
|
|
153
|
+
60000,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
with pytest.raises(ValueError, match="between 5000 and 3600000 milliseconds"):
|
|
157
|
+
await api.subscribe_real_time_data("https://example.com/hyxi", ["SN1"], 4999)
|
|
158
|
+
|
|
159
|
+
with pytest.raises(ValueError, match="between 1 and 6 hours"):
|
|
160
|
+
await api.subscribe_fm_real_time_data("https://example.com/hyxi", ["SN1"], 7)
|
|
161
|
+
|
|
162
|
+
with pytest.raises(ValueError, match="subscribe_code must be a non-empty string"):
|
|
163
|
+
await api.cancel_subscription(" ")
|
|
File without changes
|
|
File without changes
|
{hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/src/hyxi_cloud_api.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_process_alarms_and_back_discovery.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|