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.
- {hyxi_cloud_api-1.3.0/src/hyxi_cloud_api.egg-info → hyxi_cloud_api-1.3.2}/PKG-INFO +1 -1
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/pyproject.toml +1 -1
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/src/hyxi_cloud_api/__init__.py +1 -1
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/src/hyxi_cloud_api/api.py +204 -25
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2/src/hyxi_cloud_api.egg-info}/PKG-INFO +1 -1
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/src/hyxi_cloud_api.egg-info/SOURCES.txt +7 -1
- hyxi_cloud_api-1.3.2/tests/test_additional_coverage.py +194 -0
- hyxi_cloud_api-1.3.2/tests/test_compute_battery_metrics.py +78 -0
- hyxi_cloud_api-1.3.2/tests/test_compute_grid_metrics.py +39 -0
- hyxi_cloud_api-1.3.2/tests/test_compute_load_metrics.py +64 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_device_control.py +74 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_info_errors.py +25 -0
- hyxi_cloud_api-1.3.2/tests/test_push.py +293 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_subscriptions.py +61 -0
- hyxi_cloud_api-1.3.2/tests/test_validate_post_rate_ms.py +43 -0
- hyxi_cloud_api-1.3.2/tests/test_validate_subscription_device_sns.py +27 -0
- hyxi_cloud_api-1.3.0/tests/test_push.py +0 -91
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/LICENSE +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/README.md +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/setup.cfg +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/src/hyxi_cloud_api.egg-info/dependency_links.txt +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/src/hyxi_cloud_api.egg-info/requires.txt +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/src/hyxi_cloud_api.egg-info/top_level.txt +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_alarm_push.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_all_in_one.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_api.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_build_plant_tasks.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_caching.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_compute_derived_metrics.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_device_entry.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_devices_errors.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_discovery.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_execute_device_tasks.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_execute_metric_tasks.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_execute_metrics_and_map_alarms.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_extract_battery_info.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_extract_device_info_metadata.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_fetch_and_process_alarms.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_fetch_device_list_for_plant.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_fetch_plants.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_fetch_state.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_fetch_sub_device_list.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_filter_metrics.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_fuzz_parser.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_get_f.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_handle_back_discovery_alarm.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_mask_id.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_metrics_errors.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_parse_data_list.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_parse_ems_kv.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_parser.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_process_alarms_and_back_discovery.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_process_devices_for_plant.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_sanitize_dict.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_sanitize_list.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_security_fix.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_token_errors.py +0 -0
- {hyxi_cloud_api-1.3.0 → hyxi_cloud_api-1.3.2}/tests/test_token_handling.py +0 -0
|
@@ -5,5 +5,5 @@ from .api import HyxiApiClient
|
|
|
5
5
|
# Module-level alias so callers can do: from hyxi_cloud_api import VPP_ACTIVE_MODES
|
|
6
6
|
VPP_ACTIVE_MODES: frozenset[str] = HyxiApiClient.VPP_ACTIVE_MODES
|
|
7
7
|
|
|
8
|
-
__version__ = "1.3.
|
|
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 "")
|
|
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", "")
|
|
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
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
2166
|
+
metrics_to_update = _filter_collector_metrics(raw_metrics)
|
|
1998
2167
|
else:
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
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":
|
|
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
|
}
|
|
@@ -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
|