Axis discovery updates host address (#22632)

* Discovery can update host on existing entries

* Add support in device to update host on entry update

* Fix tests and listener

* Fix hound comment

* Fix failing tests from cleanup
This commit is contained in:
Robert Svensson 2019-04-02 20:13:11 +02:00 committed by Paulus Schoutsen
parent 6c14e7afa7
commit 8a0b210f87
7 changed files with 104 additions and 49 deletions

View file

@ -4,11 +4,10 @@ import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import ( from homeassistant.const import (
CONF_DEVICE, CONF_HOST, CONF_NAME, CONF_TRIGGER_TIME, CONF_DEVICE, CONF_NAME, CONF_TRIGGER_TIME, EVENT_HOMEASSISTANT_STOP)
EVENT_HOMEASSISTANT_STOP)
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from .config_flow import configured_devices, DEVICE_SCHEMA from .config_flow import DEVICE_SCHEMA
from .const import CONF_CAMERA, CONF_EVENTS, DEFAULT_TRIGGER_TIME, DOMAIN from .const import CONF_CAMERA, CONF_EVENTS, DEFAULT_TRIGGER_TIME, DOMAIN
from .device import AxisNetworkDevice, get_device from .device import AxisNetworkDevice, get_device
@ -21,14 +20,13 @@ CONFIG_SCHEMA = vol.Schema({
async def async_setup(hass, config): async def async_setup(hass, config):
"""Set up for Axis devices.""" """Set up for Axis devices."""
if DOMAIN in config: if not hass.config_entries.async_entries(DOMAIN) and DOMAIN in config:
for device_name, device_config in config[DOMAIN].items(): for device_name, device_config in config[DOMAIN].items():
if CONF_NAME not in device_config: if CONF_NAME not in device_config:
device_config[CONF_NAME] = device_name device_config[CONF_NAME] = device_name
if device_config[CONF_HOST] not in configured_devices(hass):
hass.async_create_task(hass.config_entries.flow.async_init( hass.async_create_task(hass.config_entries.flow.async_init(
DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, DOMAIN, context={'source': config_entries.SOURCE_IMPORT},
data=device_config data=device_config

View file

@ -52,8 +52,7 @@ class AxisCamera(MjpegCamera):
async def async_added_to_hass(self): async def async_added_to_hass(self):
"""Subscribe camera events.""" """Subscribe camera events."""
self.unsub_dispatcher.append(async_dispatcher_connect( self.unsub_dispatcher.append(async_dispatcher_connect(
self.hass, 'axis_{}_new_ip'.format(self.device.name), self.hass, self.device.event_new_address, self._new_address))
self._new_ip))
self.unsub_dispatcher.append(async_dispatcher_connect( self.unsub_dispatcher.append(async_dispatcher_connect(
self.hass, self.device.event_reachable, self.update_callback)) self.hass, self.device.event_reachable, self.update_callback))
@ -67,10 +66,10 @@ class AxisCamera(MjpegCamera):
"""Return True if device is available.""" """Return True if device is available."""
return self.device.available return self.device.available
def _new_ip(self, host): def _new_address(self):
"""Set new IP for video stream.""" """Set new device address for video stream."""
self._mjpeg_url = AXIS_VIDEO.format(host, self.port) self._mjpeg_url = AXIS_VIDEO.format(self.device.host, self.port)
self._still_image_url = AXIS_IMAGE.format(host, self.port) self._still_image_url = AXIS_IMAGE.format(self.device.host, self.port)
@property @property
def unique_id(self): def unique_id(self):

View file

@ -40,8 +40,8 @@ DEVICE_SCHEMA = vol.Schema({
@callback @callback
def configured_devices(hass): def configured_devices(hass):
"""Return a set of the configured devices.""" """Return a set of the configured devices."""
return set(entry.data[CONF_DEVICE][CONF_HOST] for entry return {entry.data[CONF_MAC]: entry for entry
in hass.config_entries.async_entries(DOMAIN)) in hass.config_entries.async_entries(DOMAIN)}
@config_entries.HANDLERS.register(DOMAIN) @config_entries.HANDLERS.register(DOMAIN)
@ -71,9 +71,6 @@ class AxisFlowHandler(config_entries.ConfigFlow):
if user_input is not None: if user_input is not None:
try: try:
if user_input[CONF_HOST] in configured_devices(self.hass):
raise AlreadyConfigured
self.device_config = { self.device_config = {
CONF_HOST: user_input[CONF_HOST], CONF_HOST: user_input[CONF_HOST],
CONF_PORT: user_input[CONF_PORT], CONF_PORT: user_input[CONF_PORT],
@ -84,6 +81,10 @@ class AxisFlowHandler(config_entries.ConfigFlow):
self.serial_number = device.vapix.get_param( self.serial_number = device.vapix.get_param(
VAPIX_SERIAL_NUMBER) VAPIX_SERIAL_NUMBER)
if self.serial_number in configured_devices(self.hass):
raise AlreadyConfigured
self.model = device.vapix.get_param(VAPIX_MODEL_ID) self.model = device.vapix.get_param(VAPIX_MODEL_ID)
return await self._create_entry() return await self._create_entry()
@ -142,22 +143,30 @@ class AxisFlowHandler(config_entries.ConfigFlow):
data=data data=data
) )
async def _update_entry(self, entry, host):
"""Update existing entry if it is the same device."""
entry.data[CONF_DEVICE][CONF_HOST] = host
self.hass.config_entries.async_update_entry(entry)
async def async_step_discovery(self, discovery_info): async def async_step_discovery(self, discovery_info):
"""Prepare configuration for a discovered Axis device. """Prepare configuration for a discovered Axis device.
This flow is triggered by the discovery component. This flow is triggered by the discovery component.
""" """
if discovery_info[CONF_HOST] in configured_devices(self.hass):
return self.async_abort(reason='already_configured')
if discovery_info[CONF_HOST].startswith('169.254'): if discovery_info[CONF_HOST].startswith('169.254'):
return self.async_abort(reason='link_local_address') return self.async_abort(reason='link_local_address')
serialnumber = discovery_info['properties']['macaddress']
device_entries = configured_devices(self.hass)
if serialnumber in device_entries:
entry = device_entries[serialnumber]
await self._update_entry(entry, discovery_info[CONF_HOST])
return self.async_abort(reason='already_configured')
config_file = await self.hass.async_add_executor_job( config_file = await self.hass.async_add_executor_job(
load_json, self.hass.config.path(CONFIG_FILE)) load_json, self.hass.config.path(CONFIG_FILE))
serialnumber = discovery_info['properties']['macaddress']
if serialnumber not in config_file: if serialnumber not in config_file:
self.discovery_schema = { self.discovery_schema = {
vol.Required( vol.Required(

View file

@ -101,8 +101,26 @@ class AxisNetworkDevice:
self.api.enable_events(event_callback=self.async_event_callback) self.api.enable_events(event_callback=self.async_event_callback)
self.api.start() self.api.start()
self.config_entry.add_update_listener(self.async_new_address_callback)
return True return True
@property
def event_new_address(self):
"""Device specific event to signal new device address."""
return 'axis_new_address_{}'.format(self.serial)
@staticmethod
async def async_new_address_callback(hass, entry):
"""Handle signals of device getting new address.
This is a static method because a class method (bound method),
can not be used with weak references.
"""
device = hass.data[DOMAIN][entry.data[CONF_MAC]]
device.api.config.host = device.host
async_dispatcher_send(hass, device.event_new_address)
@property @property
def event_reachable(self): def event_reachable(self):
"""Device specific event to signal a change in connection status.""" """Device specific event to signal a change in connection status."""
@ -110,7 +128,7 @@ class AxisNetworkDevice:
@callback @callback
def async_connection_status_callback(self, status): def async_connection_status_callback(self, status):
"""Handle signals of gateway connection status. """Handle signals of device connection status.
This is called on every RTSP keep-alive message. This is called on every RTSP keep-alive message.
Only signal state change if state change is true. Only signal state change if state change is true.

View file

@ -16,7 +16,7 @@ async def test_configured_devices(hass):
assert not result assert not result
entry = MockConfigEntry(domain=axis.DOMAIN, entry = MockConfigEntry(domain=axis.DOMAIN,
data={axis.CONF_DEVICE: {axis.CONF_HOST: ''}}) data={axis.config_flow.CONF_MAC: '1234'})
entry.add_to_hass(hass) entry.add_to_hass(hass)
result = config_flow.configured_devices(hass) result = config_flow.configured_devices(hass)
@ -76,11 +76,15 @@ async def test_flow_fails_already_configured(hass):
flow = config_flow.AxisFlowHandler() flow = config_flow.AxisFlowHandler()
flow.hass = hass flow.hass = hass
entry = MockConfigEntry(domain=axis.DOMAIN, data={axis.CONF_DEVICE: { entry = MockConfigEntry(domain=axis.DOMAIN,
axis.CONF_HOST: '1.2.3.4' data={axis.config_flow.CONF_MAC: '1234'})
}})
entry.add_to_hass(hass) entry.add_to_hass(hass)
mock_device = Mock()
mock_device.vapix.get_param.return_value = '1234'
with patch('homeassistant.components.axis.config_flow.get_device',
return_value=mock_coro(mock_device)):
result = await flow.async_step_user(user_input={ result = await flow.async_step_user(user_input={
config_flow.CONF_HOST: '1.2.3.4', config_flow.CONF_HOST: '1.2.3.4',
config_flow.CONF_USERNAME: 'user', config_flow.CONF_USERNAME: 'user',
@ -220,16 +224,19 @@ async def test_discovery_flow_already_configured(hass):
flow = config_flow.AxisFlowHandler() flow = config_flow.AxisFlowHandler()
flow.hass = hass flow.hass = hass
entry = MockConfigEntry(domain=axis.DOMAIN, data={axis.CONF_DEVICE: { entry = MockConfigEntry(
axis.CONF_HOST: '1.2.3.4' domain=axis.DOMAIN,
}}) data={axis.CONF_DEVICE: {axis.config_flow.CONF_HOST: '1.2.3.4'},
axis.config_flow.CONF_MAC: '1234ABCD'}
)
entry.add_to_hass(hass) entry.add_to_hass(hass)
result = await flow.async_step_discovery(discovery_info={ result = await flow.async_step_discovery(discovery_info={
config_flow.CONF_HOST: '1.2.3.4', config_flow.CONF_HOST: '1.2.3.4',
config_flow.CONF_USERNAME: 'user', config_flow.CONF_USERNAME: 'user',
config_flow.CONF_PASSWORD: 'pass', config_flow.CONF_PASSWORD: 'pass',
config_flow.CONF_PORT: 81 config_flow.CONF_PORT: 81,
'properties': {'macaddress': '1234ABCD'}
}) })
print(result) print(result)
assert result['type'] == 'abort' assert result['type'] == 'abort'

View file

@ -3,9 +3,10 @@ from unittest.mock import Mock, patch
import pytest import pytest
from tests.common import mock_coro from tests.common import mock_coro, MockConfigEntry
from homeassistant.components.axis import device, errors from homeassistant.components.axis import device, errors
from homeassistant.components.axis.camera import AxisCamera
DEVICE_DATA = { DEVICE_DATA = {
device.CONF_HOST: '1.2.3.4', device.CONF_HOST: '1.2.3.4',
@ -16,7 +17,7 @@ DEVICE_DATA = {
ENTRY_OPTIONS = { ENTRY_OPTIONS = {
device.CONF_CAMERA: True, device.CONF_CAMERA: True,
device.CONF_EVENTS: ['pir'], device.CONF_EVENTS: True,
} }
ENTRY_CONFIG = { ENTRY_CONFIG = {
@ -53,6 +54,31 @@ async def test_device_setup():
(entry, 'binary_sensor') (entry, 'binary_sensor')
async def test_device_signal_new_address(hass):
"""Successful setup."""
entry = MockConfigEntry(
domain=device.DOMAIN, data=ENTRY_CONFIG, options=ENTRY_OPTIONS)
api = Mock()
api.vapix.get_param.return_value = '1234'
axis_device = device.AxisNetworkDevice(hass, entry)
hass.data[device.DOMAIN] = {axis_device.serial: axis_device}
with patch.object(device, 'get_device', return_value=mock_coro(api)), \
patch.object(AxisCamera, '_new_address') as new_address_mock:
await axis_device.async_setup()
await hass.async_block_till_done()
entry.data[device.CONF_DEVICE][device.CONF_HOST] = '2.3.4.5'
hass.config_entries.async_update_entry(entry, data=entry.data)
await hass.async_block_till_done()
assert axis_device.host == '2.3.4.5'
assert axis_device.api.config.host == '2.3.4.5'
assert len(new_address_mock.mock_calls) == 1
async def test_device_not_accessible(): async def test_device_not_accessible():
"""Failed setup schedules a retry of setup.""" """Failed setup schedules a retry of setup."""
hass = Mock() hass = Mock()

View file

@ -9,30 +9,28 @@ from tests.common import mock_coro, MockConfigEntry
async def test_setup(hass): async def test_setup(hass):
"""Test configured options for a device are loaded via config entry.""" """Test configured options for a device are loaded via config entry."""
with patch.object(hass, 'config_entries') as mock_config_entries, \ with patch.object(hass.config_entries, 'flow') as mock_config_flow:
patch.object(axis, 'configured_devices', return_value={}):
assert await async_setup_component(hass, axis.DOMAIN, { assert await async_setup_component(hass, axis.DOMAIN, {
axis.DOMAIN: { axis.DOMAIN: {
'device_name': { 'device_name': {
axis.CONF_HOST: '1.2.3.4', axis.config_flow.CONF_HOST: '1.2.3.4',
axis.config_flow.CONF_PORT: 80, axis.config_flow.CONF_PORT: 80,
} }
} }
}) })
assert len(mock_config_entries.flow.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1
async def test_setup_device_already_configured(hass): async def test_setup_device_already_configured(hass):
"""Test already configured device does not configure a second.""" """Test already configured device does not configure a second."""
with patch.object(hass, 'config_entries') as mock_config_entries, \ with patch.object(hass, 'config_entries') as mock_config_entries:
patch.object(axis, 'configured_devices', return_value={'1.2.3.4'}):
assert await async_setup_component(hass, axis.DOMAIN, { assert await async_setup_component(hass, axis.DOMAIN, {
axis.DOMAIN: { axis.DOMAIN: {
'device_name': { 'device_name': {
axis.CONF_HOST: '1.2.3.4' axis.config_flow.CONF_HOST: '1.2.3.4'
} }
} }
}) })