Fix stale prayer times from islamic-prayer-times (#115683)

This commit is contained in:
Collin Fair 2024-04-30 01:18:09 -06:00 committed by GitHub
parent 822646749d
commit 7184543f12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 131 additions and 55 deletions

View file

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

View file

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

View file

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

View file

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