diff --git a/homeassistant/components/axis/__init__.py b/homeassistant/components/axis/__init__.py index 0cfa8923682..7d9cabd09fa 100644 --- a/homeassistant/components/axis/__init__.py +++ b/homeassistant/components/axis/__init__.py @@ -4,7 +4,8 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import ( - CONF_DEVICE, CONF_NAME, CONF_TRIGGER_TIME, EVENT_HOMEASSISTANT_STOP) + CONF_DEVICE, CONF_MAC, CONF_NAME, CONF_TRIGGER_TIME, + EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers import config_validation as cv from .config_flow import DEVICE_SCHEMA @@ -55,6 +56,12 @@ async def async_setup_entry(hass, config_entry): return True +async def async_unload_entry(hass, config_entry): + """Unload Axis device config entry.""" + device = hass.data[DOMAIN].pop(config_entry.data[CONF_MAC]) + return await device.async_reset() + + async def async_populate_options(hass, config_entry): """Populate default options for device.""" from axis.vapix import VAPIX_IMAGE_FORMAT diff --git a/homeassistant/components/axis/binary_sensor.py b/homeassistant/components/axis/binary_sensor.py index c4393380351..5af3cbac942 100644 --- a/homeassistant/components/axis/binary_sensor.py +++ b/homeassistant/components/axis/binary_sensor.py @@ -42,6 +42,11 @@ class AxisBinarySensor(BinarySensorDevice): self.unsub_dispatcher = async_dispatcher_connect( self.hass, self.device.event_reachable, self.update_callback) + async def async_will_remove_from_hass(self) -> None: + """Disconnect device object when removed.""" + self.event.remove_callback(self.update_callback) + self.unsub_dispatcher() + @callback def update_callback(self, no_delay=False): """Update the sensor's state, if needed. diff --git a/homeassistant/components/axis/camera.py b/homeassistant/components/axis/camera.py index 11368339e0d..457cc23e73d 100644 --- a/homeassistant/components/axis/camera.py +++ b/homeassistant/components/axis/camera.py @@ -56,6 +56,11 @@ class AxisCamera(MjpegCamera): self.unsub_dispatcher.append(async_dispatcher_connect( self.hass, self.device.event_reachable, self.update_callback)) + async def async_will_remove_from_hass(self) -> None: + """Disconnect device object when removed.""" + for unsub_dispatcher in self.unsub_dispatcher: + unsub_dispatcher() + @property def supported_features(self): """Return supported features.""" diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py index 87f382eeb85..155d1c47608 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -159,6 +159,24 @@ class AxisNetworkDevice: """Stop the event stream.""" self.api.stop() + async def async_reset(self): + """Reset this device to default state.""" + self.api.stop() + + if self.config_entry.options[CONF_CAMERA]: + await self.hass.config_entries.async_forward_entry_unload( + self.config_entry, 'camera') + + if self.config_entry.options[CONF_EVENTS]: + await self.hass.config_entries.async_forward_entry_unload( + self.config_entry, 'binary_sensor') + + for unsub_dispatcher in self.listeners: + unsub_dispatcher() + self.listeners = [] + + return True + async def get_device(hass, config): """Create a Axis device.""" diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 66ccce8d98f..0f2b39b9760 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -3,7 +3,7 @@ "name": "Axis", "documentation": "https://www.home-assistant.io/components/axis", "requirements": [ - "axis==19" + "axis==20" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index d954f8749b7..e8513fc0465 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -192,7 +192,7 @@ av==6.1.2 # avion==0.10 # homeassistant.components.axis -axis==19 +axis==20 # homeassistant.components.baidu baidu-aip==1.6.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2977c426060..cfb2e46cac9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -61,7 +61,7 @@ apns2==0.3.0 av==6.1.2 # homeassistant.components.axis -axis==19 +axis==20 # homeassistant.components.zha bellows-homeassistant==0.7.2 diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py index d78123abb79..0ce5757578d 100644 --- a/tests/components/axis/test_config_flow.py +++ b/tests/components/axis/test_config_flow.py @@ -26,9 +26,6 @@ async def test_configured_devices(hass): async def test_flow_works(hass): """Test that config flow works.""" - flow = config_flow.AxisFlowHandler() - flow.hass = hass - with patch('axis.AxisDevice') as mock_device: def mock_constructor( loop, host, username, password, port, web_proto): @@ -48,12 +45,23 @@ async def test_flow_works(hass): mock_device.vapix.load_params.return_value = Mock() mock_device.vapix.get_param.side_effect = mock_get_param - 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 - }) + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={'source': 'user'} + ) + + assert result['type'] == 'form' + assert result['step_id'] == 'user' + + result = await hass.config_entries.flow.async_configure( + result['flow_id'], + 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['type'] == 'create_entry' assert result['title'] == '{} - {}'.format( @@ -162,15 +170,16 @@ async def test_flow_create_entry_more_entries(hass): async def test_discovery_flow(hass): """Test that discovery for new devices work.""" - flow = config_flow.AxisFlowHandler() - flow.hass = hass - with patch.object(axis, 'get_device', return_value=mock_coro(Mock())): - result = await flow.async_step_discovery(discovery_info={ - config_flow.CONF_HOST: '1.2.3.4', - config_flow.CONF_PORT: 80, - 'properties': {'macaddress': '1234'} - }) + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + data={ + config_flow.CONF_HOST: '1.2.3.4', + config_flow.CONF_PORT: 80, + 'properties': {'macaddress': '1234'} + }, + context={'source': 'discovery'} + ) assert result['type'] == 'form' assert result['step_id'] == 'user' @@ -181,9 +190,6 @@ async def test_discovery_flow_known_device(hass): This is legacy support from devices registered with configurator. """ - flow = config_flow.AxisFlowHandler() - flow.hass = hass - with patch('homeassistant.components.axis.config_flow.load_json', return_value={'1234ABCD': { config_flow.CONF_HOST: '2.3.4.5', @@ -209,21 +215,22 @@ async def test_discovery_flow_known_device(hass): mock_device.vapix.load_params.return_value = Mock() mock_device.vapix.get_param.side_effect = mock_get_param - result = await flow.async_step_discovery(discovery_info={ - config_flow.CONF_HOST: '1.2.3.4', - config_flow.CONF_PORT: 80, - 'hostname': 'name', - 'properties': {'macaddress': '1234ABCD'} - }) + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + data={ + config_flow.CONF_HOST: '1.2.3.4', + config_flow.CONF_PORT: 80, + 'hostname': 'name', + 'properties': {'macaddress': '1234ABCD'} + }, + context={'source': 'discovery'} + ) assert result['type'] == 'create_entry' async def test_discovery_flow_already_configured(hass): """Test that discovery doesn't setup already configured devices.""" - flow = config_flow.AxisFlowHandler() - flow.hass = hass - entry = MockConfigEntry( domain=axis.DOMAIN, data={axis.CONF_DEVICE: {axis.config_flow.CONF_HOST: '1.2.3.4'}, @@ -231,34 +238,37 @@ async def test_discovery_flow_already_configured(hass): ) 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, - 'properties': {'macaddress': '1234ABCD'} - }) - print(result) + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + data={ + config_flow.CONF_HOST: '1.2.3.4', + config_flow.CONF_USERNAME: 'user', + config_flow.CONF_PASSWORD: 'pass', + config_flow.CONF_PORT: 80, + 'hostname': 'name', + 'properties': {'macaddress': '1234ABCD'} + }, + context={'source': 'discovery'} + ) + assert result['type'] == 'abort' + assert result['reason'] == 'already_configured' -async def test_discovery_flow_link_local_address(hass): +async def test_discovery_flow_ignore_link_local_address(hass): """Test that discovery doesn't setup devices with link local addresses.""" - flow = config_flow.AxisFlowHandler() - flow.hass = hass - - result = await flow.async_step_discovery(discovery_info={ - config_flow.CONF_HOST: '169.254.3.4' - }) + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + data={config_flow.CONF_HOST: '169.254.3.4'}, + context={'source': 'discovery'} + ) assert result['type'] == 'abort' + assert result['reason'] == 'link_local_address' async def test_discovery_flow_bad_config_file(hass): """Test that discovery with bad config files abort.""" - flow = config_flow.AxisFlowHandler() - flow.hass = hass - with patch('homeassistant.components.axis.config_flow.load_json', return_value={'1234ABCD': { config_flow.CONF_HOST: '2.3.4.5', @@ -267,19 +277,21 @@ async def test_discovery_flow_bad_config_file(hass): config_flow.CONF_PORT: 80}}), \ patch('homeassistant.components.axis.config_flow.DEVICE_SCHEMA', side_effect=config_flow.vol.Invalid('')): - result = await flow.async_step_discovery(discovery_info={ - config_flow.CONF_HOST: '1.2.3.4', - 'properties': {'macaddress': '1234ABCD'} - }) + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + data={ + config_flow.CONF_HOST: '1.2.3.4', + 'properties': {'macaddress': '1234ABCD'} + }, + context={'source': 'discovery'} + ) assert result['type'] == 'abort' + assert result['reason'] == 'bad_config_file' async def test_import_flow_works(hass): """Test that import flow works.""" - flow = config_flow.AxisFlowHandler() - flow.hass = hass - with patch('axis.AxisDevice') as mock_device: def mock_constructor( loop, host, username, password, port, web_proto): @@ -299,13 +311,17 @@ async def test_import_flow_works(hass): mock_device.vapix.load_params.return_value = Mock() mock_device.vapix.get_param.side_effect = mock_get_param - result = await flow.async_step_import(import_config={ - 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_NAME: 'name' - }) + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + data={ + config_flow.CONF_HOST: '1.2.3.4', + config_flow.CONF_USERNAME: 'user', + config_flow.CONF_PASSWORD: 'pass', + config_flow.CONF_PORT: 80, + config_flow.CONF_NAME: 'name' + }, + context={'source': 'import'} + ) assert result['type'] == 'create_entry' assert result['title'] == '{} - {}'.format( @@ -315,7 +331,7 @@ async def test_import_flow_works(hass): 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: 80 }, config_flow.CONF_MAC: axis_lib.vapix.VAPIX_SERIAL_NUMBER, config_flow.CONF_MODEL: axis_lib.vapix.VAPIX_MODEL_ID, diff --git a/tests/components/axis/test_device.py b/tests/components/axis/test_device.py index 35e350b323c..f6d17a3ef38 100644 --- a/tests/components/axis/test_device.py +++ b/tests/components/axis/test_device.py @@ -70,6 +70,9 @@ async def test_device_signal_new_address(hass): await axis_device.async_setup() await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + assert len(axis_device.listeners) == 1 + 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() @@ -79,6 +82,50 @@ async def test_device_signal_new_address(hass): assert len(new_address_mock.mock_calls) == 1 +async def test_device_unavailable(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(device, 'async_dispatcher_send') as mock_dispatcher: + await axis_device.async_setup() + await hass.async_block_till_done() + + axis_device.async_connection_status_callback(status=False) + + assert not axis_device.available + assert len(mock_dispatcher.mock_calls) == 1 + + +async def test_device_reset(hass): + """Successfully reset device.""" + 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)): + await axis_device.async_setup() + await hass.async_block_till_done() + + await axis_device.async_reset() + + assert len(api.stop.mock_calls) == 1 + assert len(hass.states.async_all()) == 0 + assert len(axis_device.listeners) == 0 + + 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 737c210b2aa..0fc57df2ff0 100644 --- a/tests/components/axis/test_init.py +++ b/tests/components/axis/test_init.py @@ -49,10 +49,11 @@ async def test_setup_entry(hass): entry = MockConfigEntry( domain=axis.DOMAIN, data={axis.device.CONF_MAC: '0123'}) - mock_device = Mock() - mock_device.async_setup.return_value = mock_coro(True) - mock_device.async_update_device_registry.return_value = mock_coro(True) - mock_device.serial.return_value = '1' + mock_device = axis.AxisNetworkDevice(hass, entry) + mock_device.async_setup = Mock(return_value=mock_coro(True)) + mock_device.async_update_device_registry = \ + Mock(return_value=mock_coro(True)) + mock_device.async_reset = Mock(return_value=mock_coro(True)) with patch.object(axis, 'AxisNetworkDevice') as mock_device_class, \ patch.object( @@ -62,6 +63,7 @@ async def test_setup_entry(hass): assert await axis.async_setup_entry(hass, entry) assert len(hass.data[axis.DOMAIN]) == 1 + assert '0123' in hass.data[axis.DOMAIN] async def test_setup_entry_fails(hass): @@ -80,6 +82,28 @@ async def test_setup_entry_fails(hass): assert not hass.data[axis.DOMAIN] +async def test_unload_entry(hass): + """Test successful unload of entry.""" + entry = MockConfigEntry( + domain=axis.DOMAIN, data={axis.device.CONF_MAC: '0123'}) + + mock_device = axis.AxisNetworkDevice(hass, entry) + mock_device.async_setup = Mock(return_value=mock_coro(True)) + mock_device.async_update_device_registry = \ + Mock(return_value=mock_coro(True)) + mock_device.async_reset = Mock(return_value=mock_coro(True)) + + with patch.object(axis, 'AxisNetworkDevice') as mock_device_class, \ + patch.object( + axis, 'async_populate_options', return_value=mock_coro(True)): + mock_device_class.return_value = mock_device + + assert await axis.async_setup_entry(hass, entry) + + assert await axis.async_unload_entry(hass, entry) + assert not hass.data[axis.DOMAIN] + + async def test_populate_options(hass): """Test successful populate options.""" entry = MockConfigEntry(domain=axis.DOMAIN, data={'device': {}})