Add scheduled envoy firmware checks to enphase_envoy coordinator (#136102)

* Add scheduled envoy firmware checks to enphase_envoy coordinator

* Set firmware scantime to 4 hours and split test in 2
This commit is contained in:
Arie Catsman 2025-01-20 23:58:10 +01:00 committed by GitHub
parent 11d44e608b
commit ba2c8646e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 135 additions and 3 deletions

View File

@ -79,6 +79,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) ->
"""Unload a config entry.""" """Unload a config entry."""
coordinator = entry.runtime_data coordinator = entry.runtime_data
coordinator.async_cancel_token_refresh() coordinator.async_cancel_token_refresh()
coordinator.async_cancel_firmware_refresh()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -25,6 +25,7 @@ SCAN_INTERVAL = timedelta(seconds=60)
TOKEN_REFRESH_CHECK_INTERVAL = timedelta(days=1) TOKEN_REFRESH_CHECK_INTERVAL = timedelta(days=1)
STALE_TOKEN_THRESHOLD = timedelta(days=30).total_seconds() STALE_TOKEN_THRESHOLD = timedelta(days=30).total_seconds()
NOTIFICATION_ID = "enphase_envoy_notification" NOTIFICATION_ID = "enphase_envoy_notification"
FIRMWARE_REFRESH_INTERVAL = timedelta(hours=4)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -50,6 +51,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._setup_complete = False self._setup_complete = False
self.envoy_firmware = "" self.envoy_firmware = ""
self._cancel_token_refresh: CALLBACK_TYPE | None = None self._cancel_token_refresh: CALLBACK_TYPE | None = None
self._cancel_firmware_refresh: CALLBACK_TYPE | None = None
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
@ -87,10 +89,48 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return return
self._async_update_saved_token() 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 @callback
def _async_mark_setup_complete(self) -> None: 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._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() self.async_cancel_token_refresh()
if not isinstance(self.envoy.auth, EnvoyTokenAuth): if not isinstance(self.envoy.auth, EnvoyTokenAuth):
return return
@ -204,3 +244,10 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if self._cancel_token_refresh: if self._cancel_token_refresh:
self._cancel_token_refresh() self._cancel_token_refresh()
self._cancel_token_refresh = None 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

View File

@ -1,6 +1,8 @@
"""Test Enphase Envoy runtime.""" """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 freezegun.api import FrozenDateTimeFactory
from jwt import encode from jwt import encode
@ -15,7 +17,10 @@ from homeassistant.components.enphase_envoy.const import (
OPTION_DISABLE_KEEP_ALIVE, OPTION_DISABLE_KEEP_ALIVE,
Platform, 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.config_entries import ConfigEntryState
from homeassistant.const import ( from homeassistant.const import (
CONF_HOST, CONF_HOST,
@ -377,3 +382,82 @@ async def test_option_change_reload(
OPTION_DIAGNOSTICS_INCLUDE_FIXTURES: True, OPTION_DIAGNOSTICS_INCLUDE_FIXTURES: True,
OPTION_DISABLE_KEEP_ALIVE: False, 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