ha-mcp-dev 7.4.1.dev428__tar.gz → 7.4.1.dev430__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ha_mcp_dev-7.4.1.dev428/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.4.1.dev430}/PKG-INFO +1 -1
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/pyproject.toml +1 -1
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_integrations.py +85 -31
- ha_mcp_dev-7.4.1.dev430/src/ha_mcp/utils/kill_signal_diagnostics.py +488 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp_dev.egg-info/SOURCES.txt +1 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/LICENSE +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/README.md +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/setup.cfg +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/_version.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/config.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/server.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/settings_ui.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/reference_validator.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/smart_search.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_energy.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_search.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/utils/config_hash.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/tests/test_env_manager.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ha-mcp-dev"
|
|
7
|
-
version = "7.4.1.
|
|
7
|
+
version = "7.4.1.dev430"
|
|
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"
|
|
@@ -971,7 +971,8 @@ class IntegrationTools:
|
|
|
971
971
|
)
|
|
972
972
|
|
|
973
973
|
try:
|
|
974
|
-
#
|
|
974
|
+
# Resolve unique_id via the entity registry, with a retry loop
|
|
975
|
+
# for transient registry failures.
|
|
975
976
|
unique_id = None
|
|
976
977
|
registry_result: dict[str, Any] | None = None
|
|
977
978
|
max_retries = 3
|
|
@@ -982,18 +983,18 @@ class IntegrationTools:
|
|
|
982
983
|
f"(attempt {attempt + 1}/{max_retries})"
|
|
983
984
|
)
|
|
984
985
|
|
|
985
|
-
#
|
|
986
|
+
# State check is informational only — disabled entities are
|
|
987
|
+
# missing from the state machine but resolved via the registry
|
|
988
|
+
# below (issue #1057). Kept as a debug breadcrumb rather than
|
|
989
|
+
# removed; full removal is option 3.2 in #1057, deferred to a
|
|
990
|
+
# separate PR for minimal blast radius here.
|
|
986
991
|
try:
|
|
987
992
|
state_check = await client.get_entity_state(entity_id)
|
|
988
993
|
if not state_check:
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
f"{wait_time}s before retry..."
|
|
994
|
-
)
|
|
995
|
-
await asyncio.sleep(wait_time)
|
|
996
|
-
continue
|
|
994
|
+
logger.debug(
|
|
995
|
+
f"Entity {entity_id} not in state; "
|
|
996
|
+
"proceeding to registry lookup"
|
|
997
|
+
)
|
|
997
998
|
except HomeAssistantAPIError as e:
|
|
998
999
|
# State check is best-effort here; an APIError (e.g. 404)
|
|
999
1000
|
# is informational. Auth/connection errors must propagate
|
|
@@ -1009,8 +1010,8 @@ class IntegrationTools:
|
|
|
1009
1010
|
registry_result = await client.send_websocket_message(
|
|
1010
1011
|
registry_msg
|
|
1011
1012
|
)
|
|
1012
|
-
if registry_result.get("success"):
|
|
1013
|
-
entity_entry = registry_result.get("result"
|
|
1013
|
+
if (registry_result or {}).get("success"):
|
|
1014
|
+
entity_entry = (registry_result or {}).get("result") or {}
|
|
1014
1015
|
unique_id = entity_entry.get("unique_id")
|
|
1015
1016
|
if unique_id:
|
|
1016
1017
|
logger.info(
|
|
@@ -1075,29 +1076,82 @@ class IntegrationTools:
|
|
|
1075
1076
|
)
|
|
1076
1077
|
return response
|
|
1077
1078
|
|
|
1078
|
-
# Fallback strategy 2: already-deleted check
|
|
1079
|
+
# Fallback strategy 2: already-deleted check. Confirm via the
|
|
1080
|
+
# registry too — a disabled entity is missing from the state
|
|
1081
|
+
# machine but still registry-resident, so state-absence alone
|
|
1082
|
+
# is not enough to declare success.
|
|
1079
1083
|
try:
|
|
1080
1084
|
final_state_check = await client.get_entity_state(entity_id)
|
|
1081
1085
|
if not final_state_check:
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1086
|
+
registry_still_has_entry = False
|
|
1087
|
+
try:
|
|
1088
|
+
verify_result = await client.send_websocket_message(
|
|
1089
|
+
{
|
|
1090
|
+
"type": "config/entity_registry/get",
|
|
1091
|
+
"entity_id": entity_id,
|
|
1092
|
+
}
|
|
1093
|
+
)
|
|
1094
|
+
if (verify_result or {}).get("success"):
|
|
1095
|
+
verify_entry = (verify_result or {}).get("result") or {}
|
|
1096
|
+
if verify_entry.get("entity_id"):
|
|
1097
|
+
registry_still_has_entry = True
|
|
1098
|
+
except HomeAssistantAPIError as verify_err:
|
|
1099
|
+
# On verify failure, conservatively assume the
|
|
1100
|
+
# entry is still there rather than silently
|
|
1101
|
+
# short-circuit to already_deleted.
|
|
1102
|
+
logger.debug(
|
|
1103
|
+
f"Registry verify for {entity_id} failed: "
|
|
1104
|
+
f"{verify_err}"
|
|
1105
|
+
)
|
|
1106
|
+
registry_still_has_entry = True
|
|
1107
|
+
|
|
1108
|
+
if not registry_still_has_entry:
|
|
1109
|
+
logger.info(
|
|
1110
|
+
f"Entity {entity_id} absent from state and "
|
|
1111
|
+
"registry; treating as already deleted"
|
|
1112
|
+
)
|
|
1113
|
+
return {
|
|
1114
|
+
"success": True,
|
|
1115
|
+
"action": "delete",
|
|
1116
|
+
"target": target,
|
|
1117
|
+
"helper_type": helper_type,
|
|
1118
|
+
"method": "websocket_delete",
|
|
1119
|
+
"entry_id": None,
|
|
1120
|
+
"entity_ids": [entity_id],
|
|
1121
|
+
"require_restart": False,
|
|
1122
|
+
"message": (
|
|
1123
|
+
f"Helper {target} was already deleted or "
|
|
1124
|
+
"never properly registered."
|
|
1125
|
+
),
|
|
1126
|
+
"fallback_used": "already_deleted",
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
logger.warning(
|
|
1130
|
+
f"Entity {entity_id} absent from state but still "
|
|
1131
|
+
"in registry; not already_deleted"
|
|
1132
|
+
)
|
|
1133
|
+
raise_tool_error(
|
|
1134
|
+
create_error_response(
|
|
1135
|
+
ErrorCode.SERVICE_CALL_FAILED,
|
|
1136
|
+
(
|
|
1137
|
+
f"Helper {target} could not be deleted: "
|
|
1138
|
+
"registry entry exists but unique_id was "
|
|
1139
|
+
"absent and the direct-id fallback "
|
|
1140
|
+
"delete failed."
|
|
1141
|
+
),
|
|
1142
|
+
suggestions=[
|
|
1143
|
+
"Re-enable the entity via "
|
|
1144
|
+
"ha_set_entity(enabled=True), then retry "
|
|
1145
|
+
"deletion.",
|
|
1146
|
+
"Or inspect the entity registry entry "
|
|
1147
|
+
"directly to confirm unique_id presence.",
|
|
1148
|
+
],
|
|
1149
|
+
context={
|
|
1150
|
+
"target": target,
|
|
1151
|
+
"entity_id": entity_id,
|
|
1152
|
+
},
|
|
1153
|
+
)
|
|
1085
1154
|
)
|
|
1086
|
-
return {
|
|
1087
|
-
"success": True,
|
|
1088
|
-
"action": "delete",
|
|
1089
|
-
"target": target,
|
|
1090
|
-
"helper_type": helper_type,
|
|
1091
|
-
"method": "websocket_delete",
|
|
1092
|
-
"entry_id": None,
|
|
1093
|
-
"entity_ids": [entity_id],
|
|
1094
|
-
"require_restart": False,
|
|
1095
|
-
"message": (
|
|
1096
|
-
f"Helper {target} was already deleted or "
|
|
1097
|
-
"never properly registered."
|
|
1098
|
-
),
|
|
1099
|
-
"fallback_used": "already_deleted",
|
|
1100
|
-
}
|
|
1101
1155
|
except HomeAssistantAPIError as e:
|
|
1102
1156
|
# 404 here means the state-check itself confirmed the
|
|
1103
1157
|
# entity is gone — treat as a soft signal and continue
|
|
@@ -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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/resources/skills-vendor/AGENTS.md
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/resources/skills-vendor/CLAUDE.md
RENAMED
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/resources/skills-vendor/LICENSE
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/resources/skills-vendor/README.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/best_practice_checker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_config_automations.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_config_dashboards.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_config_entry_flow.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_config_helpers.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_config_scripts.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/tools/tools_voice_assistant.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp/transforms/categorized_search.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp_dev.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{ha_mcp_dev-7.4.1.dev428 → ha_mcp_dev-7.4.1.dev430}/src/ha_mcp_dev.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|