violet-poolController-api 0.0.10__tar.gz → 0.0.12__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.10 → violet_poolcontroller_api-0.0.12}/PKG-INFO +1 -1
  2. {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/pyproject.toml +1 -1
  3. {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/setup.py +35 -35
  4. {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/tests/test_api.py +31 -5
  5. {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/violet_poolController_api.egg-info/PKG-INFO +1 -1
  6. {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/violet_poolcontroller_api/__init__.py +4 -0
  7. {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/violet_poolcontroller_api/api.py +62 -33
  8. {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/violet_poolcontroller_api/circuit_breaker.py +8 -7
  9. {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/violet_poolcontroller_api/const_api.py +3 -1
  10. {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/violet_poolcontroller_api/utils_rate_limiter.py +12 -9
  11. {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/LICENSE +0 -0
  12. {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/README.md +0 -0
  13. {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/setup.cfg +0 -0
  14. {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/violet_poolController_api.egg-info/SOURCES.txt +0 -0
  15. {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/violet_poolController_api.egg-info/dependency_links.txt +0 -0
  16. {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/violet_poolController_api.egg-info/requires.txt +0 -0
  17. {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/violet_poolController_api.egg-info/top_level.txt +0 -0
  18. {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/violet_poolcontroller_api/const_devices.py +0 -0
  19. {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/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.10
3
+ Version: 0.0.12
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)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "violet-poolController-api"
7
- version = "0.0.10"
7
+ version = "0.0.12"
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.10",
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.12",
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
+ )
@@ -257,7 +257,7 @@ async def test_dosing_standalone_detection_dict_format(mock_aioresponse, standal
257
257
 
258
258
  @pytest.mark.asyncio
259
259
  async def test_get_hardware_profile(mock_aioresponse, api_client):
260
- """Test get_hardware_profile correctly detects components."""
260
+ """Test get_hardware_profile correctly detects components via alive counters."""
261
261
  url = "http://192.168.1.100/getReadings?ALL"
262
262
 
263
263
  # 1. Base module only (no DOS, EXT)
@@ -270,8 +270,14 @@ async def test_get_hardware_profile(mock_aioresponse, api_client):
270
270
  "extension_module_2": False,
271
271
  }
272
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)
273
+ # 2. Base module + Dosing + Ext1 (via alive counters)
274
+ mock_aioresponse.get(url, payload={"getReadings": {
275
+ "PUMPSTATE": "2",
276
+ "SYSTEM_dosagemodule_alive_count": "20392243",
277
+ "SYSTEM_dosagemodule_cpu_temperature": 45.5,
278
+ "SYSTEM_ext1module_alive_count": "52443888",
279
+ "EXT1_1": "1",
280
+ }}, status=200)
275
281
  profile = await api_client.get_hardware_profile()
276
282
  assert profile == {
277
283
  "base_module": True,
@@ -280,8 +286,14 @@ async def test_get_hardware_profile(mock_aioresponse, api_client):
280
286
  "extension_module_2": False,
281
287
  }
282
288
 
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)
289
+ # 3. Base module + Ext1 + Ext2 (via alive counters, no Dosing)
290
+ mock_aioresponse.get(url, payload={"getReadings": {
291
+ "PUMPSTATE": "2",
292
+ "SYSTEM_ext1module_alive_count": "100",
293
+ "SYSTEM_ext2module_alive_count": "200",
294
+ "EXT1_1": "1",
295
+ "EXT2_1": "1",
296
+ }}, status=200)
285
297
  profile = await api_client.get_hardware_profile()
286
298
  assert profile == {
287
299
  "base_module": True,
@@ -290,6 +302,20 @@ async def test_get_hardware_profile(mock_aioresponse, api_client):
290
302
  "extension_module_2": True,
291
303
  }
292
304
 
305
+ # 4. Real-world scenario: EXT2 relay data present (value 0) but no ext2 module
306
+ # Controller always returns EXT2_* keys even when the module is absent.
307
+ mock_aioresponse.get(url, payload={"getReadings": {
308
+ "PUMPSTATE": "2",
309
+ "SYSTEM_dosagemodule_alive_count": "20392243",
310
+ "SYSTEM_ext1module_alive_count": "52443888",
311
+ "EXT1_1": 0, "EXT1_2": 0,
312
+ "EXT2_1": 0, "EXT2_2": 0,
313
+ "EXT2_1_LAST_ON": 0, "EXT2_1_LAST_OFF": 0,
314
+ }}, status=200)
315
+ profile = await api_client.get_hardware_profile()
316
+ assert profile["extension_module_1"] is True
317
+ assert profile["extension_module_2"] is False
318
+
293
319
  @pytest.mark.asyncio
294
320
  async def test_get_hardware_profile_standalone_dosing(mock_aioresponse, standalone_api_client):
295
321
  """Test get_hardware_profile with a standalone dosing configuration."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: violet-poolController-api
3
- Version: 0.0.10
3
+ Version: 0.0.12
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)
@@ -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,55 @@ 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
+ Uses the controller's alive-counters (``SYSTEM_*_alive_count``) to
504
+ reliably detect connected hardware modules. The controller always
505
+ emits relay keys (``EXT1_1``, ``EXT2_1``, …) with a default value of
506
+ ``0`` even when the physical module is absent, so checking those keys
507
+ would yield false positives.
508
+
509
+ Args:
510
+ flattened: The flattened key-value readings dict.
511
+
512
+ Returns:
513
+ A dictionary with boolean flags for connected hardware components.
514
+ """
515
+ def _module_alive(alive_key: str) -> bool:
516
+ val = flattened.get(alive_key)
517
+ if val is None:
518
+ return False
519
+ try:
520
+ return float(str(val).strip()) > 0
521
+ except (ValueError, TypeError):
522
+ return False
523
+
524
+ def _any_relay_used(prefix: str) -> bool:
525
+ for key, val in flattened.items():
526
+ if not key.startswith(prefix):
527
+ continue
528
+ if "_LAST_ON" not in key and "_LAST_OFF" not in key and "_RUNTIME" not in key:
529
+ last_on = flattened.get(f"{key}_LAST_ON")
530
+ if last_on is not None:
531
+ try:
532
+ if float(str(last_on).strip()) > 0:
533
+ return True
534
+ except (ValueError, TypeError):
535
+ pass
536
+ return False
537
+
538
+ ext1 = _module_alive("SYSTEM_ext1module_alive_count") or _any_relay_used("EXT1_")
539
+ ext2 = _module_alive("SYSTEM_ext2module_alive_count") or _any_relay_used("EXT2_")
540
+ dosing = self.dosing_standalone or _module_alive("SYSTEM_dosagemodule_alive_count")
541
+
542
+ return {
543
+ "base_module": not self.dosing_standalone,
544
+ "dosing_module": dosing,
545
+ "extension_module_1": ext1,
546
+ "extension_module_2": ext2,
547
+ }
548
+
500
549
  async def get_readings(self) -> dict[str, Any]:
501
550
  """Returns the complete dataset from the controller.
502
551
 
@@ -512,21 +561,7 @@ class VioletPoolAPI:
512
561
  payload_name="getReadings",
513
562
  )
514
563
  flattened = self._flatten_getreadings_response(response)
515
-
516
- # Determine hardware profile based on the flattened readings
517
- def is_present(key: str) -> bool:
518
- if not isinstance(flattened, dict):
519
- return False
520
- val = flattened.get(key)
521
- return val is not None and str(val).strip().upper() != "N/A"
522
-
523
- profile = {
524
- "base_module": not self.dosing_standalone,
525
- "dosing_module": self.dosing_standalone or is_present("SYSTEM_dosagemodule_cpu_temperature"),
526
- "extension_module_1": is_present("EXT1_1"),
527
- "extension_module_2": is_present("EXT2_1"),
528
- }
529
-
564
+ profile = self._build_hardware_profile(flattened)
530
565
  return self._filter_unsupported_readings(flattened, profile)
531
566
 
532
567
  def _filter_unsupported_readings(self, readings: dict[str, Any], profile: dict[str, bool]) -> dict[str, Any]:
@@ -635,20 +670,13 @@ class VioletPoolAPI:
635
670
  - extension_module_1: True if the first relay extension is present.
636
671
  - extension_module_2: True if the second relay extension is present.
637
672
  """
638
- readings = await self.get_readings()
639
-
640
- def is_present(key: str) -> bool:
641
- if not isinstance(readings, dict):
642
- return False
643
- val = readings.get(key)
644
- return val is not None and str(val).strip().upper() != "N/A"
645
-
646
- return {
647
- "base_module": not self.dosing_standalone,
648
- "dosing_module": self.dosing_standalone or is_present("SYSTEM_dosagemodule_cpu_temperature"),
649
- "extension_module_1": is_present("EXT1_1"),
650
- "extension_module_2": is_present("EXT2_1"),
651
- }
673
+ response = await self._request_json_dict(
674
+ API_READINGS,
675
+ query="ALL",
676
+ payload_name="getReadings",
677
+ )
678
+ flattened = self._flatten_getreadings_response(response)
679
+ return self._build_hardware_profile(flattened)
652
680
 
653
681
  async def get_overall_dosing(self) -> dict[str, Any]:
654
682
  """Returns aggregated dosing statistics.
@@ -835,7 +863,8 @@ class VioletPoolAPI:
835
863
  if not output:
836
864
  raise VioletPoolAPIError("Output identifier is required")
837
865
 
838
- duration_ms = max(0, int(duration)) * 1000
866
+ safe_duration = max(0, min(86400, int(duration))) # cap at 24 h
867
+ duration_ms = safe_duration * 1000
839
868
  payload = f"{output},{mode},{duration_ms}"
840
869
  body = await self._request(
841
870
  API_SET_OUTPUT_TESTMODE,
@@ -941,7 +970,7 @@ class VioletPoolAPI:
941
970
  raise VioletPoolAPIError(f"Unsupported DMX action: {action}")
942
971
 
943
972
  tasks = []
944
- for scene in range(1, 13):
973
+ for scene in range(1, DMX_SCENE_COUNT + 1):
945
974
  key = f"DMX_SCENE{scene}"
946
975
  tasks.append(self.set_switch_state(key, action))
947
976
 
@@ -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."""