diff --git a/homeassistant/components/unifi_discovery/discovery.py b/homeassistant/components/unifi_discovery/discovery.py index 6bd8a4b24cdf..5a4174a4f6b5 100644 --- a/homeassistant/components/unifi_discovery/discovery.py +++ b/homeassistant/components/unifi_discovery/discovery.py @@ -2,7 +2,8 @@ from __future__ import annotations -from dataclasses import asdict +from collections.abc import Mapping +from dataclasses import fields from datetime import timedelta import logging from typing import Any @@ -24,6 +25,24 @@ DISCOVERY_INTERVAL = timedelta(minutes=60) DATA_DISCOVERY_STARTED: HassKey[bool] = HassKey(DOMAIN) +def _device_to_dict(device: UnifiDevice) -> dict[str, Any]: + """Convert a UnifiDevice to a plain dict. + + Avoid dataclasses.asdict() because it calls copy.deepcopy() on non-builtin + types. On Python 3.14+ deepcopy cannot pickle mappingproxy objects, and + Enum members (used as dict keys in ``services``) internally reference + ``__members__`` which is a mappingproxy. This causes asdict() to crash + with ``TypeError: cannot pickle 'mappingproxy' object``. + """ + data: dict[str, Any] = {} + for f in fields(device): + value = getattr(device, f.name) + if isinstance(value, Mapping): + value = dict(value) + data[f.name] = value + return data + + @callback def async_start_discovery(hass: HomeAssistant) -> None: """Start discovery of UniFi devices.""" @@ -74,5 +93,5 @@ def async_trigger_discovery( hass, domain, context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data=asdict(device), + data=_device_to_dict(device), ) diff --git a/tests/components/unifi_discovery/__init__.py b/tests/components/unifi_discovery/__init__.py index c068a3ba1e0e..08916c8aa011 100644 --- a/tests/components/unifi_discovery/__init__.py +++ b/tests/components/unifi_discovery/__init__.py @@ -2,6 +2,7 @@ from collections.abc import Generator from contextlib import contextmanager +from types import MappingProxyType from unittest.mock import AsyncMock, MagicMock, patch from unifi_discovery import AIOUnifiScanner, UnifiDevice, UnifiService @@ -30,6 +31,21 @@ UNIFI_DISCOVERY_NO_MAC = UnifiDevice( direct_connect_domain=DIRECT_CONNECT_DOMAIN, ) +# In production the crash happens because asdict() deep-copies Enum keys in +# the services dict, and on Python 3.14+ deepcopy traverses into +# Enum.__members__ (a mappingproxy) which cannot be pickled. Whether +# deepcopy(enum) actually fails depends on the specific CPython build, so +# we force the error reliably by using MappingProxyType directly as the +# services value — deepcopy always fails on it. +UNIFI_DISCOVERY_MAPPINGPROXY_SERVICES = UnifiDevice( + source_ip=DEVICE_IP_ADDRESS, + hw_addr=DEVICE_MAC_ADDRESS, + platform=DEVICE_HOSTNAME, + hostname=DEVICE_HOSTNAME, + services=MappingProxyType({UnifiService.Protect: True}), + direct_connect_domain=DIRECT_CONNECT_DOMAIN, +) + def _patch_discovery( device: UnifiDevice | None = None, no_device: bool = False diff --git a/tests/components/unifi_discovery/test_init.py b/tests/components/unifi_discovery/test_init.py index e76511562c31..80207bd81ef5 100644 --- a/tests/components/unifi_discovery/test_init.py +++ b/tests/components/unifi_discovery/test_init.py @@ -7,7 +7,11 @@ from homeassistant.components.unifi_discovery.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from . import UNIFI_DISCOVERY_NO_MAC, _patch_discovery +from . import ( + UNIFI_DISCOVERY_MAPPINGPROXY_SERVICES, + UNIFI_DISCOVERY_NO_MAC, + _patch_discovery, +) async def test_setup_starts_discovery(hass: HomeAssistant) -> None: @@ -54,3 +58,20 @@ async def test_dependency_loads_discovery( flows = hass.config_entries.flow.async_progress_by_handler("unifiprotect") assert len(flows) == 1 assert flows[0]["context"]["source"] == config_entries.SOURCE_INTEGRATION_DISCOVERY + + +async def test_discovery_does_not_deepcopy_device(hass: HomeAssistant) -> None: + """Test discovery works without deepcopy. + + In production asdict() deep-copies Enum keys in the services dict which + can crash on Python 3.14+ because Enum.__members__ is a mappingproxy that + cannot be pickled. We force the crash reliably by using MappingProxyType + as the services value. + """ + with _patch_discovery(device=UNIFI_DISCOVERY_MAPPINGPROXY_SERVICES): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done(wait_background_tasks=True) + + flows = hass.config_entries.flow.async_progress_by_handler("unifiprotect") + assert len(flows) == 1 + assert flows[0]["context"]["source"] == config_entries.SOURCE_INTEGRATION_DISCOVERY