From 7184543f121160bcb9f29b50ceb52e3b508e9335 Mon Sep 17 00:00:00 2001 From: Collin Fair Date: Tue, 30 Apr 2024 01:18:09 -0600 Subject: [PATCH] Fix stale prayer times from `islamic-prayer-times` (#115683) --- .../islamic_prayer_times/coordinator.py | 83 +++++++++---------- .../islamic_prayer_times/__init__.py | 23 ++++- .../islamic_prayer_times/test_init.py | 49 ++++++++++- .../islamic_prayer_times/test_sensor.py | 31 +++++-- 4 files changed, 131 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py index 2785f69534c..7005bee3585 100644 --- a/homeassistant/components/islamic_prayer_times/coordinator.py +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta import logging from typing import Any, cast @@ -70,8 +70,8 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim """Return the school.""" return self.config_entry.options.get(CONF_SCHOOL, DEFAULT_SCHOOL) - def get_new_prayer_times(self) -> dict[str, Any]: - """Fetch prayer times for today.""" + def get_new_prayer_times(self, for_date: date) -> dict[str, Any]: + """Fetch prayer times for the specified date.""" calc = PrayerTimesCalculator( latitude=self.latitude, longitude=self.longitude, @@ -79,7 +79,7 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim latitudeAdjustmentMethod=self.lat_adj_method, midnightMode=self.midnight_mode, school=self.school, - date=str(dt_util.now().date()), + date=str(for_date), iso8601=True, ) return cast(dict[str, Any], calc.fetch_prayer_times()) @@ -88,51 +88,18 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim def async_schedule_future_update(self, midnight_dt: datetime) -> None: """Schedule future update for sensors. - Midnight is a calculated time. The specifics of the calculation - depends on the method of the prayer time calculation. This calculated - midnight is the time at which the time to pray the Isha prayers have - expired. + The least surprising behaviour is to load the next day's prayer times only + after the current day's prayers are complete. We will take the fiqhi opinion + that Isha should be prayed before Islamic midnight (which may be before or after 12:00 midnight), + and thus we will switch to the next day's timings at Islamic midnight. - Calculated Midnight: The Islamic midnight. - Traditional Midnight: 12:00AM - - Update logic for prayer times: - - If the Calculated Midnight is before the traditional midnight then wait - until the traditional midnight to run the update. This way the day - will have changed over and we don't need to do any fancy calculations. - - If the Calculated Midnight is after the traditional midnight, then wait - until after the calculated Midnight. We don't want to update the prayer - times too early or else the timings might be incorrect. - - Example: - calculated midnight = 11:23PM (before traditional midnight) - Update time: 12:00AM - - calculated midnight = 1:35AM (after traditional midnight) - update time: 1:36AM. + The +1s is to ensure that any automations predicated on the arrival of Islamic midnight will run. """ _LOGGER.debug("Scheduling next update for Islamic prayer times") - now = dt_util.utcnow() - - if now > midnight_dt: - next_update_at = midnight_dt + timedelta(days=1, minutes=1) - _LOGGER.debug( - "Midnight is after the day changes so schedule update for after Midnight the next day" - ) - else: - _LOGGER.debug( - "Midnight is before the day changes so schedule update for the next start of day" - ) - next_update_at = dt_util.start_of_local_day(now + timedelta(days=1)) - - _LOGGER.debug("Next update scheduled for: %s", next_update_at) - self.event_unsub = async_track_point_in_time( - self.hass, self.async_request_update, next_update_at + self.hass, self.async_request_update, midnight_dt + timedelta(seconds=1) ) async def async_request_update(self, _: datetime) -> None: @@ -140,8 +107,34 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim await self.async_request_refresh() async def _async_update_data(self) -> dict[str, datetime]: - """Update sensors with new prayer times.""" - prayer_times = self.get_new_prayer_times() + """Update sensors with new prayer times. + + Prayer time calculations "roll over" at 12:00 midnight - but this does not mean that all prayers + occur within that Gregorian calendar day. For instance Jasper, Alta. sees Isha occur after 00:00 in the summer. + It is similarly possible (albeit less likely) that Fajr occurs before 00:00. + + As such, to ensure that no prayer times are "unreachable" (e.g. we always see the Isha timestamp pass before loading the next day's times), + we calculate 3 days' worth of times (-1, 0, +1 days) and select the appropriate set based on Islamic midnight. + + The calculation is inexpensive, so there is no need to cache it. + """ + + # Zero out the us component to maintain consistent rollover at T+1s + now = dt_util.now().replace(microsecond=0) + yesterday_times = self.get_new_prayer_times((now - timedelta(days=1)).date()) + today_times = self.get_new_prayer_times(now.date()) + tomorrow_times = self.get_new_prayer_times((now + timedelta(days=1)).date()) + + if ( + yesterday_midnight := dt_util.parse_datetime(yesterday_times["Midnight"]) + ) and now <= yesterday_midnight: + prayer_times = yesterday_times + elif ( + tomorrow_midnight := dt_util.parse_datetime(today_times["Midnight"]) + ) and now > tomorrow_midnight: + prayer_times = tomorrow_times + else: + prayer_times = today_times # introduced in prayer-times-calculator 0.0.8 prayer_times.pop("date", None) diff --git a/tests/components/islamic_prayer_times/__init__.py b/tests/components/islamic_prayer_times/__init__.py index 1e6d6815921..522006b0847 100644 --- a/tests/components/islamic_prayer_times/__init__.py +++ b/tests/components/islamic_prayer_times/__init__.py @@ -12,6 +12,17 @@ MOCK_USER_INPUT = { MOCK_CONFIG = {CONF_LATITUDE: 12.34, CONF_LONGITUDE: 23.45} + +PRAYER_TIMES_YESTERDAY = { + "Fajr": "2019-12-31T06:09:00+00:00", + "Sunrise": "2019-12-31T07:24:00+00:00", + "Dhuhr": "2019-12-31T12:29:00+00:00", + "Asr": "2019-12-31T15:31:00+00:00", + "Maghrib": "2019-12-31T17:34:00+00:00", + "Isha": "2019-12-31T18:52:00+00:00", + "Midnight": "2020-01-01T00:44:00+00:00", +} + PRAYER_TIMES = { "Fajr": "2020-01-01T06:10:00+00:00", "Sunrise": "2020-01-01T07:25:00+00:00", @@ -19,7 +30,17 @@ PRAYER_TIMES = { "Asr": "2020-01-01T15:32:00+00:00", "Maghrib": "2020-01-01T17:35:00+00:00", "Isha": "2020-01-01T18:53:00+00:00", - "Midnight": "2020-01-01T00:45:00+00:00", + "Midnight": "2020-01-02T00:45:00+00:00", +} + +PRAYER_TIMES_TOMORROW = { + "Fajr": "2020-01-02T06:11:00+00:00", + "Sunrise": "2020-01-02T07:26:00+00:00", + "Dhuhr": "2020-01-02T12:31:00+00:00", + "Asr": "2020-01-02T15:33:00+00:00", + "Maghrib": "2020-01-02T17:36:00+00:00", + "Isha": "2020-01-02T18:54:00+00:00", + "Midnight": "2020-01-03T00:46:00+00:00", } NOW = datetime(2020, 1, 1, 00, 00, 0, tzinfo=dt_util.UTC) diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index c5d4933e24a..2a2597ef0ce 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -1,5 +1,6 @@ """Tests for Islamic Prayer Times init.""" +from datetime import timedelta from unittest.mock import patch from freezegun import freeze_time @@ -12,10 +13,11 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +import homeassistant.util.dt as dt_util from . import NOW, PRAYER_TIMES -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture(autouse=True) @@ -76,13 +78,16 @@ async def test_options_listener(hass: HomeAssistant) -> None: ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert mock_fetch_prayer_times.call_count == 1 + # Each scheduling run calls this 3 times (yesterday, today, tomorrow) + assert mock_fetch_prayer_times.call_count == 3 + mock_fetch_prayer_times.reset_mock() hass.config_entries.async_update_entry( entry, options={CONF_CALC_METHOD: "makkah"} ) await hass.async_block_till_done() - assert mock_fetch_prayer_times.call_count == 2 + # Each scheduling run calls this 3 times (yesterday, today, tomorrow) + assert mock_fetch_prayer_times.call_count == 3 @pytest.mark.parametrize( @@ -155,3 +160,41 @@ async def test_migration_from_1_1_to_1_2(hass: HomeAssistant) -> None: CONF_LONGITUDE: hass.config.longitude, } assert entry.minor_version == 2 + + +async def test_update_scheduling(hass: HomeAssistant) -> None: + """Test that integration schedules update immediately after Islamic midnight.""" + entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={}) + entry.add_to_hass(hass) + + with ( + patch( + "prayer_times_calculator_offline.PrayerTimesCalculator.fetch_prayer_times", + return_value=PRAYER_TIMES, + ), + freeze_time(NOW), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + with patch( + "prayer_times_calculator_offline.PrayerTimesCalculator.fetch_prayer_times", + return_value=PRAYER_TIMES, + ) as mock_fetch_prayer_times: + midnight_time = dt_util.parse_datetime(PRAYER_TIMES["Midnight"]) + assert midnight_time + with freeze_time(midnight_time): + async_fire_time_changed(hass, midnight_time) + await hass.async_block_till_done() + + mock_fetch_prayer_times.assert_not_called() + + midnight_time += timedelta(seconds=1) + with freeze_time(midnight_time): + async_fire_time_changed(hass, midnight_time) + await hass.async_block_till_done() + + # Each scheduling run calls this 3 times (yesterday, today, tomorrow) + assert mock_fetch_prayer_times.call_count == 3 diff --git a/tests/components/islamic_prayer_times/test_sensor.py b/tests/components/islamic_prayer_times/test_sensor.py index 1f8d28dfb6f..7bd1a1192ad 100644 --- a/tests/components/islamic_prayer_times/test_sensor.py +++ b/tests/components/islamic_prayer_times/test_sensor.py @@ -1,5 +1,6 @@ """The tests for the Islamic prayer times sensor platform.""" +from datetime import timedelta from unittest.mock import patch from freezegun import freeze_time @@ -8,7 +9,7 @@ import pytest from homeassistant.components.islamic_prayer_times.const import DOMAIN from homeassistant.core import HomeAssistant -from . import NOW, PRAYER_TIMES +from . import NOW, PRAYER_TIMES, PRAYER_TIMES_TOMORROW, PRAYER_TIMES_YESTERDAY from tests.common import MockConfigEntry @@ -31,20 +32,38 @@ def set_utc(hass: HomeAssistant) -> None: ("Midnight", "sensor.islamic_prayer_times_midnight_time"), ], ) +# In our example data, Islamic midnight occurs at 00:44 (yesterday's times, occurs today) and 00:45 (today's times, occurs tomorrow), +# hence we check that the times roll over at exactly the desired minute +@pytest.mark.parametrize( + ("offset", "prayer_times"), + [ + (timedelta(days=-1), PRAYER_TIMES_YESTERDAY), + (timedelta(minutes=44), PRAYER_TIMES_YESTERDAY), + (timedelta(minutes=44, seconds=1), PRAYER_TIMES), # Rolls over at 00:44 + 1 sec + (timedelta(days=1, minutes=45), PRAYER_TIMES), + ( + timedelta(days=1, minutes=45, seconds=1), # Rolls over at 00:45 + 1 sec + PRAYER_TIMES_TOMORROW, + ), + ], +) async def test_islamic_prayer_times_sensors( - hass: HomeAssistant, key: str, sensor_name: str + hass: HomeAssistant, + key: str, + sensor_name: str, + offset: timedelta, + prayer_times: dict[str, str], ) -> None: """Test minimum Islamic prayer times configuration.""" entry = MockConfigEntry(domain=DOMAIN, data={}) entry.add_to_hass(hass) - with ( patch( "prayer_times_calculator_offline.PrayerTimesCalculator.fetch_prayer_times", - return_value=PRAYER_TIMES, + side_effect=(PRAYER_TIMES_YESTERDAY, PRAYER_TIMES, PRAYER_TIMES_TOMORROW), ), - freeze_time(NOW), + freeze_time(NOW + offset), ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(sensor_name).state == PRAYER_TIMES[key] + assert hass.states.get(sensor_name).state == prayer_times[key]