diff --git a/homeassistant/components/device_tracker/meraki.py b/homeassistant/components/device_tracker/meraki.py new file mode 100644 index 00000000000000..319c19d7b73d86 --- /dev/null +++ b/homeassistant/components/device_tracker/meraki.py @@ -0,0 +1,116 @@ +""" +Support for the Meraki CMX location service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.meraki/ + +""" +import asyncio +import logging +import json + +import voluptuous as vol +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (HTTP_BAD_REQUEST, HTTP_UNPROCESSABLE_ENTITY) +from homeassistant.core import callback +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.device_tracker import ( + PLATFORM_SCHEMA, SOURCE_TYPE_ROUTER) + +CONF_VALIDATOR = 'validator' +CONF_SECRET = 'secret' +DEPENDENCIES = ['http'] +URL = '/api/meraki' +VERSION = '2.0' + + +_LOGGER = logging.getLogger(__name__) + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_VALIDATOR): cv.string, + vol.Required(CONF_SECRET): cv.string +}) + + +@asyncio.coroutine +def async_setup_scanner(hass, config, async_see, discovery_info=None): + """Set up an endpoint for the Meraki tracker.""" + hass.http.register_view( + MerakiView(config, async_see)) + + return True + + +class MerakiView(HomeAssistantView): + """View to handle Meraki requests.""" + + url = URL + name = 'api:meraki' + + def __init__(self, config, async_see): + """Initialize Meraki URL endpoints.""" + self.async_see = async_see + self.validator = config[CONF_VALIDATOR] + self.secret = config[CONF_SECRET] + + @asyncio.coroutine + def get(self, request): + """Meraki message received as GET.""" + return self.validator + + @asyncio.coroutine + def post(self, request): + """Meraki CMX message received.""" + try: + data = yield from request.json() + except ValueError: + return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) + _LOGGER.debug("Meraki Data from Post: %s", json.dumps(data)) + if not data.get('secret', False): + _LOGGER.error("secret invalid") + return self.json_message('No secret', HTTP_UNPROCESSABLE_ENTITY) + if data['secret'] != self.secret: + _LOGGER.error("Invalid Secret received from Meraki") + return self.json_message('Invalid secret', + HTTP_UNPROCESSABLE_ENTITY) + elif data['version'] != VERSION: + _LOGGER.error("Invalid API version: %s", data['version']) + return self.json_message('Invalid version', + HTTP_UNPROCESSABLE_ENTITY) + else: + _LOGGER.debug('Valid Secret') + if data['type'] not in ('DevicesSeen', 'BluetoothDevicesSeen'): + _LOGGER.error("Unknown Device %s", data['type']) + return self.json_message('Invalid device type', + HTTP_UNPROCESSABLE_ENTITY) + _LOGGER.debug("Processing %s", data['type']) + if len(data["data"]["observations"]) == 0: + _LOGGER.debug("No observations found") + return + self._handle(request.app['hass'], data) + + @callback + def _handle(self, hass, data): + for i in data["data"]["observations"]: + data["data"]["secret"] = "hidden" + mac = i["clientMac"] + _LOGGER.debug("clientMac: %s", mac) + attrs = {} + if i.get('os', False): + attrs['os'] = i['os'] + if i.get('manufacturer', False): + attrs['manufacturer'] = i['manufacturer'] + if i.get('ipv4', False): + attrs['ipv4'] = i['ipv4'] + if i.get('ipv6', False): + attrs['ipv6'] = i['ipv6'] + if i.get('seenTime', False): + attrs['seenTime'] = i['seenTime'] + if i.get('ssid', False): + attrs['ssid'] = i['ssid'] + hass.async_add_job(self.async_see( + mac=mac, + source_type=SOURCE_TYPE_ROUTER, + attributes=attrs + )) diff --git a/tests/components/device_tracker/test_meraki.py b/tests/components/device_tracker/test_meraki.py new file mode 100644 index 00000000000000..a739df804fd7aa --- /dev/null +++ b/tests/components/device_tracker/test_meraki.py @@ -0,0 +1,139 @@ +"""The tests the for Meraki device tracker.""" +import asyncio +import json +from unittest.mock import patch +import pytest +from homeassistant.components.device_tracker.meraki import ( + CONF_VALIDATOR, CONF_SECRET) +from homeassistant.setup import async_setup_component +import homeassistant.components.device_tracker as device_tracker +from homeassistant.const import CONF_PLATFORM +from homeassistant.components.device_tracker.meraki import URL + + +@pytest.fixture +def meraki_client(loop, hass, test_client): + """Meraki mock client.""" + assert loop.run_until_complete(async_setup_component( + hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'meraki', + CONF_VALIDATOR: 'validator', + CONF_SECRET: 'secret' + + } + })) + + with patch('homeassistant.components.device_tracker.update_config'): + yield loop.run_until_complete(test_client(hass.http.app)) + + +@asyncio.coroutine +def test_invalid_or_missing_data(meraki_client): + """Test validator with invalid or missing data.""" + req = yield from meraki_client.get(URL) + text = yield from req.text() + assert req.status == 200 + assert text == 'validator' + + req = yield from meraki_client.post(URL, data=b"invalid") + text = yield from req.json() + assert req.status == 400 + assert text['message'] == 'Invalid JSON' + + req = yield from meraki_client.post(URL, data=b"{}") + text = yield from req.json() + assert req.status == 422 + assert text['message'] == 'No secret' + + data = { + "version": "1.0", + "secret": "secret" + } + req = yield from meraki_client.post(URL, data=json.dumps(data)) + text = yield from req.json() + assert req.status == 422 + assert text['message'] == 'Invalid version' + + data = { + "version": "2.0", + "secret": "invalid" + } + req = yield from meraki_client.post(URL, data=json.dumps(data)) + text = yield from req.json() + assert req.status == 422 + assert text['message'] == 'Invalid secret' + + data = { + "version": "2.0", + "secret": "secret", + "type": "InvalidType" + } + req = yield from meraki_client.post(URL, data=json.dumps(data)) + text = yield from req.json() + assert req.status == 422 + assert text['message'] == 'Invalid device type' + + data = { + "version": "2.0", + "secret": "secret", + "type": "BluetoothDevicesSeen", + "data": { + "observations": [] + } + } + req = yield from meraki_client.post(URL, data=json.dumps(data)) + assert req.status == 200 + + +@asyncio.coroutine +def test_data_will_be_saved(hass, meraki_client): + """Test with valid data.""" + data = { + "version": "2.0", + "secret": "secret", + "type": "DevicesSeen", + "data": { + "observations": [ + { + "location": { + "lat": "51.5355157", + "lng": "21.0699035", + "unc": "46.3610585", + }, + "seenTime": "2016-09-12T16:23:13Z", + "ssid": 'ssid', + "os": 'HA', + "ipv6": '2607:f0d0:1002:51::4/64', + "clientMac": "00:26:ab:b8:a9:a4", + "seenEpoch": "147369739", + "rssi": "20", + "manufacturer": "Seiko Epson" + }, + { + "location": { + "lat": "51.5355357", + "lng": "21.0699635", + "unc": "46.3610585", + }, + "seenTime": "2016-09-12T16:21:13Z", + "ssid": 'ssid', + "os": 'HA', + "ipv4": '192.168.0.1', + "clientMac": "00:26:ab:b8:a9:a5", + "seenEpoch": "147369750", + "rssi": "20", + "manufacturer": "Seiko Epson" + } + ] + } + } + req = yield from meraki_client.post(URL, data=json.dumps(data)) + assert req.status == 200 + state_name = hass.states.get('{}.{}'.format('device_tracker', + '0026abb8a9a4')).state + assert 'home' == state_name + + state_name = hass.states.get('{}.{}'.format('device_tracker', + '0026abb8a9a5')).state + assert 'home' == state_name