diff --git a/homeassistant/components/axis/__init__.py b/homeassistant/components/axis/__init__.py index f25348b44cd..c467359c17e 100644 --- a/homeassistant/components/axis/__init__.py +++ b/homeassistant/components/axis/__init__.py @@ -2,7 +2,10 @@ import logging -from homeassistant.const import CONF_DEVICE, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_DEVICE, CONF_MAC, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import callback +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.entity_registry import async_migrate_entries from .const import DOMAIN as AXIS_DOMAIN from .device import AxisNetworkDevice @@ -24,12 +27,6 @@ async def async_setup_entry(hass, config_entry): if not await device.async_setup(): 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.serial_number - ) - hass.data[AXIS_DOMAIN][config_entry.unique_id] = device await device.async_update_device_registry() @@ -52,9 +49,28 @@ async def async_migrate_entry(hass, config_entry): # Flatten configuration but keep old data if user rollbacks HASS prior to 0.106 if config_entry.version == 1: config_entry.data = {**config_entry.data, **config_entry.data[CONF_DEVICE]} - + config_entry.unique_id = config_entry.data[CONF_MAC] config_entry.version = 2 + # Normalise MAC address of device which also affects entity unique IDs + if config_entry.version == 2: + old_unique_id = config_entry.unique_id + new_unique_id = format_mac(old_unique_id) + + @callback + def update_unique_id(entity_entry): + """Update unique ID of entity entry.""" + return { + "new_unique_id": entity_entry.unique_id.replace( + old_unique_id, new_unique_id + ) + } + + await async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + + config_entry.unique_id = new_unique_id + config_entry.version = 3 + _LOGGER.info("Migration to version %s successful", config_entry.version) return True diff --git a/homeassistant/components/axis/axis_base.py b/homeassistant/components/axis/axis_base.py index 976e779c20e..3e2b1a48eb7 100644 --- a/homeassistant/components/axis/axis_base.py +++ b/homeassistant/components/axis/axis_base.py @@ -30,7 +30,7 @@ class AxisEntityBase(Entity): @property def device_info(self): """Return a device description for device registry.""" - return {"identifiers": {(AXIS_DOMAIN, self.device.serial)}} + return {"identifiers": {(AXIS_DOMAIN, self.device.unique_id)}} @callback def update_callback(self, no_delay=None): @@ -73,4 +73,4 @@ class AxisEventBase(AxisEntityBase): @property def unique_id(self): """Return a unique identifier for this device.""" - return f"{self.device.serial}-{self.event.topic}-{self.event.id}" + return f"{self.device.unique_id}-{self.event.topic}-{self.event.id}" diff --git a/homeassistant/components/axis/camera.py b/homeassistant/components/axis/camera.py index 69047268b07..542f5e0f39f 100644 --- a/homeassistant/components/axis/camera.py +++ b/homeassistant/components/axis/camera.py @@ -73,7 +73,7 @@ class AxisCamera(AxisEntityBase, MjpegCamera): @property def unique_id(self): """Return a unique identifier for this device.""" - return f"{self.device.serial}-camera" + return f"{self.device.unique_id}-camera" @property def image_source(self): diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 8d52b7f8d9f..60f76edbfaf 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -8,13 +8,13 @@ from homeassistant import config_entries from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import ( CONF_HOST, - CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.helpers.device_registry import format_mac from homeassistant.util.network import is_link_local from .const import ( @@ -26,7 +26,7 @@ from .const import ( from .device import get_device from .errors import AuthenticationRequired, CannotConnect -AXIS_OUI = {"00408C", "ACCC8E", "B8A44F"} +AXIS_OUI = {"00:40:8c", "ac:cc:8e", "b8:a4:4f"} CONFIG_FILE = "axis.conf" @@ -42,7 +42,7 @@ DEFAULT_PORT = 80 class AxisFlowHandler(config_entries.ConfigFlow, domain=AXIS_DOMAIN): """Handle a Axis config flow.""" - VERSION = 2 + VERSION = 3 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH @staticmethod @@ -74,7 +74,7 @@ class AxisFlowHandler(config_entries.ConfigFlow, domain=AXIS_DOMAIN): password=user_input[CONF_PASSWORD], ) - await self.async_set_unique_id(device.vapix.serial_number) + await self.async_set_unique_id(format_mac(device.vapix.serial_number)) self._abort_if_unique_id_configured( updates={ @@ -88,7 +88,6 @@ class AxisFlowHandler(config_entries.ConfigFlow, domain=AXIS_DOMAIN): CONF_PORT: user_input[CONF_PORT], CONF_USERNAME: user_input[CONF_USERNAME], CONF_PASSWORD: user_input[CONF_PASSWORD], - CONF_MAC: device.vapix.serial_number, CONF_MODEL: device.vapix.product_number, } @@ -134,14 +133,14 @@ class AxisFlowHandler(config_entries.ConfigFlow, domain=AXIS_DOMAIN): self.device_config[CONF_NAME] = name - title = f"{model} - {self.device_config[CONF_MAC]}" + title = f"{model} - {self.unique_id}" return self.async_create_entry(title=title, data=self.device_config) async def async_step_zeroconf(self, discovery_info): """Prepare configuration for a discovered Axis device.""" - serial_number = discovery_info["properties"]["macaddress"] + serial_number = format_mac(discovery_info["properties"]["macaddress"]) - if serial_number[:6] not in AXIS_OUI: + if serial_number[:8] not in AXIS_OUI: return self.async_abort(reason="not_axis_device") if is_link_local(ip_address(discovery_info[CONF_HOST])): diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py index d589ebb46bd..f9919d3e4eb 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -74,8 +74,8 @@ class AxisNetworkDevice: return self.config_entry.data[CONF_NAME] @property - def serial(self): - """Return the serial number of this device.""" + def unique_id(self): + """Return the unique ID (serial number) of this device.""" return self.config_entry.unique_id # Options @@ -102,17 +102,17 @@ class AxisNetworkDevice: @property def signal_reachable(self): """Device specific event to signal a change in connection status.""" - return f"axis_reachable_{self.serial}" + return f"axis_reachable_{self.unique_id}" @property def signal_new_event(self): """Device specific event to signal new device event available.""" - return f"axis_new_event_{self.serial}" + return f"axis_new_event_{self.unique_id}" @property def signal_new_address(self): """Device specific event to signal a change in device address.""" - return f"axis_new_address_{self.serial}" + return f"axis_new_address_{self.unique_id}" # Callbacks @@ -151,8 +151,8 @@ class AxisNetworkDevice: device_registry = await self.hass.helpers.device_registry.async_get_registry() device_registry.async_get_or_create( config_entry_id=self.config_entry.entry_id, - connections={(CONNECTION_NETWORK_MAC, self.serial)}, - identifiers={(AXIS_DOMAIN, self.serial)}, + connections={(CONNECTION_NETWORK_MAC, self.unique_id)}, + identifiers={(AXIS_DOMAIN, self.unique_id)}, manufacturer=ATTR_MANUFACTURER, model=f"{self.model} {self.product_type}", name=self.name, @@ -169,7 +169,9 @@ class AxisNetworkDevice: if status.get("data", {}).get("status", {}).get("state") == "active": self.listeners.append( - await mqtt.async_subscribe(hass, f"{self.serial}/#", self.mqtt_message) + await mqtt.async_subscribe( + hass, f"{self.api.vapix.serial_number}/#", self.mqtt_message + ) ) @callback diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py index 91674883378..dd66790e7fb 100644 --- a/tests/components/axis/test_config_flow.py +++ b/tests/components/axis/test_config_flow.py @@ -15,7 +15,6 @@ from homeassistant.components.axis.const import ( from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import ( CONF_HOST, - CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_PORT, @@ -26,6 +25,7 @@ from homeassistant.data_entry_flow import ( RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM, ) +from homeassistant.helpers.device_registry import format_mac from .test_device import ( MAC, @@ -62,13 +62,12 @@ async def test_flow_manual_configuration(hass): ) assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == f"M1065-LW - {MAC}" + assert result["title"] == f"M1065-LW - {format_mac(MAC)}" assert result["data"] == { CONF_HOST: "1.2.3.4", CONF_USERNAME: "user", CONF_PASSWORD: "pass", CONF_PORT: 80, - CONF_MAC: MAC, CONF_MODEL: "M1065-LW", CONF_NAME: "M1065-LW 0", } @@ -220,13 +219,12 @@ async def test_flow_create_entry_multiple_existing_entries_of_same_model(hass): ) assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == f"M1065-LW - {MAC}" + assert result["title"] == f"M1065-LW - {format_mac(MAC)}" assert result["data"] == { CONF_HOST: "1.2.3.4", CONF_USERNAME: "user", CONF_PASSWORD: "pass", CONF_PORT: 80, - CONF_MAC: MAC, CONF_MODEL: "M1065-LW", CONF_NAME: "M1065-LW 2", } @@ -263,13 +261,12 @@ async def test_zeroconf_flow(hass): ) assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == f"M1065-LW - {MAC}" + assert result["title"] == f"M1065-LW - {format_mac(MAC)}" assert result["data"] == { CONF_HOST: "1.2.3.4", CONF_USERNAME: "user", CONF_PASSWORD: "pass", CONF_PORT: 80, - CONF_MAC: MAC, CONF_MODEL: "M1065-LW", CONF_NAME: "M1065-LW 0", } @@ -309,7 +306,6 @@ async def test_zeroconf_flow_updated_configuration(hass): CONF_PORT: 80, CONF_USERNAME: "root", CONF_PASSWORD: "pass", - CONF_MAC: MAC, CONF_MODEL: MODEL, CONF_NAME: NAME, } @@ -338,7 +334,6 @@ async def test_zeroconf_flow_updated_configuration(hass): CONF_PORT: 8080, CONF_USERNAME: "root", CONF_PASSWORD: "pass", - CONF_MAC: MAC, CONF_MODEL: MODEL, CONF_NAME: NAME, } diff --git a/tests/components/axis/test_device.py b/tests/components/axis/test_device.py index 6c3b35125be..99ac739b4d4 100644 --- a/tests/components/axis/test_device.py +++ b/tests/components/axis/test_device.py @@ -19,7 +19,6 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAI from homeassistant.config_entries import SOURCE_ZEROCONF from homeassistant.const import ( CONF_HOST, - CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_PORT, @@ -29,7 +28,8 @@ from homeassistant.const import ( from tests.common import MockConfigEntry, async_fire_mqtt_message -MAC = "00408C12345" +MAC = "00408C123456" +FORMATTED_MAC = "00:40:8c:12:34:56" MODEL = "model" NAME = "name" @@ -42,7 +42,6 @@ ENTRY_CONFIG = { CONF_USERNAME: "root", CONF_PASSWORD: "pass", CONF_PORT: 80, - CONF_MAC: MAC, CONF_MODEL: MODEL, CONF_NAME: NAME, } @@ -80,7 +79,7 @@ BASIC_DEVICE_INFO_RESPONSE = { "propertyList": { "ProdNbr": "M1065-LW", "ProdType": "Network Camera", - "SerialNumber": "00408C12345", + "SerialNumber": MAC, "Version": "9.80.1", } }, @@ -170,7 +169,7 @@ root.IOPort.I0.Input.Trig=closed root.Output.NbrOfOutputs=0 """ -PROPERTIES_RESPONSE = """root.Properties.API.HTTP.Version=3 +PROPERTIES_RESPONSE = f"""root.Properties.API.HTTP.Version=3 root.Properties.API.Metadata.Metadata=yes root.Properties.API.Metadata.Version=1.0 root.Properties.EmbeddedDevelopment.Version=2.16 @@ -181,7 +180,7 @@ root.Properties.Image.Format=jpeg,mjpeg,h264 root.Properties.Image.NbrOfViews=2 root.Properties.Image.Resolution=1920x1080,1280x960,1280x720,1024x768,1024x576,800x600,640x480,640x360,352x240,320x240 root.Properties.Image.Rotation=0,180 -root.Properties.System.SerialNumber=00408C12345 +root.Properties.System.SerialNumber={MAC} """ PTZ_RESPONSE = "" @@ -284,7 +283,8 @@ async def setup_axis_integration(hass, config=ENTRY_CONFIG, options=ENTRY_OPTION data=deepcopy(config), connection_class=config_entries.CONN_CLASS_LOCAL_PUSH, options=deepcopy(options), - version=2, + version=3, + unique_id=FORMATTED_MAC, ) config_entry.add_to_hass(hass) @@ -308,7 +308,7 @@ async def test_device_setup(hass): assert device.api.vapix.firmware_version == "9.10.1" assert device.api.vapix.product_number == "M1065-LW" assert device.api.vapix.product_type == "Network Camera" - assert device.api.vapix.serial_number == "00408C12345" + assert device.api.vapix.serial_number == "00408C123456" entry = device.config_entry @@ -321,7 +321,7 @@ async def test_device_setup(hass): assert device.host == ENTRY_CONFIG[CONF_HOST] assert device.model == ENTRY_CONFIG[CONF_MODEL] assert device.name == ENTRY_CONFIG[CONF_NAME] - assert device.serial == ENTRY_CONFIG[CONF_MAC] + assert device.unique_id == FORMATTED_MAC async def test_device_info(hass): @@ -336,7 +336,7 @@ async def test_device_info(hass): assert device.api.vapix.firmware_version == "9.80.1" assert device.api.vapix.product_number == "M1065-LW" assert device.api.vapix.product_type == "Network Camera" - assert device.api.vapix.serial_number == "00408C12345" + assert device.api.vapix.serial_number == "00408C123456" async def test_device_support_mqtt(hass, mqtt_mock): diff --git a/tests/components/axis/test_init.py b/tests/components/axis/test_init.py index 345dfac4d11..b7faceaf10d 100644 --- a/tests/components/axis/test_init.py +++ b/tests/components/axis/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, Mock, patch from homeassistant.components import axis from homeassistant.components.axis.const import CONF_MODEL, DOMAIN as AXIS_DOMAIN +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.const import ( CONF_DEVICE, CONF_HOST, @@ -12,6 +13,8 @@ from homeassistant.const import ( CONF_PORT, CONF_USERNAME, ) +from homeassistant.helpers import entity_registry +from homeassistant.helpers.device_registry import format_mac from homeassistant.setup import async_setup_component from .test_device import MAC, setup_axis_integration @@ -29,13 +32,13 @@ async def test_setup_entry(hass): """Test successful setup of entry.""" await setup_axis_integration(hass) assert len(hass.data[AXIS_DOMAIN]) == 1 - assert MAC in hass.data[AXIS_DOMAIN] + assert format_mac(MAC) in hass.data[AXIS_DOMAIN] async def test_setup_entry_fails(hass): """Test successful setup of entry.""" config_entry = MockConfigEntry( - domain=AXIS_DOMAIN, data={CONF_MAC: "0123"}, version=2 + domain=AXIS_DOMAIN, data={CONF_MAC: "0123"}, version=3 ) config_entry.add_to_hass(hass) @@ -69,7 +72,7 @@ async def test_migrate_entry(hass): CONF_PASSWORD: "password", CONF_PORT: 80, }, - CONF_MAC: "mac", + CONF_MAC: "00408C123456", CONF_MODEL: "model", CONF_NAME: "name", } @@ -77,6 +80,17 @@ async def test_migrate_entry(hass): assert entry.data == legacy_config assert entry.version == 1 + assert not entry.unique_id + + # Create entity entry to migrate to new unique ID + registry = await entity_registry.async_get_registry(hass) + registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + AXIS_DOMAIN, + "00408C123456-vmd4-0", + suggested_object_id="vmd4", + config_entry=entry, + ) await entry.async_migrate(hass) @@ -91,8 +105,12 @@ async def test_migrate_entry(hass): CONF_USERNAME: "username", CONF_PASSWORD: "password", CONF_PORT: 80, - CONF_MAC: "mac", + CONF_MAC: "00408C123456", CONF_MODEL: "model", CONF_NAME: "name", } - assert entry.version == 2 + assert entry.version == 3 + assert entry.unique_id == "00:40:8c:12:34:56" + + vmd4_entity = registry.async_get("binary_sensor.vmd4") + assert vmd4_entity.unique_id == "00:40:8c:12:34:56-vmd4-0"