diff --git a/homeassistant/components/fastdotcom/__init__.py b/homeassistant/components/fastdotcom/__init__.py index 2fe5b3ccafc..e872c3f501d 100644 --- a/homeassistant/components/fastdotcom/__init__.py +++ b/homeassistant/components/fastdotcom/__init__.py @@ -1,22 +1,18 @@ """Support for testing internet speed via Fast.com.""" from __future__ import annotations -from datetime import datetime, timedelta import logging -from typing import Any -from fastdotcom import fast_com import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import CoreState, Event, HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType -from .const import CONF_MANUAL, DATA_UPDATED, DEFAULT_INTERVAL, DOMAIN, PLATFORMS +from .const import CONF_MANUAL, DEFAULT_INTERVAL, DOMAIN, PLATFORMS +from .coordinator import FastdotcomDataUpdateCoordindator _LOGGER = logging.getLogger(__name__) @@ -48,21 +44,20 @@ async def async_setup_platform(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up the Fast.com component.""" - data = hass.data[DOMAIN] = SpeedtestData(hass) + """Set up Fast.com from a config entry.""" + coordinator = FastdotcomDataUpdateCoordindator(hass) - entry.async_on_unload( - async_track_time_interval(hass, data.update, timedelta(hours=DEFAULT_INTERVAL)) - ) - # Run an initial update to get a starting state - await data.update() + async def _request_refresh(event: Event) -> None: + """Request a refresh.""" + await coordinator.async_request_refresh() - async def update(service_call: ServiceCall | None = None) -> None: - """Service call to manually update the data.""" - await data.update() - - hass.services.async_register(DOMAIN, "speedtest", update) + if hass.state == CoreState.running: + await coordinator.async_config_entry_first_refresh() + else: + # Don't start the speedtest when HA is starting up + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _request_refresh) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups( entry, PLATFORMS, @@ -73,23 +68,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Fast.com config entry.""" + hass.services.async_remove(DOMAIN, "speedtest") if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data.pop(DOMAIN) + hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class SpeedtestData: - """Get the latest data from Fast.com.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the data object.""" - self.data: dict[str, Any] | None = None - self._hass = hass - - async def update(self, now: datetime | None = None) -> None: - """Get the latest data from fast.com.""" - _LOGGER.debug("Executing Fast.com speedtest") - fast_com_data = await self._hass.async_add_executor_job(fast_com) - self.data = {"download": fast_com_data} - _LOGGER.debug("Fast.com speedtest finished, with mbit/s: %s", fast_com_data) - dispatcher_send(self._hass, DATA_UPDATED) diff --git a/homeassistant/components/fastdotcom/coordinator.py b/homeassistant/components/fastdotcom/coordinator.py new file mode 100644 index 00000000000..692a85d2eda --- /dev/null +++ b/homeassistant/components/fastdotcom/coordinator.py @@ -0,0 +1,31 @@ +"""DataUpdateCoordinator for the Fast.com integration.""" +from __future__ import annotations + +from datetime import timedelta + +from fastdotcom import fast_com + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_INTERVAL, DOMAIN, LOGGER + + +class FastdotcomDataUpdateCoordindator(DataUpdateCoordinator[float]): + """Class to manage fetching Fast.com data API.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the coordinator for Fast.com.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=timedelta(hours=DEFAULT_INTERVAL), + ) + + async def _async_update_data(self) -> float: + """Run an executor job to retrieve Fast.com data.""" + try: + return await self.hass.async_add_executor_job(fast_com) + except Exception as exc: + raise UpdateFailed(f"Error communicating with Fast.com: {exc}") from exc diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index 939ab4a40e5..b82b20defb5 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -1,8 +1,6 @@ """Support for Fast.com internet speed testing sensor.""" from __future__ import annotations -from typing import Any - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -10,12 +8,12 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfDataRate -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DATA_UPDATED, DOMAIN +from .const import DOMAIN +from .coordinator import FastdotcomDataUpdateCoordindator async def async_setup_entry( @@ -24,11 +22,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fast.com sensor.""" - async_add_entities([SpeedtestSensor(entry.entry_id, hass.data[DOMAIN])]) + coordinator: FastdotcomDataUpdateCoordindator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([SpeedtestSensor(entry.entry_id, coordinator)]) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class SpeedtestSensor(RestoreEntity, SensorEntity): +class SpeedtestSensor( + CoordinatorEntity[FastdotcomDataUpdateCoordindator], SensorEntity +): """Implementation of a Fast.com sensor.""" _attr_name = "Fast.com Download" @@ -38,31 +38,16 @@ class SpeedtestSensor(RestoreEntity, SensorEntity): _attr_icon = "mdi:speedometer" _attr_should_poll = False - def __init__(self, entry_id: str, speedtest_data: dict[str, Any]) -> None: + def __init__( + self, entry_id: str, coordinator: FastdotcomDataUpdateCoordindator + ) -> None: """Initialize the sensor.""" - self._speedtest_data = speedtest_data + super().__init__(coordinator) self._attr_unique_id = entry_id - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - await super().async_added_to_hass() - - self.async_on_remove( - async_dispatcher_connect( - self.hass, DATA_UPDATED, self._schedule_immediate_update - ) - ) - - if not (state := await self.async_get_last_state()): - return - self._attr_native_value = state.state - - def update(self) -> None: - """Get the latest data and update the states.""" - if (data := self._speedtest_data.data) is None: # type: ignore[attr-defined] - return - self._attr_native_value = data["download"] - - @callback - def _schedule_immediate_update(self) -> None: - self.async_schedule_update_ha_state(True) + @property + def native_value( + self, + ) -> float: + """Return the state of the sensor.""" + return self.coordinator.data diff --git a/tests/components/fastdotcom/test_config_flow.py b/tests/components/fastdotcom/test_config_flow.py index 4314a7688d8..17e75935dae 100644 --- a/tests/components/fastdotcom/test_config_flow.py +++ b/tests/components/fastdotcom/test_config_flow.py @@ -57,10 +57,7 @@ async def test_single_instance_allowed( async def test_import_flow_success(hass: HomeAssistant) -> None: """Test import flow.""" - with patch( - "homeassistant.components.fastdotcom.__init__.SpeedtestData", - return_value={"download": "50"}, - ), patch("homeassistant.components.fastdotcom.sensor.SpeedtestSensor"): + with patch("homeassistant.components.fastdotcom.coordinator.fast_com"): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, diff --git a/tests/components/fastdotcom/test_coordinator.py b/tests/components/fastdotcom/test_coordinator.py new file mode 100644 index 00000000000..254301950fb --- /dev/null +++ b/tests/components/fastdotcom/test_coordinator.py @@ -0,0 +1,54 @@ +"""Test the FastdotcomDataUpdateCoordindator.""" +from datetime import timedelta +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.fastdotcom.const import DOMAIN +from homeassistant.components.fastdotcom.coordinator import DEFAULT_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_fastdotcom_data_update_coordinator( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test the update coordinator.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="UNIQUE_TEST_ID", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.fast_com_download") + assert state is not None + assert state.state == "5.0" + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=10.0 + ): + freezer.tick(timedelta(hours=DEFAULT_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.fast_com_download") + assert state.state == "10.0" + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", + side_effect=Exception("Test error"), + ): + freezer.tick(timedelta(hours=DEFAULT_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.fast_com_download") + assert state.state is STATE_UNAVAILABLE