diff --git a/homeassistant/components/sensor/islamic_prayer_times.py b/homeassistant/components/sensor/islamic_prayer_times.py new file mode 100644 index 00000000000..a1ea5212461 --- /dev/null +++ b/homeassistant/components/sensor/islamic_prayer_times.py @@ -0,0 +1,221 @@ +""" +Platform to retrieve Islamic prayer times information for Home Assistant. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.islamic_prayer_times/ +""" +import logging +from datetime import datetime, timedelta +import voluptuous as vol +from homeassistant.components.sensor import PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +import homeassistant.util.dt as dt_util +from homeassistant.helpers.event import async_track_point_in_time +from homeassistant.const import DEVICE_CLASS_TIMESTAMP + +REQUIREMENTS = ['prayer_times_calculator==0.0.3'] + +_LOGGER = logging.getLogger(__name__) + +PRAYER_TIMES_ICON = 'mdi:calendar-clock' + +SENSOR_TYPES = ['fajr', 'sunrise', 'dhuhr', 'asr', 'maghrib', 'isha', + 'midnight'] + +CONF_CALC_METHOD = 'calculation_method' +CONF_SENSORS = 'sensors' + +CALC_METHODS = ['karachi', 'isna', 'mwl', 'makkah'] +DEFAULT_CALC_METHOD = 'isna' +DEFAULT_SENSORS = ['fajr', 'dhuhr', 'asr', 'maghrib', 'isha'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_CALC_METHOD, default=DEFAULT_CALC_METHOD): vol.In( + CALC_METHODS), + vol.Optional(CONF_SENSORS, default=DEFAULT_SENSORS): + vol.All(cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_TYPES)]), +}) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up the Islamic prayer times sensor platform.""" + latitude = hass.config.latitude + longitude = hass.config.longitude + calc_method = config.get(CONF_CALC_METHOD) + + if None in (latitude, longitude): + _LOGGER.error("Latitude or longitude not set in Home Assistant config") + return + + prayer_times_data = IslamicPrayerTimesData(latitude, + longitude, + calc_method) + + prayer_times = prayer_times_data.get_new_prayer_times() + + sensors = [] + for sensor_type in config[CONF_SENSORS]: + sensors.append(IslamicPrayerTimeSensor(sensor_type, prayer_times_data)) + + async_add_entities(sensors, True) + + # schedule the next update for the sensors + await schedule_future_update(hass, sensors, prayer_times['Midnight'], + prayer_times_data) + + +async def schedule_future_update(hass, sensors, midnight_time, + prayer_times_data): + """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. + + 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. + """ + _LOGGER.debug("Scheduling next update for Islamic prayer times") + + now = dt_util.as_local(dt_util.now()) + today = now.date() + + midnight_dt_str = '{}::{}'.format(str(today), midnight_time) + midnight_dt = datetime.strptime(midnight_dt_str, '%Y-%m-%d::%H:%M') + + if now > dt_util.as_local(midnight_dt): + _LOGGER.debug("Midnight is after day the changes so schedule update " + "for after Midnight the next day") + + next_update_at = midnight_dt + timedelta(days=1, minutes=1) + else: + _LOGGER.debug( + "Midnight is before the day changes so schedule update for the " + "next start of day") + + tomorrow = now + timedelta(days=1) + next_update_at = dt_util.start_of_local_day(tomorrow) + + _LOGGER.debug("Next update scheduled for: %s", str(next_update_at)) + + async_track_point_in_time(hass, + update_sensors(hass, sensors, prayer_times_data), + next_update_at) + + +async def update_sensors(hass, sensors, prayer_times_data): + """Update sensors with new prayer times.""" + # Update prayer times + prayer_times = prayer_times_data.get_new_prayer_times() + + _LOGGER.debug("New prayer times retrieved. Updating sensors.") + + # Update all prayer times sensors + for sensor in sensors: + sensor.async_schedule_update_ha_state(True) + + # Schedule next update + await schedule_future_update(hass, sensors, prayer_times['Midnight'], + prayer_times_data) + + +class IslamicPrayerTimesData: + """Data object for Islamic prayer times.""" + + def __init__(self, latitude, longitude, calc_method): + """Create object to hold data.""" + self.latitude = latitude + self.longitude = longitude + self.calc_method = calc_method + self.prayer_times_info = None + + def get_new_prayer_times(self): + """Fetch prayer times for today.""" + from prayer_times_calculator import PrayerTimesCalculator + + today = datetime.today().strftime('%Y-%m-%d') + + calc = PrayerTimesCalculator(latitude=self.latitude, + longitude=self.longitude, + calculation_method=self.calc_method, + date=str(today)) + + self.prayer_times_info = calc.fetch_prayer_times() + return self.prayer_times_info + + +class IslamicPrayerTimeSensor(Entity): + """Representation of an Islamic prayer time sensor.""" + + ENTITY_ID_FORMAT = 'sensor.islamic_prayer_time_{}' + + def __init__(self, sensor_type, prayer_times_data): + """Initialize the Islamic prayer time sensor.""" + self.sensor_type = sensor_type + self.entity_id = self.ENTITY_ID_FORMAT.format(self.sensor_type) + self.prayer_times_data = prayer_times_data + self._name = self.sensor_type.capitalize() + self._device_class = DEVICE_CLASS_TIMESTAMP + prayer_time = self.prayer_times_data.prayer_times_info[ + self._name] + pt_dt = self.get_prayer_time_as_dt(prayer_time) + self._state = pt_dt.isoformat() + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Icon to display in the front end.""" + return PRAYER_TIMES_ICON + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def should_poll(self): + """Disable polling.""" + return False + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @staticmethod + def get_prayer_time_as_dt(prayer_time): + """Create a datetime object for the respective prayer time.""" + today = datetime.today().strftime('%Y-%m-%d') + date_time_str = '{} {}'.format(str(today), prayer_time) + pt_dt = dt_util.parse_datetime(date_time_str) + return pt_dt + + async def async_update(self): + """Update the sensor.""" + prayer_time = self.prayer_times_data.prayer_times_info[self.name] + pt_dt = self.get_prayer_time_as_dt(prayer_time) + self._state = pt_dt.isoformat() diff --git a/requirements_all.txt b/requirements_all.txt index 0a2ad21d41c..9ef2e604cf4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -798,6 +798,9 @@ pocketcasts==0.1 # homeassistant.components.sensor.postnl postnl_api==1.0.2 +# homeassistant.components.sensor.islamic_prayer_times +prayer_times_calculator==0.0.3 + # homeassistant.components.sensor.prezzibenzina prezzibenzina-py==1.1.4 diff --git a/tests/components/sensor/test_islamic_prayer_times.py b/tests/components/sensor/test_islamic_prayer_times.py new file mode 100644 index 00000000000..2c132ac5a8f --- /dev/null +++ b/tests/components/sensor/test_islamic_prayer_times.py @@ -0,0 +1,164 @@ +"""The tests for the Islamic prayer times sensor platform.""" +from datetime import datetime, timedelta +from unittest.mock import patch +from homeassistant.setup import async_setup_component +from homeassistant.components.sensor.islamic_prayer_times import \ + IslamicPrayerTimesData +from tests.common import MockDependency +import homeassistant.util.dt as dt_util +from tests.common import async_fire_time_changed + +LATITUDE = 41 +LONGITUDE = -87 +CALC_METHOD = 'isna' +PRAYER_TIMES = {"Fajr": "06:10", "Sunrise": "07:25", "Dhuhr": "12:30", + "Asr": "15:32", "Maghrib": "17:35", "Isha": "18:53", + "Midnight": "00:45"} +ENTITY_ID_FORMAT = 'sensor.islamic_prayer_time_{}' + + +def get_prayer_time_as_dt(prayer_time): + """Create a datetime object for the respective prayer time.""" + today = datetime.today().strftime('%Y-%m-%d') + date_time_str = '{} {}'.format(str(today), prayer_time) + pt_dt = dt_util.parse_datetime(date_time_str) + return pt_dt + + +async def test_islamic_prayer_times_min_config(hass): + """Test minimum Islamic prayer times configuration.""" + min_config_sensors = ['fajr', 'dhuhr', 'asr', 'maghrib', 'isha'] + + with MockDependency('prayer_times_calculator') as mock_pt_calc: + mock_pt_calc.PrayerTimesCalculator.return_value.fetch_prayer_times \ + .return_value = PRAYER_TIMES + + config = { + 'sensor': { + 'platform': 'islamic_prayer_times' + } + } + assert await async_setup_component(hass, 'sensor', config) is True + + for sensor in min_config_sensors: + entity_id = ENTITY_ID_FORMAT.format(sensor) + entity_id_name = sensor.capitalize() + pt_dt = get_prayer_time_as_dt(PRAYER_TIMES[entity_id_name]) + state = hass.states.get(entity_id) + assert state.state == pt_dt.isoformat() + assert state.name == entity_id_name + + +async def test_islamic_prayer_times_multiple_sensors(hass): + """Test Islamic prayer times sensor with multiple sensors setup.""" + multiple_sensors = ['fajr', 'sunrise', 'dhuhr', 'asr', 'maghrib', 'isha', + 'midnight'] + + with MockDependency('prayer_times_calculator') as mock_pt_calc: + mock_pt_calc.PrayerTimesCalculator.return_value.fetch_prayer_times \ + .return_value = PRAYER_TIMES + + config = { + 'sensor': { + 'platform': 'islamic_prayer_times', + 'sensors': multiple_sensors + } + } + + assert await async_setup_component(hass, 'sensor', config) is True + + for sensor in multiple_sensors: + entity_id = ENTITY_ID_FORMAT.format(sensor) + entity_id_name = sensor.capitalize() + pt_dt = get_prayer_time_as_dt(PRAYER_TIMES[entity_id_name]) + state = hass.states.get(entity_id) + assert state.state == pt_dt.isoformat() + assert state.name == entity_id_name + + +async def test_islamic_prayer_times_with_calculation_method(hass): + """Test Islamic prayer times configuration with calculation method.""" + sensors = ['fajr', 'maghrib'] + + with MockDependency('prayer_times_calculator') as mock_pt_calc: + mock_pt_calc.PrayerTimesCalculator.return_value.fetch_prayer_times \ + .return_value = PRAYER_TIMES + + config = { + 'sensor': { + 'platform': 'islamic_prayer_times', + 'calculation_method': 'mwl', + 'sensors': sensors + } + } + + assert await async_setup_component(hass, 'sensor', config) is True + + for sensor in sensors: + entity_id = ENTITY_ID_FORMAT.format(sensor) + entity_id_name = sensor.capitalize() + pt_dt = get_prayer_time_as_dt(PRAYER_TIMES[entity_id_name]) + state = hass.states.get(entity_id) + assert state.state == pt_dt.isoformat() + assert state.name == entity_id_name + + +async def test_islamic_prayer_times_data_get_prayer_times(hass): + """Test Islamic prayer times data fetcher.""" + with MockDependency('prayer_times_calculator') as mock_pt_calc: + mock_pt_calc.PrayerTimesCalculator.return_value.fetch_prayer_times \ + .return_value = PRAYER_TIMES + + pt_data = IslamicPrayerTimesData(latitude=LATITUDE, + longitude=LONGITUDE, + calc_method=CALC_METHOD) + + assert pt_data.get_new_prayer_times() == PRAYER_TIMES + assert pt_data.prayer_times_info == PRAYER_TIMES + + +async def test_islamic_prayer_times_sensor_update(hass): + """Test Islamic prayer times sensor update.""" + new_prayer_times = {"Fajr": "06:10", + "Sunrise": "07:25", + "Dhuhr": "12:30", + "Asr": "15:32", + "Maghrib": "17:45", + "Isha": "18:53", + "Midnight": "00:45"} + + with MockDependency('prayer_times_calculator') as mock_pt_calc: + mock_pt_calc.PrayerTimesCalculator.return_value.fetch_prayer_times \ + .side_effect = [PRAYER_TIMES, new_prayer_times] + + config = { + 'sensor': { + 'platform': 'islamic_prayer_times', + 'sensors': ['maghrib'] + } + } + + assert await async_setup_component(hass, 'sensor', config) + + entity_id = 'sensor.islamic_prayer_time_maghrib' + pt_dt = get_prayer_time_as_dt(PRAYER_TIMES['Maghrib']) + state = hass.states.get(entity_id) + assert state.state == pt_dt.isoformat() + + midnight = PRAYER_TIMES['Midnight'] + now = dt_util.as_local(dt_util.now()) + today = now.date() + + midnight_dt_str = '{}::{}'.format(str(today), midnight) + midnight_dt = datetime.strptime(midnight_dt_str, '%Y-%m-%d::%H:%M') + future = midnight_dt + timedelta(days=1, minutes=1) + + with patch( + 'homeassistant.components.sensor.islamic_prayer_times' + '.dt_util.utcnow', return_value=future): + + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + pt_dt = get_prayer_time_as_dt(new_prayer_times['Maghrib']) + assert state.state == pt_dt.isoformat()