violet-poolController-api 0.0.1__py3-none-any.whl
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/__init__.py +0 -0
- violet_poolcontroller_api/api.py +957 -0
- violet_poolcontroller_api/circuit_breaker.py +172 -0
- violet_poolcontroller_api/const_api.py +136 -0
- violet_poolcontroller_api/const_devices.py +307 -0
- violet_poolcontroller_api/utils_rate_limiter.py +235 -0
- violet_poolcontroller_api/utils_sanitizer.py +488 -0
- violet_poolcontroller_api-0.0.1.dist-info/METADATA +122 -0
- violet_poolcontroller_api-0.0.1.dist-info/RECORD +12 -0
- violet_poolcontroller_api-0.0.1.dist-info/WHEEL +5 -0
- violet_poolcontroller_api-0.0.1.dist-info/licenses/LICENSE +21 -0
- violet_poolcontroller_api-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,957 @@
|
|
|
1
|
+
"""HTTP client utilities for the Violet Pool Controller."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .const_devices import DEVICE_PARAMETERS
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from typing import Any, cast
|
|
13
|
+
from urllib.parse import quote, urlparse, urlunparse
|
|
14
|
+
|
|
15
|
+
import aiohttp
|
|
16
|
+
|
|
17
|
+
from .const_api import (
|
|
18
|
+
ACTION_ALLAUTO,
|
|
19
|
+
ACTION_ALLOFF,
|
|
20
|
+
ACTION_ALLON,
|
|
21
|
+
ACTION_COLOR,
|
|
22
|
+
ACTION_LOCK,
|
|
23
|
+
ACTION_OFF,
|
|
24
|
+
ACTION_ON,
|
|
25
|
+
ACTION_PUSH,
|
|
26
|
+
ACTION_UNLOCK,
|
|
27
|
+
API_GET_CALIB_HISTORY,
|
|
28
|
+
API_GET_CALIB_RAW_VALUES,
|
|
29
|
+
API_GET_CONFIG,
|
|
30
|
+
API_GET_HISTORY,
|
|
31
|
+
API_GET_OUTPUT_STATES,
|
|
32
|
+
API_GET_OVERALL_DOSING,
|
|
33
|
+
API_GET_WEATHER_DATA,
|
|
34
|
+
API_PRIORITY_NORMAL,
|
|
35
|
+
API_READINGS,
|
|
36
|
+
API_RESTORE_CALIBRATION,
|
|
37
|
+
API_SET_CONFIG,
|
|
38
|
+
API_SET_DOSING_PARAMETERS,
|
|
39
|
+
API_SET_FUNCTION_MANUALLY,
|
|
40
|
+
API_SET_OUTPUT_TESTMODE,
|
|
41
|
+
API_SET_TARGET_VALUES,
|
|
42
|
+
DOSING_FUNCTIONS,
|
|
43
|
+
TARGET_MIN_CHLORINE,
|
|
44
|
+
TARGET_ORP,
|
|
45
|
+
TARGET_PH,
|
|
46
|
+
)
|
|
47
|
+
from .circuit_breaker import CircuitBreaker
|
|
48
|
+
from .utils_rate_limiter import get_global_rate_limiter
|
|
49
|
+
|
|
50
|
+
_LOGGER = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class VioletPoolAPIError(Exception):
|
|
54
|
+
"""Raised when the Violet Pool Controller API returns an error."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class VioletPoolAPI:
|
|
58
|
+
"""A small HTTP client for interacting with the Violet Pool Controller.
|
|
59
|
+
|
|
60
|
+
This class handles API requests, including authentication, rate limiting,
|
|
61
|
+
and error handling. It provides methods for accessing various controller
|
|
62
|
+
endpoints.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
host: str,
|
|
69
|
+
session: aiohttp.ClientSession,
|
|
70
|
+
username: str | None = None,
|
|
71
|
+
password: str | None = None,
|
|
72
|
+
use_ssl: bool = False,
|
|
73
|
+
verify_ssl: bool = True,
|
|
74
|
+
timeout: int = 10,
|
|
75
|
+
max_retries: int = 3,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Initializes the API helper.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
host: The hostname or IP address of the controller.
|
|
81
|
+
session: The aiohttp client session.
|
|
82
|
+
username: The username for authentication.
|
|
83
|
+
password: The password for authentication.
|
|
84
|
+
use_ssl: Whether to use SSL for the connection.
|
|
85
|
+
verify_ssl: Whether to verify SSL certificates (security feature).
|
|
86
|
+
timeout: The request timeout in seconds.
|
|
87
|
+
max_retries: The maximum number of retries for failed requests.
|
|
88
|
+
"""
|
|
89
|
+
if session is None:
|
|
90
|
+
raise ValueError("A valid aiohttp session must be provided")
|
|
91
|
+
|
|
92
|
+
self._base_url = self._build_secure_base_url(host, use_ssl).rstrip("/")
|
|
93
|
+
|
|
94
|
+
self._session = session
|
|
95
|
+
self._timeout = aiohttp.ClientTimeout(
|
|
96
|
+
total=max(float(timeout), 1.0),
|
|
97
|
+
connect=max(float(timeout) * 0.8, 5.0), # 80% of timeout for connection
|
|
98
|
+
sock_connect=max(float(timeout) * 0.8, 5.0), # 80% for socket connection
|
|
99
|
+
)
|
|
100
|
+
self._max_retries = max(1, int(max_retries))
|
|
101
|
+
self._auth = None
|
|
102
|
+
if username:
|
|
103
|
+
self._auth = aiohttp.BasicAuth(username, password or "")
|
|
104
|
+
|
|
105
|
+
# SSL/TLS security configuration
|
|
106
|
+
self._verify_ssl = verify_ssl
|
|
107
|
+
self._ssl_context = None
|
|
108
|
+
if use_ssl and not verify_ssl:
|
|
109
|
+
_LOGGER.warning(
|
|
110
|
+
"SSL certificate verification is DISABLED. "
|
|
111
|
+
"This is a security risk and should only be used for testing "
|
|
112
|
+
"or with self-signed certificates in trusted networks."
|
|
113
|
+
)
|
|
114
|
+
import ssl
|
|
115
|
+
|
|
116
|
+
self._ssl_context = ssl.create_default_context()
|
|
117
|
+
self._ssl_context.check_hostname = False
|
|
118
|
+
self._ssl_context.verify_mode = ssl.CERT_NONE
|
|
119
|
+
|
|
120
|
+
# Rate limiting to protect the controller from being overloaded
|
|
121
|
+
self._rate_limiter = get_global_rate_limiter()
|
|
122
|
+
self._circuit_breaker = CircuitBreaker(expected_exception=VioletPoolAPIError)
|
|
123
|
+
_LOGGER.debug(
|
|
124
|
+
"API initialized with rate limiting enabled, SSL=%s, verify_ssl=%s",
|
|
125
|
+
use_ssl,
|
|
126
|
+
verify_ssl,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------
|
|
130
|
+
# Public Properties
|
|
131
|
+
# ---------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def timeout(self) -> float:
|
|
135
|
+
"""Get current timeout in seconds.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
The timeout value in seconds.
|
|
139
|
+
"""
|
|
140
|
+
return self._timeout.total or 0.0
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def max_retries(self) -> int:
|
|
144
|
+
"""Get maximum retry attempts.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
The maximum number of retry attempts.
|
|
148
|
+
"""
|
|
149
|
+
return self._max_retries
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------
|
|
152
|
+
# Generic helpers
|
|
153
|
+
# ---------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
def _build_secure_base_url(self, host: str, use_ssl: bool) -> str:
|
|
156
|
+
"""Securely construct base URL with comprehensive validation."""
|
|
157
|
+
# Strip existing protocols to prevent override
|
|
158
|
+
host = host.strip()
|
|
159
|
+
if host.startswith(("http://", "https://")):
|
|
160
|
+
parsed = urlparse(host)
|
|
161
|
+
host = parsed.netloc
|
|
162
|
+
|
|
163
|
+
# Validate hostname format
|
|
164
|
+
if not re.match(r"^[a-zA-Z0-9.-]+$", host):
|
|
165
|
+
raise ValueError(f"Invalid hostname format: {host}")
|
|
166
|
+
|
|
167
|
+
# Additional validation
|
|
168
|
+
if len(host) > 253 or ".." in host or "//" in host:
|
|
169
|
+
raise ValueError(f"Invalid hostname: {host}")
|
|
170
|
+
|
|
171
|
+
protocol = "https" if use_ssl else "http"
|
|
172
|
+
return urlunparse((protocol, host, "", "", "", ""))
|
|
173
|
+
|
|
174
|
+
def _build_url(self, endpoint: str) -> str:
|
|
175
|
+
"""Constructs the full URL for a given endpoint.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
endpoint: The API endpoint.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
The full URL.
|
|
182
|
+
"""
|
|
183
|
+
endpoint = endpoint if endpoint.startswith("/") else f"/{endpoint}"
|
|
184
|
+
return f"{self._base_url}{endpoint}"
|
|
185
|
+
|
|
186
|
+
async def _request(
|
|
187
|
+
self,
|
|
188
|
+
endpoint: str,
|
|
189
|
+
*,
|
|
190
|
+
method: str = "GET",
|
|
191
|
+
params: Mapping[str, Any] | None = None,
|
|
192
|
+
query: str | None = None,
|
|
193
|
+
json_payload: Any | None = None,
|
|
194
|
+
expect_json: bool = False,
|
|
195
|
+
priority: int = API_PRIORITY_NORMAL,
|
|
196
|
+
) -> Any:
|
|
197
|
+
"""Performs a request with rate limiting, retries, and error handling.
|
|
198
|
+
|
|
199
|
+
This method automatically waits if the request limit is reached.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
endpoint: The API endpoint to request.
|
|
203
|
+
method: The HTTP method to use.
|
|
204
|
+
params: A mapping of URL parameters.
|
|
205
|
+
query: A raw query string.
|
|
206
|
+
json_payload: The JSON payload for POST requests.
|
|
207
|
+
expect_json: Whether to expect a JSON response.
|
|
208
|
+
priority: The request priority for rate limiting.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
The API response, either as JSON or text.
|
|
212
|
+
|
|
213
|
+
Raises:
|
|
214
|
+
VioletPoolAPIError: If the API returns an error or the request fails.
|
|
215
|
+
"""
|
|
216
|
+
if params and query:
|
|
217
|
+
raise ValueError("'params' and 'query' are mutually exclusive")
|
|
218
|
+
|
|
219
|
+
async def _execute_request() -> Any:
|
|
220
|
+
# Wait if the rate limit is reached
|
|
221
|
+
try:
|
|
222
|
+
await self._rate_limiter.wait_if_needed(priority=priority, timeout=10.0)
|
|
223
|
+
except asyncio.TimeoutError:
|
|
224
|
+
_LOGGER.warning(
|
|
225
|
+
"Rate limiter timeout for %s (priority: %d) - continuing",
|
|
226
|
+
endpoint,
|
|
227
|
+
priority,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
url = self._build_url(endpoint)
|
|
231
|
+
if query:
|
|
232
|
+
url = f"{url}?{query}"
|
|
233
|
+
|
|
234
|
+
last_error: VioletPoolAPIError | None = None
|
|
235
|
+
|
|
236
|
+
for attempt in range(1, self._max_retries + 1):
|
|
237
|
+
try:
|
|
238
|
+
async with self._session.request(
|
|
239
|
+
method,
|
|
240
|
+
url,
|
|
241
|
+
params=params,
|
|
242
|
+
json=json_payload,
|
|
243
|
+
auth=self._auth,
|
|
244
|
+
timeout=self._timeout,
|
|
245
|
+
ssl=self._ssl_context,
|
|
246
|
+
) as response:
|
|
247
|
+
if response.status >= 500 or response.status == 429:
|
|
248
|
+
# Server error or rate limit -> trigger retry via ClientError
|
|
249
|
+
response.raise_for_status()
|
|
250
|
+
|
|
251
|
+
if response.status >= 400:
|
|
252
|
+
body = await response.text()
|
|
253
|
+
raise VioletPoolAPIError(
|
|
254
|
+
f"HTTP {response.status} for {endpoint}: {body.strip()}"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
if expect_json:
|
|
258
|
+
try:
|
|
259
|
+
return await response.json(content_type=None)
|
|
260
|
+
except (
|
|
261
|
+
aiohttp.ContentTypeError,
|
|
262
|
+
json.JSONDecodeError,
|
|
263
|
+
) as err:
|
|
264
|
+
body = await response.text()
|
|
265
|
+
raise VioletPoolAPIError(
|
|
266
|
+
f"Invalid JSON payload for {endpoint}: {body.strip()}"
|
|
267
|
+
) from err
|
|
268
|
+
|
|
269
|
+
return await response.text()
|
|
270
|
+
|
|
271
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as err:
|
|
272
|
+
last_error = VioletPoolAPIError(
|
|
273
|
+
f"Error communicating with Violet controller: {err}"
|
|
274
|
+
)
|
|
275
|
+
_LOGGER.debug(
|
|
276
|
+
"Attempt %d for %s failed: %s", attempt, endpoint, err
|
|
277
|
+
)
|
|
278
|
+
if attempt == self._max_retries:
|
|
279
|
+
raise last_error
|
|
280
|
+
# Exponential backoff with jitter
|
|
281
|
+
delay = min(2.0, 0.2 * (2 ** (attempt - 1)))
|
|
282
|
+
await asyncio.sleep(delay)
|
|
283
|
+
|
|
284
|
+
# If we reach here, all retries succeeded but returned no data
|
|
285
|
+
# This should not happen in normal operation
|
|
286
|
+
raise VioletPoolAPIError("Request completed but returned no data")
|
|
287
|
+
|
|
288
|
+
return await self._circuit_breaker.call(_execute_request)
|
|
289
|
+
|
|
290
|
+
@staticmethod
|
|
291
|
+
def _command_result(body: str | dict[str, Any]) -> dict[str, Any]:
|
|
292
|
+
"""Normalizes the controller's response for command-style requests.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
body: The raw response body or dict.
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
A dictionary indicating success and the response text.
|
|
299
|
+
"""
|
|
300
|
+
if isinstance(body, dict):
|
|
301
|
+
# Already parsed JSON or dict response
|
|
302
|
+
return body
|
|
303
|
+
|
|
304
|
+
text = (body or "").strip()
|
|
305
|
+
success = not text or "error" not in text.lower()
|
|
306
|
+
return {"success": success, "response": text}
|
|
307
|
+
|
|
308
|
+
def _build_manual_command(
|
|
309
|
+
self,
|
|
310
|
+
key: str,
|
|
311
|
+
action: str,
|
|
312
|
+
*,
|
|
313
|
+
duration: int | float | None = None,
|
|
314
|
+
last_value: int | float | None = None,
|
|
315
|
+
) -> str:
|
|
316
|
+
"""Renders the command payload based on the device parameter template.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
key: The device key.
|
|
320
|
+
action: The action to perform (e.g., ON, OFF).
|
|
321
|
+
duration: The duration for the action.
|
|
322
|
+
last_value: The last value (e.g., speed).
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
The formatted command payload.
|
|
326
|
+
|
|
327
|
+
Raises:
|
|
328
|
+
VioletPoolAPIError: If the template is misconfigured.
|
|
329
|
+
"""
|
|
330
|
+
template = cast(
|
|
331
|
+
str,
|
|
332
|
+
DEVICE_PARAMETERS.get(key, {}).get(
|
|
333
|
+
"api_template", f"{key},{{action}},{{duration}},{{value}}"
|
|
334
|
+
),
|
|
335
|
+
)
|
|
336
|
+
payload_data = {
|
|
337
|
+
"action": action,
|
|
338
|
+
"duration": int(duration or 0),
|
|
339
|
+
"speed": int(last_value or 0),
|
|
340
|
+
"value": int(last_value or 0),
|
|
341
|
+
}
|
|
342
|
+
try:
|
|
343
|
+
return template.format_map(payload_data)
|
|
344
|
+
except KeyError as err:
|
|
345
|
+
raise VioletPoolAPIError(
|
|
346
|
+
f"Template for {key} requires missing field: {err.args[0]}"
|
|
347
|
+
) from err
|
|
348
|
+
|
|
349
|
+
# ---------------------------------------------------------------------
|
|
350
|
+
# Public API surface
|
|
351
|
+
# ---------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
async def get_readings(self) -> dict[str, Any]:
|
|
354
|
+
"""Returns the complete dataset from the controller.
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
A dictionary containing all readings.
|
|
358
|
+
|
|
359
|
+
Raises:
|
|
360
|
+
VioletPoolAPIError: If the payload is unexpected.
|
|
361
|
+
"""
|
|
362
|
+
response = await self._request(
|
|
363
|
+
API_READINGS,
|
|
364
|
+
query="ALL",
|
|
365
|
+
expect_json=True,
|
|
366
|
+
)
|
|
367
|
+
if not isinstance(response, dict):
|
|
368
|
+
raise VioletPoolAPIError("Unexpected payload returned from getReadings")
|
|
369
|
+
return response
|
|
370
|
+
|
|
371
|
+
async def get_specific_readings(
|
|
372
|
+
self, categories: list[str] | tuple[str, ...]
|
|
373
|
+
) -> dict[str, Any]:
|
|
374
|
+
"""Returns a reduced dataset for the provided categories.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
categories: A list or tuple of category strings to fetch.
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
A dictionary containing the requested readings.
|
|
381
|
+
|
|
382
|
+
Raises:
|
|
383
|
+
VioletPoolAPIError: If no categories are provided or the payload is unexpected.
|
|
384
|
+
"""
|
|
385
|
+
if not categories:
|
|
386
|
+
raise VioletPoolAPIError("At least one category must be provided")
|
|
387
|
+
|
|
388
|
+
query = ",".join(category.strip() for category in categories if category)
|
|
389
|
+
if not query:
|
|
390
|
+
raise VioletPoolAPIError("No valid categories provided")
|
|
391
|
+
|
|
392
|
+
response = await self._request(
|
|
393
|
+
API_READINGS,
|
|
394
|
+
query=query,
|
|
395
|
+
expect_json=True,
|
|
396
|
+
)
|
|
397
|
+
if not isinstance(response, dict):
|
|
398
|
+
raise VioletPoolAPIError("Unexpected payload returned from getReadings")
|
|
399
|
+
return response
|
|
400
|
+
|
|
401
|
+
async def get_history(
|
|
402
|
+
self, *, hours: int = 24, sensor: str = "ALL"
|
|
403
|
+
) -> dict[str, Any]:
|
|
404
|
+
"""Fetches historical readings from the controller.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
hours: The number of hours of history to fetch.
|
|
408
|
+
sensor: The specific sensor to fetch history for, or "ALL".
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
A dictionary containing the history data.
|
|
412
|
+
|
|
413
|
+
Raises:
|
|
414
|
+
VioletPoolAPIError: If the payload is unexpected.
|
|
415
|
+
"""
|
|
416
|
+
safe_hours = max(1, int(hours))
|
|
417
|
+
params = {"hours": safe_hours, "sensor": sensor or "ALL"}
|
|
418
|
+
response = await self._request(
|
|
419
|
+
API_GET_HISTORY,
|
|
420
|
+
params=params,
|
|
421
|
+
expect_json=True,
|
|
422
|
+
)
|
|
423
|
+
if not isinstance(response, dict):
|
|
424
|
+
raise VioletPoolAPIError("Unexpected payload returned from getHistory")
|
|
425
|
+
return response
|
|
426
|
+
|
|
427
|
+
async def get_weather_data(self) -> dict[str, Any]:
|
|
428
|
+
"""Returns the current weather information used by the controller.
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
A dictionary containing weather data.
|
|
432
|
+
|
|
433
|
+
Raises:
|
|
434
|
+
VioletPoolAPIError: If the payload is unexpected.
|
|
435
|
+
"""
|
|
436
|
+
response = await self._request(
|
|
437
|
+
API_GET_WEATHER_DATA,
|
|
438
|
+
expect_json=True,
|
|
439
|
+
)
|
|
440
|
+
if not isinstance(response, dict):
|
|
441
|
+
raise VioletPoolAPIError("Unexpected payload returned from getWeatherdata")
|
|
442
|
+
return response
|
|
443
|
+
|
|
444
|
+
async def get_overall_dosing(self) -> dict[str, Any]:
|
|
445
|
+
"""Returns aggregated dosing statistics.
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
A dictionary containing overall dosing statistics.
|
|
449
|
+
|
|
450
|
+
Raises:
|
|
451
|
+
VioletPoolAPIError: If the payload is unexpected.
|
|
452
|
+
"""
|
|
453
|
+
response = await self._request(
|
|
454
|
+
API_GET_OVERALL_DOSING,
|
|
455
|
+
expect_json=True,
|
|
456
|
+
)
|
|
457
|
+
if not isinstance(response, dict):
|
|
458
|
+
raise VioletPoolAPIError(
|
|
459
|
+
"Unexpected payload returned from getOverallDosing"
|
|
460
|
+
)
|
|
461
|
+
return response
|
|
462
|
+
|
|
463
|
+
async def get_output_states(self) -> dict[str, Any]:
|
|
464
|
+
"""Returns detailed information about output states.
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
A dictionary containing output states.
|
|
468
|
+
|
|
469
|
+
Raises:
|
|
470
|
+
VioletPoolAPIError: If the payload is unexpected.
|
|
471
|
+
"""
|
|
472
|
+
response = await self._request(
|
|
473
|
+
API_GET_OUTPUT_STATES,
|
|
474
|
+
expect_json=True,
|
|
475
|
+
)
|
|
476
|
+
if not isinstance(response, dict):
|
|
477
|
+
raise VioletPoolAPIError("Unexpected payload returned from getOutputstates")
|
|
478
|
+
return response
|
|
479
|
+
|
|
480
|
+
async def get_config(
|
|
481
|
+
self, parameters: list[str] | tuple[str, ...]
|
|
482
|
+
) -> dict[str, Any]:
|
|
483
|
+
"""Fetches controller configuration values for the provided keys.
|
|
484
|
+
|
|
485
|
+
Args:
|
|
486
|
+
parameters: A list or tuple of configuration keys to fetch.
|
|
487
|
+
|
|
488
|
+
Returns:
|
|
489
|
+
A dictionary containing the configuration values.
|
|
490
|
+
|
|
491
|
+
Raises:
|
|
492
|
+
VioletPoolAPIError: If no keys are provided or the payload is unexpected.
|
|
493
|
+
"""
|
|
494
|
+
if not parameters:
|
|
495
|
+
raise VioletPoolAPIError("At least one configuration key is required")
|
|
496
|
+
|
|
497
|
+
query = ",".join(param.strip() for param in parameters if param)
|
|
498
|
+
if not query:
|
|
499
|
+
raise VioletPoolAPIError("No valid configuration keys provided")
|
|
500
|
+
|
|
501
|
+
response = await self._request(
|
|
502
|
+
API_GET_CONFIG,
|
|
503
|
+
query=query,
|
|
504
|
+
expect_json=True,
|
|
505
|
+
)
|
|
506
|
+
if not isinstance(response, dict):
|
|
507
|
+
raise VioletPoolAPIError("Unexpected payload returned from getConfig")
|
|
508
|
+
return response
|
|
509
|
+
|
|
510
|
+
async def set_config(self, config: Mapping[str, Any]) -> dict[str, Any]:
|
|
511
|
+
"""Updates controller configuration values.
|
|
512
|
+
|
|
513
|
+
Args:
|
|
514
|
+
config: A mapping of configuration keys and values to update.
|
|
515
|
+
|
|
516
|
+
Returns:
|
|
517
|
+
A dictionary with command result.
|
|
518
|
+
|
|
519
|
+
Raises:
|
|
520
|
+
VioletPoolAPIError: If configuration payload is empty.
|
|
521
|
+
"""
|
|
522
|
+
if not config:
|
|
523
|
+
raise VioletPoolAPIError("Configuration payload must not be empty")
|
|
524
|
+
|
|
525
|
+
# Sanitize all configuration parameters
|
|
526
|
+
sanitized_config = {}
|
|
527
|
+
from .utils_sanitizer import InputSanitizer
|
|
528
|
+
|
|
529
|
+
for key, value in config.items():
|
|
530
|
+
try:
|
|
531
|
+
sanitized_key = InputSanitizer.validate_api_parameter(str(key))
|
|
532
|
+
sanitized_value: str | int | float
|
|
533
|
+
|
|
534
|
+
if isinstance(value, str):
|
|
535
|
+
sanitized_value = InputSanitizer.sanitize_string(
|
|
536
|
+
value, max_length=1000, escape_html=True
|
|
537
|
+
)
|
|
538
|
+
elif isinstance(value, (int, float)):
|
|
539
|
+
sanitized_value = InputSanitizer.sanitize_numeric(value)
|
|
540
|
+
else:
|
|
541
|
+
sanitized_value = InputSanitizer.sanitize_string(str(value))
|
|
542
|
+
|
|
543
|
+
sanitized_config[sanitized_key] = sanitized_value
|
|
544
|
+
|
|
545
|
+
except ValueError as err:
|
|
546
|
+
_LOGGER.error("Invalid config parameter %s: %s", key, err)
|
|
547
|
+
raise VioletPoolAPIError(
|
|
548
|
+
f"Invalid configuration parameter: {key}"
|
|
549
|
+
) from err
|
|
550
|
+
|
|
551
|
+
body = await self._request(
|
|
552
|
+
API_SET_CONFIG,
|
|
553
|
+
method="POST",
|
|
554
|
+
json_payload=sanitized_config,
|
|
555
|
+
)
|
|
556
|
+
return self._command_result(body)
|
|
557
|
+
|
|
558
|
+
async def get_calibration_raw_values(self) -> dict[str, Any]:
|
|
559
|
+
"""Returns the current raw values for all calibration sensors.
|
|
560
|
+
|
|
561
|
+
Returns:
|
|
562
|
+
A dictionary containing raw calibration values.
|
|
563
|
+
|
|
564
|
+
Raises:
|
|
565
|
+
VioletPoolAPIError: If the payload is unexpected.
|
|
566
|
+
"""
|
|
567
|
+
response = await self._request(
|
|
568
|
+
API_GET_CALIB_RAW_VALUES,
|
|
569
|
+
expect_json=True,
|
|
570
|
+
)
|
|
571
|
+
if not isinstance(response, dict):
|
|
572
|
+
raise VioletPoolAPIError(
|
|
573
|
+
"Unexpected payload returned from getCalibRawValues"
|
|
574
|
+
)
|
|
575
|
+
return response
|
|
576
|
+
|
|
577
|
+
async def get_calibration_history(self, sensor: str) -> list[dict[str, str]]:
|
|
578
|
+
"""Returns the calibration history for the provided sensor.
|
|
579
|
+
|
|
580
|
+
Args:
|
|
581
|
+
sensor: The name of the sensor.
|
|
582
|
+
|
|
583
|
+
Returns:
|
|
584
|
+
A list of dictionaries representing the history entries.
|
|
585
|
+
|
|
586
|
+
Raises:
|
|
587
|
+
VioletPoolAPIError: If the sensor name is missing.
|
|
588
|
+
"""
|
|
589
|
+
if not sensor:
|
|
590
|
+
raise VioletPoolAPIError("Sensor name required for calibration history")
|
|
591
|
+
|
|
592
|
+
response = await self._request(
|
|
593
|
+
API_GET_CALIB_HISTORY,
|
|
594
|
+
query=sensor,
|
|
595
|
+
expect_json=False,
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
entries: list[dict[str, str]] = []
|
|
599
|
+
for line in (response or "").strip().splitlines():
|
|
600
|
+
try:
|
|
601
|
+
parts = [part.strip() for part in line.split("|")]
|
|
602
|
+
if len(parts) >= 3:
|
|
603
|
+
entries.append(
|
|
604
|
+
{
|
|
605
|
+
"timestamp": parts[0],
|
|
606
|
+
"value": parts[1],
|
|
607
|
+
"type": parts[2],
|
|
608
|
+
}
|
|
609
|
+
)
|
|
610
|
+
else:
|
|
611
|
+
_LOGGER.warning(
|
|
612
|
+
"Skipping malformed calibration history line: %s", line
|
|
613
|
+
)
|
|
614
|
+
except (IndexError, AttributeError) as err:
|
|
615
|
+
_LOGGER.warning(
|
|
616
|
+
"Error parsing calibration history line '%s': %s", line, err
|
|
617
|
+
)
|
|
618
|
+
return entries
|
|
619
|
+
|
|
620
|
+
async def restore_calibration(self, sensor: str, timestamp: str) -> dict[str, Any]:
|
|
621
|
+
"""Restores a previous calibration entry for the given sensor.
|
|
622
|
+
|
|
623
|
+
Args:
|
|
624
|
+
sensor: The name of the sensor.
|
|
625
|
+
timestamp: The timestamp of the calibration to restore.
|
|
626
|
+
|
|
627
|
+
Returns:
|
|
628
|
+
A dictionary with the command result.
|
|
629
|
+
|
|
630
|
+
Raises:
|
|
631
|
+
VioletPoolAPIError: If the sensor or timestamp is missing.
|
|
632
|
+
"""
|
|
633
|
+
if not sensor or not timestamp:
|
|
634
|
+
raise VioletPoolAPIError(
|
|
635
|
+
"Sensor and timestamp are required for calibration restore"
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
body = await self._request(
|
|
639
|
+
API_RESTORE_CALIBRATION,
|
|
640
|
+
method="POST",
|
|
641
|
+
json_payload={"sensor": sensor, "timestamp": timestamp},
|
|
642
|
+
)
|
|
643
|
+
return self._command_result(body)
|
|
644
|
+
|
|
645
|
+
async def set_output_test_mode(
|
|
646
|
+
self,
|
|
647
|
+
*,
|
|
648
|
+
output: str,
|
|
649
|
+
mode: str = "SWITCH",
|
|
650
|
+
duration: int = 120,
|
|
651
|
+
) -> dict[str, Any]:
|
|
652
|
+
"""Activates the controller's output test mode.
|
|
653
|
+
|
|
654
|
+
Args:
|
|
655
|
+
output: The identifier of the output.
|
|
656
|
+
mode: The test mode (default is "SWITCH").
|
|
657
|
+
duration: The duration in seconds (default is 120).
|
|
658
|
+
|
|
659
|
+
Returns:
|
|
660
|
+
A dictionary with the command result.
|
|
661
|
+
|
|
662
|
+
Raises:
|
|
663
|
+
VioletPoolAPIError: If the output is missing.
|
|
664
|
+
"""
|
|
665
|
+
if not output:
|
|
666
|
+
raise VioletPoolAPIError("Output identifier is required")
|
|
667
|
+
|
|
668
|
+
duration_ms = max(0, int(duration)) * 1000
|
|
669
|
+
payload = f"{output},{mode},{duration_ms}"
|
|
670
|
+
body = await self._request(
|
|
671
|
+
API_SET_OUTPUT_TESTMODE,
|
|
672
|
+
query=payload,
|
|
673
|
+
)
|
|
674
|
+
return self._command_result(body)
|
|
675
|
+
|
|
676
|
+
async def set_switch_state(
|
|
677
|
+
self,
|
|
678
|
+
key: str,
|
|
679
|
+
action: str,
|
|
680
|
+
*,
|
|
681
|
+
duration: int | float | None = None,
|
|
682
|
+
last_value: int | float | None = None,
|
|
683
|
+
) -> dict[str, Any]:
|
|
684
|
+
"""Controls a function output via /setFunctionManually.
|
|
685
|
+
|
|
686
|
+
Args:
|
|
687
|
+
key: The device key.
|
|
688
|
+
action: The action to perform (e.g., ON, OFF, AUTO).
|
|
689
|
+
duration: An optional duration for the action.
|
|
690
|
+
last_value: An optional last value (e.g., speed).
|
|
691
|
+
|
|
692
|
+
Returns:
|
|
693
|
+
A dictionary with the command result.
|
|
694
|
+
"""
|
|
695
|
+
payload = self._build_manual_command(
|
|
696
|
+
key,
|
|
697
|
+
action,
|
|
698
|
+
duration=duration,
|
|
699
|
+
last_value=last_value,
|
|
700
|
+
)
|
|
701
|
+
query = quote(payload, safe=",")
|
|
702
|
+
body = await self._request(
|
|
703
|
+
API_SET_FUNCTION_MANUALLY,
|
|
704
|
+
query=query,
|
|
705
|
+
)
|
|
706
|
+
return self._command_result(body)
|
|
707
|
+
|
|
708
|
+
async def manual_dosing(self, dosing_type: str, duration: int) -> dict[str, Any]:
|
|
709
|
+
"""Triggers a dosing run using the manual function endpoint.
|
|
710
|
+
|
|
711
|
+
Args:
|
|
712
|
+
dosing_type: The type of dosing (e.g., "Chlor").
|
|
713
|
+
duration: The duration in seconds.
|
|
714
|
+
|
|
715
|
+
Returns:
|
|
716
|
+
A dictionary with the command result.
|
|
717
|
+
|
|
718
|
+
Raises:
|
|
719
|
+
VioletPoolAPIError: If the dosing type is unknown.
|
|
720
|
+
"""
|
|
721
|
+
device_key = DOSING_FUNCTIONS.get(dosing_type)
|
|
722
|
+
if not device_key:
|
|
723
|
+
raise VioletPoolAPIError(f"Unknown dosing type: {dosing_type}")
|
|
724
|
+
|
|
725
|
+
return await self.set_switch_state(
|
|
726
|
+
device_key,
|
|
727
|
+
ACTION_ON,
|
|
728
|
+
duration=duration,
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
async def set_pv_surplus(
|
|
732
|
+
self,
|
|
733
|
+
*,
|
|
734
|
+
active: bool,
|
|
735
|
+
pump_speed: int | None = None,
|
|
736
|
+
) -> dict[str, Any]:
|
|
737
|
+
"""Enables or disables PV surplus mode.
|
|
738
|
+
|
|
739
|
+
Args:
|
|
740
|
+
active: Whether to activate PV surplus mode.
|
|
741
|
+
pump_speed: An optional pump speed.
|
|
742
|
+
|
|
743
|
+
Returns:
|
|
744
|
+
A dictionary with the command result.
|
|
745
|
+
"""
|
|
746
|
+
return await self.set_switch_state(
|
|
747
|
+
"PVSURPLUS",
|
|
748
|
+
ACTION_ON if active else ACTION_OFF,
|
|
749
|
+
last_value=pump_speed,
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
async def set_all_dmx_scenes(self, action: str) -> dict[str, Any]:
|
|
753
|
+
"""Sends the same command to all DMX scenes.
|
|
754
|
+
|
|
755
|
+
Args:
|
|
756
|
+
action: The action to perform (ALLON, ALLOFF, ALLAUTO).
|
|
757
|
+
|
|
758
|
+
Returns:
|
|
759
|
+
A dictionary with the combined command results.
|
|
760
|
+
|
|
761
|
+
Raises:
|
|
762
|
+
VioletPoolAPIError: If the action is unsupported.
|
|
763
|
+
"""
|
|
764
|
+
if action not in {ACTION_ALLON, ACTION_ALLOFF, ACTION_ALLAUTO}:
|
|
765
|
+
raise VioletPoolAPIError(f"Unsupported DMX action: {action}")
|
|
766
|
+
|
|
767
|
+
tasks = []
|
|
768
|
+
for scene in range(1, 13):
|
|
769
|
+
key = f"DMX_SCENE{scene}"
|
|
770
|
+
tasks.append(self.set_switch_state(key, action))
|
|
771
|
+
|
|
772
|
+
# Run requests concurrently
|
|
773
|
+
raw_results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
774
|
+
|
|
775
|
+
results: list[dict[str, Any]] = []
|
|
776
|
+
for res in raw_results:
|
|
777
|
+
if isinstance(res, Exception):
|
|
778
|
+
results.append({"success": False, "response": str(res)})
|
|
779
|
+
elif isinstance(res, dict):
|
|
780
|
+
results.append(res)
|
|
781
|
+
|
|
782
|
+
success = all(result.get("success") is True for result in results)
|
|
783
|
+
response = ", ".join(
|
|
784
|
+
str(result.get("response", "")) for result in results if result.get("response")
|
|
785
|
+
)
|
|
786
|
+
return {"success": success, "response": response}
|
|
787
|
+
|
|
788
|
+
async def set_light_color_pulse(self) -> dict[str, Any]:
|
|
789
|
+
"""Triggers the color pulse animation for the pool light.
|
|
790
|
+
|
|
791
|
+
Returns:
|
|
792
|
+
A dictionary with the command result.
|
|
793
|
+
"""
|
|
794
|
+
return await self.set_switch_state("LIGHT", ACTION_COLOR)
|
|
795
|
+
|
|
796
|
+
async def trigger_digital_input_rule(self, rule_key: str) -> dict[str, Any]:
|
|
797
|
+
"""Triggers a digital input rule via a PUSH action.
|
|
798
|
+
|
|
799
|
+
Args:
|
|
800
|
+
rule_key: The rule key (e.g., DIRULE_1).
|
|
801
|
+
|
|
802
|
+
Returns:
|
|
803
|
+
A dictionary with the command result.
|
|
804
|
+
"""
|
|
805
|
+
return await self.set_switch_state(rule_key, ACTION_PUSH)
|
|
806
|
+
|
|
807
|
+
async def set_digital_input_rule_lock(
|
|
808
|
+
self,
|
|
809
|
+
rule_key: str,
|
|
810
|
+
locked: bool,
|
|
811
|
+
) -> dict[str, Any]:
|
|
812
|
+
"""Locks or unlocks a digital input rule.
|
|
813
|
+
|
|
814
|
+
Args:
|
|
815
|
+
rule_key: The rule key.
|
|
816
|
+
locked: True to lock, False to unlock.
|
|
817
|
+
|
|
818
|
+
Returns:
|
|
819
|
+
A dictionary with the command result.
|
|
820
|
+
"""
|
|
821
|
+
return await self.set_switch_state(
|
|
822
|
+
rule_key,
|
|
823
|
+
ACTION_LOCK if locked else ACTION_UNLOCK,
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
async def set_device_temperature(
|
|
827
|
+
self,
|
|
828
|
+
climate_key: str,
|
|
829
|
+
temperature: float,
|
|
830
|
+
) -> dict[str, Any]:
|
|
831
|
+
"""Sets the target temperature for heater or solar circuits.
|
|
832
|
+
|
|
833
|
+
Args:
|
|
834
|
+
climate_key: The climate key (HEATER or SOLAR).
|
|
835
|
+
temperature: The target temperature.
|
|
836
|
+
|
|
837
|
+
Returns:
|
|
838
|
+
A dictionary with the command result.
|
|
839
|
+
"""
|
|
840
|
+
target_key = f"{climate_key}_TARGET_TEMP"
|
|
841
|
+
return await self.set_target_value(target_key, float(temperature))
|
|
842
|
+
|
|
843
|
+
async def set_ph_target(self, value: float) -> dict[str, Any]:
|
|
844
|
+
"""Updates the pH setpoint.
|
|
845
|
+
|
|
846
|
+
Args:
|
|
847
|
+
value: The new pH target value.
|
|
848
|
+
|
|
849
|
+
Returns:
|
|
850
|
+
A dictionary with the command result.
|
|
851
|
+
"""
|
|
852
|
+
return await self.set_target_value(TARGET_PH, float(value))
|
|
853
|
+
|
|
854
|
+
async def set_orp_target(self, value: int) -> dict[str, Any]:
|
|
855
|
+
"""Updates the ORP setpoint.
|
|
856
|
+
|
|
857
|
+
Args:
|
|
858
|
+
value: The new ORP target value.
|
|
859
|
+
|
|
860
|
+
Returns:
|
|
861
|
+
A dictionary with the command result.
|
|
862
|
+
"""
|
|
863
|
+
return await self.set_target_value(TARGET_ORP, int(value))
|
|
864
|
+
|
|
865
|
+
async def set_min_chlorine_level(self, value: float) -> dict[str, Any]:
|
|
866
|
+
"""Updates the minimum chlorine level.
|
|
867
|
+
|
|
868
|
+
Args:
|
|
869
|
+
value: The new minimum chlorine level.
|
|
870
|
+
|
|
871
|
+
Returns:
|
|
872
|
+
A dictionary with the command result.
|
|
873
|
+
"""
|
|
874
|
+
return await self.set_target_value(TARGET_MIN_CHLORINE, float(value))
|
|
875
|
+
|
|
876
|
+
async def set_target_value(self, key: str, value: float | int) -> dict[str, Any]:
|
|
877
|
+
"""Sends a generic target value update to the controller.
|
|
878
|
+
|
|
879
|
+
Args:
|
|
880
|
+
key: The target key.
|
|
881
|
+
value: The new value.
|
|
882
|
+
|
|
883
|
+
Returns:
|
|
884
|
+
A dictionary with the command result.
|
|
885
|
+
"""
|
|
886
|
+
params = {"target": key, "value": value}
|
|
887
|
+
body = await self._request(
|
|
888
|
+
API_SET_TARGET_VALUES,
|
|
889
|
+
params=params,
|
|
890
|
+
)
|
|
891
|
+
return self._command_result(body)
|
|
892
|
+
|
|
893
|
+
async def set_dosing_parameters(
|
|
894
|
+
self,
|
|
895
|
+
parameters: Mapping[str, Any],
|
|
896
|
+
) -> dict[str, Any]:
|
|
897
|
+
"""Updates dosing parameters via the dedicated endpoint.
|
|
898
|
+
|
|
899
|
+
Args:
|
|
900
|
+
parameters: A mapping of dosing parameters.
|
|
901
|
+
|
|
902
|
+
Returns:
|
|
903
|
+
A dictionary with the command result.
|
|
904
|
+
"""
|
|
905
|
+
body = await self._request(
|
|
906
|
+
API_SET_DOSING_PARAMETERS,
|
|
907
|
+
method="POST",
|
|
908
|
+
json_payload=dict(parameters),
|
|
909
|
+
)
|
|
910
|
+
return self._command_result(body)
|
|
911
|
+
|
|
912
|
+
async def set_pump_speed(
|
|
913
|
+
self,
|
|
914
|
+
speed: int,
|
|
915
|
+
duration: int = 0,
|
|
916
|
+
) -> dict[str, Any]:
|
|
917
|
+
"""Sets the pump speed.
|
|
918
|
+
|
|
919
|
+
Args:
|
|
920
|
+
speed: The pump speed (1-3, where 1=ECO, 2=Normal, 3=Boost).
|
|
921
|
+
duration: Optional duration in seconds (0 = permanent).
|
|
922
|
+
|
|
923
|
+
Returns:
|
|
924
|
+
A dictionary with the command result.
|
|
925
|
+
"""
|
|
926
|
+
safe_speed = max(1, min(3, int(speed)))
|
|
927
|
+
safe_duration = max(0, int(duration))
|
|
928
|
+
|
|
929
|
+
return await self.set_switch_state(
|
|
930
|
+
key="PUMP",
|
|
931
|
+
action=ACTION_ON,
|
|
932
|
+
duration=safe_duration,
|
|
933
|
+
last_value=safe_speed,
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
async def control_pump(
|
|
937
|
+
self,
|
|
938
|
+
action: str,
|
|
939
|
+
speed: int | None = None,
|
|
940
|
+
duration: int = 0,
|
|
941
|
+
) -> dict[str, Any]:
|
|
942
|
+
"""Controls the pump with optional speed and duration.
|
|
943
|
+
|
|
944
|
+
Args:
|
|
945
|
+
action: The action to perform (ON, OFF, AUTO).
|
|
946
|
+
speed: Optional pump speed (1-3).
|
|
947
|
+
duration: Optional duration in seconds.
|
|
948
|
+
|
|
949
|
+
Returns:
|
|
950
|
+
A dictionary with the command result.
|
|
951
|
+
"""
|
|
952
|
+
return await self.set_switch_state(
|
|
953
|
+
key="PUMP",
|
|
954
|
+
action=action,
|
|
955
|
+
duration=duration,
|
|
956
|
+
last_value=speed,
|
|
957
|
+
)
|