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.
- {hyxi_cloud_api-1.4.0/src/hyxi_cloud_api.egg-info → hyxi_cloud_api-1.4.2}/PKG-INFO +1 -1
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/pyproject.toml +1 -1
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/src/hyxi_cloud_api/__init__.py +1 -1
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/src/hyxi_cloud_api/api.py +98 -54
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2/src/hyxi_cloud_api.egg-info}/PKG-INFO +1 -1
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_additional_coverage.py +2 -2
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_api.py +6 -4
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_device_control.py +3 -3
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_execute_metric_tasks.py +12 -7
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_execute_metrics_and_map_alarms.py +11 -9
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_fetch_plants.py +35 -24
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_handle_back_discovery_alarm.py +9 -14
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_process_alarms_and_back_discovery.py +2 -2
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_process_devices_for_plant.py +2 -2
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_subscriptions.py +1 -1
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/LICENSE +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/README.md +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/setup.cfg +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/src/hyxi_cloud_api.egg-info/SOURCES.txt +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/src/hyxi_cloud_api.egg-info/dependency_links.txt +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/src/hyxi_cloud_api.egg-info/requires.txt +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/src/hyxi_cloud_api.egg-info/top_level.txt +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_alarm_push.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_all_in_one.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_api_imports.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_build_plant_tasks.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_caching.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_compute_battery_metrics.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_compute_derived_metrics.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_compute_grid_metrics.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_compute_load_metrics.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_compute_pv_metrics.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_device_entry.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_devices_errors.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_discovery.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_ensure_authenticated.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_execute_device_tasks.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_extract_battery_info.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_extract_device_info_metadata.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_fetch_and_process_alarms.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_fetch_device_list_for_plant.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_fetch_state.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_fetch_sub_device_list.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_filter_metrics.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_flatten_nested_push_device.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_fuzz_parser.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_get_f.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_info_errors.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_mask_id.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_metrics_errors.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_parse_data_list.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_parse_ems_kv.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_parser.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_push.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_sanitize_dict.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_sanitize_list.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_security_fix.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_set_log_salt.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_token_errors.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_token_handling.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_validate_post_rate_ms.py +0 -0
- {hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_validate_subscription_device_sns.py +0 -0
|
@@ -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.
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
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
|
-
|
|
1975
|
-
|
|
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
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
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:
|
|
@@ -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
|
|
38
|
-
|
|
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
|
-
|
|
341
|
-
|
|
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="
|
|
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="
|
|
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="
|
|
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 = [
|
|
28
|
+
state.metric_tasks = [("SN", {}, "TYPE")]
|
|
29
|
+
api._execute_metrics_and_map_alarms = AsyncMock()
|
|
28
30
|
plant_alarms = {}
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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)
|
|
43
|
-
await
|
|
44
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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] == "
|
|
82
|
+
assert mock_state.metric_tasks[0][0] == "SN12345"
|
|
83
83
|
assert not sub_device_tasks
|
|
84
84
|
|
|
85
|
-
|
|
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 =
|
|
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 ==
|
|
105
|
-
assert sub_device_tasks ==
|
|
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
|
|
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
|
|
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"
|
{hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/tests/test_process_alarms_and_back_discovery.py
RENAMED
|
@@ -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("
|
|
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.
|
|
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 =
|
|
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 ==
|
|
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="
|
|
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
|
|
File without changes
|
{hyxi_cloud_api-1.4.0 → hyxi_cloud_api-1.4.2}/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
|
|
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.4.0 → hyxi_cloud_api-1.4.2}/tests/test_validate_subscription_device_sns.py
RENAMED
|
File without changes
|