violet-poolController-api 0.0.37__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.
- {violet_poolcontroller_api-0.0.37/violet_poolController_api.egg-info → violet_poolcontroller_api-0.0.39}/PKG-INFO +7 -7
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/README.md +5 -5
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/pyproject.toml +6 -2
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/tests/test_api.py +217 -6
- violet_poolcontroller_api-0.0.39/tests/test_language_policy.py +154 -0
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39/violet_poolController_api.egg-info}/PKG-INFO +7 -7
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/violet_poolController_api.egg-info/SOURCES.txt +1 -0
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/violet_poolController_api.egg-info/requires.txt +1 -1
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/__init__.py +57 -4
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/_api_dosing.py +13 -2
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/_api_mixin.py +3 -0
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/_api_model.py +4 -0
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/_api_outputs.py +14 -1
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/_api_readings.py +3 -0
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/_api_system.py +14 -7
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/api.py +98 -32
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/circuit_breaker.py +12 -6
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/const_api.py +37 -4
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/const_devices.py +1 -1
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/parsers.py +10 -17
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/readings.py +38 -14
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/utils_rate_limiter.py +30 -45
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/violet_poolcontroller_api/utils_sanitizer.py +126 -119
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/LICENSE +0 -0
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/setup.cfg +0 -0
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/tests/test_api_smoke.py +0 -0
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/tests/test_circuit_breaker.py +0 -0
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/tests/test_mock_server.py +0 -0
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/tests/test_parsers.py +0 -0
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/tests/test_rate_limiter.py +0 -0
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/tests/test_readings.py +0 -0
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/tests/test_sanitizer.py +0 -0
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/violet_poolController_api.egg-info/dependency_links.txt +0 -0
- {violet_poolcontroller_api-0.0.37 → violet_poolcontroller_api-0.0.39}/violet_poolController_api.egg-info/top_level.txt +0 -0
- {violet_poolcontroller_api-0.0.37 → 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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
- **
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
- **
|
|
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.
|
|
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
|
-
|
|
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]
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# violet-poolController-api - API
|
|
1
|
+
# violet-poolController-api - API for Violet Pool Controller
|
|
2
2
|
# Copyright (C) 2024-2026 Xerolux
|
|
3
3
|
#
|
|
4
4
|
# This program is free software: you can redistribute it and/or modify
|
|
@@ -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({"
|
|
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"] == {"
|
|
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
|
|
1431
|
+
async def test_command_result_normalizes_dict_input(
|
|
1387
1432
|
api_client: VioletPoolAPI,
|
|
1388
1433
|
) -> None:
|
|
1389
|
-
"""
|
|
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
|
|
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"
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# violet-poolController-api - API for Violet Pool Controller
|
|
2
|
+
# Copyright (C) 2024-2026 Xerolux
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU Affero General Public License as published
|
|
6
|
+
# by the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU Affero General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU Affero General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""Everything written into this repository is English.
|
|
18
|
+
|
|
19
|
+
The policy lives in AGENTS.md; this file is what makes it hold. Two things are
|
|
20
|
+
deliberately exempt and must stay exempt: the controller's own error strings in
|
|
21
|
+
``const_api.py`` (and the German payloads the mock server replays), which are
|
|
22
|
+
data rather than prose, and the German half of the bilingual documentation
|
|
23
|
+
under ``docs/de/``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
# ruff: noqa: S101
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
import pytest
|
|
34
|
+
|
|
35
|
+
REPO = Path(__file__).parent.parent
|
|
36
|
+
PACKAGE = REPO / "violet_poolcontroller_api"
|
|
37
|
+
|
|
38
|
+
# Text the controller emits, reproduced verbatim. Translating it would break
|
|
39
|
+
# the match with what the device actually says.
|
|
40
|
+
#
|
|
41
|
+
# Matched by file name, not by path: `python -m build` copies the package into
|
|
42
|
+
# `build/lib/`, and an exemption keyed to the original path does not cover the
|
|
43
|
+
# copy. That is not hypothetical - it failed the 0.0.38 release job, because
|
|
44
|
+
# the release builds the wheel before running the checks.
|
|
45
|
+
CONTROLLER_VERBATIM = frozenset({"const_api.py", "mock_server.py"})
|
|
46
|
+
|
|
47
|
+
# This file has to name the German words it looks for.
|
|
48
|
+
SELF = Path(__file__).name
|
|
49
|
+
|
|
50
|
+
# Directories that hold generated or vendored copies of the sources. Scanning
|
|
51
|
+
# them says nothing about what is written in this repository.
|
|
52
|
+
GENERATED_DIRS = frozenset({".git", ".tox", ".venv", "venv", "build", "dist", "__pycache__"})
|
|
53
|
+
|
|
54
|
+
# Words that only appear in German prose. Deliberately not "in", "die" or "der":
|
|
55
|
+
# those collide with English or with identifiers.
|
|
56
|
+
GERMAN_WORDS = (
|
|
57
|
+
"für",
|
|
58
|
+
"über",
|
|
59
|
+
"nicht",
|
|
60
|
+
"wird",
|
|
61
|
+
"werden",
|
|
62
|
+
"wenn",
|
|
63
|
+
"diese",
|
|
64
|
+
"dieser",
|
|
65
|
+
"keine",
|
|
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",
|
|
85
|
+
)
|
|
86
|
+
_GERMAN = re.compile(r"\b(" + "|".join(GERMAN_WORDS) + r")\b", re.IGNORECASE)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _python_sources() -> list[Path]:
|
|
90
|
+
"""Return every Python file the policy applies to."""
|
|
91
|
+
return sorted(
|
|
92
|
+
path
|
|
93
|
+
for path in REPO.rglob("*.py")
|
|
94
|
+
if not (GENERATED_DIRS & set(path.parts))
|
|
95
|
+
and not any(part.endswith(".egg-info") for part in path.parts)
|
|
96
|
+
and path.name not in CONTROLLER_VERBATIM
|
|
97
|
+
and path.name != SELF
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@pytest.mark.parametrize("path", _python_sources(), ids=lambda p: p.name)
|
|
102
|
+
def test_python_sources_are_english(path: Path) -> None:
|
|
103
|
+
"""A German comment is read by people who do not speak German."""
|
|
104
|
+
offenders = [
|
|
105
|
+
f"{path.relative_to(REPO)}:{number}: {line.strip()}"
|
|
106
|
+
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1)
|
|
107
|
+
if _GERMAN.search(line)
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
assert not offenders, "German text outside the exempt files:\n" + "\n".join(offenders)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_the_changelog_is_english() -> None:
|
|
114
|
+
"""The changelog is what a consumer reads when a version breaks something.
|
|
115
|
+
|
|
116
|
+
Code spans are quotations, not prose: an entry that removes a German string
|
|
117
|
+
has to be able to name the string it removed.
|
|
118
|
+
"""
|
|
119
|
+
changelog = (REPO / "CHANGELOG.md").read_text(encoding="utf-8")
|
|
120
|
+
prose = re.sub(r"`[^`]*`", "", changelog)
|
|
121
|
+
|
|
122
|
+
offenders = sorted({match.group(0).lower() for match in _GERMAN.finditer(prose)})
|
|
123
|
+
|
|
124
|
+
assert not offenders, f"German outside code spans in CHANGELOG.md: {offenders}"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_the_controller_strings_stay_german() -> None:
|
|
128
|
+
"""The exemption is the point, not an oversight - guard it too.
|
|
129
|
+
|
|
130
|
+
If someone "cleans up" the error table into English, the messages stop
|
|
131
|
+
matching what the device reports.
|
|
132
|
+
"""
|
|
133
|
+
const_api = (PACKAGE / "const_api.py").read_text(encoding="utf-8")
|
|
134
|
+
|
|
135
|
+
assert "Filterdrucküberwachung (Druck zu niedrig)" in const_api
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_generated_copies_are_not_scanned() -> None:
|
|
139
|
+
"""A built wheel is not source, and scanning its copy fails the release.
|
|
140
|
+
|
|
141
|
+
`python -m build` writes `build/lib/<package>/`, so the release job runs
|
|
142
|
+
the checks with a duplicate of every module on disk.
|
|
143
|
+
"""
|
|
144
|
+
scanned = {path.name for path in _python_sources()}
|
|
145
|
+
|
|
146
|
+
assert "const_api.py" not in scanned
|
|
147
|
+
assert not [path for path in _python_sources() if "build" in path.parts]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_the_policy_is_written_down() -> None:
|
|
151
|
+
"""Without the rule in AGENTS.md this file is just an opinion."""
|
|
152
|
+
agents = (REPO / "AGENTS.md").read_text(encoding="utf-8")
|
|
153
|
+
|
|
154
|
+
assert "## Language Policy" in agents
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: violet-poolController-api
|
|
3
|
-
Version: 0.0.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
- **
|
|
232
|
+
- **Official shop:** [pooldigital.de](https://www.pooldigital.de/)
|
|
233
233
|
- **Community:** [PoolDigital Forum](http://forum.pooldigital.de/)
|
|
234
234
|
|
|
235
235
|
**Disclaimer:**
|