violet-poolController-api 0.0.1__py3-none-any.whl
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/__init__.py +0 -0
- violet_poolcontroller_api/api.py +957 -0
- violet_poolcontroller_api/circuit_breaker.py +172 -0
- violet_poolcontroller_api/const_api.py +136 -0
- violet_poolcontroller_api/const_devices.py +307 -0
- violet_poolcontroller_api/utils_rate_limiter.py +235 -0
- violet_poolcontroller_api/utils_sanitizer.py +488 -0
- violet_poolcontroller_api-0.0.1.dist-info/METADATA +122 -0
- violet_poolcontroller_api-0.0.1.dist-info/RECORD +12 -0
- violet_poolcontroller_api-0.0.1.dist-info/WHEEL +5 -0
- violet_poolcontroller_api-0.0.1.dist-info/licenses/LICENSE +21 -0
- violet_poolcontroller_api-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
|
|
2
|
+
"""Circuit breaker implementation for resilient API calls."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
_LOGGER = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CircuitBreakerState:
|
|
15
|
+
"""Circuit breaker states."""
|
|
16
|
+
|
|
17
|
+
CLOSED = "CLOSED" # Normal operation
|
|
18
|
+
OPEN = "OPEN" # Circuit is open, calls fail fast
|
|
19
|
+
HALF_OPEN = "HALF_OPEN" # Testing if service recovered
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CircuitBreaker:
|
|
23
|
+
"""Circuit breaker pattern for API calls with automatic recovery.
|
|
24
|
+
|
|
25
|
+
Protects against cascading failures when the controller or network is down.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
failure_threshold: int = 5,
|
|
31
|
+
timeout: float = 60.0,
|
|
32
|
+
recovery_timeout: float = 300.0,
|
|
33
|
+
expected_exception: type[BaseException] = Exception,
|
|
34
|
+
):
|
|
35
|
+
"""
|
|
36
|
+
Initialize circuit breaker.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
failure_threshold: Number of failures before opening circuit
|
|
40
|
+
timeout: How long to keep circuit open (seconds)
|
|
41
|
+
recovery_timeout: How long to stay in half-open state
|
|
42
|
+
expected_exception: Exception type to consider for failures
|
|
43
|
+
"""
|
|
44
|
+
self.failure_threshold = failure_threshold
|
|
45
|
+
self.timeout = timeout
|
|
46
|
+
self.recovery_timeout = recovery_timeout
|
|
47
|
+
self.expected_exception = expected_exception
|
|
48
|
+
|
|
49
|
+
self.failure_count = 0
|
|
50
|
+
self.last_failure_time = 0.0
|
|
51
|
+
self.state = CircuitBreakerState.CLOSED
|
|
52
|
+
self.half_open_start_time = 0.0
|
|
53
|
+
|
|
54
|
+
# Protects mutable state from concurrent coroutine access (e.g., asyncio.gather)
|
|
55
|
+
self._lock = asyncio.Lock()
|
|
56
|
+
|
|
57
|
+
_LOGGER.debug(
|
|
58
|
+
"Circuit breaker initialized: threshold=%d, timeout=%.1fs, recovery=%.1fs",
|
|
59
|
+
failure_threshold,
|
|
60
|
+
timeout,
|
|
61
|
+
recovery_timeout,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
async def call(self, func: Callable, *args, **kwargs) -> Any:
|
|
65
|
+
"""
|
|
66
|
+
Execute function with circuit breaker protection.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
func: The async function to call
|
|
70
|
+
*args: Arguments to pass to function
|
|
71
|
+
**kwargs: Keyword arguments to pass to function
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Result of function call
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
CircuitBreakerOpenError: If circuit is open
|
|
78
|
+
"""
|
|
79
|
+
current_time = time.monotonic()
|
|
80
|
+
|
|
81
|
+
# Check and update circuit state under lock to prevent races
|
|
82
|
+
async with self._lock:
|
|
83
|
+
# Check if circuit should be closed from timeout
|
|
84
|
+
if (
|
|
85
|
+
self.state == CircuitBreakerState.OPEN
|
|
86
|
+
and current_time - self.last_failure_time > self.timeout
|
|
87
|
+
):
|
|
88
|
+
self.state = CircuitBreakerState.HALF_OPEN
|
|
89
|
+
self.half_open_start_time = current_time
|
|
90
|
+
_LOGGER.info("Circuit breaker entering HALF_OPEN state for recovery test")
|
|
91
|
+
|
|
92
|
+
# Check if half-open timeout exceeded
|
|
93
|
+
if (
|
|
94
|
+
self.state == CircuitBreakerState.HALF_OPEN
|
|
95
|
+
and current_time - self.half_open_start_time > self.recovery_timeout
|
|
96
|
+
):
|
|
97
|
+
self.state = CircuitBreakerState.CLOSED
|
|
98
|
+
self.failure_count = 0
|
|
99
|
+
_LOGGER.info("Circuit breaker recovered to CLOSED state")
|
|
100
|
+
|
|
101
|
+
# Fail fast if circuit is open
|
|
102
|
+
if self.state == CircuitBreakerState.OPEN:
|
|
103
|
+
raise CircuitBreakerOpenError("Circuit breaker is OPEN")
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
# Execute the function outside the lock to avoid blocking other coroutines
|
|
107
|
+
result = await func(*args, **kwargs)
|
|
108
|
+
|
|
109
|
+
# Success: reset failure count and close circuit if half-open
|
|
110
|
+
async with self._lock:
|
|
111
|
+
if self.state == CircuitBreakerState.HALF_OPEN:
|
|
112
|
+
self.state = CircuitBreakerState.CLOSED
|
|
113
|
+
self.failure_count = 0
|
|
114
|
+
_LOGGER.info("Circuit breaker recovered from HALF_OPEN to CLOSED")
|
|
115
|
+
else:
|
|
116
|
+
self.failure_count = 0
|
|
117
|
+
|
|
118
|
+
return result
|
|
119
|
+
|
|
120
|
+
except self.expected_exception as err:
|
|
121
|
+
async with self._lock:
|
|
122
|
+
self.failure_count += 1
|
|
123
|
+
self.last_failure_time = current_time
|
|
124
|
+
|
|
125
|
+
_LOGGER.debug(
|
|
126
|
+
"Circuit breaker failure %d/%d: %s",
|
|
127
|
+
self.failure_count,
|
|
128
|
+
self.failure_threshold,
|
|
129
|
+
str(err),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Open circuit if threshold reached
|
|
133
|
+
if self.failure_count >= self.failure_threshold:
|
|
134
|
+
self.state = CircuitBreakerState.OPEN
|
|
135
|
+
_LOGGER.warning(
|
|
136
|
+
"Circuit breaker OPENED due to %d failures",
|
|
137
|
+
self.failure_threshold,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Re-raise the original exception
|
|
141
|
+
raise
|
|
142
|
+
|
|
143
|
+
except Exception as err:
|
|
144
|
+
# Unexpected exception - don't count for circuit breaker
|
|
145
|
+
_LOGGER.exception("Unexpected error in circuit breaker: %s", str(err))
|
|
146
|
+
raise
|
|
147
|
+
|
|
148
|
+
def get_stats(self) -> dict[str, Any]:
|
|
149
|
+
"""Get circuit breaker statistics."""
|
|
150
|
+
return {
|
|
151
|
+
"state": self.state,
|
|
152
|
+
"failure_count": self.failure_count,
|
|
153
|
+
"failure_threshold": self.failure_threshold,
|
|
154
|
+
"timeout": self.timeout,
|
|
155
|
+
"recovery_timeout": self.recovery_timeout,
|
|
156
|
+
"last_failure_time": self.last_failure_time,
|
|
157
|
+
"half_open_start_time": self.half_open_start_time,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
def reset(self) -> None:
|
|
161
|
+
"""Manually reset the circuit breaker (call from sync context only)."""
|
|
162
|
+
self.state = CircuitBreakerState.CLOSED
|
|
163
|
+
self.failure_count = 0
|
|
164
|
+
self.last_failure_time = 0.0
|
|
165
|
+
self.half_open_start_time = 0.0
|
|
166
|
+
_LOGGER.info("Circuit breaker manually reset to CLOSED state")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class CircuitBreakerOpenError(Exception):
|
|
170
|
+
"""Exception raised when circuit breaker is open."""
|
|
171
|
+
|
|
172
|
+
pass
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
|
|
2
|
+
"""This module defines constants related to the Violet Pool Controller API.
|
|
3
|
+
|
|
4
|
+
It includes API endpoints, command actions, rate limiting settings, and
|
|
5
|
+
definitions for various controllable functions like switches, covers, and dosing pumps.
|
|
6
|
+
These constants provide a centralized and consistent way to interact with the
|
|
7
|
+
controller's HTTP API.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# =============================================================================
|
|
12
|
+
# API ENDPOINTS
|
|
13
|
+
# =============================================================================
|
|
14
|
+
|
|
15
|
+
API_READINGS = "/getReadings"
|
|
16
|
+
API_SET_FUNCTION_MANUALLY = "/setFunctionManually"
|
|
17
|
+
API_SET_DOSING_PARAMETERS = "/setDosingParameters"
|
|
18
|
+
API_SET_TARGET_VALUES = "/setTargetValues"
|
|
19
|
+
API_GET_CONFIG = "/getConfig"
|
|
20
|
+
API_SET_CONFIG = "/setConfig"
|
|
21
|
+
API_GET_CALIB_RAW_VALUES = "/getCalibRawValues"
|
|
22
|
+
API_GET_CALIB_HISTORY = "/getCalibHistory"
|
|
23
|
+
API_RESTORE_CALIBRATION = "/restoreOldCalib"
|
|
24
|
+
API_SET_OUTPUT_TESTMODE = "/setOutputTestmode"
|
|
25
|
+
API_GET_HISTORY = "/getHistory"
|
|
26
|
+
API_GET_WEATHER_DATA = "/getWeatherdata"
|
|
27
|
+
API_GET_OVERALL_DOSING = "/getOverallDosing"
|
|
28
|
+
API_GET_OUTPUT_STATES = "/getOutputstates"
|
|
29
|
+
|
|
30
|
+
# Settings for optimizing data refreshes by fetching specific groups.
|
|
31
|
+
SPECIFIC_READING_GROUPS = (
|
|
32
|
+
"ADC",
|
|
33
|
+
"DOSAGE",
|
|
34
|
+
"RUNTIMES",
|
|
35
|
+
"PUMPPRIOSTATE",
|
|
36
|
+
"BACKWASH",
|
|
37
|
+
"SYSTEM",
|
|
38
|
+
"INPUT1",
|
|
39
|
+
"INPUT2",
|
|
40
|
+
"INPUT3",
|
|
41
|
+
"INPUT4",
|
|
42
|
+
"date",
|
|
43
|
+
"time",
|
|
44
|
+
)
|
|
45
|
+
SPECIFIC_FULL_REFRESH_INTERVAL = 10 # Number of updates before a full refresh
|
|
46
|
+
|
|
47
|
+
# =============================================================================
|
|
48
|
+
# API ACTIONS
|
|
49
|
+
# =============================================================================
|
|
50
|
+
|
|
51
|
+
ACTION_ON = "ON"
|
|
52
|
+
ACTION_OFF = "OFF"
|
|
53
|
+
ACTION_AUTO = "AUTO"
|
|
54
|
+
ACTION_PUSH = "PUSH"
|
|
55
|
+
ACTION_MAN = "MAN"
|
|
56
|
+
ACTION_COLOR = "COLOR"
|
|
57
|
+
ACTION_ALLON = "ALLON"
|
|
58
|
+
ACTION_ALLOFF = "ALLOFF"
|
|
59
|
+
ACTION_ALLAUTO = "ALLAUTO"
|
|
60
|
+
ACTION_LOCK = "LOCK"
|
|
61
|
+
ACTION_UNLOCK = "UNLOCK"
|
|
62
|
+
|
|
63
|
+
# Common Query and Target Parameters
|
|
64
|
+
QUERY_ALL = "ALL"
|
|
65
|
+
TARGET_PH = "pH"
|
|
66
|
+
TARGET_ORP = "ORP"
|
|
67
|
+
TARGET_MIN_CHLORINE = "MinChlorine"
|
|
68
|
+
KEY_MAINTENANCE = "MAINTENANCE"
|
|
69
|
+
KEY_PVSURPLUS = "PVSURPLUS"
|
|
70
|
+
|
|
71
|
+
# =============================================================================
|
|
72
|
+
# API RATE LIMITING
|
|
73
|
+
# =============================================================================
|
|
74
|
+
|
|
75
|
+
API_RATE_LIMIT_REQUESTS = 10 # Max requests per window
|
|
76
|
+
API_RATE_LIMIT_WINDOW = 1.0 # Window duration in seconds
|
|
77
|
+
API_RATE_LIMIT_BURST = 3 # Number of burst requests allowed
|
|
78
|
+
API_RATE_LIMIT_RETRY_AFTER = 0.1 # Wait time after exceeding the limit
|
|
79
|
+
|
|
80
|
+
# Priority levels for API requests
|
|
81
|
+
API_PRIORITY_CRITICAL = 1 # For state changes and critical operations
|
|
82
|
+
API_PRIORITY_HIGH = 2 # For target value updates
|
|
83
|
+
API_PRIORITY_NORMAL = 3 # For regular data fetches
|
|
84
|
+
API_PRIORITY_LOW = 4 # For history and statistics
|
|
85
|
+
|
|
86
|
+
# =============================================================================
|
|
87
|
+
# API FUNCTION AND KEY DEFINITIONS
|
|
88
|
+
# =============================================================================
|
|
89
|
+
|
|
90
|
+
# Base switchable functions
|
|
91
|
+
SWITCH_FUNCTIONS = {
|
|
92
|
+
"PUMP": "Filter Pump",
|
|
93
|
+
"SOLAR": "Solar Absorber",
|
|
94
|
+
"HEATER": "Heater",
|
|
95
|
+
"LIGHT": "Lighting",
|
|
96
|
+
"ECO": "Eco Mode",
|
|
97
|
+
"BACKWASH": "Backwash",
|
|
98
|
+
"BACKWASHRINSE": "Backwash Rinse",
|
|
99
|
+
"REFILL": "Water Refill",
|
|
100
|
+
"PVSURPLUS": "PV Surplus",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# Dynamically add extension relays
|
|
104
|
+
for ext_bank in [1, 2]:
|
|
105
|
+
for relay_num in range(1, 9):
|
|
106
|
+
SWITCH_FUNCTIONS[f"EXT{ext_bank}_{relay_num}"] = (
|
|
107
|
+
f"Erweiterung {ext_bank}.{relay_num}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Dynamically add DMX scenes
|
|
111
|
+
for scene_num in range(1, 13):
|
|
112
|
+
SWITCH_FUNCTIONS[f"DMX_SCENE{scene_num}"] = f"DMX Szene {scene_num}"
|
|
113
|
+
|
|
114
|
+
# Dynamically add digital input rules
|
|
115
|
+
for rule_num in range(1, 8):
|
|
116
|
+
SWITCH_FUNCTIONS[f"DIRULE_{rule_num}"] = f"Schaltregel {rule_num}"
|
|
117
|
+
|
|
118
|
+
# Dynamically add Omni DC outputs
|
|
119
|
+
for dc_num in range(6):
|
|
120
|
+
SWITCH_FUNCTIONS[f"OMNI_DC{dc_num}"] = f"Omni DC{dc_num}"
|
|
121
|
+
|
|
122
|
+
# Cover control functions
|
|
123
|
+
COVER_FUNCTIONS = {
|
|
124
|
+
"OPEN": "COVER_OPEN",
|
|
125
|
+
"CLOSE": "COVER_CLOSE",
|
|
126
|
+
"STOP": "COVER_STOP",
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
# Dosing pump functions
|
|
130
|
+
DOSING_FUNCTIONS = {
|
|
131
|
+
"pH-": "DOS_4_PHM",
|
|
132
|
+
"pH+": "DOS_5_PHP",
|
|
133
|
+
"Chlor": "DOS_1_CL",
|
|
134
|
+
"Elektrolyse": "DOS_2_ELO",
|
|
135
|
+
"Flockmittel": "DOS_6_FLOC",
|
|
136
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
|
|
2
|
+
"""This module defines constants related to device characteristics and states.
|
|
3
|
+
|
|
4
|
+
It includes detailed parameter configurations for various devices (e.g., pumps, heaters),
|
|
5
|
+
state mappings for normalizing device statuses, and visual configurations like icons
|
|
6
|
+
and colors. The module also provides helper functions and a `VioletState` class to
|
|
7
|
+
consistently interpret and manage device states throughout the integration.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, cast
|
|
12
|
+
|
|
13
|
+
# =============================================================================
|
|
14
|
+
# DEVICE PARAMETERS - Extended Configuration
|
|
15
|
+
# =============================================================================
|
|
16
|
+
|
|
17
|
+
DEVICE_PARAMETERS = {
|
|
18
|
+
"PUMP": {
|
|
19
|
+
"supports_speed": True,
|
|
20
|
+
"api_template": "PUMP,{action},{duration},{speed}",
|
|
21
|
+
},
|
|
22
|
+
"HEATER": {
|
|
23
|
+
"supports_timer": True,
|
|
24
|
+
"api_template": "HEATER,{action},{duration},0",
|
|
25
|
+
},
|
|
26
|
+
"SOLAR": {
|
|
27
|
+
"supports_timer": True,
|
|
28
|
+
"api_template": "SOLAR,{action},{duration},0",
|
|
29
|
+
},
|
|
30
|
+
"LIGHT": {
|
|
31
|
+
"supports_color_pulse": True,
|
|
32
|
+
"api_template": "LIGHT,{action},0,0",
|
|
33
|
+
},
|
|
34
|
+
"DOS_1_CL": {
|
|
35
|
+
"supports_timer": True,
|
|
36
|
+
"dosing_type": "Chlor",
|
|
37
|
+
"api_template": "DOS_1_CL,{action},{duration},0",
|
|
38
|
+
},
|
|
39
|
+
"DOS_4_PHM": {
|
|
40
|
+
"supports_timer": True,
|
|
41
|
+
"dosing_type": "pH-",
|
|
42
|
+
"api_template": "DOS_4_PHM,{action},{duration},0",
|
|
43
|
+
},
|
|
44
|
+
"DOS_5_PHP": {
|
|
45
|
+
"supports_timer": True,
|
|
46
|
+
"dosing_type": "pH+",
|
|
47
|
+
"api_template": "DOS_5_PHP,{action},{duration},0",
|
|
48
|
+
},
|
|
49
|
+
"DOS_6_FLOC": {
|
|
50
|
+
"supports_timer": True,
|
|
51
|
+
"dosing_type": "Flockmittel",
|
|
52
|
+
"api_template": "DOS_6_FLOC,{action},{duration},0",
|
|
53
|
+
},
|
|
54
|
+
"BACKWASH": {
|
|
55
|
+
"supports_timer": True,
|
|
56
|
+
"api_template": "BACKWASH,{action},{duration},0",
|
|
57
|
+
},
|
|
58
|
+
"BACKWASHRINSE": {
|
|
59
|
+
"supports_timer": True,
|
|
60
|
+
"api_template": "BACKWASHRINSE,{action},{duration},0",
|
|
61
|
+
},
|
|
62
|
+
"PVSURPLUS": {
|
|
63
|
+
"supports_speed": True,
|
|
64
|
+
"api_template": "PVSURPLUS,{action},{speed},0",
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Dynamically add extension relays
|
|
69
|
+
for ext_bank in [1, 2]:
|
|
70
|
+
for relay_num in range(1, 9):
|
|
71
|
+
key = f"EXT{ext_bank}_{relay_num}"
|
|
72
|
+
DEVICE_PARAMETERS[key] = {
|
|
73
|
+
"supports_timer": True,
|
|
74
|
+
"api_template": f"EXT{ext_bank}_{relay_num},{{action}},{{duration}},0",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Dynamically add digital input rules
|
|
78
|
+
for rule_num in range(1, 8):
|
|
79
|
+
key = f"DIRULE_{rule_num}"
|
|
80
|
+
DEVICE_PARAMETERS[key] = {
|
|
81
|
+
"supports_lock": True,
|
|
82
|
+
"api_template": f"DIRULE_{rule_num},{{action}},0,0",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# Dynamically add DMX scenes
|
|
86
|
+
for scene_num in range(1, 13):
|
|
87
|
+
key = f"DMX_SCENE{scene_num}"
|
|
88
|
+
DEVICE_PARAMETERS[key] = {
|
|
89
|
+
"api_template": f"DMX_SCENE{scene_num},{{action}},0,0",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# =============================================================================
|
|
93
|
+
# STATE MAPPINGS - Critical for 3-State Logic
|
|
94
|
+
# =============================================================================
|
|
95
|
+
|
|
96
|
+
DEVICE_STATE_MAPPING = {
|
|
97
|
+
# String-based states from the API
|
|
98
|
+
"ON": {"mode": "manual", "active": True, "desc": "Manual ON"},
|
|
99
|
+
"OFF": {"mode": "manual", "active": False, "desc": "Manual OFF"},
|
|
100
|
+
"AUTO": {"mode": "auto", "active": None, "desc": "Auto Mode"},
|
|
101
|
+
# Numeric states from the API
|
|
102
|
+
"0": {"mode": "auto", "active": False, "desc": "Auto - Standby"},
|
|
103
|
+
"1": {"mode": "manual", "active": True, "desc": "Manual ON"},
|
|
104
|
+
"2": {"mode": "auto", "active": True, "desc": "Auto - Active"},
|
|
105
|
+
"3": {"mode": "auto", "active": True, "desc": "Auto - Active (Timer)"},
|
|
106
|
+
"4": {"mode": "manual", "active": True, "desc": "Manual ON (Forced)"},
|
|
107
|
+
"5": {"mode": "auto", "active": False, "desc": "Auto - Waiting"},
|
|
108
|
+
"6": {"mode": "manual", "active": False, "desc": "Manual OFF"},
|
|
109
|
+
# Special protection modes (from PUMPSTATE field with pipe separator)
|
|
110
|
+
"3|PUMP_ANTI_FREEZE": {
|
|
111
|
+
"mode": "frost_protection",
|
|
112
|
+
"active": True,
|
|
113
|
+
"desc": "Frost Protection Active",
|
|
114
|
+
},
|
|
115
|
+
"PUMP_ANTI_FREEZE": {
|
|
116
|
+
"mode": "frost_protection",
|
|
117
|
+
"active": True,
|
|
118
|
+
"desc": "Frost Protection Active",
|
|
119
|
+
},
|
|
120
|
+
# Generic operational states
|
|
121
|
+
"STOPPED": {"mode": "manual", "active": False, "desc": "Stopped"},
|
|
122
|
+
"ERROR": {"mode": "error", "active": False, "desc": "Error State"},
|
|
123
|
+
"MAINTENANCE": {"mode": "maintenance", "active": False, "desc": "Maintenance"},
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# Simplified map for quick boolean checks (is the device considered "on"?)
|
|
127
|
+
STATE_MAP = {
|
|
128
|
+
# Numeric states (as int and str)
|
|
129
|
+
0: False,
|
|
130
|
+
1: True,
|
|
131
|
+
2: True,
|
|
132
|
+
3: True,
|
|
133
|
+
4: True,
|
|
134
|
+
5: False,
|
|
135
|
+
6: False,
|
|
136
|
+
"0": False,
|
|
137
|
+
"1": True,
|
|
138
|
+
"2": True,
|
|
139
|
+
"3": True,
|
|
140
|
+
"4": True,
|
|
141
|
+
"5": False,
|
|
142
|
+
"6": False,
|
|
143
|
+
# String states
|
|
144
|
+
"ON": True,
|
|
145
|
+
"OFF": False,
|
|
146
|
+
"AUTO": False,
|
|
147
|
+
"MAN": True,
|
|
148
|
+
"MANUAL": True,
|
|
149
|
+
"ACTIVE": True,
|
|
150
|
+
"RUNNING": True,
|
|
151
|
+
"IDLE": False,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# State mapping specific to cover entities
|
|
155
|
+
COVER_STATE_MAP = {
|
|
156
|
+
"0": "open",
|
|
157
|
+
"1": "opening",
|
|
158
|
+
"2": "closed",
|
|
159
|
+
"3": "closing",
|
|
160
|
+
"4": "stopped",
|
|
161
|
+
"OPEN": "open",
|
|
162
|
+
"OPENING": "opening",
|
|
163
|
+
"CLOSED": "closed",
|
|
164
|
+
"CLOSING": "closing",
|
|
165
|
+
"STOPPED": "stopped",
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# =============================================================================
|
|
169
|
+
# STATE VISUALIZATION
|
|
170
|
+
# =============================================================================
|
|
171
|
+
|
|
172
|
+
STATE_ICONS = {
|
|
173
|
+
"auto_active": "mdi:autorenew",
|
|
174
|
+
"auto_inactive": "mdi:autorenew-off",
|
|
175
|
+
"manual_on": "mdi:power-plug",
|
|
176
|
+
"manual_off": "mdi:power-plug-off",
|
|
177
|
+
"frost_protection": "mdi:snowflake-alert",
|
|
178
|
+
"error": "mdi:alert-circle",
|
|
179
|
+
"maintenance": "mdi:wrench",
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
STATE_COLORS = {
|
|
183
|
+
"auto_active": "#4CAF50", # Green
|
|
184
|
+
"auto_inactive": "#2196F3", # Blue
|
|
185
|
+
"manual_on": "#FF9800", # Orange
|
|
186
|
+
"manual_off": "#F44336", # Red
|
|
187
|
+
"frost_protection": "#00BCD4", # Cyan (frost/ice color)
|
|
188
|
+
"error": "#9C27B0", # Purple
|
|
189
|
+
"maintenance": "#607D8B", # Blue Grey
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
STATE_TRANSLATIONS = {
|
|
193
|
+
"en": {
|
|
194
|
+
"auto_active": "Auto (Active)",
|
|
195
|
+
"auto_inactive": "Auto (Ready)",
|
|
196
|
+
"manual_on": "Manual On",
|
|
197
|
+
"manual_off": "Manual Off",
|
|
198
|
+
"frost_protection": "Frost Protection",
|
|
199
|
+
"error": "Error",
|
|
200
|
+
"maintenance": "Maintenance",
|
|
201
|
+
"unknown": "Unknown",
|
|
202
|
+
},
|
|
203
|
+
"de": {
|
|
204
|
+
"auto_active": "Automatik (Aktiv)",
|
|
205
|
+
"auto_inactive": "Automatik (Bereit)",
|
|
206
|
+
"manual_on": "Manuell Ein",
|
|
207
|
+
"manual_off": "Manuell Aus",
|
|
208
|
+
"frost_protection": "Frostschutz",
|
|
209
|
+
"error": "Fehler",
|
|
210
|
+
"maintenance": "Wartung",
|
|
211
|
+
"unknown": "Unbekannt",
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
# =============================================================================
|
|
216
|
+
# HELPER FUNCTIONS and STATE CLASS
|
|
217
|
+
# =============================================================================
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def get_device_state_info(raw_state: Any) -> dict[str, Any]:
|
|
221
|
+
"""Get extended state information for a given raw state.
|
|
222
|
+
|
|
223
|
+
Handles plain numeric states ("2"), pipe-separated composite states
|
|
224
|
+
("2|BLOCKED_BY_OUTSIDE_TEMP"), and empty arrays ("[]").
|
|
225
|
+
"""
|
|
226
|
+
state_str = str(raw_state).upper().strip()
|
|
227
|
+
|
|
228
|
+
# Direct lookup first (handles exact matches like "3|PUMP_ANTI_FREEZE")
|
|
229
|
+
if state_str in DEVICE_STATE_MAPPING:
|
|
230
|
+
return cast(dict[str, Any], DEVICE_STATE_MAPPING[state_str])
|
|
231
|
+
|
|
232
|
+
# Handle pipe-separated composite states by extracting numeric prefix
|
|
233
|
+
if "|" in state_str:
|
|
234
|
+
prefix = state_str.split("|", 1)[0].strip()
|
|
235
|
+
if prefix in DEVICE_STATE_MAPPING:
|
|
236
|
+
return cast(dict[str, Any], DEVICE_STATE_MAPPING[prefix])
|
|
237
|
+
|
|
238
|
+
# Handle empty arrays (e.g., SOLARSTATE = "[]")
|
|
239
|
+
if state_str in ("[]", "{}", ""):
|
|
240
|
+
return {"mode": "unknown", "active": None, "desc": "No data"}
|
|
241
|
+
|
|
242
|
+
return cast(
|
|
243
|
+
dict[str, Any],
|
|
244
|
+
{"mode": "unknown", "active": None, "desc": f"Unknown: {raw_state}"},
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def get_device_mode_from_state(raw_state: Any) -> str:
|
|
249
|
+
"""Determine the UI display mode from a raw state."""
|
|
250
|
+
state_info = get_device_state_info(raw_state)
|
|
251
|
+
mode, active = state_info["mode"], state_info["active"]
|
|
252
|
+
|
|
253
|
+
if mode == "manual":
|
|
254
|
+
return "manual_on" if active else "manual_off"
|
|
255
|
+
if mode == "auto":
|
|
256
|
+
return "auto_active" if active else "auto_inactive"
|
|
257
|
+
return cast(str, mode)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class VioletState:
|
|
261
|
+
"""A helper class to interpret and manage complex device states.
|
|
262
|
+
|
|
263
|
+
This class provides a structured way to access different aspects of a
|
|
264
|
+
device's state, such as its operational mode, activity status, and
|
|
265
|
+
UI representations (icon, color, translated name).
|
|
266
|
+
|
|
267
|
+
Attributes:
|
|
268
|
+
raw_state (str): The original state value from the controller.
|
|
269
|
+
device_key (str | None): The unique key of the device.
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
def __init__(self, raw_state: Any, device_key: str | None = None):
|
|
273
|
+
self.raw_state = str(raw_state).strip()
|
|
274
|
+
self.device_key = device_key
|
|
275
|
+
self._info = get_device_state_info(self.raw_state)
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def mode(self) -> str:
|
|
279
|
+
"""The primary operational mode (e.g., 'auto', 'manual', 'error')."""
|
|
280
|
+
return cast(str, self._info["mode"])
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def is_active(self) -> bool | None:
|
|
284
|
+
"""Whether the device is currently active (running)."""
|
|
285
|
+
return cast(bool | None, self._info["active"])
|
|
286
|
+
|
|
287
|
+
@property
|
|
288
|
+
def description(self) -> str:
|
|
289
|
+
"""A human-readable description of the current state."""
|
|
290
|
+
return cast(str, self._info["desc"])
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def display_mode(self) -> str:
|
|
294
|
+
"""The translated name for the current state, suitable for UI display."""
|
|
295
|
+
mode_key = get_device_mode_from_state(self.raw_state)
|
|
296
|
+
return STATE_TRANSLATIONS.get("de", {}).get(
|
|
297
|
+
mode_key, mode_key.replace("_", " ").title()
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
@property
|
|
301
|
+
def icon(self) -> str:
|
|
302
|
+
"""The appropriate icon for the current state."""
|
|
303
|
+
mode_key = get_device_mode_from_state(self.raw_state)
|
|
304
|
+
return STATE_ICONS.get(mode_key, "mdi:help-circle")
|
|
305
|
+
|
|
306
|
+
def __repr__(self) -> str:
|
|
307
|
+
return f"VioletState(raw='{self.raw_state}', mode='{self.mode}', active={self.is_active})"
|