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.
Files changed (25) hide show
  1. {violet_poolcontroller_api-0.0.31/violet_poolController_api.egg-info → violet_poolcontroller_api-0.0.32}/PKG-INFO +1 -1
  2. {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/pyproject.toml +1 -1
  3. violet_poolcontroller_api-0.0.32/tests/test_circuit_breaker.py +204 -0
  4. violet_poolcontroller_api-0.0.32/tests/test_parsers.py +161 -0
  5. violet_poolcontroller_api-0.0.32/tests/test_readings.py +192 -0
  6. {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32/violet_poolController_api.egg-info}/PKG-INFO +1 -1
  7. {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolController_api.egg-info/SOURCES.txt +3 -0
  8. {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/api.py +5 -2
  9. {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/utils_rate_limiter.py +11 -2
  10. {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/utils_sanitizer.py +5 -0
  11. {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/LICENSE +0 -0
  12. {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/README.md +0 -0
  13. {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/setup.cfg +0 -0
  14. {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/tests/test_api.py +0 -0
  15. {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/tests/test_api_smoke.py +0 -0
  16. {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/tests/test_mock_server.py +0 -0
  17. {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolController_api.egg-info/dependency_links.txt +0 -0
  18. {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolController_api.egg-info/requires.txt +0 -0
  19. {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolController_api.egg-info/top_level.txt +0 -0
  20. {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/__init__.py +0 -0
  21. {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/circuit_breaker.py +0 -0
  22. {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/const_api.py +0 -0
  23. {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/const_devices.py +0 -0
  24. {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/parsers.py +0 -0
  25. {violet_poolcontroller_api-0.0.31 → violet_poolcontroller_api-0.0.32}/violet_poolcontroller_api/readings.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: violet-poolController-api
3
- Version: 0.0.31
3
+ Version: 0.0.32
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "violet-poolController-api"
7
- version = "0.0.31"
7
+ version = "0.0.32"
8
8
  authors = [
9
9
  { name="Basti (Xerolux)", email="git@xerolux.de" },
10
10
  ]
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: violet-poolController-api
3
- Version: 0.0.31
3
+ Version: 0.0.32
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
@@ -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
- await asyncio.sleep(delay)
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
- await asyncio.sleep(delay)
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
- # Warte auf Token-Refill
175
- await asyncio.sleep(self.retry_after)
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(