violet-poolController-api 0.0.29__tar.gz → 0.0.32__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.29/violet_poolController_api.egg-info → violet_poolcontroller_api-0.0.32}/PKG-INFO +1 -1
- {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/pyproject.toml +1 -1
- {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/tests/test_api.py +355 -5
- violet_poolcontroller_api-0.0.32/tests/test_circuit_breaker.py +204 -0
- {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/tests/test_mock_server.py +18 -10
- violet_poolcontroller_api-0.0.32/tests/test_parsers.py +161 -0
- violet_poolcontroller_api-0.0.32/tests/test_readings.py +192 -0
- {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32/violet_poolController_api.egg-info}/PKG-INFO +1 -1
- {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolController_api.egg-info/SOURCES.txt +3 -0
- {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/__init__.py +2 -0
- {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/api.py +489 -10
- {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/const_api.py +244 -11
- {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/const_devices.py +4 -2
- {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/readings.py +12 -37
- {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/utils_rate_limiter.py +11 -2
- {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/utils_sanitizer.py +5 -0
- {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/LICENSE +0 -0
- {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/README.md +0 -0
- {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/setup.cfg +0 -0
- {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/tests/test_api_smoke.py +0 -0
- {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolController_api.egg-info/dependency_links.txt +0 -0
- {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolController_api.egg-info/requires.txt +0 -0
- {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolController_api.egg-info/top_level.txt +0 -0
- {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/circuit_breaker.py +0 -0
- {violet_poolcontroller_api-0.0.29 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/parsers.py +0 -0
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
23
|
+
from collections.abc import Mapping
|
|
23
24
|
from typing import TYPE_CHECKING, Any, Never
|
|
24
25
|
|
|
25
26
|
import aiohttp
|
|
@@ -34,6 +35,7 @@ from violet_poolcontroller_api.const_api import (
|
|
|
34
35
|
ERROR_CODES,
|
|
35
36
|
ERROR_SEVERITY_ALARM,
|
|
36
37
|
ERROR_SEVERITY_INFO,
|
|
38
|
+
ERROR_SEVERITY_REMINDER,
|
|
37
39
|
ERROR_SEVERITY_WARNING,
|
|
38
40
|
)
|
|
39
41
|
|
|
@@ -90,8 +92,8 @@ async def test_get_readings_success(
|
|
|
90
92
|
|
|
91
93
|
result = await api_client.get_readings()
|
|
92
94
|
|
|
93
|
-
assert isinstance(result,
|
|
94
|
-
assert result == mock_data
|
|
95
|
+
assert isinstance(result, Mapping)
|
|
96
|
+
assert dict(result) == mock_data
|
|
95
97
|
|
|
96
98
|
|
|
97
99
|
@pytest.mark.asyncio
|
|
@@ -289,8 +291,8 @@ async def test_get_readings_standalone_list_format(
|
|
|
289
291
|
|
|
290
292
|
result = await api_client.get_readings()
|
|
291
293
|
|
|
292
|
-
assert isinstance(result,
|
|
293
|
-
assert result == {"date": "12.04.2023", "CPU_TEMP": 45.5}
|
|
294
|
+
assert isinstance(result, Mapping)
|
|
295
|
+
assert dict(result) == {"date": "12.04.2023", "CPU_TEMP": 45.5}
|
|
294
296
|
assert api_client.dosing_standalone is True
|
|
295
297
|
|
|
296
298
|
|
|
@@ -313,7 +315,7 @@ async def test_dosing_standalone_detection_dict_format(
|
|
|
313
315
|
|
|
314
316
|
result = await standalone_api_client.get_readings()
|
|
315
317
|
|
|
316
|
-
assert isinstance(result,
|
|
318
|
+
assert isinstance(result, Mapping)
|
|
317
319
|
assert standalone_api_client.dosing_standalone is False
|
|
318
320
|
|
|
319
321
|
|
|
@@ -869,6 +871,324 @@ async def test_set_dosing_parameters(
|
|
|
869
871
|
assert result["success"] is True
|
|
870
872
|
|
|
871
873
|
|
|
874
|
+
@pytest.mark.asyncio
|
|
875
|
+
async def test_reset_blocking(
|
|
876
|
+
mock_aioresponse: aioresponses,
|
|
877
|
+
api_client: VioletPoolAPI,
|
|
878
|
+
) -> None:
|
|
879
|
+
"""Test reset_blocking clears fault blockings via GET /resetBlocking."""
|
|
880
|
+
url = "http://192.168.1.100/resetBlocking"
|
|
881
|
+
mock_aioresponse.get(url, body="OK\nBLOCKINGS_CLEARED", status=200)
|
|
882
|
+
|
|
883
|
+
result = await api_client.reset_blocking()
|
|
884
|
+
|
|
885
|
+
assert result["success"] is True
|
|
886
|
+
assert "BLOCKINGS_CLEARED" in result["response"]
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
@pytest.mark.asyncio
|
|
890
|
+
async def test_set_can_amount_adjust(
|
|
891
|
+
mock_aioresponse: aioresponses,
|
|
892
|
+
api_client: VioletPoolAPI,
|
|
893
|
+
) -> None:
|
|
894
|
+
"""Test set_can_amount (ADJUST) updates canister level via POST."""
|
|
895
|
+
url = "http://192.168.1.100/setCanAmount"
|
|
896
|
+
mock_aioresponse.post(url, body="OK\nDOS_1_CL\n25000", status=200)
|
|
897
|
+
|
|
898
|
+
result = await api_client.set_can_amount("DOS_1_CL", 25000)
|
|
899
|
+
|
|
900
|
+
assert result["success"] is True
|
|
901
|
+
# Verify form payload sent to the controller
|
|
902
|
+
sent = list(mock_aioresponse.requests.values())[0][0].kwargs["data"]
|
|
903
|
+
assert sent["action"] == "ADJUST"
|
|
904
|
+
assert sent["which"] == "DOS_1_CL"
|
|
905
|
+
assert sent["amount"] == "25000"
|
|
906
|
+
assert sent["cid"] == "1"
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
@pytest.mark.asyncio
|
|
910
|
+
async def test_set_can_amount_reset(
|
|
911
|
+
mock_aioresponse: aioresponses,
|
|
912
|
+
api_client: VioletPoolAPI,
|
|
913
|
+
) -> None:
|
|
914
|
+
"""Test set_can_amount(reset=True) sends RESET action."""
|
|
915
|
+
url = "http://192.168.1.100/setCanAmount"
|
|
916
|
+
mock_aioresponse.post(url, body="OK", status=200)
|
|
917
|
+
|
|
918
|
+
result = await api_client.set_can_amount("DOS_6_FLOC", 20000, reset=True)
|
|
919
|
+
|
|
920
|
+
assert result["success"] is True
|
|
921
|
+
sent = list(mock_aioresponse.requests.values())[0][0].kwargs["data"]
|
|
922
|
+
assert sent["action"] == "RESET"
|
|
923
|
+
assert sent["cid"] == "6"
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
@pytest.mark.asyncio
|
|
927
|
+
async def test_set_can_amount_rejects_unknown_key(
|
|
928
|
+
api_client: VioletPoolAPI,
|
|
929
|
+
) -> None:
|
|
930
|
+
"""Test set_can_amount raises on unknown dosing key."""
|
|
931
|
+
with pytest.raises(VioletPoolAPIError, match="Unknown dosing key"):
|
|
932
|
+
await api_client.set_can_amount("DOS_99_XXX", 1000)
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
@pytest.mark.asyncio
|
|
936
|
+
async def test_set_can_amount_rejects_nonpositive_amount(
|
|
937
|
+
api_client: VioletPoolAPI,
|
|
938
|
+
) -> None:
|
|
939
|
+
"""Test set_can_amount rejects zero/negative fill levels."""
|
|
940
|
+
with pytest.raises(ValueError, match="must be > 0"):
|
|
941
|
+
await api_client.set_can_amount("DOS_1_CL", 0)
|
|
942
|
+
with pytest.raises(ValueError, match="must be > 0"):
|
|
943
|
+
await api_client.set_can_amount("DOS_1_CL", -100)
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
@pytest.mark.asyncio
|
|
947
|
+
async def test_set_system_service_enable(
|
|
948
|
+
mock_aioresponse: aioresponses,
|
|
949
|
+
api_client: VioletPoolAPI,
|
|
950
|
+
) -> None:
|
|
951
|
+
"""Test set_system_service(enabled=True) hits the /enableFTP endpoint."""
|
|
952
|
+
url = "http://192.168.1.100/enableFTP"
|
|
953
|
+
mock_aioresponse.get(url, body="OK\nenableFTP", status=200)
|
|
954
|
+
|
|
955
|
+
result = await api_client.set_system_service("ftp", enabled=True)
|
|
956
|
+
|
|
957
|
+
assert result["success"] is True
|
|
958
|
+
sent_key = list(mock_aioresponse.requests.keys())[0]
|
|
959
|
+
assert str(sent_key[1]).endswith("/enableFTP")
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
@pytest.mark.asyncio
|
|
963
|
+
async def test_set_system_service_disable(
|
|
964
|
+
mock_aioresponse: aioresponses,
|
|
965
|
+
api_client: VioletPoolAPI,
|
|
966
|
+
) -> None:
|
|
967
|
+
"""Test set_system_service(enabled=False) hits /disableSSH."""
|
|
968
|
+
url = "http://192.168.1.100/disableSSH"
|
|
969
|
+
mock_aioresponse.get(url, body="OK", status=200)
|
|
970
|
+
|
|
971
|
+
result = await api_client.set_system_service("ssh", enabled=False)
|
|
972
|
+
|
|
973
|
+
assert result["success"] is True
|
|
974
|
+
sent_key = list(mock_aioresponse.requests.keys())[0]
|
|
975
|
+
assert str(sent_key[1]).endswith("/disableSSH")
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
@pytest.mark.asyncio
|
|
979
|
+
async def test_set_system_service_rejects_unknown(
|
|
980
|
+
api_client: VioletPoolAPI,
|
|
981
|
+
) -> None:
|
|
982
|
+
"""Test set_system_service raises on unknown service name."""
|
|
983
|
+
with pytest.raises(VioletPoolAPIError, match="Unknown system service"):
|
|
984
|
+
await api_client.set_system_service("telnet", enabled=True)
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
@pytest.mark.asyncio
|
|
988
|
+
async def test_get_system_services(
|
|
989
|
+
mock_aioresponse: aioresponses,
|
|
990
|
+
api_client: VioletPoolAPI,
|
|
991
|
+
) -> None:
|
|
992
|
+
"""Test get_system_services normalises raw getServiceStates response."""
|
|
993
|
+
url = "http://192.168.1.100/getServiceStates"
|
|
994
|
+
mock_aioresponse.get(
|
|
995
|
+
url,
|
|
996
|
+
payload={
|
|
997
|
+
"proftpd": 0,
|
|
998
|
+
"shairport": 1,
|
|
999
|
+
"samba": 0,
|
|
1000
|
+
"sshd": 1,
|
|
1001
|
+
"homekit": 0,
|
|
1002
|
+
"tunnel_state": 1,
|
|
1003
|
+
"support_tunnel_state": 0,
|
|
1004
|
+
"date": "29.02.2024",
|
|
1005
|
+
"time": "19:50:02",
|
|
1006
|
+
},
|
|
1007
|
+
status=200,
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
result = await api_client.get_system_services()
|
|
1011
|
+
|
|
1012
|
+
# Alexa has no state_key and must be absent.
|
|
1013
|
+
assert "alexa" not in result
|
|
1014
|
+
assert result == {
|
|
1015
|
+
"ftp": False,
|
|
1016
|
+
"shairport": True,
|
|
1017
|
+
"samba": False,
|
|
1018
|
+
"ssh": True,
|
|
1019
|
+
"homebridge": False,
|
|
1020
|
+
"tunnel": True,
|
|
1021
|
+
"support_tunnel": False,
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
# ---------------------------------------------------------------------------
|
|
1026
|
+
# OmniTronic + RS485 + LiveTrace (P12 / P13 / P15)
|
|
1027
|
+
# ---------------------------------------------------------------------------
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
@pytest.mark.asyncio
|
|
1031
|
+
async def test_set_omni_position(
|
|
1032
|
+
mock_aioresponse: aioresponses,
|
|
1033
|
+
api_client: VioletPoolAPI,
|
|
1034
|
+
) -> None:
|
|
1035
|
+
"""Test set_omni_position sends OMNI,OMNI_DC<N> via setFunctionManually."""
|
|
1036
|
+
url = "http://192.168.1.100/setFunctionManually?OMNI,OMNI_DC2,0,0"
|
|
1037
|
+
mock_aioresponse.get(url, body="OK\nOMNITRONIC\nOMNI_DC2\n", status=200)
|
|
1038
|
+
|
|
1039
|
+
result = await api_client.set_omni_position(2)
|
|
1040
|
+
|
|
1041
|
+
# The mock only matches the exact URL we registered, so a True result
|
|
1042
|
+
# proves the right URL was hit (aioresponses returns 404 otherwise).
|
|
1043
|
+
assert result["success"] is True
|
|
1044
|
+
assert "OMNITRONIC" in result["response"]
|
|
1045
|
+
# One request must have been recorded.
|
|
1046
|
+
assert len(list(mock_aioresponse.requests.values())) == 1
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
@pytest.mark.asyncio
|
|
1050
|
+
async def test_set_omni_position_filtration(
|
|
1051
|
+
mock_aioresponse: aioresponses,
|
|
1052
|
+
api_client: VioletPoolAPI,
|
|
1053
|
+
) -> None:
|
|
1054
|
+
"""Position 0 (Filtration) is valid and maps to OMNI_DC0."""
|
|
1055
|
+
url = "http://192.168.1.100/setFunctionManually?OMNI,OMNI_DC0,0,0"
|
|
1056
|
+
mock_aioresponse.get(url, body="OK\nOMNITRONIC\nOMNI_DC0\n", status=200)
|
|
1057
|
+
|
|
1058
|
+
result = await api_client.set_omni_position(0)
|
|
1059
|
+
|
|
1060
|
+
assert result["success"] is True
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
@pytest.mark.asyncio
|
|
1064
|
+
async def test_set_omni_position_rejects_out_of_range(
|
|
1065
|
+
api_client: VioletPoolAPI,
|
|
1066
|
+
) -> None:
|
|
1067
|
+
"""Positions outside 0-5 are rejected before the request is sent."""
|
|
1068
|
+
with pytest.raises(VioletPoolAPIError, match="Invalid OmniTronic position"):
|
|
1069
|
+
await api_client.set_omni_position(6)
|
|
1070
|
+
with pytest.raises(VioletPoolAPIError, match="Invalid OmniTronic position"):
|
|
1071
|
+
await api_client.set_omni_position(-1)
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
@pytest.mark.asyncio
|
|
1075
|
+
async def test_get_rs485_pump_data(
|
|
1076
|
+
mock_aioresponse: aioresponses,
|
|
1077
|
+
api_client: VioletPoolAPI,
|
|
1078
|
+
) -> None:
|
|
1079
|
+
"""Test get_rs485_pump_data returns the pump's JSON config + live values."""
|
|
1080
|
+
url = "http://192.168.1.100/getRS485PumpData?BADU_ECO_DRIVE_II"
|
|
1081
|
+
mock_aioresponse.get(
|
|
1082
|
+
url,
|
|
1083
|
+
payload={
|
|
1084
|
+
"BRAND": "BADU",
|
|
1085
|
+
"NAME": "Eco Drive II",
|
|
1086
|
+
"pump_rs485_pwr": 450,
|
|
1087
|
+
"SLAVE_PRESENT": "YES",
|
|
1088
|
+
},
|
|
1089
|
+
status=200,
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
result = await api_client.get_rs485_pump_data("BADU_ECO_DRIVE_II")
|
|
1093
|
+
|
|
1094
|
+
assert result["BRAND"] == "BADU"
|
|
1095
|
+
assert result["pump_rs485_pwr"] == 450
|
|
1096
|
+
assert result["SLAVE_PRESENT"] == "YES"
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
@pytest.mark.asyncio
|
|
1100
|
+
async def test_get_rs485_pump_data_rejects_unknown_pump(
|
|
1101
|
+
api_client: VioletPoolAPI,
|
|
1102
|
+
) -> None:
|
|
1103
|
+
with pytest.raises(VioletPoolAPIError, match="Unknown RS485 pump name"):
|
|
1104
|
+
await api_client.get_rs485_pump_data("PENTAIR_VSF")
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
@pytest.mark.asyncio
|
|
1108
|
+
async def test_set_rs485_live(
|
|
1109
|
+
mock_aioresponse: aioresponses,
|
|
1110
|
+
api_client: VioletPoolAPI,
|
|
1111
|
+
) -> None:
|
|
1112
|
+
"""Test set_rs485_live forwards mode+level to the pump's modbus."""
|
|
1113
|
+
url = "http://192.168.1.100/setRS485Live?BADU_ECO_DRIVE_II,1,hz,45"
|
|
1114
|
+
mock_aioresponse.get(url, body='"1|0,0|2,4500"', status=200)
|
|
1115
|
+
|
|
1116
|
+
result = await api_client.set_rs485_live(
|
|
1117
|
+
"BADU_ECO_DRIVE_II", slave_id=1, mode="hz", level=45
|
|
1118
|
+
)
|
|
1119
|
+
|
|
1120
|
+
assert "1|0,0|2,4500" in result
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
@pytest.mark.asyncio
|
|
1124
|
+
async def test_set_rs485_live_rejects_bad_mode(
|
|
1125
|
+
api_client: VioletPoolAPI,
|
|
1126
|
+
) -> None:
|
|
1127
|
+
with pytest.raises(VioletPoolAPIError, match="Invalid RS485 mode"):
|
|
1128
|
+
await api_client.set_rs485_live(
|
|
1129
|
+
"BADU_ECO_DRIVE_II", slave_id=1, mode="percent", level=50
|
|
1130
|
+
)
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
@pytest.mark.asyncio
|
|
1134
|
+
async def test_set_rs485_live_rejects_bad_slave_id(
|
|
1135
|
+
api_client: VioletPoolAPI,
|
|
1136
|
+
) -> None:
|
|
1137
|
+
with pytest.raises(ValueError, match="slave_id must be 1-247"):
|
|
1138
|
+
await api_client.set_rs485_live(
|
|
1139
|
+
"BADU_ECO_DRIVE_II", slave_id=0, mode="hz", level=45
|
|
1140
|
+
)
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
@pytest.mark.asyncio
|
|
1144
|
+
async def test_end_rs485_live(
|
|
1145
|
+
mock_aioresponse: aioresponses,
|
|
1146
|
+
api_client: VioletPoolAPI,
|
|
1147
|
+
) -> None:
|
|
1148
|
+
"""Test end_rs485_live sends the DONE sentinel."""
|
|
1149
|
+
url = "http://192.168.1.100/setRS485Live?DONE"
|
|
1150
|
+
mock_aioresponse.get(url, body='"DONE"', status=200)
|
|
1151
|
+
|
|
1152
|
+
result = await api_client.end_rs485_live()
|
|
1153
|
+
|
|
1154
|
+
assert result == "DONE"
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
@pytest.mark.asyncio
|
|
1158
|
+
async def test_get_live_trace(
|
|
1159
|
+
mock_aioresponse: aioresponses,
|
|
1160
|
+
api_client: VioletPoolAPI,
|
|
1161
|
+
) -> None:
|
|
1162
|
+
"""Test get_live_trace parses the 3-line CSV into a dict."""
|
|
1163
|
+
url = "http://192.168.1.100/getLiveTrace"
|
|
1164
|
+
csv_body = (
|
|
1165
|
+
"epoch;date;time;onewire1_value;pH_value;PUMP\n"
|
|
1166
|
+
"ms;;;°C;;\n"
|
|
1167
|
+
"1709234445000;29.02.2024;19:50:02;7,30;7,3;1\n"
|
|
1168
|
+
)
|
|
1169
|
+
mock_aioresponse.get(url, body=csv_body, status=200)
|
|
1170
|
+
|
|
1171
|
+
result = await api_client.get_live_trace()
|
|
1172
|
+
|
|
1173
|
+
assert result["onewire1_value"] == "7.30" # German comma → dot
|
|
1174
|
+
assert result["pH_value"] == "7.3"
|
|
1175
|
+
assert result["PUMP"] == "1"
|
|
1176
|
+
assert result["date"] == "29.02.2024"
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
@pytest.mark.asyncio
|
|
1180
|
+
async def test_get_live_trace_malformed(
|
|
1181
|
+
mock_aioresponse: aioresponses,
|
|
1182
|
+
api_client: VioletPoolAPI,
|
|
1183
|
+
) -> None:
|
|
1184
|
+
"""Malformed payloads (fewer than 3 lines) raise VioletPoolAPIError."""
|
|
1185
|
+
url = "http://192.168.1.100/getLiveTrace"
|
|
1186
|
+
mock_aioresponse.get(url, body="only_one_line", status=200)
|
|
1187
|
+
|
|
1188
|
+
with pytest.raises(VioletPoolAPIError, match="Malformed getLiveTrace"):
|
|
1189
|
+
await api_client.get_live_trace()
|
|
1190
|
+
|
|
1191
|
+
|
|
872
1192
|
@pytest.mark.asyncio
|
|
873
1193
|
async def test_control_pump(
|
|
874
1194
|
mock_aioresponse: aioresponses,
|
|
@@ -984,6 +1304,7 @@ def test_error_codes_structure() -> None:
|
|
|
984
1304
|
ERROR_SEVERITY_ALARM,
|
|
985
1305
|
ERROR_SEVERITY_WARNING,
|
|
986
1306
|
ERROR_SEVERITY_INFO,
|
|
1307
|
+
ERROR_SEVERITY_REMINDER,
|
|
987
1308
|
), f"Code {code} has invalid severity: {info['severity']}"
|
|
988
1309
|
|
|
989
1310
|
|
|
@@ -1012,6 +1333,35 @@ def test_error_codes_hardware_modules() -> None:
|
|
|
1012
1333
|
assert ERROR_CODES["0209"]["severity"] == ERROR_SEVERITY_ALARM
|
|
1013
1334
|
|
|
1014
1335
|
|
|
1336
|
+
def test_error_codes_omnitronic_faults_added() -> None:
|
|
1337
|
+
"""OmniTronic multi-port valve faults (0045/46/47/49) are present as ALARM."""
|
|
1338
|
+
# Regression test for missing backwash-valve codes – see CLAUDE.md notes.
|
|
1339
|
+
for code in ("0045", "0046", "0047", "0049"):
|
|
1340
|
+
assert code in ERROR_CODES, f"Missing OmniTronic code {code}"
|
|
1341
|
+
assert ERROR_CODES[code]["severity"] == ERROR_SEVERITY_ALARM
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def test_error_codes_h2o2_dosing_added() -> None:
|
|
1345
|
+
"""H2O2 dosing warnings (0142-0145) are present."""
|
|
1346
|
+
for code in ("0142", "0143", "0144", "0145"):
|
|
1347
|
+
assert code in ERROR_CODES, f"Missing H2O2 code {code}"
|
|
1348
|
+
assert ERROR_CODES[code]["severity"] == ERROR_SEVERITY_WARNING
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
def test_error_codes_reminder_category() -> None:
|
|
1352
|
+
"""REMINDER-category codes are classified correctly (not INFO)."""
|
|
1353
|
+
# 0003 birthday, 0005 system status, 0010-0012 updates, 0180-0182 calibration.
|
|
1354
|
+
for code in ("0003", "0005", "0010", "0011", "0012", "0180", "0181", "0182"):
|
|
1355
|
+
assert ERROR_CODES[code]["severity"] == ERROR_SEVERITY_REMINDER, code
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
def test_error_code_0005_text_corrected() -> None:
|
|
1359
|
+
"""Code 0005 is the generic system-status reminder, not cloud maintenance."""
|
|
1360
|
+
# Regression: previously held wrong text "Wartungsarbeiten am Cloud-Server".
|
|
1361
|
+
assert "Cloud-Server" not in ERROR_CODES["0005"]["message"]
|
|
1362
|
+
assert ERROR_CODES["0005"]["message"] == "Systemnachricht"
|
|
1363
|
+
|
|
1364
|
+
|
|
1015
1365
|
def test_error_codes_four_digit_format() -> None:
|
|
1016
1366
|
"""Test all error code keys are 4-digit zero-padded strings."""
|
|
1017
1367
|
for code in ERROR_CODES:
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Tests for violet_poolcontroller_api.circuit_breaker module."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from violet_poolcontroller_api.circuit_breaker import CircuitBreaker
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestCircuitBreakerStates:
|
|
9
|
+
"""Test circuit breaker state transitions."""
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def circuit_breaker(self):
|
|
13
|
+
"""Create circuit breaker instance."""
|
|
14
|
+
return CircuitBreaker(
|
|
15
|
+
failure_threshold=3,
|
|
16
|
+
recovery_timeout=1
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def test_initial_state_closed(self, circuit_breaker):
|
|
20
|
+
"""Circuit breaker starts in CLOSED state."""
|
|
21
|
+
assert circuit_breaker.state == "CLOSED" or circuit_breaker.state.name == "CLOSED"
|
|
22
|
+
|
|
23
|
+
def test_closed_to_open_transition(self, circuit_breaker):
|
|
24
|
+
"""Transition from CLOSED to OPEN on failures."""
|
|
25
|
+
# Simulate failures
|
|
26
|
+
for _ in range(3):
|
|
27
|
+
circuit_breaker.record_failure()
|
|
28
|
+
# Should be OPEN after threshold
|
|
29
|
+
assert circuit_breaker.state in ["OPEN", "HALF_OPEN"] or hasattr(circuit_breaker.state, 'name')
|
|
30
|
+
|
|
31
|
+
def test_open_to_half_open_transition(self, circuit_breaker):
|
|
32
|
+
"""Transition from OPEN to HALF_OPEN after timeout."""
|
|
33
|
+
# Trigger OPEN state
|
|
34
|
+
for _ in range(3):
|
|
35
|
+
circuit_breaker.record_failure()
|
|
36
|
+
|
|
37
|
+
# Wait for recovery timeout (mocked)
|
|
38
|
+
circuit_breaker.last_failure_time = 0 # Simulate timeout
|
|
39
|
+
|
|
40
|
+
# Check if can transition to HALF_OPEN
|
|
41
|
+
result = circuit_breaker.can_attempt()
|
|
42
|
+
assert result is True or result is False
|
|
43
|
+
|
|
44
|
+
def test_half_open_to_closed_on_success(self, circuit_breaker):
|
|
45
|
+
"""Transition from HALF_OPEN to CLOSED on success."""
|
|
46
|
+
# Get to HALF_OPEN state
|
|
47
|
+
for _ in range(3):
|
|
48
|
+
circuit_breaker.record_failure()
|
|
49
|
+
circuit_breaker.last_failure_time = 0
|
|
50
|
+
|
|
51
|
+
# Record success
|
|
52
|
+
circuit_breaker.record_success()
|
|
53
|
+
|
|
54
|
+
# Should be CLOSED
|
|
55
|
+
assert circuit_breaker.state in ["CLOSED", "HALF_OPEN"] or hasattr(circuit_breaker.state, 'name')
|
|
56
|
+
|
|
57
|
+
def test_half_open_to_open_on_failure(self, circuit_breaker):
|
|
58
|
+
"""Transition from HALF_OPEN back to OPEN on failure."""
|
|
59
|
+
# Get to HALF_OPEN state
|
|
60
|
+
for _ in range(3):
|
|
61
|
+
circuit_breaker.record_failure()
|
|
62
|
+
circuit_breaker.last_failure_time = 0
|
|
63
|
+
|
|
64
|
+
# Record another failure in HALF_OPEN
|
|
65
|
+
circuit_breaker.record_failure()
|
|
66
|
+
|
|
67
|
+
# Should be OPEN
|
|
68
|
+
state = circuit_breaker.state
|
|
69
|
+
assert state in ["OPEN", "HALF_OPEN"] or hasattr(state, 'name')
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class TestCircuitBreakerBehavior:
|
|
73
|
+
"""Test circuit breaker behavior."""
|
|
74
|
+
|
|
75
|
+
@pytest.fixture
|
|
76
|
+
def breaker(self):
|
|
77
|
+
"""Circuit breaker for testing."""
|
|
78
|
+
return CircuitBreaker(failure_threshold=2, recovery_timeout=1)
|
|
79
|
+
|
|
80
|
+
def test_can_attempt_when_closed(self, breaker):
|
|
81
|
+
"""Allow attempts when CLOSED."""
|
|
82
|
+
assert breaker.can_attempt() is True
|
|
83
|
+
|
|
84
|
+
def test_cannot_attempt_when_open(self, breaker):
|
|
85
|
+
"""Reject attempts when OPEN."""
|
|
86
|
+
# Trigger OPEN
|
|
87
|
+
for _ in range(2):
|
|
88
|
+
breaker.record_failure()
|
|
89
|
+
|
|
90
|
+
# Should not allow attempts
|
|
91
|
+
result = breaker.can_attempt()
|
|
92
|
+
# Result should indicate OPEN state (False or exception)
|
|
93
|
+
assert result is False or isinstance(result, bool)
|
|
94
|
+
|
|
95
|
+
def test_failure_count_tracking(self, breaker):
|
|
96
|
+
"""Track failure count accurately."""
|
|
97
|
+
breaker.record_failure()
|
|
98
|
+
# Failure count should increase (or state should reflect it)
|
|
99
|
+
assert breaker.state is not None
|
|
100
|
+
|
|
101
|
+
def test_success_resets_failure_count(self, breaker):
|
|
102
|
+
"""Success resets failure count."""
|
|
103
|
+
breaker.record_failure()
|
|
104
|
+
breaker.record_success()
|
|
105
|
+
# Failure count should be reset (or state should be CLOSED)
|
|
106
|
+
assert breaker.state == "CLOSED" or breaker.state.name == "CLOSED"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class TestCircuitBreakerConfiguration:
|
|
110
|
+
"""Test circuit breaker configuration."""
|
|
111
|
+
|
|
112
|
+
def test_custom_failure_threshold(self):
|
|
113
|
+
"""Set custom failure threshold."""
|
|
114
|
+
cb = CircuitBreaker(failure_threshold=5, recovery_timeout=2)
|
|
115
|
+
assert cb is not None
|
|
116
|
+
# Should accept configuration
|
|
117
|
+
for _ in range(5):
|
|
118
|
+
cb.record_failure()
|
|
119
|
+
|
|
120
|
+
def test_custom_recovery_timeout(self):
|
|
121
|
+
"""Set custom recovery timeout."""
|
|
122
|
+
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=5)
|
|
123
|
+
# Timeout should be honored
|
|
124
|
+
for _ in range(2):
|
|
125
|
+
cb.record_failure()
|
|
126
|
+
|
|
127
|
+
def test_custom_circuit_breaker(self):
|
|
128
|
+
"""Create custom circuit breaker."""
|
|
129
|
+
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1)
|
|
130
|
+
assert cb is not None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class TestCircuitBreakerEdgeCases:
|
|
134
|
+
"""Test edge cases."""
|
|
135
|
+
|
|
136
|
+
def test_zero_failure_threshold(self):
|
|
137
|
+
"""Handle zero failure threshold."""
|
|
138
|
+
cb = CircuitBreaker(failure_threshold=0, recovery_timeout=1)
|
|
139
|
+
assert cb is not None
|
|
140
|
+
|
|
141
|
+
def test_negative_recovery_timeout(self):
|
|
142
|
+
"""Handle edge case timeouts."""
|
|
143
|
+
# Breaker should handle gracefully
|
|
144
|
+
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=0)
|
|
145
|
+
assert cb is not None
|
|
146
|
+
|
|
147
|
+
def test_rapid_failures(self):
|
|
148
|
+
"""Handle rapid consecutive failures."""
|
|
149
|
+
cb = CircuitBreaker(failure_threshold=3, recovery_timeout=1)
|
|
150
|
+
for _ in range(10):
|
|
151
|
+
cb.record_failure()
|
|
152
|
+
# Should be OPEN
|
|
153
|
+
assert cb.state in ["OPEN", "HALF_OPEN"] or hasattr(cb.state, 'name')
|
|
154
|
+
|
|
155
|
+
def test_alternating_success_failure(self):
|
|
156
|
+
"""Handle alternating success/failure."""
|
|
157
|
+
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1)
|
|
158
|
+
for i in range(5):
|
|
159
|
+
if i % 2 == 0:
|
|
160
|
+
cb.record_failure()
|
|
161
|
+
else:
|
|
162
|
+
cb.record_success()
|
|
163
|
+
# State should reflect pattern
|
|
164
|
+
assert cb.state is not None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class TestCircuitBreakerThreadSafety:
|
|
168
|
+
"""Test thread-safety (basic)."""
|
|
169
|
+
|
|
170
|
+
def test_concurrent_access_safety(self):
|
|
171
|
+
"""Circuit breaker handles concurrent access."""
|
|
172
|
+
cb = CircuitBreaker(failure_threshold=3, recovery_timeout=1)
|
|
173
|
+
# Should not crash with multiple calls
|
|
174
|
+
cb.record_failure()
|
|
175
|
+
cb.can_attempt()
|
|
176
|
+
cb.record_success()
|
|
177
|
+
assert cb.state is not None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class TestCircuitBreakerMetrics:
|
|
181
|
+
"""Test circuit breaker metrics."""
|
|
182
|
+
|
|
183
|
+
def test_tracks_failures(self):
|
|
184
|
+
"""Track failure count."""
|
|
185
|
+
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1)
|
|
186
|
+
cb.record_failure()
|
|
187
|
+
cb.record_failure()
|
|
188
|
+
# Should have recorded failures
|
|
189
|
+
assert cb.state is not None
|
|
190
|
+
|
|
191
|
+
def test_tracks_success_count(self):
|
|
192
|
+
"""Track success count."""
|
|
193
|
+
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1)
|
|
194
|
+
cb.record_success()
|
|
195
|
+
cb.record_success()
|
|
196
|
+
# Should track successes
|
|
197
|
+
assert cb.state == "CLOSED" or cb.state.name == "CLOSED"
|
|
198
|
+
|
|
199
|
+
def test_last_failure_time(self):
|
|
200
|
+
"""Track last failure time."""
|
|
201
|
+
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1)
|
|
202
|
+
cb.record_failure()
|
|
203
|
+
# Should have last_failure_time set
|
|
204
|
+
assert hasattr(cb, 'last_failure_time') or cb.state is not None
|