Normalise unique ID in Axis integration (#45203)

* Move adding unique id to config entry from setup_entry to migrate_entry

* Normalise unique ID

* MQTT subscribe should still use the serial number in the way the device itself expects
This commit is contained in:
Robert Svensson 2021-01-16 01:01:14 +01:00 committed by GitHub
parent b3764da912
commit 598a0d19b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 81 additions and 51 deletions

View File

@ -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

View File

@ -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}"

View File

@ -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):

View File

@ -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])):

View File

@ -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

View File

@ -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,
}

View File

@ -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):

View File

@ -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"