hyxi-cloud-api 1.5.2__tar.gz → 1.5.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.
Files changed (74) hide show
  1. {hyxi_cloud_api-1.5.2/src/hyxi_cloud_api.egg-info → hyxi_cloud_api-1.5.4}/PKG-INFO +5 -1
  2. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/README.md +4 -0
  3. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/src/hyxi_cloud_api/__init__.py +1 -1
  4. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/src/hyxi_cloud_api/api.py +136 -47
  5. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4/src/hyxi_cloud_api.egg-info}/PKG-INFO +5 -1
  6. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/src/hyxi_cloud_api.egg-info/SOURCES.txt +1 -0
  7. hyxi_cloud_api-1.5.4/tests/test_push_readings.py +183 -0
  8. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_subscriptions.py +66 -22
  9. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_token_errors.py +5 -3
  10. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/LICENSE +0 -0
  11. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/pyproject.toml +0 -0
  12. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/setup.cfg +0 -0
  13. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/src/hyxi_cloud_api.egg-info/dependency_links.txt +0 -0
  14. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/src/hyxi_cloud_api.egg-info/requires.txt +0 -0
  15. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/src/hyxi_cloud_api.egg-info/top_level.txt +0 -0
  16. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_additional_coverage.py +0 -0
  17. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_alarm_push.py +0 -0
  18. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_all_in_one.py +0 -0
  19. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_api.py +0 -0
  20. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_api_imports.py +0 -0
  21. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_build_plant_tasks.py +0 -0
  22. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_caching.py +0 -0
  23. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_compute_battery_metrics.py +0 -0
  24. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_compute_derived_metrics.py +0 -0
  25. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_compute_grid_metrics.py +0 -0
  26. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_compute_load_metrics.py +0 -0
  27. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_compute_pv_metrics.py +0 -0
  28. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_device_control.py +0 -0
  29. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_device_entry.py +0 -0
  30. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_devices_errors.py +0 -0
  31. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_discovery.py +0 -0
  32. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_ensure_authenticated.py +0 -0
  33. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_execute_device_tasks.py +0 -0
  34. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_execute_metric_tasks.py +0 -0
  35. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_execute_metrics_and_map_alarms.py +0 -0
  36. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_execute_with_auth_retry.py +0 -0
  37. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_extract_battery_info.py +0 -0
  38. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_extract_device_info_metadata.py +0 -0
  39. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_fetch_and_process_alarms.py +0 -0
  40. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_fetch_device_list_for_plant.py +0 -0
  41. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_fetch_plants.py +0 -0
  42. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_fetch_state.py +0 -0
  43. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_fetch_sub_device_list.py +0 -0
  44. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_filter_metrics.py +0 -0
  45. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_flatten_nested_push_device.py +0 -0
  46. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_fuzz_parser.py +0 -0
  47. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_gather.py +0 -0
  48. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_get_f.py +0 -0
  49. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_handle_back_discovery_alarm.py +0 -0
  50. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_info_errors.py +0 -0
  51. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_list_subscriptions.py +0 -0
  52. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_mask_id.py +0 -0
  53. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_merge_push_metrics.py +0 -0
  54. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_metrics_errors.py +0 -0
  55. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_normalize_cell_temperatures.py +0 -0
  56. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_normalize_cell_voltages.py +0 -0
  57. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_normalize_phase_ac_keys.py +0 -0
  58. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_pagination.py +0 -0
  59. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_parse_data_list.py +0 -0
  60. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_parse_ems_kv.py +0 -0
  61. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_parser.py +0 -0
  62. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_process_alarms_and_back_discovery.py +0 -0
  63. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_process_devices_for_plant.py +0 -0
  64. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_push.py +0 -0
  65. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_request_concurrency.py +0 -0
  66. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_sanitize_dict.py +0 -0
  67. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_sanitize_list.py +0 -0
  68. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_security_fix.py +0 -0
  69. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_set_log_salt.py +0 -0
  70. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_token_handling.py +0 -0
  71. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_token_rejected_propagation.py +0 -0
  72. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_update_discovery_cache.py +0 -0
  73. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_validate_post_rate_ms.py +0 -0
  74. {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/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.5.2
3
+ Version: 1.5.4
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
@@ -146,6 +146,10 @@ result = await client.query_control_result(trace_id)
146
146
  You can subscribe a callback URL to HYXI push notifications for real-time data,
147
147
  alarms, and FCAS/frequency-modulation real-time data.
148
148
 
149
+ The callback URL must use HTTPS and contain no credentials: HYXI sends each push
150
+ with your AccessKey in its headers and lists the URL with the subscription, so
151
+ `subscribe_*` raises `ValueError` for any other URL.
152
+
149
153
  ```python
150
154
  async def subscription_example(client):
151
155
  callback_url = "https://your-public-callback-host/hyxi/callback"
@@ -124,6 +124,10 @@ result = await client.query_control_result(trace_id)
124
124
  You can subscribe a callback URL to HYXI push notifications for real-time data,
125
125
  alarms, and FCAS/frequency-modulation real-time data.
126
126
 
127
+ The callback URL must use HTTPS and contain no credentials: HYXI sends each push
128
+ with your AccessKey in its headers and lists the URL with the subscription, so
129
+ `subscribe_*` raises `ValueError` for any other URL.
130
+
127
131
  ```python
128
132
  async def subscription_example(client):
129
133
  callback_url = "https://your-public-callback-host/hyxi/callback"
@@ -13,7 +13,7 @@ from .api import (
13
13
  # Module-level alias so callers can do: from hyxi_cloud_api import VPP_ACTIVE_MODES
14
14
  VPP_ACTIVE_MODES: frozenset[str] = HyxiApiClient.VPP_ACTIVE_MODES
15
15
 
16
- __version__ = "1.5.2"
16
+ __version__ = "1.5.4"
17
17
  __all__ = [
18
18
  "VPP_ACTIVE_MODES",
19
19
  "DiscoveryResult",
@@ -19,6 +19,7 @@ import os
19
19
  import re
20
20
  import secrets
21
21
  import time
22
+ import warnings
22
23
  from collections import defaultdict
23
24
  from collections.abc import Awaitable, Callable
24
25
  from dataclasses import dataclass, field
@@ -27,9 +28,9 @@ from typing import Any
27
28
  from urllib.parse import urlparse
28
29
 
29
30
  try:
30
- from datetime import UTC, datetime
31
+ from datetime import UTC, datetime, timedelta
31
32
  except ImportError: # pragma: no cover - unreachable given requires-python >=3.14
32
- from datetime import datetime, timezone
33
+ from datetime import datetime, timedelta, timezone
33
34
 
34
35
  UTC = timezone.utc # noqa
35
36
 
@@ -1151,24 +1152,102 @@ def _compute_derived_metrics(m_raw: dict, device_type: str = "") -> dict:
1151
1152
  return derived
1152
1153
 
1153
1154
 
1154
- def _resolve_push_timestamp(device: dict[str, Any], now_utc: datetime) -> datetime:
1155
- """Determine the last_seen timestamp from collectTime or reportTimestamp."""
1156
- collect_time = device.get("collectTime")
1157
- report_ts = device.get("reportTimestamp")
1155
+ # A pushed reading time further than this from its arrival comes from a
1156
+ # clock in another time zone: HYXI has stamped readings eight hours behind
1157
+ # UTC, UTC's wall time read as China time (UTC+8).
1158
+ _PUSH_CLOCK_TOLERANCE = timedelta(minutes=30)
1159
+ _PUSH_MAX_CLOCK_OFFSET = timedelta(hours=14)
1158
1160
 
1159
- if collect_time is not None:
1160
- try:
1161
- return datetime.fromtimestamp(float(collect_time), UTC)
1162
- except ValueError, TypeError, OverflowError, OSError:
1163
- pass
1164
1161
 
1165
- if report_ts is not None:
1162
+ def _correct_push_clock(reading: datetime, now_utc: datetime) -> datetime:
1163
+ """Move a reading time that is a time zone's whole hours off from its
1164
+ arrival back to UTC, and never place it after its arrival."""
1165
+ offset = now_utc - reading
1166
+ if _PUSH_CLOCK_TOLERANCE < abs(offset) <= _PUSH_MAX_CLOCK_OFFSET:
1167
+ reading += timedelta(hours=offset // timedelta(hours=1))
1168
+ return min(reading, now_utc)
1169
+
1170
+
1171
+ def _read_push_timestamp(device: dict[str, Any]) -> datetime | None:
1172
+ """The reading time a push states in collectTime or reportTimestamp."""
1173
+ for value, divisor in (
1174
+ (device.get("collectTime"), 1.0),
1175
+ (device.get("reportTimestamp"), 1000.0),
1176
+ ):
1177
+ if value is None:
1178
+ continue
1166
1179
  try:
1167
- return datetime.fromtimestamp(float(report_ts) / 1000.0, UTC)
1180
+ return datetime.fromtimestamp(float(value) / divisor, UTC)
1168
1181
  except ValueError, TypeError, OverflowError, OSError:
1169
1182
  pass
1183
+ return None
1184
+
1185
+
1186
+ def _resolve_push_timestamp(device: dict[str, Any], now_utc: datetime) -> datetime:
1187
+ """Determine the last_seen timestamp from collectTime or reportTimestamp,
1188
+ or the arrival time when neither is usable."""
1189
+ reading = _read_push_timestamp(device)
1190
+ return now_utc if reading is None else _correct_push_clock(reading, now_utc)
1191
+
1192
+
1193
+ def _held_last_seen(
1194
+ existing_metrics: dict[str, dict[str, Any]] | None, sn: str
1195
+ ) -> datetime | None:
1196
+ """The time of the reading already held for a device, if known."""
1197
+ try:
1198
+ held = datetime.fromisoformat((existing_metrics or {})[sn]["last_seen"])
1199
+ except KeyError, TypeError, ValueError:
1200
+ return None
1201
+ return held if held.tzinfo else held.replace(tzinfo=UTC)
1202
+
1203
+
1204
+ def _newest_push_readings(
1205
+ data_list: list, now_utc: datetime
1206
+ ) -> dict[str, tuple[datetime, dict, bool]]:
1207
+ """Flatten a push's readings and keep each device's newest, as its
1208
+ reading time, flat reading and whether its grid power is already kW."""
1209
+ newest: dict[str, tuple[datetime, dict, bool]] = {}
1210
+
1211
+ for device in data_list:
1212
+ if not isinstance(device, dict):
1213
+ continue
1170
1214
 
1171
- return now_utc
1215
+ # A nested grid.powerW has already been converted to kW by
1216
+ # _flatten_nested_push_device, regardless of device type.
1217
+ # Mirror its own condition for running that conversion exactly
1218
+ # (not just "a grid dict is present") -- a grid object without
1219
+ # a usable powerW (e.g. only frequencyHz) wouldn't trigger it,
1220
+ # leaving any gridP that slipped through unconverted and still
1221
+ # needing the gridP fixup in _normalize_raw_metrics.
1222
+ raw_grid = device.get("grid")
1223
+ grid_power_converted = (
1224
+ isinstance(raw_grid, dict)
1225
+ and "powerW" in raw_grid
1226
+ and raw_grid["powerW"] is not None
1227
+ )
1228
+ device = _flatten_nested_push_device(device)
1229
+
1230
+ sn = device.get("deviceSn")
1231
+ if not sn:
1232
+ continue
1233
+
1234
+ # A push can carry several readings of a device, out of order.
1235
+ last_seen = _resolve_push_timestamp(device, now_utc)
1236
+ best = newest.get(sn)
1237
+ if best is None or last_seen > best[0]:
1238
+ newest[sn] = (last_seen, device, grid_power_converted)
1239
+
1240
+ return newest
1241
+
1242
+
1243
+ def _drop_eps_phase_powers(device: dict[str, Any]) -> None:
1244
+ """Remove ph1p..ph3p where they repeat ph1peps..ph3peps: in a flat push
1245
+ they report the backup (EPS) port, not the inverter's AC phases that a
1246
+ poll reports under the same keys."""
1247
+ for i in range(1, 4):
1248
+ key, eps_key = f"ph{i}p", f"ph{i}peps"
1249
+ if eps_key in device and device.get(key) == device[eps_key]:
1250
+ device.pop(key, None)
1172
1251
 
1173
1252
 
1174
1253
  def _extract_raw_push_metrics(device: dict[str, Any]) -> dict[str, Any]:
@@ -1219,6 +1298,8 @@ def _flatten_nested_push_device(device: dict) -> dict:
1219
1298
  _flatten_phases_section(device, flat)
1220
1299
  _flatten_grid_section(device, flat)
1221
1300
  _copy_remaining_root_keys(device, flat)
1301
+ if not isinstance(device.get("phases"), dict):
1302
+ _drop_eps_phase_powers(flat)
1222
1303
  return flat
1223
1304
 
1224
1305
 
@@ -1872,6 +1953,10 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes,too-many-pu
1872
1953
  )
1873
1954
  return token_expires_at
1874
1955
 
1956
+ @warnings.deprecated(
1957
+ "HyxiApiClient._refresh_token() is deprecated; call ensure_token(), "
1958
+ "which raises HyxiAuthError or TokenRequestError on failure"
1959
+ )
1875
1960
  async def _refresh_token(self) -> bool | str | None:
1876
1961
  """Status-returning wrapper around ensure_token(), for callers that
1877
1962
  predate its exceptions: True on success, "auth_failed" for rejected
@@ -2929,13 +3014,28 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes,too-many-pu
2929
3014
  raise ValueError("device_sn_list cannot contain more than 1000 device SNs")
2930
3015
 
2931
3016
  @staticmethod
2932
- def _validate_callback_url(callback_url: str) -> None:
2933
- """Validate the subscriber callback URL."""
2934
- if not callback_url or not callback_url.strip():
3017
+ def _validate_callback_url(callback_url: str) -> str:
3018
+ """Return the subscriber callback URL without surrounding whitespace,
3019
+ after checking it is an HTTPS URL with a host, a valid port if any,
3020
+ and no credentials: HYXI sends each push with the AccessKey in its
3021
+ headers, and lists the URL with the subscription."""
3022
+ url = (callback_url or "").strip()
3023
+ if not url:
2935
3024
  raise ValueError("callback_url must be a non-empty string")
2936
- parsed = urlparse(callback_url.strip())
2937
- if parsed.scheme not in ("http", "https") or not parsed.netloc:
2938
- raise ValueError("callback_url must be a valid http/https URL")
3025
+ invalid = "callback_url must be a valid https URL"
3026
+ try:
3027
+ parsed = urlparse(url)
3028
+ if not (
3029
+ parsed.scheme == "https"
3030
+ and parsed.hostname
3031
+ and parsed.port != 0
3032
+ and parsed.username is None
3033
+ and parsed.password is None
3034
+ ):
3035
+ raise ValueError(invalid)
3036
+ except ValueError as err:
3037
+ raise ValueError(invalid) from err
3038
+ return url
2939
3039
 
2940
3040
  @staticmethod
2941
3041
  def _validate_post_rate_ms(post_rate: int) -> None:
@@ -2954,7 +3054,7 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes,too-many-pu
2954
3054
 
2955
3055
  Endpoint: POST /api/subscribe/v1/realTimeData
2956
3056
  """
2957
- self._validate_callback_url(callback_url)
3057
+ callback_url = self._validate_callback_url(callback_url)
2958
3058
  self._validate_subscription_device_sns(device_sn_list)
2959
3059
  self._validate_post_rate_ms(post_rate)
2960
3060
 
@@ -2979,7 +3079,7 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes,too-many-pu
2979
3079
 
2980
3080
  Endpoint: POST /api/subscribe/v1/alarm
2981
3081
  """
2982
- self._validate_callback_url(callback_url)
3082
+ callback_url = self._validate_callback_url(callback_url)
2983
3083
  self._validate_subscription_device_sns(device_sn_list)
2984
3084
  self._validate_post_rate_ms(post_rate)
2985
3085
 
@@ -3006,7 +3106,7 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes,too-many-pu
3006
3106
  Args:
3007
3107
  post_rate: Push rate in hours. Must be between 1 and 6.
3008
3108
  """
3009
- self._validate_callback_url(callback_url)
3109
+ callback_url = self._validate_callback_url(callback_url)
3010
3110
  self._validate_subscription_device_sns(device_sn_list)
3011
3111
  if not 1 <= post_rate <= 6:
3012
3112
  raise ValueError("post_rate must be between 1 and 6 hours")
@@ -3252,7 +3352,10 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes,too-many-pu
3252
3352
  """Process real-time push data from HYXI Cloud.
3253
3353
 
3254
3354
  Parses the flat push payload, matches it to the discovery cache,
3255
- filters collector metrics, and computes derived metrics.
3355
+ filters collector metrics, and computes derived metrics. Only the
3356
+ newest reading of each device is used, and a device is left out when
3357
+ that reading is not newer than the ``last_seen`` in
3358
+ ``existing_metrics``.
3256
3359
 
3257
3360
  Returns a dictionary of:
3258
3361
  {
@@ -3274,29 +3377,16 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes,too-many-pu
3274
3377
  return {}
3275
3378
 
3276
3379
  now_utc = datetime.now(UTC)
3277
- results = {}
3278
-
3279
- for device in data_list:
3280
- if not isinstance(device, dict):
3281
- continue
3282
-
3283
- # A nested grid.powerW has already been converted to kW by
3284
- # _flatten_nested_push_device below, regardless of device type.
3285
- # Mirror its own condition for running that conversion exactly
3286
- # (not just "a grid dict is present") -- a grid object without
3287
- # a usable powerW (e.g. only frequencyHz) wouldn't trigger it,
3288
- # leaving any gridP that slipped through unconverted and still
3289
- # needing the gridP fixup in _normalize_raw_metrics below.
3290
- raw_grid = device.get("grid")
3291
- grid_power_converted = (
3292
- isinstance(raw_grid, dict)
3293
- and "powerW" in raw_grid
3294
- and raw_grid["powerW"] is not None
3295
- )
3296
- device = _flatten_nested_push_device(device)
3380
+ newest = _newest_push_readings(data_list, now_utc)
3297
3381
 
3298
- sn = device.get("deviceSn")
3299
- if not sn:
3382
+ results = {}
3383
+ for sn, (last_seen, device, grid_power_converted) in newest.items():
3384
+ held_last_seen = _held_last_seen(existing_metrics, sn)
3385
+ if held_last_seen is not None and last_seen <= held_last_seen:
3386
+ _LOGGER.debug(
3387
+ "HYXI push: reading for %s is not newer than the held one",
3388
+ _mask_id(sn),
3389
+ )
3300
3390
  continue
3301
3391
 
3302
3392
  # Retrieve info from discovery cache
@@ -3311,7 +3401,6 @@ class HyxiApiClient: # pylint: disable=too-many-instance-attributes,too-many-pu
3311
3401
  _normalize_raw_metrics(device, device_type, skip_gridp=grid_power_converted)
3312
3402
 
3313
3403
  raw_metrics = _extract_raw_push_metrics(device)
3314
- last_seen = _resolve_push_timestamp(device, now_utc)
3315
3404
 
3316
3405
  merged_metrics = _merge_push_metrics(
3317
3406
  sn, raw_metrics, device_type, existing_metrics
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyxi-cloud-api
3
- Version: 1.5.2
3
+ Version: 1.5.4
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
@@ -146,6 +146,10 @@ result = await client.query_control_result(trace_id)
146
146
  You can subscribe a callback URL to HYXI push notifications for real-time data,
147
147
  alarms, and FCAS/frequency-modulation real-time data.
148
148
 
149
+ The callback URL must use HTTPS and contain no credentials: HYXI sends each push
150
+ with your AccessKey in its headers and lists the URL with the subscription, so
151
+ `subscribe_*` raises `ValueError` for any other URL.
152
+
149
153
  ```python
150
154
  async def subscription_example(client):
151
155
  callback_url = "https://your-public-callback-host/hyxi/callback"
@@ -57,6 +57,7 @@ tests/test_parser.py
57
57
  tests/test_process_alarms_and_back_discovery.py
58
58
  tests/test_process_devices_for_plant.py
59
59
  tests/test_push.py
60
+ tests/test_push_readings.py
60
61
  tests/test_request_concurrency.py
61
62
  tests/test_sanitize_dict.py
62
63
  tests/test_sanitize_list.py
@@ -0,0 +1,183 @@
1
+ """Tests for which pushed readings process_push_data uses, and when."""
2
+
3
+ from datetime import UTC, datetime
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ import pytest
7
+
8
+ from hyxi_cloud_api import HyxiApiClient
9
+ from hyxi_cloud_api import api as api_module
10
+
11
+ SN = "INV123"
12
+
13
+ # When HYXI delivered the captured reading below.
14
+ ARRIVAL = datetime(2026, 10, 10, 16, 33, 16, tzinfo=UTC)
15
+
16
+ # A flat reading as HYXI pushed it (trimmed): its collectTime reads
17
+ # 08:31:30Z, eight hours behind its arrival, and ph1p..ph3p repeat the
18
+ # backup port's ph1peps..ph3peps.
19
+ CAPTURED_READING = {
20
+ "deviceSn": SN,
21
+ "collectTime": 1791621090000,
22
+ "createTime": "2026-10-11T00:31:42.947836",
23
+ "acP": 9994.0,
24
+ "gridP": None,
25
+ "batSoc": 89,
26
+ "ph1p": 1,
27
+ "ph2p": 3,
28
+ "ph3p": 2,
29
+ "ph1peps": 1,
30
+ "ph2peps": 3,
31
+ "ph3peps": 2,
32
+ "ph1Loadp": 546,
33
+ "ph2Loadp": 2791,
34
+ "ph3Loadp": 57,
35
+ }
36
+
37
+ # The captured reading's corrected time.
38
+ READING_TIME = "2026-10-10T16:31:30+00:00"
39
+
40
+
41
+ class _FrozenDatetime(datetime):
42
+ @classmethod
43
+ def now(cls, tz=None):
44
+ return ARRIVAL.astimezone(tz)
45
+
46
+
47
+ @pytest.fixture
48
+ def api():
49
+ """A client that knows the device, at the captured reading's arrival."""
50
+ client = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
51
+ client._discovery_cache["device_info"] = {
52
+ SN: {"model": "HYX-H10K-HT", "device_type_code": "HYBRID_INVERTER"}
53
+ }
54
+ with patch.object(api_module, "datetime", _FrozenDatetime):
55
+ yield client
56
+
57
+
58
+ def _reading(**changes) -> dict:
59
+ return {**CAPTURED_READING, **changes}
60
+
61
+
62
+ def _ms(iso: str) -> int:
63
+ return int(datetime.fromisoformat(iso).timestamp() * 1000)
64
+
65
+
66
+ def test_a_reading_hours_off_is_corrected_to_utc(api):
67
+ """A collectTime that is a time zone's whole hours off from its arrival
68
+ is moved back to UTC."""
69
+ results = api.process_push_data({"dataList": [_reading()]})
70
+
71
+ assert results[SN]["metrics"]["last_seen"] == READING_TIME
72
+
73
+
74
+ @pytest.mark.parametrize(
75
+ ("collect_time", "last_seen"),
76
+ [
77
+ ("2026-10-10T16:20:00+00:00", "2026-10-10T16:20:00+00:00"),
78
+ ("2026-10-10T16:40:00+00:00", "2026-10-10T16:33:16+00:00"),
79
+ ("2026-10-11T00:31:30+00:00", "2026-10-10T16:31:30+00:00"),
80
+ ("2026-10-10T15:48:16+00:00", "2026-10-10T15:48:16+00:00"),
81
+ ("2026-10-09T16:00:00+00:00", "2026-10-09T16:00:00+00:00"),
82
+ ],
83
+ ids=["utc", "slightly-ahead", "hours-ahead", "45-minutes-late", "a-day-old"],
84
+ )
85
+ def test_reading_times_close_to_arrival_or_beyond_a_time_zone_are_kept(
86
+ api, collect_time, last_seen
87
+ ):
88
+ """A reading time within half an hour of its arrival is kept, but never
89
+ placed after the arrival; one less than a whole hour late, or more than
90
+ a time zone away, is not shifted."""
91
+ results = api.process_push_data(
92
+ {"dataList": [_reading(collectTime=_ms(collect_time))]}
93
+ )
94
+
95
+ assert results[SN]["metrics"]["last_seen"] == last_seen
96
+
97
+
98
+ def test_the_newest_of_several_readings_is_used(api):
99
+ """A push with several readings of a device, out of order, uses the
100
+ newest one."""
101
+ newest = _reading()
102
+ older = _reading(collectTime=CAPTURED_READING["collectTime"] - 60_000, batSoc=88)
103
+
104
+ results = api.process_push_data({"dataList": [newest, older]})
105
+
106
+ metrics = results[SN]["metrics"]
107
+ assert metrics["batSoc"] == 89
108
+ assert metrics["last_seen"] == READING_TIME
109
+
110
+
111
+ @pytest.mark.parametrize(
112
+ "held_last_seen",
113
+ [READING_TIME, "2026-10-10T16:32:00+00:00", "2026-10-10T16:32:00"],
114
+ ids=["same-reading", "newer-poll", "naive-time"],
115
+ )
116
+ def test_a_reading_not_newer_than_the_held_one_is_left_out(api, held_last_seen):
117
+ """A reading HYXI sends again, or one older than what a poll already
118
+ returned, does not replace the held metrics."""
119
+ existing = {SN: {"batSoc": 90, "last_seen": held_last_seen}}
120
+
121
+ assert api.process_push_data({"dataList": [_reading()]}, existing) == {}
122
+
123
+
124
+ @pytest.mark.parametrize(
125
+ "held_last_seen", ["2026-10-10T16:30:00+00:00", None, "not-a-time"]
126
+ )
127
+ def test_a_reading_newer_than_the_held_one_is_used(api, held_last_seen):
128
+ """A newer reading, or one for a device without a usable held time,
129
+ updates the metrics."""
130
+ existing = {SN: {"batSoc": 90, "last_seen": held_last_seen}}
131
+
132
+ results = api.process_push_data({"dataList": [_reading()]}, existing)
133
+
134
+ assert results[SN]["metrics"]["batSoc"] == 89
135
+ assert results[SN]["metrics"]["last_seen"] == READING_TIME
136
+
137
+
138
+ def test_backup_port_phase_powers_keep_the_polled_ones(api):
139
+ """Pushed ph1p..ph3p that repeat the backup port's powers do not replace
140
+ the inverter's phase powers a poll returned."""
141
+ existing = {
142
+ SN: {
143
+ "ph1p": 1201.0,
144
+ "ph2p": 1189.0,
145
+ "ph3p": 1195.0,
146
+ "last_seen": "2026-10-10T16:30:00+00:00",
147
+ }
148
+ }
149
+
150
+ metrics = api.process_push_data({"dataList": [_reading()]}, existing)[SN]["metrics"]
151
+
152
+ assert (metrics["ph1p"], metrics["ph2p"], metrics["ph3p"]) == (
153
+ 1201.0,
154
+ 1189.0,
155
+ 1195.0,
156
+ )
157
+ assert metrics["ph1peps"] == 1
158
+ assert metrics["ph2Loadp"] == 2791
159
+
160
+
161
+ def test_phase_powers_that_differ_from_the_backup_port_are_used(api):
162
+ """Pushed phase powers that are not the backup port's are kept."""
163
+ metrics = api.process_push_data(
164
+ {"dataList": [_reading(ph1p=1201.0, ph2p=None, ph3p=1195.0)]}
165
+ )[SN]["metrics"]
166
+
167
+ assert metrics["ph1p"] == 1201.0
168
+ assert "ph2p" not in metrics
169
+ assert metrics["ph3p"] == 1195.0
170
+
171
+
172
+ def test_nested_phase_powers_are_kept(api):
173
+ """Phase powers from a nested push's phases section are the inverter's,
174
+ so they are kept even when they equal the backup port's."""
175
+ nested = {
176
+ "record": {"deviceSn": SN, "collectTime": 1791621090000},
177
+ "ph1peps": 1,
178
+ "phases": {"ph1": {"powerW": 1}},
179
+ }
180
+
181
+ metrics = api.process_push_data({"dataList": [nested]})[SN]["metrics"]
182
+
183
+ assert metrics["ph1p"] == 1
@@ -220,33 +220,77 @@ async def test_subscription_validation():
220
220
  await api.cancel_subscription(" ")
221
221
 
222
222
 
223
- def test_validate_callback_url():
224
- """Test validation of subscriber callback URLs."""
225
- # Happy paths shouldn't raise any exception
226
- HyxiApiClient._validate_callback_url("http://example.com")
227
- HyxiApiClient._validate_callback_url("https://example.com/callback")
228
- HyxiApiClient._validate_callback_url(" https://example.com/whitespace ")
229
- HyxiApiClient._validate_callback_url("http://localhost:8080")
230
-
231
- # Empty and whitespace
232
- with pytest.raises(ValueError, match="callback_url must be a non-empty string"):
233
- HyxiApiClient._validate_callback_url("")
223
+ @pytest.mark.asyncio
224
+ @pytest.mark.parametrize(
225
+ ("subscribe", "post_rate"),
226
+ [
227
+ ("subscribe_real_time_data", 60000),
228
+ ("subscribe_alarm", 60000),
229
+ ("subscribe_fm_real_time_data", 6),
230
+ ],
231
+ )
232
+ async def test_a_plain_http_callback_is_never_sent(subscribe, post_rate):
233
+ """Every subscribe call refuses a plain-HTTP callback URL before
234
+ sending anything to HYXI."""
235
+ api = _client()
234
236
 
235
- with pytest.raises(ValueError, match="callback_url must be a non-empty string"):
236
- HyxiApiClient._validate_callback_url(" ")
237
+ with pytest.raises(ValueError, match="valid https URL"):
238
+ await getattr(api, subscribe)("http://example.com/hyxi", ["SN1"], post_rate)
237
239
 
238
- # Invalid schemas / URLs
239
- with pytest.raises(ValueError, match="callback_url must be a valid http/https URL"):
240
- HyxiApiClient._validate_callback_url("ftp://example.com")
240
+ api._request.assert_not_awaited()
241
241
 
242
- with pytest.raises(ValueError, match="callback_url must be a valid http/https URL"):
243
- HyxiApiClient._validate_callback_url("example.com")
244
242
 
245
- with pytest.raises(ValueError, match="callback_url must be a valid http/https URL"):
246
- HyxiApiClient._validate_callback_url("http://")
243
+ @pytest.mark.asyncio
244
+ async def test_the_callback_url_is_sent_without_surrounding_whitespace():
245
+ """The callback URL HYXI stores is the one that was validated."""
246
+ api = _client()
247
+ api._post_subscription = AsyncMock(return_value={"success": True})
247
248
 
248
- with pytest.raises(ValueError, match="callback_url must be a valid http/https URL"):
249
- HyxiApiClient._validate_callback_url("://example.com")
249
+ await api.subscribe_alarm(" https://example.com/hyxi ", ["SN1"], 60000)
250
+
251
+ body = api._post_subscription.call_args.args[1]
252
+ assert body["callBackUrl"] == "https://example.com/hyxi"
253
+
254
+
255
+ def test_validate_callback_url():
256
+ """An HTTPS callback URL is accepted and returned without surrounding
257
+ whitespace; an empty one is refused."""
258
+ assert (
259
+ HyxiApiClient._validate_callback_url("https://example.com/callback")
260
+ == "https://example.com/callback"
261
+ )
262
+ assert (
263
+ HyxiApiClient._validate_callback_url(" https://example.com/whitespace ")
264
+ == "https://example.com/whitespace"
265
+ )
266
+ assert HyxiApiClient._validate_callback_url("https://localhost:8443")
267
+
268
+ for url in ("", " ", None):
269
+ with pytest.raises(ValueError, match="callback_url must be a non-empty string"):
270
+ HyxiApiClient._validate_callback_url(url)
271
+
272
+
273
+ @pytest.mark.parametrize(
274
+ "url",
275
+ [
276
+ "ftp://example.com",
277
+ "http://example.com/callback",
278
+ "example.com",
279
+ "http://",
280
+ "://example.com",
281
+ "https://:443/callback",
282
+ "https://example.com:abc/callback",
283
+ "https://example.com:99999/callback",
284
+ "https://example.com:0/callback",
285
+ "https://user:secret@example.com/callback",
286
+ "https://[::1/callback",
287
+ ],
288
+ )
289
+ def test_a_callback_url_that_is_not_plain_https_is_refused(url):
290
+ """Anything but an HTTPS URL with a host, a valid port and no
291
+ credentials is refused."""
292
+ with pytest.raises(ValueError, match="callback_url must be a valid https URL"):
293
+ HyxiApiClient._validate_callback_url(url)
250
294
 
251
295
 
252
296
  @pytest.mark.asyncio
@@ -135,12 +135,14 @@ async def test_ensure_token_unexpected_exception_propagates():
135
135
  ],
136
136
  )
137
137
  async def test_refresh_token_keeps_status_return_values(side_effect, expected):
138
- """_refresh_token maps ensure_token's outcome onto its original return
139
- values, which ha-hyxi-cloud's config flow branches on."""
138
+ """The deprecated _refresh_token warns, and still maps ensure_token's
139
+ outcome onto its original return values, which ha-hyxi-cloud's config
140
+ flow up to 1.7.0 branches on."""
140
141
  api = HyxiApiClient("ak", "sk", "https://api.com", MagicMock())
141
142
  api.ensure_token = AsyncMock(side_effect=side_effect)
142
143
 
143
- assert await api._refresh_token() == expected
144
+ with pytest.warns(DeprecationWarning, match="call ensure_token"):
145
+ assert await api._refresh_token() == expected
144
146
 
145
147
 
146
148
  def _token_session(api, body=None, json_error=None):
File without changes
File without changes