ha-mcp-dev 6.3.1.dev137__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.
- ha_mcp/__init__.py +51 -0
- ha_mcp/__main__.py +753 -0
- ha_mcp/_pypi_marker +0 -0
- ha_mcp/auth/__init__.py +10 -0
- ha_mcp/auth/consent_form.py +501 -0
- ha_mcp/auth/provider.py +786 -0
- ha_mcp/client/__init__.py +10 -0
- ha_mcp/client/rest_client.py +924 -0
- ha_mcp/client/websocket_client.py +656 -0
- ha_mcp/client/websocket_listener.py +340 -0
- ha_mcp/config.py +175 -0
- ha_mcp/errors.py +399 -0
- ha_mcp/py.typed +0 -0
- ha_mcp/resources/card_types.json +48 -0
- ha_mcp/resources/dashboard_guide.md +573 -0
- ha_mcp/server.py +189 -0
- ha_mcp/smoke_test.py +108 -0
- ha_mcp/tools/__init__.py +11 -0
- ha_mcp/tools/backup.py +457 -0
- ha_mcp/tools/device_control.py +687 -0
- ha_mcp/tools/enhanced.py +191 -0
- ha_mcp/tools/helpers.py +184 -0
- ha_mcp/tools/registry.py +199 -0
- ha_mcp/tools/smart_search.py +797 -0
- ha_mcp/tools/tools_addons.py +343 -0
- ha_mcp/tools/tools_areas.py +478 -0
- ha_mcp/tools/tools_blueprints.py +279 -0
- ha_mcp/tools/tools_bug_report.py +570 -0
- ha_mcp/tools/tools_calendar.py +391 -0
- ha_mcp/tools/tools_camera.py +149 -0
- ha_mcp/tools/tools_config_automations.py +508 -0
- ha_mcp/tools/tools_config_dashboards.py +1502 -0
- ha_mcp/tools/tools_config_entry_flow.py +223 -0
- ha_mcp/tools/tools_config_helpers.py +925 -0
- ha_mcp/tools/tools_config_info.py +258 -0
- ha_mcp/tools/tools_config_scripts.py +267 -0
- ha_mcp/tools/tools_entities.py +76 -0
- ha_mcp/tools/tools_filesystem.py +589 -0
- ha_mcp/tools/tools_groups.py +306 -0
- ha_mcp/tools/tools_hacs.py +787 -0
- ha_mcp/tools/tools_history.py +714 -0
- ha_mcp/tools/tools_integrations.py +297 -0
- ha_mcp/tools/tools_labels.py +713 -0
- ha_mcp/tools/tools_mcp_component.py +305 -0
- ha_mcp/tools/tools_registry.py +1115 -0
- ha_mcp/tools/tools_resources.py +622 -0
- ha_mcp/tools/tools_search.py +573 -0
- ha_mcp/tools/tools_service.py +211 -0
- ha_mcp/tools/tools_services.py +352 -0
- ha_mcp/tools/tools_system.py +363 -0
- ha_mcp/tools/tools_todo.py +530 -0
- ha_mcp/tools/tools_traces.py +496 -0
- ha_mcp/tools/tools_updates.py +476 -0
- ha_mcp/tools/tools_utility.py +519 -0
- ha_mcp/tools/tools_voice_assistant.py +345 -0
- ha_mcp/tools/tools_zones.py +387 -0
- ha_mcp/tools/util_helpers.py +199 -0
- ha_mcp/utils/__init__.py +30 -0
- ha_mcp/utils/domain_handlers.py +380 -0
- ha_mcp/utils/fuzzy_search.py +339 -0
- ha_mcp/utils/operation_manager.py +442 -0
- ha_mcp/utils/usage_logger.py +290 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/METADATA +229 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/RECORD +71 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/WHEEL +5 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/entry_points.txt +7 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/licenses/LICENSE +21 -0
- ha_mcp_dev-6.3.1.dev137.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/test_constants.py +15 -0
- tests/test_env_manager.py +336 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Background WebSocket listener for Home Assistant state changes.
|
|
3
|
+
|
|
4
|
+
This module provides a background service that listens to Home Assistant
|
|
5
|
+
WebSocket events and updates operation status in real-time.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ..config import get_global_settings
|
|
14
|
+
from ..utils.operation_manager import get_operation_manager, update_pending_operations
|
|
15
|
+
from .websocket_client import HomeAssistantWebSocketClient, get_websocket_client
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class WebSocketListenerService:
|
|
21
|
+
"""Background service for listening to Home Assistant WebSocket events."""
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
"""Initialize the WebSocket listener service."""
|
|
25
|
+
self.settings = get_global_settings()
|
|
26
|
+
self.operation_manager = get_operation_manager()
|
|
27
|
+
self.websocket_client: HomeAssistantWebSocketClient | None = None
|
|
28
|
+
self.listener_task: asyncio.Task | None = None
|
|
29
|
+
self.cleanup_task: asyncio.Task | None = None
|
|
30
|
+
self.running = False
|
|
31
|
+
self._event_loop: asyncio.AbstractEventLoop | None = None
|
|
32
|
+
self.stats: dict[str, Any] = {
|
|
33
|
+
"events_processed": 0,
|
|
34
|
+
"operations_updated": 0,
|
|
35
|
+
"connection_errors": 0,
|
|
36
|
+
"last_event_time": None,
|
|
37
|
+
"start_time": None,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async def start(self) -> bool:
|
|
41
|
+
"""Start the WebSocket listener service.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
True if service started successfully
|
|
45
|
+
"""
|
|
46
|
+
if self.running:
|
|
47
|
+
logger.warning("WebSocket listener already running")
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
# Get WebSocket client
|
|
52
|
+
self.websocket_client = await get_websocket_client()
|
|
53
|
+
|
|
54
|
+
# Subscribe to state change events
|
|
55
|
+
await self.websocket_client.subscribe_events("state_changed")
|
|
56
|
+
|
|
57
|
+
# Add event handler
|
|
58
|
+
self.websocket_client.add_event_handler(
|
|
59
|
+
"state_changed", self._handle_state_change
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Start background tasks
|
|
63
|
+
self.listener_task = asyncio.create_task(self._connection_monitor())
|
|
64
|
+
self.cleanup_task = asyncio.create_task(self._periodic_cleanup())
|
|
65
|
+
|
|
66
|
+
self.running = True
|
|
67
|
+
self.stats["start_time"] = datetime.now()
|
|
68
|
+
|
|
69
|
+
logger.info("WebSocket listener service started successfully")
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
except Exception as e:
|
|
73
|
+
logger.error(f"Failed to start WebSocket listener service: {e}")
|
|
74
|
+
await self.stop()
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
async def stop(self) -> None:
|
|
78
|
+
"""Stop the WebSocket listener service."""
|
|
79
|
+
if not self.running:
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
self.running = False
|
|
83
|
+
|
|
84
|
+
# Cancel background tasks
|
|
85
|
+
if self.listener_task and not self.listener_task.done():
|
|
86
|
+
self.listener_task.cancel()
|
|
87
|
+
try:
|
|
88
|
+
await self.listener_task
|
|
89
|
+
except asyncio.CancelledError:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
if self.cleanup_task and not self.cleanup_task.done():
|
|
93
|
+
self.cleanup_task.cancel()
|
|
94
|
+
try:
|
|
95
|
+
await self.cleanup_task
|
|
96
|
+
except asyncio.CancelledError:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
# Remove event handler if WebSocket client exists
|
|
100
|
+
if self.websocket_client:
|
|
101
|
+
self.websocket_client.remove_event_handler(
|
|
102
|
+
"state_changed", self._handle_state_change
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
logger.info("WebSocket listener service stopped")
|
|
106
|
+
|
|
107
|
+
async def _handle_state_change(self, event: dict[str, Any]) -> None:
|
|
108
|
+
"""Handle state change events from Home Assistant.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
event: State change event data
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
events_processed = self.stats["events_processed"]
|
|
115
|
+
if isinstance(events_processed, int):
|
|
116
|
+
self.stats["events_processed"] = events_processed + 1
|
|
117
|
+
self.stats["last_event_time"] = datetime.now()
|
|
118
|
+
|
|
119
|
+
# Extract event data
|
|
120
|
+
entity_id = event.get("entity_id")
|
|
121
|
+
new_state = event.get("new_state")
|
|
122
|
+
old_state = event.get("old_state")
|
|
123
|
+
|
|
124
|
+
if not entity_id or not new_state:
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
# Log significant state changes for debugging
|
|
128
|
+
if old_state and old_state.get("state") != new_state.get("state"):
|
|
129
|
+
logger.debug(
|
|
130
|
+
f"State change: {entity_id} {old_state.get('state')} -> {new_state.get('state')}"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Update pending operations
|
|
134
|
+
updated_ops = update_pending_operations(entity_id, new_state)
|
|
135
|
+
if updated_ops:
|
|
136
|
+
operations_updated = self.stats["operations_updated"]
|
|
137
|
+
if isinstance(operations_updated, int):
|
|
138
|
+
self.stats["operations_updated"] = operations_updated + len(updated_ops)
|
|
139
|
+
logger.info(f"Updated {len(updated_ops)} operations for {entity_id}")
|
|
140
|
+
|
|
141
|
+
except Exception as e:
|
|
142
|
+
logger.error(f"Error handling state change event: {e}")
|
|
143
|
+
|
|
144
|
+
async def _connection_monitor(self) -> None:
|
|
145
|
+
"""Monitor WebSocket connection health."""
|
|
146
|
+
while self.running:
|
|
147
|
+
try:
|
|
148
|
+
if self.websocket_client and self.websocket_client.is_connected:
|
|
149
|
+
# Ping Home Assistant to check connection
|
|
150
|
+
ping_success = await self.websocket_client.ping()
|
|
151
|
+
if not ping_success:
|
|
152
|
+
logger.warning("WebSocket ping failed")
|
|
153
|
+
connection_errors = self.stats["connection_errors"]
|
|
154
|
+
if isinstance(connection_errors, int):
|
|
155
|
+
self.stats["connection_errors"] = connection_errors + 1
|
|
156
|
+
else:
|
|
157
|
+
logger.warning("WebSocket connection lost")
|
|
158
|
+
connection_errors = self.stats["connection_errors"]
|
|
159
|
+
if isinstance(connection_errors, int):
|
|
160
|
+
self.stats["connection_errors"] = connection_errors + 1
|
|
161
|
+
|
|
162
|
+
# Try to reconnect
|
|
163
|
+
try:
|
|
164
|
+
self.websocket_client = await get_websocket_client()
|
|
165
|
+
await self.websocket_client.subscribe_events("state_changed")
|
|
166
|
+
self.websocket_client.add_event_handler(
|
|
167
|
+
"state_changed", self._handle_state_change
|
|
168
|
+
)
|
|
169
|
+
logger.info("WebSocket reconnected successfully")
|
|
170
|
+
except Exception as e:
|
|
171
|
+
logger.error(f"WebSocket reconnection failed: {e}")
|
|
172
|
+
|
|
173
|
+
# Wait before next health check
|
|
174
|
+
await asyncio.sleep(30) # Check every 30 seconds
|
|
175
|
+
|
|
176
|
+
except asyncio.CancelledError:
|
|
177
|
+
break
|
|
178
|
+
except Exception as e:
|
|
179
|
+
logger.error(f"Connection monitor error: {e}")
|
|
180
|
+
await asyncio.sleep(30)
|
|
181
|
+
|
|
182
|
+
async def _periodic_cleanup(self) -> None:
|
|
183
|
+
"""Periodic cleanup of expired operations."""
|
|
184
|
+
while self.running:
|
|
185
|
+
try:
|
|
186
|
+
# Clean up expired operations every 5 minutes
|
|
187
|
+
self.operation_manager.cleanup_expired_operations()
|
|
188
|
+
await asyncio.sleep(300) # 5 minutes
|
|
189
|
+
|
|
190
|
+
except asyncio.CancelledError:
|
|
191
|
+
break
|
|
192
|
+
except Exception as e:
|
|
193
|
+
logger.error(f"Cleanup task error: {e}")
|
|
194
|
+
await asyncio.sleep(300)
|
|
195
|
+
|
|
196
|
+
def get_status(self) -> dict[str, Any]:
|
|
197
|
+
"""Get service status and statistics.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Dictionary with service status and statistics
|
|
201
|
+
"""
|
|
202
|
+
uptime: float | None = None
|
|
203
|
+
start_time = self.stats["start_time"]
|
|
204
|
+
if isinstance(start_time, datetime):
|
|
205
|
+
uptime = (datetime.now() - start_time).total_seconds()
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
"running": self.running,
|
|
209
|
+
"websocket_connected": (
|
|
210
|
+
self.websocket_client.is_connected if self.websocket_client else False
|
|
211
|
+
),
|
|
212
|
+
"uptime_seconds": uptime,
|
|
213
|
+
"statistics": {
|
|
214
|
+
**self.stats,
|
|
215
|
+
"last_event_time": (
|
|
216
|
+
self.stats["last_event_time"].isoformat()
|
|
217
|
+
if isinstance(self.stats["last_event_time"], datetime)
|
|
218
|
+
else None
|
|
219
|
+
),
|
|
220
|
+
"start_time": (
|
|
221
|
+
self.stats["start_time"].isoformat()
|
|
222
|
+
if isinstance(self.stats["start_time"], datetime)
|
|
223
|
+
else None
|
|
224
|
+
),
|
|
225
|
+
},
|
|
226
|
+
"operation_summary": self.operation_manager.get_operations_summary(),
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async def force_reconnect(self) -> bool:
|
|
230
|
+
"""Force a WebSocket reconnection.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
True if reconnection successful
|
|
234
|
+
"""
|
|
235
|
+
try:
|
|
236
|
+
if self.websocket_client:
|
|
237
|
+
self.websocket_client.remove_event_handler(
|
|
238
|
+
"state_changed", self._handle_state_change
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
self.websocket_client = await get_websocket_client()
|
|
242
|
+
await self.websocket_client.subscribe_events("state_changed")
|
|
243
|
+
self.websocket_client.add_event_handler(
|
|
244
|
+
"state_changed", self._handle_state_change
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
logger.info("Forced WebSocket reconnection successful")
|
|
248
|
+
return True
|
|
249
|
+
|
|
250
|
+
except Exception as e:
|
|
251
|
+
logger.error(f"Forced reconnection failed: {e}")
|
|
252
|
+
connection_errors = self.stats["connection_errors"]
|
|
253
|
+
if isinstance(connection_errors, int):
|
|
254
|
+
self.stats["connection_errors"] = connection_errors + 1
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# Global listener service instance
|
|
259
|
+
_listener_service: WebSocketListenerService | None = None
|
|
260
|
+
_listener_lock: asyncio.Lock | None = None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
async def get_listener_service() -> WebSocketListenerService:
|
|
264
|
+
"""Get the global WebSocket listener service instance."""
|
|
265
|
+
global _listener_service, _listener_lock
|
|
266
|
+
import asyncio
|
|
267
|
+
|
|
268
|
+
current_loop = asyncio.get_event_loop()
|
|
269
|
+
|
|
270
|
+
# Initialize lock if needed (lazy initialization for event loop compatibility)
|
|
271
|
+
if _listener_lock is None:
|
|
272
|
+
_listener_lock = asyncio.Lock()
|
|
273
|
+
|
|
274
|
+
# Use async lock to prevent race conditions during concurrent access
|
|
275
|
+
async with _listener_lock:
|
|
276
|
+
# If event loop changed or service doesn't exist, create new instance
|
|
277
|
+
if _listener_service is None or (
|
|
278
|
+
hasattr(_listener_service, "_event_loop")
|
|
279
|
+
and _listener_service._event_loop != current_loop
|
|
280
|
+
):
|
|
281
|
+
# Stop existing service if it exists
|
|
282
|
+
if _listener_service is not None:
|
|
283
|
+
try:
|
|
284
|
+
await _listener_service.stop()
|
|
285
|
+
except Exception as e:
|
|
286
|
+
logger.debug(f"Error stopping previous listener service: {e}")
|
|
287
|
+
|
|
288
|
+
_listener_service = WebSocketListenerService()
|
|
289
|
+
_listener_service._event_loop = current_loop
|
|
290
|
+
|
|
291
|
+
return _listener_service
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
async def start_websocket_listener() -> bool:
|
|
295
|
+
"""Start the global WebSocket listener service."""
|
|
296
|
+
service = await get_listener_service()
|
|
297
|
+
if service.running:
|
|
298
|
+
logger.debug("WebSocket listener service already running")
|
|
299
|
+
return True
|
|
300
|
+
return await service.start()
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
async def stop_websocket_listener() -> None:
|
|
304
|
+
"""Stop the global WebSocket listener service."""
|
|
305
|
+
global _listener_service
|
|
306
|
+
if _listener_service:
|
|
307
|
+
await _listener_service.stop()
|
|
308
|
+
_listener_service = None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
async def get_listener_status() -> dict[str, Any]:
|
|
312
|
+
"""Get WebSocket listener service status."""
|
|
313
|
+
service = await get_listener_service()
|
|
314
|
+
return service.get_status()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class WebSocketContextManager:
|
|
318
|
+
"""Context manager for WebSocket listener lifecycle."""
|
|
319
|
+
|
|
320
|
+
def __init__(self) -> None:
|
|
321
|
+
self.service: WebSocketListenerService | None = None
|
|
322
|
+
|
|
323
|
+
async def __aenter__(self) -> WebSocketListenerService:
|
|
324
|
+
"""Start WebSocket listener."""
|
|
325
|
+
service = await get_listener_service()
|
|
326
|
+
success = await service.start()
|
|
327
|
+
if not success:
|
|
328
|
+
raise Exception("Failed to start WebSocket listener")
|
|
329
|
+
self.service = service
|
|
330
|
+
return service
|
|
331
|
+
|
|
332
|
+
async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: object) -> None:
|
|
333
|
+
"""Stop WebSocket listener."""
|
|
334
|
+
if self.service:
|
|
335
|
+
await self.service.stop()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def websocket_listener_context() -> WebSocketContextManager:
|
|
339
|
+
"""Create a WebSocket listener context manager."""
|
|
340
|
+
return WebSocketContextManager()
|
ha_mcp/config.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management for Home Assistant MCP Server.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
# Load environment variables from .env file with HAMCP_ENV_FILE support
|
|
8
|
+
# Use absolute path to ensure .env is found regardless of cwd
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from dotenv import load_dotenv
|
|
12
|
+
from pydantic import Field, field_validator
|
|
13
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
14
|
+
|
|
15
|
+
project_root = Path(__file__).parent.parent.parent
|
|
16
|
+
|
|
17
|
+
# Demo environment token - use HOMEASSISTANT_TOKEN="demo" to connect to the public demo
|
|
18
|
+
# Demo server: https://ha-mcp-demo-server.qc-h.net (login: mcp/mcp, resets weekly)
|
|
19
|
+
DEMO_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIxOTE5ZTZlMTVkYjI0Mzk2YTQ4YjFiZTI1MDM1YmU2YSIsImlhdCI6MTc1NzI4OTc5NiwiZXhwIjoyMDcyNjQ5Nzk2fQ.Yp9SSAjm2gvl9Xcu96FFxS8SapHxWAVzaI0E3cD9xac"
|
|
20
|
+
|
|
21
|
+
# Support for different environment files via HAMCP_ENV_FILE
|
|
22
|
+
env_file = os.getenv("HAMCP_ENV_FILE", ".env")
|
|
23
|
+
env_path = project_root / env_file
|
|
24
|
+
|
|
25
|
+
# Load the specified environment file (silently, since env vars may come from other sources)
|
|
26
|
+
if env_path.exists():
|
|
27
|
+
load_dotenv(env_path)
|
|
28
|
+
else:
|
|
29
|
+
# Fallback to default .env
|
|
30
|
+
default_env_path = project_root / ".env"
|
|
31
|
+
if default_env_path.exists():
|
|
32
|
+
load_dotenv(default_env_path)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Settings(BaseSettings):
|
|
36
|
+
"""Application settings loaded from environment variables."""
|
|
37
|
+
|
|
38
|
+
# Home Assistant connection
|
|
39
|
+
# In OAuth mode, these are optional and provided per-request
|
|
40
|
+
homeassistant_url: str = Field(default="http://oauth-mode", alias="HOMEASSISTANT_URL")
|
|
41
|
+
homeassistant_token: str = Field(default="oauth-mode-token", alias="HOMEASSISTANT_TOKEN")
|
|
42
|
+
|
|
43
|
+
# Server configuration
|
|
44
|
+
timeout: int = Field(30, alias="HA_TIMEOUT")
|
|
45
|
+
max_retries: int = Field(3, alias="HA_MAX_RETRIES")
|
|
46
|
+
|
|
47
|
+
# Tool configuration
|
|
48
|
+
fuzzy_threshold: int = Field(60, alias="FUZZY_THRESHOLD")
|
|
49
|
+
entity_search_limit: int = Field(20, alias="ENTITY_SEARCH_LIMIT")
|
|
50
|
+
|
|
51
|
+
# Backup tool configuration
|
|
52
|
+
backup_hint: str = Field("normal", alias="BACKUP_HINT")
|
|
53
|
+
|
|
54
|
+
# WebSocket configuration (essential for async operations)
|
|
55
|
+
enable_websocket: bool = Field(True, alias="ENABLE_WEBSOCKET")
|
|
56
|
+
|
|
57
|
+
# Development/Debug configuration
|
|
58
|
+
debug: bool = Field(False, alias="DEBUG")
|
|
59
|
+
log_level: str = Field("INFO", alias="LOG_LEVEL")
|
|
60
|
+
|
|
61
|
+
# MCP Server configuration
|
|
62
|
+
mcp_server_name: str = Field("ha-mcp", alias="MCP_SERVER_NAME")
|
|
63
|
+
mcp_server_version: str = Field("0.1.0", alias="MCP_SERVER_VERSION")
|
|
64
|
+
|
|
65
|
+
# Environment configuration
|
|
66
|
+
environment: str = Field("development", alias="ENVIRONMENT")
|
|
67
|
+
|
|
68
|
+
# Tool filtering - comma-separated list of module names to enable
|
|
69
|
+
# Special values: "all" (default), "automation" (automation-related tools only)
|
|
70
|
+
# Examples: "tools_config_automations,tools_config_scripts,tools_traces"
|
|
71
|
+
enabled_tool_modules: str = Field("all", alias="ENABLED_TOOL_MODULES")
|
|
72
|
+
|
|
73
|
+
# Dashboard partial update tools (jq_transform, find_card)
|
|
74
|
+
# These are token-efficient alternatives to full config replacement.
|
|
75
|
+
# Disable when using clients with programmatic tool use (future).
|
|
76
|
+
enable_dashboard_partial_tools: bool = Field(True, alias="ENABLE_DASHBOARD_PARTIAL_TOOLS")
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def env_file_name(self) -> str:
|
|
80
|
+
"""Get the current environment file name."""
|
|
81
|
+
return os.getenv("HAMCP_ENV_FILE", ".env")
|
|
82
|
+
|
|
83
|
+
@field_validator("homeassistant_url")
|
|
84
|
+
@classmethod
|
|
85
|
+
def validate_homeassistant_url(cls, v: str) -> str:
|
|
86
|
+
"""Ensure URL is properly formatted."""
|
|
87
|
+
# Allow OAuth mode placeholder
|
|
88
|
+
if v == "http://oauth-mode":
|
|
89
|
+
return v
|
|
90
|
+
if not v.startswith(("http://", "https://")):
|
|
91
|
+
raise ValueError("Home Assistant URL must start with http:// or https://")
|
|
92
|
+
return v.rstrip("/") # Remove trailing slash
|
|
93
|
+
|
|
94
|
+
@field_validator("homeassistant_token")
|
|
95
|
+
@classmethod
|
|
96
|
+
def validate_homeassistant_token(cls, v: str) -> str:
|
|
97
|
+
"""Ensure token is not empty. Use 'demo' for public demo environment."""
|
|
98
|
+
# Allow OAuth mode placeholder
|
|
99
|
+
if v == "oauth-mode-token":
|
|
100
|
+
return v
|
|
101
|
+
if not v or v == "your_long_lived_access_token_here":
|
|
102
|
+
raise ValueError("Home Assistant token must be provided")
|
|
103
|
+
# Replace "demo" with actual demo token for easy onboarding
|
|
104
|
+
if v.lower() == "demo":
|
|
105
|
+
return DEMO_TOKEN
|
|
106
|
+
return v
|
|
107
|
+
|
|
108
|
+
@field_validator("fuzzy_threshold")
|
|
109
|
+
@classmethod
|
|
110
|
+
def validate_fuzzy_threshold(cls, v: int) -> int:
|
|
111
|
+
"""Ensure fuzzy threshold is reasonable."""
|
|
112
|
+
if not 0 <= v <= 100:
|
|
113
|
+
raise ValueError("Fuzzy threshold must be between 0 and 100")
|
|
114
|
+
return v
|
|
115
|
+
|
|
116
|
+
@field_validator("log_level")
|
|
117
|
+
@classmethod
|
|
118
|
+
def validate_log_level(cls, v: str) -> str:
|
|
119
|
+
"""Ensure log level is valid."""
|
|
120
|
+
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
121
|
+
if v.upper() not in valid_levels:
|
|
122
|
+
raise ValueError(f"Log level must be one of {valid_levels}")
|
|
123
|
+
return v.upper()
|
|
124
|
+
|
|
125
|
+
@field_validator("backup_hint")
|
|
126
|
+
@classmethod
|
|
127
|
+
def validate_backup_hint(cls, v: str) -> str:
|
|
128
|
+
"""Ensure backup hint is valid."""
|
|
129
|
+
valid_hints = ["strong", "normal", "weak", "auto"]
|
|
130
|
+
if v.lower() not in valid_hints:
|
|
131
|
+
raise ValueError(f"Backup hint must be one of {valid_hints}")
|
|
132
|
+
return v.lower()
|
|
133
|
+
|
|
134
|
+
model_config = SettingsConfigDict(
|
|
135
|
+
env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="allow"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def get_settings() -> Settings:
|
|
140
|
+
"""Get application settings."""
|
|
141
|
+
return Settings() # type: ignore[call-arg]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def validate_settings() -> tuple[bool, str | None]:
|
|
145
|
+
"""
|
|
146
|
+
Validate settings and return (is_valid, error_message).
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
tuple: (True, None) if valid, (False, error_message) if invalid
|
|
150
|
+
"""
|
|
151
|
+
try:
|
|
152
|
+
settings = get_settings()
|
|
153
|
+
|
|
154
|
+
# Additional validation
|
|
155
|
+
if not settings.homeassistant_url:
|
|
156
|
+
return False, "Home Assistant URL is required"
|
|
157
|
+
|
|
158
|
+
if not settings.homeassistant_token:
|
|
159
|
+
return False, "Home Assistant token is required"
|
|
160
|
+
|
|
161
|
+
return True, None
|
|
162
|
+
except Exception as e:
|
|
163
|
+
return False, str(e)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# Global settings instance
|
|
167
|
+
_settings: Settings | None = None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_global_settings() -> Settings:
|
|
171
|
+
"""Get global settings instance (singleton pattern)."""
|
|
172
|
+
global _settings
|
|
173
|
+
if _settings is None:
|
|
174
|
+
_settings = get_settings()
|
|
175
|
+
return _settings
|