hyxi-cloud-api 1.4.0__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.0/src/hyxi_cloud_api.egg-info → hyxi_cloud_api-1.4.2}/PKG-INFO +1 -1
  2. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/pyproject.toml +1 -1
  3. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/src/hyxi_cloud_api/__init__.py +1 -1
  4. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/src/hyxi_cloud_api/api.py +98 -54
  5. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2/src/hyxi_cloud_api.egg-info}/PKG-INFO +1 -1
  6. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_additional_coverage.py +2 -2
  7. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_api.py +6 -4
  8. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_device_control.py +3 -3
  9. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_execute_metric_tasks.py +12 -7
  10. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_execute_metrics_and_map_alarms.py +11 -9
  11. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_fetch_plants.py +35 -24
  12. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_handle_back_discovery_alarm.py +9 -14
  13. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_process_alarms_and_back_discovery.py +2 -2
  14. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_process_devices_for_plant.py +2 -2
  15. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_subscriptions.py +1 -1
  16. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/LICENSE +0 -0
  17. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/README.md +0 -0
  18. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/setup.cfg +0 -0
  19. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/src/hyxi_cloud_api.egg-info/SOURCES.txt +0 -0
  20. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/src/hyxi_cloud_api.egg-info/dependency_links.txt +0 -0
  21. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/src/hyxi_cloud_api.egg-info/requires.txt +0 -0
  22. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/src/hyxi_cloud_api.egg-info/top_level.txt +0 -0
  23. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_alarm_push.py +0 -0
  24. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_all_in_one.py +0 -0
  25. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_api_imports.py +0 -0
  26. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_build_plant_tasks.py +0 -0
  27. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_caching.py +0 -0
  28. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_compute_battery_metrics.py +0 -0
  29. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_compute_derived_metrics.py +0 -0
  30. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_compute_grid_metrics.py +0 -0
  31. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_compute_load_metrics.py +0 -0
  32. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_compute_pv_metrics.py +0 -0
  33. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_device_entry.py +0 -0
  34. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_devices_errors.py +0 -0
  35. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_discovery.py +0 -0
  36. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_ensure_authenticated.py +0 -0
  37. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_execute_device_tasks.py +0 -0
  38. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_extract_battery_info.py +0 -0
  39. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_extract_device_info_metadata.py +0 -0
  40. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_fetch_and_process_alarms.py +0 -0
  41. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_fetch_device_list_for_plant.py +0 -0
  42. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_fetch_state.py +0 -0
  43. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_fetch_sub_device_list.py +0 -0
  44. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_filter_metrics.py +0 -0
  45. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_flatten_nested_push_device.py +0 -0
  46. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_fuzz_parser.py +0 -0
  47. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_get_f.py +0 -0
  48. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_info_errors.py +0 -0
  49. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_mask_id.py +0 -0
  50. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_metrics_errors.py +0 -0
  51. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_parse_data_list.py +0 -0
  52. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_parse_ems_kv.py +0 -0
  53. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_parser.py +0 -0
  54. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_push.py +0 -0
  55. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_sanitize_dict.py +0 -0
  56. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_sanitize_list.py +0 -0
  57. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_security_fix.py +0 -0
  58. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_set_log_salt.py +0 -0
  59. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_token_errors.py +0 -0
  60. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_token_handling.py +0 -0
  61. {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_validate_post_rate_ms.py +0 -0
  62. {hyxi_cloud_api-1.4.0 → 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.0
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.0"
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.0"
8
+ __version__ = "1.4.2"
9
9
  __all__ = ["VPP_ACTIVE_MODES", "HyxiApiClient"]
@@ -32,6 +32,10 @@ except ImportError:
32
32
  import aiohttp
33
33
 
34
34
 
35
+ class TokenRejectedError(aiohttp.ClientError):
36
+ """Raised when the HYXI Cloud API rejects an authentication token."""
37
+
38
+
35
39
  @dataclass
36
40
  class FetchState:
37
41
  """State object to hold shared data during a device fetch cycle."""
@@ -522,6 +526,23 @@ INTERNAL_ERROR_MAP = {
522
526
  "C999999": "Service exception, please contact the service provide",
523
527
  }
524
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
+
525
546
 
526
547
  # Official HYXI Device Type Reference Table
527
548
  DEVICE_TYPE_MAP = {
@@ -1207,6 +1228,18 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1207
1228
 
1208
1229
  response.raise_for_status()
1209
1230
  res = await response.json()
1231
+
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")
1242
+
1210
1243
  return status, res
1211
1244
 
1212
1245
  def _apply_token_response(self, data: dict) -> bool:
@@ -1281,6 +1314,28 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1281
1314
  if not token_status:
1282
1315
  raise error_cls("Could not obtain API token")
1283
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
+
1284
1339
  async def _fetch_device_metrics(self, sn, entry):
1285
1340
  """Helper to fetch detailed metrics for a single device."""
1286
1341
  q_path = "/api/device/v1/queryDeviceData"
@@ -1316,6 +1371,8 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1316
1371
  _mask_id(sn),
1317
1372
  _sanitize_dict(res_q),
1318
1373
  )
1374
+ except TokenRejectedError: # pylint: disable=try-except-raise
1375
+ raise
1319
1376
  except Exception as e:
1320
1377
  _LOGGER.error("Error fetching metrics for %s: %s", _mask_id(sn), e)
1321
1378
 
@@ -1334,6 +1391,8 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1334
1391
  _mask_id(ems_sn),
1335
1392
  _sanitize_dict(res),
1336
1393
  )
1394
+ except TokenRejectedError: # pylint: disable=try-except-raise
1395
+ raise
1337
1396
  except Exception as e:
1338
1397
  _LOGGER.error(
1339
1398
  "HYXI EMS Basic Data Request Failed for %s: %s", _mask_id(ems_sn), e
@@ -1417,6 +1476,8 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1417
1476
  _sanitize_dict(res_i),
1418
1477
  )
1419
1478
 
1479
+ except TokenRejectedError: # pylint: disable=try-except-raise
1480
+ raise
1420
1481
  except Exception as e:
1421
1482
  _LOGGER.error("Error fetching device info for %s: %s", _mask_id(sn), e)
1422
1483
 
@@ -1492,6 +1553,8 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1492
1553
 
1493
1554
  await self._process_devices_for_plant(devices, state)
1494
1555
 
1556
+ except TokenRejectedError: # pylint: disable=try-except-raise
1557
+ raise
1495
1558
  except Exception as e:
1496
1559
  _LOGGER.error(
1497
1560
  "Error fetching devices for plant %s: %s", _mask_id(plant_id), e
@@ -1510,7 +1573,7 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1510
1573
 
1511
1574
  self._update_discovery_cache(sn, entry)
1512
1575
 
1513
- state.metric_tasks.append(self._fetch_all_for_device(sn, entry, dev_type))
1576
+ state.metric_tasks.append((sn, entry, dev_type))
1514
1577
 
1515
1578
  # 🚀 DEEP DISCOVERY: If this is a Collector, DMU, or Inverter, find its children!
1516
1579
  if dev_type in _parent_device_types:
@@ -1544,6 +1607,8 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1544
1607
 
1545
1608
  data_val = res_sd.get("data", {})
1546
1609
  return data_val.get("childDevice", []) if isinstance(data_val, dict) else []
1610
+ except TokenRejectedError: # pylint: disable=try-except-raise
1611
+ raise
1547
1612
  except Exception as e:
1548
1613
  _LOGGER.error(
1549
1614
  "Error fetching sub-device list for %s: %s", _mask_id(parent_sn), e
@@ -1575,11 +1640,11 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1575
1640
 
1576
1641
  self._update_discovery_cache(sn, entry)
1577
1642
 
1578
- # These are real devices, so fetch their metrics/info
1579
- state.metric_tasks.append(
1580
- self._fetch_all_for_device(sn, entry, raw_type)
1581
- )
1643
+ # These are real devices, so store args for later metric/info fetch
1644
+ state.metric_tasks.append((sn, entry, raw_type))
1582
1645
 
1646
+ except TokenRejectedError: # pylint: disable=try-except-raise
1647
+ raise
1583
1648
  except Exception as e:
1584
1649
  _LOGGER.error(
1585
1650
  "Error fetching sub-devices for %s: %s", _mask_id(parent_sn), e
@@ -1613,6 +1678,8 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1613
1678
  a["alarmName"] = alarm_name
1614
1679
 
1615
1680
  return alarms
1681
+ except TokenRejectedError: # pylint: disable=try-except-raise # pylint: disable=try-except-raise
1682
+ raise
1616
1683
  except Exception as e:
1617
1684
  _LOGGER.error(
1618
1685
  "Error fetching alarms for plant %s: %s", _mask_id(plant_id), e
@@ -1671,16 +1738,6 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1671
1738
  )
1672
1739
 
1673
1740
  if not res_p.get("success"):
1674
- # 🚀 If the server rejects the token, wipe it and force a retry!
1675
- if res_p.get("code") in ("A000002", "A000005"):
1676
- _LOGGER.debug(
1677
- "HYXI Server rejected our token (A000002/A000005). Forcing immediate token refresh..."
1678
- )
1679
- self.token = None
1680
- self.token_expires_at = 0
1681
- # Raising this error kicks it back up to the retry loop
1682
- raise aiohttp.ClientError("Server rejected token")
1683
-
1684
1741
  _LOGGER.error("HYXI API Plant Fetch Rejected: %s", _sanitize_dict(res_p))
1685
1742
  return None
1686
1743
 
@@ -1742,11 +1799,10 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1742
1799
  if device_fetch_tasks:
1743
1800
  await asyncio.gather(*device_fetch_tasks)
1744
1801
 
1745
- @staticmethod
1746
- async def _execute_metric_tasks(plant_alarms, state: FetchState):
1802
+ async def _execute_metric_tasks(self, plant_alarms, state: FetchState):
1747
1803
  """Helper to conditionally execute metrics and map alarms."""
1748
1804
  if state.metric_tasks:
1749
- await HyxiApiClient._execute_metrics_and_map_alarms(plant_alarms, state)
1805
+ await self._execute_metrics_and_map_alarms(plant_alarms, state)
1750
1806
 
1751
1807
  async def _process_plants_data(
1752
1808
  self, state: FetchState, allow_back_discovery: bool = False
@@ -1783,11 +1839,11 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1783
1839
  state.discovered_sns.add(sn)
1784
1840
 
1785
1841
  entry, dev_type = HyxiApiClient._build_device_entry(sn, a, state.now)
1786
- state.metric_tasks.append(self._fetch_all_for_device(sn, entry, dev_type))
1842
+ state.metric_tasks.append((sn, entry, dev_type))
1787
1843
 
1788
1844
  # 🚀 DEEP BACK-DISCOVERY: If this is a parent, search for ITS children too!
1789
1845
  if dev_type in _parent_device_types:
1790
- sub_device_tasks.append(self._fetch_sub_devices(sn, state))
1846
+ sub_device_tasks.append((sn, state))
1791
1847
 
1792
1848
  async def _process_alarms_and_back_discovery(
1793
1849
  self,
@@ -1816,12 +1872,12 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1816
1872
  )
1817
1873
 
1818
1874
  if sub_device_tasks:
1819
- 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)
1820
1877
 
1821
1878
  return plant_alarms
1822
1879
 
1823
- @staticmethod
1824
- async def _execute_metrics_and_map_alarms(plant_alarms, state: FetchState):
1880
+ async def _execute_metrics_and_map_alarms(self, plant_alarms, state: FetchState):
1825
1881
  """Helper to execute metric tasks and map alarms to devices."""
1826
1882
  # Precompute alarm mapping to optimize from O(N*M) to O(N+M)
1827
1883
  alarms_by_sn = defaultdict(list)
@@ -1830,12 +1886,19 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1830
1886
  if sn:
1831
1887
  alarms_by_sn[sn].append(a)
1832
1888
 
1833
- updated_entries = await asyncio.gather(*state.metric_tasks)
1834
- for sn, entry in updated_entries:
1835
- if sn:
1836
- # Map the relevant active alarms to this specific device
1837
- entry["alarms"] = alarms_by_sn.get(sn, [])
1838
- 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
1839
1902
 
1840
1903
  async def _execute_fetch_all(
1841
1904
  self, allow_back_discovery: bool = False, force_discovery: bool = False
@@ -1882,9 +1945,7 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1882
1945
  "hw_version": info.get("hw_version"),
1883
1946
  "metrics": {"last_seen": state.now},
1884
1947
  }
1885
- state.metric_tasks.append(
1886
- self._fetch_all_for_device(sn, entry, info["device_type_code"])
1887
- )
1948
+ state.metric_tasks.append((sn, entry, info["device_type_code"]))
1888
1949
  state.discovered_sns = set(info_cache.keys())
1889
1950
 
1890
1951
  # Fetch alarms (to allow back-discovery if enabled) and metrics
@@ -1960,8 +2021,6 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1960
2021
  _LOGGER.warning("set_device_control called with empty settings")
1961
2022
  return {}
1962
2023
 
1963
- await self._ensure_authenticated(self.ControlError)
1964
-
1965
2024
  path = "/api/device/v2/control"
1966
2025
  body = {
1967
2026
  "deviceControlMap": {device_sn: {str(k): v for k, v in control_map.items()}}
@@ -1971,33 +2030,18 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1971
2030
  _mask_id(device_sn),
1972
2031
  body["deviceControlMap"][device_sn],
1973
2032
  )
1974
- _, res = await self._request("POST", path, json=body)
1975
- if res is None or not res.get("success"):
1976
- code = res.get("code", "unknown") if res else "no_response"
1977
- msg = res.get("msg", "") if res else ""
1978
- raise self.ControlError(f"controlMap write failed (code={code}): {msg}")
1979
- _LOGGER.debug(
1980
- "HYXI CONTROL response for %s: success=%s",
1981
- _mask_id(device_sn),
1982
- res.get("success"),
2033
+ return await self._execute_with_auth_retry(
2034
+ "POST", path, self.ControlError, json=body
1983
2035
  )
1984
- return res
1985
2036
 
1986
2037
  # ── Subscription API ────────────────────────────────────────────────
1987
2038
 
1988
2039
  async def _post_subscription(self, path: str, body: dict) -> dict:
1989
2040
  """Send an authenticated subscription request."""
1990
- await self._ensure_authenticated(self.SubscriptionError)
1991
-
1992
2041
  _LOGGER.debug("HYXI subscription request to %s", path)
1993
- _, res = await self._request("POST", path, json=body)
1994
- if res is None or not res.get("success"):
1995
- code = res.get("code", "unknown") if res else "no_response"
1996
- msg = res.get("msg", "") if res else ""
1997
- raise self.SubscriptionError(
1998
- f"subscription request failed (code={code}): {msg}"
1999
- )
2000
- return res
2042
+ return await self._execute_with_auth_retry(
2043
+ "POST", path, self.SubscriptionError, json=body
2044
+ )
2001
2045
 
2002
2046
  @staticmethod
2003
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.0
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
@@ -20,10 +20,9 @@ mock_aiohttp = sys.modules["aiohttp"]
20
20
 
21
21
  from unittest.mock import AsyncMock, MagicMock, patch
22
22
 
23
- import aiohttp
24
23
  import pytest
25
24
 
26
- from hyxi_cloud_api.api import HyxiApiClient
25
+ from hyxi_cloud_api.api import HyxiApiClient, TokenRejectedError
27
26
 
28
27
 
29
28
  @pytest.mark.asyncio
@@ -46,28 +45,6 @@ async def test_fetch_plants_success():
46
45
  assert plants[1]["plantId"] == "Pl456"
47
46
 
48
47
 
49
- @pytest.mark.parametrize("error_code", ["A000002", "A000005"])
50
- @pytest.mark.asyncio
51
- async def test_fetch_plants_token_rejection(error_code):
52
- """Verify that _fetch_plants handles token rejection correctly."""
53
- api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
54
- api.token = "Bearer old_token"
55
- api.token_expires_at = 9999999999.0
56
-
57
- api._request = AsyncMock(
58
- return_value=(
59
- 200,
60
- {"success": False, "code": error_code, "message": "Invalid access token"},
61
- )
62
- )
63
-
64
- with pytest.raises(aiohttp.ClientError, match="Server rejected token"):
65
- await api._fetch_plants()
66
-
67
- assert api.token is None
68
- assert api.token_expires_at == 0
69
-
70
-
71
48
  @pytest.mark.asyncio
72
49
  async def test_fetch_plants_generic_failure():
73
50
  """Verify that _fetch_plants returns None on generic failure."""
@@ -111,3 +88,37 @@ async def test_fetch_plants_empty_list_warning():
111
88
  "If your developer email differs from your app email, you must share "
112
89
  "your Plant from the app to the developer email first."
113
90
  )
91
+
92
+
93
+ @pytest.mark.asyncio
94
+ @pytest.mark.parametrize("error_code", ["A000002", "A000005", "C000006"])
95
+ async def test_request_token_rejection_errors_are_raised(error_code):
96
+ """
97
+ When the HYXI backend rejects a token with a known error code, _request
98
+ should raise TokenRejectedError.
99
+ """
100
+ api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
101
+ api.token = "Bearer good_token"
102
+ api.token_expires_at = 9999999999.0
103
+
104
+ mocked_response = {
105
+ "success": False,
106
+ "code": error_code,
107
+ "msg": "Server rejected token",
108
+ }
109
+
110
+ # Mock the aiohttp client session post method
111
+ mock_post = MagicMock()
112
+ mock_response = AsyncMock()
113
+ mock_response.status = 200
114
+ mock_response.json = AsyncMock(return_value=mocked_response)
115
+ mock_post.return_value.__aenter__.return_value = mock_response
116
+ api.session.post = mock_post
117
+
118
+ with pytest.raises(TokenRejectedError) as exc_info:
119
+ await api._request("POST", "/api/plant/v1/page", json={})
120
+
121
+ assert "Server rejected token" in str(exc_info.value)
122
+ # Ensure token is cleared
123
+ assert api.token is None
124
+ assert api.token_expires_at == 0
@@ -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