violet-poolController-api 0.0.27__tar.gz → 0.0.29__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.27/violet_poolController_api.egg-info → violet_poolcontroller_api-0.0.29}/PKG-INFO +1 -1
- {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/pyproject.toml +1 -1
- {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29/violet_poolController_api.egg-info}/PKG-INFO +1 -1
- {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/violet_poolController_api.egg-info/SOURCES.txt +2 -0
- {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/violet_poolcontroller_api/__init__.py +61 -4
- {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/violet_poolcontroller_api/api.py +204 -25
- {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/violet_poolcontroller_api/const_devices.py +101 -0
- violet_poolcontroller_api-0.0.29/violet_poolcontroller_api/parsers.py +185 -0
- violet_poolcontroller_api-0.0.29/violet_poolcontroller_api/readings.py +515 -0
- {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/LICENSE +0 -0
- {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/README.md +0 -0
- {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/setup.cfg +0 -0
- {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/tests/test_api.py +0 -0
- {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/tests/test_api_smoke.py +0 -0
- {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/tests/test_mock_server.py +0 -0
- {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/violet_poolController_api.egg-info/dependency_links.txt +0 -0
- {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/violet_poolController_api.egg-info/requires.txt +0 -0
- {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/violet_poolController_api.egg-info/top_level.txt +0 -0
- {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/violet_poolcontroller_api/circuit_breaker.py +0 -0
- {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/violet_poolcontroller_api/const_api.py +0 -0
- {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/violet_poolcontroller_api/utils_rate_limiter.py +0 -0
- {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/violet_poolcontroller_api/utils_sanitizer.py +0 -0
|
@@ -14,5 +14,7 @@ violet_poolcontroller_api/api.py
|
|
|
14
14
|
violet_poolcontroller_api/circuit_breaker.py
|
|
15
15
|
violet_poolcontroller_api/const_api.py
|
|
16
16
|
violet_poolcontroller_api/const_devices.py
|
|
17
|
+
violet_poolcontroller_api/parsers.py
|
|
18
|
+
violet_poolcontroller_api/readings.py
|
|
17
19
|
violet_poolcontroller_api/utils_rate_limiter.py
|
|
18
20
|
violet_poolcontroller_api/utils_sanitizer.py
|
|
@@ -16,7 +16,17 @@
|
|
|
16
16
|
|
|
17
17
|
"""Violet Pool Controller API client library."""
|
|
18
18
|
|
|
19
|
-
from .api import
|
|
19
|
+
from .api import (
|
|
20
|
+
SETPOINT_RANGES,
|
|
21
|
+
VioletAuthError,
|
|
22
|
+
VioletPayloadError,
|
|
23
|
+
VioletPoolAPI,
|
|
24
|
+
VioletPoolAPIError,
|
|
25
|
+
VioletSetpointError,
|
|
26
|
+
VioletTimeoutError,
|
|
27
|
+
VioletUnsafeOperationError,
|
|
28
|
+
validate_setpoint,
|
|
29
|
+
)
|
|
20
30
|
from .circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
|
|
21
31
|
from .const_api import ( # noqa: F401
|
|
22
32
|
ACTION_ALLAUTO,
|
|
@@ -39,25 +49,70 @@ from .const_devices import ( # noqa: F401
|
|
|
39
49
|
COVER_STATE_MAP,
|
|
40
50
|
DEVICE_PARAMETERS,
|
|
41
51
|
STATE_TRANSLATIONS,
|
|
52
|
+
CoverState,
|
|
53
|
+
DmxSceneState,
|
|
54
|
+
OnewireState,
|
|
55
|
+
OutputState,
|
|
56
|
+
PvSurplusState,
|
|
57
|
+
RuleState,
|
|
42
58
|
VioletState,
|
|
43
59
|
get_state_translation_language,
|
|
44
60
|
set_state_translation_language,
|
|
45
61
|
)
|
|
62
|
+
from .parsers import ( # noqa: F401
|
|
63
|
+
parse_epoch_milliseconds,
|
|
64
|
+
parse_epoch_seconds,
|
|
65
|
+
parse_hms_string,
|
|
66
|
+
parse_optional_seconds,
|
|
67
|
+
parse_runtime_string,
|
|
68
|
+
parse_uptime_string,
|
|
69
|
+
)
|
|
70
|
+
from .readings import VioletReadings
|
|
46
71
|
from .utils_rate_limiter import RateLimiter, get_global_rate_limiter
|
|
47
72
|
from .utils_sanitizer import InputSanitizer
|
|
48
73
|
|
|
49
74
|
__all__ = [
|
|
75
|
+
# Core client
|
|
50
76
|
"VioletPoolAPI",
|
|
77
|
+
# Exception hierarchy
|
|
51
78
|
"VioletPoolAPIError",
|
|
79
|
+
"VioletAuthError",
|
|
80
|
+
"VioletTimeoutError",
|
|
81
|
+
"VioletPayloadError",
|
|
82
|
+
"VioletSetpointError",
|
|
83
|
+
"VioletUnsafeOperationError",
|
|
84
|
+
# Setpoint validation
|
|
85
|
+
"SETPOINT_RANGES",
|
|
86
|
+
"validate_setpoint",
|
|
87
|
+
# Circuit breaker
|
|
52
88
|
"CircuitBreaker",
|
|
53
89
|
"CircuitBreakerOpenError",
|
|
90
|
+
# Enums
|
|
91
|
+
"OutputState",
|
|
92
|
+
"DmxSceneState",
|
|
93
|
+
"RuleState",
|
|
94
|
+
"CoverState",
|
|
95
|
+
"OnewireState",
|
|
96
|
+
"PvSurplusState",
|
|
97
|
+
# State helpers
|
|
54
98
|
"VioletState",
|
|
99
|
+
"STATE_TRANSLATIONS",
|
|
100
|
+
"get_state_translation_language",
|
|
101
|
+
"set_state_translation_language",
|
|
102
|
+
# Typed readings snapshot
|
|
103
|
+
"VioletReadings",
|
|
104
|
+
# Parsers
|
|
105
|
+
"parse_runtime_string",
|
|
106
|
+
"parse_hms_string",
|
|
107
|
+
"parse_uptime_string",
|
|
108
|
+
"parse_epoch_seconds",
|
|
109
|
+
"parse_epoch_milliseconds",
|
|
110
|
+
"parse_optional_seconds",
|
|
111
|
+
# Utilities
|
|
55
112
|
"InputSanitizer",
|
|
56
113
|
"RateLimiter",
|
|
57
114
|
"get_global_rate_limiter",
|
|
58
|
-
|
|
59
|
-
"set_state_translation_language",
|
|
60
|
-
"STATE_TRANSLATIONS",
|
|
115
|
+
# Action constants
|
|
61
116
|
"ACTION_ALLAUTO",
|
|
62
117
|
"ACTION_ALLOFF",
|
|
63
118
|
"ACTION_ALLON",
|
|
@@ -68,9 +123,11 @@ __all__ = [
|
|
|
68
123
|
"ACTION_ON",
|
|
69
124
|
"ACTION_PUSH",
|
|
70
125
|
"ACTION_UNLOCK",
|
|
126
|
+
# Device constants
|
|
71
127
|
"COVER_FUNCTIONS",
|
|
72
128
|
"COVER_STATE_MAP",
|
|
73
129
|
"DEVICE_PARAMETERS",
|
|
130
|
+
# Error codes
|
|
74
131
|
"ERROR_CODES",
|
|
75
132
|
"ERROR_SEVERITY_ALARM",
|
|
76
133
|
"ERROR_SEVERITY_INFO",
|
|
@@ -21,6 +21,7 @@ from __future__ import annotations
|
|
|
21
21
|
import asyncio
|
|
22
22
|
import json
|
|
23
23
|
import logging
|
|
24
|
+
import math
|
|
24
25
|
import re
|
|
25
26
|
import ssl
|
|
26
27
|
from typing import TYPE_CHECKING, Any, cast
|
|
@@ -68,7 +69,8 @@ from .const_api import (
|
|
|
68
69
|
TARGET_ORP,
|
|
69
70
|
TARGET_PH,
|
|
70
71
|
)
|
|
71
|
-
from .const_devices import DEVICE_PARAMETERS
|
|
72
|
+
from .const_devices import COVER_FUNCTIONS, DEVICE_PARAMETERS
|
|
73
|
+
from .readings import VioletReadings
|
|
72
74
|
from .utils_rate_limiter import get_global_rate_limiter
|
|
73
75
|
from .utils_sanitizer import InputSanitizer
|
|
74
76
|
|
|
@@ -81,11 +83,61 @@ _MAX_HOSTNAME_LENGTH = 253
|
|
|
81
83
|
_HTTP_SERVER_ERROR = 500
|
|
82
84
|
_HTTP_CLIENT_ERROR = 400
|
|
83
85
|
_HTTP_TOO_MANY_REQUESTS = 429
|
|
86
|
+
_HTTP_UNAUTHORIZED = 401
|
|
87
|
+
_HTTP_FORBIDDEN = 403
|
|
84
88
|
_MIN_CALIB_HISTORY_PARTS = 3
|
|
85
89
|
|
|
90
|
+
# Valid setpoint ranges for each configurable target key (inclusive bounds).
|
|
91
|
+
# Values outside these ranges or non-finite values are rejected before the
|
|
92
|
+
# HTTP call to avoid surprising controller behaviour.
|
|
93
|
+
SETPOINT_RANGES: dict[str, tuple[float, float]] = {
|
|
94
|
+
TARGET_PH: (6.0, 8.0),
|
|
95
|
+
TARGET_ORP: (500.0, 900.0),
|
|
96
|
+
TARGET_MIN_CHLORINE: (0.0, 5.0),
|
|
97
|
+
"HEATER_set_temp": (5.0, 45.0),
|
|
98
|
+
"SOLAR_maxtemp": (5.0, 55.0),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# =============================================================================
|
|
103
|
+
# Exception hierarchy
|
|
104
|
+
# =============================================================================
|
|
105
|
+
|
|
86
106
|
|
|
87
107
|
class VioletPoolAPIError(Exception):
|
|
88
|
-
"""
|
|
108
|
+
"""Base exception for all Violet Pool Controller API errors.
|
|
109
|
+
|
|
110
|
+
Callers can catch this base class to handle any API failure, or use
|
|
111
|
+
the specific subclasses for more targeted error handling.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class VioletAuthError(VioletPoolAPIError):
|
|
116
|
+
"""Raised when the controller rejects credentials (HTTP 401 or 403)."""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class VioletTimeoutError(VioletPoolAPIError):
|
|
120
|
+
"""Raised when an HTTP request to the controller exceeds the timeout."""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class VioletPayloadError(VioletPoolAPIError):
|
|
124
|
+
"""Raised when the controller returns a malformed or unparseable response."""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class VioletSetpointError(VioletPoolAPIError, ValueError):
|
|
128
|
+
"""Raised when a setpoint value is outside its documented valid range.
|
|
129
|
+
|
|
130
|
+
Inherits from both ``VioletPoolAPIError`` and ``ValueError`` so callers
|
|
131
|
+
can catch it as either.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class VioletUnsafeOperationError(VioletPoolAPIError):
|
|
136
|
+
"""Raised for potentially dangerous operations without explicit acknowledgment.
|
|
137
|
+
|
|
138
|
+
Pass ``acknowledge_unsafe=True`` to the relevant method to confirm that
|
|
139
|
+
the caller is aware of the risk (e.g. motorised cover movement).
|
|
140
|
+
"""
|
|
89
141
|
|
|
90
142
|
|
|
91
143
|
class _DeterministicClientError(Exception):
|
|
@@ -96,9 +148,43 @@ class _DeterministicClientError(Exception):
|
|
|
96
148
|
circuit breaker failure count: 4xx responses are deterministic
|
|
97
149
|
(bad credentials, unknown endpoint) and must not open the breaker
|
|
98
150
|
that protects against a down controller or network. It is
|
|
99
|
-
translated to
|
|
151
|
+
translated to the appropriate VioletPoolAPIError subclass before
|
|
152
|
+
reaching the caller.
|
|
100
153
|
"""
|
|
101
154
|
|
|
155
|
+
def __init__(self, msg: str, *, is_auth: bool = False) -> None:
|
|
156
|
+
super().__init__(msg)
|
|
157
|
+
self.is_auth = is_auth
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def validate_setpoint(field: str, value: float) -> None:
|
|
161
|
+
"""Validate a setpoint value against documented controller ranges.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
field: The configuration key (e.g. ``"DOSAGE_phminus_setpoint"``).
|
|
165
|
+
value: The numeric value to validate.
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
VioletSetpointError: If ``value`` is non-finite or outside the
|
|
169
|
+
documented valid range for ``field``. Fields with no registered
|
|
170
|
+
range are accepted without range checking.
|
|
171
|
+
"""
|
|
172
|
+
if not math.isfinite(value):
|
|
173
|
+
msg = f"Invalid setpoint for '{field}': {value!r} is not a finite number"
|
|
174
|
+
raise VioletSetpointError(msg)
|
|
175
|
+
|
|
176
|
+
bounds = SETPOINT_RANGES.get(field)
|
|
177
|
+
if bounds is None:
|
|
178
|
+
return # No documented range — accept any finite value
|
|
179
|
+
|
|
180
|
+
lo, hi = bounds
|
|
181
|
+
if not lo <= value <= hi:
|
|
182
|
+
msg = (
|
|
183
|
+
f"Setpoint '{field}' value {value} is outside the valid range "
|
|
184
|
+
f"[{lo}, {hi}]"
|
|
185
|
+
)
|
|
186
|
+
raise VioletSetpointError(msg)
|
|
187
|
+
|
|
102
188
|
|
|
103
189
|
class VioletPoolAPI:
|
|
104
190
|
"""A small HTTP client for interacting with the Violet Pool Controller.
|
|
@@ -339,7 +425,11 @@ class VioletPoolAPI:
|
|
|
339
425
|
# deterministic - fail fast instead of retrying.
|
|
340
426
|
body = await response.text()
|
|
341
427
|
msg = f"HTTP {response.status} for {endpoint}: {body.strip()}"
|
|
342
|
-
|
|
428
|
+
is_auth = response.status in (
|
|
429
|
+
_HTTP_UNAUTHORIZED,
|
|
430
|
+
_HTTP_FORBIDDEN,
|
|
431
|
+
)
|
|
432
|
+
raise _DeterministicClientError(msg, is_auth=is_auth)
|
|
343
433
|
|
|
344
434
|
if expect_json:
|
|
345
435
|
try:
|
|
@@ -350,13 +440,25 @@ class VioletPoolAPI:
|
|
|
350
440
|
) as err:
|
|
351
441
|
body = await response.text()
|
|
352
442
|
msg = f"Invalid JSON payload for {endpoint}: {body.strip()}"
|
|
353
|
-
raise
|
|
354
|
-
msg,
|
|
355
|
-
) from err
|
|
443
|
+
raise VioletPayloadError(msg) from err
|
|
356
444
|
|
|
357
445
|
return await response.text()
|
|
358
446
|
|
|
359
|
-
except (TimeoutError, aiohttp.
|
|
447
|
+
except (TimeoutError, aiohttp.ServerTimeoutError) as err:
|
|
448
|
+
last_error = VioletTimeoutError(
|
|
449
|
+
f"Request to {endpoint} timed out: {err}",
|
|
450
|
+
)
|
|
451
|
+
_LOGGER.debug(
|
|
452
|
+
"Attempt %d for %s timed out: %s",
|
|
453
|
+
attempt,
|
|
454
|
+
endpoint,
|
|
455
|
+
err,
|
|
456
|
+
)
|
|
457
|
+
if attempt == self._max_retries:
|
|
458
|
+
raise last_error from None
|
|
459
|
+
delay = min(2.0, 0.2 * (2 ** (attempt - 1)))
|
|
460
|
+
await asyncio.sleep(delay)
|
|
461
|
+
except aiohttp.ClientError as err:
|
|
360
462
|
last_error = VioletPoolAPIError(
|
|
361
463
|
f"Error communicating with Violet controller: {err}",
|
|
362
464
|
)
|
|
@@ -378,6 +480,8 @@ class VioletPoolAPI:
|
|
|
378
480
|
try:
|
|
379
481
|
return await self._circuit_breaker.call(_execute_request)
|
|
380
482
|
except _DeterministicClientError as err:
|
|
483
|
+
if err.is_auth:
|
|
484
|
+
raise VioletAuthError(str(err)) from err
|
|
381
485
|
raise VioletPoolAPIError(str(err)) from err
|
|
382
486
|
except CircuitBreakerOpenError as err:
|
|
383
487
|
msg = "Circuit breaker is open due to repeated communication failures"
|
|
@@ -614,11 +718,17 @@ class VioletPoolAPI:
|
|
|
614
718
|
readings = {k: v for k, v in readings.items() if not k.startswith("EXT2")}
|
|
615
719
|
return readings
|
|
616
720
|
|
|
617
|
-
async def get_readings(self) ->
|
|
618
|
-
"""Return the complete dataset from the controller.
|
|
721
|
+
async def get_readings(self) -> VioletReadings:
|
|
722
|
+
"""Return the complete dataset from the controller as a typed snapshot.
|
|
723
|
+
|
|
724
|
+
The returned :class:`~violet_poolcontroller_api.readings.VioletReadings`
|
|
725
|
+
object implements :class:`~collections.abc.Mapping`, so all existing
|
|
726
|
+
code that accesses ``data.get("KEY")`` or ``"KEY" in data`` continues
|
|
727
|
+
to work unchanged. Typed properties (``readings.pump``,
|
|
728
|
+
``readings.ph``, etc.) are available as an additive convenience.
|
|
619
729
|
|
|
620
730
|
Returns:
|
|
621
|
-
A
|
|
731
|
+
A :class:`VioletReadings` instance wrapping all readings.
|
|
622
732
|
|
|
623
733
|
Raises:
|
|
624
734
|
VioletPoolAPIError: If the payload is unexpected.
|
|
@@ -629,7 +739,8 @@ class VioletPoolAPI:
|
|
|
629
739
|
query="ALL",
|
|
630
740
|
payload_name="getReadings",
|
|
631
741
|
)
|
|
632
|
-
|
|
742
|
+
flat = self._flatten_getreadings_response(response)
|
|
743
|
+
return VioletReadings(flat)
|
|
633
744
|
|
|
634
745
|
async def get_hardware_profile(self) -> dict[str, bool]:
|
|
635
746
|
"""Detect connected hardware modules from the controller readings.
|
|
@@ -647,28 +758,29 @@ class VioletPoolAPI:
|
|
|
647
758
|
query="ALL",
|
|
648
759
|
payload_name="getReadings",
|
|
649
760
|
)
|
|
650
|
-
|
|
761
|
+
# Use flat dict directly (VioletReadings wrapping not needed here)
|
|
762
|
+
flat = self._flatten_getreadings_response(response)
|
|
651
763
|
|
|
652
|
-
has_base = not self._dosing_standalone and bool(
|
|
764
|
+
has_base = not self._dosing_standalone and bool(flat)
|
|
653
765
|
return {
|
|
654
766
|
"base_module": has_base,
|
|
655
767
|
"dosing_module": self._dosing_standalone
|
|
656
|
-
or "SYSTEM_dosagemodule_alive_count" in
|
|
657
|
-
"extension_module_1": "SYSTEM_ext1module_alive_count" in
|
|
658
|
-
"extension_module_2": "SYSTEM_ext2module_alive_count" in
|
|
768
|
+
or "SYSTEM_dosagemodule_alive_count" in flat,
|
|
769
|
+
"extension_module_1": "SYSTEM_ext1module_alive_count" in flat,
|
|
770
|
+
"extension_module_2": "SYSTEM_ext2module_alive_count" in flat,
|
|
659
771
|
}
|
|
660
772
|
|
|
661
773
|
async def get_specific_readings(
|
|
662
774
|
self,
|
|
663
775
|
categories: list[str] | tuple[str, ...],
|
|
664
|
-
) ->
|
|
665
|
-
"""Return a reduced
|
|
776
|
+
) -> VioletReadings:
|
|
777
|
+
"""Return a reduced typed snapshot for the provided categories.
|
|
666
778
|
|
|
667
779
|
Args:
|
|
668
780
|
categories: A list or tuple of category strings to fetch.
|
|
669
781
|
|
|
670
782
|
Returns:
|
|
671
|
-
A
|
|
783
|
+
A :class:`VioletReadings` instance for the requested categories.
|
|
672
784
|
|
|
673
785
|
Raises:
|
|
674
786
|
VioletPoolAPIError: If no categories are provided
|
|
@@ -685,7 +797,7 @@ class VioletPoolAPI:
|
|
|
685
797
|
query=query,
|
|
686
798
|
payload_name="getReadings",
|
|
687
799
|
)
|
|
688
|
-
return self._flatten_getreadings_response(response)
|
|
800
|
+
return VioletReadings(self._flatten_getreadings_response(response))
|
|
689
801
|
|
|
690
802
|
async def get_history(
|
|
691
803
|
self,
|
|
@@ -1164,6 +1276,45 @@ class VioletPoolAPI:
|
|
|
1164
1276
|
|
|
1165
1277
|
return await self.set_switch_state("DMX_SCENE1", action)
|
|
1166
1278
|
|
|
1279
|
+
async def set_cover_command(
|
|
1280
|
+
self,
|
|
1281
|
+
action: str,
|
|
1282
|
+
*,
|
|
1283
|
+
acknowledge_unsafe: bool = False,
|
|
1284
|
+
) -> dict[str, Any]:
|
|
1285
|
+
"""Send an open, close, or stop command to the pool cover.
|
|
1286
|
+
|
|
1287
|
+
Cover movement is a potentially hazardous operation (motorised cover,
|
|
1288
|
+
risk of entrapment). Callers must explicitly pass
|
|
1289
|
+
``acknowledge_unsafe=True`` to confirm they are aware of the risk and
|
|
1290
|
+
have taken appropriate safety precautions.
|
|
1291
|
+
|
|
1292
|
+
Args:
|
|
1293
|
+
action: ``"OPEN"``, ``"CLOSE"``, or ``"STOP"`` (case-insensitive).
|
|
1294
|
+
acknowledge_unsafe: Must be ``True`` to allow the command.
|
|
1295
|
+
|
|
1296
|
+
Returns:
|
|
1297
|
+
A dictionary with the command result.
|
|
1298
|
+
|
|
1299
|
+
Raises:
|
|
1300
|
+
VioletUnsafeOperationError: If ``acknowledge_unsafe`` is ``False``.
|
|
1301
|
+
VioletPoolAPIError: If ``action`` is not a known cover action.
|
|
1302
|
+
|
|
1303
|
+
"""
|
|
1304
|
+
if not acknowledge_unsafe:
|
|
1305
|
+
msg = (
|
|
1306
|
+
"Cover movement is a potentially unsafe operation. "
|
|
1307
|
+
"Pass acknowledge_unsafe=True to confirm you are aware of the risk."
|
|
1308
|
+
)
|
|
1309
|
+
raise VioletUnsafeOperationError(msg)
|
|
1310
|
+
|
|
1311
|
+
cover_key = COVER_FUNCTIONS.get(action.strip().upper())
|
|
1312
|
+
if not cover_key:
|
|
1313
|
+
msg = f"Unknown cover action '{action}'. Valid: {list(COVER_FUNCTIONS)}"
|
|
1314
|
+
raise VioletPoolAPIError(msg)
|
|
1315
|
+
|
|
1316
|
+
return await self.set_switch_state(cover_key, ACTION_PUSH)
|
|
1317
|
+
|
|
1167
1318
|
async def set_light_color_pulse(self) -> dict[str, Any]:
|
|
1168
1319
|
"""Trigger the color pulse animation for the pool light.
|
|
1169
1320
|
|
|
@@ -1215,11 +1366,15 @@ class VioletPoolAPI:
|
|
|
1215
1366
|
|
|
1216
1367
|
Args:
|
|
1217
1368
|
climate_key: The climate key (HEATER or SOLAR).
|
|
1218
|
-
temperature: The target temperature.
|
|
1369
|
+
temperature: The target temperature in °C.
|
|
1219
1370
|
|
|
1220
1371
|
Returns:
|
|
1221
1372
|
A dictionary with the command result.
|
|
1222
1373
|
|
|
1374
|
+
Raises:
|
|
1375
|
+
VioletSetpointError: If ``temperature`` is outside the valid range
|
|
1376
|
+
(5–45 °C for heater, 5–55 °C for solar).
|
|
1377
|
+
|
|
1223
1378
|
"""
|
|
1224
1379
|
config_key = (
|
|
1225
1380
|
"SOLAR_maxtemp" if climate_key.upper() == "SOLAR" else f"{climate_key}_set_temp"
|
|
@@ -1230,41 +1385,60 @@ class VioletPoolAPI:
|
|
|
1230
1385
|
"""Update the pH setpoint.
|
|
1231
1386
|
|
|
1232
1387
|
Args:
|
|
1233
|
-
value: The new pH target value.
|
|
1388
|
+
value: The new pH target value (valid range: 6.0–8.0).
|
|
1234
1389
|
|
|
1235
1390
|
Returns:
|
|
1236
1391
|
A dictionary with the command result.
|
|
1237
1392
|
|
|
1393
|
+
Raises:
|
|
1394
|
+
VioletSetpointError: If ``value`` is outside the valid range or
|
|
1395
|
+
is not a finite number.
|
|
1396
|
+
|
|
1238
1397
|
"""
|
|
1398
|
+
validate_setpoint(TARGET_PH, float(value))
|
|
1239
1399
|
return await self.set_target_value(TARGET_PH, float(value))
|
|
1240
1400
|
|
|
1241
1401
|
async def set_orp_target(self, value: int) -> dict[str, Any]:
|
|
1242
1402
|
"""Update the ORP setpoint.
|
|
1243
1403
|
|
|
1244
1404
|
Args:
|
|
1245
|
-
value: The new ORP target value.
|
|
1405
|
+
value: The new ORP target value in mV (valid range: 500–900).
|
|
1246
1406
|
|
|
1247
1407
|
Returns:
|
|
1248
1408
|
A dictionary with the command result.
|
|
1249
1409
|
|
|
1410
|
+
Raises:
|
|
1411
|
+
VioletSetpointError: If ``value`` is outside the valid range or
|
|
1412
|
+
is not a finite number.
|
|
1413
|
+
|
|
1250
1414
|
"""
|
|
1415
|
+
validate_setpoint(TARGET_ORP, float(value))
|
|
1251
1416
|
return await self.set_target_value(TARGET_ORP, int(value))
|
|
1252
1417
|
|
|
1253
1418
|
async def set_min_chlorine_level(self, value: float) -> dict[str, Any]:
|
|
1254
1419
|
"""Update the minimum chlorine level.
|
|
1255
1420
|
|
|
1256
1421
|
Args:
|
|
1257
|
-
value: The new minimum chlorine level.
|
|
1422
|
+
value: The new minimum chlorine level in mg/L (valid range: 0.0–5.0).
|
|
1258
1423
|
|
|
1259
1424
|
Returns:
|
|
1260
1425
|
A dictionary with the command result.
|
|
1261
1426
|
|
|
1427
|
+
Raises:
|
|
1428
|
+
VioletSetpointError: If ``value`` is outside the valid range or
|
|
1429
|
+
is not a finite number.
|
|
1430
|
+
|
|
1262
1431
|
"""
|
|
1432
|
+
validate_setpoint(TARGET_MIN_CHLORINE, float(value))
|
|
1263
1433
|
return await self.set_target_value(TARGET_MIN_CHLORINE, float(value))
|
|
1264
1434
|
|
|
1265
1435
|
async def set_target_value(self, key: str, value: float) -> dict[str, Any]:
|
|
1266
1436
|
"""Send a generic target value update to the controller.
|
|
1267
1437
|
|
|
1438
|
+
For known setpoint keys (see ``SETPOINT_RANGES``), validation is
|
|
1439
|
+
performed automatically. Call ``validate_setpoint()`` directly for
|
|
1440
|
+
keys not covered by the convenience methods.
|
|
1441
|
+
|
|
1268
1442
|
Args:
|
|
1269
1443
|
key: The target key.
|
|
1270
1444
|
value: The new value.
|
|
@@ -1272,7 +1446,12 @@ class VioletPoolAPI:
|
|
|
1272
1446
|
Returns:
|
|
1273
1447
|
A dictionary with the command result.
|
|
1274
1448
|
|
|
1449
|
+
Raises:
|
|
1450
|
+
VioletSetpointError: If ``value`` is non-finite or outside a
|
|
1451
|
+
known valid range for ``key``.
|
|
1452
|
+
|
|
1275
1453
|
"""
|
|
1454
|
+
validate_setpoint(key, float(value))
|
|
1276
1455
|
return await self.set_config({key: value})
|
|
1277
1456
|
|
|
1278
1457
|
async def set_dosing_parameters(
|
|
@@ -26,8 +26,109 @@ throughout the integration.
|
|
|
26
26
|
|
|
27
27
|
from __future__ import annotations
|
|
28
28
|
|
|
29
|
+
from enum import IntEnum, StrEnum
|
|
29
30
|
from typing import Any, cast
|
|
30
31
|
|
|
32
|
+
# =============================================================================
|
|
33
|
+
# TYPED ENUMERATIONS
|
|
34
|
+
# =============================================================================
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OutputState(IntEnum):
|
|
38
|
+
"""Output state codes returned by getReadings (manual section 26.1).
|
|
39
|
+
|
|
40
|
+
These codes apply to ~30 outputs: pump, heater, solar, light, dosing
|
|
41
|
+
channels, extension relays, etc. Use the ``is_on``, ``is_manual``, and
|
|
42
|
+
``is_emergency`` properties instead of comparing raw integers.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
AUTO_OFF = 0
|
|
46
|
+
AUTO_ON = 1
|
|
47
|
+
AUTO_PRIO_OFF = 2
|
|
48
|
+
AUTO_PRIO_ON = 3
|
|
49
|
+
MANUAL_ON = 4
|
|
50
|
+
EMERGENCY_OFF = 5
|
|
51
|
+
MANUAL_OFF = 6
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def is_on(self) -> bool:
|
|
55
|
+
"""Return True when the output is currently active."""
|
|
56
|
+
return self in (OutputState.AUTO_ON, OutputState.AUTO_PRIO_ON, OutputState.MANUAL_ON)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def is_manual(self) -> bool:
|
|
60
|
+
"""Return True when the output is in manual (non-auto) mode."""
|
|
61
|
+
return self in (OutputState.MANUAL_ON, OutputState.MANUAL_OFF)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def is_emergency(self) -> bool:
|
|
65
|
+
"""Return True when an emergency rule is responsible for the state."""
|
|
66
|
+
return self in (OutputState.AUTO_PRIO_ON, OutputState.EMERGENCY_OFF)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class DmxSceneState(IntEnum):
|
|
70
|
+
"""Output state codes for DMX scenes (subset of OutputState values)."""
|
|
71
|
+
|
|
72
|
+
AUTO_OFF = 0
|
|
73
|
+
AUTO_ON = 1
|
|
74
|
+
MANUAL_ON = 4
|
|
75
|
+
MANUAL_OFF = 6
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def is_on(self) -> bool:
|
|
79
|
+
"""Return True when the DMX scene is active."""
|
|
80
|
+
return self in (DmxSceneState.AUTO_ON, DmxSceneState.MANUAL_ON)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class RuleState(IntEnum):
|
|
84
|
+
"""State codes for digital-input switching rules (DIRULE_*)."""
|
|
85
|
+
|
|
86
|
+
INACTIVE = 0
|
|
87
|
+
ACTIVE = 1
|
|
88
|
+
BLOCKED_BY_RULE = 5
|
|
89
|
+
BLOCKED_MANUALLY = 6
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class CoverState(StrEnum):
|
|
93
|
+
"""Pool cover motion states returned by the COVER_STATE reading."""
|
|
94
|
+
|
|
95
|
+
OPEN = "OPEN"
|
|
96
|
+
CLOSED = "CLOSED"
|
|
97
|
+
OPENING = "OPENING"
|
|
98
|
+
CLOSING = "CLOSING"
|
|
99
|
+
STOPPED = "STOPPED"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class OnewireState(StrEnum):
|
|
103
|
+
"""1-wire temperature sensor status values (OW*_state readings).
|
|
104
|
+
|
|
105
|
+
Note: The controller uses ``DATA_MISSMATCH`` (double-s) — preserved here
|
|
106
|
+
for exact string matching against the API response.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
OK = "OK"
|
|
110
|
+
CRC_FAULT = "CRC_FAULT"
|
|
111
|
+
DATA_MISMATCH = "DATA_MISSMATCH"
|
|
112
|
+
NOT_CONNECTED = "NOT_CONNECTED"
|
|
113
|
+
NO_SENSOR_CONFIGURED = "NO_SENSOR_CONFIGURED"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class PvSurplusState(IntEnum):
|
|
117
|
+
"""PV surplus trigger source states returned by the PVSURPLUS reading.
|
|
118
|
+
|
|
119
|
+
Unlike other outputs, PVSURPLUS uses values 0/1/2 instead of the
|
|
120
|
+
standard 0-6 scheme (manual section 26.3).
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
OFF = 0
|
|
124
|
+
ON_BY_INPUT = 1
|
|
125
|
+
ON_BY_HTTP = 2
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def is_on(self) -> bool:
|
|
129
|
+
"""Return True when PV surplus mode is active (regardless of source)."""
|
|
130
|
+
return self in (PvSurplusState.ON_BY_INPUT, PvSurplusState.ON_BY_HTTP)
|
|
131
|
+
|
|
31
132
|
# =============================================================================
|
|
32
133
|
# COVER CONTROL FUNCTIONS
|
|
33
134
|
# =============================================================================
|