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.
Files changed (34) hide show
  1. {violet_poolcontroller_api-0.0.36/violet_poolController_api.egg-info → violet_poolcontroller_api-0.0.37}/PKG-INFO +1 -1
  2. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/pyproject.toml +1 -1
  3. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/tests/test_api.py +82 -1
  4. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/tests/test_readings.py +31 -0
  5. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/tests/test_sanitizer.py +18 -0
  6. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37/violet_poolController_api.egg-info}/PKG-INFO +1 -1
  7. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/_api_dosing.py +36 -8
  8. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/_api_outputs.py +14 -4
  9. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/_api_system.py +7 -2
  10. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/api.py +15 -11
  11. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/readings.py +1 -1
  12. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/utils_sanitizer.py +13 -2
  13. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/LICENSE +0 -0
  14. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/README.md +0 -0
  15. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/setup.cfg +0 -0
  16. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/tests/test_api_smoke.py +0 -0
  17. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/tests/test_circuit_breaker.py +0 -0
  18. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/tests/test_mock_server.py +0 -0
  19. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/tests/test_parsers.py +0 -0
  20. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/tests/test_rate_limiter.py +0 -0
  21. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolController_api.egg-info/SOURCES.txt +0 -0
  22. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolController_api.egg-info/dependency_links.txt +0 -0
  23. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolController_api.egg-info/requires.txt +0 -0
  24. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolController_api.egg-info/top_level.txt +0 -0
  25. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/__init__.py +0 -0
  26. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/_api_mixin.py +0 -0
  27. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/_api_model.py +0 -0
  28. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/_api_readings.py +0 -0
  29. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/circuit_breaker.py +0 -0
  30. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/const_api.py +0 -0
  31. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/const_devices.py +0 -0
  32. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/parsers.py +0 -0
  33. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/py.typed +0 -0
  34. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/utils_rate_limiter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: violet-poolController-api
3
- Version: 0.0.36
3
+ Version: 0.0.37
4
4
  Summary: Asynchronous Python client for the Violet Pool Controller.
5
5
  Author-email: "Basti (Xerolux)" <git@xerolux.de>
6
6
  License-Expression: AGPL-3.0-or-later
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "violet-poolController-api"
7
- version = "0.0.36"
7
+ version = "0.0.37"
8
8
  authors = [
9
9
  { name="Basti (Xerolux)", email="git@xerolux.de" },
10
10
  ]
@@ -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 VioletPoolAPI, VioletPoolAPIError
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")
@@ -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
 
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: violet-poolController-api
3
- Version: 0.0.36
3
+ Version: 0.0.37
4
4
  Summary: Asynchronous Python client for the Violet Pool Controller.
5
5
  Author-email: "Basti (Xerolux)" <git@xerolux.de>
6
6
  License-Expression: AGPL-3.0-or-later
@@ -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
- validate_setpoint(TARGET_PH, float(value))
146
- return await self.set_target_value(TARGET_PH, float(value))
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
- validate_setpoint(TARGET_ORP, float(value))
163
- return await self.set_target_value(TARGET_ORP, int(value))
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
- validate_setpoint(TARGET_MIN_CHLORINE, float(value))
180
- return await self.set_target_value(TARGET_MIN_CHLORINE, float(value))
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
- validate_setpoint(key, float(value))
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=payload,
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
- if slave_id < 1 or slave_id > 247:
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
- if not math.isfinite(float(level)):
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},{int(slave_id)},"
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
- result[service] = bool(int(raw[state_key]))
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()
@@ -444,7 +444,7 @@ class VioletReadings(Mapping[str, Any]):
444
444
  result[key] = None
445
445
  else:
446
446
  try:
447
- result[key] = int(v)
447
+ result[key] = int(float(v))
448
448
  except (ValueError, TypeError):
449
449
  result[key] = None
450
450
  return result
@@ -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-9.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=9.0,
418
+ max_value=8.0,
408
419
  precision=1,
409
420
  default=7.2,
410
421
  )