violet-poolController-api 0.0.35__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 (36) hide show
  1. {violet_poolcontroller_api-0.0.35/violet_poolController_api.egg-info → violet_poolcontroller_api-0.0.37}/PKG-INFO +7 -6
  2. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/pyproject.toml +17 -13
  3. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/tests/test_api.py +352 -3
  4. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/tests/test_api_smoke.py +43 -22
  5. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/tests/test_circuit_breaker.py +44 -3
  6. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/tests/test_mock_server.py +11 -5
  7. violet_poolcontroller_api-0.0.37/tests/test_parsers.py +139 -0
  8. violet_poolcontroller_api-0.0.37/tests/test_rate_limiter.py +52 -0
  9. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/tests/test_readings.py +42 -2
  10. violet_poolcontroller_api-0.0.37/tests/test_sanitizer.py +43 -0
  11. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37/violet_poolController_api.egg-info}/PKG-INFO +7 -6
  12. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolController_api.egg-info/SOURCES.txt +8 -0
  13. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolController_api.egg-info/requires.txt +2 -1
  14. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/__init__.py +1 -1
  15. violet_poolcontroller_api-0.0.37/violet_poolcontroller_api/_api_dosing.py +363 -0
  16. violet_poolcontroller_api-0.0.37/violet_poolcontroller_api/_api_mixin.py +85 -0
  17. violet_poolcontroller_api-0.0.37/violet_poolcontroller_api/_api_model.py +81 -0
  18. violet_poolcontroller_api-0.0.37/violet_poolcontroller_api/_api_outputs.py +546 -0
  19. violet_poolcontroller_api-0.0.37/violet_poolcontroller_api/_api_readings.py +256 -0
  20. violet_poolcontroller_api-0.0.37/violet_poolcontroller_api/_api_system.py +380 -0
  21. violet_poolcontroller_api-0.0.37/violet_poolcontroller_api/api.py +807 -0
  22. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/circuit_breaker.py +39 -26
  23. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/const_api.py +2 -0
  24. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/const_devices.py +1 -1
  25. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/parsers.py +2 -2
  26. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/readings.py +4 -2
  27. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/utils_rate_limiter.py +102 -48
  28. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/utils_sanitizer.py +22 -56
  29. violet_poolcontroller_api-0.0.35/tests/test_parsers.py +0 -161
  30. violet_poolcontroller_api-0.0.35/violet_poolcontroller_api/api.py +0 -2222
  31. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/LICENSE +0 -0
  32. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/README.md +0 -0
  33. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/setup.cfg +0 -0
  34. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolController_api.egg-info/dependency_links.txt +0 -0
  35. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolController_api.egg-info/top_level.txt +0 -0
  36. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/py.typed +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: violet-poolController-api
3
- Version: 0.0.35
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
- License: AGPL-3.0-or-later
7
- Project-URL: Homepage, https://github.com/Xerolux/violet-hass
8
- Project-URL: Bug Tracker, https://github.com/Xerolux/violet-hass/issues
6
+ License-Expression: AGPL-3.0-or-later
7
+ Project-URL: Homepage, https://github.com/Xerolux/violet-poolController-api
8
+ Project-URL: Bug Tracker, https://github.com/Xerolux/violet-poolController-api/issues
9
9
  Project-URL: License, https://www.gnu.org/licenses/agpl-3.0.html
10
- Project-URL: Changelog, https://github.com/Xerolux/violet-hass/blob/main/violet_poolcontroller_api/CHANGELOG.md
10
+ Project-URL: Changelog, https://github.com/Xerolux/violet-poolController-api/blob/main/CHANGELOG.md
11
11
  Keywords: pool,violet,controller,home-assistant,async
12
12
  Classifier: Programming Language :: Python :: 3
13
13
  Classifier: Programming Language :: Python :: 3.12
@@ -22,7 +22,8 @@ Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Requires-Dist: aiohttp<3.15,>=3.11.0
24
24
  Provides-Extra: test
25
- Requires-Dist: aioresponses>=0.7.8; extra == "test"
25
+ Requires-Dist: aioresponses>=0.7.9; extra == "test"
26
+ Requires-Dist: mypy>=1.15; extra == "test"
26
27
  Requires-Dist: pytest>=8.3; extra == "test"
27
28
  Requires-Dist: pytest-asyncio>=0.24; extra == "test"
28
29
  Dynamic: license-file
@@ -1,23 +1,18 @@
1
1
  [build-system]
2
- requires = ["setuptools>=61.0.0", "wheel"]
2
+ requires = ["setuptools>=77.0.0", "wheel"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
- [tool.setuptools.packages.find]
6
- include = ["violet_poolcontroller_api*"]
7
-
8
- [tool.setuptools.package-data]
9
- violet_poolcontroller_api = ["py.typed"]
10
-
11
5
  [project]
12
6
  name = "violet-poolController-api"
13
- version = "0.0.35"
7
+ version = "0.0.37"
14
8
  authors = [
15
9
  { name="Basti (Xerolux)", email="git@xerolux.de" },
16
10
  ]
17
11
  description = "Asynchronous Python client for the Violet Pool Controller."
18
12
  readme = "README.md"
19
13
  requires-python = ">=3.12"
20
- license = { text = "AGPL-3.0-or-later" }
14
+ license = "AGPL-3.0-or-later"
15
+ license-files = ["LICENSE"]
21
16
  keywords = ["pool", "violet", "controller", "home-assistant", "async"]
22
17
  classifiers = [
23
18
  "Programming Language :: Python :: 3",
@@ -35,20 +30,27 @@ dependencies = [
35
30
 
36
31
  [project.optional-dependencies]
37
32
  test = [
38
- "aioresponses>=0.7.8",
33
+ "aioresponses>=0.7.9",
34
+ "mypy>=1.15",
39
35
  "pytest>=8.3",
40
36
  "pytest-asyncio>=0.24",
41
37
  ]
42
38
 
43
39
  [project.urls]
44
- "Homepage" = "https://github.com/Xerolux/violet-hass"
45
- "Bug Tracker" = "https://github.com/Xerolux/violet-hass/issues"
40
+ "Homepage" = "https://github.com/Xerolux/violet-poolController-api"
41
+ "Bug Tracker" = "https://github.com/Xerolux/violet-poolController-api/issues"
46
42
  "License" = "https://www.gnu.org/licenses/agpl-3.0.html"
47
- "Changelog" = "https://github.com/Xerolux/violet-hass/blob/main/violet_poolcontroller_api/CHANGELOG.md"
43
+ "Changelog" = "https://github.com/Xerolux/violet-poolController-api/blob/main/CHANGELOG.md"
48
44
 
49
45
  [tool.pytest.ini_options]
50
46
  asyncio_mode = "auto"
51
47
 
48
+ [tool.setuptools.packages.find]
49
+ include = ["violet_poolcontroller_api*"]
50
+
51
+ [tool.setuptools.package-data]
52
+ violet_poolcontroller_api = ["py.typed"]
53
+
52
54
  [tool.ruff]
53
55
  line-length = 100
54
56
  target-version = "py312"
@@ -61,3 +63,5 @@ ignore = ["E501"]
61
63
  python_version = "3.12"
62
64
  warn_return_any = true
63
65
  warn_unused_configs = true
66
+ check_untyped_defs = true
67
+ disallow_untyped_defs = true
@@ -1,4 +1,4 @@
1
- # violet-poolController-api - API f├╝r Violet Pool Controller
1
+ # violet-poolController-api - API für 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:
@@ -527,7 +533,7 @@ async def test_ext1_readings_filtered_when_not_detected(mock_aioresponse, api_cl
527
533
  payload={
528
534
  "getReadings": {
529
535
  "PUMPSTATE": "2",
530
- # No SYSTEM_ext1module_alive_count  module not connected
536
+ # No SYSTEM_ext1module_alive_count module not connected
531
537
  "EXT1_1": 0,
532
538
  "EXT1_2": 0,
533
539
  }
@@ -842,6 +848,32 @@ async def test_set_device_temperature_solar(
842
848
  assert result["success"] is True
843
849
 
844
850
 
851
+ @pytest.mark.asyncio
852
+ async def test_set_device_temperature_normalizes_key(
853
+ mock_aioresponse: aioresponses,
854
+ api_client: VioletPoolAPI,
855
+ ) -> None:
856
+ url = "http://192.168.1.100/setConfig"
857
+ mock_aioresponse.post(url, body="OK", status=200)
858
+
859
+ await api_client.set_device_temperature(" heater ", 28.0)
860
+
861
+ request = mock_aioresponse.requests[("POST", URL(url))][0]
862
+ assert request.kwargs["data"] == {"HEATER_set_temp": 28.0}
863
+
864
+
865
+ @pytest.mark.asyncio
866
+ async def test_set_device_temperature_rejects_unknown_key(api_client: VioletPoolAPI) -> None:
867
+ with pytest.raises(VioletPoolAPIError, match="Expected HEATER or SOLAR"):
868
+ await api_client.set_device_temperature("DOSAGE", 28.0)
869
+
870
+
871
+ @pytest.mark.asyncio
872
+ async def test_set_device_temperature_cannot_bypass_range(api_client: VioletPoolAPI) -> None:
873
+ with pytest.raises(VioletPoolAPIError, match="outside the valid range"):
874
+ await api_client.set_device_temperature("heater", 1000.0)
875
+
876
+
845
877
  @pytest.mark.asyncio
846
878
  async def test_set_ph_target(
847
879
  mock_aioresponse: aioresponses,
@@ -912,6 +944,29 @@ async def test_set_dosing_parameters(
912
944
  assert result["success"] is True
913
945
 
914
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
+
956
+ @pytest.mark.asyncio
957
+ @pytest.mark.parametrize(("raw_value", "expected"), [("1", True), ("1.0", True), ("0.0", False)])
958
+ async def test_is_dosage_enabled_accepts_numeric_strings(
959
+ mock_aioresponse: aioresponses,
960
+ api_client: VioletPoolAPI,
961
+ raw_value: str,
962
+ expected: bool,
963
+ ) -> None:
964
+ url = "http://192.168.1.100/getConfig?DOSAGE_phminus_use"
965
+ mock_aioresponse.get(url, payload={"DOSAGE_phminus_use": raw_value}, status=200)
966
+
967
+ assert await api_client.is_dosage_enabled("pH-") is expected
968
+
969
+
915
970
  @pytest.mark.asyncio
916
971
  async def test_reset_blocking(
917
972
  mock_aioresponse: aioresponses,
@@ -1063,6 +1118,38 @@ async def test_get_system_services(
1063
1118
  }
1064
1119
 
1065
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
+
1066
1153
  # ---------------------------------------------------------------------------
1067
1154
  # OmniTronic + RS485 + LiveTrace (P12 / P13 / P15)
1068
1155
  # ---------------------------------------------------------------------------
@@ -1556,6 +1643,40 @@ async def test_manual_dosing_unknown_type(api_client: VioletPoolAPI) -> None:
1556
1643
  assert "Unknown dosing type" in str(exc_info.value)
1557
1644
 
1558
1645
 
1646
+ @pytest.mark.asyncio
1647
+ @pytest.mark.parametrize("duration", [0.5, 86401, float("nan"), float("inf")])
1648
+ async def test_dosing_start_rejects_unsafe_duration(
1649
+ api_client: VioletPoolAPI,
1650
+ duration: float,
1651
+ ) -> None:
1652
+ with pytest.raises(VioletPoolAPIError, match="Duration must be a whole number"):
1653
+ await api_client.set_switch_state("DOS_1_CL", "ON", duration=duration)
1654
+
1655
+
1656
+ @pytest.mark.asyncio
1657
+ async def test_manual_dosing_post_is_not_retried(
1658
+ mock_aioresponse: aioresponses,
1659
+ ) -> None:
1660
+ url = "http://192.168.1.100/triggerManualDosing"
1661
+ mock_aioresponse.post(url, status=500, body="boom")
1662
+ async with aiohttp.ClientSession() as session:
1663
+ api = VioletPoolAPI(host="192.168.1.100", session=session, max_retries=3)
1664
+ with pytest.raises(VioletPoolAPIError):
1665
+ await api.manual_dosing("Chlor", 30)
1666
+
1667
+ assert len(mock_aioresponse.requests[("POST", URL(url))]) == 1
1668
+
1669
+
1670
+ @pytest.mark.asyncio
1671
+ @pytest.mark.parametrize("duration", [-1, 0.5, 86401, float("nan")])
1672
+ async def test_switch_state_rejects_invalid_duration(
1673
+ api_client: VioletPoolAPI,
1674
+ duration: float,
1675
+ ) -> None:
1676
+ with pytest.raises(VioletPoolAPIError, match="Duration must be a whole number"):
1677
+ await api_client.set_switch_state("PUMP", "ON", duration=duration)
1678
+
1679
+
1559
1680
  @pytest.mark.asyncio
1560
1681
  async def test_manual_dosing_stop(
1561
1682
  mock_aioresponse: aioresponses,
@@ -1730,6 +1851,51 @@ async def test_client_error_does_not_trip_circuit_breaker(
1730
1851
  assert stats["state"].name == "CLOSED"
1731
1852
 
1732
1853
 
1854
+ @pytest.mark.asyncio
1855
+ async def test_empty_get_readings_payload_is_flattened(
1856
+ mock_aioresponse: aioresponses,
1857
+ api_client: VioletPoolAPI,
1858
+ ) -> None:
1859
+ url = "http://192.168.1.100/getReadings?ALL"
1860
+ mock_aioresponse.get(url, payload={"getReadings": {}}, status=200)
1861
+
1862
+ readings = await api_client.get_readings()
1863
+
1864
+ assert dict(readings) == {}
1865
+
1866
+
1867
+ @pytest.mark.asyncio
1868
+ async def test_empty_standalone_payload_updates_mode(api_client: VioletPoolAPI) -> None:
1869
+ result = api_client._flatten_getreadings_response({"getReadings": []}) # noqa: SLF001
1870
+
1871
+ assert result == {}
1872
+ assert api_client.dosing_standalone is True
1873
+
1874
+
1875
+ @pytest.mark.asyncio
1876
+ async def test_clients_use_independent_rate_limiters() -> None:
1877
+ async with aiohttp.ClientSession() as session:
1878
+ first = VioletPoolAPI(host="192.168.1.100", session=session)
1879
+ second = VioletPoolAPI(host="192.168.1.101", session=session)
1880
+
1881
+ assert first._rate_limiter is not second._rate_limiter # noqa: SLF001
1882
+
1883
+
1884
+ @pytest.mark.asyncio
1885
+ async def test_basic_auth_uses_authorization_header(
1886
+ mock_aioresponse: aioresponses,
1887
+ api_client: VioletPoolAPI,
1888
+ ) -> None:
1889
+ url = "http://192.168.1.100/getReadings?ALL"
1890
+ mock_aioresponse.get(url, payload={}, status=200)
1891
+
1892
+ await api_client.get_readings()
1893
+
1894
+ request = next(iter(mock_aioresponse.requests.values()))[0]
1895
+ assert request.kwargs["headers"]["Authorization"].startswith("Basic ")
1896
+ assert "auth" not in request.kwargs
1897
+
1898
+
1733
1899
  @pytest.mark.asyncio
1734
1900
  async def test_server_error_still_counts_for_circuit_breaker(
1735
1901
  mock_aioresponse: aioresponses,
@@ -1748,6 +1914,40 @@ async def test_server_error_still_counts_for_circuit_breaker(
1748
1914
  assert stats["failure_count"] == 1
1749
1915
 
1750
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
+
1751
1951
  def test_command_result_error_first_line() -> None:
1752
1952
  """Line 1 of the response decides success per manual section 26.2."""
1753
1953
  result = VioletPoolAPI._command_result("ERROR\nPUMP\nUNKNOWN OUTPUT")
@@ -1781,3 +1981,152 @@ def test_state_translation_language_switch() -> None:
1781
1981
 
1782
1982
  with pytest.raises(ValueError, match="Unsupported language"):
1783
1983
  set_state_translation_language("fr")
1984
+
1985
+
1986
+ @pytest.mark.asyncio
1987
+ async def test_set_config_preserves_int_values(
1988
+ api_client: VioletPoolAPI,
1989
+ monkeypatch: pytest.MonkeyPatch,
1990
+ ) -> None:
1991
+ """Integer config values must be sent as integers, not coerced to float.
1992
+
1993
+ Regression for v0.0.36: ``InputSanitizer.sanitize_numeric`` returns a
1994
+ float, so an integer flag like ``1`` became ``1.0`` on the wire. The
1995
+ controller firmware expects integer flags (e.g. ``DOS_*_use``).
1996
+ """
1997
+ captured: dict[str, object] = {}
1998
+
1999
+ async def fake_request(endpoint: str, **kwargs: Any) -> str: # noqa: ANN401
2000
+ captured["data"] = kwargs.get("data")
2001
+ return "OK"
2002
+
2003
+ monkeypatch.setattr(api_client, "_request", fake_request)
2004
+
2005
+ await api_client.set_config({"FLAG_enabled": 1, "FLAG_disabled": 0, "ph_set": 7.4})
2006
+
2007
+ data = captured["data"]
2008
+ assert data == {"FLAG_enabled": 1, "FLAG_disabled": 0, "ph_set": 7.4}
2009
+ assert isinstance(data["FLAG_enabled"], int)
2010
+ assert isinstance(data["ph_set"], float)
2011
+
2012
+
2013
+ @pytest.mark.asyncio
2014
+ async def test_set_config_preserves_bool_as_int_flag(
2015
+ api_client: VioletPoolAPI,
2016
+ monkeypatch: pytest.MonkeyPatch,
2017
+ ) -> None:
2018
+ """Booleans are sent as 0/1 integer flags (bool is an int subclass)."""
2019
+ captured: dict[str, object] = {}
2020
+
2021
+ async def fake_request(endpoint: str, **kwargs: Any) -> str: # noqa: ANN401
2022
+ captured["data"] = kwargs.get("data")
2023
+ return "OK"
2024
+
2025
+ monkeypatch.setattr(api_client, "_request", fake_request)
2026
+
2027
+ await api_client.set_config({"DOS_1_use": True, "DOS_2_use": False})
2028
+
2029
+ data = captured["data"]
2030
+ assert data == {"DOS_1_use": 1, "DOS_2_use": 0}
2031
+
2032
+
2033
+ @pytest.mark.asyncio
2034
+ async def test_set_config_not_retried_on_server_error(
2035
+ mock_aioresponse: aioresponses,
2036
+ api_client: VioletPoolAPI,
2037
+ ) -> None:
2038
+ """set_config must not retry on 5xx — consistent with other POSTs.
2039
+
2040
+ A duplicated POST could re-apply a configuration change. POST methods
2041
+ default to non-retryable; set_config previously opted into retries.
2042
+ """
2043
+ url = "http://192.168.1.100/setConfig"
2044
+ mock_aioresponse.post(url, status=500, body="server error")
2045
+
2046
+ with pytest.raises(VioletPoolAPIError):
2047
+ await api_client.set_config({"some_key": 1})
2048
+
2049
+ assert len(mock_aioresponse.requests[("POST", URL(url))]) == 1
2050
+
2051
+
2052
+ @pytest.mark.asyncio
2053
+ async def test_get_calibration_history_url_encodes_sensor(
2054
+ api_client: VioletPoolAPI,
2055
+ monkeypatch: pytest.MonkeyPatch,
2056
+ ) -> None:
2057
+ """Sensor names with reserved characters must be percent-encoded.
2058
+
2059
+ Regression for v0.0.36: ``sensor`` was concatenated raw into the URL,
2060
+ so a value containing ``&`` or space could inject extra query
2061
+ parameters. The value passed to ``_request`` must be percent-encoded.
2062
+ """
2063
+ captured: dict[str, object] = {}
2064
+
2065
+ async def fake_request(endpoint: str, **kwargs: Any) -> str: # noqa: ANN401
2066
+ captured["query"] = kwargs.get("query")
2067
+ return "2024-01-01 | 7.2 | pH_CALIB"
2068
+
2069
+ monkeypatch.setattr(api_client, "_request", fake_request)
2070
+
2071
+ await api_client.get_calibration_history("pH value & test")
2072
+
2073
+ assert captured["query"] == "pH%20value%20%26%20test"
2074
+
2075
+
2076
+ @pytest.mark.asyncio
2077
+ async def test_get_log_rejects_unknown_log_type(api_client: VioletPoolAPI) -> None:
2078
+ """Unknown log_type values must be rejected before the request is sent."""
2079
+ with pytest.raises(VioletPoolAPIError, match="Unsupported log_type"):
2080
+ await api_client.get_log("drop_table")
2081
+
2082
+
2083
+ @pytest.mark.asyncio
2084
+ async def test_get_log_valid_log_type_reaches_request(
2085
+ api_client: VioletPoolAPI,
2086
+ monkeypatch: pytest.MonkeyPatch,
2087
+ ) -> None:
2088
+ """Valid log_type is passed through to the request layer."""
2089
+ captured: dict[str, object] = {}
2090
+
2091
+ async def fake_request(endpoint: str, **kwargs: Any) -> str: # noqa: ANN401
2092
+ captured["query"] = kwargs.get("query")
2093
+ return "line one\nLOAD_MORE"
2094
+
2095
+ monkeypatch.setattr(api_client, "_request", fake_request)
2096
+
2097
+ result = await api_client.get_log("actions", page=0)
2098
+
2099
+ assert captured["query"] == "actions&0"
2100
+ assert result["has_more"] is True
2101
+ assert result["lines"] == ["line one"]
2102
+
2103
+
2104
+ @pytest.mark.asyncio
2105
+ async def test_restore_calibration_sanitizes_payload(
2106
+ api_client: VioletPoolAPI,
2107
+ monkeypatch: pytest.MonkeyPatch,
2108
+ ) -> None:
2109
+ """restore_calibration must sanitize sensor/timestamp before posting."""
2110
+ captured: dict[str, object] = {}
2111
+
2112
+ async def fake_request(endpoint: str, **kwargs: Any) -> str: # noqa: ANN401
2113
+ captured["data"] = kwargs.get("data")
2114
+ return "OK"
2115
+
2116
+ monkeypatch.setattr(api_client, "_request", fake_request)
2117
+
2118
+ await api_client.restore_calibration("pH", "2024-01-01 12:00")
2119
+
2120
+ assert captured["data"] == {"sensor": "pH", "timestamp": "2024-01-01 12:00"}
2121
+
2122
+
2123
+ def test_input_sanitizer_no_duplicate_validate_duration() -> None:
2124
+ """InputSanitizer must not expose validate_duration/validate_speed.
2125
+
2126
+ These names collided with the raising validators in ``_api_model`` but
2127
+ silently clamped instead. They were removed in v0.0.36.
2128
+ """
2129
+ from violet_poolcontroller_api.utils_sanitizer import InputSanitizer
2130
+
2131
+ assert not hasattr(InputSanitizer, "validate_duration")
2132
+ assert not hasattr(InputSanitizer, "validate_speed")
@@ -27,14 +27,14 @@ from violet_poolcontroller_api.api import VioletPoolAPI, VioletPoolAPIError
27
27
 
28
28
 
29
29
  @dataclasses.dataclass
30
- class TestResult:
30
+ class SmokeResult:
31
31
  name: str
32
32
  status: str # "PASS" | "FAIL" | "SKIP"
33
33
  detail: str = ""
34
34
  duration_ms: float = 0.0
35
35
 
36
36
 
37
- _results: list[TestResult] = []
37
+ _results: list[SmokeResult] = []
38
38
 
39
39
 
40
40
  async def _run(
@@ -53,7 +53,7 @@ async def _run(
53
53
  error_msg = check(result)
54
54
 
55
55
  if error_msg:
56
- _results.append(TestResult(name, "FAIL", error_msg, elapsed))
56
+ _results.append(SmokeResult(name, "FAIL", error_msg, elapsed))
57
57
  else:
58
58
  detail = ""
59
59
  if isinstance(result, dict):
@@ -67,16 +67,16 @@ async def _run(
67
67
  detail = f"{len(result)} entries"
68
68
  elif isinstance(result, bool):
69
69
  detail = str(result)
70
- _results.append(TestResult(name, "PASS", detail, elapsed))
70
+ _results.append(SmokeResult(name, "PASS", detail, elapsed))
71
71
  return result
72
72
  except VioletPoolAPIError as exc:
73
73
  elapsed = (time.monotonic() - start) * 1000
74
- _results.append(TestResult(name, "FAIL", f"VioletPoolAPIError: {exc}", elapsed))
74
+ _results.append(SmokeResult(name, "FAIL", f"VioletPoolAPIError: {exc}", elapsed))
75
75
  return None
76
76
  except Exception as exc:
77
77
  elapsed = (time.monotonic() - start) * 1000
78
78
  tb = traceback.format_exc()
79
- _results.append(TestResult(name, "FAIL", f"{type(exc).__name__}: {exc}\n{tb}", elapsed))
79
+ _results.append(SmokeResult(name, "FAIL", f"{type(exc).__name__}: {exc}\n{tb}", elapsed))
80
80
  return None
81
81
 
82
82
 
@@ -90,21 +90,17 @@ async def _expect_error(
90
90
  try:
91
91
  result = await coro
92
92
  elapsed = (time.monotonic() - start) * 1000
93
- _results.append(TestResult(name, "FAIL", f"Expected error but got success: {result}", elapsed))
93
+ _results.append(SmokeResult(name, "FAIL", f"Expected error but got success: {result}", elapsed))
94
94
  except VioletPoolAPIError as exc:
95
95
  elapsed = (time.monotonic() - start) * 1000
96
96
  msg = str(exc)
97
97
  if error_contains and error_contains not in msg:
98
- _results.append(TestResult(name, "FAIL", f"Error message mismatch: '{msg}' does not contain '{error_contains}'", elapsed))
98
+ _results.append(SmokeResult(name, "FAIL", f"Error message mismatch: '{msg}' does not contain '{error_contains}'", elapsed))
99
99
  else:
100
- _results.append(TestResult(name, "PASS", f"Correctly raised: {msg}", elapsed))
100
+ _results.append(SmokeResult(name, "PASS", f"Correctly raised: {msg}", elapsed))
101
101
  except Exception as exc:
102
102
  elapsed = (time.monotonic() - start) * 1000
103
- _results.append(TestResult(name, "FAIL", f"Unexpected {type(exc).__name__}: {exc}", elapsed))
104
-
105
-
106
- def _skip(name: str, reason: str) -> None:
107
- _results.append(TestResult(name, "SKIP", reason))
103
+ _results.append(SmokeResult(name, "FAIL", f"Unexpected {type(exc).__name__}: {exc}", elapsed))
108
104
 
109
105
 
110
106
  async def run_all_tests(api: VioletPoolAPI) -> None:
@@ -184,6 +180,18 @@ async def run_all_tests(api: VioletPoolAPI) -> None:
184
180
  check=lambda r: "empty" if not r else None,
185
181
  )
186
182
 
183
+ await _run(
184
+ "get_log(actions)",
185
+ api.get_log("actions"),
186
+ check=lambda r: "missing lines" if "lines" not in r else None,
187
+ )
188
+
189
+ await _run(
190
+ "get_notifications",
191
+ api.get_notifications(),
192
+ check=lambda r: "unexpected payload" if not isinstance(r, dict) else None,
193
+ )
194
+
187
195
  # ── POST endpoints (config / commands) ──────────────────────────────
188
196
  print("[Config & Commands]")
189
197
 
@@ -229,6 +237,12 @@ async def run_all_tests(api: VioletPoolAPI) -> None:
229
237
  check=lambda r: "not success" if not r.get("success") else None,
230
238
  )
231
239
 
240
+ await _run(
241
+ "is_dosage_enabled(pH-)",
242
+ api.is_dosage_enabled("pH-"),
243
+ check=lambda r: "expected enabled" if not r else None,
244
+ )
245
+
232
246
  await _run(
233
247
  "set_device_temperature(HEATER, 28.0)",
234
248
  api.set_device_temperature("HEATER", 28.0),
@@ -365,6 +379,21 @@ async def run_all_tests(api: VioletPoolAPI) -> None:
365
379
  check=lambda r: "not success" if not r.get("success") else None,
366
380
  )
367
381
 
382
+ # ── OmniTronic multi-port valve ─────────────────────────────────────
383
+ print("[OmniTronic]")
384
+
385
+ await _run(
386
+ "set_omni_position(2)",
387
+ api.set_omni_position(2),
388
+ check=lambda r: "missing OMNITRONIC ack" if "OMNITRONIC" not in r.get("response", "") else None,
389
+ )
390
+
391
+ await _run(
392
+ "set_omni_position(0) - filtration",
393
+ api.set_omni_position(0),
394
+ check=lambda r: "not success" if not r.get("success") else None,
395
+ )
396
+
368
397
  # ── PV Surplus ──────────────────────────────────────────────────────
369
398
  print("[PV Surplus]")
370
399
 
@@ -491,14 +520,6 @@ async def run_all_tests(api: VioletPoolAPI) -> None:
491
520
  error_contains="Unknown dosing type",
492
521
  )
493
522
 
494
- # ── Skipped methods (use _api_request, not available in source) ─────
495
- print("[Skipped - requires _api_request]")
496
-
497
- _skip("get_log(actions)", "uses _api_request (not in source, only in installed package)")
498
- _skip("get_notifications", "uses _api_request (not in source, only in installed package)")
499
- _skip("is_dosage_enabled(pH-)", "uses _request_json_dict without payload_name param mismatch")
500
-
501
-
502
523
  async def _run_prop(api: VioletPoolAPI, prop: str) -> Any:
503
524
  return getattr(api, prop)
504
525