violet-poolController-api 0.0.36__tar.gz → 0.0.38__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 (35) hide show
  1. {violet_poolcontroller_api-0.0.36/violet_poolController_api.egg-info → violet_poolcontroller_api-0.0.38}/PKG-INFO +1 -1
  2. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/pyproject.toml +1 -1
  3. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/tests/test_api.py +83 -2
  4. violet_poolcontroller_api-0.0.38/tests/test_language_policy.py +136 -0
  5. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/tests/test_readings.py +31 -0
  6. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/tests/test_sanitizer.py +18 -0
  7. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38/violet_poolController_api.egg-info}/PKG-INFO +1 -1
  8. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolController_api.egg-info/SOURCES.txt +1 -0
  9. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/__init__.py +1 -1
  10. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/_api_dosing.py +36 -8
  11. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/_api_outputs.py +14 -4
  12. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/_api_system.py +7 -2
  13. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/api.py +16 -12
  14. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/circuit_breaker.py +1 -1
  15. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/const_api.py +1 -1
  16. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/const_devices.py +1 -1
  17. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/parsers.py +1 -1
  18. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/readings.py +2 -2
  19. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/utils_rate_limiter.py +7 -7
  20. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/utils_sanitizer.py +27 -16
  21. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/LICENSE +0 -0
  22. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/README.md +0 -0
  23. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/setup.cfg +0 -0
  24. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/tests/test_api_smoke.py +0 -0
  25. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/tests/test_circuit_breaker.py +0 -0
  26. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/tests/test_mock_server.py +0 -0
  27. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/tests/test_parsers.py +0 -0
  28. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/tests/test_rate_limiter.py +0 -0
  29. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolController_api.egg-info/dependency_links.txt +0 -0
  30. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolController_api.egg-info/requires.txt +0 -0
  31. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolController_api.egg-info/top_level.txt +0 -0
  32. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/_api_mixin.py +0 -0
  33. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/_api_model.py +0 -0
  34. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/_api_readings.py +0 -0
  35. {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/py.typed +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.38
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.38"
8
8
  authors = [
9
9
  { name="Basti (Xerolux)", email="git@xerolux.de" },
10
10
  ]
@@ -1,4 +1,4 @@
1
- # violet-poolController-api - API für Violet Pool Controller
1
+ # violet-poolController-api - API for Violet Pool Controller
2
2
  # Copyright (C) 2024-2026 Xerolux
3
3
  #
4
4
  # This program is free software: you can redistribute it and/or modify
@@ -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")
@@ -0,0 +1,136 @@
1
+ # violet-poolController-api - API for Violet Pool Controller
2
+ # Copyright (C) 2024-2026 Xerolux
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published
6
+ # by the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """Everything written into this repository is English.
18
+
19
+ The policy lives in AGENTS.md; this file is what makes it hold. Two things are
20
+ deliberately exempt and must stay exempt: the controller's own error strings in
21
+ ``const_api.py`` (and the German payloads the mock server replays), which are
22
+ data rather than prose, and the German half of the bilingual documentation
23
+ under ``docs/de/``.
24
+ """
25
+
26
+ # ruff: noqa: S101
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ from pathlib import Path
32
+
33
+ import pytest
34
+
35
+ REPO = Path(__file__).parent.parent
36
+ PACKAGE = REPO / "violet_poolcontroller_api"
37
+
38
+ # Text the controller emits, reproduced verbatim. Translating it would break
39
+ # the match with what the device actually says.
40
+ #
41
+ # Matched by file name, not by path: `python -m build` copies the package into
42
+ # `build/lib/`, and an exemption keyed to the original path does not cover the
43
+ # copy. That is not hypothetical - it failed the 0.0.38 release job, because
44
+ # the release builds the wheel before running the checks.
45
+ CONTROLLER_VERBATIM = frozenset({"const_api.py", "mock_server.py"})
46
+
47
+ # This file has to name the German words it looks for.
48
+ SELF = Path(__file__).name
49
+
50
+ # Directories that hold generated or vendored copies of the sources. Scanning
51
+ # them says nothing about what is written in this repository.
52
+ GENERATED_DIRS = frozenset({".git", ".tox", ".venv", "venv", "build", "dist", "__pycache__"})
53
+
54
+ # Words that only appear in German prose. Deliberately not "in", "die" or "der":
55
+ # those collide with English or with identifiers.
56
+ GERMAN_WORDS = (
57
+ "für",
58
+ "über",
59
+ "nicht",
60
+ "wird",
61
+ "werden",
62
+ "wenn",
63
+ "diese",
64
+ "dieser",
65
+ "keine",
66
+ "sollte",
67
+ )
68
+ _GERMAN = re.compile(r"\b(" + "|".join(GERMAN_WORDS) + r")\b", re.IGNORECASE)
69
+
70
+
71
+ def _python_sources() -> list[Path]:
72
+ """Return every Python file the policy applies to."""
73
+ return sorted(
74
+ path
75
+ for path in REPO.rglob("*.py")
76
+ if not (GENERATED_DIRS & set(path.parts))
77
+ and not any(part.endswith(".egg-info") for part in path.parts)
78
+ and path.name not in CONTROLLER_VERBATIM
79
+ and path.name != SELF
80
+ )
81
+
82
+
83
+ @pytest.mark.parametrize("path", _python_sources(), ids=lambda p: p.name)
84
+ def test_python_sources_are_english(path: Path) -> None:
85
+ """A German comment is read by people who do not speak German."""
86
+ offenders = [
87
+ f"{path.relative_to(REPO)}:{number}: {line.strip()}"
88
+ for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1)
89
+ if _GERMAN.search(line)
90
+ ]
91
+
92
+ assert not offenders, "German text outside the exempt files:\n" + "\n".join(offenders)
93
+
94
+
95
+ def test_the_changelog_is_english() -> None:
96
+ """The changelog is what a consumer reads when a version breaks something.
97
+
98
+ Code spans are quotations, not prose: an entry that removes a German string
99
+ has to be able to name the string it removed.
100
+ """
101
+ changelog = (REPO / "CHANGELOG.md").read_text(encoding="utf-8")
102
+ prose = re.sub(r"`[^`]*`", "", changelog)
103
+
104
+ offenders = sorted({match.group(0).lower() for match in _GERMAN.finditer(prose)})
105
+
106
+ assert not offenders, f"German outside code spans in CHANGELOG.md: {offenders}"
107
+
108
+
109
+ def test_the_controller_strings_stay_german() -> None:
110
+ """The exemption is the point, not an oversight - guard it too.
111
+
112
+ If someone "cleans up" the error table into English, the messages stop
113
+ matching what the device reports.
114
+ """
115
+ const_api = (PACKAGE / "const_api.py").read_text(encoding="utf-8")
116
+
117
+ assert "Filterdrucküberwachung (Druck zu niedrig)" in const_api
118
+
119
+
120
+ def test_generated_copies_are_not_scanned() -> None:
121
+ """A built wheel is not source, and scanning its copy fails the release.
122
+
123
+ `python -m build` writes `build/lib/<package>/`, so the release job runs
124
+ the checks with a duplicate of every module on disk.
125
+ """
126
+ scanned = {path.name for path in _python_sources()}
127
+
128
+ assert "const_api.py" not in scanned
129
+ assert not [path for path in _python_sources() if "build" in path.parts]
130
+
131
+
132
+ def test_the_policy_is_written_down() -> None:
133
+ """Without the rule in AGENTS.md this file is just an opinion."""
134
+ agents = (REPO / "AGENTS.md").read_text(encoding="utf-8")
135
+
136
+ assert "## Language Policy" in agents
@@ -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.38
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,6 +4,7 @@ pyproject.toml
4
4
  tests/test_api.py
5
5
  tests/test_api_smoke.py
6
6
  tests/test_circuit_breaker.py
7
+ tests/test_language_policy.py
7
8
  tests/test_mock_server.py
8
9
  tests/test_parsers.py
9
10
  tests/test_rate_limiter.py
@@ -1,4 +1,4 @@
1
- # violet-poolController-api - API für Violet Pool Controller
1
+ # violet-poolController-api - API for Violet Pool Controller
2
2
  # Copyright (C) 2024-2026 Xerolux
3
3
  #
4
4
  # This program is free software: you can redistribute it and/or modify
@@ -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]:
@@ -1,4 +1,4 @@
1
- # violet-poolController-api - API für Violet Pool Controller
1
+ # violet-poolController-api - API for Violet Pool Controller
2
2
  # Copyright (C) 2024-2026 Xerolux
3
3
  #
4
4
  # This program is free software: you can redistribute it and/or modify
@@ -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()
@@ -1,4 +1,4 @@
1
- # violet-poolController-api - API für Violet Pool Controller
1
+ # violet-poolController-api - API for Violet Pool Controller
2
2
  # Copyright (C) 2024-2026 Xerolux
3
3
  #
4
4
  # This program is free software: you can redistribute it and/or modify
@@ -1,4 +1,4 @@
1
- # violet-poolController-api - API für Violet Pool Controller
1
+ # violet-poolController-api - API for Violet Pool Controller
2
2
  # Copyright (C) 2024-2026 Xerolux
3
3
  #
4
4
  # This program is free software: you can redistribute it and/or modify
@@ -1,4 +1,4 @@
1
- # violet-poolController-api - API für Violet Pool Controller
1
+ # violet-poolController-api - API for Violet Pool Controller
2
2
  # Copyright (C) 2024-2026 Xerolux
3
3
  #
4
4
  # This program is free software: you can redistribute it and/or modify
@@ -1,4 +1,4 @@
1
- # violet-poolController-api - API für Violet Pool Controller
1
+ # violet-poolController-api - API for Violet Pool Controller
2
2
  # Copyright (C) 2024-2026 Xerolux
3
3
  #
4
4
  # This program is free software: you can redistribute it and/or modify
@@ -1,4 +1,4 @@
1
- # violet-poolController-api - API für Violet Pool Controller
1
+ # violet-poolController-api - API for Violet Pool Controller
2
2
  # Copyright (C) 2024-2026 Xerolux
3
3
  #
4
4
  # This program is free software: you can redistribute it and/or modify
@@ -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
@@ -1,4 +1,4 @@
1
- # violet-poolController-api - API für Violet Pool Controller
1
+ # violet-poolController-api - API for Violet Pool Controller
2
2
  # Copyright (C) 2024-2026 Xerolux
3
3
  #
4
4
  # This program is free software: you can redistribute it and/or modify
@@ -14,7 +14,7 @@
14
14
  # You should have received a copy of the GNU Affero General Public License
15
15
  # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
16
 
17
- """Rate Limiter für API-Requests - Token Bucket Algorithm."""
17
+ """Rate limiter for API requests - token bucket algorithm."""
18
18
 
19
19
  from __future__ import annotations
20
20
 
@@ -34,8 +34,8 @@ class RateLimiter:
34
34
 
35
35
  Verhindert API-Overload durch:
36
36
  - Maximale Requests pro Zeitfenster
37
- - Burst-Support für kurzzeitige Spitzen
38
- - Priority Queue für kritische Requests
37
+ - Burst support for short spikes
38
+ - Priority queue for critical requests
39
39
  - Graceful Degradation bei Limit-Überschreitung
40
40
  """
41
41
 
@@ -89,7 +89,7 @@ class RateLimiter:
89
89
  "last_minute_reset": time.monotonic(),
90
90
  }
91
91
 
92
- # Lock für Thread-Safety
92
+ # Lock for thread safety
93
93
  self._lock = asyncio.Lock()
94
94
  self._waiters: list[tuple[int, int, asyncio.Event]] = []
95
95
  self._waiter_sequence = 0
@@ -169,7 +169,7 @@ class RateLimiter:
169
169
  timeout: Maximale Wartezeit in Sekunden
170
170
 
171
171
  Raises:
172
- TimeoutError: Wenn Timeout erreicht
172
+ TimeoutError: If the timeout is reached
173
173
 
174
174
  """
175
175
  start_time = time.monotonic()
@@ -279,7 +279,7 @@ class RateLimiter:
279
279
  _LOGGER.debug("Rate Limiter zurückgesetzt")
280
280
 
281
281
 
282
- # Global Rate Limiter-Instanz (kann pro API-Instanz auch separat erstellt werden)
282
+ # Global rate limiter instance (one can also be created per API instance)
283
283
  _global_rate_limiter: RateLimiter | None = None
284
284
 
285
285
 
@@ -1,4 +1,4 @@
1
- # violet-poolController-api - API für Violet Pool Controller
1
+ # violet-poolController-api - API for Violet Pool Controller
2
2
  # Copyright (C) 2024-2026 Xerolux
3
3
  #
4
4
  # This program is free software: you can redistribute it and/or modify
@@ -15,7 +15,7 @@
15
15
  # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
16
 
17
17
 
18
- """Input Sanitization Utilities für User-Inputs und API-Parameter."""
18
+ """Input sanitization utilities for user inputs and API parameters."""
19
19
 
20
20
  from __future__ import annotations
21
21
 
@@ -33,11 +33,11 @@ _MAX_API_PARAM_LENGTH = 100
33
33
 
34
34
 
35
35
  class InputSanitizer:
36
- """Input Sanitization für Sicherheit und Datenintegrität.
36
+ """Input sanitization for security and data integrity.
37
37
 
38
38
  Schützt vor:
39
39
  - XSS (Cross-Site Scripting)
40
- - SQL Injection (nicht relevant bei HTTP API, aber defensiv)
40
+ - SQL injection (not relevant for an HTTP API, but defended against)
41
41
  - Command Injection
42
42
  - Path Traversal
43
43
  - Unerwarteten Zeichen
@@ -85,14 +85,14 @@ class InputSanitizer:
85
85
  # Konvertiere zu String
86
86
  str_value = str(value).strip()
87
87
 
88
- # Unicode-Normalisierung (NFKD für Defense-in-Depth)
88
+ # Unicode normalisation (NFKD, defence in depth)
89
89
  # Normalized Form Compatibility Decomposition
90
90
  str_value = unicodedata.normalize("NFKD", str_value)
91
91
 
92
92
  # Längen-Validierung
93
93
  if len(str_value) > max_length:
94
94
  _LOGGER.warning(
95
- "String zu lang (%d > %d), wird gekürzt: %s...",
95
+ "String too long (%d > %d), truncating: %s...",
96
96
  len(str_value),
97
97
  max_length,
98
98
  str_value[:50],
@@ -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:
@@ -223,7 +233,7 @@ class InputSanitizer:
223
233
  float_value = float(value)
224
234
  if not math.isfinite(float_value):
225
235
  _LOGGER.warning(
226
- "Nicht-endlicher Float-Wert '%s', verwende default %.2f",
236
+ "Non-finite float value '%s', using default %.2f",
227
237
  value,
228
238
  default,
229
239
  )
@@ -305,7 +315,7 @@ class InputSanitizer:
305
315
 
306
316
  """
307
317
  if not key:
308
- msg = "Device-Key darf nicht leer sein"
318
+ msg = "Device key must not be empty"
309
319
  raise ValueError(msg)
310
320
 
311
321
  # Nur Großbuchstaben, Zahlen und Underscore erlaubt
@@ -341,7 +351,7 @@ class InputSanitizer:
341
351
 
342
352
  """
343
353
  if not param:
344
- msg = "API-Parameter darf nicht leer sein"
354
+ msg = "API parameter must not be empty"
345
355
  raise ValueError(msg)
346
356
 
347
357
  # Prüfe auf Path Traversal VOR der Bereinigung
@@ -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
  )
@@ -447,27 +458,27 @@ class InputSanitizer:
447
458
  )
448
459
 
449
460
 
450
- # Singleton-Instanz für einfachen Zugriff
461
+ # Singleton instance for convenient access
451
462
  _sanitizer = InputSanitizer()
452
463
 
453
464
 
454
465
  def sanitize_string(*args: Any, **kwargs: Any) -> str: # noqa: ANN401
455
- """Shortcut für InputSanitizer.sanitize_string()."""
466
+ """Shortcut for InputSanitizer.sanitize_string()."""
456
467
  return _sanitizer.sanitize_string(*args, **kwargs)
457
468
 
458
469
 
459
470
  def sanitize_integer(*args: Any, **kwargs: Any) -> int: # noqa: ANN401
460
- """Shortcut für InputSanitizer.sanitize_integer()."""
471
+ """Shortcut for InputSanitizer.sanitize_integer()."""
461
472
  return _sanitizer.sanitize_integer(*args, **kwargs)
462
473
 
463
474
 
464
475
  def sanitize_float(*args: Any, **kwargs: Any) -> float: # noqa: ANN401
465
- """Shortcut für InputSanitizer.sanitize_float()."""
476
+ """Shortcut for InputSanitizer.sanitize_float()."""
466
477
  return _sanitizer.sanitize_float(*args, **kwargs)
467
478
 
468
479
 
469
480
  def sanitize_boolean(*args: Any, **kwargs: Any) -> bool: # noqa: ANN401
470
- """Shortcut für InputSanitizer.sanitize_boolean()."""
481
+ """Shortcut for InputSanitizer.sanitize_boolean()."""
471
482
  return _sanitizer.sanitize_boolean(*args, **kwargs)
472
483
 
473
484