hyxi-cloud-api 1.3.7__tar.gz → 1.3.9__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.7/src/hyxi_cloud_api.egg-info → hyxi_cloud_api-1.3.9}/PKG-INFO +1 -4
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/pyproject.toml +1 -4
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/src/hyxi_cloud_api/__init__.py +1 -1
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/src/hyxi_cloud_api/api.py +54 -21
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9/src/hyxi_cloud_api.egg-info}/PKG-INFO +1 -4
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/src/hyxi_cloud_api.egg-info/SOURCES.txt +3 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_additional_coverage.py +4 -3
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_api.py +2 -1
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_compute_battery_metrics.py +59 -0
- hyxi_cloud_api-1.3.9/tests/test_compute_pv_metrics.py +50 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_device_control.py +24 -0
- hyxi_cloud_api-1.3.9/tests/test_ensure_authenticated.py +46 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_fetch_device_list_for_plant.py +0 -1
- hyxi_cloud_api-1.3.9/tests/test_filter_metrics.py +133 -0
- hyxi_cloud_api-1.3.9/tests/test_flatten_nested_push_device.py +59 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_info_errors.py +6 -7
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_mask_id.py +6 -2
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_metrics_errors.py +14 -16
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_push.py +65 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_security_fix.py +6 -6
- hyxi_cloud_api-1.3.7/tests/test_filter_metrics.py +0 -75
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/LICENSE +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/README.md +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/setup.cfg +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/src/hyxi_cloud_api.egg-info/dependency_links.txt +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/src/hyxi_cloud_api.egg-info/requires.txt +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/src/hyxi_cloud_api.egg-info/top_level.txt +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_alarm_push.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_all_in_one.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_build_plant_tasks.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_caching.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_compute_derived_metrics.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_compute_grid_metrics.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_compute_load_metrics.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_device_entry.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_devices_errors.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_discovery.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_execute_device_tasks.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_execute_metric_tasks.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_execute_metrics_and_map_alarms.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_extract_battery_info.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_extract_device_info_metadata.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_fetch_and_process_alarms.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_fetch_plants.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_fetch_state.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_fetch_sub_device_list.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_fuzz_parser.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_get_f.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_handle_back_discovery_alarm.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_parse_data_list.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_parse_ems_kv.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_parser.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_process_alarms_and_back_discovery.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_process_devices_for_plant.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_sanitize_dict.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_sanitize_list.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_subscriptions.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_token_errors.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_token_handling.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_validate_post_rate_ms.py +0 -0
- {hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_validate_subscription_device_sns.py +0 -0
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hyxi-cloud-api
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.9
|
|
4
4
|
Summary: An async API client for HYXi Cloud.
|
|
5
5
|
Author-email: Veldkornet <Veldkornet@users.noreply.github.com>
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
7
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
8
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
9
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
10
7
|
Classifier: Programming Language :: Python :: 3.14
|
|
11
8
|
Classifier: License :: OSI Approved :: MIT License
|
|
12
9
|
Classifier: Operating System :: OS Independent
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hyxi-cloud-api"
|
|
7
|
-
version = "1.3.
|
|
7
|
+
version = "1.3.9"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name="Veldkornet", email="Veldkornet@users.noreply.github.com" },
|
|
10
10
|
]
|
|
@@ -16,9 +16,6 @@ dependencies = [
|
|
|
16
16
|
]
|
|
17
17
|
classifiers = [
|
|
18
18
|
"Programming Language :: Python :: 3",
|
|
19
|
-
"Programming Language :: Python :: 3.11",
|
|
20
|
-
"Programming Language :: Python :: 3.12",
|
|
21
|
-
"Programming Language :: Python :: 3.13",
|
|
22
19
|
"Programming Language :: Python :: 3.14",
|
|
23
20
|
"License :: OSI Approved :: MIT License",
|
|
24
21
|
"Operating System :: OS Independent",
|
|
@@ -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.3.
|
|
8
|
+
__version__ = "1.3.9"
|
|
9
9
|
__all__ = ["VPP_ACTIVE_MODES", "HyxiApiClient"]
|
|
@@ -15,6 +15,7 @@ import hmac
|
|
|
15
15
|
import logging
|
|
16
16
|
import os
|
|
17
17
|
import re
|
|
18
|
+
import secrets
|
|
18
19
|
import time
|
|
19
20
|
from collections import defaultdict
|
|
20
21
|
from dataclasses import dataclass, field
|
|
@@ -646,28 +647,44 @@ def _compute_battery_metrics(
|
|
|
646
647
|
return True
|
|
647
648
|
|
|
648
649
|
# Unify cumulative battery charge energy telemetry keys to resolve polling/push mismatch
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
derived["totalEchg"] =
|
|
658
|
-
derived["
|
|
650
|
+
total_echg_val = (
|
|
651
|
+
_get_f("totalEchg", m_raw) if _is_valid_metric("totalEchg") else None
|
|
652
|
+
)
|
|
653
|
+
bat_charge_val = (
|
|
654
|
+
_get_f("batCharge", m_raw) if _is_valid_metric("batCharge") else None
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
if total_echg_val is not None:
|
|
658
|
+
derived["totalEchg"] = total_echg_val
|
|
659
|
+
derived["bat_charge_total"] = total_echg_val
|
|
660
|
+
if bat_charge_val is None:
|
|
661
|
+
derived["batCharge"] = total_echg_val
|
|
662
|
+
|
|
663
|
+
if bat_charge_val is not None:
|
|
664
|
+
derived["batCharge"] = bat_charge_val
|
|
665
|
+
if total_echg_val is None:
|
|
666
|
+
derived["totalEchg"] = bat_charge_val
|
|
667
|
+
derived["bat_charge_total"] = bat_charge_val
|
|
659
668
|
|
|
660
669
|
# Unify cumulative battery discharge energy telemetry keys to resolve polling/push mismatch
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
derived["totalEdchg"] =
|
|
670
|
-
derived["
|
|
670
|
+
total_edchg_val = (
|
|
671
|
+
_get_f("totalEdchg", m_raw) if _is_valid_metric("totalEdchg") else None
|
|
672
|
+
)
|
|
673
|
+
bat_discharge_val = (
|
|
674
|
+
_get_f("batDisCharge", m_raw) if _is_valid_metric("batDisCharge") else None
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
if total_edchg_val is not None:
|
|
678
|
+
derived["totalEdchg"] = total_edchg_val
|
|
679
|
+
derived["bat_discharge_total"] = total_edchg_val
|
|
680
|
+
if bat_discharge_val is None:
|
|
681
|
+
derived["batDisCharge"] = total_edchg_val
|
|
682
|
+
|
|
683
|
+
if bat_discharge_val is not None:
|
|
684
|
+
derived["batDisCharge"] = bat_discharge_val
|
|
685
|
+
if total_edchg_val is None:
|
|
686
|
+
derived["totalEdchg"] = bat_discharge_val
|
|
687
|
+
derived["bat_discharge_total"] = bat_discharge_val
|
|
671
688
|
|
|
672
689
|
|
|
673
690
|
def _compute_pv_metrics(m_raw: dict, derived: dict[str, float]) -> None:
|
|
@@ -883,6 +900,22 @@ def _flatten_nested_push_device(device: dict) -> dict: # pylint: disable=too-ma
|
|
|
883
900
|
return flat
|
|
884
901
|
|
|
885
902
|
|
|
903
|
+
# Deployment-configured secret for log masking. If not provided,
|
|
904
|
+
# falls back to a process-random secret.
|
|
905
|
+
_LOG_SALT = os.environ.get("HYXI_LOG_SALT", "").encode("utf-8") or secrets.token_bytes(
|
|
906
|
+
16
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def set_log_salt(salt: str | bytes) -> None:
|
|
911
|
+
"""Set a deployment-stable secret for cross-device log correlation."""
|
|
912
|
+
global _LOG_SALT # pylint: disable=global-statement
|
|
913
|
+
if isinstance(salt, str):
|
|
914
|
+
salt = salt.encode("utf-8")
|
|
915
|
+
_LOG_SALT = salt
|
|
916
|
+
_mask_id.cache_clear()
|
|
917
|
+
|
|
918
|
+
|
|
886
919
|
@functools.lru_cache(maxsize=1024)
|
|
887
920
|
def _mask_id(value: str) -> str:
|
|
888
921
|
"""Mask an identifier (SN, plant ID, etc.) for logs.
|
|
@@ -896,7 +929,7 @@ def _mask_id(value: str) -> str:
|
|
|
896
929
|
if not value or value == "None":
|
|
897
930
|
return "****"
|
|
898
931
|
id_str = str(value)
|
|
899
|
-
return
|
|
932
|
+
return hmac.new(_LOG_SALT, id_str.encode("utf-8"), hashlib.sha256).hexdigest()[:8]
|
|
900
933
|
|
|
901
934
|
|
|
902
935
|
# Keys in raw API response dicts that contain identifying or personal information.
|
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hyxi-cloud-api
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.9
|
|
4
4
|
Summary: An async API client for HYXi Cloud.
|
|
5
5
|
Author-email: Veldkornet <Veldkornet@users.noreply.github.com>
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
7
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
8
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
9
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
10
7
|
Classifier: Programming Language :: Python :: 3.14
|
|
11
8
|
Classifier: License :: OSI Approved :: MIT License
|
|
12
9
|
Classifier: Operating System :: OS Independent
|
|
@@ -18,10 +18,12 @@ tests/test_compute_battery_metrics.py
|
|
|
18
18
|
tests/test_compute_derived_metrics.py
|
|
19
19
|
tests/test_compute_grid_metrics.py
|
|
20
20
|
tests/test_compute_load_metrics.py
|
|
21
|
+
tests/test_compute_pv_metrics.py
|
|
21
22
|
tests/test_device_control.py
|
|
22
23
|
tests/test_device_entry.py
|
|
23
24
|
tests/test_devices_errors.py
|
|
24
25
|
tests/test_discovery.py
|
|
26
|
+
tests/test_ensure_authenticated.py
|
|
25
27
|
tests/test_execute_device_tasks.py
|
|
26
28
|
tests/test_execute_metric_tasks.py
|
|
27
29
|
tests/test_execute_metrics_and_map_alarms.py
|
|
@@ -33,6 +35,7 @@ tests/test_fetch_plants.py
|
|
|
33
35
|
tests/test_fetch_state.py
|
|
34
36
|
tests/test_fetch_sub_device_list.py
|
|
35
37
|
tests/test_filter_metrics.py
|
|
38
|
+
tests/test_flatten_nested_push_device.py
|
|
36
39
|
tests/test_fuzz_parser.py
|
|
37
40
|
tests/test_get_f.py
|
|
38
41
|
tests/test_handle_back_discovery_alarm.py
|
|
@@ -32,7 +32,7 @@ async def test_fetch_sub_devices_coverage(caplog):
|
|
|
32
32
|
|
|
33
33
|
# Verify debug log was hit (line 1243)
|
|
34
34
|
# PARENT_SN is masked to ca7b0e8c
|
|
35
|
-
assert "HYXI Found 3 sub-devices under
|
|
35
|
+
assert "HYXI Found 3 sub-devices under " in caplog.text
|
|
36
36
|
# Verify duplicate/empty check worked (line 1253)
|
|
37
37
|
assert api._fetch_all_for_device.call_count == 1
|
|
38
38
|
api._fetch_all_for_device.assert_called_once_with(
|
|
@@ -54,7 +54,8 @@ async def test_fetch_sub_devices_coverage(caplog):
|
|
|
54
54
|
api._fetch_sub_device_list = AsyncMock(side_effect=Exception("Database failure"))
|
|
55
55
|
await api._fetch_sub_devices("PARENT_SN", state)
|
|
56
56
|
# PARENT_SN is masked to ca7b0e8c
|
|
57
|
-
assert "Error fetching sub-devices for
|
|
57
|
+
assert "Error fetching sub-devices for " in caplog.text
|
|
58
|
+
assert "Database failure" in caplog.text
|
|
58
59
|
|
|
59
60
|
|
|
60
61
|
@pytest.mark.asyncio
|
|
@@ -71,7 +72,7 @@ async def test_fetch_alarms_for_plant_coverage(caplog):
|
|
|
71
72
|
res = await api._fetch_alarms_for_plant("PLANT_123")
|
|
72
73
|
assert res == []
|
|
73
74
|
# PLANT_123 is masked to b7a0873d
|
|
74
|
-
assert "HYXI API Alarm Fetch Rejected for Plant
|
|
75
|
+
assert "HYXI API Alarm Fetch Rejected for Plant " in caplog.text
|
|
75
76
|
|
|
76
77
|
# 2. Alarm Name Mapping (line 1295)
|
|
77
78
|
api._request = AsyncMock(
|
|
@@ -592,7 +592,8 @@ async def test_fetch_alarms_for_plant_error(caplog):
|
|
|
592
592
|
assert alarms == []
|
|
593
593
|
|
|
594
594
|
log_text = caplog.text
|
|
595
|
-
assert "Error fetching alarms for plant
|
|
595
|
+
assert "Error fetching alarms for plant " in log_text
|
|
596
|
+
assert "Connection reset" in log_text
|
|
596
597
|
|
|
597
598
|
|
|
598
599
|
@pytest.mark.asyncio
|
|
@@ -107,3 +107,62 @@ def test_compute_battery_metrics_none_device_type():
|
|
|
107
107
|
_compute_battery_metrics(m_raw, derived, None) # type: ignore
|
|
108
108
|
assert derived["bat_charging"] == 100.0
|
|
109
109
|
assert derived["bat_power_dc"] == -100.0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_compute_battery_metrics_both_present():
|
|
113
|
+
"""Test that when both polling and daily keys are present, they are preserved.
|
|
114
|
+
|
|
115
|
+
Cumulative values should take priority for battery totals.
|
|
116
|
+
"""
|
|
117
|
+
derived: dict[str, float] = {}
|
|
118
|
+
m_raw = {
|
|
119
|
+
"totalEchg": 366.3,
|
|
120
|
+
"totalEdchg": 371.3,
|
|
121
|
+
"batCharge": 2.3,
|
|
122
|
+
"batDisCharge": 4.4,
|
|
123
|
+
}
|
|
124
|
+
_compute_battery_metrics(m_raw, derived, "OTHER")
|
|
125
|
+
assert derived["bat_charge_total"] == 366.3
|
|
126
|
+
assert derived["bat_discharge_total"] == 371.3
|
|
127
|
+
assert derived["totalEchg"] == 366.3
|
|
128
|
+
assert derived["totalEdchg"] == 371.3
|
|
129
|
+
assert derived["batCharge"] == 2.3
|
|
130
|
+
assert derived["batDisCharge"] == 4.4
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_is_valid_metric_edge_cases():
|
|
134
|
+
"""Test all edge cases for _is_valid_metric helper via _compute_battery_metrics."""
|
|
135
|
+
invalid_values = [
|
|
136
|
+
None,
|
|
137
|
+
"",
|
|
138
|
+
" ",
|
|
139
|
+
"null",
|
|
140
|
+
"NULL",
|
|
141
|
+
" none ",
|
|
142
|
+
"na",
|
|
143
|
+
" NA ",
|
|
144
|
+
"--",
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
for val in invalid_values:
|
|
148
|
+
derived: dict[str, float] = {}
|
|
149
|
+
m_raw = {"totalEchg": val}
|
|
150
|
+
_compute_battery_metrics(m_raw, derived, "OTHER")
|
|
151
|
+
assert "totalEchg" not in derived
|
|
152
|
+
assert "bat_charge_total" not in derived
|
|
153
|
+
|
|
154
|
+
valid_values = [
|
|
155
|
+
0,
|
|
156
|
+
1.5,
|
|
157
|
+
"1.5",
|
|
158
|
+
" 2.5 ",
|
|
159
|
+
"0",
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
for val in valid_values:
|
|
163
|
+
derived: dict[str, float] = {}
|
|
164
|
+
m_raw = {"totalEchg": val}
|
|
165
|
+
_compute_battery_metrics(m_raw, derived, "OTHER")
|
|
166
|
+
assert "totalEchg" in derived
|
|
167
|
+
assert "bat_charge_total" in derived
|
|
168
|
+
assert derived["totalEchg"] == float(val)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Tests for the _compute_pv_metrics helper function in api.py."""
|
|
2
|
+
|
|
3
|
+
from src.hyxi_cloud_api.api import _compute_pv_metrics
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestComputePvMetrics:
|
|
7
|
+
"""Tests for _compute_pv_metrics."""
|
|
8
|
+
|
|
9
|
+
def test_compute_pv_metrics_with_direct_power(self):
|
|
10
|
+
"""Test PV power uses direct power when provided."""
|
|
11
|
+
m_raw = {"pv1v": 100.0, "pv1i": 5.0, "pv1p": 600.0}
|
|
12
|
+
derived: dict[str, float] = {}
|
|
13
|
+
_compute_pv_metrics(m_raw, derived)
|
|
14
|
+
assert derived["pv1p"] == 600.0
|
|
15
|
+
|
|
16
|
+
def test_compute_pv_metrics_with_voltage_current(self):
|
|
17
|
+
"""Test PV power calculates voltage * current when direct power is missing."""
|
|
18
|
+
m_raw = {"pv1v": 100.0, "pv1i": 5.5}
|
|
19
|
+
derived: dict[str, float] = {}
|
|
20
|
+
_compute_pv_metrics(m_raw, derived)
|
|
21
|
+
assert derived["pv1p"] == 550.0
|
|
22
|
+
|
|
23
|
+
def test_compute_pv_metrics_empty_dict(self):
|
|
24
|
+
"""Test empty input results in empty derived metrics."""
|
|
25
|
+
m_raw = {}
|
|
26
|
+
derived: dict[str, float] = {}
|
|
27
|
+
_compute_pv_metrics(m_raw, derived)
|
|
28
|
+
assert not derived
|
|
29
|
+
|
|
30
|
+
def test_compute_pv_metrics_pv1_fallback_normal(self):
|
|
31
|
+
"""Test pv1p fallback is calculated correctly from ppv and pv2p."""
|
|
32
|
+
m_raw = {"ppv": 1000.0}
|
|
33
|
+
derived: dict[str, float] = {"pv2p": 400.0}
|
|
34
|
+
_compute_pv_metrics(m_raw, derived)
|
|
35
|
+
assert derived["pv1p"] == 600.0
|
|
36
|
+
assert derived["pv2p"] == 400.0
|
|
37
|
+
|
|
38
|
+
def test_compute_pv_metrics_pv1_fallback_negative(self):
|
|
39
|
+
"""Test pv1p fallback bounds negative results to 0.0."""
|
|
40
|
+
m_raw = {"ppv": 300.0}
|
|
41
|
+
derived: dict[str, float] = {"pv2p": 400.0}
|
|
42
|
+
_compute_pv_metrics(m_raw, derived)
|
|
43
|
+
assert derived["pv1p"] == 0.0
|
|
44
|
+
|
|
45
|
+
def test_compute_pv_metrics_zero_direct_power_with_vi(self):
|
|
46
|
+
"""Test PV power falls back to calculated value if direct power is exactly 0.0."""
|
|
47
|
+
m_raw = {"pv1v": 100.0, "pv1i": 5.0, "pv1p": 0.0}
|
|
48
|
+
derived: dict[str, float] = {}
|
|
49
|
+
_compute_pv_metrics(m_raw, derived)
|
|
50
|
+
assert derived["pv1p"] == 500.0
|
|
@@ -245,6 +245,30 @@ async def test_set_micro_power_limit_mocked_control():
|
|
|
245
245
|
assert result == {"success": True}
|
|
246
246
|
|
|
247
247
|
|
|
248
|
+
@pytest.mark.asyncio
|
|
249
|
+
async def test_restart_device_mocked_control():
|
|
250
|
+
"""Test restart_device by mocking set_device_control and checking edge cases."""
|
|
251
|
+
api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
|
|
252
|
+
api.set_device_control = AsyncMock(return_value={"success": True})
|
|
253
|
+
|
|
254
|
+
result = await api.restart_device(device_sn="SN123")
|
|
255
|
+
|
|
256
|
+
assert result == {"success": True}
|
|
257
|
+
api.set_device_control.assert_called_once()
|
|
258
|
+
|
|
259
|
+
call_args, call_kwargs = api.set_device_control.call_args
|
|
260
|
+
if "param_code" in call_kwargs:
|
|
261
|
+
assert call_kwargs["device_sn"] == "SN123"
|
|
262
|
+
assert call_kwargs["param_code"] == "rebootSet"
|
|
263
|
+
assert call_kwargs["value"] == 1
|
|
264
|
+
elif len(call_args) > 1 and call_args[1] == "rebootSet":
|
|
265
|
+
assert call_args[0] == "SN123"
|
|
266
|
+
assert call_args[1] == "rebootSet"
|
|
267
|
+
assert call_args[2] == 1
|
|
268
|
+
else:
|
|
269
|
+
api.set_device_control.assert_awaited_once_with("SN123", {3013: "1"})
|
|
270
|
+
|
|
271
|
+
|
|
248
272
|
@pytest.mark.asyncio
|
|
249
273
|
async def test_restart_device():
|
|
250
274
|
"""Test restart_device sends controlId 3013 with value '1'."""
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Tests for _ensure_authenticated."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from hyxi_cloud_api.api import HyxiApiClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DummyError(Exception):
|
|
11
|
+
"""A dummy exception class for testing."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def api_client():
|
|
16
|
+
"""Fixture for HyxiApiClient."""
|
|
17
|
+
session = MagicMock()
|
|
18
|
+
return HyxiApiClient("access_key", "secret_key", "https://api.example.com", session)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.mark.asyncio
|
|
22
|
+
async def test_ensure_authenticated_success(api_client):
|
|
23
|
+
"""Test _ensure_authenticated succeeds without raising when token refresh is successful."""
|
|
24
|
+
api_client._refresh_token = AsyncMock(return_value=True)
|
|
25
|
+
await api_client._ensure_authenticated(DummyError)
|
|
26
|
+
api_client._refresh_token.assert_awaited_once()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.mark.asyncio
|
|
30
|
+
async def test_ensure_authenticated_auth_failed(api_client):
|
|
31
|
+
"""Test _ensure_authenticated raises error_cls with specific message on auth_failed."""
|
|
32
|
+
api_client._refresh_token = AsyncMock(return_value="auth_failed")
|
|
33
|
+
with pytest.raises(DummyError, match="Authentication failed"):
|
|
34
|
+
await api_client._ensure_authenticated(DummyError)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.mark.asyncio
|
|
38
|
+
async def test_ensure_authenticated_no_token(api_client):
|
|
39
|
+
"""Test _ensure_authenticated raises error_cls when token cannot be obtained."""
|
|
40
|
+
api_client._refresh_token = AsyncMock(return_value=False)
|
|
41
|
+
with pytest.raises(DummyError, match="Could not obtain API token"):
|
|
42
|
+
await api_client._ensure_authenticated(DummyError)
|
|
43
|
+
|
|
44
|
+
api_client._refresh_token = AsyncMock(return_value=None)
|
|
45
|
+
with pytest.raises(DummyError, match="Could not obtain API token"):
|
|
46
|
+
await api_client._ensure_authenticated(DummyError)
|
|
@@ -86,7 +86,6 @@ async def test_fetch_device_list_for_plant_debug_logging(caplog):
|
|
|
86
86
|
await api._fetch_device_list_for_plant("plant123")
|
|
87
87
|
assert "HYXI Discovered Devices for Plant" in caplog.text
|
|
88
88
|
# SN12345678 -> 10 chars. _mask_id should show last 4. 10-4 = 6 X's.
|
|
89
|
-
assert "5d82af15" in caplog.text
|
|
90
89
|
|
|
91
90
|
|
|
92
91
|
@pytest.mark.asyncio
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from unittest.mock import MagicMock
|
|
3
|
+
|
|
4
|
+
if "aiohttp" not in sys.modules or not hasattr(sys.modules["aiohttp"], "ClientError"):
|
|
5
|
+
m = MagicMock()
|
|
6
|
+
|
|
7
|
+
class MockExp(Exception):
|
|
8
|
+
def __init__(self, *args, **kwargs):
|
|
9
|
+
super().__init__(*args)
|
|
10
|
+
for k, v in kwargs.items():
|
|
11
|
+
setattr(self, k, v)
|
|
12
|
+
|
|
13
|
+
m.ClientError = MockExp
|
|
14
|
+
m.ClientResponseError = type("ClientResponseError", (MockExp,), {})
|
|
15
|
+
m.ContentTypeError = type("ContentTypeError", (MockExp,), {})
|
|
16
|
+
sys.modules["aiohttp"] = m
|
|
17
|
+
mock_aiohttp = sys.modules["aiohttp"]
|
|
18
|
+
|
|
19
|
+
"""Tests for _filter_collector_metrics optimization."""
|
|
20
|
+
|
|
21
|
+
from src.hyxi_cloud_api.api import (
|
|
22
|
+
_filter_collector_metrics,
|
|
23
|
+
_is_collector_key_allowed,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_filter_collector_metrics_filtering():
|
|
28
|
+
"""Verify that sensitive metrics are filtered out."""
|
|
29
|
+
m_raw = {
|
|
30
|
+
"totalE": "2731.9",
|
|
31
|
+
"pbat": "-500",
|
|
32
|
+
"gridP": "1.5",
|
|
33
|
+
"ph1Loadp": "0.5",
|
|
34
|
+
"ph2Loadp": "0.5",
|
|
35
|
+
"ph3Loadp": "0.5",
|
|
36
|
+
"temp": "40.5",
|
|
37
|
+
"vpv1": "400",
|
|
38
|
+
"vbat": "52",
|
|
39
|
+
"soc": "100",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
filtered = _filter_collector_metrics(m_raw)
|
|
43
|
+
|
|
44
|
+
# These should be filtered out
|
|
45
|
+
assert "pbat" not in filtered
|
|
46
|
+
assert "gridP" not in filtered
|
|
47
|
+
assert "ph1Loadp" not in filtered
|
|
48
|
+
assert "ph2Loadp" not in filtered
|
|
49
|
+
assert "ph3Loadp" not in filtered
|
|
50
|
+
assert "vpv1" not in filtered
|
|
51
|
+
assert "vbat" not in filtered
|
|
52
|
+
|
|
53
|
+
# These should remain
|
|
54
|
+
assert "totalE" in filtered
|
|
55
|
+
assert "temp" in filtered
|
|
56
|
+
assert "soc" in filtered
|
|
57
|
+
assert filtered["totalE"] == "2731.9"
|
|
58
|
+
assert filtered["temp"] == "40.5"
|
|
59
|
+
assert filtered["soc"] == "100"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_filter_collector_metrics_empty():
|
|
63
|
+
"""Verify empty dict handling."""
|
|
64
|
+
assert not _filter_collector_metrics({})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_filter_collector_metrics_no_match():
|
|
68
|
+
"""Verify dict with no matches handling."""
|
|
69
|
+
m_raw = {"abc": "123", "def": "456"}
|
|
70
|
+
assert _filter_collector_metrics(m_raw) == m_raw
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_filter_collector_metrics_case_insensitivity():
|
|
74
|
+
"""Verify case insensitivity of filtering."""
|
|
75
|
+
m_raw = {"BATTERY_TEMP": "25", "PV_VOLTAGE": "200"}
|
|
76
|
+
filtered = _filter_collector_metrics(m_raw)
|
|
77
|
+
assert "BATTERY_TEMP" not in filtered
|
|
78
|
+
assert "PV_VOLTAGE" not in filtered
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_is_collector_key_allowed_true():
|
|
82
|
+
"""Verify that safe keys are allowed."""
|
|
83
|
+
assert _is_collector_key_allowed("totalE") is True
|
|
84
|
+
assert _is_collector_key_allowed("temp") is True
|
|
85
|
+
assert _is_collector_key_allowed("soc") is True
|
|
86
|
+
assert _is_collector_key_allowed("voltage") is True
|
|
87
|
+
assert _is_collector_key_allowed("current") is True
|
|
88
|
+
assert _is_collector_key_allowed("randomKey") is True
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_is_collector_key_allowed_false():
|
|
92
|
+
"""Verify that sensitive keys are not allowed."""
|
|
93
|
+
assert _is_collector_key_allowed("pbat") is False
|
|
94
|
+
assert _is_collector_key_allowed("gridP") is False
|
|
95
|
+
assert _is_collector_key_allowed("ph1Loadp") is False
|
|
96
|
+
assert _is_collector_key_allowed("ph2Loadp") is False
|
|
97
|
+
assert _is_collector_key_allowed("ph3Loadp") is False
|
|
98
|
+
assert _is_collector_key_allowed("vpv1") is False
|
|
99
|
+
assert _is_collector_key_allowed("vbat") is False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_is_collector_key_allowed_case_insensitivity():
|
|
103
|
+
"""Verify case insensitivity."""
|
|
104
|
+
assert _is_collector_key_allowed("BAT_VOLTAGE") is False
|
|
105
|
+
assert _is_collector_key_allowed("PV_VOLTAGE") is False
|
|
106
|
+
assert _is_collector_key_allowed("GRID_POWER") is False
|
|
107
|
+
assert _is_collector_key_allowed("LOAD_POWER") is False
|
|
108
|
+
assert _is_collector_key_allowed("PH1_LOAD") is False
|
|
109
|
+
assert _is_collector_key_allowed("PH2_LOAD") is False
|
|
110
|
+
assert _is_collector_key_allowed("PH3_LOAD") is False
|
|
111
|
+
assert _is_collector_key_allowed("BaT") is False
|
|
112
|
+
assert _is_collector_key_allowed("pBAt") is False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_is_collector_key_allowed_cache():
|
|
116
|
+
"""Verify that the lru_cache works and caches results."""
|
|
117
|
+
# Clear cache to ensure clean state
|
|
118
|
+
_is_collector_key_allowed.cache_clear()
|
|
119
|
+
|
|
120
|
+
# First call - should be a miss
|
|
121
|
+
res1 = _is_collector_key_allowed("totalE")
|
|
122
|
+
|
|
123
|
+
# Second call - should be a hit
|
|
124
|
+
res2 = _is_collector_key_allowed("totalE")
|
|
125
|
+
|
|
126
|
+
# Check cache info
|
|
127
|
+
cache_info = _is_collector_key_allowed.cache_info()
|
|
128
|
+
|
|
129
|
+
assert res1 is True
|
|
130
|
+
assert res2 is True
|
|
131
|
+
assert cache_info.hits == 1
|
|
132
|
+
assert cache_info.misses == 1
|
|
133
|
+
assert cache_info.currsize == 1
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Tests for _flatten_nested_push_device error handling."""
|
|
2
|
+
|
|
3
|
+
from hyxi_cloud_api.api import _flatten_nested_push_device
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_flatten_nested_push_device_record_collecttime_valueerror():
|
|
7
|
+
"""Test ValueError is handled when collectTime in record is an invalid string."""
|
|
8
|
+
device = {"record": {"collectTime": "invalid_time"}}
|
|
9
|
+
flat = _flatten_nested_push_device(device)
|
|
10
|
+
assert flat["collectTime"] == "invalid_time"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_flatten_nested_push_device_record_collecttime_typeerror():
|
|
14
|
+
"""Test TypeError is handled when collectTime in record is of an invalid type."""
|
|
15
|
+
device = {"record": {"collectTime": {"nested": "dict"}}}
|
|
16
|
+
flat = _flatten_nested_push_device(device)
|
|
17
|
+
assert flat["collectTime"] == {"nested": "dict"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_flatten_nested_push_device_root_collecttime_valueerror():
|
|
21
|
+
"""Test ValueError is handled when collectTime in root is an invalid string."""
|
|
22
|
+
device = {"collectTime": "invalid_time"}
|
|
23
|
+
flat = _flatten_nested_push_device(device)
|
|
24
|
+
assert flat["collectTime"] == "invalid_time"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_flatten_nested_push_device_root_collecttime_typeerror():
|
|
28
|
+
"""Test TypeError is handled when collectTime in root is of an invalid type."""
|
|
29
|
+
device = {"collectTime": ["invalid", "type"]}
|
|
30
|
+
flat = _flatten_nested_push_device(device)
|
|
31
|
+
assert flat["collectTime"] == ["invalid", "type"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_flatten_nested_push_device_root_collecttime_valid_large():
|
|
35
|
+
"""Test large valid collectTime in root."""
|
|
36
|
+
device = {"collectTime": 20000000000}
|
|
37
|
+
flat = _flatten_nested_push_device(device)
|
|
38
|
+
assert flat["collectTime"] == 20000000.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_flatten_nested_push_device_root_collecttime_valid_small():
|
|
42
|
+
"""Test small valid collectTime in root."""
|
|
43
|
+
device = {"collectTime": 5000000000}
|
|
44
|
+
flat = _flatten_nested_push_device(device)
|
|
45
|
+
assert flat["collectTime"] == 5000000000
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_flatten_nested_push_device_root_reporttimestamp():
|
|
49
|
+
"""Test reportTimestamp in root."""
|
|
50
|
+
device = {"reportTimestamp": 1234567890}
|
|
51
|
+
flat = _flatten_nested_push_device(device)
|
|
52
|
+
assert flat["reportTimestamp"] == 1234567890
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_flatten_nested_push_device_system_workmode():
|
|
56
|
+
"""Test workMode in system."""
|
|
57
|
+
device = {"system": {"workMode": "test_mode"}}
|
|
58
|
+
flat = _flatten_nested_push_device(device)
|
|
59
|
+
assert flat["workMode"] == "test_mode"
|
|
@@ -41,7 +41,8 @@ async def test_fetch_device_info_network_error(caplog):
|
|
|
41
41
|
# Use a longer SN so it's not fully masked to ****
|
|
42
42
|
await api._fetch_device_info("10602251600016", entry)
|
|
43
43
|
|
|
44
|
-
assert "Error fetching device info for
|
|
44
|
+
assert "Error fetching device info for " in caplog.text
|
|
45
|
+
assert "Connection reset" in caplog.text
|
|
45
46
|
# Ensure it didn't crash and entry was not updated with metrics
|
|
46
47
|
assert "sw_version" not in entry
|
|
47
48
|
|
|
@@ -70,7 +71,7 @@ async def test_fetch_device_info_invalid_json(caplog):
|
|
|
70
71
|
entry = {"metrics": {}, "device_type_code": "INVERTER"}
|
|
71
72
|
await api._fetch_device_info("10602251600016", entry)
|
|
72
73
|
|
|
73
|
-
assert "Error fetching device info for
|
|
74
|
+
assert "Error fetching device info for " in caplog.text
|
|
74
75
|
assert "sw_version" not in entry
|
|
75
76
|
|
|
76
77
|
|
|
@@ -97,7 +98,7 @@ async def test_fetch_device_info_api_error(caplog):
|
|
|
97
98
|
entry = {"metrics": {}, "device_type_code": "INVERTER"}
|
|
98
99
|
await api._fetch_device_info("10602251600016", entry)
|
|
99
100
|
|
|
100
|
-
assert "HYXI INFO API Rejected for
|
|
101
|
+
assert "HYXI INFO API Rejected for " in caplog.text
|
|
101
102
|
assert "success" in caplog.text # _sanitize_dict logs full response dict
|
|
102
103
|
assert "sw_version" not in entry
|
|
103
104
|
|
|
@@ -117,10 +118,8 @@ async def test_fetch_device_info_client_error_handling(caplog):
|
|
|
117
118
|
await api._fetch_device_info("10602251600016", entry)
|
|
118
119
|
|
|
119
120
|
# Verify that the exception is caught and logged
|
|
120
|
-
assert
|
|
121
|
-
|
|
122
|
-
in caplog.text
|
|
123
|
-
)
|
|
121
|
+
assert "Error fetching device info for" in caplog.text
|
|
122
|
+
assert "Simulated fallback error" in caplog.text
|
|
124
123
|
# Ensure it didn't crash and entry was not updated with metrics
|
|
125
124
|
assert "sw_version" not in entry
|
|
126
125
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"""Tests for the _mask_id and _sanitize_dict log sanitisation helpers in api.py."""
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
|
+
import hmac
|
|
4
5
|
|
|
5
|
-
from hyxi_cloud_api.api import _mask_id, _sanitize_dict
|
|
6
|
+
from hyxi_cloud_api.api import _LOG_SALT, _mask_id, _sanitize_dict
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
class TestMaskId:
|
|
@@ -13,7 +14,10 @@ class TestMaskId:
|
|
|
13
14
|
def test_normal_sn_is_masked_with_hash(self):
|
|
14
15
|
"""A typical device SN should be masked using a hash, yielding an 8-char string."""
|
|
15
16
|
sn = "HYXABC12345678"
|
|
16
|
-
|
|
17
|
+
|
|
18
|
+
expected = hmac.new(_LOG_SALT, sn.encode("utf-8"), hashlib.sha256).hexdigest()[
|
|
19
|
+
:8
|
|
20
|
+
]
|
|
17
21
|
result = _mask_id(sn)
|
|
18
22
|
assert result == expected
|
|
19
23
|
assert len(result) == 8
|
|
@@ -41,7 +41,8 @@ async def test_fetch_device_metrics_request_error(caplog):
|
|
|
41
41
|
# Use a longer SN so it's not fully masked to ****
|
|
42
42
|
await api._fetch_device_metrics("10602251600016", entry)
|
|
43
43
|
|
|
44
|
-
assert "Error fetching metrics for
|
|
44
|
+
assert "Error fetching metrics for " in caplog.text
|
|
45
|
+
assert "Mock client error" in caplog.text
|
|
45
46
|
# Ensure it didn't crash and entry was not updated with metrics
|
|
46
47
|
assert not entry["metrics"]
|
|
47
48
|
|
|
@@ -60,7 +61,8 @@ async def test_fetch_device_metrics_network_error(caplog):
|
|
|
60
61
|
# Use a longer SN so it's not fully masked to ****
|
|
61
62
|
await api._fetch_device_metrics("10602251600016", entry)
|
|
62
63
|
|
|
63
|
-
assert "Error fetching metrics for
|
|
64
|
+
assert "Error fetching metrics for " in caplog.text
|
|
65
|
+
assert "Connection reset" in caplog.text
|
|
64
66
|
# Ensure it didn't crash and entry was not updated with metrics
|
|
65
67
|
assert not entry["metrics"]
|
|
66
68
|
|
|
@@ -90,7 +92,7 @@ async def test_fetch_device_metrics_invalid_json(caplog):
|
|
|
90
92
|
entry = {"metrics": {}, "device_type_code": "INVERTER"}
|
|
91
93
|
await api._fetch_device_metrics("10602251600016", entry)
|
|
92
94
|
|
|
93
|
-
assert "Error fetching metrics for
|
|
95
|
+
assert "Error fetching metrics for " in caplog.text
|
|
94
96
|
assert not entry["metrics"]
|
|
95
97
|
|
|
96
98
|
|
|
@@ -118,7 +120,7 @@ async def test_fetch_device_metrics_api_error(caplog):
|
|
|
118
120
|
entry = {"metrics": {}, "device_type_code": "INVERTER"}
|
|
119
121
|
await api._fetch_device_metrics("10602251600016", entry)
|
|
120
122
|
|
|
121
|
-
assert "HYXI API metrics rejected for
|
|
123
|
+
assert "HYXI API metrics rejected for " in caplog.text
|
|
122
124
|
assert "success" in caplog.text # _sanitize_dict logs full response dict
|
|
123
125
|
assert not entry["metrics"]
|
|
124
126
|
|
|
@@ -138,7 +140,7 @@ async def test_fetch_ems_basic_data_no_data(caplog):
|
|
|
138
140
|
entry = {"metrics": {}, "device_type_code": "EMS"}
|
|
139
141
|
await api._fetch_all_for_device("10602251600016", entry, "INVERTER")
|
|
140
142
|
|
|
141
|
-
assert "HYXI EMS telemetry probe returned no data for
|
|
143
|
+
assert "HYXI EMS telemetry probe returned no data for " in caplog.text
|
|
142
144
|
# No EMS data should be merged into metrics.
|
|
143
145
|
assert "new_metric" not in entry["metrics"]
|
|
144
146
|
|
|
@@ -160,7 +162,7 @@ async def test_fetch_ems_basic_data_error(caplog):
|
|
|
160
162
|
|
|
161
163
|
# No EMS data should be merged into metrics.
|
|
162
164
|
assert "batSoc" not in entry["metrics"]
|
|
163
|
-
assert "HYXI EMS telemetry probe returned no data for
|
|
165
|
+
assert "HYXI EMS telemetry probe returned no data for " in caplog.text
|
|
164
166
|
|
|
165
167
|
|
|
166
168
|
@pytest.mark.asyncio
|
|
@@ -177,10 +179,8 @@ async def test_query_ems_basic_details_error(caplog):
|
|
|
177
179
|
|
|
178
180
|
assert result == {}
|
|
179
181
|
|
|
180
|
-
assert
|
|
181
|
-
|
|
182
|
-
in caplog.text
|
|
183
|
-
)
|
|
182
|
+
assert "HYXI EMS Basic Data Request Failed for" in caplog.text
|
|
183
|
+
assert "EMS query failed" in caplog.text
|
|
184
184
|
|
|
185
185
|
|
|
186
186
|
@pytest.mark.asyncio
|
|
@@ -197,10 +197,8 @@ async def test_query_ems_basic_details_network_error(caplog):
|
|
|
197
197
|
|
|
198
198
|
assert result == {}
|
|
199
199
|
|
|
200
|
-
assert
|
|
201
|
-
|
|
202
|
-
in caplog.text
|
|
203
|
-
)
|
|
200
|
+
assert "HYXI EMS Basic Data Request Failed for" in caplog.text
|
|
201
|
+
assert "Connection reset" in caplog.text
|
|
204
202
|
|
|
205
203
|
|
|
206
204
|
@pytest.mark.asyncio
|
|
@@ -221,7 +219,7 @@ async def test_query_ems_basic_details_non_zero_code(caplog):
|
|
|
221
219
|
result = await api.query_ems_basic_details("10602251600016")
|
|
222
220
|
|
|
223
221
|
assert result == {}
|
|
224
|
-
assert "HYXI EMS Basic Data Request Rejected for
|
|
222
|
+
assert "HYXI EMS Basic Data Request Rejected for " in caplog.text
|
|
225
223
|
|
|
226
224
|
|
|
227
225
|
@pytest.mark.asyncio
|
|
@@ -237,5 +235,5 @@ async def test_query_ems_basic_details_malformed_response(caplog):
|
|
|
237
235
|
result = await api.query_ems_basic_details("10602251600016")
|
|
238
236
|
|
|
239
237
|
assert result == {}
|
|
240
|
-
assert "HYXI EMS Basic Data Request Failed for
|
|
238
|
+
assert "HYXI EMS Basic Data Request Failed for " in caplog.text
|
|
241
239
|
assert "object has no attribute 'get'" in caplog.text
|
|
@@ -305,3 +305,68 @@ def test_process_push_data_flat_ms_collect_time():
|
|
|
305
305
|
assert metrics["pv1v"] == 119.0
|
|
306
306
|
assert metrics["pv1i"] == 1.2
|
|
307
307
|
assert metrics["pv1p"] == 142.8
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_process_push_data_invalid_time_formats():
|
|
311
|
+
"""Test process_push_data correctly handles ValueError and TypeError when parsing timestamps."""
|
|
312
|
+
|
|
313
|
+
api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
|
|
314
|
+
api._discovery_cache["device_info"] = {
|
|
315
|
+
"TEST_DEV": {
|
|
316
|
+
"model": "TEST",
|
|
317
|
+
"device_type_code": "TEST_TYPE",
|
|
318
|
+
"device_name": "Test Device",
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
# Test invalid collectTime (ValueError)
|
|
323
|
+
payload_invalid_collect_time = {
|
|
324
|
+
"dataList": [
|
|
325
|
+
{
|
|
326
|
+
"deviceSn": "TEST_DEV",
|
|
327
|
+
"collectTime": "invalid-time",
|
|
328
|
+
}
|
|
329
|
+
]
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
# Test invalid collectTime (TypeError)
|
|
333
|
+
payload_type_err_collect_time = {
|
|
334
|
+
"dataList": [
|
|
335
|
+
{
|
|
336
|
+
"deviceSn": "TEST_DEV",
|
|
337
|
+
"collectTime": {"a": "b"},
|
|
338
|
+
}
|
|
339
|
+
]
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
# Test invalid reportTimestamp (ValueError)
|
|
343
|
+
payload_invalid_report_ts = {
|
|
344
|
+
"dataList": [
|
|
345
|
+
{
|
|
346
|
+
"deviceSn": "TEST_DEV",
|
|
347
|
+
"reportTimestamp": "invalid-time",
|
|
348
|
+
}
|
|
349
|
+
]
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
# Test invalid reportTimestamp (TypeError)
|
|
353
|
+
payload_type_err_report_ts = {
|
|
354
|
+
"dataList": [
|
|
355
|
+
{
|
|
356
|
+
"deviceSn": "TEST_DEV",
|
|
357
|
+
"reportTimestamp": {"a": "b"},
|
|
358
|
+
}
|
|
359
|
+
]
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
res1 = api.process_push_data(payload_invalid_collect_time)
|
|
363
|
+
res2 = api.process_push_data(payload_type_err_collect_time)
|
|
364
|
+
res3 = api.process_push_data(payload_invalid_report_ts)
|
|
365
|
+
res4 = api.process_push_data(payload_type_err_report_ts)
|
|
366
|
+
|
|
367
|
+
# Check that last_seen was not updated to a specific time, but fell back to now_utc
|
|
368
|
+
# In api.process_push_data, if it fails, last_seen = now_utc
|
|
369
|
+
assert "last_seen" in res1["TEST_DEV"]["metrics"]
|
|
370
|
+
assert "last_seen" in res2["TEST_DEV"]["metrics"]
|
|
371
|
+
assert "last_seen" in res3["TEST_DEV"]["metrics"]
|
|
372
|
+
assert "last_seen" in res4["TEST_DEV"]["metrics"]
|
|
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock
|
|
|
5
5
|
|
|
6
6
|
import pytest
|
|
7
7
|
|
|
8
|
-
from src.hyxi_cloud_api.api import HyxiApiClient, _sanitize_dict
|
|
8
|
+
from src.hyxi_cloud_api.api import HyxiApiClient, _mask_id, _sanitize_dict
|
|
9
9
|
|
|
10
10
|
# Mock aiohttp before importing the API client
|
|
11
11
|
if "aiohttp" not in sys.modules or not hasattr(sys.modules["aiohttp"], "ClientError"):
|
|
@@ -44,20 +44,20 @@ def test_sanitize_dict_recursive():
|
|
|
44
44
|
sanitized = _sanitize_dict(raw)
|
|
45
45
|
|
|
46
46
|
assert sanitized["plantAddress"] == "[REDACTED]"
|
|
47
|
-
assert sanitized["deviceSn"] == "
|
|
47
|
+
assert sanitized["deviceSn"] == _mask_id("SN123456789")
|
|
48
48
|
assert sanitized["normalKey"] == "normalValue"
|
|
49
49
|
|
|
50
50
|
# Check nested list of dicts
|
|
51
|
-
assert sanitized["data"][0]["deviceSn"] == "
|
|
52
|
-
assert sanitized["data"][0]["nested"]["plantId"] == "
|
|
51
|
+
assert sanitized["data"][0]["deviceSn"] == _mask_id("SN987654321")
|
|
52
|
+
assert sanitized["data"][0]["nested"]["plantId"] == _mask_id("PID123")
|
|
53
53
|
assert sanitized["data"][0]["nested"]["normal"] == "value"
|
|
54
54
|
assert sanitized["data"][1] == "not a dict"
|
|
55
55
|
|
|
56
56
|
# Check nested dict
|
|
57
|
-
assert sanitized["nestedDict"]["batSn"] == "
|
|
57
|
+
assert sanitized["nestedDict"]["batSn"] == _mask_id("BAT12345")
|
|
58
58
|
|
|
59
59
|
# Check nested list
|
|
60
|
-
assert sanitized["nestedList"][0][0]["deviceSn"] == "
|
|
60
|
+
assert sanitized["nestedList"][0][0]["deviceSn"] == _mask_id("SN0000")
|
|
61
61
|
|
|
62
62
|
|
|
63
63
|
@pytest.mark.asyncio
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import sys
|
|
2
|
-
from unittest.mock import MagicMock
|
|
3
|
-
|
|
4
|
-
if "aiohttp" not in sys.modules or not hasattr(sys.modules["aiohttp"], "ClientError"):
|
|
5
|
-
m = MagicMock()
|
|
6
|
-
|
|
7
|
-
class MockExp(Exception):
|
|
8
|
-
def __init__(self, *args, **kwargs):
|
|
9
|
-
super().__init__(*args)
|
|
10
|
-
for k, v in kwargs.items():
|
|
11
|
-
setattr(self, k, v)
|
|
12
|
-
|
|
13
|
-
m.ClientError = MockExp
|
|
14
|
-
m.ClientResponseError = type("ClientResponseError", (MockExp,), {})
|
|
15
|
-
m.ContentTypeError = type("ContentTypeError", (MockExp,), {})
|
|
16
|
-
sys.modules["aiohttp"] = m
|
|
17
|
-
mock_aiohttp = sys.modules["aiohttp"]
|
|
18
|
-
|
|
19
|
-
"""Tests for _filter_collector_metrics optimization."""
|
|
20
|
-
|
|
21
|
-
from src.hyxi_cloud_api.api import _filter_collector_metrics
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def test_filter_collector_metrics_filtering():
|
|
25
|
-
"""Verify that sensitive metrics are filtered out."""
|
|
26
|
-
m_raw = {
|
|
27
|
-
"totalE": "2731.9",
|
|
28
|
-
"pbat": "-500",
|
|
29
|
-
"gridP": "1.5",
|
|
30
|
-
"ph1Loadp": "0.5",
|
|
31
|
-
"ph2Loadp": "0.5",
|
|
32
|
-
"ph3Loadp": "0.5",
|
|
33
|
-
"temp": "40.5",
|
|
34
|
-
"vpv1": "400",
|
|
35
|
-
"vbat": "52",
|
|
36
|
-
"soc": "100",
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
filtered = _filter_collector_metrics(m_raw)
|
|
40
|
-
|
|
41
|
-
# These should be filtered out
|
|
42
|
-
assert "pbat" not in filtered
|
|
43
|
-
assert "gridP" not in filtered
|
|
44
|
-
assert "ph1Loadp" not in filtered
|
|
45
|
-
assert "ph2Loadp" not in filtered
|
|
46
|
-
assert "ph3Loadp" not in filtered
|
|
47
|
-
assert "vpv1" not in filtered
|
|
48
|
-
assert "vbat" not in filtered
|
|
49
|
-
|
|
50
|
-
# These should remain
|
|
51
|
-
assert "totalE" in filtered
|
|
52
|
-
assert "temp" in filtered
|
|
53
|
-
assert "soc" in filtered
|
|
54
|
-
assert filtered["totalE"] == "2731.9"
|
|
55
|
-
assert filtered["temp"] == "40.5"
|
|
56
|
-
assert filtered["soc"] == "100"
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def test_filter_collector_metrics_empty():
|
|
60
|
-
"""Verify empty dict handling."""
|
|
61
|
-
assert not _filter_collector_metrics({})
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def test_filter_collector_metrics_no_match():
|
|
65
|
-
"""Verify dict with no matches handling."""
|
|
66
|
-
m_raw = {"abc": "123", "def": "456"}
|
|
67
|
-
assert _filter_collector_metrics(m_raw) == m_raw
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def test_filter_collector_metrics_case_insensitivity():
|
|
71
|
-
"""Verify case insensitivity of filtering."""
|
|
72
|
-
m_raw = {"BATTERY_TEMP": "25", "PV_VOLTAGE": "200"}
|
|
73
|
-
filtered = _filter_collector_metrics(m_raw)
|
|
74
|
-
assert "BATTERY_TEMP" not in filtered
|
|
75
|
-
assert "PV_VOLTAGE" not in filtered
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/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
|
{hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/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
|
|
File without changes
|
{hyxi_cloud_api-1.3.7 → hyxi_cloud_api-1.3.9}/tests/test_validate_subscription_device_sns.py
RENAMED
|
File without changes
|