violet-poolController-api 0.0.35__tar.gz → 0.0.36__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.36}/PKG-INFO +7 -6
  2. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/pyproject.toml +17 -13
  3. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/tests/test_api.py +270 -2
  4. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/tests/test_api_smoke.py +43 -22
  5. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/tests/test_circuit_breaker.py +44 -3
  6. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/tests/test_mock_server.py +11 -5
  7. violet_poolcontroller_api-0.0.36/tests/test_parsers.py +139 -0
  8. violet_poolcontroller_api-0.0.36/tests/test_rate_limiter.py +52 -0
  9. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/tests/test_readings.py +11 -2
  10. violet_poolcontroller_api-0.0.36/tests/test_sanitizer.py +25 -0
  11. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36/violet_poolController_api.egg-info}/PKG-INFO +7 -6
  12. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/violet_poolController_api.egg-info/SOURCES.txt +8 -0
  13. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/violet_poolController_api.egg-info/requires.txt +2 -1
  14. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/violet_poolcontroller_api/__init__.py +1 -1
  15. violet_poolcontroller_api-0.0.36/violet_poolcontroller_api/_api_dosing.py +335 -0
  16. violet_poolcontroller_api-0.0.36/violet_poolcontroller_api/_api_mixin.py +85 -0
  17. violet_poolcontroller_api-0.0.36/violet_poolcontroller_api/_api_model.py +81 -0
  18. violet_poolcontroller_api-0.0.36/violet_poolcontroller_api/_api_outputs.py +536 -0
  19. violet_poolcontroller_api-0.0.36/violet_poolcontroller_api/_api_readings.py +256 -0
  20. violet_poolcontroller_api-0.0.36/violet_poolcontroller_api/_api_system.py +375 -0
  21. violet_poolcontroller_api-0.0.36/violet_poolcontroller_api/api.py +803 -0
  22. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/violet_poolcontroller_api/circuit_breaker.py +39 -26
  23. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/violet_poolcontroller_api/const_api.py +2 -0
  24. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/violet_poolcontroller_api/const_devices.py +1 -1
  25. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/violet_poolcontroller_api/parsers.py +2 -2
  26. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/violet_poolcontroller_api/readings.py +3 -1
  27. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/violet_poolcontroller_api/utils_rate_limiter.py +102 -48
  28. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/violet_poolcontroller_api/utils_sanitizer.py +9 -54
  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.36}/LICENSE +0 -0
  32. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/README.md +0 -0
  33. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/setup.cfg +0 -0
  34. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/violet_poolController_api.egg-info/dependency_links.txt +0 -0
  35. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/violet_poolController_api.egg-info/top_level.txt +0 -0
  36. {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/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.36
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.36"
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
@@ -527,7 +527,7 @@ async def test_ext1_readings_filtered_when_not_detected(mock_aioresponse, api_cl
527
527
  payload={
528
528
  "getReadings": {
529
529
  "PUMPSTATE": "2",
530
- # No SYSTEM_ext1module_alive_count  module not connected
530
+ # No SYSTEM_ext1module_alive_count module not connected
531
531
  "EXT1_1": 0,
532
532
  "EXT1_2": 0,
533
533
  }
@@ -842,6 +842,32 @@ async def test_set_device_temperature_solar(
842
842
  assert result["success"] is True
843
843
 
844
844
 
845
+ @pytest.mark.asyncio
846
+ async def test_set_device_temperature_normalizes_key(
847
+ mock_aioresponse: aioresponses,
848
+ api_client: VioletPoolAPI,
849
+ ) -> None:
850
+ url = "http://192.168.1.100/setConfig"
851
+ mock_aioresponse.post(url, body="OK", status=200)
852
+
853
+ await api_client.set_device_temperature(" heater ", 28.0)
854
+
855
+ request = mock_aioresponse.requests[("POST", URL(url))][0]
856
+ assert request.kwargs["data"] == {"HEATER_set_temp": 28.0}
857
+
858
+
859
+ @pytest.mark.asyncio
860
+ async def test_set_device_temperature_rejects_unknown_key(api_client: VioletPoolAPI) -> None:
861
+ with pytest.raises(VioletPoolAPIError, match="Expected HEATER or SOLAR"):
862
+ await api_client.set_device_temperature("DOSAGE", 28.0)
863
+
864
+
865
+ @pytest.mark.asyncio
866
+ async def test_set_device_temperature_cannot_bypass_range(api_client: VioletPoolAPI) -> None:
867
+ with pytest.raises(VioletPoolAPIError, match="outside the valid range"):
868
+ await api_client.set_device_temperature("heater", 1000.0)
869
+
870
+
845
871
  @pytest.mark.asyncio
846
872
  async def test_set_ph_target(
847
873
  mock_aioresponse: aioresponses,
@@ -912,6 +938,20 @@ async def test_set_dosing_parameters(
912
938
  assert result["success"] is True
913
939
 
914
940
 
941
+ @pytest.mark.asyncio
942
+ @pytest.mark.parametrize(("raw_value", "expected"), [("1", True), ("1.0", True), ("0.0", False)])
943
+ async def test_is_dosage_enabled_accepts_numeric_strings(
944
+ mock_aioresponse: aioresponses,
945
+ api_client: VioletPoolAPI,
946
+ raw_value: str,
947
+ expected: bool,
948
+ ) -> None:
949
+ url = "http://192.168.1.100/getConfig?DOSAGE_phminus_use"
950
+ mock_aioresponse.get(url, payload={"DOSAGE_phminus_use": raw_value}, status=200)
951
+
952
+ assert await api_client.is_dosage_enabled("pH-") is expected
953
+
954
+
915
955
  @pytest.mark.asyncio
916
956
  async def test_reset_blocking(
917
957
  mock_aioresponse: aioresponses,
@@ -1556,6 +1596,40 @@ async def test_manual_dosing_unknown_type(api_client: VioletPoolAPI) -> None:
1556
1596
  assert "Unknown dosing type" in str(exc_info.value)
1557
1597
 
1558
1598
 
1599
+ @pytest.mark.asyncio
1600
+ @pytest.mark.parametrize("duration", [0.5, 86401, float("nan"), float("inf")])
1601
+ async def test_dosing_start_rejects_unsafe_duration(
1602
+ api_client: VioletPoolAPI,
1603
+ duration: float,
1604
+ ) -> None:
1605
+ with pytest.raises(VioletPoolAPIError, match="Duration must be a whole number"):
1606
+ await api_client.set_switch_state("DOS_1_CL", "ON", duration=duration)
1607
+
1608
+
1609
+ @pytest.mark.asyncio
1610
+ async def test_manual_dosing_post_is_not_retried(
1611
+ mock_aioresponse: aioresponses,
1612
+ ) -> None:
1613
+ url = "http://192.168.1.100/triggerManualDosing"
1614
+ mock_aioresponse.post(url, status=500, body="boom")
1615
+ async with aiohttp.ClientSession() as session:
1616
+ api = VioletPoolAPI(host="192.168.1.100", session=session, max_retries=3)
1617
+ with pytest.raises(VioletPoolAPIError):
1618
+ await api.manual_dosing("Chlor", 30)
1619
+
1620
+ assert len(mock_aioresponse.requests[("POST", URL(url))]) == 1
1621
+
1622
+
1623
+ @pytest.mark.asyncio
1624
+ @pytest.mark.parametrize("duration", [-1, 0.5, 86401, float("nan")])
1625
+ async def test_switch_state_rejects_invalid_duration(
1626
+ api_client: VioletPoolAPI,
1627
+ duration: float,
1628
+ ) -> None:
1629
+ with pytest.raises(VioletPoolAPIError, match="Duration must be a whole number"):
1630
+ await api_client.set_switch_state("PUMP", "ON", duration=duration)
1631
+
1632
+
1559
1633
  @pytest.mark.asyncio
1560
1634
  async def test_manual_dosing_stop(
1561
1635
  mock_aioresponse: aioresponses,
@@ -1730,6 +1804,51 @@ async def test_client_error_does_not_trip_circuit_breaker(
1730
1804
  assert stats["state"].name == "CLOSED"
1731
1805
 
1732
1806
 
1807
+ @pytest.mark.asyncio
1808
+ async def test_empty_get_readings_payload_is_flattened(
1809
+ mock_aioresponse: aioresponses,
1810
+ api_client: VioletPoolAPI,
1811
+ ) -> None:
1812
+ url = "http://192.168.1.100/getReadings?ALL"
1813
+ mock_aioresponse.get(url, payload={"getReadings": {}}, status=200)
1814
+
1815
+ readings = await api_client.get_readings()
1816
+
1817
+ assert dict(readings) == {}
1818
+
1819
+
1820
+ @pytest.mark.asyncio
1821
+ async def test_empty_standalone_payload_updates_mode(api_client: VioletPoolAPI) -> None:
1822
+ result = api_client._flatten_getreadings_response({"getReadings": []}) # noqa: SLF001
1823
+
1824
+ assert result == {}
1825
+ assert api_client.dosing_standalone is True
1826
+
1827
+
1828
+ @pytest.mark.asyncio
1829
+ async def test_clients_use_independent_rate_limiters() -> None:
1830
+ async with aiohttp.ClientSession() as session:
1831
+ first = VioletPoolAPI(host="192.168.1.100", session=session)
1832
+ second = VioletPoolAPI(host="192.168.1.101", session=session)
1833
+
1834
+ assert first._rate_limiter is not second._rate_limiter # noqa: SLF001
1835
+
1836
+
1837
+ @pytest.mark.asyncio
1838
+ async def test_basic_auth_uses_authorization_header(
1839
+ mock_aioresponse: aioresponses,
1840
+ api_client: VioletPoolAPI,
1841
+ ) -> None:
1842
+ url = "http://192.168.1.100/getReadings?ALL"
1843
+ mock_aioresponse.get(url, payload={}, status=200)
1844
+
1845
+ await api_client.get_readings()
1846
+
1847
+ request = next(iter(mock_aioresponse.requests.values()))[0]
1848
+ assert request.kwargs["headers"]["Authorization"].startswith("Basic ")
1849
+ assert "auth" not in request.kwargs
1850
+
1851
+
1733
1852
  @pytest.mark.asyncio
1734
1853
  async def test_server_error_still_counts_for_circuit_breaker(
1735
1854
  mock_aioresponse: aioresponses,
@@ -1781,3 +1900,152 @@ def test_state_translation_language_switch() -> None:
1781
1900
 
1782
1901
  with pytest.raises(ValueError, match="Unsupported language"):
1783
1902
  set_state_translation_language("fr")
1903
+
1904
+
1905
+ @pytest.mark.asyncio
1906
+ async def test_set_config_preserves_int_values(
1907
+ api_client: VioletPoolAPI,
1908
+ monkeypatch: pytest.MonkeyPatch,
1909
+ ) -> None:
1910
+ """Integer config values must be sent as integers, not coerced to float.
1911
+
1912
+ Regression for v0.0.36: ``InputSanitizer.sanitize_numeric`` returns a
1913
+ float, so an integer flag like ``1`` became ``1.0`` on the wire. The
1914
+ controller firmware expects integer flags (e.g. ``DOS_*_use``).
1915
+ """
1916
+ captured: dict[str, object] = {}
1917
+
1918
+ async def fake_request(endpoint: str, **kwargs: Any) -> str: # noqa: ANN401
1919
+ captured["data"] = kwargs.get("data")
1920
+ return "OK"
1921
+
1922
+ monkeypatch.setattr(api_client, "_request", fake_request)
1923
+
1924
+ await api_client.set_config({"FLAG_enabled": 1, "FLAG_disabled": 0, "ph_set": 7.4})
1925
+
1926
+ data = captured["data"]
1927
+ assert data == {"FLAG_enabled": 1, "FLAG_disabled": 0, "ph_set": 7.4}
1928
+ assert isinstance(data["FLAG_enabled"], int)
1929
+ assert isinstance(data["ph_set"], float)
1930
+
1931
+
1932
+ @pytest.mark.asyncio
1933
+ async def test_set_config_preserves_bool_as_int_flag(
1934
+ api_client: VioletPoolAPI,
1935
+ monkeypatch: pytest.MonkeyPatch,
1936
+ ) -> None:
1937
+ """Booleans are sent as 0/1 integer flags (bool is an int subclass)."""
1938
+ captured: dict[str, object] = {}
1939
+
1940
+ async def fake_request(endpoint: str, **kwargs: Any) -> str: # noqa: ANN401
1941
+ captured["data"] = kwargs.get("data")
1942
+ return "OK"
1943
+
1944
+ monkeypatch.setattr(api_client, "_request", fake_request)
1945
+
1946
+ await api_client.set_config({"DOS_1_use": True, "DOS_2_use": False})
1947
+
1948
+ data = captured["data"]
1949
+ assert data == {"DOS_1_use": 1, "DOS_2_use": 0}
1950
+
1951
+
1952
+ @pytest.mark.asyncio
1953
+ async def test_set_config_not_retried_on_server_error(
1954
+ mock_aioresponse: aioresponses,
1955
+ api_client: VioletPoolAPI,
1956
+ ) -> None:
1957
+ """set_config must not retry on 5xx — consistent with other POSTs.
1958
+
1959
+ A duplicated POST could re-apply a configuration change. POST methods
1960
+ default to non-retryable; set_config previously opted into retries.
1961
+ """
1962
+ url = "http://192.168.1.100/setConfig"
1963
+ mock_aioresponse.post(url, status=500, body="server error")
1964
+
1965
+ with pytest.raises(VioletPoolAPIError):
1966
+ await api_client.set_config({"some_key": 1})
1967
+
1968
+ assert len(mock_aioresponse.requests[("POST", URL(url))]) == 1
1969
+
1970
+
1971
+ @pytest.mark.asyncio
1972
+ async def test_get_calibration_history_url_encodes_sensor(
1973
+ api_client: VioletPoolAPI,
1974
+ monkeypatch: pytest.MonkeyPatch,
1975
+ ) -> None:
1976
+ """Sensor names with reserved characters must be percent-encoded.
1977
+
1978
+ Regression for v0.0.36: ``sensor`` was concatenated raw into the URL,
1979
+ so a value containing ``&`` or space could inject extra query
1980
+ parameters. The value passed to ``_request`` must be percent-encoded.
1981
+ """
1982
+ captured: dict[str, object] = {}
1983
+
1984
+ async def fake_request(endpoint: str, **kwargs: Any) -> str: # noqa: ANN401
1985
+ captured["query"] = kwargs.get("query")
1986
+ return "2024-01-01 | 7.2 | pH_CALIB"
1987
+
1988
+ monkeypatch.setattr(api_client, "_request", fake_request)
1989
+
1990
+ await api_client.get_calibration_history("pH value & test")
1991
+
1992
+ assert captured["query"] == "pH%20value%20%26%20test"
1993
+
1994
+
1995
+ @pytest.mark.asyncio
1996
+ async def test_get_log_rejects_unknown_log_type(api_client: VioletPoolAPI) -> None:
1997
+ """Unknown log_type values must be rejected before the request is sent."""
1998
+ with pytest.raises(VioletPoolAPIError, match="Unsupported log_type"):
1999
+ await api_client.get_log("drop_table")
2000
+
2001
+
2002
+ @pytest.mark.asyncio
2003
+ async def test_get_log_valid_log_type_reaches_request(
2004
+ api_client: VioletPoolAPI,
2005
+ monkeypatch: pytest.MonkeyPatch,
2006
+ ) -> None:
2007
+ """Valid log_type is passed through to the request layer."""
2008
+ captured: dict[str, object] = {}
2009
+
2010
+ async def fake_request(endpoint: str, **kwargs: Any) -> str: # noqa: ANN401
2011
+ captured["query"] = kwargs.get("query")
2012
+ return "line one\nLOAD_MORE"
2013
+
2014
+ monkeypatch.setattr(api_client, "_request", fake_request)
2015
+
2016
+ result = await api_client.get_log("actions", page=0)
2017
+
2018
+ assert captured["query"] == "actions&0"
2019
+ assert result["has_more"] is True
2020
+ assert result["lines"] == ["line one"]
2021
+
2022
+
2023
+ @pytest.mark.asyncio
2024
+ async def test_restore_calibration_sanitizes_payload(
2025
+ api_client: VioletPoolAPI,
2026
+ monkeypatch: pytest.MonkeyPatch,
2027
+ ) -> None:
2028
+ """restore_calibration must sanitize sensor/timestamp before posting."""
2029
+ captured: dict[str, object] = {}
2030
+
2031
+ async def fake_request(endpoint: str, **kwargs: Any) -> str: # noqa: ANN401
2032
+ captured["data"] = kwargs.get("data")
2033
+ return "OK"
2034
+
2035
+ monkeypatch.setattr(api_client, "_request", fake_request)
2036
+
2037
+ await api_client.restore_calibration("pH", "2024-01-01 12:00")
2038
+
2039
+ assert captured["data"] == {"sensor": "pH", "timestamp": "2024-01-01 12:00"}
2040
+
2041
+
2042
+ def test_input_sanitizer_no_duplicate_validate_duration() -> None:
2043
+ """InputSanitizer must not expose validate_duration/validate_speed.
2044
+
2045
+ These names collided with the raising validators in ``_api_model`` but
2046
+ silently clamped instead. They were removed in v0.0.36.
2047
+ """
2048
+ from violet_poolcontroller_api.utils_sanitizer import InputSanitizer
2049
+
2050
+ assert not hasattr(InputSanitizer, "validate_duration")
2051
+ 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
 
@@ -1,5 +1,8 @@
1
1
  """Tests for violet_poolcontroller_api.circuit_breaker module."""
2
2
 
3
+ import asyncio
4
+ import time
5
+
3
6
  import pytest
4
7
 
5
8
  from violet_poolcontroller_api.circuit_breaker import (
@@ -90,7 +93,7 @@ class TestCircuitBreakerConfiguration:
90
93
  cb = CircuitBreaker(failure_threshold=5, recovery_timeout=2)
91
94
  assert cb.failure_threshold == 5
92
95
 
93
- def test_zero_failure_threshold(self):
96
+ async def test_zero_failure_threshold(self):
94
97
  """Zero failure threshold opens on first failure."""
95
98
  cb = CircuitBreaker(failure_threshold=0, recovery_timeout=1)
96
99
 
@@ -98,7 +101,8 @@ class TestCircuitBreakerConfiguration:
98
101
  raise ValueError("fail")
99
102
 
100
103
  with pytest.raises(ValueError):
101
- cb.call(failing) # no await needed — fails before reaching callable
104
+ await cb.call(failing)
105
+ assert cb.state.name == "OPEN"
102
106
 
103
107
  def test_negative_recovery_timeout(self):
104
108
  """Negative timeout handled gracefully."""
@@ -133,6 +137,43 @@ class TestCircuitBreakerMetrics:
133
137
  await cb.call(failing)
134
138
 
135
139
  assert cb.state.name == "OPEN"
136
- cb.reset()
140
+ await cb.reset()
137
141
  assert cb.state.name == "CLOSED"
138
142
  assert cb.failure_count == 0
143
+
144
+
145
+ async def test_half_open_allows_only_one_recovery_probe() -> None:
146
+ cb = CircuitBreaker(failure_threshold=1, timeout=0, recovery_timeout=1)
147
+ cb.state = cb.state.OPEN
148
+ cb.last_failure_time = time.monotonic() - 1
149
+ started = asyncio.Event()
150
+ release = asyncio.Event()
151
+
152
+ async def probe() -> str:
153
+ started.set()
154
+ await release.wait()
155
+ return "ok"
156
+
157
+ first = asyncio.create_task(cb.call(probe))
158
+ await started.wait()
159
+ with pytest.raises(CircuitBreakerOpenError, match="probe"):
160
+ await cb.call(probe)
161
+ release.set()
162
+
163
+ assert await first == "ok"
164
+ assert cb.state.name == "CLOSED"
165
+
166
+
167
+ async def test_half_open_probe_timeout_reopens_circuit() -> None:
168
+ cb = CircuitBreaker(failure_threshold=1, timeout=0, recovery_timeout=0.01)
169
+ cb.state = cb.state.OPEN
170
+ cb.last_failure_time = time.monotonic() - 1
171
+
172
+ async def probe() -> None:
173
+ await asyncio.sleep(1)
174
+
175
+ with pytest.raises(TimeoutError):
176
+ await cb.call(probe)
177
+
178
+ assert cb.state.name == "OPEN"
179
+ assert cb.get_stats()["half_open_probe_in_flight"] is False
@@ -7,6 +7,7 @@ credentials, and verifies that authentication is enforced properly.
7
7
  from __future__ import annotations
8
8
 
9
9
  import asyncio
10
+ import base64
10
11
  import socket
11
12
  import subprocess
12
13
  import sys
@@ -19,7 +20,7 @@ import pytest
19
20
 
20
21
  from violet_poolcontroller_api.api import VioletPoolAPI, VioletPoolAPIError
21
22
 
22
- HOST = "localhost"
23
+ HOST = "127.0.0.1"
23
24
  PORT = 8499
24
25
  USER = "admin"
25
26
  PASS = "secret"
@@ -69,9 +70,14 @@ def mock_server() -> Iterator[subprocess.Popen[bytes]]:
69
70
  proc.wait(timeout=5)
70
71
 
71
72
 
72
- async def _request(url: str, auth: aiohttp.BasicAuth | None = None) -> tuple[int, str]:
73
+ def _auth_headers(username: str, password: str) -> dict[str, str]:
74
+ token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
75
+ return {"Authorization": f"Basic {token}"}
76
+
77
+
78
+ async def _request(url: str, headers: dict[str, str] | None = None) -> tuple[int, str]:
73
79
  async with aiohttp.ClientSession() as session:
74
- async with session.get(url, auth=auth) as r:
80
+ async with session.get(url, headers=headers) as r:
75
81
  return r.status, await r.text()
76
82
 
77
83
 
@@ -97,7 +103,7 @@ async def test_raw_auth() -> None:
97
103
  print("=" * 60)
98
104
  status, body = await _request(
99
105
  f"http://{HOST}:{PORT}/getConfig",
100
- auth=aiohttp.BasicAuth("admin", "wrongpassword"),
106
+ headers=_auth_headers("admin", "wrongpassword"),
101
107
  )
102
108
  assert status == 401, f"Expected 401, got {status}"
103
109
  print(f" OK: status={status} body={body!r}")
@@ -108,7 +114,7 @@ async def test_raw_auth() -> None:
108
114
  print("=" * 60)
109
115
  status, body = await _request(
110
116
  f"http://{HOST}:{PORT}/getConfig",
111
- auth=aiohttp.BasicAuth(USER, PASS),
117
+ headers=_auth_headers(USER, PASS),
112
118
  )
113
119
  assert status == 200, f"Expected 200, got {status}"
114
120
  print(f" OK: status={status} body_len={len(body)}")