diff --git a/.coveragerc b/.coveragerc index 8cb2b06683b..e63ac6daec9 100644 --- a/.coveragerc +++ b/.coveragerc @@ -81,8 +81,8 @@ omit = homeassistant/components/dominos.py - homeassistant/components/doorbird.py - homeassistant/components/*/doorbird.py + homeassistant/components/doorbird.py + homeassistant/components/*/doorbird.py homeassistant/components/dweet.py homeassistant/components/*/dweet.py diff --git a/homeassistant/components/doorbird.py b/homeassistant/components/doorbird.py index ab929eb90bb..578855011cc 100644 --- a/homeassistant/components/doorbird.py +++ b/homeassistant/components/doorbird.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_HOST, CONF_USERNAME, \ import homeassistant.helpers.config_validation as cv from homeassistant.util import slugify -REQUIREMENTS = ['DoorBirdPy==0.1.3'] +REQUIREMENTS = ['doorbirdpy==2.0.4'] _LOGGER = logging.getLogger(__name__) @@ -22,22 +22,31 @@ DOMAIN = 'doorbird' API_URL = '/api/{}'.format(DOMAIN) -CONF_DOORBELL_EVENTS = 'doorbell_events' CONF_CUSTOM_URL = 'hass_url_override' +CONF_DOORBELL_EVENTS = 'doorbell_events' +CONF_DOORBELL_NUMS = 'doorbell_numbers' +CONF_MOTION_EVENTS = 'motion_events' +CONF_TOKEN = 'token' -DOORBELL_EVENT = 'doorbell' -MOTION_EVENT = 'motionsensor' - -# Sensor types: Name, device_class, event SENSOR_TYPES = { - 'doorbell': ['Button', 'occupancy', DOORBELL_EVENT], - 'motion': ['Motion', 'motion', MOTION_EVENT], + 'doorbell': { + 'name': 'Button', + 'device_class': 'occupancy', + }, + 'motion': { + 'name': 'Motion', + 'device_class': 'motion', + }, } +RESET_DEVICE_FAVORITES = 'doorbird_reset_favorites' + DEVICE_SCHEMA = vol.Schema({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_DOORBELL_NUMS, default=[1]): vol.All( + cv.ensure_list, [cv.positive_int]), vol.Optional(CONF_CUSTOM_URL): cv.string, vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): @@ -46,6 +55,7 @@ DEVICE_SCHEMA = vol.Schema({ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ + vol.Required(CONF_TOKEN): cv.string, vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, [DEVICE_SCHEMA]) }), }, extra=vol.ALLOW_EXTRA) @@ -55,8 +65,13 @@ def setup(hass, config): """Set up the DoorBird component.""" from doorbirdpy import DoorBird + token = config[DOMAIN].get(CONF_TOKEN) + # Provide an endpoint for the doorstations to call to trigger events - hass.http.register_view(DoorbirdRequestView()) + hass.http.register_view(DoorBirdRequestView(token)) + + # Provide an endpoint for the user to call to clear device changes + hass.http.register_view(DoorBirdCleanupView(token)) doorstations = [] @@ -64,6 +79,7 @@ def setup(hass, config): device_ip = doorstation_config.get(CONF_HOST) username = doorstation_config.get(CONF_USERNAME) password = doorstation_config.get(CONF_PASSWORD) + doorbell_nums = doorstation_config.get(CONF_DOORBELL_NUMS) custom_url = doorstation_config.get(CONF_CUSTOM_URL) events = doorstation_config.get(CONF_MONITORED_CONDITIONS) name = (doorstation_config.get(CONF_NAME) @@ -73,68 +89,73 @@ def setup(hass, config): status = device.ready() if status[0]: - _LOGGER.info("Connected to DoorBird at %s as %s", device_ip, - username) - doorstation = ConfiguredDoorbird(device, name, events, custom_url) + doorstation = ConfiguredDoorBird(device, name, events, custom_url, + doorbell_nums, token) doorstations.append(doorstation) + _LOGGER.info('Connected to DoorBird "%s" as %s@%s', + doorstation.name, username, device_ip) elif status[1] == 401: - _LOGGER.error("Authorization rejected by DoorBird at %s", - device_ip) + _LOGGER.error("Authorization rejected by DoorBird for %s@%s", + username, device_ip) return False else: - _LOGGER.error("Could not connect to DoorBird at %s: Error %s", - device_ip, str(status[1])) + _LOGGER.error("Could not connect to DoorBird as %s@%s: Error %s", + username, device_ip, str(status[1])) return False - # SETUP EVENT SUBSCRIBERS + # Subscribe to doorbell or motion events if events is not None: - # This will make HA the only service that receives events. - doorstation.device.reset_notifications() - - # Subscribe to doorbell or motion events - subscribe_events(hass, doorstation) + doorstation.update_schedule(hass) hass.data[DOMAIN] = doorstations + def _reset_device_favorites_handler(event): + """Handle clearing favorites on device.""" + slug = event.data.get('slug') + + if slug is None: + return + + doorstation = get_doorstation_by_slug(hass, slug) + + if doorstation is None: + _LOGGER.error('Device not found %s', format(slug)) + + # Clear webhooks + favorites = doorstation.device.favorites() + + for favorite_type in favorites: + for favorite_id in favorites[favorite_type]: + doorstation.device.delete_favorite(favorite_type, favorite_id) + + hass.bus.listen(RESET_DEVICE_FAVORITES, _reset_device_favorites_handler) + return True -def subscribe_events(hass, doorstation): - """Initialize the subscriber.""" - for sensor_type in doorstation.monitored_events: - name = '{} {}'.format(doorstation.name, - SENSOR_TYPES[sensor_type][0]) - event_type = SENSOR_TYPES[sensor_type][2] - - # Get the URL of this server - hass_url = hass.config.api.base_url - - # Override url if another is specified onth configuration - if doorstation.custom_url is not None: - hass_url = doorstation.custom_url - - slug = slugify(name) - - url = '{}{}/{}'.format(hass_url, API_URL, slug) - - _LOGGER.info("DoorBird will connect to this instance via %s", - url) - - _LOGGER.info("You may use the following event name for automations" - ": %s_%s", DOMAIN, slug) - - doorstation.device.subscribe_notification(event_type, url) +def get_doorstation_by_slug(hass, slug): + """Get doorstation by slug.""" + for doorstation in hass.data[DOMAIN]: + if slugify(doorstation.name) in slug: + return doorstation -class ConfiguredDoorbird(): +def handle_event(event): + """Handle dummy events.""" + return None + + +class ConfiguredDoorBird(): """Attach additional information to pass along with configured device.""" - def __init__(self, device, name, events=None, custom_url=None): + def __init__(self, device, name, events, custom_url, doorbell_nums, token): """Initialize configured device.""" self._name = name self._device = device self._custom_url = custom_url self._monitored_events = events + self._doorbell_nums = doorbell_nums + self._token = token @property def name(self): @@ -151,16 +172,139 @@ class ConfiguredDoorbird(): """Get custom url for device.""" return self._custom_url - @property - def monitored_events(self): - """Get monitored events.""" - if self._monitored_events is None: - return [] + def update_schedule(self, hass): + """Register monitored sensors and deregister others.""" + from doorbirdpy import DoorBirdScheduleEntrySchedule - return self._monitored_events + # Create a new schedule (24/7) + schedule = DoorBirdScheduleEntrySchedule() + schedule.add_weekday(0, 604800) # seconds in a week + + # Get the URL of this server + hass_url = hass.config.api.base_url + + # Override url if another is specified in the configuration + if self.custom_url is not None: + hass_url = self.custom_url + + # For all sensor types (enabled + disabled) + for sensor_type in SENSOR_TYPES: + name = '{} {}'.format(self.name, SENSOR_TYPES[sensor_type]['name']) + slug = slugify(name) + + url = '{}{}/{}?token={}'.format(hass_url, API_URL, slug, + self._token) + if sensor_type in self._monitored_events: + # Enabled -> register + self._register_event(url, sensor_type, schedule) + _LOGGER.info('Registered for %s pushes from DoorBird "%s". ' + 'Use the "%s_%s" event for automations.', + sensor_type, self.name, DOMAIN, slug) + + # Register a dummy listener so event is listed in GUI + hass.bus.listen('{}_{}'.format(DOMAIN, slug), handle_event) + else: + # Disabled -> deregister + self._deregister_event(url, sensor_type) + _LOGGER.info('Deregistered %s pushes from DoorBird "%s". ' + 'If any old favorites or schedules remain, ' + 'follow the instructions in the component ' + 'documentation to clear device registrations.', + sensor_type, self.name) + + def _register_event(self, hass_url, event, schedule): + """Add a schedule entry in the device for a sensor.""" + from doorbirdpy import DoorBirdScheduleEntryOutput + + # Register HA URL as webhook if not already, then get the ID + if not self.webhook_is_registered(hass_url): + self.device.change_favorite('http', + 'Home Assistant on {} ({} events)' + .format(hass_url, event), hass_url) + fav_id = self.get_webhook_id(hass_url) + + if not fav_id: + _LOGGER.warning('Could not find favorite for URL "%s". ' + 'Skipping sensor "%s".', hass_url, event) + return + + # Add event handling to device schedule + output = DoorBirdScheduleEntryOutput(event='http', + param=fav_id, + schedule=schedule) + + if event == 'doorbell': + # Repeat edit for each monitored doorbell number + for doorbell in self._doorbell_nums: + entry = self.device.get_schedule_entry(event, str(doorbell)) + entry.output.append(output) + self.device.change_schedule(entry) + else: + entry = self.device.get_schedule_entry(event) + entry.output.append(output) + self.device.change_schedule(entry) + + def _deregister_event(self, hass_url, event): + """Remove the schedule entry in the device for a sensor.""" + # Find the right favorite and delete it + fav_id = self.get_webhook_id(hass_url) + if not fav_id: + return + + self._device.delete_favorite('http', fav_id) + + if event == 'doorbell': + # Delete the matching schedule for each doorbell number + for doorbell in self._doorbell_nums: + self._delete_schedule_action(event, fav_id, str(doorbell)) + else: + self._delete_schedule_action(event, fav_id) + + def _delete_schedule_action(self, sensor, fav_id, param=""): + """Remove the HA output from a schedule.""" + entries = self._device.schedule() + for entry in entries: + if entry.input != sensor or entry.param != param: + continue + + for action in entry.output: + if action.event == 'http' and action.param == fav_id: + entry.output.remove(action) + + self._device.change_schedule(entry) + + def webhook_is_registered(self, ha_url, favs=None) -> bool: + """Return whether the given URL is registered as a device favorite.""" + favs = favs if favs else self.device.favorites() + + if 'http' not in favs: + return False + + for fav in favs['http'].values(): + if fav['value'] == ha_url: + return True + + return False + + def get_webhook_id(self, ha_url, favs=None) -> str or None: + """ + Return the device favorite ID for the given URL. + + The favorite must exist or there will be problems. + """ + favs = favs if favs else self.device.favorites() + + if 'http' not in favs: + return None + + for fav_id in favs['http']: + if favs['http'][fav_id]['value'] == ha_url: + return fav_id + + return None -class DoorbirdRequestView(HomeAssistantView): +class DoorBirdRequestView(HomeAssistantView): """Provide a page for the device to call.""" requires_auth = False @@ -168,11 +312,63 @@ class DoorbirdRequestView(HomeAssistantView): name = API_URL[1:].replace('/', ':') extra_urls = [API_URL + '/{sensor}'] + def __init__(self, token): + """Initialize view.""" + HomeAssistantView.__init__(self) + self._token = token + # pylint: disable=no-self-use async def get(self, request, sensor): """Respond to requests from the device.""" + from aiohttp import web hass = request.app['hass'] + request_token = request.query.get('token') + + authenticated = request_token == self._token + + if request_token == '' or not authenticated: + return web.Response(status=401, text='Unauthorized') + hass.bus.async_fire('{}_{}'.format(DOMAIN, sensor)) - return 'OK' + return web.Response(status=200, text='OK') + + +class DoorBirdCleanupView(HomeAssistantView): + """Provide a URL to call to delete ALL webhooks/schedules.""" + + requires_auth = False + url = API_URL + '/clear/{slug}' + name = 'DoorBird Cleanup' + + def __init__(self, token): + """Initialize view.""" + HomeAssistantView.__init__(self) + self._token = token + + # pylint: disable=no-self-use + async def get(self, request, slug): + """Act on requests.""" + from aiohttp import web + hass = request.app['hass'] + + request_token = request.query.get('token') + + authenticated = request_token == self._token + + if request_token == '' or not authenticated: + return web.Response(status=401, text='Unauthorized') + + device = get_doorstation_by_slug(hass, slug) + + # No matching device + if device is None: + return web.Response(status=404, + text='Device slug {} not found'.format(slug)) + + hass.bus.async_fire(RESET_DEVICE_FAVORITES, + {'slug': slug}) + + message = 'Clearing schedule for {}'.format(slug) + return web.Response(status=200, text=message) diff --git a/homeassistant/components/switch/doorbird.py b/homeassistant/components/switch/doorbird.py index 17a4757d4ac..376713d4b27 100644 --- a/homeassistant/components/switch/doorbird.py +++ b/homeassistant/components/switch/doorbird.py @@ -2,48 +2,14 @@ import datetime import logging -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv from homeassistant.components.doorbird import DOMAIN as DOORBIRD_DOMAIN -from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice -from homeassistant.const import CONF_SWITCHES +from homeassistant.components.switch import SwitchDevice DEPENDENCIES = ['doorbird'] _LOGGER = logging.getLogger(__name__) -SWITCHES = { - "open_door": { - "name": "{} Open Door", - "icon": { - True: "lock-open", - False: "lock" - }, - "time": datetime.timedelta(seconds=3) - }, - "open_door_2": { - "name": "{} Open Door 2", - "icon": { - True: "lock-open", - False: "lock" - }, - "time": datetime.timedelta(seconds=3) - }, - "light_on": { - "name": "{} Light On", - "icon": { - True: "lightbulb-on", - False: "lightbulb" - }, - "time": datetime.timedelta(minutes=5) - } -} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_SWITCHES, default=[]): - vol.All(cv.ensure_list([vol.In(SWITCHES)])) -}) +IR_RELAY = '__ir_light__' def setup_platform(hass, config, add_entities, discovery_info=None): @@ -51,14 +17,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): switches = [] for doorstation in hass.data[DOORBIRD_DOMAIN]: + relays = doorstation.device.info()['RELAYS'] + relays.append(IR_RELAY) - device = doorstation.device - - for switch in SWITCHES: - - _LOGGER.debug("Adding DoorBird switch %s", - SWITCHES[switch]["name"].format(doorstation.name)) - switches.append(DoorBirdSwitch(device, switch, doorstation.name)) + for relay in relays: + switch = DoorBirdSwitch(doorstation, relay) + switches.append(switch) add_entities(switches) @@ -66,23 +30,30 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class DoorBirdSwitch(SwitchDevice): """A relay in a DoorBird device.""" - def __init__(self, device, switch, name): + def __init__(self, doorstation, relay): """Initialize a relay in a DoorBird device.""" - self._device = device - self._switch = switch - self._name = name + self._doorstation = doorstation + self._relay = relay self._state = False self._assume_off = datetime.datetime.min + if relay == IR_RELAY: + self._time = datetime.timedelta(minutes=5) + else: + self._time = datetime.timedelta(seconds=5) + @property def name(self): """Return the name of the switch.""" - return SWITCHES[self._switch]["name"].format(self._name) + if self._relay == IR_RELAY: + return "{} IR".format(self._doorstation.name) + + return "{} Relay {}".format(self._doorstation.name, self._relay) @property def icon(self): """Return the icon to display.""" - return "mdi:{}".format(SWITCHES[self._switch]["icon"][self._state]) + return "mdi:lightbulb" if self._relay == IR_RELAY else "mdi:dip-switch" @property def is_on(self): @@ -91,15 +62,13 @@ class DoorBirdSwitch(SwitchDevice): def turn_on(self, **kwargs): """Power the relay.""" - if self._switch == "open_door": - self._state = self._device.open_door() - elif self._switch == "open_door_2": - self._state = self._device.open_door(2) - elif self._switch == "light_on": - self._state = self._device.turn_light_on() + if self._relay == IR_RELAY: + self._state = self._doorstation.device.turn_light_on() + else: + self._state = self._doorstation.device.energize_relay(self._relay) now = datetime.datetime.now() - self._assume_off = now + SWITCHES[self._switch]["time"] + self._assume_off = now + self._time def turn_off(self, **kwargs): """Turn off the relays is not needed. They are time-based.""" diff --git a/requirements_all.txt b/requirements_all.txt index be86435ea84..9f88916d245 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -31,9 +31,6 @@ Adafruit-SHT31==1.0.2 # homeassistant.components.bbb_gpio # Adafruit_BBIO==1.0.0 -# homeassistant.components.doorbird -DoorBirdPy==0.1.3 - # homeassistant.components.homekit HAP-python==2.2.2 @@ -313,6 +310,9 @@ distro==1.3.0 # homeassistant.components.switch.digitalloggers dlipower==0.7.165 +# homeassistant.components.doorbird +doorbirdpy==2.0.4 + # homeassistant.components.sensor.dovado dovado==0.4.1