Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,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
Expand Down
259 changes: 201 additions & 58 deletions homeassistant/components/doorbird.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import homeassistant.helpers.config_validation as cv
from homeassistant.util import slugify

REQUIREMENTS = ['DoorBirdPy==0.1.3']
REQUIREMENTS = ['doorbirdpy==2.0.4']
Comment thread
balloob marked this conversation as resolved.

_LOGGER = logging.getLogger(__name__)

Expand All @@ -24,21 +24,27 @@
API_URL = '/api/{}'.format(DOMAIN)

CONF_DOORBELL_EVENTS = 'doorbell_events'
CONF_MOTION_EVENTS = 'motion_events'
CONF_CUSTOM_URL = 'hass_url_override'
CONF_DOORBELL_NUMS = 'doorbell_numbers'

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',
},
}

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=[]):
Expand All @@ -57,14 +63,18 @@ def setup(hass, config):
from doorbirdpy import DoorBird

# Provide an endpoint for the doorstations to call to trigger events
hass.http.register_view(DoorbirdRequestView())
hass.http.register_view(DoorBirdRequestView())

# Provide an endpoint for the user to call to clear device changes
hass.http.register_view(DoorBirdCleanupView())

doorstations = []

for index, doorstation_config in enumerate(config[DOMAIN][CONF_DEVICES]):
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)
Expand All @@ -74,68 +84,39 @@ 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)
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

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)


class ConfiguredDoorbird():
class ConfiguredDoorBird(object):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expected 2 blank lines, found 1

"""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):
"""Initialize configured device."""
self._name = name
self._device = device
self._custom_url = custom_url
self._monitored_events = events
self._doorbell_nums = doorbell_nums

@property
def name(self):
Expand All @@ -152,16 +133,137 @@ def custom_url(self):
"""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

# 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 = '{}{}/{}'.format(hass_url, API_URL, slug)

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)
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=""):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why should this method be defined in home assistant? Looks like it belongs in the interface library.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think what HA is doing here is kind of an edge case and it would not be useful in most other applications of the library.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't registering a webhook a common use case?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doorbird API requires that webhooks be registered on the device. The registered webhooks are called when an event happens, but are also limited to when they can be fired by a user defined schedule. The nomenclature could be clearer, but clearing a schedule is simply removing a webhook. Doorbird API does not scope access to created schedules/webhooks, so each app is responsible for playing nice with other entries that may have been created by another app. The code here is simply making sure that the appropriate schedule/webhook is being removed without blowing away all of the entries.

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

return self._monitored_events
self._device.change_schedule(entry)

def webhook_is_registered(self, ha_url, favs=None) -> bool:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DoorBirdPy is designed to be simple and does not offer caching, nor do I have access to a physical DoorBird anymore to test such a feature (I'm moving). The way this method is implemented in HA makes use of caching by accessing data that was retrieved during HA startup.

"""Return whether the given URL is registered as a device favorite."""
favs = favs if favs else self.device.favorites()

class DoorbirdRequestView(HomeAssistantView):
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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same explanation as webhook_is_registered

"""
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']:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is possible to delete all favorites via the Doorbird app which will cause a KeyError because 'http' key is missing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an old version of the code, this function is now called get_ha_webhook_id. This is not an issue because the favorite is added (creating an http object if one does not already exist) if webhook_is_registered (above: ha_is_favorite) returns false (and it handles a missing http object gracefully; see lines 222-223 above).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have changes that aren't pushed? Ran into that while testing the schedule clearing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought I had pushed them (just renaming some things), but apparent I made them against the branch that didn't have your changes on it 🤦‍♂️

if favs['http'][fav_id]['value'] == ha_url:
return fav_id

return None


class DoorBirdRequestView(HomeAssistantView):
"""Provide a page for the device to call."""

requires_auth = False
Expand All @@ -173,8 +275,49 @@ class DoorbirdRequestView(HomeAssistantView):
@asyncio.coroutine
def get(self, request, sensor):
"""Respond to requests from the device."""
from aiohttp import web
hass = request.app['hass']

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/{name}'
name = 'DoorBird Cleanup'

# pylint: disable=no-self-use
async def get(self, request, name):
"""Act on requests."""
from aiohttp import web
hass = request.app['hass']

# Find matching device
for config_device in hass.data[DOMAIN]:
if config_device.name == name:
return self._clear(config_device.device)

# No matching device
return web.Response(status=404,
text='Device "{}" not found'.format(name))

@staticmethod
def _clear(device):
from aiohttp import web

# Clear webhooks
favorites = device.favorites()
for favorite_type in favorites:
for favorite_id in favorites[favorite_type]:
device.delete_favorite(favorite_type, favorite_id)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is does doing I/O? You're calling this from an async method which does not allow doing I/O


# Clear schedule
schedule = device.schedule()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is does doing I/O? You're calling this from an async method which does not allow doing I/O

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you need to do I/O, do await self.hass.async_add_executor_job(self._clear)

for entry in schedule:
device.delete_schedule(entry.input, entry.param)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blank line at end of file

return web.Response(status=202)
Loading