mirror of
https://github.com/home-assistant/core.git
synced 2026-04-19 07:59:14 +02:00
Compare commits
2 Commits
edenhaus-t
...
add_device
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6602375b70 | ||
|
|
a00a34052e |
130
.github/workflows/builder.yml
vendored
130
.github/workflows/builder.yml
vendored
@@ -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
|
||||
|
||||
9
.github/workflows/ci.yaml
vendored
9
.github/workflows/ci.yaml
vendored
@@ -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
4
CODEOWNERS
generated
@@ -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
|
||||
|
||||
@@ -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/
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["homematicip"],
|
||||
"requirements": ["homematicip==2.8.0"]
|
||||
"requirements": ["homematicip==2.7.0"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 |= {
|
||||
|
||||
@@ -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%]"
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%]"
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "vicare",
|
||||
"name": "Viessmann ViCare",
|
||||
"codeowners": ["@CFenner", "@lackas"],
|
||||
"codeowners": ["@CFenner"],
|
||||
"config_flow": true,
|
||||
"dhcp": [
|
||||
{
|
||||
|
||||
2
requirements_all.txt
generated
2
requirements_all.txt
generated
@@ -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
|
||||
|
||||
2
requirements_test_all.txt
generated
2
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user