Make Axis integration use config entry unique id (#30461)

* Make Axis integration use config entry unique id
This commit is contained in:
Robert Svensson 2020-01-04 08:58:18 +01:00 committed by GitHub
parent 075d3f6e32
commit 63347ebeb5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 87 additions and 133 deletions

View File

@ -4,7 +4,8 @@
"already_configured": "Device is already configured", "already_configured": "Device is already configured",
"bad_config_file": "Bad data from config file", "bad_config_file": "Bad data from config file",
"link_local_address": "Link local addresses are not supported", "link_local_address": "Link local addresses are not supported",
"not_axis_device": "Discovered device not an Axis device" "not_axis_device": "Discovered device not an Axis device",
"updated_configuration": "Updated device configuration with new host address"
}, },
"error": { "error": {
"already_configured": "Device is already configured", "already_configured": "Device is already configured",

View File

@ -29,6 +29,12 @@ async def async_setup_entry(hass, config_entry):
if not await device.async_setup(): if not await device.async_setup():
return False return False
# 0.104 introduced config entry unique id, this makes upgrading possible
if config_entry.unique_id is None:
hass.config_entries.async_update_entry(
config_entry, unique_id=device.api.vapix.params.system_serialnumber
)
hass.data[DOMAIN][device.serial] = device hass.data[DOMAIN][device.serial] = device
await device.async_update_device_registry() await device.async_update_device_registry()

View File

@ -5,7 +5,7 @@ from datetime import timedelta
from axis.event_stream import CLASS_INPUT, CLASS_OUTPUT from axis.event_stream import CLASS_INPUT, CLASS_OUTPUT
from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.binary_sensor import BinarySensorDevice
from homeassistant.const import CONF_MAC, CONF_TRIGGER_TIME from homeassistant.const import CONF_TRIGGER_TIME
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.event import async_track_point_in_utc_time
@ -17,8 +17,7 @@ from .const import DOMAIN as AXIS_DOMAIN
async def async_setup_entry(hass, config_entry, async_add_entities): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up a Axis binary sensor.""" """Set up a Axis binary sensor."""
serial_number = config_entry.data[CONF_MAC] device = hass.data[AXIS_DOMAIN][config_entry.unique_id]
device = hass.data[AXIS_DOMAIN][serial_number]
@callback @callback
def async_add_sensor(event_id): def async_add_sensor(event_id):

View File

@ -11,7 +11,6 @@ from homeassistant.const import (
CONF_AUTHENTICATION, CONF_AUTHENTICATION,
CONF_DEVICE, CONF_DEVICE,
CONF_HOST, CONF_HOST,
CONF_MAC,
CONF_NAME, CONF_NAME,
CONF_PASSWORD, CONF_PASSWORD,
CONF_PORT, CONF_PORT,
@ -32,8 +31,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Axis camera video stream.""" """Set up the Axis camera video stream."""
filter_urllib3_logging() filter_urllib3_logging()
serial_number = config_entry.data[CONF_MAC] device = hass.data[AXIS_DOMAIN][config_entry.unique_id]
device = hass.data[AXIS_DOMAIN][serial_number]
config = { config = {
CONF_NAME: config_entry.data[CONF_NAME], CONF_NAME: config_entry.data[CONF_NAME],

View File

@ -12,12 +12,10 @@ from homeassistant.const import (
CONF_PORT, CONF_PORT,
CONF_USERNAME, CONF_USERNAME,
) )
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from .const import CONF_MODEL, DOMAIN from .const import CONF_MODEL, DOMAIN
from .device import get_device from .device import get_device
from .errors import AlreadyConfigured, AuthenticationRequired, CannotConnect from .errors import AuthenticationRequired, CannotConnect
AXIS_OUI = {"00408C", "ACCC8E", "B8A44F"} AXIS_OUI = {"00408C", "ACCC8E", "B8A44F"}
@ -29,31 +27,8 @@ PLATFORMS = ["camera"]
AXIS_INCLUDE = EVENT_TYPES + PLATFORMS AXIS_INCLUDE = EVENT_TYPES + PLATFORMS
AXIS_DEFAULT_HOST = "192.168.0.90"
AXIS_DEFAULT_USERNAME = "root"
AXIS_DEFAULT_PASSWORD = "pass"
DEFAULT_PORT = 80 DEFAULT_PORT = 80
DEVICE_SCHEMA = vol.Schema(
{
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_HOST, default=AXIS_DEFAULT_HOST): cv.string,
vol.Optional(CONF_USERNAME, default=AXIS_DEFAULT_USERNAME): cv.string,
vol.Optional(CONF_PASSWORD, default=AXIS_DEFAULT_PASSWORD): cv.string,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
},
extra=vol.ALLOW_EXTRA,
)
@callback
def configured_devices(hass):
"""Return a set of the configured devices."""
return {
entry.data[CONF_MAC]: entry
for entry in hass.config_entries.async_entries(DOMAIN)
}
class AxisFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class AxisFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a Axis config flow.""" """Handle a Axis config flow."""
@ -90,16 +65,13 @@ class AxisFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
self.serial_number = device.vapix.params.system_serialnumber self.serial_number = device.vapix.params.system_serialnumber
if self.serial_number in configured_devices(self.hass): await self.async_set_unique_id(self.serial_number)
raise AlreadyConfigured self._abort_if_unique_id_configured()
self.model = device.vapix.params.prodnbr self.model = device.vapix.params.prodnbr
return await self._create_entry() return await self._create_entry()
except AlreadyConfigured:
errors["base"] = "already_configured"
except AuthenticationRequired: except AuthenticationRequired:
errors["base"] = "faulty_credentials" errors["base"] = "faulty_credentials"
@ -147,40 +119,39 @@ class AxisFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
title = f"{self.model} - {self.serial_number}" title = f"{self.model} - {self.serial_number}"
return self.async_create_entry(title=title, data=data) return self.async_create_entry(title=title, data=data)
async def _update_entry(self, entry, host): def _update_entry(self, entry, host, port):
"""Update existing entry if it is the same device.""" """Update existing entry."""
if (
entry.data[CONF_DEVICE][CONF_HOST] == host
and entry.data[CONF_DEVICE][CONF_PORT] == port
):
return self.async_abort(reason="already_configured")
entry.data[CONF_DEVICE][CONF_HOST] = host entry.data[CONF_DEVICE][CONF_HOST] = host
entry.data[CONF_DEVICE][CONF_PORT] = port
self.hass.config_entries.async_update_entry(entry) self.hass.config_entries.async_update_entry(entry)
return self.async_abort(reason="updated_configuration")
async def async_step_zeroconf(self, discovery_info): async def async_step_zeroconf(self, discovery_info):
"""Prepare configuration for a discovered Axis device. """Prepare configuration for a discovered Axis device."""
serial_number = discovery_info["properties"]["macaddress"]
This flow is triggered by the discovery component. if serial_number[:6] not in AXIS_OUI:
"""
serialnumber = discovery_info["properties"]["macaddress"]
if serialnumber[:6] not in AXIS_OUI:
return self.async_abort(reason="not_axis_device") return self.async_abort(reason="not_axis_device")
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")
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 for entry in self.hass.config_entries.async_entries(DOMAIN):
self.context["macaddress"] = serialnumber if serial_number == entry.unique_id:
return self._update_entry(
if any( entry,
serialnumber == flow["context"]["macaddress"] host=discovery_info[CONF_HOST],
for flow in self._async_in_progress() port=discovery_info[CONF_PORT],
): )
return self.async_abort(reason="already_in_progress")
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")
await self.async_set_unique_id(serial_number)
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
self.context["title_placeholders"] = { self.context["title_placeholders"] = {
"name": discovery_info["hostname"][:-7], "name": discovery_info["hostname"][:-7],

View File

@ -56,8 +56,8 @@ class AxisNetworkDevice:
@property @property
def serial(self): def serial(self):
"""Return the mac of this device.""" """Return the serial number of this device."""
return self.config_entry.data[CONF_MAC] return self.config_entry.unique_id
async def async_update_device_registry(self): async def async_update_device_registry(self):
"""Update device registry.""" """Update device registry."""

View File

@ -23,7 +23,8 @@
"already_configured": "Device is already configured", "already_configured": "Device is already configured",
"bad_config_file": "Bad data from config file", "bad_config_file": "Bad data from config file",
"link_local_address": "Link local addresses are not supported", "link_local_address": "Link local addresses are not supported",
"not_axis_device": "Discovered device not an Axis device" "not_axis_device": "Discovered device not an Axis device",
"updated_configuration": "Updated device configuration with new host address"
} }
} }
} }

View File

@ -3,7 +3,6 @@
from axis.event_stream import CLASS_OUTPUT from axis.event_stream import CLASS_OUTPUT
from homeassistant.components.switch import SwitchDevice from homeassistant.components.switch import SwitchDevice
from homeassistant.const import CONF_MAC
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
@ -13,8 +12,7 @@ from .const import DOMAIN as AXIS_DOMAIN
async def async_setup_entry(hass, config_entry, async_add_entities): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up a Axis switch.""" """Set up a Axis switch."""
serial_number = config_entry.data[CONF_MAC] device = hass.data[AXIS_DOMAIN][config_entry.unique_id]
device = hass.data[AXIS_DOMAIN][serial_number]
@callback @callback
def async_add_switch(event_id): def async_add_switch(event_id):

View File

@ -1,28 +1,17 @@
"""Test Axis config flow.""" """Test Axis config flow."""
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
import pytest
import homeassistant
from homeassistant.components import axis from homeassistant.components import axis
from homeassistant.components.axis import config_flow from homeassistant.components.axis import config_flow
from .test_device import MAC, setup_axis_integration
from tests.common import MockConfigEntry, mock_coro from tests.common import MockConfigEntry, mock_coro
async def test_configured_devices(hass):
"""Test that configured devices works as expected."""
result = config_flow.configured_devices(hass)
assert not result
entry = MockConfigEntry(
domain=axis.DOMAIN, data={axis.config_flow.CONF_MAC: "1234"}
)
entry.add_to_hass(hass)
result = config_flow.configured_devices(hass)
assert len(result) == 1
async def test_flow_works(hass): async def test_flow_works(hass):
"""Test that config flow works.""" """Test that config flow works."""
with patch("axis.AxisDevice") as mock_device: with patch("axis.AxisDevice") as mock_device:
@ -76,22 +65,20 @@ async def test_flow_works(hass):
async def test_flow_fails_already_configured(hass): async def test_flow_fails_already_configured(hass):
"""Test that config flow fails on already configured device.""" """Test that config flow fails on already configured device."""
await setup_axis_integration(hass)
flow = config_flow.AxisFlowHandler() flow = config_flow.AxisFlowHandler()
flow.hass = hass flow.hass = hass
flow.context = {}
entry = MockConfigEntry(
domain=axis.DOMAIN, data={axis.config_flow.CONF_MAC: "1234"}
)
entry.add_to_hass(hass)
mock_device = Mock() mock_device = Mock()
mock_device.vapix.params.system_serialnumber = "1234" mock_device.vapix.params.system_serialnumber = MAC
with patch( with patch(
"homeassistant.components.axis.config_flow.get_device", "homeassistant.components.axis.config_flow.get_device",
return_value=mock_coro(mock_device), return_value=mock_coro(mock_device),
): ), pytest.raises(homeassistant.data_entry_flow.AbortFlow):
result = await flow.async_step_user( await flow.async_step_user(
user_input={ 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",
@ -100,8 +87,6 @@ async def test_flow_fails_already_configured(hass):
} }
) )
assert result["errors"] == {"base": "already_configured"}
async def test_flow_fails_faulty_credentials(hass): async def test_flow_fails_faulty_credentials(hass):
"""Test that config flow fails on faulty credentials.""" """Test that config flow fails on faulty credentials."""
@ -198,21 +183,13 @@ async def test_zeroconf_flow(hass):
async def test_zeroconf_flow_already_configured(hass): async def test_zeroconf_flow_already_configured(hass):
"""Test that zeroconf doesn't setup already configured devices.""" """Test that zeroconf doesn't setup already configured devices."""
entry = MockConfigEntry( device = await setup_axis_integration(hass)
domain=axis.DOMAIN, assert device.host == "1.2.3.4"
data={
axis.CONF_DEVICE: {axis.config_flow.CONF_HOST: "1.2.3.4"},
axis.config_flow.CONF_MAC: "00408C12345",
},
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN, config_flow.DOMAIN,
data={ data={
config_flow.CONF_HOST: "1.2.3.4", 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_PORT: 80,
"hostname": "name", "hostname": "name",
"properties": {"macaddress": "00408C12345"}, "properties": {"macaddress": "00408C12345"},
@ -222,6 +199,31 @@ async def test_zeroconf_flow_already_configured(hass):
assert result["type"] == "abort" assert result["type"] == "abort"
assert result["reason"] == "already_configured" assert result["reason"] == "already_configured"
assert device.host == "1.2.3.4"
async def test_zeroconf_flow_updated_configuration(hass):
"""Test that zeroconf update configuration with new parameters."""
device = await setup_axis_integration(hass)
assert device.host == "1.2.3.4"
result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN,
data={
config_flow.CONF_HOST: "2.3.4.5",
config_flow.CONF_PORT: 8080,
"hostname": "name",
"properties": {"macaddress": MAC},
},
context={"source": "zeroconf"},
)
assert result["type"] == "abort"
assert result["reason"] == "updated_configuration"
assert device.host == "2.3.4.5"
assert (
device.config_entry.data[config_flow.CONF_DEVICE][config_flow.CONF_PORT] == 8080
)
async def test_zeroconf_flow_ignore_non_axis_device(hass): async def test_zeroconf_flow_ignore_non_axis_device(hass):

View File

@ -18,7 +18,7 @@ DEVICE_DATA = {
axis.device.CONF_HOST: "1.2.3.4", axis.device.CONF_HOST: "1.2.3.4",
axis.device.CONF_USERNAME: "username", axis.device.CONF_USERNAME: "username",
axis.device.CONF_PASSWORD: "password", axis.device.CONF_PASSWORD: "password",
axis.device.CONF_PORT: 1234, axis.device.CONF_PORT: 80,
} }
ENTRY_OPTIONS = {axis.device.CONF_CAMERA: True, axis.device.CONF_EVENTS: True} ENTRY_OPTIONS = {axis.device.CONF_CAMERA: True, axis.device.CONF_EVENTS: True}

View File

@ -4,6 +4,8 @@ from unittest.mock import Mock, patch
from homeassistant.components import axis from homeassistant.components import axis
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from .test_device import MAC, setup_axis_integration
from tests.common import MockConfigEntry, mock_coro from tests.common import MockConfigEntry, mock_coro
@ -28,22 +30,9 @@ async def test_setup_no_config(hass):
async def test_setup_entry(hass): async def test_setup_entry(hass):
"""Test successful setup of entry.""" """Test successful setup of entry."""
entry = MockConfigEntry(domain=axis.DOMAIN, data={axis.device.CONF_MAC: "0123"}) await setup_axis_integration(hass)
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 len(hass.data[axis.DOMAIN]) == 1 assert len(hass.data[axis.DOMAIN]) == 1
assert "0123" in hass.data[axis.DOMAIN] assert MAC in hass.data[axis.DOMAIN]
async def test_setup_entry_fails(hass): async def test_setup_entry_fails(hass):
@ -65,21 +54,10 @@ async def test_setup_entry_fails(hass):
async def test_unload_entry(hass): async def test_unload_entry(hass):
"""Test successful unload of entry.""" """Test successful unload of entry."""
entry = MockConfigEntry(domain=axis.DOMAIN, data={axis.device.CONF_MAC: "0123"}) device = await setup_axis_integration(hass)
assert hass.data[axis.DOMAIN]
mock_device = axis.AxisNetworkDevice(hass, entry) assert await axis.async_unload_entry(hass, device.config_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] assert not hass.data[axis.DOMAIN]