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.
Files changed (48) hide show
  1. {hyxi_cloud_api-1.2.1/src/hyxi_cloud_api.egg-info → hyxi_cloud_api-1.2.3}/PKG-INFO +1 -1
  2. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/pyproject.toml +1 -1
  3. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/src/hyxi_cloud_api/__init__.py +1 -1
  4. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/src/hyxi_cloud_api/api.py +59 -28
  5. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3/src/hyxi_cloud_api.egg-info}/PKG-INFO +1 -1
  6. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_api.py +20 -0
  7. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_device_control.py +5 -14
  8. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/LICENSE +0 -0
  9. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/README.md +0 -0
  10. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/setup.cfg +0 -0
  11. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/src/hyxi_cloud_api.egg-info/SOURCES.txt +0 -0
  12. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/src/hyxi_cloud_api.egg-info/dependency_links.txt +0 -0
  13. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/src/hyxi_cloud_api.egg-info/requires.txt +0 -0
  14. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/src/hyxi_cloud_api.egg-info/top_level.txt +0 -0
  15. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_all_in_one.py +0 -0
  16. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_build_plant_tasks.py +0 -0
  17. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_caching.py +0 -0
  18. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_compute_derived_metrics.py +0 -0
  19. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_device_entry.py +0 -0
  20. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_devices_errors.py +0 -0
  21. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_discovery.py +0 -0
  22. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_execute_device_tasks.py +0 -0
  23. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_execute_metric_tasks.py +0 -0
  24. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_execute_metrics_and_map_alarms.py +0 -0
  25. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_extract_battery_info.py +0 -0
  26. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_extract_device_info_metadata.py +0 -0
  27. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_fetch_and_process_alarms.py +0 -0
  28. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_fetch_device_list_for_plant.py +0 -0
  29. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_fetch_plants.py +0 -0
  30. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_fetch_state.py +0 -0
  31. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_fetch_sub_device_list.py +0 -0
  32. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_filter_metrics.py +0 -0
  33. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_fuzz_parser.py +0 -0
  34. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_get_f.py +0 -0
  35. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_handle_back_discovery_alarm.py +0 -0
  36. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_info_errors.py +0 -0
  37. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_mask_id.py +0 -0
  38. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_metrics_errors.py +0 -0
  39. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_parse_data_list.py +0 -0
  40. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_parse_ems_kv.py +0 -0
  41. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_parser.py +0 -0
  42. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_process_alarms_and_back_discovery.py +0 -0
  43. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_process_devices_for_plant.py +0 -0
  44. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_sanitize_dict.py +0 -0
  45. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_sanitize_list.py +0 -0
  46. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_security_fix.py +0 -0
  47. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/tests/test_token_errors.py +0 -0
  48. {hyxi_cloud_api-1.2.1 → hyxi_cloud_api-1.2.3}/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.2.1
3
+ Version: 1.2.3
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.2.1"
7
+ version = "1.2.3"
8
8
  authors = [
9
9
  { name="Veldkornet", email="Veldkornet@users.noreply.github.com" },
10
10
  ]
@@ -2,5 +2,5 @@
2
2
 
3
3
  from .api import HyxiApiClient
4
4
 
5
- __version__ = "1.2.1"
5
+ __version__ = "1.2.2"
6
6
  __all__ = ["HyxiApiClient"]
@@ -43,7 +43,15 @@ class FetchState:
43
43
 
44
44
 
45
45
  _LOGGER = logging.getLogger(__name__)
46
- _battery_device_types = ("INVERTER", "ESS", "HALO", "1", "15", "16")
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 not _COLLECTOR_FILTER_REGEX.search(k)}
577
+ return {k: v for k, v in m_raw.items() if _is_collector_key_allowed(k)}
563
578
 
564
579
 
565
- def _compute_derived_metrics(m_raw: dict, device_type: str = "") -> dict:
566
- """Calculate derived metrics from raw metrics map.
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
- # 2. Grid Metrics
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
- # 3. Battery Metrics
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 device_type.upper() == "ALL_IN_ONE":
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
- # 4. PV String Powers (Derived if missing)
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
- # 5. Fallback mappings for Micro ESS
626
- # Derive standard Solar Power (ppv) from Micro ESS pvPower if ppv is missing
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 set_micro_power_on(self, device_sn: str) -> dict:
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
- async def set_micro_power_off(self, device_sn: str) -> dict:
1649
- """Turn off a Microinverter (controlId 3011).
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(device_sn, {3011: "0"})
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).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyxi-cloud-api
3
- Version: 1.2.1
3
+ Version: 1.2.3
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
@@ -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 test_set_micro_power_on():
180
- """Test set_micro_power_on sends controlId 3011 with value '1'."""
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.set_micro_power_on("SN123")
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
- @pytest.mark.asyncio
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