ha-mcp-dev 7.4.1.dev427__tar.gz → 7.4.1.dev429__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 (106) hide show
  1. {ha_mcp_dev-7.4.1.dev427/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.4.1.dev429}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_energy.py +108 -13
  4. ha_mcp_dev-7.4.1.dev429/src/ha_mcp/utils/kill_signal_diagnostics.py +488 -0
  5. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  6. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp_dev.egg-info/SOURCES.txt +1 -0
  7. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/LICENSE +0 -0
  8. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/MANIFEST.in +0 -0
  9. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/README.md +0 -0
  10. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/setup.cfg +0 -0
  11. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/__init__.py +0 -0
  12. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/__main__.py +0 -0
  13. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/_pypi_marker +0 -0
  14. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/_version.py +0 -0
  15. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/auth/__init__.py +0 -0
  16. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/auth/consent_form.py +0 -0
  17. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/auth/provider.py +0 -0
  18. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/client/__init__.py +0 -0
  19. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/client/rest_client.py +0 -0
  20. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/client/websocket_client.py +0 -0
  21. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/client/websocket_listener.py +0 -0
  22. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/config.py +0 -0
  23. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/errors.py +0 -0
  24. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/py.typed +0 -0
  25. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  26. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  27. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  28. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  29. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  30. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  31. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  32. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  33. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  34. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  35. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  36. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  37. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  38. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  39. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  40. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  41. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  42. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  43. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  44. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  45. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/server.py +0 -0
  46. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/settings_ui.py +0 -0
  47. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/smoke_test.py +0 -0
  48. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/__init__.py +0 -0
  49. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/backup.py +0 -0
  50. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  51. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/device_control.py +0 -0
  52. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/enhanced.py +0 -0
  53. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/helpers.py +0 -0
  54. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/reference_validator.py +0 -0
  55. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/registry.py +0 -0
  56. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/smart_search.py +0 -0
  57. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_addons.py +0 -0
  58. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_areas.py +0 -0
  59. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  60. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  61. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_calendar.py +0 -0
  62. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_camera.py +0 -0
  63. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_categories.py +0 -0
  64. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  65. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  66. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  67. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  68. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  69. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_entities.py +0 -0
  70. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  71. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_groups.py +0 -0
  72. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_hacs.py +0 -0
  73. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_history.py +0 -0
  74. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_integrations.py +0 -0
  75. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_labels.py +0 -0
  76. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  77. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_registry.py +0 -0
  78. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_resources.py +0 -0
  79. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_search.py +0 -0
  80. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_service.py +0 -0
  81. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_services.py +0 -0
  82. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_system.py +0 -0
  83. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_todo.py +0 -0
  84. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_traces.py +0 -0
  85. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_updates.py +0 -0
  86. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_utility.py +0 -0
  87. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  88. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  89. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/tools_zones.py +0 -0
  90. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/tools/util_helpers.py +0 -0
  91. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/transforms/__init__.py +0 -0
  92. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/transforms/categorized_search.py +0 -0
  93. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/utils/__init__.py +0 -0
  94. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/utils/config_hash.py +0 -0
  95. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/utils/domain_handlers.py +0 -0
  96. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  97. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/utils/operation_manager.py +0 -0
  98. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/utils/python_sandbox.py +0 -0
  99. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp/utils/usage_logger.py +0 -0
  100. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  101. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  102. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  103. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  104. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/tests/__init__.py +0 -0
  105. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/tests/test_constants.py +0 -0
  106. {ha_mcp_dev-7.4.1.dev427 → ha_mcp_dev-7.4.1.dev429}/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.dev427
3
+ Version: 7.4.1.dev429
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.dev427"
7
+ version = "7.4.1.dev429"
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"
@@ -96,7 +96,7 @@ def _compute_per_key_hashes(prefs: dict[str, Any]) -> dict[_PrefsKey, str]:
96
96
 
97
97
 
98
98
  def _is_no_prefs_error(error_msg: str) -> bool:
99
- """True if an error string from send_websocket_message indicates
99
+ """Return True if an error string from send_websocket_message indicates
100
100
  ``ERR_NOT_FOUND "No prefs"`` from HA Core's energy/get_prefs handler.
101
101
 
102
102
  HA Core wraps the error as ``f"Command failed: {message}"``; the
@@ -143,13 +143,30 @@ def _flatten_validation_errors(raw: Any) -> list[dict[str, str]]:
143
143
  return errors
144
144
 
145
145
 
146
- def _shape_check(config: dict[str, Any]) -> list[dict[str, str]]:
147
- """Cheap local shape check before sending to the server.
146
+ def _shape_check(
147
+ config: dict[str, Any],
148
+ validate_only: dict[str, set[int]] | None = None,
149
+ ) -> list[dict[str, str]]:
150
+ """Validate config shape locally before sending to the server.
148
151
 
149
152
  Validates that top-level keys have the expected list-of-dicts shape and
150
153
  that required identifying fields are present. Does NOT validate semantic
151
154
  correctness (stat IDs existing, units matching, etc.) — that's surfaced
152
155
  by the post-save server-side ``energy/validate`` call.
156
+
157
+ ``validate_only`` scopes the per-entry check. ``None`` (default) validates
158
+ every entry under every present top-level key — the original contract.
159
+ A dict scopes the check to the listed keys and, within each, only the
160
+ listed indices: top-level keys absent from the dict are skipped entirely,
161
+ indices outside each key's set are skipped per-entry. The "must be a
162
+ list" structural check still fires for any present-and-listed key with a
163
+ non-list value, so ``validate_only`` cannot be used to bypass structural
164
+ sanity. A dict with an empty set for a key (``{key: set()}``) skips the
165
+ per-entry pass for that key while preserving the structural check —
166
+ this is what convenience-mode write paths pass for remove operations
167
+ (no new entries to validate, but the list shape is still checked). An
168
+ empty dict (``{}``) skips all keys entirely. See issue #1086 for the
169
+ asymmetric over-validation problem this addresses.
153
170
  """
154
171
  errors: list[dict[str, str]] = []
155
172
 
@@ -159,11 +176,18 @@ def _shape_check(config: dict[str, Any]) -> list[dict[str, str]]:
159
176
  for key in _PREFS_TOP_LEVEL_KEYS:
160
177
  if key not in config:
161
178
  continue
179
+ if validate_only is not None and key not in validate_only:
180
+ continue
162
181
  value = config[key]
163
182
  if not isinstance(value, list):
164
183
  errors.append({"path": key, "message": "must be a list"})
165
184
  continue
185
+ allowed_indices: set[int] | None = (
186
+ validate_only[key] if validate_only is not None else None
187
+ )
166
188
  for idx, entry in enumerate(value):
189
+ if allowed_indices is not None and idx not in allowed_indices:
190
+ continue
167
191
  if not isinstance(entry, dict):
168
192
  errors.append(
169
193
  {
@@ -217,6 +241,47 @@ def _shape_check(config: dict[str, Any]) -> list[dict[str, str]]:
217
241
  return errors
218
242
 
219
243
 
244
+ def _appended_tail_indices(existing: list[Any], new: list[Any]) -> set[int]:
245
+ """Return the indices in ``new`` that lie past the end of ``existing``.
246
+
247
+ Per issue #1086, this builds the ``validate_only`` index set scoped to the
248
+ appended tail of an append-only / shrink-only mutation, so pre-existing
249
+ HA-validated siblings are not re-checked on every add/remove:
250
+
251
+ - Append-only mutators (``_add_*``): returns indices of the new entries.
252
+ - Shrink-only mutators (``_remove_*``): returns an empty set — nothing
253
+ new to validate, and the surviving entries already passed HA validation.
254
+
255
+ Refuses in-place mutators where ``len(new) == len(existing)`` but the
256
+ contents differ — the appended-tail formula would yield an empty index
257
+ set on a list whose entries actually changed, silently bypassing
258
+ per-entry validation. Add explicit handling (e.g. an
259
+ ``_indices_of_modified_entries`` helper) before introducing such a
260
+ mutator; do not extend this one.
261
+ """
262
+ if len(new) == len(existing) and new != existing:
263
+ raise_tool_error(
264
+ create_error_response(
265
+ ErrorCode.INTERNAL_ERROR,
266
+ "_appended_tail_indices: in-place mutation detected "
267
+ "(same length, different content) — the appended-tail "
268
+ "validation heuristic only covers append-only / shrink-only "
269
+ "mutators. Add explicit per-entry validation handling for "
270
+ "in-place mutators before reusing this helper.",
271
+ context={
272
+ "existing_len": len(existing),
273
+ "new_len": len(new),
274
+ },
275
+ suggestions=[
276
+ "If introducing a _replace_* / _update_* mutator, "
277
+ "compute the indices of modified entries explicitly "
278
+ "and pass those to _shape_check via validate_only.",
279
+ ],
280
+ )
281
+ )
282
+ return set(range(len(existing), len(new)))
283
+
284
+
220
285
  class EnergyTools:
221
286
  """Energy Dashboard preference management tools for Home Assistant."""
222
287
 
@@ -629,6 +694,7 @@ class EnergyTools:
629
694
  config_hash: str | dict[_PrefsKey, str],
630
695
  *,
631
696
  current_prefs: dict[str, Any] | None = None,
697
+ validate_only: dict[str, set[int]] | None = None,
632
698
  ) -> dict[str, Any]:
633
699
  """Shape-check → hash-check → save → post-save validate.
634
700
 
@@ -650,11 +716,18 @@ class EnergyTools:
650
716
  path uses this to avoid a second ``energy/get_prefs`` round trip
651
717
  per attempt (the snapshot was already fetched by ``_mutate_atomic``).
652
718
  Convenience modes always pass a ``str`` hash; the dict form is
653
- only reachable via direct mode='set' callers.
719
+ only reachable via direct mode='set' callers. The hash check still
720
+ runs against the provided snapshot as a defensive guard.
721
+
722
+ ``validate_only`` is forwarded to ``_shape_check`` and lets a caller
723
+ scope the per-entry check to specific top-level keys / indices.
724
+ Convenience-mode writes pass the appended tail indices so
725
+ pre-existing (HA-validated) entries are not re-validated against the
726
+ local schema — see issue #1086.
654
727
  """
655
728
  try:
656
729
  # 1. Shape check (fast local, fail closed)
657
- shape_errors = _shape_check(config)
730
+ shape_errors = _shape_check(config, validate_only=validate_only)
658
731
  if shape_errors:
659
732
  raise_tool_error(
660
733
  create_error_response(
@@ -1176,7 +1249,7 @@ class EnergyTools:
1176
1249
  dry_run: bool,
1177
1250
  preview_payload: dict[str, Any],
1178
1251
  ) -> dict[str, Any]:
1179
- """Read-modify-write loop for convenience modes.
1252
+ """Run convenience-mode read-modify-write with dry-run backstop and hash-conflict retry.
1180
1253
 
1181
1254
  Atomicity is with respect to the *entire* prefs snapshot, not just
1182
1255
  ``target_key``: ``_set_prefs`` validates the full ``config_hash``, so
@@ -1205,9 +1278,14 @@ class EnergyTools:
1205
1278
  new_list = mutator(existing_list)
1206
1279
 
1207
1280
  # Backstop shape-check, mirroring the real-run path through
1208
- # ``_set_prefs`` — keeps dry_run/real-run shape-equivalent
1209
- # if the entry-construction logic ever changes.
1210
- shape_errors = _shape_check({target_key: new_list})
1281
+ # ``_set_prefs`` — keeps dry_run/real-run shape-equivalent if
1282
+ # the entry-construction logic ever changes. See
1283
+ # ``_appended_tail_indices`` for the validate_only contract.
1284
+ appended_indices = _appended_tail_indices(existing_list, new_list)
1285
+ shape_errors = _shape_check(
1286
+ {target_key: new_list},
1287
+ validate_only={target_key: appended_indices},
1288
+ )
1211
1289
  if shape_errors:
1212
1290
  raise_tool_error(
1213
1291
  create_error_response(
@@ -1241,11 +1319,17 @@ class EnergyTools:
1241
1319
  new_list = mutator(existing_list)
1242
1320
 
1243
1321
  partial_config = {target_key: new_list}
1322
+ # Per issue #1086: validate only the appended tail so a
1323
+ # pre-existing HA-validated entry cannot block an unrelated
1324
+ # add/remove. See ``_appended_tail_indices`` for the
1325
+ # validate_only contract.
1326
+ appended_indices = _appended_tail_indices(existing_list, new_list)
1244
1327
  try:
1245
1328
  set_result = await self._set_prefs(
1246
1329
  partial_config,
1247
1330
  current_hash,
1248
1331
  current_prefs=current_config,
1332
+ validate_only={target_key: appended_indices},
1249
1333
  )
1250
1334
  except ToolError as exc:
1251
1335
  # _set_prefs raises ToolError(RESOURCE_LOCKED) on hash mismatch.
@@ -1289,10 +1373,21 @@ class EnergyTools:
1289
1373
  # Unreachable as long as every iteration either returns or raises:
1290
1374
  # the only ``continue`` is gated on ``attempt + 1 < max_attempts``,
1291
1375
  # which is False on the final iteration — so the bare ``raise``
1292
- # in the except block always fires there.
1293
- raise AssertionError(
1294
- f"_mutate_atomic({mode}, {target_key}): "
1295
- "retry loop exited without a return or raise"
1376
+ # in the except block always fires there. Surface as an actionable
1377
+ # structured error rather than a bare AssertionError that would
1378
+ # otherwise fall through to ``except Exception`` and lose context.
1379
+ raise_tool_error(
1380
+ create_error_response(
1381
+ ErrorCode.INTERNAL_ERROR,
1382
+ f"_mutate_atomic({mode}, {target_key}): retry loop exited "
1383
+ "without a return or raise",
1384
+ context={"mode": mode, "target_key": target_key},
1385
+ suggestions=[
1386
+ "This indicates a bug in the optimistic-concurrency "
1387
+ "loop logic — please file an issue with the mode and "
1388
+ "target_key from the context.",
1389
+ ],
1390
+ )
1296
1391
  )
1297
1392
 
1298
1393
  except ToolError:
@@ -0,0 +1,488 @@
1
+ """Kill-signal diagnostics for the HA MCP add-on.
2
+
3
+ Opt-in (gated by the "Advanced debug logging" addon toggle) signal handler
4
+ that, on SIGTERM/SIGINT/SIGHUP, captures and logs:
5
+
6
+ - Signal name + ``si_code`` (USER/KERNEL/QUEUE/TKILL/...).
7
+ - Sender PID + its ``comm`` and ``cmdline`` from ``/proc/<pid>``, captured
8
+ via ``sigaction(SA_SIGINFO)`` through ``ctypes`` so we can read
9
+ ``siginfo_t`` (Python's ``signal.signal`` only sees ``signum``).
10
+ - ``/proc/self/status`` snapshot of memory + OOM context.
11
+
12
+ Then chains to whatever handler was previously installed (typically
13
+ uvicorn's ``handle_exit`` for SIGTERM/SIGINT) so the server still shuts
14
+ down cleanly. SIGHUP, which uvicorn doesn't capture, falls back to
15
+ libc-direct ``SIG_DFL`` + re-raise. Linux-only by design.
16
+
17
+ Without this, the addon only sees that ``mcp.run()`` returned cleanly —
18
+ it can't tell whether Supervisor sent SIGTERM, the OOM killer fired, a
19
+ container watchdog acted, or something else.
20
+
21
+ Install ordering
22
+ ----------------
23
+ ``signal.signal(...)`` from CPython calls libc's ``sigaction`` with no
24
+ ``SA_SIGINFO`` flag, which overwrites any ``SA_SIGINFO`` handler we
25
+ installed first. uvicorn's ``Server.capture_signals()`` does exactly
26
+ this for SIGTERM and SIGINT immediately after ``serve()`` enters. So
27
+ installing from ``start.py`` *before* ``mcp.run()`` would silently lose
28
+ the SA_SIGINFO bit before any signal arrives.
29
+
30
+ ``schedule_install_after_uvicorn`` spawns a daemon thread that polls
31
+ ``signal.getsignal`` until uvicorn's handler is detected (or a timeout
32
+ elapses), then calls ``install_kill_signal_diagnostics`` which captures
33
+ the existing handler and overlays SA_SIGINFO on top. The handler chains
34
+ to the captured handler so uvicorn still receives the shutdown signal.
35
+
36
+ Async-signal-safety
37
+ -------------------
38
+ The handler is best-effort, not strict POSIX AS-safe:
39
+
40
+ - It does **not** call any code that takes Python-level locks; the
41
+ ``usage_logger`` ring buffer is intentionally excluded because its
42
+ ``threading.Lock`` is held by the main thread during normal tool calls
43
+ and would deadlock the handler.
44
+ - It uses ``os.write(STDERR_FILENO, ...)`` (AS-safe) instead of ``print``.
45
+ - It chains to the captured uvicorn handler in pure Python (uvicorn's
46
+ ``handle_exit`` only sets attributes — synchronous, no locks). The
47
+ fallback re-raise path uses ``libc.signal(sig, SIG_DFL)`` and
48
+ ``kill(2)`` directly (both AS-safe).
49
+ - ``/proc`` reads use ``open(2)``, which POSIX classifies as not strictly
50
+ AS-safe. In practice the kernel side of ``/proc`` doesn't take
51
+ userspace-allocator locks, so this is acceptable for an opt-in
52
+ diagnostic.
53
+
54
+ ctypes adds one more theoretical risk: the trampoline acquires the GIL
55
+ on entry to Python code. In a single-threaded asyncio event loop (this
56
+ addon's shape) the GIL acquisition is a no-op when the handler runs on
57
+ the main thread. Multi-threaded callers should evaluate before enabling.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import ctypes
63
+ import ctypes.util
64
+ import logging
65
+ import os
66
+ import signal
67
+ import sys
68
+ import threading
69
+ import time
70
+ from collections.abc import Callable
71
+ from typing import Any
72
+
73
+ logger = logging.getLogger(__name__)
74
+
75
+ # SIGKILL/SIGSTOP omitted — uncatchable by design.
76
+ _INSTRUMENTED_SIGNALS = (signal.SIGTERM, signal.SIGINT, signal.SIGHUP)
77
+
78
+ # si_code constants from Linux's <asm-generic/siginfo.h>. Pinned in
79
+ # tests so a wrong value can't silently mislabel diagnostics.
80
+ _SI_CODE_NAMES = {
81
+ 0: "SI_USER",
82
+ 0x80: "SI_KERNEL",
83
+ -1: "SI_QUEUE",
84
+ -2: "SI_TIMER",
85
+ -3: "SI_MESGQ",
86
+ -4: "SI_ASYNCIO",
87
+ -5: "SI_SIGIO",
88
+ -6: "SI_TKILL",
89
+ }
90
+
91
+
92
+ class _Siginfo(ctypes.Structure):
93
+ """Minimal ``siginfo_t`` for kill-style signals.
94
+
95
+ Linux's ``siginfo_t`` is arch-dependent: on architectures without
96
+ ``__ARCH_HAS_SWAPPED_SIGINFO`` (x86, x86_64, arm, aarch64 — all of
97
+ the addon's target arches), the leading layout is ``si_signo``,
98
+ ``si_errno``, ``si_code``, then the ``_kill`` union starting with
99
+ ``si_pid`` / ``si_uid``. Trailing bytes are reserved padding from
100
+ the kernel's ``SI_MAX_SIZE = 128``.
101
+ """
102
+
103
+ _fields_ = [
104
+ ("si_signo", ctypes.c_int),
105
+ ("si_errno", ctypes.c_int),
106
+ ("si_code", ctypes.c_int),
107
+ ("_pad0", ctypes.c_int), # 64-bit alignment for the _kill union
108
+ ("si_pid", ctypes.c_int),
109
+ ("si_uid", ctypes.c_uint),
110
+ # Pad out to the kernel's SI_MAX_SIZE so libc writes the full
111
+ # union without truncating.
112
+ ("_tail", ctypes.c_byte * 104),
113
+ ]
114
+
115
+
116
+ # Pinned by SI_MAX_SIZE in the kernel. If a future ctypes change shifts
117
+ # field offsets, fail loudly at import rather than during signal delivery.
118
+ assert ctypes.sizeof(_Siginfo) == 128, (
119
+ f"_Siginfo size {ctypes.sizeof(_Siginfo)} != kernel SI_MAX_SIZE 128"
120
+ )
121
+ assert _Siginfo.si_pid.offset == 16, (
122
+ f"_Siginfo.si_pid offset {_Siginfo.si_pid.offset} != expected 16"
123
+ )
124
+ assert _Siginfo.si_uid.offset == 20, (
125
+ f"_Siginfo.si_uid offset {_Siginfo.si_uid.offset} != expected 20"
126
+ )
127
+
128
+
129
+ _SignalHandler = ctypes.CFUNCTYPE(None, ctypes.c_int, ctypes.POINTER(_Siginfo), ctypes.c_void_p)
130
+
131
+
132
+ class _Sigaction(ctypes.Structure):
133
+ _fields_ = [
134
+ ("sa_sigaction", _SignalHandler),
135
+ ("sa_mask", ctypes.c_byte * 128), # sigset_t — opaque, zeroed
136
+ ("sa_flags", ctypes.c_int),
137
+ ("sa_restorer", ctypes.c_void_p),
138
+ ]
139
+
140
+
141
+ _SA_SIGINFO = 0x00000004
142
+ _SA_RESTART = 0x10000000
143
+
144
+ # SIG_DFL = 0 cast to a function pointer; libc.signal accepts this to
145
+ # restore the kernel's default disposition.
146
+ _SIG_DFL_PTR = ctypes.c_void_p(0)
147
+
148
+
149
+ def read_proc_status_summary() -> dict[str, str]:
150
+ """Return a small dict of memory/OOM-relevant fields from /proc/self/status.
151
+
152
+ Empty dict on non-Linux or unreadable status — callers don't need
153
+ to special-case missing data.
154
+ """
155
+ fields = {"VmRSS", "VmHWM", "VmPeak", "Threads", "State", "oom_score", "oom_score_adj"}
156
+ out: dict[str, str] = {}
157
+ try:
158
+ with open("/proc/self/status", "rb") as f:
159
+ for raw_line in f:
160
+ line = raw_line.decode("utf-8", errors="replace")
161
+ key, _, value = line.partition(":")
162
+ if key in fields:
163
+ out[key] = value.strip()
164
+ except OSError:
165
+ return {}
166
+ return out
167
+
168
+
169
+ def read_proc_comm(pid: int) -> str:
170
+ """Return the ``comm`` (process name, ≤15 chars) for the given PID.
171
+
172
+ Empty string if the PID is gone or /proc isn't available. Reads as
173
+ bytes + ``errors="replace"`` because comm can contain arbitrary
174
+ bytes (set via ``prctl(PR_SET_NAME)``) — strict UTF-8 decode would
175
+ raise on those.
176
+ """
177
+ if pid <= 0:
178
+ return ""
179
+ try:
180
+ with open(f"/proc/{pid}/comm", "rb") as f:
181
+ return f.read().decode("utf-8", errors="replace").strip()
182
+ except OSError:
183
+ return ""
184
+
185
+
186
+ def read_proc_cmdline(pid: int) -> str:
187
+ """Return the cmdline (argv joined by spaces) for the given PID.
188
+
189
+ Cmdline can be more informative than ``comm`` (which is truncated to
190
+ 15 chars and often shows just "supervisor" for many distinct
191
+ binaries). Empty string if unavailable.
192
+ """
193
+ if pid <= 0:
194
+ return ""
195
+ try:
196
+ with open(f"/proc/{pid}/cmdline", "rb") as f:
197
+ raw = f.read()
198
+ except OSError:
199
+ return ""
200
+ return raw.replace(b"\x00", b" ").decode("utf-8", errors="replace").strip()
201
+
202
+
203
+ def format_diagnostic_block(
204
+ *,
205
+ signum: int,
206
+ si_code: int,
207
+ sender_pid: int,
208
+ sender_comm: str,
209
+ sender_cmdline: str,
210
+ proc_status: dict[str, str],
211
+ ) -> str:
212
+ """Compose the multi-line log block written when a signal is caught."""
213
+ sig_name = signal.Signals(signum).name if signum in signal.Signals.__members__.values() else str(signum)
214
+ code_name = _SI_CODE_NAMES.get(si_code, f"SI_UNKNOWN({si_code})")
215
+
216
+ # si_pid == 0 from the kernel means the sender was outside our PID
217
+ # namespace (typically Supervisor or the host) — its PID didn't
218
+ # translate, so /proc/0/{comm,cmdline} can't resolve. Render an
219
+ # explicit label so the diagnostic isn't read as "we failed to
220
+ # capture the sender" — the cross-namespace case is itself the
221
+ # signal in #1109-style reports.
222
+ if sender_pid == 0:
223
+ sender_pid_str = "0 (cross-namespace; likely Supervisor or host process)"
224
+ sender_comm_str = "<cross-namespace>"
225
+ sender_cmdline_str = "<cross-namespace>"
226
+ else:
227
+ sender_pid_str = str(sender_pid)
228
+ sender_comm_str = sender_comm or "<unavailable>"
229
+ sender_cmdline_str = sender_cmdline or "<unavailable>"
230
+
231
+ lines = [
232
+ "=" * 80,
233
+ "ADVANCED DEBUG LOGGING — kill-signal diagnostics",
234
+ "=" * 80,
235
+ f"Signal: {sig_name} ({signum})",
236
+ f"si_code: {code_name}",
237
+ f"Sender PID: {sender_pid_str}",
238
+ f"Sender comm: {sender_comm_str}",
239
+ f"Sender cmdline: {sender_cmdline_str}",
240
+ "",
241
+ "Process state (from /proc/self/status):",
242
+ ]
243
+ if proc_status:
244
+ lines.extend(
245
+ f" {key}: {proc_status[key]}"
246
+ for key in ("State", "VmRSS", "VmHWM", "VmPeak", "Threads", "oom_score", "oom_score_adj")
247
+ if key in proc_status
248
+ )
249
+ else:
250
+ lines.append(" <unavailable — non-Linux or /proc not mounted>")
251
+ lines.append("=" * 80)
252
+ return "\n".join(lines)
253
+
254
+
255
+ # Module-level reference set so the kernel-installed pointer isn't GC'd
256
+ # mid-flight. Comment exists because the variable looks unused — without
257
+ # it a future maintainer will delete it and ship a use-after-free.
258
+ _handler_refs: list[Any] = []
259
+ _libc: Any = None
260
+ # Captured at install time so our handler can chain back to whatever
261
+ # was installed before (typically uvicorn's handle_exit).
262
+ _chained_handlers: dict[int, Any] = {}
263
+
264
+
265
+ def _emit_block_safely(block: str) -> None:
266
+ """Write ``block`` to stderr using only async-signal-safe primitives."""
267
+ payload = (block + "\n").encode("utf-8", errors="replace")
268
+ try:
269
+ os.write(2, payload)
270
+ except OSError:
271
+ pass
272
+
273
+
274
+ def _restore_default_and_reraise(signum: int) -> None:
275
+ """Reset disposition to SIG_DFL via direct libc and re-raise.
276
+
277
+ Uses ``libc.signal(signum, SIG_DFL)`` (AS-safe) instead of Python's
278
+ ``signal.signal`` because the latter mutates CPython signal-state
279
+ bookkeeping that assumes main-thread + bytecode-boundary calls.
280
+ """
281
+ if _libc is not None:
282
+ try:
283
+ _libc.signal(int(signum), _SIG_DFL_PTR)
284
+ except OSError:
285
+ pass
286
+ os.kill(os.getpid(), signum)
287
+
288
+
289
+ def _chain_or_reraise(signum: int) -> None:
290
+ """Hand control to the previously-installed handler, or re-raise default.
291
+
292
+ Uvicorn's ``handle_exit`` only sets ``self.should_exit`` (synchronous,
293
+ no locks), so calling it directly from this trampoline is safe.
294
+ """
295
+ chained = _chained_handlers.get(signum)
296
+ if callable(chained):
297
+ try:
298
+ chained(signum, None)
299
+ return
300
+ except Exception:
301
+ # Fall through to the default-disposition path so the
302
+ # process still terminates if the chained handler explodes.
303
+ pass
304
+ _restore_default_and_reraise(signum)
305
+
306
+
307
+ def _make_handler() -> Any:
308
+ """Build the C-callable signal handler closure.
309
+
310
+ Returns a ``_SignalHandler`` (``ctypes.CFUNCTYPE`` instance), typed
311
+ as ``Any`` because Pyright doesn't accept dynamically-generated
312
+ ctypes function-pointer types in static type expressions.
313
+ """
314
+
315
+ def _handler(signum: int, info_ptr: Any, _ucontext: int) -> None:
316
+ try:
317
+ info = info_ptr.contents
318
+ si_code = int(info.si_code)
319
+ sender_pid = int(info.si_pid)
320
+ block = format_diagnostic_block(
321
+ signum=signum,
322
+ si_code=si_code,
323
+ sender_pid=sender_pid,
324
+ sender_comm=read_proc_comm(sender_pid),
325
+ sender_cmdline=read_proc_cmdline(sender_pid),
326
+ proc_status=read_proc_status_summary(),
327
+ )
328
+ _emit_block_safely(block)
329
+ except Exception as exc: # pragma: no cover — last-resort safety
330
+ try:
331
+ os.write(
332
+ 2,
333
+ f"advanced_debug_logging handler failed for signal {signum}: {exc!r}\n".encode(
334
+ "utf-8", errors="replace"
335
+ ),
336
+ )
337
+ except OSError:
338
+ pass
339
+
340
+ _chain_or_reraise(signum)
341
+
342
+ return _SignalHandler(_handler)
343
+
344
+
345
+ def install_kill_signal_diagnostics() -> bool:
346
+ """Install the SA_SIGINFO signal handler.
347
+
348
+ Captures any previously-installed handler (e.g. uvicorn's
349
+ ``handle_exit``) via ``signal.getsignal`` so the SA_SIGINFO handler
350
+ can chain to it. Idempotent — second call is a no-op.
351
+
352
+ Returns True if at least one signal was installed; False on
353
+ non-Linux, missing libc, or if every sigaction call failed. Never
354
+ raises: callers don't need to wrap in try/except. This contract is
355
+ load-bearing — diagnostics must not block addon startup.
356
+ """
357
+ global _libc
358
+
359
+ if sys.platform != "linux":
360
+ logger.warning(
361
+ "advanced_debug_logging is Linux-only; skipping signal handler install on %s",
362
+ sys.platform,
363
+ )
364
+ return False
365
+
366
+ if _handler_refs:
367
+ logger.warning(
368
+ "advanced_debug_logging: install_kill_signal_diagnostics already called; skipping"
369
+ )
370
+ return True
371
+
372
+ try:
373
+ libc_path = ctypes.util.find_library("c")
374
+ if libc_path is None:
375
+ logger.warning("advanced_debug_logging: libc not found; skipping signal handler install")
376
+ return False
377
+
378
+ libc = ctypes.CDLL(libc_path, use_errno=True)
379
+ libc.sigaction.restype = ctypes.c_int
380
+ libc.sigaction.argtypes = [ctypes.c_int, ctypes.POINTER(_Sigaction), ctypes.POINTER(_Sigaction)]
381
+ # signal(int, sighandler_t) — used by the handler itself to
382
+ # restore SIG_DFL via the AS-safe libc entry point.
383
+ libc.signal.restype = ctypes.c_void_p
384
+ libc.signal.argtypes = [ctypes.c_int, ctypes.c_void_p]
385
+ _libc = libc
386
+
387
+ # Snapshot the existing handler for each instrumented signal
388
+ # before we overwrite. This is what we chain back to so uvicorn
389
+ # (or whoever was there) still receives the shutdown signal.
390
+ for sig in _INSTRUMENTED_SIGNALS:
391
+ existing = signal.getsignal(int(sig))
392
+ if callable(existing):
393
+ _chained_handlers[int(sig)] = existing
394
+
395
+ handler = _make_handler()
396
+ _handler_refs.append(handler)
397
+
398
+ sa = _Sigaction()
399
+ ctypes.memset(ctypes.byref(sa), 0, ctypes.sizeof(sa))
400
+ sa.sa_sigaction = handler
401
+ sa.sa_flags = _SA_SIGINFO | _SA_RESTART
402
+ _handler_refs.append(sa)
403
+
404
+ installed_for: list[str] = []
405
+ for sig in _INSTRUMENTED_SIGNALS:
406
+ rc = libc.sigaction(int(sig), ctypes.byref(sa), None)
407
+ if rc != 0:
408
+ err = ctypes.get_errno()
409
+ logger.warning(
410
+ "advanced_debug_logging: sigaction(%s) failed: errno=%d",
411
+ sig.name,
412
+ err,
413
+ )
414
+ continue
415
+ installed_for.append(sig.name)
416
+ except Exception as exc:
417
+ logger.warning(
418
+ "advanced_debug_logging: install failed (%r); continuing without diagnostics",
419
+ exc,
420
+ )
421
+ _handler_refs.clear()
422
+ _chained_handlers.clear()
423
+ _libc = None
424
+ return False
425
+
426
+ if installed_for:
427
+ chained_signals = sorted(signal.Signals(s).name for s in _chained_handlers)
428
+ logger.info(
429
+ "advanced_debug_logging enabled — kill-signal diagnostics installed for: %s "
430
+ "(chains to existing handlers for: %s)",
431
+ ", ".join(installed_for),
432
+ ", ".join(chained_signals) or "<none>",
433
+ )
434
+ return True
435
+ _handler_refs.clear()
436
+ _chained_handlers.clear()
437
+ _libc = None
438
+ return False
439
+
440
+
441
+ def schedule_install_after_uvicorn(
442
+ *,
443
+ timeout_secs: float = 10.0,
444
+ poll_interval_secs: float = 0.1,
445
+ install: Callable[[], bool] = install_kill_signal_diagnostics,
446
+ ) -> threading.Thread:
447
+ """Defer install until uvicorn's ``capture_signals()`` has run.
448
+
449
+ uvicorn's ``Server.capture_signals()`` calls
450
+ ``signal.signal(SIGTERM/SIGINT, handle_exit)`` immediately after
451
+ ``Server.serve()`` enters. Python's ``signal.signal`` reaches libc's
452
+ ``sigaction`` *without* ``SA_SIGINFO``, so any handler we installed
453
+ before ``mcp.run()`` would lose its SA_SIGINFO bit before any signal
454
+ arrived. This polls ``signal.getsignal(SIGTERM)`` from a daemon
455
+ thread until uvicorn replaces the default disposition, then calls
456
+ ``install`` so our SA_SIGINFO handler lands on top and chains to
457
+ uvicorn's ``handle_exit``.
458
+
459
+ If uvicorn never installs (e.g. addon was started without HTTP
460
+ transport), install runs anyway after ``timeout_secs``.
461
+
462
+ Returns the started thread so callers can ``.join()`` in tests.
463
+ """
464
+
465
+ def _wait_then_install() -> None:
466
+ deadline = time.monotonic() + timeout_secs
467
+ while time.monotonic() < deadline:
468
+ current = signal.getsignal(signal.SIGTERM)
469
+ if callable(current) and current not in (signal.SIG_DFL, signal.SIG_IGN):
470
+ logger.debug(
471
+ "advanced_debug_logging: detected uvicorn signal handler; installing on top"
472
+ )
473
+ install()
474
+ return
475
+ time.sleep(poll_interval_secs)
476
+ logger.info(
477
+ "advanced_debug_logging: uvicorn handler not detected within %.1fs; installing anyway",
478
+ timeout_secs,
479
+ )
480
+ install()
481
+
482
+ thread = threading.Thread(
483
+ target=_wait_then_install,
484
+ name="kill-signal-diagnostics-install",
485
+ daemon=True,
486
+ )
487
+ thread.start()
488
+ return thread
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.4.1.dev427
3
+ Version: 7.4.1.dev429
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
@@ -89,6 +89,7 @@ src/ha_mcp/utils/__init__.py
89
89
  src/ha_mcp/utils/config_hash.py
90
90
  src/ha_mcp/utils/domain_handlers.py
91
91
  src/ha_mcp/utils/fuzzy_search.py
92
+ src/ha_mcp/utils/kill_signal_diagnostics.py
92
93
  src/ha_mcp/utils/operation_manager.py
93
94
  src/ha_mcp/utils/python_sandbox.py
94
95
  src/ha_mcp/utils/usage_logger.py