violet-poolController-api 0.0.31__tar.gz → 0.0.32__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.31/violet_poolController_api.egg-info → violet_poolcontroller_api-0.0.32}/PKG-INFO +1 -1
- {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/pyproject.toml +1 -1
- violet_poolcontroller_api-0.0.32/tests/test_circuit_breaker.py +204 -0
- violet_poolcontroller_api-0.0.32/tests/test_parsers.py +161 -0
- violet_poolcontroller_api-0.0.32/tests/test_readings.py +192 -0
- {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32/violet_poolController_api.egg-info}/PKG-INFO +1 -1
- {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolController_api.egg-info/SOURCES.txt +3 -0
- {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/api.py +5 -2
- {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/utils_rate_limiter.py +11 -2
- {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/utils_sanitizer.py +5 -0
- {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/LICENSE +0 -0
- {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/README.md +0 -0
- {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/setup.cfg +0 -0
- {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/tests/test_api.py +0 -0
- {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/tests/test_api_smoke.py +0 -0
- {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/tests/test_mock_server.py +0 -0
- {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolController_api.egg-info/dependency_links.txt +0 -0
- {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolController_api.egg-info/requires.txt +0 -0
- {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolController_api.egg-info/top_level.txt +0 -0
- {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/__init__.py +0 -0
- {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/circuit_breaker.py +0 -0
- {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/const_api.py +0 -0
- {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/const_devices.py +0 -0
- {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/parsers.py +0 -0
- {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/readings.py +0 -0
|
@@ -0,0 +1,204 @@
|
|
|
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
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Tests for violet_poolcontroller_api.parsers module."""
|
|
2
|
+
|
|
3
|
+
from violet_poolcontroller_api.parsers import (
|
|
4
|
+
parse_epoch_milliseconds,
|
|
5
|
+
parse_epoch_seconds,
|
|
6
|
+
parse_hms_string,
|
|
7
|
+
parse_optional_seconds,
|
|
8
|
+
parse_runtime_string,
|
|
9
|
+
parse_uptime_string,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestParseRuntimeString:
|
|
14
|
+
"""Test parse_runtime_string parser."""
|
|
15
|
+
|
|
16
|
+
def test_valid_runtime_string(self):
|
|
17
|
+
"""Parse valid runtime format."""
|
|
18
|
+
assert parse_runtime_string("00:05:30") == 330 # 5min 30sec in seconds
|
|
19
|
+
assert parse_runtime_string("01:30:45") == 5445 # 1h 30min 45sec
|
|
20
|
+
assert parse_runtime_string("00:00:00") == 0
|
|
21
|
+
|
|
22
|
+
def test_invalid_runtime_string(self):
|
|
23
|
+
"""Handle invalid runtime formats gracefully."""
|
|
24
|
+
assert parse_runtime_string("invalid") is None or isinstance(parse_runtime_string("invalid"), (int, float))
|
|
25
|
+
assert parse_runtime_string("") is None or isinstance(parse_runtime_string(""), (int, float))
|
|
26
|
+
assert parse_runtime_string(None) is None or isinstance(parse_runtime_string(None), (int, float))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestParseHmsString:
|
|
30
|
+
"""Test parse_hms_string parser."""
|
|
31
|
+
|
|
32
|
+
def test_valid_hms_string(self):
|
|
33
|
+
"""Parse valid HH:MM:SS format."""
|
|
34
|
+
assert parse_hms_string("01:30:45") == 5445
|
|
35
|
+
assert parse_hms_string("00:00:01") == 1
|
|
36
|
+
assert parse_hms_string("23:59:59") == 86399
|
|
37
|
+
|
|
38
|
+
def test_edge_cases(self):
|
|
39
|
+
"""Handle edge cases."""
|
|
40
|
+
assert parse_hms_string("00:00:00") == 0
|
|
41
|
+
# All-zeros case
|
|
42
|
+
|
|
43
|
+
def test_invalid_hms(self):
|
|
44
|
+
"""Handle invalid HMS formats."""
|
|
45
|
+
result = parse_hms_string("invalid")
|
|
46
|
+
assert result is None or isinstance(result, (int, float))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestParseUptimeString:
|
|
50
|
+
"""Test parse_uptime_string parser."""
|
|
51
|
+
|
|
52
|
+
def test_valid_uptime_formats(self):
|
|
53
|
+
"""Parse valid uptime formats."""
|
|
54
|
+
# Support various uptime formats
|
|
55
|
+
result = parse_uptime_string("5 days, 3:45:22")
|
|
56
|
+
assert isinstance(result, (int, float)) or result is None
|
|
57
|
+
|
|
58
|
+
def test_uptime_with_days(self):
|
|
59
|
+
"""Parse uptime with days."""
|
|
60
|
+
result = parse_uptime_string("365 days")
|
|
61
|
+
assert isinstance(result, (int, float)) or result is None
|
|
62
|
+
|
|
63
|
+
def test_invalid_uptime(self):
|
|
64
|
+
"""Handle invalid uptime formats."""
|
|
65
|
+
result = parse_uptime_string("invalid")
|
|
66
|
+
assert result is None or isinstance(result, (int, float))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TestParseOptionalSeconds:
|
|
70
|
+
"""Test parse_optional_seconds parser."""
|
|
71
|
+
|
|
72
|
+
def test_valid_seconds(self):
|
|
73
|
+
"""Parse valid second values."""
|
|
74
|
+
assert parse_optional_seconds("120") == 120
|
|
75
|
+
assert parse_optional_seconds("0") == 0
|
|
76
|
+
assert parse_optional_seconds("3600") == 3600
|
|
77
|
+
|
|
78
|
+
def test_none_sentinel(self):
|
|
79
|
+
"""Handle 'NONE' sentinel value."""
|
|
80
|
+
result = parse_optional_seconds("NONE")
|
|
81
|
+
assert result is None or result == 0 or result == -1
|
|
82
|
+
|
|
83
|
+
def test_invalid_seconds(self):
|
|
84
|
+
"""Handle invalid seconds."""
|
|
85
|
+
result = parse_optional_seconds("invalid")
|
|
86
|
+
assert result is None or isinstance(result, (int, float))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TestParseEpochSeconds:
|
|
90
|
+
"""Test parse_epoch_seconds parser."""
|
|
91
|
+
|
|
92
|
+
def test_valid_epoch(self):
|
|
93
|
+
"""Parse valid epoch timestamp (seconds)."""
|
|
94
|
+
# Jan 1, 1970 00:00:00 UTC
|
|
95
|
+
assert parse_epoch_seconds("0") == 0
|
|
96
|
+
# Some arbitrary timestamp
|
|
97
|
+
assert isinstance(parse_epoch_seconds("1609459200"), (int, float))
|
|
98
|
+
|
|
99
|
+
def test_zero_epoch(self):
|
|
100
|
+
"""Handle zero epoch."""
|
|
101
|
+
assert parse_epoch_seconds("0") == 0
|
|
102
|
+
|
|
103
|
+
def test_invalid_epoch(self):
|
|
104
|
+
"""Handle invalid epoch values."""
|
|
105
|
+
result = parse_epoch_seconds("invalid")
|
|
106
|
+
assert result is None or isinstance(result, (int, float))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class TestParseEpochMilliseconds:
|
|
110
|
+
"""Test parse_epoch_milliseconds parser."""
|
|
111
|
+
|
|
112
|
+
def test_valid_epoch_ms(self):
|
|
113
|
+
"""Parse valid epoch timestamp (milliseconds)."""
|
|
114
|
+
result = parse_epoch_milliseconds("1609459200000")
|
|
115
|
+
assert isinstance(result, (int, float)) or result is None
|
|
116
|
+
|
|
117
|
+
def test_zero_epoch_ms(self):
|
|
118
|
+
"""Handle zero epoch."""
|
|
119
|
+
result = parse_epoch_milliseconds("0")
|
|
120
|
+
assert result == 0 or result is None
|
|
121
|
+
|
|
122
|
+
def test_invalid_epoch_ms(self):
|
|
123
|
+
"""Handle invalid epoch values."""
|
|
124
|
+
result = parse_epoch_milliseconds("invalid")
|
|
125
|
+
assert result is None or isinstance(result, (int, float))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TestParserEdgeCases:
|
|
129
|
+
"""Test edge cases across all parsers."""
|
|
130
|
+
|
|
131
|
+
def test_empty_string(self):
|
|
132
|
+
"""Parsers handle empty strings gracefully."""
|
|
133
|
+
for parser in [
|
|
134
|
+
parse_runtime_string,
|
|
135
|
+
parse_hms_string,
|
|
136
|
+
parse_uptime_string,
|
|
137
|
+
parse_optional_seconds,
|
|
138
|
+
parse_epoch_seconds,
|
|
139
|
+
parse_epoch_milliseconds,
|
|
140
|
+
]:
|
|
141
|
+
result = parser("")
|
|
142
|
+
assert result is None or isinstance(result, (int, float))
|
|
143
|
+
|
|
144
|
+
def test_none_input(self):
|
|
145
|
+
"""Parsers handle None input gracefully."""
|
|
146
|
+
for parser in [
|
|
147
|
+
parse_runtime_string,
|
|
148
|
+
parse_hms_string,
|
|
149
|
+
parse_uptime_string,
|
|
150
|
+
parse_optional_seconds,
|
|
151
|
+
parse_epoch_seconds,
|
|
152
|
+
parse_epoch_milliseconds,
|
|
153
|
+
]:
|
|
154
|
+
result = parser(None)
|
|
155
|
+
assert result is None or isinstance(result, (int, float))
|
|
156
|
+
|
|
157
|
+
def test_whitespace_handling(self):
|
|
158
|
+
"""Parsers handle whitespace appropriately."""
|
|
159
|
+
# Parsers should either strip or reject whitespace
|
|
160
|
+
result = parse_runtime_string(" 00:05:30 ")
|
|
161
|
+
assert result is None or isinstance(result, (int, float))
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Tests for violet_poolcontroller_api.readings module."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from violet_poolcontroller_api.readings import VioletReadings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestVioletReadingsBasic:
|
|
9
|
+
"""Test VioletReadings basic functionality."""
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def sample_data(self):
|
|
13
|
+
"""Provide sample API response data."""
|
|
14
|
+
return {
|
|
15
|
+
"POOL_TEMP": 24.5,
|
|
16
|
+
"SOLAR_TEMP": 32.1,
|
|
17
|
+
"AMBIENT_TEMP": 18.3,
|
|
18
|
+
"pH": 7.2,
|
|
19
|
+
"ORP": 650,
|
|
20
|
+
"PUMP_STATE": "1",
|
|
21
|
+
"HEATER_STATE": "0",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
def test_readings_creation(self, sample_data):
|
|
25
|
+
"""Create VioletReadings from dict."""
|
|
26
|
+
readings = VioletReadings(sample_data)
|
|
27
|
+
assert readings is not None
|
|
28
|
+
assert isinstance(readings, dict) # VioletReadings is dict-like
|
|
29
|
+
|
|
30
|
+
def test_readings_value_access(self, sample_data):
|
|
31
|
+
"""Access values from VioletReadings."""
|
|
32
|
+
readings = VioletReadings(sample_data)
|
|
33
|
+
assert readings.get("POOL_TEMP") == 24.5
|
|
34
|
+
assert readings.get("pH") == 7.2
|
|
35
|
+
|
|
36
|
+
def test_readings_missing_key(self, sample_data):
|
|
37
|
+
"""Handle missing keys gracefully."""
|
|
38
|
+
readings = VioletReadings(sample_data)
|
|
39
|
+
assert readings.get("NONEXISTENT") is None
|
|
40
|
+
assert readings.get("NONEXISTENT", "default") == "default"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestVioletReadingsTypeConversions:
|
|
44
|
+
"""Test typed property access and conversions."""
|
|
45
|
+
|
|
46
|
+
@pytest.fixture
|
|
47
|
+
def mixed_data(self):
|
|
48
|
+
"""Data with various types."""
|
|
49
|
+
return {
|
|
50
|
+
"TEMPERATURE": "24.5",
|
|
51
|
+
"STATE": "1",
|
|
52
|
+
"RUNTIME": 3600,
|
|
53
|
+
"ENABLED": "true",
|
|
54
|
+
"DISABLED": "false",
|
|
55
|
+
"NULL_VALUE": None,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def test_temperature_conversion(self, mixed_data):
|
|
59
|
+
"""Convert temperature string to float."""
|
|
60
|
+
readings = VioletReadings(mixed_data)
|
|
61
|
+
temp = readings.get("TEMPERATURE")
|
|
62
|
+
# Should be convertible to float
|
|
63
|
+
assert isinstance(temp, (int, float, str))
|
|
64
|
+
|
|
65
|
+
def test_state_conversion(self, mixed_data):
|
|
66
|
+
"""Convert state string to int."""
|
|
67
|
+
readings = VioletReadings(mixed_data)
|
|
68
|
+
state = readings.get("STATE")
|
|
69
|
+
assert state is not None
|
|
70
|
+
|
|
71
|
+
def test_boolean_conversion(self, mixed_data):
|
|
72
|
+
"""Convert boolean strings."""
|
|
73
|
+
readings = VioletReadings(mixed_data)
|
|
74
|
+
enabled = readings.get("ENABLED")
|
|
75
|
+
disabled = readings.get("DISABLED")
|
|
76
|
+
# Should be interpretable as boolean
|
|
77
|
+
assert enabled is not None
|
|
78
|
+
assert disabled is not None
|
|
79
|
+
|
|
80
|
+
def test_none_handling(self, mixed_data):
|
|
81
|
+
"""Handle None values properly."""
|
|
82
|
+
readings = VioletReadings(mixed_data)
|
|
83
|
+
null_val = readings.get("NULL_VALUE")
|
|
84
|
+
assert null_val is None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TestVioletReadingsAggregation:
|
|
88
|
+
"""Test readings aggregation and derived properties."""
|
|
89
|
+
|
|
90
|
+
def test_readings_contains_all_keys(self):
|
|
91
|
+
"""Verify readings contains all provided keys."""
|
|
92
|
+
data = {
|
|
93
|
+
"KEY1": "value1",
|
|
94
|
+
"KEY2": "value2",
|
|
95
|
+
"KEY3": "value3",
|
|
96
|
+
}
|
|
97
|
+
readings = VioletReadings(data)
|
|
98
|
+
assert "KEY1" in readings
|
|
99
|
+
assert "KEY2" in readings
|
|
100
|
+
assert "KEY3" in readings
|
|
101
|
+
|
|
102
|
+
def test_readings_iteration(self):
|
|
103
|
+
"""Iterate over readings."""
|
|
104
|
+
data = {"A": 1, "B": 2, "C": 3}
|
|
105
|
+
readings = VioletReadings(data)
|
|
106
|
+
keys = list(readings.keys())
|
|
107
|
+
assert "A" in keys
|
|
108
|
+
assert "B" in keys
|
|
109
|
+
assert "C" in keys
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TestVioletReadingsEdgeCases:
|
|
113
|
+
"""Test edge cases in readings handling."""
|
|
114
|
+
|
|
115
|
+
def test_empty_readings(self):
|
|
116
|
+
"""Create readings from empty dict."""
|
|
117
|
+
readings = VioletReadings({})
|
|
118
|
+
assert len(readings) == 0
|
|
119
|
+
|
|
120
|
+
def test_large_dataset(self):
|
|
121
|
+
"""Handle large dataset."""
|
|
122
|
+
large_data = {f"KEY_{i}": f"value_{i}" for i in range(1000)}
|
|
123
|
+
readings = VioletReadings(large_data)
|
|
124
|
+
assert len(readings) == 1000
|
|
125
|
+
assert readings.get("KEY_0") == "value_0"
|
|
126
|
+
assert readings.get("KEY_999") == "value_999"
|
|
127
|
+
|
|
128
|
+
def test_special_characters_in_values(self):
|
|
129
|
+
"""Handle special characters in values."""
|
|
130
|
+
data = {
|
|
131
|
+
"UNICODE": "测试",
|
|
132
|
+
"SPECIAL": "!@#$%^&*()",
|
|
133
|
+
"QUOTES": 'value with "quotes"',
|
|
134
|
+
}
|
|
135
|
+
readings = VioletReadings(data)
|
|
136
|
+
assert readings.get("UNICODE") == "测试"
|
|
137
|
+
assert readings.get("SPECIAL") == "!@#$%^&*()"
|
|
138
|
+
|
|
139
|
+
def test_numeric_edge_cases(self):
|
|
140
|
+
"""Handle numeric edge cases."""
|
|
141
|
+
data = {
|
|
142
|
+
"ZERO": 0,
|
|
143
|
+
"NEGATIVE": -100,
|
|
144
|
+
"FLOAT": 3.14159,
|
|
145
|
+
"LARGE": 999999999,
|
|
146
|
+
"SCIENTIFIC": 1e-6,
|
|
147
|
+
}
|
|
148
|
+
readings = VioletReadings(data)
|
|
149
|
+
assert readings.get("ZERO") == 0
|
|
150
|
+
assert readings.get("NEGATIVE") == -100
|
|
151
|
+
assert readings.get("FLOAT") == 3.14159
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class TestVioletReadingsIntegration:
|
|
155
|
+
"""Integration tests with realistic data."""
|
|
156
|
+
|
|
157
|
+
def test_realistic_pool_data(self):
|
|
158
|
+
"""Test with realistic pool controller data."""
|
|
159
|
+
realistic_data = {
|
|
160
|
+
"POOL_TEMP": 24.5,
|
|
161
|
+
"SOLAR_TEMP": 35.2,
|
|
162
|
+
"AMBIENT_TEMP": 22.0,
|
|
163
|
+
"pH": 7.3,
|
|
164
|
+
"ORP": 680,
|
|
165
|
+
"CONDUCTIVITY": 1200,
|
|
166
|
+
"PUMP_STATE": "1",
|
|
167
|
+
"HEATER_STATE": "0",
|
|
168
|
+
"SOLAR_STATE": "1",
|
|
169
|
+
"DOSING_PH_MINUS": "0",
|
|
170
|
+
"DOSING_PH_PLUS": "0",
|
|
171
|
+
"DOSING_CL": "1",
|
|
172
|
+
"PUMP_RUNTIME": 3600,
|
|
173
|
+
"FILTER_RUNTIME": 3600,
|
|
174
|
+
"ERROR_CODE": "0",
|
|
175
|
+
"DI1": "0",
|
|
176
|
+
"DI2": "1",
|
|
177
|
+
"DO1": "0",
|
|
178
|
+
"DO2": "1",
|
|
179
|
+
}
|
|
180
|
+
readings = VioletReadings(realistic_data)
|
|
181
|
+
assert len(readings) == 20
|
|
182
|
+
assert readings.get("POOL_TEMP") == 24.5
|
|
183
|
+
assert readings.get("DI1") == "0"
|
|
184
|
+
|
|
185
|
+
def test_readings_snapshot_behavior(self):
|
|
186
|
+
"""Verify readings behave as immutable snapshots."""
|
|
187
|
+
data = {"TEMP": 25.0}
|
|
188
|
+
readings = VioletReadings(data)
|
|
189
|
+
# Original data shouldn't affect readings after creation
|
|
190
|
+
original_temp = readings.get("TEMP")
|
|
191
|
+
# Readings should be stable
|
|
192
|
+
assert readings.get("TEMP") == original_temp
|
|
@@ -3,7 +3,10 @@ README.md
|
|
|
3
3
|
pyproject.toml
|
|
4
4
|
tests/test_api.py
|
|
5
5
|
tests/test_api_smoke.py
|
|
6
|
+
tests/test_circuit_breaker.py
|
|
6
7
|
tests/test_mock_server.py
|
|
8
|
+
tests/test_parsers.py
|
|
9
|
+
tests/test_readings.py
|
|
7
10
|
violet_poolController_api.egg-info/PKG-INFO
|
|
8
11
|
violet_poolController_api.egg-info/SOURCES.txt
|
|
9
12
|
violet_poolController_api.egg-info/dependency_links.txt
|
|
@@ -22,6 +22,7 @@ import asyncio
|
|
|
22
22
|
import json
|
|
23
23
|
import logging
|
|
24
24
|
import math
|
|
25
|
+
import random
|
|
25
26
|
import re
|
|
26
27
|
import ssl
|
|
27
28
|
from typing import TYPE_CHECKING, Any, cast
|
|
@@ -470,7 +471,8 @@ class VioletPoolAPI:
|
|
|
470
471
|
if attempt == self._max_retries:
|
|
471
472
|
raise last_error from None
|
|
472
473
|
delay = min(2.0, 0.2 * (2 ** (attempt - 1)))
|
|
473
|
-
|
|
474
|
+
jitter = random.uniform(0, delay * 0.1) # Add 0-10% jitter
|
|
475
|
+
await asyncio.sleep(delay + jitter)
|
|
474
476
|
except aiohttp.ClientError as err:
|
|
475
477
|
last_error = VioletPoolAPIError(
|
|
476
478
|
f"Error communicating with Violet controller: {err}",
|
|
@@ -485,7 +487,8 @@ class VioletPoolAPI:
|
|
|
485
487
|
raise last_error from None
|
|
486
488
|
# Exponential backoff with jitter
|
|
487
489
|
delay = min(2.0, 0.2 * (2 ** (attempt - 1)))
|
|
488
|
-
|
|
490
|
+
jitter = random.uniform(0, delay * 0.1) # Add 0-10% jitter
|
|
491
|
+
await asyncio.sleep(delay + jitter)
|
|
489
492
|
|
|
490
493
|
msg = "All retry attempts exhausted"
|
|
491
494
|
raise VioletPoolAPIError(msg)
|
|
@@ -171,8 +171,17 @@ class RateLimiter:
|
|
|
171
171
|
msg = f"Rate Limiter timeout nach {elapsed:.1f}s"
|
|
172
172
|
raise TimeoutError(msg)
|
|
173
173
|
|
|
174
|
-
#
|
|
175
|
-
|
|
174
|
+
# Adaptive Wartezeit: Berechne basierend auf Refill-Rate
|
|
175
|
+
# Statt fest retry_after zu warten, berechne optimale Wartezeit
|
|
176
|
+
refill_rate = self.max_requests / self.time_window
|
|
177
|
+
needed_tokens = 1.0 - self.tokens # Wieviele Tokens fehlen
|
|
178
|
+
if refill_rate > 0:
|
|
179
|
+
optimal_wait = needed_tokens / refill_rate
|
|
180
|
+
wait_time = min(optimal_wait, self.retry_after)
|
|
181
|
+
else:
|
|
182
|
+
wait_time = self.retry_after
|
|
183
|
+
|
|
184
|
+
await asyncio.sleep(wait_time)
|
|
176
185
|
|
|
177
186
|
def _refill_tokens(self, current_time: float) -> None:
|
|
178
187
|
"""Fülle Token-Bucket basierend auf verstrichener Zeit."""
|
|
@@ -21,6 +21,7 @@ from __future__ import annotations
|
|
|
21
21
|
|
|
22
22
|
import logging
|
|
23
23
|
import re
|
|
24
|
+
import unicodedata
|
|
24
25
|
from html import escape
|
|
25
26
|
from typing import Any
|
|
26
27
|
|
|
@@ -83,6 +84,10 @@ class InputSanitizer:
|
|
|
83
84
|
# Konvertiere zu String
|
|
84
85
|
str_value = str(value).strip()
|
|
85
86
|
|
|
87
|
+
# Unicode-Normalisierung (NFKD für Defense-in-Depth)
|
|
88
|
+
# Normalized Form Compatibility Decomposition
|
|
89
|
+
str_value = unicodedata.normalize("NFKD", str_value)
|
|
90
|
+
|
|
86
91
|
# Längen-Validierung
|
|
87
92
|
if len(str_value) > max_length:
|
|
88
93
|
_LOGGER.warning(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/tests/test_api_smoke.py
RENAMED
|
File without changes
|
{violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/tests/test_mock_server.py
RENAMED
|
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
|
|
File without changes
|