violet-poolController-api 0.0.36__tar.gz → 0.0.37__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.
- {violet_poolcontroller_api-0.0.36/violet_poolController_api.egg-info → violet_poolcontroller_api-0.0.37}/PKG-INFO +1 -1
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/pyproject.toml +1 -1
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/tests/test_api.py +82 -1
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/tests/test_readings.py +31 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/tests/test_sanitizer.py +18 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37/violet_poolController_api.egg-info}/PKG-INFO +1 -1
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/_api_dosing.py +36 -8
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/_api_outputs.py +14 -4
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/_api_system.py +7 -2
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/api.py +15 -11
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/readings.py +1 -1
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/utils_sanitizer.py +13 -2
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/LICENSE +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/README.md +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/setup.cfg +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/tests/test_api_smoke.py +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/tests/test_circuit_breaker.py +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/tests/test_mock_server.py +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/tests/test_parsers.py +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/tests/test_rate_limiter.py +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolController_api.egg-info/SOURCES.txt +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolController_api.egg-info/dependency_links.txt +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolController_api.egg-info/requires.txt +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolController_api.egg-info/top_level.txt +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/__init__.py +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/_api_mixin.py +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/_api_model.py +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/_api_readings.py +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/circuit_breaker.py +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/const_api.py +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/const_devices.py +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/parsers.py +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/py.typed +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/utils_rate_limiter.py +0 -0
|
@@ -22,6 +22,7 @@ from __future__ import annotations
|
|
|
22
22
|
|
|
23
23
|
from collections.abc import Mapping
|
|
24
24
|
from typing import TYPE_CHECKING, Any, Never
|
|
25
|
+
from unittest.mock import AsyncMock
|
|
25
26
|
|
|
26
27
|
import aiohttp
|
|
27
28
|
import pytest
|
|
@@ -29,7 +30,11 @@ import pytest_asyncio
|
|
|
29
30
|
from aioresponses import aioresponses
|
|
30
31
|
from yarl import URL
|
|
31
32
|
|
|
32
|
-
from violet_poolcontroller_api.api import
|
|
33
|
+
from violet_poolcontroller_api.api import (
|
|
34
|
+
VioletPoolAPI,
|
|
35
|
+
VioletPoolAPIError,
|
|
36
|
+
VioletSetpointError,
|
|
37
|
+
)
|
|
33
38
|
from violet_poolcontroller_api.circuit_breaker import CircuitBreakerOpenError
|
|
34
39
|
from violet_poolcontroller_api.const_api import (
|
|
35
40
|
ERROR_CODES,
|
|
@@ -37,6 +42,7 @@ from violet_poolcontroller_api.const_api import (
|
|
|
37
42
|
ERROR_SEVERITY_INFO,
|
|
38
43
|
ERROR_SEVERITY_REMINDER,
|
|
39
44
|
ERROR_SEVERITY_WARNING,
|
|
45
|
+
TARGET_PH,
|
|
40
46
|
)
|
|
41
47
|
|
|
42
48
|
if TYPE_CHECKING:
|
|
@@ -938,6 +944,15 @@ async def test_set_dosing_parameters(
|
|
|
938
944
|
assert result["success"] is True
|
|
939
945
|
|
|
940
946
|
|
|
947
|
+
@pytest.mark.asyncio
|
|
948
|
+
async def test_set_dosing_parameters_enforces_setpoint_range(
|
|
949
|
+
api_client: VioletPoolAPI,
|
|
950
|
+
) -> None:
|
|
951
|
+
"""set_dosing_parameters must not bypass the pH/ORP/chlorine safety bounds."""
|
|
952
|
+
with pytest.raises(VioletSetpointError, match="outside the valid range"):
|
|
953
|
+
await api_client.set_dosing_parameters({TARGET_PH: 20.0})
|
|
954
|
+
|
|
955
|
+
|
|
941
956
|
@pytest.mark.asyncio
|
|
942
957
|
@pytest.mark.parametrize(("raw_value", "expected"), [("1", True), ("1.0", True), ("0.0", False)])
|
|
943
958
|
async def test_is_dosage_enabled_accepts_numeric_strings(
|
|
@@ -1103,6 +1118,38 @@ async def test_get_system_services(
|
|
|
1103
1118
|
}
|
|
1104
1119
|
|
|
1105
1120
|
|
|
1121
|
+
@pytest.mark.asyncio
|
|
1122
|
+
async def test_get_system_services_accepts_decimal_state(
|
|
1123
|
+
mock_aioresponse: aioresponses,
|
|
1124
|
+
api_client: VioletPoolAPI,
|
|
1125
|
+
) -> None:
|
|
1126
|
+
"""The controller may report service state as a decimal string like '1.0'."""
|
|
1127
|
+
url = "http://192.168.1.100/getServiceStates"
|
|
1128
|
+
mock_aioresponse.get(
|
|
1129
|
+
url,
|
|
1130
|
+
payload={"proftpd": "1.0", "shairport": "0.0"},
|
|
1131
|
+
status=200,
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
result = await api_client.get_system_services()
|
|
1135
|
+
|
|
1136
|
+
assert result["ftp"] is True
|
|
1137
|
+
assert result["shairport"] is False
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
@pytest.mark.asyncio
|
|
1141
|
+
async def test_get_system_services_rejects_garbage_state(
|
|
1142
|
+
mock_aioresponse: aioresponses,
|
|
1143
|
+
api_client: VioletPoolAPI,
|
|
1144
|
+
) -> None:
|
|
1145
|
+
"""A non-numeric service state must raise, not crash with a bare ValueError."""
|
|
1146
|
+
url = "http://192.168.1.100/getServiceStates"
|
|
1147
|
+
mock_aioresponse.get(url, payload={"proftpd": "not-a-number"}, status=200)
|
|
1148
|
+
|
|
1149
|
+
with pytest.raises(VioletPoolAPIError, match="Invalid service state"):
|
|
1150
|
+
await api_client.get_system_services()
|
|
1151
|
+
|
|
1152
|
+
|
|
1106
1153
|
# ---------------------------------------------------------------------------
|
|
1107
1154
|
# OmniTronic + RS485 + LiveTrace (P12 / P13 / P15)
|
|
1108
1155
|
# ---------------------------------------------------------------------------
|
|
@@ -1867,6 +1914,40 @@ async def test_server_error_still_counts_for_circuit_breaker(
|
|
|
1867
1914
|
assert stats["failure_count"] == 1
|
|
1868
1915
|
|
|
1869
1916
|
|
|
1917
|
+
@pytest.mark.asyncio
|
|
1918
|
+
async def test_rate_limiter_reacquired_on_every_retry(
|
|
1919
|
+
mock_aioresponse: aioresponses,
|
|
1920
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1921
|
+
) -> None:
|
|
1922
|
+
"""Each retry attempt must re-acquire a rate-limit token, not just the first.
|
|
1923
|
+
|
|
1924
|
+
Regression test: wait_if_needed() used to be awaited once before the
|
|
1925
|
+
retry loop, so retries fired real HTTP requests without ever going
|
|
1926
|
+
through the limiter again.
|
|
1927
|
+
"""
|
|
1928
|
+
url = "http://192.168.1.100/getReadings?ALL"
|
|
1929
|
+
mock_aioresponse.get(url, status=500, body="boom", repeat=True)
|
|
1930
|
+
# Backoff sleeps aren't what this test verifies; skip them for speed.
|
|
1931
|
+
monkeypatch.setattr("violet_poolcontroller_api.api.asyncio.sleep", AsyncMock())
|
|
1932
|
+
|
|
1933
|
+
async with aiohttp.ClientSession() as session:
|
|
1934
|
+
api = VioletPoolAPI(host="192.168.1.100", session=session, max_retries=3)
|
|
1935
|
+
call_count = 0
|
|
1936
|
+
original_wait = api._rate_limiter.wait_if_needed # noqa: SLF001
|
|
1937
|
+
|
|
1938
|
+
async def counting_wait(*args: object, **kwargs: object) -> None:
|
|
1939
|
+
nonlocal call_count
|
|
1940
|
+
call_count += 1
|
|
1941
|
+
await original_wait(*args, **kwargs)
|
|
1942
|
+
|
|
1943
|
+
api._rate_limiter.wait_if_needed = counting_wait # type: ignore[method-assign] # noqa: SLF001
|
|
1944
|
+
|
|
1945
|
+
with pytest.raises(VioletPoolAPIError):
|
|
1946
|
+
await api.get_readings()
|
|
1947
|
+
|
|
1948
|
+
assert call_count == 3
|
|
1949
|
+
|
|
1950
|
+
|
|
1870
1951
|
def test_command_result_error_first_line() -> None:
|
|
1871
1952
|
"""Line 1 of the response decides success per manual section 26.2."""
|
|
1872
1953
|
result = VioletPoolAPI._command_result("ERROR\nPUMP\nUNKNOWN OUTPUT")
|
{violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/tests/test_readings.py
RENAMED
|
@@ -86,6 +86,37 @@ class TestVioletReadingsTypeConversions:
|
|
|
86
86
|
assert null_val is None
|
|
87
87
|
|
|
88
88
|
|
|
89
|
+
class TestVioletReadingsDosingAmounts:
|
|
90
|
+
"""Regression tests for dosing_daily_amounts_ml decimal parsing."""
|
|
91
|
+
|
|
92
|
+
def test_decimal_amounts_are_not_dropped(self):
|
|
93
|
+
"""Fractional mL amounts (the controller's actual wire format) must parse."""
|
|
94
|
+
data = {
|
|
95
|
+
"DOS_1_CL_DAILY_DOSING_AMOUNT_ML": "12.5",
|
|
96
|
+
"DOS_2_ELO_DAILY_DOSING_AMOUNT_ML": "0.0",
|
|
97
|
+
"DOS_4_PHM_DAILY_DOSING_AMOUNT_ML": "8.3",
|
|
98
|
+
"DOS_5_PHP_DAILY_DOSING_AMOUNT_ML": "0.0",
|
|
99
|
+
"DOS_6_FLOC_DAILY_DOSING_AMOUNT_ML": "3.1",
|
|
100
|
+
}
|
|
101
|
+
readings = VioletReadings(data)
|
|
102
|
+
amounts = readings.dosing_daily_amounts_ml
|
|
103
|
+
assert amounts["DOS_1_CL"] == 12
|
|
104
|
+
assert amounts["DOS_4_PHM"] == 8
|
|
105
|
+
assert amounts["DOS_6_FLOC"] == 3
|
|
106
|
+
|
|
107
|
+
def test_missing_amount_is_none(self):
|
|
108
|
+
"""Missing keys stay None rather than raising."""
|
|
109
|
+
readings = VioletReadings({})
|
|
110
|
+
amounts = readings.dosing_daily_amounts_ml
|
|
111
|
+
assert amounts["DOS_1_CL"] is None
|
|
112
|
+
|
|
113
|
+
def test_garbage_amount_is_none(self):
|
|
114
|
+
"""Non-numeric values fall back to None instead of raising."""
|
|
115
|
+
readings = VioletReadings({"DOS_1_CL_DAILY_DOSING_AMOUNT_ML": "not-a-number"})
|
|
116
|
+
amounts = readings.dosing_daily_amounts_ml
|
|
117
|
+
assert amounts["DOS_1_CL"] is None
|
|
118
|
+
|
|
119
|
+
|
|
89
120
|
class TestVioletReadingsAggregation:
|
|
90
121
|
"""Test readings aggregation and derived properties."""
|
|
91
122
|
|
{violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/tests/test_sanitizer.py
RENAMED
|
@@ -23,3 +23,21 @@ def test_sanitize_integer_replaces_infinite_values(value: object) -> None:
|
|
|
23
23
|
def test_numeric_sanitizer_rejects_non_finite_values() -> None:
|
|
24
24
|
assert InputSanitizer.sanitize_numeric(float("nan")) == 0.0
|
|
25
25
|
assert InputSanitizer.sanitize_numeric(float("inf")) == 0.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_numeric_sanitizer_parses_scientific_notation() -> None:
|
|
29
|
+
"""'1e10' must parse as 1e10, not as digit-stripped '110'."""
|
|
30
|
+
assert InputSanitizer.sanitize_numeric("1e10") == 1e10
|
|
31
|
+
assert InputSanitizer.sanitize_numeric("-2.5e-3") == -2.5e-3
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_numeric_sanitizer_still_extracts_digits_from_messy_strings() -> None:
|
|
35
|
+
"""Non-numeric strings still fall back to best-effort digit extraction."""
|
|
36
|
+
assert InputSanitizer.sanitize_numeric("12.5 mL") == 12.5
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_validate_ph_value_matches_controller_setpoint_range() -> None:
|
|
40
|
+
"""The sanitizer's pH bounds must agree with SETPOINT_RANGES (6.0-8.0)."""
|
|
41
|
+
assert InputSanitizer.validate_ph_value(8.5) == 8.0
|
|
42
|
+
assert InputSanitizer.validate_ph_value(5.0) == 6.0
|
|
43
|
+
assert InputSanitizer.validate_ph_value(7.2) == 7.2
|
|
@@ -7,8 +7,10 @@ from typing import Any
|
|
|
7
7
|
|
|
8
8
|
from ._api_mixin import APIClientMixin
|
|
9
9
|
from ._api_model import (
|
|
10
|
+
SETPOINT_RANGES,
|
|
10
11
|
VioletPayloadError,
|
|
11
12
|
VioletPoolAPIError,
|
|
13
|
+
VioletSetpointError,
|
|
12
14
|
validate_duration,
|
|
13
15
|
validate_setpoint,
|
|
14
16
|
)
|
|
@@ -29,6 +31,20 @@ from .const_api import (
|
|
|
29
31
|
)
|
|
30
32
|
|
|
31
33
|
|
|
34
|
+
def _to_float(field: str, value: Any) -> float:
|
|
35
|
+
"""Convert ``value`` to float, raising the documented exception type.
|
|
36
|
+
|
|
37
|
+
``float()`` alone raises a bare ``ValueError``/``TypeError`` for
|
|
38
|
+
non-numeric input, which callers relying on the ``VioletSetpointError``
|
|
39
|
+
contract documented on the setpoint setters would not catch.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
return float(value)
|
|
43
|
+
except (TypeError, ValueError) as err:
|
|
44
|
+
msg = f"Invalid setpoint for '{field}': {value!r} is not a number"
|
|
45
|
+
raise VioletSetpointError(msg) from err
|
|
46
|
+
|
|
47
|
+
|
|
32
48
|
class DosingMixin(APIClientMixin):
|
|
33
49
|
"""Chemical dosing and setpoint operations."""
|
|
34
50
|
|
|
@@ -142,8 +158,9 @@ class DosingMixin(APIClientMixin):
|
|
|
142
158
|
is not a finite number.
|
|
143
159
|
|
|
144
160
|
"""
|
|
145
|
-
|
|
146
|
-
|
|
161
|
+
numeric_value = _to_float(TARGET_PH, value)
|
|
162
|
+
validate_setpoint(TARGET_PH, numeric_value)
|
|
163
|
+
return await self.set_target_value(TARGET_PH, numeric_value)
|
|
147
164
|
|
|
148
165
|
async def set_orp_target(self, value: int) -> dict[str, Any]:
|
|
149
166
|
"""Update the ORP setpoint.
|
|
@@ -159,8 +176,9 @@ class DosingMixin(APIClientMixin):
|
|
|
159
176
|
is not a finite number.
|
|
160
177
|
|
|
161
178
|
"""
|
|
162
|
-
|
|
163
|
-
|
|
179
|
+
numeric_value = _to_float(TARGET_ORP, value)
|
|
180
|
+
validate_setpoint(TARGET_ORP, numeric_value)
|
|
181
|
+
return await self.set_target_value(TARGET_ORP, int(numeric_value))
|
|
164
182
|
|
|
165
183
|
async def set_min_chlorine_level(self, value: float) -> dict[str, Any]:
|
|
166
184
|
"""Update the minimum chlorine level.
|
|
@@ -176,8 +194,9 @@ class DosingMixin(APIClientMixin):
|
|
|
176
194
|
is not a finite number.
|
|
177
195
|
|
|
178
196
|
"""
|
|
179
|
-
|
|
180
|
-
|
|
197
|
+
numeric_value = _to_float(TARGET_MIN_CHLORINE, value)
|
|
198
|
+
validate_setpoint(TARGET_MIN_CHLORINE, numeric_value)
|
|
199
|
+
return await self.set_target_value(TARGET_MIN_CHLORINE, numeric_value)
|
|
181
200
|
|
|
182
201
|
async def set_target_value(self, key: str, value: float) -> dict[str, Any]:
|
|
183
202
|
"""Send a generic target value update to the controller.
|
|
@@ -198,7 +217,8 @@ class DosingMixin(APIClientMixin):
|
|
|
198
217
|
known valid range for ``key``.
|
|
199
218
|
|
|
200
219
|
"""
|
|
201
|
-
|
|
220
|
+
numeric_value = _to_float(key, value)
|
|
221
|
+
validate_setpoint(key, numeric_value)
|
|
202
222
|
return await self.set_config({key: value})
|
|
203
223
|
|
|
204
224
|
async def set_dosing_parameters(
|
|
@@ -217,7 +237,15 @@ class DosingMixin(APIClientMixin):
|
|
|
217
237
|
Returns:
|
|
218
238
|
A dictionary with the command result.
|
|
219
239
|
|
|
240
|
+
Raises:
|
|
241
|
+
VioletSetpointError: If a known setpoint key (see
|
|
242
|
+
``SETPOINT_RANGES``, e.g. ``TARGET_PH``) is present with a
|
|
243
|
+
value outside its documented valid range.
|
|
244
|
+
|
|
220
245
|
"""
|
|
246
|
+
for key, value in parameters.items():
|
|
247
|
+
if key in SETPOINT_RANGES:
|
|
248
|
+
validate_setpoint(key, _to_float(key, value))
|
|
221
249
|
return await self.set_config(dict(parameters))
|
|
222
250
|
|
|
223
251
|
async def set_dosage_enabled(
|
|
@@ -316,7 +344,7 @@ class DosingMixin(APIClientMixin):
|
|
|
316
344
|
f"Expected one of: {sorted(DOSING_CANISTER_ID)}"
|
|
317
345
|
)
|
|
318
346
|
raise VioletPoolAPIError(msg)
|
|
319
|
-
if amount_ml <= 0:
|
|
347
|
+
if int(amount_ml) <= 0:
|
|
320
348
|
raise ValueError(f"amount_ml must be > 0, got {amount_ml}")
|
|
321
349
|
|
|
322
350
|
action = "RESET" if reset else "ADJUST"
|
|
@@ -64,9 +64,10 @@ class OutputsMixin(APIClientMixin):
|
|
|
64
64
|
|
|
65
65
|
duration_ms = validate_duration(duration) * 1000
|
|
66
66
|
payload = f"{output},{mode},{duration_ms}"
|
|
67
|
+
query = quote(payload, safe=",")
|
|
67
68
|
body = await self._request(
|
|
68
69
|
API_SET_OUTPUT_TESTMODE,
|
|
69
|
-
query=
|
|
70
|
+
query=query,
|
|
70
71
|
)
|
|
71
72
|
return self._command_result(body)
|
|
72
73
|
|
|
@@ -493,13 +494,22 @@ class OutputsMixin(APIClientMixin):
|
|
|
493
494
|
f"Expected one of {RS485_PUMP_MODES}"
|
|
494
495
|
)
|
|
495
496
|
raise VioletPoolAPIError(msg)
|
|
496
|
-
|
|
497
|
+
try:
|
|
498
|
+
slave_id_int = int(slave_id)
|
|
499
|
+
except (TypeError, ValueError) as err:
|
|
500
|
+
raise ValueError(f"slave_id must be an integer, got {slave_id!r}") from err
|
|
501
|
+
if slave_id_int < 1 or slave_id_int > 247:
|
|
497
502
|
raise ValueError(f"slave_id must be 1-247, got {slave_id}")
|
|
498
|
-
|
|
503
|
+
|
|
504
|
+
try:
|
|
505
|
+
level_finite = math.isfinite(float(level))
|
|
506
|
+
except (TypeError, ValueError) as err:
|
|
507
|
+
raise ValueError(f"level must be a number, got {level!r}") from err
|
|
508
|
+
if not level_finite:
|
|
499
509
|
raise ValueError(f"level must be finite, got {level}")
|
|
500
510
|
|
|
501
511
|
url = (
|
|
502
|
-
f"{API_SET_RS485_LIVE}?{pump_name},{
|
|
512
|
+
f"{API_SET_RS485_LIVE}?{pump_name},{slave_id_int},"
|
|
503
513
|
f"{mode.lower()},{level}"
|
|
504
514
|
)
|
|
505
515
|
body = await self._request(
|
|
@@ -7,7 +7,7 @@ from collections.abc import Mapping
|
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
9
|
from ._api_mixin import APIClientMixin
|
|
10
|
-
from ._api_model import VioletPoolAPIError
|
|
10
|
+
from ._api_model import VioletPayloadError, VioletPoolAPIError
|
|
11
11
|
from .const_api import (
|
|
12
12
|
API_GET_LIVE_TRACE,
|
|
13
13
|
API_GET_LOG,
|
|
@@ -268,7 +268,12 @@ class SystemMixin(APIClientMixin):
|
|
|
268
268
|
if not state_key:
|
|
269
269
|
continue
|
|
270
270
|
if state_key in raw:
|
|
271
|
-
|
|
271
|
+
raw_value = raw[state_key]
|
|
272
|
+
try:
|
|
273
|
+
result[service] = bool(int(float(raw_value)))
|
|
274
|
+
except (TypeError, ValueError, OverflowError) as err:
|
|
275
|
+
msg = f"Invalid service state for {service}: {raw_value!r}"
|
|
276
|
+
raise VioletPayloadError(msg) from err
|
|
272
277
|
return result
|
|
273
278
|
|
|
274
279
|
async def get_live_trace(self) -> dict[str, str]:
|
|
@@ -332,17 +332,6 @@ class VioletPoolAPI(ReadingsMixin, DosingMixin, OutputsMixin, SystemMixin):
|
|
|
332
332
|
raise ValueError(msg)
|
|
333
333
|
|
|
334
334
|
async def _execute_request() -> Any: # noqa: ANN401
|
|
335
|
-
# Wait if the rate limit is reached
|
|
336
|
-
try:
|
|
337
|
-
await self._rate_limiter.wait_if_needed(priority=priority, timeout=10.0)
|
|
338
|
-
except TimeoutError:
|
|
339
|
-
_LOGGER.warning(
|
|
340
|
-
"Rate limiter timeout for %s (priority: %d) - applying fallback delay",
|
|
341
|
-
endpoint,
|
|
342
|
-
priority,
|
|
343
|
-
)
|
|
344
|
-
await asyncio.sleep(1.0)
|
|
345
|
-
|
|
346
335
|
url = self._build_url(endpoint)
|
|
347
336
|
if query:
|
|
348
337
|
url = f"{url}?{query}"
|
|
@@ -352,6 +341,18 @@ class VioletPoolAPI(ReadingsMixin, DosingMixin, OutputsMixin, SystemMixin):
|
|
|
352
341
|
attempt_limit = self._max_retries if should_retry else 1
|
|
353
342
|
|
|
354
343
|
for attempt in range(1, attempt_limit + 1):
|
|
344
|
+
# Wait if the rate limit is reached; re-acquired on every
|
|
345
|
+
# attempt so retries cannot bypass the per-request cap.
|
|
346
|
+
try:
|
|
347
|
+
await self._rate_limiter.wait_if_needed(priority=priority, timeout=10.0)
|
|
348
|
+
except TimeoutError:
|
|
349
|
+
_LOGGER.warning(
|
|
350
|
+
"Rate limiter timeout for %s (priority: %d) - applying fallback delay",
|
|
351
|
+
endpoint,
|
|
352
|
+
priority,
|
|
353
|
+
)
|
|
354
|
+
await asyncio.sleep(1.0)
|
|
355
|
+
|
|
355
356
|
try:
|
|
356
357
|
async with self._session.request(
|
|
357
358
|
method,
|
|
@@ -367,6 +368,9 @@ class VioletPoolAPI(ReadingsMixin, DosingMixin, OutputsMixin, SystemMixin):
|
|
|
367
368
|
response.status >= _HTTP_SERVER_ERROR
|
|
368
369
|
or response.status == _HTTP_TOO_MANY_REQUESTS
|
|
369
370
|
):
|
|
371
|
+
# Drain the body so aiohttp can return the
|
|
372
|
+
# connection to the pool instead of closing it.
|
|
373
|
+
await response.read()
|
|
370
374
|
# Server error or rate limit
|
|
371
375
|
# -> trigger retry via ClientError
|
|
372
376
|
response.raise_for_status()
|
|
@@ -132,6 +132,16 @@ class InputSanitizer:
|
|
|
132
132
|
return float(value)
|
|
133
133
|
|
|
134
134
|
str_value = str(value).strip()
|
|
135
|
+
try:
|
|
136
|
+
parsed = float(str_value)
|
|
137
|
+
except ValueError:
|
|
138
|
+
pass
|
|
139
|
+
else:
|
|
140
|
+
if not math.isfinite(parsed):
|
|
141
|
+
_LOGGER.warning("Unendlicher/NaN-Wert: %s", value)
|
|
142
|
+
return 0.0
|
|
143
|
+
return parsed
|
|
144
|
+
|
|
135
145
|
has_minus = str_value.startswith("-")
|
|
136
146
|
cleaned = re.sub(r"[^0-9.]", "", str_value)
|
|
137
147
|
if has_minus and cleaned:
|
|
@@ -398,13 +408,14 @@ class InputSanitizer:
|
|
|
398
408
|
ph: pH-Wert
|
|
399
409
|
|
|
400
410
|
Returns:
|
|
401
|
-
Validierter pH-Wert (6.0-
|
|
411
|
+
Validierter pH-Wert (6.0-8.0), passend zum vom Controller
|
|
412
|
+
akzeptierten Sollwertbereich (siehe ``SETPOINT_RANGES``).
|
|
402
413
|
|
|
403
414
|
"""
|
|
404
415
|
return InputSanitizer.sanitize_float(
|
|
405
416
|
ph,
|
|
406
417
|
min_value=6.0,
|
|
407
|
-
max_value=
|
|
418
|
+
max_value=8.0,
|
|
408
419
|
precision=1,
|
|
409
420
|
default=7.2,
|
|
410
421
|
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/tests/test_api_smoke.py
RENAMED
|
File without changes
|
{violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/tests/test_circuit_breaker.py
RENAMED
|
File without changes
|
{violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/tests/test_mock_server.py
RENAMED
|
File without changes
|
|
File without changes
|
{violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/tests/test_rate_limiter.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
|
|
File without changes
|
|
File without changes
|