Skip to main content
Glama
search-entities.tool.ts10.4 kB
/** * Search Entities Tool for Home Assistant * * Powerful entity search with filtering by domain, device_class, state, * area, pattern matching, attribute conditions, and time-based queries. */ import { z } from "zod"; import { UserError } from "fastmcp"; import { logger } from "../utils/logger.js"; import { get_hass } from "../hass/index.js"; import { Tool } from "../types/index.js"; // Attribute filter schema const attributeFilterSchema = z.object({ key: z.string().describe("Attribute name (e.g., 'battery_level', 'brightness', 'person')"), op: z .enum(["=", "!=", "<", ">", "<=", ">=", "contains"]) .describe("Comparison operator"), value: z .union([z.string(), z.number(), z.boolean()]) .describe("Value to compare against"), }); // Main schema const searchEntitiesSchema = z.object({ domain: z .union([z.string(), z.array(z.string())]) .optional() .describe("Filter by domain (e.g., 'binary_sensor', 'sensor', 'light') - single or array"), device_class: z .union([z.string(), z.array(z.string())]) .optional() .describe("Filter by device_class (e.g., 'motion', 'door', 'temperature', 'battery')"), state: z .string() .optional() .describe("Filter by state - exact match ('on', 'off') or comparison ('>50', '<20', '!=unavailable')"), area: z .union([z.string(), z.array(z.string())]) .optional() .describe("Filter by area (e.g., 'living_room', 'office')"), pattern: z .string() .optional() .describe("Glob pattern to match entity_id or friendly_name (e.g., '*motion*', 'sensor.frigate*', '*chad*person*')"), attributes: z .array(attributeFilterSchema) .optional() .describe("Attribute conditions with AND logic (e.g., [{key: 'battery_level', op: '<', value: 20}])"), changed_within: z .string() .optional() .describe("Filter entities changed within duration (e.g., '5m', '1h', '24h')"), changed_after: z .string() .optional() .describe("Filter entities changed after ISO timestamp"), output: z .enum(["minimal", "summary", "full"]) .optional() .default("summary") .describe("Output mode: 'minimal' (entity_ids only), 'summary' (id, state, name, device_class, last_changed), 'full' (all attributes)"), sort_by: z .enum(["last_changed", "last_updated", "entity_id", "state"]) .optional() .default("entity_id") .describe("Sort results by field"), sort_order: z .enum(["asc", "desc"]) .optional() .default("asc") .describe("Sort order"), limit: z .number() .optional() .describe("Maximum number of results to return"), }); type SearchEntitiesParams = z.infer<typeof searchEntitiesSchema>; type AttributeFilter = z.infer<typeof attributeFilterSchema>; /** * Parse duration string to milliseconds */ function parseDuration(duration: string): number { const match = duration.match(/^(\d+)(s|m|h|d)$/); if (!match) { throw new UserError(`Invalid duration format: ${duration}. Use format like '5m', '1h', '24h', '7d'`); } const value = parseInt(match[1], 10); const unit = match[2]; const multipliers: Record<string, number> = { s: 1000, m: 60 * 1000, h: 60 * 60 * 1000, d: 24 * 60 * 60 * 1000, }; return value * multipliers[unit]; } /** * Convert glob pattern to regex */ function globToRegex(pattern: string): RegExp { const escaped = pattern .replace(/[.+^${}()|[\]\\]/g, "\\$&") .replace(/\*/g, ".*") .replace(/\?/g, "."); return new RegExp(`^${escaped}$`, "i"); } /** * Compare values with operator */ function compareValues(actual: unknown, op: string, expected: string | number | boolean): boolean { // Handle null/undefined if (actual === null || actual === undefined) { return op === "!=" ? true : false; } switch (op) { case "=": return String(actual).toLowerCase() === String(expected).toLowerCase(); case "!=": return String(actual).toLowerCase() !== String(expected).toLowerCase(); case "<": return Number(actual) < Number(expected); case ">": return Number(actual) > Number(expected); case "<=": return Number(actual) <= Number(expected); case ">=": return Number(actual) >= Number(expected); case "contains": return String(actual).toLowerCase().includes(String(expected).toLowerCase()); default: return false; } } /** * Parse state filter (exact match or comparison) */ function parseStateFilter(stateFilter: string): { op: string; value: string } { const comparisonMatch = stateFilter.match(/^(!=|<=|>=|<|>|=)?(.+)$/); if (comparisonMatch && comparisonMatch[1]) { return { op: comparisonMatch[1], value: comparisonMatch[2] }; } return { op: "=", value: stateFilter }; } /** * Check if value matches array or single value filter */ function matchesFilter(value: unknown, filter: string | string[] | undefined): boolean { if (!filter) return true; const filters = Array.isArray(filter) ? filter : [filter]; return filters.some((f) => String(value).toLowerCase() === f.toLowerCase()); } async function executeSearchEntitiesLogic(params: SearchEntitiesParams): Promise<string> { try { const hass = await get_hass(); let entities = await hass.getStates(); // Apply domain filter if (params.domain) { const domains = Array.isArray(params.domain) ? params.domain : [params.domain]; entities = entities.filter((e) => { const entityDomain = e.entity_id.split(".")[0]; return domains.some((d) => d.toLowerCase() === entityDomain.toLowerCase()); }); } // Apply device_class filter if (params.device_class) { entities = entities.filter((e) => matchesFilter(e.attributes?.device_class, params.device_class) ); } // Apply state filter if (params.state) { const { op, value } = parseStateFilter(params.state); entities = entities.filter((e) => compareValues(e.state, op, value)); } // Apply area filter if (params.area) { entities = entities.filter((e) => matchesFilter(e.attributes?.area_id, params.area) ); } // Apply pattern filter if (params.pattern) { const regex = globToRegex(params.pattern); entities = entities.filter((e) => { const friendlyName = e.attributes?.friendly_name || ""; return regex.test(e.entity_id) || regex.test(friendlyName); }); } // Apply attribute filters if (params.attributes && params.attributes.length > 0) { entities = entities.filter((e) => { return params.attributes!.every((filter: AttributeFilter) => { const attrValue = e.attributes?.[filter.key]; return compareValues(attrValue, filter.op, filter.value); }); }); } // Apply time filters if (params.changed_within) { const durationMs = parseDuration(params.changed_within); const cutoff = new Date(Date.now() - durationMs); entities = entities.filter((e) => { const changed = new Date(e.last_changed); return changed >= cutoff; }); } if (params.changed_after) { const afterDate = new Date(params.changed_after); if (isNaN(afterDate.getTime())) { throw new UserError(`Invalid date format: ${params.changed_after}`); } entities = entities.filter((e) => { const changed = new Date(e.last_changed); return changed >= afterDate; }); } // Sort results const sortBy = params.sort_by || "entity_id"; const sortOrder = params.sort_order || "asc"; entities.sort((a, b) => { let aVal: string | number; let bVal: string | number; switch (sortBy) { case "last_changed": aVal = new Date(a.last_changed).getTime(); bVal = new Date(b.last_changed).getTime(); break; case "last_updated": aVal = new Date(a.last_updated).getTime(); bVal = new Date(b.last_updated).getTime(); break; case "state": aVal = a.state; bVal = b.state; break; case "entity_id": default: aVal = a.entity_id; bVal = b.entity_id; break; } if (aVal < bVal) return sortOrder === "asc" ? -1 : 1; if (aVal > bVal) return sortOrder === "asc" ? 1 : -1; return 0; }); // Apply limit if (params.limit && params.limit > 0) { entities = entities.slice(0, params.limit); } // Format output based on mode const output = params.output || "summary"; let results: unknown[]; switch (output) { case "minimal": results = entities.map((e) => e.entity_id); break; case "summary": results = entities.map((e) => ({ entity_id: e.entity_id, state: e.state, friendly_name: e.attributes?.friendly_name, device_class: e.attributes?.device_class, last_changed: e.last_changed, })); break; case "full": default: results = entities.map((e) => ({ entity_id: e.entity_id, state: e.state, last_changed: e.last_changed, last_updated: e.last_updated, attributes: e.attributes, })); break; } return JSON.stringify({ count: results.length, results, }); } catch (error) { if (error instanceof UserError) throw error; logger.error(`Failed to search entities: ${error}`); throw new UserError(`Search failed: ${error instanceof Error ? error.message : String(error)}`); } } export const searchEntitiesTool: Tool = { name: "search_entities", description: "Search Home Assistant entities with powerful filtering. Supports domain, device_class, state (exact or comparison like '>50'), area, glob patterns ('*motion*'), attribute conditions (battery_level < 20), and time-based queries (changed_within: '5m'). Examples: Find active motion sensors, low battery devices, recently triggered sensors, or when a Frigate-tracked person was last seen.", parameters: searchEntitiesSchema, execute: executeSearchEntitiesLogic, annotations: { title: "Search Entities", description: "Powerful entity search with filtering and sorting", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/jango-blockchained/advanced-homeassistant-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server