hyxi-cloud-api 1.3.0__tar.gz → 1.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. {hyxi_cloud_api-1.3.0/src/hyxi_cloud_api.egg-info → hyxi_cloud_api-1.3.2}/PKG-INFO +1 -1
  2. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/pyproject.toml +1 -1
  3. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/src/hyxi_cloud_api/__init__.py +1 -1
  4. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/src/hyxi_cloud_api/api.py +204 -25
  5. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2/src/hyxi_cloud_api.egg-info}/PKG-INFO +1 -1
  6. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/src/hyxi_cloud_api.egg-info/SOURCES.txt +7 -1
  7. hyxi_cloud_api-1.3.2/tests/test_additional_coverage.py +194 -0
  8. hyxi_cloud_api-1.3.2/tests/test_compute_battery_metrics.py +78 -0
  9. hyxi_cloud_api-1.3.2/tests/test_compute_grid_metrics.py +39 -0
  10. hyxi_cloud_api-1.3.2/tests/test_compute_load_metrics.py +64 -0
  11. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_device_control.py +74 -0
  12. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_info_errors.py +25 -0
  13. hyxi_cloud_api-1.3.2/tests/test_push.py +293 -0
  14. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_subscriptions.py +61 -0
  15. hyxi_cloud_api-1.3.2/tests/test_validate_post_rate_ms.py +43 -0
  16. hyxi_cloud_api-1.3.2/tests/test_validate_subscription_device_sns.py +27 -0
  17. hyxi_cloud_api-1.3.0/tests/test_push.py +0 -91
  18. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/LICENSE +0 -0
  19. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/README.md +0 -0
  20. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/setup.cfg +0 -0
  21. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/src/hyxi_cloud_api.egg-info/dependency_links.txt +0 -0
  22. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/src/hyxi_cloud_api.egg-info/requires.txt +0 -0
  23. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/src/hyxi_cloud_api.egg-info/top_level.txt +0 -0
  24. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_alarm_push.py +0 -0
  25. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_all_in_one.py +0 -0
  26. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_api.py +0 -0
  27. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_build_plant_tasks.py +0 -0
  28. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_caching.py +0 -0
  29. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_compute_derived_metrics.py +0 -0
  30. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_device_entry.py +0 -0
  31. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_devices_errors.py +0 -0
  32. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_discovery.py +0 -0
  33. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_execute_device_tasks.py +0 -0
  34. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_execute_metric_tasks.py +0 -0
  35. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_execute_metrics_and_map_alarms.py +0 -0
  36. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_extract_battery_info.py +0 -0
  37. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_extract_device_info_metadata.py +0 -0
  38. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_fetch_and_process_alarms.py +0 -0
  39. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_fetch_device_list_for_plant.py +0 -0
  40. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_fetch_plants.py +0 -0
  41. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_fetch_state.py +0 -0
  42. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_fetch_sub_device_list.py +0 -0
  43. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_filter_metrics.py +0 -0
  44. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_fuzz_parser.py +0 -0
  45. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_get_f.py +0 -0
  46. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_handle_back_discovery_alarm.py +0 -0
  47. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_mask_id.py +0 -0
  48. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_metrics_errors.py +0 -0
  49. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_parse_data_list.py +0 -0
  50. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_parse_ems_kv.py +0 -0
  51. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_parser.py +0 -0
  52. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_process_alarms_and_back_discovery.py +0 -0
  53. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_process_devices_for_plant.py +0 -0
  54. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_sanitize_dict.py +0 -0
  55. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_sanitize_list.py +0 -0
  56. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_security_fix.py +0 -0
  57. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_token_errors.py +0 -0
  58. {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_token_handling.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyxi-cloud-api
3
- Version: 1.3.0
3
+ Version: 1.3.2
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hyxi-cloud-api"
7
- version = "1.3.0"
7
+ version = "1.3.2"
8
8
  authors = [
9
9
  { name="Veldkornet", email="Veldkornet@users.noreply.github.com" },
10
10
  ]
@@ -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.0"
8
+ __version__ = "1.3.2"
9
9
  __all__ = ["VPP_ACTIVE_MODES", "HyxiApiClient"]
@@ -587,6 +587,16 @@ def _compute_load_metrics(m_raw: dict, derived: dict[str, float]) -> None:
587
587
  + _get_f("ph3Loadp", m_raw)
588
588
  )
589
589
 
590
+ if "loadPower" in m_raw or "totalPac" in m_raw:
591
+ derived["load_power_w"] = _get_f("loadPower", m_raw)
592
+
593
+ if (
594
+ derived["load_power_w"] == 0.0
595
+ and m_raw.get("status") == 1
596
+ and "totalPac" in m_raw
597
+ ):
598
+ derived["load_power_w"] = _get_f("totalPac", m_raw)
599
+
590
600
 
591
601
  def _compute_grid_metrics(m_raw: dict, derived: dict[str, float]) -> None:
592
602
  """Calculate grid import/export metrics."""
@@ -602,7 +612,7 @@ def _compute_battery_metrics(
602
612
  """Calculate battery charge/discharge metrics."""
603
613
  bat_p_dc = _get_f("batP", m_raw)
604
614
  pbat = _get_f("pbat", m_raw)
605
- device_type_str = str(device_type or "").upper()
615
+ device_type_str = str(device_type or "")
606
616
 
607
617
  if "batP" in m_raw or "pbat" in m_raw or device_type_str in ("EMS", "15", "16"):
608
618
  # ALL_IN_ONE: prefer pbat — batP can have an inverted sign convention,
@@ -667,6 +677,157 @@ def _compute_derived_metrics(m_raw: dict, device_type: str = "") -> dict:
667
677
  return derived
668
678
 
669
679
 
680
+ def _flatten_nested_push_device(device: dict) -> dict: # pylint: disable=too-many-statements
681
+ """Flatten a nested push device payload into the flat layout expected by the SDK."""
682
+ flat: dict = {}
683
+
684
+ # 1. record
685
+ if "record" in device and isinstance(device["record"], dict):
686
+ rec = device["record"]
687
+ if "deviceSn" in rec:
688
+ flat["deviceSn"] = rec["deviceSn"]
689
+ if "collectTime" in rec:
690
+ try:
691
+ flat["collectTime"] = float(rec["collectTime"]) / 1000.0
692
+ except ValueError, TypeError:
693
+ flat["collectTime"] = rec["collectTime"]
694
+ if "parentSn" in rec:
695
+ flat["parentSn"] = rec["parentSn"]
696
+ if "deviceState" in rec:
697
+ flat["deviceState"] = rec["deviceState"]
698
+
699
+ # Copy root device keys that might already be flat
700
+ for k in ("deviceSn", "collectTime", "reportTimestamp"):
701
+ if k in device and device[k] is not None:
702
+ val = device[k]
703
+ if k == "collectTime":
704
+ try:
705
+ num_val = float(val)
706
+ if num_val > 10000000000:
707
+ flat[k] = num_val / 1000.0
708
+ else:
709
+ flat[k] = val
710
+ except ValueError, TypeError:
711
+ flat[k] = val
712
+ else:
713
+ flat[k] = val
714
+
715
+ # 2. system
716
+ if "system" in device and isinstance(device["system"], dict):
717
+ sys_info = device["system"]
718
+ if "workMode" in sys_info:
719
+ flat["workMode"] = sys_info["workMode"]
720
+
721
+ # 3. ac
722
+ if "ac" in device and isinstance(device["ac"], dict):
723
+ ac_info = device["ac"]
724
+ if "frequencyHz" in ac_info:
725
+ flat["f"] = ac_info["frequencyHz"]
726
+ if "powerW" in ac_info:
727
+ flat["acP"] = ac_info["powerW"]
728
+ if "energyKwh" in ac_info:
729
+ flat["acE"] = ac_info["energyKwh"]
730
+
731
+ # 4. pv
732
+ if "pv" in device and isinstance(device["pv"], dict):
733
+ pv_info = device["pv"]
734
+ if "totalPowerW" in pv_info:
735
+ flat["ppv"] = pv_info["totalPowerW"]
736
+ for i in range(1, 5):
737
+ pv_key = f"pv{i}"
738
+ if pv_key in pv_info and isinstance(pv_info[pv_key], dict):
739
+ pvi = pv_info[pv_key]
740
+ if "voltageV" in pvi:
741
+ flat[f"pv{i}v"] = pvi["voltageV"]
742
+ if "currentA" in pvi:
743
+ flat[f"pv{i}i"] = pvi["currentA"]
744
+ if "powerW" in pvi:
745
+ flat[f"pv{i}p"] = pvi["powerW"]
746
+
747
+ # 5. battery
748
+ if "battery" in device and isinstance(device["battery"], dict):
749
+ bat = device["battery"]
750
+ if "serialNumber" in bat:
751
+ flat["batSn"] = bat["serialNumber"]
752
+ if "capacityKwh" in bat:
753
+ flat["batCap"] = bat["capacityKwh"]
754
+ if "socPercent" in bat:
755
+ flat["batSoc"] = bat["socPercent"]
756
+ if "sohPercent" in bat:
757
+ flat["batSoh"] = bat["sohPercent"]
758
+ if "powerW" in bat:
759
+ flat["batP"] = bat["powerW"]
760
+ if "pbatW" in bat:
761
+ flat["pbat"] = bat["pbatW"]
762
+ if "voltageV" in bat:
763
+ flat["batV"] = bat["voltageV"]
764
+ if "currentA" in bat:
765
+ flat["batI"] = bat["currentA"]
766
+ if "chargeEnergyKwh" in bat:
767
+ flat["batCharge"] = bat["chargeEnergyKwh"]
768
+ if "dischargeEnergyKwh" in bat:
769
+ flat["batDisCharge"] = bat["dischargeEnergyKwh"]
770
+
771
+ # battery.temperature
772
+ if "temperature" in bat and isinstance(bat["temperature"], dict):
773
+ btemp = bat["temperature"]
774
+ if "chargeTempC" in btemp:
775
+ flat["batTch"] = btemp["chargeTempC"]
776
+ if "cellLowTempC" in btemp:
777
+ flat["batTcl"] = btemp["cellLowTempC"]
778
+
779
+ # battery.limits
780
+ if "limits" in bat and isinstance(bat["limits"], dict):
781
+ blim = bat["limits"]
782
+ if "maxChargePowerW" in blim:
783
+ flat["maxChargePower"] = blim["maxChargePowerW"]
784
+ if "maxDischargePowerW" in blim:
785
+ flat["maxDischargePower"] = blim["maxDischargePowerW"]
786
+
787
+ # battery.cellVoltage
788
+ if "cellVoltage" in bat and isinstance(bat["cellVoltage"], dict):
789
+ bvol = bat["cellVoltage"]
790
+ if "cellVoltageLowV" in bvol:
791
+ flat["batVcl"] = bvol["cellVoltageLowV"]
792
+ if "cellVoltageHighV" in bvol:
793
+ flat["batVch"] = bvol["cellVoltageHighV"]
794
+
795
+ # 6. dcBus
796
+ if "dcBus" in device and isinstance(device["dcBus"], dict):
797
+ dbus = device["dcBus"]
798
+ if "vbus" in dbus:
799
+ flat["vbus"] = dbus["vbus"]
800
+
801
+ # 7. temperatures
802
+ if "temperatures" in device and isinstance(device["temperatures"], dict):
803
+ temps = device["temperatures"]
804
+ if "inverterTempC" in temps:
805
+ flat["tinv"] = temps["inverterTempC"]
806
+
807
+ # 8. phases
808
+ if "phases" in device and isinstance(device["phases"], dict):
809
+ phs = device["phases"]
810
+ for i in range(1, 4):
811
+ ph_key = f"ph{i}"
812
+ if ph_key in phs and isinstance(phs[ph_key], dict):
813
+ phi = phs[ph_key]
814
+ if "voltageV" in phi:
815
+ flat[f"ph{i}v"] = phi["voltageV"]
816
+ if "currentA" in phi:
817
+ flat[f"ph{i}i"] = phi["currentA"]
818
+ if "powerW" in phi:
819
+ flat[f"ph{i}p"] = phi["powerW"]
820
+ if "epsPowerW" in phi:
821
+ flat[f"ph{i}Loadp"] = phi["epsPowerW"]
822
+
823
+ # Copy any other keys at the root that aren't dictionaries
824
+ for k, v in device.items():
825
+ if k not in flat and not isinstance(v, dict):
826
+ flat[k] = v
827
+
828
+ return flat
829
+
830
+
670
831
  @functools.lru_cache(maxsize=1024)
671
832
  def _mask_id(value: str) -> str:
672
833
  """Mask an identifier (SN, plant ID, etc.) for logs.
@@ -1042,7 +1203,7 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1042
1203
  "ratedFrequency": i_raw.get("ratedFrequency"),
1043
1204
  }
1044
1205
 
1045
- device_type_code = entry.get("device_type_code", "").upper()
1206
+ device_type_code = entry.get("device_type_code", "")
1046
1207
  if _BATTERY_DEVICE_REGEX.search(device_type_code):
1047
1208
  base_info.update(HyxiApiClient._extract_battery_info(i_raw))
1048
1209
 
@@ -1426,12 +1587,13 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1426
1587
  """Helper to concurrently process plants to gather metrics and alarms."""
1427
1588
  device_fetch_tasks, alarm_fetch_tasks = self._build_plant_tasks(state)
1428
1589
 
1429
- await HyxiApiClient._execute_device_tasks(device_fetch_tasks)
1430
-
1431
- plant_alarms = await self._fetch_and_process_alarms(
1432
- alarm_fetch_tasks,
1433
- state,
1434
- allow_back_discovery=allow_back_discovery,
1590
+ _, plant_alarms = await asyncio.gather(
1591
+ HyxiApiClient._execute_device_tasks(device_fetch_tasks),
1592
+ self._fetch_and_process_alarms(
1593
+ alarm_fetch_tasks,
1594
+ state,
1595
+ allow_back_discovery=allow_back_discovery,
1596
+ ),
1435
1597
  )
1436
1598
 
1437
1599
  # 3. Concurrent Metrics
@@ -1457,8 +1619,7 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1457
1619
  state.metric_tasks.append(self._fetch_all_for_device(sn, entry, dev_type))
1458
1620
 
1459
1621
  # 🚀 DEEP BACK-DISCOVERY: If this is a parent, search for ITS children too!
1460
- dev_type_upper = dev_type.upper()
1461
- if _PARENT_DEVICE_REGEX.search(dev_type_upper):
1622
+ if _PARENT_DEVICE_REGEX.search(dev_type):
1462
1623
  sub_device_tasks.append(self._fetch_sub_devices(sn, state))
1463
1624
 
1464
1625
  async def _process_alarms_and_back_discovery(
@@ -1593,7 +1754,7 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1593
1754
  @staticmethod
1594
1755
  def _build_device_entry(sn, device_data, now):
1595
1756
  """Build a standardized device entry dictionary from raw API data."""
1596
- dev_type = str(device_data.get("deviceType") or "UNKNOWN")
1757
+ dev_type = str(device_data.get("deviceType") or "UNKNOWN").upper()
1597
1758
  friendly_name = (
1598
1759
  DEVICE_TYPE_MAP.get(dev_type) or dev_type.replace("_", " ").title()
1599
1760
  )
@@ -1628,6 +1789,10 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1628
1789
  Values are strings per the developer docs ('' for idle/self-consumption,
1629
1790
  a wattage like '100' for 1063/1064, '0'/'1' for switches).
1630
1791
  """
1792
+ if not control_map:
1793
+ _LOGGER.warning("set_device_control called with empty settings")
1794
+ return {}
1795
+
1631
1796
  await self._ensure_authenticated(self.ControlError)
1632
1797
 
1633
1798
  path = "/api/device/v2/control"
@@ -1922,7 +2087,11 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1922
2087
  """Calculate derived metrics (grid import/export, bat charging/discharging, etc.) from raw metrics."""
1923
2088
  return _compute_derived_metrics(m_raw, device_type)
1924
2089
 
1925
- def process_push_data(self, payload: dict) -> dict[str, dict[str, Any]]:
2090
+ def process_push_data(
2091
+ self,
2092
+ payload: dict,
2093
+ existing_metrics: dict[str, dict[str, Any]] | None = None,
2094
+ ) -> dict[str, dict[str, Any]]:
1926
2095
  """Process real-time push data from HYXI Cloud.
1927
2096
 
1928
2097
  Parses the flat push payload, matches it to the discovery cache,
@@ -1954,6 +2123,8 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1954
2123
  if not isinstance(device, dict):
1955
2124
  continue
1956
2125
 
2126
+ device = _flatten_nested_push_device(device)
2127
+
1957
2128
  sn = device.get("deviceSn")
1958
2129
  if not sn:
1959
2130
  continue
@@ -1966,6 +2137,10 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1966
2137
  continue
1967
2138
  raw_metrics[k] = v
1968
2139
 
2140
+ # Retrieve info from discovery cache
2141
+ device_info = self._discovery_cache.get("device_info", {}).get(sn, {})
2142
+ device_type = str(device_info.get("device_type_code") or "")
2143
+
1969
2144
  # Handle collectTime / reportTimestamp to resolve last_seen
1970
2145
  last_seen = now_utc
1971
2146
  collect_time = device.get("collectTime")
@@ -1986,25 +2161,29 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1986
2161
  except ValueError, TypeError:
1987
2162
  pass
1988
2163
 
1989
- raw_metrics["last_seen"] = last_seen
1990
-
1991
- # Retrieve info from discovery cache
1992
- device_info = self._discovery_cache.get("device_info", {}).get(sn, {})
1993
- device_type = str(device_info.get("device_type_code") or "").upper()
1994
-
1995
2164
  # Filter collector metrics
1996
2165
  if device_type == "COLLECTOR":
1997
- metrics = _filter_collector_metrics(raw_metrics)
2166
+ metrics_to_update = _filter_collector_metrics(raw_metrics)
1998
2167
  else:
1999
- metrics = raw_metrics.copy()
2000
-
2001
- # Compute derived metrics
2002
- derived = _compute_derived_metrics(metrics, device_type)
2003
- metrics.update(derived)
2168
+ metrics_to_update = raw_metrics.copy()
2169
+ # Merge with existing metrics if provided, ignoring None/null values
2170
+ merged_metrics = {}
2171
+ if existing_metrics and sn in existing_metrics:
2172
+ # Copy existing to avoid mutating caller's dictionary directly
2173
+ merged_metrics = dict(existing_metrics[sn])
2174
+
2175
+ for k, v in metrics_to_update.items():
2176
+ if v is not None:
2177
+ merged_metrics[k] = v
2178
+ merged_metrics["last_seen"] = last_seen
2179
+
2180
+ # Compute derived metrics on the full merged dataset
2181
+ derived = _compute_derived_metrics(merged_metrics, device_type)
2182
+ merged_metrics.update(derived)
2004
2183
 
2005
2184
  results[sn] = {
2006
2185
  "sn": sn,
2007
- "metrics": metrics,
2186
+ "metrics": merged_metrics,
2008
2187
  "model": device_info.get("model", "Unknown"),
2009
2188
  "device_type_code": device_info.get("device_type_code", "Unknown"),
2010
2189
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyxi-cloud-api
3
- Version: 1.3.0
3
+ Version: 1.3.2
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
@@ -8,12 +8,16 @@ src/hyxi_cloud_api.egg-info/SOURCES.txt
8
8
  src/hyxi_cloud_api.egg-info/dependency_links.txt
9
9
  src/hyxi_cloud_api.egg-info/requires.txt
10
10
  src/hyxi_cloud_api.egg-info/top_level.txt
11
+ tests/test_additional_coverage.py
11
12
  tests/test_alarm_push.py
12
13
  tests/test_all_in_one.py
13
14
  tests/test_api.py
14
15
  tests/test_build_plant_tasks.py
15
16
  tests/test_caching.py
17
+ tests/test_compute_battery_metrics.py
16
18
  tests/test_compute_derived_metrics.py
19
+ tests/test_compute_grid_metrics.py
20
+ tests/test_compute_load_metrics.py
17
21
  tests/test_device_control.py
18
22
  tests/test_device_entry.py
19
23
  tests/test_devices_errors.py
@@ -46,4 +50,6 @@ tests/test_sanitize_list.py
46
50
  tests/test_security_fix.py
47
51
  tests/test_subscriptions.py
48
52
  tests/test_token_errors.py
49
- tests/test_token_handling.py
53
+ tests/test_token_handling.py
54
+ tests/test_validate_post_rate_ms.py
55
+ tests/test_validate_subscription_device_sns.py
@@ -0,0 +1,194 @@
1
+ import logging
2
+ from unittest.mock import AsyncMock, MagicMock
3
+
4
+ import pytest
5
+
6
+ from src.hyxi_cloud_api.api import FetchState, HyxiApiClient
7
+
8
+
9
+ @pytest.mark.asyncio
10
+ async def test_fetch_sub_devices_coverage(caplog):
11
+ """Test _fetch_sub_devices debug logging, duplicates, and exceptions."""
12
+ caplog.set_level(logging.DEBUG)
13
+ mock_session = MagicMock()
14
+ api = HyxiApiClient("ak", "sk", "https://api.com", mock_session)
15
+
16
+ # 1. Test successful sub-device discovery with debug logging
17
+ state = FetchState(now="2026-06-02T08:00:00")
18
+ state.discovered_sns.add("DUPLICATE_SN")
19
+
20
+ children_data = [
21
+ {"deviceSn": "NEW_SN", "deviceType": "INVERTER"},
22
+ {
23
+ "deviceSn": "DUPLICATE_SN",
24
+ "deviceType": "INVERTER",
25
+ }, # should be skipped (duplicate)
26
+ {"deviceSn": "", "deviceType": "INVERTER"}, # should be skipped (missing SN)
27
+ ]
28
+ api._fetch_sub_device_list = AsyncMock(return_value=children_data)
29
+ api._fetch_all_for_device = AsyncMock()
30
+
31
+ await api._fetch_sub_devices("PARENT_SN", state)
32
+
33
+ # Verify debug log was hit (line 1243)
34
+ # PARENT_SN is masked to ca7b0e8c
35
+ assert "HYXI Found 3 sub-devices under ca7b0e8c" in caplog.text
36
+ # Verify duplicate/empty check worked (line 1253)
37
+ assert api._fetch_all_for_device.call_count == 1
38
+ api._fetch_all_for_device.assert_called_once_with(
39
+ "NEW_SN",
40
+ {
41
+ "sn": "NEW_SN",
42
+ "device_name": "Inverter NEW_SN",
43
+ "model": "Inverter",
44
+ "device_type_code": "INVERTER",
45
+ "sw_version": None,
46
+ "hw_version": None,
47
+ "metrics": {"last_seen": "2026-06-02T08:00:00"},
48
+ },
49
+ "INVERTER",
50
+ )
51
+
52
+ # 2. Test exception logging in _fetch_sub_devices (lines 1265-1266)
53
+ caplog.clear()
54
+ api._fetch_sub_device_list = AsyncMock(side_effect=Exception("Database failure"))
55
+ await api._fetch_sub_devices("PARENT_SN", state)
56
+ # PARENT_SN is masked to ca7b0e8c
57
+ assert "Error fetching sub-devices for ca7b0e8c: Database failure" in caplog.text
58
+
59
+
60
+ @pytest.mark.asyncio
61
+ async def test_fetch_alarms_for_plant_coverage(caplog):
62
+ """Test _fetch_alarms_for_plant rejection and alarm name mapping."""
63
+ caplog.set_level(logging.ERROR)
64
+ mock_session = MagicMock()
65
+ api = HyxiApiClient("ak", "sk", "https://api.com", mock_session)
66
+
67
+ # 1. Rejection logging (lines 1281-1286)
68
+ api._request = AsyncMock(
69
+ return_value=(200, {"success": False, "msg": "Limit exceeded"})
70
+ )
71
+ res = await api._fetch_alarms_for_plant("PLANT_123")
72
+ assert res == []
73
+ # PLANT_123 is masked to b7a0873d
74
+ assert "HYXI API Alarm Fetch Rejected for Plant b7a0873d" in caplog.text
75
+
76
+ # 2. Alarm Name Mapping (line 1295)
77
+ api._request = AsyncMock(
78
+ return_value=(
79
+ 200,
80
+ {
81
+ "success": True,
82
+ "data": {
83
+ "pageData": [
84
+ {
85
+ "alarmCode": "704",
86
+ "alarmState": "1",
87
+ } # "704" is mapped in ALARM_CODE_MAP
88
+ ]
89
+ },
90
+ },
91
+ )
92
+ )
93
+ res = await api._fetch_alarms_for_plant("PLANT_123")
94
+ assert len(res) == 1
95
+ assert (
96
+ res[0]["alarmName"] == "The ambient temperature is too high"
97
+ ) # ALARM_CODE_MAP["704"]
98
+
99
+
100
+ @pytest.mark.asyncio
101
+ async def test_execute_fetch_all_errors():
102
+ """Test _execute_fetch_all authentication fail paths (lines 1530, 1532)."""
103
+ mock_session = MagicMock()
104
+ api = HyxiApiClient("ak", "sk", "https://api.com", mock_session)
105
+
106
+ # 1. auth_failed path (line 1530)
107
+ api._refresh_token = AsyncMock(return_value="auth_failed")
108
+ res = await api._execute_fetch_all()
109
+ assert res == "auth_failed"
110
+
111
+ # 2. False token status path (line 1532)
112
+ api._refresh_token = AsyncMock(return_value=False)
113
+ res = await api._execute_fetch_all()
114
+ assert res is None
115
+
116
+
117
+ @pytest.mark.asyncio
118
+ async def test_execute_fetch_full_discovery_error():
119
+ """Test _execute_fetch_full_discovery returns None if fetch_plants fails (line 1588)."""
120
+ mock_session = MagicMock()
121
+ api = HyxiApiClient("ak", "sk", "https://api.com", mock_session)
122
+ state = FetchState(now="now")
123
+
124
+ api._fetch_plants = AsyncMock(return_value=None)
125
+ res = await api._execute_fetch_full_discovery(state, allow_back_discovery=False)
126
+ assert res is None
127
+
128
+
129
+ @pytest.mark.asyncio
130
+ async def test_alter_alarm_token_errors():
131
+ """Test alter_alarm raises ControlError on token failure (line 1911)."""
132
+ mock_session = MagicMock()
133
+ api = HyxiApiClient("ak", "sk", "https://api.com", mock_session)
134
+
135
+ # 1. auth_failed
136
+ api._refresh_token = AsyncMock(return_value="auth_failed")
137
+ with pytest.raises(HyxiApiClient.ControlError, match="Authentication failed"):
138
+ await api.alter_alarm([123])
139
+
140
+ # 2. False status (line 1911)
141
+ api._refresh_token = AsyncMock(return_value=False)
142
+ with pytest.raises(HyxiApiClient.ControlError, match="Could not obtain API token"):
143
+ await api.alter_alarm([123])
144
+
145
+
146
+ def test_compute_derived_metrics_classmethod():
147
+ """Test compute_derived_metrics proxy call (line 1937)."""
148
+ res = HyxiApiClient.compute_derived_metrics({"gridP": 100}, "INVERTER")
149
+ assert isinstance(res, dict)
150
+ assert "grid_import" in res
151
+
152
+
153
+ def test_process_push_data_edge_cases(caplog):
154
+ """Test process_push_data ignores invalid items and dates (lines 1973, 1977, 2001-2002, 2008-2009)."""
155
+ caplog.set_level(logging.WARNING)
156
+ mock_session = MagicMock()
157
+ api = HyxiApiClient("ak", "sk", "https://api.com", mock_session)
158
+
159
+ # dataList contains invalid elements (non-dict, missing SN, invalid dates)
160
+ payload = {
161
+ "dataList": [
162
+ 123, # not a dict (line 1973)
163
+ {"reportTimestamp": 1712728593000}, # missing SN (line 1977)
164
+ {
165
+ "deviceSn": "SN123",
166
+ "collectTime": "invalid_date_type", # invalid date type (line 2001-2002)
167
+ "reportTimestamp": "invalid_ts_type", # invalid timestamp type (line 2008-2009)
168
+ },
169
+ ]
170
+ }
171
+
172
+ res = api.process_push_data(payload)
173
+ assert "SN123" in res
174
+ assert res["SN123"]["metrics"]["last_seen"].startswith("2") # starts with year 202x
175
+
176
+
177
+ def test_process_alarm_push_data_edge_cases(caplog):
178
+ """Test process_alarm_push_data handles invalid elements (lines 2078, 2082)."""
179
+ caplog.set_level(logging.WARNING)
180
+ mock_session = MagicMock()
181
+ api = HyxiApiClient("ak", "sk", "https://api.com", mock_session)
182
+
183
+ payload = {
184
+ "dataList": [
185
+ 123, # not a dict (line 2078)
186
+ {"alarmCode": "12"}, # missing SN (line 2082)
187
+ {"deviceSn": "SN123", "alarmCode": "12", "alarmState": "1"}, # valid
188
+ ]
189
+ }
190
+
191
+ res = api.process_alarm_push_data(payload)
192
+ assert "SN123" in res
193
+ assert len(res["SN123"]) == 1
194
+ assert res["SN123"][0]["alarmCode"] == "12"
@@ -0,0 +1,78 @@
1
+ """Tests for the _compute_battery_metrics helper function."""
2
+
3
+ from src.hyxi_cloud_api.api import _compute_battery_metrics
4
+
5
+
6
+ def test_compute_battery_metrics_all_in_one():
7
+ """Test ALL_IN_ONE device specific logic where pbat is preferred."""
8
+ derived: dict[str, float] = {}
9
+ m_raw = {"batP": 500.0, "pbat": -450.0}
10
+ _compute_battery_metrics(m_raw, derived, "ALL_IN_ONE")
11
+ assert derived["bat_charging"] == 450.0
12
+ assert derived["bat_discharging"] == 0.0
13
+ assert derived["bat_power_dc"] == 500.0
14
+
15
+
16
+ def test_compute_battery_metrics_all_in_one_fallback_to_bat_p():
17
+ """Test ALL_IN_ONE fallback logic when pbat is zero."""
18
+ derived: dict[str, float] = {}
19
+ m_raw = {"batP": -500.0, "pbat": 0.0}
20
+ _compute_battery_metrics(m_raw, derived, "ALL_IN_ONE")
21
+ assert derived["bat_charging"] == 500.0
22
+ assert derived["bat_discharging"] == 0.0
23
+ assert derived["bat_power_dc"] == -500.0
24
+
25
+
26
+ def test_compute_battery_metrics_other_devices():
27
+ """Test standard device logic where batP is preferred."""
28
+ derived: dict[str, float] = {}
29
+ m_raw = {"batP": 600.0, "pbat": 550.0}
30
+ _compute_battery_metrics(m_raw, derived, "OTHER")
31
+ assert derived["bat_charging"] == 0.0
32
+ assert derived["bat_discharging"] == 600.0
33
+ assert derived["bat_power_dc"] == 600.0
34
+
35
+
36
+ def test_compute_battery_metrics_other_devices_fallback_to_pbat():
37
+ """Test standard device fallback logic when batP is zero."""
38
+ derived: dict[str, float] = {}
39
+ m_raw = {"batP": 0.0, "pbat": 400.0}
40
+ _compute_battery_metrics(m_raw, derived, "OTHER")
41
+ assert derived["bat_charging"] == 0.0
42
+ assert derived["bat_discharging"] == 400.0
43
+ assert derived["bat_power_dc"] == 0.0
44
+
45
+
46
+ def test_compute_battery_metrics_ems_device_type_no_keys():
47
+ """Test EMS device type logic handles empty input keys."""
48
+ derived: dict[str, float] = {}
49
+ m_raw: dict = {}
50
+ _compute_battery_metrics(m_raw, derived, "EMS")
51
+ assert derived["bat_charging"] == 0.0
52
+ assert derived["bat_discharging"] == 0.0
53
+ assert derived["bat_power_dc"] == 0.0
54
+
55
+
56
+ def test_compute_battery_metrics_totals():
57
+ """Test handling of total charging and discharging statistics."""
58
+ derived: dict[str, float] = {}
59
+ m_raw = {"batCharge": 12.5, "batDisCharge": 45.6}
60
+ _compute_battery_metrics(m_raw, derived, "OTHER")
61
+ assert derived["bat_charge_total"] == 12.5
62
+ assert derived["bat_discharge_total"] == 45.6
63
+
64
+
65
+ def test_compute_battery_metrics_empty():
66
+ """Test graceful handling of empty inputs."""
67
+ derived: dict[str, float] = {}
68
+ _compute_battery_metrics({}, derived, "")
69
+ assert not derived
70
+
71
+
72
+ def test_compute_battery_metrics_none_device_type():
73
+ """Test graceful handling of missing device_type."""
74
+ derived: dict[str, float] = {}
75
+ m_raw = {"batP": -100.0}
76
+ _compute_battery_metrics(m_raw, derived, None) # type: ignore
77
+ assert derived["bat_charging"] == 100.0
78
+ assert derived["bat_power_dc"] == -100.0