hyxi-cloud-api 1.2.2__tar.gz → 1.2.4__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.2.2/src/hyxi_cloud_api.egg-info → hyxi_cloud_api-1.2.4}/PKG-INFO +1 -1
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/pyproject.toml +1 -1
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/src/hyxi_cloud_api/__init__.py +1 -1
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/src/hyxi_cloud_api/api.py +56 -29
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4/src/hyxi_cloud_api.egg-info}/PKG-INFO +1 -1
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_api.py +20 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_device_control.py +5 -14
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_fetch_plants.py +25 -1
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/LICENSE +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/README.md +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/setup.cfg +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/src/hyxi_cloud_api.egg-info/SOURCES.txt +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/src/hyxi_cloud_api.egg-info/dependency_links.txt +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/src/hyxi_cloud_api.egg-info/requires.txt +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/src/hyxi_cloud_api.egg-info/top_level.txt +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_all_in_one.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_build_plant_tasks.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_caching.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_compute_derived_metrics.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_device_entry.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_devices_errors.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_discovery.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_execute_device_tasks.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_execute_metric_tasks.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_execute_metrics_and_map_alarms.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_extract_battery_info.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_extract_device_info_metadata.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_fetch_and_process_alarms.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_fetch_device_list_for_plant.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_fetch_state.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_fetch_sub_device_list.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_filter_metrics.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_fuzz_parser.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_get_f.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_handle_back_discovery_alarm.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_info_errors.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_mask_id.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_metrics_errors.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_parse_data_list.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_parse_ems_kv.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_parser.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_process_alarms_and_back_discovery.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_process_devices_for_plant.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_sanitize_dict.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_sanitize_list.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_security_fix.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_token_errors.py +0 -0
- {hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_token_handling.py +0 -0
|
@@ -566,20 +566,19 @@ def _get_f(key: str, data_map: dict, mult: float = 1.0) -> float:
|
|
|
566
566
|
return 0.0
|
|
567
567
|
|
|
568
568
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
569
|
+
@functools.lru_cache(maxsize=1024)
|
|
570
|
+
def _is_collector_key_allowed(key: str) -> bool:
|
|
571
|
+
"""Check if metric key is allowed for Collectors and cache the result."""
|
|
572
|
+
return not _COLLECTOR_FILTER_REGEX.search(key)
|
|
572
573
|
|
|
573
574
|
|
|
574
|
-
def
|
|
575
|
-
"""
|
|
575
|
+
def _filter_collector_metrics(m_raw: dict) -> dict:
|
|
576
|
+
"""Remove battery/power metrics that shouldn't be present on Collectors."""
|
|
577
|
+
return {k: v for k, v in m_raw.items() if _is_collector_key_allowed(k)}
|
|
576
578
|
|
|
577
|
-
Only keys that have relevant base data in m_raw will be included in the
|
|
578
|
-
resulting dictionary to avoid 'ghost' sensors for unsupported features.
|
|
579
|
-
"""
|
|
580
|
-
derived = {}
|
|
581
579
|
|
|
582
|
-
|
|
580
|
+
def _compute_load_metrics(m_raw: dict, derived: dict[str, float]) -> None:
|
|
581
|
+
"""Calculate home load metrics."""
|
|
583
582
|
if "ph1Loadp" in m_raw or "ph2Loadp" in m_raw or "ph3Loadp" in m_raw:
|
|
584
583
|
derived["home_load"] = (
|
|
585
584
|
_get_f("ph1Loadp", m_raw)
|
|
@@ -587,21 +586,28 @@ def _compute_derived_metrics(m_raw: dict, device_type: str = "") -> dict:
|
|
|
587
586
|
+ _get_f("ph3Loadp", m_raw)
|
|
588
587
|
)
|
|
589
588
|
|
|
590
|
-
|
|
589
|
+
|
|
590
|
+
def _compute_grid_metrics(m_raw: dict, derived: dict[str, float]) -> None:
|
|
591
|
+
"""Calculate grid import/export metrics."""
|
|
591
592
|
if "gridP" in m_raw:
|
|
592
593
|
grid = _get_f("gridP", m_raw, 1000.0)
|
|
593
594
|
derived["grid_import"] = abs(grid) if grid < 0 else 0.0
|
|
594
595
|
derived["grid_export"] = grid if grid > 0 else 0.0
|
|
595
596
|
|
|
596
|
-
|
|
597
|
+
|
|
598
|
+
def _compute_battery_metrics(
|
|
599
|
+
m_raw: dict, derived: dict[str, float], device_type: str
|
|
600
|
+
) -> None:
|
|
601
|
+
"""Calculate battery charge/discharge metrics."""
|
|
597
602
|
bat_p_dc = _get_f("batP", m_raw)
|
|
598
603
|
pbat = _get_f("pbat", m_raw)
|
|
604
|
+
device_type_str = str(device_type or "").upper()
|
|
599
605
|
|
|
600
|
-
if "batP" in m_raw or "pbat" in m_raw:
|
|
606
|
+
if "batP" in m_raw or "pbat" in m_raw or device_type_str in ("EMS", "15", "16"):
|
|
601
607
|
# ALL_IN_ONE: prefer pbat — batP can have an inverted sign convention,
|
|
602
608
|
# while pbat is consistently negative-for-charging / positive-for-discharging.
|
|
603
609
|
# Other devices: prefer batP (DC terminals), fall back to pbat.
|
|
604
|
-
if
|
|
610
|
+
if device_type_str == "ALL_IN_ONE":
|
|
605
611
|
power_source = pbat if pbat != 0.0 else bat_p_dc
|
|
606
612
|
else:
|
|
607
613
|
power_source = bat_p_dc if bat_p_dc != 0.0 else pbat
|
|
@@ -614,7 +620,9 @@ def _compute_derived_metrics(m_raw: dict, device_type: str = "") -> dict:
|
|
|
614
620
|
if "batDisCharge" in m_raw:
|
|
615
621
|
derived["bat_discharge_total"] = _get_f("batDisCharge", m_raw)
|
|
616
622
|
|
|
617
|
-
|
|
623
|
+
|
|
624
|
+
def _compute_pv_metrics(m_raw: dict, derived: dict[str, float]) -> None:
|
|
625
|
+
"""Calculate PV string powers."""
|
|
618
626
|
for v_k, i_k, p_k in _PV_KEYS:
|
|
619
627
|
if v_k in m_raw or i_k in m_raw or p_k in m_raw:
|
|
620
628
|
derived[p_k] = _get_f(p_k, m_raw) or round(
|
|
@@ -631,15 +639,30 @@ def _compute_derived_metrics(m_raw: dict, device_type: str = "") -> dict:
|
|
|
631
639
|
ppv_total = _get_f("ppv", m_raw)
|
|
632
640
|
derived["pv1p"] = round(max(ppv_total - derived["pv2p"], 0), 2)
|
|
633
641
|
|
|
634
|
-
|
|
635
|
-
|
|
642
|
+
|
|
643
|
+
def _compute_micro_ess_fallback_metrics(m_raw: dict, derived: dict[str, float]) -> None:
|
|
644
|
+
"""Derive standard metrics from Micro ESS specific metrics."""
|
|
636
645
|
if "pvPower" in m_raw and "ppv" not in m_raw:
|
|
637
646
|
derived["ppv"] = _get_f("pvPower", m_raw)
|
|
638
647
|
|
|
639
|
-
# Derive standard Grid Frequency (f) from Micro ESS gridF if f is missing
|
|
640
648
|
if "gridF" in m_raw and "f" not in m_raw:
|
|
641
649
|
derived["f"] = _get_f("gridF", m_raw)
|
|
642
650
|
|
|
651
|
+
|
|
652
|
+
def _compute_derived_metrics(m_raw: dict, device_type: str = "") -> dict:
|
|
653
|
+
"""Calculate derived metrics from raw metrics map.
|
|
654
|
+
|
|
655
|
+
Only keys that have relevant base data in m_raw will be included in the
|
|
656
|
+
resulting dictionary to avoid 'ghost' sensors for unsupported features.
|
|
657
|
+
"""
|
|
658
|
+
derived: dict[str, float] = {}
|
|
659
|
+
|
|
660
|
+
_compute_load_metrics(m_raw, derived)
|
|
661
|
+
_compute_grid_metrics(m_raw, derived)
|
|
662
|
+
_compute_battery_metrics(m_raw, derived, device_type)
|
|
663
|
+
_compute_pv_metrics(m_raw, derived)
|
|
664
|
+
_compute_micro_ess_fallback_metrics(m_raw, derived)
|
|
665
|
+
|
|
643
666
|
return derived
|
|
644
667
|
|
|
645
668
|
|
|
@@ -1286,8 +1309,13 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
1286
1309
|
data_p = res_p.get("data", {})
|
|
1287
1310
|
plants = data_p.get("list", []) if isinstance(data_p, dict) else []
|
|
1288
1311
|
|
|
1289
|
-
|
|
1290
|
-
|
|
1312
|
+
if not plants:
|
|
1313
|
+
_LOGGER.warning(
|
|
1314
|
+
"HYXI API: No plants found associated with this account. "
|
|
1315
|
+
"If your developer email differs from your app email, you must share "
|
|
1316
|
+
"your Plant from the app to the developer email first."
|
|
1317
|
+
)
|
|
1318
|
+
elif _LOGGER.isEnabledFor(logging.DEBUG):
|
|
1291
1319
|
_LOGGER.debug(
|
|
1292
1320
|
"HYXI Discovered Plants: [%s]",
|
|
1293
1321
|
", ".join(_mask_id(p.get("plantId", "UNKNOWN")) for p in plants),
|
|
@@ -1647,19 +1675,18 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
1647
1675
|
|
|
1648
1676
|
# ── Microinverter Controls ───────────────────────────────────────────
|
|
1649
1677
|
|
|
1650
|
-
async def
|
|
1651
|
-
"""Turn on a Microinverter (controlId 3011).
|
|
1678
|
+
async def set_micro_power(self, device_sn: str, power_on: bool) -> dict:
|
|
1679
|
+
"""Turn on or off a Microinverter (controlId 3011).
|
|
1652
1680
|
|
|
1653
1681
|
For **MICRO_INVERTER** devices.
|
|
1654
|
-
"""
|
|
1655
|
-
return await self.set_device_control(device_sn, {3011: "1"})
|
|
1656
|
-
|
|
1657
|
-
async def set_micro_power_off(self, device_sn: str) -> dict:
|
|
1658
|
-
"""Turn off a Microinverter (controlId 3011).
|
|
1659
1682
|
|
|
1660
|
-
|
|
1683
|
+
Args:
|
|
1684
|
+
device_sn: Device serial number.
|
|
1685
|
+
power_on: True to turn on ("1"), False to turn off ("0").
|
|
1661
1686
|
"""
|
|
1662
|
-
return await self.set_device_control(
|
|
1687
|
+
return await self.set_device_control(
|
|
1688
|
+
device_sn, {3011: "1" if power_on else "0"}
|
|
1689
|
+
)
|
|
1663
1690
|
|
|
1664
1691
|
async def set_micro_power_limit(self, device_sn: str, percentage: int) -> dict:
|
|
1665
1692
|
"""Set Maximum Power Limitation for a Microinverter (controlId 3012).
|
|
@@ -117,6 +117,26 @@ async def test_get_all_device_data_retry_exhaustion(monkeypatch, caplog):
|
|
|
117
117
|
assert "HYXI Cloud connection failed after 3 attempts" in caplog.text
|
|
118
118
|
|
|
119
119
|
|
|
120
|
+
@pytest.mark.asyncio
|
|
121
|
+
async def test_get_all_device_data_timeout_error(monkeypatch, caplog):
|
|
122
|
+
"""Test that get_all_device_data exhausts retries on TimeoutError."""
|
|
123
|
+
fake_session = MagicMock()
|
|
124
|
+
api = HyxiApiClient("ak", "sk", "https://api.com", fake_session)
|
|
125
|
+
|
|
126
|
+
# Mock _execute_fetch_all to raise TimeoutError
|
|
127
|
+
api._execute_fetch_all = AsyncMock(side_effect=TimeoutError("Connection timed out"))
|
|
128
|
+
|
|
129
|
+
mock_sleep = AsyncMock()
|
|
130
|
+
monkeypatch.setattr("asyncio.sleep", mock_sleep)
|
|
131
|
+
|
|
132
|
+
result = await api.get_all_device_data()
|
|
133
|
+
|
|
134
|
+
assert result is None
|
|
135
|
+
assert api._execute_fetch_all.call_count == 3
|
|
136
|
+
assert mock_sleep.call_count == 2
|
|
137
|
+
assert "HYXI Cloud connection failed after 3 attempts" in caplog.text
|
|
138
|
+
|
|
139
|
+
|
|
120
140
|
@pytest.mark.asyncio
|
|
121
141
|
async def test_get_all_device_data_auth_failed():
|
|
122
142
|
"""Test that get_all_device_data immediately fails on auth failure."""
|
|
@@ -176,28 +176,19 @@ async def test_set_mode_discharge_invalid_watts():
|
|
|
176
176
|
|
|
177
177
|
|
|
178
178
|
@pytest.mark.asyncio
|
|
179
|
-
async def
|
|
180
|
-
"""Test
|
|
179
|
+
async def test_set_micro_power():
|
|
180
|
+
"""Test set_micro_power sends controlId 3011 with value '1' or '0'."""
|
|
181
181
|
api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
|
|
182
182
|
api._refresh_token = AsyncMock(return_value=True)
|
|
183
183
|
api._request = AsyncMock(return_value=(200, {"success": True}))
|
|
184
184
|
|
|
185
|
-
await api.
|
|
186
|
-
|
|
185
|
+
await api.set_micro_power("SN123", power_on=True)
|
|
187
186
|
call_kwargs = api._request.call_args
|
|
188
187
|
body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
|
|
189
188
|
assert body["deviceControlMap"]["SN123"]["3011"] == "1"
|
|
190
189
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
async def test_set_micro_power_off():
|
|
194
|
-
"""Test set_micro_power_off sends controlId 3011 with value '0'."""
|
|
195
|
-
api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
|
|
196
|
-
api._refresh_token = AsyncMock(return_value=True)
|
|
197
|
-
api._request = AsyncMock(return_value=(200, {"success": True}))
|
|
198
|
-
|
|
199
|
-
await api.set_micro_power_off("SN123")
|
|
200
|
-
|
|
190
|
+
api._request.reset_mock()
|
|
191
|
+
await api.set_micro_power("SN123", power_on=False)
|
|
201
192
|
call_kwargs = api._request.call_args
|
|
202
193
|
body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
|
|
203
194
|
assert body["deviceControlMap"]["SN123"]["3011"] == "0"
|
|
@@ -18,7 +18,7 @@ mock_aiohttp = sys.modules["aiohttp"]
|
|
|
18
18
|
|
|
19
19
|
"""Tests for fetching plants from the API."""
|
|
20
20
|
|
|
21
|
-
from unittest.mock import AsyncMock, MagicMock
|
|
21
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
22
22
|
|
|
23
23
|
import aiohttp
|
|
24
24
|
import pytest
|
|
@@ -87,3 +87,27 @@ async def test_fetch_plants_generic_failure():
|
|
|
87
87
|
assert plants is None
|
|
88
88
|
assert api.token == "Bearer good_token"
|
|
89
89
|
assert api.token_expires_at == 9999999999.0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.mark.asyncio
|
|
93
|
+
async def test_fetch_plants_empty_list_warning():
|
|
94
|
+
"""Verify that _fetch_plants logs a warning when the plant list is empty."""
|
|
95
|
+
api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
|
|
96
|
+
api._request = AsyncMock(
|
|
97
|
+
return_value=(
|
|
98
|
+
200,
|
|
99
|
+
{
|
|
100
|
+
"success": True,
|
|
101
|
+
"data": {"list": []},
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
with patch("src.hyxi_cloud_api.api._LOGGER") as mock_logger:
|
|
107
|
+
plants = await api._fetch_plants()
|
|
108
|
+
assert plants == []
|
|
109
|
+
mock_logger.warning.assert_called_once_with(
|
|
110
|
+
"HYXI API: No plants found associated with this account. "
|
|
111
|
+
"If your developer email differs from your app email, you must share "
|
|
112
|
+
"your Plant from the app to the developer email first."
|
|
113
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/src/hyxi_cloud_api.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hyxi_cloud_api-1.2.2 → hyxi_cloud_api-1.2.4}/tests/test_process_alarms_and_back_discovery.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|