hyxi-cloud-api 1.2.1__tar.gz → 1.2.3__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.1/src/hyxi_cloud_api.egg-info → hyxi_cloud_api-1.2.3}/PKG-INFO +1 -1
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/pyproject.toml +1 -1
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/src/hyxi_cloud_api/__init__.py +1 -1
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/src/hyxi_cloud_api/api.py +59 -28
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3/src/hyxi_cloud_api.egg-info}/PKG-INFO +1 -1
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_api.py +20 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_device_control.py +5 -14
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/LICENSE +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/README.md +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/setup.cfg +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/src/hyxi_cloud_api.egg-info/SOURCES.txt +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/src/hyxi_cloud_api.egg-info/dependency_links.txt +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/src/hyxi_cloud_api.egg-info/requires.txt +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/src/hyxi_cloud_api.egg-info/top_level.txt +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_all_in_one.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_build_plant_tasks.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_caching.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_compute_derived_metrics.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_device_entry.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_devices_errors.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_discovery.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_execute_device_tasks.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_execute_metric_tasks.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_execute_metrics_and_map_alarms.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_extract_battery_info.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_extract_device_info_metadata.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_fetch_and_process_alarms.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_fetch_device_list_for_plant.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_fetch_plants.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_fetch_state.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_fetch_sub_device_list.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_filter_metrics.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_fuzz_parser.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_get_f.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_handle_back_discovery_alarm.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_info_errors.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_mask_id.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_metrics_errors.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_parse_data_list.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_parse_ems_kv.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_parser.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_process_alarms_and_back_discovery.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_process_devices_for_plant.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_sanitize_dict.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_sanitize_list.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_security_fix.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_token_errors.py +0 -0
- {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_token_handling.py +0 -0
|
@@ -43,7 +43,15 @@ class FetchState:
|
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
_LOGGER = logging.getLogger(__name__)
|
|
46
|
-
_battery_device_types = (
|
|
46
|
+
_battery_device_types = (
|
|
47
|
+
"INVERTER",
|
|
48
|
+
"ESS",
|
|
49
|
+
"HALO",
|
|
50
|
+
"1",
|
|
51
|
+
"15",
|
|
52
|
+
"16",
|
|
53
|
+
"MICRO_STORAGE_ALL_IN_ONE",
|
|
54
|
+
)
|
|
47
55
|
_BATTERY_DEVICE_REGEX = re.compile("|".join(_battery_device_types))
|
|
48
56
|
_parent_device_types = ("COLLECTOR", "DMU", "INVERTER", "ALL_IN_ONE")
|
|
49
57
|
_PARENT_DEVICE_REGEX = re.compile("|".join(_parent_device_types))
|
|
@@ -502,6 +510,7 @@ DEVICE_TYPE_MAP = {
|
|
|
502
510
|
"ENERGY_STORAGE_BATTERY": "Battery",
|
|
503
511
|
"ALL_IN_ONE": "all-in-one machine",
|
|
504
512
|
"AC_BATTERY": "AC Battery",
|
|
513
|
+
"MICRO_STORAGE_ALL_IN_ONE": "Micro ESS",
|
|
505
514
|
# Official Numeric IDs (as seen in getSubDevicePage)
|
|
506
515
|
"1": "Hybrid Inverter",
|
|
507
516
|
"2": "Grid-Connected Inverter",
|
|
@@ -557,20 +566,19 @@ def _get_f(key: str, data_map: dict, mult: float = 1.0) -> float:
|
|
|
557
566
|
return 0.0
|
|
558
567
|
|
|
559
568
|
|
|
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)
|
|
573
|
+
|
|
574
|
+
|
|
560
575
|
def _filter_collector_metrics(m_raw: dict) -> dict:
|
|
561
576
|
"""Remove battery/power metrics that shouldn't be present on Collectors."""
|
|
562
|
-
return {k: v for k, v in m_raw.items() if
|
|
577
|
+
return {k: v for k, v in m_raw.items() if _is_collector_key_allowed(k)}
|
|
563
578
|
|
|
564
579
|
|
|
565
|
-
def
|
|
566
|
-
"""Calculate
|
|
567
|
-
|
|
568
|
-
Only keys that have relevant base data in m_raw will be included in the
|
|
569
|
-
resulting dictionary to avoid 'ghost' sensors for unsupported features.
|
|
570
|
-
"""
|
|
571
|
-
derived = {}
|
|
572
|
-
|
|
573
|
-
# 1. Load Calculation
|
|
580
|
+
def _compute_load_metrics(m_raw: dict, derived: dict[str, float]) -> None:
|
|
581
|
+
"""Calculate home load metrics."""
|
|
574
582
|
if "ph1Loadp" in m_raw or "ph2Loadp" in m_raw or "ph3Loadp" in m_raw:
|
|
575
583
|
derived["home_load"] = (
|
|
576
584
|
_get_f("ph1Loadp", m_raw)
|
|
@@ -578,21 +586,28 @@ def _compute_derived_metrics(m_raw: dict, device_type: str = "") -> dict:
|
|
|
578
586
|
+ _get_f("ph3Loadp", m_raw)
|
|
579
587
|
)
|
|
580
588
|
|
|
581
|
-
|
|
589
|
+
|
|
590
|
+
def _compute_grid_metrics(m_raw: dict, derived: dict[str, float]) -> None:
|
|
591
|
+
"""Calculate grid import/export metrics."""
|
|
582
592
|
if "gridP" in m_raw:
|
|
583
593
|
grid = _get_f("gridP", m_raw, 1000.0)
|
|
584
594
|
derived["grid_import"] = abs(grid) if grid < 0 else 0.0
|
|
585
595
|
derived["grid_export"] = grid if grid > 0 else 0.0
|
|
586
596
|
|
|
587
|
-
|
|
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."""
|
|
588
602
|
bat_p_dc = _get_f("batP", m_raw)
|
|
589
603
|
pbat = _get_f("pbat", m_raw)
|
|
604
|
+
device_type_str = str(device_type or "").upper()
|
|
590
605
|
|
|
591
|
-
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"):
|
|
592
607
|
# ALL_IN_ONE: prefer pbat — batP can have an inverted sign convention,
|
|
593
608
|
# while pbat is consistently negative-for-charging / positive-for-discharging.
|
|
594
609
|
# Other devices: prefer batP (DC terminals), fall back to pbat.
|
|
595
|
-
if
|
|
610
|
+
if device_type_str == "ALL_IN_ONE":
|
|
596
611
|
power_source = pbat if pbat != 0.0 else bat_p_dc
|
|
597
612
|
else:
|
|
598
613
|
power_source = bat_p_dc if bat_p_dc != 0.0 else pbat
|
|
@@ -605,7 +620,9 @@ def _compute_derived_metrics(m_raw: dict, device_type: str = "") -> dict:
|
|
|
605
620
|
if "batDisCharge" in m_raw:
|
|
606
621
|
derived["bat_discharge_total"] = _get_f("batDisCharge", m_raw)
|
|
607
622
|
|
|
608
|
-
|
|
623
|
+
|
|
624
|
+
def _compute_pv_metrics(m_raw: dict, derived: dict[str, float]) -> None:
|
|
625
|
+
"""Calculate PV string powers."""
|
|
609
626
|
for v_k, i_k, p_k in _PV_KEYS:
|
|
610
627
|
if v_k in m_raw or i_k in m_raw or p_k in m_raw:
|
|
611
628
|
derived[p_k] = _get_f(p_k, m_raw) or round(
|
|
@@ -622,15 +639,30 @@ def _compute_derived_metrics(m_raw: dict, device_type: str = "") -> dict:
|
|
|
622
639
|
ppv_total = _get_f("ppv", m_raw)
|
|
623
640
|
derived["pv1p"] = round(max(ppv_total - derived["pv2p"], 0), 2)
|
|
624
641
|
|
|
625
|
-
|
|
626
|
-
|
|
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."""
|
|
627
645
|
if "pvPower" in m_raw and "ppv" not in m_raw:
|
|
628
646
|
derived["ppv"] = _get_f("pvPower", m_raw)
|
|
629
647
|
|
|
630
|
-
# Derive standard Grid Frequency (f) from Micro ESS gridF if f is missing
|
|
631
648
|
if "gridF" in m_raw and "f" not in m_raw:
|
|
632
649
|
derived["f"] = _get_f("gridF", m_raw)
|
|
633
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
|
+
|
|
634
666
|
return derived
|
|
635
667
|
|
|
636
668
|
|
|
@@ -1638,19 +1670,18 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
|
|
|
1638
1670
|
|
|
1639
1671
|
# ── Microinverter Controls ───────────────────────────────────────────
|
|
1640
1672
|
|
|
1641
|
-
async def
|
|
1642
|
-
"""Turn on a Microinverter (controlId 3011).
|
|
1673
|
+
async def set_micro_power(self, device_sn: str, power_on: bool) -> dict:
|
|
1674
|
+
"""Turn on or off a Microinverter (controlId 3011).
|
|
1643
1675
|
|
|
1644
1676
|
For **MICRO_INVERTER** devices.
|
|
1645
|
-
"""
|
|
1646
|
-
return await self.set_device_control(device_sn, {3011: "1"})
|
|
1647
1677
|
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
For **MICRO_INVERTER** devices.
|
|
1678
|
+
Args:
|
|
1679
|
+
device_sn: Device serial number.
|
|
1680
|
+
power_on: True to turn on ("1"), False to turn off ("0").
|
|
1652
1681
|
"""
|
|
1653
|
-
return await self.set_device_control(
|
|
1682
|
+
return await self.set_device_control(
|
|
1683
|
+
device_sn, {3011: "1" if power_on else "0"}
|
|
1684
|
+
)
|
|
1654
1685
|
|
|
1655
1686
|
async def set_micro_power_limit(self, device_sn: str, percentage: int) -> dict:
|
|
1656
1687
|
"""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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/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
|
|
File without changes
|
{hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/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
|