hyxi-cloud-api 1.3.8__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.8/src/hyxi_cloud_api.egg-info → hyxi_cloud_api-1.4.0}/PKG-INFO +1 -4
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/pyproject.toml +1 -4
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/src/hyxi_cloud_api/__init__.py +1 -1
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/src/hyxi_cloud_api/api.py +149 -80
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0/src/hyxi_cloud_api.egg-info}/PKG-INFO +1 -4
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/src/hyxi_cloud_api.egg-info/SOURCES.txt +5 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_additional_coverage.py +4 -3
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_api.py +119 -5
- hyxi_cloud_api-1.4.0/tests/test_api_imports.py +33 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_caching.py +3 -4
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_compute_battery_metrics.py +38 -0
- hyxi_cloud_api-1.4.0/tests/test_compute_pv_metrics.py +50 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_device_control.py +24 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_devices_errors.py +21 -1
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_discovery.py +28 -3
- hyxi_cloud_api-1.4.0/tests/test_ensure_authenticated.py +46 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_fetch_device_list_for_plant.py +0 -1
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_fetch_plants.py +2 -2
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_fetch_sub_device_list.py +19 -0
- hyxi_cloud_api-1.4.0/tests/test_filter_metrics.py +133 -0
- hyxi_cloud_api-1.4.0/tests/test_flatten_nested_push_device.py +59 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_get_f.py +10 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_info_errors.py +6 -7
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_mask_id.py +6 -2
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_metrics_errors.py +63 -19
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_push.py +67 -2
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_sanitize_dict.py +9 -9
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_sanitize_list.py +2 -2
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_security_fix.py +6 -6
- hyxi_cloud_api-1.4.0/tests/test_set_log_salt.py +55 -0
- hyxi_cloud_api-1.3.8/tests/test_filter_metrics.py +0 -75
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/LICENSE +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/README.md +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/setup.cfg +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/src/hyxi_cloud_api.egg-info/dependency_links.txt +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/src/hyxi_cloud_api.egg-info/requires.txt +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/src/hyxi_cloud_api.egg-info/top_level.txt +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_alarm_push.py +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_all_in_one.py +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_build_plant_tasks.py +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_compute_derived_metrics.py +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_compute_grid_metrics.py +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_compute_load_metrics.py +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_device_entry.py +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_execute_device_tasks.py +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_execute_metric_tasks.py +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_execute_metrics_and_map_alarms.py +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_extract_battery_info.py +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_extract_device_info_metadata.py +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_fetch_and_process_alarms.py +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_fetch_state.py +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_fuzz_parser.py +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_handle_back_discovery_alarm.py +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_parse_data_list.py +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_parse_ems_kv.py +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_parser.py +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_process_alarms_and_back_discovery.py +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_process_devices_for_plant.py +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_subscriptions.py +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_token_errors.py +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_token_handling.py +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_validate_post_rate_ms.py +0 -0
- {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/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
|
+
Version: 1.4.0
|
|
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.
|
|
7
|
+
version = "1.4.0"
|
|
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.
|
|
8
|
+
__version__ = "1.4.0"
|
|
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
|
|
@@ -24,10 +25,9 @@ from urllib.parse import urlparse
|
|
|
24
25
|
try:
|
|
25
26
|
from datetime import UTC, datetime
|
|
26
27
|
except ImportError:
|
|
27
|
-
from datetime import datetime
|
|
28
|
+
from datetime import datetime, timezone
|
|
28
29
|
|
|
29
|
-
#
|
|
30
|
-
UTC = UTC
|
|
30
|
+
UTC = timezone.utc # noqa
|
|
31
31
|
|
|
32
32
|
import aiohttp
|
|
33
33
|
|
|
@@ -44,18 +44,41 @@ class FetchState:
|
|
|
44
44
|
|
|
45
45
|
|
|
46
46
|
_LOGGER = logging.getLogger(__name__)
|
|
47
|
-
_battery_device_types = (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
+
)
|
|
55
81
|
)
|
|
56
|
-
_BATTERY_DEVICE_REGEX = re.compile("|".join(_battery_device_types))
|
|
57
|
-
_parent_device_types = ("COLLECTOR", "DMU", "INVERTER", "ALL_IN_ONE")
|
|
58
|
-
_PARENT_DEVICE_REGEX = re.compile("|".join(_parent_device_types))
|
|
59
82
|
_COLLECTOR_FILTER_KEYWORDS = (
|
|
60
83
|
"bat",
|
|
61
84
|
"pv",
|
|
@@ -563,9 +586,7 @@ def _get_f(key: str, data_map: dict, mult: float = 1.0) -> float:
|
|
|
563
586
|
if val is None or val == "":
|
|
564
587
|
return 0.0
|
|
565
588
|
return round(float(val) * mult, 2)
|
|
566
|
-
except ValueError:
|
|
567
|
-
return 0.0
|
|
568
|
-
except TypeError:
|
|
589
|
+
except ValueError, TypeError:
|
|
569
590
|
return 0.0
|
|
570
591
|
|
|
571
592
|
|
|
@@ -624,7 +645,7 @@ def _compute_battery_metrics(
|
|
|
624
645
|
pbat = _get_f("pbat", m_raw)
|
|
625
646
|
device_type_str = str(device_type or "")
|
|
626
647
|
|
|
627
|
-
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:
|
|
628
649
|
# ALL_IN_ONE: prefer pbat — batP can have an inverted sign convention,
|
|
629
650
|
# while pbat is consistently negative-for-charging / positive-for-discharging.
|
|
630
651
|
# Other devices: prefer batP (DC terminals), fall back to pbat.
|
|
@@ -731,6 +752,60 @@ def _compute_derived_metrics(m_raw: dict, device_type: str = "") -> dict:
|
|
|
731
752
|
return derived
|
|
732
753
|
|
|
733
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
|
+
|
|
734
809
|
def _flatten_nested_push_device(device: dict) -> dict: # pylint: disable=too-many-statements
|
|
735
810
|
"""Flatten a nested push device payload into the flat layout expected by the SDK."""
|
|
736
811
|
flat: dict = {}
|
|
@@ -750,21 +825,31 @@ def _flatten_nested_push_device(device: dict) -> dict: # pylint: disable=too-ma
|
|
|
750
825
|
if "deviceState" in rec:
|
|
751
826
|
flat["deviceState"] = rec["deviceState"]
|
|
752
827
|
|
|
753
|
-
#
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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":
|
|
758
843
|
try:
|
|
759
|
-
num_val = float(
|
|
844
|
+
num_val = float(v)
|
|
760
845
|
if num_val > 10000000000:
|
|
761
|
-
flat[
|
|
846
|
+
flat[mapped_k] = num_val / 1000.0
|
|
762
847
|
else:
|
|
763
|
-
flat[
|
|
848
|
+
flat[mapped_k] = v
|
|
764
849
|
except ValueError, TypeError:
|
|
765
|
-
flat[
|
|
850
|
+
flat[mapped_k] = v
|
|
766
851
|
else:
|
|
767
|
-
flat[
|
|
852
|
+
flat[mapped_k] = v
|
|
768
853
|
|
|
769
854
|
# 2. system
|
|
770
855
|
if "system" in device and isinstance(device["system"], dict):
|
|
@@ -887,18 +972,35 @@ def _flatten_nested_push_device(device: dict) -> dict: # pylint: disable=too-ma
|
|
|
887
972
|
if "powerFactor" in grd:
|
|
888
973
|
flat["gridPfd"] = grd["powerFactor"]
|
|
889
974
|
if "energyInKwh" in grd:
|
|
890
|
-
flat["
|
|
975
|
+
flat["totalEnt"] = grd["energyInKwh"]
|
|
891
976
|
if "energyOutKwh" in grd:
|
|
892
|
-
flat["
|
|
977
|
+
flat["totalEpt"] = grd["energyOutKwh"]
|
|
893
978
|
|
|
894
979
|
# Copy any other keys at the root that aren't dictionaries
|
|
895
980
|
for k, v in device.items():
|
|
896
981
|
if k not in flat and not isinstance(v, dict):
|
|
897
|
-
|
|
982
|
+
mapped_k = _key_map.get(k, k)
|
|
983
|
+
flat[mapped_k] = v
|
|
898
984
|
|
|
899
985
|
return flat
|
|
900
986
|
|
|
901
987
|
|
|
988
|
+
# Deployment-configured secret for log masking. If not provided,
|
|
989
|
+
# falls back to a process-random secret.
|
|
990
|
+
_LOG_SALT = os.environ.get("HYXI_LOG_SALT", "").encode("utf-8") or secrets.token_bytes(
|
|
991
|
+
16
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def set_log_salt(salt: str | bytes) -> None:
|
|
996
|
+
"""Set a deployment-stable secret for cross-device log correlation."""
|
|
997
|
+
global _LOG_SALT # pylint: disable=global-statement
|
|
998
|
+
if isinstance(salt, str):
|
|
999
|
+
salt = salt.encode("utf-8")
|
|
1000
|
+
_LOG_SALT = salt
|
|
1001
|
+
_mask_id.cache_clear()
|
|
1002
|
+
|
|
1003
|
+
|
|
902
1004
|
@functools.lru_cache(maxsize=1024)
|
|
903
1005
|
def _mask_id(value: str) -> str:
|
|
904
1006
|
"""Mask an identifier (SN, plant ID, etc.) for logs.
|
|
@@ -912,7 +1014,7 @@ def _mask_id(value: str) -> str:
|
|
|
912
1014
|
if not value or value == "None":
|
|
913
1015
|
return "****"
|
|
914
1016
|
id_str = str(value)
|
|
915
|
-
return
|
|
1017
|
+
return hmac.new(_LOG_SALT, id_str.encode("utf-8"), hashlib.sha256).hexdigest()[:8]
|
|
916
1018
|
|
|
917
1019
|
|
|
918
1020
|
# Keys in raw API response dicts that contain identifying or personal information.
|
|
@@ -1277,7 +1379,7 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
1277
1379
|
}
|
|
1278
1380
|
|
|
1279
1381
|
device_type_code = entry.get("device_type_code", "")
|
|
1280
|
-
if
|
|
1382
|
+
if device_type_code in _battery_device_types:
|
|
1281
1383
|
base_info.update(HyxiApiClient._extract_battery_info(i_raw))
|
|
1282
1384
|
|
|
1283
1385
|
entry["metrics"].update(base_info)
|
|
@@ -1326,8 +1428,11 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
1326
1428
|
ems_task = None
|
|
1327
1429
|
if not is_comm_unit:
|
|
1328
1430
|
tasks.append(asyncio.create_task(self._fetch_device_metrics(sn, entry)))
|
|
1329
|
-
|
|
1330
|
-
|
|
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)
|
|
1331
1436
|
|
|
1332
1437
|
# Wait for them to finish
|
|
1333
1438
|
if tasks:
|
|
@@ -1408,7 +1513,7 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
1408
1513
|
state.metric_tasks.append(self._fetch_all_for_device(sn, entry, dev_type))
|
|
1409
1514
|
|
|
1410
1515
|
# 🚀 DEEP DISCOVERY: If this is a Collector, DMU, or Inverter, find its children!
|
|
1411
|
-
if
|
|
1516
|
+
if dev_type in _parent_device_types:
|
|
1412
1517
|
_LOGGER.debug(
|
|
1413
1518
|
"HYXI Parent Device Found: %s (%s). Probing for sub-devices...",
|
|
1414
1519
|
_mask_id(sn),
|
|
@@ -1681,7 +1786,7 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
1681
1786
|
state.metric_tasks.append(self._fetch_all_for_device(sn, entry, dev_type))
|
|
1682
1787
|
|
|
1683
1788
|
# 🚀 DEEP BACK-DISCOVERY: If this is a parent, search for ITS children too!
|
|
1684
|
-
if
|
|
1789
|
+
if dev_type in _parent_device_types:
|
|
1685
1790
|
sub_device_tasks.append(self._fetch_sub_devices(sn, state))
|
|
1686
1791
|
|
|
1687
1792
|
async def _process_alarms_and_back_discovery(
|
|
@@ -2178,7 +2283,7 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
2178
2283
|
_LOGGER.warning("HYXI Push: dataList is missing or not a list")
|
|
2179
2284
|
return {}
|
|
2180
2285
|
|
|
2181
|
-
now_utc = datetime.now(UTC)
|
|
2286
|
+
now_utc = datetime.now(UTC)
|
|
2182
2287
|
results = {}
|
|
2183
2288
|
|
|
2184
2289
|
for device in data_list:
|
|
@@ -2191,53 +2296,17 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
2191
2296
|
if not sn:
|
|
2192
2297
|
continue
|
|
2193
2298
|
|
|
2194
|
-
# Extract metrics from flat payload dictionary
|
|
2195
|
-
# Strip metadata/routing keys
|
|
2196
|
-
raw_metrics = {}
|
|
2197
|
-
for k, v in device.items():
|
|
2198
|
-
if k in _METRICS_EXCLUDED_KEYS:
|
|
2199
|
-
continue
|
|
2200
|
-
raw_metrics[k] = v
|
|
2201
|
-
|
|
2202
2299
|
# Retrieve info from discovery cache
|
|
2203
2300
|
device_info = self._discovery_cache.get("device_info", {}).get(sn, {})
|
|
2204
2301
|
device_type = str(device_info.get("device_type_code") or "")
|
|
2205
2302
|
|
|
2206
|
-
|
|
2207
|
-
last_seen = now_utc
|
|
2208
|
-
collect_time = device.get("collectTime")
|
|
2209
|
-
report_ts = device.get("reportTimestamp")
|
|
2210
|
-
|
|
2211
|
-
if collect_time is not None:
|
|
2212
|
-
try:
|
|
2213
|
-
last_seen = datetime.fromtimestamp(
|
|
2214
|
-
float(collect_time), UTC
|
|
2215
|
-
).isoformat()
|
|
2216
|
-
except ValueError, TypeError:
|
|
2217
|
-
pass
|
|
2218
|
-
elif report_ts is not None:
|
|
2219
|
-
try:
|
|
2220
|
-
last_seen = datetime.fromtimestamp(
|
|
2221
|
-
float(report_ts) / 1000.0, UTC
|
|
2222
|
-
).isoformat()
|
|
2223
|
-
except ValueError, TypeError:
|
|
2224
|
-
pass
|
|
2303
|
+
raw_metrics = _extract_raw_push_metrics(device)
|
|
2304
|
+
last_seen = _resolve_push_timestamp(device, now_utc)
|
|
2225
2305
|
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
metrics_to_update = raw_metrics.copy()
|
|
2231
|
-
# Merge with existing metrics if provided, ignoring None/null values
|
|
2232
|
-
merged_metrics = {}
|
|
2233
|
-
if existing_metrics and sn in existing_metrics:
|
|
2234
|
-
# Copy existing to avoid mutating caller's dictionary directly
|
|
2235
|
-
merged_metrics = dict(existing_metrics[sn])
|
|
2236
|
-
|
|
2237
|
-
for k, v in metrics_to_update.items():
|
|
2238
|
-
if v is not None:
|
|
2239
|
-
merged_metrics[k] = v
|
|
2240
|
-
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()
|
|
2241
2310
|
|
|
2242
2311
|
# Compute derived metrics on the full merged dataset
|
|
2243
2312
|
derived = _compute_derived_metrics(merged_metrics, device_type)
|
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hyxi-cloud-api
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
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
|
|
@@ -12,16 +12,19 @@ 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
|
|
18
19
|
tests/test_compute_derived_metrics.py
|
|
19
20
|
tests/test_compute_grid_metrics.py
|
|
20
21
|
tests/test_compute_load_metrics.py
|
|
22
|
+
tests/test_compute_pv_metrics.py
|
|
21
23
|
tests/test_device_control.py
|
|
22
24
|
tests/test_device_entry.py
|
|
23
25
|
tests/test_devices_errors.py
|
|
24
26
|
tests/test_discovery.py
|
|
27
|
+
tests/test_ensure_authenticated.py
|
|
25
28
|
tests/test_execute_device_tasks.py
|
|
26
29
|
tests/test_execute_metric_tasks.py
|
|
27
30
|
tests/test_execute_metrics_and_map_alarms.py
|
|
@@ -33,6 +36,7 @@ tests/test_fetch_plants.py
|
|
|
33
36
|
tests/test_fetch_state.py
|
|
34
37
|
tests/test_fetch_sub_device_list.py
|
|
35
38
|
tests/test_filter_metrics.py
|
|
39
|
+
tests/test_flatten_nested_push_device.py
|
|
36
40
|
tests/test_fuzz_parser.py
|
|
37
41
|
tests/test_get_f.py
|
|
38
42
|
tests/test_handle_back_discovery_alarm.py
|
|
@@ -48,6 +52,7 @@ tests/test_push.py
|
|
|
48
52
|
tests/test_sanitize_dict.py
|
|
49
53
|
tests/test_sanitize_list.py
|
|
50
54
|
tests/test_security_fix.py
|
|
55
|
+
tests/test_set_log_salt.py
|
|
51
56
|
tests/test_subscriptions.py
|
|
52
57
|
tests/test_token_errors.py
|
|
53
58
|
tests/test_token_handling.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(
|
|
@@ -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) ---
|
|
@@ -592,7 +607,8 @@ async def test_fetch_alarms_for_plant_error(caplog):
|
|
|
592
607
|
assert alarms == []
|
|
593
608
|
|
|
594
609
|
log_text = caplog.text
|
|
595
|
-
assert "Error fetching alarms for plant
|
|
610
|
+
assert "Error fetching alarms for plant " in log_text
|
|
611
|
+
assert "Connection reset" in log_text
|
|
596
612
|
|
|
597
613
|
|
|
598
614
|
@pytest.mark.asyncio
|
|
@@ -688,3 +704,101 @@ async def test_execute_fetch_all_force_discovery_integration():
|
|
|
688
704
|
# Ensure we successfully parsed the data, meaning full discovery worked
|
|
689
705
|
assert "device_1" in result
|
|
690
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
|