ha-mcp-dev 7.4.1.dev438__tar.gz → 7.4.1.dev440__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.dev438/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.4.1.dev440}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/client/rest_client.py +217 -24
  4. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/settings_ui.py +51 -27
  5. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_utility.py +228 -33
  6. ha_mcp_dev-7.4.1.dev440/src/ha_mcp/utils/data_paths.py +135 -0
  7. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/utils/usage_logger.py +30 -13
  8. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  9. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp_dev.egg-info/SOURCES.txt +1 -0
  10. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/LICENSE +0 -0
  11. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/MANIFEST.in +0 -0
  12. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/README.md +0 -0
  13. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/setup.cfg +0 -0
  14. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/__init__.py +0 -0
  15. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/__main__.py +0 -0
  16. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/_pypi_marker +0 -0
  17. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/_version.py +0 -0
  18. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/auth/__init__.py +0 -0
  19. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/auth/consent_form.py +0 -0
  20. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/auth/provider.py +0 -0
  21. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/client/__init__.py +0 -0
  22. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/client/websocket_client.py +0 -0
  23. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/client/websocket_listener.py +0 -0
  24. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/config.py +0 -0
  25. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/errors.py +0 -0
  26. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/py.typed +0 -0
  27. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  28. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  29. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  30. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  31. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  32. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  33. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  34. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  35. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  36. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  37. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  38. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  39. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  40. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  41. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  42. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  43. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  44. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  45. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  46. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  47. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/server.py +0 -0
  48. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/smoke_test.py +0 -0
  49. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/__init__.py +0 -0
  50. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/backup.py +0 -0
  51. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  52. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/device_control.py +0 -0
  53. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/enhanced.py +0 -0
  54. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/helpers.py +0 -0
  55. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/reference_validator.py +0 -0
  56. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/registry.py +0 -0
  57. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/smart_search.py +0 -0
  58. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_addons.py +0 -0
  59. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_areas.py +0 -0
  60. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  61. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  62. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_calendar.py +0 -0
  63. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_camera.py +0 -0
  64. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_categories.py +0 -0
  65. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  66. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  67. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  68. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  69. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  70. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_energy.py +0 -0
  71. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_entities.py +0 -0
  72. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  73. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_groups.py +0 -0
  74. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_hacs.py +0 -0
  75. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_history.py +0 -0
  76. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_integrations.py +0 -0
  77. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_labels.py +0 -0
  78. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  79. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_registry.py +0 -0
  80. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_resources.py +0 -0
  81. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_search.py +0 -0
  82. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_service.py +0 -0
  83. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_services.py +0 -0
  84. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_system.py +0 -0
  85. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_todo.py +0 -0
  86. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_traces.py +0 -0
  87. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_updates.py +0 -0
  88. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  89. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  90. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/tools_zones.py +0 -0
  91. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/tools/util_helpers.py +0 -0
  92. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/transforms/__init__.py +0 -0
  93. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/transforms/categorized_search.py +0 -0
  94. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/utils/__init__.py +0 -0
  95. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/utils/config_hash.py +0 -0
  96. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/utils/domain_handlers.py +0 -0
  97. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  98. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  99. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/utils/operation_manager.py +0 -0
  100. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp/utils/python_sandbox.py +0 -0
  101. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  102. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  103. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  104. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  105. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/tests/__init__.py +0 -0
  106. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/tests/test_constants.py +0 -0
  107. {ha_mcp_dev-7.4.1.dev438 → ha_mcp_dev-7.4.1.dev440}/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.dev438
3
+ Version: 7.4.1.dev440
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.dev438"
7
+ version = "7.4.1.dev440"
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"
@@ -5,11 +5,13 @@ Home Assistant HTTP client with authentication and error handling.
5
5
  import asyncio
6
6
  import json
7
7
  import logging
8
+ import os
8
9
  import ssl
9
10
  from typing import Any
10
11
 
11
12
  import httpx
12
13
 
14
+ from .._version import is_running_in_addon
13
15
  from ..config import get_global_settings
14
16
 
15
17
 
@@ -26,6 +28,7 @@ def _is_ssl_error(exc: BaseException) -> bool:
26
28
  cur = cur.__cause__ or cur.__context__
27
29
  return False
28
30
 
31
+
29
32
  logger = logging.getLogger(__name__)
30
33
 
31
34
 
@@ -33,17 +36,14 @@ class HomeAssistantError(Exception):
33
36
  """Base exception for Home Assistant API errors."""
34
37
 
35
38
 
36
-
37
39
  class HomeAssistantConnectionError(HomeAssistantError):
38
40
  """Connection error to Home Assistant."""
39
41
 
40
42
 
41
-
42
43
  class HomeAssistantAuthError(HomeAssistantError):
43
44
  """Authentication error with Home Assistant."""
44
45
 
45
46
 
46
-
47
47
  class HomeAssistantAPIError(HomeAssistantError):
48
48
  """API error from Home Assistant."""
49
49
 
@@ -125,7 +125,7 @@ class HomeAssistantClient:
125
125
 
126
126
  logger.info(f"Initialized Home Assistant client for {self.base_url}")
127
127
 
128
- async def __aenter__(self) -> 'HomeAssistantClient':
128
+ async def __aenter__(self) -> "HomeAssistantClient":
129
129
  """Async context manager entry."""
130
130
  return self
131
131
 
@@ -192,7 +192,9 @@ class HomeAssistantClient:
192
192
  except httpx.HTTPError as e:
193
193
  raise HomeAssistantConnectionError(f"HTTP error: {e}") from e
194
194
 
195
- async def _request(self, method: str, endpoint: str, **kwargs: Any) -> dict[str, Any]:
195
+ async def _request(
196
+ self, method: str, endpoint: str, **kwargs: Any
197
+ ) -> dict[str, Any]:
196
198
  """
197
199
  Make authenticated request to Home Assistant API and parse JSON body.
198
200
 
@@ -267,8 +269,11 @@ class HomeAssistantClient:
267
269
  return await self._request("POST", f"/states/{entity_id}", json=payload)
268
270
 
269
271
  async def call_service(
270
- self, domain: str, service: str, data: dict[str, Any] | None = None,
271
- return_response: bool = False
272
+ self,
273
+ domain: str,
274
+ service: str,
275
+ data: dict[str, Any] | None = None,
276
+ return_response: bool = False,
272
277
  ) -> list[dict[str, Any]] | dict[str, Any]:
273
278
  """
274
279
  Call Home Assistant service.
@@ -284,7 +289,9 @@ class HomeAssistantClient:
284
289
  Service response data - list of affected states normally, or dict with
285
290
  service response if return_response=True
286
291
  """
287
- logger.debug(f"Calling service {domain}.{service} (return_response={return_response})")
292
+ logger.debug(
293
+ f"Calling service {domain}.{service} (return_response={return_response})"
294
+ )
288
295
 
289
296
  payload = data or {}
290
297
 
@@ -294,7 +301,10 @@ class HomeAssistantClient:
294
301
  params["return_response"] = "true"
295
302
 
296
303
  result = await self._request(
297
- "POST", f"/services/{domain}/{service}", json=payload, params=params if params else None
304
+ "POST",
305
+ f"/services/{domain}/{service}",
306
+ json=payload,
307
+ params=params if params else None,
298
308
  )
299
309
 
300
310
  # When return_response is True, HA returns a dict with service_response key
@@ -366,7 +376,9 @@ class HomeAssistantClient:
366
376
  Returns:
367
377
  Logbook entries
368
378
  """
369
- logger.debug(f"Fetching logbook entries for entity: {entity_id}, start: {start_time}, end: {end_time}")
379
+ logger.debug(
380
+ f"Fetching logbook entries for entity: {entity_id}, start: {start_time}, end: {end_time}"
381
+ )
370
382
 
371
383
  # Build endpoint - start_time goes in URL path if provided
372
384
  if start_time:
@@ -428,27 +440,66 @@ class HomeAssistantClient:
428
440
  return await self._request("POST", "/config/core/check_config")
429
441
 
430
442
  async def get_error_log(self) -> str:
431
- """Get Home Assistant error log."""
432
- logger.debug("Fetching error log")
443
+ """Get Home Assistant error log.
444
+
445
+ Branch on ``is_running_in_addon()``: inside the add-on container,
446
+ HA Core's ``bootstrap.py`` sets ``err_log_path = None`` when the
447
+ ``SUPERVISOR`` env var is present, so ``hass.data[DATA_LOGGING]``
448
+ is never populated and the ``APIErrorLog`` view is not registered
449
+ — ``/api/error_log`` returns 404 by-design on HA OS / Supervised.
450
+ Route to ``_supervisor_logs_get("core")`` on this branch: same
451
+ content (HA Core's container log) via a different transport
452
+ (Supervisor REST). On non-addon installs keep the
453
+ ``/api/error_log`` proxy path.
454
+
455
+ Same root cause and fix shape as ``get_addon_logs`` — see #1116.
456
+
457
+ Raises:
458
+ HomeAssistantAuthError: 401, or empty ``SUPERVISOR_TOKEN`` on
459
+ the addon branch.
460
+ HomeAssistantAPIError: 403 (role too low — addon needs
461
+ ``hassio_role: manager``), 404, other non-2xx.
462
+ HomeAssistantConnectionError: Network, timeout, or transport
463
+ error.
464
+ """
465
+ if is_running_in_addon():
466
+ logger.debug("Fetching error log via Supervisor direct (core service)")
467
+ return await self._supervisor_logs_get("core")
468
+
469
+ logger.debug("Fetching error log via HA Core proxy")
433
470
  response = await self._request("GET", "/error_log")
434
471
  return response if isinstance(response, str) else str(response)
435
472
 
436
473
  async def get_addon_logs(self, slug: str) -> str:
437
- """Fetch an add-on's container logs via HA Core's Supervisor REST proxy.
474
+ """Fetch an add-on's container logs.
475
+
476
+ Branch on ``is_running_in_addon()`` (which keys off ``SUPERVISOR_TOKEN``
477
+ in env): inside the add-on container goes directly to the Supervisor
478
+ REST API at ``http://supervisor/addons/{slug}/logs`` with the
479
+ Supervisor token. The HA Core proxy at
480
+ ``/api/hassio/addons/{slug}/logs`` rejects this token+path combination
481
+ on current HA Core releases (see #1116) — the direct path bypasses
482
+ HA Core entirely and is the documented Supervisor contract.
438
483
 
439
- Uses `/api/hassio/addons/{slug}/logs`, which HA Core proxies to
440
- Supervisor and returns as `text/plain`. This avoids the
441
- `supervisor/api` websocket path that tries to JSON-decode the text
442
- body and always fails (see #950).
484
+ On non-addon installs (Docker, pyinstaller, pip pointing at a normal
485
+ HA URL), falls back to the HA Core proxy path. That path requires an
486
+ admin LLA but works fine when not invoked from the add-on container.
487
+
488
+ Both branches return ``text/plain`` log content.
443
489
 
444
490
  Raises:
445
- HomeAssistantAuthError: 401 from HA Core.
446
- HomeAssistantAPIError: Non-2xx response (e.g. 404 unknown slug,
447
- 400 addon not installed). `status_code` is set so callers
448
- can map to specific suggestions.
491
+ HomeAssistantAuthError: 401 response, or ``SUPERVISOR_TOKEN`` empty
492
+ at call time on the addon branch.
493
+ HomeAssistantAPIError: 403 (role too low addon needs hassio_role
494
+ ``manager``), 404 (unknown slug), or other non-2xx. The
495
+ ``status_code`` attribute lets callers map to specific
496
+ suggestions.
449
497
  HomeAssistantConnectionError: Network, timeout, or transport error.
450
498
  """
451
- logger.debug(f"Fetching addon logs for slug={slug}")
499
+ if is_running_in_addon():
500
+ return await self._get_addon_logs_via_supervisor(slug)
501
+
502
+ logger.debug(f"Fetching addon logs for slug={slug} via HA Core proxy")
452
503
  response = await self._raw_request(
453
504
  "GET",
454
505
  f"/hassio/addons/{slug}/logs",
@@ -456,6 +507,146 @@ class HomeAssistantClient:
456
507
  )
457
508
  return response.text
458
509
 
510
+ async def _supervisor_logs_get(self, path: str) -> str:
511
+ """Fetch ``text/plain`` logs from a Supervisor REST endpoint.
512
+
513
+ ``path`` is everything between ``http://supervisor/`` and ``/logs``:
514
+
515
+ - ``"addons/<slug>"`` for add-on container logs
516
+ - ``"<service>"`` (where service ∈ {supervisor, host, core, dns, audio,
517
+ multicast, observer}) for system-service logs
518
+
519
+ Bypasses ``HomeAssistantClient.httpx_client`` because the Supervisor
520
+ endpoint uses a different base URL (``http://supervisor``) and a
521
+ different token (``SUPERVISOR_TOKEN``) than HA Core REST. Both
522
+ endpoints require the addon's ``hassio_role`` to be ``manager`` (not
523
+ ``default``); a ``default`` role gets a 403 here — see #1116.
524
+
525
+ Raises:
526
+ HomeAssistantAuthError: ``SUPERVISOR_TOKEN`` absent at call time,
527
+ or 401 from Supervisor.
528
+ HomeAssistantAPIError: 403 (role too low — distinct branch with
529
+ role hint), 404, other 4xx/5xx. Tries to parse Supervisor's
530
+ ``{"result":"error","message":"..."}`` JSON envelope before
531
+ falling back to text body / reason phrase / placeholder.
532
+ HomeAssistantConnectionError: Timeout or transport error, with
533
+ distinct messages so callers can tell them apart.
534
+ """
535
+ token = os.environ.get("SUPERVISOR_TOKEN", "")
536
+ if not token:
537
+ # The is_running_in_addon() gate already keys off SUPERVISOR_TOKEN
538
+ # being truthy, so a direct caller landing here without one is a
539
+ # detection/config mismatch — fail-fast with a distinct message
540
+ # so operators don't read it as "token rejected".
541
+ raise HomeAssistantAuthError(
542
+ f"Supervisor token absent at call time for /{path}/logs "
543
+ "(addon-mode gate fired but SUPERVISOR_TOKEN env var not set)"
544
+ )
545
+
546
+ url = f"http://supervisor/{path}/logs"
547
+ logger.debug("Fetching %s via Supervisor direct", url)
548
+
549
+ try:
550
+ async with httpx.AsyncClient(
551
+ timeout=httpx.Timeout(self.timeout),
552
+ # `verify` is a no-op for plain http://supervisor, but kept
553
+ # for symmetry with the other two direct-Supervisor httpx
554
+ # clients (#1128 establishes the 3-site convention).
555
+ verify=self.verify_ssl,
556
+ ) as client:
557
+ response = await client.get(
558
+ url,
559
+ headers={
560
+ "Authorization": f"Bearer {token}",
561
+ "Accept": "text/plain",
562
+ },
563
+ )
564
+ except httpx.TimeoutException as e:
565
+ raise HomeAssistantConnectionError(
566
+ f"Timeout fetching /{path}/logs from Supervisor: {e}"
567
+ ) from e
568
+ except httpx.HTTPError as e:
569
+ raise HomeAssistantConnectionError(
570
+ f"Transport error fetching /{path}/logs from Supervisor: {e}"
571
+ ) from e
572
+
573
+ if response.status_code == 401:
574
+ raise HomeAssistantAuthError(f"Invalid Supervisor token for /{path}/logs")
575
+ if response.status_code == 403:
576
+ # Distinct from 401: token is valid but addon's hassio_role isn't
577
+ # high enough. Most-likely cause for this exact endpoint at the
578
+ # time #1116 surfaced (default → manager bump in addon config.yaml
579
+ # is the same-PR companion fix).
580
+ logger.warning(
581
+ "Supervisor returned 403 for /%s/logs — addon hassio_role may "
582
+ "be too low (need 'manager')",
583
+ path,
584
+ )
585
+ raise HomeAssistantAPIError(
586
+ f"Supervisor forbids /{path}/logs (403) — addon's hassio_role "
587
+ "may be 'default'; need 'manager' or higher",
588
+ status_code=403,
589
+ response_data={"path": path},
590
+ )
591
+ if response.status_code >= 400:
592
+ text_body = response.text
593
+ # Supervisor returns {"result":"error","message":"..."} JSON on
594
+ # some 4xx paths. Try parsing that first so the user sees the
595
+ # human message instead of a JSON blob; then fall back to the
596
+ # text body, then reason_phrase, then a placeholder.
597
+ message = ""
598
+ try:
599
+ envelope = json.loads(text_body) if text_body else None
600
+ if isinstance(envelope, dict):
601
+ msg = envelope.get("message")
602
+ if isinstance(msg, str) and msg:
603
+ message = msg
604
+ except json.JSONDecodeError:
605
+ pass
606
+ if not message:
607
+ message = text_body.strip() or response.reason_phrase or "<empty body>"
608
+ logger.warning(
609
+ "Supervisor returned %s for /%s/logs: %s",
610
+ response.status_code,
611
+ path,
612
+ message,
613
+ )
614
+ raise HomeAssistantAPIError(
615
+ f"API error: {response.status_code} - {message}",
616
+ status_code=response.status_code,
617
+ response_data={"message": text_body, "path": path},
618
+ )
619
+ return response.text
620
+
621
+ async def _get_addon_logs_via_supervisor(self, slug: str) -> str:
622
+ """Fetch add-on container logs directly from Supervisor's REST API.
623
+
624
+ Distinct from ``tools_bug_report._fetch_addon_logs``: that helper is
625
+ hardcoded to ``/addons/self/logs`` and silently swallows failures
626
+ (it's an aux-data fetch for bug reports, fine to skip on error). This
627
+ helper takes arbitrary slugs and surfaces failures as exceptions
628
+ because callers (``ha_get_logs(source="supervisor", slug=...)``) need
629
+ them. Both endpoints require ``hassio_role: manager``.
630
+
631
+ Delegates to ``_supervisor_logs_get`` so error handling stays in
632
+ lockstep with ``_get_system_service_logs``.
633
+ """
634
+ return await self._supervisor_logs_get(f"addons/{slug}")
635
+
636
+ async def _get_system_service_logs(self, service: str) -> str:
637
+ """Fetch HA system-service logs directly from Supervisor's REST API.
638
+
639
+ Hits ``http://supervisor/{service}/logs``. ``service`` must be one of
640
+ the seven Supervisor-managed services: ``supervisor``, ``host``,
641
+ ``core``, ``dns``, ``audio``, ``multicast``, ``observer``. Caller is
642
+ responsible for validating ``service`` against the allowed set; this
643
+ helper does no validation and will raise ``HomeAssistantAPIError`` on
644
+ any unknown path (404 from Supervisor).
645
+
646
+ Requires ``hassio_role: manager`` like the addon-logs path.
647
+ """
648
+ return await self._supervisor_logs_get(service)
649
+
459
650
  async def test_connection(self) -> tuple[bool, str | None]:
460
651
  """
461
652
  Test connection to Home Assistant.
@@ -920,7 +1111,9 @@ class HomeAssistantClient:
920
1111
  await asyncio.sleep(retry_delay)
921
1112
  continue
922
1113
  else:
923
- logger.error(f"WebSocket 403 error after {max_retries} attempts: {error_str}")
1114
+ logger.error(
1115
+ f"WebSocket 403 error after {max_retries} attempts: {error_str}"
1116
+ )
924
1117
  return {
925
1118
  "success": False,
926
1119
  "error": f"WebSocket request blocked (403 Forbidden): {error_str}",
@@ -1018,7 +1211,7 @@ class HomeAssistantClient:
1018
1211
  except Exception:
1019
1212
  logger.debug(
1020
1213
  f"Entity registry lookup failed for {entity_id}, using bare id: {bare_id}",
1021
- exc_info=True # Log full traceback for better debugging
1214
+ exc_info=True, # Log full traceback for better debugging
1022
1215
  )
1023
1216
  return bare_id
1024
1217
 
@@ -19,8 +19,10 @@ import httpx
19
19
  from starlette.requests import Request
20
20
  from starlette.responses import HTMLResponse, JSONResponse
21
21
 
22
+ from ._version import is_running_in_addon
22
23
  from .errors import ErrorCode, create_error_response
23
24
  from .transforms import DEFAULT_PINNED_TOOLS
25
+ from .utils.data_paths import get_data_dir
24
26
 
25
27
  if TYPE_CHECKING:
26
28
  from fastmcp import FastMCP
@@ -99,36 +101,38 @@ FEATURE_GATED_TOOLS: dict[str, dict[str, str]] = {
99
101
  }
100
102
 
101
103
 
102
- def _is_addon() -> bool:
103
- """Return True when running inside the Home Assistant add-on container.
104
+ def _get_config_path() -> Path:
105
+ """Return the path to the tool config JSON file.
104
106
 
105
- Mirrors the existing convention in this module (and ``__main__.py``)
106
- of treating ``SUPERVISOR_TOKEN`` as the add-on detector. Using the env
107
- var is more reliable than checking for ``/data`` because some Docker
108
- setups (and macOS dev environments) have a ``/data`` directory that
109
- isn't the add-on data dir.
107
+ Delegates directory resolution to :func:`utils.data_paths.get_data_dir`,
108
+ which handles ``HA_MCP_CONFIG_DIR`` override, add-on ``/data``,
109
+ home-dir, and tmpdir fallback (memoized).
110
110
  """
111
- return bool(os.environ.get("SUPERVISOR_TOKEN"))
112
-
113
-
114
- def _get_config_path() -> Path:
115
- """Return the path to the tool config JSON file."""
116
- if _is_addon():
117
- return Path("/data") / "tool_config.json"
118
- home_dir = Path.home() / ".ha-mcp"
119
- home_dir.mkdir(parents=True, exist_ok=True)
120
- return home_dir / "tool_config.json"
111
+ return get_data_dir() / "tool_config.json"
121
112
 
122
113
 
123
114
  def load_tool_config(settings: Settings | None = None) -> dict[str, Any]:
124
115
  """Load persisted tool config, seeding from env vars if no file exists."""
125
116
  path = _get_config_path()
126
- if path.exists():
117
+ # ``Path.exists()`` only swallows ``ENOENT/ENOTDIR/EBADF/ELOOP``; an
118
+ # ``EACCES`` (e.g. ``HA_MCP_CONFIG_DIR`` pointing at a dir that exists
119
+ # but isn't readable by the runtime UID) propagates. Read directly and
120
+ # treat ``FileNotFoundError`` as "no config yet"; log other ``OSError``s.
121
+ try:
122
+ raw = path.read_text()
123
+ except FileNotFoundError:
124
+ raw = None
125
+ except OSError:
126
+ logger.warning("Cannot read tool config at %s", path, exc_info=True)
127
+ raw = None
128
+
129
+ if raw is not None:
127
130
  try:
128
- result: dict[str, Any] = json.loads(path.read_text())
131
+ result: dict[str, Any] = json.loads(raw)
132
+ except json.JSONDecodeError:
133
+ logger.warning("Tool config at %s is not valid JSON; ignoring.", path)
134
+ else:
129
135
  return result
130
- except (OSError, json.JSONDecodeError):
131
- logger.warning("Failed to read tool config from %s", path)
132
136
 
133
137
  if settings is None:
134
138
  return {}
@@ -156,14 +160,23 @@ def load_tool_config(settings: Settings | None = None) -> dict[str, Any]:
156
160
  return {}
157
161
 
158
162
 
159
- def save_tool_config(config: dict[str, Any]) -> None:
160
- """Persist tool config to disk."""
163
+ def save_tool_config(config: dict[str, Any]) -> bool:
164
+ """Persist tool config to disk.
165
+
166
+ Returns True on success, False on failure (read-only filesystem,
167
+ permission denied, etc.). Caller is responsible for surfacing the
168
+ failure to the user — the HTTP route at ``_save_tools`` returns 500
169
+ so the UI's ``saveConfig`` shows "Save failed!" instead of the
170
+ misleading "Saved — restart required".
171
+ """
161
172
  path = _get_config_path()
162
173
  try:
163
174
  path.write_text(json.dumps(config, indent=2))
164
- logger.info("Saved tool config to %s", path)
165
175
  except OSError:
166
176
  logger.exception("Failed to save tool config to %s", path)
177
+ return False
178
+ logger.info("Saved tool config to %s", path)
179
+ return True
167
180
 
168
181
 
169
182
  async def _get_tool_metadata(server: HomeAssistantSmartMCPServer) -> list[dict[str, Any]]:
@@ -760,7 +773,18 @@ def register_settings_routes(
760
773
 
761
774
  config = load_tool_config()
762
775
  config["tools"] = states
763
- save_tool_config(config)
776
+ if not save_tool_config(config):
777
+ return JSONResponse(
778
+ create_error_response(
779
+ ErrorCode.INTERNAL_ERROR,
780
+ "Failed to persist tool config to disk",
781
+ suggestions=[
782
+ "Set HA_MCP_CONFIG_DIR to a writable path (read-only filesystem?)",
783
+ "Check the server logs for the underlying OSError",
784
+ ],
785
+ ),
786
+ status_code=500,
787
+ )
764
788
 
765
789
  disabled_count = sum(1 for s in states.values() if s == "disabled")
766
790
  pinned_count = sum(1 for s in states.values() if s == "pinned")
@@ -823,11 +847,11 @@ def register_settings_routes(
823
847
 
824
848
  async def _settings_info(_: Request) -> JSONResponse:
825
849
  return JSONResponse({
826
- "is_addon": _is_addon(),
850
+ "is_addon": is_running_in_addon(),
827
851
  })
828
852
 
829
853
  secret_prefix = secret_path.rstrip("/") if secret_path else ""
830
- is_addon = _is_addon()
854
+ is_addon = is_running_in_addon()
831
855
 
832
856
  if not is_addon and not secret_prefix:
833
857
  logger.warning(