diff --git a/homeassistant/components/axis/__init__.py b/homeassistant/components/axis/__init__.py index 6082c96863f..e9ed37477a5 100644 --- a/homeassistant/components/axis/__init__.py +++ b/homeassistant/components/axis/__init__.py @@ -4,11 +4,10 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import ( - CONF_DEVICE, CONF_HOST, CONF_NAME, CONF_TRIGGER_TIME, - EVENT_HOMEASSISTANT_STOP) + CONF_DEVICE, CONF_NAME, CONF_TRIGGER_TIME, EVENT_HOMEASSISTANT_STOP) 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 .device import AxisNetworkDevice, get_device @@ -21,18 +20,17 @@ CONFIG_SCHEMA = vol.Schema({ async def async_setup(hass, config): """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(): if CONF_NAME not in device_config: 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( - DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, - data=device_config - )) + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, + data=device_config + )) return True diff --git a/homeassistant/components/axis/camera.py b/homeassistant/components/axis/camera.py index 34b6da778a8..ec1d761d3d0 100644 --- a/homeassistant/components/axis/camera.py +++ b/homeassistant/components/axis/camera.py @@ -52,8 +52,7 @@ class AxisCamera(MjpegCamera): async def async_added_to_hass(self): """Subscribe camera events.""" self.unsub_dispatcher.append(async_dispatcher_connect( - self.hass, 'axis_{}_new_ip'.format(self.device.name), - self._new_ip)) + self.hass, self.device.event_new_address, self._new_address)) self.unsub_dispatcher.append(async_dispatcher_connect( self.hass, self.device.event_reachable, self.update_callback)) @@ -67,10 +66,10 @@ class AxisCamera(MjpegCamera): """Return True if device is available.""" return self.device.available - def _new_ip(self, host): - """Set new IP for video stream.""" - self._mjpeg_url = AXIS_VIDEO.format(host, self.port) - self._still_image_url = AXIS_IMAGE.format(host, self.port) + def _new_address(self): + """Set new device address for video stream.""" + self._mjpeg_url = AXIS_VIDEO.format(self.device.host, self.port) + self._still_image_url = AXIS_IMAGE.format(self.device.host, self.port) @property def unique_id(self): diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 24c286b140a..54d93f768d2 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -40,8 +40,8 @@ DEVICE_SCHEMA = vol.Schema({ @callback def configured_devices(hass): """Return a set of the configured devices.""" - return set(entry.data[CONF_DEVICE][CONF_HOST] for entry - in hass.config_entries.async_entries(DOMAIN)) + return {entry.data[CONF_MAC]: entry for entry + in hass.config_entries.async_entries(DOMAIN)} @config_entries.HANDLERS.register(DOMAIN) @@ -71,9 +71,6 @@ class AxisFlowHandler(config_entries.ConfigFlow): if user_input is not None: try: - if user_input[CONF_HOST] in configured_devices(self.hass): - raise AlreadyConfigured - self.device_config = { CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT], @@ -84,6 +81,10 @@ class AxisFlowHandler(config_entries.ConfigFlow): self.serial_number = device.vapix.get_param( VAPIX_SERIAL_NUMBER) + + if self.serial_number in configured_devices(self.hass): + raise AlreadyConfigured + self.model = device.vapix.get_param(VAPIX_MODEL_ID) return await self._create_entry() @@ -142,22 +143,30 @@ class AxisFlowHandler(config_entries.ConfigFlow): 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): """Prepare configuration for a discovered Axis device. 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'): 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( load_json, self.hass.config.path(CONFIG_FILE)) - serialnumber = discovery_info['properties']['macaddress'] - if serialnumber not in config_file: self.discovery_schema = { vol.Required( diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py index 746808e0d91..3b3a35f1a2d 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -101,8 +101,26 @@ class AxisNetworkDevice: self.api.enable_events(event_callback=self.async_event_callback) self.api.start() + self.config_entry.add_update_listener(self.async_new_address_callback) + 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 def event_reachable(self): """Device specific event to signal a change in connection status.""" @@ -110,7 +128,7 @@ class AxisNetworkDevice: @callback 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. Only signal state change if state change is true. diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py index 086c2692d44..d78123abb79 100644 --- a/tests/components/axis/test_config_flow.py +++ b/tests/components/axis/test_config_flow.py @@ -16,7 +16,7 @@ async def test_configured_devices(hass): assert not result entry = MockConfigEntry(domain=axis.DOMAIN, - data={axis.CONF_DEVICE: {axis.CONF_HOST: ''}}) + data={axis.config_flow.CONF_MAC: '1234'}) entry.add_to_hass(hass) result = config_flow.configured_devices(hass) @@ -76,17 +76,21 @@ async def test_flow_fails_already_configured(hass): flow = config_flow.AxisFlowHandler() flow.hass = hass - entry = MockConfigEntry(domain=axis.DOMAIN, data={axis.CONF_DEVICE: { - axis.CONF_HOST: '1.2.3.4' - }}) + entry = MockConfigEntry(domain=axis.DOMAIN, + data={axis.config_flow.CONF_MAC: '1234'}) entry.add_to_hass(hass) - result = await flow.async_step_user(user_input={ - config_flow.CONF_HOST: '1.2.3.4', - config_flow.CONF_USERNAME: 'user', - config_flow.CONF_PASSWORD: 'pass', - config_flow.CONF_PORT: 81 - }) + 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={ + config_flow.CONF_HOST: '1.2.3.4', + config_flow.CONF_USERNAME: 'user', + config_flow.CONF_PASSWORD: 'pass', + config_flow.CONF_PORT: 81 + }) assert result['errors'] == {'base': 'already_configured'} @@ -220,16 +224,19 @@ async def test_discovery_flow_already_configured(hass): flow = config_flow.AxisFlowHandler() flow.hass = hass - entry = MockConfigEntry(domain=axis.DOMAIN, data={axis.CONF_DEVICE: { - axis.CONF_HOST: '1.2.3.4' - }}) + entry = MockConfigEntry( + 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) result = await flow.async_step_discovery(discovery_info={ config_flow.CONF_HOST: '1.2.3.4', config_flow.CONF_USERNAME: 'user', config_flow.CONF_PASSWORD: 'pass', - config_flow.CONF_PORT: 81 + config_flow.CONF_PORT: 81, + 'properties': {'macaddress': '1234ABCD'} }) print(result) assert result['type'] == 'abort' diff --git a/tests/components/axis/test_device.py b/tests/components/axis/test_device.py index 72d426819c6..35e350b323c 100644 --- a/tests/components/axis/test_device.py +++ b/tests/components/axis/test_device.py @@ -3,9 +3,10 @@ from unittest.mock import Mock, patch 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.camera import AxisCamera DEVICE_DATA = { device.CONF_HOST: '1.2.3.4', @@ -16,7 +17,7 @@ DEVICE_DATA = { ENTRY_OPTIONS = { device.CONF_CAMERA: True, - device.CONF_EVENTS: ['pir'], + device.CONF_EVENTS: True, } ENTRY_CONFIG = { @@ -53,6 +54,31 @@ async def test_device_setup(): (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(): """Failed setup schedules a retry of setup.""" hass = Mock() diff --git a/tests/components/axis/test_init.py b/tests/components/axis/test_init.py index c1c4c06f6ac..737c210b2aa 100644 --- a/tests/components/axis/test_init.py +++ b/tests/components/axis/test_init.py @@ -9,30 +9,28 @@ from tests.common import mock_coro, MockConfigEntry async def test_setup(hass): """Test configured options for a device are loaded via config entry.""" - with patch.object(hass, 'config_entries') as mock_config_entries, \ - patch.object(axis, 'configured_devices', return_value={}): + with patch.object(hass.config_entries, 'flow') as mock_config_flow: assert await async_setup_component(hass, axis.DOMAIN, { axis.DOMAIN: { 'device_name': { - axis.CONF_HOST: '1.2.3.4', + axis.config_flow.CONF_HOST: '1.2.3.4', 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): """Test already configured device does not configure a second.""" - with patch.object(hass, 'config_entries') as mock_config_entries, \ - patch.object(axis, 'configured_devices', return_value={'1.2.3.4'}): + with patch.object(hass, 'config_entries') as mock_config_entries: assert await async_setup_component(hass, axis.DOMAIN, { axis.DOMAIN: { 'device_name': { - axis.CONF_HOST: '1.2.3.4' + axis.config_flow.CONF_HOST: '1.2.3.4' } } })