hyxi-cloud-api 1.2.0__tar.gz → 1.2.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.2.0/src/hyxi_cloud_api.egg-info → hyxi_cloud_api-1.2.2}/PKG-INFO +1 -1
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/pyproject.toml +1 -1
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/src/hyxi_cloud_api/__init__.py +1 -1
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/src/hyxi_cloud_api/api.py +89 -23
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2/src/hyxi_cloud_api.egg-info}/PKG-INFO +1 -1
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_api.py +141 -27
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_caching.py +65 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_compute_derived_metrics.py +63 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_device_control.py +49 -1
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_extract_device_info_metadata.py +23 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_metrics_errors.py +9 -9
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_parser.py +38 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_process_devices_for_plant.py +15 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_token_handling.py +12 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/LICENSE +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/README.md +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/setup.cfg +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/src/hyxi_cloud_api.egg-info/SOURCES.txt +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/src/hyxi_cloud_api.egg-info/dependency_links.txt +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/src/hyxi_cloud_api.egg-info/requires.txt +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/src/hyxi_cloud_api.egg-info/top_level.txt +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_all_in_one.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_build_plant_tasks.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_device_entry.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_devices_errors.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_discovery.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_execute_device_tasks.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_execute_metric_tasks.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_execute_metrics_and_map_alarms.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_extract_battery_info.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_fetch_and_process_alarms.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_fetch_device_list_for_plant.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_fetch_plants.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_fetch_state.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_fetch_sub_device_list.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_filter_metrics.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_fuzz_parser.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_get_f.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_handle_back_discovery_alarm.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_info_errors.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_mask_id.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_parse_data_list.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_parse_ems_kv.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_process_alarms_and_back_discovery.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_sanitize_dict.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_sanitize_list.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_security_fix.py +0 -0
- {hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_token_errors.py +0 -0
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import asyncio
|
|
11
11
|
import base64
|
|
12
|
+
import functools
|
|
12
13
|
import hashlib
|
|
13
14
|
import hmac
|
|
14
15
|
import logging
|
|
@@ -42,7 +43,15 @@ class FetchState:
|
|
|
42
43
|
|
|
43
44
|
|
|
44
45
|
_LOGGER = logging.getLogger(__name__)
|
|
45
|
-
_battery_device_types = (
|
|
46
|
+
_battery_device_types = (
|
|
47
|
+
"INVERTER",
|
|
48
|
+
"ESS",
|
|
49
|
+
"HALO",
|
|
50
|
+
"1",
|
|
51
|
+
"15",
|
|
52
|
+
"16",
|
|
53
|
+
"MICRO_STORAGE_ALL_IN_ONE",
|
|
54
|
+
)
|
|
46
55
|
_BATTERY_DEVICE_REGEX = re.compile("|".join(_battery_device_types))
|
|
47
56
|
_parent_device_types = ("COLLECTOR", "DMU", "INVERTER", "ALL_IN_ONE")
|
|
48
57
|
_PARENT_DEVICE_REGEX = re.compile("|".join(_parent_device_types))
|
|
@@ -501,6 +510,7 @@ DEVICE_TYPE_MAP = {
|
|
|
501
510
|
"ENERGY_STORAGE_BATTERY": "Battery",
|
|
502
511
|
"ALL_IN_ONE": "all-in-one machine",
|
|
503
512
|
"AC_BATTERY": "AC Battery",
|
|
513
|
+
"MICRO_STORAGE_ALL_IN_ONE": "Micro ESS",
|
|
504
514
|
# Official Numeric IDs (as seen in getSubDevicePage)
|
|
505
515
|
"1": "Hybrid Inverter",
|
|
506
516
|
"2": "Grid-Connected Inverter",
|
|
@@ -621,9 +631,19 @@ def _compute_derived_metrics(m_raw: dict, device_type: str = "") -> dict:
|
|
|
621
631
|
ppv_total = _get_f("ppv", m_raw)
|
|
622
632
|
derived["pv1p"] = round(max(ppv_total - derived["pv2p"], 0), 2)
|
|
623
633
|
|
|
634
|
+
# 5. Fallback mappings for Micro ESS
|
|
635
|
+
# Derive standard Solar Power (ppv) from Micro ESS pvPower if ppv is missing
|
|
636
|
+
if "pvPower" in m_raw and "ppv" not in m_raw:
|
|
637
|
+
derived["ppv"] = _get_f("pvPower", m_raw)
|
|
638
|
+
|
|
639
|
+
# Derive standard Grid Frequency (f) from Micro ESS gridF if f is missing
|
|
640
|
+
if "gridF" in m_raw and "f" not in m_raw:
|
|
641
|
+
derived["f"] = _get_f("gridF", m_raw)
|
|
642
|
+
|
|
624
643
|
return derived
|
|
625
644
|
|
|
626
645
|
|
|
646
|
+
@functools.lru_cache(maxsize=1024)
|
|
627
647
|
def _mask_id(value: str) -> str:
|
|
628
648
|
"""Mask an identifier (SN, plant ID, etc.) for logs.
|
|
629
649
|
|
|
@@ -884,7 +904,13 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
884
904
|
else:
|
|
885
905
|
entry["metrics"].update(m_raw)
|
|
886
906
|
|
|
887
|
-
if
|
|
907
|
+
if (
|
|
908
|
+
"gridP" in m_raw
|
|
909
|
+
or "pbat" in m_raw
|
|
910
|
+
or "batP" in m_raw
|
|
911
|
+
or "pvPower" in m_raw
|
|
912
|
+
or "gridF" in m_raw
|
|
913
|
+
):
|
|
888
914
|
entry["metrics"].update(
|
|
889
915
|
_compute_derived_metrics(
|
|
890
916
|
m_raw, entry.get("device_type_code", "")
|
|
@@ -899,17 +925,6 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
899
925
|
except Exception as e:
|
|
900
926
|
_LOGGER.error("Error fetching metrics for %s: %s", _mask_id(sn), e)
|
|
901
927
|
|
|
902
|
-
async def _fetch_ems_basic_data(self, ems_sn, entry):
|
|
903
|
-
"""Helper to fetch and merge EMS-specific basic details."""
|
|
904
|
-
_LOGGER.debug("HYXI Probing EMS telemetry for %s...", _mask_id(ems_sn))
|
|
905
|
-
m_raw = await self.query_ems_basic_details(ems_sn)
|
|
906
|
-
if m_raw:
|
|
907
|
-
entry["metrics"].update(m_raw)
|
|
908
|
-
else:
|
|
909
|
-
_LOGGER.debug(
|
|
910
|
-
"HYXI EMS telemetry probe returned no data for %s", _mask_id(ems_sn)
|
|
911
|
-
)
|
|
912
|
-
|
|
913
928
|
async def query_ems_basic_details(self, ems_sn):
|
|
914
929
|
"""Acquire basic data for Energy Storage Systems (ESS)."""
|
|
915
930
|
path = "/api/ems/v1/queryBasicDetails"
|
|
@@ -958,10 +973,11 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
958
973
|
"_sw_ver_sys": sw_ver,
|
|
959
974
|
"signalIntensity": i_raw.get("signalIntensity"),
|
|
960
975
|
"signalVal": i_raw.get("signalVal"),
|
|
961
|
-
"wifiVer": i_raw.get("wifiVer"),
|
|
976
|
+
"wifiVer": i_raw.get("wifiVer") or i_raw.get("swVerWifi"),
|
|
962
977
|
"comMode": i_raw.get("comMode"),
|
|
963
978
|
"swVerMaster": i_raw.get("swVerMaster"),
|
|
964
979
|
"swVerSlave": i_raw.get("swVerSlave"),
|
|
980
|
+
"ratedFrequency": i_raw.get("ratedFrequency"),
|
|
965
981
|
}
|
|
966
982
|
|
|
967
983
|
device_type_code = entry.get("device_type_code", "").upper()
|
|
@@ -1011,14 +1027,25 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
1011
1027
|
tasks = [asyncio.create_task(self._fetch_device_info(sn, entry))]
|
|
1012
1028
|
is_comm_unit = dev_type in ("COLLECTOR", "DMU", "3")
|
|
1013
1029
|
|
|
1030
|
+
ems_task = None
|
|
1014
1031
|
if not is_comm_unit:
|
|
1015
1032
|
tasks.append(asyncio.create_task(self._fetch_device_metrics(sn, entry)))
|
|
1016
|
-
|
|
1033
|
+
ems_task = asyncio.create_task(self.query_ems_basic_details(sn))
|
|
1034
|
+
tasks.append(ems_task)
|
|
1017
1035
|
|
|
1018
1036
|
# Wait for them to finish
|
|
1019
1037
|
if tasks:
|
|
1020
1038
|
await asyncio.gather(*tasks)
|
|
1021
1039
|
|
|
1040
|
+
if ems_task:
|
|
1041
|
+
m_raw = ems_task.result()
|
|
1042
|
+
if m_raw:
|
|
1043
|
+
entry["metrics"].update(m_raw)
|
|
1044
|
+
else:
|
|
1045
|
+
_LOGGER.debug(
|
|
1046
|
+
"HYXI EMS telemetry probe returned no data for %s", _mask_id(sn)
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1022
1049
|
return sn, entry
|
|
1023
1050
|
|
|
1024
1051
|
async def _fetch_device_list_for_plant(self, plant_id: str) -> list[dict] | None:
|
|
@@ -1049,9 +1076,9 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
1049
1076
|
|
|
1050
1077
|
if _LOGGER.isEnabledFor(logging.DEBUG):
|
|
1051
1078
|
_LOGGER.debug(
|
|
1052
|
-
"HYXI Discovered Devices for Plant %s: %s",
|
|
1079
|
+
"HYXI Discovered Devices for Plant %s: [%s]",
|
|
1053
1080
|
_mask_id(plant_id),
|
|
1054
|
-
|
|
1081
|
+
", ".join(_mask_id(d.get("deviceSn", "UNKNOWN")) for d in devices),
|
|
1055
1082
|
)
|
|
1056
1083
|
return devices
|
|
1057
1084
|
|
|
@@ -1131,10 +1158,10 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
1131
1158
|
|
|
1132
1159
|
if _LOGGER.isEnabledFor(logging.DEBUG):
|
|
1133
1160
|
_LOGGER.debug(
|
|
1134
|
-
"HYXI Found %s sub-devices under %s: %s",
|
|
1161
|
+
"HYXI Found %s sub-devices under %s: [%s]",
|
|
1135
1162
|
len(children),
|
|
1136
1163
|
_mask_id(parent_sn),
|
|
1137
|
-
|
|
1164
|
+
", ".join(_mask_id(c.get("deviceSn", "UNKNOWN")) for c in children),
|
|
1138
1165
|
)
|
|
1139
1166
|
|
|
1140
1167
|
for c in children:
|
|
@@ -1197,6 +1224,7 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
1197
1224
|
"""Fetches data with built-in retry logic and returns attempt count."""
|
|
1198
1225
|
|
|
1199
1226
|
for attempt in range(1, MAX_RETRIES + 1):
|
|
1227
|
+
err: aiohttp.ClientError | TimeoutError | None = None
|
|
1200
1228
|
try:
|
|
1201
1229
|
data = await self._execute_fetch_all(
|
|
1202
1230
|
allow_back_discovery=allow_back_discovery,
|
|
@@ -1209,9 +1237,12 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
1209
1237
|
return {"data": data, "attempts": attempt}
|
|
1210
1238
|
|
|
1211
1239
|
# If we get here, data was None (soft failure). Trigger a retry manually.
|
|
1212
|
-
|
|
1240
|
+
err = aiohttp.ClientError("Fetch returned None, triggering retry.")
|
|
1213
1241
|
|
|
1214
|
-
except (aiohttp.ClientError, TimeoutError) as
|
|
1242
|
+
except (aiohttp.ClientError, TimeoutError) as e:
|
|
1243
|
+
err = e
|
|
1244
|
+
|
|
1245
|
+
if err is not None:
|
|
1215
1246
|
if attempt < MAX_RETRIES:
|
|
1216
1247
|
wait_time = attempt * RETRY_DELAY
|
|
1217
1248
|
_LOGGER.debug(
|
|
@@ -1258,8 +1289,8 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
1258
1289
|
# 👇 Log the discovered plants
|
|
1259
1290
|
if _LOGGER.isEnabledFor(logging.DEBUG):
|
|
1260
1291
|
_LOGGER.debug(
|
|
1261
|
-
"HYXI Discovered Plants: %s",
|
|
1262
|
-
|
|
1292
|
+
"HYXI Discovered Plants: [%s]",
|
|
1293
|
+
", ".join(_mask_id(p.get("plantId", "UNKNOWN")) for p in plants),
|
|
1263
1294
|
)
|
|
1264
1295
|
|
|
1265
1296
|
return plants
|
|
@@ -1650,3 +1681,38 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
1650
1681
|
For **MICRO_INVERTER** devices.
|
|
1651
1682
|
"""
|
|
1652
1683
|
return await self.set_device_control(device_sn, {3013: "1"})
|
|
1684
|
+
|
|
1685
|
+
# ── Alarm Controls ───────────────────────────────────────────────────
|
|
1686
|
+
|
|
1687
|
+
async def alter_alarm(self, alarm_ids: list[int]) -> dict:
|
|
1688
|
+
"""Process/Acknowledge alarm information.
|
|
1689
|
+
|
|
1690
|
+
Endpoint: POST /api/alarm/v1/alterAlarm
|
|
1691
|
+
Body: {"ids": [id1, id2, ...], "state": 1}
|
|
1692
|
+
"""
|
|
1693
|
+
token_status = await self._refresh_token()
|
|
1694
|
+
if token_status == "auth_failed":
|
|
1695
|
+
raise self.ControlError("Authentication failed")
|
|
1696
|
+
if not token_status:
|
|
1697
|
+
raise self.ControlError("Could not obtain API token")
|
|
1698
|
+
|
|
1699
|
+
path = "/api/alarm/v1/alterAlarm"
|
|
1700
|
+
body = {
|
|
1701
|
+
"ids": alarm_ids,
|
|
1702
|
+
"state": 1,
|
|
1703
|
+
}
|
|
1704
|
+
_LOGGER.debug(
|
|
1705
|
+
"HYXI ALTER_ALARM request for ids %s: %s",
|
|
1706
|
+
alarm_ids,
|
|
1707
|
+
body,
|
|
1708
|
+
)
|
|
1709
|
+
_, res = await self._request("POST", path, json=body)
|
|
1710
|
+
if res is None or not res.get("success"):
|
|
1711
|
+
code = res.get("code", "unknown") if res else "no_response"
|
|
1712
|
+
msg = res.get("msg", "") if res else ""
|
|
1713
|
+
raise self.ControlError(f"alarm alteration failed (code={code}): {msg}")
|
|
1714
|
+
_LOGGER.debug(
|
|
1715
|
+
"HYXI ALTER_ALARM response: success=%s",
|
|
1716
|
+
res.get("success"),
|
|
1717
|
+
)
|
|
1718
|
+
return res
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import sys
|
|
2
|
-
|
|
2
|
+
import time
|
|
3
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
3
4
|
|
|
4
5
|
if "aiohttp" not in sys.modules or not hasattr(sys.modules["aiohttp"], "ClientError"):
|
|
5
6
|
m = MagicMock()
|
|
@@ -19,7 +20,7 @@ mock_aiohttp = sys.modules["aiohttp"]
|
|
|
19
20
|
"""Tests for the HYXI Cloud API client."""
|
|
20
21
|
|
|
21
22
|
import logging
|
|
22
|
-
from unittest.mock import
|
|
23
|
+
from unittest.mock import MagicMock
|
|
23
24
|
|
|
24
25
|
import aiohttp
|
|
25
26
|
import pytest
|
|
@@ -49,6 +50,20 @@ def test_api_initialization():
|
|
|
49
50
|
assert api.token is None
|
|
50
51
|
|
|
51
52
|
|
|
53
|
+
@pytest.mark.asyncio
|
|
54
|
+
async def test_request_unsupported_method():
|
|
55
|
+
"""Test that _request raises ValueError for unsupported HTTP methods."""
|
|
56
|
+
fake_session = MagicMock()
|
|
57
|
+
api = HyxiApiClient(
|
|
58
|
+
access_key="fake_access_key",
|
|
59
|
+
secret_key="fake_secret_key",
|
|
60
|
+
base_url="https://fake-hyxi-url.com",
|
|
61
|
+
session=fake_session,
|
|
62
|
+
)
|
|
63
|
+
with pytest.raises(ValueError, match="Unsupported HTTP method: PUT"):
|
|
64
|
+
await api._request("PUT", "/some/path")
|
|
65
|
+
|
|
66
|
+
|
|
52
67
|
# --- TEST 2: The Retry Logic Wrapper ---
|
|
53
68
|
@pytest.mark.asyncio
|
|
54
69
|
async def test_get_all_device_data_success():
|
|
@@ -234,17 +249,19 @@ async def test_query_ems_basic_details_success():
|
|
|
234
249
|
|
|
235
250
|
@pytest.mark.asyncio
|
|
236
251
|
async def test_fetch_ems_basic_data_success(caplog):
|
|
237
|
-
"""Test
|
|
252
|
+
"""Test _fetch_all_for_device when basic details are returned."""
|
|
238
253
|
caplog.set_level(logging.DEBUG)
|
|
239
254
|
|
|
240
255
|
mock_session = MagicMock()
|
|
241
256
|
api = HyxiApiClient("ak", "sk", "https://api.com", mock_session)
|
|
242
257
|
api.query_ems_basic_details = AsyncMock(return_value={"new_metric": "new_value"})
|
|
258
|
+
api._fetch_device_info = AsyncMock()
|
|
259
|
+
api._fetch_device_metrics = AsyncMock()
|
|
243
260
|
|
|
244
261
|
ems_sn = "10602251600016"
|
|
245
262
|
entry = {"device_type_code": "EMS", "metrics": {"existing_metric": "value"}}
|
|
246
263
|
|
|
247
|
-
await api.
|
|
264
|
+
await api._fetch_all_for_device(ems_sn, entry, "INVERTER")
|
|
248
265
|
|
|
249
266
|
# Assert query_ems_basic_details was called
|
|
250
267
|
api.query_ems_basic_details.assert_called_once_with(ems_sn)
|
|
@@ -255,17 +272,19 @@ async def test_fetch_ems_basic_data_success(caplog):
|
|
|
255
272
|
|
|
256
273
|
@pytest.mark.asyncio
|
|
257
274
|
async def test_fetch_ems_basic_data_no_data(caplog):
|
|
258
|
-
"""Test
|
|
275
|
+
"""Test _fetch_all_for_device when no basic details are returned."""
|
|
259
276
|
caplog.set_level(logging.DEBUG)
|
|
260
277
|
|
|
261
278
|
mock_session = MagicMock()
|
|
262
279
|
api = HyxiApiClient("ak", "sk", "https://api.com", mock_session)
|
|
263
280
|
api.query_ems_basic_details = AsyncMock(return_value={})
|
|
281
|
+
api._fetch_device_info = AsyncMock()
|
|
282
|
+
api._fetch_device_metrics = AsyncMock()
|
|
264
283
|
|
|
265
284
|
ems_sn = "EMS123"
|
|
266
285
|
entry = {"device_type_code": "EMS", "metrics": {"existing_metric": "value"}}
|
|
267
286
|
|
|
268
|
-
await api.
|
|
287
|
+
await api._fetch_all_for_device(ems_sn, entry, "INVERTER")
|
|
269
288
|
|
|
270
289
|
# Assert query_ems_basic_details was called
|
|
271
290
|
api.query_ems_basic_details.assert_called_once_with("EMS123")
|
|
@@ -427,18 +446,15 @@ async def test_fetch_alarms_for_plant_sanitization(caplog):
|
|
|
427
446
|
|
|
428
447
|
@pytest.mark.asyncio
|
|
429
448
|
async def test_fetch_all_for_device_collector():
|
|
430
|
-
"""Test _fetch_all_for_device when dev_type is COLLECTOR.
|
|
431
|
-
api = HyxiApiClient("key", "secret", "url", session=MagicMock())
|
|
449
|
+
"""Test _fetch_all_for_device when dev_type is COLLECTOR.
|
|
432
450
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
async def dummy_metrics(*args, **kwargs):
|
|
437
|
-
pass
|
|
451
|
+
Collector/DMU units skip metrics and EMS probing — only device info is fetched.
|
|
452
|
+
"""
|
|
453
|
+
api = HyxiApiClient("key", "secret", "url", session=MagicMock())
|
|
438
454
|
|
|
439
|
-
api._fetch_device_info =
|
|
440
|
-
api._fetch_device_metrics =
|
|
441
|
-
api.
|
|
455
|
+
api._fetch_device_info = AsyncMock()
|
|
456
|
+
api._fetch_device_metrics = AsyncMock()
|
|
457
|
+
api.query_ems_basic_details = AsyncMock()
|
|
442
458
|
|
|
443
459
|
sn = "SN_123"
|
|
444
460
|
entry = {"initial": "state"}
|
|
@@ -451,25 +467,27 @@ async def test_fetch_all_for_device_collector():
|
|
|
451
467
|
|
|
452
468
|
api._fetch_device_info.assert_called_once_with(sn, entry)
|
|
453
469
|
api._fetch_device_metrics.assert_not_called()
|
|
470
|
+
api.query_ems_basic_details.assert_not_called()
|
|
454
471
|
|
|
455
472
|
|
|
456
473
|
@pytest.mark.asyncio
|
|
457
474
|
async def test_fetch_all_for_device_non_collector():
|
|
458
|
-
"""Test _fetch_all_for_device when dev_type is not COLLECTOR.
|
|
459
|
-
api = HyxiApiClient("key", "secret", "url", session=MagicMock())
|
|
475
|
+
"""Test _fetch_all_for_device when dev_type is not COLLECTOR.
|
|
460
476
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
477
|
+
Non-collector devices (e.g. INVERTER) trigger device info, metrics, and EMS
|
|
478
|
+
telemetry probing concurrently. query_ems_basic_details must be mocked to
|
|
479
|
+
prevent unawaited coroutine RuntimeWarnings from the real HTTP path.
|
|
480
|
+
"""
|
|
481
|
+
api = HyxiApiClient("key", "secret", "url", session=MagicMock())
|
|
466
482
|
|
|
467
|
-
api._fetch_device_info =
|
|
468
|
-
api._fetch_device_metrics =
|
|
469
|
-
|
|
483
|
+
api._fetch_device_info = AsyncMock()
|
|
484
|
+
api._fetch_device_metrics = AsyncMock()
|
|
485
|
+
# Must mock query_ems_basic_details: _fetch_all_for_device fires it as an
|
|
486
|
+
# asyncio.create_task. Without this mock the real coroutine leaks unawaited.
|
|
487
|
+
api.query_ems_basic_details = AsyncMock(return_value={})
|
|
470
488
|
|
|
471
489
|
sn = "SN_456"
|
|
472
|
-
entry = {"initial": "state2"}
|
|
490
|
+
entry = {"metrics": {}, "initial": "state2"}
|
|
473
491
|
dev_type = "INVERTER"
|
|
474
492
|
|
|
475
493
|
result_sn, result_entry = await api._fetch_all_for_device(sn, entry, dev_type)
|
|
@@ -479,6 +497,7 @@ async def test_fetch_all_for_device_non_collector():
|
|
|
479
497
|
|
|
480
498
|
api._fetch_device_info.assert_called_once_with(sn, entry)
|
|
481
499
|
api._fetch_device_metrics.assert_called_once_with(sn, entry)
|
|
500
|
+
api.query_ems_basic_details.assert_called_once_with(sn)
|
|
482
501
|
|
|
483
502
|
|
|
484
503
|
# --- TEST 6: Empty Data Response (The "Halo ESS" Scenario) ---
|
|
@@ -551,3 +570,98 @@ async def test_fetch_alarms_for_plant_error(caplog):
|
|
|
551
570
|
|
|
552
571
|
log_text = caplog.text
|
|
553
572
|
assert "Error fetching alarms for plant ef797c81: Connection reset" in log_text
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
@pytest.mark.asyncio
|
|
576
|
+
async def test_execute_fetch_all_force_discovery():
|
|
577
|
+
"""Verify that _execute_fetch_all respects the force_discovery flag to bypass cache."""
|
|
578
|
+
api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
|
|
579
|
+
|
|
580
|
+
# Bypass token validation
|
|
581
|
+
api._refresh_token = AsyncMock(return_value=True)
|
|
582
|
+
|
|
583
|
+
# Mock cache state to be valid
|
|
584
|
+
api._discovery_cache["plants"] = [{"plantId": "plant_1"}]
|
|
585
|
+
api._discovery_cache_time = time.time()
|
|
586
|
+
api._discovery_cache_ttl = 3600
|
|
587
|
+
|
|
588
|
+
# Mock internal methods
|
|
589
|
+
api._execute_fetch_cached = AsyncMock(return_value="cached_result")
|
|
590
|
+
api._execute_fetch_full_discovery = AsyncMock(return_value="full_discovery_result")
|
|
591
|
+
|
|
592
|
+
# Test 1: force_discovery=True should bypass cache and call full discovery
|
|
593
|
+
result = await api._execute_fetch_all(force_discovery=True)
|
|
594
|
+
|
|
595
|
+
assert result == "full_discovery_result"
|
|
596
|
+
api._execute_fetch_full_discovery.assert_called_once()
|
|
597
|
+
api._execute_fetch_cached.assert_not_called()
|
|
598
|
+
|
|
599
|
+
# Reset mocks
|
|
600
|
+
api._execute_fetch_full_discovery.reset_mock()
|
|
601
|
+
api._execute_fetch_cached.reset_mock()
|
|
602
|
+
|
|
603
|
+
# Test 2: force_discovery=False should use cache
|
|
604
|
+
result = await api._execute_fetch_all(force_discovery=False)
|
|
605
|
+
|
|
606
|
+
assert result == "cached_result"
|
|
607
|
+
api._execute_fetch_cached.assert_called_once()
|
|
608
|
+
api._execute_fetch_full_discovery.assert_not_called()
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
@pytest.mark.asyncio
|
|
612
|
+
async def test_execute_fetch_all_force_discovery_integration():
|
|
613
|
+
"""Verify that force_discovery=True bypasses cache and integrates with full discovery."""
|
|
614
|
+
from unittest.mock import patch # pylint: disable=import-outside-toplevel
|
|
615
|
+
|
|
616
|
+
api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
|
|
617
|
+
|
|
618
|
+
# Bypass token validation
|
|
619
|
+
api._refresh_token = AsyncMock(return_value=True)
|
|
620
|
+
|
|
621
|
+
# Mock cache state to be valid to test bypass
|
|
622
|
+
api._discovery_cache["plants"] = [{"plantId": "plant_1"}]
|
|
623
|
+
api._discovery_cache_time = time.time()
|
|
624
|
+
api._discovery_cache_ttl = 3600
|
|
625
|
+
|
|
626
|
+
# Mock the HTTP layer to return valid JSON responses for a full discovery flow
|
|
627
|
+
plant_resp = {"success": True, "data": {"list": [{"plantId": "plant_1"}]}}
|
|
628
|
+
device_resp = {
|
|
629
|
+
"success": True,
|
|
630
|
+
"data": {"deviceList": [{"deviceSn": "device_1", "deviceType": "INVERTER"}]},
|
|
631
|
+
}
|
|
632
|
+
sub_dev_resp = {"success": True, "data": {"childDevice": []}}
|
|
633
|
+
alarms_resp = {"success": True, "data": {"pageData": []}}
|
|
634
|
+
info_resp = {"success": True, "data": {"swVerSys": "v1.0", "hwVer": "v1.0"}}
|
|
635
|
+
metrics_resp = {"success": True, "data": [{"dataKey": "gridP", "dataValue": "100"}]}
|
|
636
|
+
|
|
637
|
+
with patch.object(api, "_request") as mock_req:
|
|
638
|
+
mock_req.side_effect = [
|
|
639
|
+
(200, plant_resp),
|
|
640
|
+
(200, device_resp),
|
|
641
|
+
(200, sub_dev_resp),
|
|
642
|
+
(200, alarms_resp),
|
|
643
|
+
(200, info_resp),
|
|
644
|
+
(200, metrics_resp),
|
|
645
|
+
(200, {}), # EMS Probe
|
|
646
|
+
]
|
|
647
|
+
|
|
648
|
+
# Use wraps to spy on internal methods while preserving their original logic
|
|
649
|
+
with (
|
|
650
|
+
patch.object(
|
|
651
|
+
api,
|
|
652
|
+
"_execute_fetch_full_discovery",
|
|
653
|
+
wraps=api._execute_fetch_full_discovery,
|
|
654
|
+
) as mock_full,
|
|
655
|
+
patch.object(
|
|
656
|
+
api, "_execute_fetch_cached", wraps=api._execute_fetch_cached
|
|
657
|
+
) as mock_cached,
|
|
658
|
+
):
|
|
659
|
+
result = await api._execute_fetch_all(force_discovery=True)
|
|
660
|
+
|
|
661
|
+
# Assert that the full discovery method was called and cached was not
|
|
662
|
+
mock_full.assert_called_once()
|
|
663
|
+
mock_cached.assert_not_called()
|
|
664
|
+
|
|
665
|
+
# Ensure we successfully parsed the data, meaning full discovery worked
|
|
666
|
+
assert "device_1" in result
|
|
667
|
+
assert result["device_1"]["sw_version"] == "v1.0"
|
|
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, patch
|
|
|
6
6
|
import pytest
|
|
7
7
|
|
|
8
8
|
from hyxi_cloud_api import HyxiApiClient
|
|
9
|
+
from hyxi_cloud_api.api import FetchState
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
@pytest.mark.asyncio
|
|
@@ -91,3 +92,67 @@ async def test_discovery_caching_logic():
|
|
|
91
92
|
]
|
|
92
93
|
await client.get_all_device_data(force_discovery=True)
|
|
93
94
|
assert mock_req.call_count == 7
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pytest.mark.asyncio
|
|
98
|
+
async def test_execute_fetch_cached_no_device_info():
|
|
99
|
+
"""Verify _execute_fetch_cached handles missing device_info without errors."""
|
|
100
|
+
session = AsyncMock()
|
|
101
|
+
client = HyxiApiClient("key", "secret", "http://api.com", session)
|
|
102
|
+
|
|
103
|
+
# Empty cache
|
|
104
|
+
client._discovery_cache = {
|
|
105
|
+
"plants": [{"plantId": "P1"}],
|
|
106
|
+
"device_info": None,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
state = FetchState(now="2023-01-01T00:00:00Z")
|
|
110
|
+
|
|
111
|
+
with (
|
|
112
|
+
patch.object(client, "_build_plant_tasks", return_value=([], [])) as mock_build,
|
|
113
|
+
patch.object(
|
|
114
|
+
client, "_fetch_and_process_alarms", return_value={}
|
|
115
|
+
) as mock_alarms,
|
|
116
|
+
patch.object(
|
|
117
|
+
client, "_execute_metric_tasks", new_callable=AsyncMock
|
|
118
|
+
) as mock_exec,
|
|
119
|
+
):
|
|
120
|
+
results = await client._execute_fetch_cached(state, allow_back_discovery=True)
|
|
121
|
+
|
|
122
|
+
# Verify it runs without error and executes the next steps
|
|
123
|
+
assert results == {}
|
|
124
|
+
assert len(state.metric_tasks) == 0
|
|
125
|
+
mock_build.assert_called_once()
|
|
126
|
+
mock_alarms.assert_called_once()
|
|
127
|
+
mock_exec.assert_called_once()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@pytest.mark.asyncio
|
|
131
|
+
async def test_execute_fetch_cached_empty_cache():
|
|
132
|
+
"""Verify _execute_fetch_cached handles fully empty cache without errors."""
|
|
133
|
+
session = AsyncMock()
|
|
134
|
+
client = HyxiApiClient("key", "secret", "http://api.com", session)
|
|
135
|
+
|
|
136
|
+
# Fully empty cache
|
|
137
|
+
client._discovery_cache = {}
|
|
138
|
+
|
|
139
|
+
state = FetchState(now="2023-01-01T00:00:00Z")
|
|
140
|
+
|
|
141
|
+
with (
|
|
142
|
+
patch.object(client, "_build_plant_tasks", return_value=([], [])) as mock_build,
|
|
143
|
+
patch.object(
|
|
144
|
+
client, "_fetch_and_process_alarms", return_value={}
|
|
145
|
+
) as mock_alarms,
|
|
146
|
+
patch.object(
|
|
147
|
+
client, "_execute_metric_tasks", new_callable=AsyncMock
|
|
148
|
+
) as mock_exec,
|
|
149
|
+
):
|
|
150
|
+
results = await client._execute_fetch_cached(state, allow_back_discovery=True)
|
|
151
|
+
|
|
152
|
+
# Verify it runs without error and executes the next steps
|
|
153
|
+
assert results == {}
|
|
154
|
+
assert not state.plants
|
|
155
|
+
assert len(state.metric_tasks) == 0
|
|
156
|
+
mock_build.assert_called_once()
|
|
157
|
+
mock_alarms.assert_called_once()
|
|
158
|
+
mock_exec.assert_called_once()
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Tests for the _compute_derived_metrics helper function in api.py."""
|
|
2
2
|
|
|
3
|
+
# pylint: disable=too-many-public-methods
|
|
4
|
+
|
|
3
5
|
from src.hyxi_cloud_api.api import _compute_derived_metrics
|
|
4
6
|
|
|
5
7
|
|
|
@@ -89,6 +91,55 @@ class TestComputeDerivedMetrics:
|
|
|
89
91
|
result = _compute_derived_metrics({})
|
|
90
92
|
assert not result
|
|
91
93
|
|
|
94
|
+
def test_missing_pv_power_derived(self):
|
|
95
|
+
"""Test PV power is derived when missing but voltage and current are present."""
|
|
96
|
+
data = {"pv1v": 100.0, "pv1i": 5.0}
|
|
97
|
+
result = _compute_derived_metrics(data)
|
|
98
|
+
assert "pv1p" in result
|
|
99
|
+
assert result["pv1p"] == 500.0
|
|
100
|
+
|
|
101
|
+
def test_provided_pv_power_takes_precedence(self):
|
|
102
|
+
"""Test provided PV power takes precedence over derived power."""
|
|
103
|
+
data = {"pv1v": 100.0, "pv1i": 5.0, "pv1p": 600.0}
|
|
104
|
+
result = _compute_derived_metrics(data)
|
|
105
|
+
assert "pv1p" in result
|
|
106
|
+
assert result["pv1p"] == 600.0
|
|
107
|
+
|
|
108
|
+
def test_partial_pv_data(self):
|
|
109
|
+
"""Test PV power derivation with partial data (e.g. only voltage)."""
|
|
110
|
+
data = {"pv1v": 100.0}
|
|
111
|
+
result = _compute_derived_metrics(data)
|
|
112
|
+
assert "pv1p" in result
|
|
113
|
+
assert result["pv1p"] == 0.0
|
|
114
|
+
|
|
115
|
+
def test_pv_power_recalculated_when_zero(self):
|
|
116
|
+
"""Test PV power recalculation if provided value is falsy (e.g. 0.0) but voltage/current exist."""
|
|
117
|
+
data = {"pv1v": 100.0, "pv1i": 5.0, "pv1p": 0.0}
|
|
118
|
+
result = _compute_derived_metrics(data)
|
|
119
|
+
assert "pv1p" in result
|
|
120
|
+
assert result["pv1p"] == 500.0
|
|
121
|
+
|
|
122
|
+
def test_pv_power_calculation_rounding(self):
|
|
123
|
+
"""Test the rounding logic when calculating PV power from voltage and current."""
|
|
124
|
+
data = {"pv1v": 100.33, "pv1i": 5.12}
|
|
125
|
+
result = _compute_derived_metrics(data)
|
|
126
|
+
assert "pv1p" in result
|
|
127
|
+
assert result["pv1p"] == 513.69 # 100.33 * 5.12 = 513.6896, rounded to 513.69
|
|
128
|
+
|
|
129
|
+
def test_pv1p_derived_from_ppv_and_pv2p(self):
|
|
130
|
+
"""Test the ALL_IN_ONE fallback logic where pv1p is derived from ppv - pv2p."""
|
|
131
|
+
data = {"ppv": 1500.0, "pv2v": 100.0, "pv2i": 5.0} # pv2p will be 500.0
|
|
132
|
+
result = _compute_derived_metrics(data)
|
|
133
|
+
assert "pv1p" in result
|
|
134
|
+
assert result["pv1p"] == 1000.0
|
|
135
|
+
|
|
136
|
+
def test_pv1p_derived_from_ppv_and_pv2p_zero_floor(self):
|
|
137
|
+
"""Test that the derived pv1p does not go below zero."""
|
|
138
|
+
data = {"ppv": 400.0, "pv2v": 100.0, "pv2i": 5.0} # pv2p will be 500.0
|
|
139
|
+
result = _compute_derived_metrics(data)
|
|
140
|
+
assert "pv1p" in result
|
|
141
|
+
assert result["pv1p"] == 0.0
|
|
142
|
+
|
|
92
143
|
def test_selective_pv_strings(self):
|
|
93
144
|
"""Test that only PV strings present in input are present in output."""
|
|
94
145
|
data = {"pv1v": 100, "pv1i": 5} # PV1 only
|
|
@@ -107,3 +158,15 @@ class TestComputeDerivedMetrics:
|
|
|
107
158
|
result = _compute_derived_metrics({"ph1Loadp": 100.0})
|
|
108
159
|
assert "home_load" in result
|
|
109
160
|
assert result["home_load"] == 100.0
|
|
161
|
+
|
|
162
|
+
def test_micro_ess_pv_power_fallback(self):
|
|
163
|
+
"""Test that ppv is derived from pvPower when ppv is missing (Micro ESS fallback)."""
|
|
164
|
+
data = {"pvPower": 1200.5}
|
|
165
|
+
result = _compute_derived_metrics(data)
|
|
166
|
+
assert result["ppv"] == 1200.5
|
|
167
|
+
|
|
168
|
+
def test_micro_ess_grid_frequency_fallback(self):
|
|
169
|
+
"""Test that f is derived from gridF when f is missing (Micro ESS fallback)."""
|
|
170
|
+
data = {"gridF": 49.98}
|
|
171
|
+
result = _compute_derived_metrics(data)
|
|
172
|
+
assert result["f"] == 49.98
|
|
@@ -235,10 +235,58 @@ async def test_restart_device():
|
|
|
235
235
|
"""Test restart_device sends controlId 3013 with value '1'."""
|
|
236
236
|
api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
|
|
237
237
|
api._refresh_token = AsyncMock(return_value=True)
|
|
238
|
-
api._request =
|
|
238
|
+
api._request = MagicMock()
|
|
239
|
+
# Handle async response for _request
|
|
240
|
+
async_mock = AsyncMock(return_value=(200, {"success": True}))
|
|
241
|
+
api._request.side_effect = async_mock
|
|
239
242
|
|
|
240
243
|
await api.restart_device("SN123")
|
|
241
244
|
|
|
245
|
+
api._request.assert_called_once()
|
|
242
246
|
call_kwargs = api._request.call_args
|
|
243
247
|
body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
|
|
244
248
|
assert body["deviceControlMap"]["SN123"]["3013"] == "1"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@pytest.mark.asyncio
|
|
252
|
+
async def test_alter_alarm():
|
|
253
|
+
"""Test alter_alarm sends correct POST request payload."""
|
|
254
|
+
api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
|
|
255
|
+
api._refresh_token = AsyncMock(return_value=True)
|
|
256
|
+
api._request = AsyncMock(return_value=(200, {"success": True}))
|
|
257
|
+
|
|
258
|
+
result = await api.alter_alarm([10086, 10087])
|
|
259
|
+
|
|
260
|
+
assert result["success"] is True
|
|
261
|
+
api._request.assert_called_once()
|
|
262
|
+
call_args, call_kwargs = api._request.call_args
|
|
263
|
+
assert call_args[0] == "POST"
|
|
264
|
+
assert call_args[1] == "/api/alarm/v1/alterAlarm"
|
|
265
|
+
body = call_kwargs.get("json")
|
|
266
|
+
assert body == {"ids": [10086, 10087], "state": 1}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@pytest.mark.asyncio
|
|
270
|
+
async def test_alter_alarm_auth_failed():
|
|
271
|
+
"""Test alter_alarm raises ControlError when authentication fails."""
|
|
272
|
+
api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
|
|
273
|
+
api._refresh_token = AsyncMock(return_value="auth_failed")
|
|
274
|
+
|
|
275
|
+
with pytest.raises(api.ControlError, match="Authentication failed"):
|
|
276
|
+
await api.alter_alarm([10086])
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@pytest.mark.asyncio
|
|
280
|
+
async def test_alter_alarm_api_failure():
|
|
281
|
+
"""Test alter_alarm raises ControlError when API returns success=False."""
|
|
282
|
+
api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
|
|
283
|
+
api._refresh_token = AsyncMock(return_value=True)
|
|
284
|
+
api._request = AsyncMock(
|
|
285
|
+
return_value=(
|
|
286
|
+
200,
|
|
287
|
+
{"success": False, "code": "C000002", "msg": "Internal error"},
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
with pytest.raises(api.ControlError, match="alarm alteration failed"):
|
|
292
|
+
await api.alter_alarm([10086])
|
|
@@ -87,3 +87,26 @@ def test_extract_device_info_metadata_battery_fallbacks():
|
|
|
87
87
|
assert base_info["packNum"] == 1
|
|
88
88
|
assert base_info["maxChargePower"] == 30.0
|
|
89
89
|
assert base_info["maxDischargePower"] == 30.0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_extract_device_info_metadata_micro_ess():
|
|
93
|
+
"""Test extraction of battery-specific metrics and fallbacks for Micro ESS (type 16) devices."""
|
|
94
|
+
entry = {"metrics": {}, "device_type_code": "16"}
|
|
95
|
+
i_raw = {
|
|
96
|
+
"batCap": "15.0",
|
|
97
|
+
"packNum": "1",
|
|
98
|
+
"maxChargePower": "700",
|
|
99
|
+
"maxDischargePower": "700",
|
|
100
|
+
"swVerWifi": "V01.00.00.01",
|
|
101
|
+
"ratedFrequency": "50",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
base_info = HyxiApiClient._extract_device_info_metadata(entry, i_raw)
|
|
105
|
+
|
|
106
|
+
assert base_info["batCap"] == 15.0
|
|
107
|
+
assert base_info["packNum"] == 1
|
|
108
|
+
assert base_info["maxChargePower"] == 700.0
|
|
109
|
+
assert base_info["maxDischargePower"] == 700.0
|
|
110
|
+
assert base_info["wifiVer"] == "V01.00.00.01"
|
|
111
|
+
assert base_info["ratedFrequency"] == "50"
|
|
112
|
+
assert "batCap" in entry["metrics"]
|
|
@@ -125,39 +125,39 @@ async def test_fetch_device_metrics_api_error(caplog):
|
|
|
125
125
|
|
|
126
126
|
@pytest.mark.asyncio
|
|
127
127
|
async def test_fetch_ems_basic_data_no_data(caplog):
|
|
128
|
-
"""Test that
|
|
128
|
+
"""Test that _fetch_all_for_device handles empty EMS response gracefully."""
|
|
129
129
|
caplog.set_level(logging.DEBUG)
|
|
130
130
|
mock_session = MagicMock()
|
|
131
131
|
api = HyxiApiClient("ak", "sk", "https://api.com", mock_session)
|
|
132
132
|
|
|
133
133
|
# Mock query_ems_basic_details to return {} (no data — the actual return contract)
|
|
134
134
|
api.query_ems_basic_details = AsyncMock(return_value={})
|
|
135
|
+
api._fetch_device_info = AsyncMock()
|
|
136
|
+
api._fetch_device_metrics = AsyncMock()
|
|
135
137
|
|
|
136
138
|
entry = {"metrics": {}, "device_type_code": "EMS"}
|
|
137
|
-
await api.
|
|
139
|
+
await api._fetch_all_for_device("10602251600016", entry, "INVERTER")
|
|
138
140
|
|
|
139
141
|
assert "HYXI EMS telemetry probe returned no data for fefbfd75" in caplog.text
|
|
140
|
-
# Ensure entry was not updated with metrics
|
|
142
|
+
# Ensure entry was not updated with EMS metrics
|
|
141
143
|
assert not entry["metrics"]
|
|
142
144
|
|
|
143
145
|
|
|
144
146
|
@pytest.mark.asyncio
|
|
145
147
|
async def test_fetch_ems_basic_data_error(caplog):
|
|
146
|
-
"""Test that
|
|
148
|
+
"""Test that _fetch_all_for_device handles errors from query_ems_basic_details."""
|
|
147
149
|
caplog.set_level(logging.DEBUG)
|
|
148
150
|
mock_session = MagicMock()
|
|
149
151
|
api = HyxiApiClient("ak", "sk", "https://api.com", mock_session)
|
|
150
152
|
|
|
151
|
-
# Mock _request to raise an Exception to ensure query_ems_basic_details returns {}
|
|
152
153
|
api._request = AsyncMock(side_effect=Exception("EMS data fetch failed"))
|
|
154
|
+
api._fetch_device_info = AsyncMock()
|
|
155
|
+
api._fetch_device_metrics = AsyncMock()
|
|
153
156
|
|
|
154
157
|
entry = {"metrics": {}, "device_type_code": "EMS"}
|
|
155
|
-
await api.
|
|
158
|
+
await api._fetch_all_for_device("10602251600016", entry, "INVERTER")
|
|
156
159
|
|
|
157
|
-
# Assert entry['metrics'] is unchanged
|
|
158
160
|
assert not entry["metrics"]
|
|
159
|
-
|
|
160
|
-
# Assert the correct debug log was emitted from _fetch_ems_basic_data due to empty return
|
|
161
161
|
assert "HYXI EMS telemetry probe returned no data for fefbfd75" in caplog.text
|
|
162
162
|
|
|
163
163
|
|
|
@@ -284,3 +284,41 @@ def test_compute_derived_metrics_zero_edges():
|
|
|
284
284
|
assert res_neg["grid_export"] == 0.0
|
|
285
285
|
assert res_neg["bat_charging"] == 0.01
|
|
286
286
|
assert res_neg["bat_discharging"] == 0.0
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@pytest.mark.asyncio
|
|
290
|
+
async def test_api_parsing_micro_ess_fallbacks():
|
|
291
|
+
"""Verify that Micro ESS specific keys (pvPower, gridF) are parsed and correctly trigger derived fallbacks."""
|
|
292
|
+
fake_json = {
|
|
293
|
+
"success": True,
|
|
294
|
+
"data": [
|
|
295
|
+
{"dataKey": "pvPower", "dataValue": "850.5"},
|
|
296
|
+
{"dataKey": "gridF", "dataValue": "50.02"},
|
|
297
|
+
],
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
mock_response = MagicMock()
|
|
301
|
+
mock_response.json = AsyncMock(return_value=fake_json)
|
|
302
|
+
mock_response.raise_for_status = MagicMock()
|
|
303
|
+
|
|
304
|
+
mock_session = MagicMock()
|
|
305
|
+
mock_session.get.return_value.__aenter__.return_value = mock_response
|
|
306
|
+
|
|
307
|
+
api = HyxiApiClient(
|
|
308
|
+
access_key="test_ak",
|
|
309
|
+
secret_key="test_sk",
|
|
310
|
+
base_url="https://test.com",
|
|
311
|
+
session=mock_session,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
entry = {"metrics": {}, "device_type_code": "15"} # Micro ESS
|
|
315
|
+
|
|
316
|
+
await api._fetch_device_metrics("SN123", entry)
|
|
317
|
+
|
|
318
|
+
# Raw metrics must be present
|
|
319
|
+
assert entry["metrics"]["pvPower"] == "850.5"
|
|
320
|
+
assert entry["metrics"]["gridF"] == "50.02"
|
|
321
|
+
|
|
322
|
+
# Derived fallback metrics must be computed and present
|
|
323
|
+
assert entry["metrics"]["ppv"] == 850.5
|
|
324
|
+
assert entry["metrics"]["f"] == 50.02
|
|
@@ -34,6 +34,21 @@ def mock_state():
|
|
|
34
34
|
return FetchState(now="2024-01-01T00:00:00Z")
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
@pytest.mark.asyncio
|
|
38
|
+
async def test_process_devices_for_plant_missing_sn_edge_cases(mock_api, mock_state):
|
|
39
|
+
"""Test with devices having missing, None, or empty string serial numbers."""
|
|
40
|
+
devices = [
|
|
41
|
+
{"deviceType": "INVERTER"},
|
|
42
|
+
{"deviceSn": None, "deviceType": "INVERTER"},
|
|
43
|
+
{"deviceSn": "", "deviceType": "INVERTER"},
|
|
44
|
+
]
|
|
45
|
+
with patch("asyncio.gather", new_callable=AsyncMock) as mock_gather:
|
|
46
|
+
await mock_api._process_devices_for_plant(devices, mock_state)
|
|
47
|
+
mock_gather.assert_not_called()
|
|
48
|
+
assert len(mock_state.discovered_sns) == 0
|
|
49
|
+
assert len(mock_state.metric_tasks) == 0
|
|
50
|
+
|
|
51
|
+
|
|
37
52
|
@pytest.mark.asyncio
|
|
38
53
|
async def test_process_devices_for_plant_empty(mock_api, mock_state):
|
|
39
54
|
"""Test with an empty list of devices."""
|
|
@@ -8,6 +8,7 @@ from unittest.mock import MagicMock
|
|
|
8
8
|
# Mock aiohttp before importing the client to avoid ModuleNotFoundError in environments without it
|
|
9
9
|
sys.modules["aiohttp"] = MagicMock()
|
|
10
10
|
|
|
11
|
+
import time
|
|
11
12
|
from unittest.mock import patch
|
|
12
13
|
|
|
13
14
|
import pytest
|
|
@@ -167,6 +168,17 @@ def test_calculate_token_expiration_expiresIn(mock_time, api_client):
|
|
|
167
168
|
assert expires_at == 1003300.0
|
|
168
169
|
|
|
169
170
|
|
|
171
|
+
def test_calculate_token_expiration_missing_expires_in(api_client):
|
|
172
|
+
"""Verify missing expires_in calculates default buffer relative to current time."""
|
|
173
|
+
# Rationale: Calling `_calculate_token_expiration({})` and ensuring the result is close
|
|
174
|
+
# to `time.time() + 6600 - 300` would verify this boundary.
|
|
175
|
+
expected = time.time() + 6600 - 300
|
|
176
|
+
expires_at = api_client._calculate_token_expiration({})
|
|
177
|
+
|
|
178
|
+
# Check if the result is within 1 second of expected to account for execution time
|
|
179
|
+
assert expires_at == pytest.approx(expected, abs=1.0)
|
|
180
|
+
|
|
181
|
+
|
|
170
182
|
@patch("time.time")
|
|
171
183
|
def test_calculate_token_expiration_expires_in(mock_time, api_client):
|
|
172
184
|
"""Verify 'expires_in' is used correctly."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.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
|
{hyxi_cloud_api-1.2.0 → hyxi_cloud_api-1.2.2}/tests/test_process_alarms_and_back_discovery.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|