Skip to content

Commit c1f52be

Browse files
committed
Add HA search
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 58893b0 commit c1f52be

2 files changed

Lines changed: 169 additions & 0 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ A Home Assistant integration server that allows AI models to interact with and c
184184
- `list_entities` - List all entities in Home Assistant
185185
- `get_services` - Get all available services in Home Assistant
186186
- `call_service` - Call a service in Home Assistant (e.g., turn_on, turn_off, toggle)
187+
- `search_entities` - Search for entities by keyword (searches across entity ID, domain, state, and friendly name)
188+
- `search_services` - Search for services by keyword (searches across service domain and name)
187189

188190
**Configuration:**
189191
- `HA_TOKEN` - Home Assistant API token (required)
@@ -217,6 +219,20 @@ A Home Assistant integration server that allows AI models to interact with and c
217219
}
218220
```
219221

222+
**Search Entities Example:**
223+
```json
224+
{
225+
"keyword": "living room light"
226+
}
227+
```
228+
229+
**Search Services Example:**
230+
```json
231+
{
232+
"keyword": "turn_on"
233+
}
234+
```
235+
220236
**Docker Image:**
221237
```bash
222238
docker run -e HA_TOKEN="your-token-here" -e HA_HOST="http://IP:PORT" ghcr.io/mudler/mcps/homeassistant:latest

homeassistant/main.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ type CallServiceInput struct {
2929
EntityID string `json:"entity_id" jsonschema:"the entity ID (e.g., 'switch.switch_1')"`
3030
}
3131

32+
type SearchEntitiesInput struct {
33+
Keyword string `json:"keyword" jsonschema:"search keyword to match in entity ID, domain, state, or friendly name"`
34+
}
35+
36+
type SearchServicesInput struct {
37+
Keyword string `json:"keyword" jsonschema:"search keyword to match in service domain or name"`
38+
}
39+
3240
// Output types
3341
type Entity struct {
3442
EntityID string `json:"entity_id" jsonschema:"the entity ID"`
@@ -64,6 +72,16 @@ type CallServiceOutput struct {
6472
Message string `json:"message" jsonschema:"status message"`
6573
}
6674

75+
type SearchEntitiesOutput struct {
76+
Entities []Entity `json:"entities" jsonschema:"list of matching entities"`
77+
Count int `json:"count" jsonschema:"number of matching entities"`
78+
}
79+
80+
type SearchServicesOutput struct {
81+
Services []Service `json:"services" jsonschema:"list of matching services"`
82+
Count int `json:"count" jsonschema:"number of matching services"`
83+
}
84+
6785
// ListEntities returns all entities in Home Assistant
6886
func ListEntities(ctx context.Context, req *mcp.CallToolRequest, input ListEntitiesInput) (
6987
*mcp.CallToolResult,
@@ -190,6 +208,131 @@ func CallService(ctx context.Context, req *mcp.CallToolRequest, input CallServic
190208
return nil, output, nil
191209
}
192210

211+
// SearchEntities searches for entities matching the keyword
212+
func SearchEntities(ctx context.Context, req *mcp.CallToolRequest, input SearchEntitiesInput) (
213+
*mcp.CallToolResult,
214+
SearchEntitiesOutput,
215+
error,
216+
) {
217+
states, err := client.GetStates(ctx)
218+
if err != nil {
219+
return nil, SearchEntitiesOutput{}, fmt.Errorf("failed to get states: %w", err)
220+
}
221+
222+
keyword := strings.ToLower(input.Keyword)
223+
var matchingEntities []Entity
224+
225+
for _, state := range states {
226+
// Extract domain from entity ID
227+
data := strings.Split(state.EntityId, ".")
228+
domain := ""
229+
if len(data) > 0 {
230+
domain = data[0]
231+
}
232+
233+
// Check if keyword matches in entity ID
234+
entityIDMatch := strings.Contains(strings.ToLower(state.EntityId), keyword)
235+
236+
// Check if keyword matches in domain
237+
domainMatch := strings.Contains(strings.ToLower(domain), keyword)
238+
239+
// Check if keyword matches in state
240+
stateMatch := strings.Contains(strings.ToLower(state.State), keyword)
241+
242+
// Check if keyword matches in friendly name
243+
friendlyNameMatch := false
244+
if friendlyName, ok := state.Attributes["friendly_name"].(string); ok {
245+
friendlyNameMatch = strings.Contains(strings.ToLower(friendlyName), keyword)
246+
}
247+
248+
// If keyword matches in any field, include this entity
249+
if entityIDMatch || domainMatch || stateMatch || friendlyNameMatch {
250+
friendlyName := state.Attributes["friendly_name"]
251+
entity := Entity{
252+
EntityID: state.EntityId,
253+
State: state.State,
254+
FriendlyName: friendlyName,
255+
Domain: domain,
256+
}
257+
matchingEntities = append(matchingEntities, entity)
258+
}
259+
}
260+
261+
output := SearchEntitiesOutput{
262+
Entities: matchingEntities,
263+
Count: len(matchingEntities),
264+
}
265+
266+
return nil, output, nil
267+
}
268+
269+
// SearchServices searches for services matching the keyword
270+
func SearchServices(ctx context.Context, req *mcp.CallToolRequest, input SearchServicesInput) (
271+
*mcp.CallToolResult,
272+
SearchServicesOutput,
273+
error,
274+
) {
275+
services, err := client.GetServices(ctx)
276+
if err != nil {
277+
return nil, SearchServicesOutput{}, fmt.Errorf("failed to get services: %w", err)
278+
}
279+
280+
keyword := strings.ToLower(input.Keyword)
281+
var result []Service
282+
283+
for _, s := range services {
284+
// Check if keyword matches in domain
285+
domainMatch := strings.Contains(strings.ToLower(s.Domain), keyword)
286+
287+
for serviceName, serviceInfo := range s.Services {
288+
// Check if keyword matches in service name
289+
serviceNameMatch := strings.Contains(strings.ToLower(serviceName), keyword)
290+
291+
// If keyword matches in domain or service name, include this service
292+
if domainMatch || serviceNameMatch {
293+
// Convert service fields to our format
294+
fields := make(map[string]ServiceField)
295+
for fieldName, fieldInfo := range serviceInfo.Fields {
296+
field := ServiceField{
297+
Description: fieldInfo.Description,
298+
}
299+
300+
// Set example if available (convert to string if needed)
301+
if fieldInfo.Example != nil {
302+
switch v := fieldInfo.Example.(type) {
303+
case string:
304+
field.Example = v
305+
default:
306+
field.Example = fmt.Sprintf("%v", v)
307+
}
308+
}
309+
310+
// Set required flag if available
311+
if fieldInfo.Selector != nil {
312+
field.Required = true
313+
}
314+
315+
fields[fieldName] = field
316+
}
317+
318+
service := Service{
319+
Domain: s.Domain,
320+
Name: serviceName,
321+
Fields: fields,
322+
}
323+
result = append(result, service)
324+
}
325+
}
326+
}
327+
328+
output := SearchServicesOutput{
329+
Services: result,
330+
Count: len(result),
331+
}
332+
333+
return nil, output, nil
334+
}
335+
193336
func main() {
194337
// Get configuration from environment variables
195338
token := os.Getenv("HA_TOKEN")
@@ -239,6 +382,16 @@ func main() {
239382
Description: "Call a service in Home Assistant (e.g., turn_on, turn_off, toggle)",
240383
}, CallService)
241384

385+
mcp.AddTool(server, &mcp.Tool{
386+
Name: "search_entities",
387+
Description: "Search for entities in Home Assistant by keyword (searches across entity ID, domain, state, and friendly name)",
388+
}, SearchEntities)
389+
390+
mcp.AddTool(server, &mcp.Tool{
391+
Name: "search_services",
392+
Description: "Search for services in Home Assistant by keyword (searches across service domain and name)",
393+
}, SearchServices)
394+
242395
// Run the server
243396
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
244397
log.Fatal(err)

0 commit comments

Comments
 (0)