violet-poolController-api 0.0.34__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.34/violet_poolController_api.egg-info → violet_poolcontroller_api-0.0.36}/PKG-INFO +7 -6
- {violet_poolcontroller_api-0.0.34 → violet_poolcontroller_api-0.0.36}/pyproject.toml +17 -13
- {violet_poolcontroller_api-0.0.34 → violet_poolcontroller_api-0.0.36}/tests/test_api.py +311 -2
- {violet_poolcontroller_api-0.0.34 → violet_poolcontroller_api-0.0.36}/tests/test_api_smoke.py +43 -22
- {violet_poolcontroller_api-0.0.34 → violet_poolcontroller_api-0.0.36}/tests/test_circuit_breaker.py +44 -3
- {violet_poolcontroller_api-0.0.34 → 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.34 → 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.34 → violet_poolcontroller_api-0.0.36/violet_poolController_api.egg-info}/PKG-INFO +7 -6
- {violet_poolcontroller_api-0.0.34 → violet_poolcontroller_api-0.0.36}/violet_poolController_api.egg-info/SOURCES.txt +8 -0
- {violet_poolcontroller_api-0.0.34 → violet_poolcontroller_api-0.0.36}/violet_poolController_api.egg-info/requires.txt +2 -1
- {violet_poolcontroller_api-0.0.34 → 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.34 → violet_poolcontroller_api-0.0.36}/violet_poolcontroller_api/circuit_breaker.py +39 -26
- {violet_poolcontroller_api-0.0.34 → violet_poolcontroller_api-0.0.36}/violet_poolcontroller_api/const_api.py +2 -0
- {violet_poolcontroller_api-0.0.34 → violet_poolcontroller_api-0.0.36}/violet_poolcontroller_api/const_devices.py +1 -1
- {violet_poolcontroller_api-0.0.34 → violet_poolcontroller_api-0.0.36}/violet_poolcontroller_api/parsers.py +2 -2
- {violet_poolcontroller_api-0.0.34 → violet_poolcontroller_api-0.0.36}/violet_poolcontroller_api/readings.py +3 -1
- {violet_poolcontroller_api-0.0.34 → violet_poolcontroller_api-0.0.36}/violet_poolcontroller_api/utils_rate_limiter.py +102 -48
- {violet_poolcontroller_api-0.0.34 → violet_poolcontroller_api-0.0.36}/violet_poolcontroller_api/utils_sanitizer.py +9 -54
- violet_poolcontroller_api-0.0.34/tests/test_parsers.py +0 -161
- violet_poolcontroller_api-0.0.34/violet_poolcontroller_api/api.py +0 -2227
- {violet_poolcontroller_api-0.0.34 → violet_poolcontroller_api-0.0.36}/LICENSE +0 -0
- {violet_poolcontroller_api-0.0.34 → violet_poolcontroller_api-0.0.36}/README.md +0 -0
- {violet_poolcontroller_api-0.0.34 → violet_poolcontroller_api-0.0.36}/setup.cfg +0 -0
- {violet_poolcontroller_api-0.0.34 → violet_poolcontroller_api-0.0.36}/violet_poolController_api.egg-info/dependency_links.txt +0 -0
- {violet_poolcontroller_api-0.0.34 → violet_poolcontroller_api-0.0.36}/violet_poolController_api.egg-info/top_level.txt +0 -0
- {violet_poolcontroller_api-0.0.34 → 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
|
|
@@ -96,6 +96,47 @@ async def test_get_readings_success(
|
|
|
96
96
|
assert dict(result) == mock_data
|
|
97
97
|
|
|
98
98
|
|
|
99
|
+
@pytest.mark.asyncio
|
|
100
|
+
async def test_get_output_runtimes_success(
|
|
101
|
+
mock_aioresponse: aioresponses,
|
|
102
|
+
api_client: VioletPoolAPI,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Test get_output_runtimes parses a JSON dict response.
|
|
105
|
+
|
|
106
|
+
Regression: previously the endpoint was requested without expect_json,
|
|
107
|
+
so the raw text body was returned and the method logged a warning
|
|
108
|
+
("Unexpected non-dict response for get_output_runtimes: str") and
|
|
109
|
+
returned an empty dict.
|
|
110
|
+
"""
|
|
111
|
+
url = "http://192.168.1.100/getOutputruntimes"
|
|
112
|
+
mock_data = {
|
|
113
|
+
"PUMP_RUNTIME": "04h 33m 12s",
|
|
114
|
+
"PUMP_LAST_ON": 1700000000,
|
|
115
|
+
"PUMP_LAST_OFF": 1699996800,
|
|
116
|
+
"CPU_UPTIME": "12d 4h 30m",
|
|
117
|
+
"LOAD_AVG": "0.42",
|
|
118
|
+
}
|
|
119
|
+
mock_aioresponse.get(url, payload=mock_data, status=200)
|
|
120
|
+
|
|
121
|
+
result = await api_client.get_output_runtimes()
|
|
122
|
+
|
|
123
|
+
assert isinstance(result, dict)
|
|
124
|
+
assert result == mock_data
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@pytest.mark.asyncio
|
|
128
|
+
async def test_get_output_runtimes_non_dict_raises(
|
|
129
|
+
mock_aioresponse: aioresponses,
|
|
130
|
+
api_client: VioletPoolAPI,
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Test get_output_runtimes raises when the controller returns a non-dict."""
|
|
133
|
+
url = "http://192.168.1.100/getOutputruntimes"
|
|
134
|
+
mock_aioresponse.get(url, body="not-json", status=200)
|
|
135
|
+
|
|
136
|
+
with pytest.raises(VioletPoolAPIError):
|
|
137
|
+
await api_client.get_output_runtimes()
|
|
138
|
+
|
|
139
|
+
|
|
99
140
|
@pytest.mark.asyncio
|
|
100
141
|
async def test_set_pump_speed_success(
|
|
101
142
|
mock_aioresponse: aioresponses,
|
|
@@ -486,7 +527,7 @@ async def test_ext1_readings_filtered_when_not_detected(mock_aioresponse, api_cl
|
|
|
486
527
|
payload={
|
|
487
528
|
"getReadings": {
|
|
488
529
|
"PUMPSTATE": "2",
|
|
489
|
-
# No SYSTEM_ext1module_alive_count
|
|
530
|
+
# No SYSTEM_ext1module_alive_count → module not connected
|
|
490
531
|
"EXT1_1": 0,
|
|
491
532
|
"EXT1_2": 0,
|
|
492
533
|
}
|
|
@@ -801,6 +842,32 @@ async def test_set_device_temperature_solar(
|
|
|
801
842
|
assert result["success"] is True
|
|
802
843
|
|
|
803
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
|
+
|
|
804
871
|
@pytest.mark.asyncio
|
|
805
872
|
async def test_set_ph_target(
|
|
806
873
|
mock_aioresponse: aioresponses,
|
|
@@ -871,6 +938,20 @@ async def test_set_dosing_parameters(
|
|
|
871
938
|
assert result["success"] is True
|
|
872
939
|
|
|
873
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
|
+
|
|
874
955
|
@pytest.mark.asyncio
|
|
875
956
|
async def test_reset_blocking(
|
|
876
957
|
mock_aioresponse: aioresponses,
|
|
@@ -1515,6 +1596,40 @@ async def test_manual_dosing_unknown_type(api_client: VioletPoolAPI) -> None:
|
|
|
1515
1596
|
assert "Unknown dosing type" in str(exc_info.value)
|
|
1516
1597
|
|
|
1517
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
|
+
|
|
1518
1633
|
@pytest.mark.asyncio
|
|
1519
1634
|
async def test_manual_dosing_stop(
|
|
1520
1635
|
mock_aioresponse: aioresponses,
|
|
@@ -1689,6 +1804,51 @@ async def test_client_error_does_not_trip_circuit_breaker(
|
|
|
1689
1804
|
assert stats["state"].name == "CLOSED"
|
|
1690
1805
|
|
|
1691
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
|
+
|
|
1692
1852
|
@pytest.mark.asyncio
|
|
1693
1853
|
async def test_server_error_still_counts_for_circuit_breaker(
|
|
1694
1854
|
mock_aioresponse: aioresponses,
|
|
@@ -1740,3 +1900,152 @@ def test_state_translation_language_switch() -> None:
|
|
|
1740
1900
|
|
|
1741
1901
|
with pytest.raises(ValueError, match="Unsupported language"):
|
|
1742
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.34 → 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.34 → 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
|