diff --git a/homeassistant/components/binary_sensor/workday.py b/homeassistant/components/binary_sensor/workday.py new file mode 100644 index 00000000000..1b53738a25f --- /dev/null +++ b/homeassistant/components/binary_sensor/workday.py @@ -0,0 +1,157 @@ +"""Sensor to indicate whether the current day is a workday.""" +import asyncio +import logging +import datetime + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (STATE_ON, STATE_OFF, STATE_UNKNOWN, + CONF_NAME, WEEKDAYS) +import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['holidays==0.8.1'] + +# List of all countries currently supported by holidays +# There seems to be no way to get the list out at runtime +ALL_COUNTRIES = ['Australia', 'AU', 'Austria', 'AT', 'Canada', 'CA', + 'Colombia', 'CO', 'Czech', 'CZ', 'Denmark', 'DK', 'England', + 'EuropeanCentralBank', 'ECB', 'TAR', 'Germany', 'DE', + 'Ireland', 'Isle of Man', 'Mexico', 'MX', 'Netherlands', 'NL', + 'NewZealand', 'NZ', 'Northern Ireland', 'Norway', 'NO', + 'Portugal', 'PT', 'PortugalExt', 'PTE', 'Scotland', 'Spain', + 'ES', 'UnitedKingdom', 'UK', 'UnitedStates', 'US', 'Wales'] +CONF_COUNTRY = 'country' +CONF_PROVINCE = 'province' +CONF_WORKDAYS = 'workdays' +# By default, Monday - Friday are workdays +DEFAULT_WORKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri'] +CONF_EXCLUDES = 'excludes' +# By default, public holidays, Saturdays and Sundays are excluded from workdays +DEFAULT_EXCLUDES = ['sat', 'sun', 'holiday'] +DEFAULT_NAME = 'Workday Sensor' +ALLOWED_DAYS = WEEKDAYS + ['holiday'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_COUNTRY): vol.In(ALL_COUNTRIES), + vol.Optional(CONF_PROVINCE, default=None): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_WORKDAYS, default=DEFAULT_WORKDAYS): + vol.All(cv.ensure_list, [vol.In(ALLOWED_DAYS)]), + vol.Optional(CONF_EXCLUDES, default=DEFAULT_EXCLUDES): + vol.All(cv.ensure_list, [vol.In(ALLOWED_DAYS)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Workday sensor.""" + import holidays + + # Get the Sensor name from the config + sensor_name = config.get(CONF_NAME) + + # Get the country code from the config + country = config.get(CONF_COUNTRY) + + # Get the province from the config + province = config.get(CONF_PROVINCE) + + # Get the list of workdays from the config + workdays = config.get(CONF_WORKDAYS) + + # Get the list of excludes from the config + excludes = config.get(CONF_EXCLUDES) + + # Instantiate the holidays module for the current year + year = datetime.datetime.now().year + obj_holidays = getattr(holidays, country)(years=year) + + # Also apply the provience, if available for the configured country + if province: + if province not in obj_holidays.PROVINCES: + _LOGGER.error('There is no province/state %s in country %s', + province, country) + return False + else: + year = datetime.datetime.now().year + obj_holidays = getattr(holidays, country)(prov=province, + years=year) + + # Output found public holidays via the debug channel + _LOGGER.debug("Found the following holidays for your configuration:") + for date, name in sorted(obj_holidays.items()): + _LOGGER.debug("%s %s", date, name) + + # Add ourselves as device + add_devices([IsWorkdaySensor(obj_holidays, workdays, + excludes, sensor_name)], True) + + +def day_to_string(day): + """Convert day index 0 - 7 to string.""" + try: + return ALLOWED_DAYS[day] + except IndexError: + return None + + +class IsWorkdaySensor(Entity): + """Implementation of a Workday sensor.""" + + def __init__(self, obj_holidays, workdays, excludes, name): + """Initialize the Workday sensor.""" + self._name = name + self._obj_holidays = obj_holidays + self._workdays = workdays + self._excludes = excludes + self._state = STATE_UNKNOWN + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._state + + def is_include(self, day, now): + """Check if given day is in the includes list.""" + # Check includes + if day in self._workdays: + return True + elif 'holiday' in self._workdays and now in self._obj_holidays: + return True + + return False + + def is_exclude(self, day, now): + """Check if given day is in the excludes list.""" + # Check excludes + if day in self._excludes: + return True + elif 'holiday' in self._excludes and now in self._obj_holidays: + return True + + return False + + @asyncio.coroutine + def async_update(self): + """Get date and look whether it is a holiday.""" + # Default is no workday + self._state = STATE_OFF + + # Get iso day of the week (1 = Monday, 7 = Sunday) + day = datetime.datetime.today().isoweekday() - 1 + day_of_week = day_to_string(day) + + if self.is_include(day_of_week, dt_util.now()): + self._state = STATE_ON + + if self.is_exclude(day_of_week, dt_util.now()): + self._state = STATE_OFF diff --git a/requirements_all.txt b/requirements_all.txt index 277ddb31a9f..a1bf0773748 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -215,6 +215,9 @@ heatmiserV3==0.9.1 # homeassistant.components.switch.hikvisioncam hikvision==0.4 +# homeassistant.components.binary_sensor.workday +holidays==0.8.1 + # homeassistant.components.sensor.dht # http://github.com/adafruit/Adafruit_Python_DHT/archive/da8cddf7fb629c1ef4f046ca44f42523c9cf2d11.zip#Adafruit_DHT==1.3.0 diff --git a/requirements_test.txt b/requirements_test.txt index 07f8e192839..2d8ebe6a238 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -17,3 +17,4 @@ requests_mock>=1.0 mock-open>=1.3.1 flake8-docstrings==1.0.2 asynctest>=0.8.0 +freezegun>=0.3.8 diff --git a/tests/components/binary_sensor/test_workday.py b/tests/components/binary_sensor/test_workday.py new file mode 100644 index 00000000000..814606613f5 --- /dev/null +++ b/tests/components/binary_sensor/test_workday.py @@ -0,0 +1,153 @@ +"""Tests the HASS workday binary sensor.""" +from freezegun import freeze_time +from homeassistant.components.binary_sensor.workday import day_to_string +from homeassistant.setup import setup_component + +from tests.common import ( + get_test_home_assistant, assert_setup_component) + + +class TestWorkdaySetup(object): + """Test class for workday sensor.""" + + def setup_method(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + # Set valid default config for test + self.config_province = { + 'binary_sensor': { + 'platform': 'workday', + 'country': 'DE', + 'province': 'BW' + }, + } + + self.config_noprovince = { + 'binary_sensor': { + 'platform': 'workday', + 'country': 'DE', + }, + } + + self.config_invalidprovince = { + 'binary_sensor': { + 'platform': 'workday', + 'country': 'DE', + 'province': 'invalid' + }, + } + + self.config_includeholiday = { + 'binary_sensor': { + 'platform': 'workday', + 'country': 'DE', + 'province': 'BW', + 'workdays': ['holiday', 'mon', 'tue', 'wed', 'thu', 'fri'], + 'excludes': ['sat', 'sun'] + }, + } + + def teardown_method(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_setup_component_province(self): + """Setup workday component.""" + with assert_setup_component(1, 'binary_sensor'): + setup_component(self.hass, 'binary_sensor', self.config_province) + + assert self.hass.states.get('binary_sensor.workday_sensor') is not None + + # Freeze time to a workday + @freeze_time("Mar 15th, 2017") + def test_workday_province(self): + """Test if workdays are reported correctly.""" + with assert_setup_component(1, 'binary_sensor'): + setup_component(self.hass, 'binary_sensor', self.config_province) + + assert self.hass.states.get('binary_sensor.workday_sensor') is not None + + self.hass.start() + + entity = self.hass.states.get('binary_sensor.workday_sensor') + assert entity.state == 'on' + + # Freeze time to a weekend + @freeze_time("Mar 12th, 2017") + def test_weekend_province(self): + """Test if weekends are reported correctly.""" + with assert_setup_component(1, 'binary_sensor'): + setup_component(self.hass, 'binary_sensor', self.config_province) + + assert self.hass.states.get('binary_sensor.workday_sensor') is not None + + self.hass.start() + + entity = self.hass.states.get('binary_sensor.workday_sensor') + assert entity.state == 'off' + + # Freeze time to a public holiday in province BW + @freeze_time("Jan 6th, 2017") + def test_public_holiday_province(self): + """Test if public holidays are reported correctly.""" + with assert_setup_component(1, 'binary_sensor'): + setup_component(self.hass, 'binary_sensor', self.config_province) + + assert self.hass.states.get('binary_sensor.workday_sensor') is not None + + self.hass.start() + + entity = self.hass.states.get('binary_sensor.workday_sensor') + assert entity.state == 'off' + + def test_setup_component_noprovince(self): + """Setup workday component.""" + with assert_setup_component(1, 'binary_sensor'): + setup_component(self.hass, 'binary_sensor', self.config_noprovince) + + assert self.hass.states.get('binary_sensor.workday_sensor') is not None + + # Freeze time to a public holiday in province BW + @freeze_time("Jan 6th, 2017") + def test_public_holiday_noprovince(self): + """Test if public holidays are reported correctly.""" + with assert_setup_component(1, 'binary_sensor'): + setup_component(self.hass, 'binary_sensor', self.config_noprovince) + + assert self.hass.states.get('binary_sensor.workday_sensor') is not None + + self.hass.start() + + entity = self.hass.states.get('binary_sensor.workday_sensor') + assert entity.state == 'on' + + def test_setup_component_invalidprovince(self): + """Setup workday component.""" + with assert_setup_component(1, 'binary_sensor'): + setup_component(self.hass, 'binary_sensor', + self.config_invalidprovince) + + assert self.hass.states.get('binary_sensor.workday_sensor') is None + + # Freeze time to a public holiday in province BW + @freeze_time("Jan 6th, 2017") + def test_public_holiday_includeholiday(self): + """Test if public holidays are reported correctly.""" + with assert_setup_component(1, 'binary_sensor'): + setup_component(self.hass, 'binary_sensor', + self.config_includeholiday) + + assert self.hass.states.get('binary_sensor.workday_sensor') is not None + + self.hass.start() + + entity = self.hass.states.get('binary_sensor.workday_sensor') + assert entity.state == 'on' + + def test_day_to_string(self): + """Test if day_to_string is behaving correctly.""" + assert day_to_string(0) == 'mon' + assert day_to_string(1) == 'tue' + assert day_to_string(7) == 'holiday' + assert day_to_string(8) is None