Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Meraki AP Device tracker #10971

Merged
merged 19 commits into from
Dec 6, 2017
116 changes: 116 additions & 0 deletions homeassistant/components/device_tracker/meraki.py
Original file line number Diff line number Diff line change
@@ -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
))
139 changes: 139 additions & 0 deletions tests/components/device_tracker/test_meraki.py
Original file line number Diff line number Diff line change
@@ -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