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.
Files changed (49) hide show
  1. {hyxi_cloud_api-1.2.6/src/hyxi_cloud_api.egg-info → hyxi_cloud_api-1.2.7}/PKG-INFO +43 -1
  2. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/README.md +42 -0
  3. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/pyproject.toml +1 -1
  4. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/src/hyxi_cloud_api/__init__.py +1 -1
  5. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/src/hyxi_cloud_api/api.py +145 -5
  6. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7/src/hyxi_cloud_api.egg-info}/PKG-INFO +43 -1
  7. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/src/hyxi_cloud_api.egg-info/SOURCES.txt +1 -0
  8. hyxi_cloud_api-1.2.7/tests/test_subscriptions.py +163 -0
  9. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/LICENSE +0 -0
  10. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/setup.cfg +0 -0
  11. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/src/hyxi_cloud_api.egg-info/dependency_links.txt +0 -0
  12. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/src/hyxi_cloud_api.egg-info/requires.txt +0 -0
  13. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/src/hyxi_cloud_api.egg-info/top_level.txt +0 -0
  14. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_all_in_one.py +0 -0
  15. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_api.py +0 -0
  16. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_build_plant_tasks.py +0 -0
  17. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_caching.py +0 -0
  18. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_compute_derived_metrics.py +0 -0
  19. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_device_control.py +0 -0
  20. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_device_entry.py +0 -0
  21. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_devices_errors.py +0 -0
  22. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_discovery.py +0 -0
  23. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_execute_device_tasks.py +0 -0
  24. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_execute_metric_tasks.py +0 -0
  25. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_execute_metrics_and_map_alarms.py +0 -0
  26. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_extract_battery_info.py +0 -0
  27. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_extract_device_info_metadata.py +0 -0
  28. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_fetch_and_process_alarms.py +0 -0
  29. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_fetch_device_list_for_plant.py +0 -0
  30. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_fetch_plants.py +0 -0
  31. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_fetch_state.py +0 -0
  32. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_fetch_sub_device_list.py +0 -0
  33. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_filter_metrics.py +0 -0
  34. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_fuzz_parser.py +0 -0
  35. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_get_f.py +0 -0
  36. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_handle_back_discovery_alarm.py +0 -0
  37. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_info_errors.py +0 -0
  38. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_mask_id.py +0 -0
  39. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_metrics_errors.py +0 -0
  40. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_parse_data_list.py +0 -0
  41. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_parse_ems_kv.py +0 -0
  42. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_parser.py +0 -0
  43. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_process_alarms_and_back_discovery.py +0 -0
  44. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_process_devices_for_plant.py +0 -0
  45. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_sanitize_dict.py +0 -0
  46. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_sanitize_list.py +0 -0
  47. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_security_fix.py +0 -0
  48. {hyxi_cloud_api-1.2.6 → hyxi_cloud_api-1.2.7}/tests/test_token_errors.py +0 -0
  49. {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.6
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hyxi-cloud-api"
7
- version = "1.2.6"
7
+ version = "1.2.7"
8
8
  authors = [
9
9
  { name="Veldkornet", email="Veldkornet@users.noreply.github.com" },
10
10
  ]
@@ -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.6"
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
- token_status = await self._refresh_token()
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.6
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
@@ -42,5 +42,6 @@ tests/test_process_devices_for_plant.py
42
42
  tests/test_sanitize_dict.py
43
43
  tests/test_sanitize_list.py
44
44
  tests/test_security_fix.py
45
+ tests/test_subscriptions.py
45
46
  tests/test_token_errors.py
46
47
  tests/test_token_handling.py
@@ -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