ha-mcp-dev 7.4.1.dev440__tar.gz → 7.4.1.dev441__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 (107) hide show
  1. {ha_mcp_dev-7.4.1.dev440/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.4.1.dev441}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_entities.py +277 -74
  4. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  5. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/LICENSE +0 -0
  6. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/MANIFEST.in +0 -0
  7. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/README.md +0 -0
  8. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/setup.cfg +0 -0
  9. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/__init__.py +0 -0
  10. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/__main__.py +0 -0
  11. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/_pypi_marker +0 -0
  12. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/_version.py +0 -0
  13. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/auth/__init__.py +0 -0
  14. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/auth/consent_form.py +0 -0
  15. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/auth/provider.py +0 -0
  16. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/client/__init__.py +0 -0
  17. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/client/rest_client.py +0 -0
  18. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/client/websocket_client.py +0 -0
  19. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/client/websocket_listener.py +0 -0
  20. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/config.py +0 -0
  21. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/errors.py +0 -0
  22. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/py.typed +0 -0
  23. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  24. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  25. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  26. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  27. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  28. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  29. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  30. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  31. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  32. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  33. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  34. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  35. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  36. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  37. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  38. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  39. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  40. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  41. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  42. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  43. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/server.py +0 -0
  44. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/settings_ui.py +0 -0
  45. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/smoke_test.py +0 -0
  46. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/__init__.py +0 -0
  47. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/backup.py +0 -0
  48. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  49. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/device_control.py +0 -0
  50. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/enhanced.py +0 -0
  51. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/helpers.py +0 -0
  52. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/reference_validator.py +0 -0
  53. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/registry.py +0 -0
  54. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/smart_search.py +0 -0
  55. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_addons.py +0 -0
  56. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_areas.py +0 -0
  57. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  58. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  59. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_calendar.py +0 -0
  60. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_camera.py +0 -0
  61. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_categories.py +0 -0
  62. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  63. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  64. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  65. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  66. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  67. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_energy.py +0 -0
  68. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  69. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_groups.py +0 -0
  70. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_hacs.py +0 -0
  71. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_history.py +0 -0
  72. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_integrations.py +0 -0
  73. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_labels.py +0 -0
  74. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  75. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_registry.py +0 -0
  76. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_resources.py +0 -0
  77. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_search.py +0 -0
  78. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_service.py +0 -0
  79. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_services.py +0 -0
  80. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_system.py +0 -0
  81. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_todo.py +0 -0
  82. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_traces.py +0 -0
  83. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_updates.py +0 -0
  84. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_utility.py +0 -0
  85. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  86. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  87. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/tools_zones.py +0 -0
  88. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/tools/util_helpers.py +0 -0
  89. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/transforms/__init__.py +0 -0
  90. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/transforms/categorized_search.py +0 -0
  91. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/utils/__init__.py +0 -0
  92. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/utils/config_hash.py +0 -0
  93. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/utils/data_paths.py +0 -0
  94. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/utils/domain_handlers.py +0 -0
  95. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  96. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  97. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/utils/operation_manager.py +0 -0
  98. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/utils/python_sandbox.py +0 -0
  99. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp/utils/usage_logger.py +0 -0
  100. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp_dev.egg-info/SOURCES.txt +0 -0
  101. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  102. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  103. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  104. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  105. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/tests/__init__.py +0 -0
  106. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/tests/test_constants.py +0 -0
  107. {ha_mcp_dev-7.4.1.dev440 → ha_mcp_dev-7.4.1.dev441}/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.dev440
3
+ Version: 7.4.1.dev441
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.dev440"
7
+ version = "7.4.1.dev441"
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"
@@ -38,9 +38,30 @@ def _format_entity_entry(entry: dict[str, Any]) -> dict[str, Any]:
38
38
  "aliases": entry.get("aliases", []),
39
39
  "labels": entry.get("labels", []),
40
40
  "categories": entry.get("categories", {}),
41
+ "device_class": entry.get("device_class"),
42
+ "original_device_class": entry.get("original_device_class"),
43
+ "options": entry.get("options", {}),
41
44
  }
42
45
 
43
46
 
47
+ def _extract_ws_error(result: dict[str, Any]) -> str:
48
+ """Pull a user-readable message out of a failed WebSocket response.
49
+
50
+ Falls back to a static placeholder + warning log when HA returns an
51
+ empty or malformed error envelope, so the user-facing message never
52
+ degrades to literal "{}".
53
+ """
54
+ error = result.get("error")
55
+ if isinstance(error, dict):
56
+ msg = error.get("message")
57
+ if isinstance(msg, str) and msg:
58
+ return msg
59
+ elif isinstance(error, str) and error:
60
+ return error
61
+ logger.warning("HA WS response had no usable error detail: %r", result)
62
+ return "no error detail returned by Home Assistant"
63
+
64
+
44
65
  def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
45
66
  """Register entity management tools with the MCP server."""
46
67
 
@@ -52,13 +73,7 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
52
73
  }
53
74
  result = await client.send_websocket_message(get_msg)
54
75
  if not result.get("success"):
55
- error = result.get("error", {})
56
- error_msg = (
57
- error.get("message", str(error))
58
- if isinstance(error, dict)
59
- else str(error)
60
- )
61
- return None, error_msg
76
+ return None, _extract_ws_error(result)
62
77
  return result.get("result", {}).get("labels", []), None
63
78
 
64
79
  async def _update_single_entity(
@@ -75,6 +90,8 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
75
90
  parsed_expose_to: dict[str, bool] | None,
76
91
  new_entity_id: str | None = None,
77
92
  new_device_name: str | None = None,
93
+ device_class: str | None = None,
94
+ parsed_options: dict[str, dict[str, Any]] | None = None,
78
95
  ) -> dict[str, Any]:
79
96
  """Update a single entity. Returns the response dict."""
80
97
  # For add/remove operations, we need to fetch current labels first
@@ -120,6 +137,17 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
120
137
  message["icon"] = icon if icon else None
121
138
  updates_made.append(f"icon='{icon}'" if icon else "icon cleared")
122
139
 
140
+ if device_class is not None:
141
+ # Treat whitespace-only as the documented "clear" sentinel so
142
+ # accidental spaces don't reach HA as a literal validation error.
143
+ normalized_device_class = device_class.strip() or None
144
+ message["device_class"] = normalized_device_class
145
+ updates_made.append(
146
+ f"device_class='{normalized_device_class}'"
147
+ if normalized_device_class
148
+ else "device_class cleared"
149
+ )
150
+
123
151
  if enabled is not None:
124
152
  try:
125
153
  enabled_bool = coerce_bool_param(enabled, "enabled")
@@ -203,13 +231,16 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
203
231
  if new_device_name is not None:
204
232
  updates_made.append(f"device_name -> {new_device_name}")
205
233
 
206
- if not updates_made:
234
+ # parsed_options entries are appended to updates_made AFTER each per-domain
235
+ # WS call succeeds, so the response never falsely claims an unwritten domain
236
+ # was updated. Empty-input check below treats them as "pending" updates.
237
+ if not updates_made and not parsed_options:
207
238
  raise_tool_error(
208
239
  create_error_response(
209
240
  ErrorCode.VALIDATION_INVALID_PARAMETER,
210
241
  "No updates specified",
211
242
  suggestions=[
212
- "Provide at least one of: area_id, name, icon, enabled, hidden, aliases, categories, labels, expose_to, new_entity_id, or new_device_name"
243
+ "Provide at least one of: area_id, name, icon, device_class, enabled, hidden, aliases, categories, labels, options, expose_to, new_entity_id, or new_device_name"
213
244
  ],
214
245
  )
215
246
  )
@@ -231,25 +262,24 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
231
262
  result = await client.send_websocket_message(message)
232
263
 
233
264
  if not result.get("success"):
234
- error = result.get("error", {})
235
- error_msg = (
236
- error.get("message", str(error))
237
- if isinstance(error, dict)
238
- else str(error)
239
- )
265
+ error_msg = _extract_ws_error(result)
240
266
  suggestions = [
241
267
  "Verify the entity_id exists using ha_search_entities()",
242
268
  ]
243
269
  if new_entity_id is not None:
244
- suggestions.extend([
245
- "Check that the new entity_id doesn't already exist",
246
- "Ensure the entity has a unique_id (some legacy entities cannot be renamed)",
247
- ])
270
+ suggestions.extend(
271
+ [
272
+ "Check that the new entity_id doesn't already exist",
273
+ "Ensure the entity has a unique_id (some legacy entities cannot be renamed)",
274
+ ]
275
+ )
248
276
  else:
249
- suggestions.extend([
250
- "Check that area_id exists if specified",
251
- "Some entities may not support all update options",
252
- ])
277
+ suggestions.extend(
278
+ [
279
+ "Check that area_id exists if specified",
280
+ "Some entities may not support all update options",
281
+ ]
282
+ )
253
283
  raise_tool_error(
254
284
  create_error_response(
255
285
  ErrorCode.SERVICE_CALL_FAILED,
@@ -265,6 +295,64 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
265
295
  if new_entity_id:
266
296
  entity_id = new_entity_id
267
297
 
298
+ # Per-domain options updates: HA's WS schema requires `options_domain`
299
+ # and `options` to be sent paired one domain per call (the API takes a
300
+ # single domain's sub-dict). An agent-supplied {domain: {...}, ...} is
301
+ # therefore split into one registry update per domain.
302
+ options_succeeded: dict[str, dict[str, Any]] = {}
303
+ if parsed_options:
304
+ for opts_domain, opts_sub in parsed_options.items():
305
+ opts_msg: dict[str, Any] = {
306
+ "type": "config/entity_registry/update",
307
+ "entity_id": entity_id,
308
+ "options_domain": opts_domain,
309
+ "options": opts_sub,
310
+ }
311
+ opts_result = await client.send_websocket_message(opts_msg)
312
+ if not opts_result.get("success"):
313
+ err_msg = _extract_ws_error(opts_result)
314
+ partial = bool(options_succeeded) or has_registry_updates
315
+ msg_prefix = (
316
+ "Partially updated entity; failed updating options for"
317
+ if partial
318
+ else "Failed to update options for"
319
+ )
320
+ # `options_succeeded` is the structured retriable form
321
+ # (agent can re-feed it minus the failing domain).
322
+ # `updates_applied` is the human-readable prose list
323
+ # including non-options updates (name=, icon=, etc.).
324
+ # Both are surfaced — they serve different consumers.
325
+ options_failure_context: dict[str, Any] = {
326
+ "entity_id": entity_id,
327
+ "options_domain": opts_domain,
328
+ "partial": partial,
329
+ "options_succeeded": options_succeeded,
330
+ "updates_applied": list(updates_made),
331
+ }
332
+ # Only include entity_entry when something actually mutated;
333
+ # _format_entity_entry({}) returns an all-None stub that's
334
+ # indistinguishable from "entity has nothing set". Mirrors
335
+ # the expose_to failure path below.
336
+ if partial:
337
+ options_failure_context["entity_entry"] = _format_entity_entry(
338
+ entity_entry
339
+ )
340
+ raise_tool_error(
341
+ create_error_response(
342
+ ErrorCode.SERVICE_CALL_FAILED,
343
+ f"{msg_prefix} domain '{opts_domain}': {err_msg}",
344
+ context=options_failure_context,
345
+ )
346
+ )
347
+ # HA returns the cumulative entity_entry on each per-domain
348
+ # call, so last-call-wins reassignment leaves the final loop
349
+ # iteration carrying the full state.
350
+ entity_entry = opts_result.get("result", {}).get(
351
+ "entity_entry", entity_entry
352
+ )
353
+ options_succeeded[opts_domain] = opts_sub
354
+ updates_made.append(f"options[{opts_domain}]={opts_sub}")
355
+
268
356
  # Handle new_device_name — rename the associated device
269
357
  # Normalize empty string to None (no-op, don't clear device name)
270
358
  if new_device_name is not None and not new_device_name.strip():
@@ -281,12 +369,18 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
281
369
  if get_result.get("success"):
282
370
  entity_entry = get_result.get("result", {})
283
371
  else:
284
- logger.warning(f"Entity registry lookup failed for {entity_id}: {get_result.get('error')}")
372
+ logger.warning(
373
+ "Entity registry lookup failed for %s: %s",
374
+ entity_id,
375
+ _extract_ws_error(get_result),
376
+ )
285
377
  device_rename_result = {
286
378
  "warning": "Entity registry lookup failed — could not determine device. Retry may succeed.",
287
379
  }
288
380
 
289
- device_id = entity_entry.get("device_id") if not device_rename_result else None
381
+ device_id = (
382
+ entity_entry.get("device_id") if not device_rename_result else None
383
+ )
290
384
  if not device_id:
291
385
  device_rename_result = {
292
386
  "warning": "Entity has no associated device — device rename skipped",
@@ -302,7 +396,7 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
302
396
  device_rename_result = {"success": True, "device_id": device_id}
303
397
  else:
304
398
  device_rename_result = {
305
- "warning": f"Entity updated but device rename failed: {device_result.get('error', 'Unknown error')}",
399
+ "warning": f"Entity updated but device rename failed: {_extract_ws_error(device_result)}",
306
400
  "device_id": device_id,
307
401
  }
308
402
 
@@ -336,27 +430,43 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
336
430
  expose_result = await client.send_websocket_message(expose_msg)
337
431
 
338
432
  if not expose_result.get("success"):
339
- error = expose_result.get("error", {})
340
- error_msg = (
341
- error.get("message", str(error))
342
- if isinstance(error, dict)
343
- else str(error)
344
- )
433
+ error_msg = _extract_ws_error(expose_result)
345
434
  failed = dict.fromkeys(assistants, should_expose)
435
+ # `partial` must reflect every prior mutation in the function:
436
+ # main registry update, per-domain options, device rename, and
437
+ # any expose_to batch (e.g. expose_true) that ran before this
438
+ # one (expose_false) failed. Anything truthy in those means
439
+ # the registry already moved.
440
+ prior_mutation = (
441
+ has_registry_updates
442
+ or bool(options_succeeded)
443
+ or bool(succeeded)
444
+ or bool(
445
+ device_rename_result and device_rename_result.get("success")
446
+ )
447
+ )
346
448
  context: dict[str, Any] = {
347
449
  "entity_id": entity_id,
348
450
  "exposure_succeeded": succeeded,
349
451
  "exposure_failed": failed,
350
452
  }
351
- if has_registry_updates:
453
+ if prior_mutation:
352
454
  context["partial"] = True
353
455
  context["entity_entry"] = _format_entity_entry(entity_entry)
354
- raise_tool_error(create_error_response(
355
- ErrorCode.SERVICE_CALL_FAILED,
356
- f"Exposure failed: {error_msg}",
357
- context=context,
358
- suggestions=["Check Home Assistant connection and entity availability"],
359
- ))
456
+ if options_succeeded:
457
+ context["options_succeeded"] = options_succeeded
458
+ if device_rename_result and device_rename_result.get("success"):
459
+ context["device_rename_succeeded"] = True
460
+ raise_tool_error(
461
+ create_error_response(
462
+ ErrorCode.SERVICE_CALL_FAILED,
463
+ f"Exposure failed: {error_msg}",
464
+ context=context,
465
+ suggestions=[
466
+ "Check Home Assistant connection and entity availability"
467
+ ],
468
+ )
469
+ )
360
470
 
361
471
  # Track successful exposures
362
472
  for a in assistants:
@@ -374,15 +484,20 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
374
484
  if get_result.get("success"):
375
485
  entity_entry = get_result.get("result", {})
376
486
  else:
377
- raise_tool_error(create_error_response(
378
- ErrorCode.ENTITY_NOT_FOUND,
379
- f"Entity '{entity_id}' not found in registry after applying exposure changes",
380
- context={"entity_id": entity_id, "exposure_succeeded": exposure_result},
381
- suggestions=[
382
- "Verify the entity_id exists using ha_search_entities()",
383
- "The entity's exposure settings were likely changed, but its current state could not be confirmed.",
384
- ],
385
- ))
487
+ raise_tool_error(
488
+ create_error_response(
489
+ ErrorCode.ENTITY_NOT_FOUND,
490
+ f"Entity '{entity_id}' not found in registry after applying exposure changes",
491
+ context={
492
+ "entity_id": entity_id,
493
+ "exposure_succeeded": exposure_result,
494
+ },
495
+ suggestions=[
496
+ "Verify the entity_id exists using ha_search_entities()",
497
+ "The entity's exposure settings were likely changed, but its current state could not be confirmed.",
498
+ ],
499
+ )
500
+ )
386
501
 
387
502
  response_data: dict[str, Any] = {
388
503
  "success": True,
@@ -427,7 +542,7 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
427
542
  entity_id: Annotated[
428
543
  str | list[str],
429
544
  Field(
430
- description="Entity ID or list of entity IDs to update. Bulk operations (list) only support labels and expose_to parameters."
545
+ description="Entity ID or list of entity IDs to update. Bulk operations (list) only support labels, expose_to, and categories parameters."
431
546
  ),
432
547
  ],
433
548
  area_id: Annotated[
@@ -451,6 +566,37 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
451
566
  default=None,
452
567
  ),
453
568
  ] = None,
569
+ device_class: Annotated[
570
+ str | None,
571
+ Field(
572
+ description=(
573
+ "Override the entity's display device class — what the HA UI's "
574
+ "'Show As' dropdown writes. Use empty string '' to clear the "
575
+ "override and fall back to the integration default. None (the "
576
+ "default) means 'no change' — pass an explicit '' to clear. "
577
+ "Single entity only. Examples: 'window', 'door', 'motion' for "
578
+ "binary_sensor; 'temperature', 'humidity' for sensor."
579
+ ),
580
+ default=None,
581
+ ),
582
+ ] = None,
583
+ options: Annotated[
584
+ str | dict[str, dict[str, Any]] | None,
585
+ Field(
586
+ description=(
587
+ "Per-domain entity registry options (e.g. sensor 'display_precision', "
588
+ "weather 'forecast_type'). Pass a dict mapping domain to a sub-dict, "
589
+ 'e.g. {"sensor": {"display_precision": 2}}. JSON-string form also accepted. '
590
+ "Multiple domains are sent as separate registry updates. "
591
+ "For 'Show As' use the dedicated `device_class` parameter — that is "
592
+ "what the HA UI Show As dropdown writes. Voice-assistant exposure is "
593
+ "stored under `options.<assistant>.should_expose` but must be managed "
594
+ "via the dedicated `expose_to` parameter, not this options dict. "
595
+ "Single entity only."
596
+ ),
597
+ default=None,
598
+ ),
599
+ ] = None,
454
600
  enabled: Annotated[
455
601
  bool | str | None,
456
602
  Field(
@@ -540,18 +686,30 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
540
686
  """Update entity properties in the entity registry.
541
687
 
542
688
  Allows modifying entity metadata such as area assignment, display name,
543
- icon, enabled/disabled state, visibility, aliases, labels, voice
544
- assistant exposure, and entity_id rename in a single call.
689
+ icon, "Show As" device class override, per-domain registry options,
690
+ enabled/disabled state, visibility, aliases, labels, voice assistant
691
+ exposure, and entity_id rename in a single call.
545
692
 
546
693
  BULK OPERATIONS:
547
694
  When entity_id is a list, only labels, expose_to, and categories parameters are supported.
548
- Other parameters (area_id, name, icon, enabled, hidden, aliases, new_entity_id, new_device_name) require single entity.
695
+ Other parameters (area_id, name, icon, device_class, options, enabled, hidden, aliases, new_entity_id, new_device_name) require single entity.
549
696
 
550
697
  LABEL OPERATIONS:
551
698
  - label_operation="set" (default): Replace all labels with the provided list. Use [] to clear.
552
699
  - label_operation="add": Add labels to existing ones without removing any.
553
700
  - label_operation="remove": Remove specified labels from the entity.
554
701
 
702
+ SHOW AS / DEVICE CLASS:
703
+ device_class overrides the entity's display device class — equivalent to the
704
+ HA UI's "Show As" dropdown. Use empty string '' to clear. Applies instantly,
705
+ no reload needed.
706
+
707
+ REGISTRY OPTIONS:
708
+ options carries per-domain registry options (sensor display_precision,
709
+ weather forecast_type, etc). Pass {domain: {key: value}}; multi-domain
710
+ dicts are sent as separate registry updates because HA's WS schema
711
+ requires options_domain + options to be paired one domain at a time.
712
+
555
713
  ENTITY ID RENAME:
556
714
  Use new_entity_id to change an entity's ID (e.g., sensor.old -> sensor.new).
557
715
  Domain must match. Voice exposure settings are preserved automatically.
@@ -576,6 +734,9 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
576
734
  Single entity:
577
735
  - Assign to area: ha_set_entity("sensor.temp", area_id="living_room")
578
736
  - Rename display name: ha_set_entity("sensor.temp", name="Living Room Temperature")
737
+ - Set Show As: ha_set_entity("binary_sensor.zone_10", device_class="window")
738
+ - Clear Show As: ha_set_entity("binary_sensor.zone_10", device_class="")
739
+ - Set sensor precision: ha_set_entity("sensor.power", options={"sensor": {"display_precision": 2}})
579
740
  - Rename entity_id: ha_set_entity("light.old_name", new_entity_id="light.new_name")
580
741
  - Rename entity and device: ha_set_entity("light.old", new_entity_id="light.new", new_device_name="New Lamp")
581
742
  - Rename entity_id with friendly name: ha_set_entity("sensor.old", new_entity_id="sensor.new", name="New Name")
@@ -638,6 +799,8 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
638
799
  "area_id": area_id,
639
800
  "name": name,
640
801
  "icon": icon,
802
+ "device_class": device_class,
803
+ "options": options,
641
804
  "enabled": enabled,
642
805
  "hidden": hidden,
643
806
  "aliases": aliases,
@@ -655,7 +818,7 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
655
818
  f"Bulk operations (multiple entity_ids) only support categories, labels, and expose_to. "
656
819
  f"Single-entity parameters provided: {non_null_single_params}",
657
820
  suggestions=[
658
- "Use a single entity_id for area_id, name, icon, enabled, hidden, or aliases",
821
+ "Use a single entity_id for area_id, name, icon, device_class, options, enabled, hidden, or aliases",
659
822
  "Or remove single-entity parameters to use bulk categories/labels/expose_to",
660
823
  ],
661
824
  )
@@ -747,6 +910,51 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
747
910
  )
748
911
  )
749
912
 
913
+ parsed_options: dict[str, dict[str, Any]] | None = None
914
+ if options is not None:
915
+ try:
916
+ parsed_opts = parse_json_param(options, "options")
917
+ except ValueError as e:
918
+ raise_tool_error(
919
+ create_error_response(
920
+ ErrorCode.VALIDATION_INVALID_PARAMETER,
921
+ f"Invalid options parameter: {e}",
922
+ )
923
+ )
924
+
925
+ if not isinstance(parsed_opts, dict):
926
+ raise_tool_error(
927
+ create_error_response(
928
+ ErrorCode.VALIDATION_INVALID_PARAMETER,
929
+ f"options must be a dict mapping domain to a sub-dict "
930
+ f"(got {type(parsed_opts).__name__}), "
931
+ 'e.g. {"sensor": {"display_precision": 2}}',
932
+ )
933
+ )
934
+ if not parsed_opts:
935
+ raise_tool_error(
936
+ create_error_response(
937
+ ErrorCode.VALIDATION_INVALID_PARAMETER,
938
+ "options cannot be an empty dict — pass at least one "
939
+ 'domain entry, e.g. {"sensor": {"display_precision": 2}}, '
940
+ "or omit the parameter entirely.",
941
+ )
942
+ )
943
+ bad_subs = [
944
+ f"{k!r}: {type(v).__name__}"
945
+ for k, v in parsed_opts.items()
946
+ if not isinstance(v, dict)
947
+ ]
948
+ if bad_subs:
949
+ raise_tool_error(
950
+ create_error_response(
951
+ ErrorCode.VALIDATION_INVALID_PARAMETER,
952
+ "options sub-values must be dicts, got non-dict for: "
953
+ f"{', '.join(bad_subs)}",
954
+ )
955
+ )
956
+ parsed_options = parsed_opts
957
+
750
958
  # Parse and validate expose_to parameter
751
959
  parsed_expose_to: dict[str, bool] | None = None
752
960
  if expose_to is not None:
@@ -819,6 +1027,8 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
819
1027
  parsed_expose_to,
820
1028
  new_entity_id=new_entity_id,
821
1029
  new_device_name=new_device_name,
1030
+ device_class=device_class,
1031
+ parsed_options=parsed_options,
822
1032
  )
823
1033
 
824
1034
  # Bulk case - process each entity
@@ -856,7 +1066,10 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
856
1066
  "error": str(result),
857
1067
  }
858
1068
  )
859
- elif result.get("success"):
1069
+ else:
1070
+ # _update_single_entity always returns success-shape or
1071
+ # raises ToolError (caught above as BaseException), so the
1072
+ # `result.get("success") is False` branch is unreachable.
860
1073
  succeeded.append(
861
1074
  {
862
1075
  "entity_id": eid,
@@ -864,13 +1077,6 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
864
1077
  "updates": result.get("updates"),
865
1078
  }
866
1079
  )
867
- else:
868
- failed.append(
869
- {
870
- "entity_id": eid,
871
- "error": result.get("error", "Unknown error"),
872
- }
873
- )
874
1080
 
875
1081
  response: dict[str, Any] = {
876
1082
  "success": len(failed) == 0,
@@ -937,6 +1143,11 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
937
1143
  - aliases: Voice assistant aliases
938
1144
  - labels: Assigned label IDs
939
1145
  - categories: Category assignments (dict mapping scope to category_id)
1146
+ - device_class: User "Show As" override (null = use original_device_class)
1147
+ - original_device_class: Default device class from the integration
1148
+ - options: Per-domain registry options (e.g. sensor display_precision).
1149
+ Voice-assistant exposure is also stored here but should be set/cleared
1150
+ via the ha_set_entity(expose_to=...) parameter, not the options dict.
940
1151
  - platform: Integration platform (e.g., "hue", "zwave_js")
941
1152
  - device_id: Associated device ID (null if standalone)
942
1153
  - unique_id: Integration's unique identifier
@@ -983,13 +1194,7 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
983
1194
  result = await client.send_websocket_message(message)
984
1195
 
985
1196
  if not result.get("success"):
986
- error = result.get("error", {})
987
- error_msg = (
988
- error.get("message", str(error))
989
- if isinstance(error, dict)
990
- else str(error)
991
- )
992
- raise ValueError(error_msg)
1197
+ raise ValueError(_extract_ws_error(result))
993
1198
 
994
1199
  entry = result.get("result", {})
995
1200
  return {
@@ -1005,6 +1210,9 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
1005
1210
  "aliases": entry.get("aliases", []),
1006
1211
  "labels": entry.get("labels", []),
1007
1212
  "categories": entry.get("categories", {}),
1213
+ "device_class": entry.get("device_class"),
1214
+ "original_device_class": entry.get("original_device_class"),
1215
+ "options": entry.get("options", {}),
1008
1216
  "platform": entry.get("platform"),
1009
1217
  "device_id": entry.get("device_id"),
1010
1218
  "unique_id": entry.get("unique_id"),
@@ -1131,12 +1339,7 @@ def register_entity_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
1131
1339
  )
1132
1340
 
1133
1341
  if not result.get("success"):
1134
- error = result.get("error", {})
1135
- error_msg = (
1136
- error.get("message", str(error))
1137
- if isinstance(error, dict)
1138
- else str(error)
1139
- )
1342
+ error_msg = _extract_ws_error(result)
1140
1343
  if "not found" in error_msg.lower():
1141
1344
  raise_tool_error(
1142
1345
  create_error_response(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.4.1.dev440
3
+ Version: 7.4.1.dev441
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