mirror of
https://github.com/home-assistant/core.git
synced 2025-07-26 06:37:52 +00:00
Use UID instead of MAC or channel for unique_ID in Reolink (#119744)
This commit is contained in:
parent
d6be733287
commit
8b4a5042bb
@ -15,6 +15,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||||
|
from homeassistant.helpers.device_registry import format_mac
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
@ -141,8 +142,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
|||||||
firmware_coordinator=firmware_coordinator,
|
firmware_coordinator=firmware_coordinator,
|
||||||
)
|
)
|
||||||
|
|
||||||
cleanup_disconnected_cams(hass, config_entry.entry_id, host)
|
# first migrate and then cleanup, otherwise entities lost
|
||||||
migrate_entity_ids(hass, config_entry.entry_id, host)
|
migrate_entity_ids(hass, config_entry.entry_id, host)
|
||||||
|
cleanup_disconnected_cams(hass, config_entry.entry_id, host)
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||||
|
|
||||||
@ -173,6 +175,24 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
|||||||
return unload_ok
|
return unload_ok
|
||||||
|
|
||||||
|
|
||||||
|
def get_device_uid_and_ch(
|
||||||
|
device: dr.DeviceEntry, host: ReolinkHost
|
||||||
|
) -> tuple[list[str], int | None]:
|
||||||
|
"""Get the channel and the split device_uid from a reolink DeviceEntry."""
|
||||||
|
device_uid = [
|
||||||
|
dev_id[1].split("_") for dev_id in device.identifiers if dev_id[0] == DOMAIN
|
||||||
|
][0]
|
||||||
|
|
||||||
|
if len(device_uid) < 2:
|
||||||
|
# NVR itself
|
||||||
|
ch = None
|
||||||
|
elif device_uid[1].startswith("ch") and len(device_uid[1]) <= 5:
|
||||||
|
ch = int(device_uid[1][2:])
|
||||||
|
else:
|
||||||
|
ch = host.api.channel_for_uid(device_uid[1])
|
||||||
|
return (device_uid, ch)
|
||||||
|
|
||||||
|
|
||||||
def cleanup_disconnected_cams(
|
def cleanup_disconnected_cams(
|
||||||
hass: HomeAssistant, config_entry_id: str, host: ReolinkHost
|
hass: HomeAssistant, config_entry_id: str, host: ReolinkHost
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -183,17 +203,10 @@ def cleanup_disconnected_cams(
|
|||||||
device_reg = dr.async_get(hass)
|
device_reg = dr.async_get(hass)
|
||||||
devices = dr.async_entries_for_config_entry(device_reg, config_entry_id)
|
devices = dr.async_entries_for_config_entry(device_reg, config_entry_id)
|
||||||
for device in devices:
|
for device in devices:
|
||||||
device_id = [
|
(device_uid, ch) = get_device_uid_and_ch(device, host)
|
||||||
dev_id[1].split("_ch")
|
if ch is None:
|
||||||
for dev_id in device.identifiers
|
continue # Do not consider the NVR itself
|
||||||
if dev_id[0] == DOMAIN
|
|
||||||
][0]
|
|
||||||
|
|
||||||
if len(device_id) < 2:
|
|
||||||
# Do not consider the NVR itself
|
|
||||||
continue
|
|
||||||
|
|
||||||
ch = int(device_id[1])
|
|
||||||
ch_model = host.api.camera_model(ch)
|
ch_model = host.api.camera_model(ch)
|
||||||
remove = False
|
remove = False
|
||||||
if ch not in host.api.channels:
|
if ch not in host.api.channels:
|
||||||
@ -225,11 +238,54 @@ def migrate_entity_ids(
|
|||||||
hass: HomeAssistant, config_entry_id: str, host: ReolinkHost
|
hass: HomeAssistant, config_entry_id: str, host: ReolinkHost
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Migrate entity IDs if needed."""
|
"""Migrate entity IDs if needed."""
|
||||||
|
device_reg = dr.async_get(hass)
|
||||||
|
devices = dr.async_entries_for_config_entry(device_reg, config_entry_id)
|
||||||
|
ch_device_ids = {}
|
||||||
|
for device in devices:
|
||||||
|
(device_uid, ch) = get_device_uid_and_ch(device, host)
|
||||||
|
|
||||||
|
if host.api.supported(None, "UID") and device_uid[0] != host.unique_id:
|
||||||
|
if ch is None:
|
||||||
|
new_device_id = f"{host.unique_id}"
|
||||||
|
else:
|
||||||
|
new_device_id = f"{host.unique_id}_{device_uid[1]}"
|
||||||
|
new_identifiers = {(DOMAIN, new_device_id)}
|
||||||
|
device_reg.async_update_device(device.id, new_identifiers=new_identifiers)
|
||||||
|
|
||||||
|
if ch is None:
|
||||||
|
continue # Do not consider the NVR itself
|
||||||
|
|
||||||
|
ch_device_ids[device.id] = ch
|
||||||
|
if host.api.supported(ch, "UID") and device_uid[1] != host.api.camera_uid(ch):
|
||||||
|
if host.api.supported(None, "UID"):
|
||||||
|
new_device_id = f"{host.unique_id}_{host.api.camera_uid(ch)}"
|
||||||
|
else:
|
||||||
|
new_device_id = f"{device_uid[0]}_{host.api.camera_uid(ch)}"
|
||||||
|
new_identifiers = {(DOMAIN, new_device_id)}
|
||||||
|
device_reg.async_update_device(device.id, new_identifiers=new_identifiers)
|
||||||
|
|
||||||
entity_reg = er.async_get(hass)
|
entity_reg = er.async_get(hass)
|
||||||
entities = er.async_entries_for_config_entry(entity_reg, config_entry_id)
|
entities = er.async_entries_for_config_entry(entity_reg, config_entry_id)
|
||||||
for entity in entities:
|
for entity in entities:
|
||||||
# Can be removed in HA 2025.1.0
|
# Can be removed in HA 2025.1.0
|
||||||
if entity.domain == "update" and entity.unique_id == host.unique_id:
|
if entity.domain == "update" and entity.unique_id in [
|
||||||
|
host.unique_id,
|
||||||
|
format_mac(host.api.mac_address),
|
||||||
|
]:
|
||||||
entity_reg.async_update_entity(
|
entity_reg.async_update_entity(
|
||||||
entity.entity_id, new_unique_id=f"{host.unique_id}_firmware"
|
entity.entity_id, new_unique_id=f"{host.unique_id}_firmware"
|
||||||
)
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if host.api.supported(None, "UID") and not entity.unique_id.startswith(
|
||||||
|
host.unique_id
|
||||||
|
):
|
||||||
|
new_id = f"{host.unique_id}_{entity.unique_id.split("_", 1)[1]}"
|
||||||
|
entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_id)
|
||||||
|
|
||||||
|
if entity.device_id in ch_device_ids:
|
||||||
|
ch = ch_device_ids[entity.device_id]
|
||||||
|
id_parts = entity.unique_id.split("_", 2)
|
||||||
|
if host.api.supported(ch, "UID") and id_parts[1] != host.api.camera_uid(ch):
|
||||||
|
new_id = f"{host.unique_id}_{host.api.camera_uid(ch)}_{id_parts[2]}"
|
||||||
|
entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_id)
|
||||||
|
@ -228,8 +228,9 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
user_input[CONF_PORT] = host.api.port
|
user_input[CONF_PORT] = host.api.port
|
||||||
user_input[CONF_USE_HTTPS] = host.api.use_https
|
user_input[CONF_USE_HTTPS] = host.api.use_https
|
||||||
|
|
||||||
|
mac_address = format_mac(host.api.mac_address)
|
||||||
existing_entry = await self.async_set_unique_id(
|
existing_entry = await self.async_set_unique_id(
|
||||||
host.unique_id, raise_on_progress=False
|
mac_address, raise_on_progress=False
|
||||||
)
|
)
|
||||||
if existing_entry and self._reauth:
|
if existing_entry and self._reauth:
|
||||||
if self.hass.config_entries.async_update_entry(
|
if self.hass.config_entries.async_update_entry(
|
||||||
|
@ -112,6 +112,9 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
|
|||||||
super().__init__(reolink_data, coordinator)
|
super().__init__(reolink_data, coordinator)
|
||||||
|
|
||||||
self._channel = channel
|
self._channel = channel
|
||||||
|
if self._host.api.supported(channel, "UID"):
|
||||||
|
self._attr_unique_id = f"{self._host.unique_id}_{self._host.api.camera_uid(channel)}_{self.entity_description.key}"
|
||||||
|
else:
|
||||||
self._attr_unique_id = (
|
self._attr_unique_id = (
|
||||||
f"{self._host.unique_id}_{channel}_{self.entity_description.key}"
|
f"{self._host.unique_id}_{channel}_{self.entity_description.key}"
|
||||||
)
|
)
|
||||||
@ -121,8 +124,13 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
|
|||||||
dev_ch = 0
|
dev_ch = 0
|
||||||
|
|
||||||
if self._host.api.is_nvr:
|
if self._host.api.is_nvr:
|
||||||
|
if self._host.api.supported(dev_ch, "UID"):
|
||||||
|
dev_id = f"{self._host.unique_id}_{self._host.api.camera_uid(dev_ch)}"
|
||||||
|
else:
|
||||||
|
dev_id = f"{self._host.unique_id}_ch{dev_ch}"
|
||||||
|
|
||||||
self._attr_device_info = DeviceInfo(
|
self._attr_device_info = DeviceInfo(
|
||||||
identifiers={(DOMAIN, f"{self._host.unique_id}_ch{dev_ch}")},
|
identifiers={(DOMAIN, dev_id)},
|
||||||
via_device=(DOMAIN, self._host.unique_id),
|
via_device=(DOMAIN, self._host.unique_id),
|
||||||
name=self._host.api.camera_name(dev_ch),
|
name=self._host.api.camera_name(dev_ch),
|
||||||
model=self._host.api.camera_model(dev_ch),
|
model=self._host.api.camera_model(dev_ch),
|
||||||
|
@ -191,6 +191,9 @@ class ReolinkHost:
|
|||||||
else:
|
else:
|
||||||
ir.async_delete_issue(self._hass, DOMAIN, "enable_port")
|
ir.async_delete_issue(self._hass, DOMAIN, "enable_port")
|
||||||
|
|
||||||
|
if self._api.supported(None, "UID"):
|
||||||
|
self._unique_id = self._api.uid
|
||||||
|
else:
|
||||||
self._unique_id = format_mac(self._api.mac_address)
|
self._unique_id = format_mac(self._api.mac_address)
|
||||||
|
|
||||||
if self._onvif_push_supported:
|
if self._onvif_push_supported:
|
||||||
|
@ -164,10 +164,14 @@ class ReolinkVODMediaSource(MediaSource):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
device = device_reg.async_get(entity.device_id)
|
device = device_reg.async_get(entity.device_id)
|
||||||
ch = entity.unique_id.split("_")[1]
|
ch_id = entity.unique_id.split("_")[1]
|
||||||
if ch in channels or device is None:
|
if ch_id in channels or device is None:
|
||||||
continue
|
continue
|
||||||
channels.append(ch)
|
channels.append(ch_id)
|
||||||
|
|
||||||
|
ch: int | str = ch_id
|
||||||
|
if len(ch_id) > 3:
|
||||||
|
ch = host.api.channel_for_uid(ch_id)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
host.api.api_version("recReplay", int(ch)) < 1
|
host.api.api_version("recReplay", int(ch)) < 1
|
||||||
|
@ -330,8 +330,6 @@ class ReolinkNVRSwitchEntity(ReolinkHostCoordinatorEntity, SwitchEntity):
|
|||||||
self.entity_description = entity_description
|
self.entity_description = entity_description
|
||||||
super().__init__(reolink_data)
|
super().__init__(reolink_data)
|
||||||
|
|
||||||
self._attr_unique_id = f"{self._host.unique_id}_{entity_description.key}"
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_on(self) -> bool:
|
def is_on(self) -> bool:
|
||||||
"""Return true if switch is on."""
|
"""Return true if switch is on."""
|
||||||
|
@ -29,6 +29,7 @@ TEST_MAC = "aa:bb:cc:dd:ee:ff"
|
|||||||
TEST_MAC2 = "ff:ee:dd:cc:bb:aa"
|
TEST_MAC2 = "ff:ee:dd:cc:bb:aa"
|
||||||
DHCP_FORMATTED_MAC = "aabbccddeeff"
|
DHCP_FORMATTED_MAC = "aabbccddeeff"
|
||||||
TEST_UID = "ABC1234567D89EFG"
|
TEST_UID = "ABC1234567D89EFG"
|
||||||
|
TEST_UID_CAM = "DEF7654321D89GHT"
|
||||||
TEST_PORT = 1234
|
TEST_PORT = 1234
|
||||||
TEST_NVR_NAME = "test_reolink_name"
|
TEST_NVR_NAME = "test_reolink_name"
|
||||||
TEST_NVR_NAME2 = "test2_reolink_name"
|
TEST_NVR_NAME2 = "test2_reolink_name"
|
||||||
@ -86,7 +87,8 @@ def reolink_connect_class() -> Generator[MagicMock]:
|
|||||||
host_mock.camera_name.return_value = TEST_NVR_NAME
|
host_mock.camera_name.return_value = TEST_NVR_NAME
|
||||||
host_mock.camera_hardware_version.return_value = "IPC_00001"
|
host_mock.camera_hardware_version.return_value = "IPC_00001"
|
||||||
host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000"
|
host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000"
|
||||||
host_mock.camera_uid.return_value = TEST_UID
|
host_mock.camera_uid.return_value = TEST_UID_CAM
|
||||||
|
host_mock.channel_for_uid.return_value = 0
|
||||||
host_mock.get_encoding.return_value = "h264"
|
host_mock.get_encoding.return_value = "h264"
|
||||||
host_mock.firmware_update_available.return_value = False
|
host_mock.firmware_update_available.return_value = False
|
||||||
host_mock.session_active = True
|
host_mock.session_active = True
|
||||||
|
@ -20,7 +20,14 @@ from homeassistant.helpers import (
|
|||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
from homeassistant.util.dt import utcnow
|
from homeassistant.util.dt import utcnow
|
||||||
|
|
||||||
from .conftest import TEST_CAM_MODEL, TEST_HOST_MODEL, TEST_MAC, TEST_NVR_NAME
|
from .conftest import (
|
||||||
|
TEST_CAM_MODEL,
|
||||||
|
TEST_HOST_MODEL,
|
||||||
|
TEST_MAC,
|
||||||
|
TEST_NVR_NAME,
|
||||||
|
TEST_UID,
|
||||||
|
TEST_UID_CAM,
|
||||||
|
)
|
||||||
|
|
||||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||||
|
|
||||||
@ -178,17 +185,104 @@ async def test_cleanup_disconnected_cams(
|
|||||||
assert sorted(device_models) == sorted(expected_models)
|
assert sorted(device_models) == sorted(expected_models)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
(
|
||||||
|
"original_id",
|
||||||
|
"new_id",
|
||||||
|
"original_dev_id",
|
||||||
|
"new_dev_id",
|
||||||
|
"domain",
|
||||||
|
"support_uid",
|
||||||
|
"support_ch_uid",
|
||||||
|
),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
TEST_MAC,
|
||||||
|
f"{TEST_MAC}_firmware",
|
||||||
|
f"{TEST_MAC}",
|
||||||
|
f"{TEST_MAC}",
|
||||||
|
Platform.UPDATE,
|
||||||
|
False,
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
TEST_MAC,
|
||||||
|
f"{TEST_UID}_firmware",
|
||||||
|
f"{TEST_MAC}",
|
||||||
|
f"{TEST_UID}",
|
||||||
|
Platform.UPDATE,
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
f"{TEST_MAC}_0_record_audio",
|
||||||
|
f"{TEST_UID}_0_record_audio",
|
||||||
|
f"{TEST_MAC}_ch0",
|
||||||
|
f"{TEST_UID}_ch0",
|
||||||
|
Platform.SWITCH,
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
f"{TEST_MAC}_0_record_audio",
|
||||||
|
f"{TEST_MAC}_{TEST_UID_CAM}_record_audio",
|
||||||
|
f"{TEST_MAC}_ch0",
|
||||||
|
f"{TEST_MAC}_{TEST_UID_CAM}",
|
||||||
|
Platform.SWITCH,
|
||||||
|
False,
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
f"{TEST_MAC}_0_record_audio",
|
||||||
|
f"{TEST_UID}_{TEST_UID_CAM}_record_audio",
|
||||||
|
f"{TEST_MAC}_ch0",
|
||||||
|
f"{TEST_UID}_{TEST_UID_CAM}",
|
||||||
|
Platform.SWITCH,
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
f"{TEST_UID}_0_record_audio",
|
||||||
|
f"{TEST_UID}_{TEST_UID_CAM}_record_audio",
|
||||||
|
f"{TEST_UID}_ch0",
|
||||||
|
f"{TEST_UID}_{TEST_UID_CAM}",
|
||||||
|
Platform.SWITCH,
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
async def test_migrate_entity_ids(
|
async def test_migrate_entity_ids(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
config_entry: MockConfigEntry,
|
config_entry: MockConfigEntry,
|
||||||
reolink_connect: MagicMock,
|
reolink_connect: MagicMock,
|
||||||
entity_registry: er.EntityRegistry,
|
entity_registry: er.EntityRegistry,
|
||||||
|
device_registry: dr.DeviceRegistry,
|
||||||
|
original_id: str,
|
||||||
|
new_id: str,
|
||||||
|
original_dev_id: str,
|
||||||
|
new_dev_id: str,
|
||||||
|
domain: Platform,
|
||||||
|
support_uid: bool,
|
||||||
|
support_ch_uid: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test entity ids that need to be migrated."""
|
"""Test entity ids that need to be migrated."""
|
||||||
|
|
||||||
|
def mock_supported(ch, capability):
|
||||||
|
if capability == "UID" and ch is None:
|
||||||
|
return support_uid
|
||||||
|
if capability == "UID":
|
||||||
|
return support_ch_uid
|
||||||
|
return True
|
||||||
|
|
||||||
reolink_connect.channels = [0]
|
reolink_connect.channels = [0]
|
||||||
original_id = f"{TEST_MAC}"
|
reolink_connect.supported = mock_supported
|
||||||
new_id = f"{TEST_MAC}_firmware"
|
|
||||||
domain = Platform.UPDATE
|
dev_entry = device_registry.async_get_or_create(
|
||||||
|
identifiers={(const.DOMAIN, original_dev_id)},
|
||||||
|
config_entry_id=config_entry.entry_id,
|
||||||
|
disabled_by=None,
|
||||||
|
)
|
||||||
|
|
||||||
entity_registry.async_get_or_create(
|
entity_registry.async_get_or_create(
|
||||||
domain=domain,
|
domain=domain,
|
||||||
@ -197,11 +291,21 @@ async def test_migrate_entity_ids(
|
|||||||
config_entry=config_entry,
|
config_entry=config_entry,
|
||||||
suggested_object_id=original_id,
|
suggested_object_id=original_id,
|
||||||
disabled_by=None,
|
disabled_by=None,
|
||||||
|
device_id=dev_entry.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert entity_registry.async_get_entity_id(domain, const.DOMAIN, original_id)
|
assert entity_registry.async_get_entity_id(domain, const.DOMAIN, original_id)
|
||||||
assert entity_registry.async_get_entity_id(domain, const.DOMAIN, new_id) is None
|
assert entity_registry.async_get_entity_id(domain, const.DOMAIN, new_id) is None
|
||||||
|
|
||||||
|
assert device_registry.async_get_device(
|
||||||
|
identifiers={(const.DOMAIN, original_dev_id)}
|
||||||
|
)
|
||||||
|
if new_dev_id != original_dev_id:
|
||||||
|
assert (
|
||||||
|
device_registry.async_get_device(identifiers={(const.DOMAIN, new_dev_id)})
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
# setup CH 0 and host entities/device
|
# setup CH 0 and host entities/device
|
||||||
with patch("homeassistant.components.reolink.PLATFORMS", [domain]):
|
with patch("homeassistant.components.reolink.PLATFORMS", [domain]):
|
||||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
@ -212,6 +316,15 @@ async def test_migrate_entity_ids(
|
|||||||
)
|
)
|
||||||
assert entity_registry.async_get_entity_id(domain, const.DOMAIN, new_id)
|
assert entity_registry.async_get_entity_id(domain, const.DOMAIN, new_id)
|
||||||
|
|
||||||
|
if new_dev_id != original_dev_id:
|
||||||
|
assert (
|
||||||
|
device_registry.async_get_device(
|
||||||
|
identifiers={(const.DOMAIN, original_dev_id)}
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
assert device_registry.async_get_device(identifiers={(const.DOMAIN, new_dev_id)})
|
||||||
|
|
||||||
|
|
||||||
async def test_no_repair_issue(
|
async def test_no_repair_issue(
|
||||||
hass: HomeAssistant, config_entry: MockConfigEntry, issue_registry: ir.IssueRegistry
|
hass: HomeAssistant, config_entry: MockConfigEntry, issue_registry: ir.IssueRegistry
|
||||||
|
Loading…
x
Reference in New Issue
Block a user