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.
Files changed (109) hide show
  1. {ha_mcp_dev-7.4.1.dev461/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.4.1.dev462}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/client/rest_client.py +130 -0
  4. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/smart_search.py +366 -8
  5. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_code.py +7 -6
  6. ha_mcp_dev-7.4.1.dev462/src/ha_mcp/tools/tools_config_scenes.py +1010 -0
  7. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_search.py +12 -11
  8. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  9. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp_dev.egg-info/SOURCES.txt +1 -0
  10. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/LICENSE +0 -0
  11. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/MANIFEST.in +0 -0
  12. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/README.md +0 -0
  13. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/setup.cfg +0 -0
  14. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/__init__.py +0 -0
  15. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/__main__.py +0 -0
  16. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/_pypi_marker +0 -0
  17. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/_version.py +0 -0
  18. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/auth/__init__.py +0 -0
  19. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/auth/consent_form.py +0 -0
  20. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/auth/provider.py +0 -0
  21. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/client/__init__.py +0 -0
  22. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/client/websocket_client.py +0 -0
  23. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/client/websocket_listener.py +0 -0
  24. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/config.py +0 -0
  25. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/errors.py +0 -0
  26. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/py.typed +0 -0
  27. {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
  28. {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
  29. {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
  30. {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
  31. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  32. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  33. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  34. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  35. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  36. {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
  37. {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
  38. {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
  39. {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
  40. {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
  41. {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
  42. {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
  43. {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
  44. {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
  45. {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
  46. {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
  47. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/server.py +0 -0
  48. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/settings_ui.py +0 -0
  49. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/smoke_test.py +0 -0
  50. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/__init__.py +0 -0
  51. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/backup.py +0 -0
  52. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  53. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/device_control.py +0 -0
  54. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/enhanced.py +0 -0
  55. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/helpers.py +0 -0
  56. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/reference_validator.py +0 -0
  57. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/registry.py +0 -0
  58. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_addons.py +0 -0
  59. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_areas.py +0 -0
  60. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  61. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  62. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_calendar.py +0 -0
  63. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_camera.py +0 -0
  64. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_categories.py +0 -0
  65. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  66. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  67. {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
  68. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  69. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  70. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_energy.py +0 -0
  71. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_entities.py +0 -0
  72. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  73. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_groups.py +0 -0
  74. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_hacs.py +0 -0
  75. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_history.py +0 -0
  76. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_integrations.py +0 -0
  77. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_labels.py +0 -0
  78. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  79. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_registry.py +0 -0
  80. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_resources.py +0 -0
  81. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_service.py +0 -0
  82. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_services.py +0 -0
  83. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_system.py +0 -0
  84. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_todo.py +0 -0
  85. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_traces.py +0 -0
  86. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_updates.py +0 -0
  87. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_utility.py +0 -0
  88. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  89. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  90. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/tools_zones.py +0 -0
  91. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/tools/util_helpers.py +0 -0
  92. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/transforms/__init__.py +0 -0
  93. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/transforms/categorized_search.py +0 -0
  94. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/utils/__init__.py +0 -0
  95. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/utils/config_hash.py +0 -0
  96. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/utils/data_paths.py +0 -0
  97. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/utils/domain_handlers.py +0 -0
  98. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  99. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  100. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/utils/operation_manager.py +0 -0
  101. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/utils/python_sandbox.py +0 -0
  102. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp/utils/usage_logger.py +0 -0
  103. {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
  104. {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
  105. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  106. {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
  107. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/tests/__init__.py +0 -0
  108. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/tests/test_constants.py +0 -0
  109. {ha_mcp_dev-7.4.1.dev461 → ha_mcp_dev-7.4.1.dev462}/tests/test_env_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.4.1.dev461
3
+ Version: 7.4.1.dev462
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT
@@ -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.dev461"
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
- {area: {"count": info["count"]} for area, info in area_stats.items()}
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 definitions.
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 config fields.
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(uid: str) -> tuple[str, dict[str, Any] | None]:
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(sid: str) -> tuple[str, dict[str, Any] | None]:
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>