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,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()
|