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."""
coordinator = entry.runtime_data
coordinator.async_cancel_token_refresh()
coordinator.async_cancel_firmware_refresh()
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)
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

View File

@ -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