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.
- {violet_poolcontroller_api-0.0.35/violet_poolController_api.egg-info → violet_poolcontroller_api-0.0.36}/PKG-INFO +7 -6
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/pyproject.toml +17 -13
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/tests/test_api.py +270 -2
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/tests/test_api_smoke.py +43 -22
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/tests/test_circuit_breaker.py +44 -3
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/tests/test_mock_server.py +11 -5
- violet_poolcontroller_api-0.0.36/tests/test_parsers.py +139 -0
- violet_poolcontroller_api-0.0.36/tests/test_rate_limiter.py +52 -0
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/tests/test_readings.py +11 -2
- violet_poolcontroller_api-0.0.36/tests/test_sanitizer.py +25 -0
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36/violet_poolController_api.egg-info}/PKG-INFO +7 -6
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/violet_poolController_api.egg-info/SOURCES.txt +8 -0
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/violet_poolController_api.egg-info/requires.txt +2 -1
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/violet_poolcontroller_api/__init__.py +1 -1
- violet_poolcontroller_api-0.0.36/violet_poolcontroller_api/_api_dosing.py +335 -0
- violet_poolcontroller_api-0.0.36/violet_poolcontroller_api/_api_mixin.py +85 -0
- violet_poolcontroller_api-0.0.36/violet_poolcontroller_api/_api_model.py +81 -0
- violet_poolcontroller_api-0.0.36/violet_poolcontroller_api/_api_outputs.py +536 -0
- violet_poolcontroller_api-0.0.36/violet_poolcontroller_api/_api_readings.py +256 -0
- violet_poolcontroller_api-0.0.36/violet_poolcontroller_api/_api_system.py +375 -0
- violet_poolcontroller_api-0.0.36/violet_poolcontroller_api/api.py +803 -0
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/violet_poolcontroller_api/circuit_breaker.py +39 -26
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/violet_poolcontroller_api/const_api.py +2 -0
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/violet_poolcontroller_api/const_devices.py +1 -1
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/violet_poolcontroller_api/parsers.py +2 -2
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/violet_poolcontroller_api/readings.py +3 -1
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/violet_poolcontroller_api/utils_rate_limiter.py +102 -48
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/violet_poolcontroller_api/utils_sanitizer.py +9 -54
- violet_poolcontroller_api-0.0.35/tests/test_parsers.py +0 -161
- violet_poolcontroller_api-0.0.35/violet_poolcontroller_api/api.py +0 -2222
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/LICENSE +0 -0
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/README.md +0 -0
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/setup.cfg +0 -0
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/violet_poolController_api.egg-info/dependency_links.txt +0 -0
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/violet_poolController_api.egg-info/top_level.txt +0 -0
- {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.
|
|
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-
|
|
8
|
-
Project-URL: Bug Tracker, https://github.com/Xerolux/violet-
|
|
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-
|
|
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.
|
|
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>=
|
|
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.
|
|
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 =
|
|
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.
|
|
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-
|
|
45
|
-
"Bug Tracker" = "https://github.com/Xerolux/violet-
|
|
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-
|
|
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
|
|
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
|
|
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")
|
{violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/tests/test_api_smoke.py
RENAMED
|
@@ -27,14 +27,14 @@ from violet_poolcontroller_api.api import VioletPoolAPI, VioletPoolAPIError
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
@dataclasses.dataclass
|
|
30
|
-
class
|
|
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[
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
98
|
+
_results.append(SmokeResult(name, "FAIL", f"Error message mismatch: '{msg}' does not contain '{error_contains}'", elapsed))
|
|
99
99
|
else:
|
|
100
|
-
_results.append(
|
|
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(
|
|
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
|
|
{violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/tests/test_circuit_breaker.py
RENAMED
|
@@ -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)
|
|
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
|
{violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.36}/tests/test_mock_server.py
RENAMED
|
@@ -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 = "
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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)}")
|