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.
@@ -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})"