violet-poolController-api 0.0.27__tar.gz → 0.0.31__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {violet_poolcontroller_api-0.0.27/violet_poolController_api.egg-info → violet_poolcontroller_api-0.0.31}/PKG-INFO +1 -1
  2. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.31}/pyproject.toml +1 -1
  3. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.31}/tests/test_api.py +355 -5
  4. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.31}/tests/test_mock_server.py +18 -10
  5. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.31/violet_poolController_api.egg-info}/PKG-INFO +1 -1
  6. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.31}/violet_poolController_api.egg-info/SOURCES.txt +2 -0
  7. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.31}/violet_poolcontroller_api/__init__.py +63 -4
  8. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.31}/violet_poolcontroller_api/api.py +683 -28
  9. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.31}/violet_poolcontroller_api/const_api.py +244 -11
  10. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.31}/violet_poolcontroller_api/const_devices.py +105 -2
  11. violet_poolcontroller_api-0.0.31/violet_poolcontroller_api/parsers.py +185 -0
  12. violet_poolcontroller_api-0.0.31/violet_poolcontroller_api/readings.py +490 -0
  13. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.31}/LICENSE +0 -0
  14. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.31}/README.md +0 -0
  15. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.31}/setup.cfg +0 -0
  16. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.31}/tests/test_api_smoke.py +0 -0
  17. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.31}/violet_poolController_api.egg-info/dependency_links.txt +0 -0
  18. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.31}/violet_poolController_api.egg-info/requires.txt +0 -0
  19. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.31}/violet_poolController_api.egg-info/top_level.txt +0 -0
  20. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.31}/violet_poolcontroller_api/circuit_breaker.py +0 -0
  21. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.31}/violet_poolcontroller_api/utils_rate_limiter.py +0 -0
  22. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.31}/violet_poolcontroller_api/utils_sanitizer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: violet-poolController-api
3
- Version: 0.0.27
3
+ Version: 0.0.31
4
4
  Summary: Asynchronous Python client for the Violet Pool Controller.
5
5
  Author-email: "Basti (Xerolux)" <git@xerolux.de>
6
6
  License: AGPL-3.0-or-later
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "violet-poolController-api"
7
- version = "0.0.27"
7
+ version = "0.0.31"
8
8
  authors = [
9
9
  { name="Basti (Xerolux)", email="git@xerolux.de" },
10
10
  ]
@@ -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, dict)
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, dict)
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, dict)
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:
@@ -77,18 +77,26 @@ async def _request(url: str, auth: aiohttp.BasicAuth | None = None) -> tuple[int
77
77
 
78
78
  async def test_raw_auth() -> None:
79
79
  print("=" * 60)
80
- print("TEST 1: Raw HTTP - no credentials -> 401")
80
+ print("TEST 1: Raw HTTP - /getReadings without credentials -> 200")
81
81
  print("=" * 60)
82
82
  status, body = await _request(f"http://{HOST}:{PORT}/getReadings?ALL")
83
+ assert status == 200, f"Expected 200, got {status}"
84
+ print(f" OK: status={status} body_len={len(body)}")
85
+
86
+ print()
87
+ print("=" * 60)
88
+ print("TEST 2: Raw HTTP - /getConfig without credentials -> 401")
89
+ print("=" * 60)
90
+ status, body = await _request(f"http://{HOST}:{PORT}/getConfig")
83
91
  assert status == 401, f"Expected 401, got {status}"
84
92
  print(f" OK: status={status} body={body!r}")
85
93
 
86
94
  print()
87
95
  print("=" * 60)
88
- print("TEST 2: Raw HTTP - wrong password -> 401")
96
+ print("TEST 3: Raw HTTP - /getConfig with wrong password -> 401")
89
97
  print("=" * 60)
90
98
  status, body = await _request(
91
- f"http://{HOST}:{PORT}/getReadings?ALL",
99
+ f"http://{HOST}:{PORT}/getConfig",
92
100
  auth=aiohttp.BasicAuth("admin", "wrongpassword"),
93
101
  )
94
102
  assert status == 401, f"Expected 401, got {status}"
@@ -96,10 +104,10 @@ async def test_raw_auth() -> None:
96
104
 
97
105
  print()
98
106
  print("=" * 60)
99
- print("TEST 3: Raw HTTP - correct credentials -> 200")
107
+ print("TEST 4: Raw HTTP - /getConfig with correct credentials -> 200")
100
108
  print("=" * 60)
101
109
  status, body = await _request(
102
- f"http://{HOST}:{PORT}/getReadings?ALL",
110
+ f"http://{HOST}:{PORT}/getConfig",
103
111
  auth=aiohttp.BasicAuth(USER, PASS),
104
112
  )
105
113
  assert status == 200, f"Expected 200, got {status}"
@@ -107,7 +115,7 @@ async def test_raw_auth() -> None:
107
115
 
108
116
  print()
109
117
  print("=" * 60)
110
- print("TEST 4: /mock/* endpoints bypass auth")
118
+ print("TEST 5: /mock/* endpoints bypass auth")
111
119
  print("=" * 60)
112
120
  status, body = await _request(f"http://{HOST}:{PORT}/mock/state")
113
121
  assert status == 200, f"Expected 200, got {status}"
@@ -117,7 +125,7 @@ async def test_raw_auth() -> None:
117
125
  async def test_api_client() -> None:
118
126
  print()
119
127
  print("=" * 60)
120
- print("TEST 5: VioletPoolAPI - wrong credentials -> error")
128
+ print("TEST 6: VioletPoolAPI - wrong credentials -> error on /getConfig")
121
129
  print("=" * 60)
122
130
  async with aiohttp.ClientSession() as session:
123
131
  api_bad = VioletPoolAPI(
@@ -128,14 +136,14 @@ async def test_api_client() -> None:
128
136
  max_retries=1,
129
137
  )
130
138
  try:
131
- await api_bad.get_readings()
139
+ await api_bad.get_config(["DOSAGE_phminus_setpoint"])
132
140
  assert False, "Should have raised VioletPoolAPIError"
133
141
  except VioletPoolAPIError as exc:
134
142
  print(f" OK: VioletPoolAPIError raised: {exc}")
135
143
 
136
144
  print()
137
145
  print("=" * 60)
138
- print("TEST 6: VioletPoolAPI - correct credentials -> full workflow")
146
+ print("TEST 7: VioletPoolAPI - correct credentials -> full workflow")
139
147
  print("=" * 60)
140
148
  async with aiohttp.ClientSession() as session:
141
149
  api = VioletPoolAPI(
@@ -229,7 +237,7 @@ async def test_api_client() -> None:
229
237
 
230
238
  print()
231
239
  print("=" * 60)
232
- print("TEST 7: Error simulation with auth")
240
+ print("TEST 8: Error simulation with auth")
233
241
  print("=" * 60)
234
242
  async with aiohttp.ClientSession() as session:
235
243
  async with session.get(f"http://{HOST}:{PORT}/mock/error?code=500&count=1") as r:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: violet-poolController-api
3
- Version: 0.0.27
3
+ Version: 0.0.31
4
4
  Summary: Asynchronous Python client for the Violet Pool Controller.
5
5
  Author-email: "Basti (Xerolux)" <git@xerolux.de>
6
6
  License: AGPL-3.0-or-later
@@ -14,5 +14,7 @@ violet_poolcontroller_api/api.py
14
14
  violet_poolcontroller_api/circuit_breaker.py
15
15
  violet_poolcontroller_api/const_api.py
16
16
  violet_poolcontroller_api/const_devices.py
17
+ violet_poolcontroller_api/parsers.py
18
+ violet_poolcontroller_api/readings.py
17
19
  violet_poolcontroller_api/utils_rate_limiter.py
18
20
  violet_poolcontroller_api/utils_sanitizer.py
@@ -16,7 +16,17 @@
16
16
 
17
17
  """Violet Pool Controller API client library."""
18
18
 
19
- from .api import VioletPoolAPI, VioletPoolAPIError
19
+ from .api import (
20
+ SETPOINT_RANGES,
21
+ VioletAuthError,
22
+ VioletPayloadError,
23
+ VioletPoolAPI,
24
+ VioletPoolAPIError,
25
+ VioletSetpointError,
26
+ VioletTimeoutError,
27
+ VioletUnsafeOperationError,
28
+ validate_setpoint,
29
+ )
20
30
  from .circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
21
31
  from .const_api import ( # noqa: F401
22
32
  ACTION_ALLAUTO,
@@ -32,6 +42,7 @@ from .const_api import ( # noqa: F401
32
42
  ERROR_CODES,
33
43
  ERROR_SEVERITY_ALARM,
34
44
  ERROR_SEVERITY_INFO,
45
+ ERROR_SEVERITY_REMINDER,
35
46
  ERROR_SEVERITY_WARNING,
36
47
  )
37
48
  from .const_devices import ( # noqa: F401
@@ -39,25 +50,70 @@ from .const_devices import ( # noqa: F401
39
50
  COVER_STATE_MAP,
40
51
  DEVICE_PARAMETERS,
41
52
  STATE_TRANSLATIONS,
53
+ CoverState,
54
+ DmxSceneState,
55
+ OnewireState,
56
+ OutputState,
57
+ PvSurplusState,
58
+ RuleState,
42
59
  VioletState,
43
60
  get_state_translation_language,
44
61
  set_state_translation_language,
45
62
  )
63
+ from .parsers import ( # noqa: F401
64
+ parse_epoch_milliseconds,
65
+ parse_epoch_seconds,
66
+ parse_hms_string,
67
+ parse_optional_seconds,
68
+ parse_runtime_string,
69
+ parse_uptime_string,
70
+ )
71
+ from .readings import VioletReadings
46
72
  from .utils_rate_limiter import RateLimiter, get_global_rate_limiter
47
73
  from .utils_sanitizer import InputSanitizer
48
74
 
49
75
  __all__ = [
76
+ # Core client
50
77
  "VioletPoolAPI",
78
+ # Exception hierarchy
51
79
  "VioletPoolAPIError",
80
+ "VioletAuthError",
81
+ "VioletTimeoutError",
82
+ "VioletPayloadError",
83
+ "VioletSetpointError",
84
+ "VioletUnsafeOperationError",
85
+ # Setpoint validation
86
+ "SETPOINT_RANGES",
87
+ "validate_setpoint",
88
+ # Circuit breaker
52
89
  "CircuitBreaker",
53
90
  "CircuitBreakerOpenError",
91
+ # Enums
92
+ "OutputState",
93
+ "DmxSceneState",
94
+ "RuleState",
95
+ "CoverState",
96
+ "OnewireState",
97
+ "PvSurplusState",
98
+ # State helpers
54
99
  "VioletState",
100
+ "STATE_TRANSLATIONS",
101
+ "get_state_translation_language",
102
+ "set_state_translation_language",
103
+ # Typed readings snapshot
104
+ "VioletReadings",
105
+ # Parsers
106
+ "parse_runtime_string",
107
+ "parse_hms_string",
108
+ "parse_uptime_string",
109
+ "parse_epoch_seconds",
110
+ "parse_epoch_milliseconds",
111
+ "parse_optional_seconds",
112
+ # Utilities
55
113
  "InputSanitizer",
56
114
  "RateLimiter",
57
115
  "get_global_rate_limiter",
58
- "get_state_translation_language",
59
- "set_state_translation_language",
60
- "STATE_TRANSLATIONS",
116
+ # Action constants
61
117
  "ACTION_ALLAUTO",
62
118
  "ACTION_ALLOFF",
63
119
  "ACTION_ALLON",
@@ -68,11 +124,14 @@ __all__ = [
68
124
  "ACTION_ON",
69
125
  "ACTION_PUSH",
70
126
  "ACTION_UNLOCK",
127
+ # Device constants
71
128
  "COVER_FUNCTIONS",
72
129
  "COVER_STATE_MAP",
73
130
  "DEVICE_PARAMETERS",
131
+ # Error codes
74
132
  "ERROR_CODES",
75
133
  "ERROR_SEVERITY_ALARM",
76
134
  "ERROR_SEVERITY_INFO",
135
+ "ERROR_SEVERITY_REMINDER",
77
136
  "ERROR_SEVERITY_WARNING",
78
137
  ]