From 1d15e6a9b19bd63ae3ec3168f17e9d37ef602ca9 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 30 Sep 2026 18:57:02 +0200 Subject: [PATCH] Don't forget command line ignored shell repair issues (#183765) --- .../components/command_line/__init__.py | 36 ++++++++++++-- .../components/command_line/utils.py | 43 +++++++++++----- .../configuration_shell_template.yaml | 4 ++ tests/components/command_line/test_init.py | 49 +++++++++++++++++++ 4 files changed, 115 insertions(+), 17 deletions(-) create mode 100644 tests/components/command_line/fixtures/configuration_shell_template.yaml diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index c0dceb48f0c7..a103e4c82563 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -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]]], diff --git a/homeassistant/components/command_line/utils.py b/homeassistant/components/command_line/utils.py index 9ac27e8c3f72..46186db36cb4 100644 --- a/homeassistant/components/command_line/utils.py +++ b/homeassistant/components/command_line/utils.py @@ -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. diff --git a/tests/components/command_line/fixtures/configuration_shell_template.yaml b/tests/components/command_line/fixtures/configuration_shell_template.yaml new file mode 100644 index 000000000000..459ccebd6c91 --- /dev/null +++ b/tests/components/command_line/fixtures/configuration_shell_template.yaml @@ -0,0 +1,4 @@ +command_line: + - "sensor": + "name": "Test" + "command": "echo {{ states.sensor.input_sensor.state }} | cat" diff --git a/tests/components/command_line/test_init.py b/tests/components/command_line/test_init.py index 4f3ab32a2736..cfe50b5d8f8d 100644 --- a/tests/components/command_line/test_init.py +++ b/tests/components/command_line/test_init.py @@ -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