violet-poolController-api 0.0.9__tar.gz → 0.0.11__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 (19) hide show
  1. {violet_poolcontroller_api-0.0.9 → violet_poolcontroller_api-0.0.11}/PKG-INFO +17 -1
  2. {violet_poolcontroller_api-0.0.9 → violet_poolcontroller_api-0.0.11}/README.md +16 -0
  3. {violet_poolcontroller_api-0.0.9 → violet_poolcontroller_api-0.0.11}/pyproject.toml +1 -1
  4. {violet_poolcontroller_api-0.0.9 → violet_poolcontroller_api-0.0.11}/setup.py +35 -35
  5. {violet_poolcontroller_api-0.0.9 → violet_poolcontroller_api-0.0.11}/tests/test_api.py +61 -0
  6. {violet_poolcontroller_api-0.0.9 → violet_poolcontroller_api-0.0.11}/violet_poolController_api.egg-info/PKG-INFO +17 -1
  7. {violet_poolcontroller_api-0.0.9 → violet_poolcontroller_api-0.0.11}/violet_poolcontroller_api/__init__.py +4 -0
  8. {violet_poolcontroller_api-0.0.9 → violet_poolcontroller_api-0.0.11}/violet_poolcontroller_api/api.py +82 -5
  9. {violet_poolcontroller_api-0.0.9 → violet_poolcontroller_api-0.0.11}/violet_poolcontroller_api/circuit_breaker.py +8 -7
  10. {violet_poolcontroller_api-0.0.9 → violet_poolcontroller_api-0.0.11}/violet_poolcontroller_api/const_api.py +3 -1
  11. {violet_poolcontroller_api-0.0.9 → violet_poolcontroller_api-0.0.11}/violet_poolcontroller_api/utils_rate_limiter.py +12 -9
  12. {violet_poolcontroller_api-0.0.9 → violet_poolcontroller_api-0.0.11}/LICENSE +0 -0
  13. {violet_poolcontroller_api-0.0.9 → violet_poolcontroller_api-0.0.11}/setup.cfg +0 -0
  14. {violet_poolcontroller_api-0.0.9 → violet_poolcontroller_api-0.0.11}/violet_poolController_api.egg-info/SOURCES.txt +0 -0
  15. {violet_poolcontroller_api-0.0.9 → violet_poolcontroller_api-0.0.11}/violet_poolController_api.egg-info/dependency_links.txt +0 -0
  16. {violet_poolcontroller_api-0.0.9 → violet_poolcontroller_api-0.0.11}/violet_poolController_api.egg-info/requires.txt +0 -0
  17. {violet_poolcontroller_api-0.0.9 → violet_poolcontroller_api-0.0.11}/violet_poolController_api.egg-info/top_level.txt +0 -0
  18. {violet_poolcontroller_api-0.0.9 → violet_poolcontroller_api-0.0.11}/violet_poolcontroller_api/const_devices.py +0 -0
  19. {violet_poolcontroller_api-0.0.9 → violet_poolcontroller_api-0.0.11}/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.9
3
+ Version: 0.0.11
4
4
  Summary: Asynchronous Python client for the Violet Pool Controller.
5
5
  Home-page: https://github.com/Xerolux/violet-poolController-api
6
6
  Author: Basti (Xerolux)
@@ -136,6 +136,22 @@ In this mode, dosing functions (for example `manual_dosing` and dosing parameter
136
136
  **Note on getReadings format:**
137
137
  As of version `0.0.7`, the API client automatically detects and normalizes the payload output from the controller. Whether your Violet Controller returns the classic base-module `dict` structure (`{"PUMPSTATE": "2", "PH": 7.2}`) or the new standalone `list` structure, the `get_readings()` and `get_specific_readings()` functions will always return a seamless, flattened key-value dictionary. Your Home Assistant integration or downstream application will work uniformly with both formats without requiring any extra code!
138
138
 
139
+ **Hardware Profile Detection:**
140
+ As of the latest release, the API client provides a method to detect the specific hardware configuration of your Violet Controller.
141
+ The API automatically detects the connected modules and updates internal states based on the available readings.
142
+ ```python
143
+ profile = await api.get_hardware_profile()
144
+ print(profile)
145
+ # Output example:
146
+ # {
147
+ # "base_module": True,
148
+ # "dosing_module": True,
149
+ # "extension_module_1": True,
150
+ # "extension_module_2": False,
151
+ # }
152
+ ```
153
+ This detection parses `get_readings()` to check for the presence of certain internal status parameters (`SYSTEM_dosagemodule_cpu_temperature`, `EXT1_1`, `EXT2_1`), allowing your application to dynamically adapt to the connected modules (Base Module, Dosing Module, Relay Extension 1 and 2). By utilizing this detection, developers and integrations can accurately filter out features for missing hardware, ensuring that only supported options are exposed to the user.
154
+
139
155
  ## License
140
156
  GNU Affero General Public License v3.0 or later (AGPLv3+)
141
157
 
@@ -108,6 +108,22 @@ In this mode, dosing functions (for example `manual_dosing` and dosing parameter
108
108
  **Note on getReadings format:**
109
109
  As of version `0.0.7`, the API client automatically detects and normalizes the payload output from the controller. Whether your Violet Controller returns the classic base-module `dict` structure (`{"PUMPSTATE": "2", "PH": 7.2}`) or the new standalone `list` structure, the `get_readings()` and `get_specific_readings()` functions will always return a seamless, flattened key-value dictionary. Your Home Assistant integration or downstream application will work uniformly with both formats without requiring any extra code!
110
110
 
111
+ **Hardware Profile Detection:**
112
+ As of the latest release, the API client provides a method to detect the specific hardware configuration of your Violet Controller.
113
+ The API automatically detects the connected modules and updates internal states based on the available readings.
114
+ ```python
115
+ profile = await api.get_hardware_profile()
116
+ print(profile)
117
+ # Output example:
118
+ # {
119
+ # "base_module": True,
120
+ # "dosing_module": True,
121
+ # "extension_module_1": True,
122
+ # "extension_module_2": False,
123
+ # }
124
+ ```
125
+ This detection parses `get_readings()` to check for the presence of certain internal status parameters (`SYSTEM_dosagemodule_cpu_temperature`, `EXT1_1`, `EXT2_1`), allowing your application to dynamically adapt to the connected modules (Base Module, Dosing Module, Relay Extension 1 and 2). By utilizing this detection, developers and integrations can accurately filter out features for missing hardware, ensuring that only supported options are exposed to the user.
126
+
111
127
  ## License
112
128
  GNU Affero General Public License v3.0 or later (AGPLv3+)
113
129
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "violet-poolController-api"
7
- version = "0.0.9"
7
+ version = "0.0.11"
8
8
  authors = [
9
9
  { name="Basti (Xerolux)", email="git@xerolux.de" },
10
10
  ]
@@ -1,35 +1,35 @@
1
- from setuptools import setup, find_packages
2
- setup(
3
- name="violet-poolController-api",
4
- version="0.0.9",
5
- author="Basti (Xerolux)",
6
- author_email="git@xerolux.de",
7
- description="Asynchronous Python client for the Violet Pool Controller.",
8
- long_description=open("README.md").read(),
9
- long_description_content_type="text/markdown",
10
- url="https://github.com/Xerolux/violet-poolController-api",
11
- packages=find_packages(),
12
- license="AGPL-3.0-or-later",
13
- classifiers=[
14
- "Programming Language :: Python :: 3",
15
- "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
16
- "Operating System :: OS Independent",
17
- "Intended Audience :: Developers",
18
- "Topic :: Home Automation",
19
- ],
20
- python_requires=">=3.12",
21
- install_requires=[
22
- "aiohttp>=3.9.0",
23
- ],
24
- extras_require={
25
- "test": [
26
- "aioresponses>=0.7.6",
27
- "pytest>=8.0",
28
- "pytest-asyncio>=0.23",
29
- ],
30
- },
31
- project_urls={
32
- "Bug Tracker": "https://github.com/Xerolux/violet-poolController-api/issues",
33
- "License": "https://www.gnu.org/licenses/agpl-3.0.html",
34
- },
35
- )
1
+ from setuptools import setup, find_packages
2
+ setup(
3
+ name="violet-poolController-api",
4
+ version="0.0.11",
5
+ author="Basti (Xerolux)",
6
+ author_email="git@xerolux.de",
7
+ description="Asynchronous Python client for the Violet Pool Controller.",
8
+ long_description=open("README.md").read(),
9
+ long_description_content_type="text/markdown",
10
+ url="https://github.com/Xerolux/violet-poolController-api",
11
+ packages=find_packages(),
12
+ license="AGPL-3.0-or-later",
13
+ classifiers=[
14
+ "Programming Language :: Python :: 3",
15
+ "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
16
+ "Operating System :: OS Independent",
17
+ "Intended Audience :: Developers",
18
+ "Topic :: Home Automation",
19
+ ],
20
+ python_requires=">=3.12",
21
+ install_requires=[
22
+ "aiohttp>=3.9.0",
23
+ ],
24
+ extras_require={
25
+ "test": [
26
+ "aioresponses>=0.7.6",
27
+ "pytest>=8.0",
28
+ "pytest-asyncio>=0.23",
29
+ ],
30
+ },
31
+ project_urls={
32
+ "Bug Tracker": "https://github.com/Xerolux/violet-poolController-api/issues",
33
+ "License": "https://www.gnu.org/licenses/agpl-3.0.html",
34
+ },
35
+ )
@@ -254,3 +254,64 @@ async def test_dosing_standalone_detection_dict_format(mock_aioresponse, standal
254
254
 
255
255
  assert isinstance(result, dict)
256
256
  assert standalone_api_client.dosing_standalone is False
257
+
258
+ @pytest.mark.asyncio
259
+ async def test_get_hardware_profile(mock_aioresponse, api_client):
260
+ """Test get_hardware_profile correctly detects components."""
261
+ url = "http://192.168.1.100/getReadings?ALL"
262
+
263
+ # 1. Base module only (no DOS, EXT)
264
+ mock_aioresponse.get(url, payload={"getReadings": {"PUMPSTATE": "2", "SYSTEM_dosagemodule_cpu_temperature": "N/A"}}, status=200)
265
+ profile = await api_client.get_hardware_profile()
266
+ assert profile == {
267
+ "base_module": True,
268
+ "dosing_module": False,
269
+ "extension_module_1": False,
270
+ "extension_module_2": False,
271
+ }
272
+
273
+ # 2. Base module + Dosing + Ext1
274
+ mock_aioresponse.get(url, payload={"getReadings": {"PUMPSTATE": "2", "SYSTEM_dosagemodule_cpu_temperature": 45.5, "EXT1_1": "1"}}, status=200)
275
+ profile = await api_client.get_hardware_profile()
276
+ assert profile == {
277
+ "base_module": True,
278
+ "dosing_module": True,
279
+ "extension_module_1": True,
280
+ "extension_module_2": False,
281
+ }
282
+
283
+ # 3. Base module + Ext1 + Ext2 (no Dosing)
284
+ mock_aioresponse.get(url, payload={"getReadings": {"PUMPSTATE": "2", "EXT1_1": "1", "EXT2_1": "1"}}, status=200)
285
+ profile = await api_client.get_hardware_profile()
286
+ assert profile == {
287
+ "base_module": True,
288
+ "dosing_module": False,
289
+ "extension_module_1": True,
290
+ "extension_module_2": True,
291
+ }
292
+
293
+ @pytest.mark.asyncio
294
+ async def test_get_hardware_profile_standalone_dosing(mock_aioresponse, standalone_api_client):
295
+ """Test get_hardware_profile with a standalone dosing configuration."""
296
+ url = "http://192.168.1.100/getReadings?ALL"
297
+ # Using the standalone list format
298
+ mock_data = {
299
+ "getReadings": [
300
+ {
301
+ "VALUE NAME": " \"DOS_1_CL\"",
302
+ "DESCRIPTION": "Current state of OUTPUT: CL-DOSING",
303
+ "FORMAT": "INTEGER",
304
+ "DETAILS": "0 - AUTO (not on)",
305
+ "VALUE": 2
306
+ }
307
+ ]
308
+ }
309
+ mock_aioresponse.get(url, payload=mock_data, status=200)
310
+
311
+ profile = await standalone_api_client.get_hardware_profile()
312
+ assert profile == {
313
+ "base_module": False,
314
+ "dosing_module": True,
315
+ "extension_module_1": False,
316
+ "extension_module_2": False,
317
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: violet-poolController-api
3
- Version: 0.0.9
3
+ Version: 0.0.11
4
4
  Summary: Asynchronous Python client for the Violet Pool Controller.
5
5
  Home-page: https://github.com/Xerolux/violet-poolController-api
6
6
  Author: Basti (Xerolux)
@@ -136,6 +136,22 @@ In this mode, dosing functions (for example `manual_dosing` and dosing parameter
136
136
  **Note on getReadings format:**
137
137
  As of version `0.0.7`, the API client automatically detects and normalizes the payload output from the controller. Whether your Violet Controller returns the classic base-module `dict` structure (`{"PUMPSTATE": "2", "PH": 7.2}`) or the new standalone `list` structure, the `get_readings()` and `get_specific_readings()` functions will always return a seamless, flattened key-value dictionary. Your Home Assistant integration or downstream application will work uniformly with both formats without requiring any extra code!
138
138
 
139
+ **Hardware Profile Detection:**
140
+ As of the latest release, the API client provides a method to detect the specific hardware configuration of your Violet Controller.
141
+ The API automatically detects the connected modules and updates internal states based on the available readings.
142
+ ```python
143
+ profile = await api.get_hardware_profile()
144
+ print(profile)
145
+ # Output example:
146
+ # {
147
+ # "base_module": True,
148
+ # "dosing_module": True,
149
+ # "extension_module_1": True,
150
+ # "extension_module_2": False,
151
+ # }
152
+ ```
153
+ This detection parses `get_readings()` to check for the presence of certain internal status parameters (`SYSTEM_dosagemodule_cpu_temperature`, `EXT1_1`, `EXT2_1`), allowing your application to dynamically adapt to the connected modules (Base Module, Dosing Module, Relay Extension 1 and 2). By utilizing this detection, developers and integrations can accurately filter out features for missing hardware, ensuring that only supported options are exposed to the user.
154
+
139
155
  ## License
140
156
  GNU Affero General Public License v3.0 or later (AGPLv3+)
141
157
 
@@ -13,3 +13,7 @@
13
13
  #
14
14
  # You should have received a copy of the GNU Affero General Public License
15
15
  # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ from .api import VioletPoolAPI, VioletPoolAPIError
18
+
19
+ __all__ = ["VioletPoolAPI", "VioletPoolAPIError"]
@@ -18,8 +18,6 @@
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
- from .const_devices import DEVICE_PARAMETERS
22
-
23
21
  import asyncio
24
22
  import json
25
23
  import logging
@@ -55,12 +53,14 @@ from .const_api import (
55
53
  API_SET_FUNCTION_MANUALLY,
56
54
  API_SET_OUTPUT_TESTMODE,
57
55
  API_SET_TARGET_VALUES,
56
+ DMX_SCENE_COUNT,
58
57
  DOSING_FUNCTIONS,
59
58
  TARGET_MIN_CHLORINE,
60
59
  TARGET_ORP,
61
60
  TARGET_PH,
62
61
  )
63
62
  from .circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
63
+ from .const_devices import DEVICE_PARAMETERS
64
64
  from .utils_rate_limiter import get_global_rate_limiter
65
65
  from .utils_sanitizer import InputSanitizer
66
66
 
@@ -497,6 +497,28 @@ class VioletPoolAPI:
497
497
 
498
498
  return response
499
499
 
500
+ def _build_hardware_profile(self, flattened: dict[str, Any]) -> dict[str, bool]:
501
+ """Build a hardware presence profile from flattened getReadings data.
502
+
503
+ Args:
504
+ flattened: The flattened key-value readings dict.
505
+
506
+ Returns:
507
+ A dictionary with boolean flags for connected hardware components.
508
+ """
509
+ def is_present(key: str) -> bool:
510
+ if not isinstance(flattened, dict):
511
+ return False
512
+ val = flattened.get(key)
513
+ return val is not None and str(val).strip().upper() != "N/A"
514
+
515
+ return {
516
+ "base_module": not self.dosing_standalone,
517
+ "dosing_module": self.dosing_standalone or is_present("SYSTEM_dosagemodule_cpu_temperature"),
518
+ "extension_module_1": is_present("EXT1_1"),
519
+ "extension_module_2": is_present("EXT2_1"),
520
+ }
521
+
500
522
  async def get_readings(self) -> dict[str, Any]:
501
523
  """Returns the complete dataset from the controller.
502
524
 
@@ -511,7 +533,43 @@ class VioletPoolAPI:
511
533
  query="ALL",
512
534
  payload_name="getReadings",
513
535
  )
514
- return self._flatten_getreadings_response(response)
536
+ flattened = self._flatten_getreadings_response(response)
537
+ profile = self._build_hardware_profile(flattened)
538
+ return self._filter_unsupported_readings(flattened, profile)
539
+
540
+ def _filter_unsupported_readings(self, readings: dict[str, Any], profile: dict[str, bool]) -> dict[str, Any]:
541
+ """Filters out readings for hardware modules that are not present."""
542
+ if not isinstance(readings, dict):
543
+ return readings
544
+
545
+ filtered_readings = {}
546
+ for key, value in readings.items():
547
+ normalized_key = key.upper()
548
+
549
+ # Base Module specific keys
550
+ if not profile.get("base_module"):
551
+ if normalized_key in {
552
+ "PUMPSTATE", "PUMP_SPEED", "HEATER", "LIGHT", "ECO",
553
+ "BACKWASH", "BACKWASHRINSE", "REFILL", "PVSURPLUS", "TEMP_PUMP"
554
+ } or (normalized_key.startswith("SYSTEM_") and normalized_key != "SYSTEM_DOSAGEMODULE_CPU_TEMPERATURE"):
555
+ continue
556
+
557
+ # Extension Module 1 specific keys
558
+ if not profile.get("extension_module_1") and normalized_key.startswith("EXT1_"):
559
+ continue
560
+
561
+ # Extension Module 2 specific keys
562
+ if not profile.get("extension_module_2") and normalized_key.startswith("EXT2_"):
563
+ continue
564
+
565
+ # Dosing Module specific keys
566
+ if not profile.get("dosing_module"):
567
+ if normalized_key.startswith("DOS_") or normalized_key == "SYSTEM_DOSAGEMODULE_CPU_TEMPERATURE":
568
+ continue
569
+
570
+ filtered_readings[key] = value
571
+
572
+ return filtered_readings
515
573
 
516
574
  async def get_specific_readings(
517
575
  self, categories: list[str] | tuple[str, ...]
@@ -575,6 +633,24 @@ class VioletPoolAPI:
575
633
  payload_name="getWeatherdata",
576
634
  )
577
635
 
636
+ async def get_hardware_profile(self) -> dict[str, bool]:
637
+ """Detects connected hardware based on available readings.
638
+
639
+ Returns:
640
+ A dictionary with boolean flags for connected hardware components:
641
+ - base_module: True if the base module is present.
642
+ - dosing_module: True if the dosing module is present.
643
+ - extension_module_1: True if the first relay extension is present.
644
+ - extension_module_2: True if the second relay extension is present.
645
+ """
646
+ response = await self._request_json_dict(
647
+ API_READINGS,
648
+ query="ALL",
649
+ payload_name="getReadings",
650
+ )
651
+ flattened = self._flatten_getreadings_response(response)
652
+ return self._build_hardware_profile(flattened)
653
+
578
654
  async def get_overall_dosing(self) -> dict[str, Any]:
579
655
  """Returns aggregated dosing statistics.
580
656
 
@@ -760,7 +836,8 @@ class VioletPoolAPI:
760
836
  if not output:
761
837
  raise VioletPoolAPIError("Output identifier is required")
762
838
 
763
- duration_ms = max(0, int(duration)) * 1000
839
+ safe_duration = max(0, min(86400, int(duration))) # cap at 24 h
840
+ duration_ms = safe_duration * 1000
764
841
  payload = f"{output},{mode},{duration_ms}"
765
842
  body = await self._request(
766
843
  API_SET_OUTPUT_TESTMODE,
@@ -866,7 +943,7 @@ class VioletPoolAPI:
866
943
  raise VioletPoolAPIError(f"Unsupported DMX action: {action}")
867
944
 
868
945
  tasks = []
869
- for scene in range(1, 13):
946
+ for scene in range(1, DMX_SCENE_COUNT + 1):
870
947
  key = f"DMX_SCENE{scene}"
871
948
  tasks.append(self.set_switch_state(key, action))
872
949
 
@@ -22,7 +22,7 @@ import asyncio
22
22
  import logging
23
23
  import time
24
24
  from collections.abc import Callable
25
- from enum import StrEnum
25
+ from enum import StrEnum
26
26
  from typing import Any
27
27
 
28
28
  _LOGGER = logging.getLogger(__name__)
@@ -175,12 +175,13 @@ class CircuitBreaker:
175
175
  "half_open_start_time": self.half_open_start_time,
176
176
  }
177
177
 
178
- def reset(self) -> None:
179
- """Manually reset the circuit breaker (call from sync context only)."""
180
- self.state = CircuitBreakerState.CLOSED
181
- self.failure_count = 0
182
- self.last_failure_time = 0.0
183
- self.half_open_start_time = 0.0
178
+ async def reset(self) -> None:
179
+ """Manually reset the circuit breaker to CLOSED state."""
180
+ async with self._lock:
181
+ self.state = CircuitBreakerState.CLOSED
182
+ self.failure_count = 0
183
+ self.last_failure_time = 0.0
184
+ self.half_open_start_time = 0.0
184
185
  _LOGGER.info("Circuit breaker manually reset to CLOSED state")
185
186
 
186
187
 
@@ -123,8 +123,10 @@ for ext_bank in [1, 2]:
123
123
  f"Erweiterung {ext_bank}.{relay_num}"
124
124
  )
125
125
 
126
+ DMX_SCENE_COUNT = 12 # Number of DMX scenes supported by the controller
127
+
126
128
  # Dynamically add DMX scenes
127
- for scene_num in range(1, 13):
129
+ for scene_num in range(1, DMX_SCENE_COUNT + 1):
128
130
  SWITCH_FUNCTIONS[f"DMX_SCENE{scene_num}"] = f"DMX Szene {scene_num}"
129
131
 
130
132
  # Dynamically add digital input rules
@@ -125,17 +125,20 @@ class RateLimiter:
125
125
  # Track failures efficiently
126
126
  self.blocked_requests += 1
127
127
  self._recent_stats["blocked_last_minute"] += 1
128
+ self.request_history.append(
129
+ {"time": current_time, "priority": priority, "blocked": True}
130
+ )
128
131
  return False
129
132
 
130
- def _cleanup_history(self, current_time: float) -> None:
131
- """Clean up old history entries to prevent memory leaks."""
132
- # Remove entries older than 1 hour
133
- cutoff_time = current_time - 3600
134
-
135
- while self.request_history and self.request_history[0]["time"] <= cutoff_time:
136
- self.request_history.popleft()
137
-
138
- _LOGGER.debug("Rate limiter history cleanup completed")
133
+ def _cleanup_history(self, current_time: float) -> None:
134
+ """Clean up old history entries to prevent memory leaks."""
135
+ # Remove entries older than 1 hour
136
+ cutoff_time = current_time - 3600
137
+
138
+ while self.request_history and self.request_history[0]["time"] <= cutoff_time:
139
+ self.request_history.popleft()
140
+
141
+ _LOGGER.debug("Rate limiter history cleanup completed")
139
142
 
140
143
  def _update_recent_stats(self, current_time: float) -> None:
141
144
  """Update memory-efficient recent statistics."""