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,211 @@
1
+ """
2
+ Service call and device operation tools for Home Assistant MCP server.
3
+
4
+ This module provides service execution and WebSocket-enabled operation monitoring tools.
5
+ """
6
+
7
+ from typing import Any, cast
8
+
9
+ from ..errors import (
10
+ create_validation_error,
11
+ )
12
+ from .helpers import exception_to_structured_error
13
+ from .util_helpers import coerce_bool_param, parse_json_param
14
+
15
+
16
+ def register_service_tools(mcp, client, **kwargs):
17
+ """Register service call and operation monitoring tools with the MCP server."""
18
+ device_tools = kwargs.get("device_tools")
19
+ if not device_tools:
20
+ raise ValueError("device_tools is required for service tools registration")
21
+
22
+ @mcp.tool(annotations={"destructiveHint": True, "title": "Call Service"})
23
+ async def ha_call_service(
24
+ domain: str,
25
+ service: str,
26
+ entity_id: str | None = None,
27
+ data: str | dict[str, Any] | None = None,
28
+ return_response: bool | str = False,
29
+ ) -> dict[str, Any]:
30
+ """
31
+ Execute Home Assistant services to control entities and trigger automations.
32
+
33
+ This is the universal tool for controlling all Home Assistant entities. Services follow
34
+ the pattern domain.service (e.g., light.turn_on, climate.set_temperature).
35
+
36
+ **Basic Usage:**
37
+ ```python
38
+ # Turn on a light
39
+ ha_call_service("light", "turn_on", entity_id="light.living_room")
40
+
41
+ # Set temperature with parameters
42
+ ha_call_service("climate", "set_temperature",
43
+ entity_id="climate.thermostat", data={"temperature": 22})
44
+
45
+ # Trigger automation
46
+ ha_call_service("automation", "trigger", entity_id="automation.morning_routine")
47
+
48
+ # Universal controls work with any entity
49
+ ha_call_service("homeassistant", "toggle", entity_id="switch.porch_light")
50
+ ```
51
+
52
+ **Parameters:**
53
+ - **domain**: Service domain (light, climate, automation, etc.)
54
+ - **service**: Service name (turn_on, set_temperature, trigger, etc.)
55
+ - **entity_id**: Optional target entity. For some services (e.g., light.turn_off), omitting this targets all entities in the domain
56
+ - **data**: Optional dict of service-specific parameters
57
+ - **return_response**: Set to True for services that return data
58
+
59
+ **For detailed service documentation and parameters, use ha_get_domain_docs(domain).**
60
+
61
+ Common patterns: Use ha_get_state() to check current values before making changes.
62
+ Use ha_search_entities() to find correct entity IDs.
63
+ """
64
+ try:
65
+ # Parse JSON data if provided as string
66
+ try:
67
+ parsed_data = parse_json_param(data, "data")
68
+ except ValueError as e:
69
+ return create_validation_error(
70
+ f"Invalid data parameter: {e}",
71
+ parameter="data",
72
+ invalid_json=True,
73
+ )
74
+
75
+ # Ensure service_data is a dict
76
+ service_data: dict[str, Any] = {}
77
+ if parsed_data is not None:
78
+ if isinstance(parsed_data, dict):
79
+ service_data = parsed_data
80
+ else:
81
+ return create_validation_error(
82
+ "Data parameter must be a JSON object",
83
+ parameter="data",
84
+ details=f"Received type: {type(parsed_data).__name__}",
85
+ )
86
+
87
+ if entity_id:
88
+ service_data["entity_id"] = entity_id
89
+
90
+ # Coerce return_response boolean parameter
91
+ return_response_bool = coerce_bool_param(return_response, "return_response", default=False) or False
92
+
93
+ result = await client.call_service(domain, service, service_data, return_response=return_response_bool)
94
+
95
+ response = {
96
+ "success": True,
97
+ "domain": domain,
98
+ "service": service,
99
+ "entity_id": entity_id,
100
+ "parameters": data,
101
+ "result": result,
102
+ "message": f"Successfully executed {domain}.{service}",
103
+ }
104
+
105
+ # If return_response was requested, include the service_response key prominently
106
+ if return_response_bool and isinstance(result, dict):
107
+ response["service_response"] = result.get("service_response", result)
108
+
109
+ return response
110
+ except Exception as error:
111
+ # Use structured error response
112
+ error_response = exception_to_structured_error(
113
+ error,
114
+ context={
115
+ "domain": domain,
116
+ "service": service,
117
+ "entity_id": entity_id,
118
+ },
119
+ )
120
+ # Add service-specific suggestions
121
+ suggestions = [
122
+ f"Verify {entity_id} exists using ha_get_state()" if entity_id else "Specify an entity_id for targeted service calls",
123
+ f"Check available services for {domain} domain using ha_get_domain_docs()",
124
+ "Use ha_search_entities() to find correct entity IDs",
125
+ ]
126
+ if entity_id:
127
+ suggestions.extend([
128
+ f"For automation: ha_call_service('automation', 'trigger', entity_id='{entity_id}')",
129
+ f"For universal control: ha_call_service('homeassistant', 'toggle', entity_id='{entity_id}')",
130
+ ])
131
+ # Merge suggestions into error response
132
+ if "error" in error_response and isinstance(error_response["error"], dict):
133
+ error_response["error"]["suggestions"] = suggestions
134
+ return error_response
135
+
136
+ @mcp.tool(annotations={"readOnlyHint": True, "title": "Get Operation Status"})
137
+ async def ha_get_operation_status(
138
+ operation_id: str, timeout_seconds: int = 10
139
+ ) -> dict[str, Any]:
140
+ """Check status of device operation with real-time WebSocket verification."""
141
+ result = await device_tools.get_device_operation_status(
142
+ operation_id=operation_id, timeout_seconds=timeout_seconds
143
+ )
144
+ return cast(dict[str, Any], result)
145
+
146
+ @mcp.tool(annotations={"destructiveHint": True, "title": "Bulk Control"})
147
+ async def ha_bulk_control(
148
+ operations: str | list[dict[str, Any]], parallel: bool | str = True
149
+ ) -> dict[str, Any]:
150
+ """Control multiple devices with bulk operation support and WebSocket tracking."""
151
+ # Coerce boolean parameter that may come as string from XML-style calls
152
+ parallel_bool = coerce_bool_param(parallel, "parallel", default=True)
153
+ assert parallel_bool is not None # default=True guarantees non-None
154
+
155
+ # Parse JSON operations if provided as string
156
+ try:
157
+ parsed_operations = parse_json_param(operations, "operations")
158
+ except ValueError as e:
159
+ return create_validation_error(
160
+ f"Invalid operations parameter: {e}",
161
+ parameter="operations",
162
+ invalid_json=True,
163
+ )
164
+
165
+ # Ensure operations is a list of dicts
166
+ if parsed_operations is None or not isinstance(parsed_operations, list):
167
+ return create_validation_error(
168
+ "Operations parameter must be a list",
169
+ parameter="operations",
170
+ details=f"Received type: {type(parsed_operations).__name__}",
171
+ )
172
+
173
+ operations_list = cast(list[dict[str, Any]], parsed_operations)
174
+ result = await device_tools.bulk_device_control(
175
+ operations=operations_list, parallel=parallel_bool
176
+ )
177
+ return cast(dict[str, Any], result)
178
+
179
+ @mcp.tool(annotations={"readOnlyHint": True, "title": "Get Bulk Operation Status"})
180
+ async def ha_get_bulk_status(operation_ids: list[str]) -> dict[str, Any]:
181
+ """
182
+ Check status of multiple device control operations.
183
+
184
+ Use this tool to check the status of operations initiated by ha_bulk_control
185
+ or control_device_smart. Each of these tools returns unique operation_ids
186
+ that can be tracked here.
187
+
188
+ **IMPORTANT:** This tool is for tracking async device operations, NOT for
189
+ checking current entity states. To get current states of entities, use
190
+ ha_get_state instead.
191
+
192
+ **Args:**
193
+ operation_ids: List of operation IDs returned by ha_bulk_control or
194
+ control_device_smart (e.g., ["op_1234", "op_5678"])
195
+
196
+ **Returns:**
197
+ Status summary with completion/pending/failed counts and detailed
198
+ results for each operation.
199
+
200
+ **Example:**
201
+ # After calling control_device_smart
202
+ result = control_device_smart("light.kitchen", "on")
203
+ op_id = result["operation_id"] # e.g., "op_1234"
204
+
205
+ # Check operation status
206
+ status = ha_get_bulk_status([op_id])
207
+ """
208
+ result = await device_tools.get_bulk_operation_status(
209
+ operation_ids=operation_ids
210
+ )
211
+ return cast(dict[str, Any], result)
@@ -0,0 +1,352 @@
1
+ """
2
+ Service discovery tools for Home Assistant MCP server.
3
+
4
+ This module provides service listing and discovery capabilities,
5
+ allowing AI to explore available Home Assistant services/actions.
6
+ """
7
+
8
+ import logging
9
+ from typing import Any
10
+
11
+ from .helpers import log_tool_usage
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def register_services_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
17
+ """Register service discovery tools with the MCP server."""
18
+
19
+ @mcp.tool(annotations={"idempotentHint": True, "readOnlyHint": True, "tags": ["service"], "title": "List Available Services"})
20
+ @log_tool_usage
21
+ async def ha_list_services(
22
+ domain: str | None = None,
23
+ query: str | None = None,
24
+ ) -> dict[str, Any]:
25
+ """
26
+ List available Home Assistant services with their parameters.
27
+
28
+ Discovers services/actions that can be called via ha_call_service.
29
+ Returns service definitions including field names, types, and descriptions.
30
+
31
+ Args:
32
+ domain: Filter by domain (e.g., 'light', 'switch', 'climate').
33
+ If not provided, returns services from all domains.
34
+ query: Search in service names and descriptions.
35
+ Matches against service IDs and their descriptions.
36
+
37
+ Returns:
38
+ Dictionary with:
39
+ - success: Whether the operation succeeded
40
+ - domains: List of available domains (when no domain filter)
41
+ - services: Dictionary of service definitions keyed by domain.service
42
+ - total_count: Total number of services returned
43
+
44
+ Examples:
45
+ # List all light services
46
+ ha_list_services(domain="light")
47
+
48
+ # Search for services related to temperature
49
+ ha_list_services(query="temperature")
50
+
51
+ # Get all available services (may be large)
52
+ ha_list_services()
53
+ """
54
+ try:
55
+ # Get services from REST API (includes parameter definitions)
56
+ rest_services = await client.get_services()
57
+
58
+ # Get translations for service descriptions via WebSocket
59
+ translations = await _get_service_translations(client)
60
+
61
+ # Process and filter services
62
+ result = _process_services(
63
+ rest_services=rest_services,
64
+ translations=translations,
65
+ domain_filter=domain,
66
+ query_filter=query,
67
+ )
68
+
69
+ return result
70
+
71
+ except Exception as e:
72
+ logger.error(f"Failed to list services: {e}")
73
+ return {
74
+ "success": False,
75
+ "error": str(e),
76
+ "suggestions": [
77
+ "Check Home Assistant connection",
78
+ "Verify WebSocket API is available",
79
+ "Try with a specific domain filter",
80
+ ],
81
+ }
82
+
83
+
84
+ async def _get_service_translations(client: Any) -> dict[str, Any]:
85
+ """
86
+ Get service translations from Home Assistant via WebSocket.
87
+
88
+ Uses the frontend/get_translations command to retrieve
89
+ human-readable service names and descriptions.
90
+ """
91
+ try:
92
+ response = await client.send_websocket_message(
93
+ {
94
+ "type": "frontend/get_translations",
95
+ "language": "en",
96
+ "category": "services",
97
+ }
98
+ )
99
+
100
+ if response.get("success") and response.get("result"):
101
+ result = response["result"]
102
+ if isinstance(result, dict):
103
+ resources: dict[str, Any] = result.get("resources", {})
104
+ return resources
105
+ return {}
106
+
107
+ except Exception as e:
108
+ logger.warning(f"Failed to get service translations: {e}")
109
+ return {}
110
+
111
+
112
+ def _process_services(
113
+ rest_services: Any,
114
+ translations: dict[str, Any],
115
+ domain_filter: str | None = None,
116
+ query_filter: str | None = None,
117
+ ) -> dict[str, Any]:
118
+ """
119
+ Process raw service data into structured output.
120
+
121
+ Args:
122
+ rest_services: Raw services from REST API
123
+ translations: Service translations from WebSocket
124
+ domain_filter: Optional domain to filter by
125
+ query_filter: Optional search query
126
+
127
+ Returns:
128
+ Processed service dictionary
129
+ """
130
+ services: dict[str, dict[str, Any]] = {}
131
+ domains_seen: set[str] = set()
132
+
133
+ # Handle both list and dict formats from REST API
134
+ if isinstance(rest_services, list):
135
+ # Format: [{"domain": "light", "services": {...}}, ...]
136
+ service_data = rest_services
137
+ elif isinstance(rest_services, dict):
138
+ # Format: {"light": {"services": {...}}, ...}
139
+ service_data = [
140
+ {"domain": domain, "services": data.get("services", data)}
141
+ for domain, data in rest_services.items()
142
+ ]
143
+ else:
144
+ return {
145
+ "success": False,
146
+ "error": "Unexpected service data format",
147
+ "services": {},
148
+ "domains": [],
149
+ "total_count": 0,
150
+ }
151
+
152
+ query_lower = query_filter.lower() if query_filter else None
153
+
154
+ for domain_entry in service_data:
155
+ domain = domain_entry.get("domain", "")
156
+ if not domain:
157
+ continue
158
+
159
+ # Apply domain filter
160
+ if domain_filter and domain != domain_filter:
161
+ continue
162
+
163
+ domains_seen.add(domain)
164
+ domain_services = domain_entry.get("services", {})
165
+
166
+ for service_name, service_def in domain_services.items():
167
+ service_key = f"{domain}.{service_name}"
168
+
169
+ # Get translations for this service
170
+ translation_key = f"component.{domain}.services.{service_name}"
171
+ service_trans = translations.get(translation_key, {})
172
+
173
+ # Build service description
174
+ name = service_trans.get("name", service_name.replace("_", " ").title())
175
+ description = service_trans.get(
176
+ "description",
177
+ service_def.get("description", ""),
178
+ )
179
+
180
+ # Apply query filter
181
+ if query_lower:
182
+ searchable = f"{service_key} {name} {description}".lower()
183
+ if query_lower not in searchable:
184
+ continue
185
+
186
+ # Process fields/parameters
187
+ fields = _process_service_fields(
188
+ service_def.get("fields", {}),
189
+ service_trans.get("fields", {}),
190
+ )
191
+
192
+ # Build service entry
193
+ services[service_key] = {
194
+ "name": name,
195
+ "description": description,
196
+ "domain": domain,
197
+ "service": service_name,
198
+ "fields": fields,
199
+ }
200
+
201
+ # Add target only if present
202
+ target = service_def.get("target")
203
+ if target is not None:
204
+ services[service_key]["target"] = target
205
+
206
+ # Sort domains alphabetically
207
+ sorted_domains = sorted(domains_seen)
208
+
209
+ return {
210
+ "success": True,
211
+ "domains": sorted_domains,
212
+ "services": services,
213
+ "total_count": len(services),
214
+ "filters_applied": {
215
+ "domain": domain_filter,
216
+ "query": query_filter,
217
+ },
218
+ }
219
+
220
+
221
+ def _process_service_fields(
222
+ fields_def: dict[str, Any],
223
+ fields_trans: dict[str, Any],
224
+ ) -> dict[str, dict[str, Any]]:
225
+ """
226
+ Process service field definitions into structured output.
227
+
228
+ Args:
229
+ fields_def: Field definitions from REST API
230
+ fields_trans: Field translations from WebSocket
231
+
232
+ Returns:
233
+ Dictionary of processed field definitions
234
+ """
235
+ processed: dict[str, dict[str, Any]] = {}
236
+
237
+ for field_name, field_info in fields_def.items():
238
+ trans = fields_trans.get(field_name, {})
239
+
240
+ # Determine field type from selector
241
+ selector = field_info.get("selector", {})
242
+ field_type = _get_field_type(selector)
243
+
244
+ processed[field_name] = {
245
+ "name": trans.get("name", field_name.replace("_", " ").title()),
246
+ "description": trans.get(
247
+ "description",
248
+ field_info.get("description", ""),
249
+ ),
250
+ "required": field_info.get("required", False),
251
+ "type": field_type,
252
+ "example": trans.get("example", field_info.get("example")),
253
+ }
254
+
255
+ # Add selector details for complex types
256
+ if selector:
257
+ processed[field_name]["selector"] = selector
258
+
259
+ # Add default value if present
260
+ if "default" in field_info:
261
+ processed[field_name]["default"] = field_info["default"]
262
+
263
+ return processed
264
+
265
+
266
+ def _get_field_type(selector: dict[str, Any]) -> str:
267
+ """
268
+ Determine field type from selector definition.
269
+
270
+ Args:
271
+ selector: Field selector from service definition
272
+
273
+ Returns:
274
+ Human-readable type string
275
+ """
276
+ if not selector:
277
+ return "any"
278
+
279
+ # Check for common selector types
280
+ if "number" in selector:
281
+ num_sel = selector["number"]
282
+ if isinstance(num_sel, dict) and "min" in num_sel and "max" in num_sel:
283
+ return f"number ({num_sel['min']}-{num_sel['max']})"
284
+ return "number"
285
+
286
+ if "boolean" in selector:
287
+ return "boolean"
288
+
289
+ if "text" in selector:
290
+ return "text"
291
+
292
+ if "select" in selector:
293
+ select_sel = selector["select"]
294
+ if isinstance(select_sel, dict):
295
+ options = select_sel.get("options", [])
296
+ if options and len(options) <= 5:
297
+ # Show options inline for small lists
298
+ option_values = [
299
+ opt.get("value", opt) if isinstance(opt, dict) else opt
300
+ for opt in options
301
+ ]
302
+ return f"select ({', '.join(str(v) for v in option_values)})"
303
+ return "select"
304
+
305
+ if "entity" in selector:
306
+ entity_sel = selector["entity"]
307
+ if isinstance(entity_sel, dict) and "domain" in entity_sel:
308
+ domains = entity_sel["domain"]
309
+ if isinstance(domains, list):
310
+ return f"entity ({', '.join(domains)})"
311
+ return f"entity ({domains})"
312
+ return "entity"
313
+
314
+ if "target" in selector:
315
+ return "target (entity/area/device)"
316
+
317
+ if "time" in selector:
318
+ return "time"
319
+
320
+ if "date" in selector:
321
+ return "date"
322
+
323
+ if "datetime" in selector:
324
+ return "datetime"
325
+
326
+ if "color_temp" in selector:
327
+ return "color_temp"
328
+
329
+ if "color_rgb" in selector:
330
+ return "color_rgb"
331
+
332
+ if "object" in selector:
333
+ return "object"
334
+
335
+ if "template" in selector:
336
+ return "template"
337
+
338
+ if "area" in selector:
339
+ return "area"
340
+
341
+ if "device" in selector:
342
+ return "device"
343
+
344
+ if "duration" in selector:
345
+ return "duration"
346
+
347
+ # Return the first key as type name
348
+ selector_types = list(selector.keys())
349
+ if selector_types:
350
+ return selector_types[0]
351
+
352
+ return "any"