Compare commits

..

11 Commits

Author SHA1 Message Date
Robert Resch
4820f8f5ab test 2026-04-14 14:33:10 +00:00
Robert Resch
3341b245e9 Just copy explicit files in the DockerFile 2026-04-14 14:31:43 +00:00
Artur Pragacz
041fed4b48 Fix missing async_request_call in single-entity service call path (#168171) 2026-04-14 14:35:28 +02:00
Shay Levy
6311e6feec Revert "Replace 'custom component' with 'community integration' in bmw_connected_drive" (#168159) 2026-04-14 14:03:29 +02:00
Jan Čermák
582a0a5ae3 Add MariaDB 11.4 to CI tests (#168111) 2026-04-14 13:39:55 +02:00
Christopher Fenner
1a3f75c6fc Add additional codeowner to ViCare integration (#168169) 2026-04-14 13:38:29 +02:00
Shay Levy
21301e43a9 Revert "Update "custom component" to "community integration" in Shelly" (#168162) 2026-04-14 14:25:50 +03:00
Christian Lackas
cbe7823fd5 Bump homematicip to 2.8.0 (#168168) 2026-04-14 13:10:01 +02:00
Raphael Hehl
7a5951b72d Add discovery support to unifi_access via unifi_discovery (#168085) 2026-04-14 13:00:06 +02:00
Shay Levy
42771ed0a7 Revert "Replace "custom" with "community" in homeassistant" (#168161) 2026-04-14 12:58:33 +02:00
Aidan Timson
ded34b4430 Fix device_class removal in template binary sensors (#167775) 2026-04-14 11:40:13 +02:00
27 changed files with 873 additions and 397 deletions

View File

@@ -53,10 +53,10 @@ jobs:
with:
type: ${{ env.BUILD_TYPE }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@master # zizmor: ignore[unpinned-uses]
with:
ignore-dev: true
# - name: Verify version
# uses: home-assistant/actions/helpers/verify-version@master # zizmor: ignore[unpinned-uses]
# with:
# ignore-dev: true
- name: Fail if translations files are checked in
run: |
@@ -207,353 +207,353 @@ jobs:
push: true
version: ${{ needs.init.outputs.version }}
build_machine:
name: Build ${{ matrix.machine }} machine core image
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"]
runs-on: ${{ matrix.runs-on }}
permissions:
contents: read # To check out the repository
packages: write # To push to GHCR
id-token: write # For cosign signing
strategy:
matrix:
machine:
- generic-x86-64
- khadas-vim3
- odroid-c2
- odroid-c4
- odroid-m1
- odroid-n2
- qemuarm-64
- qemux86-64
- raspberrypi3-64
- raspberrypi4-64
- raspberrypi5-64
- yellow
- green
include:
# Default: aarch64 on native ARM runner
- arch: aarch64
runs-on: ubuntu-24.04-arm
# Overrides for amd64 machines
- machine: generic-x86-64
arch: amd64
runs-on: ubuntu-24.04
- machine: qemux86-64
arch: amd64
runs-on: ubuntu-24.04
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# build_machine:
# name: Build ${{ matrix.machine }} machine core image
# if: github.repository_owner == 'home-assistant'
# needs: ["init", "build_base"]
# runs-on: ${{ matrix.runs-on }}
# permissions:
# contents: read # To check out the repository
# packages: write # To push to GHCR
# id-token: write # For cosign signing
# strategy:
# matrix:
# machine:
# - generic-x86-64
# - khadas-vim3
# - odroid-c2
# - odroid-c4
# - odroid-m1
# - odroid-n2
# - qemuarm-64
# - qemux86-64
# - raspberrypi3-64
# - raspberrypi4-64
# - raspberrypi5-64
# - yellow
# - green
# include:
# # Default: aarch64 on native ARM runner
# - arch: aarch64
# runs-on: ubuntu-24.04-arm
# # Overrides for amd64 machines
# - machine: generic-x86-64
# arch: amd64
# runs-on: ubuntu-24.04
# - machine: qemux86-64
# arch: amd64
# runs-on: ubuntu-24.04
# steps:
# - name: Checkout the repository
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# with:
# persist-credentials: false
- name: Compute extra tags
id: tags
shell: bash
env:
VERSION: ${{ needs.init.outputs.version }}
run: |
if [[ "${VERSION}" =~ d ]]; then
echo "extra_tags=dev" >> "$GITHUB_OUTPUT"
elif [[ "${VERSION}" =~ b ]]; then
echo "extra_tags=beta" >> "$GITHUB_OUTPUT"
else
echo "extra_tags=stable" >> "$GITHUB_OUTPUT"
fi
# - name: Compute extra tags
# id: tags
# shell: bash
# env:
# VERSION: ${{ needs.init.outputs.version }}
# run: |
# if [[ "${VERSION}" =~ d ]]; then
# echo "extra_tags=dev" >> "$GITHUB_OUTPUT"
# elif [[ "${VERSION}" =~ b ]]; then
# echo "extra_tags=beta" >> "$GITHUB_OUTPUT"
# else
# echo "extra_tags=stable" >> "$GITHUB_OUTPUT"
# fi
- name: Build machine 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:${{ needs.init.outputs.version }}
cache-gha: false
container-registry-password: ${{ secrets.GITHUB_TOKEN }}
context: machine/
cosign-base-identity: "https://github.com/home-assistant/core/.*"
cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
file: machine/${{ matrix.machine }}
image: ghcr.io/home-assistant/${{ matrix.machine }}-homeassistant
image-tags: |
${{ needs.init.outputs.version }}
${{ steps.tags.outputs.extra_tags }}
push: true
version: ${{ needs.init.outputs.version }}
# - name: Build machine 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:${{ needs.init.outputs.version }}
# cache-gha: false
# container-registry-password: ${{ secrets.GITHUB_TOKEN }}
# context: machine/
# cosign-base-identity: "https://github.com/home-assistant/core/.*"
# cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
# file: machine/${{ matrix.machine }}
# image: ghcr.io/home-assistant/${{ matrix.machine }}-homeassistant
# image-tags: |
# ${{ needs.init.outputs.version }}
# ${{ steps.tags.outputs.extra_tags }}
# push: true
# version: ${{ needs.init.outputs.version }}
publish_ha:
name: Publish version files
environment: ${{ needs.init.outputs.channel }}
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_machine"]
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# publish_ha:
# name: Publish version files
# environment: ${{ needs.init.outputs.channel }}
# if: github.repository_owner == 'home-assistant'
# needs: ["init", "build_machine"]
# runs-on: ubuntu-latest
# permissions:
# contents: read
# steps:
# - name: Checkout the repository
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# with:
# persist-credentials: false
- name: Initialize git
uses: home-assistant/actions/helpers/git-init@master # zizmor: ignore[unpinned-uses]
with:
name: ${{ secrets.GIT_NAME }}
email: ${{ secrets.GIT_EMAIL }}
token: ${{ secrets.GIT_TOKEN }}
# - name: Initialize git
# uses: home-assistant/actions/helpers/git-init@master # zizmor: ignore[unpinned-uses]
# with:
# name: ${{ secrets.GIT_NAME }}
# email: ${{ secrets.GIT_EMAIL }}
# token: ${{ secrets.GIT_TOKEN }}
- name: Update version file
uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
with:
key: "homeassistant[]"
key-description: "Home Assistant Core"
version: ${{ needs.init.outputs.version }}
channel: ${{ needs.init.outputs.channel }}
exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
# - name: Update version file
# uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
# with:
# key: "homeassistant[]"
# key-description: "Home Assistant Core"
# version: ${{ needs.init.outputs.version }}
# channel: ${{ needs.init.outputs.channel }}
# exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
- name: Update version file (stable -> beta)
if: needs.init.outputs.channel == 'stable'
uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
with:
key: "homeassistant[]"
key-description: "Home Assistant Core"
version: ${{ needs.init.outputs.version }}
channel: beta
exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
# - name: Update version file (stable -> beta)
# if: needs.init.outputs.channel == 'stable'
# uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
# with:
# key: "homeassistant[]"
# key-description: "Home Assistant Core"
# version: ${{ needs.init.outputs.version }}
# channel: beta
# exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
publish_container:
name: Publish meta container for ${{ matrix.registry }}
environment: ${{ needs.init.outputs.channel }}
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"]
runs-on: ubuntu-latest
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:
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
steps:
- name: Install Cosign
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
with:
cosign-release: "v2.5.3"
# publish_container:
# name: Publish meta container for ${{ matrix.registry }}
# environment: ${{ needs.init.outputs.channel }}
# if: github.repository_owner == 'home-assistant'
# needs: ["init", "build_base"]
# runs-on: ubuntu-latest
# 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:
# registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
# steps:
# - name: Install Cosign
# uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
# with:
# cosign-release: "v2.5.3"
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# - name: Login to DockerHub
# if: matrix.registry == 'docker.io/homeassistant'
# uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
# with:
# username: ${{ secrets.DOCKERHUB_USERNAME }}
# password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
# - name: Login to GitHub Container Registry
# uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
# with:
# registry: ghcr.io
# username: ${{ github.repository_owner }}
# password: ${{ secrets.GITHUB_TOKEN }}
- name: Verify architecture image signatures
shell: bash
env:
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
VERSION: ${{ needs.init.outputs.version }}
run: |
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
for arch in $ARCHS; do
echo "Verifying ${arch} image signature..."
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp https://github.com/home-assistant/core/.* \
"ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"
done
echo "✓ All images verified successfully"
# - name: Verify architecture image signatures
# shell: bash
# env:
# ARCHITECTURES: ${{ needs.init.outputs.architectures }}
# VERSION: ${{ needs.init.outputs.version }}
# run: |
# ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
# for arch in $ARCHS; do
# echo "Verifying ${arch} image signature..."
# cosign verify \
# --certificate-oidc-issuer https://token.actions.githubusercontent.com \
# --certificate-identity-regexp https://github.com/home-assistant/core/.* \
# "ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"
# done
# echo "✓ All images verified successfully"
# Generate all Docker tags based on version string
# Version format: YYYY.MM.PATCH, YYYY.MM.PATCHbN (beta), or YYYY.MM.PATCH.devYYYYMMDDHHMM (dev)
# Examples:
# 2025.12.1 (stable) -> tags: 2025.12.1, 2025.12, stable, latest, beta, rc
# 2025.12.0b3 (beta) -> tags: 2025.12.0b3, beta, rc
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ matrix.registry }}/home-assistant
sep-tags: ","
tags: |
type=raw,value=${{ needs.init.outputs.version }},priority=9999
type=raw,value=dev,enable=${{ contains(needs.init.outputs.version, 'd') }}
type=raw,value=beta,enable=${{ !contains(needs.init.outputs.version, 'd') }}
type=raw,value=rc,enable=${{ !contains(needs.init.outputs.version, 'd') }}
type=raw,value=stable,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
type=raw,value=latest,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
# # Generate all Docker tags based on version string
# # Version format: YYYY.MM.PATCH, YYYY.MM.PATCHbN (beta), or YYYY.MM.PATCH.devYYYYMMDDHHMM (dev)
# # Examples:
# # 2025.12.1 (stable) -> tags: 2025.12.1, 2025.12, stable, latest, beta, rc
# # 2025.12.0b3 (beta) -> tags: 2025.12.0b3, beta, rc
# # 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
# - name: Generate Docker metadata
# id: meta
# uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
# with:
# images: ${{ matrix.registry }}/home-assistant
# sep-tags: ","
# tags: |
# type=raw,value=${{ needs.init.outputs.version }},priority=9999
# type=raw,value=dev,enable=${{ contains(needs.init.outputs.version, 'd') }}
# type=raw,value=beta,enable=${{ !contains(needs.init.outputs.version, 'd') }}
# type=raw,value=rc,enable=${{ !contains(needs.init.outputs.version, 'd') }}
# type=raw,value=stable,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
# type=raw,value=latest,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
# type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
# - name: Set up Docker Buildx
# uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
- name: Copy architecture images to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
shell: bash
env:
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
VERSION: ${{ needs.init.outputs.version }}
run: |
# Use imagetools to copy image blobs directly between registries
# This preserves provenance/attestations and seems to be much faster than pull/push
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
for arch in $ARCHS; do
echo "Copying ${arch} image to DockerHub..."
for attempt in 1 2 3; do
if docker buildx imagetools create \
--tag "docker.io/homeassistant/${arch}-homeassistant:${VERSION}" \
"ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"; then
break
fi
echo "Attempt ${attempt} failed, retrying in 10 seconds..."
sleep 10
if [ "${attempt}" -eq 3 ]; then
echo "Failed after 3 attempts"
exit 1
fi
done
cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${VERSION}"
done
# - name: Copy architecture images to DockerHub
# if: matrix.registry == 'docker.io/homeassistant'
# shell: bash
# env:
# ARCHITECTURES: ${{ needs.init.outputs.architectures }}
# VERSION: ${{ needs.init.outputs.version }}
# run: |
# # Use imagetools to copy image blobs directly between registries
# # This preserves provenance/attestations and seems to be much faster than pull/push
# ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
# for arch in $ARCHS; do
# echo "Copying ${arch} image to DockerHub..."
# for attempt in 1 2 3; do
# if docker buildx imagetools create \
# --tag "docker.io/homeassistant/${arch}-homeassistant:${VERSION}" \
# "ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"; then
# break
# fi
# echo "Attempt ${attempt} failed, retrying in 10 seconds..."
# sleep 10
# if [ "${attempt}" -eq 3 ]; then
# echo "Failed after 3 attempts"
# exit 1
# fi
# done
# cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${VERSION}"
# done
- name: Create and push multi-arch manifests
shell: bash
env:
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
REGISTRY: ${{ matrix.registry }}
VERSION: ${{ needs.init.outputs.version }}
META_TAGS: ${{ steps.meta.outputs.tags }}
run: |
# Build list of architecture images dynamically
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
ARCH_IMAGES=()
for arch in $ARCHS; do
ARCH_IMAGES+=("${REGISTRY}/${arch}-homeassistant:${VERSION}")
done
# - name: Create and push multi-arch manifests
# shell: bash
# env:
# ARCHITECTURES: ${{ needs.init.outputs.architectures }}
# REGISTRY: ${{ matrix.registry }}
# VERSION: ${{ needs.init.outputs.version }}
# META_TAGS: ${{ steps.meta.outputs.tags }}
# run: |
# # Build list of architecture images dynamically
# ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
# ARCH_IMAGES=()
# for arch in $ARCHS; do
# ARCH_IMAGES+=("${REGISTRY}/${arch}-homeassistant:${VERSION}")
# done
# Build list of all tags for single manifest creation
# Note: Using sep-tags=',' in metadata-action for easier parsing
TAG_ARGS=()
IFS=',' read -ra TAGS <<< "${META_TAGS}"
for tag in "${TAGS[@]}"; do
TAG_ARGS+=("--tag" "${tag}")
done
# # Build list of all tags for single manifest creation
# # Note: Using sep-tags=',' in metadata-action for easier parsing
# TAG_ARGS=()
# IFS=',' read -ra TAGS <<< "${META_TAGS}"
# for tag in "${TAGS[@]}"; do
# TAG_ARGS+=("--tag" "${tag}")
# done
# Create manifest with ALL tags in a single operation (much faster!)
echo "Creating multi-arch manifest with tags: ${TAGS[*]}"
docker buildx imagetools create "${TAG_ARGS[@]}" "${ARCH_IMAGES[@]}"
# # Create manifest with ALL tags in a single operation (much faster!)
# echo "Creating multi-arch manifest with tags: ${TAGS[*]}"
# docker buildx imagetools create "${TAG_ARGS[@]}" "${ARCH_IMAGES[@]}"
# Sign each tag separately (signing requires individual tag names)
echo "Signing all tags..."
for tag in "${TAGS[@]}"; do
echo "Signing ${tag}"
cosign sign --yes "${tag}"
done
# # Sign each tag separately (signing requires individual tag names)
# echo "Signing all tags..."
# for tag in "${TAGS[@]}"; do
# echo "Signing ${tag}"
# cosign sign --yes "${tag}"
# done
echo "All manifests created and signed successfully"
# echo "All manifests created and signed successfully"
build_python:
name: Build PyPi package
environment: ${{ needs.init.outputs.channel }}
needs: ["init", "build_base"]
runs-on: ubuntu-latest
permissions:
contents: read # To check out the repository
id-token: write # For PyPI trusted publishing
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# build_python:
# name: Build PyPi package
# environment: ${{ needs.init.outputs.channel }}
# needs: ["init", "build_base"]
# runs-on: ubuntu-latest
# permissions:
# contents: read # To check out the repository
# id-token: write # For PyPI trusted publishing
# if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
# 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: Set up Python
# uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
# with:
# python-version-file: ".python-version"
- name: Download translations
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: translations
# - 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: Extract translations
# run: |
# tar xvf translations.tar.gz
# rm translations.tar.gz
- name: Build package
shell: bash
run: |
# Remove dist, build, and homeassistant.egg-info
# when build locally for testing!
pip install build
python -m build
# - name: Build package
# shell: bash
# run: |
# # Remove dist, build, and homeassistant.egg-info
# # when build locally for testing!
# pip install build
# python -m build
- name: Upload package to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
skip-existing: true
# - name: Upload package to PyPI
# uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
# with:
# skip-existing: true
hassfest-image:
name: Build and test hassfest image
runs-on: ubuntu-latest
permissions:
contents: read # To check out the repository
packages: write # To push to GHCR
attestations: write # For build provenance attestation
id-token: write # For build provenance attestation
needs: ["init"]
if: github.repository_owner == 'home-assistant'
env:
HASSFEST_IMAGE_NAME: ghcr.io/home-assistant/hassfest
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# hassfest-image:
# name: Build and test hassfest image
# runs-on: ubuntu-latest
# permissions:
# contents: read # To check out the repository
# packages: write # To push to GHCR
# attestations: write # For build provenance attestation
# id-token: write # For build provenance attestation
# needs: ["init"]
# if: github.repository_owner == 'home-assistant'
# env:
# HASSFEST_IMAGE_NAME: ghcr.io/home-assistant/hassfest
# HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
# steps:
# - name: Checkout repository
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# with:
# persist-credentials: false
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
# - name: Login to GitHub Container Registry
# uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
# with:
# registry: ghcr.io
# username: ${{ github.repository_owner }}
# password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
load: true
tags: ${{ env.HASSFEST_IMAGE_TAG }}
# - name: Build Docker image
# uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
# with:
# context: . # So action will not pull the repository again
# file: ./script/hassfest/docker/Dockerfile
# load: true
# tags: ${{ env.HASSFEST_IMAGE_TAG }}
- name: Run hassfest against core
run: docker run --rm -v "${GITHUB_WORKSPACE}":/github/workspace "${HASSFEST_IMAGE_TAG}" --core-path=/github/workspace
# - name: Run hassfest against core
# run: docker run --rm -v "${GITHUB_WORKSPACE}":/github/workspace "${HASSFEST_IMAGE_TAG}" --core-path=/github/workspace
- name: Push Docker image
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
id: push
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
push: true
tags: ${{ env.HASSFEST_IMAGE_TAG }},${{ env.HASSFEST_IMAGE_NAME }}:latest
# - name: Push Docker image
# if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
# id: push
# uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
# with:
# context: . # So action will not pull the repository again
# file: ./script/hassfest/docker/Dockerfile
# push: true
# tags: ${{ env.HASSFEST_IMAGE_TAG }},${{ env.HASSFEST_IMAGE_NAME }}:latest
- name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
# - name: Generate artifact attestation
# if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
# uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
# with:
# subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
# subject-digest: ${{ steps.push.outputs.digest }}
# push-to-registry: true

View File

@@ -50,9 +50,11 @@ 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','mysql:8.0.32']"
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']"
# 12 is the oldest supported version
# - 12.14 is the latest (as of 9 Feb 2023)
# 15 is the latest version
@@ -1062,7 +1064,9 @@ jobs:
- 3306:3306
env:
MYSQL_ROOT_PASSWORD: password
options: --health-cmd="mysqladmin ping -uroot -ppassword" --health-interval=5s --health-timeout=2s --health-retries=3
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
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
/tests/components/vicare/ @CFenner
/homeassistant/components/vicare/ @CFenner @lackas
/tests/components/vicare/ @CFenner @lackas
/homeassistant/components/victron_ble/ @rajlaud
/tests/components/victron_ble/ @rajlaud
/homeassistant/components/victron_gx/ @tomer-w

5
Dockerfile generated
View File

@@ -34,8 +34,7 @@ RUN \
WORKDIR /usr/src
## Setup Home Assistant Core dependencies
COPY requirements.txt homeassistant/
COPY homeassistant/package_constraints.txt homeassistant/homeassistant/
COPY --parents requirements.txt homeassistant/package_constraints.txt homeassistant/
RUN \
uv pip install \
--no-build \
@@ -51,7 +50,7 @@ RUN \
-r homeassistant/requirements_all.txt
## Setup Home Assistant Core
COPY . homeassistant/
COPY --parents CODEOWNERS LICENSE* README* homeassistant pyproject.toml homeassistant/
RUN \
uv pip install \
-e ./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 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}).",
"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}).",
"title": "The BMW Connected Drive integration has been removed"
}
}

View File

@@ -54,7 +54,6 @@ from homeassistant.helpers.target import (
)
from homeassistant.helpers.template import async_load_custom_templates
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.package import is_virtual_env
# The scene integration will do a late import of scene
# so we want to make sure its loaded with the component
@@ -418,7 +417,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
installation_type = info["installation_type"][15:]
if installation_type in {"Core", "Container"}:
core_installation = installation_type == "Core"
deprecated_method = installation_type == "Core"
bit32 = _is_32_bit()
arch = info["arch"]
if bit32 and installation_type == "Container":
@@ -434,14 +433,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
translation_placeholders={"arch": arch},
)
deprecated_architecture = bit32 and installation_type != "Container"
if core_installation or deprecated_architecture:
if deprecated_method or deprecated_architecture:
issue_id = "deprecated"
if core_installation:
if deprecated_method:
issue_id += "_method"
if deprecated_architecture:
issue_id += "_architecture"
if core_installation and not is_virtual_env():
issue_id += "_local_deps"
ir.async_create_issue(
hass,
DOMAIN,

View File

@@ -113,14 +113,6 @@
"description": "This system is using the {installation_type} installation type, and 32-bit hardware (`{arch}`), both of which have been deprecated and will no longer be supported after the release of Home Assistant 2025.12.",
"title": "Deprecation notice"
},
"deprecated_method_architecture_local_deps": {
"description": "This system is using the {installation_type} installation type outside a virtual environment, and 32-bit hardware (`{arch}`), both of which have been deprecated and will no longer be supported after the release of Home Assistant 2025.12.",
"title": "Deprecation notice"
},
"deprecated_method_local_deps": {
"description": "This system is using the {installation_type} installation type outside a virtual environment, which has been unsupported since Home Assistant 2025.12 and will not work after the release of Home Assistant 2026.11. To continue receiving updates and support, migrate to a supported installation method.",
"title": "Deprecation notice: Installation method"
},
"deprecated_os_aarch64": {
"description": "This system is running on a 32-bit operating system (`armv7`), which has been deprecated and will no longer receive updates after the release of Home Assistant 2025.12. To continue using Home Assistant on this hardware, you will need to install a 64-bit operating system. Please refer to our [installation guide]({installation_guide}).",
"title": "[%key:component::homeassistant::issues::deprecated_architecture::title%]"
@@ -156,7 +148,7 @@
},
"step": {
"init": {
"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.",
"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.",
"menu_options": {
"confirm": "Remove previous configurations",
"ignore": "Ignore"
@@ -244,7 +236,7 @@
"description": "Restarts Home Assistant.",
"fields": {
"safe_mode": {
"description": "Disable community integrations and community cards.",
"description": "Disable custom integrations and custom cards.",
"name": "Safe mode"
}
},

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
"requirements": ["homematicip==2.7.0"]
"requirements": ["homematicip==2.8.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 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.
# 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.
if not entry.data.get(CONF_HOST):
LOGGER.warning(
(
"The config entry %s probably comes from a community integration, "
"please remove it if you want to use the Core Shelly integration"
"The config entry %s probably comes from a custom integration, please"
" remove it if you want to use core Shelly integration"
),
entry.title,
)

View File

@@ -180,18 +180,16 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema:
}
if domain == Platform.BINARY_SENSOR:
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,
),
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,
),
}
),
}
if domain == Platform.BUTTON:
schema |= {

View File

@@ -608,6 +608,7 @@
},
"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,9 +9,10 @@ from typing import Any
from unifi_access_api import ApiAuthError, ApiConnectionError, UnifiAccessApiClient
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import SOURCE_IGNORE, 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
@@ -25,6 +26,11 @@ 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] = {}
@@ -117,6 +123,66 @@ 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,6 +3,7 @@
"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,8 +42,10 @@ rules:
# Gold
devices: done
diagnostics: done
discovery-update-info: todo
discovery: todo
discovery-update-info: done
discovery:
status: exempt
comment: Discovery is handled via unifi_discovery dependency using SOURCE_INTEGRATION_DISCOVERY.
docs-data-update: done
docs-examples: done
docs-known-limitations: done

View File

@@ -12,6 +12,17 @@
"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,4 +9,5 @@ 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"],
"codeowners": ["@CFenner", "@lackas"],
"config_flow": true,
"dhcp": [
{

View File

@@ -831,8 +831,8 @@ async def entity_service_call(
if len(entities) == 1:
# Single entity case avoids creating task
entity = entities[0]
single_response = await _handle_entity_call(
hass, entity, func, data, call.context
single_response = await entity.async_request_call(
_handle_entity_call(hass, entity, func, data, call.context)
)
if entity.should_poll:
# Context expires if the turn on commands took a long time.

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.7.0
homematicip==2.8.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.7.0
homematicip==2.8.0
# homeassistant.components.homevolt
homevolt==0.5.0

View File

@@ -49,8 +49,7 @@ RUN \
WORKDIR /usr/src
## Setup Home Assistant Core dependencies
COPY requirements.txt homeassistant/
COPY homeassistant/package_constraints.txt homeassistant/homeassistant/
COPY --parents requirements.txt homeassistant/package_constraints.txt homeassistant/
RUN \
uv pip install \
--no-build \
@@ -66,7 +65,7 @@ RUN \
-r homeassistant/requirements_all.txt
## Setup Home Assistant Core
COPY . homeassistant/
COPY --parents CODEOWNERS LICENSE* README* homeassistant pyproject.toml homeassistant/
RUN \
uv pip install \
-e ./homeassistant \

View File

@@ -642,18 +642,13 @@ async def test_reload_all(
@pytest.mark.parametrize(
("arch", "bit_32", "venv", "expected_issue"),
("arch", "bit_32", "expected_issue"),
[
("i386", True, False, "deprecated_method_architecture_local_deps"),
("armhf", True, False, "deprecated_method_architecture_local_deps"),
("armv7", True, False, "deprecated_method_architecture_local_deps"),
("aarch64", False, False, "deprecated_method_local_deps"),
("generic-x86-64", False, False, "deprecated_method_local_deps"),
("i386", True, True, "deprecated_method_architecture"),
("armhf", True, True, "deprecated_method_architecture"),
("armv7", True, True, "deprecated_method_architecture"),
("aarch64", False, True, "deprecated_method"),
("generic-x86-64", False, True, "deprecated_method"),
("i386", True, "deprecated_method_architecture"),
("armhf", True, "deprecated_method_architecture"),
("armv7", True, "deprecated_method_architecture"),
("aarch64", False, "deprecated_method"),
("generic-x86-64", False, "deprecated_method"),
],
)
async def test_deprecated_installation_issue_core(
@@ -661,7 +656,6 @@ async def test_deprecated_installation_issue_core(
issue_registry: ir.IssueRegistry,
arch: str,
bit_32: bool,
venv: bool,
expected_issue: str,
) -> None:
"""Test deprecated installation issue."""
@@ -673,9 +667,6 @@ async def test_deprecated_installation_issue_core(
"arch": arch,
},
),
patch(
"homeassistant.components.homeassistant.is_virtual_env", return_value=venv
),
patch(
"homeassistant.components.homeassistant._is_32_bit",
return_value=bit_32,

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 community integration" in caplog.text
assert "probably comes from a custom integration" in caplog.text
@pytest.mark.parametrize("gen", [1, 2, 3])

View File

@@ -564,6 +564,7 @@ async def test_config_flow_device(
"extra_options",
"options_options",
"key_template",
"suggested_device_class",
),
[
(
@@ -576,9 +577,10 @@ async def test_config_flow_device(
},
["on", "off"],
{"one": "on", "two": "off"},
{},
{},
{"device_class": "motion"},
{"device_class": "window"},
"state",
"motion",
),
(
"sensor",
@@ -593,6 +595,7 @@ async def test_config_flow_device(
{},
{},
"state",
None,
),
(
"button",
@@ -620,6 +623,7 @@ async def test_config_flow_device(
],
},
"state",
None,
),
(
"cover",
@@ -630,6 +634,7 @@ async def test_config_flow_device(
{"set_cover_position": []},
{"set_cover_position": []},
"state",
None,
),
(
"event",
@@ -640,6 +645,7 @@ async def test_config_flow_device(
{"event_types": "{{ ['single', 'double'] }}"},
{"event_types": "{{ ['single', 'double'] }}"},
"event_type",
None,
),
(
"fan",
@@ -650,6 +656,7 @@ async def test_config_flow_device(
{"turn_on": [], "turn_off": []},
{"turn_on": [], "turn_off": []},
"state",
None,
),
(
"image",
@@ -667,6 +674,7 @@ async def test_config_flow_device(
"verify_ssl": True,
},
"url",
None,
),
(
"light",
@@ -677,6 +685,7 @@ async def test_config_flow_device(
{"turn_on": [], "turn_off": []},
{"turn_on": [], "turn_off": []},
"state",
None,
),
(
"lock",
@@ -687,6 +696,7 @@ async def test_config_flow_device(
{"lock": [], "unlock": []},
{"lock": [], "unlock": []},
"state",
None,
),
(
"number",
@@ -717,6 +727,7 @@ async def test_config_flow_device(
},
},
"state",
None,
),
(
"alarm_control_panel",
@@ -727,6 +738,7 @@ async def test_config_flow_device(
{"code_arm_required": True, "code_format": "number"},
{"code_arm_required": True, "code_format": "number"},
"value_template",
None,
),
(
"select",
@@ -737,6 +749,7 @@ async def test_config_flow_device(
{"options": "{{ ['off', 'on', 'auto'] }}", "select_option": []},
{"options": "{{ ['off', 'on', 'auto'] }}", "select_option": []},
"state",
None,
),
(
"switch",
@@ -747,6 +760,7 @@ async def test_config_flow_device(
{},
{},
"value_template",
None,
),
(
"update",
@@ -757,6 +771,7 @@ async def test_config_flow_device(
{"latest_version": "{{ '2.0' }}"},
{"latest_version": "{{ '2.0' }}"},
"installed_version",
None,
),
(
"vacuum",
@@ -767,6 +782,7 @@ async def test_config_flow_device(
{"start": []},
{"start": []},
"state",
None,
),
(
"weather",
@@ -777,6 +793,7 @@ async def test_config_flow_device(
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
"condition",
None,
),
],
)
@@ -791,6 +808,7 @@ 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"]
@@ -828,6 +846,10 @@ 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"],
@@ -842,6 +864,7 @@ async def test_options(
"template_type": template_type,
**new_state_template,
**extra_options,
**options_options,
}
assert config_entry.data == {}
assert config_entry.options == {
@@ -849,6 +872,7 @@ async def test_options(
"template_type": template_type,
**new_state_template,
**extra_options,
**options_options,
}
assert config_entry.title == "My template"
@@ -877,6 +901,63 @@ 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,6 +13,7 @@ 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
@@ -29,6 +30,19 @@ 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,7 +9,11 @@ import pytest
from unifi_access_api import ApiAuthError, ApiConnectionError
from homeassistant.components.unifi_access.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.config_entries import (
SOURCE_IGNORE,
SOURCE_INTEGRATION_DISCOVERY,
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
@@ -572,3 +576,283 @@ 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"

View File

@@ -79,6 +79,7 @@ def mock_handle_entity_call():
"""Mock service platform call."""
with patch(
"homeassistant.helpers.service._handle_entity_call",
new_callable=AsyncMock,
return_value=None,
) as mock_call:
yield mock_call
@@ -1683,6 +1684,40 @@ async def test_call_with_sync_attr(hass: HomeAssistant, mock_entities) -> None:
assert mock_method.mock_calls[0][2] == {}
async def test_call_single_entity_uses_parallel_updates(
hass: HomeAssistant, mock_handle_entity_call, mock_entities
) -> None:
"""Check that single entity calls go through async_request_call."""
entity = mock_entities["light.kitchen"]
entity.parallel_updates = asyncio.Semaphore(1)
# Hold the semaphore so the service would block if it respects it
await entity.parallel_updates.acquire()
service_call = service.entity_service_call(
hass,
mock_entities,
Mock(),
ServiceCall(
hass,
"test_domain",
"test_service",
{"entity_id": "light.kitchen"},
),
)
task = hass.async_create_task(service_call)
# Give the event loop a chance to progress; the call should be blocked
await asyncio.sleep(0)
assert mock_handle_entity_call.await_count == 0
# Release the semaphore so the call can proceed
entity.parallel_updates.release()
await task
assert mock_handle_entity_call.await_count == 1
async def test_call_context_user_not_exist(hass: HomeAssistant) -> None:
"""Check we don't allow deleted users to do things."""
with pytest.raises(exceptions.UnknownUser) as err: