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,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"
|