Compare commits

..

2 Commits

Author SHA1 Message Date
Erik
6602375b70 Adjust strings 2026-04-15 08:01:25 +02:00
Erik
a00a34052e Add device_tracker conditions in_zone and not_in_zone 2026-04-14 18:28:32 +02:00
28 changed files with 433 additions and 619 deletions

View File

@@ -66,8 +66,24 @@ jobs:
exit 1
fi
- name: Download Translations
run: python3 -m script.translations download
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
- name: Archive translations
shell: bash
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
- name: Upload translations
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: translations
path: translations.tar.gz
if-no-files-found: error
build_base:
name: Build ${{ matrix.arch }} base core image without translations
name: Build ${{ matrix.arch }} base core image
if: github.repository_owner == 'home-assistant'
needs: init
runs-on: ${{ matrix.os }}
@@ -161,12 +177,22 @@ jobs:
sed -i "s|home-assistant-intents==.*||" requirements_all.txt requirements.txt
fi
- name: Download translations
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: translations
- name: Extract translations
run: |
tar xvf translations.tar.gz
rm translations.tar.gz
- name: Write meta info file
shell: bash
run: |
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
- name: Build base image without translations
- name: Build base image
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
with:
arch: ${{ matrix.arch }}
@@ -176,87 +202,6 @@ jobs:
container-registry-password: ${{ secrets.GITHUB_TOKEN }}
cosign-base-identity: "https://github.com/home-assistant/docker/.*"
cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ needs.init.outputs.base_image_version }}
image: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-no-translations
image-tags: ${{ needs.init.outputs.version }}
push: true
version: ${{ needs.init.outputs.version }}
download_translations:
name: Download translations
needs: init
if: github.repository_owner == 'home-assistant'
runs-on: ubuntu-latest
permissions:
contents: read # To check out the repository
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version-file: ".python-version"
- name: Download Translations
run: python3 -m script.translations download
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
- name: Archive translations
shell: bash
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
- name: Upload translations
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: translations
path: translations.tar.gz
if-no-files-found: error
build_translations:
name: Build ${{ matrix.arch }} core image with translations
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base", "download_translations"]
runs-on: ${{ matrix.os }}
permissions:
contents: read # To check out the repository
packages: write # To push to GHCR
id-token: write # For cosign signing
strategy:
fail-fast: false
matrix:
arch: ${{ fromJson(needs.init.outputs.architectures) }}
include:
- arch: amd64
os: ubuntu-24.04
- arch: aarch64
os: ubuntu-24.04-arm
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Download translations artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: translations
path: build/translations-output
- name: Build translations image
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
with:
arch: ${{ matrix.arch }}
build-args: |
BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-no-translations:${{ needs.init.outputs.version }}
cache-gha: false
container-registry-password: ${{ secrets.GITHUB_TOKEN }}
context: build/translations-output
cosign-base-identity: "https://github.com/home-assistant/core/.*"
cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-no-translations:${{ needs.init.outputs.version }}
file: Dockerfile.translations
image: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant
image-tags: ${{ needs.init.outputs.version }}
push: true
@@ -265,7 +210,7 @@ jobs:
build_machine:
name: Build ${{ matrix.machine }} machine core image
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_translations"]
needs: ["init", "build_base"]
runs-on: ${{ matrix.runs-on }}
permissions:
contents: read # To check out the repository
@@ -381,7 +326,7 @@ jobs:
name: Publish meta container for ${{ matrix.registry }}
environment: ${{ needs.init.outputs.channel }}
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_translations"]
needs: ["init", "build_base"]
runs-on: ubuntu-latest
permissions:
contents: read # To check out the repository
@@ -518,7 +463,7 @@ jobs:
build_python:
name: Build PyPi package
environment: ${{ needs.init.outputs.channel }}
needs: ["init", "download_translations"]
needs: ["init", "build_base"]
runs-on: ubuntu-latest
permissions:
contents: read # To check out the repository
@@ -536,13 +481,14 @@ jobs:
python-version-file: ".python-version"
- name: Download translations
shell: bash
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
TRANSLATION_PROCESS_ID: ${{ needs.init.outputs.translation_download_process }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: translations
- name: Extract translations
run: |
pip install -r script/translations/requirements.txt
python3 -u -m script.translations download --process-id "$TRANSLATION_PROCESS_ID"
tar xvf translations.tar.gz
rm translations.tar.gz
- name: Build package
shell: bash

View File

@@ -50,11 +50,9 @@ env:
# - 10.10.3 is the latest (as of 6 Feb 2023)
# 10.11 is the latest long-term-support
# - 10.11.2 is the version currently shipped with Synology (as of 11 Oct 2023)
# 11.4 is an LTS with support until May 2029
# - 11.4.9 is used in Alpine 3.23 (used in latest HA base images as of 11 Apr 2026)
# mysql 8.0.32 does not always behave the same as MariaDB
# and some queries that work on MariaDB do not work on MySQL
MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3','mariadb:10.11.2','mariadb:11.4.9','mysql:8.0.32']"
MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3','mariadb:10.11.2','mysql:8.0.32']"
# 12 is the oldest supported version
# - 12.14 is the latest (as of 9 Feb 2023)
# 15 is the latest version
@@ -323,7 +321,6 @@ jobs:
file:
- Dockerfile
- Dockerfile.dev
- Dockerfile.translations
- script/hassfest/docker/Dockerfile
steps:
- name: Check out code from GitHub
@@ -1065,9 +1062,7 @@ jobs:
- 3306:3306
env:
MYSQL_ROOT_PASSWORD: password
options: >-
--health-cmd="if command -v mariadb-admin >/dev/null; then mariadb-admin ping -uroot -ppassword; else mysqladmin ping -uroot -ppassword; fi"
--health-interval=5s --health-timeout=2s --health-retries=3
options: --health-cmd="mysqladmin ping -uroot -ppassword" --health-interval=5s --health-timeout=2s --health-retries=3
needs:
- info
- base

4
CODEOWNERS generated
View File

@@ -1877,8 +1877,8 @@ CLAUDE.md @home-assistant/core
/tests/components/version/ @ludeeus
/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven
/homeassistant/components/vicare/ @CFenner @lackas
/tests/components/vicare/ @CFenner @lackas
/homeassistant/components/vicare/ @CFenner
/tests/components/vicare/ @CFenner
/homeassistant/components/victron_ble/ @rajlaud
/tests/components/victron_ble/ @rajlaud
/homeassistant/components/victron_gx/ @tomer-w

View File

@@ -1,7 +0,0 @@
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM
FROM ${BUILD_FROM}
COPY homeassistant/ /usr/src/homeassistant/homeassistant/

View File

@@ -1,7 +1,7 @@
{
"issues": {
"integration_removed": {
"description": "The BMW Connected Drive integration has been removed from Home Assistant.\n\nIn September 2025, BMW blocked third-party access to their servers by adding additional security measures. For EU-registered cars, a community-developed [custom component]({custom_component_url}) using BMW's CarData API is available as an alternative.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing BMW Connected Drive integration entries]({entries}).",
"description": "The BMW Connected Drive integration has been removed from Home Assistant.\n\nIn September 2025, BMW blocked third-party access to their servers by adding additional security measures. For EU-registered cars, a [community integration]({custom_component_url}) using BMW's CarData API is available as an alternative.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing BMW Connected Drive integration entries]({entries}).",
"title": "The BMW Connected Drive integration has been removed"
}
}

View File

@@ -1,14 +1,88 @@
"""Provides conditions for device trackers."""
from homeassistant.const import STATE_HOME, STATE_NOT_HOME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.components.zone import ENTITY_ID_HOME as ENTITY_ID_HOME_ZONE
from homeassistant.const import CONF_OPTIONS, CONF_ZONE, STATE_HOME, STATE_NOT_HOME
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL,
Condition,
ConditionConfig,
EntityConditionBase,
make_entity_state_condition,
)
from .const import ATTR_IN_ZONES, DOMAIN
ZONE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_ZONE): vol.All(
cv.ensure_list,
vol.Length(min=1),
[cv.entity_domain("zone")],
),
},
}
)
_IN_ZONES_SPEC = {DOMAIN: DomainSpec(value_source=ATTR_IN_ZONES)}
class ZoneConditionBase(EntityConditionBase):
"""Base for zone-based device tracker conditions."""
_domain_specs = _IN_ZONES_SPEC
_schema = ZONE_CONDITION_SCHEMA
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize the condition."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
self._zones: set[str] = set(config.options[CONF_ZONE])
def _in_target_zones(self, state: State) -> bool:
"""Check if the device is in any of the selected zones.
For GPS-based trackers, uses the in_zones attribute.
For scanner-based trackers (no in_zones attribute), infers from
state: 'home' means the device is in zone.home.
"""
if (in_zones := self._get_tracked_value(state)) is not None:
return bool(set(in_zones).intersection(self._zones))
# Scanner tracker: state 'home' means in zone.home
if state.state == STATE_HOME:
return ENTITY_ID_HOME_ZONE in self._zones
return False
class InZoneCondition(ZoneConditionBase):
"""Condition that tests if a device tracker is in one of the selected zones."""
def is_valid_state(self, entity_state: State) -> bool:
"""Check that the device is in at least one of the selected zones."""
return self._in_target_zones(entity_state)
class NotInZoneCondition(ZoneConditionBase):
"""Condition that tests if a device tracker is not in any of the selected zones."""
def is_valid_state(self, entity_state: State) -> bool:
"""Check that the device is not in any of the selected zones."""
return not self._in_target_zones(entity_state)
from .const import DOMAIN
CONDITIONS: dict[str, type[Condition]] = {
"in_zone": InZoneCondition,
"is_home": make_entity_state_condition(DOMAIN, STATE_HOME),
"is_not_home": make_entity_state_condition(DOMAIN, STATE_NOT_HOME),
"not_in_zone": NotInZoneCondition,
}

View File

@@ -1,9 +1,9 @@
.condition_common: &condition_common
target:
target: &condition_target
entity:
domain: device_tracker
fields:
behavior:
behavior: &condition_behavior
required: true
default: any
selector:
@@ -13,5 +13,18 @@
- all
- any
.condition_zone: &condition_zone
<<: *condition_common
fields:
behavior: *condition_behavior
zone:
required: true
selector:
entity:
domain: zone
multiple: true
in_zone: *condition_zone
is_home: *condition_common
is_not_home: *condition_common
not_in_zone: *condition_zone

View File

@@ -1,10 +1,16 @@
{
"conditions": {
"in_zone": {
"condition": "mdi:map-marker-check"
},
"is_home": {
"condition": "mdi:account"
},
"is_not_home": {
"condition": "mdi:account-arrow-right"
},
"not_in_zone": {
"condition": "mdi:map-marker-remove"
}
},
"entity_component": {

View File

@@ -1,9 +1,24 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_zone_description": "The zones to check for.",
"condition_zone_name": "Zone",
"trigger_behavior_name": "Trigger when"
},
"conditions": {
"in_zone": {
"description": "Tests if one or more device trackers are in a zone.",
"fields": {
"behavior": {
"name": "[%key:component::device_tracker::common::condition_behavior_name%]"
},
"zone": {
"description": "[%key:component::device_tracker::common::condition_zone_description%]",
"name": "[%key:component::device_tracker::common::condition_zone_name%]"
}
},
"name": "Device tracker is in zone"
},
"is_home": {
"description": "Tests if one or more device trackers are home.",
"fields": {
@@ -21,6 +36,19 @@
}
},
"name": "Device tracker is not home"
},
"not_in_zone": {
"description": "Tests if one or more device trackers are not in a zone.",
"fields": {
"behavior": {
"name": "[%key:component::device_tracker::common::condition_behavior_name%]"
},
"zone": {
"description": "[%key:component::device_tracker::common::condition_zone_description%]",
"name": "[%key:component::device_tracker::common::condition_zone_name%]"
}
},
"name": "Device tracker is not in zone"
}
},
"device_automation": {

View File

@@ -148,7 +148,7 @@
},
"step": {
"init": {
"description": "The integration `{domain}` could not be found. This happens when a (custom) integration was removed from Home Assistant, but there are still configurations for this `integration`. Please use the buttons below to either remove the previous configurations for `{domain}` or ignore this.",
"description": "The integration `{domain}` could not be found. This happens when a (community) integration was removed from Home Assistant, but there are still configurations for this `integration`. Please use the buttons below to either remove the previous configurations for `{domain}` or ignore this.",
"menu_options": {
"confirm": "Remove previous configurations",
"ignore": "Ignore"
@@ -236,7 +236,7 @@
"description": "Restarts Home Assistant.",
"fields": {
"safe_mode": {
"description": "Disable custom integrations and custom cards.",
"description": "Disable community integrations and community cards.",
"name": "Safe mode"
}
},

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
"requirements": ["homematicip==2.8.0"]
"requirements": ["homematicip==2.7.0"]
}

View File

@@ -128,16 +128,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> bo
"""Set up Shelly from a config entry."""
entry.runtime_data = ShellyEntryData([])
# The custom component for Shelly devices uses shelly domain as well as core
# integration. If the user removes the custom component but doesn't remove the
# config entry, core integration will try to configure that config entry with an
# error. The config entry data for this custom component doesn't contain host
# value, so if host isn't present, config entry will not be configured.
# The community integration for Shelly devices uses Shelly domain as well as Core
# integration. If the user removes the community integration but doesn't remove
# the config entry, Core integration will try to configure that config entry with
# an error. The config entry data for this community integration doesn't contain
# host value, so if host isn't present, config entry will not be configured.
if not entry.data.get(CONF_HOST):
LOGGER.warning(
(
"The config entry %s probably comes from a custom integration, please"
" remove it if you want to use core Shelly integration"
"The config entry %s probably comes from a community integration, "
"please remove it if you want to use the Core Shelly integration"
),
entry.title,
)

View File

@@ -180,16 +180,18 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema:
}
if domain == Platform.BINARY_SENSOR:
schema |= _SCHEMA_STATE | {
vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[cls.value for cls in BinarySensorDeviceClass],
mode=selector.SelectSelectorMode.DROPDOWN,
translation_key="binary_sensor_device_class",
sort=True,
schema |= _SCHEMA_STATE
if flow_type == "config":
schema |= {
vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[cls.value for cls in BinarySensorDeviceClass],
mode=selector.SelectSelectorMode.DROPDOWN,
translation_key="binary_sensor_device_class",
sort=True,
),
),
),
}
}
if domain == Platform.BUTTON:
schema |= {

View File

@@ -608,7 +608,6 @@
},
"binary_sensor": {
"data": {
"device_class": "[%key:component::template::common::device_class%]",
"device_id": "[%key:common::config_flow::data::device%]",
"state": "[%key:component::template::common::state%]"
},

View File

@@ -9,10 +9,9 @@ from typing import Any
from unifi_access_api import ApiAuthError, ApiConnectionError, UnifiAccessApiClient
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IGNORE, ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_VERIFY_SSL
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import DiscoveryInfoType
from homeassistant.util.ssl import create_no_verify_ssl_context
from .const import DOMAIN
@@ -26,11 +25,6 @@ class UnifiAccessConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
MINOR_VERSION = 1
def __init__(self) -> None:
"""Init the config flow."""
super().__init__()
self._discovered_device: dict[str, Any] = {}
async def _validate_input(self, user_input: dict[str, Any]) -> dict[str, str]:
"""Validate user input and return errors dict."""
errors: dict[str, str] = {}
@@ -123,66 +117,6 @@ class UnifiAccessConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_integration_discovery(
self, discovery_info: DiscoveryInfoType
) -> ConfigFlowResult:
"""Handle discovery via unifi_discovery."""
self._discovered_device = discovery_info
source_ip = discovery_info["source_ip"]
mac = discovery_info["hw_addr"].replace(":", "").upper()
await self.async_set_unique_id(mac)
for entry in self._async_current_entries():
if entry.source == SOURCE_IGNORE:
continue
if entry.data.get(CONF_HOST) == source_ip:
if not entry.unique_id:
self.hass.config_entries.async_update_entry(entry, unique_id=mac)
return self.async_abort(reason="already_configured")
self._abort_if_unique_id_configured(updates={CONF_HOST: source_ip})
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm discovery and collect API token."""
errors: dict[str, str] = {}
discovery_info = self._discovered_device
source_ip = discovery_info["source_ip"]
if user_input is not None:
merged_input = {
CONF_HOST: source_ip,
CONF_API_TOKEN: user_input[CONF_API_TOKEN],
CONF_VERIFY_SSL: user_input.get(CONF_VERIFY_SSL, False),
}
errors = await self._validate_input(merged_input)
if not errors:
return self.async_create_entry(
title="UniFi Access",
data=merged_input,
)
name = discovery_info.get("hostname") or discovery_info.get("platform")
if not name:
short_mac = discovery_info["hw_addr"].replace(":", "").upper()[-6:]
name = f"Access {short_mac}"
placeholders = {
"name": name,
"ip_address": source_ip,
}
self.context["title_placeholders"] = placeholders
return self.async_show_form(
step_id="discovery_confirm",
data_schema=vol.Schema(
{
vol.Required(CONF_API_TOKEN): str,
vol.Required(CONF_VERIFY_SSL, default=False): bool,
}
),
description_placeholders=placeholders,
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:

View File

@@ -3,7 +3,6 @@
"name": "UniFi Access",
"codeowners": ["@imhotep", "@RaHehl"],
"config_flow": true,
"dependencies": ["unifi_discovery"],
"documentation": "https://www.home-assistant.io/integrations/unifi_access",
"integration_type": "hub",
"iot_class": "local_push",

View File

@@ -42,10 +42,8 @@ rules:
# Gold
devices: done
diagnostics: done
discovery-update-info: done
discovery:
status: exempt
comment: Discovery is handled via unifi_discovery dependency using SOURCE_INTEGRATION_DISCOVERY.
discovery-update-info: todo
discovery: todo
docs-data-update: done
docs-examples: done
docs-known-limitations: done

View File

@@ -12,17 +12,6 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"discovery_confirm": {
"data": {
"api_token": "[%key:common::config_flow::data::api_token%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"api_token": "[%key:component::unifi_access::config::step::user::data_description::api_token%]",
"verify_ssl": "[%key:component::unifi_access::config::step::user::data_description::verify_ssl%]"
},
"description": "A UniFi Access controller was discovered at {ip_address} ({name})."
},
"reauth_confirm": {
"data": {
"api_token": "[%key:common::config_flow::data::api_token%]"

View File

@@ -9,5 +9,4 @@ DOMAIN = "unifi_discovery"
# when initial discovery runs — the same pattern DHCP/SSDP use with manifest matchers.
CONSUMER_MAPPING: dict[UnifiService, str] = {
UnifiService.Protect: "unifiprotect",
UnifiService.Access: "unifi_access",
}

View File

@@ -1,7 +1,7 @@
{
"domain": "vicare",
"name": "Viessmann ViCare",
"codeowners": ["@CFenner", "@lackas"],
"codeowners": ["@CFenner"],
"config_flow": true,
"dhcp": [
{

2
requirements_all.txt generated
View File

@@ -1247,7 +1247,7 @@ homekit-audio-proxy==1.2.1
homelink-integration-api==0.0.1
# homeassistant.components.homematicip_cloud
homematicip==2.8.0
homematicip==2.7.0
# homeassistant.components.homevolt
homevolt==0.5.0

View File

@@ -1111,7 +1111,7 @@ homekit-audio-proxy==1.2.1
homelink-integration-api==0.0.1
# homeassistant.components.homematicip_cloud
homematicip==2.8.0
homematicip==2.7.0
# homeassistant.components.homevolt
homevolt==0.5.0

View File

@@ -76,15 +76,6 @@ RUN \
WORKDIR /config
"""
DOCKERFILE_TRANSLATIONS_TEMPLATE = r"""# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM
FROM ${{BUILD_FROM}}
COPY homeassistant/ /usr/src/homeassistant/homeassistant/
"""
@dataclass(frozen=True)
class _MachineConfig:
@@ -243,10 +234,6 @@ def _generate_files(config: Config) -> list[File]:
),
config.root / "Dockerfile",
),
File(
DOCKERFILE_TRANSLATIONS_TEMPLATE.format(),
config.root / "Dockerfile.translations",
),
File(
_HASSFEST_TEMPLATE.format(
timeout=timeout,

View File

@@ -1,11 +1,22 @@
"""Test device tracker conditions."""
from contextlib import AbstractContextManager, nullcontext as does_not_raise
from typing import Any
import pytest
import voluptuous as vol
from homeassistant.const import STATE_HOME, STATE_NOT_HOME
from homeassistant.components.device_tracker.const import ATTR_IN_ZONES
from homeassistant.const import (
CONF_ENTITY_ID,
CONF_OPTIONS,
CONF_TARGET,
CONF_ZONE,
STATE_HOME,
STATE_NOT_HOME,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import async_validate_condition_config
from tests.components.common import (
ConditionStateDescription,
@@ -18,6 +29,13 @@ from tests.components.common import (
target_entities,
)
STATE_WORK_ZONE = "work"
def _gps_state(state: str, in_zones: list[str]) -> tuple[str, dict[str, list[str]]]:
"""Create a GPS-based device tracker state with in_zones attribute."""
return (state, {ATTR_IN_ZONES: in_zones})
@pytest.fixture
async def target_device_trackers(hass: HomeAssistant) -> dict[str, list[str]]:
@@ -28,8 +46,10 @@ async def target_device_trackers(hass: HomeAssistant) -> dict[str, list[str]]:
@pytest.mark.parametrize(
"condition",
[
"device_tracker.in_zone",
"device_tracker.is_home",
"device_tracker.is_not_home",
"device_tracker.not_in_zone",
],
)
async def test_device_tracker_conditions_gated_by_labs_flag(
@@ -123,3 +143,214 @@ async def test_device_tracker_state_condition_behavior_all(
condition_options=condition_options,
states=states,
)
# Zone conditions for GPS-based trackers (have in_zones attribute)
GPS_ZONE_CONDITIONS_ANY = [
*parametrize_condition_states_any(
condition="device_tracker.in_zone",
condition_options={CONF_ZONE: ["zone.home", "zone.work"]},
target_states=[
_gps_state(STATE_HOME, ["zone.home"]),
_gps_state(STATE_WORK_ZONE, ["zone.work"]),
_gps_state(STATE_HOME, ["zone.home", "zone.work"]),
],
other_states=[
_gps_state(STATE_NOT_HOME, []),
_gps_state("school", ["zone.school"]),
],
),
*parametrize_condition_states_any(
condition="device_tracker.not_in_zone",
condition_options={CONF_ZONE: ["zone.home", "zone.work"]},
target_states=[
_gps_state(STATE_NOT_HOME, []),
_gps_state("school", ["zone.school"]),
],
other_states=[
_gps_state(STATE_HOME, ["zone.home"]),
_gps_state(STATE_WORK_ZONE, ["zone.work"]),
_gps_state(STATE_HOME, ["zone.home", "zone.work"]),
],
),
]
GPS_ZONE_CONDITIONS_ALL = [
*parametrize_condition_states_all(
condition="device_tracker.in_zone",
condition_options={CONF_ZONE: ["zone.home", "zone.work"]},
target_states=[
_gps_state(STATE_HOME, ["zone.home"]),
_gps_state(STATE_WORK_ZONE, ["zone.work"]),
_gps_state(STATE_HOME, ["zone.home", "zone.work"]),
],
other_states=[
_gps_state(STATE_NOT_HOME, []),
_gps_state("school", ["zone.school"]),
],
),
*parametrize_condition_states_all(
condition="device_tracker.not_in_zone",
condition_options={CONF_ZONE: ["zone.home", "zone.work"]},
target_states=[
_gps_state(STATE_NOT_HOME, []),
_gps_state("school", ["zone.school"]),
],
other_states=[
_gps_state(STATE_HOME, ["zone.home"]),
_gps_state(STATE_WORK_ZONE, ["zone.work"]),
_gps_state(STATE_HOME, ["zone.home", "zone.work"]),
],
),
]
# Zone conditions for scanner-based trackers (no in_zones attribute)
SCANNER_ZONE_CONDITIONS_ANY = [
*parametrize_condition_states_any(
condition="device_tracker.in_zone",
condition_options={CONF_ZONE: ["zone.home"]},
target_states=[STATE_HOME],
other_states=[STATE_NOT_HOME],
),
*parametrize_condition_states_any(
condition="device_tracker.not_in_zone",
condition_options={CONF_ZONE: ["zone.home"]},
target_states=[STATE_NOT_HOME],
other_states=[STATE_HOME],
),
]
SCANNER_ZONE_CONDITIONS_ALL = [
*parametrize_condition_states_all(
condition="device_tracker.in_zone",
condition_options={CONF_ZONE: ["zone.home"]},
target_states=[STATE_HOME],
other_states=[STATE_NOT_HOME],
),
*parametrize_condition_states_all(
condition="device_tracker.not_in_zone",
condition_options={CONF_ZONE: ["zone.home"]},
target_states=[STATE_NOT_HOME],
other_states=[STATE_HOME],
),
]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger", "trigger_options", "expected_result"),
[
# Valid configurations
(
"device_tracker.in_zone",
{CONF_ZONE: ["zone.home", "zone.work"]},
does_not_raise(),
),
(
"device_tracker.in_zone",
{CONF_ZONE: "zone.home"},
does_not_raise(),
),
(
"device_tracker.not_in_zone",
{CONF_ZONE: ["zone.home"]},
does_not_raise(),
),
# Invalid configurations
(
"device_tracker.in_zone",
{CONF_ZONE: []},
pytest.raises(vol.Invalid),
),
(
"device_tracker.in_zone",
{},
pytest.raises(vol.Invalid),
),
(
"device_tracker.in_zone",
{CONF_ZONE: ["light.living_room"]},
pytest.raises(vol.Invalid),
),
],
)
async def test_device_tracker_zone_condition_validation(
hass: HomeAssistant,
trigger: str,
trigger_options: dict[str, Any],
expected_result: AbstractContextManager,
) -> None:
"""Test device_tracker zone condition config validation."""
with expected_result:
await async_validate_condition_config(
hass,
{
"condition": trigger,
CONF_TARGET: {CONF_ENTITY_ID: "device_tracker.test"},
CONF_OPTIONS: trigger_options,
},
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("device_tracker"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[*GPS_ZONE_CONDITIONS_ANY, *SCANNER_ZONE_CONDITIONS_ANY],
)
async def test_device_tracker_zone_condition_behavior_any(
hass: HomeAssistant,
target_device_trackers: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the device tracker zone condition with the 'any' behavior."""
await assert_condition_behavior_any(
hass,
target_entities=target_device_trackers,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("device_tracker"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[*GPS_ZONE_CONDITIONS_ALL, *SCANNER_ZONE_CONDITIONS_ALL],
)
async def test_device_tracker_zone_condition_behavior_all(
hass: HomeAssistant,
target_device_trackers: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the device tracker zone condition with the 'all' behavior."""
await assert_condition_behavior_all(
hass,
target_entities=target_device_trackers,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)

View File

@@ -149,7 +149,7 @@ async def test_setup_entry_not_shelly(
) -> None:
"""Test not Shelly entry."""
await init_integration(hass, 1, data={})
assert "probably comes from a custom integration" in caplog.text
assert "probably comes from a community integration" in caplog.text
@pytest.mark.parametrize("gen", [1, 2, 3])

View File

@@ -564,7 +564,6 @@ async def test_config_flow_device(
"extra_options",
"options_options",
"key_template",
"suggested_device_class",
),
[
(
@@ -577,10 +576,9 @@ async def test_config_flow_device(
},
["on", "off"],
{"one": "on", "two": "off"},
{"device_class": "motion"},
{"device_class": "window"},
{},
{},
"state",
"motion",
),
(
"sensor",
@@ -595,7 +593,6 @@ async def test_config_flow_device(
{},
{},
"state",
None,
),
(
"button",
@@ -623,7 +620,6 @@ async def test_config_flow_device(
],
},
"state",
None,
),
(
"cover",
@@ -634,7 +630,6 @@ async def test_config_flow_device(
{"set_cover_position": []},
{"set_cover_position": []},
"state",
None,
),
(
"event",
@@ -645,7 +640,6 @@ async def test_config_flow_device(
{"event_types": "{{ ['single', 'double'] }}"},
{"event_types": "{{ ['single', 'double'] }}"},
"event_type",
None,
),
(
"fan",
@@ -656,7 +650,6 @@ async def test_config_flow_device(
{"turn_on": [], "turn_off": []},
{"turn_on": [], "turn_off": []},
"state",
None,
),
(
"image",
@@ -674,7 +667,6 @@ async def test_config_flow_device(
"verify_ssl": True,
},
"url",
None,
),
(
"light",
@@ -685,7 +677,6 @@ async def test_config_flow_device(
{"turn_on": [], "turn_off": []},
{"turn_on": [], "turn_off": []},
"state",
None,
),
(
"lock",
@@ -696,7 +687,6 @@ async def test_config_flow_device(
{"lock": [], "unlock": []},
{"lock": [], "unlock": []},
"state",
None,
),
(
"number",
@@ -727,7 +717,6 @@ async def test_config_flow_device(
},
},
"state",
None,
),
(
"alarm_control_panel",
@@ -738,7 +727,6 @@ async def test_config_flow_device(
{"code_arm_required": True, "code_format": "number"},
{"code_arm_required": True, "code_format": "number"},
"value_template",
None,
),
(
"select",
@@ -749,7 +737,6 @@ async def test_config_flow_device(
{"options": "{{ ['off', 'on', 'auto'] }}", "select_option": []},
{"options": "{{ ['off', 'on', 'auto'] }}", "select_option": []},
"state",
None,
),
(
"switch",
@@ -760,7 +747,6 @@ async def test_config_flow_device(
{},
{},
"value_template",
None,
),
(
"update",
@@ -771,7 +757,6 @@ async def test_config_flow_device(
{"latest_version": "{{ '2.0' }}"},
{"latest_version": "{{ '2.0' }}"},
"installed_version",
None,
),
(
"vacuum",
@@ -782,7 +767,6 @@ async def test_config_flow_device(
{"start": []},
{"start": []},
"state",
None,
),
(
"weather",
@@ -793,7 +777,6 @@ async def test_config_flow_device(
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
"condition",
None,
),
],
)
@@ -808,7 +791,6 @@ async def test_options(
extra_options: dict[str, Any],
options_options: dict[str, Any],
key_template: str,
suggested_device_class: str | None,
) -> None:
"""Test reconfiguring."""
input_entities = ["one", "two"]
@@ -846,10 +828,6 @@ async def test_options(
result["data_schema"].schema, key_template
) == old_state_template.get(key_template)
assert "name" not in result["data_schema"].schema
assert (
get_schema_suggested_value(result["data_schema"].schema, "device_class")
== suggested_device_class
)
result = await hass.config_entries.options.async_configure(
result["flow_id"],
@@ -864,7 +842,6 @@ async def test_options(
"template_type": template_type,
**new_state_template,
**extra_options,
**options_options,
}
assert config_entry.data == {}
assert config_entry.options == {
@@ -872,7 +849,6 @@ async def test_options(
"template_type": template_type,
**new_state_template,
**extra_options,
**options_options,
}
assert config_entry.title == "My template"
@@ -901,63 +877,6 @@ async def test_options(
)
@pytest.mark.freeze_time("2024-07-09 00:00:00+00:00")
async def test_options_binary_sensor_remove_device_class(hass: HomeAssistant) -> None:
"""Test removing the binary sensor device class in options."""
hass.states.async_set("binary_sensor.one", "on", {})
hass.states.async_set("binary_sensor.two", "off", {})
old_state_template = {
"state": "{{ states('binary_sensor.one') == 'on' or states('binary_sensor.two') == 'on' }}"
}
new_state_template = {
"state": "{{ states('binary_sensor.one') == 'on' and states('binary_sensor.two') == 'on' }}"
}
config_entry = MockConfigEntry(
data={},
domain=DOMAIN,
options={
"name": "My template",
"template_type": "binary_sensor",
**old_state_template,
"device_class": "motion",
},
title="My template",
)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "binary_sensor"
assert (
get_schema_suggested_value(result["data_schema"].schema, "device_class")
== "motion"
)
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
**new_state_template,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
"name": "My template",
"template_type": "binary_sensor",
**new_state_template,
}
assert config_entry.options == {
"name": "My template",
"template_type": "binary_sensor",
**new_state_template,
}
assert "device_class" not in config_entry.options
@pytest.mark.parametrize(
(
"template_type",

View File

@@ -13,7 +13,6 @@ from unifi_access_api import (
DoorPositionStatus,
EmergencyStatus,
)
from unifi_discovery import AIOUnifiScanner
from homeassistant.components.unifi_access.const import DOMAIN
from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_VERIFY_SSL
@@ -30,19 +29,6 @@ MOCK_API_TOKEN = "test-api-token-12345"
MOCK_ENTRY_ID = "mock-unifi-access-entry-id"
@pytest.fixture(autouse=True)
def mock_discovery() -> Generator[None]:
"""Prevent real network scanning in all unifi_access tests."""
mock_aio_discovery = MagicMock(spec=AIOUnifiScanner)
mock_aio_discovery.async_scan = AsyncMock(return_value=[])
mock_aio_discovery.found_devices = []
with patch(
"homeassistant.components.unifi_discovery.discovery.AIOUnifiScanner",
return_value=mock_aio_discovery,
):
yield
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return a mock config entry."""

View File

@@ -9,11 +9,7 @@ import pytest
from unifi_access_api import ApiAuthError, ApiConnectionError
from homeassistant.components.unifi_access.const import DOMAIN
from homeassistant.config_entries import (
SOURCE_IGNORE,
SOURCE_INTEGRATION_DISCOVERY,
SOURCE_USER,
)
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@@ -576,283 +572,3 @@ async def test_reconfigure_flow_protect_api_key(
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
DISCOVERY_INFO = {
"source_ip": "10.0.0.5",
"hw_addr": "aa:bb:cc:dd:ee:ff",
"hostname": "unvr",
"platform": "unvr",
"services": {"Protect": True, "Access": True},
"direct_connect_domain": "x.ui.direct",
}
async def test_discovery_new_device(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_client: MagicMock,
) -> None:
"""Test integration discovery shows confirm form for new device."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_INTEGRATION_DISCOVERY},
data=DISCOVERY_INFO,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
async def test_discovery_confirm_success(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_client: MagicMock,
) -> None:
"""Test successful discovery confirm creates entry."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_INTEGRATION_DISCOVERY},
data=DISCOVERY_INFO,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_API_TOKEN: MOCK_API_TOKEN,
CONF_VERIFY_SSL: False,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "UniFi Access"
assert result["data"] == {
CONF_HOST: "10.0.0.5",
CONF_API_TOKEN: MOCK_API_TOKEN,
CONF_VERIFY_SSL: False,
}
async def test_discovery_confirm_errors(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_client: MagicMock,
) -> None:
"""Test discovery confirm handles errors and recovery."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_INTEGRATION_DISCOVERY},
data=DISCOVERY_INFO,
)
mock_client.authenticate.side_effect = ApiConnectionError("Connection failed")
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_API_TOKEN: "bad-token",
CONF_VERIFY_SSL: False,
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
mock_client.authenticate.side_effect = ApiAuthError()
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_API_TOKEN: "bad-token",
CONF_VERIFY_SSL: False,
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "invalid_auth"}
mock_client.authenticate.side_effect = RuntimeError("boom")
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_API_TOKEN: "bad-token",
CONF_VERIFY_SSL: False,
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "unknown"}
mock_client.authenticate.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_API_TOKEN: MOCK_API_TOKEN,
CONF_VERIFY_SSL: False,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_discovery_already_configured_by_host(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_client: MagicMock,
) -> None:
"""Test discovery aborts when host is already configured."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "10.0.0.5",
CONF_API_TOKEN: MOCK_API_TOKEN,
CONF_VERIFY_SSL: False,
},
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_INTEGRATION_DISCOVERY},
data=DISCOVERY_INFO,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_discovery_updates_host_for_known_mac(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_client: MagicMock,
) -> None:
"""Test discovery updates host when MAC matches but IP changed."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="AABBCCDDEEFF",
data={
CONF_HOST: "192.168.1.100",
CONF_API_TOKEN: MOCK_API_TOKEN,
CONF_VERIFY_SSL: False,
},
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_INTEGRATION_DISCOVERY},
data=DISCOVERY_INFO,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert entry.data[CONF_HOST] == "10.0.0.5"
async def test_discovery_sets_unique_id_on_manual_entry(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_client: MagicMock,
) -> None:
"""Test discovery adds unique_id (MAC) to manually configured entry."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "10.0.0.5",
CONF_API_TOKEN: MOCK_API_TOKEN,
CONF_VERIFY_SSL: False,
},
)
entry.add_to_hass(hass)
assert entry.unique_id is None
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_INTEGRATION_DISCOVERY},
data=DISCOVERY_INFO,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert entry.unique_id == "AABBCCDDEEFF"
async def test_discovery_already_configured_by_host_with_unique_id(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_client: MagicMock,
) -> None:
"""Test discovery is a no-op when entry already has unique_id and matching IP."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="AABBCCDDEEFF",
data={
CONF_HOST: "10.0.0.5",
CONF_API_TOKEN: MOCK_API_TOKEN,
CONF_VERIFY_SSL: False,
},
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_INTEGRATION_DISCOVERY},
data=DISCOVERY_INFO,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert entry.unique_id == "AABBCCDDEEFF"
assert entry.data[CONF_HOST] == "10.0.0.5"
async def test_discovery_ignored_entry(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_client: MagicMock,
) -> None:
"""Test discovery aborts when ignored entry with same unique_id exists."""
entry = MockConfigEntry(
domain=DOMAIN,
source=SOURCE_IGNORE,
unique_id="AABBCCDDEEFF",
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_INTEGRATION_DISCOVERY},
data=DISCOVERY_INFO,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_discovery_fallback_name_from_mac(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_client: MagicMock,
) -> None:
"""Test discovery confirm uses MAC-based name when hostname and platform are absent."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_INTEGRATION_DISCOVERY},
data={
"source_ip": "10.0.0.5",
"hw_addr": "aa:bb:cc:dd:ee:ff",
"hostname": None,
"platform": None,
"services": {"Access": True},
"direct_connect_domain": "x.ui.direct",
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
assert result["description_placeholders"]["name"] == "Access DDEEFF"