violet-poolController-api 0.0.35__tar.gz → 0.0.37__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {violet_poolcontroller_api-0.0.35/violet_poolController_api.egg-info → violet_poolcontroller_api-0.0.37}/PKG-INFO +7 -6
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/pyproject.toml +17 -13
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/tests/test_api.py +352 -3
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/tests/test_api_smoke.py +43 -22
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/tests/test_circuit_breaker.py +44 -3
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/tests/test_mock_server.py +11 -5
- violet_poolcontroller_api-0.0.37/tests/test_parsers.py +139 -0
- violet_poolcontroller_api-0.0.37/tests/test_rate_limiter.py +52 -0
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/tests/test_readings.py +42 -2
- violet_poolcontroller_api-0.0.37/tests/test_sanitizer.py +43 -0
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37/violet_poolController_api.egg-info}/PKG-INFO +7 -6
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolController_api.egg-info/SOURCES.txt +8 -0
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolController_api.egg-info/requires.txt +2 -1
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/__init__.py +1 -1
- violet_poolcontroller_api-0.0.37/violet_poolcontroller_api/_api_dosing.py +363 -0
- violet_poolcontroller_api-0.0.37/violet_poolcontroller_api/_api_mixin.py +85 -0
- violet_poolcontroller_api-0.0.37/violet_poolcontroller_api/_api_model.py +81 -0
- violet_poolcontroller_api-0.0.37/violet_poolcontroller_api/_api_outputs.py +546 -0
- violet_poolcontroller_api-0.0.37/violet_poolcontroller_api/_api_readings.py +256 -0
- violet_poolcontroller_api-0.0.37/violet_poolcontroller_api/_api_system.py +380 -0
- violet_poolcontroller_api-0.0.37/violet_poolcontroller_api/api.py +807 -0
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/circuit_breaker.py +39 -26
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/const_api.py +2 -0
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/const_devices.py +1 -1
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/parsers.py +2 -2
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/readings.py +4 -2
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/utils_rate_limiter.py +102 -48
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/utils_sanitizer.py +22 -56
- 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.37}/LICENSE +0 -0
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/README.md +0 -0
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/setup.cfg +0 -0
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolController_api.egg-info/dependency_links.txt +0 -0
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolController_api.egg-info/top_level.txt +0 -0
- {violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/violet_poolcontroller_api/py.typed +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: violet-poolController-api
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.37
|
|
4
4
|
Summary: Asynchronous Python client for the Violet Pool Controller.
|
|
5
5
|
Author-email: "Basti (Xerolux)" <git@xerolux.de>
|
|
6
|
-
License: AGPL-3.0-or-later
|
|
7
|
-
Project-URL: Homepage, https://github.com/Xerolux/violet-
|
|
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.37"
|
|
14
8
|
authors = [
|
|
15
9
|
{ name="Basti (Xerolux)", email="git@xerolux.de" },
|
|
16
10
|
]
|
|
17
11
|
description = "Asynchronous Python client for the Violet Pool Controller."
|
|
18
12
|
readme = "README.md"
|
|
19
13
|
requires-python = ">=3.12"
|
|
20
|
-
license =
|
|
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
|
|
@@ -22,6 +22,7 @@ from __future__ import annotations
|
|
|
22
22
|
|
|
23
23
|
from collections.abc import Mapping
|
|
24
24
|
from typing import TYPE_CHECKING, Any, Never
|
|
25
|
+
from unittest.mock import AsyncMock
|
|
25
26
|
|
|
26
27
|
import aiohttp
|
|
27
28
|
import pytest
|
|
@@ -29,7 +30,11 @@ import pytest_asyncio
|
|
|
29
30
|
from aioresponses import aioresponses
|
|
30
31
|
from yarl import URL
|
|
31
32
|
|
|
32
|
-
from violet_poolcontroller_api.api import
|
|
33
|
+
from violet_poolcontroller_api.api import (
|
|
34
|
+
VioletPoolAPI,
|
|
35
|
+
VioletPoolAPIError,
|
|
36
|
+
VioletSetpointError,
|
|
37
|
+
)
|
|
33
38
|
from violet_poolcontroller_api.circuit_breaker import CircuitBreakerOpenError
|
|
34
39
|
from violet_poolcontroller_api.const_api import (
|
|
35
40
|
ERROR_CODES,
|
|
@@ -37,6 +42,7 @@ from violet_poolcontroller_api.const_api import (
|
|
|
37
42
|
ERROR_SEVERITY_INFO,
|
|
38
43
|
ERROR_SEVERITY_REMINDER,
|
|
39
44
|
ERROR_SEVERITY_WARNING,
|
|
45
|
+
TARGET_PH,
|
|
40
46
|
)
|
|
41
47
|
|
|
42
48
|
if TYPE_CHECKING:
|
|
@@ -527,7 +533,7 @@ async def test_ext1_readings_filtered_when_not_detected(mock_aioresponse, api_cl
|
|
|
527
533
|
payload={
|
|
528
534
|
"getReadings": {
|
|
529
535
|
"PUMPSTATE": "2",
|
|
530
|
-
# No SYSTEM_ext1module_alive_count
|
|
536
|
+
# No SYSTEM_ext1module_alive_count → module not connected
|
|
531
537
|
"EXT1_1": 0,
|
|
532
538
|
"EXT1_2": 0,
|
|
533
539
|
}
|
|
@@ -842,6 +848,32 @@ async def test_set_device_temperature_solar(
|
|
|
842
848
|
assert result["success"] is True
|
|
843
849
|
|
|
844
850
|
|
|
851
|
+
@pytest.mark.asyncio
|
|
852
|
+
async def test_set_device_temperature_normalizes_key(
|
|
853
|
+
mock_aioresponse: aioresponses,
|
|
854
|
+
api_client: VioletPoolAPI,
|
|
855
|
+
) -> None:
|
|
856
|
+
url = "http://192.168.1.100/setConfig"
|
|
857
|
+
mock_aioresponse.post(url, body="OK", status=200)
|
|
858
|
+
|
|
859
|
+
await api_client.set_device_temperature(" heater ", 28.0)
|
|
860
|
+
|
|
861
|
+
request = mock_aioresponse.requests[("POST", URL(url))][0]
|
|
862
|
+
assert request.kwargs["data"] == {"HEATER_set_temp": 28.0}
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
@pytest.mark.asyncio
|
|
866
|
+
async def test_set_device_temperature_rejects_unknown_key(api_client: VioletPoolAPI) -> None:
|
|
867
|
+
with pytest.raises(VioletPoolAPIError, match="Expected HEATER or SOLAR"):
|
|
868
|
+
await api_client.set_device_temperature("DOSAGE", 28.0)
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
@pytest.mark.asyncio
|
|
872
|
+
async def test_set_device_temperature_cannot_bypass_range(api_client: VioletPoolAPI) -> None:
|
|
873
|
+
with pytest.raises(VioletPoolAPIError, match="outside the valid range"):
|
|
874
|
+
await api_client.set_device_temperature("heater", 1000.0)
|
|
875
|
+
|
|
876
|
+
|
|
845
877
|
@pytest.mark.asyncio
|
|
846
878
|
async def test_set_ph_target(
|
|
847
879
|
mock_aioresponse: aioresponses,
|
|
@@ -912,6 +944,29 @@ async def test_set_dosing_parameters(
|
|
|
912
944
|
assert result["success"] is True
|
|
913
945
|
|
|
914
946
|
|
|
947
|
+
@pytest.mark.asyncio
|
|
948
|
+
async def test_set_dosing_parameters_enforces_setpoint_range(
|
|
949
|
+
api_client: VioletPoolAPI,
|
|
950
|
+
) -> None:
|
|
951
|
+
"""set_dosing_parameters must not bypass the pH/ORP/chlorine safety bounds."""
|
|
952
|
+
with pytest.raises(VioletSetpointError, match="outside the valid range"):
|
|
953
|
+
await api_client.set_dosing_parameters({TARGET_PH: 20.0})
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
@pytest.mark.asyncio
|
|
957
|
+
@pytest.mark.parametrize(("raw_value", "expected"), [("1", True), ("1.0", True), ("0.0", False)])
|
|
958
|
+
async def test_is_dosage_enabled_accepts_numeric_strings(
|
|
959
|
+
mock_aioresponse: aioresponses,
|
|
960
|
+
api_client: VioletPoolAPI,
|
|
961
|
+
raw_value: str,
|
|
962
|
+
expected: bool,
|
|
963
|
+
) -> None:
|
|
964
|
+
url = "http://192.168.1.100/getConfig?DOSAGE_phminus_use"
|
|
965
|
+
mock_aioresponse.get(url, payload={"DOSAGE_phminus_use": raw_value}, status=200)
|
|
966
|
+
|
|
967
|
+
assert await api_client.is_dosage_enabled("pH-") is expected
|
|
968
|
+
|
|
969
|
+
|
|
915
970
|
@pytest.mark.asyncio
|
|
916
971
|
async def test_reset_blocking(
|
|
917
972
|
mock_aioresponse: aioresponses,
|
|
@@ -1063,6 +1118,38 @@ async def test_get_system_services(
|
|
|
1063
1118
|
}
|
|
1064
1119
|
|
|
1065
1120
|
|
|
1121
|
+
@pytest.mark.asyncio
|
|
1122
|
+
async def test_get_system_services_accepts_decimal_state(
|
|
1123
|
+
mock_aioresponse: aioresponses,
|
|
1124
|
+
api_client: VioletPoolAPI,
|
|
1125
|
+
) -> None:
|
|
1126
|
+
"""The controller may report service state as a decimal string like '1.0'."""
|
|
1127
|
+
url = "http://192.168.1.100/getServiceStates"
|
|
1128
|
+
mock_aioresponse.get(
|
|
1129
|
+
url,
|
|
1130
|
+
payload={"proftpd": "1.0", "shairport": "0.0"},
|
|
1131
|
+
status=200,
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
result = await api_client.get_system_services()
|
|
1135
|
+
|
|
1136
|
+
assert result["ftp"] is True
|
|
1137
|
+
assert result["shairport"] is False
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
@pytest.mark.asyncio
|
|
1141
|
+
async def test_get_system_services_rejects_garbage_state(
|
|
1142
|
+
mock_aioresponse: aioresponses,
|
|
1143
|
+
api_client: VioletPoolAPI,
|
|
1144
|
+
) -> None:
|
|
1145
|
+
"""A non-numeric service state must raise, not crash with a bare ValueError."""
|
|
1146
|
+
url = "http://192.168.1.100/getServiceStates"
|
|
1147
|
+
mock_aioresponse.get(url, payload={"proftpd": "not-a-number"}, status=200)
|
|
1148
|
+
|
|
1149
|
+
with pytest.raises(VioletPoolAPIError, match="Invalid service state"):
|
|
1150
|
+
await api_client.get_system_services()
|
|
1151
|
+
|
|
1152
|
+
|
|
1066
1153
|
# ---------------------------------------------------------------------------
|
|
1067
1154
|
# OmniTronic + RS485 + LiveTrace (P12 / P13 / P15)
|
|
1068
1155
|
# ---------------------------------------------------------------------------
|
|
@@ -1556,6 +1643,40 @@ async def test_manual_dosing_unknown_type(api_client: VioletPoolAPI) -> None:
|
|
|
1556
1643
|
assert "Unknown dosing type" in str(exc_info.value)
|
|
1557
1644
|
|
|
1558
1645
|
|
|
1646
|
+
@pytest.mark.asyncio
|
|
1647
|
+
@pytest.mark.parametrize("duration", [0.5, 86401, float("nan"), float("inf")])
|
|
1648
|
+
async def test_dosing_start_rejects_unsafe_duration(
|
|
1649
|
+
api_client: VioletPoolAPI,
|
|
1650
|
+
duration: float,
|
|
1651
|
+
) -> None:
|
|
1652
|
+
with pytest.raises(VioletPoolAPIError, match="Duration must be a whole number"):
|
|
1653
|
+
await api_client.set_switch_state("DOS_1_CL", "ON", duration=duration)
|
|
1654
|
+
|
|
1655
|
+
|
|
1656
|
+
@pytest.mark.asyncio
|
|
1657
|
+
async def test_manual_dosing_post_is_not_retried(
|
|
1658
|
+
mock_aioresponse: aioresponses,
|
|
1659
|
+
) -> None:
|
|
1660
|
+
url = "http://192.168.1.100/triggerManualDosing"
|
|
1661
|
+
mock_aioresponse.post(url, status=500, body="boom")
|
|
1662
|
+
async with aiohttp.ClientSession() as session:
|
|
1663
|
+
api = VioletPoolAPI(host="192.168.1.100", session=session, max_retries=3)
|
|
1664
|
+
with pytest.raises(VioletPoolAPIError):
|
|
1665
|
+
await api.manual_dosing("Chlor", 30)
|
|
1666
|
+
|
|
1667
|
+
assert len(mock_aioresponse.requests[("POST", URL(url))]) == 1
|
|
1668
|
+
|
|
1669
|
+
|
|
1670
|
+
@pytest.mark.asyncio
|
|
1671
|
+
@pytest.mark.parametrize("duration", [-1, 0.5, 86401, float("nan")])
|
|
1672
|
+
async def test_switch_state_rejects_invalid_duration(
|
|
1673
|
+
api_client: VioletPoolAPI,
|
|
1674
|
+
duration: float,
|
|
1675
|
+
) -> None:
|
|
1676
|
+
with pytest.raises(VioletPoolAPIError, match="Duration must be a whole number"):
|
|
1677
|
+
await api_client.set_switch_state("PUMP", "ON", duration=duration)
|
|
1678
|
+
|
|
1679
|
+
|
|
1559
1680
|
@pytest.mark.asyncio
|
|
1560
1681
|
async def test_manual_dosing_stop(
|
|
1561
1682
|
mock_aioresponse: aioresponses,
|
|
@@ -1730,6 +1851,51 @@ async def test_client_error_does_not_trip_circuit_breaker(
|
|
|
1730
1851
|
assert stats["state"].name == "CLOSED"
|
|
1731
1852
|
|
|
1732
1853
|
|
|
1854
|
+
@pytest.mark.asyncio
|
|
1855
|
+
async def test_empty_get_readings_payload_is_flattened(
|
|
1856
|
+
mock_aioresponse: aioresponses,
|
|
1857
|
+
api_client: VioletPoolAPI,
|
|
1858
|
+
) -> None:
|
|
1859
|
+
url = "http://192.168.1.100/getReadings?ALL"
|
|
1860
|
+
mock_aioresponse.get(url, payload={"getReadings": {}}, status=200)
|
|
1861
|
+
|
|
1862
|
+
readings = await api_client.get_readings()
|
|
1863
|
+
|
|
1864
|
+
assert dict(readings) == {}
|
|
1865
|
+
|
|
1866
|
+
|
|
1867
|
+
@pytest.mark.asyncio
|
|
1868
|
+
async def test_empty_standalone_payload_updates_mode(api_client: VioletPoolAPI) -> None:
|
|
1869
|
+
result = api_client._flatten_getreadings_response({"getReadings": []}) # noqa: SLF001
|
|
1870
|
+
|
|
1871
|
+
assert result == {}
|
|
1872
|
+
assert api_client.dosing_standalone is True
|
|
1873
|
+
|
|
1874
|
+
|
|
1875
|
+
@pytest.mark.asyncio
|
|
1876
|
+
async def test_clients_use_independent_rate_limiters() -> None:
|
|
1877
|
+
async with aiohttp.ClientSession() as session:
|
|
1878
|
+
first = VioletPoolAPI(host="192.168.1.100", session=session)
|
|
1879
|
+
second = VioletPoolAPI(host="192.168.1.101", session=session)
|
|
1880
|
+
|
|
1881
|
+
assert first._rate_limiter is not second._rate_limiter # noqa: SLF001
|
|
1882
|
+
|
|
1883
|
+
|
|
1884
|
+
@pytest.mark.asyncio
|
|
1885
|
+
async def test_basic_auth_uses_authorization_header(
|
|
1886
|
+
mock_aioresponse: aioresponses,
|
|
1887
|
+
api_client: VioletPoolAPI,
|
|
1888
|
+
) -> None:
|
|
1889
|
+
url = "http://192.168.1.100/getReadings?ALL"
|
|
1890
|
+
mock_aioresponse.get(url, payload={}, status=200)
|
|
1891
|
+
|
|
1892
|
+
await api_client.get_readings()
|
|
1893
|
+
|
|
1894
|
+
request = next(iter(mock_aioresponse.requests.values()))[0]
|
|
1895
|
+
assert request.kwargs["headers"]["Authorization"].startswith("Basic ")
|
|
1896
|
+
assert "auth" not in request.kwargs
|
|
1897
|
+
|
|
1898
|
+
|
|
1733
1899
|
@pytest.mark.asyncio
|
|
1734
1900
|
async def test_server_error_still_counts_for_circuit_breaker(
|
|
1735
1901
|
mock_aioresponse: aioresponses,
|
|
@@ -1748,6 +1914,40 @@ async def test_server_error_still_counts_for_circuit_breaker(
|
|
|
1748
1914
|
assert stats["failure_count"] == 1
|
|
1749
1915
|
|
|
1750
1916
|
|
|
1917
|
+
@pytest.mark.asyncio
|
|
1918
|
+
async def test_rate_limiter_reacquired_on_every_retry(
|
|
1919
|
+
mock_aioresponse: aioresponses,
|
|
1920
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1921
|
+
) -> None:
|
|
1922
|
+
"""Each retry attempt must re-acquire a rate-limit token, not just the first.
|
|
1923
|
+
|
|
1924
|
+
Regression test: wait_if_needed() used to be awaited once before the
|
|
1925
|
+
retry loop, so retries fired real HTTP requests without ever going
|
|
1926
|
+
through the limiter again.
|
|
1927
|
+
"""
|
|
1928
|
+
url = "http://192.168.1.100/getReadings?ALL"
|
|
1929
|
+
mock_aioresponse.get(url, status=500, body="boom", repeat=True)
|
|
1930
|
+
# Backoff sleeps aren't what this test verifies; skip them for speed.
|
|
1931
|
+
monkeypatch.setattr("violet_poolcontroller_api.api.asyncio.sleep", AsyncMock())
|
|
1932
|
+
|
|
1933
|
+
async with aiohttp.ClientSession() as session:
|
|
1934
|
+
api = VioletPoolAPI(host="192.168.1.100", session=session, max_retries=3)
|
|
1935
|
+
call_count = 0
|
|
1936
|
+
original_wait = api._rate_limiter.wait_if_needed # noqa: SLF001
|
|
1937
|
+
|
|
1938
|
+
async def counting_wait(*args: object, **kwargs: object) -> None:
|
|
1939
|
+
nonlocal call_count
|
|
1940
|
+
call_count += 1
|
|
1941
|
+
await original_wait(*args, **kwargs)
|
|
1942
|
+
|
|
1943
|
+
api._rate_limiter.wait_if_needed = counting_wait # type: ignore[method-assign] # noqa: SLF001
|
|
1944
|
+
|
|
1945
|
+
with pytest.raises(VioletPoolAPIError):
|
|
1946
|
+
await api.get_readings()
|
|
1947
|
+
|
|
1948
|
+
assert call_count == 3
|
|
1949
|
+
|
|
1950
|
+
|
|
1751
1951
|
def test_command_result_error_first_line() -> None:
|
|
1752
1952
|
"""Line 1 of the response decides success per manual section 26.2."""
|
|
1753
1953
|
result = VioletPoolAPI._command_result("ERROR\nPUMP\nUNKNOWN OUTPUT")
|
|
@@ -1781,3 +1981,152 @@ def test_state_translation_language_switch() -> None:
|
|
|
1781
1981
|
|
|
1782
1982
|
with pytest.raises(ValueError, match="Unsupported language"):
|
|
1783
1983
|
set_state_translation_language("fr")
|
|
1984
|
+
|
|
1985
|
+
|
|
1986
|
+
@pytest.mark.asyncio
|
|
1987
|
+
async def test_set_config_preserves_int_values(
|
|
1988
|
+
api_client: VioletPoolAPI,
|
|
1989
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1990
|
+
) -> None:
|
|
1991
|
+
"""Integer config values must be sent as integers, not coerced to float.
|
|
1992
|
+
|
|
1993
|
+
Regression for v0.0.36: ``InputSanitizer.sanitize_numeric`` returns a
|
|
1994
|
+
float, so an integer flag like ``1`` became ``1.0`` on the wire. The
|
|
1995
|
+
controller firmware expects integer flags (e.g. ``DOS_*_use``).
|
|
1996
|
+
"""
|
|
1997
|
+
captured: dict[str, object] = {}
|
|
1998
|
+
|
|
1999
|
+
async def fake_request(endpoint: str, **kwargs: Any) -> str: # noqa: ANN401
|
|
2000
|
+
captured["data"] = kwargs.get("data")
|
|
2001
|
+
return "OK"
|
|
2002
|
+
|
|
2003
|
+
monkeypatch.setattr(api_client, "_request", fake_request)
|
|
2004
|
+
|
|
2005
|
+
await api_client.set_config({"FLAG_enabled": 1, "FLAG_disabled": 0, "ph_set": 7.4})
|
|
2006
|
+
|
|
2007
|
+
data = captured["data"]
|
|
2008
|
+
assert data == {"FLAG_enabled": 1, "FLAG_disabled": 0, "ph_set": 7.4}
|
|
2009
|
+
assert isinstance(data["FLAG_enabled"], int)
|
|
2010
|
+
assert isinstance(data["ph_set"], float)
|
|
2011
|
+
|
|
2012
|
+
|
|
2013
|
+
@pytest.mark.asyncio
|
|
2014
|
+
async def test_set_config_preserves_bool_as_int_flag(
|
|
2015
|
+
api_client: VioletPoolAPI,
|
|
2016
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2017
|
+
) -> None:
|
|
2018
|
+
"""Booleans are sent as 0/1 integer flags (bool is an int subclass)."""
|
|
2019
|
+
captured: dict[str, object] = {}
|
|
2020
|
+
|
|
2021
|
+
async def fake_request(endpoint: str, **kwargs: Any) -> str: # noqa: ANN401
|
|
2022
|
+
captured["data"] = kwargs.get("data")
|
|
2023
|
+
return "OK"
|
|
2024
|
+
|
|
2025
|
+
monkeypatch.setattr(api_client, "_request", fake_request)
|
|
2026
|
+
|
|
2027
|
+
await api_client.set_config({"DOS_1_use": True, "DOS_2_use": False})
|
|
2028
|
+
|
|
2029
|
+
data = captured["data"]
|
|
2030
|
+
assert data == {"DOS_1_use": 1, "DOS_2_use": 0}
|
|
2031
|
+
|
|
2032
|
+
|
|
2033
|
+
@pytest.mark.asyncio
|
|
2034
|
+
async def test_set_config_not_retried_on_server_error(
|
|
2035
|
+
mock_aioresponse: aioresponses,
|
|
2036
|
+
api_client: VioletPoolAPI,
|
|
2037
|
+
) -> None:
|
|
2038
|
+
"""set_config must not retry on 5xx — consistent with other POSTs.
|
|
2039
|
+
|
|
2040
|
+
A duplicated POST could re-apply a configuration change. POST methods
|
|
2041
|
+
default to non-retryable; set_config previously opted into retries.
|
|
2042
|
+
"""
|
|
2043
|
+
url = "http://192.168.1.100/setConfig"
|
|
2044
|
+
mock_aioresponse.post(url, status=500, body="server error")
|
|
2045
|
+
|
|
2046
|
+
with pytest.raises(VioletPoolAPIError):
|
|
2047
|
+
await api_client.set_config({"some_key": 1})
|
|
2048
|
+
|
|
2049
|
+
assert len(mock_aioresponse.requests[("POST", URL(url))]) == 1
|
|
2050
|
+
|
|
2051
|
+
|
|
2052
|
+
@pytest.mark.asyncio
|
|
2053
|
+
async def test_get_calibration_history_url_encodes_sensor(
|
|
2054
|
+
api_client: VioletPoolAPI,
|
|
2055
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2056
|
+
) -> None:
|
|
2057
|
+
"""Sensor names with reserved characters must be percent-encoded.
|
|
2058
|
+
|
|
2059
|
+
Regression for v0.0.36: ``sensor`` was concatenated raw into the URL,
|
|
2060
|
+
so a value containing ``&`` or space could inject extra query
|
|
2061
|
+
parameters. The value passed to ``_request`` must be percent-encoded.
|
|
2062
|
+
"""
|
|
2063
|
+
captured: dict[str, object] = {}
|
|
2064
|
+
|
|
2065
|
+
async def fake_request(endpoint: str, **kwargs: Any) -> str: # noqa: ANN401
|
|
2066
|
+
captured["query"] = kwargs.get("query")
|
|
2067
|
+
return "2024-01-01 | 7.2 | pH_CALIB"
|
|
2068
|
+
|
|
2069
|
+
monkeypatch.setattr(api_client, "_request", fake_request)
|
|
2070
|
+
|
|
2071
|
+
await api_client.get_calibration_history("pH value & test")
|
|
2072
|
+
|
|
2073
|
+
assert captured["query"] == "pH%20value%20%26%20test"
|
|
2074
|
+
|
|
2075
|
+
|
|
2076
|
+
@pytest.mark.asyncio
|
|
2077
|
+
async def test_get_log_rejects_unknown_log_type(api_client: VioletPoolAPI) -> None:
|
|
2078
|
+
"""Unknown log_type values must be rejected before the request is sent."""
|
|
2079
|
+
with pytest.raises(VioletPoolAPIError, match="Unsupported log_type"):
|
|
2080
|
+
await api_client.get_log("drop_table")
|
|
2081
|
+
|
|
2082
|
+
|
|
2083
|
+
@pytest.mark.asyncio
|
|
2084
|
+
async def test_get_log_valid_log_type_reaches_request(
|
|
2085
|
+
api_client: VioletPoolAPI,
|
|
2086
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2087
|
+
) -> None:
|
|
2088
|
+
"""Valid log_type is passed through to the request layer."""
|
|
2089
|
+
captured: dict[str, object] = {}
|
|
2090
|
+
|
|
2091
|
+
async def fake_request(endpoint: str, **kwargs: Any) -> str: # noqa: ANN401
|
|
2092
|
+
captured["query"] = kwargs.get("query")
|
|
2093
|
+
return "line one\nLOAD_MORE"
|
|
2094
|
+
|
|
2095
|
+
monkeypatch.setattr(api_client, "_request", fake_request)
|
|
2096
|
+
|
|
2097
|
+
result = await api_client.get_log("actions", page=0)
|
|
2098
|
+
|
|
2099
|
+
assert captured["query"] == "actions&0"
|
|
2100
|
+
assert result["has_more"] is True
|
|
2101
|
+
assert result["lines"] == ["line one"]
|
|
2102
|
+
|
|
2103
|
+
|
|
2104
|
+
@pytest.mark.asyncio
|
|
2105
|
+
async def test_restore_calibration_sanitizes_payload(
|
|
2106
|
+
api_client: VioletPoolAPI,
|
|
2107
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2108
|
+
) -> None:
|
|
2109
|
+
"""restore_calibration must sanitize sensor/timestamp before posting."""
|
|
2110
|
+
captured: dict[str, object] = {}
|
|
2111
|
+
|
|
2112
|
+
async def fake_request(endpoint: str, **kwargs: Any) -> str: # noqa: ANN401
|
|
2113
|
+
captured["data"] = kwargs.get("data")
|
|
2114
|
+
return "OK"
|
|
2115
|
+
|
|
2116
|
+
monkeypatch.setattr(api_client, "_request", fake_request)
|
|
2117
|
+
|
|
2118
|
+
await api_client.restore_calibration("pH", "2024-01-01 12:00")
|
|
2119
|
+
|
|
2120
|
+
assert captured["data"] == {"sensor": "pH", "timestamp": "2024-01-01 12:00"}
|
|
2121
|
+
|
|
2122
|
+
|
|
2123
|
+
def test_input_sanitizer_no_duplicate_validate_duration() -> None:
|
|
2124
|
+
"""InputSanitizer must not expose validate_duration/validate_speed.
|
|
2125
|
+
|
|
2126
|
+
These names collided with the raising validators in ``_api_model`` but
|
|
2127
|
+
silently clamped instead. They were removed in v0.0.36.
|
|
2128
|
+
"""
|
|
2129
|
+
from violet_poolcontroller_api.utils_sanitizer import InputSanitizer
|
|
2130
|
+
|
|
2131
|
+
assert not hasattr(InputSanitizer, "validate_duration")
|
|
2132
|
+
assert not hasattr(InputSanitizer, "validate_speed")
|
{violet_poolcontroller_api-0.0.35 → violet_poolcontroller_api-0.0.37}/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
|
|