violet-poolController-api 0.0.38__tar.gz → 0.0.40__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.40}/PKG-INFO +7 -7
  2. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/README.md +5 -5
  3. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/pyproject.toml +6 -2
  4. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/tests/test_api.py +251 -20
  5. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/tests/test_language_policy.py +18 -0
  6. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/tests/test_mock_server.py +16 -0
  7. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40/violet_poolController_api.egg-info}/PKG-INFO +7 -7
  8. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/violet_poolController_api.egg-info/requires.txt +1 -1
  9. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/__init__.py +56 -3
  10. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/_api_dosing.py +13 -2
  11. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/_api_mixin.py +3 -0
  12. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/_api_model.py +4 -0
  13. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/_api_outputs.py +14 -1
  14. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/_api_readings.py +14 -2
  15. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/_api_system.py +14 -7
  16. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/api.py +97 -31
  17. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/circuit_breaker.py +11 -5
  18. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/const_api.py +44 -3
  19. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/parsers.py +9 -16
  20. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/readings.py +37 -13
  21. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/utils_rate_limiter.py +23 -38
  22. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/utils_sanitizer.py +112 -105
  23. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/LICENSE +0 -0
  24. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/setup.cfg +0 -0
  25. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/tests/test_api_smoke.py +0 -0
  26. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/tests/test_circuit_breaker.py +0 -0
  27. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/tests/test_parsers.py +0 -0
  28. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/tests/test_rate_limiter.py +0 -0
  29. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/tests/test_readings.py +0 -0
  30. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/tests/test_sanitizer.py +0 -0
  31. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/violet_poolController_api.egg-info/SOURCES.txt +0 -0
  32. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/violet_poolController_api.egg-info/dependency_links.txt +0 -0
  33. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/violet_poolController_api.egg-info/top_level.txt +0 -0
  34. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/violet_poolcontroller_api/const_devices.py +0 -0
  35. {violet_poolcontroller_api-0.0.38 → violet_poolcontroller_api-0.0.40}/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.40
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.40"
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]
@@ -42,6 +42,7 @@ from violet_poolcontroller_api.const_api import (
42
42
  ERROR_SEVERITY_INFO,
43
43
  ERROR_SEVERITY_REMINDER,
44
44
  ERROR_SEVERITY_WARNING,
45
+ QUERY_FULL_REFRESH,
45
46
  TARGET_PH,
46
47
  )
47
48
 
@@ -92,7 +93,7 @@ async def test_get_readings_success(
92
93
  api_client: VioletPoolAPI,
93
94
  ) -> None:
94
95
  """Test get_readings returns the correct parsed JSON dictionary."""
95
- url = "http://192.168.1.100/getReadings?ALL"
96
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
96
97
  mock_data = {"PUMPSTATE": "2", "PH": 7.2}
97
98
  mock_aioresponse.get(url, payload=mock_data, status=200)
98
99
 
@@ -102,6 +103,25 @@ async def test_get_readings_success(
102
103
  assert dict(result) == mock_data
103
104
 
104
105
 
106
+ @pytest.mark.asyncio
107
+ async def test_get_readings_requests_all_feature_flags(
108
+ mock_aioresponse: aioresponses,
109
+ api_client: VioletPoolAPI,
110
+ ) -> None:
111
+ """Plain ``ALL`` omits the computed fields (daily dosing totals, runtime
112
+ strings, priority-state composites) on some firmware states, leaving the
113
+ matching sensors at ``unknown``. get_readings() must therefore always
114
+ ask for ``ALL`` combined with every feature-flag token."""
115
+ url = f"http://192.168.1.100/getReadings?{QUERY_FULL_REFRESH}"
116
+ mock_aioresponse.get(url, payload={"PUMPSTATE": "2"}, status=200)
117
+
118
+ await api_client.get_readings()
119
+
120
+ # aioresponses matches the URL exactly; reaching this point without an
121
+ # unsatisfied-mock error proves the full query string was sent.
122
+ assert mock_aioresponse.requests
123
+
124
+
105
125
  @pytest.mark.asyncio
106
126
  async def test_get_output_runtimes_success(
107
127
  mock_aioresponse: aioresponses,
@@ -164,7 +184,7 @@ async def test_request_server_error(
164
184
  api_client: VioletPoolAPI,
165
185
  ) -> None:
166
186
  """Test that a 500 error raises VioletPoolAPIError after retrying."""
167
- url = "http://192.168.1.100/getReadings?ALL"
187
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
168
188
  mock_aioresponse.get(url, status=500)
169
189
  # the second time it retries
170
190
  mock_aioresponse.get(url, status=500)
@@ -274,11 +294,56 @@ async def test_set_config_sanitizes_payload_before_request(
274
294
 
275
295
  monkeypatch.setattr(api_client, "_request", fake_request)
276
296
 
277
- result = await api_client.set_config({"pool mode": "A<mode>", "speed": 3.7})
297
+ result = await api_client.set_config({"POOL_MODE": "A<mode>", "speed": 3.7})
278
298
 
279
299
  assert result["success"] is True
280
300
  assert result["response"] == "OK"
281
- assert captured["data"] == {"poolmode": "A<mode>", "speed": 3.7}
301
+ assert captured["data"] == {"POOL_MODE": "A<mode>", "speed": 3.7}
302
+
303
+
304
+ @pytest.mark.asyncio
305
+ async def test_set_config_rejects_invalid_key_instead_of_rewriting_it(
306
+ api_client: VioletPoolAPI,
307
+ monkeypatch: pytest.Monkeypatch,
308
+ ) -> None:
309
+ """A malformed key is an error, never a silent write to a different key.
310
+
311
+ ``"pool mode"`` used to be rewritten to ``"poolmode"`` and sent, so a typo
312
+ in a configuration key changed a setting the caller never named.
313
+ """
314
+ sent = False
315
+
316
+ async def fake_request(_endpoint: str, **_kwargs: Any) -> str: # noqa: ANN401
317
+ nonlocal sent
318
+ sent = True
319
+ return "OK"
320
+
321
+ monkeypatch.setattr(api_client, "_request", fake_request)
322
+
323
+ with pytest.raises(VioletPoolAPIError, match="Invalid configuration parameter"):
324
+ await api_client.set_config({"pool mode": "A"})
325
+
326
+ assert sent is False
327
+
328
+
329
+ @pytest.mark.asyncio
330
+ async def test_set_config_rejects_unsupported_value_types(
331
+ api_client: VioletPoolAPI,
332
+ monkeypatch: pytest.Monkeypatch,
333
+ ) -> None:
334
+ """None and containers must not reach the controller as "None" or "1 2"."""
335
+
336
+ async def fake_request(_endpoint: str, **_kwargs: Any) -> str: # noqa: ANN401
337
+ return "OK"
338
+
339
+ monkeypatch.setattr(api_client, "_request", fake_request)
340
+
341
+ for bad_value in (None, [1, 2], {"a": 1}):
342
+ with pytest.raises(VioletPoolAPIError):
343
+ await api_client.set_config({"SOME_KEY": bad_value})
344
+
345
+ with pytest.raises(VioletPoolAPIError, match="Non-finite"):
346
+ await api_client.set_config({"SOME_KEY": float("nan")})
282
347
 
283
348
 
284
349
  @pytest.mark.asyncio
@@ -313,7 +378,7 @@ async def test_get_readings_standalone_list_format(
313
378
  api_client: VioletPoolAPI,
314
379
  ) -> None:
315
380
  """Test get_readings parses the standalone list format correctly."""
316
- url = "http://192.168.1.100/getReadings?ALL"
381
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
317
382
  mock_data = {
318
383
  "getReadings": [
319
384
  {
@@ -349,7 +414,7 @@ async def test_dosing_standalone_detection_dict_format(
349
414
  standalone_api_client: VioletPoolAPI,
350
415
  ) -> None:
351
416
  """Test dosing_standalone is set to False when dict format is received."""
352
- url = "http://192.168.1.100/getReadings?ALL"
417
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
353
418
  mock_data = {
354
419
  "getReadings": {
355
420
  "PUMPSTATE": "2",
@@ -369,7 +434,7 @@ async def test_dosing_standalone_detection_dict_format(
369
434
  @pytest.mark.asyncio
370
435
  async def test_get_hardware_profile(mock_aioresponse, api_client):
371
436
  """Test get_hardware_profile correctly detects components via alive counters."""
372
- url = "http://192.168.1.100/getReadings?ALL"
437
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
373
438
 
374
439
  # 1. Base module only (no DOS, EXT)
375
440
  mock_aioresponse.get(
@@ -479,7 +544,7 @@ async def test_module_alive_on_zero_count(mock_aioresponse, api_client):
479
544
  old code required value > 0, which caused EXT1_* readings to be filtered
480
545
  and the relay switch to appear broken right after a restart.
481
546
  """
482
- url = "http://192.168.1.100/getReadings?ALL"
547
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
483
548
  mock_aioresponse.get(
484
549
  url,
485
550
  payload={
@@ -503,7 +568,7 @@ async def test_module_alive_on_zero_count(mock_aioresponse, api_client):
503
568
  @pytest.mark.asyncio
504
569
  async def test_ext1_readings_not_filtered_when_detected(mock_aioresponse, api_client):
505
570
  """EXT1_* readings are included when extension_module_1 is detected."""
506
- url = "http://192.168.1.100/getReadings?ALL"
571
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
507
572
  mock_aioresponse.get(
508
573
  url,
509
574
  payload={
@@ -527,7 +592,7 @@ async def test_ext1_readings_not_filtered_when_detected(mock_aioresponse, api_cl
527
592
  @pytest.mark.asyncio
528
593
  async def test_ext1_readings_filtered_when_not_detected(mock_aioresponse, api_client):
529
594
  """EXT1_* readings are stripped when extension_module_1 key is absent."""
530
- url = "http://192.168.1.100/getReadings?ALL"
595
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
531
596
  mock_aioresponse.get(
532
597
  url,
533
598
  payload={
@@ -549,7 +614,7 @@ async def test_ext1_readings_filtered_when_not_detected(mock_aioresponse, api_cl
549
614
  @pytest.mark.asyncio
550
615
  async def test_get_hardware_profile_standalone_dosing(mock_aioresponse, standalone_api_client):
551
616
  """Test get_hardware_profile with a standalone dosing configuration."""
552
- url = "http://192.168.1.100/getReadings?ALL"
617
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
553
618
  # Using the standalone list format
554
619
  mock_data = {
555
620
  "getReadings": [
@@ -1383,14 +1448,20 @@ async def test_command_result_dosing_started(
1383
1448
 
1384
1449
 
1385
1450
  @pytest.mark.asyncio
1386
- async def test_command_result_dict_passthrough(
1451
+ async def test_command_result_normalizes_dict_input(
1387
1452
  api_client: VioletPoolAPI,
1388
1453
  ) -> None:
1389
- """Test _command_result passes dict through unchanged."""
1454
+ """A dict body is normalized, so ``success`` is always present.
1455
+
1456
+ The old passthrough returned the dict unchanged, so a caller reading
1457
+ ``result["success"]`` got a KeyError instead of a result.
1458
+ """
1390
1459
  data = {"key": "value", "nested": {"a": 1}}
1391
1460
  result = VioletPoolAPI._command_result(data)
1392
1461
 
1393
- assert result is data
1462
+ assert result["success"] is True
1463
+ assert result["key"] == "value"
1464
+ assert result["nested"] == {"a": 1}
1394
1465
 
1395
1466
 
1396
1467
  @pytest.mark.asyncio
@@ -1823,7 +1894,7 @@ async def test_client_error_fails_fast_without_retry(
1823
1894
  api_client: VioletPoolAPI,
1824
1895
  ) -> None:
1825
1896
  """A 4xx response raises immediately instead of being retried."""
1826
- url = "http://192.168.1.100/getReadings?ALL"
1897
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
1827
1898
  mock_aioresponse.get(url, status=401, body="Unauthorized")
1828
1899
 
1829
1900
  with pytest.raises(VioletPoolAPIError, match="HTTP 401"):
@@ -1836,7 +1907,7 @@ async def test_client_error_does_not_trip_circuit_breaker(
1836
1907
  api_client: VioletPoolAPI,
1837
1908
  ) -> None:
1838
1909
  """Deterministic 4xx errors must not count as circuit breaker failures."""
1839
- url = "http://192.168.1.100/getReadings?ALL"
1910
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
1840
1911
  threshold = api_client._circuit_breaker.failure_threshold
1841
1912
 
1842
1913
  for _ in range(threshold + 1):
@@ -1856,7 +1927,7 @@ async def test_empty_get_readings_payload_is_flattened(
1856
1927
  mock_aioresponse: aioresponses,
1857
1928
  api_client: VioletPoolAPI,
1858
1929
  ) -> None:
1859
- url = "http://192.168.1.100/getReadings?ALL"
1930
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
1860
1931
  mock_aioresponse.get(url, payload={"getReadings": {}}, status=200)
1861
1932
 
1862
1933
  readings = await api_client.get_readings()
@@ -1886,7 +1957,7 @@ async def test_basic_auth_uses_authorization_header(
1886
1957
  mock_aioresponse: aioresponses,
1887
1958
  api_client: VioletPoolAPI,
1888
1959
  ) -> None:
1889
- url = "http://192.168.1.100/getReadings?ALL"
1960
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
1890
1961
  mock_aioresponse.get(url, payload={}, status=200)
1891
1962
 
1892
1963
  await api_client.get_readings()
@@ -1902,7 +1973,7 @@ async def test_server_error_still_counts_for_circuit_breaker(
1902
1973
  api_client: VioletPoolAPI,
1903
1974
  ) -> None:
1904
1975
  """5xx errors keep counting as circuit breaker failures."""
1905
- url = "http://192.168.1.100/getReadings?ALL"
1976
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
1906
1977
  mock_aioresponse.get(url, status=500, body="boom", repeat=True)
1907
1978
 
1908
1979
  with pytest.raises(VioletPoolAPIError):
@@ -1925,7 +1996,7 @@ async def test_rate_limiter_reacquired_on_every_retry(
1925
1996
  retry loop, so retries fired real HTTP requests without ever going
1926
1997
  through the limiter again.
1927
1998
  """
1928
- url = "http://192.168.1.100/getReadings?ALL"
1999
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
1929
2000
  mock_aioresponse.get(url, status=500, body="boom", repeat=True)
1930
2001
  # Backoff sleeps aren't what this test verifies; skip them for speed.
1931
2002
  monkeypatch.setattr("violet_poolcontroller_api.api.asyncio.sleep", AsyncMock())
@@ -2130,3 +2201,163 @@ def test_input_sanitizer_no_duplicate_validate_duration() -> None:
2130
2201
 
2131
2202
  assert not hasattr(InputSanitizer, "validate_duration")
2132
2203
  assert not hasattr(InputSanitizer, "validate_speed")
2204
+
2205
+
2206
+ def _request_count(mocked: aioresponses, method: str, path: str) -> int:
2207
+ """Count recorded requests to *path*, ignoring query-string normalization.
2208
+
2209
+ yarl rewrites a value-less query (``?PUMP,ON,0,0``) by appending ``=``, so
2210
+ the recorded key never equals the URL that was requested.
2211
+ """
2212
+ return sum(
2213
+ len(calls)
2214
+ for (recorded_method, url), calls in mocked.requests.items()
2215
+ if recorded_method == method and url.path == path
2216
+ )
2217
+
2218
+
2219
+ # ---------------------------------------------------------------------------
2220
+ # Regression: state-changing commands are sent exactly once (0.0.39)
2221
+ # ---------------------------------------------------------------------------
2222
+
2223
+
2224
+ @pytest.mark.asyncio
2225
+ async def test_switch_command_is_not_retried(
2226
+ mock_aioresponse: aioresponses,
2227
+ ) -> None:
2228
+ """A failed switch command must not be repeated.
2229
+
2230
+ The controller applies state changes through GET, and a request that
2231
+ timed out may well have been applied. Repeating it can toggle the output
2232
+ back or apply it a second time.
2233
+ """
2234
+ url = "http://192.168.1.100/setFunctionManually?PUMP,ON,0,0"
2235
+ mock_aioresponse.get(url, status=500, body="boom")
2236
+
2237
+ async with aiohttp.ClientSession() as session:
2238
+ api = VioletPoolAPI(host="192.168.1.100", session=session, max_retries=3)
2239
+ with pytest.raises(VioletPoolAPIError):
2240
+ await api.set_switch_state("PUMP", "ON")
2241
+
2242
+ assert _request_count(mock_aioresponse, "GET", "/setFunctionManually") == 1
2243
+
2244
+
2245
+ @pytest.mark.asyncio
2246
+ async def test_digital_rule_trigger_is_not_retried(
2247
+ mock_aioresponse: aioresponses,
2248
+ ) -> None:
2249
+ """PUSH is a toggle: a retry would undo the change it just made."""
2250
+ url = "http://192.168.1.100/setFunctionManually?DIRULE_1,PUSH,0,0"
2251
+ mock_aioresponse.get(url, status=500, body="boom")
2252
+
2253
+ async with aiohttp.ClientSession() as session:
2254
+ api = VioletPoolAPI(host="192.168.1.100", session=session, max_retries=3)
2255
+ with pytest.raises(VioletPoolAPIError):
2256
+ await api.trigger_digital_input_rule("DIRULE_1")
2257
+
2258
+ assert _request_count(mock_aioresponse, "GET", "/setFunctionManually") == 1
2259
+
2260
+
2261
+ @pytest.mark.asyncio
2262
+ async def test_init_update_is_not_retried(
2263
+ mock_aioresponse: aioresponses,
2264
+ ) -> None:
2265
+ """Starting a firmware update twice is not harmless."""
2266
+ url = "http://192.168.1.100/initUpdate"
2267
+ mock_aioresponse.get(url, status=503, body="busy")
2268
+
2269
+ async with aiohttp.ClientSession() as session:
2270
+ api = VioletPoolAPI(host="192.168.1.100", session=session, max_retries=3)
2271
+ with pytest.raises(VioletPoolAPIError):
2272
+ await api.init_update()
2273
+
2274
+ assert _request_count(mock_aioresponse, "GET", "/initUpdate") == 1
2275
+
2276
+
2277
+ @pytest.mark.asyncio
2278
+ async def test_reads_are_still_retried(
2279
+ mock_aioresponse: aioresponses,
2280
+ ) -> None:
2281
+ """The read path keeps its retries; repeating a read costs nothing."""
2282
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
2283
+ mock_aioresponse.get(url, status=500, body="boom")
2284
+ mock_aioresponse.get(url, status=500, body="boom")
2285
+ mock_aioresponse.get(url, payload={"getReadings": {"PUMP": "1"}}, status=200)
2286
+
2287
+ async with aiohttp.ClientSession() as session:
2288
+ api = VioletPoolAPI(host="192.168.1.100", session=session, max_retries=3)
2289
+ readings = await api.get_readings()
2290
+
2291
+ assert readings["PUMP"] == "1"
2292
+ assert _request_count(mock_aioresponse, "GET", "/getReadings") == 3
2293
+
2294
+
2295
+ @pytest.mark.asyncio
2296
+ async def test_rate_limit_wait_timeout_fails_instead_of_bypassing(
2297
+ mock_aioresponse: aioresponses,
2298
+ monkeypatch: pytest.Monkeypatch,
2299
+ ) -> None:
2300
+ """A limiter timeout must fail the request, not send it without a token.
2301
+
2302
+ Sending anyway let every caller that waited out the timeout hit the
2303
+ controller at once, which is the pile-up the limiter exists to prevent.
2304
+ """
2305
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
2306
+ mock_aioresponse.get(url, payload={"getReadings": {}}, status=200)
2307
+
2308
+ async with aiohttp.ClientSession() as session:
2309
+ api = VioletPoolAPI(host="192.168.1.100", session=session, max_retries=1)
2310
+
2311
+ async def always_timeout(**_kwargs: Any) -> None:
2312
+ raise TimeoutError
2313
+
2314
+ monkeypatch.setattr(api._rate_limiter, "wait_if_needed", always_timeout)
2315
+
2316
+ with pytest.raises(VioletPoolAPIError, match="Rate limit wait"):
2317
+ await api.get_readings()
2318
+
2319
+ assert _request_count(mock_aioresponse, "GET", "/getReadings") == 0
2320
+
2321
+
2322
+ @pytest.mark.asyncio
2323
+ async def test_html_login_page_does_not_open_the_circuit_breaker(
2324
+ mock_aioresponse: aioresponses,
2325
+ ) -> None:
2326
+ """A captive portal answers 200 with HTML every time, deterministically.
2327
+
2328
+ Counting those as transient failures opened the breaker and replaced the
2329
+ payload error that explains the actual problem.
2330
+ """
2331
+ url = "http://192.168.1.100/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
2332
+ for _ in range(8):
2333
+ mock_aioresponse.get(url, body="<html>login</html>", status=200)
2334
+
2335
+ async with aiohttp.ClientSession() as session:
2336
+ api = VioletPoolAPI(host="192.168.1.100", session=session, max_retries=1)
2337
+ for _ in range(8):
2338
+ with pytest.raises(VioletPoolAPIError) as excinfo:
2339
+ await api.get_readings()
2340
+ assert "Invalid JSON payload" in str(excinfo.value)
2341
+
2342
+
2343
+ def test_hostname_error_never_echoes_credentials() -> None:
2344
+ """The message reaches config-flow errors and logs; it must stay clean."""
2345
+ with pytest.raises(ValueError, match="must not contain credentials") as excinfo:
2346
+ VioletPoolAPI._build_secure_base_url(
2347
+ VioletPoolAPI,
2348
+ "http://admin:s3cret@192.168.1.5",
2349
+ use_ssl=False,
2350
+ )
2351
+
2352
+ assert "s3cret" not in str(excinfo.value)
2353
+
2354
+
2355
+ def test_hostname_accepts_underscores() -> None:
2356
+ """mDNS and router-assigned names use underscores."""
2357
+ url = VioletPoolAPI._build_secure_base_url(
2358
+ VioletPoolAPI,
2359
+ "violet_pool.local",
2360
+ use_ssl=False,
2361
+ )
2362
+
2363
+ 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
 
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  import asyncio
10
10
  import base64
11
+ import json
11
12
  import socket
12
13
  import subprocess
13
14
  import sys
@@ -89,6 +90,21 @@ async def test_raw_auth() -> None:
89
90
  assert status == 200, f"Expected 200, got {status}"
90
91
  print(f" OK: status={status} body_len={len(body)}")
91
92
 
93
+ print()
94
+ print("=" * 60)
95
+ print("TEST 1b: ALL plus feature-flag tokens must not filter the payload")
96
+ print("=" * 60)
97
+ status_mixed, body_mixed = await _request(
98
+ f"http://{HOST}:{PORT}/getReadings?ALL,DOSAGE,RUNTIMES,PUMPPRIOSTATE,BACKWASH,SYSTEM"
99
+ )
100
+ assert status_mixed == 200, f"Expected 200, got {status_mixed}"
101
+ # build_readings() re-randomizes sensor values per call, so compare the
102
+ # key set: ALL + tokens must return the same unfiltered key set as ALL.
103
+ keys_all = set(json.loads(body)["getReadings"])
104
+ keys_mixed = set(json.loads(body_mixed)["getReadings"])
105
+ assert keys_mixed == keys_all, "ALL + tokens must not filter any keys"
106
+ print(f" OK: status={status_mixed} keys={len(keys_mixed)} (same as ALL)")
107
+
92
108
  print()
93
109
  print("=" * 60)
94
110
  print("TEST 2: Raw HTTP - /getConfig without credentials -> 401")
@@ -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.40
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:**