diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index cdbb7080674..ba4aedf5013 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -79,6 +79,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> """Unload a config entry.""" coordinator = entry.runtime_data coordinator.async_cancel_token_refresh() + coordinator.async_cancel_firmware_refresh() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index 67f43ca64a8..d92b998e731 100644 --- a/homeassistant/components/enphase_envoy/coordinator.py +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -25,6 +25,7 @@ SCAN_INTERVAL = timedelta(seconds=60) TOKEN_REFRESH_CHECK_INTERVAL = timedelta(days=1) STALE_TOKEN_THRESHOLD = timedelta(days=30).total_seconds() NOTIFICATION_ID = "enphase_envoy_notification" +FIRMWARE_REFRESH_INTERVAL = timedelta(hours=4) _LOGGER = logging.getLogger(__name__) @@ -50,6 +51,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._setup_complete = False self.envoy_firmware = "" self._cancel_token_refresh: CALLBACK_TYPE | None = None + self._cancel_firmware_refresh: CALLBACK_TYPE | None = None super().__init__( hass, _LOGGER, @@ -87,10 +89,48 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): return self._async_update_saved_token() + @callback + def _async_refresh_firmware(self, now: datetime.datetime) -> None: + """Proactively check for firmware changes in Envoy.""" + self.hass.async_create_background_task( + self._async_try_refresh_firmware(), "{name} firmware refresh" + ) + + async def _async_try_refresh_firmware(self) -> None: + """Check firmware in Envoy and reload config entry if changed.""" + # envoy.setup just reads firmware, serial and partnumber from /info + try: + await self.envoy.setup() + except EnvoyError as err: + # just try again next time + _LOGGER.debug("%s: Error reading firmware: %s", err, self.name) + return + if (current_firmware := self.envoy_firmware) and current_firmware != ( + new_firmware := self.envoy.firmware + ): + self.envoy_firmware = new_firmware + _LOGGER.warning( + "Envoy firmware changed from: %s to: %s, reloading config entry %s", + current_firmware, + new_firmware, + self.name, + ) + # reload the integration to get all established again + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.config_entry.entry_id) + ) + @callback def _async_mark_setup_complete(self) -> None: - """Mark setup as complete and setup token refresh if needed.""" + """Mark setup as complete and setup firmware checks and token refresh if needed.""" self._setup_complete = True + self.async_cancel_firmware_refresh() + self._cancel_firmware_refresh = async_track_time_interval( + self.hass, + self._async_refresh_firmware, + FIRMWARE_REFRESH_INTERVAL, + cancel_on_shutdown=True, + ) self.async_cancel_token_refresh() if not isinstance(self.envoy.auth, EnvoyTokenAuth): return @@ -204,3 +244,10 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): if self._cancel_token_refresh: self._cancel_token_refresh() self._cancel_token_refresh = None + + @callback + def async_cancel_firmware_refresh(self) -> None: + """Cancel firmware refresh.""" + if self._cancel_firmware_refresh: + self._cancel_firmware_refresh() + self._cancel_firmware_refresh = None diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py index 10cf65a298d..620bd654aca 100644 --- a/tests/components/enphase_envoy/test_init.py +++ b/tests/components/enphase_envoy/test_init.py @@ -1,6 +1,8 @@ """Test Enphase Envoy runtime.""" -from unittest.mock import AsyncMock, patch +from datetime import timedelta +import logging +from unittest.mock import AsyncMock, MagicMock, patch from freezegun.api import FrozenDateTimeFactory from jwt import encode @@ -15,7 +17,10 @@ from homeassistant.components.enphase_envoy.const import ( OPTION_DISABLE_KEEP_ALIVE, Platform, ) -from homeassistant.components.enphase_envoy.coordinator import SCAN_INTERVAL +from homeassistant.components.enphase_envoy.coordinator import ( + FIRMWARE_REFRESH_INTERVAL, + SCAN_INTERVAL, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_HOST, @@ -377,3 +382,82 @@ async def test_option_change_reload( OPTION_DIAGNOSTICS_INCLUDE_FIXTURES: True, OPTION_DISABLE_KEEP_ALIVE: False, } + + +def mock_envoy_setup(mock_envoy: AsyncMock): + """Mock envoy.setup.""" + mock_envoy.firmware = "9.9.9999" + + +@patch( + "homeassistant.components.enphase_envoy.coordinator.SCAN_INTERVAL", + timedelta(days=1), +) +@respx.mock +async def test_coordinator_firmware_refresh( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test coordinator scheduled firmware check.""" + await setup_integration(hass, config_entry) + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.LOADED + + # Move time to next firmware check moment + # SCAN_INTERVAL is patched to 1 day to disable it's firmware detection + mock_envoy.setup.reset_mock() + freezer.tick(FIRMWARE_REFRESH_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + mock_envoy.setup.assert_called_once_with() + mock_envoy.setup.reset_mock() + + envoy = config_entry.runtime_data.envoy + assert envoy.firmware == "7.6.175" + + caplog.set_level(logging.WARNING) + + with patch( + "homeassistant.components.enphase_envoy.Envoy.setup", + MagicMock(return_value=mock_envoy_setup(mock_envoy)), + ): + freezer.tick(FIRMWARE_REFRESH_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + "Envoy firmware changed from: 7.6.175 to: 9.9.9999, reloading config entry Envoy 1234" + in caplog.text + ) + envoy = config_entry.runtime_data.envoy + assert envoy.firmware == "9.9.9999" + + +@respx.mock +async def test_coordinator_firmware_refresh_with_envoy_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test coordinator scheduled firmware check.""" + await setup_integration(hass, config_entry) + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.LOADED + + caplog.set_level(logging.DEBUG) + logging.getLogger("homeassistant.components.enphase_envoy.coordinator").setLevel( + logging.DEBUG + ) + + mock_envoy.setup.side_effect = EnvoyError + freezer.tick(FIRMWARE_REFRESH_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert "Error reading firmware:" in caplog.text