Skip to content

Commit 0d68eee

Browse files
authored
fix: resolve entity areas through device registry in get_system_overview (#729)
* fix: resolve entity areas through device registry in get_system_overview * fix: remove unused area_name_map and add regression test for device area resolution
1 parent ad29a03 commit 0d68eee

2 files changed

Lines changed: 85 additions & 7 deletions

File tree

src/ha_mcp/tools/smart_search.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -374,12 +374,16 @@ async def get_system_overview(
374374
entity_registry_task = self.client.send_websocket_message(
375375
{"type": "config/entity_registry/list"}
376376
)
377+
device_registry_task = self.client.send_websocket_message(
378+
{"type": "config/device_registry/list"}
379+
)
377380

378381
results = await asyncio.gather(
379382
entities_task,
380383
services_task,
381384
area_registry_task,
382385
entity_registry_task,
386+
device_registry_task,
383387
return_exceptions=True,
384388
)
385389

@@ -401,11 +405,26 @@ async def get_system_overview(
401405
elif isinstance(results[3], dict) and results[3].get("success"):
402406
entity_registry = results[3].get("result", [])
403407

404-
# Build entity_id -> area_id mapping from entity registry
408+
# Handle device registry result
409+
device_area_map: dict[str, str | None] = {}
410+
if isinstance(results[4], Exception):
411+
logger.debug(f"Could not fetch device registry: {results[4]}")
412+
elif isinstance(results[4], dict) and results[4].get("success"):
413+
for device in results[4].get("result", []):
414+
device_id = device.get("id", "")
415+
if device_id:
416+
device_area_map[device_id] = device.get("area_id")
417+
418+
# Build entity_id -> area_id mapping from entity + device registries
419+
# Priority: entity direct area_id > device area_id
405420
entity_area_map: dict[str, str | None] = {}
406421
for entry in entity_registry:
407422
entity_id = entry.get("entity_id")
408423
area_id = entry.get("area_id")
424+
if not area_id:
425+
device_id = entry.get("device_id")
426+
if device_id:
427+
area_id = device_area_map.get(device_id)
409428
if entity_id:
410429
entity_area_map[entity_id] = area_id
411430

@@ -417,9 +436,19 @@ async def get_system_overview(
417436
if include_entity_id is None:
418437
include_entity_id = detail_level == "full"
419438

439+
# Pre-populate area_stats to include empty areas
440+
area_stats: dict[str, dict[str, Any]] = {}
441+
for area in area_registry:
442+
area_id = area.get("area_id", "")
443+
if area_id:
444+
area_stats[area_id] = {
445+
"name": area.get("name", area_id),
446+
"count": 0,
447+
"domains": {},
448+
}
449+
420450
# Analyze entities by domain
421451
domain_stats: dict[str, dict[str, Any]] = {}
422-
area_stats: dict[str, dict[str, Any]] = {}
423452
device_types: dict[str, int] = {}
424453

425454
for entity in entities:
@@ -454,11 +483,9 @@ async def get_system_overview(
454483

455484
domain_stats[domain]["all_entities"].append(entity_data)
456485

457-
# Area analysis - use entity registry mapping, not state attributes
486+
# Area analysis - use entity + device registry mapping
458487
area_id = entity_area_map.get(entity_id)
459-
if area_id:
460-
if area_id not in area_stats:
461-
area_stats[area_id] = {"count": 0, "domains": {}}
488+
if area_id and area_id in area_stats:
462489
area_stats[area_id]["count"] += 1
463490
if domain not in area_stats[area_id]["domains"]:
464491
area_stats[area_id]["domains"][domain] = 0

tests/src/unit/test_performance_parallelization.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ async def test_parallel_calls_faster_than_sequential(
235235
elapsed = time.time() - start
236236

237237
assert result["success"] is True
238-
# 4 data sources at 0.05s each: sequential = 0.2s, parallel ≈ 0.05s
238+
# 5 data sources at 0.05s each: sequential = 0.25s, parallel ≈ 0.05s
239239
assert elapsed < 0.15, f"Expected parallel speedup, took {elapsed:.2f}s"
240240

241241
@pytest.mark.asyncio
@@ -258,6 +258,57 @@ async def failing_websocket(message: dict) -> dict:
258258
assert result["system_summary"]["total_entities"] == 2
259259
assert result["system_summary"]["total_areas"] == 0
260260

261+
@pytest.mark.asyncio
262+
async def test_resolves_area_through_device_registry(self, sample_services):
263+
"""Entities with no direct area_id inherit area from their parent device."""
264+
entities = [
265+
{
266+
"entity_id": "light.kitchen",
267+
"attributes": {"friendly_name": "Kitchen Light"},
268+
"state": "on",
269+
},
270+
]
271+
client = MockClient(entities=entities, services=sample_services)
272+
273+
# Override websocket to return entity without area_id but with device_id,
274+
# and a device registry that maps that device to an area.
275+
original_ws = client.send_websocket_message
276+
277+
async def ws_with_device_registry(message: dict) -> dict:
278+
msg_type = message.get("type", "")
279+
if msg_type == "config/entity_registry/list":
280+
return {
281+
"success": True,
282+
"result": [
283+
{
284+
"entity_id": "light.kitchen",
285+
"area_id": None,
286+
"device_id": "device_1",
287+
},
288+
],
289+
}
290+
if msg_type == "config/device_registry/list":
291+
return {
292+
"success": True,
293+
"result": [
294+
{"id": "device_1", "area_id": "living_room"},
295+
],
296+
}
297+
return await original_ws(message)
298+
299+
client.send_websocket_message = ws_with_device_registry
300+
301+
with patch("ha_mcp.tools.smart_search.get_global_settings") as mock_settings:
302+
mock_settings.return_value.fuzzy_threshold = 60
303+
tools = SmartSearchTools(client=client)
304+
result = await tools.get_system_overview(detail_level="full")
305+
306+
assert result["success"] is True
307+
assert result["system_summary"]["total_areas"] == 2
308+
area = result["area_analysis"]["living_room"]
309+
assert area["count"] == 1
310+
assert area["domains"]["light"] == 1
311+
261312

262313
# ---------------------------------------------------------------------------
263314
# deep_search – outcome-based tests

0 commit comments

Comments
 (0)