violet-poolController-api 0.0.38__tar.gz → 0.0.39__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 (35) hide show
  1. {violet_poolcontroller_api-0.0.38/violet_poolController_api.egg-info → violet_poolcontroller_api-0.0.39}/PKG-INFO +7 -7
  2. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/README.md +5 -5
  3. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/pyproject.toml +6 -2
  4. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/tests/test_api.py +216 -5
  5. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/tests/test_language_policy.py +18 -0
  6. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39/violet_poolController_api.egg-info}/PKG-INFO +7 -7
  7. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/violet_poolController_api.egg-info/requires.txt +1 -1
  8. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/__init__.py +56 -3
  9. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/_api_dosing.py +13 -2
  10. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/_api_mixin.py +3 -0
  11. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/_api_model.py +4 -0
  12. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/_api_outputs.py +14 -1
  13. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/_api_readings.py +3 -0
  14. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/_api_system.py +14 -7
  15. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/api.py +97 -31
  16. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/circuit_breaker.py +11 -5
  17. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/const_api.py +36 -3
  18. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/parsers.py +9 -16
  19. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/readings.py +37 -13
  20. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/utils_rate_limiter.py +23 -38
  21. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/utils_sanitizer.py +112 -105
  22. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/LICENSE +0 -0
  23. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/setup.cfg +0 -0
  24. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/tests/test_api_smoke.py +0 -0
  25. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/tests/test_circuit_breaker.py +0 -0
  26. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/tests/test_mock_server.py +0 -0
  27. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/tests/test_parsers.py +0 -0
  28. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/tests/test_rate_limiter.py +0 -0
  29. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/tests/test_readings.py +0 -0
  30. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/tests/test_sanitizer.py +0 -0
  31. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/violet_poolController_api.egg-info/SOURCES.txt +0 -0
  32. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/violet_poolController_api.egg-info/dependency_links.txt +0 -0
  33. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/violet_poolController_api.egg-info/top_level.txt +0 -0
  34. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/const_devices.py +0 -0
  35. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: violet-poolController-api
3
- Version: 0.0.38
3
+ Version: 0.0.39
4
4
  Summary: Asynchronous Python client for the Violet Pool Controller.
5
5
  Author-email: "Basti (Xerolux)" <git@xerolux.de>
6
6
  License-Expression: AGPL-3.0-or-later
@@ -20,7 +20,7 @@ Classifier: Topic :: Home Automation
20
20
  Requires-Python: >=3.12
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
- Requires-Dist: aiohttp<3.15,>=3.11.0
23
+ Requires-Dist: aiohttp>=3.11.0
24
24
  Provides-Extra: test
25
25
  Requires-Dist: aioresponses>=0.7.9; extra == "test"
26
26
  Requires-Dist: mypy>=1.15; extra == "test"
@@ -40,7 +40,7 @@ Dynamic: license-file
40
40
 
41
41
  An asynchronous Python client for interacting with the **Violet Pool Controller**.
42
42
 
43
- This library is primarily designed to power the official [Violet Pool Controller Home Assistant Integration](https://github.com/Xerolux/violet-hass), but it can be used independently for any Python project that needs to fetch readings or control a Violet Pool system.
43
+ This library is primarily designed to power the [Violet Pool Controller Home Assistant Integration](https://github.com/Xerolux/violet-hass), but it can be used independently for any Python project that needs to fetch readings or control a Violet Pool system.
44
44
 
45
45
  > **📖 Documentation:**
46
46
  > - GitHub Pages: https://xerolux.github.io/violet-poolController-api/
@@ -117,7 +117,7 @@ The API client includes many more functions tailored to the Violet Controller:
117
117
  - `set_pv_surplus(active=True)`: Enable the PV-Surplus mode.
118
118
  - `manual_dosing(dosing_type="Chlor", duration=120)`: Trigger manual chemical dosing.
119
119
 
120
- For a full list of available commands and more detailed examples, please refer to the [Wiki](https://github.com/Xerolux/violet-poolController-api/wiki) or the source code in `api.py`.
120
+ For a full list of available commands and more detailed examples, please refer to the [Wiki](https://github.com/Xerolux/violet-poolController-api/wiki) or the `_api_*.py` mixins, which hold the public methods.
121
121
 
122
122
  ## Violet Dosing Standalone Mode
123
123
 
@@ -136,7 +136,7 @@ api = VioletPoolAPI(
136
136
  In this mode, dosing functions (for example `manual_dosing` and dosing parameter/target updates) stay available, while base-module-only switch functions (for example pump/light/backwash) are blocked with a clear error message.
137
137
 
138
138
  **Note on getReadings format:**
139
- As of version `0.0.7`, the API client automatically detects and normalizes the payload output from the controller. Whether your Violet Controller returns the classic base-module `dict` structure (`{"PUMPSTATE": "2", "PH": 7.2}`) or the new standalone `list` structure, the `get_readings()` and `get_specific_readings()` functions will always return a seamless, flattened key-value dictionary. Your Home Assistant integration or downstream application will work uniformly with both formats without requiring any extra code!
139
+ As of version `0.0.7`, the API client automatically detects and normalizes the payload output from the controller. Whether your Violet Controller returns the classic base-module `dict` structure (`{"PUMPSTATE": "2", "PH": 7.2}`) or the new standalone `list` structure, the `get_readings()` and `get_specific_readings()` functions always return a flattened key-value view. `get_readings()` returns a `VioletReadings`, a read-only `Mapping` with typed accessors on top; use `dict(readings)` where a plain `dict` is required, for example before JSON serialization. Your Home Assistant integration or downstream application will work uniformly with both formats without requiring any extra code!
140
140
 
141
141
  **Hardware Profile Detection:**
142
142
  As of the latest release, the API client provides a method to detect the specific hardware configuration of your Violet Controller.
@@ -152,7 +152,7 @@ print(profile)
152
152
  # "extension_module_2": False,
153
153
  # }
154
154
  ```
155
- This detection parses `get_readings()` to check for the presence of certain internal status parameters (`SYSTEM_dosagemodule_cpu_temperature`, `EXT1_1`, `EXT2_1`), allowing your application to dynamically adapt to the connected modules (Base Module, Dosing Module, Relay Extension 1 and 2). By utilizing this detection, developers and integrations can accurately filter out features for missing hardware, ensuring that only supported options are exposed to the user.
155
+ This detection reads the controller's own module counters (`SYSTEM_dosagemodule_alive_count`, `SYSTEM_ext1module_alive_count`, `SYSTEM_ext2module_alive_count`), allowing your application to dynamically adapt to the connected modules (Base Module, Dosing Module, Relay Extension 1 and 2). By utilizing this detection, developers and integrations can accurately filter out features for missing hardware, ensuring that only supported options are exposed to the user.
156
156
 
157
157
  ## Mock Server (Testing Without Hardware)
158
158
 
@@ -229,7 +229,7 @@ GNU Affero General Public License v3.0 or later (AGPLv3+)
229
229
 
230
230
  The **VIOLET Pool Controller** by [PoolDigital GmbH & Co. KG](https://www.pooldigital.de/) is a premium smart pool automation system developed in Germany, featuring a JSON API for seamless Home Assistant integration.
231
231
 
232
- - **Offizieller Shop:** [pooldigital.de](https://www.pooldigital.de/)
232
+ - **Official shop:** [pooldigital.de](https://www.pooldigital.de/)
233
233
  - **Community:** [PoolDigital Forum](http://forum.pooldigital.de/)
234
234
 
235
235
  **Disclaimer:**
@@ -10,7 +10,7 @@
10
10
 
11
11
  An asynchronous Python client for interacting with the **Violet Pool Controller**.
12
12
 
13
- This library is primarily designed to power the official [Violet Pool Controller Home Assistant Integration](https://github.com/Xerolux/violet-hass), but it can be used independently for any Python project that needs to fetch readings or control a Violet Pool system.
13
+ This library is primarily designed to power the [Violet Pool Controller Home Assistant Integration](https://github.com/Xerolux/violet-hass), but it can be used independently for any Python project that needs to fetch readings or control a Violet Pool system.
14
14
 
15
15
  > **📖 Documentation:**
16
16
  > - GitHub Pages: https://xerolux.github.io/violet-poolController-api/
@@ -87,7 +87,7 @@ The API client includes many more functions tailored to the Violet Controller:
87
87
  - `set_pv_surplus(active=True)`: Enable the PV-Surplus mode.
88
88
  - `manual_dosing(dosing_type="Chlor", duration=120)`: Trigger manual chemical dosing.
89
89
 
90
- For a full list of available commands and more detailed examples, please refer to the [Wiki](https://github.com/Xerolux/violet-poolController-api/wiki) or the source code in `api.py`.
90
+ For a full list of available commands and more detailed examples, please refer to the [Wiki](https://github.com/Xerolux/violet-poolController-api/wiki) or the `_api_*.py` mixins, which hold the public methods.
91
91
 
92
92
  ## Violet Dosing Standalone Mode
93
93
 
@@ -106,7 +106,7 @@ api = VioletPoolAPI(
106
106
  In this mode, dosing functions (for example `manual_dosing` and dosing parameter/target updates) stay available, while base-module-only switch functions (for example pump/light/backwash) are blocked with a clear error message.
107
107
 
108
108
  **Note on getReadings format:**
109
- As of version `0.0.7`, the API client automatically detects and normalizes the payload output from the controller. Whether your Violet Controller returns the classic base-module `dict` structure (`{"PUMPSTATE": "2", "PH": 7.2}`) or the new standalone `list` structure, the `get_readings()` and `get_specific_readings()` functions will always return a seamless, flattened key-value dictionary. Your Home Assistant integration or downstream application will work uniformly with both formats without requiring any extra code!
109
+ As of version `0.0.7`, the API client automatically detects and normalizes the payload output from the controller. Whether your Violet Controller returns the classic base-module `dict` structure (`{"PUMPSTATE": "2", "PH": 7.2}`) or the new standalone `list` structure, the `get_readings()` and `get_specific_readings()` functions always return a flattened key-value view. `get_readings()` returns a `VioletReadings`, a read-only `Mapping` with typed accessors on top; use `dict(readings)` where a plain `dict` is required, for example before JSON serialization. Your Home Assistant integration or downstream application will work uniformly with both formats without requiring any extra code!
110
110
 
111
111
  **Hardware Profile Detection:**
112
112
  As of the latest release, the API client provides a method to detect the specific hardware configuration of your Violet Controller.
@@ -122,7 +122,7 @@ print(profile)
122
122
  # "extension_module_2": False,
123
123
  # }
124
124
  ```
125
- This detection parses `get_readings()` to check for the presence of certain internal status parameters (`SYSTEM_dosagemodule_cpu_temperature`, `EXT1_1`, `EXT2_1`), allowing your application to dynamically adapt to the connected modules (Base Module, Dosing Module, Relay Extension 1 and 2). By utilizing this detection, developers and integrations can accurately filter out features for missing hardware, ensuring that only supported options are exposed to the user.
125
+ This detection reads the controller's own module counters (`SYSTEM_dosagemodule_alive_count`, `SYSTEM_ext1module_alive_count`, `SYSTEM_ext2module_alive_count`), allowing your application to dynamically adapt to the connected modules (Base Module, Dosing Module, Relay Extension 1 and 2). By utilizing this detection, developers and integrations can accurately filter out features for missing hardware, ensuring that only supported options are exposed to the user.
126
126
 
127
127
  ## Mock Server (Testing Without Hardware)
128
128
 
@@ -199,7 +199,7 @@ GNU Affero General Public License v3.0 or later (AGPLv3+)
199
199
 
200
200
  The **VIOLET Pool Controller** by [PoolDigital GmbH & Co. KG](https://www.pooldigital.de/) is a premium smart pool automation system developed in Germany, featuring a JSON API for seamless Home Assistant integration.
201
201
 
202
- - **Offizieller Shop:** [pooldigital.de](https://www.pooldigital.de/)
202
+ - **Official shop:** [pooldigital.de](https://www.pooldigital.de/)
203
203
  - **Community:** [PoolDigital Forum](http://forum.pooldigital.de/)
204
204
 
205
205
  **Disclaimer:**
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "violet-poolController-api"
7
- version = "0.0.38"
7
+ version = "0.0.39"
8
8
  authors = [
9
9
  { name="Basti (Xerolux)", email="git@xerolux.de" },
10
10
  ]
@@ -25,7 +25,11 @@ classifiers = [
25
25
  "Topic :: Home Automation",
26
26
  ]
27
27
  dependencies = [
28
- "aiohttp>=3.11.0,<3.15",
28
+ # No upper bound on purpose: Home Assistant installs this package into
29
+ # its own environment and follows aiohttp closely. A "<3.15" cap made pip
30
+ # refuse the install the day HA moved past it, taking the integration down
31
+ # with it. Compatibility with newer aiohttp is handled in the test shim.
32
+ "aiohttp>=3.11.0",
29
33
  ]
30
34
 
31
35
  [project.optional-dependencies]
@@ -274,11 +274,56 @@ async def test_set_config_sanitizes_payload_before_request(
274
274
 
275
275
  monkeypatch.setattr(api_client, "_request", fake_request)
276
276
 
277
- result = await api_client.set_config({"pool mode": "A<mode>", "speed": 3.7})
277
+ result = await api_client.set_config({"POOL_MODE": "A<mode>", "speed": 3.7})
278
278
 
279
279
  assert result["success"] is True
280
280
  assert result["response"] == "OK"
281
- assert captured["data"] == {"poolmode": "A<mode>", "speed": 3.7}
281
+ assert captured["data"] == {"POOL_MODE": "A<mode>", "speed": 3.7}
282
+
283
+
284
+ @pytest.mark.asyncio
285
+ async def test_set_config_rejects_invalid_key_instead_of_rewriting_it(
286
+ api_client: VioletPoolAPI,
287
+ monkeypatch: pytest.Monkeypatch,
288
+ ) -> None:
289
+ """A malformed key is an error, never a silent write to a different key.
290
+
291
+ ``"pool mode"`` used to be rewritten to ``"poolmode"`` and sent, so a typo
292
+ in a configuration key changed a setting the caller never named.
293
+ """
294
+ sent = False
295
+
296
+ async def fake_request(_endpoint: str, **_kwargs: Any) -> str: # noqa: ANN401
297
+ nonlocal sent
298
+ sent = True
299
+ return "OK"
300
+
301
+ monkeypatch.setattr(api_client, "_request", fake_request)
302
+
303
+ with pytest.raises(VioletPoolAPIError, match="Invalid configuration parameter"):
304
+ await api_client.set_config({"pool mode": "A"})
305
+
306
+ assert sent is False
307
+
308
+
309
+ @pytest.mark.asyncio
310
+ async def test_set_config_rejects_unsupported_value_types(
311
+ api_client: VioletPoolAPI,
312
+ monkeypatch: pytest.Monkeypatch,
313
+ ) -> None:
314
+ """None and containers must not reach the controller as "None" or "1 2"."""
315
+
316
+ async def fake_request(_endpoint: str, **_kwargs: Any) -> str: # noqa: ANN401
317
+ return "OK"
318
+
319
+ monkeypatch.setattr(api_client, "_request", fake_request)
320
+
321
+ for bad_value in (None, [1, 2], {"a": 1}):
322
+ with pytest.raises(VioletPoolAPIError):
323
+ await api_client.set_config({"SOME_KEY": bad_value})
324
+
325
+ with pytest.raises(VioletPoolAPIError, match="Non-finite"):
326
+ await api_client.set_config({"SOME_KEY": float("nan")})
282
327
 
283
328
 
284
329
  @pytest.mark.asyncio
@@ -1383,14 +1428,20 @@ async def test_command_result_dosing_started(
1383
1428
 
1384
1429
 
1385
1430
  @pytest.mark.asyncio
1386
- async def test_command_result_dict_passthrough(
1431
+ async def test_command_result_normalizes_dict_input(
1387
1432
  api_client: VioletPoolAPI,
1388
1433
  ) -> None:
1389
- """Test _command_result passes dict through unchanged."""
1434
+ """A dict body is normalized, so ``success`` is always present.
1435
+
1436
+ The old passthrough returned the dict unchanged, so a caller reading
1437
+ ``result["success"]`` got a KeyError instead of a result.
1438
+ """
1390
1439
  data = {"key": "value", "nested": {"a": 1}}
1391
1440
  result = VioletPoolAPI._command_result(data)
1392
1441
 
1393
- assert result is data
1442
+ assert result["success"] is True
1443
+ assert result["key"] == "value"
1444
+ assert result["nested"] == {"a": 1}
1394
1445
 
1395
1446
 
1396
1447
  @pytest.mark.asyncio
@@ -2130,3 +2181,163 @@ def test_input_sanitizer_no_duplicate_validate_duration() -> None:
2130
2181
 
2131
2182
  assert not hasattr(InputSanitizer, "validate_duration")
2132
2183
  assert not hasattr(InputSanitizer, "validate_speed")
2184
+
2185
+
2186
+ def _request_count(mocked: aioresponses, method: str, path: str) -> int:
2187
+ """Count recorded requests to *path*, ignoring query-string normalization.
2188
+
2189
+ yarl rewrites a value-less query (``?PUMP,ON,0,0``) by appending ``=``, so
2190
+ the recorded key never equals the URL that was requested.
2191
+ """
2192
+ return sum(
2193
+ len(calls)
2194
+ for (recorded_method, url), calls in mocked.requests.items()
2195
+ if recorded_method == method and url.path == path
2196
+ )
2197
+
2198
+
2199
+ # ---------------------------------------------------------------------------
2200
+ # Regression: state-changing commands are sent exactly once (0.0.39)
2201
+ # ---------------------------------------------------------------------------
2202
+
2203
+
2204
+ @pytest.mark.asyncio
2205
+ async def test_switch_command_is_not_retried(
2206
+ mock_aioresponse: aioresponses,
2207
+ ) -> None:
2208
+ """A failed switch command must not be repeated.
2209
+
2210
+ The controller applies state changes through GET, and a request that
2211
+ timed out may well have been applied. Repeating it can toggle the output
2212
+ back or apply it a second time.
2213
+ """
2214
+ url = "http://192.168.1.100/setFunctionManually?PUMP,ON,0,0"
2215
+ mock_aioresponse.get(url, status=500, body="boom")
2216
+
2217
+ async with aiohttp.ClientSession() as session:
2218
+ api = VioletPoolAPI(host="192.168.1.100", session=session, max_retries=3)
2219
+ with pytest.raises(VioletPoolAPIError):
2220
+ await api.set_switch_state("PUMP", "ON")
2221
+
2222
+ assert _request_count(mock_aioresponse, "GET", "/setFunctionManually") == 1
2223
+
2224
+
2225
+ @pytest.mark.asyncio
2226
+ async def test_digital_rule_trigger_is_not_retried(
2227
+ mock_aioresponse: aioresponses,
2228
+ ) -> None:
2229
+ """PUSH is a toggle: a retry would undo the change it just made."""
2230
+ url = "http://192.168.1.100/setFunctionManually?DIRULE_1,PUSH,0,0"
2231
+ mock_aioresponse.get(url, status=500, body="boom")
2232
+
2233
+ async with aiohttp.ClientSession() as session:
2234
+ api = VioletPoolAPI(host="192.168.1.100", session=session, max_retries=3)
2235
+ with pytest.raises(VioletPoolAPIError):
2236
+ await api.trigger_digital_input_rule("DIRULE_1")
2237
+
2238
+ assert _request_count(mock_aioresponse, "GET", "/setFunctionManually") == 1
2239
+
2240
+
2241
+ @pytest.mark.asyncio
2242
+ async def test_init_update_is_not_retried(
2243
+ mock_aioresponse: aioresponses,
2244
+ ) -> None:
2245
+ """Starting a firmware update twice is not harmless."""
2246
+ url = "http://192.168.1.100/initUpdate"
2247
+ mock_aioresponse.get(url, status=503, body="busy")
2248
+
2249
+ async with aiohttp.ClientSession() as session:
2250
+ api = VioletPoolAPI(host="192.168.1.100", session=session, max_retries=3)
2251
+ with pytest.raises(VioletPoolAPIError):
2252
+ await api.init_update()
2253
+
2254
+ assert _request_count(mock_aioresponse, "GET", "/initUpdate") == 1
2255
+
2256
+
2257
+ @pytest.mark.asyncio
2258
+ async def test_reads_are_still_retried(
2259
+ mock_aioresponse: aioresponses,
2260
+ ) -> None:
2261
+ """The read path keeps its retries; repeating a read costs nothing."""
2262
+ url = "http://192.168.1.100/getReadings?ALL"
2263
+ mock_aioresponse.get(url, status=500, body="boom")
2264
+ mock_aioresponse.get(url, status=500, body="boom")
2265
+ mock_aioresponse.get(url, payload={"getReadings": {"PUMP": "1"}}, status=200)
2266
+
2267
+ async with aiohttp.ClientSession() as session:
2268
+ api = VioletPoolAPI(host="192.168.1.100", session=session, max_retries=3)
2269
+ readings = await api.get_readings()
2270
+
2271
+ assert readings["PUMP"] == "1"
2272
+ assert _request_count(mock_aioresponse, "GET", "/getReadings") == 3
2273
+
2274
+
2275
+ @pytest.mark.asyncio
2276
+ async def test_rate_limit_wait_timeout_fails_instead_of_bypassing(
2277
+ mock_aioresponse: aioresponses,
2278
+ monkeypatch: pytest.Monkeypatch,
2279
+ ) -> None:
2280
+ """A limiter timeout must fail the request, not send it without a token.
2281
+
2282
+ Sending anyway let every caller that waited out the timeout hit the
2283
+ controller at once, which is the pile-up the limiter exists to prevent.
2284
+ """
2285
+ url = "http://192.168.1.100/getReadings?ALL"
2286
+ mock_aioresponse.get(url, payload={"getReadings": {}}, status=200)
2287
+
2288
+ async with aiohttp.ClientSession() as session:
2289
+ api = VioletPoolAPI(host="192.168.1.100", session=session, max_retries=1)
2290
+
2291
+ async def always_timeout(**_kwargs: Any) -> None:
2292
+ raise TimeoutError
2293
+
2294
+ monkeypatch.setattr(api._rate_limiter, "wait_if_needed", always_timeout)
2295
+
2296
+ with pytest.raises(VioletPoolAPIError, match="Rate limit wait"):
2297
+ await api.get_readings()
2298
+
2299
+ assert _request_count(mock_aioresponse, "GET", "/getReadings") == 0
2300
+
2301
+
2302
+ @pytest.mark.asyncio
2303
+ async def test_html_login_page_does_not_open_the_circuit_breaker(
2304
+ mock_aioresponse: aioresponses,
2305
+ ) -> None:
2306
+ """A captive portal answers 200 with HTML every time, deterministically.
2307
+
2308
+ Counting those as transient failures opened the breaker and replaced the
2309
+ payload error that explains the actual problem.
2310
+ """
2311
+ url = "http://192.168.1.100/getReadings?ALL"
2312
+ for _ in range(8):
2313
+ mock_aioresponse.get(url, body="<html>login</html>", status=200)
2314
+
2315
+ async with aiohttp.ClientSession() as session:
2316
+ api = VioletPoolAPI(host="192.168.1.100", session=session, max_retries=1)
2317
+ for _ in range(8):
2318
+ with pytest.raises(VioletPoolAPIError) as excinfo:
2319
+ await api.get_readings()
2320
+ assert "Invalid JSON payload" in str(excinfo.value)
2321
+
2322
+
2323
+ def test_hostname_error_never_echoes_credentials() -> None:
2324
+ """The message reaches config-flow errors and logs; it must stay clean."""
2325
+ with pytest.raises(ValueError, match="must not contain credentials") as excinfo:
2326
+ VioletPoolAPI._build_secure_base_url(
2327
+ VioletPoolAPI,
2328
+ "http://admin:s3cret@192.168.1.5",
2329
+ use_ssl=False,
2330
+ )
2331
+
2332
+ assert "s3cret" not in str(excinfo.value)
2333
+
2334
+
2335
+ def test_hostname_accepts_underscores() -> None:
2336
+ """mDNS and router-assigned names use underscores."""
2337
+ url = VioletPoolAPI._build_secure_base_url(
2338
+ VioletPoolAPI,
2339
+ "violet_pool.local",
2340
+ use_ssl=False,
2341
+ )
2342
+
2343
+ assert url == "http://violet_pool.local"
@@ -64,6 +64,24 @@ GERMAN_WORDS = (
64
64
  "dieser",
65
65
  "keine",
66
66
  "sollte",
67
+ # Added after 0.0.38 shipped with ~20 German docstrings and log lines in
68
+ # utils_sanitizer.py and utils_rate_limiter.py that this list did not see.
69
+ "einen",
70
+ "eine",
71
+ "verwende",
72
+ "ungültig",
73
+ "ungültige",
74
+ "ungültigen",
75
+ "ungültiger",
76
+ "verfügbar",
77
+ "gefährlich",
78
+ "gefährliche",
79
+ "erlaubt",
80
+ "erlaubter",
81
+ "zurückgesetzt",
82
+ "initialisiert",
83
+ "unbekannter",
84
+ "warte",
67
85
  )
68
86
  _GERMAN = re.compile(r"\b(" + "|".join(GERMAN_WORDS) + r")\b", re.IGNORECASE)
69
87
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: violet-poolController-api
3
- Version: 0.0.38
3
+ Version: 0.0.39
4
4
  Summary: Asynchronous Python client for the Violet Pool Controller.
5
5
  Author-email: "Basti (Xerolux)" <git@xerolux.de>
6
6
  License-Expression: AGPL-3.0-or-later
@@ -20,7 +20,7 @@ Classifier: Topic :: Home Automation
20
20
  Requires-Python: >=3.12
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
- Requires-Dist: aiohttp<3.15,>=3.11.0
23
+ Requires-Dist: aiohttp>=3.11.0
24
24
  Provides-Extra: test
25
25
  Requires-Dist: aioresponses>=0.7.9; extra == "test"
26
26
  Requires-Dist: mypy>=1.15; extra == "test"
@@ -40,7 +40,7 @@ Dynamic: license-file
40
40
 
41
41
  An asynchronous Python client for interacting with the **Violet Pool Controller**.
42
42
 
43
- This library is primarily designed to power the official [Violet Pool Controller Home Assistant Integration](https://github.com/Xerolux/violet-hass), but it can be used independently for any Python project that needs to fetch readings or control a Violet Pool system.
43
+ This library is primarily designed to power the [Violet Pool Controller Home Assistant Integration](https://github.com/Xerolux/violet-hass), but it can be used independently for any Python project that needs to fetch readings or control a Violet Pool system.
44
44
 
45
45
  > **📖 Documentation:**
46
46
  > - GitHub Pages: https://xerolux.github.io/violet-poolController-api/
@@ -117,7 +117,7 @@ The API client includes many more functions tailored to the Violet Controller:
117
117
  - `set_pv_surplus(active=True)`: Enable the PV-Surplus mode.
118
118
  - `manual_dosing(dosing_type="Chlor", duration=120)`: Trigger manual chemical dosing.
119
119
 
120
- For a full list of available commands and more detailed examples, please refer to the [Wiki](https://github.com/Xerolux/violet-poolController-api/wiki) or the source code in `api.py`.
120
+ For a full list of available commands and more detailed examples, please refer to the [Wiki](https://github.com/Xerolux/violet-poolController-api/wiki) or the `_api_*.py` mixins, which hold the public methods.
121
121
 
122
122
  ## Violet Dosing Standalone Mode
123
123
 
@@ -136,7 +136,7 @@ api = VioletPoolAPI(
136
136
  In this mode, dosing functions (for example `manual_dosing` and dosing parameter/target updates) stay available, while base-module-only switch functions (for example pump/light/backwash) are blocked with a clear error message.
137
137
 
138
138
  **Note on getReadings format:**
139
- As of version `0.0.7`, the API client automatically detects and normalizes the payload output from the controller. Whether your Violet Controller returns the classic base-module `dict` structure (`{"PUMPSTATE": "2", "PH": 7.2}`) or the new standalone `list` structure, the `get_readings()` and `get_specific_readings()` functions will always return a seamless, flattened key-value dictionary. Your Home Assistant integration or downstream application will work uniformly with both formats without requiring any extra code!
139
+ As of version `0.0.7`, the API client automatically detects and normalizes the payload output from the controller. Whether your Violet Controller returns the classic base-module `dict` structure (`{"PUMPSTATE": "2", "PH": 7.2}`) or the new standalone `list` structure, the `get_readings()` and `get_specific_readings()` functions always return a flattened key-value view. `get_readings()` returns a `VioletReadings`, a read-only `Mapping` with typed accessors on top; use `dict(readings)` where a plain `dict` is required, for example before JSON serialization. Your Home Assistant integration or downstream application will work uniformly with both formats without requiring any extra code!
140
140
 
141
141
  **Hardware Profile Detection:**
142
142
  As of the latest release, the API client provides a method to detect the specific hardware configuration of your Violet Controller.
@@ -152,7 +152,7 @@ print(profile)
152
152
  # "extension_module_2": False,
153
153
  # }
154
154
  ```
155
- This detection parses `get_readings()` to check for the presence of certain internal status parameters (`SYSTEM_dosagemodule_cpu_temperature`, `EXT1_1`, `EXT2_1`), allowing your application to dynamically adapt to the connected modules (Base Module, Dosing Module, Relay Extension 1 and 2). By utilizing this detection, developers and integrations can accurately filter out features for missing hardware, ensuring that only supported options are exposed to the user.
155
+ This detection reads the controller's own module counters (`SYSTEM_dosagemodule_alive_count`, `SYSTEM_ext1module_alive_count`, `SYSTEM_ext2module_alive_count`), allowing your application to dynamically adapt to the connected modules (Base Module, Dosing Module, Relay Extension 1 and 2). By utilizing this detection, developers and integrations can accurately filter out features for missing hardware, ensuring that only supported options are exposed to the user.
156
156
 
157
157
  ## Mock Server (Testing Without Hardware)
158
158
 
@@ -229,7 +229,7 @@ GNU Affero General Public License v3.0 or later (AGPLv3+)
229
229
 
230
230
  The **VIOLET Pool Controller** by [PoolDigital GmbH & Co. KG](https://www.pooldigital.de/) is a premium smart pool automation system developed in Germany, featuring a JSON API for seamless Home Assistant integration.
231
231
 
232
- - **Offizieller Shop:** [pooldigital.de](https://www.pooldigital.de/)
232
+ - **Official shop:** [pooldigital.de](https://www.pooldigital.de/)
233
233
  - **Community:** [PoolDigital Forum](http://forum.pooldigital.de/)
234
234
 
235
235
  **Disclaimer:**
@@ -1,4 +1,4 @@
1
- aiohttp<3.15,>=3.11.0
1
+ aiohttp>=3.11.0
2
2
 
3
3
  [test]
4
4
  aioresponses>=0.7.9
@@ -25,9 +25,10 @@ from .api import (
25
25
  VioletSetpointError,
26
26
  VioletTimeoutError,
27
27
  VioletUnsafeOperationError,
28
+ validate_duration,
28
29
  validate_setpoint,
29
30
  )
30
- from .circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
31
+ from .circuit_breaker import CircuitBreaker, CircuitBreakerOpenError, CircuitBreakerState
31
32
  from .const_api import ( # noqa: F401
32
33
  ACTION_ALLAUTO,
33
34
  ACTION_ALLOFF,
@@ -39,16 +40,39 @@ from .const_api import ( # noqa: F401
39
40
  ACTION_ON,
40
41
  ACTION_PUSH,
41
42
  ACTION_UNLOCK,
43
+ API_PRIORITY_CRITICAL,
44
+ API_PRIORITY_HIGH,
45
+ API_PRIORITY_LOW,
46
+ API_PRIORITY_NORMAL,
47
+ DOSING_CANISTER_ID,
48
+ DOSING_CONFIG_PREFIX,
49
+ DOSING_FUNCTIONS,
50
+ DOSING_OUTPUT_INDEX,
42
51
  ERROR_CODES,
43
52
  ERROR_SEVERITY_ALARM,
44
53
  ERROR_SEVERITY_INFO,
45
54
  ERROR_SEVERITY_REMINDER,
46
55
  ERROR_SEVERITY_WARNING,
56
+ LOG_TYPE_ACTIONS,
57
+ LOG_TYPE_ONEWIRE,
58
+ LOG_TYPE_SWITCHING,
59
+ LOG_TYPES,
60
+ NON_RETRYABLE_ENDPOINTS,
61
+ OMNI_POSITIONS,
62
+ RS485_PUMP_MODES,
63
+ RS485_PUMP_NAMES,
64
+ SPECIFIC_READING_GROUPS,
65
+ SWITCH_FUNCTIONS,
66
+ SYSTEM_SERVICES,
67
+ TARGET_MIN_CHLORINE,
68
+ TARGET_ORP,
69
+ TARGET_PH,
47
70
  )
48
71
  from .const_devices import ( # noqa: F401
49
72
  COVER_FUNCTIONS,
50
73
  COVER_STATE_MAP,
51
74
  DEVICE_PARAMETERS,
75
+ DEVICE_STATE_MAPPING,
52
76
  STATE_TRANSLATIONS,
53
77
  CoverState,
54
78
  DmxSceneState,
@@ -57,6 +81,8 @@ from .const_devices import ( # noqa: F401
57
81
  PvSurplusState,
58
82
  RuleState,
59
83
  VioletState,
84
+ get_device_mode_from_state,
85
+ get_device_state_info,
60
86
  get_state_translation_language,
61
87
  set_state_translation_language,
62
88
  )
@@ -82,12 +108,14 @@ __all__ = [
82
108
  "VioletPayloadError",
83
109
  "VioletSetpointError",
84
110
  "VioletUnsafeOperationError",
85
- # Setpoint validation
111
+ # Setpoint and duration validation
86
112
  "SETPOINT_RANGES",
87
113
  "validate_setpoint",
114
+ "validate_duration",
88
115
  # Circuit breaker
89
116
  "CircuitBreaker",
90
117
  "CircuitBreakerOpenError",
118
+ "CircuitBreakerState",
91
119
  # Enums
92
120
  "OutputState",
93
121
  "DmxSceneState",
@@ -97,6 +125,9 @@ __all__ = [
97
125
  "PvSurplusState",
98
126
  # State helpers
99
127
  "VioletState",
128
+ "DEVICE_STATE_MAPPING",
129
+ "get_device_state_info",
130
+ "get_device_mode_from_state",
100
131
  "STATE_TRANSLATIONS",
101
132
  "get_state_translation_language",
102
133
  "set_state_translation_language",
@@ -124,10 +155,32 @@ __all__ = [
124
155
  "ACTION_ON",
125
156
  "ACTION_PUSH",
126
157
  "ACTION_UNLOCK",
127
- # Device constants
158
+ # Device and API constants
128
159
  "COVER_FUNCTIONS",
129
160
  "COVER_STATE_MAP",
130
161
  "DEVICE_PARAMETERS",
162
+ "SWITCH_FUNCTIONS",
163
+ "DOSING_FUNCTIONS",
164
+ "DOSING_OUTPUT_INDEX",
165
+ "DOSING_CONFIG_PREFIX",
166
+ "DOSING_CANISTER_ID",
167
+ "OMNI_POSITIONS",
168
+ "SYSTEM_SERVICES",
169
+ "RS485_PUMP_NAMES",
170
+ "RS485_PUMP_MODES",
171
+ "LOG_TYPES",
172
+ "LOG_TYPE_ACTIONS",
173
+ "LOG_TYPE_SWITCHING",
174
+ "LOG_TYPE_ONEWIRE",
175
+ "SPECIFIC_READING_GROUPS",
176
+ "NON_RETRYABLE_ENDPOINTS",
177
+ "TARGET_PH",
178
+ "TARGET_ORP",
179
+ "TARGET_MIN_CHLORINE",
180
+ "API_PRIORITY_CRITICAL",
181
+ "API_PRIORITY_HIGH",
182
+ "API_PRIORITY_NORMAL",
183
+ "API_PRIORITY_LOW",
131
184
  # Error codes
132
185
  "ERROR_CODES",
133
186
  "ERROR_SEVERITY_ALARM",
@@ -54,6 +54,7 @@ class DosingMixin(APIClientMixin):
54
54
  action: str,
55
55
  *,
56
56
  duration: int | None = None,
57
+ source: int = 1,
57
58
  ) -> dict[str, Any]:
58
59
  """Trigger or stop a manual dosing run via /triggerManualDosing.
59
60
 
@@ -67,6 +68,9 @@ class DosingMixin(APIClientMixin):
67
68
  action: ON/START → DOSSTART; OFF/STOP/AUTO → DOSSTOP
68
69
  (stopping a run returns the channel to automatic mode).
69
70
  duration: Duration in seconds (whole number).
71
+ source: Value of the firmware's ``from`` form field, which selects
72
+ the chemical on channels shared by more than one agent. The
73
+ default (1) addresses the channel's primary agent.
70
74
 
71
75
  Returns:
72
76
  A dictionary with the command result.
@@ -103,7 +107,7 @@ class DosingMixin(APIClientMixin):
103
107
  "action": dos_action,
104
108
  "output": str(output_index),
105
109
  "runtime": str(dos_duration),
106
- "from": "1",
110
+ "from": str(int(source)),
107
111
  "runtime_formatted": f"{dos_duration // 60:02d}:{dos_duration % 60:02d}",
108
112
  }
109
113
  body = await self._request(
@@ -111,6 +115,7 @@ class DosingMixin(APIClientMixin):
111
115
  method="POST",
112
116
  data=form_data,
113
117
  priority=API_PRIORITY_CRITICAL,
118
+ retryable=False,
114
119
  )
115
120
  return self._command_result(body)
116
121
 
@@ -133,6 +138,10 @@ class DosingMixin(APIClientMixin):
133
138
  msg = f"Unknown dosing type: {dosing_type}"
134
139
  raise VioletPoolAPIError(msg)
135
140
 
141
+ # Validate before comparing: a non-numeric duration used to raise a
142
+ # bare TypeError from the ``duration <= 0`` test below.
143
+ duration = validate_duration(duration, minimum=0)
144
+
136
145
  # /triggerManualDosing requires an explicit runtime; duration <= 0
137
146
  # stops a running manual dosing instead (documented behavior).
138
147
  if duration <= 0:
@@ -219,7 +228,9 @@ class DosingMixin(APIClientMixin):
219
228
  """
220
229
  numeric_value = _to_float(key, value)
221
230
  validate_setpoint(key, numeric_value)
222
- return await self.set_config({key: value})
231
+ # Send the validated number, not the original: a string like " 28.5 "
232
+ # would otherwise reach the controller unparsed.
233
+ return await self.set_config({key: numeric_value})
223
234
 
224
235
  async def set_dosing_parameters(
225
236
  self,
@@ -4,6 +4,8 @@ from __future__ import annotations
4
4
 
5
5
  from typing import TYPE_CHECKING, Any
6
6
 
7
+ from .const_api import API_PRIORITY_NORMAL
8
+
7
9
  if TYPE_CHECKING:
8
10
  from collections.abc import Iterable, Mapping
9
11
 
@@ -35,6 +37,7 @@ class APIClientMixin:
35
37
  params: Mapping[str, Any] | None = None,
36
38
  query: str | None = None,
37
39
  payload_name: str,
40
+ priority: int = API_PRIORITY_NORMAL,
38
41
  ) -> dict[str, Any]:
39
42
  raise NotImplementedError
40
43