hyxi-cloud-api 1.3.9__tar.gz → 1.4.0__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 (62) hide show
  1. {hyxi_cloud_api-1.3.9/src/hyxi_cloud_api.egg-info → hyxi_cloud_api-1.4.0}/PKG-INFO +1 -1
  2. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/pyproject.toml +1 -1
  3. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/src/hyxi_cloud_api/__init__.py +1 -1
  4. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/src/hyxi_cloud_api/api.py +131 -79
  5. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0/src/hyxi_cloud_api.egg-info}/PKG-INFO +1 -1
  6. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/src/hyxi_cloud_api.egg-info/SOURCES.txt +2 -0
  7. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_api.py +117 -4
  8. hyxi_cloud_api-1.4.0/tests/test_api_imports.py +33 -0
  9. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_caching.py +3 -4
  10. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_devices_errors.py +21 -1
  11. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_discovery.py +28 -3
  12. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_fetch_plants.py +2 -2
  13. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_fetch_sub_device_list.py +19 -0
  14. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_get_f.py +10 -0
  15. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_metrics_errors.py +49 -3
  16. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_push.py +2 -2
  17. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_sanitize_dict.py +9 -9
  18. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_sanitize_list.py +2 -2
  19. hyxi_cloud_api-1.4.0/tests/test_set_log_salt.py +55 -0
  20. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/LICENSE +0 -0
  21. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/README.md +0 -0
  22. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/setup.cfg +0 -0
  23. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/src/hyxi_cloud_api.egg-info/dependency_links.txt +0 -0
  24. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/src/hyxi_cloud_api.egg-info/requires.txt +0 -0
  25. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/src/hyxi_cloud_api.egg-info/top_level.txt +0 -0
  26. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_additional_coverage.py +0 -0
  27. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_alarm_push.py +0 -0
  28. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_all_in_one.py +0 -0
  29. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_build_plant_tasks.py +0 -0
  30. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_compute_battery_metrics.py +0 -0
  31. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_compute_derived_metrics.py +0 -0
  32. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_compute_grid_metrics.py +0 -0
  33. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_compute_load_metrics.py +0 -0
  34. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_compute_pv_metrics.py +0 -0
  35. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_device_control.py +0 -0
  36. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_device_entry.py +0 -0
  37. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_ensure_authenticated.py +0 -0
  38. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_execute_device_tasks.py +0 -0
  39. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_execute_metric_tasks.py +0 -0
  40. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_execute_metrics_and_map_alarms.py +0 -0
  41. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_extract_battery_info.py +0 -0
  42. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_extract_device_info_metadata.py +0 -0
  43. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_fetch_and_process_alarms.py +0 -0
  44. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_fetch_device_list_for_plant.py +0 -0
  45. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_fetch_state.py +0 -0
  46. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_filter_metrics.py +0 -0
  47. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_flatten_nested_push_device.py +0 -0
  48. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_fuzz_parser.py +0 -0
  49. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_handle_back_discovery_alarm.py +0 -0
  50. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_info_errors.py +0 -0
  51. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_mask_id.py +0 -0
  52. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_parse_data_list.py +0 -0
  53. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_parse_ems_kv.py +0 -0
  54. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_parser.py +0 -0
  55. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_process_alarms_and_back_discovery.py +0 -0
  56. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_process_devices_for_plant.py +0 -0
  57. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_security_fix.py +0 -0
  58. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_subscriptions.py +0 -0
  59. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_token_errors.py +0 -0
  60. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_token_handling.py +0 -0
  61. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_validate_post_rate_ms.py +0 -0
  62. {hyxi_cloud_api-1.3.9 → hyxi_cloud_api-1.4.0}/tests/test_validate_subscription_device_sns.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyxi-cloud-api
3
- Version: 1.3.9
3
+ Version: 1.4.0
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.9"
7
+ version = "1.4.0"
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.9"
8
+ __version__ = "1.4.0"
9
9
  __all__ = ["VPP_ACTIVE_MODES", "HyxiApiClient"]
@@ -25,10 +25,9 @@ from urllib.parse import urlparse
25
25
  try:
26
26
  from datetime import UTC, datetime
27
27
  except ImportError:
28
- from datetime import datetime
28
+ from datetime import datetime, timezone
29
29
 
30
- # pylint: disable=W0127,E0601
31
- UTC = UTC
30
+ UTC = timezone.utc # noqa
32
31
 
33
32
  import aiohttp
34
33
 
@@ -45,18 +44,41 @@ class FetchState:
45
44
 
46
45
 
47
46
  _LOGGER = logging.getLogger(__name__)
48
- _battery_device_types = (
49
- "INVERTER",
50
- "ESS",
51
- "HALO",
52
- "1",
53
- "15",
54
- "16",
55
- "MICRO_STORAGE_ALL_IN_ONE",
47
+ _battery_device_types = frozenset(
48
+ (
49
+ "INVERTER",
50
+ "HYBRID_INVERTER",
51
+ "STRING_INVERTER",
52
+ "MICRO_INVERTER",
53
+ "ESS",
54
+ "HALO",
55
+ "1",
56
+ "15",
57
+ "16",
58
+ "MICRO_STORAGE_ALL_IN_ONE",
59
+ )
60
+ )
61
+ _parent_device_types = frozenset(
62
+ (
63
+ "COLLECTOR",
64
+ "DMU",
65
+ "INVERTER",
66
+ "HYBRID_INVERTER",
67
+ "STRING_INVERTER",
68
+ "MICRO_INVERTER",
69
+ "ALL_IN_ONE",
70
+ "MICRO_STORAGE_ALL_IN_ONE",
71
+ )
72
+ )
73
+ _EMS_DEVICE_TYPES = frozenset(
74
+ (
75
+ "EMS",
76
+ "ENERGY_STORAGE_BATTERY",
77
+ "MICRO_STORAGE_ALL_IN_ONE",
78
+ "15",
79
+ "16",
80
+ )
56
81
  )
57
- _BATTERY_DEVICE_REGEX = re.compile("|".join(_battery_device_types))
58
- _parent_device_types = ("COLLECTOR", "DMU", "INVERTER", "ALL_IN_ONE")
59
- _PARENT_DEVICE_REGEX = re.compile("|".join(_parent_device_types))
60
82
  _COLLECTOR_FILTER_KEYWORDS = (
61
83
  "bat",
62
84
  "pv",
@@ -564,9 +586,7 @@ def _get_f(key: str, data_map: dict, mult: float = 1.0) -> float:
564
586
  if val is None or val == "":
565
587
  return 0.0
566
588
  return round(float(val) * mult, 2)
567
- except ValueError:
568
- return 0.0
569
- except TypeError:
589
+ except ValueError, TypeError:
570
590
  return 0.0
571
591
 
572
592
 
@@ -625,7 +645,7 @@ def _compute_battery_metrics(
625
645
  pbat = _get_f("pbat", m_raw)
626
646
  device_type_str = str(device_type or "")
627
647
 
628
- if "batP" in m_raw or "pbat" in m_raw or device_type_str in ("EMS", "15", "16"):
648
+ if "batP" in m_raw or "pbat" in m_raw or device_type_str in _EMS_DEVICE_TYPES:
629
649
  # ALL_IN_ONE: prefer pbat — batP can have an inverted sign convention,
630
650
  # while pbat is consistently negative-for-charging / positive-for-discharging.
631
651
  # Other devices: prefer batP (DC terminals), fall back to pbat.
@@ -732,6 +752,60 @@ def _compute_derived_metrics(m_raw: dict, device_type: str = "") -> dict:
732
752
  return derived
733
753
 
734
754
 
755
+ def _resolve_push_timestamp(device: dict[str, Any], now_utc: datetime) -> datetime:
756
+ """Determine the last_seen timestamp from collectTime or reportTimestamp."""
757
+ collect_time = device.get("collectTime")
758
+ report_ts = device.get("reportTimestamp")
759
+
760
+ if collect_time is not None:
761
+ try:
762
+ return datetime.fromtimestamp(float(collect_time), UTC)
763
+ except ValueError, TypeError, OverflowError, OSError:
764
+ pass
765
+
766
+ if report_ts is not None:
767
+ try:
768
+ return datetime.fromtimestamp(float(report_ts) / 1000.0, UTC)
769
+ except ValueError, TypeError, OverflowError, OSError:
770
+ pass
771
+
772
+ return now_utc
773
+
774
+
775
+ def _extract_raw_push_metrics(device: dict[str, Any]) -> dict[str, Any]:
776
+ """Extract metrics from flat payload dictionary, stripping metadata/routing keys."""
777
+ raw_metrics: dict[str, Any] = {}
778
+ for k, v in device.items():
779
+ if k in _METRICS_EXCLUDED_KEYS:
780
+ continue
781
+ raw_metrics[k] = v
782
+ return raw_metrics
783
+
784
+
785
+ def _merge_push_metrics(
786
+ sn: str,
787
+ raw_metrics: dict[str, Any],
788
+ device_type: str,
789
+ existing_metrics: dict[str, dict[str, Any]] | None,
790
+ ) -> dict[str, Any]:
791
+ """Filter collector metrics and merge with existing metrics."""
792
+ if device_type == "COLLECTOR":
793
+ metrics_to_update = _filter_collector_metrics(raw_metrics)
794
+ else:
795
+ metrics_to_update = raw_metrics.copy()
796
+
797
+ merged_metrics: dict[str, Any] = {}
798
+ if existing_metrics and sn in existing_metrics:
799
+ # Copy existing to avoid mutating caller's dictionary directly
800
+ merged_metrics = dict(existing_metrics[sn])
801
+
802
+ for k, v in metrics_to_update.items():
803
+ if v is not None:
804
+ merged_metrics[k] = v
805
+
806
+ return merged_metrics
807
+
808
+
735
809
  def _flatten_nested_push_device(device: dict) -> dict: # pylint: disable=too-many-statements
736
810
  """Flatten a nested push device payload into the flat layout expected by the SDK."""
737
811
  flat: dict = {}
@@ -751,21 +825,31 @@ def _flatten_nested_push_device(device: dict) -> dict: # pylint: disable=too-ma
751
825
  if "deviceState" in rec:
752
826
  flat["deviceState"] = rec["deviceState"]
753
827
 
754
- # Copy root device keys that might already be flat
755
- for k in ("deviceSn", "collectTime", "reportTimestamp"):
756
- if k in device and device[k] is not None:
757
- val = device[k]
758
- if k == "collectTime":
828
+ # Map discrepancies between Flat Push payload and Flat Pull payload
829
+ _key_map = {
830
+ "gridEin": "totalEnt",
831
+ "gridEIn": "totalEnt",
832
+ "gridEout": "totalEpt",
833
+ "gridEOut": "totalEpt",
834
+ }
835
+
836
+ # Copy all root-level keys that are primitive types (support new flat format)
837
+ for k, v in device.items():
838
+ if not isinstance(v, dict) and not isinstance(v, list) and v is not None:
839
+ # Normalize push metric names to match pull metric names
840
+ mapped_k = _key_map.get(k, k)
841
+
842
+ if mapped_k == "collectTime":
759
843
  try:
760
- num_val = float(val)
844
+ num_val = float(v)
761
845
  if num_val > 10000000000:
762
- flat[k] = num_val / 1000.0
846
+ flat[mapped_k] = num_val / 1000.0
763
847
  else:
764
- flat[k] = val
848
+ flat[mapped_k] = v
765
849
  except ValueError, TypeError:
766
- flat[k] = val
850
+ flat[mapped_k] = v
767
851
  else:
768
- flat[k] = val
852
+ flat[mapped_k] = v
769
853
 
770
854
  # 2. system
771
855
  if "system" in device and isinstance(device["system"], dict):
@@ -888,14 +972,15 @@ def _flatten_nested_push_device(device: dict) -> dict: # pylint: disable=too-ma
888
972
  if "powerFactor" in grd:
889
973
  flat["gridPfd"] = grd["powerFactor"]
890
974
  if "energyInKwh" in grd:
891
- flat["gridEIn"] = grd["energyInKwh"]
975
+ flat["totalEnt"] = grd["energyInKwh"]
892
976
  if "energyOutKwh" in grd:
893
- flat["gridEOut"] = grd["energyOutKwh"]
977
+ flat["totalEpt"] = grd["energyOutKwh"]
894
978
 
895
979
  # Copy any other keys at the root that aren't dictionaries
896
980
  for k, v in device.items():
897
981
  if k not in flat and not isinstance(v, dict):
898
- flat[k] = v
982
+ mapped_k = _key_map.get(k, k)
983
+ flat[mapped_k] = v
899
984
 
900
985
  return flat
901
986
 
@@ -1294,7 +1379,7 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1294
1379
  }
1295
1380
 
1296
1381
  device_type_code = entry.get("device_type_code", "")
1297
- if _BATTERY_DEVICE_REGEX.search(device_type_code):
1382
+ if device_type_code in _battery_device_types:
1298
1383
  base_info.update(HyxiApiClient._extract_battery_info(i_raw))
1299
1384
 
1300
1385
  entry["metrics"].update(base_info)
@@ -1343,8 +1428,11 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1343
1428
  ems_task = None
1344
1429
  if not is_comm_unit:
1345
1430
  tasks.append(asyncio.create_task(self._fetch_device_metrics(sn, entry)))
1346
- ems_task = asyncio.create_task(self.query_ems_basic_details(sn))
1347
- tasks.append(ems_task)
1431
+
1432
+ actual_type = entry.get("device_type_code", dev_type)
1433
+ if actual_type in _EMS_DEVICE_TYPES:
1434
+ ems_task = asyncio.create_task(self.query_ems_basic_details(sn))
1435
+ tasks.append(ems_task)
1348
1436
 
1349
1437
  # Wait for them to finish
1350
1438
  if tasks:
@@ -1425,7 +1513,7 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1425
1513
  state.metric_tasks.append(self._fetch_all_for_device(sn, entry, dev_type))
1426
1514
 
1427
1515
  # 🚀 DEEP DISCOVERY: If this is a Collector, DMU, or Inverter, find its children!
1428
- if _PARENT_DEVICE_REGEX.search(dev_type):
1516
+ if dev_type in _parent_device_types:
1429
1517
  _LOGGER.debug(
1430
1518
  "HYXI Parent Device Found: %s (%s). Probing for sub-devices...",
1431
1519
  _mask_id(sn),
@@ -1698,7 +1786,7 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
1698
1786
  state.metric_tasks.append(self._fetch_all_for_device(sn, entry, dev_type))
1699
1787
 
1700
1788
  # 🚀 DEEP BACK-DISCOVERY: If this is a parent, search for ITS children too!
1701
- if _PARENT_DEVICE_REGEX.search(dev_type):
1789
+ if dev_type in _parent_device_types:
1702
1790
  sub_device_tasks.append(self._fetch_sub_devices(sn, state))
1703
1791
 
1704
1792
  async def _process_alarms_and_back_discovery(
@@ -2195,7 +2283,7 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
2195
2283
  _LOGGER.warning("HYXI Push: dataList is missing or not a list")
2196
2284
  return {}
2197
2285
 
2198
- now_utc = datetime.now(UTC).isoformat()
2286
+ now_utc = datetime.now(UTC)
2199
2287
  results = {}
2200
2288
 
2201
2289
  for device in data_list:
@@ -2208,53 +2296,17 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes
2208
2296
  if not sn:
2209
2297
  continue
2210
2298
 
2211
- # Extract metrics from flat payload dictionary
2212
- # Strip metadata/routing keys
2213
- raw_metrics = {}
2214
- for k, v in device.items():
2215
- if k in _METRICS_EXCLUDED_KEYS:
2216
- continue
2217
- raw_metrics[k] = v
2218
-
2219
2299
  # Retrieve info from discovery cache
2220
2300
  device_info = self._discovery_cache.get("device_info", {}).get(sn, {})
2221
2301
  device_type = str(device_info.get("device_type_code") or "")
2222
2302
 
2223
- # Handle collectTime / reportTimestamp to resolve last_seen
2224
- last_seen = now_utc
2225
- collect_time = device.get("collectTime")
2226
- report_ts = device.get("reportTimestamp")
2227
-
2228
- if collect_time is not None:
2229
- try:
2230
- last_seen = datetime.fromtimestamp(
2231
- float(collect_time), UTC
2232
- ).isoformat()
2233
- except ValueError, TypeError:
2234
- pass
2235
- elif report_ts is not None:
2236
- try:
2237
- last_seen = datetime.fromtimestamp(
2238
- float(report_ts) / 1000.0, UTC
2239
- ).isoformat()
2240
- except ValueError, TypeError:
2241
- pass
2303
+ raw_metrics = _extract_raw_push_metrics(device)
2304
+ last_seen = _resolve_push_timestamp(device, now_utc)
2242
2305
 
2243
- # Filter collector metrics
2244
- if device_type == "COLLECTOR":
2245
- metrics_to_update = _filter_collector_metrics(raw_metrics)
2246
- else:
2247
- metrics_to_update = raw_metrics.copy()
2248
- # Merge with existing metrics if provided, ignoring None/null values
2249
- merged_metrics = {}
2250
- if existing_metrics and sn in existing_metrics:
2251
- # Copy existing to avoid mutating caller's dictionary directly
2252
- merged_metrics = dict(existing_metrics[sn])
2253
-
2254
- for k, v in metrics_to_update.items():
2255
- if v is not None:
2256
- merged_metrics[k] = v
2257
- merged_metrics["last_seen"] = last_seen
2306
+ merged_metrics = _merge_push_metrics(
2307
+ sn, raw_metrics, device_type, existing_metrics
2308
+ )
2309
+ merged_metrics["last_seen"] = last_seen.isoformat()
2258
2310
 
2259
2311
  # Compute derived metrics on the full merged dataset
2260
2312
  derived = _compute_derived_metrics(merged_metrics, device_type)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyxi-cloud-api
3
- Version: 1.3.9
3
+ Version: 1.4.0
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
@@ -12,6 +12,7 @@ tests/test_additional_coverage.py
12
12
  tests/test_alarm_push.py
13
13
  tests/test_all_in_one.py
14
14
  tests/test_api.py
15
+ tests/test_api_imports.py
15
16
  tests/test_build_plant_tasks.py
16
17
  tests/test_caching.py
17
18
  tests/test_compute_battery_metrics.py
@@ -51,6 +52,7 @@ tests/test_push.py
51
52
  tests/test_sanitize_dict.py
52
53
  tests/test_sanitize_list.py
53
54
  tests/test_security_fix.py
55
+ tests/test_set_log_salt.py
54
56
  tests/test_subscriptions.py
55
57
  tests/test_token_errors.py
56
58
  tests/test_token_handling.py
@@ -281,7 +281,7 @@ async def test_fetch_ems_basic_data_success(caplog):
281
281
  ems_sn = "10602251600016"
282
282
  entry = {"device_type_code": "EMS", "metrics": {"existing_metric": "value"}}
283
283
 
284
- await api._fetch_all_for_device(ems_sn, entry, "INVERTER")
284
+ await api._fetch_all_for_device(ems_sn, entry, "EMS")
285
285
 
286
286
  # Assert query_ems_basic_details was called
287
287
  api.query_ems_basic_details.assert_called_once_with(ems_sn)
@@ -306,7 +306,7 @@ async def test_fetch_ems_basic_data_no_data(caplog):
306
306
  ems_sn = "EMS123"
307
307
  entry = {"device_type_code": "EMS", "metrics": {"existing_metric": "value"}}
308
308
 
309
- await api._fetch_all_for_device(ems_sn, entry, "INVERTER")
309
+ await api._fetch_all_for_device(ems_sn, entry, "EMS")
310
310
 
311
311
  # Assert query_ems_basic_details was called
312
312
  api.query_ems_basic_details.assert_called_once_with("EMS123")
@@ -497,7 +497,7 @@ async def test_fetch_all_for_device_collector():
497
497
  async def test_fetch_all_for_device_non_collector():
498
498
  """Test _fetch_all_for_device when dev_type is not COLLECTOR.
499
499
 
500
- Non-collector devices (e.g. INVERTER) trigger device info, metrics, and EMS
500
+ Non-collector devices (e.g. INVERTER) trigger device info and metrics
501
501
  telemetry probing concurrently. query_ems_basic_details must be mocked to
502
502
  prevent unawaited coroutine RuntimeWarnings from the real HTTP path.
503
503
  """
@@ -520,7 +520,22 @@ async def test_fetch_all_for_device_non_collector():
520
520
 
521
521
  api._fetch_device_info.assert_called_once_with(sn, entry)
522
522
  api._fetch_device_metrics.assert_called_once_with(sn, entry)
523
- api.query_ems_basic_details.assert_called_once_with(sn)
523
+ api.query_ems_basic_details.assert_not_called()
524
+
525
+ # Reset mocks before verifying EMS-capable device behaviour
526
+ api._fetch_device_info.reset_mock()
527
+ api._fetch_device_metrics.reset_mock()
528
+ api.query_ems_basic_details.reset_mock()
529
+
530
+ # EMS-capable non-collector devices should still trigger the EMS probe
531
+ ems_sn = "EMS123"
532
+ ems_entry = {"device_type_code": "EMS", "metrics": {"existing_metric": "value"}}
533
+
534
+ await api._fetch_all_for_device(ems_sn, ems_entry, "EMS")
535
+
536
+ api._fetch_device_info.assert_called_once_with(ems_sn, ems_entry)
537
+ api._fetch_device_metrics.assert_called_once_with(ems_sn, ems_entry)
538
+ api.query_ems_basic_details.assert_called_once_with(ems_sn)
524
539
 
525
540
 
526
541
  # --- TEST 6: Empty Data Response (The "Halo ESS" Scenario) ---
@@ -689,3 +704,101 @@ async def test_execute_fetch_all_force_discovery_integration():
689
704
  # Ensure we successfully parsed the data, meaning full discovery worked
690
705
  assert "device_1" in result
691
706
  assert result["device_1"]["sw_version"] == "v1.0"
707
+
708
+
709
+ @pytest.mark.asyncio
710
+ async def test_get_all_device_data_unexpected_error():
711
+ """Test that get_all_device_data propagates unexpected errors."""
712
+ fake_session = MagicMock()
713
+ api = HyxiApiClient("ak", "sk", "https://api.com", fake_session)
714
+
715
+ # Mock _execute_fetch_all to raise Exception
716
+ api._execute_fetch_all = AsyncMock(side_effect=Exception("Unexpected Error"))
717
+
718
+ with pytest.raises(Exception, match="Unexpected Error"):
719
+ await api.get_all_device_data()
720
+
721
+
722
+ @pytest.mark.asyncio
723
+ async def test_process_push_data_flat_payload():
724
+ """Test process_push_data correctly handles flat payloads and normalizes grid keys."""
725
+ fake_session = MagicMock()
726
+ api = HyxiApiClient("ak", "sk", "https://api.com", fake_session)
727
+ api.devices_cache = {
728
+ "SN_FLAT": {"device_sn": "SN_FLAT", "metrics": {"oldMetric": "old"}}
729
+ }
730
+
731
+ # Simulate flat payload from webhook
732
+ payload = {
733
+ "dataList": [
734
+ {
735
+ "deviceSn": "SN_FLAT",
736
+ "batP": 123,
737
+ "gridEin": 456,
738
+ "gridEout": 789,
739
+ "reportTimestamp": 1000000000000,
740
+ }
741
+ ]
742
+ }
743
+ existing = {"SN_FLAT": {"oldMetric": "old"}}
744
+
745
+ results = api.process_push_data(payload, existing_metrics=existing)
746
+ assert "SN_FLAT" in results
747
+ metrics = results["SN_FLAT"]["metrics"]
748
+ assert metrics["batP"] == 123
749
+ assert metrics["oldMetric"] == "old" # preserves existing
750
+ assert metrics["totalEnt"] == 456 # validates flat key mapping
751
+ assert metrics["totalEpt"] == 789
752
+ assert "last_seen" in metrics
753
+
754
+
755
+ @pytest.mark.asyncio
756
+ async def test_process_push_data_nested_payload():
757
+ """Test process_push_data handles legacy nested payloads and normalizes nested grid keys."""
758
+ fake_session = MagicMock()
759
+ api = HyxiApiClient("ak", "sk", "https://api.com", fake_session)
760
+
761
+ payload = {
762
+ "dataList": [
763
+ {
764
+ "deviceSn": "SN_NEST",
765
+ "battery": {"powerW": 123},
766
+ "grid": {"energyInKwh": 456, "energyOutKwh": 789},
767
+ "reportTimestamp": 1000000000000,
768
+ }
769
+ ]
770
+ }
771
+ existing = {"SN_NEST": {"old": "old"}}
772
+
773
+ results = api.process_push_data(payload, existing_metrics=existing)
774
+ assert "SN_NEST" in results
775
+ metrics = results["SN_NEST"]["metrics"]
776
+ assert metrics["batP"] == 123
777
+ assert metrics["totalEnt"] == 456
778
+ assert metrics["totalEpt"] == 789
779
+ assert metrics["old"] == "old"
780
+
781
+
782
+ @pytest.mark.asyncio
783
+ async def test_process_push_data_timestamp_fallback():
784
+ """Test process_push_data uses fallback timestamps when invalid."""
785
+ fake_session = MagicMock()
786
+ api = HyxiApiClient("ak", "sk", "https://api.com", fake_session)
787
+
788
+ payload = {
789
+ "dataList": [
790
+ {
791
+ "deviceSn": "SN_TIME",
792
+ "batP": 123,
793
+ "reportTimestamp": "INVALID",
794
+ "collectTime": "INVALID_TOO",
795
+ }
796
+ ]
797
+ }
798
+
799
+ # Should not raise exception
800
+ results = api.process_push_data(payload, existing_metrics={"SN_TIME": {}})
801
+ assert "SN_TIME" in results
802
+ metrics = results["SN_TIME"]["metrics"]
803
+ assert "last_seen" in metrics
804
+ assert metrics["batP"] == 123
@@ -0,0 +1,33 @@
1
+ """Tests for import fallbacks in the API client module."""
2
+
3
+ import builtins
4
+ import sys
5
+ import unittest.mock
6
+
7
+
8
+ def test_utc_import_fallback():
9
+ """Test the ImportError fallback for datetime.UTC at line 27."""
10
+ original_import = builtins.__import__
11
+
12
+ def mocked_import(name, global_vars=None, local_vars=None, fromlist=(), level=0):
13
+ # Trigger ImportError when trying to import UTC from datetime
14
+ if name == "datetime" and fromlist and "UTC" in fromlist:
15
+ raise ImportError("mocked ImportError for UTC")
16
+ return original_import(name, global_vars, local_vars, fromlist, level)
17
+
18
+ original_api = sys.modules.get("hyxi_cloud_api.api")
19
+ if "hyxi_cloud_api.api" in sys.modules:
20
+ del sys.modules["hyxi_cloud_api.api"]
21
+
22
+ try:
23
+ with unittest.mock.patch("builtins.__import__", side_effect=mocked_import):
24
+ from hyxi_cloud_api import api # pylint: disable=import-outside-toplevel
25
+
26
+ assert api.UTC is not None
27
+ assert api.UTC.tzname(None) == "UTC"
28
+ finally:
29
+ # Restore normal module state
30
+ if "hyxi_cloud_api.api" in sys.modules:
31
+ del sys.modules["hyxi_cloud_api.api"]
32
+ if original_api is not None:
33
+ sys.modules["hyxi_cloud_api.api"] = original_api
@@ -53,14 +53,13 @@ async def test_discovery_caching_logic():
53
53
  # First call: Full Discovery
54
54
  res1 = await client.get_all_device_data()
55
55
  assert res1["data"]["S1"]["sw_version"] == "V1"
56
- assert mock_req.call_count == 7
56
+ assert mock_req.call_count == 6
57
57
 
58
58
  # Second call: Should use cache (Fast Poll)
59
59
  # Sequence expected for Fast Poll:
60
60
  # 1. Alarms (for Plant)
61
61
  # 2. Info (for SN)
62
62
  # 3. Metrics (for SN)
63
- # 4. EMS Probe
64
63
  mock_req.reset_mock()
65
64
  mock_req.side_effect = [
66
65
  (200, alarms_resp),
@@ -71,7 +70,7 @@ async def test_discovery_caching_logic():
71
70
 
72
71
  res2 = await client.get_all_device_data()
73
72
  assert res2["data"]["S1"]["sw_version"] == "V1" # Still there from cache
74
- assert mock_req.call_count == 4
73
+ assert mock_req.call_count == 3
75
74
 
76
75
  # Verify specific URL paths for fast poll
77
76
  calls = mock_req.call_args_list
@@ -91,7 +90,7 @@ async def test_discovery_caching_logic():
91
90
  (200, {}), # EMS
92
91
  ]
93
92
  await client.get_all_device_data(force_discovery=True)
94
- assert mock_req.call_count == 7
93
+ assert mock_req.call_count == 6
95
94
 
96
95
 
97
96
  @pytest.mark.asyncio
@@ -1,5 +1,5 @@
1
1
  import sys
2
- from unittest.mock import MagicMock
2
+ from unittest.mock import AsyncMock, MagicMock
3
3
 
4
4
  if "aiohttp" not in sys.modules or not hasattr(sys.modules["aiohttp"], "ClientError"):
5
5
  m = MagicMock()
@@ -96,3 +96,23 @@ async def test_fetch_devices_for_plant_invalid_json(caplog):
96
96
  assert "Error fetching devices for plant" in caplog.text
97
97
  assert not state.metric_tasks
98
98
  assert not state.discovered_sns
99
+
100
+
101
+ @pytest.mark.asyncio
102
+ async def test_fetch_devices_for_plant_general_exception(caplog):
103
+ """Test that _fetch_devices_for_plant handles general exceptions."""
104
+ caplog.set_level(logging.ERROR)
105
+ mock_session = MagicMock()
106
+ api = HyxiApiClient("ak", "sk", "https://api.com", mock_session)
107
+
108
+ api._fetch_device_list_for_plant = AsyncMock(
109
+ side_effect=Exception("Unexpected generic error")
110
+ )
111
+
112
+ state = FetchState(now="2024-01-01")
113
+ await api._fetch_devices_for_plant("plant123", state)
114
+
115
+ assert "Error fetching devices for plant" in caplog.text
116
+ assert "Unexpected generic error" in caplog.text
117
+ assert not state.metric_tasks
118
+ assert not state.discovered_sns
@@ -22,7 +22,7 @@ from unittest.mock import AsyncMock, MagicMock
22
22
 
23
23
  import pytest
24
24
 
25
- from src.hyxi_cloud_api.api import FetchState, HyxiApiClient
25
+ from hyxi_cloud_api.api import FetchState, HyxiApiClient
26
26
 
27
27
 
28
28
  def _setup_mock_api():
@@ -259,7 +259,7 @@ async def test_fetch_sub_devices_rejected():
259
259
 
260
260
  with pytest.MonkeyPatch.context() as m:
261
261
  mock_logger = MagicMock()
262
- m.setattr("src.hyxi_cloud_api.api._LOGGER", mock_logger)
262
+ m.setattr("hyxi_cloud_api.api._LOGGER", mock_logger)
263
263
 
264
264
  await api._fetch_sub_devices("BAD_SN", state)
265
265
 
@@ -284,7 +284,7 @@ async def test_fetch_sub_devices_exception():
284
284
 
285
285
  with pytest.MonkeyPatch.context() as m:
286
286
  mock_logger = MagicMock()
287
- m.setattr("src.hyxi_cloud_api.api._LOGGER", mock_logger)
287
+ m.setattr("hyxi_cloud_api.api._LOGGER", mock_logger)
288
288
 
289
289
  await api._fetch_sub_devices("SOME_SN", state)
290
290
 
@@ -337,3 +337,28 @@ async def test_back_discovery_sn_validation():
337
337
  res = await api.get_all_device_data(allow_back_discovery=True)
338
338
  assert "123" not in res["data"]
339
339
  api._fetch_all_for_device.assert_not_called()
340
+
341
+
342
+ @pytest.mark.asyncio
343
+ async def test_fetch_sub_devices_outer_exception():
344
+ """Verify that an exception in _fetch_sub_devices is caught and logged."""
345
+ api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
346
+
347
+ # Mock _fetch_sub_device_list to raise an exception directly
348
+ api._fetch_sub_device_list = AsyncMock(side_effect=Exception("Outer Exception"))
349
+
350
+ state = FetchState(now="Plant123")
351
+
352
+ with pytest.MonkeyPatch.context() as m:
353
+ mock_logger = MagicMock()
354
+ m.setattr("hyxi_cloud_api.api._LOGGER", mock_logger)
355
+
356
+ await api._fetch_sub_devices("SOME_SN", state)
357
+
358
+ assert len(state.metric_tasks) == 0
359
+ assert len(state.discovered_sns) == 0
360
+
361
+ # Verify the logger caught the exception
362
+ mock_logger.error.assert_called_once()
363
+ args, _ = mock_logger.error.call_args
364
+ assert "Error fetching sub-devices for" in args[0]
@@ -23,7 +23,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
23
23
  import aiohttp
24
24
  import pytest
25
25
 
26
- from src.hyxi_cloud_api.api import HyxiApiClient
26
+ from hyxi_cloud_api.api import HyxiApiClient
27
27
 
28
28
 
29
29
  @pytest.mark.asyncio
@@ -103,7 +103,7 @@ async def test_fetch_plants_empty_list_warning():
103
103
  )
104
104
  )
105
105
 
106
- with patch("src.hyxi_cloud_api.api._LOGGER") as mock_logger:
106
+ with patch("hyxi_cloud_api.api._LOGGER") as mock_logger:
107
107
  plants = await api._fetch_plants()
108
108
  assert plants == []
109
109
  mock_logger.warning.assert_called_once_with(
@@ -106,3 +106,22 @@ async def test_fetch_sub_device_list_client_error(caplog):
106
106
  assert result == []
107
107
  assert "Error fetching sub-device list for" in caplog.text
108
108
  assert "Connection refused" in caplog.text
109
+
110
+
111
+ @pytest.mark.asyncio
112
+ async def test_fetch_sub_device_list_processing_error(caplog):
113
+ """Verify that a general Exception during processing is caught, logged, and returns an empty list."""
114
+ caplog.set_level(logging.ERROR)
115
+ api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
116
+ # Mocking the request to return something that causes an exception (like a list instead of dict)
117
+ # when attempting to use res_sd.get("success")
118
+ api._request = AsyncMock(
119
+ return_value=(200, ["this", "is", "a", "list", "not", "a", "dict"])
120
+ )
121
+
122
+ result = await api._fetch_sub_device_list("parent123")
123
+
124
+ assert result == []
125
+ assert "Error fetching sub-device list for" in caplog.text
126
+ assert "no attribute 'get'" in caplog.text
127
+ assert any(record.levelname == "ERROR" for record in caplog.records)
@@ -86,3 +86,13 @@ class TestGetF:
86
86
  """Test a condition that triggers a ValueError and returns 0.0."""
87
87
  data = {"key": "not-a-number"}
88
88
  assert _get_f("key", data) == 0.0
89
+
90
+ def test_get_f_explicit_type_error(self):
91
+ """Test specific condition triggering TypeError at line 567."""
92
+ data = {"key": []}
93
+ assert _get_f("key", data) == 0.0
94
+
95
+ def test_type_error_mult(self):
96
+ """Invalid multiplier type should return 0.0 due to TypeError."""
97
+ data = {"key": 10}
98
+ assert _get_f("key", data, mult="invalid") == 0.0
@@ -19,7 +19,7 @@ mock_aiohttp = sys.modules["aiohttp"]
19
19
  """Tests for exception handling in _fetch_device_metrics."""
20
20
 
21
21
  import logging
22
- from unittest.mock import AsyncMock, MagicMock
22
+ from unittest.mock import AsyncMock, MagicMock, patch
23
23
 
24
24
  import aiohttp
25
25
  import pytest
@@ -138,7 +138,7 @@ async def test_fetch_ems_basic_data_no_data(caplog):
138
138
  api._fetch_device_metrics = AsyncMock()
139
139
 
140
140
  entry = {"metrics": {}, "device_type_code": "EMS"}
141
- await api._fetch_all_for_device("10602251600016", entry, "INVERTER")
141
+ await api._fetch_all_for_device("10602251600016", entry, "EMS")
142
142
 
143
143
  assert "HYXI EMS telemetry probe returned no data for " in caplog.text
144
144
  # No EMS data should be merged into metrics.
@@ -158,7 +158,7 @@ async def test_fetch_ems_basic_data_error(caplog):
158
158
  api._fetch_device_metrics = AsyncMock()
159
159
 
160
160
  entry = {"metrics": {}, "device_type_code": "EMS"}
161
- await api._fetch_all_for_device("10602251600016", entry, "INVERTER")
161
+ await api._fetch_all_for_device("10602251600016", entry, "EMS")
162
162
 
163
163
  # No EMS data should be merged into metrics.
164
164
  assert "batSoc" not in entry["metrics"]
@@ -237,3 +237,49 @@ async def test_query_ems_basic_details_malformed_response(caplog):
237
237
  assert result == {}
238
238
  assert "HYXI EMS Basic Data Request Failed for " in caplog.text
239
239
  assert "object has no attribute 'get'" in caplog.text
240
+
241
+
242
+ @pytest.mark.asyncio
243
+ async def test_query_ems_basic_details_parse_error(caplog):
244
+ """Test that query_ems_basic_details handles exceptions during parsing."""
245
+ caplog.set_level(logging.ERROR)
246
+ mock_session = MagicMock()
247
+ api = HyxiApiClient("ak", "sk", "https://api.com", mock_session)
248
+
249
+ # Return a response with code 0 but invalid data type to trigger an exception
250
+ api._request = AsyncMock(return_value=(200, {"code": "0", "data": []}))
251
+
252
+ with patch(
253
+ "hyxi_cloud_api.api._parse_ems_kv", side_effect=Exception("Parsing error")
254
+ ):
255
+ result = await api.query_ems_basic_details("10602251600016")
256
+
257
+ assert result == {}
258
+ assert "HYXI EMS Basic Data Request Failed for" in caplog.text
259
+
260
+
261
+ @pytest.mark.asyncio
262
+ async def test_fetch_device_metrics_parsing_error(caplog, monkeypatch):
263
+ """Test that _fetch_device_metrics handles parsing errors gracefully."""
264
+ caplog.set_level(logging.ERROR)
265
+ mock_session = MagicMock()
266
+ api = HyxiApiClient("ak", "sk", "https://api.com", mock_session)
267
+
268
+ # Return a success response so that it enters the 'if res_q.get("success"):' block
269
+ api._request = AsyncMock(
270
+ return_value=(200, {"success": True, "data": [{"key": "bad"}]})
271
+ )
272
+
273
+ def mock_parse(*args, **kwargs):
274
+ raise ValueError("Mock parsing error")
275
+
276
+ # Mock _parse_data_list to trigger the exception block at line 1234
277
+ import hyxi_cloud_api.api as api_module # pylint: disable=import-outside-toplevel
278
+
279
+ monkeypatch.setattr(api_module, "_parse_data_list", mock_parse)
280
+
281
+ entry = {"metrics": {}, "device_type_code": "INVERTER"}
282
+ await api._fetch_device_metrics("10602251600016", entry)
283
+
284
+ assert "Error fetching metrics for " in caplog.text
285
+ assert "Mock parsing error" in caplog.text
@@ -267,8 +267,8 @@ def test_process_push_data_nested_format():
267
267
  assert metrics["gridP"] == -1.5
268
268
  assert metrics["gridF"] == 50.02
269
269
  assert metrics["gridPfd"] == 0.98
270
- assert metrics["gridEIn"] == 1023.4
271
- assert metrics["gridEOut"] == 504.2
270
+ assert metrics["totalEnt"] == 1023.4
271
+ assert metrics["totalEpt"] == 504.2
272
272
  assert metrics["grid_import"] == 1500.0
273
273
  assert metrics["grid_export"] == 0.0
274
274
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  from unittest.mock import patch
4
4
 
5
- from src.hyxi_cloud_api.api import _sanitize_dict, _sanitize_list
5
+ from hyxi_cloud_api.api import _sanitize_dict, _sanitize_list
6
6
 
7
7
 
8
8
  def test_sanitize_dict_plant_address():
@@ -16,7 +16,7 @@ def test_sanitize_dict_plant_address():
16
16
  assert result["plantAddress"] == "[REDACTED]"
17
17
 
18
18
 
19
- @patch("src.hyxi_cloud_api.api._mask_id", return_value="MASKED")
19
+ @patch("hyxi_cloud_api.api._mask_id", return_value="MASKED")
20
20
  def test_sanitize_dict_sensitive_keys(mock_mask_id):
21
21
  """Test that keys matching _SENSITIVE_KEYS are correctly masked."""
22
22
  raw = {
@@ -25,7 +25,7 @@ def test_sanitize_dict_sensitive_keys(mock_mask_id):
25
25
  "plantId": None, # falsy value
26
26
  }
27
27
  with patch(
28
- "src.hyxi_cloud_api.api._SENSITIVE_KEYS", {"deviceSn", "parentSn", "plantId"}
28
+ "hyxi_cloud_api.api._SENSITIVE_KEYS", {"deviceSn", "parentSn", "plantId"}
29
29
  ):
30
30
  result = _sanitize_dict(raw)
31
31
 
@@ -60,7 +60,7 @@ def test_sanitize_dict_alarmstate_passthrough():
60
60
  assert result["alarmState"] == "inactive"
61
61
 
62
62
 
63
- @patch("src.hyxi_cloud_api.api._mask_id", return_value="MASKED")
63
+ @patch("hyxi_cloud_api.api._mask_id", return_value="MASKED")
64
64
  def test_sanitize_dict_auth_keys(mock_mask_id):
65
65
  """Test that authentication keys are correctly masked."""
66
66
  raw = {
@@ -70,7 +70,7 @@ def test_sanitize_dict_auth_keys(mock_mask_id):
70
70
  "password": "super_secret_password",
71
71
  }
72
72
  with patch(
73
- "src.hyxi_cloud_api.api._SENSITIVE_KEYS",
73
+ "hyxi_cloud_api.api._SENSITIVE_KEYS",
74
74
  {"token", "access_token", "refresh_token", "password"},
75
75
  ):
76
76
  result = _sanitize_dict(raw)
@@ -128,8 +128,8 @@ def test_sanitize_list():
128
128
  "",
129
129
  [{"plantId": "987654321", "otherKey": 123}, ""],
130
130
  ]
131
- with patch("src.hyxi_cloud_api.api._SENSITIVE_KEYS", {"deviceSn", "plantId"}):
132
- with patch("src.hyxi_cloud_api.api._mask_id", return_value="MASKED"):
131
+ with patch("hyxi_cloud_api.api._SENSITIVE_KEYS", {"deviceSn", "plantId"}):
132
+ with patch("hyxi_cloud_api.api._mask_id", return_value="MASKED"):
133
133
  result = _sanitize_list(raw_list)
134
134
 
135
135
  expected = [
@@ -160,10 +160,10 @@ def test_sanitize_list_edge_cases():
160
160
  [[{"password": "xyz", "empty": ""}, ""]],
161
161
  ]
162
162
  with patch(
163
- "src.hyxi_cloud_api.api._SENSITIVE_KEYS",
163
+ "hyxi_cloud_api.api._SENSITIVE_KEYS",
164
164
  {"deviceSn", "plantId", "token", "password"},
165
165
  ):
166
- with patch("src.hyxi_cloud_api.api._mask_id", return_value="MASKED"):
166
+ with patch("hyxi_cloud_api.api._mask_id", return_value="MASKED"):
167
167
  result = _sanitize_list(raw_list)
168
168
 
169
169
  expected = [
@@ -2,7 +2,7 @@
2
2
 
3
3
  from unittest.mock import patch
4
4
 
5
- from src.hyxi_cloud_api.api import _sanitize_list
5
+ from hyxi_cloud_api.api import _sanitize_list
6
6
 
7
7
 
8
8
  def test_sanitize_list_empty():
@@ -29,7 +29,7 @@ def test_sanitize_list_nested():
29
29
  assert _sanitize_list(raw) == [1, [None, 2], None]
30
30
 
31
31
 
32
- @patch("src.hyxi_cloud_api.api._mask_id", return_value="MASKED")
32
+ @patch("hyxi_cloud_api.api._mask_id", return_value="MASKED")
33
33
  def test_sanitize_list_with_dicts(mock_mask):
34
34
  """Test that dictionaries within a list are sanitized correctly."""
35
35
  raw = [{"deviceSn": "123456789"}, ""]
@@ -0,0 +1,55 @@
1
+ """Tests for the set_log_salt function in api.py."""
2
+
3
+ import pytest
4
+
5
+ from hyxi_cloud_api import api
6
+ from hyxi_cloud_api.api import _mask_id, set_log_salt
7
+
8
+
9
+ @pytest.fixture(autouse=True)
10
+ def restore_log_salt():
11
+ """Fixture to ensure the original _LOG_SALT is restored after each test."""
12
+ original_salt = api._LOG_SALT
13
+ yield
14
+ api._LOG_SALT = original_salt
15
+ _mask_id.cache_clear()
16
+
17
+
18
+ def test_set_log_salt_with_string():
19
+ """Test that setting log salt with a string correctly encodes it to bytes and clears cache."""
20
+ # Given an initial salt
21
+ set_log_salt(b"initial_salt_123")
22
+
23
+ # Pre-populate cache for a specific ID
24
+ test_id = "test_sn_123"
25
+ initial_masked = _mask_id(test_id)
26
+
27
+ # Act
28
+ set_log_salt("new_string_salt")
29
+
30
+ # Assert
31
+ assert api._LOG_SALT == b"new_string_salt"
32
+
33
+ # Verify the cache was cleared and new salt is used
34
+ new_masked = _mask_id(test_id)
35
+ assert new_masked != initial_masked
36
+
37
+
38
+ def test_set_log_salt_with_bytes():
39
+ """Test that setting log salt with bytes correctly assigns it and clears cache."""
40
+ # Given an initial salt
41
+ set_log_salt("initial_salt_123")
42
+
43
+ # Pre-populate cache for a specific ID
44
+ test_id = "test_sn_123"
45
+ initial_masked = _mask_id(test_id)
46
+
47
+ # Act
48
+ set_log_salt(b"new_bytes_salt")
49
+
50
+ # Assert
51
+ assert api._LOG_SALT == b"new_bytes_salt"
52
+
53
+ # Verify the cache was cleared and new salt is used
54
+ new_masked = _mask_id(test_id)
55
+ assert new_masked != initial_masked
File without changes
File without changes
File without changes