ha-mcp-dev 7.4.1.dev461__tar.gz → 7.4.1.dev462__tar.gz
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_dev-7.4.1.dev461/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.4.1.dev462}/PKG-INFO +1 -1
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/pyproject.toml +1 -1
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/client/rest_client.py +130 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/smart_search.py +366 -8
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_code.py +7 -6
- ha_mcp_dev-7.4.1.dev462/src/ha_mcp/tools/tools_config_scenes.py +1010 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_search.py +12 -11
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp_dev.egg-info/SOURCES.txt +1 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/LICENSE +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/README.md +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/setup.cfg +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/_version.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/config.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/server.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/settings_ui.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/reference_validator.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_energy.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/utils/config_hash.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/utils/data_paths.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/tests/test_env_manager.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ha-mcp-dev"
|
|
7
|
-
version = "7.4.1.
|
|
7
|
+
version = "7.4.1.dev462"
|
|
8
8
|
description = "Home Assistant MCP Server - Complete control of Home Assistant through MCP"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.13,<3.14"
|
|
@@ -1302,6 +1302,136 @@ class HomeAssistantClient:
|
|
|
1302
1302
|
raise
|
|
1303
1303
|
|
|
1304
1304
|
|
|
1305
|
+
async def resolve_scene_id(self, identifier: str) -> str:
|
|
1306
|
+
"""
|
|
1307
|
+
Resolve a scene identifier to its storage key via the entity registry.
|
|
1308
|
+
|
|
1309
|
+
Scenes may be renamed in the HA UI, changing the entity_id but keeping
|
|
1310
|
+
the original storage key. Mirrors :meth:`_resolve_script_id` — scenes
|
|
1311
|
+
likewise need a WebSocket entity registry lookup; their state attributes
|
|
1312
|
+
do not surface the storage key.
|
|
1313
|
+
|
|
1314
|
+
Args:
|
|
1315
|
+
identifier: Scene ID (with or without ``scene.`` prefix)
|
|
1316
|
+
|
|
1317
|
+
Returns:
|
|
1318
|
+
The storage key for the configuration API
|
|
1319
|
+
"""
|
|
1320
|
+
bare_id = identifier.removeprefix("scene.")
|
|
1321
|
+
entity_id = f"scene.{bare_id}"
|
|
1322
|
+
try:
|
|
1323
|
+
result = await self.send_websocket_message(
|
|
1324
|
+
{"type": "config/entity_registry/get", "entity_id": entity_id}
|
|
1325
|
+
)
|
|
1326
|
+
if result.get("success") is not False:
|
|
1327
|
+
unique_id = result.get("result", {}).get("unique_id")
|
|
1328
|
+
if unique_id:
|
|
1329
|
+
if unique_id != bare_id:
|
|
1330
|
+
logger.debug(
|
|
1331
|
+
f"Resolved scene entity_id {entity_id} to storage key {unique_id}"
|
|
1332
|
+
)
|
|
1333
|
+
return str(unique_id)
|
|
1334
|
+
except Exception:
|
|
1335
|
+
logger.debug(
|
|
1336
|
+
f"Entity registry lookup failed for {entity_id}, using bare id: {bare_id}",
|
|
1337
|
+
exc_info=True,
|
|
1338
|
+
)
|
|
1339
|
+
return bare_id
|
|
1340
|
+
|
|
1341
|
+
async def get_scene_config(self, scene_id: str) -> dict[str, Any]:
|
|
1342
|
+
"""Get Home Assistant scene configuration by scene_id."""
|
|
1343
|
+
resolved_id = await self.resolve_scene_id(scene_id)
|
|
1344
|
+
try:
|
|
1345
|
+
endpoint = f"config/scene/config/{resolved_id}"
|
|
1346
|
+
response = await self._request("GET", endpoint)
|
|
1347
|
+
|
|
1348
|
+
return {"success": True, "scene_id": resolved_id, "config": response}
|
|
1349
|
+
except HomeAssistantAPIError as e:
|
|
1350
|
+
if e.status_code == 404:
|
|
1351
|
+
msg = f"Scene not found: {scene_id}"
|
|
1352
|
+
if resolved_id != scene_id:
|
|
1353
|
+
msg += f" (resolved storage key: {resolved_id})"
|
|
1354
|
+
raise HomeAssistantAPIError(msg, status_code=404) from e
|
|
1355
|
+
raise
|
|
1356
|
+
except Exception as e:
|
|
1357
|
+
logger.error(f"Failed to get scene config for {scene_id}: {e}")
|
|
1358
|
+
raise
|
|
1359
|
+
|
|
1360
|
+
async def upsert_scene_config(
|
|
1361
|
+
self, config: dict[str, Any], scene_id: str
|
|
1362
|
+
) -> dict[str, Any]:
|
|
1363
|
+
"""Create or update Home Assistant scene configuration."""
|
|
1364
|
+
resolved_id = await self.resolve_scene_id(scene_id)
|
|
1365
|
+
try:
|
|
1366
|
+
endpoint = f"config/scene/config/{resolved_id}"
|
|
1367
|
+
|
|
1368
|
+
# Default a name when missing — mirrors the script upsert behaviour
|
|
1369
|
+
# so a bare config dict is still acceptable.
|
|
1370
|
+
if "name" not in config:
|
|
1371
|
+
config["name"] = scene_id
|
|
1372
|
+
|
|
1373
|
+
# Validate required field. ``entities`` is a dict keyed by entity_id,
|
|
1374
|
+
# not a list — distinct from script ``sequence`` and automation ``action``.
|
|
1375
|
+
if "entities" not in config:
|
|
1376
|
+
raise ValueError(
|
|
1377
|
+
"Scene configuration must include an 'entities' field "
|
|
1378
|
+
"(a dict keyed by entity_id)"
|
|
1379
|
+
)
|
|
1380
|
+
|
|
1381
|
+
response = await self._request("POST", endpoint, json=config)
|
|
1382
|
+
|
|
1383
|
+
# Issue #1168 R3 blocker 4: HA's POST /config/scene/config/<id>
|
|
1384
|
+
# returns the same ``"ok"`` for both create and update — the
|
|
1385
|
+
# previous ``"created" if "ok" else "updated"`` heuristic was a
|
|
1386
|
+
# tautology that always reported "created". Drop the field
|
|
1387
|
+
# rather than ship a misleading signal; callers tracking write
|
|
1388
|
+
# activity should diff before/after via ``ha_config_get_scene``.
|
|
1389
|
+
return {
|
|
1390
|
+
"success": True,
|
|
1391
|
+
"scene_id": resolved_id,
|
|
1392
|
+
"result": response.get("result", "ok"),
|
|
1393
|
+
}
|
|
1394
|
+
except Exception as e:
|
|
1395
|
+
logger.error(f"Failed to upsert scene config for {scene_id}: {e}")
|
|
1396
|
+
raise
|
|
1397
|
+
|
|
1398
|
+
async def delete_scene_config(self, scene_id: str) -> dict[str, Any]:
|
|
1399
|
+
"""Delete Home Assistant scene configuration."""
|
|
1400
|
+
resolved_id = await self.resolve_scene_id(scene_id)
|
|
1401
|
+
try:
|
|
1402
|
+
endpoint = f"config/scene/config/{resolved_id}"
|
|
1403
|
+
response = await self._request("DELETE", endpoint)
|
|
1404
|
+
|
|
1405
|
+
return {
|
|
1406
|
+
"success": True,
|
|
1407
|
+
"scene_id": resolved_id,
|
|
1408
|
+
"result": response.get("result", "ok"),
|
|
1409
|
+
"operation": "deleted",
|
|
1410
|
+
}
|
|
1411
|
+
except HomeAssistantAPIError as e:
|
|
1412
|
+
if e.status_code == 404:
|
|
1413
|
+
msg = f"Scene not found: {scene_id}"
|
|
1414
|
+
if resolved_id != scene_id:
|
|
1415
|
+
msg += f" (resolved storage key: {resolved_id})"
|
|
1416
|
+
raise HomeAssistantAPIError(msg, status_code=404) from e
|
|
1417
|
+
elif e.status_code == 405:
|
|
1418
|
+
raise HomeAssistantAPIError(
|
|
1419
|
+
f"Cannot delete scene '{scene_id}': The HTTP DELETE method is blocked. "
|
|
1420
|
+
f"This typically occurs when running ha-mcp as a Home Assistant add-on, because "
|
|
1421
|
+
f"the Supervisor ingress proxy only allows GET and POST requests. "
|
|
1422
|
+
f"It may also occur if the scene is defined in YAML configuration files. "
|
|
1423
|
+
f"WORKAROUNDS: "
|
|
1424
|
+
f"(1) Use ha-mcp via pip, Docker, or as an external MCP server instead of the add-on. "
|
|
1425
|
+
f"(2) Use a long-lived access token to connect directly to Home Assistant's API. "
|
|
1426
|
+
f"(3) If the scene is YAML-defined, edit the configuration file directly. "
|
|
1427
|
+
f"(4) As a fallback, rename the scene with a 'DELETE_' prefix "
|
|
1428
|
+
f"(e.g., 'DELETE_{scene_id}') so you can identify and manually delete it later "
|
|
1429
|
+
f"via the Home Assistant UI (Settings > Automations & Scenes > Scenes).",
|
|
1430
|
+
status_code=405,
|
|
1431
|
+
) from e
|
|
1432
|
+
raise
|
|
1433
|
+
|
|
1434
|
+
|
|
1305
1435
|
async def create_client() -> HomeAssistantClient:
|
|
1306
1436
|
"""Create and return a new Home Assistant client."""
|
|
1307
1437
|
return HomeAssistantClient()
|
|
@@ -33,6 +33,7 @@ BULK_REST_TIMEOUT = 5.0 # Timeout for bulk REST endpoint calls
|
|
|
33
33
|
BULK_WEBSOCKET_TIMEOUT = 3.0 # Timeout for bulk WebSocket calls
|
|
34
34
|
INDIVIDUAL_CONFIG_TIMEOUT = 5.0 # Timeout for individual config fetches
|
|
35
35
|
|
|
36
|
+
|
|
36
37
|
# Time budgets for fallback individual fetching (in seconds).
|
|
37
38
|
# Configurable via env vars for instances with many automations/scripts.
|
|
38
39
|
def _env_float(key: str, default: float) -> float:
|
|
@@ -45,8 +46,10 @@ def _env_float(key: str, default: float) -> float:
|
|
|
45
46
|
logger.warning(f"Invalid value for {key}={raw!r}, using default {default}")
|
|
46
47
|
return default
|
|
47
48
|
|
|
49
|
+
|
|
48
50
|
AUTOMATION_CONFIG_TIME_BUDGET = _env_float("HAMCP_AUTOMATION_CONFIG_TIME_BUDGET", 30.0)
|
|
49
51
|
SCRIPT_CONFIG_TIME_BUDGET = _env_float("HAMCP_SCRIPT_CONFIG_TIME_BUDGET", 20.0)
|
|
52
|
+
SCENE_CONFIG_TIME_BUDGET = _env_float("HAMCP_SCENE_CONFIG_TIME_BUDGET", 20.0)
|
|
50
53
|
|
|
51
54
|
# Batch size for parallel individual config fetches (Attempt C fallback)
|
|
52
55
|
INDIVIDUAL_FETCH_BATCH_SIZE = 10
|
|
@@ -758,7 +761,10 @@ class SmartSearchTools:
|
|
|
758
761
|
"system_summary": system_summary,
|
|
759
762
|
"domain_stats": formatted_domain_stats,
|
|
760
763
|
"area_analysis": (
|
|
761
|
-
{
|
|
764
|
+
{
|
|
765
|
+
area: {"count": info["count"]}
|
|
766
|
+
for area, info in area_stats.items()
|
|
767
|
+
}
|
|
762
768
|
if detail_level == "minimal"
|
|
763
769
|
else area_stats
|
|
764
770
|
),
|
|
@@ -808,14 +814,16 @@ class SmartSearchTools:
|
|
|
808
814
|
ctx: Context | None = None,
|
|
809
815
|
) -> dict[str, Any]:
|
|
810
816
|
"""
|
|
811
|
-
Deep search across automation, script, helper, and dashboard
|
|
817
|
+
Deep search across automation, script, scene, helper, and dashboard
|
|
818
|
+
definitions.
|
|
812
819
|
|
|
813
820
|
Searches not just entity names but also within configuration definitions
|
|
814
|
-
including triggers, actions, sequences, and other
|
|
821
|
+
including triggers, actions, sequences, scene entity sets, and other
|
|
822
|
+
config fields.
|
|
815
823
|
|
|
816
824
|
Args:
|
|
817
825
|
query: Search query (can be partial, with typos when exact_match=False)
|
|
818
|
-
search_types: Types to search (default: ["automation", "script", "helper"])
|
|
826
|
+
search_types: Types to search (default: ["automation", "script", "scene", "helper"])
|
|
819
827
|
limit: Maximum total results to return (default: 5)
|
|
820
828
|
offset: Number of results to skip for pagination (default: 0)
|
|
821
829
|
include_config: Include full config in results (default: False)
|
|
@@ -826,12 +834,13 @@ class SmartSearchTools:
|
|
|
826
834
|
Dictionary with search results grouped by type
|
|
827
835
|
"""
|
|
828
836
|
if search_types is None:
|
|
829
|
-
search_types = ["automation", "script", "helper"]
|
|
837
|
+
search_types = ["automation", "script", "scene", "helper"]
|
|
830
838
|
|
|
831
839
|
try:
|
|
832
840
|
results: dict[str, list[dict[str, Any]]] = {
|
|
833
841
|
"automations": [],
|
|
834
842
|
"scripts": [],
|
|
843
|
+
"scenes": [],
|
|
835
844
|
"helpers": [],
|
|
836
845
|
"dashboards": [],
|
|
837
846
|
}
|
|
@@ -961,7 +970,9 @@ class SmartSearchTools:
|
|
|
961
970
|
fetched_count = 0
|
|
962
971
|
failed_count = 0
|
|
963
972
|
|
|
964
|
-
async def _fetch_automation_config(
|
|
973
|
+
async def _fetch_automation_config(
|
|
974
|
+
uid: str,
|
|
975
|
+
) -> tuple[str, dict[str, Any] | None]:
|
|
965
976
|
try:
|
|
966
977
|
config = await asyncio.wait_for(
|
|
967
978
|
self.client._request(
|
|
@@ -1123,7 +1134,9 @@ class SmartSearchTools:
|
|
|
1123
1134
|
fetched_count = 0
|
|
1124
1135
|
failed_count = 0
|
|
1125
1136
|
|
|
1126
|
-
async def _fetch_script_config(
|
|
1137
|
+
async def _fetch_script_config(
|
|
1138
|
+
sid: str,
|
|
1139
|
+
) -> tuple[str, dict[str, Any] | None]:
|
|
1127
1140
|
try:
|
|
1128
1141
|
config_resp = await asyncio.wait_for(
|
|
1129
1142
|
self.client.get_script_config(sid),
|
|
@@ -1203,6 +1216,304 @@ class SmartSearchTools:
|
|
|
1203
1216
|
message=f"scripts searched ({len(results['scripts'])} matches)",
|
|
1204
1217
|
)
|
|
1205
1218
|
|
|
1219
|
+
# ================================================================
|
|
1220
|
+
# SCENE SEARCH (same 3-tier strategy: REST bulk -> WS bulk -> individual)
|
|
1221
|
+
# Scenes have no listing primitive, so entities are enumerated
|
|
1222
|
+
# from get_states() and configs fetched per id. The script branch
|
|
1223
|
+
# uses the same shape today; treat them as parallel implementations
|
|
1224
|
+
# that can diverge if either domain's listing primitive lands later.
|
|
1225
|
+
# ================================================================
|
|
1226
|
+
scene_fetch_failed_count = 0
|
|
1227
|
+
scene_fetch_skipped_count = 0
|
|
1228
|
+
scene_integration_skipped_count = 0
|
|
1229
|
+
scene_registry_fetch_failed = False # B11: signals fallback engaged
|
|
1230
|
+
if "scene" in search_types:
|
|
1231
|
+
scene_entities = [
|
|
1232
|
+
e
|
|
1233
|
+
for e in all_entities
|
|
1234
|
+
if e.get("entity_id", "").startswith("scene.")
|
|
1235
|
+
]
|
|
1236
|
+
|
|
1237
|
+
# Phase 1: Score all scenes by name (instant)
|
|
1238
|
+
scene_name_scored: list[tuple[str, str, str, int]] = []
|
|
1239
|
+
for entity in scene_entities:
|
|
1240
|
+
entity_id = entity.get("entity_id", "")
|
|
1241
|
+
friendly_name = entity.get("attributes", {}).get(
|
|
1242
|
+
"friendly_name", entity_id
|
|
1243
|
+
)
|
|
1244
|
+
scene_id = entity_id.replace("scene.", "")
|
|
1245
|
+
name_score = self.fuzzy_searcher._calculate_entity_score(
|
|
1246
|
+
entity_id, friendly_name, "scene", query_lower
|
|
1247
|
+
)
|
|
1248
|
+
scene_name_scored.append(
|
|
1249
|
+
(entity_id, friendly_name, scene_id, name_score)
|
|
1250
|
+
)
|
|
1251
|
+
|
|
1252
|
+
# Phase 2: Try bulk fetch for scenes
|
|
1253
|
+
all_scene_configs: dict[str, dict[str, Any]] = {}
|
|
1254
|
+
scene_bulk_fetched = False
|
|
1255
|
+
|
|
1256
|
+
# Attempt A: REST bulk endpoint
|
|
1257
|
+
try:
|
|
1258
|
+
resp = await asyncio.wait_for(
|
|
1259
|
+
self.client._request("GET", "/config/scene/config"),
|
|
1260
|
+
timeout=INDIVIDUAL_CONFIG_TIMEOUT,
|
|
1261
|
+
)
|
|
1262
|
+
if isinstance(resp, list):
|
|
1263
|
+
for item in resp:
|
|
1264
|
+
sid = item.get("id") or item.get(
|
|
1265
|
+
"name", ""
|
|
1266
|
+
).lower().replace(" ", "_")
|
|
1267
|
+
if sid:
|
|
1268
|
+
all_scene_configs[sid] = item
|
|
1269
|
+
scene_bulk_fetched = True
|
|
1270
|
+
except Exception as e:
|
|
1271
|
+
logger.debug(f"Scene REST bulk fetch failed: {e}")
|
|
1272
|
+
|
|
1273
|
+
# Attempt B: WebSocket bulk endpoints
|
|
1274
|
+
if not scene_bulk_fetched:
|
|
1275
|
+
for ws_type in [
|
|
1276
|
+
"config/scene/config/list",
|
|
1277
|
+
"scene/config/list",
|
|
1278
|
+
]:
|
|
1279
|
+
if scene_bulk_fetched:
|
|
1280
|
+
break
|
|
1281
|
+
try:
|
|
1282
|
+
ws_resp = await asyncio.wait_for(
|
|
1283
|
+
self.client.send_websocket_message({"type": ws_type}),
|
|
1284
|
+
timeout=BULK_WEBSOCKET_TIMEOUT,
|
|
1285
|
+
)
|
|
1286
|
+
if isinstance(ws_resp, dict) and ws_resp.get("success"):
|
|
1287
|
+
for item in ws_resp.get("result", []):
|
|
1288
|
+
sid = item.get("id") or item.get(
|
|
1289
|
+
"name", ""
|
|
1290
|
+
).lower().replace(" ", "_")
|
|
1291
|
+
if sid:
|
|
1292
|
+
all_scene_configs[sid] = item
|
|
1293
|
+
scene_bulk_fetched = True
|
|
1294
|
+
except Exception as e:
|
|
1295
|
+
logger.debug(
|
|
1296
|
+
f"Scene WebSocket bulk fetch ({ws_type}) failed: {e}"
|
|
1297
|
+
)
|
|
1298
|
+
|
|
1299
|
+
# Phase 2.5: walk the entity registry once. Two outputs:
|
|
1300
|
+
#
|
|
1301
|
+
# 1. ``homeassistant_scene_uids`` — the set of unique_ids
|
|
1302
|
+
# backed by ``platform == "homeassistant"`` (HA's storage
|
|
1303
|
+
# collection). Integration-managed scenes (Hue, IKEA,
|
|
1304
|
+
# deCONZ, …) are entity-only — the per-id REST endpoint
|
|
1305
|
+
# ``/config/scene/config/<id>`` can't fetch them and
|
|
1306
|
+
# treating their 404s as ``failed_count`` produces a
|
|
1307
|
+
# misleading ``partial: true`` flag on every install
|
|
1308
|
+
# with integration scenes (issue #1168 R3 blocker 2).
|
|
1309
|
+
# 2. Slug-keyed aliases pointing at the bulk-fetched
|
|
1310
|
+
# config. HA derives a scene's entity_id from the
|
|
1311
|
+
# ``name`` field via its own slugify (collapsing runs
|
|
1312
|
+
# of underscores, replacing all non-alnum with
|
|
1313
|
+
# underscores, etc.); approximating that with
|
|
1314
|
+
# `.replace()` chains produces near-misses.
|
|
1315
|
+
#
|
|
1316
|
+
# Run the registry fetch unconditionally so the platform
|
|
1317
|
+
# filter is available even when Phase 2 returned nothing
|
|
1318
|
+
# (the common Hue-only case where bulk fetches the lone
|
|
1319
|
+
# HA-managed scene and Attempt C would otherwise try every
|
|
1320
|
+
# Hue scene).
|
|
1321
|
+
homeassistant_scene_uids: set[str] = set()
|
|
1322
|
+
# Issue #1168 R7 blocker 17/21: registry-derived slug→storage
|
|
1323
|
+
# map for the result-builder fallback. When ``all_scene_configs``
|
|
1324
|
+
# has no entry for a scene (bulk omitted it, integration-
|
|
1325
|
+
# managed, or ``id`` field absent), the result-builder
|
|
1326
|
+
# previously fell back silently to the entity-id slug. With
|
|
1327
|
+
# this map the storage key stays correct for any scene the
|
|
1328
|
+
# registry knows about, regardless of bulk-fetch coverage.
|
|
1329
|
+
slug_to_storage_id: dict[str, str] = {}
|
|
1330
|
+
try:
|
|
1331
|
+
reg_resp = await asyncio.wait_for(
|
|
1332
|
+
self.client.send_websocket_message(
|
|
1333
|
+
{"type": "config/entity_registry/list"}
|
|
1334
|
+
),
|
|
1335
|
+
timeout=BULK_WEBSOCKET_TIMEOUT,
|
|
1336
|
+
)
|
|
1337
|
+
if isinstance(reg_resp, dict) and reg_resp.get("success"):
|
|
1338
|
+
for entry in reg_resp.get("result") or []:
|
|
1339
|
+
ent_id = entry.get("entity_id") or ""
|
|
1340
|
+
uid = entry.get("unique_id")
|
|
1341
|
+
if not ent_id.startswith("scene.") or not uid:
|
|
1342
|
+
continue
|
|
1343
|
+
if entry.get("platform") == "homeassistant":
|
|
1344
|
+
homeassistant_scene_uids.add(uid)
|
|
1345
|
+
slug = ent_id.removeprefix("scene.")
|
|
1346
|
+
if slug:
|
|
1347
|
+
slug_to_storage_id[slug] = uid
|
|
1348
|
+
if uid in all_scene_configs:
|
|
1349
|
+
if slug and slug != uid:
|
|
1350
|
+
all_scene_configs[slug] = all_scene_configs[uid]
|
|
1351
|
+
except Exception as e:
|
|
1352
|
+
# Issue #1168 R5 blocker 11: promote DEBUG → WARNING
|
|
1353
|
+
# and signal the fallback so partial_reason can
|
|
1354
|
+
# explain why the count looks elevated. The previous
|
|
1355
|
+
# DEBUG-only log meant a true registry outage looked
|
|
1356
|
+
# identical to the steady-state happy path on stderr.
|
|
1357
|
+
logger.warning(
|
|
1358
|
+
"Scene entity-registry augmentation failed: %s; "
|
|
1359
|
+
"integration-platform filter unavailable, attempting all scenes",
|
|
1360
|
+
e,
|
|
1361
|
+
)
|
|
1362
|
+
scene_registry_fetch_failed = True
|
|
1363
|
+
|
|
1364
|
+
# Attempt C: parallel per-id fetch with a wall-clock budget so a
|
|
1365
|
+
# few slow scenes don't tank the whole search; remaining ids
|
|
1366
|
+
# bail out via SCENE_CONFIG_TIME_BUDGET below.
|
|
1367
|
+
if not scene_bulk_fetched:
|
|
1368
|
+
budget_start = time.perf_counter()
|
|
1369
|
+
# Issue #1168 R3 blocker 2: skip integration-managed
|
|
1370
|
+
# scenes — their per-id REST endpoint 404s by design,
|
|
1371
|
+
# and surfacing those as fetch failures masks real
|
|
1372
|
+
# errors. Counted separately so the partial_reason
|
|
1373
|
+
# string can distinguish the two failure modes. When
|
|
1374
|
+
# the registry call failed (homeassistant_scene_uids
|
|
1375
|
+
# empty), fall back to attempting all scenes — false
|
|
1376
|
+
# partials beat dropping legitimate HA-managed scenes
|
|
1377
|
+
# silently.
|
|
1378
|
+
if homeassistant_scene_uids:
|
|
1379
|
+
sids_to_fetch = []
|
|
1380
|
+
for _, _, sid, _ in scene_name_scored:
|
|
1381
|
+
if not sid or sid in all_scene_configs:
|
|
1382
|
+
continue
|
|
1383
|
+
if sid in homeassistant_scene_uids:
|
|
1384
|
+
sids_to_fetch.append(sid)
|
|
1385
|
+
else:
|
|
1386
|
+
scene_integration_skipped_count += 1
|
|
1387
|
+
else:
|
|
1388
|
+
sids_to_fetch = [
|
|
1389
|
+
sid
|
|
1390
|
+
for _, _, sid, _ in scene_name_scored
|
|
1391
|
+
if sid and sid not in all_scene_configs
|
|
1392
|
+
]
|
|
1393
|
+
total_to_fetch = len(sids_to_fetch)
|
|
1394
|
+
fetched_count = 0
|
|
1395
|
+
failed_count = 0
|
|
1396
|
+
|
|
1397
|
+
async def _fetch_scene_config(
|
|
1398
|
+
sid: str,
|
|
1399
|
+
) -> tuple[str, dict[str, Any] | None]:
|
|
1400
|
+
try:
|
|
1401
|
+
config_resp = await asyncio.wait_for(
|
|
1402
|
+
self.client.get_scene_config(sid),
|
|
1403
|
+
timeout=INDIVIDUAL_CONFIG_TIMEOUT,
|
|
1404
|
+
)
|
|
1405
|
+
return (sid, config_resp.get("config", {}))
|
|
1406
|
+
except Exception as e:
|
|
1407
|
+
logger.debug(
|
|
1408
|
+
f"Scene individual config fetch ({sid}) failed: {e}"
|
|
1409
|
+
)
|
|
1410
|
+
return (sid, None)
|
|
1411
|
+
|
|
1412
|
+
for i in range(0, len(sids_to_fetch), INDIVIDUAL_FETCH_BATCH_SIZE):
|
|
1413
|
+
if (
|
|
1414
|
+
time.perf_counter() - budget_start
|
|
1415
|
+
> SCENE_CONFIG_TIME_BUDGET
|
|
1416
|
+
):
|
|
1417
|
+
scene_fetch_skipped_count = (
|
|
1418
|
+
total_to_fetch - fetched_count - failed_count
|
|
1419
|
+
)
|
|
1420
|
+
logger.warning(
|
|
1421
|
+
f"Scene config fetch budget exhausted "
|
|
1422
|
+
f"({SCENE_CONFIG_TIME_BUDGET}s). "
|
|
1423
|
+
f"Fetched {fetched_count}/{total_to_fetch} "
|
|
1424
|
+
f"({failed_count} failed), "
|
|
1425
|
+
f"skipped {scene_fetch_skipped_count} scenes."
|
|
1426
|
+
)
|
|
1427
|
+
break
|
|
1428
|
+
batch = sids_to_fetch[i : i + INDIVIDUAL_FETCH_BATCH_SIZE]
|
|
1429
|
+
batch_results = await asyncio.gather(
|
|
1430
|
+
*[_fetch_scene_config(sid) for sid in batch],
|
|
1431
|
+
)
|
|
1432
|
+
for sid_result, config_result in batch_results:
|
|
1433
|
+
if config_result is not None:
|
|
1434
|
+
all_scene_configs[sid_result] = config_result
|
|
1435
|
+
fetched_count += 1
|
|
1436
|
+
else:
|
|
1437
|
+
failed_count += 1
|
|
1438
|
+
scene_fetch_failed_count = failed_count
|
|
1439
|
+
|
|
1440
|
+
# Phase 3: Score scenes
|
|
1441
|
+
for (
|
|
1442
|
+
entity_id,
|
|
1443
|
+
friendly_name,
|
|
1444
|
+
scene_id,
|
|
1445
|
+
name_score,
|
|
1446
|
+
) in scene_name_scored:
|
|
1447
|
+
scene_config = all_scene_configs.get(scene_id, {})
|
|
1448
|
+
config_match_score = (
|
|
1449
|
+
self._search_in_dict(scene_config, query_lower, exact_match)
|
|
1450
|
+
if scene_config
|
|
1451
|
+
else 0
|
|
1452
|
+
)
|
|
1453
|
+
total_score, threshold, match_in_name = self._score_deep_match(
|
|
1454
|
+
entity_id,
|
|
1455
|
+
friendly_name,
|
|
1456
|
+
name_score,
|
|
1457
|
+
config_match_score,
|
|
1458
|
+
query_lower,
|
|
1459
|
+
exact_match,
|
|
1460
|
+
)
|
|
1461
|
+
|
|
1462
|
+
if total_score >= threshold:
|
|
1463
|
+
# Issue #1168 R6 blocker 17 (refined per R7
|
|
1464
|
+
# blockers 17/21): ``scene_id`` here must be the
|
|
1465
|
+
# storage key (matching the contract used by
|
|
1466
|
+
# ``ha_config_get_scene`` / ``ha_config_set_scene``),
|
|
1467
|
+
# not the entity_id-slug derived at fetch time.
|
|
1468
|
+
# Three-tier resolution:
|
|
1469
|
+
# 1. ``scene_config["id"]`` — most direct, present
|
|
1470
|
+
# whenever the bulk fetch carried this scene.
|
|
1471
|
+
# 2. ``slug_to_storage_id`` — registry-derived
|
|
1472
|
+
# mapping built during the Phase-2.5 walk,
|
|
1473
|
+
# covers integration-managed scenes and any
|
|
1474
|
+
# scene whose bulk record omitted ``id``.
|
|
1475
|
+
# 3. ``scene_id`` itself (the entity-id slug) —
|
|
1476
|
+
# final fallback when the registry walk also
|
|
1477
|
+
# failed; surfaced via ``logger.warning`` so
|
|
1478
|
+
# the silent-slug-mismatch path becomes
|
|
1479
|
+
# observable.
|
|
1480
|
+
if isinstance(scene_config, dict) and isinstance(
|
|
1481
|
+
scene_config.get("id"), str
|
|
1482
|
+
):
|
|
1483
|
+
storage_id = scene_config["id"]
|
|
1484
|
+
elif scene_id in slug_to_storage_id:
|
|
1485
|
+
storage_id = slug_to_storage_id[scene_id]
|
|
1486
|
+
else:
|
|
1487
|
+
storage_id = scene_id
|
|
1488
|
+
logger.warning(
|
|
1489
|
+
"ha_deep_search scene result fell back to "
|
|
1490
|
+
"entity-id slug for scene_id=%r — neither "
|
|
1491
|
+
"bulk config nor registry walk produced a "
|
|
1492
|
+
"storage key. ``ha_config_get_scene`` will "
|
|
1493
|
+
"rely on its resolver remap to land on the "
|
|
1494
|
+
"right scene.",
|
|
1495
|
+
scene_id,
|
|
1496
|
+
)
|
|
1497
|
+
results["scenes"].append(
|
|
1498
|
+
{
|
|
1499
|
+
"entity_id": entity_id,
|
|
1500
|
+
"scene_id": storage_id,
|
|
1501
|
+
"friendly_name": friendly_name,
|
|
1502
|
+
"score": total_score,
|
|
1503
|
+
"match_in_name": match_in_name,
|
|
1504
|
+
"match_in_config": config_match_score >= threshold,
|
|
1505
|
+
"config": scene_config if scene_config else None,
|
|
1506
|
+
}
|
|
1507
|
+
)
|
|
1508
|
+
|
|
1509
|
+
phase_done += 1
|
|
1510
|
+
if ctx is not None:
|
|
1511
|
+
await ctx.report_progress(
|
|
1512
|
+
progress=phase_done,
|
|
1513
|
+
total=total_phases,
|
|
1514
|
+
message=f"scenes searched ({len(results['scenes'])} matches)",
|
|
1515
|
+
)
|
|
1516
|
+
|
|
1206
1517
|
# Search helpers with parallel WebSocket calls
|
|
1207
1518
|
if "helper" in search_types:
|
|
1208
1519
|
helper_types = [
|
|
@@ -1408,6 +1719,7 @@ class SmartSearchTools:
|
|
|
1408
1719
|
final_results: dict[str, list[dict[str, Any]]] = {
|
|
1409
1720
|
"automations": [],
|
|
1410
1721
|
"scripts": [],
|
|
1722
|
+
"scenes": [],
|
|
1411
1723
|
"helpers": [],
|
|
1412
1724
|
"dashboards": [],
|
|
1413
1725
|
}
|
|
@@ -1429,14 +1741,60 @@ class SmartSearchTools:
|
|
|
1429
1741
|
"next_offset": offset + limit if has_more else None,
|
|
1430
1742
|
"automations": final_results["automations"],
|
|
1431
1743
|
"scripts": final_results["scripts"],
|
|
1744
|
+
"scenes": final_results["scenes"],
|
|
1432
1745
|
"helpers": final_results["helpers"],
|
|
1433
1746
|
"search_types": search_types,
|
|
1434
1747
|
}
|
|
1435
1748
|
|
|
1436
|
-
# Only include dashboards key when dashboard search was requested
|
|
1749
|
+
# Only include the dashboards key when dashboard search was requested.
|
|
1750
|
+
# ``scenes`` is in the default ``search_types`` so the bucket is
|
|
1751
|
+
# always-present alongside automations/scripts/helpers; gating it
|
|
1752
|
+
# would break test helpers that iterate the standard tuple.
|
|
1437
1753
|
if "dashboard" in search_types:
|
|
1438
1754
|
response["dashboards"] = final_results["dashboards"]
|
|
1439
1755
|
|
|
1756
|
+
# Surface partial results from the scene Attempt-C fetch so the
|
|
1757
|
+
# caller can distinguish "no scene matched" from "matches may be
|
|
1758
|
+
# missing because some configs failed or timed out". Only set
|
|
1759
|
+
# ``partial: True`` when something actually went wrong; downstream
|
|
1760
|
+
# consumers should treat absence as success.
|
|
1761
|
+
#
|
|
1762
|
+
# Issue #1168 R3 blocker 2: integration-managed scenes (Hue,
|
|
1763
|
+
# IKEA, deCONZ, …) intentionally don't go through the per-id
|
|
1764
|
+
# fetch — they're scored on entity attributes only — so they
|
|
1765
|
+
# are NOT considered a fault for the partial flag. The
|
|
1766
|
+
# ``_integration_skipped`` count is informational; it never
|
|
1767
|
+
# raises ``partial: true`` on its own.
|
|
1768
|
+
if scene_fetch_failed_count or scene_fetch_skipped_count:
|
|
1769
|
+
response["partial"] = True
|
|
1770
|
+
reason_parts = [
|
|
1771
|
+
f"Scene config fetch incomplete: "
|
|
1772
|
+
f"{scene_fetch_failed_count} failed, "
|
|
1773
|
+
f"{scene_fetch_skipped_count} skipped (time budget)."
|
|
1774
|
+
]
|
|
1775
|
+
if scene_integration_skipped_count:
|
|
1776
|
+
reason_parts.append(
|
|
1777
|
+
f" {scene_integration_skipped_count} integration-managed "
|
|
1778
|
+
"scenes are scored by attribute only (no per-id fetch)."
|
|
1779
|
+
)
|
|
1780
|
+
if scene_registry_fetch_failed:
|
|
1781
|
+
# Issue #1168 R5 blocker 11: when the registry fetch
|
|
1782
|
+
# errors, the integration-platform filter is
|
|
1783
|
+
# unavailable and Attempt C falls back to attempting
|
|
1784
|
+
# all scenes — surface that so an elevated
|
|
1785
|
+
# ``failed_count`` isn't mistaken for a real config
|
|
1786
|
+
# outage.
|
|
1787
|
+
reason_parts.append(
|
|
1788
|
+
" Entity-registry fetch failed; integration-platform "
|
|
1789
|
+
"filter unavailable, attempted all scenes "
|
|
1790
|
+
"(false-positive failures expected for integration-managed scenes)."
|
|
1791
|
+
)
|
|
1792
|
+
reason_parts.append(
|
|
1793
|
+
" Some scene matches may be missing config data; tune "
|
|
1794
|
+
"HAMCP_SCENE_CONFIG_TIME_BUDGET to raise the budget."
|
|
1795
|
+
)
|
|
1796
|
+
response["partial_reason"] = "".join(reason_parts)
|
|
1797
|
+
|
|
1440
1798
|
return response
|
|
1441
1799
|
|
|
1442
1800
|
except ToolError:
|
|
@@ -85,12 +85,7 @@ _SAVE_NAME_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$")
|
|
|
85
85
|
# override real ones in the in-memory state machine).
|
|
86
86
|
# 2. Endpoints whose corresponding wrapping MCP tool performs
|
|
87
87
|
# validation / lint / hash-locking that raw ``api_post`` would skip
|
|
88
|
-
# — currently ``config/{automation,script}/config/``.
|
|
89
|
-
# Scene config writes (``config/scene/config/*``) are intentionally NOT
|
|
90
|
-
# in this list: there is no ``ha_config_set_scene`` tool to redirect to,
|
|
91
|
-
# and blocking the path without offering a substitute would just remove
|
|
92
|
-
# capability with no validated alternative. Add the block back when a
|
|
93
|
-
# wrapping tool lands.
|
|
88
|
+
# — currently ``config/{automation,script,scene}/config/``.
|
|
94
89
|
# The prefixes are matched after ``_normalize_endpoint`` strips the
|
|
95
90
|
# leading ``api/`` so they are written as plain HA-relative paths.
|
|
96
91
|
_API_POST_BLOCKED_PREFIXES: tuple[tuple[str, str, str], ...] = (
|
|
@@ -111,6 +106,12 @@ _API_POST_BLOCKED_PREFIXES: tuple[tuple[str, str, str], ...] = (
|
|
|
111
106
|
"Direct writes to /api/config/script/config/*",
|
|
112
107
|
"use call_tool('ha_config_set_script', ...)",
|
|
113
108
|
),
|
|
109
|
+
(
|
|
110
|
+
"config/scene/config/",
|
|
111
|
+
"Direct writes to /api/config/scene/config/*",
|
|
112
|
+
"use call_tool('ha_config_set_scene', ...) so the entities-must-be-dict "
|
|
113
|
+
"shape check, reference validation, and hash-locking run",
|
|
114
|
+
),
|
|
114
115
|
)
|
|
115
116
|
|
|
116
117
|
# HA Core internal events. Firing one of these via POST /api/events/<name>
|