Skip to content

Commit 229788d

Browse files
kingpanther13claudegemini-code-assist[bot]
authored
Fix #504 (#511)
* chore: update uv.lock after dependency sync https://claude.ai/code/session_01Pv5Wp2wZSvfJEbVF4Zxyy9 * fix: use HA registries for accurate area filtering in entity search The area_filter parameter in ha_search_entities returned entities from unrelated areas because get_entities_by_area relied on fuzzy matching entity friendly names to infer area membership. This caused false positives (e.g., searching for "salon" could match entities in area "abc" if partial string ratios exceeded the threshold). Replace the fuzzy name-matching approach with proper Home Assistant registry lookups: - Entity registry: direct entity -> area_id assignments - Device registry: device -> area_id (inherited by entities) - Area registry: area_id -> area name (for fuzzy query matching) Entity area resolution priority: entity direct area > device area. Fixes #504 https://claude.ai/code/session_01Pv5Wp2wZSvfJEbVF4Zxyy9 * Update src/ha_mcp/tools/smart_search.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 9db19e1 commit 229788d

3 files changed

Lines changed: 571 additions & 29 deletions

File tree

src/ha_mcp/tools/smart_search.py

Lines changed: 139 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,11 @@ async def get_entities_by_area(
143143
self, area_query: str, group_by_domain: bool = True
144144
) -> dict[str, Any]:
145145
"""
146-
Get entities grouped by area/room with fuzzy matching.
146+
Get entities grouped by area/room using the HA registries for accurate area resolution.
147+
148+
Uses entity registry, device registry, and area registry to determine
149+
which area each entity belongs to. Fuzzy matches the query against
150+
area names/IDs to find the target area(s).
147151
148152
Args:
149153
area_query: Area/room name to search for
@@ -153,55 +157,163 @@ async def get_entities_by_area(
153157
Dictionary with area-grouped entities
154158
"""
155159
try:
156-
# Get all entities
157-
entities = await self.client.get_states()
160+
# Fetch all registries and states in parallel
161+
entities_task = self.client.get_states()
162+
area_registry_task = self.client.send_websocket_message(
163+
{"type": "config/area_registry/list"}
164+
)
165+
entity_registry_task = self.client.send_websocket_message(
166+
{"type": "config/entity_registry/list"}
167+
)
168+
device_registry_task = self.client.send_websocket_message(
169+
{"type": "config/device_registry/list"}
170+
)
158171

159-
# Search by area
160-
area_matches = self.fuzzy_searcher.search_by_area(entities, area_query)
172+
results = await asyncio.gather(
173+
entities_task,
174+
area_registry_task,
175+
entity_registry_task,
176+
device_registry_task,
177+
return_exceptions=True,
178+
)
161179

162-
# Format results
163-
formatted_areas = {}
180+
entities = results[0] if not isinstance(results[0], Exception) else []
181+
182+
# Parse area registry: area_id -> area info
183+
area_registry: dict[str, dict[str, Any]] = {}
184+
if isinstance(results[1], dict) and results[1].get("success"):
185+
for area in results[1].get("result", []):
186+
area_id = area.get("area_id", "")
187+
if area_id:
188+
area_registry[area_id] = area
189+
190+
# Parse entity registry: entity_id -> {area_id, device_id}
191+
entity_reg_map: dict[str, dict[str, str | None]] = {}
192+
if isinstance(results[2], dict) and results[2].get("success"):
193+
for entry in results[2].get("result", []):
194+
entity_id = entry.get("entity_id")
195+
if entity_id:
196+
entity_reg_map[entity_id] = {
197+
"area_id": entry.get("area_id"),
198+
"device_id": entry.get("device_id"),
199+
}
200+
201+
# Parse device registry: device_id -> area_id
202+
device_area_map: dict[str, str | None] = {}
203+
if isinstance(results[3], dict) and results[3].get("success"):
204+
for device in results[3].get("result", []):
205+
device_id = device.get("id", "")
206+
if device_id:
207+
device_area_map[device_id] = device.get("area_id")
208+
209+
# Fuzzy match area_query against known area names and IDs
210+
area_query_lower = area_query.lower().strip()
211+
matched_area_ids: set[str] = set()
212+
213+
for area_id, area_info in area_registry.items():
214+
area_name = area_info.get("name", "")
215+
# Exact match on area_id or name (case-insensitive)
216+
if area_query_lower == area_id.lower() or area_query_lower == area_name.lower():
217+
matched_area_ids.add(area_id)
218+
continue
219+
# Fuzzy match on area name
220+
name_score = calculate_partial_ratio(area_query_lower, area_name.lower())
221+
id_score = calculate_partial_ratio(area_query_lower, area_id.lower())
222+
best_score = max(name_score, id_score)
223+
if best_score >= 80:
224+
matched_area_ids.add(area_id)
225+
226+
if not matched_area_ids:
227+
return {
228+
"area_query": area_query,
229+
"total_areas_found": 0,
230+
"total_entities": 0,
231+
"areas": {},
232+
"search_metadata": {
233+
"grouped_by_domain": group_by_domain,
234+
"area_inference_method": "registry_lookup",
235+
"available_areas": [
236+
{"area_id": aid, "name": ainfo.get("name", aid)}
237+
for aid, ainfo in area_registry.items()
238+
],
239+
},
240+
"usage_tips": [
241+
"No areas matched your query. See available_areas for valid area names.",
242+
"Try exact area names from Home Assistant",
243+
"Use ha_list_areas() to see all areas",
244+
],
245+
}
246+
247+
# Build entity_id -> resolved area_id mapping
248+
# Priority: entity direct area_id > device area_id
249+
entity_area_resolved: dict[str, str] = {}
250+
for entity_id, reg_info in entity_reg_map.items():
251+
area_id = reg_info.get("area_id")
252+
if not area_id and reg_info.get("device_id"):
253+
area_id = device_area_map.get(reg_info["device_id"])
254+
if area_id:
255+
entity_area_resolved[entity_id] = area_id
256+
257+
# Build state lookup for entity details
258+
state_map: dict[str, dict[str, Any]] = {}
259+
for entity in entities:
260+
eid = entity.get("entity_id", "")
261+
if eid:
262+
state_map[eid] = entity
263+
264+
# Collect entities belonging to matched areas
265+
formatted_areas: dict[str, dict[str, Any]] = {}
164266
total_entities = 0
165267

166-
for area_name, area_entities in area_matches.items():
167-
area_data = {
268+
for area_id in matched_area_ids:
269+
area_info = area_registry.get(area_id, {})
270+
area_name = area_info.get("name", area_id)
271+
272+
# Find all entities in this area
273+
area_entities = [
274+
entity_id
275+
for entity_id, resolved_area in entity_area_resolved.items()
276+
if resolved_area == area_id
277+
]
278+
279+
area_data: dict[str, Any] = {
168280
"area_name": area_name,
281+
"area_id": area_id,
169282
"entity_count": len(area_entities),
170283
"entities": {},
171284
}
172285

173286
if group_by_domain:
174-
# Group by domain
175287
domains: dict[str, list[dict[str, Any]]] = {}
176-
for entity in area_entities:
177-
domain = entity["entity_id"].split(".")[0]
288+
for entity_id in area_entities:
289+
domain = entity_id.split(".")[0]
290+
state_info = state_map.get(entity_id, {})
178291
if domain not in domains:
179292
domains[domain] = []
180293
domains[domain].append(
181294
{
182-
"entity_id": entity["entity_id"],
183-
"friendly_name": entity.get("attributes", {}).get(
184-
"friendly_name", entity["entity_id"]
295+
"entity_id": entity_id,
296+
"friendly_name": state_info.get("attributes", {}).get(
297+
"friendly_name", entity_id
185298
),
186-
"state": entity.get("state", "unknown"),
299+
"state": state_info.get("state", "unknown"),
187300
}
188301
)
189302
area_data["entities"] = domains
190303
else:
191-
# Flat list
192304
area_data["entities"] = [
193305
{
194-
"entity_id": entity["entity_id"],
195-
"friendly_name": entity.get("attributes", {}).get(
196-
"friendly_name", entity["entity_id"]
197-
),
198-
"domain": entity["entity_id"].split(".")[0],
199-
"state": entity.get("state", "unknown"),
306+
"entity_id": entity_id,
307+
"friendly_name": (state_info := state_map.get(entity_id, {}))
308+
.get("attributes", {})
309+
.get("friendly_name", entity_id),
310+
"domain": entity_id.split(".")[0],
311+
"state": state_info.get("state", "unknown"),
200312
}
201-
for entity in area_entities
313+
for entity_id in area_entities
202314
]
203315

204-
formatted_areas[area_name] = area_data
316+
formatted_areas[area_id] = area_data
205317
total_entities += len(area_entities)
206318

207319
return {
@@ -211,13 +323,12 @@ async def get_entities_by_area(
211323
"areas": formatted_areas,
212324
"search_metadata": {
213325
"grouped_by_domain": group_by_domain,
214-
"area_inference_method": "fuzzy_name_matching",
326+
"area_inference_method": "registry_lookup",
215327
},
216328
"usage_tips": [
217329
"Try room names: 'salon', 'chambre', 'cuisine'",
218330
"English names: 'living', 'bedroom', 'kitchen'",
219-
"Partial matches: 'bed' finds 'bedroom' entities",
220-
"Use get_all_states to see all area_id attributes",
331+
"Use ha_list_areas() to see all available areas",
221332
],
222333
}
223334

0 commit comments

Comments
 (0)