UniFi Protect removing early access checks and issue creation (#147432)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Raphael Hehl 2025-06-27 17:15:34 +02:00 committed by GitHub
parent 4b02f22724
commit 8a18dea8c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 50 additions and 235 deletions

View File

@ -8,7 +8,6 @@ import logging
from aiohttp.client_exceptions import ServerDisconnectedError
from uiprotect.api import DEVICE_UPDATE_INTERVAL
from uiprotect.data import Bootstrap
from uiprotect.data.types import FirmwareReleaseChannel
from uiprotect.exceptions import ClientError, NotAuthorized
# Import the test_util.anonymize module from the uiprotect package
@ -16,6 +15,7 @@ from uiprotect.exceptions import ClientError, NotAuthorized
# diagnostics module will not be imported in the executor.
from uiprotect.test_util.anonymize import anonymize_data # noqa: F401
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
@ -58,10 +58,6 @@ SCAN_INTERVAL = timedelta(seconds=DEVICE_UPDATE_INTERVAL)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
EARLY_ACCESS_URL = (
"https://www.home-assistant.io/integrations/unifiprotect#software-support"
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the UniFi Protect."""
@ -123,47 +119,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, data_service.async_stop)
)
if not entry.options.get(CONF_ALLOW_EA, False) and (
await nvr_info.get_is_prerelease()
or nvr_info.release_channel != FirmwareReleaseChannel.RELEASE
):
ir.async_create_issue(
hass,
DOMAIN,
"ea_channel_warning",
is_fixable=True,
is_persistent=False,
learn_more_url=EARLY_ACCESS_URL,
severity=IssueSeverity.WARNING,
translation_key="ea_channel_warning",
translation_placeholders={"version": str(nvr_info.version)},
data={"entry_id": entry.entry_id},
)
try:
await _async_setup_entry(hass, entry, data_service, bootstrap)
except Exception as err:
if await nvr_info.get_is_prerelease():
# If they are running a pre-release, its quite common for setup
# to fail so we want to create a repair issue for them so its
# obvious what the problem is.
ir.async_create_issue(
hass,
DOMAIN,
f"ea_setup_failed_{nvr_info.version}",
is_fixable=False,
is_persistent=False,
learn_more_url="https://www.home-assistant.io/integrations/unifiprotect#about-unifi-early-access",
severity=IssueSeverity.ERROR,
translation_key="ea_setup_failed",
translation_placeholders={
"error": str(err),
"version": str(nvr_info.version),
},
)
ir.async_delete_issue(hass, DOMAIN, "ea_channel_warning")
_LOGGER.exception("Error setting up UniFi Protect integration")
raise
return True
@ -211,3 +167,23 @@ async def async_remove_config_entry_device(
if device.is_adopted_by_us and device.mac in unifi_macs:
return False
return True
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate entry."""
_LOGGER.debug("Migrating configuration from version %s", entry.version)
if entry.version > 1:
return False
if entry.version == 1:
options = dict(entry.options)
if CONF_ALLOW_EA in options:
options.pop(CONF_ALLOW_EA)
hass.config_entries.async_update_entry(
entry, unique_id=str(entry.unique_id), version=2, options=options
)
_LOGGER.debug("Migration to configuration version %s successful", entry.version)
return True

View File

@ -44,7 +44,6 @@ from homeassistant.util.network import is_ip_address
from .const import (
CONF_ALL_UPDATES,
CONF_ALLOW_EA,
CONF_DISABLE_RTSP,
CONF_MAX_MEDIA,
CONF_OVERRIDE_CHOST,
@ -238,7 +237,6 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_ALL_UPDATES: False,
CONF_OVERRIDE_CHOST: False,
CONF_MAX_MEDIA: DEFAULT_MAX_MEDIA,
CONF_ALLOW_EA: False,
},
)
@ -408,10 +406,6 @@ class OptionsFlowHandler(OptionsFlow):
CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA
),
): vol.All(vol.Coerce(int), vol.Range(min=100, max=10000)),
vol.Optional(
CONF_ALLOW_EA,
default=self.config_entry.options.get(CONF_ALLOW_EA, False),
): bool,
}
),
)

View File

@ -6,7 +6,6 @@ from typing import cast
from uiprotect import ProtectApiClient
from uiprotect.data import Bootstrap, Camera, ModelType
from uiprotect.data.types import FirmwareReleaseChannel
import voluptuous as vol
from homeassistant import data_entry_flow
@ -15,7 +14,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir
from .const import CONF_ALLOW_EA
from .data import UFPConfigEntry, async_get_data_for_entry_id
from .utils import async_create_api_client
@ -45,52 +43,6 @@ class ProtectRepair(RepairsFlow):
return description_placeholders
class EAConfirmRepair(ProtectRepair):
"""Handler for an issue fixing flow."""
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_start()
async def async_step_start(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is None:
placeholders = self._async_get_placeholders()
return self.async_show_form(
step_id="start",
data_schema=vol.Schema({}),
description_placeholders=placeholders,
)
nvr = await self._api.get_nvr()
if nvr.release_channel != FirmwareReleaseChannel.RELEASE:
return await self.async_step_confirm()
await self.hass.config_entries.async_reload(self._entry.entry_id)
return self.async_create_entry(data={})
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is not None:
options = dict(self._entry.options)
options[CONF_ALLOW_EA] = True
self.hass.config_entries.async_update_entry(self._entry, options=options)
return self.async_create_entry(data={})
placeholders = self._async_get_placeholders()
return self.async_show_form(
step_id="confirm",
data_schema=vol.Schema({}),
description_placeholders=placeholders,
)
class CloudAccountRepair(ProtectRepair):
"""Handler for an issue fixing flow."""
@ -242,8 +194,6 @@ async def async_create_fix_flow(
and (entry := hass.config_entries.async_get_entry(cast(str, data["entry_id"])))
):
api = _async_get_or_create_api_client(hass, entry)
if issue_id == "ea_channel_warning":
return EAConfirmRepair(api=api, entry=entry)
if issue_id == "cloud_user":
return CloudAccountRepair(api=api, entry=entry)
if issue_id.startswith("rtsp_disabled_"):

View File

@ -55,32 +55,12 @@
"disable_rtsp": "Disable the RTSP stream",
"all_updates": "Realtime metrics (WARNING: Greatly increases CPU usage)",
"override_connection_host": "Override connection host",
"max_media": "Max number of event to load for Media Browser (increases RAM usage)",
"allow_ea_channel": "Allow Early Access versions of Protect (WARNING: Will mark your integration as unsupported)"
"max_media": "Max number of event to load for Media Browser (increases RAM usage)"
}
}
}
},
"issues": {
"ea_channel_warning": {
"title": "UniFi Protect Early Access enabled",
"fix_flow": {
"step": {
"start": {
"title": "UniFi Protect Early Access enabled",
"description": "You are either running an Early Access version of UniFi Protect (v{version}) or opt-ed into a release channel that is not the official release channel.\n\nAs these Early Access releases may not be tested yet, using it may cause the UniFi Protect integration to behave unexpectedly. [Read more about Early Access and Home Assistant]({learn_more}).\n\nSubmit to dismiss this message."
},
"confirm": {
"title": "[%key:component::unifiprotect::issues::ea_channel_warning::fix_flow::step::start::title%]",
"description": "Are you sure you want to run unsupported versions of UniFi Protect? This may cause your Home Assistant integration to break."
}
}
}
},
"ea_setup_failed": {
"title": "Setup error using Early Access version",
"description": "You are using v{version} of UniFi Protect which is an Early Access version. An unrecoverable error occurred while trying to load the integration. Please restore a backup of a stable release of UniFi Protect to continue using the integration.\n\nError: {error}"
},
"cloud_user": {
"title": "Ubiquiti Cloud Users are not Supported",
"fix_flow": {

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from dataclasses import asdict
import socket
from unittest.mock import patch
from unittest.mock import AsyncMock, Mock, patch
import pytest
from uiprotect import NotAuthorized, NvrError, ProtectApiClient
@ -325,7 +325,6 @@ async def test_form_options(hass: HomeAssistant, ufp_client: ProtectApiClient) -
"disable_rtsp": True,
"override_connection_host": True,
"max_media": 1000,
"allow_ea_channel": False,
}
await hass.async_block_till_done()
await hass.config_entries.async_unload(mock_config.entry_id)
@ -794,6 +793,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa
},
unique_id="FFFFFFAAAAAA",
)
mock_config.runtime_data = Mock(async_stop=AsyncMock())
mock_config.add_to_hass(hass)
other_ip_dict = UNIFI_DISCOVERY_DICT.copy()
@ -855,7 +855,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa
"port": 443,
"verify_ssl": True,
}
assert len(mock_setup_entry.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 2
assert len(mock_setup.mock_calls) == 1

View File

@ -2,7 +2,6 @@
from uiprotect.data import NVR, Light
from homeassistant.components.unifiprotect.const import CONF_ALLOW_EA
from homeassistant.core import HomeAssistant
from .utils import MockUFPFixture, init_entry
@ -22,7 +21,6 @@ async def test_diagnostics(
await init_entry(hass, ufp, [light])
options = dict(ufp.entry.options)
options[CONF_ALLOW_EA] = True
hass.config_entries.async_update_entry(ufp.entry, options=options)
await hass.async_block_till_done()
@ -30,7 +28,6 @@ async def test_diagnostics(
assert "options" in diag and isinstance(diag["options"], dict)
options = diag["options"]
assert options[CONF_ALLOW_EA] is True
assert "bootstrap" in diag and isinstance(diag["bootstrap"], dict)
bootstrap = diag["bootstrap"]

View File

@ -11,6 +11,7 @@ from uiprotect.data import NVR, Bootstrap, CloudAccount, Light
from homeassistant.components.unifiprotect.const import (
AUTH_RETRIES,
CONF_ALLOW_EA,
CONF_DISABLE_RTSP,
DOMAIN,
)
@ -345,3 +346,24 @@ async def test_async_ufp_instance_for_config_entry_ids(
result = async_ufp_instance_for_config_entry_ids(hass, entry_ids)
assert result == expected_result
async def test_migrate_entry_version_2(hass: HomeAssistant) -> None:
"""Test remove CONF_ALLOW_EA from options while migrating a 1 config entry to 2."""
with (
patch(
"homeassistant.components.unifiprotect.async_setup_entry", return_value=True
),
patch("homeassistant.components.unifiprotect.async_start_discovery"),
):
entry = MockConfigEntry(
domain=DOMAIN,
data={"test": "1", "test2": "2", CONF_ALLOW_EA: "True"},
version=1,
unique_id="123456",
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
assert entry.version == 2
assert entry.options.get(CONF_ALLOW_EA) is None
assert entry.unique_id == "123456"

View File

@ -2,8 +2,8 @@
from __future__ import annotations
from copy import copy, deepcopy
from unittest.mock import AsyncMock, Mock
from copy import deepcopy
from unittest.mock import AsyncMock
from uiprotect.data import Camera, CloudAccount, ModelType, Version
@ -21,110 +21,6 @@ from tests.components.repairs import (
from tests.typing import ClientSessionGenerator, WebSocketGenerator
async def test_ea_warning_ignore(
hass: HomeAssistant,
ufp: MockUFPFixture,
hass_client: ClientSessionGenerator,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test EA warning is created if using prerelease version of Protect."""
ufp.api.bootstrap.nvr.release_channel = "beta"
ufp.api.bootstrap.nvr.version = Version("1.21.0-beta.2")
version = ufp.api.bootstrap.nvr.version
assert version.is_prerelease
await init_entry(hass, ufp, [])
await async_process_repairs_platforms(hass)
ws_client = await hass_ws_client(hass)
client = await hass_client()
await ws_client.send_json({"id": 1, "type": "repairs/list_issues"})
msg = await ws_client.receive_json()
assert msg["success"]
assert len(msg["result"]["issues"]) > 0
issue = None
for i in msg["result"]["issues"]:
if i["issue_id"] == "ea_channel_warning":
issue = i
assert issue is not None
data = await start_repair_fix_flow(client, DOMAIN, "ea_channel_warning")
flow_id = data["flow_id"]
assert data["description_placeholders"] == {
"learn_more": "https://www.home-assistant.io/integrations/unifiprotect#software-support",
"version": str(version),
}
assert data["step_id"] == "start"
data = await process_repair_fix_flow(client, flow_id)
flow_id = data["flow_id"]
assert data["description_placeholders"] == {
"learn_more": "https://www.home-assistant.io/integrations/unifiprotect#software-support",
"version": str(version),
}
assert data["step_id"] == "confirm"
data = await process_repair_fix_flow(client, flow_id)
assert data["type"] == "create_entry"
async def test_ea_warning_fix(
hass: HomeAssistant,
ufp: MockUFPFixture,
hass_client: ClientSessionGenerator,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test EA warning is created if using prerelease version of Protect."""
ufp.api.bootstrap.nvr.release_channel = "beta"
ufp.api.bootstrap.nvr.version = Version("1.21.0-beta.2")
version = ufp.api.bootstrap.nvr.version
assert version.is_prerelease
await init_entry(hass, ufp, [])
await async_process_repairs_platforms(hass)
ws_client = await hass_ws_client(hass)
client = await hass_client()
await ws_client.send_json({"id": 1, "type": "repairs/list_issues"})
msg = await ws_client.receive_json()
assert msg["success"]
assert len(msg["result"]["issues"]) > 0
issue = None
for i in msg["result"]["issues"]:
if i["issue_id"] == "ea_channel_warning":
issue = i
assert issue is not None
data = await start_repair_fix_flow(client, DOMAIN, "ea_channel_warning")
flow_id = data["flow_id"]
assert data["description_placeholders"] == {
"learn_more": "https://www.home-assistant.io/integrations/unifiprotect#software-support",
"version": str(version),
}
assert data["step_id"] == "start"
new_nvr = copy(ufp.api.bootstrap.nvr)
new_nvr.release_channel = "release"
new_nvr.version = Version("2.2.6")
mock_msg = Mock()
mock_msg.changed_data = {"version": "2.2.6", "releaseChannel": "release"}
mock_msg.new_obj = new_nvr
ufp.api.bootstrap.nvr = new_nvr
ufp.ws_msg(mock_msg)
await hass.async_block_till_done()
data = await process_repair_fix_flow(client, flow_id)
assert data["type"] == "create_entry"
async def test_cloud_user_fix(
hass: HomeAssistant,
ufp: MockUFPFixture,