diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 6b8d100ea8faaa..142b5f9c521b40 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -9,6 +9,7 @@ from aioshelly.const import RPC_GENERATIONS from aioshelly.exceptions import ( DeviceConnectionError, + FirmwareUnsupported, InvalidAuthError, MacAddressMismatchError, ) @@ -37,6 +38,7 @@ DATA_CONFIG_ENTRY, DEFAULT_COAP_PORT, DOMAIN, + FIRMWARE_UNSUPPORTED_ISSUE_ID, LOGGER, MODELS_WITH_WRONG_SLEEP_PERIOD, PUSH_UPDATE_ISSUE_ID, @@ -50,6 +52,7 @@ get_entry_data, ) from .utils import ( + async_create_issue_unsupported_firmware, get_block_device_sleep_period, get_coap_context, get_device_entry_gen, @@ -216,6 +219,9 @@ def _async_device_online(_: Any, update_type: BlockUpdateType) -> None: raise ConfigEntryNotReady(repr(err)) from err except InvalidAuthError as err: raise ConfigEntryAuthFailed(repr(err)) from err + except FirmwareUnsupported as err: + async_create_issue_unsupported_firmware(hass, entry) + raise ConfigEntryNotReady from err await _async_block_device_setup() elif sleep_period is None or device_entry is None: @@ -230,6 +236,9 @@ def _async_device_online(_: Any, update_type: BlockUpdateType) -> None: LOGGER.debug("Setting up offline block device %s", entry.title) await _async_block_device_setup() + ir.async_delete_issue( + hass, DOMAIN, FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=entry.unique_id) + ) return True @@ -296,6 +305,9 @@ def _async_device_online(_: Any, update_type: RpcUpdateType) -> None: LOGGER.debug("Setting up online RPC device %s", entry.title) try: await device.initialize() + except FirmwareUnsupported as err: + async_create_issue_unsupported_firmware(hass, entry) + raise ConfigEntryNotReady from err except (DeviceConnectionError, MacAddressMismatchError) as err: raise ConfigEntryNotReady(repr(err)) from err except InvalidAuthError as err: @@ -314,6 +326,9 @@ def _async_device_online(_: Any, update_type: RpcUpdateType) -> None: LOGGER.debug("Setting up offline block device %s", entry.title) await _async_rpc_device_setup() + ir.async_delete_issue( + hass, DOMAIN, FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=entry.unique_id) + ) return True diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 6cc513015d382a..827a6c00a30e9f 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -212,6 +212,8 @@ class BLEScannerMode(StrEnum): NOT_CALIBRATED_ISSUE_ID = "not_calibrated_{unique}" +FIRMWARE_UNSUPPORTED_ISSUE_ID = "firmware_unsupported_{unique}" + GAS_VALVE_OPEN_STATES = ("opening", "opened") OTA_BEGIN = "ota_begin" diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index c1f9b799444dfb..9676c24f8830ca 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -168,6 +168,10 @@ "deprecated_valve_switch_entity": { "title": "Deprecated switch entity for Shelly Gas Valve detected in {info}", "description": "Your Shelly Gas Valve entity `{entity}` is being used in `{info}`. A valve entity is available and should be used going forward.\n\nPlease adjust `{info}` to fix this issue." + }, + "unsupported_firmware": { + "title": "Unsupported firmware for device {device_name}", + "description": "Your Shelly device {device_name} with IP address {ip_address} is running an unsupported firmware. Please update the firmware.\n\nIf the device does not offer an update, check internet connectivity (gateway, DNS, time) and restart the device." } } } diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index d40b22ca50a4fa..f5196504fe605c 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import singleton +from homeassistant.helpers import issue_registry as ir, singleton from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, async_get as dr_async_get, @@ -38,6 +38,7 @@ DEFAULT_COAP_PORT, DEVICES_WITHOUT_FIRMWARE_CHANGELOG, DOMAIN, + FIRMWARE_UNSUPPORTED_ISSUE_ID, GEN1_RELEASE_URL, GEN2_RELEASE_URL, LOGGER, @@ -426,3 +427,23 @@ def get_release_url(gen: int, model: str, beta: bool) -> str | None: return None return GEN1_RELEASE_URL if gen in BLOCK_GENERATIONS else GEN2_RELEASE_URL + + +@callback +def async_create_issue_unsupported_firmware( + hass: HomeAssistant, entry: ConfigEntry +) -> None: + """Create a repair issue if the device runs an unsupported firmware.""" + ir.async_create_issue( + hass, + DOMAIN, + FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=entry.unique_id), + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.ERROR, + translation_key="unsupported_firmware", + translation_placeholders={ + "device_name": entry.title, + "ip_address": entry.data["host"], + }, + ) diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 8e288ba16874cf..f17d8491782dd2 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -3,7 +3,11 @@ from unittest.mock import AsyncMock, patch from aioshelly.const import MODEL_BULB, MODEL_BUTTON1 -from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError +from aioshelly.exceptions import ( + DeviceConnectionError, + FirmwareUnsupported, + InvalidAuthError, +) from freezegun.api import FrozenDateTimeFactory from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN @@ -186,6 +190,27 @@ async def test_block_rest_update_auth_error( assert flow["context"].get("entry_id") == entry.entry_id +async def test_block_firmware_unsupported( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch +) -> None: + """Test block device polling authentication error.""" + monkeypatch.setattr( + mock_block_device, + "update", + AsyncMock(side_effect=FirmwareUnsupported), + ) + entry = await init_integration(hass, 1) + + assert entry.state is ConfigEntryState.LOADED + + # Move time to generate polling + freezer.tick(timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + async def test_block_polling_connection_error( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: @@ -504,7 +529,28 @@ async def test_rpc_sleeping_device_no_periodic_updates( async_fire_time_changed(hass) await hass.async_block_till_done() - assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE + assert get_entity_state(hass, entity_id) is STATE_UNAVAILABLE + + +async def test_rpc_firmware_unsupported( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device, monkeypatch +) -> None: + """Test RPC update entry unsupported firmware.""" + entry = await init_integration(hass, 2) + register_entity( + hass, + SENSOR_DOMAIN, + "test_name_temperature", + "temperature:0-temperature_0", + entry, + ) + + # Move time to generate sleep period update + freezer.tick(timedelta(seconds=600 * SLEEP_PERIOD_MULTIPLIER)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED async def test_rpc_reconnect_auth_error( diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index bc0ba045a552a2..0cd206e33a2b3a 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -5,6 +5,7 @@ from aioshelly.exceptions import ( DeviceConnectionError, + FirmwareUnsupported, InvalidAuthError, MacAddressMismatchError, ) @@ -79,15 +80,21 @@ async def test_setup_entry_not_shelly( @pytest.mark.parametrize("gen", [1, 2, 3]) +@pytest.mark.parametrize("side_effect", [DeviceConnectionError, FirmwareUnsupported]) async def test_device_connection_error( - hass: HomeAssistant, gen, mock_block_device, mock_rpc_device, monkeypatch + hass: HomeAssistant, + gen, + side_effect, + mock_block_device, + mock_rpc_device, + monkeypatch, ) -> None: """Test device connection error.""" monkeypatch.setattr( - mock_block_device, "initialize", AsyncMock(side_effect=DeviceConnectionError) + mock_block_device, "initialize", AsyncMock(side_effect=side_effect) ) monkeypatch.setattr( - mock_rpc_device, "initialize", AsyncMock(side_effect=DeviceConnectionError) + mock_rpc_device, "initialize", AsyncMock(side_effect=side_effect) ) entry = await init_integration(hass, gen)