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.
- {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/PKG-INFO +1 -1
- {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/pyproject.toml +1 -1
- {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/setup.py +35 -35
- {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/tests/test_api.py +31 -5
- {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/violet_poolController_api.egg-info/PKG-INFO +1 -1
- {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/violet_poolcontroller_api/__init__.py +4 -0
- {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/violet_poolcontroller_api/api.py +62 -33
- {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/violet_poolcontroller_api/circuit_breaker.py +8 -7
- {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/violet_poolcontroller_api/const_api.py +3 -1
- {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/violet_poolcontroller_api/utils_rate_limiter.py +12 -9
- {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/LICENSE +0 -0
- {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/README.md +0 -0
- {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/setup.cfg +0 -0
- {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/violet_poolController_api.egg-info/SOURCES.txt +0 -0
- {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/violet_poolController_api.egg-info/dependency_links.txt +0 -0
- {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/violet_poolController_api.egg-info/requires.txt +0 -0
- {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/violet_poolController_api.egg-info/top_level.txt +0 -0
- {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/violet_poolcontroller_api/const_devices.py +0 -0
- {violet_poolcontroller_api-0.0.10 → violet_poolcontroller_api-0.0.12}/violet_poolcontroller_api/utils_sanitizer.py +0 -0
|
@@ -1,35 +1,35 @@
|
|
|
1
|
-
from setuptools import setup, find_packages
|
|
2
|
-
setup(
|
|
3
|
-
name="violet-poolController-api",
|
|
4
|
-
version="0.0.
|
|
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": {
|
|
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": {
|
|
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."""
|
|
@@ -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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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,
|
|
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."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|