Compare commits

...

20 Commits

Author SHA1 Message Date
Franck Nijhof
6a3051718a Add reconfiguration flow to Elgato (#168036) 2026-04-13 13:06:33 +02:00
Retha Runolfsson
95c3624b01 Bump PySwitchbot to 2.0.1 (#168090) 2026-04-13 12:43:14 +02:00
Tom Matheussen
f53b629dfd Bump satel-integra to 1.1.1 (#168091) 2026-04-13 12:41:56 +02:00
Giga77
d901541f48 Add hacf/reviewers as codeowners to Freebox (#168050) 2026-04-13 12:13:14 +02:00
Giga77
cdcf810506 Remove hacf-fr from Epic Games Store (#168038) 2026-04-13 12:02:47 +02:00
Giga77
274146cbb2 Remove hacf-fr from Synology DSM (#168039) 2026-04-13 11:55:10 +02:00
Giga77
b8cdd8dccc Remove hacf-fr (#168054) 2026-04-13 11:53:43 +02:00
Raphael Hehl
5abaa2ae72 Bump python-melcloud to 0.1.3 (#168086) 2026-04-13 11:34:05 +02:00
Simone Chemelli
4a511a3e53 Bump aioamazondevices to 13.4.0 (#167984) 2026-04-13 11:27:12 +02:00
Andrew Jackson
81a657ab2c Bump mastodon.py to 2.2.1 (#168084) 2026-04-13 11:11:30 +02:00
Giga77
e9a79ee0e5 Replace hacf-fr by hacf-fr reviewers team (#168056) 2026-04-13 11:06:40 +02:00
Fabian Neundorf
ffd439abc5 Add support for KM7576 in Miele integration (#168069) 2026-04-13 10:30:33 +02:00
Niracler
982a2b8af7 Bump PySrDaliGateway to 0.20.4 (#168078) 2026-04-13 10:28:14 +02:00
Raphael Hehl
ef589f9b46 Add unifi_discovery integration, migrate unifiprotect discovery (#168030)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-04-13 09:50:39 +02:00
Denis Shulyaka
81f8319af4 Fix llm tool results mutation (#167485)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-13 09:33:37 +02:00
Richard Kroegel
a061e47bec Improve eurotronic_cometblue tests (#168046) 2026-04-13 07:16:22 +02:00
Franck Nijhof
e5c49b6455 Set parallel updates to 0 for Sensor.Community (#168063) 2026-04-13 06:11:16 +02:00
Christian Lackas
5c51820869 Add Heatbox3 to ViCare unsupported devices list (#168067) 2026-04-13 05:49:12 +02:00
Franck Nijhof
eb64589115 Translate coordinator exceptions for Tailwind (#168027) 2026-04-12 18:45:37 +02:00
Franck Nijhof
4ebf0bf0b6 Fix untranslated button error in Tailwind (#168031) 2026-04-12 12:20:12 +02:00
64 changed files with 1357 additions and 368 deletions

22
CODEOWNERS generated
View File

@@ -489,8 +489,8 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/environment_canada/ @gwww @michaeldavie
/tests/components/environment_canada/ @gwww @michaeldavie
/homeassistant/components/ephember/ @ttroy50 @roberty99
/homeassistant/components/epic_games_store/ @hacf-fr @Quentame
/tests/components/epic_games_store/ @hacf-fr @Quentame
/homeassistant/components/epic_games_store/ @Quentame
/tests/components/epic_games_store/ @Quentame
/homeassistant/components/epion/ @lhgravendeel
/tests/components/epion/ @lhgravendeel
/homeassistant/components/epson/ @pszafer
@@ -566,8 +566,8 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/fortios/ @kimfrellsen
/homeassistant/components/foscam/ @Foscam-wangzhengyu
/tests/components/foscam/ @Foscam-wangzhengyu
/homeassistant/components/freebox/ @hacf-fr @Quentame
/tests/components/freebox/ @hacf-fr @Quentame
/homeassistant/components/freebox/ @hacf-fr/reviewers @Quentame
/tests/components/freebox/ @hacf-fr/reviewers @Quentame
/homeassistant/components/freedompro/ @stefano055415
/tests/components/freedompro/ @stefano055415
/homeassistant/components/freshr/ @SierraNL
@@ -1057,8 +1057,8 @@ CLAUDE.md @home-assistant/core
/tests/components/met/ @danielhiversen
/homeassistant/components/met_eireann/ @DylanGore
/tests/components/met_eireann/ @DylanGore
/homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
/tests/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
/homeassistant/components/meteo_france/ @hacf-fr/reviewers @oncleben31 @Quentame
/tests/components/meteo_france/ @hacf-fr/reviewers @oncleben31 @Quentame
/homeassistant/components/meteo_lt/ @xE1H
/tests/components/meteo_lt/ @xE1H
/homeassistant/components/meteoalarm/ @rolfberkenbosch
@@ -1150,8 +1150,8 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/netatmo/ @cgtobi
/tests/components/netatmo/ @cgtobi
/homeassistant/components/netdata/ @fabaff
/homeassistant/components/netgear/ @hacf-fr @Quentame @starkillerOG
/tests/components/netgear/ @hacf-fr @Quentame @starkillerOG
/homeassistant/components/netgear/ @Quentame @starkillerOG
/tests/components/netgear/ @Quentame @starkillerOG
/homeassistant/components/netgear_lte/ @tkdrob
/tests/components/netgear_lte/ @tkdrob
/homeassistant/components/network/ @home-assistant/core
@@ -1694,8 +1694,8 @@ CLAUDE.md @home-assistant/core
/tests/components/syncthing/ @zhulik
/homeassistant/components/syncthru/ @nielstron
/tests/components/syncthru/ @nielstron
/homeassistant/components/synology_dsm/ @hacf-fr @Quentame @mib1185
/tests/components/synology_dsm/ @hacf-fr @Quentame @mib1185
/homeassistant/components/synology_dsm/ @Quentame @mib1185
/tests/components/synology_dsm/ @Quentame @mib1185
/homeassistant/components/synology_srm/ @aerialls
/homeassistant/components/system_bridge/ @timmo001
/tests/components/system_bridge/ @timmo001
@@ -1828,6 +1828,8 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/unifi_access/ @imhotep @RaHehl
/tests/components/unifi_access/ @imhotep @RaHehl
/homeassistant/components/unifi_direct/ @tofuSCHNITZEL
/homeassistant/components/unifi_discovery/ @RaHehl
/tests/components/unifi_discovery/ @RaHehl
/homeassistant/components/unifiled/ @florisvdk
/homeassistant/components/unifiprotect/ @RaHehl
/tests/components/unifiprotect/ @RaHehl

View File

@@ -6,6 +6,7 @@
"unifi",
"unifi_access",
"unifi_direct",
"unifi_discovery",
"unifiled",
"unifiprotect"
]

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==13.3.2"]
"requirements": ["aioamazondevices==13.4.0"]
}

View File

@@ -71,6 +71,43 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a flow initiated by zeroconf."""
return self._async_create_entry()
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of an existing Elgato device."""
errors: dict[str, str] = {}
if user_input is not None:
elgato = Elgato(
host=user_input[CONF_HOST],
session=async_get_clientsession(self.hass),
)
try:
info = await elgato.info()
except ElgatoError:
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(info.serial_number)
self._abort_if_unique_id_mismatch(reason="different_device")
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data_updates={CONF_HOST: user_input[CONF_HOST]},
)
return self.async_show_form(
step_id="reconfigure",
data_schema=vol.Schema(
{
vol.Required(
CONF_HOST,
default=self._get_reconfigure_entry().data[CONF_HOST],
): str,
}
),
errors=errors,
)
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:

View File

@@ -62,7 +62,7 @@ rules:
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
reconfiguration-flow: done
repair-issues:
status: exempt
comment: |

View File

@@ -3,13 +3,23 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
"different_device": "The configured Elgato device is not the same as the one at this address.",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"flow_title": "{serial_number}",
"step": {
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "[%key:component::elgato::config::step::user::data_description::host%]"
}
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"

View File

@@ -1,7 +1,7 @@
{
"domain": "epic_games_store",
"name": "Epic Games Store",
"codeowners": ["@hacf-fr", "@Quentame"],
"codeowners": ["@Quentame"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/epic_games_store",
"integration_type": "service",

View File

@@ -1,7 +1,7 @@
{
"domain": "freebox",
"name": "Freebox",
"codeowners": ["@hacf-fr", "@Quentame"],
"codeowners": ["@hacf-fr/reviewers", "@Quentame"],
"config_flow": true,
"dependencies": ["ffmpeg"],
"documentation": "https://www.home-assistant.io/integrations/freebox",

View File

@@ -27,6 +27,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTR_SENSOR_ID, CONF_SENSOR_ID, DOMAIN
from .coordinator import LuftdatenConfigEntry, LuftdatenDataUpdateCoordinator
PARALLEL_UPDATES = 0
SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="temperature",

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["mastodon"],
"quality_scale": "gold",
"requirements": ["Mastodon.py==2.1.2"]
"requirements": ["Mastodon.py==2.2.1"]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "cloud_polling",
"loggers": ["melcloud"],
"requirements": ["python-melcloud==0.1.2"]
"requirements": ["python-melcloud==0.1.3"]
}

View File

@@ -1,7 +1,7 @@
{
"domain": "meteo_france",
"name": "M\u00e9t\u00e9o-France",
"codeowners": ["@hacf-fr", "@oncleben31", "@Quentame"],
"codeowners": ["@hacf-fr/reviewers", "@oncleben31", "@Quentame"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/meteo_france",
"integration_type": "service",

View File

@@ -59,6 +59,7 @@ DEFAULT_PLATE_COUNT = 4
PLATE_COUNT = {
"KM7575": 6,
"KM7576": 6,
"KM7678": 6,
"KM7697": 6,
"KM7699": 5,

View File

@@ -1,7 +1,7 @@
{
"domain": "netgear",
"name": "NETGEAR",
"codeowners": ["@hacf-fr", "@Quentame", "@starkillerOG"],
"codeowners": ["@Quentame", "@starkillerOG"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/netgear",
"integration_type": "hub",

View File

@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["satel_integra"],
"quality_scale": "bronze",
"requirements": ["satel-integra==1.1.0"]
"requirements": ["satel-integra==1.1.1"]
}

View File

@@ -12,5 +12,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"quality_scale": "silver",
"requirements": ["PySrDaliGateway==0.19.3"]
"requirements": ["PySrDaliGateway==0.20.4"]
}

View File

@@ -42,5 +42,5 @@
"iot_class": "local_push",
"loggers": ["switchbot"],
"quality_scale": "gold",
"requirements": ["PySwitchbot==2.0.0"]
"requirements": ["PySwitchbot==2.0.1"]
}

View File

@@ -1,7 +1,7 @@
{
"domain": "synology_dsm",
"name": "Synology DSM",
"codeowners": ["@hacf-fr", "@Quentame", "@mib1185"],
"codeowners": ["@Quentame", "@mib1185"],
"config_flow": true,
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/synology_dsm",

View File

@@ -68,7 +68,6 @@ class TailwindButtonEntity(TailwindEntity, ButtonEntity):
await self.entity_description.press_fn(self.coordinator.tailwind)
except TailwindError as exc:
raise HomeAssistantError(
str(exc),
translation_domain=DOMAIN,
translation_key="communication_error",
) from exc

View File

@@ -5,6 +5,7 @@ from datetime import timedelta
from gotailwind import (
Tailwind,
TailwindAuthenticationError,
TailwindConnectionError,
TailwindDeviceStatus,
TailwindError,
)
@@ -45,5 +46,13 @@ class TailwindDataUpdateCoordinator(DataUpdateCoordinator[TailwindDeviceStatus])
return await self.tailwind.status()
except TailwindAuthenticationError as err:
raise ConfigEntryAuthFailed from err
except TailwindConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="communication_error",
) from err
except TailwindError as err:
raise UpdateFailed(err) from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="unknown_error",
) from err

View File

@@ -55,10 +55,7 @@ rules:
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations:
status: exempt
comment: |
The coordinator needs translation when the update failed.
exception-translations: done
icon-translations: done
reconfiguration-flow: done
repair-issues:

View File

@@ -83,6 +83,9 @@
},
"door_locked_out": {
"message": "The door is locked out and cannot be operated."
},
"unknown_error": {
"message": "An unknown error occurred while communicating with the Tailwind device."
}
}
}

View File

@@ -0,0 +1,18 @@
"""The UniFi Discovery integration."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .discovery import async_start_discovery
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up UniFi Discovery."""
async_start_discovery(hass)
return True

View File

@@ -0,0 +1,46 @@
"""Config flow for UniFi Discovery."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from .const import DOMAIN
from .discovery import async_start_discovery
_LOGGER = logging.getLogger(__name__)
class UnifiDiscoveryFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for UniFi Discovery."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a user-initiated flow."""
async_start_discovery(self.hass)
return self.async_abort(reason="discovery_started")
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle discovery via DHCP."""
_LOGGER.debug("Starting discovery via DHCP: %s", discovery_info)
if self._async_in_progress():
return self.async_abort(reason="already_in_progress")
async_start_discovery(self.hass)
return self.async_abort(reason="discovery_started")
async def async_step_ssdp(
self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle discovery via SSDP."""
_LOGGER.debug("Starting discovery via SSDP: %s", discovery_info)
if self._async_in_progress():
return self.async_abort(reason="already_in_progress")
async_start_discovery(self.hass)
return self.async_abort(reason="discovery_started")

View File

@@ -0,0 +1,12 @@
"""Constants for the UniFi Discovery integration."""
from unifi_discovery import UnifiService
DOMAIN = "unifi_discovery"
# Static mapping of UniFi service types to their Home Assistant integration domains.
# This must be static (not a runtime registry) because consumers may not be loaded
# when initial discovery runs — the same pattern DHCP/SSDP use with manifest matchers.
CONSUMER_MAPPING: dict[UnifiService, str] = {
UnifiService.Protect: "unifiprotect",
}

View File

@@ -1,13 +1,13 @@
"""The unifiprotect integration discovery."""
"""UniFi network device discovery."""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from dataclasses import asdict
from datetime import timedelta
import logging
from typing import Any
from unifi_discovery import AIOUnifiScanner, UnifiDevice, UnifiService
from unifi_discovery import AIOUnifiScanner, UnifiDevice
from homeassistant import config_entries
from homeassistant.core import HomeAssistant, callback
@@ -15,32 +15,21 @@ from homeassistant.helpers import discovery_flow
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN
from .const import CONSUMER_MAPPING, DOMAIN
_LOGGER = logging.getLogger(__name__)
@dataclass
class UniFiProtectRuntimeData:
"""Runtime data stored in hass.data[DOMAIN]."""
auth_retries: dict[str, int] = field(default_factory=dict)
discovery_started: bool = False
# Typed key for hass.data access at DOMAIN level
DATA_UNIFIPROTECT: HassKey[UniFiProtectRuntimeData] = HassKey(DOMAIN)
DISCOVERY_INTERVAL = timedelta(minutes=60)
DATA_DISCOVERY_STARTED: HassKey[bool] = HassKey(DOMAIN)
@callback
def async_start_discovery(hass: HomeAssistant) -> None:
"""Start discovery."""
domain_data = hass.data.setdefault(DATA_UNIFIPROTECT, UniFiProtectRuntimeData())
if domain_data.discovery_started:
"""Start discovery of UniFi devices."""
if hass.data.get(DATA_DISCOVERY_STARTED):
return
domain_data.discovery_started = True
hass.data[DATA_DISCOVERY_STARTED] = True
async def _async_discovery() -> None:
async_trigger_discovery(hass, await async_discover_devices())
@@ -48,7 +37,9 @@ def async_start_discovery(hass: HomeAssistant) -> None:
@callback
def _async_start_background_discovery(*_: Any) -> None:
"""Run discovery in the background."""
hass.async_create_background_task(_async_discovery(), "unifiprotect-discovery")
hass.async_create_background_task(
_async_discovery(), "unifi_discovery-discovery"
)
# Do not block startup since discovery takes 31s or more
_async_start_background_discovery()
@@ -61,7 +52,7 @@ def async_start_discovery(hass: HomeAssistant) -> None:
async def async_discover_devices() -> list[UnifiDevice]:
"""Discover devices."""
"""Discover UniFi devices on the network."""
scanner = AIOUnifiScanner()
devices = await scanner.async_scan()
_LOGGER.debug("Found devices: %s", devices)
@@ -75,10 +66,13 @@ def async_trigger_discovery(
) -> None:
"""Trigger config flows for discovered devices."""
for device in discovered_devices:
if device.services[UnifiService.Protect] and device.hw_addr:
discovery_flow.async_create_flow(
hass,
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=asdict(device),
)
if not device.hw_addr:
continue
for service, domain in CONSUMER_MAPPING.items():
if device.services.get(service):
discovery_flow.async_create_flow(
hass,
domain,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=asdict(device),
)

View File

@@ -0,0 +1,63 @@
{
"domain": "unifi_discovery",
"name": "UniFi Discovery",
"codeowners": ["@RaHehl"],
"config_flow": true,
"dhcp": [
{
"macaddress": "B4FBE4*"
},
{
"macaddress": "802AA8*"
},
{
"macaddress": "F09FC2*"
},
{
"macaddress": "68D79A*"
},
{
"macaddress": "18E829*"
},
{
"macaddress": "245A4C*"
},
{
"macaddress": "784558*"
},
{
"macaddress": "E063DA*"
},
{
"macaddress": "265A4C*"
},
{
"macaddress": "74ACB9*"
}
],
"documentation": "https://www.home-assistant.io/integrations/unifi_discovery",
"integration_type": "system",
"iot_class": "local_polling",
"loggers": ["unifi_discovery"],
"quality_scale": "internal",
"requirements": ["unifi-discovery==1.4.0"],
"single_config_entry": true,
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",
"modelDescription": "UniFi Dream Machine"
},
{
"manufacturer": "Ubiquiti Networks",
"modelDescription": "UniFi Dream Machine Pro"
},
{
"manufacturer": "Ubiquiti Networks",
"modelDescription": "UniFi Dream Machine SE"
},
{
"manufacturer": "Ubiquiti Networks",
"modelDescription": "UniFi Dream Machine Pro Max"
}
]
}

View File

@@ -0,0 +1,13 @@
{
"config": {
"abort": {
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"discovery_started": "Discovery started"
},
"step": {
"user": {
"description": "UniFi Discovery is set up automatically."
}
}
}
}

View File

@@ -40,7 +40,6 @@ from .const import (
PLATFORMS,
)
from .data import ProtectData, UFPConfigEntry
from .discovery import DATA_UNIFIPROTECT, UniFiProtectRuntimeData, async_start_discovery
from .migrate import async_migrate_data
from .services import async_setup_services
from .utils import (
@@ -64,11 +63,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the UniFi Protect."""
# Initialize domain data structure (setdefault in case discovery already started)
hass.data.setdefault(DATA_UNIFIPROTECT, UniFiProtectRuntimeData())
# Only start discovery once regardless of how many entries they have
async_setup_services(hass)
async_start_discovery(hass)
return True
@@ -78,20 +73,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool:
protect = async_create_api_client(hass, entry)
_LOGGER.debug("Connect to UniFi Protect")
# Reuse ProtectData from previous retry or create new
if hasattr(entry, "runtime_data"):
data_service = entry.runtime_data
data_service.api = protect
else:
data_service = ProtectData(hass, protect, SCAN_INTERVAL, entry)
entry.runtime_data = data_service
try:
await protect.update()
except NotAuthorized as err:
domain_data = hass.data.setdefault(DATA_UNIFIPROTECT, UniFiProtectRuntimeData())
retries = domain_data.auth_retries.get(entry.entry_id, 0)
if retries < AUTH_RETRIES:
retries += 1
domain_data.auth_retries[entry.entry_id] = retries
raise ConfigEntryNotReady from err
raise ConfigEntryAuthFailed(err) from err
data_service.auth_retries += 1
if data_service.auth_retries > AUTH_RETRIES:
raise ConfigEntryAuthFailed(err) from err
raise ConfigEntryNotReady from err
except (TimeoutError, ClientError, ServerDisconnectedError) as err:
raise ConfigEntryNotReady from err
data_service = ProtectData(hass, protect, SCAN_INTERVAL, entry)
bootstrap = protect.bootstrap
nvr_info = bootstrap.nvr
auth_user = bootstrap.users.get(bootstrap.auth_user_id)
@@ -142,7 +140,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool:
if entry.unique_id is None:
hass.config_entries.async_update_entry(entry, unique_id=nvr_info.mac)
entry.runtime_data = data_service
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, data_service.async_stop)
)

View File

@@ -36,8 +36,6 @@ from homeassistant.helpers.aiohttp_client import (
async_create_clientsession,
async_get_clientsession,
)
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from homeassistant.helpers.storage import STORAGE_DIR
from homeassistant.helpers.typing import DiscoveryInfoType
from homeassistant.loader import async_get_integration
@@ -56,7 +54,6 @@ from .const import (
OUTDATED_LOG_MESSAGE,
)
from .data import UFPConfigEntry, async_last_update_was_successful
from .discovery import async_start_discovery
from .utils import (
_async_resolve,
_async_short_mac,
@@ -205,28 +202,6 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
super().__init__()
self._discovered_device: dict[str, str] = {}
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle discovery via dhcp."""
_LOGGER.debug("Starting discovery via: %s", discovery_info)
return await self._async_discovery_handoff()
async def async_step_ssdp(
self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered UniFi device."""
_LOGGER.debug("Starting discovery via: %s", discovery_info)
return await self._async_discovery_handoff()
async def _async_discovery_handoff(self) -> ConfigFlowResult:
"""Ensure discovery is active."""
# Discovery requires an additional check so we use
# SSDP and DHCP to tell us to start it so it only
# runs on networks where unifi devices are present.
async_start_discovery(self.hass)
return self.async_abort(reason="discovery_started")
async def async_step_integration_discovery(
self, discovery_info: DiscoveryInfoType
) -> ConfigFlowResult:

View File

@@ -86,6 +86,7 @@ class ProtectData:
self._pending_camera_ids: set[str] = set()
self._unsubs: list[CALLBACK_TYPE] = []
self._auth_failures = 0
self.auth_retries = 0
self.last_update_success = False
self.api = protect
self.adopt_signal = _async_dispatch_id(entry, DISPATCH_ADOPT)

View File

@@ -3,61 +3,11 @@
"name": "UniFi Protect",
"codeowners": ["@RaHehl"],
"config_flow": true,
"dependencies": ["http", "repairs"],
"dhcp": [
{
"macaddress": "B4FBE4*"
},
{
"macaddress": "802AA8*"
},
{
"macaddress": "F09FC2*"
},
{
"macaddress": "68D79A*"
},
{
"macaddress": "18E829*"
},
{
"macaddress": "245A4C*"
},
{
"macaddress": "784558*"
},
{
"macaddress": "E063DA*"
},
{
"macaddress": "265A4C*"
},
{
"macaddress": "74ACB9*"
}
],
"dependencies": ["http", "repairs", "unifi_discovery"],
"documentation": "https://www.home-assistant.io/integrations/unifiprotect",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["uiprotect", "unifi_discovery"],
"loggers": ["uiprotect"],
"quality_scale": "platinum",
"requirements": ["uiprotect==10.2.6", "unifi-discovery==1.4.0"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",
"modelDescription": "UniFi Dream Machine"
},
{
"manufacturer": "Ubiquiti Networks",
"modelDescription": "UniFi Dream Machine Pro"
},
{
"manufacturer": "Ubiquiti Networks",
"modelDescription": "UniFi Dream Machine SE"
},
{
"manufacturer": "Ubiquiti Networks",
"modelDescription": "UniFi Dream Machine Pro Max"
}
]
"requirements": ["uiprotect==10.2.6"]
}

View File

@@ -39,7 +39,9 @@ rules:
devices: done
diagnostics: done
discovery-update-info: done
discovery: done
discovery:
status: exempt
comment: Discovery is handled via unifi_discovery dependency using SOURCE_INTEGRATION_DISCOVERY.
docs-data-update: done
docs-examples: done
docs-known-limitations: done

View File

@@ -18,6 +18,7 @@ PLATFORMS = [
UNSUPPORTED_DEVICES = [
"Heatbox1",
"Heatbox2_SRC",
"Heatbox3",
"E3_TCU10_x07",
"E3_TCU41_x04",
"E3_RoomControl_One_522",

View File

@@ -766,6 +766,7 @@ FLOWS = {
"ukraine_alarm",
"unifi",
"unifi_access",
"unifi_discovery",
"unifiprotect",
"upb",
"upcloud",

View File

@@ -1331,43 +1331,43 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"hostname": "twinkly-*",
},
{
"domain": "unifiprotect",
"domain": "unifi_discovery",
"macaddress": "B4FBE4*",
},
{
"domain": "unifiprotect",
"domain": "unifi_discovery",
"macaddress": "802AA8*",
},
{
"domain": "unifiprotect",
"domain": "unifi_discovery",
"macaddress": "F09FC2*",
},
{
"domain": "unifiprotect",
"domain": "unifi_discovery",
"macaddress": "68D79A*",
},
{
"domain": "unifiprotect",
"domain": "unifi_discovery",
"macaddress": "18E829*",
},
{
"domain": "unifiprotect",
"domain": "unifi_discovery",
"macaddress": "245A4C*",
},
{
"domain": "unifiprotect",
"domain": "unifi_discovery",
"macaddress": "784558*",
},
{
"domain": "unifiprotect",
"domain": "unifi_discovery",
"macaddress": "E063DA*",
},
{
"domain": "unifiprotect",
"domain": "unifi_discovery",
"macaddress": "265A4C*",
},
{
"domain": "unifiprotect",
"domain": "unifi_discovery",
"macaddress": "74ACB9*",
},
{

View File

@@ -359,7 +359,7 @@ SSDP = {
"modelDescription": "UniFi Dream Machine Pro Max",
},
],
"unifiprotect": [
"unifi_discovery": [
{
"manufacturer": "Ubiquiti Networks",
"modelDescription": "UniFi Dream Machine",

View File

@@ -1434,16 +1434,16 @@ class IntentResponse:
def as_dict(self) -> dict[str, Any]:
"""Return a dictionary representation of an intent response."""
response_dict: dict[str, Any] = {
"speech": self.speech,
"card": self.card,
"speech": {k: dict(v) for k, v in self.speech.items()},
"card": {k: dict(v) for k, v in self.card.items()},
"language": self.language,
"response_type": self.response_type.value,
}
if self.reprompt:
response_dict["reprompt"] = self.reprompt
response_dict["reprompt"] = {k: dict(v) for k, v in self.reprompt.items()}
if self.speech_slots:
response_dict["speech_slots"] = self.speech_slots
response_dict["speech_slots"] = self.speech_slots.copy()
response_data: dict[str, Any] = {}

14
requirements_all.txt generated
View File

@@ -25,7 +25,7 @@ HATasmota==0.10.1
HueBLE==2.1.0
# homeassistant.components.mastodon
Mastodon.py==2.1.2
Mastodon.py==2.2.1
# homeassistant.components.playstation_network
PSNAWP==3.0.3
@@ -80,10 +80,10 @@ PyQRCode==1.2.1
PyRMVtransport==0.3.3
# homeassistant.components.sunricher_dali
PySrDaliGateway==0.19.3
PySrDaliGateway==0.20.4
# homeassistant.components.switchbot
PySwitchbot==2.0.0
PySwitchbot==2.0.1
# homeassistant.components.switchmate
PySwitchmate==0.5.1
@@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.5
# homeassistant.components.alexa_devices
aioamazondevices==13.3.2
aioamazondevices==13.4.0
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -2617,7 +2617,7 @@ python-kasa[speedups]==0.10.2
python-linkplay==0.2.12
# homeassistant.components.melcloud
python-melcloud==0.1.2
python-melcloud==0.1.3
# homeassistant.components.xiaomi_miio
python-miio==0.5.12
@@ -2886,7 +2886,7 @@ samsungtvws[async,encrypted]==2.7.2
sanix==1.0.6
# homeassistant.components.satel_integra
satel-integra==1.1.0
satel-integra==1.1.1
# homeassistant.components.screenlogic
screenlogicpy==0.10.2
@@ -3186,7 +3186,7 @@ uiprotect==10.2.6
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7
# homeassistant.components.unifiprotect
# homeassistant.components.unifi_discovery
unifi-discovery==1.4.0
# homeassistant.components.unifi_direct

View File

@@ -25,7 +25,7 @@ HATasmota==0.10.1
HueBLE==2.1.0
# homeassistant.components.mastodon
Mastodon.py==2.1.2
Mastodon.py==2.2.1
# homeassistant.components.playstation_network
PSNAWP==3.0.3
@@ -80,10 +80,10 @@ PyQRCode==1.2.1
PyRMVtransport==0.3.3
# homeassistant.components.sunricher_dali
PySrDaliGateway==0.19.3
PySrDaliGateway==0.20.4
# homeassistant.components.switchbot
PySwitchbot==2.0.0
PySwitchbot==2.0.1
# homeassistant.components.syncthru
PySyncThru==0.8.0
@@ -181,7 +181,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.5
# homeassistant.components.alexa_devices
aioamazondevices==13.3.2
aioamazondevices==13.4.0
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -2219,7 +2219,7 @@ python-kasa[speedups]==0.10.2
python-linkplay==0.2.12
# homeassistant.components.melcloud
python-melcloud==0.1.2
python-melcloud==0.1.3
# homeassistant.components.xiaomi_miio
python-miio==0.5.12
@@ -2449,7 +2449,7 @@ samsungtvws[async,encrypted]==2.7.2
sanix==1.0.6
# homeassistant.components.satel_integra
satel-integra==1.1.0
satel-integra==1.1.1
# homeassistant.components.screenlogic
screenlogicpy==0.10.2
@@ -2698,7 +2698,7 @@ uiprotect==10.2.6
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7
# homeassistant.components.unifiprotect
# homeassistant.components.unifi_discovery
unifi-discovery==1.4.0
# homeassistant.components.homeassistant_hardware

View File

@@ -9,7 +9,7 @@ from .brand import validate as validate_brands
from .model import Brand, Config, Integration, IntegrationType
from .serializer import format_python_namespace
UNIQUE_ID_IGNORE = {"huawei_lte", "mqtt", "adguard"}
UNIQUE_ID_IGNORE = {"huawei_lte", "mqtt", "adguard", "unifi_discovery"}
def _validate_integration(config: Config, integration: Integration) -> None:

View File

@@ -2151,6 +2151,7 @@ NO_QUALITY_SCALE = [
"search",
"system_health",
"system_log",
"unifi_discovery",
"tag",
"temperature",
"timer",

View File

@@ -3,12 +3,12 @@
from typing import Any
from unittest.mock import AsyncMock, patch
from aioamazondevices.api import AmazonDeviceSensor
from aioamazondevices.exceptions import (
CannotAuthenticate,
CannotConnect,
CannotRetrieveData,
)
from aioamazondevices.structures import AmazonDeviceSensor
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion

View File

@@ -3,7 +3,7 @@
from copy import deepcopy
from unittest.mock import AsyncMock, patch
from aioamazondevices.api import AmazonDeviceSensor
from aioamazondevices.structures import AmazonDeviceSensor
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion

View File

@@ -11,6 +11,7 @@ async def test_async_get_result_from_chat_log(
) -> None:
"""Test getting result from chat log."""
intent_response = intent.IntentResponse(language="en")
tool_result = llm.IntentResponseDict(intent_response)
with (
chat_session.async_get_chat_session(hass) as session,
conversation.async_get_chat_log(
@@ -23,7 +24,7 @@ async def test_async_get_result_from_chat_log(
agent_id="mock-agent-id",
tool_call_id="mock-tool-call-id",
tool_name="mock-tool-name",
tool_result=llm.IntentResponseDict(intent_response),
tool_result=tool_result,
),
conversation.AssistantContent(
agent_id="mock-agent-id",
@@ -37,3 +38,4 @@ async def test_async_get_result_from_chat_log(
# Original intent response is returned with speech set
assert result.response is intent_response
assert result.response.speech["plain"]["speech"] == "This is a response."
assert tool_result["speech"] != result.response.speech

View File

@@ -3,7 +3,7 @@
from ipaddress import ip_address
from unittest.mock import AsyncMock, MagicMock
from elgato import ElgatoConnectionError
from elgato import ElgatoConnectionError, ElgatoError
import pytest
from homeassistant.components.elgato.const import DOMAIN
@@ -331,3 +331,76 @@ async def test_dhcp_discovery_no_match(
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
assert mock_config_entry.data[CONF_HOST] == "127.0.0.1"
@pytest.mark.usefixtures("mock_elgato")
async def test_reconfigure_flow(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reconfiguring an existing Elgato device."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_HOST: "127.0.0.42"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert mock_config_entry.data[CONF_HOST] == "127.0.0.42"
async def test_reconfigure_flow_cannot_connect(
hass: HomeAssistant,
mock_elgato: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reconfigure flow recovers from a connection error."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
mock_elgato.info.side_effect = ElgatoError
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_HOST: "127.0.0.42"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
assert result["errors"] == {"base": "cannot_connect"}
mock_elgato.info.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_HOST: "127.0.0.42"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
async def test_reconfigure_flow_different_device(
hass: HomeAssistant,
mock_elgato: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reconfigure aborts when the device at the new host has a different serial."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
mock_elgato.info.return_value.serial_number = "DIFFERENT_SERIAL"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_HOST: "127.0.0.42"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "different_device"
assert mock_config_entry.data[CONF_HOST] == "127.0.0.1"

View File

@@ -1 +1,67 @@
"""Tests for the Eurotronic Comet Blue integration."""
from eurotronic_cometblue_ha import const as cometblue_const
from homeassistant.const import CONF_PIN
FIXTURE_DEVICE_NAME = "Comet Blue"
FIXTURE_MAC = "aa:bb:cc:dd:ee:ff"
FIXTURE_RSSI = -60
FIXTURE_SERVICE_UUID = "47e9ee00-47e9-11e4-8939-164230d1df67"
WRITEABLE_CHARACTERISTICS = [
cometblue_const.CHARACTERISTIC_DATETIME,
cometblue_const.CHARACTERISTIC_MONDAY,
cometblue_const.CHARACTERISTIC_TUESDAY,
cometblue_const.CHARACTERISTIC_WEDNESDAY,
cometblue_const.CHARACTERISTIC_THURSDAY,
cometblue_const.CHARACTERISTIC_FRIDAY,
cometblue_const.CHARACTERISTIC_SATURDAY,
cometblue_const.CHARACTERISTIC_SUNDAY,
cometblue_const.CHARACTERISTIC_HOLIDAY_1,
cometblue_const.CHARACTERISTIC_SETTINGS,
cometblue_const.CHARACTERISTIC_TEMPERATURE,
cometblue_const.CHARACTERISTIC_PIN,
]
WRITEABLE_CHARACTERISTICS_ALLOW_UNCHANGED = [
cometblue_const.CHARACTERISTIC_SETTINGS,
cometblue_const.CHARACTERISTIC_TEMPERATURE,
]
FIXTURE_DEFAULT_CHARACTERISTICS = {
cometblue_const.CHARACTERISTIC_MODEL: b"Comet Blue",
cometblue_const.CHARACTERISTIC_VERSION: b"0.0.10",
cometblue_const.CHARACTERISTIC_MANUFACTURER: b"Eurotronic GmbH",
cometblue_const.CHARACTERISTIC_HOLIDAY_1: [
128,
27,
11,
22,
128,
27,
11,
22,
34,
],
cometblue_const.CHARACTERISTIC_TEMPERATURE: [
41,
40,
34,
42,
0,
4,
10,
],
cometblue_const.CHARACTERISTIC_BATTERY: b"48",
cometblue_const.CHARACTERISTIC_MONDAY: [37, 137, 0, 0, 0, 0, 0, 0],
cometblue_const.CHARACTERISTIC_TUESDAY: [37, 137, 0, 0, 0, 0, 0, 0],
cometblue_const.CHARACTERISTIC_WEDNESDAY: [37, 137, 0, 0, 0, 0, 0, 0],
cometblue_const.CHARACTERISTIC_THURSDAY: [37, 137, 0, 0, 0, 0, 0, 0],
cometblue_const.CHARACTERISTIC_FRIDAY: [0, 1, 10, 20, 21, 130, 140, 143],
cometblue_const.CHARACTERISTIC_SATURDAY: [37, 137, 0, 0, 0, 0, 0, 0],
cometblue_const.CHARACTERISTIC_SUNDAY: [37, 137, 0, 0, 0, 0, 0, 0],
}
FIXTURE_USER_INPUT = {
CONF_PIN: "000000",
}

View File

@@ -1,6 +1,6 @@
"""Session fixtures."""
from collections.abc import Generator
from collections.abc import Buffer, Generator
from typing import Any
from unittest.mock import AsyncMock, patch
import uuid
@@ -12,17 +12,21 @@ from eurotronic_cometblue_ha import CometBlueBleakClient
import pytest
from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
from homeassistant.components.eurotronic_cometblue import PLATFORMS
from homeassistant.components.eurotronic_cometblue.const import DOMAIN
from homeassistant.const import CONF_ADDRESS
from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import format_mac
from .const import (
from . import (
FIXTURE_DEFAULT_CHARACTERISTICS,
FIXTURE_DEVICE_NAME,
FIXTURE_GATT_CHARACTERISTICS,
FIXTURE_MAC,
FIXTURE_RSSI,
FIXTURE_SERVICE_UUID,
FIXTURE_USER_INPUT,
WRITEABLE_CHARACTERISTICS,
WRITEABLE_CHARACTERISTICS_ALLOW_UNCHANGED,
)
from tests.common import MockConfigEntry
@@ -58,9 +62,24 @@ FAKE_SERVICE_INFO = BluetoothServiceInfoBleak(
)
def _normalize_characteristic(
char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID,
) -> uuid.UUID:
"""Normalize a characteristic specifier to UUID."""
if not isinstance(char_specifier, (BleakGATTCharacteristic, str, uuid.UUID)):
raise BleakCharacteristicNotFoundError(char_specifier)
if isinstance(char_specifier, BleakGATTCharacteristic):
char_specifier = char_specifier.uuid
if not isinstance(char_specifier, uuid.UUID):
char_specifier = uuid.UUID(char_specifier)
return char_specifier
class MockCometBlueBleakClient(CometBlueBleakClient):
"""Mock BleakClient."""
characteristics: dict[uuid.UUID, bytearray] = {}
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Mock init."""
super().__init__(*args, **kwargs)
@@ -94,17 +113,40 @@ class MockCometBlueBleakClient(CometBlueBleakClient):
**kwargs: Any,
) -> bytearray:
"""Mock read_gatt_char."""
if not isinstance(char_specifier, (BleakGATTCharacteristic, str, uuid.UUID)):
raise BleakCharacteristicNotFoundError(char_specifier)
if isinstance(char_specifier, BleakGATTCharacteristic):
char_specifier = char_specifier.uuid
if not isinstance(char_specifier, uuid.UUID):
char_specifier = uuid.UUID(char_specifier)
char_specifier = _normalize_characteristic(char_specifier)
try:
return FIXTURE_GATT_CHARACTERISTICS[char_specifier]
return bytearray(self.characteristics[char_specifier])
except KeyError:
raise BleakCharacteristicNotFoundError(char_specifier)
async def write_gatt_char(
self,
char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID,
data: Buffer,
response: bool | None = None,
) -> None:
"""Mock write_gatt_char."""
char_specifier = _normalize_characteristic(char_specifier)
if char_specifier not in WRITEABLE_CHARACTERISTICS:
raise BleakCharacteristicNotFoundError(char_specifier)
data = bytearray(data)
# when writing temperature it is possible that 128 will be sent, meaning "no change"
# we have to restore the original value in this case to keep tests working
if char_specifier in WRITEABLE_CHARACTERISTICS_ALLOW_UNCHANGED:
for i, byte in enumerate(data):
if byte == 128:
data[i] = self.characteristics[char_specifier][i]
self.characteristics[char_specifier] = data
@pytest.fixture
def mock_gatt_characteristics() -> dict[uuid.UUID, bytearray]:
"""Provide a mutable per-test GATT characteristic store."""
return {
characteristic: bytearray(value)
for characteristic, value in FIXTURE_DEFAULT_CHARACTERISTICS.items()
}
@pytest.fixture
def mock_service_info() -> Generator[None]:
@@ -133,13 +175,26 @@ def mock_ble_device() -> Generator[None]:
@pytest.fixture(autouse=True)
def mock_bluetooth(enable_bluetooth: None) -> Generator[None]:
def mock_bluetooth(
enable_bluetooth: None,
mock_gatt_characteristics: dict[uuid.UUID, bytearray],
) -> Generator[None]:
"""Auto mock bluetooth."""
with patch(
"eurotronic_cometblue_ha.CometBlueBleakClient", MockCometBlueBleakClient
MockCometBlueBleakClient.characteristics = mock_gatt_characteristics
with (
patch(
"homeassistant.components.eurotronic_cometblue.entity.bluetooth.async_address_present",
return_value=True,
),
patch(
"homeassistant.components.eurotronic_cometblue.coordinator.COMMAND_RETRY_INTERVAL",
0,
),
patch("eurotronic_cometblue_ha.CometBlueBleakClient", MockCometBlueBleakClient),
):
yield
MockCometBlueBleakClient.characteristics = {}
# Home Assistant related fixtures
@@ -164,3 +219,16 @@ def mock_setup_entry() -> Generator[AsyncMock]:
return_value=True,
) as mock_setup:
yield mock_setup
async def setup_with_selected_platforms(
hass: HomeAssistant, entry: MockConfigEntry, platforms: list[Platform] | None = None
) -> None:
"""Set up the Eurotronic Comet Blue integration with the selected platforms."""
entry.add_to_hass(hass)
with patch(
"homeassistant.components.eurotronic_cometblue.PLATFORMS",
platforms or PLATFORMS,
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()

View File

@@ -1,29 +0,0 @@
"""Constants for Eurotronic CometBlue tests."""
from uuid import UUID
from homeassistant.const import CONF_PIN
FIXTURE_DEVICE_NAME = "Comet Blue"
FIXTURE_MAC = "aa:bb:cc:dd:ee:ff"
FIXTURE_RSSI = -60
FIXTURE_SERVICE_UUID = "47e9ee00-47e9-11e4-8939-164230d1df67"
FIXTURE_GATT_CHARACTERISTICS = {
UUID("00002a24-0000-1000-8000-00805f9b34fb"): bytearray(b"Comet Blue"), # model
UUID("00002a26-0000-1000-8000-00805f9b34fb"): bytearray(b"0.0.10"), # version
UUID("00002a29-0000-1000-8000-00805f9b34fb"): bytearray(
b"Eurotronic GmbH"
), # manufacturer
UUID("47e9ee20-47e9-11e4-8939-164230d1df67"): bytearray(
b'\x80\x1b\x0b\x16\x80\x1b\x0b\x16"'
), # holiday 1
UUID("47e9ee2b-47e9-11e4-8939-164230d1df67"): bytearray(
b"/999\x00\x04\n"
), # temperature
UUID("47e9ee2c-47e9-11e4-8939-164230d1df67"): bytearray(b"48"), # battery
}
FIXTURE_USER_INPUT = {
CONF_PIN: "000000",
}

View File

@@ -0,0 +1,88 @@
# serializer version: 1
# name: test_climate_state[climate.comet_blue_aa_bb_cc_dd_ee_ff-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT: 'heat'>,
<HVACMode.OFF: 'off'>,
]),
'max_temp': 28.5,
'min_temp': 7.5,
'preset_modes': list([
'comfort',
'eco',
'boost',
'away',
'none',
]),
'target_temp_step': 0.5,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.comet_blue_aa_bb_cc_dd_ee_ff',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'eurotronic_cometblue',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 403>,
'translation_key': None,
'unique_id': 'aa:bb:cc:dd:ee:ff',
'unit_of_measurement': None,
})
# ---
# name: test_climate_state[climate.comet_blue_aa_bb_cc_dd_ee_ff-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 20.5,
'friendly_name': 'Comet Blue aa:bb:cc:dd:ee:ff',
'hvac_modes': list([
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT: 'heat'>,
<HVACMode.OFF: 'off'>,
]),
'max_temp': 28.5,
'min_temp': 7.5,
'preset_mode': 'none',
'preset_modes': list([
'comfort',
'eco',
'boost',
'away',
'none',
]),
'supported_features': <ClimateEntityFeature: 403>,
'target_temp_high': 21.0,
'target_temp_low': 17.0,
'target_temp_step': 0.5,
'temperature': 20.0,
}),
'context': <ANY>,
'entity_id': 'climate.comet_blue_aa_bb_cc_dd_ee_ff',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'auto',
})
# ---

View File

@@ -0,0 +1,331 @@
"""Test the eurotronic_cometblue climate platform."""
from unittest.mock import patch
import uuid
from eurotronic_cometblue_ha import const as cometblue_const
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.climate import (
ATTR_CURRENT_TEMPERATURE,
ATTR_HVAC_MODE,
ATTR_PRESET_MODE,
DOMAIN as CLIMATE_DOMAIN,
PRESET_AWAY,
PRESET_BOOST,
PRESET_COMFORT,
PRESET_ECO,
PRESET_NONE,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_PRESET_MODE,
SERVICE_SET_TEMPERATURE,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
HVACMode,
)
from homeassistant.components.eurotronic_cometblue.climate import MAX_TEMP, MIN_TEMP
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
from .conftest import setup_with_selected_platforms
from tests.common import MockConfigEntry, snapshot_platform
ENTITY_ID = "climate.comet_blue_aa_bb_cc_dd_ee_ff"
async def test_climate_state(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test climate entity state and registry data."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("temperature_values", "expected_hvac_mode", "expected_preset"),
[
([47, 15, 34, 42, 0, 4, 10], HVACMode.OFF, PRESET_NONE),
([47, 40, 34, 42, 0, 4, 10], HVACMode.AUTO, PRESET_NONE),
([47, 42, 34, 42, 0, 4, 10], HVACMode.AUTO, PRESET_COMFORT),
([47, 34, 34, 42, 0, 4, 10], HVACMode.AUTO, PRESET_ECO),
([47, 57, 57, 57, 0, 4, 10], HVACMode.HEAT, PRESET_BOOST),
],
)
async def test_climate_hvac_and_preset_states(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_gatt_characteristics: dict[uuid.UUID, bytearray],
temperature_values: list[int],
expected_hvac_mode: HVACMode,
expected_preset: str,
) -> None:
"""Test climate state mapping from device temperatures."""
mock_gatt_characteristics[cometblue_const.CHARACTERISTIC_TEMPERATURE] = bytearray(
temperature_values
)
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
assert (state := hass.states.get(ENTITY_ID))
assert state.state == expected_hvac_mode
assert state.attributes[ATTR_PRESET_MODE] == expected_preset
async def test_set_temperature(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setting target temperature."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
assert (state := hass.states.get(ENTITY_ID))
assert state.attributes[ATTR_TEMPERATURE] == 20.0
assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.5
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 21.0},
blocking=True,
)
assert (state := hass.states.get(ENTITY_ID))
assert state.attributes[ATTR_TEMPERATURE] == 21.0
assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.5
async def test_climate_preset_away_active(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_gatt_characteristics: dict[uuid.UUID, bytearray],
) -> None:
"""Test away preset detection from holiday data."""
# Holiday active if start hour >= 128 and end hour < 128
mock_gatt_characteristics[cometblue_const.CHARACTERISTIC_HOLIDAY_1] = bytearray(
[128, 1, 1, 26, 10, 2, 1, 26, 34]
)
# Current target temperature must match holiday temperature for away preset to be active
mock_gatt_characteristics[cometblue_const.CHARACTERISTIC_TEMPERATURE] = bytearray(
[47, 34, 34, 42, 0, 4, 10]
)
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
assert (state := hass.states.get(ENTITY_ID))
assert state.attributes[ATTR_PRESET_MODE] == PRESET_AWAY
with pytest.raises(
ServiceValidationError,
match="Cannot adjust TRV remotely",
):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 21.0},
blocking=True,
)
@pytest.mark.parametrize(
("preset_mode", "expected_temperature", "expected_state"),
[
(PRESET_ECO, 17.0, HVACMode.AUTO),
(PRESET_COMFORT, 21.0, HVACMode.AUTO),
(PRESET_BOOST, MAX_TEMP, HVACMode.HEAT),
],
)
async def test_set_preset_mode(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
preset_mode: str,
expected_temperature: float,
expected_state: HVACMode,
) -> None:
"""Test setting preset modes."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: preset_mode},
blocking=True,
)
assert (state := hass.states.get(ENTITY_ID))
assert state.attributes[ATTR_TEMPERATURE] == expected_temperature
assert state.attributes[ATTR_PRESET_MODE] == preset_mode
assert state.state == expected_state
@pytest.mark.parametrize("preset_mode", [PRESET_NONE, PRESET_AWAY])
async def test_set_preset_mode_display_only_raises(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
preset_mode: str,
) -> None:
"""Test display-only presets cannot be set."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
with pytest.raises(ServiceValidationError, match="Unable to set preset"):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: preset_mode},
blocking=True,
)
@pytest.mark.parametrize(
("hvac_mode", "expected_temperature", "expected_preset"),
[
(HVACMode.OFF, MIN_TEMP, PRESET_NONE),
(HVACMode.HEAT, MAX_TEMP, PRESET_BOOST),
(HVACMode.AUTO, 17.0, PRESET_ECO),
],
)
async def test_set_hvac_mode(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
hvac_mode: HVACMode,
expected_temperature: float,
expected_preset: str,
) -> None:
"""Test setting HVAC modes."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: hvac_mode},
blocking=True,
)
assert (state := hass.states.get(ENTITY_ID))
assert state.attributes[ATTR_TEMPERATURE] == expected_temperature
assert state.attributes[ATTR_PRESET_MODE] == expected_preset
assert state.state == hvac_mode
@pytest.mark.parametrize(
("service", "expected_temperature"),
[
(SERVICE_TURN_OFF, MIN_TEMP),
(SERVICE_TURN_ON, 17.0),
],
)
async def test_turn_on_turn_off(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
service: str,
expected_temperature: float,
) -> None:
"""Test turn_on and turn_off services."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
assert (state := hass.states.get(ENTITY_ID))
assert state.attributes[ATTR_TEMPERATURE] == 20.0
await hass.services.async_call(
CLIMATE_DOMAIN,
service,
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
)
assert (state := hass.states.get(ENTITY_ID))
assert state.attributes[ATTR_TEMPERATURE] == expected_temperature
@pytest.mark.parametrize(
("raise_exception", "raised_exception"),
[
(TimeoutError, HomeAssistantError),
(ValueError, ServiceValidationError),
],
)
async def test_set_temperature_errors(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
raise_exception: type[Exception],
raised_exception: type[Exception],
) -> None:
"""Test setting target temperature."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
# raise exceptions to test error handling
with (
pytest.raises(raised_exception),
patch(
"homeassistant.components.eurotronic_cometblue.coordinator.AsyncCometBlue.set_temperature_async",
side_effect=raise_exception(),
),
):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 21.0},
blocking=True,
)
async def test_update_data_error_handling(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that update data errors are handled and retried."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
assert (state := hass.states.get(ENTITY_ID))
assert state.attributes[ATTR_TEMPERATURE] == 20.0
# Fail with TimeoutError (expected) and raise UpdateFailed after 3 retries
with patch.object(
mock_config_entry.runtime_data.device,
"get_temperature_async",
side_effect=TimeoutError(),
) as mock_get_temperature:
await mock_config_entry.runtime_data.async_refresh()
await hass.async_block_till_done()
assert mock_get_temperature.call_count == 3
assert mock_config_entry.runtime_data.last_update_success is False
assert (state := hass.states.get(ENTITY_ID))
assert state.attributes[ATTR_TEMPERATURE] == 20.0
# Fail with OSError (unexpected) and raise UpdateFailed directly
with patch.object(
mock_config_entry.runtime_data.device,
"get_temperature_async",
side_effect=OSError(),
) as mock_get_temperature:
await mock_config_entry.runtime_data.async_refresh()
await hass.async_block_till_done()
assert mock_get_temperature.call_count == 1
assert mock_config_entry.runtime_data.last_update_success is False
assert (state := hass.states.get(ENTITY_ID))
assert state.attributes[ATTR_TEMPERATURE] == 20.0
# Fail once with TimeoutError and then succeed, verify that data is updated
updated_temperatures = dict(mock_config_entry.runtime_data.data.temperatures)
updated_temperatures["manualTemp"] = 27.0
with patch.object(
mock_config_entry.runtime_data.device,
"get_temperature_async",
side_effect=[TimeoutError(), updated_temperatures],
) as mock_get_temperature:
await mock_config_entry.runtime_data.async_refresh()
await hass.async_block_till_done()
assert mock_get_temperature.call_count == 2
assert mock_config_entry.runtime_data.last_update_success is True
assert (state := hass.states.get(ENTITY_ID))
assert state.attributes[ATTR_TEMPERATURE] == 27.0

View File

@@ -16,8 +16,8 @@ from homeassistant.const import CONF_ADDRESS, CONF_PIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import FIXTURE_DEVICE_NAME, FIXTURE_MAC, FIXTURE_USER_INPUT
from .conftest import FAKE_SERVICE_INFO
from .const import FIXTURE_DEVICE_NAME, FIXTURE_MAC, FIXTURE_USER_INPUT
from tests.common import MockConfigEntry

View File

@@ -4,6 +4,7 @@
'account': dict({
'acct': 'trwnh',
'avatar': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png',
'avatar_description': None,
'avatar_static': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png',
'bot': True,
'created_at': '2016-11-24T00:00:00+00:00',
@@ -37,6 +38,7 @@
'following_count': 328,
'group': False,
'header': 'https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg',
'header_description': None,
'header_static': 'https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg',
'hide_collections': True,
'id': '14715',
@@ -53,6 +55,9 @@
'role': None,
'roles': list([
]),
'show_featured': None,
'show_media': None,
'show_media_replies': None,
'source': None,
'statuses_count': 69523,
'suspended': None,
@@ -80,6 +85,7 @@
}),
}),
'version': '4.4.0-nightly.2025-02-07',
'wrapstodon': None,
}),
})
# ---
@@ -88,6 +94,7 @@
'account': dict({
'acct': 'trwnh',
'avatar': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png',
'avatar_description': None,
'avatar_static': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png',
'bot': True,
'created_at': '2016-11-24T00:00:00+00:00',
@@ -121,6 +128,7 @@
'following_count': 328,
'group': False,
'header': 'https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg',
'header_description': None,
'header_static': 'https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg',
'hide_collections': True,
'id': '14715',
@@ -137,6 +145,9 @@
'role': None,
'roles': list([
]),
'show_featured': None,
'show_media': None,
'show_media_replies': None,
'source': None,
'statuses_count': 69523,
'suspended': None,
@@ -164,6 +175,7 @@
}),
}),
'version': '4.4.0-nightly.2025-02-07',
'wrapstodon': None,
}),
})
# ---

View File

@@ -58,6 +58,11 @@
'indexable': False,
'hide_collections': True,
'memorial': None,
'avatar_description': None,
'header_description': None,
'show_media': None,
'show_media_replies': None,
'show_featured': None,
'moved_to_account': None,
}),
})

View File

@@ -54,7 +54,10 @@ async def test_number_entities(
# Test error handling
mock_tailwind.identify.side_effect = TailwindError("Some error")
with pytest.raises(HomeAssistantError, match="Some error") as excinfo:
with pytest.raises(
HomeAssistantError,
match="An error occurred while communicating with the Tailwind device",
) as excinfo:
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,

View File

@@ -2,7 +2,12 @@
from unittest.mock import MagicMock
from gotailwind import TailwindAuthenticationError, TailwindConnectionError
from gotailwind import (
TailwindAuthenticationError,
TailwindConnectionError,
TailwindError,
)
import pytest
from homeassistant.components.tailwind.const import DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
@@ -31,13 +36,22 @@ async def test_load_unload_config_entry(
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
@pytest.mark.parametrize(
("side_effect", "expected_translation_key"),
[
(TailwindConnectionError, "communication_error"),
(TailwindError, "unknown_error"),
],
)
async def test_config_entry_not_ready(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_tailwind: MagicMock,
side_effect: type[Exception],
expected_translation_key: str,
) -> None:
"""Test the Tailwind configuration entry not ready."""
mock_tailwind.status.side_effect = TailwindConnectionError
mock_tailwind.status.side_effect = side_effect
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)

View File

@@ -0,0 +1,50 @@
"""Tests for the UniFi Discovery integration."""
from collections.abc import Generator
from contextlib import contextmanager
from unittest.mock import AsyncMock, MagicMock, patch
from unifi_discovery import AIOUnifiScanner, UnifiDevice, UnifiService
DEVICE_HOSTNAME = "unvr"
DEVICE_IP_ADDRESS = "127.0.0.1"
DEVICE_MAC_ADDRESS = "aa:bb:cc:dd:ee:ff"
DIRECT_CONNECT_DOMAIN = "x.ui.direct"
UNIFI_DISCOVERY_PROTECT = UnifiDevice(
source_ip=DEVICE_IP_ADDRESS,
hw_addr=DEVICE_MAC_ADDRESS,
platform=DEVICE_HOSTNAME,
hostname=DEVICE_HOSTNAME,
services={UnifiService.Protect: True},
direct_connect_domain=DIRECT_CONNECT_DOMAIN,
)
UNIFI_DISCOVERY_NO_MAC = UnifiDevice(
source_ip=DEVICE_IP_ADDRESS,
hw_addr=None,
platform=DEVICE_HOSTNAME,
hostname=DEVICE_HOSTNAME,
services={UnifiService.Protect: True},
direct_connect_domain=DIRECT_CONNECT_DOMAIN,
)
def _patch_discovery(
device: UnifiDevice | None = None, no_device: bool = False
) -> Generator[MagicMock]:
mock_aio_discovery = MagicMock(spec=AIOUnifiScanner)
scanner_return = [] if no_device else [device or UNIFI_DISCOVERY_PROTECT]
mock_aio_discovery.async_scan = AsyncMock(return_value=scanner_return)
mock_aio_discovery.found_devices = scanner_return
@contextmanager
def _patcher():
with patch(
"homeassistant.components.unifi_discovery.discovery.AIOUnifiScanner",
return_value=mock_aio_discovery,
):
yield mock_aio_discovery
return _patcher()

View File

@@ -0,0 +1,98 @@
"""Test the UniFi Discovery config flow."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from homeassistant import config_entries
from homeassistant.components.unifi_discovery.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from . import DEVICE_HOSTNAME, DEVICE_IP_ADDRESS, DEVICE_MAC_ADDRESS, _patch_discovery
DHCP_DISCOVERY = DhcpServiceInfo(
hostname=DEVICE_HOSTNAME,
ip=DEVICE_IP_ADDRESS,
macaddress=DEVICE_MAC_ADDRESS.lower().replace(":", ""),
)
SSDP_DISCOVERY = SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="mock_st",
upnp={
"manufacturer": "Ubiquiti Networks",
"modelDescription": "UniFi Dream Machine",
},
)
@pytest.mark.parametrize(
("source", "data"),
[
(config_entries.SOURCE_DHCP, DHCP_DISCOVERY),
(config_entries.SOURCE_SSDP, SSDP_DISCOVERY),
],
)
async def test_dhcp_ssdp_abort_with_discovery_started(
hass: HomeAssistant, source: str, data: DhcpServiceInfo | SsdpServiceInfo
) -> None:
"""Test DHCP and SSDP discovery triggers scanner and aborts."""
with _patch_discovery() as mock_scanner:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": source},
data=data,
)
await hass.async_block_till_done(wait_background_tasks=True)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "discovery_started"
assert mock_scanner.async_scan.call_count == 1
@pytest.mark.parametrize(
("source", "data"),
[
(config_entries.SOURCE_DHCP, DHCP_DISCOVERY),
(config_entries.SOURCE_SSDP, SSDP_DISCOVERY),
],
)
async def test_dhcp_ssdp_abort_already_in_progress(
hass: HomeAssistant, source: str, data: DhcpServiceInfo | SsdpServiceInfo
) -> None:
"""Test DHCP and SSDP abort when another flow is already in progress."""
with (
_patch_discovery(),
patch(
"homeassistant.components.unifi_discovery.config_flow.UnifiDiscoveryFlowHandler._async_in_progress",
return_value=[{"flow_id": "mock_flow"}],
),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": source},
data=data,
)
await hass.async_block_till_done(wait_background_tasks=True)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_in_progress"
async def test_user_flow_aborts(hass: HomeAssistant) -> None:
"""Test user-initiated flow aborts."""
with _patch_discovery() as mock_scanner:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
await hass.async_block_till_done(wait_background_tasks=True)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "discovery_started"
assert mock_scanner.async_scan.call_count == 1

View File

@@ -0,0 +1,56 @@
"""Test the UniFi Discovery init."""
from __future__ import annotations
from homeassistant import config_entries
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
async def test_setup_starts_discovery(hass: HomeAssistant) -> None:
"""Test that async_setup starts discovery and dispatches flows."""
with _patch_discovery():
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done(wait_background_tasks=True)
# The scanner should have dispatched a discovery flow for the Protect consumer
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_setup_no_devices(hass: HomeAssistant) -> None:
"""Test setup with no devices found."""
with _patch_discovery(no_device=True):
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) == 0
async def test_setup_device_without_mac(hass: HomeAssistant) -> None:
"""Test that devices without hw_addr are skipped."""
with _patch_discovery(device=UNIFI_DISCOVERY_NO_MAC):
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) == 0
async def test_dependency_loads_discovery(
hass: HomeAssistant,
) -> None:
"""Test that loading unifiprotect triggers unifi_discovery as dependency."""
with _patch_discovery():
assert await async_setup_component(hass, "unifiprotect", {})
await hass.async_block_till_done(wait_background_tasks=True)
# unifi_discovery should have been loaded as a dependency and started scanning
flows = hass.config_entries.flow.async_progress_by_handler("unifiprotect")
assert len(flows) == 1
assert flows[0]["context"]["source"] == config_entries.SOURCE_INTEGRATION_DISCOVERY

View File

@@ -43,7 +43,7 @@ def _patch_discovery(device=None, no_device=False):
@contextmanager
def _patcher():
with patch(
"homeassistant.components.unifiprotect.discovery.AIOUnifiScanner",
"homeassistant.components.unifi_discovery.discovery.AIOUnifiScanner",
return_value=mock_aio_discovery,
):
yield

View File

@@ -60,6 +60,13 @@ DEFAULT_PASSWORD = "test-password"
DEFAULT_API_KEY = "test-api-key"
@pytest.fixture(autouse=True)
def mock_discovery():
"""Prevent real network scanning in all unifiprotect tests."""
with _patch_discovery(no_device=True):
yield
@pytest.fixture(name="nvr")
def mock_nvr():
"""Mock UniFi Protect Camera device."""
@@ -158,7 +165,6 @@ def mock_entry(
"""Mock ProtectApiClient for testing."""
with (
_patch_discovery(no_device=True),
patch(
"homeassistant.components.unifiprotect.utils.ProtectApiClient"
) as mock_api,

View File

@@ -30,8 +30,6 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from . import (
DEVICE_HOSTNAME,
@@ -40,7 +38,6 @@ from . import (
DIRECT_CONNECT_DOMAIN,
UNIFI_DISCOVERY,
UNIFI_DISCOVERY_PARTIAL,
_patch_discovery,
)
from .conftest import (
DEFAULT_API_KEY,
@@ -54,24 +51,6 @@ from .conftest import (
from tests.common import MockConfigEntry
DHCP_DISCOVERY = DhcpServiceInfo(
hostname=DEVICE_HOSTNAME,
ip=DEVICE_IP_ADDRESS,
macaddress=DEVICE_MAC_ADDRESS.lower().replace(":", ""),
)
SSDP_DISCOVERY = (
SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="mock_st",
ssdp_location=f"http://{DEVICE_IP_ADDRESS}:41417/rootDesc.xml",
upnp={
"friendlyName": "UniFi Dream Machine",
"modelDescription": "UniFi Dream Machine Pro",
"serialNumber": DEVICE_MAC_ADDRESS,
},
),
)
# Base user input without credentials (for tests that override them)
BASE_USER_INPUT = {
CONF_HOST: DEFAULT_HOST,
@@ -563,8 +542,6 @@ async def test_form_options(
ufp_config_entry.add_to_hass(hass)
with (
_patch_discovery(),
patch("homeassistant.components.unifiprotect.async_start_discovery"),
patch(
"homeassistant.components.unifiprotect.utils.ProtectApiClient"
) as mock_api,
@@ -600,42 +577,17 @@ async def test_form_options(
await hass.config_entries.async_unload(ufp_config_entry.entry_id)
@pytest.mark.parametrize(
("source", "data"),
[
(config_entries.SOURCE_DHCP, DHCP_DISCOVERY),
(config_entries.SOURCE_SSDP, SSDP_DISCOVERY),
],
)
async def test_discovered_by_ssdp_or_dhcp(
hass: HomeAssistant, source: str, data: DhcpServiceInfo | SsdpServiceInfo
) -> None:
"""Test we handoff to unifi-discovery when discovered via ssdp or dhcp."""
with _patch_discovery():
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": source},
data=data,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "discovery_started"
async def test_discovered_by_unifi_discovery_direct_connect(
hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR
) -> None:
"""Test a discovery from unifi-discovery."""
with _patch_discovery():
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
@@ -714,13 +666,12 @@ async def test_discovered_by_unifi_discovery_direct_connect_updated(
)
mock_config.add_to_hass(hass)
with _patch_discovery():
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@@ -747,7 +698,6 @@ async def test_discovered_by_unifi_discovery_direct_connect_updated_but_not_usin
mock_config.add_to_hass(hass)
with (
_patch_discovery(),
patch(
"homeassistant.components.unifiprotect.config_flow.async_console_is_alive",
return_value=False,
@@ -785,7 +735,6 @@ async def test_discovered_by_unifi_discovery_does_not_update_ip_when_console_is_
mock_config.add_to_hass(hass)
with (
_patch_discovery(),
patch(
"homeassistant.components.unifiprotect.config_flow.async_console_is_alive",
return_value=True,
@@ -821,13 +770,12 @@ async def test_discovered_host_not_updated_if_existing_is_a_hostname(
)
mock_config.add_to_hass(hass)
with _patch_discovery():
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@@ -839,13 +787,12 @@ async def test_discovered_by_unifi_discovery(
) -> None:
"""Test a discovery from unifi-discovery."""
with _patch_discovery():
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
@@ -909,13 +856,12 @@ async def test_discovered_by_unifi_discovery_partial(
) -> None:
"""Test a discovery from unifi-discovery partial."""
with _patch_discovery():
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT_PARTIAL,
)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT_PARTIAL,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
@@ -993,13 +939,12 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa
)
mock_config.add_to_hass(hass)
with _patch_discovery():
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@@ -1024,13 +969,12 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa
)
mock_config.add_to_hass(hass)
with _patch_discovery():
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@@ -1060,7 +1004,6 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa
other_ip_dict["direct_connect_domain"] = "nomatchsameip.ui.direct"
with (
_patch_discovery(),
patch.object(
hass.loop,
"getaddrinfo",
@@ -1103,7 +1046,6 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa
other_ip_dict["direct_connect_domain"] = "nomatchsameip.ui.direct"
with (
_patch_discovery(),
patch.object(hass.loop, "getaddrinfo", side_effect=OSError),
):
result = await hass.config_entries.flow.async_init(
@@ -1193,7 +1135,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa
other_ip_dict["source_ip"] = "127.0.0.2"
other_ip_dict["direct_connect_domain"] = "y.ui.direct"
with _patch_discovery(), patch.object(hass.loop, "getaddrinfo", return_value=[]):
with patch.object(hass.loop, "getaddrinfo", return_value=[]):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
@@ -1214,13 +1156,12 @@ async def test_discovery_can_be_ignored(hass: HomeAssistant) -> None:
source=config_entries.SOURCE_IGNORE,
)
mock_config.add_to_hass(hass)
with _patch_discovery():
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@@ -1256,13 +1197,12 @@ async def test_discovery_with_both_ignored_and_normal_entry(
# Discovery should:
# 1. Skip all ignored entries with different MAC (line 182 - continue)
# 2. Continue to discovery flow since no matching entries
with _patch_discovery():
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
# Flow continues to discovery step since no match found
assert result["type"] is FlowResultType.FORM
@@ -1312,13 +1252,12 @@ async def test_discovery_confirm_fallback_to_ip(
mock_api_meta_info: Mock,
) -> None:
"""Test discovery confirm falls back to IP when direct connect fails."""
with _patch_discovery():
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
@@ -1363,13 +1302,12 @@ async def test_discovery_confirm_with_api_key_error(
mock_api_meta_info: Mock,
) -> None:
"""Test discovery confirm preserves API key in form data on error."""
with _patch_discovery():
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=UNIFI_DISCOVERY_DICT,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"

View File

@@ -328,7 +328,7 @@ async def test_setup_failed_auth(hass: HomeAssistant, ufp: MockUFPFixture) -> No
async def test_setup_starts_discovery(
hass: HomeAssistant, ufp_config_entry: ConfigEntry, ufp_client: ProtectApiClient
) -> None:
"""Test setting up will start discovery."""
"""Test setting up will start discovery via unifi_discovery dependency."""
with (
_patch_discovery(),
patch(
@@ -340,9 +340,9 @@ async def test_setup_starts_discovery(
ufp = MockUFPFixture(ufp_config_entry, ufp_client)
await hass.config_entries.async_setup(ufp.entry.entry_id)
await hass.async_block_till_done()
await hass.async_block_till_done(wait_background_tasks=True)
assert ufp.entry.state is ConfigEntryState.LOADED
await hass.async_block_till_done()
# Discovery is now handled by unifi_discovery dependency
assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1
@@ -586,7 +586,6 @@ async def test_migrate_entry_version_2(hass: HomeAssistant) -> None:
patch(
"homeassistant.components.unifiprotect.async_setup_entry", return_value=True
),
patch("homeassistant.components.unifiprotect.async_start_discovery"),
):
entry = MockConfigEntry(
domain=DOMAIN,

View File

@@ -1,6 +1,7 @@
"""Tests for the intent helpers."""
import asyncio
from copy import deepcopy
from unittest.mock import MagicMock, patch
import pytest
@@ -983,3 +984,76 @@ async def test_get_all_entity_aliases(
state = State("light.test", "on", {"friendly_name": friendly_name})
assert intent.async_get_entity_aliases(hass, entry, state=state) == expected
async def test_intent_response_dict() -> None:
"""Test that IntentResponse.as_dict() copies mutable objects."""
response = intent.IntentResponse(
language="en",
intent=None,
)
# Prepare the intent response initial state
response.async_set_speech(
speech="Hello", speech_type="plain", extra_data={"key": "value"}
)
response.async_set_reprompt(
speech="Hi", speech_type="plain", extra_data={"key2": "value2"}
)
response.async_set_card(title="Title", content="Content", card_type="simple")
response.async_set_results(
success_results=[
intent.IntentResponseTarget(
type=intent.IntentResponseTargetType.FLOOR,
name="first floor",
id="floor-1",
)
],
failed_results=[
intent.IntentResponseTarget(
type=intent.IntentResponseTargetType.ENTITY,
name="kitchen light",
id="light.kitchen",
)
],
)
response.async_set_states(
matched_states=[State("light.kitchen", "on")],
unmatched_states=[State("light.bedroom", "off")],
)
response.async_set_speech_slots({"name": {"value": "kitchen"}})
response_dict1 = response.as_dict()
response_dict2 = deepcopy(response_dict1)
# Mutate the original object
response.async_set_speech(
speech="Changed", speech_type="plain", extra_data={"key": "changed"}
)
response.async_set_reprompt(
speech="Changed", speech_type="plain", extra_data={"key2": "changed2"}
)
response.async_set_card(title="Changed", content="Changed", card_type="simple")
response.async_set_results(
success_results=[
intent.IntentResponseTarget(
type=intent.IntentResponseTargetType.FLOOR,
name="changed floor",
id="floor-changed",
)
],
failed_results=[
intent.IntentResponseTarget(
type=intent.IntentResponseTargetType.ENTITY,
name="changed light",
id="light.changed",
)
],
)
response.async_set_states(
matched_states=[State("light.changed", "on")],
unmatched_states=[State("light.changed_bedroom", "off")],
)
response.async_set_speech_slots({"name": {"value": "changed"}})
# The original dict should not be affected by the mutations
assert response_dict1 == response_dict2