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.
Files changed (71) hide show
  1. ha_mcp/__init__.py +51 -0
  2. ha_mcp/__main__.py +753 -0
  3. ha_mcp/_pypi_marker +0 -0
  4. ha_mcp/auth/__init__.py +10 -0
  5. ha_mcp/auth/consent_form.py +501 -0
  6. ha_mcp/auth/provider.py +786 -0
  7. ha_mcp/client/__init__.py +10 -0
  8. ha_mcp/client/rest_client.py +924 -0
  9. ha_mcp/client/websocket_client.py +656 -0
  10. ha_mcp/client/websocket_listener.py +340 -0
  11. ha_mcp/config.py +175 -0
  12. ha_mcp/errors.py +399 -0
  13. ha_mcp/py.typed +0 -0
  14. ha_mcp/resources/card_types.json +48 -0
  15. ha_mcp/resources/dashboard_guide.md +573 -0
  16. ha_mcp/server.py +189 -0
  17. ha_mcp/smoke_test.py +108 -0
  18. ha_mcp/tools/__init__.py +11 -0
  19. ha_mcp/tools/backup.py +457 -0
  20. ha_mcp/tools/device_control.py +687 -0
  21. ha_mcp/tools/enhanced.py +191 -0
  22. ha_mcp/tools/helpers.py +184 -0
  23. ha_mcp/tools/registry.py +199 -0
  24. ha_mcp/tools/smart_search.py +797 -0
  25. ha_mcp/tools/tools_addons.py +343 -0
  26. ha_mcp/tools/tools_areas.py +478 -0
  27. ha_mcp/tools/tools_blueprints.py +279 -0
  28. ha_mcp/tools/tools_bug_report.py +570 -0
  29. ha_mcp/tools/tools_calendar.py +391 -0
  30. ha_mcp/tools/tools_camera.py +149 -0
  31. ha_mcp/tools/tools_config_automations.py +508 -0
  32. ha_mcp/tools/tools_config_dashboards.py +1502 -0
  33. ha_mcp/tools/tools_config_entry_flow.py +223 -0
  34. ha_mcp/tools/tools_config_helpers.py +925 -0
  35. ha_mcp/tools/tools_config_info.py +258 -0
  36. ha_mcp/tools/tools_config_scripts.py +267 -0
  37. ha_mcp/tools/tools_entities.py +76 -0
  38. ha_mcp/tools/tools_filesystem.py +589 -0
  39. ha_mcp/tools/tools_groups.py +306 -0
  40. ha_mcp/tools/tools_hacs.py +787 -0
  41. ha_mcp/tools/tools_history.py +714 -0
  42. ha_mcp/tools/tools_integrations.py +297 -0
  43. ha_mcp/tools/tools_labels.py +713 -0
  44. ha_mcp/tools/tools_mcp_component.py +305 -0
  45. ha_mcp/tools/tools_registry.py +1115 -0
  46. ha_mcp/tools/tools_resources.py +622 -0
  47. ha_mcp/tools/tools_search.py +573 -0
  48. ha_mcp/tools/tools_service.py +211 -0
  49. ha_mcp/tools/tools_services.py +352 -0
  50. ha_mcp/tools/tools_system.py +363 -0
  51. ha_mcp/tools/tools_todo.py +530 -0
  52. ha_mcp/tools/tools_traces.py +496 -0
  53. ha_mcp/tools/tools_updates.py +476 -0
  54. ha_mcp/tools/tools_utility.py +519 -0
  55. ha_mcp/tools/tools_voice_assistant.py +345 -0
  56. ha_mcp/tools/tools_zones.py +387 -0
  57. ha_mcp/tools/util_helpers.py +199 -0
  58. ha_mcp/utils/__init__.py +30 -0
  59. ha_mcp/utils/domain_handlers.py +380 -0
  60. ha_mcp/utils/fuzzy_search.py +339 -0
  61. ha_mcp/utils/operation_manager.py +442 -0
  62. ha_mcp/utils/usage_logger.py +290 -0
  63. ha_mcp_dev-6.3.1.dev137.dist-info/METADATA +229 -0
  64. ha_mcp_dev-6.3.1.dev137.dist-info/RECORD +71 -0
  65. ha_mcp_dev-6.3.1.dev137.dist-info/WHEEL +5 -0
  66. ha_mcp_dev-6.3.1.dev137.dist-info/entry_points.txt +7 -0
  67. ha_mcp_dev-6.3.1.dev137.dist-info/licenses/LICENSE +21 -0
  68. ha_mcp_dev-6.3.1.dev137.dist-info/top_level.txt +2 -0
  69. tests/__init__.py +1 -0
  70. tests/test_constants.py +15 -0
  71. 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