diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index 35e43f593cc..f5cf7951970 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -284,6 +284,19 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity): UpdateDeviceClass, static_info.device_class ) + def version_is_newer(self, latest_version: str, installed_version: str) -> bool: + """Return True if latest_version is newer than installed_version. + + ESPHome project versions can carry a build suffix (e.g. + 2025.11.5_c51f7548) that AwesomeVersion cannot parse. Without stripping + it the base comparison raises and the entity is forced on for every + build mismatch. Drop the suffix so the versions compare cleanly and we + only report genuinely newer firmware. + """ + return super().version_is_newer( + latest_version.partition("_")[0], installed_version.partition("_")[0] + ) + @property @esphome_state_property def installed_version(self) -> str: diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index 0af9cc7d71e..1bdb4b4ac63 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -5,6 +5,8 @@ from typing import Any from unittest.mock import patch from aioesphomeapi import APIClient, UpdateCommand, UpdateInfo, UpdateState +from awesomeversion import AwesomeVersion +from awesomeversion.exceptions import AwesomeVersionCompareException import pytest from homeassistant.components.esphome.dashboard import async_get_dashboard @@ -547,6 +549,128 @@ async def test_generic_device_update_entity_has_update( ) +@pytest.mark.parametrize( + ("current_version", "latest_version"), + [ + ("2025.11.5_c51f7548", "2025.11.6_aabbccdd"), + ("2025.11.5_c51f7548", "2025.11.5_aabbccdd"), + ("2025.11.6_aabbccdd", "2025.11.5_c51f7548"), + ], + ids=["newer_base", "same_base_new_build", "older_base"], +) +def test_awesomeversion_cannot_compare_project_versions( + current_version: str, latest_version: str +) -> None: + """Prove AwesomeVersion raises on ESPHome project versions. + + ESPHome project versions carry a build suffix (e.g. 2025.11.5_c51f7548). + AwesomeVersion cannot parse these, so the base UpdateEntity comparison would + raise and force the entity on, which is why ESPHomeUpdateEntity mirrors the + device by comparing with a plain string inequality instead. + """ + with pytest.raises(AwesomeVersionCompareException): + assert AwesomeVersion(latest_version) > current_version + + +@pytest.mark.parametrize( + ("current_version", "latest_version", "expected_state"), + [ + ("2025.11.5_c51f7548", "2025.11.6_aabbccdd", STATE_ON), + ("2025.11.5_c51f7548", "2025.11.5_aabbccdd", STATE_OFF), + ("2025.11.6_aabbccdd", "2025.11.5_c51f7548", STATE_OFF), + ("2025.11.5_c51f7548", "2025.11.5_c51f7548", STATE_OFF), + ], + ids=["newer_base", "same_base_new_build", "older_base", "identical"], +) +async def test_generic_device_update_entity_project_version( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, + current_version: str, + latest_version: str, + expected_state: str, +) -> None: + """Test version comparison for ESPHome project versions. + + AwesomeVersion cannot parse the build suffix, so the entity strips it and + compares the real versions: only a genuinely newer base version is offered; + a different build of the same version or an older version is not. + """ + entity_info = [ + UpdateInfo( + object_id="myupdate", + key=1, + name="my update", + ) + ] + states = [ + UpdateState( + key=1, + current_version=current_version, + latest_version=latest_version, + title="ESPHome Project", + release_summary=RELEASE_SUMMARY, + release_url=RELEASE_URL, + ) + ] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == expected_state + + +async def test_generic_device_update_entity_clears_after_ota( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test a project version update clears once the device runs the new build.""" + entity_info = [ + UpdateInfo( + object_id="myupdate", + key=1, + name="my update", + ) + ] + states = [ + UpdateState( + key=1, + current_version="2025.11.5_c51f7548", + latest_version="2025.11.6_aabbccdd", + title="ESPHome Project", + release_summary=RELEASE_SUMMARY, + release_url=RELEASE_URL, + ) + ] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + + mock_device.set_state( + UpdateState( + key=1, + current_version="2025.11.6_aabbccdd", + latest_version="2025.11.6_aabbccdd", + title="ESPHome Project", + release_summary=RELEASE_SUMMARY, + release_url=RELEASE_URL, + ) + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + + async def test_update_entity_release_notes( hass: HomeAssistant, mock_client: APIClient,