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.
Files changed (63) hide show
  1. {hyxi_cloud_api-1.3.8/src/hyxi_cloud_api.egg-info → hyxi_cloud_api-1.4.0}/PKG-INFO +1 -4
  2. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/pyproject.toml +1 -4
  3. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/src/hyxi_cloud_api/__init__.py +1 -1
  4. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/src/hyxi_cloud_api/api.py +149 -80
  5. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0/src/hyxi_cloud_api.egg-info}/PKG-INFO +1 -4
  6. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/src/hyxi_cloud_api.egg-info/SOURCES.txt +5 -0
  7. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_additional_coverage.py +4 -3
  8. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_api.py +119 -5
  9. hyxi_cloud_api-1.4.0/tests/test_api_imports.py +33 -0
  10. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_caching.py +3 -4
  11. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_compute_battery_metrics.py +38 -0
  12. hyxi_cloud_api-1.4.0/tests/test_compute_pv_metrics.py +50 -0
  13. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_device_control.py +24 -0
  14. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_devices_errors.py +21 -1
  15. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_discovery.py +28 -3
  16. hyxi_cloud_api-1.4.0/tests/test_ensure_authenticated.py +46 -0
  17. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_fetch_device_list_for_plant.py +0 -1
  18. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_fetch_plants.py +2 -2
  19. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_fetch_sub_device_list.py +19 -0
  20. hyxi_cloud_api-1.4.0/tests/test_filter_metrics.py +133 -0
  21. hyxi_cloud_api-1.4.0/tests/test_flatten_nested_push_device.py +59 -0
  22. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_get_f.py +10 -0
  23. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_info_errors.py +6 -7
  24. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_mask_id.py +6 -2
  25. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_metrics_errors.py +63 -19
  26. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_push.py +67 -2
  27. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_sanitize_dict.py +9 -9
  28. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_sanitize_list.py +2 -2
  29. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_security_fix.py +6 -6
  30. hyxi_cloud_api-1.4.0/tests/test_set_log_salt.py +55 -0
  31. hyxi_cloud_api-1.3.8/tests/test_filter_metrics.py +0 -75
  32. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/LICENSE +0 -0
  33. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/README.md +0 -0
  34. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/setup.cfg +0 -0
  35. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/src/hyxi_cloud_api.egg-info/dependency_links.txt +0 -0
  36. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/src/hyxi_cloud_api.egg-info/requires.txt +0 -0
  37. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/src/hyxi_cloud_api.egg-info/top_level.txt +0 -0
  38. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_alarm_push.py +0 -0
  39. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_all_in_one.py +0 -0
  40. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_build_plant_tasks.py +0 -0
  41. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_compute_derived_metrics.py +0 -0
  42. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_compute_grid_metrics.py +0 -0
  43. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_compute_load_metrics.py +0 -0
  44. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_device_entry.py +0 -0
  45. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_execute_device_tasks.py +0 -0
  46. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_execute_metric_tasks.py +0 -0
  47. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_execute_metrics_and_map_alarms.py +0 -0
  48. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_extract_battery_info.py +0 -0
  49. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_extract_device_info_metadata.py +0 -0
  50. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_fetch_and_process_alarms.py +0 -0
  51. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_fetch_state.py +0 -0
  52. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_fuzz_parser.py +0 -0
  53. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_handle_back_discovery_alarm.py +0 -0
  54. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_parse_data_list.py +0 -0
  55. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_parse_ems_kv.py +0 -0
  56. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_parser.py +0 -0
  57. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_process_alarms_and_back_discovery.py +0 -0
  58. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_process_devices_for_plant.py +0 -0
  59. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_subscriptions.py +0 -0
  60. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_token_errors.py +0 -0
  61. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_token_handling.py +0 -0
  62. {hyxi_cloud_api-1.3.8 → hyxi_cloud_api-1.4.0}/tests/test_validate_post_rate_ms.py +0 -0
  63. {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.8
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.3.8"
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.3.8"
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
- # pylint: disable=W0127,E0601
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
- "INVERTER",
49
- "ESS",
50
- "HALO",
51
- "1",
52
- "15",
53
- "16",
54
- "MICRO_STORAGE_ALL_IN_ONE",
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 ("EMS", "15", "16"):
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
- # Copy root device keys that might already be flat
754
- for k in ("deviceSn", "collectTime", "reportTimestamp"):
755
- if k in device and device[k] is not None:
756
- val = device[k]
757
- if k == "collectTime":
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(val)
844
+ num_val = float(v)
760
845
  if num_val > 10000000000:
761
- flat[k] = num_val / 1000.0
846
+ flat[mapped_k] = num_val / 1000.0
762
847
  else:
763
- flat[k] = val
848
+ flat[mapped_k] = v
764
849
  except ValueError, TypeError:
765
- flat[k] = val
850
+ flat[mapped_k] = v
766
851
  else:
767
- flat[k] = val
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["gridEIn"] = grd["energyInKwh"]
975
+ flat["totalEnt"] = grd["energyInKwh"]
891
976
  if "energyOutKwh" in grd:
892
- flat["gridEOut"] = grd["energyOutKwh"]
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
- flat[k] = v
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 hashlib.sha256(id_str.encode("utf-8")).hexdigest()[:8]
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 _BATTERY_DEVICE_REGEX.search(device_type_code):
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
- ems_task = asyncio.create_task(self.query_ems_basic_details(sn))
1330
- tasks.append(ems_task)
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 _PARENT_DEVICE_REGEX.search(dev_type):
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 _PARENT_DEVICE_REGEX.search(dev_type):
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).isoformat()
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
- # Handle collectTime / reportTimestamp to resolve last_seen
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
- # Filter collector metrics
2227
- if device_type == "COLLECTOR":
2228
- metrics_to_update = _filter_collector_metrics(raw_metrics)
2229
- else:
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.8
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 ca7b0e8c" in caplog.text
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 ca7b0e8c: Database failure" in caplog.text
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 b7a0873d" in caplog.text
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, "INVERTER")
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, "INVERTER")
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, metrics, and EMS
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.assert_called_once_with(sn)
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 ef797c81: Connection reset" in log_text
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