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 aiohttp.client_exceptions import ServerDisconnectedError
from uiprotect.api import DEVICE_UPDATE_INTERVAL from uiprotect.api import DEVICE_UPDATE_INTERVAL
from uiprotect.data import Bootstrap from uiprotect.data import Bootstrap
from uiprotect.data.types import FirmwareReleaseChannel
from uiprotect.exceptions import ClientError, NotAuthorized from uiprotect.exceptions import ClientError, NotAuthorized
# Import the test_util.anonymize module from the uiprotect package # 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. # diagnostics module will not be imported in the executor.
from uiprotect.test_util.anonymize import anonymize_data # noqa: F401 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.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady 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) 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: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the UniFi Protect.""" """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) 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) 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 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: if device.is_adopted_by_us and device.mac in unifi_macs:
return False return False
return True 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 ( from .const import (
CONF_ALL_UPDATES, CONF_ALL_UPDATES,
CONF_ALLOW_EA,
CONF_DISABLE_RTSP, CONF_DISABLE_RTSP,
CONF_MAX_MEDIA, CONF_MAX_MEDIA,
CONF_OVERRIDE_CHOST, CONF_OVERRIDE_CHOST,
@ -238,7 +237,6 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_ALL_UPDATES: False, CONF_ALL_UPDATES: False,
CONF_OVERRIDE_CHOST: False, CONF_OVERRIDE_CHOST: False,
CONF_MAX_MEDIA: DEFAULT_MAX_MEDIA, CONF_MAX_MEDIA: DEFAULT_MAX_MEDIA,
CONF_ALLOW_EA: False,
}, },
) )
@ -408,10 +406,6 @@ class OptionsFlowHandler(OptionsFlow):
CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA
), ),
): vol.All(vol.Coerce(int), vol.Range(min=100, max=10000)), ): 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 import ProtectApiClient
from uiprotect.data import Bootstrap, Camera, ModelType from uiprotect.data import Bootstrap, Camera, ModelType
from uiprotect.data.types import FirmwareReleaseChannel
import voluptuous as vol import voluptuous as vol
from homeassistant import data_entry_flow from homeassistant import data_entry_flow
@ -15,7 +14,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir 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 .data import UFPConfigEntry, async_get_data_for_entry_id
from .utils import async_create_api_client from .utils import async_create_api_client
@ -45,52 +43,6 @@ class ProtectRepair(RepairsFlow):
return description_placeholders 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): class CloudAccountRepair(ProtectRepair):
"""Handler for an issue fixing flow.""" """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"]))) and (entry := hass.config_entries.async_get_entry(cast(str, data["entry_id"])))
): ):
api = _async_get_or_create_api_client(hass, entry) 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": if issue_id == "cloud_user":
return CloudAccountRepair(api=api, entry=entry) return CloudAccountRepair(api=api, entry=entry)
if issue_id.startswith("rtsp_disabled_"): if issue_id.startswith("rtsp_disabled_"):

View File

@ -55,32 +55,12 @@
"disable_rtsp": "Disable the RTSP stream", "disable_rtsp": "Disable the RTSP stream",
"all_updates": "Realtime metrics (WARNING: Greatly increases CPU usage)", "all_updates": "Realtime metrics (WARNING: Greatly increases CPU usage)",
"override_connection_host": "Override connection host", "override_connection_host": "Override connection host",
"max_media": "Max number of event to load for Media Browser (increases RAM usage)", "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)"
} }
} }
} }
}, },
"issues": { "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": { "cloud_user": {
"title": "Ubiquiti Cloud Users are not Supported", "title": "Ubiquiti Cloud Users are not Supported",
"fix_flow": { "fix_flow": {

View File

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

View File

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

View File

@ -11,6 +11,7 @@ from uiprotect.data import NVR, Bootstrap, CloudAccount, Light
from homeassistant.components.unifiprotect.const import ( from homeassistant.components.unifiprotect.const import (
AUTH_RETRIES, AUTH_RETRIES,
CONF_ALLOW_EA,
CONF_DISABLE_RTSP, CONF_DISABLE_RTSP,
DOMAIN, 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) result = async_ufp_instance_for_config_entry_ids(hass, entry_ids)
assert result == expected_result 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 __future__ import annotations
from copy import copy, deepcopy from copy import deepcopy
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock
from uiprotect.data import Camera, CloudAccount, ModelType, Version from uiprotect.data import Camera, CloudAccount, ModelType, Version
@ -21,110 +21,6 @@ from tests.components.repairs import (
from tests.typing import ClientSessionGenerator, WebSocketGenerator 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( async def test_cloud_user_fix(
hass: HomeAssistant, hass: HomeAssistant,
ufp: MockUFPFixture, ufp: MockUFPFixture,