violet-poolController-api 0.0.33__tar.gz → 0.0.35__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.33/violet_poolController_api.egg-info → violet_poolcontroller_api-0.0.35}/PKG-INFO +2 -2
- {violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35}/pyproject.toml +8 -2
- {violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35}/tests/test_api.py +47 -2
- violet_poolcontroller_api-0.0.35/tests/test_circuit_breaker.py +138 -0
- {violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35/violet_poolController_api.egg-info}/PKG-INFO +2 -2
- {violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35}/violet_poolController_api.egg-info/SOURCES.txt +1 -0
- {violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35}/violet_poolController_api.egg-info/requires.txt +1 -1
- {violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35}/violet_poolcontroller_api/api.py +27 -29
- {violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35}/violet_poolcontroller_api/circuit_breaker.py +11 -8
- {violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35}/violet_poolcontroller_api/parsers.py +8 -2
- violet_poolcontroller_api-0.0.35/violet_poolcontroller_api/py.typed +0 -0
- {violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35}/violet_poolcontroller_api/readings.py +20 -10
- {violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35}/violet_poolcontroller_api/utils_rate_limiter.py +6 -4
- {violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35}/violet_poolcontroller_api/utils_sanitizer.py +13 -12
- violet_poolcontroller_api-0.0.33/tests/test_circuit_breaker.py +0 -204
- {violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35}/LICENSE +0 -0
- {violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35}/README.md +0 -0
- {violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35}/setup.cfg +0 -0
- {violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35}/tests/test_api_smoke.py +0 -0
- {violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35}/tests/test_mock_server.py +0 -0
- {violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35}/tests/test_parsers.py +0 -0
- {violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35}/tests/test_readings.py +0 -0
- {violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35}/violet_poolController_api.egg-info/dependency_links.txt +0 -0
- {violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35}/violet_poolController_api.egg-info/top_level.txt +0 -0
- {violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35}/violet_poolcontroller_api/__init__.py +0 -0
- {violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35}/violet_poolcontroller_api/const_api.py +0 -0
- {violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35}/violet_poolcontroller_api/const_devices.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: violet-poolController-api
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.35
|
|
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
|
|
@@ -20,7 +20,7 @@ Classifier: Topic :: Home Automation
|
|
|
20
20
|
Requires-Python: >=3.12
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
License-File: LICENSE
|
|
23
|
-
Requires-Dist: aiohttp<3.
|
|
23
|
+
Requires-Dist: aiohttp<3.15,>=3.11.0
|
|
24
24
|
Provides-Extra: test
|
|
25
25
|
Requires-Dist: aioresponses>=0.7.8; extra == "test"
|
|
26
26
|
Requires-Dist: pytest>=8.3; extra == "test"
|
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
requires = ["setuptools>=61.0.0", "wheel"]
|
|
3
3
|
build-backend = "setuptools.build_meta"
|
|
4
4
|
|
|
5
|
+
[tool.setuptools.packages.find]
|
|
6
|
+
include = ["violet_poolcontroller_api*"]
|
|
7
|
+
|
|
8
|
+
[tool.setuptools.package-data]
|
|
9
|
+
violet_poolcontroller_api = ["py.typed"]
|
|
10
|
+
|
|
5
11
|
[project]
|
|
6
12
|
name = "violet-poolController-api"
|
|
7
|
-
version = "0.0.
|
|
13
|
+
version = "0.0.35"
|
|
8
14
|
authors = [
|
|
9
15
|
{ name="Basti (Xerolux)", email="git@xerolux.de" },
|
|
10
16
|
]
|
|
@@ -24,7 +30,7 @@ classifiers = [
|
|
|
24
30
|
"Topic :: Home Automation",
|
|
25
31
|
]
|
|
26
32
|
dependencies = [
|
|
27
|
-
"aiohttp>=3.11.0,<3.
|
|
33
|
+
"aiohttp>=3.11.0,<3.15",
|
|
28
34
|
]
|
|
29
35
|
|
|
30
36
|
[project.optional-dependencies]
|
|
@@ -96,6 +96,47 @@ async def test_get_readings_success(
|
|
|
96
96
|
assert dict(result) == mock_data
|
|
97
97
|
|
|
98
98
|
|
|
99
|
+
@pytest.mark.asyncio
|
|
100
|
+
async def test_get_output_runtimes_success(
|
|
101
|
+
mock_aioresponse: aioresponses,
|
|
102
|
+
api_client: VioletPoolAPI,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Test get_output_runtimes parses a JSON dict response.
|
|
105
|
+
|
|
106
|
+
Regression: previously the endpoint was requested without expect_json,
|
|
107
|
+
so the raw text body was returned and the method logged a warning
|
|
108
|
+
("Unexpected non-dict response for get_output_runtimes: str") and
|
|
109
|
+
returned an empty dict.
|
|
110
|
+
"""
|
|
111
|
+
url = "http://192.168.1.100/getOutputruntimes"
|
|
112
|
+
mock_data = {
|
|
113
|
+
"PUMP_RUNTIME": "04h 33m 12s",
|
|
114
|
+
"PUMP_LAST_ON": 1700000000,
|
|
115
|
+
"PUMP_LAST_OFF": 1699996800,
|
|
116
|
+
"CPU_UPTIME": "12d 4h 30m",
|
|
117
|
+
"LOAD_AVG": "0.42",
|
|
118
|
+
}
|
|
119
|
+
mock_aioresponse.get(url, payload=mock_data, status=200)
|
|
120
|
+
|
|
121
|
+
result = await api_client.get_output_runtimes()
|
|
122
|
+
|
|
123
|
+
assert isinstance(result, dict)
|
|
124
|
+
assert result == mock_data
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@pytest.mark.asyncio
|
|
128
|
+
async def test_get_output_runtimes_non_dict_raises(
|
|
129
|
+
mock_aioresponse: aioresponses,
|
|
130
|
+
api_client: VioletPoolAPI,
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Test get_output_runtimes raises when the controller returns a non-dict."""
|
|
133
|
+
url = "http://192.168.1.100/getOutputruntimes"
|
|
134
|
+
mock_aioresponse.get(url, body="not-json", status=200)
|
|
135
|
+
|
|
136
|
+
with pytest.raises(VioletPoolAPIError):
|
|
137
|
+
await api_client.get_output_runtimes()
|
|
138
|
+
|
|
139
|
+
|
|
99
140
|
@pytest.mark.asyncio
|
|
100
141
|
async def test_set_pump_speed_success(
|
|
101
142
|
mock_aioresponse: aioresponses,
|
|
@@ -1683,8 +1724,10 @@ async def test_client_error_does_not_trip_circuit_breaker(
|
|
|
1683
1724
|
await api_client.get_readings()
|
|
1684
1725
|
|
|
1685
1726
|
stats = api_client._circuit_breaker.get_stats()
|
|
1727
|
+
if "note" in stats:
|
|
1728
|
+
pytest.skip("Lock held during stats collection")
|
|
1686
1729
|
assert stats["failure_count"] == 0
|
|
1687
|
-
assert stats["state"] == "CLOSED"
|
|
1730
|
+
assert stats["state"].name == "CLOSED"
|
|
1688
1731
|
|
|
1689
1732
|
|
|
1690
1733
|
@pytest.mark.asyncio
|
|
@@ -1694,12 +1737,14 @@ async def test_server_error_still_counts_for_circuit_breaker(
|
|
|
1694
1737
|
) -> None:
|
|
1695
1738
|
"""5xx errors keep counting as circuit breaker failures."""
|
|
1696
1739
|
url = "http://192.168.1.100/getReadings?ALL"
|
|
1697
|
-
mock_aioresponse.get(url, status=500, body="boom")
|
|
1740
|
+
mock_aioresponse.get(url, status=500, body="boom", repeat=True)
|
|
1698
1741
|
|
|
1699
1742
|
with pytest.raises(VioletPoolAPIError):
|
|
1700
1743
|
await api_client.get_readings()
|
|
1701
1744
|
|
|
1702
1745
|
stats = api_client._circuit_breaker.get_stats()
|
|
1746
|
+
if "note" in stats:
|
|
1747
|
+
pytest.skip("Lock held during stats collection")
|
|
1703
1748
|
assert stats["failure_count"] == 1
|
|
1704
1749
|
|
|
1705
1750
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Tests for violet_poolcontroller_api.circuit_breaker module."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from violet_poolcontroller_api.circuit_breaker import (
|
|
6
|
+
CircuitBreaker,
|
|
7
|
+
CircuitBreakerOpenError,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestCircuitBreakerStates:
|
|
12
|
+
"""Test circuit breaker state transitions."""
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def circuit_breaker(self):
|
|
16
|
+
"""Create circuit breaker instance."""
|
|
17
|
+
return CircuitBreaker(failure_threshold=3, recovery_timeout=1)
|
|
18
|
+
|
|
19
|
+
async def test_initial_state_closed(self, circuit_breaker):
|
|
20
|
+
"""Circuit breaker starts in CLOSED state."""
|
|
21
|
+
assert circuit_breaker.state.name == "CLOSED"
|
|
22
|
+
|
|
23
|
+
async def test_state_transitions_closed_to_open(self, circuit_breaker):
|
|
24
|
+
"""CLOSED → OPEN after threshold failures."""
|
|
25
|
+
async def failing():
|
|
26
|
+
raise ValueError("simulated failure")
|
|
27
|
+
|
|
28
|
+
for _ in range(3):
|
|
29
|
+
with pytest.raises(ValueError):
|
|
30
|
+
await circuit_breaker.call(failing)
|
|
31
|
+
|
|
32
|
+
assert circuit_breaker.state.name == "OPEN"
|
|
33
|
+
assert circuit_breaker.failure_count == 3
|
|
34
|
+
|
|
35
|
+
async def test_circuit_open_rejects_calls(self, circuit_breaker):
|
|
36
|
+
"""OPEN circuit raises CircuitBreakerOpenError."""
|
|
37
|
+
async def failing():
|
|
38
|
+
raise ValueError("fail")
|
|
39
|
+
|
|
40
|
+
for _ in range(3):
|
|
41
|
+
with pytest.raises(ValueError):
|
|
42
|
+
await circuit_breaker.call(failing)
|
|
43
|
+
|
|
44
|
+
with pytest.raises(CircuitBreakerOpenError):
|
|
45
|
+
await circuit_breaker.call(failing)
|
|
46
|
+
|
|
47
|
+
async def test_ignored_exceptions_dont_count(self):
|
|
48
|
+
"""Ignored exceptions pass through without incrementing failure count."""
|
|
49
|
+
cb = CircuitBreaker(
|
|
50
|
+
failure_threshold=2,
|
|
51
|
+
recovery_timeout=1,
|
|
52
|
+
ignored_exceptions=(ValueError,),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
async def value_err():
|
|
56
|
+
raise ValueError("should be ignored")
|
|
57
|
+
|
|
58
|
+
for _ in range(5):
|
|
59
|
+
with pytest.raises(ValueError):
|
|
60
|
+
await cb.call(value_err)
|
|
61
|
+
|
|
62
|
+
assert cb.failure_count == 0
|
|
63
|
+
assert cb.state.name == "CLOSED"
|
|
64
|
+
|
|
65
|
+
async def test_success_resets_failure_count(self, circuit_breaker):
|
|
66
|
+
"""Success resets failure count."""
|
|
67
|
+
async def failing():
|
|
68
|
+
raise ValueError("fail")
|
|
69
|
+
|
|
70
|
+
async def succeeding():
|
|
71
|
+
return "ok"
|
|
72
|
+
|
|
73
|
+
# Trigger one failure
|
|
74
|
+
with pytest.raises(ValueError):
|
|
75
|
+
await circuit_breaker.call(failing)
|
|
76
|
+
assert circuit_breaker.failure_count == 1
|
|
77
|
+
|
|
78
|
+
# Success
|
|
79
|
+
result = await circuit_breaker.call(succeeding)
|
|
80
|
+
assert result == "ok"
|
|
81
|
+
assert circuit_breaker.failure_count == 0
|
|
82
|
+
assert circuit_breaker.state.name == "CLOSED"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TestCircuitBreakerConfiguration:
|
|
86
|
+
"""Test circuit breaker configuration."""
|
|
87
|
+
|
|
88
|
+
def test_custom_failure_threshold(self):
|
|
89
|
+
"""Set custom failure threshold."""
|
|
90
|
+
cb = CircuitBreaker(failure_threshold=5, recovery_timeout=2)
|
|
91
|
+
assert cb.failure_threshold == 5
|
|
92
|
+
|
|
93
|
+
def test_zero_failure_threshold(self):
|
|
94
|
+
"""Zero failure threshold opens on first failure."""
|
|
95
|
+
cb = CircuitBreaker(failure_threshold=0, recovery_timeout=1)
|
|
96
|
+
|
|
97
|
+
async def failing():
|
|
98
|
+
raise ValueError("fail")
|
|
99
|
+
|
|
100
|
+
with pytest.raises(ValueError):
|
|
101
|
+
cb.call(failing) # no await needed — fails before reaching callable
|
|
102
|
+
|
|
103
|
+
def test_negative_recovery_timeout(self):
|
|
104
|
+
"""Negative timeout handled gracefully."""
|
|
105
|
+
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=-1)
|
|
106
|
+
assert cb is not None
|
|
107
|
+
|
|
108
|
+
def test_custom_recovery_timeout(self):
|
|
109
|
+
"""Set custom recovery timeout."""
|
|
110
|
+
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=5)
|
|
111
|
+
assert cb.recovery_timeout == 5
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TestCircuitBreakerMetrics:
|
|
115
|
+
"""Test circuit breaker metrics."""
|
|
116
|
+
|
|
117
|
+
async def test_get_stats_returns_state_info(self):
|
|
118
|
+
"""get_stats returns current state info."""
|
|
119
|
+
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1)
|
|
120
|
+
stats = cb.get_stats()
|
|
121
|
+
assert stats["state"].name == "CLOSED"
|
|
122
|
+
assert stats["failure_count"] == 0
|
|
123
|
+
|
|
124
|
+
async def test_reset_clears_state(self):
|
|
125
|
+
"""reset restores CLOSED state and clears counters."""
|
|
126
|
+
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1)
|
|
127
|
+
|
|
128
|
+
async def failing():
|
|
129
|
+
raise ValueError("fail")
|
|
130
|
+
|
|
131
|
+
for _ in range(2):
|
|
132
|
+
with pytest.raises(ValueError):
|
|
133
|
+
await cb.call(failing)
|
|
134
|
+
|
|
135
|
+
assert cb.state.name == "OPEN"
|
|
136
|
+
cb.reset()
|
|
137
|
+
assert cb.state.name == "CLOSED"
|
|
138
|
+
assert cb.failure_count == 0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: violet-poolController-api
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.35
|
|
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
|
|
@@ -20,7 +20,7 @@ Classifier: Topic :: Home Automation
|
|
|
20
20
|
Requires-Python: >=3.12
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
License-File: LICENSE
|
|
23
|
-
Requires-Dist: aiohttp<3.
|
|
23
|
+
Requires-Dist: aiohttp<3.15,>=3.11.0
|
|
24
24
|
Provides-Extra: test
|
|
25
25
|
Requires-Dist: aioresponses>=0.7.8; extra == "test"
|
|
26
26
|
Requires-Dist: pytest>=8.3; extra == "test"
|
|
@@ -18,6 +18,7 @@ violet_poolcontroller_api/circuit_breaker.py
|
|
|
18
18
|
violet_poolcontroller_api/const_api.py
|
|
19
19
|
violet_poolcontroller_api/const_devices.py
|
|
20
20
|
violet_poolcontroller_api/parsers.py
|
|
21
|
+
violet_poolcontroller_api/py.typed
|
|
21
22
|
violet_poolcontroller_api/readings.py
|
|
22
23
|
violet_poolcontroller_api/utils_rate_limiter.py
|
|
23
24
|
violet_poolcontroller_api/utils_sanitizer.py
|
|
@@ -446,10 +446,11 @@ class VioletPoolAPI:
|
|
|
446
446
|
await self._rate_limiter.wait_if_needed(priority=priority, timeout=10.0)
|
|
447
447
|
except TimeoutError:
|
|
448
448
|
_LOGGER.warning(
|
|
449
|
-
"Rate limiter timeout for %s (priority: %d) -
|
|
449
|
+
"Rate limiter timeout for %s (priority: %d) - applying fallback delay",
|
|
450
450
|
endpoint,
|
|
451
451
|
priority,
|
|
452
452
|
)
|
|
453
|
+
await asyncio.sleep(1.0)
|
|
453
454
|
|
|
454
455
|
url = self._build_url(endpoint)
|
|
455
456
|
if query:
|
|
@@ -481,8 +482,6 @@ class VioletPoolAPI:
|
|
|
481
482
|
response.status >= _HTTP_CLIENT_ERROR
|
|
482
483
|
and response.status < _HTTP_SERVER_ERROR
|
|
483
484
|
):
|
|
484
|
-
# Client errors (4xx, except 429 handled above) are
|
|
485
|
-
# deterministic - fail fast instead of retrying.
|
|
486
485
|
body = await response.text()
|
|
487
486
|
msg = f"HTTP {response.status} for {endpoint}: {body.strip()}"
|
|
488
487
|
is_auth = response.status in (
|
|
@@ -492,19 +491,18 @@ class VioletPoolAPI:
|
|
|
492
491
|
raise _DeterministicClientError(msg, is_auth=is_auth)
|
|
493
492
|
|
|
494
493
|
if expect_json:
|
|
494
|
+
body_text = await response.text()
|
|
495
495
|
try:
|
|
496
|
-
return
|
|
496
|
+
return json.loads(body_text)
|
|
497
497
|
except (
|
|
498
|
-
aiohttp.ContentTypeError,
|
|
499
498
|
json.JSONDecodeError,
|
|
500
499
|
) as err:
|
|
501
|
-
|
|
502
|
-
msg = f"Invalid JSON payload for {endpoint}: {body.strip()}"
|
|
500
|
+
msg = f"Invalid JSON payload for {endpoint}: {body_text.strip()}"
|
|
503
501
|
raise VioletPayloadError(msg) from err
|
|
504
502
|
|
|
505
503
|
return await response.text()
|
|
506
504
|
|
|
507
|
-
except (TimeoutError,
|
|
505
|
+
except (TimeoutError,) as err:
|
|
508
506
|
last_error = VioletTimeoutError(
|
|
509
507
|
f"Request to {endpoint} timed out: {err}",
|
|
510
508
|
)
|
|
@@ -516,8 +514,8 @@ class VioletPoolAPI:
|
|
|
516
514
|
)
|
|
517
515
|
if attempt == self._max_retries:
|
|
518
516
|
raise last_error from None
|
|
519
|
-
delay = min(
|
|
520
|
-
jitter = random.uniform(0, delay * 0.1)
|
|
517
|
+
delay = min(300.0, 10.0 * (2 ** (attempt - 1)))
|
|
518
|
+
jitter = random.uniform(0, delay * 0.1)
|
|
521
519
|
await asyncio.sleep(delay + jitter)
|
|
522
520
|
except aiohttp.ClientError as err:
|
|
523
521
|
last_error = VioletPoolAPIError(
|
|
@@ -531,9 +529,8 @@ class VioletPoolAPI:
|
|
|
531
529
|
)
|
|
532
530
|
if attempt == self._max_retries:
|
|
533
531
|
raise last_error from None
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
jitter = random.uniform(0, delay * 0.1) # Add 0-10% jitter
|
|
532
|
+
delay = min(300.0, 10.0 * (2 ** (attempt - 1)))
|
|
533
|
+
jitter = random.uniform(0, delay * 0.1)
|
|
537
534
|
await asyncio.sleep(delay + jitter)
|
|
538
535
|
|
|
539
536
|
msg = "All retry attempts exhausted"
|
|
@@ -582,10 +579,10 @@ class VioletPoolAPI:
|
|
|
582
579
|
# a substring check when the first line is neither marker.
|
|
583
580
|
if first_line.startswith("ERROR"):
|
|
584
581
|
success = False
|
|
585
|
-
elif first_line == "OK":
|
|
582
|
+
elif first_line == "OK" or first_line.startswith("MANDOS_"):
|
|
586
583
|
success = True
|
|
587
584
|
else:
|
|
588
|
-
success =
|
|
585
|
+
success = False
|
|
589
586
|
|
|
590
587
|
result: dict[str, Any] = {"success": success, "response": text}
|
|
591
588
|
if len(lines) >= 2:
|
|
@@ -693,7 +690,7 @@ class VioletPoolAPI:
|
|
|
693
690
|
|
|
694
691
|
sanitized_config[sanitized_key] = sanitized_value
|
|
695
692
|
except ValueError as err:
|
|
696
|
-
_LOGGER.
|
|
693
|
+
_LOGGER.warning("Invalid config parameter %s", key)
|
|
697
694
|
msg = f"Invalid configuration parameter: {key}"
|
|
698
695
|
raise VioletPoolAPIError(
|
|
699
696
|
msg,
|
|
@@ -757,7 +754,7 @@ class VioletPoolAPI:
|
|
|
757
754
|
for item in readings:
|
|
758
755
|
if isinstance(item, dict) and item.get("VALUE NAME"):
|
|
759
756
|
key = str(item["VALUE NAME"]).strip().strip('"')
|
|
760
|
-
val = item.get("VALUE", item.get("VALUE ", item.get("value")))
|
|
757
|
+
val = item.get("VALUE", item.get("VALUE ", item.get("value"))) # "VALUE " fallback for firmware <1.0.9 trailing-space bug
|
|
761
758
|
flat_dict[key] = val
|
|
762
759
|
return flat_dict
|
|
763
760
|
|
|
@@ -1666,7 +1663,10 @@ class VioletPoolAPI:
|
|
|
1666
1663
|
is_info, is_reminder.
|
|
1667
1664
|
|
|
1668
1665
|
"""
|
|
1669
|
-
code = str(error_code).strip()
|
|
1666
|
+
code = str(error_code).strip()
|
|
1667
|
+
if not code:
|
|
1668
|
+
return {"code": "0000", "severity": "UNKNOWN", "message": "Invalid empty error code"}
|
|
1669
|
+
code = code.zfill(4)
|
|
1670
1670
|
info = ERROR_CODES.get(code)
|
|
1671
1671
|
|
|
1672
1672
|
if info:
|
|
@@ -2131,7 +2131,9 @@ class VioletPoolAPI:
|
|
|
2131
2131
|
header = lines[0].split(";")
|
|
2132
2132
|
values = lines[2].split(";")
|
|
2133
2133
|
result: dict[str, str] = {}
|
|
2134
|
-
|
|
2134
|
+
if len(header) != len(values):
|
|
2135
|
+
_LOGGER.warning("Live trace header/value length mismatch: %d vs %d", len(header), len(values))
|
|
2136
|
+
for key, raw_value in zip(header, values):
|
|
2135
2137
|
key = key.strip()
|
|
2136
2138
|
if not key:
|
|
2137
2139
|
continue
|
|
@@ -2203,22 +2205,18 @@ class VioletPoolAPI:
|
|
|
2203
2205
|
|
|
2204
2206
|
Returns a flat dict with runtime (HH:MM:SS format) and last-on/off
|
|
2205
2207
|
(ISO datetime strings) for all outputs: PUMP, SOLAR, HEATER, BACKWASH,
|
|
2206
|
-
REFILL, LIGHT, ECO, all dosing outputs,
|
|
2208
|
+
REFILL, LIGHT, ECO, all dosing outputs, OMNIDC channels, and extension
|
|
2207
2209
|
relay channels. Also includes CPU_UPTIME, LOAD_AVG, and version fields.
|
|
2208
2210
|
|
|
2209
2211
|
Returns:
|
|
2210
|
-
Dict with runtime/timestamp strings for all outputs
|
|
2211
|
-
on error.
|
|
2212
|
+
Dict with runtime/timestamp strings for all outputs.
|
|
2212
2213
|
|
|
2213
2214
|
Raises:
|
|
2214
|
-
VioletPoolAPIError: If the API call fails
|
|
2215
|
+
VioletPoolAPIError: If the API call fails or the response is not a
|
|
2216
|
+
JSON object.
|
|
2215
2217
|
|
|
2216
2218
|
"""
|
|
2217
|
-
|
|
2219
|
+
return await self._request_json_dict(
|
|
2218
2220
|
API_GET_OUTPUT_RUNTIMES,
|
|
2219
|
-
|
|
2220
|
-
priority=API_PRIORITY_NORMAL,
|
|
2221
|
+
payload_name="getOutputruntimes",
|
|
2221
2222
|
)
|
|
2222
|
-
if isinstance(resp, dict):
|
|
2223
|
-
return resp
|
|
2224
|
-
return {}
|
|
@@ -26,7 +26,7 @@ from enum import StrEnum
|
|
|
26
26
|
from typing import TYPE_CHECKING, Any
|
|
27
27
|
|
|
28
28
|
if TYPE_CHECKING:
|
|
29
|
-
from collections.abc import Callable
|
|
29
|
+
from collections.abc import Awaitable, Callable
|
|
30
30
|
|
|
31
31
|
_LOGGER = logging.getLogger(__name__)
|
|
32
32
|
|
|
@@ -86,7 +86,7 @@ class CircuitBreaker:
|
|
|
86
86
|
recovery_timeout,
|
|
87
87
|
)
|
|
88
88
|
|
|
89
|
-
async def call(self, func: Callable, *args: Any, **kwargs: Any) -> Any: # noqa: ANN401
|
|
89
|
+
async def call(self, func: Callable[..., Awaitable[Any]], *args: Any, **kwargs: Any) -> Any: # noqa: ANN401
|
|
90
90
|
"""Execute function with circuit breaker protection.
|
|
91
91
|
|
|
92
92
|
Args:
|
|
@@ -116,14 +116,15 @@ class CircuitBreaker:
|
|
|
116
116
|
"Circuit breaker entering HALF_OPEN state for recovery test",
|
|
117
117
|
)
|
|
118
118
|
|
|
119
|
-
# Check if half-open timeout exceeded
|
|
119
|
+
# Check if half-open timeout exceeded — but ONLY transition to CLOSED
|
|
120
|
+
# when a call actually succeeds (see the try block below).
|
|
120
121
|
if (
|
|
121
122
|
self.state == CircuitBreakerState.HALF_OPEN
|
|
122
123
|
and current_time - self.half_open_start_time > self.recovery_timeout
|
|
123
124
|
):
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
_LOGGER.info(
|
|
126
|
+
"Circuit breaker HALF_OPEN timeout — will close on next success"
|
|
127
|
+
)
|
|
127
128
|
|
|
128
129
|
# Fail fast if circuit is open
|
|
129
130
|
if self.state == CircuitBreakerState.OPEN:
|
|
@@ -164,7 +165,7 @@ class CircuitBreaker:
|
|
|
164
165
|
|
|
165
166
|
except Exception:
|
|
166
167
|
# Unexpected exception - don't count for circuit breaker
|
|
167
|
-
_LOGGER.exception("
|
|
168
|
+
_LOGGER.exception("Unhandled exception escaped circuit breaker")
|
|
168
169
|
raise
|
|
169
170
|
|
|
170
171
|
else:
|
|
@@ -180,7 +181,9 @@ class CircuitBreaker:
|
|
|
180
181
|
return result
|
|
181
182
|
|
|
182
183
|
def get_stats(self) -> dict[str, Any]:
|
|
183
|
-
"""Get circuit breaker statistics."""
|
|
184
|
+
"""Get circuit breaker statistics (thread-safe snapshot)."""
|
|
185
|
+
if self._lock.locked():
|
|
186
|
+
return {"state": self.state, "note": "lock held, stats may be stale"}
|
|
184
187
|
return {
|
|
185
188
|
"state": self.state,
|
|
186
189
|
"failure_count": self.failure_count,
|
|
@@ -135,7 +135,10 @@ def parse_epoch_seconds(value: int | float | str) -> datetime | None:
|
|
|
135
135
|
return None
|
|
136
136
|
if not math.isfinite(ts) or ts == 0:
|
|
137
137
|
return None
|
|
138
|
-
|
|
138
|
+
try:
|
|
139
|
+
return datetime.fromtimestamp(ts, tz=UTC)
|
|
140
|
+
except (OSError, OverflowError):
|
|
141
|
+
return None
|
|
139
142
|
|
|
140
143
|
|
|
141
144
|
def parse_epoch_milliseconds(value: int | float | str) -> datetime | None:
|
|
@@ -157,7 +160,10 @@ def parse_epoch_milliseconds(value: int | float | str) -> datetime | None:
|
|
|
157
160
|
return None
|
|
158
161
|
if not math.isfinite(ts_ms) or ts_ms == 0:
|
|
159
162
|
return None
|
|
160
|
-
|
|
163
|
+
try:
|
|
164
|
+
return datetime.fromtimestamp(ts_ms / 1000.0, tz=UTC)
|
|
165
|
+
except (OSError, OverflowError):
|
|
166
|
+
return None
|
|
161
167
|
|
|
162
168
|
|
|
163
169
|
def parse_optional_seconds(value: str | float | int) -> timedelta | None:
|
|
File without changes
|
|
@@ -71,7 +71,7 @@ def _opt_float(raw: Any) -> float | None: # noqa: ANN401
|
|
|
71
71
|
return None
|
|
72
72
|
try:
|
|
73
73
|
return float(raw)
|
|
74
|
-
except (ValueError, TypeError):
|
|
74
|
+
except (ValueError, TypeError, OverflowError):
|
|
75
75
|
return None
|
|
76
76
|
|
|
77
77
|
|
|
@@ -410,7 +410,14 @@ class VioletReadings(Mapping[str, Any]):
|
|
|
410
410
|
``True`` means the input is closed (logic 1), ``False`` means open (0).
|
|
411
411
|
Absent inputs default to ``False``.
|
|
412
412
|
"""
|
|
413
|
-
|
|
413
|
+
result: dict[int, bool] = {}
|
|
414
|
+
for i in range(1, 13):
|
|
415
|
+
raw = self._raw.get(f"INPUT{i}", 0)
|
|
416
|
+
try:
|
|
417
|
+
result[i] = bool(int(raw or 0))
|
|
418
|
+
except (ValueError, TypeError):
|
|
419
|
+
result[i] = False
|
|
420
|
+
return result
|
|
414
421
|
|
|
415
422
|
# ------------------------------------------------------------------
|
|
416
423
|
# Dosing channel output states
|
|
@@ -428,14 +435,17 @@ class VioletReadings(Mapping[str, Any]):
|
|
|
428
435
|
@cached_property
|
|
429
436
|
def dosing_daily_amounts_ml(self) -> dict[str, int | None]:
|
|
430
437
|
"""Today's dosing amounts in mL for each dosing channel."""
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
438
|
+
result: dict[str, int | None] = {}
|
|
439
|
+
for key in _DOSING_KEYS:
|
|
440
|
+
v = self._raw.get(f"{key}_DAILY_DOSING_AMOUNT_ML")
|
|
441
|
+
if v is None:
|
|
442
|
+
result[key] = None
|
|
443
|
+
else:
|
|
444
|
+
try:
|
|
445
|
+
result[key] = int(v)
|
|
446
|
+
except (ValueError, TypeError):
|
|
447
|
+
result[key] = None
|
|
448
|
+
return result
|
|
439
449
|
|
|
440
450
|
# ------------------------------------------------------------------
|
|
441
451
|
# DMX light scenes (1-12)
|
|
@@ -65,9 +65,10 @@ class RateLimiter:
|
|
|
65
65
|
self.last_refill = time.monotonic()
|
|
66
66
|
|
|
67
67
|
# Optimized request history with size and time limits
|
|
68
|
-
self.request_history: deque = deque(maxlen=500)
|
|
68
|
+
self.request_history: deque = deque(maxlen=500)
|
|
69
69
|
self.blocked_requests = 0
|
|
70
70
|
self.total_requests = 0
|
|
71
|
+
self._last_known_tokens = 0.0
|
|
71
72
|
self.history_cleanup_interval = 300 # 5 minutes
|
|
72
73
|
self.last_cleanup_time = time.monotonic()
|
|
73
74
|
|
|
@@ -126,6 +127,7 @@ class RateLimiter:
|
|
|
126
127
|
# Track failures efficiently
|
|
127
128
|
self.blocked_requests += 1
|
|
128
129
|
self._recent_stats["blocked_last_minute"] += 1
|
|
130
|
+
self._last_known_tokens = self.tokens
|
|
129
131
|
return False
|
|
130
132
|
|
|
131
133
|
def _cleanup_history(self, current_time: float) -> None:
|
|
@@ -171,10 +173,8 @@ class RateLimiter:
|
|
|
171
173
|
msg = f"Rate Limiter timeout nach {elapsed:.1f}s"
|
|
172
174
|
raise TimeoutError(msg)
|
|
173
175
|
|
|
174
|
-
# Adaptive Wartezeit: Berechne basierend auf Refill-Rate
|
|
175
|
-
# Statt fest retry_after zu warten, berechne optimale Wartezeit
|
|
176
176
|
refill_rate = self.max_requests / self.time_window
|
|
177
|
-
needed_tokens = 1.0 - self.
|
|
177
|
+
needed_tokens = 1.0 - self._last_known_tokens
|
|
178
178
|
if refill_rate > 0:
|
|
179
179
|
optimal_wait = needed_tokens / refill_rate
|
|
180
180
|
wait_time = min(optimal_wait, self.retry_after)
|
|
@@ -220,6 +220,8 @@ class RateLimiter:
|
|
|
220
220
|
self.blocked_requests = 0
|
|
221
221
|
self.total_requests = 0
|
|
222
222
|
self.request_history.clear()
|
|
223
|
+
self._recent_stats["requests_last_minute"] = 0
|
|
224
|
+
self._recent_stats["blocked_last_minute"] = 0
|
|
223
225
|
_LOGGER.debug("Rate Limiter zurückgesetzt")
|
|
224
226
|
|
|
225
227
|
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
from __future__ import annotations
|
|
21
21
|
|
|
22
22
|
import logging
|
|
23
|
+
import math
|
|
23
24
|
import re
|
|
24
25
|
import unicodedata
|
|
25
26
|
from html import escape
|
|
@@ -98,20 +99,16 @@ class InputSanitizer:
|
|
|
98
99
|
)
|
|
99
100
|
str_value = str_value[:max_length]
|
|
100
101
|
|
|
101
|
-
# Wenn keine Sonderzeichen erlaubt sind, entfernen wir sie ZUERST.
|
|
102
102
|
if not allow_special_chars:
|
|
103
|
-
# Entferne gefährliche Zeichen
|
|
104
103
|
original = str_value
|
|
105
|
-
str_value = re.sub(r"[^a-zA-Z0-
|
|
104
|
+
str_value = re.sub(r"[^a-zA-Z0-9 _-]", "", str_value)
|
|
106
105
|
if str_value != original:
|
|
107
106
|
_LOGGER.warning(
|
|
108
107
|
"Gefährliche Zeichen entfernt: '%s' → '%s'",
|
|
109
108
|
original,
|
|
110
109
|
str_value,
|
|
111
110
|
)
|
|
112
|
-
|
|
113
|
-
# (sonst sind < und > bereits durch Regex entfernt)
|
|
114
|
-
elif escape_html:
|
|
111
|
+
if escape_html:
|
|
115
112
|
str_value = escape(str_value)
|
|
116
113
|
|
|
117
114
|
return str_value
|
|
@@ -129,18 +126,22 @@ class InputSanitizer:
|
|
|
129
126
|
"""
|
|
130
127
|
try:
|
|
131
128
|
if isinstance(value, (int, float)):
|
|
129
|
+
if not math.isfinite(float(value)):
|
|
130
|
+
_LOGGER.warning("Unendlicher/NaN-Wert: %s", value)
|
|
131
|
+
return 0.0
|
|
132
132
|
return float(value)
|
|
133
133
|
|
|
134
|
-
# Konvertiere String zu Zahl
|
|
135
134
|
str_value = str(value).strip()
|
|
136
|
-
|
|
137
|
-
cleaned = re.sub(r"[^0-9
|
|
135
|
+
has_minus = str_value.startswith("-")
|
|
136
|
+
cleaned = re.sub(r"[^0-9.]", "", str_value)
|
|
137
|
+
if has_minus and cleaned:
|
|
138
|
+
cleaned = "-" + cleaned
|
|
138
139
|
|
|
139
140
|
if not cleaned:
|
|
140
141
|
return 0.0
|
|
141
142
|
|
|
142
143
|
return float(cleaned)
|
|
143
|
-
except (ValueError, TypeError):
|
|
144
|
+
except (ValueError, TypeError, OverflowError):
|
|
144
145
|
_LOGGER.warning("Ungültiger numerischer Wert: %s", value)
|
|
145
146
|
return 0.0
|
|
146
147
|
|
|
@@ -166,7 +167,7 @@ class InputSanitizer:
|
|
|
166
167
|
try:
|
|
167
168
|
try:
|
|
168
169
|
int_value = int(value)
|
|
169
|
-
except (ValueError, TypeError):
|
|
170
|
+
except (ValueError, TypeError, OverflowError):
|
|
170
171
|
int_value = int(float(value))
|
|
171
172
|
|
|
172
173
|
# Range-Validierung
|
|
@@ -466,7 +467,7 @@ class InputSanitizer:
|
|
|
466
467
|
"""
|
|
467
468
|
return InputSanitizer.sanitize_integer(
|
|
468
469
|
orp,
|
|
469
|
-
min_value=
|
|
470
|
+
min_value=500,
|
|
470
471
|
max_value=900,
|
|
471
472
|
default=700,
|
|
472
473
|
)
|
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
"""Tests for violet_poolcontroller_api.circuit_breaker module."""
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
4
|
-
|
|
5
|
-
from violet_poolcontroller_api.circuit_breaker import CircuitBreaker
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class TestCircuitBreakerStates:
|
|
9
|
-
"""Test circuit breaker state transitions."""
|
|
10
|
-
|
|
11
|
-
@pytest.fixture
|
|
12
|
-
def circuit_breaker(self):
|
|
13
|
-
"""Create circuit breaker instance."""
|
|
14
|
-
return CircuitBreaker(
|
|
15
|
-
failure_threshold=3,
|
|
16
|
-
recovery_timeout=1
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
def test_initial_state_closed(self, circuit_breaker):
|
|
20
|
-
"""Circuit breaker starts in CLOSED state."""
|
|
21
|
-
assert circuit_breaker.state == "CLOSED" or circuit_breaker.state.name == "CLOSED"
|
|
22
|
-
|
|
23
|
-
def test_closed_to_open_transition(self, circuit_breaker):
|
|
24
|
-
"""Transition from CLOSED to OPEN on failures."""
|
|
25
|
-
# Simulate failures
|
|
26
|
-
for _ in range(3):
|
|
27
|
-
circuit_breaker.record_failure()
|
|
28
|
-
# Should be OPEN after threshold
|
|
29
|
-
assert circuit_breaker.state in ["OPEN", "HALF_OPEN"] or hasattr(circuit_breaker.state, 'name')
|
|
30
|
-
|
|
31
|
-
def test_open_to_half_open_transition(self, circuit_breaker):
|
|
32
|
-
"""Transition from OPEN to HALF_OPEN after timeout."""
|
|
33
|
-
# Trigger OPEN state
|
|
34
|
-
for _ in range(3):
|
|
35
|
-
circuit_breaker.record_failure()
|
|
36
|
-
|
|
37
|
-
# Wait for recovery timeout (mocked)
|
|
38
|
-
circuit_breaker.last_failure_time = 0 # Simulate timeout
|
|
39
|
-
|
|
40
|
-
# Check if can transition to HALF_OPEN
|
|
41
|
-
result = circuit_breaker.can_attempt()
|
|
42
|
-
assert result is True or result is False
|
|
43
|
-
|
|
44
|
-
def test_half_open_to_closed_on_success(self, circuit_breaker):
|
|
45
|
-
"""Transition from HALF_OPEN to CLOSED on success."""
|
|
46
|
-
# Get to HALF_OPEN state
|
|
47
|
-
for _ in range(3):
|
|
48
|
-
circuit_breaker.record_failure()
|
|
49
|
-
circuit_breaker.last_failure_time = 0
|
|
50
|
-
|
|
51
|
-
# Record success
|
|
52
|
-
circuit_breaker.record_success()
|
|
53
|
-
|
|
54
|
-
# Should be CLOSED
|
|
55
|
-
assert circuit_breaker.state in ["CLOSED", "HALF_OPEN"] or hasattr(circuit_breaker.state, 'name')
|
|
56
|
-
|
|
57
|
-
def test_half_open_to_open_on_failure(self, circuit_breaker):
|
|
58
|
-
"""Transition from HALF_OPEN back to OPEN on failure."""
|
|
59
|
-
# Get to HALF_OPEN state
|
|
60
|
-
for _ in range(3):
|
|
61
|
-
circuit_breaker.record_failure()
|
|
62
|
-
circuit_breaker.last_failure_time = 0
|
|
63
|
-
|
|
64
|
-
# Record another failure in HALF_OPEN
|
|
65
|
-
circuit_breaker.record_failure()
|
|
66
|
-
|
|
67
|
-
# Should be OPEN
|
|
68
|
-
state = circuit_breaker.state
|
|
69
|
-
assert state in ["OPEN", "HALF_OPEN"] or hasattr(state, 'name')
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
class TestCircuitBreakerBehavior:
|
|
73
|
-
"""Test circuit breaker behavior."""
|
|
74
|
-
|
|
75
|
-
@pytest.fixture
|
|
76
|
-
def breaker(self):
|
|
77
|
-
"""Circuit breaker for testing."""
|
|
78
|
-
return CircuitBreaker(failure_threshold=2, recovery_timeout=1)
|
|
79
|
-
|
|
80
|
-
def test_can_attempt_when_closed(self, breaker):
|
|
81
|
-
"""Allow attempts when CLOSED."""
|
|
82
|
-
assert breaker.can_attempt() is True
|
|
83
|
-
|
|
84
|
-
def test_cannot_attempt_when_open(self, breaker):
|
|
85
|
-
"""Reject attempts when OPEN."""
|
|
86
|
-
# Trigger OPEN
|
|
87
|
-
for _ in range(2):
|
|
88
|
-
breaker.record_failure()
|
|
89
|
-
|
|
90
|
-
# Should not allow attempts
|
|
91
|
-
result = breaker.can_attempt()
|
|
92
|
-
# Result should indicate OPEN state (False or exception)
|
|
93
|
-
assert result is False or isinstance(result, bool)
|
|
94
|
-
|
|
95
|
-
def test_failure_count_tracking(self, breaker):
|
|
96
|
-
"""Track failure count accurately."""
|
|
97
|
-
breaker.record_failure()
|
|
98
|
-
# Failure count should increase (or state should reflect it)
|
|
99
|
-
assert breaker.state is not None
|
|
100
|
-
|
|
101
|
-
def test_success_resets_failure_count(self, breaker):
|
|
102
|
-
"""Success resets failure count."""
|
|
103
|
-
breaker.record_failure()
|
|
104
|
-
breaker.record_success()
|
|
105
|
-
# Failure count should be reset (or state should be CLOSED)
|
|
106
|
-
assert breaker.state == "CLOSED" or breaker.state.name == "CLOSED"
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
class TestCircuitBreakerConfiguration:
|
|
110
|
-
"""Test circuit breaker configuration."""
|
|
111
|
-
|
|
112
|
-
def test_custom_failure_threshold(self):
|
|
113
|
-
"""Set custom failure threshold."""
|
|
114
|
-
cb = CircuitBreaker(failure_threshold=5, recovery_timeout=2)
|
|
115
|
-
assert cb is not None
|
|
116
|
-
# Should accept configuration
|
|
117
|
-
for _ in range(5):
|
|
118
|
-
cb.record_failure()
|
|
119
|
-
|
|
120
|
-
def test_custom_recovery_timeout(self):
|
|
121
|
-
"""Set custom recovery timeout."""
|
|
122
|
-
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=5)
|
|
123
|
-
# Timeout should be honored
|
|
124
|
-
for _ in range(2):
|
|
125
|
-
cb.record_failure()
|
|
126
|
-
|
|
127
|
-
def test_custom_circuit_breaker(self):
|
|
128
|
-
"""Create custom circuit breaker."""
|
|
129
|
-
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1)
|
|
130
|
-
assert cb is not None
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
class TestCircuitBreakerEdgeCases:
|
|
134
|
-
"""Test edge cases."""
|
|
135
|
-
|
|
136
|
-
def test_zero_failure_threshold(self):
|
|
137
|
-
"""Handle zero failure threshold."""
|
|
138
|
-
cb = CircuitBreaker(failure_threshold=0, recovery_timeout=1)
|
|
139
|
-
assert cb is not None
|
|
140
|
-
|
|
141
|
-
def test_negative_recovery_timeout(self):
|
|
142
|
-
"""Handle edge case timeouts."""
|
|
143
|
-
# Breaker should handle gracefully
|
|
144
|
-
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=0)
|
|
145
|
-
assert cb is not None
|
|
146
|
-
|
|
147
|
-
def test_rapid_failures(self):
|
|
148
|
-
"""Handle rapid consecutive failures."""
|
|
149
|
-
cb = CircuitBreaker(failure_threshold=3, recovery_timeout=1)
|
|
150
|
-
for _ in range(10):
|
|
151
|
-
cb.record_failure()
|
|
152
|
-
# Should be OPEN
|
|
153
|
-
assert cb.state in ["OPEN", "HALF_OPEN"] or hasattr(cb.state, 'name')
|
|
154
|
-
|
|
155
|
-
def test_alternating_success_failure(self):
|
|
156
|
-
"""Handle alternating success/failure."""
|
|
157
|
-
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1)
|
|
158
|
-
for i in range(5):
|
|
159
|
-
if i % 2 == 0:
|
|
160
|
-
cb.record_failure()
|
|
161
|
-
else:
|
|
162
|
-
cb.record_success()
|
|
163
|
-
# State should reflect pattern
|
|
164
|
-
assert cb.state is not None
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
class TestCircuitBreakerThreadSafety:
|
|
168
|
-
"""Test thread-safety (basic)."""
|
|
169
|
-
|
|
170
|
-
def test_concurrent_access_safety(self):
|
|
171
|
-
"""Circuit breaker handles concurrent access."""
|
|
172
|
-
cb = CircuitBreaker(failure_threshold=3, recovery_timeout=1)
|
|
173
|
-
# Should not crash with multiple calls
|
|
174
|
-
cb.record_failure()
|
|
175
|
-
cb.can_attempt()
|
|
176
|
-
cb.record_success()
|
|
177
|
-
assert cb.state is not None
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
class TestCircuitBreakerMetrics:
|
|
181
|
-
"""Test circuit breaker metrics."""
|
|
182
|
-
|
|
183
|
-
def test_tracks_failures(self):
|
|
184
|
-
"""Track failure count."""
|
|
185
|
-
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1)
|
|
186
|
-
cb.record_failure()
|
|
187
|
-
cb.record_failure()
|
|
188
|
-
# Should have recorded failures
|
|
189
|
-
assert cb.state is not None
|
|
190
|
-
|
|
191
|
-
def test_tracks_success_count(self):
|
|
192
|
-
"""Track success count."""
|
|
193
|
-
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1)
|
|
194
|
-
cb.record_success()
|
|
195
|
-
cb.record_success()
|
|
196
|
-
# Should track successes
|
|
197
|
-
assert cb.state == "CLOSED" or cb.state.name == "CLOSED"
|
|
198
|
-
|
|
199
|
-
def test_last_failure_time(self):
|
|
200
|
-
"""Track last failure time."""
|
|
201
|
-
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1)
|
|
202
|
-
cb.record_failure()
|
|
203
|
-
# Should have last_failure_time set
|
|
204
|
-
assert hasattr(cb, 'last_failure_time') or cb.state is not None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35}/tests/test_api_smoke.py
RENAMED
|
File without changes
|
{violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35}/tests/test_mock_server.py
RENAMED
|
File without changes
|
|
File without changes
|
{violet_poolcontroller_api-0.0.33 → violet_poolcontroller_api-0.0.35}/tests/test_readings.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|