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,336 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Home Assistant MCP Test Environment Manager
4
+
5
+ Interactive test environment for Home Assistant MCP Server development.
6
+ Uses testcontainers to manage a Home Assistant instance for testing.
7
+
8
+ Environment Variables:
9
+ HA_TEST_PORT: Optional fixed port for Home Assistant container (default: random)
10
+ Example: HA_TEST_PORT=8123
11
+ """
12
+
13
+ import logging
14
+ import os
15
+ import subprocess
16
+ import sys
17
+ import time
18
+ from pathlib import Path
19
+
20
+ import requests
21
+ from testcontainers.core.container import DockerContainer
22
+
23
+ # Add tests directory to path for imports
24
+ sys.path.insert(0, str(Path(__file__).parent))
25
+ from test_constants import TEST_PASSWORD, TEST_TOKEN, TEST_USER
26
+
27
+ # Configure logging
28
+ logging.basicConfig(
29
+ level=logging.INFO,
30
+ format="%(asctime)s [%(levelname)8s] %(name)s: %(message)s",
31
+ datefmt="%H:%M:%S",
32
+ )
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ class HomeAssistantTestEnvironment:
37
+ """Manages a containerized Home Assistant test environment."""
38
+
39
+ def __init__(self):
40
+ self.container: DockerContainer | None = None
41
+ self.ha_url: str | None = None
42
+ self.ha_token = TEST_TOKEN
43
+ self.test_user = TEST_USER
44
+ self.test_password = TEST_PASSWORD
45
+
46
+ def _setup_config_directory(self) -> Path:
47
+ """Set up Home Assistant configuration directory."""
48
+ import tempfile
49
+ import shutil
50
+
51
+ # Create temporary directory for HA config
52
+ config_dir = Path(tempfile.mkdtemp(prefix="ha_test_env_"))
53
+
54
+ # Find initial_test_state directory
55
+ test_root = Path(__file__).parent
56
+ initial_state_paths = [
57
+ test_root / "initial_test_state",
58
+ test_root / "setup" / "homeassistant" / "initial_test_state",
59
+ test_root.parent / "initial_test_state",
60
+ ]
61
+
62
+ initial_state_dir = None
63
+ for path in initial_state_paths:
64
+ if path.exists():
65
+ initial_state_dir = path
66
+ break
67
+
68
+ if not initial_state_dir:
69
+ raise FileNotFoundError(
70
+ "Could not find initial_test_state directory. Checked:\n"
71
+ + "\n".join(f" - {p}" for p in initial_state_paths)
72
+ )
73
+
74
+ # Copy initial test state to config directory
75
+ logger.info(f"๐Ÿ“ Setting up config from: {initial_state_dir}")
76
+ for item in initial_state_dir.iterdir():
77
+ if item.is_file():
78
+ shutil.copy2(item, config_dir)
79
+ elif item.is_dir():
80
+ shutil.copytree(item, config_dir / item.name)
81
+
82
+ # Set proper permissions
83
+ os.chmod(config_dir, 0o755)
84
+ for item in config_dir.rglob("*"):
85
+ if item.is_file():
86
+ os.chmod(item, 0o644)
87
+ elif item.is_dir():
88
+ os.chmod(item, 0o755)
89
+
90
+ logger.info(f"๐Ÿ“‚ Config directory prepared: {config_dir}")
91
+ return config_dir
92
+
93
+ def start_container(self) -> None:
94
+ """Start the Home Assistant container."""
95
+ if self.container:
96
+ logger.warning("Container is already running")
97
+ return
98
+
99
+ logger.info("๐Ÿณ Starting Home Assistant test container...")
100
+
101
+ # Set up config directory
102
+ config_dir = self._setup_config_directory()
103
+
104
+ # Create container with port configuration
105
+ # renovate: datasource=docker depName=ghcr.io/home-assistant/home-assistant
106
+ container = DockerContainer("ghcr.io/home-assistant/home-assistant:2025.12.4")
107
+
108
+ # Check for custom port via environment variable
109
+ custom_port = os.environ.get("HA_TEST_PORT")
110
+ if custom_port:
111
+ try:
112
+ port = int(custom_port)
113
+ container = container.with_bind_ports(8123, port)
114
+ logger.info(f"๐Ÿ”Œ Using fixed port {port} (from HA_TEST_PORT)")
115
+ except ValueError:
116
+ logger.warning(f"โš ๏ธ Invalid HA_TEST_PORT '{custom_port}', using random port")
117
+ container = container.with_bind_ports(8123, None)
118
+ else:
119
+ container = container.with_bind_ports(8123, None) # Random host port
120
+
121
+ self.container = (
122
+ container
123
+ .with_volume_mapping(str(config_dir), "/config", "rw")
124
+ .with_env("TZ", "UTC")
125
+ .with_kwargs(privileged=True)
126
+ )
127
+
128
+ self.container.start()
129
+
130
+ # Get connection details
131
+ host_port = self.container.get_exposed_port(8123)
132
+ self.ha_url = f"http://localhost:{host_port}"
133
+
134
+ logger.info(
135
+ f"๐Ÿš€ Container started: {self.container.get_container_host_ip()}:{host_port}"
136
+ )
137
+ logger.info(f"๐ŸŒ Home Assistant URL: {self.ha_url}")
138
+
139
+ # Wait for Home Assistant to be ready
140
+ self._wait_for_home_assistant()
141
+
142
+ def _wait_for_home_assistant(self, timeout: int = 120) -> None:
143
+ """Wait for Home Assistant to be ready."""
144
+ logger.info("โณ Waiting for Home Assistant to become ready...")
145
+
146
+ start_time = time.time()
147
+ attempts = 0
148
+
149
+ while time.time() - start_time < timeout:
150
+ attempts += 1
151
+ try:
152
+ # Check frontend (no auth required) to see if HA is up
153
+ response = requests.get(f"{self.ha_url}/", timeout=5)
154
+ logger.debug(f"Attempt {attempts}: HTTP {response.status_code}")
155
+ if response.status_code == 200:
156
+ logger.info("โœ… Home Assistant frontend is ready!")
157
+ # Now verify API with token
158
+ try:
159
+ headers = {"Authorization": f"Bearer {self.ha_token}"}
160
+ api_response = requests.get(
161
+ f"{self.ha_url}/api/config", headers=headers, timeout=5
162
+ )
163
+ if api_response.status_code == 200:
164
+ config = api_response.json()
165
+ logger.info(
166
+ f"โœ… API authenticated! Version: {config.get('version', 'unknown')}"
167
+ )
168
+ logger.info(
169
+ f"๐Ÿ  Components loaded: {len(config.get('components', []))}"
170
+ )
171
+ else:
172
+ logger.warning(
173
+ f"โš ๏ธ API token may be invalid (HTTP {api_response.status_code}). "
174
+ "Tests may fail. See tests/README.md for token update instructions."
175
+ )
176
+ except requests.RequestException as e:
177
+ logger.warning(f"โš ๏ธ Could not verify API token: {e}")
178
+ return
179
+ else:
180
+ logger.debug(f"Non-200 response: {response.status_code}")
181
+ except requests.RequestException as e:
182
+ logger.debug(f"Request failed: {type(e).__name__}: {e}")
183
+
184
+ if attempts % 6 == 0: # Every 30 seconds
185
+ logger.info(f"โณ Still waiting... ({attempts * 5}s elapsed)")
186
+
187
+ time.sleep(5)
188
+
189
+ raise TimeoutError(f"Home Assistant not ready after {timeout}s")
190
+
191
+ def stop_container(self) -> None:
192
+ """Stop and clean up the container."""
193
+ if not self.container:
194
+ logger.warning("No container to stop")
195
+ return
196
+
197
+ logger.info("๐Ÿ›‘ Stopping Home Assistant container...")
198
+ self.container.stop()
199
+ self.container = None
200
+ self.ha_url = None
201
+ logger.info("โœ… Container stopped and cleaned up")
202
+
203
+ def run_tests(self) -> None:
204
+ """Run all E2E tests against the running container."""
205
+ if not self.ha_url:
206
+ logger.error("โŒ No container running. Start container first.")
207
+ return
208
+
209
+ logger.info("๐Ÿงช Running E2E tests...")
210
+
211
+ # Set environment variables for tests
212
+ env = os.environ.copy()
213
+ env["HOMEASSISTANT_URL"] = self.ha_url
214
+ env["HOMEASSISTANT_TOKEN"] = self.ha_token
215
+
216
+ # Run pytest
217
+ cmd = [sys.executable, "-m", "pytest", "tests/src/e2e/", "-v", "--tb=short"]
218
+
219
+ try:
220
+ result = subprocess.run(cmd, env=env, cwd=Path(__file__).parent.parent)
221
+ if result.returncode == 0:
222
+ logger.info("โœ… All tests passed!")
223
+ else:
224
+ logger.warning(f"โš ๏ธ Tests completed with exit code: {result.returncode}")
225
+ except KeyboardInterrupt:
226
+ logger.info("๐Ÿ›‘ Test run interrupted by user")
227
+ except Exception as e:
228
+ logger.error(f"โŒ Error running tests: {e}")
229
+
230
+ def print_status(self) -> None:
231
+ """Print current environment status."""
232
+ print("\n" + "=" * 80)
233
+ print("๐Ÿ  HOME ASSISTANT MCP TEST ENVIRONMENT")
234
+ print("=" * 80)
235
+
236
+ if self.container and self.ha_url:
237
+ print(f"\n๐ŸŒ Web UI: {self.ha_url}")
238
+ print(f" Username: {self.test_user}")
239
+ print(f" Password: {self.test_password}")
240
+ print(f"\n๐Ÿ“‹ Copy-paste for testing:")
241
+ print(f" export HOMEASSISTANT_URL={self.ha_url}")
242
+ print(f" export HOMEASSISTANT_TOKEN={self.ha_token}")
243
+ print(f"\n๐Ÿ”‘ Full API Token:")
244
+ print(f" {self.ha_token}")
245
+ print(f"\n๐Ÿณ Container Status: Running")
246
+ print("๐Ÿ“Š API Health: ", end="")
247
+
248
+ try:
249
+ headers = {"Authorization": f"Bearer {self.ha_token}"}
250
+ response = requests.get(
251
+ f"{self.ha_url}/api/config", headers=headers, timeout=5
252
+ )
253
+ if response.status_code == 200:
254
+ print("โœ… Ready")
255
+ else:
256
+ print(f"โš ๏ธ Status {response.status_code}")
257
+ except:
258
+ print("โŒ Not accessible")
259
+ else:
260
+ print("๐Ÿณ Container Status: Not running")
261
+
262
+ print("=" * 70)
263
+
264
+
265
+ def show_menu() -> str:
266
+ """Show interactive menu and return user choice."""
267
+ print("\n๐Ÿ“‹ MENU:")
268
+ print("1) Run all E2E tests")
269
+ print("2) Stop container and exit")
270
+ print("3) Show environment status")
271
+
272
+ while True:
273
+ choice = input("\nChoose option (1-3): ").strip()
274
+ if choice in ["1", "2", "3"]:
275
+ return choice
276
+ print("โŒ Invalid choice. Please enter 1, 2, or 3.")
277
+
278
+
279
+ def main():
280
+ """Main entry point for the test environment manager."""
281
+ import argparse
282
+
283
+ parser = argparse.ArgumentParser(
284
+ description="Home Assistant MCP Test Environment Manager"
285
+ )
286
+ parser.add_argument(
287
+ "--no-interactive",
288
+ action="store_true",
289
+ help="Run in non-interactive mode (wait for SIGINT instead of showing menu)",
290
+ )
291
+ args = parser.parse_args()
292
+
293
+ print("๐Ÿš€ Home Assistant MCP Test Environment Manager")
294
+ print("=" * 50)
295
+
296
+ env = HomeAssistantTestEnvironment()
297
+
298
+ try:
299
+ # Start the container
300
+ env.start_container()
301
+ env.print_status()
302
+
303
+ if args.no_interactive:
304
+ # Non-interactive mode: just wait for interrupt
305
+ logger.info("๐Ÿ”„ Running in non-interactive mode. Press Ctrl+C to stop.")
306
+ try:
307
+ while True:
308
+ time.sleep(1)
309
+ except KeyboardInterrupt:
310
+ logger.info("\n๐Ÿ›‘ Received interrupt signal")
311
+ else:
312
+ # Interactive menu loop
313
+ while True:
314
+ choice = show_menu()
315
+
316
+ if choice == "1":
317
+ env.run_tests()
318
+ elif choice == "2":
319
+ env.stop_container()
320
+ print("๐Ÿ‘‹ Goodbye!")
321
+ break
322
+ elif choice == "3":
323
+ env.print_status()
324
+
325
+ except KeyboardInterrupt:
326
+ print("\n๐Ÿ›‘ Interrupted by user")
327
+ except Exception as e:
328
+ logger.error(f"โŒ Error: {e}")
329
+ finally:
330
+ # Cleanup
331
+ if env.container:
332
+ env.stop_container()
333
+
334
+
335
+ if __name__ == "__main__":
336
+ main()