diff --git a/custom_components/carbon_intensity/const.py b/custom_components/carbon_intensity/const.py index 74fc9eb..5242730 100644 --- a/custom_components/carbon_intensity/const.py +++ b/custom_components/carbon_intensity/const.py @@ -26,4 +26,5 @@ EVENT_CURRENT_DAY_RATES = "carbon_intensity_current_day_rates" EVENT_NEXT_DAY_RATES = "carbon_intensity_next_day_rates" +REFRESH_RATE_IN_MINUTES_RATES = 30 COORDINATOR_REFRESH_IN_SECONDS = 60 \ No newline at end of file diff --git a/custom_components/carbon_intensity/coordinators/__init__.py b/custom_components/carbon_intensity/coordinators/__init__.py index b0e57b0..d124720 100644 --- a/custom_components/carbon_intensity/coordinators/__init__.py +++ b/custom_components/carbon_intensity/coordinators/__init__.py @@ -1,8 +1,24 @@ +import logging from datetime import datetime, timedelta from typing import Callable, Any from homeassistant.util.dt import (as_utc) from ..utils.attributes import dict_to_typed_dict +from ..utils.requests import calculate_next_refresh + +_LOGGER = logging.getLogger(__name__) + +class BaseCoordinatorResult: + last_retrieved: datetime + next_refresh: datetime + request_attempts: int + refresh_rate_in_minutes: int + + def __init__(self, last_retrieved: datetime, request_attempts: int, refresh_rate_in_minutes: int): + self.last_retrieved = last_retrieved + self.request_attempts = request_attempts + self.next_refresh = calculate_next_refresh(last_retrieved, request_attempts, refresh_rate_in_minutes) + _LOGGER.debug(f'last_retrieved: {last_retrieved}; request_attempts: {request_attempts}; refresh_rate_in_minutes: {refresh_rate_in_minutes}; next_refresh: {self.next_refresh}') def raise_rate_events(now: datetime, rates: list, @@ -26,10 +42,10 @@ def raise_rate_events(now: datetime, else: current_rates.append(rate) - event_data = { "rates": current_rates } + event_data = dict_to_typed_dict({ "rates": current_rates }) event_data.update(additional_attributes) - fire_event(current_event_key, dict_to_typed_dict(event_data)) + fire_event(current_event_key, event_data) - event_data = { "rates": next_rates } + event_data = dict_to_typed_dict({ "rates": next_rates }) event_data.update(additional_attributes) - fire_event(next_event_key, dict_to_typed_dict(event_data)) \ No newline at end of file + fire_event(next_event_key, event_data) \ No newline at end of file diff --git a/custom_components/carbon_intensity/coordinators/rates.py b/custom_components/carbon_intensity/coordinators/rates.py index 41e81d0..bfffdde 100644 --- a/custom_components/carbon_intensity/coordinators/rates.py +++ b/custom_components/carbon_intensity/coordinators/rates.py @@ -15,19 +15,20 @@ DOMAIN, EVENT_CURRENT_DAY_RATES, EVENT_NEXT_DAY_RATES, + REFRESH_RATE_IN_MINUTES_RATES, ) from ..api_client import CarbonIntensityApiClient -from . import raise_rate_events +from . import BaseCoordinatorResult, raise_rate_events _LOGGER = logging.getLogger(__name__) -class RatesCoordinatorResult: +class RatesCoordinatorResult(BaseCoordinatorResult): last_retrieved: datetime rates: list - def __init__(self, last_retrieved: datetime, rates: list): - self.last_retrieved = last_retrieved + def __init__(self, last_retrieved: datetime, request_attempts: int, rates: list): + super().__init__(last_retrieved, request_attempts, REFRESH_RATE_IN_MINUTES_RATES) self.rates = rates async def async_refresh_rates_data( @@ -40,15 +41,30 @@ async def async_refresh_rates_data( period_from = as_utc(current.replace(hour=0, minute=0, second=0, microsecond=0)) new_rates: list = None - if ((current.minute % 30) == 0 or - existing_rates_result is None or - existing_rates_result.rates is None or - len(existing_rates_result.rates) < 1 or + if (existing_rates_result is None or + current >= existing_rates_result.next_refresh or existing_rates_result.rates[-1]["from"] < period_from): try: new_rates = await client.async_get_intensity_and_generation_rates(period_from, region) except: _LOGGER.debug(f'Failed to retrieve rates for {region}') + if (existing_rates_result is not None): + result = RatesCoordinatorResult( + existing_rates_result.last_retrieved, + existing_rates_result.request_attempts + 1, + existing_rates_result.rates + ) + _LOGGER.warning(f"Failed to retrieve new carbon intensity rates - using cached rates. Next attempt at {result.next_refresh}") + else: + # We want to force into our fallback mode + result = RatesCoordinatorResult( + current - timedelta(minutes=REFRESH_RATE_IN_MINUTES_RATES), + 2, + None + ) + _LOGGER.warning(f"Failed to retrieve new carbon intensity rates. Next attempt at {result.next_refresh}") + + return result if new_rates is not None: _LOGGER.debug(f'Rates retrieved for {region}') @@ -60,7 +76,7 @@ async def async_refresh_rates_data( EVENT_CURRENT_DAY_RATES, EVENT_NEXT_DAY_RATES) - return RatesCoordinatorResult(current, new_rates) + return RatesCoordinatorResult(current, 1, new_rates) return existing_rates_result diff --git a/custom_components/carbon_intensity/entities/current_rating.py b/custom_components/carbon_intensity/entities/current_rating.py index 5d39d93..ea9186d 100644 --- a/custom_components/carbon_intensity/entities/current_rating.py +++ b/custom_components/carbon_intensity/entities/current_rating.py @@ -1,6 +1,8 @@ import logging +from homeassistant.core import HomeAssistant, callback from homeassistant.util.dt import (utcnow) +from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.update_coordinator import ( CoordinatorEntity ) @@ -10,20 +12,22 @@ from ..entities import get_current_rate from ..utils import get_region_for_unique_id_from_id, get_region_from_id +from ..utils.attributes import dict_to_typed_dict _LOGGER = logging.getLogger(__name__) class CarbonIntensityCurrentRating(CoordinatorEntity, RestoreSensor): """Sensor for displaying the current rate.""" - def __init__(self, coordinator, region: str): + def __init__(self, hass: HomeAssistant, coordinator, region: str): """Init sensor.""" - # Pass coordinator to base class - super().__init__(coordinator) + CoordinatorEntity.__init__(self, coordinator) self._state = None self._region = region + self.entity_id = generate_entity_id("sensor.{}", self.unique_id, hass=hass) + @property def unique_id(self): """The id of the sensor.""" @@ -32,7 +36,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Carbon Intensity {get_region_from_id(self._region)} Current Rating" + return f"Current Rating ({get_region_from_id(self._region)})" @property def icon(self): @@ -52,6 +56,10 @@ def unit_of_measurement(self): @property def state(self): """The state of the sensor.""" + return self._state + + @callback + def _handle_coordinator_update(self) -> None: # Find the current rate. We only need to do this every half an hour now = utcnow() self._state = None @@ -68,8 +76,9 @@ def state(self): if current_rate is not None: self._state = current_rate["intensity_forecast"] - - return self._state + + self._attributes = dict_to_typed_dict(self._attributes) + super()._handle_coordinator_update() async def async_added_to_hass(self): """Call when entity about to be added to hass.""" @@ -81,8 +90,9 @@ async def async_added_to_hass(self): self._state = state.state self._attributes = {} for x in state.attributes.keys(): - if x != "all_rates": self._attributes[x] = state.attributes[x] + + self._attributes = dict_to_typed_dict(self._attributes) _LOGGER.debug(f'Restored CarbonIntensityCurrentRating state: {self._state}') \ No newline at end of file diff --git a/custom_components/carbon_intensity/entities/rates_current_day.py b/custom_components/carbon_intensity/entities/rates_current_day.py index 4e7458a..c844f33 100644 --- a/custom_components/carbon_intensity/entities/rates_current_day.py +++ b/custom_components/carbon_intensity/entities/rates_current_day.py @@ -5,6 +5,7 @@ from homeassistant.components.event import ( EventEntity, ) +from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.restore_state import RestoreEntity from ..const import EVENT_CURRENT_DAY_RATES @@ -28,6 +29,7 @@ def __init__(self, hass: HomeAssistant, region): self._last_updated = None self._attr_event_types = [EVENT_CURRENT_DAY_RATES] + self.entity_id = generate_entity_id("event.{}", self.unique_id, hass=hass) @property def unique_id(self): @@ -37,7 +39,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Carbon Intensity {get_region_from_id(self._region)} Current Day Rates" + return f"Current Day Rates ({get_region_from_id(self._region)})" @property def entity_registry_enabled_default(self) -> bool: diff --git a/custom_components/carbon_intensity/entities/rates_next_day.py b/custom_components/carbon_intensity/entities/rates_next_day.py index 166f4bb..4a88ef4 100644 --- a/custom_components/carbon_intensity/entities/rates_next_day.py +++ b/custom_components/carbon_intensity/entities/rates_next_day.py @@ -5,6 +5,7 @@ from homeassistant.components.event import ( EventEntity, ) +from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.restore_state import RestoreEntity from ..const import EVENT_NEXT_DAY_RATES @@ -28,6 +29,7 @@ def __init__(self, hass: HomeAssistant, region): self._last_updated = None self._attr_event_types = [EVENT_NEXT_DAY_RATES] + self.entity_id = generate_entity_id("event.{}", self.unique_id, hass=hass) @property def unique_id(self): @@ -37,7 +39,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Carbon Intensity {get_region_from_id(self._region)} Next Day Rates" + return f"Next Day Rates ({get_region_from_id(self._region)})" @property def entity_registry_enabled_default(self) -> bool: diff --git a/custom_components/carbon_intensity/sensor.py b/custom_components/carbon_intensity/sensor.py index a7d5407..b27b089 100644 --- a/custom_components/carbon_intensity/sensor.py +++ b/custom_components/carbon_intensity/sensor.py @@ -30,6 +30,6 @@ async def async_setup_default_sensors(hass, entry, async_add_entities): region = config[CONFIG_MAIN_REGION] - entities = [CarbonIntensityCurrentRating(rate_coordinator, region)] + entities = [CarbonIntensityCurrentRating(hass, rate_coordinator, region)] async_add_entities(entities, True) \ No newline at end of file diff --git a/custom_components/carbon_intensity/utils/requests.py b/custom_components/carbon_intensity/utils/requests.py new file mode 100644 index 0000000..c97ab79 --- /dev/null +++ b/custom_components/carbon_intensity/utils/requests.py @@ -0,0 +1,15 @@ +from datetime import datetime, timedelta + +def triangle_number(n): + sum = 0 + for i in range(1, n + 1): + sum += i * (i + 1) / 2 + return sum + +def calculate_next_refresh(current: datetime, request_attempts: int, refresh_rate_in_minutes: int): + next_rate = current + timedelta(minutes=refresh_rate_in_minutes) + if (request_attempts > 1): + i = request_attempts - 1 + target_minutes = i * (i + 1) / 2 + next_rate = next_rate + timedelta(minutes=target_minutes) + return next_rate \ No newline at end of file diff --git a/tests/unit/utils/test_calculate_next_refresh.py b/tests/unit/utils/test_calculate_next_refresh.py new file mode 100644 index 0000000..e9b4896 --- /dev/null +++ b/tests/unit/utils/test_calculate_next_refresh.py @@ -0,0 +1,26 @@ +from datetime import datetime, timedelta +import pytest + +from custom_components.carbon_intensity.utils.requests import calculate_next_refresh + +@pytest.mark.asyncio +@pytest.mark.parametrize("request_attempts,expected_next_refresh",[ + (1, datetime.strptime("2023-07-14T10:35:01+01:00", "%Y-%m-%dT%H:%M:%S%z")), + (2, datetime.strptime("2023-07-14T10:36:01+01:00", "%Y-%m-%dT%H:%M:%S%z")), + (3, datetime.strptime("2023-07-14T10:38:01+01:00", "%Y-%m-%dT%H:%M:%S%z")), + (4, datetime.strptime("2023-07-14T10:41:01+01:00", "%Y-%m-%dT%H:%M:%S%z")), + (5, datetime.strptime("2023-07-14T10:45:01+01:00", "%Y-%m-%dT%H:%M:%S%z")), + (6, datetime.strptime("2023-07-14T10:50:01+01:00", "%Y-%m-%dT%H:%M:%S%z")) +]) +async def test_when_data_provided_then_expected_rate_is_returned(request_attempts, expected_next_refresh): + # Arrange + refresh_rate_in_minutes = 5 + request_datetime = datetime.strptime("2023-07-14T10:30:01+01:00", "%Y-%m-%dT%H:%M:%S%z") + current = request_datetime + timedelta(minutes=refresh_rate_in_minutes) + timedelta(minutes=request_attempts - 1) + + # Act + next_refresh = calculate_next_refresh(request_datetime, request_attempts, refresh_rate_in_minutes) + + # Assert + assert next_refresh == expected_next_refresh + assert current <= next_refresh \ No newline at end of file