hyxi-cloud-api 1.4.1__tar.gz → 1.4.2__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 (62) hide show
  1. {hyxi_cloud_api-1.4.1/src/hyxi_cloud_api.egg-info → hyxi_cloud_api-1.4.2}/PKG-INFO +1 -1
  2. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/pyproject.toml +1 -1
  3. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/src/hyxi_cloud_api/__init__.py +1 -1
  4. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/src/hyxi_cloud_api/api.py +78 -59
  5. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2/src/hyxi_cloud_api.egg-info}/PKG-INFO +1 -1
  6. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_additional_coverage.py +2 -2
  7. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_api.py +6 -4
  8. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_device_control.py +3 -3
  9. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_execute_metric_tasks.py +12 -7
  10. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_execute_metrics_and_map_alarms.py +11 -9
  11. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_handle_back_discovery_alarm.py +9 -14
  12. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_process_alarms_and_back_discovery.py +2 -2
  13. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_process_devices_for_plant.py +2 -2
  14. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_subscriptions.py +1 -1
  15. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/LICENSE +0 -0
  16. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/README.md +0 -0
  17. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/setup.cfg +0 -0
  18. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/src/hyxi_cloud_api.egg-info/SOURCES.txt +0 -0
  19. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/src/hyxi_cloud_api.egg-info/dependency_links.txt +0 -0
  20. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/src/hyxi_cloud_api.egg-info/requires.txt +0 -0
  21. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/src/hyxi_cloud_api.egg-info/top_level.txt +0 -0
  22. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_alarm_push.py +0 -0
  23. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_all_in_one.py +0 -0
  24. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_api_imports.py +0 -0
  25. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_build_plant_tasks.py +0 -0
  26. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_caching.py +0 -0
  27. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_compute_battery_metrics.py +0 -0
  28. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_compute_derived_metrics.py +0 -0
  29. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_compute_grid_metrics.py +0 -0
  30. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_compute_load_metrics.py +0 -0
  31. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_compute_pv_metrics.py +0 -0
  32. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_device_entry.py +0 -0
  33. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_devices_errors.py +0 -0
  34. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_discovery.py +0 -0
  35. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_ensure_authenticated.py +0 -0
  36. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_execute_device_tasks.py +0 -0
  37. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_extract_battery_info.py +0 -0
  38. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_extract_device_info_metadata.py +0 -0
  39. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_fetch_and_process_alarms.py +0 -0
  40. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_fetch_device_list_for_plant.py +0 -0
  41. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_fetch_plants.py +0 -0
  42. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_fetch_state.py +0 -0
  43. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_fetch_sub_device_list.py +0 -0
  44. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_filter_metrics.py +0 -0
  45. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_flatten_nested_push_device.py +0 -0
  46. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_fuzz_parser.py +0 -0
  47. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_get_f.py +0 -0
  48. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_info_errors.py +0 -0
  49. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_mask_id.py +0 -0
  50. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_metrics_errors.py +0 -0
  51. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_parse_data_list.py +0 -0
  52. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_parse_ems_kv.py +0 -0
  53. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_parser.py +0 -0
  54. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_push.py +0 -0
  55. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_sanitize_dict.py +0 -0
  56. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_sanitize_list.py +0 -0
  57. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_security_fix.py +0 -0
  58. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_set_log_salt.py +0 -0
  59. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_token_errors.py +0 -0
  60. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_token_handling.py +0 -0
  61. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_validate_post_rate_ms.py +0 -0
  62. {hyxi_cloud_api-1.4.1 → hyxi_cloud_api-1.4.2}/tests/test_validate_subscription_device_sns.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyxi-cloud-api
3
- Version: 1.4.1
3
+ Version: 1.4.2
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hyxi-cloud-api"
7
- version = "1.4.1"
7
+ version = "1.4.2"
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.4.1"
8
+ __version__ = "1.4.2"
9
9
  __all__ = ["VPP_ACTIVE_MODES", "HyxiApiClient"]
@@ -526,6 +526,23 @@ INTERNAL_ERROR_MAP = {
526
526
  "C999999": "Service exception, please contact the service provide",
527
527
  }
528
528
 
529
+ _TOKEN_REJECTION_CODES = frozenset(
530
+ (
531
+ "A000001",
532
+ "A000002",
533
+ "A000003",
534
+ "A000004",
535
+ "A000005",
536
+ "A000006",
537
+ "A000007",
538
+ "A000008",
539
+ "A000009",
540
+ "A000010",
541
+ "A000011",
542
+ "C000006",
543
+ )
544
+ )
545
+
529
546
 
530
547
  # Official HYXI Device Type Reference Table
531
548
  DEVICE_TYPE_MAP = {
@@ -1212,21 +1229,16 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1212
1229
  response.raise_for_status()
1213
1230
  res = await response.json()
1214
1231
 
1215
- if (
1216
- not is_token_request
1217
- and not res.get("success")
1218
- and res.get("code")
1219
- and (
1220
- res.get("code").startswith("A0000") or res.get("code") == "C000006"
1221
- )
1222
- ):
1223
- _LOGGER.debug(
1224
- "HYXI Server rejected our token (%s). Forcing immediate token refresh...",
1225
- res.get("code"),
1226
- )
1227
- self.token = None
1228
- self.token_expires_at = 0
1229
- raise TokenRejectedError("Server rejected token")
1232
+ if not is_token_request and not res.get("success") and res.get("code"):
1233
+ api_code = res.get("code")
1234
+ if api_code in _TOKEN_REJECTION_CODES:
1235
+ _LOGGER.debug(
1236
+ "HYXI Server rejected our token (%s). Forcing immediate token refresh...",
1237
+ api_code,
1238
+ )
1239
+ self.token = None
1240
+ self.token_expires_at = 0
1241
+ raise TokenRejectedError("Server rejected token")
1230
1242
 
1231
1243
  return status, res
1232
1244
 
@@ -1302,6 +1314,28 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1302
1314
  if not token_status:
1303
1315
  raise error_cls("Could not obtain API token")
1304
1316
 
1317
+ async def _execute_with_auth_retry(
1318
+ self, method: str, path: str, error_cls: type[Exception], **kwargs
1319
+ ) -> dict:
1320
+ """Execute a request with automatic re-authentication if the token is rejected."""
1321
+ await self._ensure_authenticated(error_cls)
1322
+ try:
1323
+ _, res = await self._request(method, path, **kwargs)
1324
+ except TokenRejectedError:
1325
+ _LOGGER.debug(
1326
+ "Token rejected, forcing re-authentication and retrying request to %s...",
1327
+ path,
1328
+ )
1329
+ await self._ensure_authenticated(error_cls)
1330
+ _, res = await self._request(method, path, **kwargs)
1331
+
1332
+ if res is None or not res.get("success"):
1333
+ code = res.get("code", "unknown") if res else "no_response"
1334
+ msg = res.get("msg", "") if res else ""
1335
+ raise error_cls(f"request failed (code={code}): {msg}")
1336
+
1337
+ return res
1338
+
1305
1339
  async def _fetch_device_metrics(self, sn, entry):
1306
1340
  """Helper to fetch detailed metrics for a single device."""
1307
1341
  q_path = "/api/device/v1/queryDeviceData"
@@ -1539,7 +1573,7 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1539
1573
 
1540
1574
  self._update_discovery_cache(sn, entry)
1541
1575
 
1542
- state.metric_tasks.append(self._fetch_all_for_device(sn, entry, dev_type))
1576
+ state.metric_tasks.append((sn, entry, dev_type))
1543
1577
 
1544
1578
  # 🚀 DEEP DISCOVERY: If this is a Collector, DMU, or Inverter, find its children!
1545
1579
  if dev_type in _parent_device_types:
@@ -1606,10 +1640,8 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1606
1640
 
1607
1641
  self._update_discovery_cache(sn, entry)
1608
1642
 
1609
- # These are real devices, so fetch their metrics/info
1610
- state.metric_tasks.append(
1611
- self._fetch_all_for_device(sn, entry, raw_type)
1612
- )
1643
+ # These are real devices, so store args for later metric/info fetch
1644
+ state.metric_tasks.append((sn, entry, raw_type))
1613
1645
 
1614
1646
  except TokenRejectedError: # pylint: disable=try-except-raise
1615
1647
  raise
@@ -1767,11 +1799,10 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1767
1799
  if device_fetch_tasks:
1768
1800
  await asyncio.gather(*device_fetch_tasks)
1769
1801
 
1770
- @staticmethod
1771
- async def _execute_metric_tasks(plant_alarms, state: FetchState):
1802
+ async def _execute_metric_tasks(self, plant_alarms, state: FetchState):
1772
1803
  """Helper to conditionally execute metrics and map alarms."""
1773
1804
  if state.metric_tasks:
1774
- await HyxiApiClient._execute_metrics_and_map_alarms(plant_alarms, state)
1805
+ await self._execute_metrics_and_map_alarms(plant_alarms, state)
1775
1806
 
1776
1807
  async def _process_plants_data(
1777
1808
  self, state: FetchState, allow_back_discovery: bool = False
@@ -1808,11 +1839,11 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1808
1839
  state.discovered_sns.add(sn)
1809
1840
 
1810
1841
  entry, dev_type = HyxiApiClient._build_device_entry(sn, a, state.now)
1811
- state.metric_tasks.append(self._fetch_all_for_device(sn, entry, dev_type))
1842
+ state.metric_tasks.append((sn, entry, dev_type))
1812
1843
 
1813
1844
  # 🚀 DEEP BACK-DISCOVERY: If this is a parent, search for ITS children too!
1814
1845
  if dev_type in _parent_device_types:
1815
- sub_device_tasks.append(self._fetch_sub_devices(sn, state))
1846
+ sub_device_tasks.append((sn, state))
1816
1847
 
1817
1848
  async def _process_alarms_and_back_discovery(
1818
1849
  self,
@@ -1841,12 +1872,12 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1841
1872
  )
1842
1873
 
1843
1874
  if sub_device_tasks:
1844
- await asyncio.gather(*sub_device_tasks)
1875
+ tasks = [self._fetch_sub_devices(sn, s) for sn, s in sub_device_tasks]
1876
+ await asyncio.gather(*tasks)
1845
1877
 
1846
1878
  return plant_alarms
1847
1879
 
1848
- @staticmethod
1849
- async def _execute_metrics_and_map_alarms(plant_alarms, state: FetchState):
1880
+ async def _execute_metrics_and_map_alarms(self, plant_alarms, state: FetchState):
1850
1881
  """Helper to execute metric tasks and map alarms to devices."""
1851
1882
  # Precompute alarm mapping to optimize from O(N*M) to O(N+M)
1852
1883
  alarms_by_sn = defaultdict(list)
@@ -1855,12 +1886,19 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1855
1886
  if sn:
1856
1887
  alarms_by_sn[sn].append(a)
1857
1888
 
1858
- updated_entries = await asyncio.gather(*state.metric_tasks)
1859
- for sn, entry in updated_entries:
1860
- if sn:
1861
- # Map the relevant active alarms to this specific device
1862
- entry["alarms"] = alarms_by_sn.get(sn, [])
1863
- state.results[sn] = entry
1889
+ # Convert argument tuples to coroutines just in time
1890
+ tasks = [
1891
+ self._fetch_all_for_device(sn, entry, dev_type)
1892
+ for sn, entry, dev_type in state.metric_tasks
1893
+ ]
1894
+
1895
+ if tasks:
1896
+ updated_entries = await asyncio.gather(*tasks)
1897
+ for sn, entry in updated_entries:
1898
+ if sn:
1899
+ # Map the relevant active alarms to this specific device
1900
+ entry["alarms"] = alarms_by_sn.get(sn, [])
1901
+ state.results[sn] = entry
1864
1902
 
1865
1903
  async def _execute_fetch_all(
1866
1904
  self, allow_back_discovery: bool = False, force_discovery: bool = False
@@ -1907,9 +1945,7 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1907
1945
  "hw_version": info.get("hw_version"),
1908
1946
  "metrics": {"last_seen": state.now},
1909
1947
  }
1910
- state.metric_tasks.append(
1911
- self._fetch_all_for_device(sn, entry, info["device_type_code"])
1912
- )
1948
+ state.metric_tasks.append((sn, entry, info["device_type_code"]))
1913
1949
  state.discovered_sns = set(info_cache.keys())
1914
1950
 
1915
1951
  # Fetch alarms (to allow back-discovery if enabled) and metrics
@@ -1985,8 +2021,6 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1985
2021
  _LOGGER.warning("set_device_control called with empty settings")
1986
2022
  return {}
1987
2023
 
1988
- await self._ensure_authenticated(self.ControlError)
1989
-
1990
2024
  path = "/api/device/v2/control"
1991
2025
  body = {
1992
2026
  "deviceControlMap": {device_sn: {str(k): v for k, v in control_map.items()}}
@@ -1996,33 +2030,18 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1996
2030
  _mask_id(device_sn),
1997
2031
  body["deviceControlMap"][device_sn],
1998
2032
  )
1999
- _, res = await self._request("POST", path, json=body)
2000
- if res is None or not res.get("success"):
2001
- code = res.get("code", "unknown") if res else "no_response"
2002
- msg = res.get("msg", "") if res else ""
2003
- raise self.ControlError(f"controlMap write failed (code={code}): {msg}")
2004
- _LOGGER.debug(
2005
- "HYXI CONTROL response for %s: success=%s",
2006
- _mask_id(device_sn),
2007
- res.get("success"),
2033
+ return await self._execute_with_auth_retry(
2034
+ "POST", path, self.ControlError, json=body
2008
2035
  )
2009
- return res
2010
2036
 
2011
2037
  # ── Subscription API ────────────────────────────────────────────────
2012
2038
 
2013
2039
  async def _post_subscription(self, path: str, body: dict) -> dict:
2014
2040
  """Send an authenticated subscription request."""
2015
- await self._ensure_authenticated(self.SubscriptionError)
2016
-
2017
2041
  _LOGGER.debug("HYXI subscription request to %s", path)
2018
- _, res = await self._request("POST", path, json=body)
2019
- if res is None or not res.get("success"):
2020
- code = res.get("code", "unknown") if res else "no_response"
2021
- msg = res.get("msg", "") if res else ""
2022
- raise self.SubscriptionError(
2023
- f"subscription request failed (code={code}): {msg}"
2024
- )
2025
- return res
2042
+ return await self._execute_with_auth_retry(
2043
+ "POST", path, self.SubscriptionError, json=body
2044
+ )
2026
2045
 
2027
2046
  @staticmethod
2028
2047
  def _validate_subscription_device_sns(device_sn_list: list[str]) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyxi-cloud-api
3
- Version: 1.4.1
3
+ Version: 1.4.2
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
@@ -34,8 +34,8 @@ async def test_fetch_sub_devices_coverage(caplog):
34
34
  # PARENT_SN is masked to ca7b0e8c
35
35
  assert "HYXI Found 3 sub-devices under " in caplog.text
36
36
  # Verify duplicate/empty check worked (line 1253)
37
- assert api._fetch_all_for_device.call_count == 1
38
- api._fetch_all_for_device.assert_called_once_with(
37
+ assert len(state.metric_tasks) == 1
38
+ assert state.metric_tasks[0] == (
39
39
  "NEW_SN",
40
40
  {
41
41
  "sn": "NEW_SN",
@@ -337,13 +337,15 @@ async def test_execute_fetch_all_concurrent():
337
337
  # Mock the _fetch_devices_for_plant internal call
338
338
  # It must return an awaitable AND add an awaitable to state.metric_tasks
339
339
  async def mock_fetch_devices(plant_id, state):
340
- async def mock_metric_task():
341
- return (f"SN_{plant_id}", {"device_name": f"Device {plant_id}"})
342
-
343
- state.metric_tasks.append(mock_metric_task())
340
+ state.metric_tasks.append(
341
+ (f"SN_{plant_id}", {"device_name": f"Device {plant_id}"}, "MOCK")
342
+ )
344
343
  return None
345
344
 
346
345
  api._fetch_devices_for_plant = MagicMock(side_effect=mock_fetch_devices)
346
+ api._fetch_all_for_device = AsyncMock(
347
+ side_effect=lambda sn, entry, dev_type: (sn, entry)
348
+ )
347
349
 
348
350
  # Configure the mock response to simulate aiohttp's async context manager.
349
351
  mock_response = MagicMock()
@@ -145,7 +145,7 @@ async def test_control_error_on_api_failure():
145
145
  )
146
146
  )
147
147
 
148
- with pytest.raises(api.ControlError, match="controlMap write failed"):
148
+ with pytest.raises(api.ControlError, match="request failed"):
149
149
  await api.set_mode_charge("SN123", watts=3000)
150
150
 
151
151
 
@@ -372,7 +372,7 @@ async def test_set_device_control_no_response():
372
372
  api._request = AsyncMock(return_value=(200, None))
373
373
 
374
374
  with pytest.raises(
375
- api.ControlError, match="controlMap write failed \\(code=no_response\\):"
375
+ api.ControlError, match="request failed \\(code=no_response\\):"
376
376
  ):
377
377
  await api.set_device_control("SN123", {"1062": "1"})
378
378
 
@@ -387,7 +387,7 @@ async def test_set_device_control_api_failure():
387
387
  )
388
388
 
389
389
  with pytest.raises(
390
- api.ControlError, match="controlMap write failed \\(code=E123\\): API Error"
390
+ api.ControlError, match="request failed \\(code=E123\\): API Error"
391
391
  ):
392
392
  await api.set_device_control("SN123", {"1062": "1"})
393
393
 
@@ -23,22 +23,27 @@ from src.hyxi_cloud_api.api import FetchState, HyxiApiClient
23
23
  @pytest.mark.asyncio
24
24
  async def test_execute_metric_tasks_with_tasks():
25
25
  """Test _execute_metric_tasks when metric tasks are present."""
26
+ api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
26
27
  state = FetchState(plants=[{"id": 1}], now="2024-01-01T00:00:00Z")
27
- state.metric_tasks = [AsyncMock()]
28
+ state.metric_tasks = [("SN", {}, "TYPE")]
29
+ api._execute_metrics_and_map_alarms = AsyncMock()
28
30
  plant_alarms = {}
29
31
 
30
- with patch("asyncio.gather", new_callable=AsyncMock) as mock_gather:
31
- await HyxiApiClient._execute_metric_tasks(plant_alarms, state)
32
- mock_gather.assert_awaited_once()
32
+ api._fetch_all_for_device = AsyncMock()
33
+ with patch("asyncio.gather", new_callable=AsyncMock):
34
+ await api._execute_metric_tasks(plant_alarms, state)
35
+ api._execute_metrics_and_map_alarms.assert_awaited_once()
33
36
 
34
37
 
35
38
  @pytest.mark.asyncio
36
39
  async def test_execute_metric_tasks_without_tasks():
37
40
  """Test _execute_metric_tasks when metric tasks are absent."""
41
+ api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
38
42
  state = FetchState(plants=[{"id": 1}], now="2024-01-01T00:00:00Z")
39
43
  state.metric_tasks = []
40
44
  plant_alarms = {}
45
+ api._execute_metrics_and_map_alarms = AsyncMock()
41
46
 
42
- with patch("asyncio.gather", new_callable=AsyncMock) as mock_gather:
43
- await HyxiApiClient._execute_metric_tasks(plant_alarms, state)
44
- mock_gather.assert_not_awaited()
47
+ with patch("asyncio.gather", new_callable=AsyncMock):
48
+ await api._execute_metric_tasks(plant_alarms, state)
49
+ api._execute_metrics_and_map_alarms.assert_not_awaited()
@@ -16,7 +16,6 @@ if "aiohttp" not in sys.modules or not hasattr(sys.modules["aiohttp"], "ClientEr
16
16
  sys.modules["aiohttp"] = m
17
17
  mock_aiohttp = sys.modules["aiohttp"]
18
18
 
19
- import asyncio
20
19
 
21
20
  import pytest
22
21
 
@@ -34,17 +33,20 @@ async def test_execute_metrics_and_map_alarms():
34
33
 
35
34
  state = FetchState(now="2023-10-27")
36
35
 
37
- async def mock_metric_task(sn, result):
38
- return (sn, result)
39
-
40
36
  state.metric_tasks = [
41
- asyncio.create_task(mock_metric_task("SN1", {"data": 1})),
42
- asyncio.create_task(mock_metric_task("SN2", {"data": 2})),
43
- asyncio.create_task(mock_metric_task("SN3", {"data": 3})), # No alarms
44
- asyncio.create_task(mock_metric_task(None, {"data": 4})), # None SN
37
+ ("SN1", {"data": 1}, "TYPE"),
38
+ ("SN2", {"data": 2}, "TYPE"),
39
+ ("SN3", {"data": 3}, "TYPE"), # No alarms
40
+ (None, {"data": 4}, "TYPE"), # None SN
45
41
  ]
46
42
 
47
- await HyxiApiClient._execute_metrics_and_map_alarms(plant_alarms, state)
43
+ api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
44
+
45
+ async def mock_fetch(sn, entry, t):
46
+ return (sn, entry)
47
+
48
+ api._fetch_all_for_device = mock_fetch
49
+ await api._execute_metrics_and_map_alarms(plant_alarms, state)
48
50
 
49
51
  assert state.results["SN1"]["data"] == 1
50
52
  assert len(state.results["SN1"]["alarms"]) == 2
@@ -2,7 +2,7 @@
2
2
  # pylint: disable=redefined-outer-name
3
3
 
4
4
  import sys
5
- from unittest.mock import ANY, MagicMock
5
+ from unittest.mock import AsyncMock, MagicMock
6
6
 
7
7
  if "aiohttp" not in sys.modules or not hasattr(sys.modules["aiohttp"], "ClientError"):
8
8
  m = MagicMock()
@@ -73,17 +73,16 @@ def test_handle_back_discovery_alarm_success_non_parent(mock_api, mock_state):
73
73
  """Test successful discovery of a non-parent device."""
74
74
  sub_device_tasks = []
75
75
  alarm = {"deviceSn": "SN12345", "deviceType": "METER", "deviceName": "My Meter"}
76
- mock_api._fetch_all_for_device = MagicMock(return_value="mock_task")
76
+ mock_api._fetch_all_for_device = AsyncMock()
77
77
 
78
78
  mock_api._handle_back_discovery_alarm(alarm, "plant1", mock_state, sub_device_tasks)
79
79
 
80
80
  assert "SN12345" in mock_state.discovered_sns
81
81
  assert len(mock_state.metric_tasks) == 1
82
- assert mock_state.metric_tasks[0] == "mock_task"
82
+ assert mock_state.metric_tasks[0][0] == "SN12345"
83
83
  assert not sub_device_tasks
84
84
 
85
- mock_api._fetch_all_for_device.assert_called_once()
86
- args, _ = mock_api._fetch_all_for_device.call_args
85
+ args = mock_state.metric_tasks[0]
87
86
  assert args[0] == "SN12345"
88
87
  assert args[1]["sn"] == "SN12345"
89
88
  assert args[1]["device_name"] == "My Meter"
@@ -95,17 +94,13 @@ def test_handle_back_discovery_alarm_success_parent(mock_api, mock_state):
95
94
  """Test successful discovery of a parent device."""
96
95
  sub_device_tasks = []
97
96
  alarm = {"deviceSn": "SN_COLL_1", "deviceType": "COLLECTOR"}
98
- mock_api._fetch_all_for_device = MagicMock(return_value="metric_task")
99
- mock_api._fetch_sub_devices = MagicMock(return_value="sub_task")
97
+ mock_api._fetch_all_for_device = AsyncMock()
100
98
 
101
99
  mock_api._handle_back_discovery_alarm(alarm, "plant1", mock_state, sub_device_tasks)
102
100
 
103
101
  assert "SN_COLL_1" in mock_state.discovered_sns
104
- assert mock_state.metric_tasks == ["metric_task"]
105
- assert sub_device_tasks == ["sub_task"]
106
-
107
- mock_api._fetch_all_for_device.assert_called_with("SN_COLL_1", ANY, "COLLECTOR")
108
- mock_api._fetch_sub_devices.assert_called_with("SN_COLL_1", mock_state)
102
+ assert len(mock_state.metric_tasks) == 1
103
+ assert len(sub_device_tasks) == 1
109
104
 
110
105
 
111
106
  def test_handle_back_discovery_alarm_fallback_name(mock_api, mock_state):
@@ -116,7 +111,7 @@ def test_handle_back_discovery_alarm_fallback_name(mock_api, mock_state):
116
111
 
117
112
  mock_api._handle_back_discovery_alarm(alarm, "plant1", mock_state, sub_device_tasks)
118
113
 
119
- args, _ = mock_api._fetch_all_for_device.call_args
114
+ args = mock_state.metric_tasks[0]
120
115
  assert args[1]["device_name"] == "Hybrid Inverter SN_INV_1"
121
116
  assert args[1]["model"] == "Hybrid Inverter"
122
117
 
@@ -129,6 +124,6 @@ def test_handle_back_discovery_alarm_unknown_type(mock_api, mock_state):
129
124
 
130
125
  mock_api._handle_back_discovery_alarm(alarm, "plant1", mock_state, sub_device_tasks)
131
126
 
132
- args, _ = mock_api._fetch_all_for_device.call_args
127
+ args = mock_state.metric_tasks[0]
133
128
  assert args[1]["model"] == "Unknown Type"
134
129
  assert args[1]["device_name"] == "Unknown Type SN_UNKNOWN"
@@ -114,7 +114,7 @@ async def test_process_alarms_gathers_sub_device_tasks(
114
114
  """Test that sub-device tasks created during back-discovery are awaited."""
115
115
 
116
116
  def side_effect(a, plant_id, state, sub_device_tasks):
117
- sub_device_tasks.append("mock_task")
117
+ sub_device_tasks.append(("sn", state))
118
118
 
119
119
  mock_api._handle_back_discovery_alarm = MagicMock(side_effect=side_effect)
120
120
  alarm_results = [[{"id": "a1"}]]
@@ -123,4 +123,4 @@ async def test_process_alarms_gathers_sub_device_tasks(
123
123
  alarm_results, mock_state, allow_back_discovery=True
124
124
  )
125
125
 
126
- mock_gather.assert_called_once_with("mock_task")
126
+ mock_gather.assert_called_once()
@@ -23,7 +23,7 @@ def mock_api():
23
23
  """Fixture for a mock API client."""
24
24
  api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
25
25
  api._update_discovery_cache = MagicMock()
26
- api._fetch_all_for_device = MagicMock(return_value="fetch_all_task")
26
+ api._fetch_all_for_device = AsyncMock()
27
27
  api._fetch_sub_devices = MagicMock(return_value="fetch_sub_device_task")
28
28
  return api
29
29
 
@@ -85,7 +85,7 @@ async def test_process_devices_for_plant_normal_devices(mock_api, mock_state):
85
85
  assert "SN_NORMAL_2" in mock_state.discovered_sns
86
86
  assert mock_api._update_discovery_cache.call_count == 2
87
87
  assert len(mock_state.metric_tasks) == 2
88
- assert mock_state.metric_tasks == ["fetch_all_task", "fetch_all_task"]
88
+ assert len(mock_state.metric_tasks) == 2
89
89
 
90
90
 
91
91
  @pytest.mark.asyncio
@@ -188,7 +188,7 @@ async def test_subscription_error_on_api_failure():
188
188
  )
189
189
  )
190
190
 
191
- with pytest.raises(api.SubscriptionError, match="subscription request failed"):
191
+ with pytest.raises(api.SubscriptionError, match="request failed"):
192
192
  await api.subscribe_real_time_data("https://example.com/hyxi", ["SN1"], 60000)
193
193
 
194
194
 
File without changes
File without changes
File without changes