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.
Files changed (22) hide show
  1. {violet_poolcontroller_api-0.0.27/violet_poolController_api.egg-info → violet_poolcontroller_api-0.0.29}/PKG-INFO +1 -1
  2. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/pyproject.toml +1 -1
  3. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29/violet_poolController_api.egg-info}/PKG-INFO +1 -1
  4. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/violet_poolController_api.egg-info/SOURCES.txt +2 -0
  5. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/violet_poolcontroller_api/__init__.py +61 -4
  6. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/violet_poolcontroller_api/api.py +204 -25
  7. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/violet_poolcontroller_api/const_devices.py +101 -0
  8. violet_poolcontroller_api-0.0.29/violet_poolcontroller_api/parsers.py +185 -0
  9. violet_poolcontroller_api-0.0.29/violet_poolcontroller_api/readings.py +515 -0
  10. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/LICENSE +0 -0
  11. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/README.md +0 -0
  12. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/setup.cfg +0 -0
  13. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/tests/test_api.py +0 -0
  14. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/tests/test_api_smoke.py +0 -0
  15. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/tests/test_mock_server.py +0 -0
  16. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/violet_poolController_api.egg-info/dependency_links.txt +0 -0
  17. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/violet_poolController_api.egg-info/requires.txt +0 -0
  18. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/violet_poolController_api.egg-info/top_level.txt +0 -0
  19. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/violet_poolcontroller_api/circuit_breaker.py +0 -0
  20. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/violet_poolcontroller_api/const_api.py +0 -0
  21. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/violet_poolcontroller_api/utils_rate_limiter.py +0 -0
  22. {violet_poolcontroller_api-0.0.27 → violet_poolcontroller_api-0.0.29}/violet_poolcontroller_api/utils_sanitizer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: violet-poolController-api
3
- Version: 0.0.27
3
+ Version: 0.0.29
4
4
  Summary: Asynchronous Python client for the Violet Pool Controller.
5
5
  Author-email: "Basti (Xerolux)" <git@xerolux.de>
6
6
  License: AGPL-3.0-or-later
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "violet-poolController-api"
7
- version = "0.0.27"
7
+ version = "0.0.29"
8
8
  authors = [
9
9
  { name="Basti (Xerolux)", email="git@xerolux.de" },
10
10
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: violet-poolController-api
3
- Version: 0.0.27
3
+ Version: 0.0.29
4
4
  Summary: Asynchronous Python client for the Violet Pool Controller.
5
5
  Author-email: "Basti (Xerolux)" <git@xerolux.de>
6
6
  License: AGPL-3.0-or-later
@@ -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 VioletPoolAPI, VioletPoolAPIError
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
- "get_state_translation_language",
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
- """Raised when the Violet Pool Controller API returns an error."""
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 VioletPoolAPIError before reaching the caller.
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
- raise _DeterministicClientError(msg)
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 VioletPoolAPIError(
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.ClientError) as err:
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) -> dict[str, Any]:
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 dictionary containing all readings.
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
- return self._flatten_getreadings_response(response)
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
- readings = self._flatten_getreadings_response(response)
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(readings)
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 readings,
657
- "extension_module_1": "SYSTEM_ext1module_alive_count" in readings,
658
- "extension_module_2": "SYSTEM_ext2module_alive_count" in readings,
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
- ) -> dict[str, Any]:
665
- """Return a reduced dataset for the provided categories.
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 dictionary containing the requested readings.
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
  # =============================================================================