hyxi-cloud-api 1.3.9__tar.gz → 1.4.0__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.3.9/src/hyxi_cloud_api.egg-info → hyxi_cloud_api-1.4.0}/PKG-INFO +1 -1
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/pyproject.toml +1 -1
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/src/hyxi_cloud_api/__init__.py +1 -1
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/src/hyxi_cloud_api/api.py +131 -79
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0/src/hyxi_cloud_api.egg-info}/PKG-INFO +1 -1
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/src/hyxi_cloud_api.egg-info/SOURCES.txt +2 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_api.py +117 -4
- hyxi_cloud_api-1.4.0/tests/test_api_imports.py +33 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_caching.py +3 -4
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_devices_errors.py +21 -1
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_discovery.py +28 -3
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_fetch_plants.py +2 -2
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_fetch_sub_device_list.py +19 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_get_f.py +10 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_metrics_errors.py +49 -3
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_push.py +2 -2
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_sanitize_dict.py +9 -9
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_sanitize_list.py +2 -2
- hyxi_cloud_api-1.4.0/tests/test_set_log_salt.py +55 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/LICENSE +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/README.md +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/setup.cfg +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/src/hyxi_cloud_api.egg-info/dependency_links.txt +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/src/hyxi_cloud_api.egg-info/requires.txt +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/src/hyxi_cloud_api.egg-info/top_level.txt +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_additional_coverage.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_alarm_push.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_all_in_one.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_build_plant_tasks.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_compute_battery_metrics.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_compute_derived_metrics.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_compute_grid_metrics.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_compute_load_metrics.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_compute_pv_metrics.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_device_control.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_device_entry.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_ensure_authenticated.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_execute_device_tasks.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_execute_metric_tasks.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_execute_metrics_and_map_alarms.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_extract_battery_info.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_extract_device_info_metadata.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_fetch_and_process_alarms.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_fetch_device_list_for_plant.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_fetch_state.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_filter_metrics.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_flatten_nested_push_device.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_fuzz_parser.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_handle_back_discovery_alarm.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_info_errors.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_mask_id.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_parse_data_list.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_parse_ems_kv.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_parser.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_process_alarms_and_back_discovery.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_process_devices_for_plant.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_security_fix.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_subscriptions.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_token_errors.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_token_handling.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_validate_post_rate_ms.py +0 -0
- {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/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.
|
|
8
|
+
__version__ = "1.4.0"
|
|
9
9
|
__all__ = ["VPP_ACTIVE_MODES", "HyxiApiClient"]
|
|
@@ -25,10 +25,9 @@ from urllib.parse import urlparse
|
|
|
25
25
|
try:
|
|
26
26
|
from datetime import UTC, datetime
|
|
27
27
|
except ImportError:
|
|
28
|
-
from datetime import datetime
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
29
|
|
|
30
|
-
#
|
|
31
|
-
UTC = UTC
|
|
30
|
+
UTC = timezone.utc # noqa
|
|
32
31
|
|
|
33
32
|
import aiohttp
|
|
34
33
|
|
|
@@ -45,18 +44,41 @@ class FetchState:
|
|
|
45
44
|
|
|
46
45
|
|
|
47
46
|
_LOGGER = logging.getLogger(__name__)
|
|
48
|
-
_battery_device_types = (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
47
|
+
_battery_device_types = frozenset(
|
|
48
|
+
(
|
|
49
|
+
"INVERTER",
|
|
50
|
+
"HYBRID_INVERTER",
|
|
51
|
+
"STRING_INVERTER",
|
|
52
|
+
"MICRO_INVERTER",
|
|
53
|
+
"ESS",
|
|
54
|
+
"HALO",
|
|
55
|
+
"1",
|
|
56
|
+
"15",
|
|
57
|
+
"16",
|
|
58
|
+
"MICRO_STORAGE_ALL_IN_ONE",
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
_parent_device_types = frozenset(
|
|
62
|
+
(
|
|
63
|
+
"COLLECTOR",
|
|
64
|
+
"DMU",
|
|
65
|
+
"INVERTER",
|
|
66
|
+
"HYBRID_INVERTER",
|
|
67
|
+
"STRING_INVERTER",
|
|
68
|
+
"MICRO_INVERTER",
|
|
69
|
+
"ALL_IN_ONE",
|
|
70
|
+
"MICRO_STORAGE_ALL_IN_ONE",
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
_EMS_DEVICE_TYPES = frozenset(
|
|
74
|
+
(
|
|
75
|
+
"EMS",
|
|
76
|
+
"ENERGY_STORAGE_BATTERY",
|
|
77
|
+
"MICRO_STORAGE_ALL_IN_ONE",
|
|
78
|
+
"15",
|
|
79
|
+
"16",
|
|
80
|
+
)
|
|
56
81
|
)
|
|
57
|
-
_BATTERY_DEVICE_REGEX = re.compile("|".join(_battery_device_types))
|
|
58
|
-
_parent_device_types = ("COLLECTOR", "DMU", "INVERTER", "ALL_IN_ONE")
|
|
59
|
-
_PARENT_DEVICE_REGEX = re.compile("|".join(_parent_device_types))
|
|
60
82
|
_COLLECTOR_FILTER_KEYWORDS = (
|
|
61
83
|
"bat",
|
|
62
84
|
"pv",
|
|
@@ -564,9 +586,7 @@ def _get_f(key: str, data_map: dict, mult: float = 1.0) -> float:
|
|
|
564
586
|
if val is None or val == "":
|
|
565
587
|
return 0.0
|
|
566
588
|
return round(float(val) * mult, 2)
|
|
567
|
-
except ValueError:
|
|
568
|
-
return 0.0
|
|
569
|
-
except TypeError:
|
|
589
|
+
except ValueError, TypeError:
|
|
570
590
|
return 0.0
|
|
571
591
|
|
|
572
592
|
|
|
@@ -625,7 +645,7 @@ def _compute_battery_metrics(
|
|
|
625
645
|
pbat = _get_f("pbat", m_raw)
|
|
626
646
|
device_type_str = str(device_type or "")
|
|
627
647
|
|
|
628
|
-
if "batP" in m_raw or "pbat" in m_raw or device_type_str in
|
|
648
|
+
if "batP" in m_raw or "pbat" in m_raw or device_type_str in _EMS_DEVICE_TYPES:
|
|
629
649
|
# ALL_IN_ONE: prefer pbat — batP can have an inverted sign convention,
|
|
630
650
|
# while pbat is consistently negative-for-charging / positive-for-discharging.
|
|
631
651
|
# Other devices: prefer batP (DC terminals), fall back to pbat.
|
|
@@ -732,6 +752,60 @@ def _compute_derived_metrics(m_raw: dict, device_type: str = "") -> dict:
|
|
|
732
752
|
return derived
|
|
733
753
|
|
|
734
754
|
|
|
755
|
+
def _resolve_push_timestamp(device: dict[str, Any], now_utc: datetime) -> datetime:
|
|
756
|
+
"""Determine the last_seen timestamp from collectTime or reportTimestamp."""
|
|
757
|
+
collect_time = device.get("collectTime")
|
|
758
|
+
report_ts = device.get("reportTimestamp")
|
|
759
|
+
|
|
760
|
+
if collect_time is not None:
|
|
761
|
+
try:
|
|
762
|
+
return datetime.fromtimestamp(float(collect_time), UTC)
|
|
763
|
+
except ValueError, TypeError, OverflowError, OSError:
|
|
764
|
+
pass
|
|
765
|
+
|
|
766
|
+
if report_ts is not None:
|
|
767
|
+
try:
|
|
768
|
+
return datetime.fromtimestamp(float(report_ts) / 1000.0, UTC)
|
|
769
|
+
except ValueError, TypeError, OverflowError, OSError:
|
|
770
|
+
pass
|
|
771
|
+
|
|
772
|
+
return now_utc
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _extract_raw_push_metrics(device: dict[str, Any]) -> dict[str, Any]:
|
|
776
|
+
"""Extract metrics from flat payload dictionary, stripping metadata/routing keys."""
|
|
777
|
+
raw_metrics: dict[str, Any] = {}
|
|
778
|
+
for k, v in device.items():
|
|
779
|
+
if k in _METRICS_EXCLUDED_KEYS:
|
|
780
|
+
continue
|
|
781
|
+
raw_metrics[k] = v
|
|
782
|
+
return raw_metrics
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _merge_push_metrics(
|
|
786
|
+
sn: str,
|
|
787
|
+
raw_metrics: dict[str, Any],
|
|
788
|
+
device_type: str,
|
|
789
|
+
existing_metrics: dict[str, dict[str, Any]] | None,
|
|
790
|
+
) -> dict[str, Any]:
|
|
791
|
+
"""Filter collector metrics and merge with existing metrics."""
|
|
792
|
+
if device_type == "COLLECTOR":
|
|
793
|
+
metrics_to_update = _filter_collector_metrics(raw_metrics)
|
|
794
|
+
else:
|
|
795
|
+
metrics_to_update = raw_metrics.copy()
|
|
796
|
+
|
|
797
|
+
merged_metrics: dict[str, Any] = {}
|
|
798
|
+
if existing_metrics and sn in existing_metrics:
|
|
799
|
+
# Copy existing to avoid mutating caller's dictionary directly
|
|
800
|
+
merged_metrics = dict(existing_metrics[sn])
|
|
801
|
+
|
|
802
|
+
for k, v in metrics_to_update.items():
|
|
803
|
+
if v is not None:
|
|
804
|
+
merged_metrics[k] = v
|
|
805
|
+
|
|
806
|
+
return merged_metrics
|
|
807
|
+
|
|
808
|
+
|
|
735
809
|
def _flatten_nested_push_device(device: dict) -> dict: # pylint: disable=too-many-statements
|
|
736
810
|
"""Flatten a nested push device payload into the flat layout expected by the SDK."""
|
|
737
811
|
flat: dict = {}
|
|
@@ -751,21 +825,31 @@ def _flatten_nested_push_device(device: dict) -> dict: # pylint: disable=too-ma
|
|
|
751
825
|
if "deviceState" in rec:
|
|
752
826
|
flat["deviceState"] = rec["deviceState"]
|
|
753
827
|
|
|
754
|
-
#
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
828
|
+
# Map discrepancies between Flat Push payload and Flat Pull payload
|
|
829
|
+
_key_map = {
|
|
830
|
+
"gridEin": "totalEnt",
|
|
831
|
+
"gridEIn": "totalEnt",
|
|
832
|
+
"gridEout": "totalEpt",
|
|
833
|
+
"gridEOut": "totalEpt",
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
# Copy all root-level keys that are primitive types (support new flat format)
|
|
837
|
+
for k, v in device.items():
|
|
838
|
+
if not isinstance(v, dict) and not isinstance(v, list) and v is not None:
|
|
839
|
+
# Normalize push metric names to match pull metric names
|
|
840
|
+
mapped_k = _key_map.get(k, k)
|
|
841
|
+
|
|
842
|
+
if mapped_k == "collectTime":
|
|
759
843
|
try:
|
|
760
|
-
num_val = float(
|
|
844
|
+
num_val = float(v)
|
|
761
845
|
if num_val > 10000000000:
|
|
762
|
-
flat[
|
|
846
|
+
flat[mapped_k] = num_val / 1000.0
|
|
763
847
|
else:
|
|
764
|
-
flat[
|
|
848
|
+
flat[mapped_k] = v
|
|
765
849
|
except ValueError, TypeError:
|
|
766
|
-
flat[
|
|
850
|
+
flat[mapped_k] = v
|
|
767
851
|
else:
|
|
768
|
-
flat[
|
|
852
|
+
flat[mapped_k] = v
|
|
769
853
|
|
|
770
854
|
# 2. system
|
|
771
855
|
if "system" in device and isinstance(device["system"], dict):
|
|
@@ -888,14 +972,15 @@ def _flatten_nested_push_device(device: dict) -> dict: # pylint: disable=too-ma
|
|
|
888
972
|
if "powerFactor" in grd:
|
|
889
973
|
flat["gridPfd"] = grd["powerFactor"]
|
|
890
974
|
if "energyInKwh" in grd:
|
|
891
|
-
flat["
|
|
975
|
+
flat["totalEnt"] = grd["energyInKwh"]
|
|
892
976
|
if "energyOutKwh" in grd:
|
|
893
|
-
flat["
|
|
977
|
+
flat["totalEpt"] = grd["energyOutKwh"]
|
|
894
978
|
|
|
895
979
|
# Copy any other keys at the root that aren't dictionaries
|
|
896
980
|
for k, v in device.items():
|
|
897
981
|
if k not in flat and not isinstance(v, dict):
|
|
898
|
-
|
|
982
|
+
mapped_k = _key_map.get(k, k)
|
|
983
|
+
flat[mapped_k] = v
|
|
899
984
|
|
|
900
985
|
return flat
|
|
901
986
|
|
|
@@ -1294,7 +1379,7 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
1294
1379
|
}
|
|
1295
1380
|
|
|
1296
1381
|
device_type_code = entry.get("device_type_code", "")
|
|
1297
|
-
if
|
|
1382
|
+
if device_type_code in _battery_device_types:
|
|
1298
1383
|
base_info.update(HyxiApiClient._extract_battery_info(i_raw))
|
|
1299
1384
|
|
|
1300
1385
|
entry["metrics"].update(base_info)
|
|
@@ -1343,8 +1428,11 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
1343
1428
|
ems_task = None
|
|
1344
1429
|
if not is_comm_unit:
|
|
1345
1430
|
tasks.append(asyncio.create_task(self._fetch_device_metrics(sn, entry)))
|
|
1346
|
-
|
|
1347
|
-
|
|
1431
|
+
|
|
1432
|
+
actual_type = entry.get("device_type_code", dev_type)
|
|
1433
|
+
if actual_type in _EMS_DEVICE_TYPES:
|
|
1434
|
+
ems_task = asyncio.create_task(self.query_ems_basic_details(sn))
|
|
1435
|
+
tasks.append(ems_task)
|
|
1348
1436
|
|
|
1349
1437
|
# Wait for them to finish
|
|
1350
1438
|
if tasks:
|
|
@@ -1425,7 +1513,7 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
1425
1513
|
state.metric_tasks.append(self._fetch_all_for_device(sn, entry, dev_type))
|
|
1426
1514
|
|
|
1427
1515
|
# 🚀 DEEP DISCOVERY: If this is a Collector, DMU, or Inverter, find its children!
|
|
1428
|
-
if
|
|
1516
|
+
if dev_type in _parent_device_types:
|
|
1429
1517
|
_LOGGER.debug(
|
|
1430
1518
|
"HYXI Parent Device Found: %s (%s). Probing for sub-devices...",
|
|
1431
1519
|
_mask_id(sn),
|
|
@@ -1698,7 +1786,7 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
1698
1786
|
state.metric_tasks.append(self._fetch_all_for_device(sn, entry, dev_type))
|
|
1699
1787
|
|
|
1700
1788
|
# 🚀 DEEP BACK-DISCOVERY: If this is a parent, search for ITS children too!
|
|
1701
|
-
if
|
|
1789
|
+
if dev_type in _parent_device_types:
|
|
1702
1790
|
sub_device_tasks.append(self._fetch_sub_devices(sn, state))
|
|
1703
1791
|
|
|
1704
1792
|
async def _process_alarms_and_back_discovery(
|
|
@@ -2195,7 +2283,7 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
2195
2283
|
_LOGGER.warning("HYXI Push: dataList is missing or not a list")
|
|
2196
2284
|
return {}
|
|
2197
2285
|
|
|
2198
|
-
now_utc = datetime.now(UTC)
|
|
2286
|
+
now_utc = datetime.now(UTC)
|
|
2199
2287
|
results = {}
|
|
2200
2288
|
|
|
2201
2289
|
for device in data_list:
|
|
@@ -2208,53 +2296,17 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
2208
2296
|
if not sn:
|
|
2209
2297
|
continue
|
|
2210
2298
|
|
|
2211
|
-
# Extract metrics from flat payload dictionary
|
|
2212
|
-
# Strip metadata/routing keys
|
|
2213
|
-
raw_metrics = {}
|
|
2214
|
-
for k, v in device.items():
|
|
2215
|
-
if k in _METRICS_EXCLUDED_KEYS:
|
|
2216
|
-
continue
|
|
2217
|
-
raw_metrics[k] = v
|
|
2218
|
-
|
|
2219
2299
|
# Retrieve info from discovery cache
|
|
2220
2300
|
device_info = self._discovery_cache.get("device_info", {}).get(sn, {})
|
|
2221
2301
|
device_type = str(device_info.get("device_type_code") or "")
|
|
2222
2302
|
|
|
2223
|
-
|
|
2224
|
-
last_seen = now_utc
|
|
2225
|
-
collect_time = device.get("collectTime")
|
|
2226
|
-
report_ts = device.get("reportTimestamp")
|
|
2227
|
-
|
|
2228
|
-
if collect_time is not None:
|
|
2229
|
-
try:
|
|
2230
|
-
last_seen = datetime.fromtimestamp(
|
|
2231
|
-
float(collect_time), UTC
|
|
2232
|
-
).isoformat()
|
|
2233
|
-
except ValueError, TypeError:
|
|
2234
|
-
pass
|
|
2235
|
-
elif report_ts is not None:
|
|
2236
|
-
try:
|
|
2237
|
-
last_seen = datetime.fromtimestamp(
|
|
2238
|
-
float(report_ts) / 1000.0, UTC
|
|
2239
|
-
).isoformat()
|
|
2240
|
-
except ValueError, TypeError:
|
|
2241
|
-
pass
|
|
2303
|
+
raw_metrics = _extract_raw_push_metrics(device)
|
|
2304
|
+
last_seen = _resolve_push_timestamp(device, now_utc)
|
|
2242
2305
|
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
metrics_to_update = raw_metrics.copy()
|
|
2248
|
-
# Merge with existing metrics if provided, ignoring None/null values
|
|
2249
|
-
merged_metrics = {}
|
|
2250
|
-
if existing_metrics and sn in existing_metrics:
|
|
2251
|
-
# Copy existing to avoid mutating caller's dictionary directly
|
|
2252
|
-
merged_metrics = dict(existing_metrics[sn])
|
|
2253
|
-
|
|
2254
|
-
for k, v in metrics_to_update.items():
|
|
2255
|
-
if v is not None:
|
|
2256
|
-
merged_metrics[k] = v
|
|
2257
|
-
merged_metrics["last_seen"] = last_seen
|
|
2306
|
+
merged_metrics = _merge_push_metrics(
|
|
2307
|
+
sn, raw_metrics, device_type, existing_metrics
|
|
2308
|
+
)
|
|
2309
|
+
merged_metrics["last_seen"] = last_seen.isoformat()
|
|
2258
2310
|
|
|
2259
2311
|
# Compute derived metrics on the full merged dataset
|
|
2260
2312
|
derived = _compute_derived_metrics(merged_metrics, device_type)
|
|
@@ -12,6 +12,7 @@ tests/test_additional_coverage.py
|
|
|
12
12
|
tests/test_alarm_push.py
|
|
13
13
|
tests/test_all_in_one.py
|
|
14
14
|
tests/test_api.py
|
|
15
|
+
tests/test_api_imports.py
|
|
15
16
|
tests/test_build_plant_tasks.py
|
|
16
17
|
tests/test_caching.py
|
|
17
18
|
tests/test_compute_battery_metrics.py
|
|
@@ -51,6 +52,7 @@ tests/test_push.py
|
|
|
51
52
|
tests/test_sanitize_dict.py
|
|
52
53
|
tests/test_sanitize_list.py
|
|
53
54
|
tests/test_security_fix.py
|
|
55
|
+
tests/test_set_log_salt.py
|
|
54
56
|
tests/test_subscriptions.py
|
|
55
57
|
tests/test_token_errors.py
|
|
56
58
|
tests/test_token_handling.py
|
|
@@ -281,7 +281,7 @@ async def test_fetch_ems_basic_data_success(caplog):
|
|
|
281
281
|
ems_sn = "10602251600016"
|
|
282
282
|
entry = {"device_type_code": "EMS", "metrics": {"existing_metric": "value"}}
|
|
283
283
|
|
|
284
|
-
await api._fetch_all_for_device(ems_sn, entry, "
|
|
284
|
+
await api._fetch_all_for_device(ems_sn, entry, "EMS")
|
|
285
285
|
|
|
286
286
|
# Assert query_ems_basic_details was called
|
|
287
287
|
api.query_ems_basic_details.assert_called_once_with(ems_sn)
|
|
@@ -306,7 +306,7 @@ async def test_fetch_ems_basic_data_no_data(caplog):
|
|
|
306
306
|
ems_sn = "EMS123"
|
|
307
307
|
entry = {"device_type_code": "EMS", "metrics": {"existing_metric": "value"}}
|
|
308
308
|
|
|
309
|
-
await api._fetch_all_for_device(ems_sn, entry, "
|
|
309
|
+
await api._fetch_all_for_device(ems_sn, entry, "EMS")
|
|
310
310
|
|
|
311
311
|
# Assert query_ems_basic_details was called
|
|
312
312
|
api.query_ems_basic_details.assert_called_once_with("EMS123")
|
|
@@ -497,7 +497,7 @@ async def test_fetch_all_for_device_collector():
|
|
|
497
497
|
async def test_fetch_all_for_device_non_collector():
|
|
498
498
|
"""Test _fetch_all_for_device when dev_type is not COLLECTOR.
|
|
499
499
|
|
|
500
|
-
Non-collector devices (e.g. INVERTER) trigger device info
|
|
500
|
+
Non-collector devices (e.g. INVERTER) trigger device info and metrics
|
|
501
501
|
telemetry probing concurrently. query_ems_basic_details must be mocked to
|
|
502
502
|
prevent unawaited coroutine RuntimeWarnings from the real HTTP path.
|
|
503
503
|
"""
|
|
@@ -520,7 +520,22 @@ async def test_fetch_all_for_device_non_collector():
|
|
|
520
520
|
|
|
521
521
|
api._fetch_device_info.assert_called_once_with(sn, entry)
|
|
522
522
|
api._fetch_device_metrics.assert_called_once_with(sn, entry)
|
|
523
|
-
api.query_ems_basic_details.
|
|
523
|
+
api.query_ems_basic_details.assert_not_called()
|
|
524
|
+
|
|
525
|
+
# Reset mocks before verifying EMS-capable device behaviour
|
|
526
|
+
api._fetch_device_info.reset_mock()
|
|
527
|
+
api._fetch_device_metrics.reset_mock()
|
|
528
|
+
api.query_ems_basic_details.reset_mock()
|
|
529
|
+
|
|
530
|
+
# EMS-capable non-collector devices should still trigger the EMS probe
|
|
531
|
+
ems_sn = "EMS123"
|
|
532
|
+
ems_entry = {"device_type_code": "EMS", "metrics": {"existing_metric": "value"}}
|
|
533
|
+
|
|
534
|
+
await api._fetch_all_for_device(ems_sn, ems_entry, "EMS")
|
|
535
|
+
|
|
536
|
+
api._fetch_device_info.assert_called_once_with(ems_sn, ems_entry)
|
|
537
|
+
api._fetch_device_metrics.assert_called_once_with(ems_sn, ems_entry)
|
|
538
|
+
api.query_ems_basic_details.assert_called_once_with(ems_sn)
|
|
524
539
|
|
|
525
540
|
|
|
526
541
|
# --- TEST 6: Empty Data Response (The "Halo ESS" Scenario) ---
|
|
@@ -689,3 +704,101 @@ async def test_execute_fetch_all_force_discovery_integration():
|
|
|
689
704
|
# Ensure we successfully parsed the data, meaning full discovery worked
|
|
690
705
|
assert "device_1" in result
|
|
691
706
|
assert result["device_1"]["sw_version"] == "v1.0"
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
@pytest.mark.asyncio
|
|
710
|
+
async def test_get_all_device_data_unexpected_error():
|
|
711
|
+
"""Test that get_all_device_data propagates unexpected errors."""
|
|
712
|
+
fake_session = MagicMock()
|
|
713
|
+
api = HyxiApiClient("ak", "sk", "https://api.com", fake_session)
|
|
714
|
+
|
|
715
|
+
# Mock _execute_fetch_all to raise Exception
|
|
716
|
+
api._execute_fetch_all = AsyncMock(side_effect=Exception("Unexpected Error"))
|
|
717
|
+
|
|
718
|
+
with pytest.raises(Exception, match="Unexpected Error"):
|
|
719
|
+
await api.get_all_device_data()
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
@pytest.mark.asyncio
|
|
723
|
+
async def test_process_push_data_flat_payload():
|
|
724
|
+
"""Test process_push_data correctly handles flat payloads and normalizes grid keys."""
|
|
725
|
+
fake_session = MagicMock()
|
|
726
|
+
api = HyxiApiClient("ak", "sk", "https://api.com", fake_session)
|
|
727
|
+
api.devices_cache = {
|
|
728
|
+
"SN_FLAT": {"device_sn": "SN_FLAT", "metrics": {"oldMetric": "old"}}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
# Simulate flat payload from webhook
|
|
732
|
+
payload = {
|
|
733
|
+
"dataList": [
|
|
734
|
+
{
|
|
735
|
+
"deviceSn": "SN_FLAT",
|
|
736
|
+
"batP": 123,
|
|
737
|
+
"gridEin": 456,
|
|
738
|
+
"gridEout": 789,
|
|
739
|
+
"reportTimestamp": 1000000000000,
|
|
740
|
+
}
|
|
741
|
+
]
|
|
742
|
+
}
|
|
743
|
+
existing = {"SN_FLAT": {"oldMetric": "old"}}
|
|
744
|
+
|
|
745
|
+
results = api.process_push_data(payload, existing_metrics=existing)
|
|
746
|
+
assert "SN_FLAT" in results
|
|
747
|
+
metrics = results["SN_FLAT"]["metrics"]
|
|
748
|
+
assert metrics["batP"] == 123
|
|
749
|
+
assert metrics["oldMetric"] == "old" # preserves existing
|
|
750
|
+
assert metrics["totalEnt"] == 456 # validates flat key mapping
|
|
751
|
+
assert metrics["totalEpt"] == 789
|
|
752
|
+
assert "last_seen" in metrics
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
@pytest.mark.asyncio
|
|
756
|
+
async def test_process_push_data_nested_payload():
|
|
757
|
+
"""Test process_push_data handles legacy nested payloads and normalizes nested grid keys."""
|
|
758
|
+
fake_session = MagicMock()
|
|
759
|
+
api = HyxiApiClient("ak", "sk", "https://api.com", fake_session)
|
|
760
|
+
|
|
761
|
+
payload = {
|
|
762
|
+
"dataList": [
|
|
763
|
+
{
|
|
764
|
+
"deviceSn": "SN_NEST",
|
|
765
|
+
"battery": {"powerW": 123},
|
|
766
|
+
"grid": {"energyInKwh": 456, "energyOutKwh": 789},
|
|
767
|
+
"reportTimestamp": 1000000000000,
|
|
768
|
+
}
|
|
769
|
+
]
|
|
770
|
+
}
|
|
771
|
+
existing = {"SN_NEST": {"old": "old"}}
|
|
772
|
+
|
|
773
|
+
results = api.process_push_data(payload, existing_metrics=existing)
|
|
774
|
+
assert "SN_NEST" in results
|
|
775
|
+
metrics = results["SN_NEST"]["metrics"]
|
|
776
|
+
assert metrics["batP"] == 123
|
|
777
|
+
assert metrics["totalEnt"] == 456
|
|
778
|
+
assert metrics["totalEpt"] == 789
|
|
779
|
+
assert metrics["old"] == "old"
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
@pytest.mark.asyncio
|
|
783
|
+
async def test_process_push_data_timestamp_fallback():
|
|
784
|
+
"""Test process_push_data uses fallback timestamps when invalid."""
|
|
785
|
+
fake_session = MagicMock()
|
|
786
|
+
api = HyxiApiClient("ak", "sk", "https://api.com", fake_session)
|
|
787
|
+
|
|
788
|
+
payload = {
|
|
789
|
+
"dataList": [
|
|
790
|
+
{
|
|
791
|
+
"deviceSn": "SN_TIME",
|
|
792
|
+
"batP": 123,
|
|
793
|
+
"reportTimestamp": "INVALID",
|
|
794
|
+
"collectTime": "INVALID_TOO",
|
|
795
|
+
}
|
|
796
|
+
]
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
# Should not raise exception
|
|
800
|
+
results = api.process_push_data(payload, existing_metrics={"SN_TIME": {}})
|
|
801
|
+
assert "SN_TIME" in results
|
|
802
|
+
metrics = results["SN_TIME"]["metrics"]
|
|
803
|
+
assert "last_seen" in metrics
|
|
804
|
+
assert metrics["batP"] == 123
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Tests for import fallbacks in the API client module."""
|
|
2
|
+
|
|
3
|
+
import builtins
|
|
4
|
+
import sys
|
|
5
|
+
import unittest.mock
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_utc_import_fallback():
|
|
9
|
+
"""Test the ImportError fallback for datetime.UTC at line 27."""
|
|
10
|
+
original_import = builtins.__import__
|
|
11
|
+
|
|
12
|
+
def mocked_import(name, global_vars=None, local_vars=None, fromlist=(), level=0):
|
|
13
|
+
# Trigger ImportError when trying to import UTC from datetime
|
|
14
|
+
if name == "datetime" and fromlist and "UTC" in fromlist:
|
|
15
|
+
raise ImportError("mocked ImportError for UTC")
|
|
16
|
+
return original_import(name, global_vars, local_vars, fromlist, level)
|
|
17
|
+
|
|
18
|
+
original_api = sys.modules.get("hyxi_cloud_api.api")
|
|
19
|
+
if "hyxi_cloud_api.api" in sys.modules:
|
|
20
|
+
del sys.modules["hyxi_cloud_api.api"]
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
with unittest.mock.patch("builtins.__import__", side_effect=mocked_import):
|
|
24
|
+
from hyxi_cloud_api import api # pylint: disable=import-outside-toplevel
|
|
25
|
+
|
|
26
|
+
assert api.UTC is not None
|
|
27
|
+
assert api.UTC.tzname(None) == "UTC"
|
|
28
|
+
finally:
|
|
29
|
+
# Restore normal module state
|
|
30
|
+
if "hyxi_cloud_api.api" in sys.modules:
|
|
31
|
+
del sys.modules["hyxi_cloud_api.api"]
|
|
32
|
+
if original_api is not None:
|
|
33
|
+
sys.modules["hyxi_cloud_api.api"] = original_api
|
|
@@ -53,14 +53,13 @@ async def test_discovery_caching_logic():
|
|
|
53
53
|
# First call: Full Discovery
|
|
54
54
|
res1 = await client.get_all_device_data()
|
|
55
55
|
assert res1["data"]["S1"]["sw_version"] == "V1"
|
|
56
|
-
assert mock_req.call_count ==
|
|
56
|
+
assert mock_req.call_count == 6
|
|
57
57
|
|
|
58
58
|
# Second call: Should use cache (Fast Poll)
|
|
59
59
|
# Sequence expected for Fast Poll:
|
|
60
60
|
# 1. Alarms (for Plant)
|
|
61
61
|
# 2. Info (for SN)
|
|
62
62
|
# 3. Metrics (for SN)
|
|
63
|
-
# 4. EMS Probe
|
|
64
63
|
mock_req.reset_mock()
|
|
65
64
|
mock_req.side_effect = [
|
|
66
65
|
(200, alarms_resp),
|
|
@@ -71,7 +70,7 @@ async def test_discovery_caching_logic():
|
|
|
71
70
|
|
|
72
71
|
res2 = await client.get_all_device_data()
|
|
73
72
|
assert res2["data"]["S1"]["sw_version"] == "V1" # Still there from cache
|
|
74
|
-
assert mock_req.call_count ==
|
|
73
|
+
assert mock_req.call_count == 3
|
|
75
74
|
|
|
76
75
|
# Verify specific URL paths for fast poll
|
|
77
76
|
calls = mock_req.call_args_list
|
|
@@ -91,7 +90,7 @@ async def test_discovery_caching_logic():
|
|
|
91
90
|
(200, {}), # EMS
|
|
92
91
|
]
|
|
93
92
|
await client.get_all_device_data(force_discovery=True)
|
|
94
|
-
assert mock_req.call_count ==
|
|
93
|
+
assert mock_req.call_count == 6
|
|
95
94
|
|
|
96
95
|
|
|
97
96
|
@pytest.mark.asyncio
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import sys
|
|
2
|
-
from unittest.mock import MagicMock
|
|
2
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
3
3
|
|
|
4
4
|
if "aiohttp" not in sys.modules or not hasattr(sys.modules["aiohttp"], "ClientError"):
|
|
5
5
|
m = MagicMock()
|
|
@@ -96,3 +96,23 @@ async def test_fetch_devices_for_plant_invalid_json(caplog):
|
|
|
96
96
|
assert "Error fetching devices for plant" in caplog.text
|
|
97
97
|
assert not state.metric_tasks
|
|
98
98
|
assert not state.discovered_sns
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@pytest.mark.asyncio
|
|
102
|
+
async def test_fetch_devices_for_plant_general_exception(caplog):
|
|
103
|
+
"""Test that _fetch_devices_for_plant handles general exceptions."""
|
|
104
|
+
caplog.set_level(logging.ERROR)
|
|
105
|
+
mock_session = MagicMock()
|
|
106
|
+
api = HyxiApiClient("ak", "sk", "https://api.com", mock_session)
|
|
107
|
+
|
|
108
|
+
api._fetch_device_list_for_plant = AsyncMock(
|
|
109
|
+
side_effect=Exception("Unexpected generic error")
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
state = FetchState(now="2024-01-01")
|
|
113
|
+
await api._fetch_devices_for_plant("plant123", state)
|
|
114
|
+
|
|
115
|
+
assert "Error fetching devices for plant" in caplog.text
|
|
116
|
+
assert "Unexpected generic error" in caplog.text
|
|
117
|
+
assert not state.metric_tasks
|
|
118
|
+
assert not state.discovered_sns
|
|
@@ -22,7 +22,7 @@ from unittest.mock import AsyncMock, MagicMock
|
|
|
22
22
|
|
|
23
23
|
import pytest
|
|
24
24
|
|
|
25
|
-
from
|
|
25
|
+
from hyxi_cloud_api.api import FetchState, HyxiApiClient
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def _setup_mock_api():
|
|
@@ -259,7 +259,7 @@ async def test_fetch_sub_devices_rejected():
|
|
|
259
259
|
|
|
260
260
|
with pytest.MonkeyPatch.context() as m:
|
|
261
261
|
mock_logger = MagicMock()
|
|
262
|
-
m.setattr("
|
|
262
|
+
m.setattr("hyxi_cloud_api.api._LOGGER", mock_logger)
|
|
263
263
|
|
|
264
264
|
await api._fetch_sub_devices("BAD_SN", state)
|
|
265
265
|
|
|
@@ -284,7 +284,7 @@ async def test_fetch_sub_devices_exception():
|
|
|
284
284
|
|
|
285
285
|
with pytest.MonkeyPatch.context() as m:
|
|
286
286
|
mock_logger = MagicMock()
|
|
287
|
-
m.setattr("
|
|
287
|
+
m.setattr("hyxi_cloud_api.api._LOGGER", mock_logger)
|
|
288
288
|
|
|
289
289
|
await api._fetch_sub_devices("SOME_SN", state)
|
|
290
290
|
|
|
@@ -337,3 +337,28 @@ async def test_back_discovery_sn_validation():
|
|
|
337
337
|
res = await api.get_all_device_data(allow_back_discovery=True)
|
|
338
338
|
assert "123" not in res["data"]
|
|
339
339
|
api._fetch_all_for_device.assert_not_called()
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@pytest.mark.asyncio
|
|
343
|
+
async def test_fetch_sub_devices_outer_exception():
|
|
344
|
+
"""Verify that an exception in _fetch_sub_devices is caught and logged."""
|
|
345
|
+
api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
|
|
346
|
+
|
|
347
|
+
# Mock _fetch_sub_device_list to raise an exception directly
|
|
348
|
+
api._fetch_sub_device_list = AsyncMock(side_effect=Exception("Outer Exception"))
|
|
349
|
+
|
|
350
|
+
state = FetchState(now="Plant123")
|
|
351
|
+
|
|
352
|
+
with pytest.MonkeyPatch.context() as m:
|
|
353
|
+
mock_logger = MagicMock()
|
|
354
|
+
m.setattr("hyxi_cloud_api.api._LOGGER", mock_logger)
|
|
355
|
+
|
|
356
|
+
await api._fetch_sub_devices("SOME_SN", state)
|
|
357
|
+
|
|
358
|
+
assert len(state.metric_tasks) == 0
|
|
359
|
+
assert len(state.discovered_sns) == 0
|
|
360
|
+
|
|
361
|
+
# Verify the logger caught the exception
|
|
362
|
+
mock_logger.error.assert_called_once()
|
|
363
|
+
args, _ = mock_logger.error.call_args
|
|
364
|
+
assert "Error fetching sub-devices for" in args[0]
|
|
@@ -23,7 +23,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
23
23
|
import aiohttp
|
|
24
24
|
import pytest
|
|
25
25
|
|
|
26
|
-
from
|
|
26
|
+
from hyxi_cloud_api.api import HyxiApiClient
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
@pytest.mark.asyncio
|
|
@@ -103,7 +103,7 @@ async def test_fetch_plants_empty_list_warning():
|
|
|
103
103
|
)
|
|
104
104
|
)
|
|
105
105
|
|
|
106
|
-
with patch("
|
|
106
|
+
with patch("hyxi_cloud_api.api._LOGGER") as mock_logger:
|
|
107
107
|
plants = await api._fetch_plants()
|
|
108
108
|
assert plants == []
|
|
109
109
|
mock_logger.warning.assert_called_once_with(
|
|
@@ -106,3 +106,22 @@ async def test_fetch_sub_device_list_client_error(caplog):
|
|
|
106
106
|
assert result == []
|
|
107
107
|
assert "Error fetching sub-device list for" in caplog.text
|
|
108
108
|
assert "Connection refused" in caplog.text
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@pytest.mark.asyncio
|
|
112
|
+
async def test_fetch_sub_device_list_processing_error(caplog):
|
|
113
|
+
"""Verify that a general Exception during processing is caught, logged, and returns an empty list."""
|
|
114
|
+
caplog.set_level(logging.ERROR)
|
|
115
|
+
api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
|
|
116
|
+
# Mocking the request to return something that causes an exception (like a list instead of dict)
|
|
117
|
+
# when attempting to use res_sd.get("success")
|
|
118
|
+
api._request = AsyncMock(
|
|
119
|
+
return_value=(200, ["this", "is", "a", "list", "not", "a", "dict"])
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
result = await api._fetch_sub_device_list("parent123")
|
|
123
|
+
|
|
124
|
+
assert result == []
|
|
125
|
+
assert "Error fetching sub-device list for" in caplog.text
|
|
126
|
+
assert "no attribute 'get'" in caplog.text
|
|
127
|
+
assert any(record.levelname == "ERROR" for record in caplog.records)
|
|
@@ -86,3 +86,13 @@ class TestGetF:
|
|
|
86
86
|
"""Test a condition that triggers a ValueError and returns 0.0."""
|
|
87
87
|
data = {"key": "not-a-number"}
|
|
88
88
|
assert _get_f("key", data) == 0.0
|
|
89
|
+
|
|
90
|
+
def test_get_f_explicit_type_error(self):
|
|
91
|
+
"""Test specific condition triggering TypeError at line 567."""
|
|
92
|
+
data = {"key": []}
|
|
93
|
+
assert _get_f("key", data) == 0.0
|
|
94
|
+
|
|
95
|
+
def test_type_error_mult(self):
|
|
96
|
+
"""Invalid multiplier type should return 0.0 due to TypeError."""
|
|
97
|
+
data = {"key": 10}
|
|
98
|
+
assert _get_f("key", data, mult="invalid") == 0.0
|
|
@@ -19,7 +19,7 @@ mock_aiohttp = sys.modules["aiohttp"]
|
|
|
19
19
|
"""Tests for exception handling in _fetch_device_metrics."""
|
|
20
20
|
|
|
21
21
|
import logging
|
|
22
|
-
from unittest.mock import AsyncMock, MagicMock
|
|
22
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
23
23
|
|
|
24
24
|
import aiohttp
|
|
25
25
|
import pytest
|
|
@@ -138,7 +138,7 @@ async def test_fetch_ems_basic_data_no_data(caplog):
|
|
|
138
138
|
api._fetch_device_metrics = AsyncMock()
|
|
139
139
|
|
|
140
140
|
entry = {"metrics": {}, "device_type_code": "EMS"}
|
|
141
|
-
await api._fetch_all_for_device("10602251600016", entry, "
|
|
141
|
+
await api._fetch_all_for_device("10602251600016", entry, "EMS")
|
|
142
142
|
|
|
143
143
|
assert "HYXI EMS telemetry probe returned no data for " in caplog.text
|
|
144
144
|
# No EMS data should be merged into metrics.
|
|
@@ -158,7 +158,7 @@ async def test_fetch_ems_basic_data_error(caplog):
|
|
|
158
158
|
api._fetch_device_metrics = AsyncMock()
|
|
159
159
|
|
|
160
160
|
entry = {"metrics": {}, "device_type_code": "EMS"}
|
|
161
|
-
await api._fetch_all_for_device("10602251600016", entry, "
|
|
161
|
+
await api._fetch_all_for_device("10602251600016", entry, "EMS")
|
|
162
162
|
|
|
163
163
|
# No EMS data should be merged into metrics.
|
|
164
164
|
assert "batSoc" not in entry["metrics"]
|
|
@@ -237,3 +237,49 @@ async def test_query_ems_basic_details_malformed_response(caplog):
|
|
|
237
237
|
assert result == {}
|
|
238
238
|
assert "HYXI EMS Basic Data Request Failed for " in caplog.text
|
|
239
239
|
assert "object has no attribute 'get'" in caplog.text
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@pytest.mark.asyncio
|
|
243
|
+
async def test_query_ems_basic_details_parse_error(caplog):
|
|
244
|
+
"""Test that query_ems_basic_details handles exceptions during parsing."""
|
|
245
|
+
caplog.set_level(logging.ERROR)
|
|
246
|
+
mock_session = MagicMock()
|
|
247
|
+
api = HyxiApiClient("ak", "sk", "https://api.com", mock_session)
|
|
248
|
+
|
|
249
|
+
# Return a response with code 0 but invalid data type to trigger an exception
|
|
250
|
+
api._request = AsyncMock(return_value=(200, {"code": "0", "data": []}))
|
|
251
|
+
|
|
252
|
+
with patch(
|
|
253
|
+
"hyxi_cloud_api.api._parse_ems_kv", side_effect=Exception("Parsing error")
|
|
254
|
+
):
|
|
255
|
+
result = await api.query_ems_basic_details("10602251600016")
|
|
256
|
+
|
|
257
|
+
assert result == {}
|
|
258
|
+
assert "HYXI EMS Basic Data Request Failed for" in caplog.text
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@pytest.mark.asyncio
|
|
262
|
+
async def test_fetch_device_metrics_parsing_error(caplog, monkeypatch):
|
|
263
|
+
"""Test that _fetch_device_metrics handles parsing errors gracefully."""
|
|
264
|
+
caplog.set_level(logging.ERROR)
|
|
265
|
+
mock_session = MagicMock()
|
|
266
|
+
api = HyxiApiClient("ak", "sk", "https://api.com", mock_session)
|
|
267
|
+
|
|
268
|
+
# Return a success response so that it enters the 'if res_q.get("success"):' block
|
|
269
|
+
api._request = AsyncMock(
|
|
270
|
+
return_value=(200, {"success": True, "data": [{"key": "bad"}]})
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def mock_parse(*args, **kwargs):
|
|
274
|
+
raise ValueError("Mock parsing error")
|
|
275
|
+
|
|
276
|
+
# Mock _parse_data_list to trigger the exception block at line 1234
|
|
277
|
+
import hyxi_cloud_api.api as api_module # pylint: disable=import-outside-toplevel
|
|
278
|
+
|
|
279
|
+
monkeypatch.setattr(api_module, "_parse_data_list", mock_parse)
|
|
280
|
+
|
|
281
|
+
entry = {"metrics": {}, "device_type_code": "INVERTER"}
|
|
282
|
+
await api._fetch_device_metrics("10602251600016", entry)
|
|
283
|
+
|
|
284
|
+
assert "Error fetching metrics for " in caplog.text
|
|
285
|
+
assert "Mock parsing error" in caplog.text
|
|
@@ -267,8 +267,8 @@ def test_process_push_data_nested_format():
|
|
|
267
267
|
assert metrics["gridP"] == -1.5
|
|
268
268
|
assert metrics["gridF"] == 50.02
|
|
269
269
|
assert metrics["gridPfd"] == 0.98
|
|
270
|
-
assert metrics["
|
|
271
|
-
assert metrics["
|
|
270
|
+
assert metrics["totalEnt"] == 1023.4
|
|
271
|
+
assert metrics["totalEpt"] == 504.2
|
|
272
272
|
assert metrics["grid_import"] == 1500.0
|
|
273
273
|
assert metrics["grid_export"] == 0.0
|
|
274
274
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from unittest.mock import patch
|
|
4
4
|
|
|
5
|
-
from
|
|
5
|
+
from hyxi_cloud_api.api import _sanitize_dict, _sanitize_list
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
def test_sanitize_dict_plant_address():
|
|
@@ -16,7 +16,7 @@ def test_sanitize_dict_plant_address():
|
|
|
16
16
|
assert result["plantAddress"] == "[REDACTED]"
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
@patch("
|
|
19
|
+
@patch("hyxi_cloud_api.api._mask_id", return_value="MASKED")
|
|
20
20
|
def test_sanitize_dict_sensitive_keys(mock_mask_id):
|
|
21
21
|
"""Test that keys matching _SENSITIVE_KEYS are correctly masked."""
|
|
22
22
|
raw = {
|
|
@@ -25,7 +25,7 @@ def test_sanitize_dict_sensitive_keys(mock_mask_id):
|
|
|
25
25
|
"plantId": None, # falsy value
|
|
26
26
|
}
|
|
27
27
|
with patch(
|
|
28
|
-
"
|
|
28
|
+
"hyxi_cloud_api.api._SENSITIVE_KEYS", {"deviceSn", "parentSn", "plantId"}
|
|
29
29
|
):
|
|
30
30
|
result = _sanitize_dict(raw)
|
|
31
31
|
|
|
@@ -60,7 +60,7 @@ def test_sanitize_dict_alarmstate_passthrough():
|
|
|
60
60
|
assert result["alarmState"] == "inactive"
|
|
61
61
|
|
|
62
62
|
|
|
63
|
-
@patch("
|
|
63
|
+
@patch("hyxi_cloud_api.api._mask_id", return_value="MASKED")
|
|
64
64
|
def test_sanitize_dict_auth_keys(mock_mask_id):
|
|
65
65
|
"""Test that authentication keys are correctly masked."""
|
|
66
66
|
raw = {
|
|
@@ -70,7 +70,7 @@ def test_sanitize_dict_auth_keys(mock_mask_id):
|
|
|
70
70
|
"password": "super_secret_password",
|
|
71
71
|
}
|
|
72
72
|
with patch(
|
|
73
|
-
"
|
|
73
|
+
"hyxi_cloud_api.api._SENSITIVE_KEYS",
|
|
74
74
|
{"token", "access_token", "refresh_token", "password"},
|
|
75
75
|
):
|
|
76
76
|
result = _sanitize_dict(raw)
|
|
@@ -128,8 +128,8 @@ def test_sanitize_list():
|
|
|
128
128
|
"",
|
|
129
129
|
[{"plantId": "987654321", "otherKey": 123}, ""],
|
|
130
130
|
]
|
|
131
|
-
with patch("
|
|
132
|
-
with patch("
|
|
131
|
+
with patch("hyxi_cloud_api.api._SENSITIVE_KEYS", {"deviceSn", "plantId"}):
|
|
132
|
+
with patch("hyxi_cloud_api.api._mask_id", return_value="MASKED"):
|
|
133
133
|
result = _sanitize_list(raw_list)
|
|
134
134
|
|
|
135
135
|
expected = [
|
|
@@ -160,10 +160,10 @@ def test_sanitize_list_edge_cases():
|
|
|
160
160
|
[[{"password": "xyz", "empty": ""}, ""]],
|
|
161
161
|
]
|
|
162
162
|
with patch(
|
|
163
|
-
"
|
|
163
|
+
"hyxi_cloud_api.api._SENSITIVE_KEYS",
|
|
164
164
|
{"deviceSn", "plantId", "token", "password"},
|
|
165
165
|
):
|
|
166
|
-
with patch("
|
|
166
|
+
with patch("hyxi_cloud_api.api._mask_id", return_value="MASKED"):
|
|
167
167
|
result = _sanitize_list(raw_list)
|
|
168
168
|
|
|
169
169
|
expected = [
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from unittest.mock import patch
|
|
4
4
|
|
|
5
|
-
from
|
|
5
|
+
from hyxi_cloud_api.api import _sanitize_list
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
def test_sanitize_list_empty():
|
|
@@ -29,7 +29,7 @@ def test_sanitize_list_nested():
|
|
|
29
29
|
assert _sanitize_list(raw) == [1, [None, 2], None]
|
|
30
30
|
|
|
31
31
|
|
|
32
|
-
@patch("
|
|
32
|
+
@patch("hyxi_cloud_api.api._mask_id", return_value="MASKED")
|
|
33
33
|
def test_sanitize_list_with_dicts(mock_mask):
|
|
34
34
|
"""Test that dictionaries within a list are sanitized correctly."""
|
|
35
35
|
raw = [{"deviceSn": "123456789"}, ""]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Tests for the set_log_salt function in api.py."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from hyxi_cloud_api import api
|
|
6
|
+
from hyxi_cloud_api.api import _mask_id, set_log_salt
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture(autouse=True)
|
|
10
|
+
def restore_log_salt():
|
|
11
|
+
"""Fixture to ensure the original _LOG_SALT is restored after each test."""
|
|
12
|
+
original_salt = api._LOG_SALT
|
|
13
|
+
yield
|
|
14
|
+
api._LOG_SALT = original_salt
|
|
15
|
+
_mask_id.cache_clear()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_set_log_salt_with_string():
|
|
19
|
+
"""Test that setting log salt with a string correctly encodes it to bytes and clears cache."""
|
|
20
|
+
# Given an initial salt
|
|
21
|
+
set_log_salt(b"initial_salt_123")
|
|
22
|
+
|
|
23
|
+
# Pre-populate cache for a specific ID
|
|
24
|
+
test_id = "test_sn_123"
|
|
25
|
+
initial_masked = _mask_id(test_id)
|
|
26
|
+
|
|
27
|
+
# Act
|
|
28
|
+
set_log_salt("new_string_salt")
|
|
29
|
+
|
|
30
|
+
# Assert
|
|
31
|
+
assert api._LOG_SALT == b"new_string_salt"
|
|
32
|
+
|
|
33
|
+
# Verify the cache was cleared and new salt is used
|
|
34
|
+
new_masked = _mask_id(test_id)
|
|
35
|
+
assert new_masked != initial_masked
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_set_log_salt_with_bytes():
|
|
39
|
+
"""Test that setting log salt with bytes correctly assigns it and clears cache."""
|
|
40
|
+
# Given an initial salt
|
|
41
|
+
set_log_salt("initial_salt_123")
|
|
42
|
+
|
|
43
|
+
# Pre-populate cache for a specific ID
|
|
44
|
+
test_id = "test_sn_123"
|
|
45
|
+
initial_masked = _mask_id(test_id)
|
|
46
|
+
|
|
47
|
+
# Act
|
|
48
|
+
set_log_salt(b"new_bytes_salt")
|
|
49
|
+
|
|
50
|
+
# Assert
|
|
51
|
+
assert api._LOG_SALT == b"new_bytes_salt"
|
|
52
|
+
|
|
53
|
+
# Verify the cache was cleared and new salt is used
|
|
54
|
+
new_masked = _mask_id(test_id)
|
|
55
|
+
assert new_masked != initial_masked
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/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
|
{hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_process_alarms_and_back_discovery.py
RENAMED
|
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.3.9 → hyxi_cloud_api-1.4.0}/tests/test_validate_subscription_device_sns.py
RENAMED
|
File without changes
|