Don't forget command line ignored shell repair issues (#183765)

This commit is contained in:
Martin Hjelmare
2026-09-30 18:57:02 +02:00
committed by GitHub
parent 705ad58fbf
commit 1d15e6a9b1
4 changed files with 115 additions and 17 deletions
@@ -67,7 +67,7 @@ from .const import (
DEFAULT_TIMEOUT,
DOMAIN,
)
from .utils import async_clear_shell_template_issues
from .utils import async_prune_shell_template_issues, build_shell_template_issue_id
BINARY_SENSOR_DEFAULT_NAME = "Binary Command Sensor"
DEFAULT_PAYLOAD_ON = "ON"
@@ -210,9 +210,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
for reset_platform in reset_platforms:
_LOGGER.debug("Reload resetting platform: %s", reset_platform.domain)
await reset_platform.async_reset()
# Prune stale template deprecation issues; entities still needing one
# recreate it on their next update after reload.
async_clear_shell_template_issues(hass)
# Prune template deprecation issues for entities that no longer exist,
# keeping issues for still-configured entities so an ignored issue is not
# reset by a delete-and-recreate. Each entity refreshes or clears its own
# issue on its next update after reload.
valid_issue_ids = _shell_template_issue_ids(
reload_config.get(DOMAIN, []) if reload_config else []
)
async_prune_shell_template_issues(hass, valid_issue_ids)
if not reload_config:
return
await async_load_platforms(hass, reload_config.get(DOMAIN, []), reload_config)
@@ -224,6 +229,29 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
def _shell_template_issue_ids(
command_line_config: list[dict[str, dict[str, Any]]],
) -> set[str]:
"""Return the shell template deprecation issue ids for the given config.
Only sensor, binary_sensor and notify run templated commands and can raise
the issue. The name mirrors each platform's setup: sensor and binary_sensor
always have a name (schema default), while notify falls back to the
integration domain when no name is configured.
"""
issue_ids: set[str] = set()
for platform_config in command_line_config:
for platform, platform_conf in platform_config.items():
if platform == NOTIFY_DOMAIN:
name = platform_conf.get(CONF_NAME) or DOMAIN
elif platform in (SENSOR_DOMAIN, BINARY_SENSOR_DOMAIN):
name = platform_conf[CONF_NAME]
else:
continue
issue_ids.add(build_shell_template_issue_id(platform, name))
return issue_ids
async def async_load_platforms(
hass: HomeAssistant,
command_line_config: list[dict[str, dict[str, Any]]],
+30 -13
View File
@@ -73,16 +73,38 @@ def _update_issue(
)
@callback
def async_clear_shell_template_issues(hass: HomeAssistant) -> None:
"""Delete all shell command template deprecation issues.
def build_shell_template_issue_id(platform: str, name: str) -> str:
"""Build the shell command template deprecation issue id for an entity.
Called on reload so issues for removed or renamed entities are not left
stale. Entities that still need one recreate it on their next update.
A hash of the raw name is appended because slugify is not injective (e.g.
"Test More" and "Test_(More)" both slugify to "test_more"), which would
otherwise let one entity clear another's issue. Hashing the name rather than
the command keeps the id stable across command edits so following the repair
instructions clears it.
"""
name_hash = hashlib.sha256(name.encode()).hexdigest()[:8]
return f"{_ISSUE_ID_PREFIX}{platform}_{slugify(name)}_{name_hash}"
@callback
def async_prune_shell_template_issues(
hass: HomeAssistant, valid_issue_ids: set[str]
) -> None:
"""Delete deprecation issues for entities that no longer exist.
Called on reload to remove issues left behind by removed or renamed
entities. Issues for still-configured entities are kept so a user's decision
to ignore an issue survives the reload; each entity refreshes or clears its
own issue on its next update. Deleting and later recreating an issue would
reset the ignore state, so we never delete an issue we cannot prove is stale.
"""
registry = ir.async_get(hass)
for domain, issue_id in list(registry.issues):
if domain == DOMAIN and issue_id.startswith(_ISSUE_ID_PREFIX):
if (
domain == DOMAIN
and issue_id.startswith(_ISSUE_ID_PREFIX)
and issue_id not in valid_issue_ids
):
ir.async_delete_issue(hass, DOMAIN, issue_id)
@@ -240,13 +262,8 @@ def render_template_args(
# Template substitution occurred. Determine the safe execution path.
# The name makes the issue id unique per entity so two entities that happen
# to share a command string get their own issue. A hash of the raw name is
# appended because slugify is not injective (e.g. "Test More" and
# "Test_(More)" both slugify to "test_more"), which would otherwise let one
# entity clear another's issue. Hashing the name rather than the command
# keeps the id stable across command edits so the repair instructions clear it.
name_hash = hashlib.sha256(name.encode()).hexdigest()[:8]
issue_id = f"{_ISSUE_ID_PREFIX}{platform}_{slugify(name)}_{name_hash}"
# to share a command string get their own issue.
issue_id = build_shell_template_issue_id(platform, name)
# Classify and parse the whole command, not just the rendered args, so shell
# features and quoting in the executable token are handled too.
@@ -0,0 +1,4 @@
command_line:
- "sensor":
"name": "Test"
"command": "echo {{ states.sensor.input_sensor.state }} | cat"
@@ -156,3 +156,52 @@ async def test_reload_prunes_stale_template_issue(
issue.translation_key == "shell_command_template_deprecation"
for issue in issue_registry.issues.values()
)
async def test_reload_keeps_valid_template_issue_and_ignore_state(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
) -> None:
"""A still-valid deprecation issue and its ignore state survive reload."""
hass.states.async_set("sensor.input_sensor", "safe_value")
await setup.async_setup_component(
hass,
DOMAIN,
{
"command_line": [
{
"sensor": {
"name": "Test",
"command": "echo {{ states.sensor.input_sensor.state }} | cat",
}
}
]
},
)
await hass.async_block_till_done()
with mock_asyncio_subprocess_run(b"safe_value\n"):
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=1))
await hass.async_block_till_done(wait_background_tasks=True)
issue = next(
entry
for entry in issue_registry.issues.values()
if entry.translation_key == "shell_command_template_deprecation"
)
# The user ignores the issue; a delete-and-recreate would reset this.
ir.async_ignore_issue(hass, DOMAIN, issue.issue_id, True)
assert issue_registry.issues[(DOMAIN, issue.issue_id)].dismissed_version
# Reload with the same entity still configured.
yaml_path = get_fixture_path("configuration_shell_template.yaml", "command_line")
with (
patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path),
mock_asyncio_subprocess_run(b"safe_value\n"),
):
await hass.services.async_call(DOMAIN, SERVICE_RELOAD, {}, blocking=True)
await hass.async_block_till_done(wait_background_tasks=True)
kept_issue = issue_registry.issues.get((DOMAIN, issue.issue_id))
assert kept_issue is not None
assert kept_issue.dismissed_version