diff --git a/.coveragerc b/.coveragerc index 4d6d43056c7ea..c5729df352161 100644 --- a/.coveragerc +++ b/.coveragerc @@ -29,6 +29,9 @@ omit = homeassistant/components/arlo.py homeassistant/components/*/arlo.py + homeassistant/components/asterisk_mbox.py + homeassistant/components/*/asterisk_mbox.py + homeassistant/components/axis.py homeassistant/components/*/axis.py diff --git a/homeassistant/components/asterisk_mbox.py b/homeassistant/components/asterisk_mbox.py new file mode 100644 index 0000000000000..c1dafb87a6d57 --- /dev/null +++ b/homeassistant/components/asterisk_mbox.py @@ -0,0 +1,82 @@ +"""Support for Asterisk Voicemail interface.""" + +import logging + +import voluptuous as vol + + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import discovery +from homeassistant.const import (CONF_HOST, + CONF_PORT, CONF_PASSWORD) + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import (async_dispatcher_connect, + async_dispatcher_send) + +REQUIREMENTS = ['asterisk_mbox==0.4.0'] + +SIGNAL_MESSAGE_UPDATE = 'asterisk_mbox.message_updated' +SIGNAL_MESSAGE_REQUEST = 'asterisk_mbox.message_request' + +DOMAIN = 'asterisk_mbox' + +_LOGGER = logging.getLogger(__name__) + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORT): int, + vol.Required(CONF_PASSWORD): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up for the Asterisk Voicemail box.""" + conf = config.get(DOMAIN) + + host = conf.get(CONF_HOST) + port = conf.get(CONF_PORT) + password = conf.get(CONF_PASSWORD) + + hass.data[DOMAIN] = AsteriskData(hass, host, port, password) + + discovery.load_platform(hass, "mailbox", DOMAIN, {}, config) + + return True + + +class AsteriskData(object): + """Store Asterisk mailbox data.""" + + def __init__(self, hass, host, port, password): + """Init the Asterisk data object.""" + from asterisk_mbox import Client as asteriskClient + + self.hass = hass + self.client = asteriskClient(host, port, password, self.handle_data) + self.messages = [] + + async_dispatcher_connect( + self.hass, SIGNAL_MESSAGE_REQUEST, self._request_messages) + + @callback + def handle_data(self, command, msg): + """Handle changes to the mailbox.""" + from asterisk_mbox.commands import CMD_MESSAGE_LIST + + if command == CMD_MESSAGE_LIST: + _LOGGER.info("AsteriskVM sent updated message list") + self.messages = sorted(msg, + key=lambda item: item['info']['origtime'], + reverse=True) + async_dispatcher_send(self.hass, SIGNAL_MESSAGE_UPDATE, + self.messages) + + @callback + def _request_messages(self): + """Handle changes to the mailbox.""" + _LOGGER.info("Requesting message list") + self.client.messages() diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index 187e899aacdcd..2f1dde05bab4f 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -31,6 +31,7 @@ 'sensor', 'switch', 'tts', + 'mailbox', ] diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py new file mode 100644 index 0000000000000..69552cf5d4284 --- /dev/null +++ b/homeassistant/components/mailbox/__init__.py @@ -0,0 +1,254 @@ +""" +Provides functionality for mailboxes. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/mailbox/ +""" + +import asyncio +import logging +from contextlib import suppress +from datetime import timedelta + +import async_timeout + +from aiohttp import web +from aiohttp.web_exceptions import HTTPNotFound + +from homeassistant.core import callback +from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity import Entity +from homeassistant.components.http import HomeAssistantView +from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_prepare_setup_platform + +DEPENDENCIES = ['http'] +DOMAIN = 'mailbox' +EVENT = 'mailbox_updated' +CONTENT_TYPE_MPEG = 'audio/mpeg' +SCAN_INTERVAL = timedelta(seconds=30) +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup(hass, config): + """Track states and offer events for mailboxes.""" + mailboxes = [] + hass.components.frontend.register_built_in_panel( + 'mailbox', 'Mailbox', 'mdi:account-location') + hass.http.register_view(MailboxPlatformsView(mailboxes)) + hass.http.register_view(MailboxMessageView(mailboxes)) + hass.http.register_view(MailboxMediaView(mailboxes)) + hass.http.register_view(MailboxDeleteView(mailboxes)) + + @asyncio.coroutine + def async_setup_platform(p_type, p_config=None, discovery_info=None): + """Set up a mailbox platform.""" + if p_config is None: + p_config = {} + if discovery_info is None: + discovery_info = {} + + platform = yield from async_prepare_setup_platform( + hass, config, DOMAIN, p_type) + + if platform is None: + _LOGGER.error("Unknown mailbox platform specified") + return + + _LOGGER.info("Setting up %s.%s", DOMAIN, p_type) + mailbox = None + try: + if hasattr(platform, 'async_get_handler'): + mailbox = yield from \ + platform.async_get_handler(hass, p_config, discovery_info) + elif hasattr(platform, 'get_handler'): + mailbox = yield from hass.async_add_job( + platform.get_handler, hass, p_config, discovery_info) + else: + raise HomeAssistantError("Invalid mailbox platform.") + + if mailbox is None: + _LOGGER.error( + "Failed to initialize mailbox platform %s", p_type) + return + + except Exception: # pylint: disable=broad-except + _LOGGER.exception('Error setting up platform %s', p_type) + return + + mailboxes.append(mailbox) + mailbox_entity = MailboxEntity(hass, mailbox) + component = EntityComponent( + logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL) + yield from component.async_add_entity(mailbox_entity) + + setup_tasks = [async_setup_platform(p_type, p_config) for p_type, p_config + in config_per_platform(config, DOMAIN)] + + if setup_tasks: + yield from asyncio.wait(setup_tasks, loop=hass.loop) + + @asyncio.coroutine + def async_platform_discovered(platform, info): + """Handle for discovered platform.""" + yield from async_setup_platform(platform, discovery_info=info) + + discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) + + return True + + +class MailboxEntity(Entity): + """Entity for each mailbox platform.""" + + def __init__(self, hass, mailbox): + """Initialize mailbox entity.""" + self.mailbox = mailbox + self.hass = hass + self.message_count = 0 + + @callback + def _mailbox_updated(event): + self.hass.async_add_job(self.async_update_ha_state(True)) + + hass.bus.async_listen(EVENT, _mailbox_updated) + + @property + def state(self): + """Return the state of the binary sensor.""" + return str(self.message_count) + + @property + def name(self): + """Return the name of the entity.""" + return self.mailbox.name + + @asyncio.coroutine + def async_update(self): + """Retrieve messages from platform.""" + messages = yield from self.mailbox.async_get_messages() + self.message_count = len(messages) + + +class Mailbox(object): + """Represent an mailbox device.""" + + def __init__(self, hass, name): + """Initialize mailbox object.""" + self.hass = hass + self.name = name + + def async_update(self): + """Send event notification of updated mailbox.""" + self.hass.bus.async_fire(EVENT) + + @property + def media_type(self): + """Return the supported media type.""" + raise NotImplementedError() + + @asyncio.coroutine + def async_get_media(self, msgid): + """Return the media blob for the msgid.""" + raise NotImplementedError() + + @asyncio.coroutine + def async_get_messages(self): + """Return a list of the current messages.""" + raise NotImplementedError() + + def async_delete(self, msgid): + """Delete the specified messages.""" + raise NotImplementedError() + + +class StreamError(Exception): + """Media streaming exception.""" + + pass + + +class MailboxView(HomeAssistantView): + """Base mailbox view.""" + + def __init__(self, mailboxes): + """Initialize a basic mailbox view.""" + self.mailboxes = mailboxes + + def get_mailbox(self, platform): + """Retrieve the specified mailbox.""" + for mailbox in self.mailboxes: + if mailbox.name == platform: + return mailbox + raise HTTPNotFound + + +class MailboxPlatformsView(MailboxView): + """View to return the list of mailbox platforms.""" + + url = "/api/mailbox/platforms" + name = "api:mailbox:platforms" + + @asyncio.coroutine + def get(self, request): + """Retrieve list of platforms.""" + platforms = [] + for mailbox in self.mailboxes: + platforms.append(mailbox.name) + return self.json(platforms) + + +class MailboxMessageView(MailboxView): + """View to return the list of messages.""" + + url = "/api/mailbox/messages/{platform}" + name = "api:mailbox:messages" + + @asyncio.coroutine + def get(self, request, platform): + """Retrieve messages.""" + mailbox = self.get_mailbox(platform) + messages = yield from mailbox.async_get_messages() + return self.json(messages) + + +class MailboxDeleteView(MailboxView): + """View to delete selected messages.""" + + url = "/api/mailbox/delete/{platform}/{msgid}" + name = "api:mailbox:delete" + + @asyncio.coroutine + def delete(self, request, platform, msgid): + """Delete items.""" + mailbox = self.get_mailbox(platform) + mailbox.async_delete(msgid) + + +class MailboxMediaView(MailboxView): + """View to return a media file.""" + + url = r"/api/mailbox/media/{platform}/{msgid}" + name = "api:asteriskmbox:media" + + @asyncio.coroutine + def get(self, request, platform, msgid): + """Retrieve media.""" + mailbox = self.get_mailbox(platform) + + hass = request.app['hass'] + with suppress(asyncio.CancelledError, asyncio.TimeoutError): + with async_timeout.timeout(10, loop=hass.loop): + try: + stream = yield from mailbox.async_get_media(msgid) + except StreamError as err: + error_msg = "Error getting media: %s" % (err) + _LOGGER.error(error_msg) + return web.Response(status=500) + if stream: + return web.Response(body=stream, + content_type=mailbox.media_type) + + return web.Response(status=500) diff --git a/homeassistant/components/mailbox/asterisk_mbox.py b/homeassistant/components/mailbox/asterisk_mbox.py new file mode 100644 index 0000000000000..a1953839f4f68 --- /dev/null +++ b/homeassistant/components/mailbox/asterisk_mbox.py @@ -0,0 +1,68 @@ +""" +Asterisk Voicemail interface. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/mailbox.asteriskvm/ +""" +import asyncio +import logging + +from homeassistant.core import callback +from homeassistant.components.asterisk_mbox import DOMAIN +from homeassistant.components.mailbox import (Mailbox, CONTENT_TYPE_MPEG, + StreamError) +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +DEPENDENCIES = ['asterisk_mbox'] +_LOGGER = logging.getLogger(__name__) + +SIGNAL_MESSAGE_UPDATE = 'asterisk_mbox.message_updated' +SIGNAL_MESSAGE_REQUEST = 'asterisk_mbox.message_request' + + +@asyncio.coroutine +def async_get_handler(hass, config, async_add_devices, discovery_info=None): + """Set up the Asterix VM platform.""" + return AsteriskMailbox(hass, DOMAIN) + + +class AsteriskMailbox(Mailbox): + """Asterisk VM Sensor.""" + + def __init__(self, hass, name): + """Initialie Asterisk mailbox.""" + super().__init__(hass, name) + async_dispatcher_connect( + self.hass, SIGNAL_MESSAGE_UPDATE, self._update_callback) + + @callback + def _update_callback(self, msg): + """Update the message count in HA, if needed.""" + self.async_update() + + @property + def media_type(self): + """Return the supported media type.""" + return CONTENT_TYPE_MPEG + + @asyncio.coroutine + def async_get_media(self, msgid): + """Return the media blob for the msgid.""" + from asterisk_mbox import ServerError + client = self.hass.data[DOMAIN].client + try: + return client.mp3(msgid, sync=True) + except ServerError as err: + raise StreamError(err) + + @asyncio.coroutine + def async_get_messages(self): + """Return a list of the current messages.""" + return self.hass.data[DOMAIN].messages + + def async_delete(self, msgid): + """Delete the specified messages.""" + client = self.hass.data[DOMAIN].client + _LOGGER.info("Deleting: %s", msgid) + client.delete(msgid) + return True diff --git a/homeassistant/components/mailbox/demo.py b/homeassistant/components/mailbox/demo.py new file mode 100644 index 0000000000000..aba2ca3bdaf92 --- /dev/null +++ b/homeassistant/components/mailbox/demo.py @@ -0,0 +1,75 @@ +""" +Asterisk Voicemail interface. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/mailbox.asteriskvm/ +""" +import asyncio +import logging +import os +from hashlib import sha1 + +import homeassistant.util.dt as dt + +from homeassistant.components.mailbox import (Mailbox, CONTENT_TYPE_MPEG, + StreamError) + +_LOGGER = logging.getLogger(__name__) +DOMAIN = "DemoMailbox" + + +@asyncio.coroutine +def async_get_handler(hass, config, discovery_info=None): + """Set up the Demo mailbox.""" + return DemoMailbox(hass, DOMAIN) + + +class DemoMailbox(Mailbox): + """Demo Mailbox.""" + + def __init__(self, hass, name): + """Initialize Demo mailbox.""" + super().__init__(hass, name) + self._messages = {} + for idx in range(0, 10): + msgtime = int(dt.as_timestamp( + dt.utcnow()) - 3600 * 24 * (10 - idx)) + msgtxt = "This is recorded message # %d" % (idx) + msgsha = sha1(msgtxt.encode('utf-8')).hexdigest() + msg = {"info": {"origtime": msgtime, + "callerid": "John Doe <212-555-1212>", + "duration": "10"}, + "text": msgtxt, + "sha": msgsha} + self._messages[msgsha] = msg + + @property + def media_type(self): + """Return the supported media type.""" + return CONTENT_TYPE_MPEG + + @asyncio.coroutine + def async_get_media(self, msgid): + """Return the media blob for the msgid.""" + if msgid not in self._messages: + raise StreamError("Message not found") + + audio_path = os.path.join( + os.path.dirname(__file__), '..', 'tts', 'demo.mp3') + with open(audio_path, 'rb') as file: + return file.read() + + @asyncio.coroutine + def async_get_messages(self): + """Return a list of the current messages.""" + return sorted(self._messages.values(), + key=lambda item: item['info']['origtime'], + reverse=True) + + def async_delete(self, msgid): + """Delete the specified messages.""" + if msgid in self._messages: + _LOGGER.info("Deleting: %s", msgid) + del self._messages[msgid] + self.async_update() + return True diff --git a/requirements_all.txt b/requirements_all.txt index 8939b32d934c6..a0e06926e7673 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -72,6 +72,9 @@ apcaccess==0.0.13 # homeassistant.components.notify.apns apns2==0.1.1 +# homeassistant.components.asterisk_mbox +asterisk_mbox==0.4.0 + # homeassistant.components.light.avion # avion==0.7 diff --git a/tests/components/mailbox/__init__.py b/tests/components/mailbox/__init__.py new file mode 100644 index 0000000000000..5e21235457948 --- /dev/null +++ b/tests/components/mailbox/__init__.py @@ -0,0 +1 @@ +"""The tests for mailbox platforms.""" diff --git a/tests/components/mailbox/test_init.py b/tests/components/mailbox/test_init.py new file mode 100644 index 0000000000000..566feeb61d1b3 --- /dev/null +++ b/tests/components/mailbox/test_init.py @@ -0,0 +1,116 @@ +"""The tests for the mailbox component.""" +import asyncio +from hashlib import sha1 + +import pytest + +from homeassistant.bootstrap import async_setup_component +import homeassistant.components.mailbox as mailbox + + +@pytest.fixture +def mock_http_client(hass, test_client): + """Start the Hass HTTP component.""" + config = { + mailbox.DOMAIN: { + 'platform': 'demo' + } + } + hass.loop.run_until_complete( + async_setup_component(hass, mailbox.DOMAIN, config)) + return hass.loop.run_until_complete(test_client(hass.http.app)) + + +@asyncio.coroutine +def test_get_platforms_from_mailbox(mock_http_client): + """Get platforms from mailbox.""" + url = "/api/mailbox/platforms" + + req = yield from mock_http_client.get(url) + assert req.status == 200 + result = yield from req.json() + assert len(result) == 1 and "DemoMailbox" in result + + +@asyncio.coroutine +def test_get_messages_from_mailbox(mock_http_client): + """Get messages from mailbox.""" + url = "/api/mailbox/messages/DemoMailbox" + + req = yield from mock_http_client.get(url) + assert req.status == 200 + result = yield from req.json() + assert len(result) == 10 + + +@asyncio.coroutine +def test_get_media_from_mailbox(mock_http_client): + """Get audio from mailbox.""" + mp3sha = "3f67c4ea33b37d1710f772a26dd3fb43bb159d50" + msgtxt = "This is recorded message # 1" + msgsha = sha1(msgtxt.encode('utf-8')).hexdigest() + + url = "/api/mailbox/media/DemoMailbox/%s" % (msgsha) + req = yield from mock_http_client.get(url) + assert req.status == 200 + data = yield from req.read() + assert sha1(data).hexdigest() == mp3sha + + +@asyncio.coroutine +def test_delete_from_mailbox(mock_http_client): + """Get audio from mailbox.""" + msgtxt1 = "This is recorded message # 1" + msgtxt2 = "This is recorded message # 2" + msgsha1 = sha1(msgtxt1.encode('utf-8')).hexdigest() + msgsha2 = sha1(msgtxt2.encode('utf-8')).hexdigest() + + for msg in [msgsha1, msgsha2]: + url = "/api/mailbox/delete/DemoMailbox/%s" % (msg) + req = yield from mock_http_client.delete(url) + assert req.status == 200 + + url = "/api/mailbox/messages/DemoMailbox" + req = yield from mock_http_client.get(url) + assert req.status == 200 + result = yield from req.json() + assert len(result) == 8 + + +@asyncio.coroutine +def test_get_messages_from_invalid_mailbox(mock_http_client): + """Get messages from mailbox.""" + url = "/api/mailbox/messages/mailbox.invalid_mailbox" + + req = yield from mock_http_client.get(url) + assert req.status == 404 + + +@asyncio.coroutine +def test_get_media_from_invalid_mailbox(mock_http_client): + """Get messages from mailbox.""" + msgsha = "0000000000000000000000000000000000000000" + url = "/api/mailbox/media/mailbox.invalid_mailbox/%s" % (msgsha) + + req = yield from mock_http_client.get(url) + assert req.status == 404 + + +@asyncio.coroutine +def test_get_media_from_invalid_msgid(mock_http_client): + """Get messages from mailbox.""" + msgsha = "0000000000000000000000000000000000000000" + url = "/api/mailbox/media/DemoMailbox/%s" % (msgsha) + + req = yield from mock_http_client.get(url) + assert req.status == 500 + + +@asyncio.coroutine +def test_delete_from_invalid_mailbox(mock_http_client): + """Get audio from mailbox.""" + msgsha = "0000000000000000000000000000000000000000" + url = "/api/mailbox/delete/mailbox.invalid_mailbox/%s" % (msgsha) + + req = yield from mock_http_client.delete(url) + assert req.status == 404