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.
- {hyxi_cloud_api-1.5.2/src/hyxi_cloud_api.egg-info → hyxi_cloud_api-1.5.4}/PKG-INFO +5 -1
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/README.md +4 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/src/hyxi_cloud_api/__init__.py +1 -1
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/src/hyxi_cloud_api/api.py +136 -47
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4/src/hyxi_cloud_api.egg-info}/PKG-INFO +5 -1
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/src/hyxi_cloud_api.egg-info/SOURCES.txt +1 -0
- hyxi_cloud_api-1.5.4/tests/test_push_readings.py +183 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_subscriptions.py +66 -22
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_token_errors.py +5 -3
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/LICENSE +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/pyproject.toml +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/setup.cfg +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/src/hyxi_cloud_api.egg-info/dependency_links.txt +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/src/hyxi_cloud_api.egg-info/requires.txt +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/src/hyxi_cloud_api.egg-info/top_level.txt +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_additional_coverage.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_alarm_push.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_all_in_one.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_api.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_api_imports.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_build_plant_tasks.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_caching.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_compute_battery_metrics.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_compute_derived_metrics.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_compute_grid_metrics.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_compute_load_metrics.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_compute_pv_metrics.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_device_control.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_device_entry.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_devices_errors.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_discovery.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_ensure_authenticated.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_execute_device_tasks.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_execute_metric_tasks.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_execute_metrics_and_map_alarms.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_execute_with_auth_retry.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_extract_battery_info.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_extract_device_info_metadata.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_fetch_and_process_alarms.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_fetch_device_list_for_plant.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_fetch_plants.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_fetch_state.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_fetch_sub_device_list.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_filter_metrics.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_flatten_nested_push_device.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_fuzz_parser.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_gather.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_get_f.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_handle_back_discovery_alarm.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_info_errors.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_list_subscriptions.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_mask_id.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_merge_push_metrics.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_metrics_errors.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_normalize_cell_temperatures.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_normalize_cell_voltages.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_normalize_phase_ac_keys.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_pagination.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_parse_data_list.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_parse_ems_kv.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_parser.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_process_alarms_and_back_discovery.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_process_devices_for_plant.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_push.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_request_concurrency.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_sanitize_dict.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_sanitize_list.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_security_fix.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_set_log_salt.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_token_handling.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_token_rejected_propagation.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_update_discovery_cache.py +0 -0
- {hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_validate_post_rate_ms.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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) ->
|
|
2933
|
-
"""
|
|
2934
|
-
|
|
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
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3299
|
-
|
|
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.
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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="
|
|
236
|
-
|
|
237
|
+
with pytest.raises(ValueError, match="valid https URL"):
|
|
238
|
+
await getattr(api, subscribe)("http://example.com/hyxi", ["SN1"], post_rate)
|
|
237
239
|
|
|
238
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
|
139
|
-
values, which ha-hyxi-cloud's config
|
|
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
|
-
|
|
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
|
|
File without changes
|
{hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/src/hyxi_cloud_api.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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.5.2 → hyxi_cloud_api-1.5.4}/tests/test_process_alarms_and_back_discovery.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hyxi_cloud_api-1.5.2 → hyxi_cloud_api-1.5.4}/tests/test_validate_subscription_device_sns.py
RENAMED
|
File without changes
|