Compare commits

..

5 Commits

55 changed files with 1777 additions and 2128 deletions

View File

@@ -47,10 +47,10 @@ jobs:
with:
type: ${{ env.BUILD_TYPE }}
# - name: Verify version
# uses: home-assistant/actions/helpers/verify-version@master
# with:
# ignore-dev: true
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@master
with:
ignore-dev: true
- name: Fail if translations files are checked in
run: |
@@ -99,7 +99,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Download nightly wheels of frontend
#if: needs.init.outputs.channel == 'dev'
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@5c98f0b039f36ef966fdb7dfa9779262785ecb05 # v14
with:
github_token: ${{secrets.GITHUB_TOKEN}}
@@ -110,7 +110,7 @@ jobs:
name: wheels
- name: Download nightly wheels of intents
#if: needs.init.outputs.channel == 'dev'
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@5c98f0b039f36ef966fdb7dfa9779262785ecb05 # v14
with:
github_token: ${{secrets.GITHUB_TOKEN}}
@@ -121,13 +121,13 @@ jobs:
name: package
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
#if: needs.init.outputs.channel == 'dev'
if: needs.init.outputs.channel == 'dev'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Adjust nightly version
#if: needs.init.outputs.channel == 'dev'
if: needs.init.outputs.channel == 'dev'
shell: bash
env:
UV_PRERELEASE: allow
@@ -165,7 +165,7 @@ jobs:
sed -i "s|home-assistant-intents==.*|home-assistant-intents==${BASH_REMATCH[1]}|" \
homeassistant/package_constraints.txt
sed -i "s|home-assistant-intents==.*||" requirements_all.txt requirements.txt
sed -i "s|home-assistant-intents==.*||" requirements_all.txt
fi
- name: Download translations
@@ -246,314 +246,314 @@ jobs:
run: |
cosign sign --yes "ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}@${{ steps.build.outputs.digest }}"
# build_machine:
# name: Build ${{ matrix.machine }} machine core image
# if: github.repository_owner == 'home-assistant'
# needs: ["init", "build_base"]
# runs-on: ubuntu-latest
# permissions:
# contents: read
# packages: write
# id-token: write
# strategy:
# matrix:
# machine:
# - generic-x86-64
# - intel-nuc
# - khadas-vim3
# - odroid-c2
# - odroid-c4
# - odroid-m1
# - odroid-n2
# - qemuarm-64
# - qemux86-64
# - raspberrypi3-64
# - raspberrypi4-64
# - raspberrypi5-64
# - yellow
# - green
# steps:
# - name: Checkout the repository
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
build_machine:
name: Build ${{ matrix.machine }} machine core image
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
strategy:
matrix:
machine:
- generic-x86-64
- intel-nuc
- khadas-vim3
- odroid-c2
- odroid-c4
- odroid-m1
- odroid-n2
- qemuarm-64
- qemux86-64
- raspberrypi3-64
- raspberrypi4-64
- raspberrypi5-64
- yellow
- green
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# - name: Set build additional args
# run: |
# # Create general tags
# if [[ "${{ needs.init.outputs.version }}" =~ d ]]; then
# echo "BUILD_ARGS=--additional-tag dev" >> $GITHUB_ENV
# elif [[ "${{ needs.init.outputs.version }}" =~ b ]]; then
# echo "BUILD_ARGS=--additional-tag beta" >> $GITHUB_ENV
# else
# echo "BUILD_ARGS=--additional-tag stable" >> $GITHUB_ENV
# fi
- name: Set build additional args
run: |
# Create general tags
if [[ "${{ needs.init.outputs.version }}" =~ d ]]; then
echo "BUILD_ARGS=--additional-tag dev" >> $GITHUB_ENV
elif [[ "${{ needs.init.outputs.version }}" =~ b ]]; then
echo "BUILD_ARGS=--additional-tag beta" >> $GITHUB_ENV
else
echo "BUILD_ARGS=--additional-tag stable" >> $GITHUB_ENV
fi
# - name: Login to GitHub Container Registry
# uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
# with:
# registry: ghcr.io
# username: ${{ github.repository_owner }}
# password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
# # home-assistant/builder doesn't support sha pinning
# - name: Build base image
# uses: home-assistant/builder@2025.11.0
# with:
# args: |
# $BUILD_ARGS \
# --target /data/machine \
# --cosign \
# --machine "${{ needs.init.outputs.version }}=${{ matrix.machine }}"
# home-assistant/builder doesn't support sha pinning
- name: Build base image
uses: home-assistant/builder@2025.11.0
with:
args: |
$BUILD_ARGS \
--target /data/machine \
--cosign \
--machine "${{ needs.init.outputs.version }}=${{ matrix.machine }}"
# 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
# steps:
# - name: Checkout the repository
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# - name: Initialize git
# uses: home-assistant/actions/helpers/git-init@master
# with:
# name: ${{ secrets.GIT_NAME }}
# email: ${{ secrets.GIT_EMAIL }}
# token: ${{ secrets.GIT_TOKEN }}
- name: Initialize git
uses: home-assistant/actions/helpers/git-init@master
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
# 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
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
# 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
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
# packages: write
# id-token: write
# strategy:
# fail-fast: false
# matrix:
# registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
# steps:
# - *install_cosign
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
packages: write
id-token: write
strategy:
fail-fast: false
matrix:
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
steps:
- *install_cosign
# - name: Login to DockerHub
# if: matrix.registry == 'docker.io/homeassistant'
# uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
# with:
# username: ${{ secrets.DOCKERHUB_USERNAME }}
# password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# - name: Login to GitHub Container Registry
# uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
# with:
# registry: ghcr.io
# username: ${{ github.repository_owner }}
# password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
# - name: Verify architecture image signatures
# shell: bash
# run: |
# ARCHS=$(echo '${{ needs.init.outputs.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:${{ needs.init.outputs.version }}"
# done
# echo "✓ All images verified successfully"
- name: Verify architecture image signatures
shell: bash
run: |
ARCHS=$(echo '${{ needs.init.outputs.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:${{ needs.init.outputs.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@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.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@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.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@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.7.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.7.1
# - name: Copy architecture images to DockerHub
# if: matrix.registry == 'docker.io/homeassistant'
# shell: bash
# 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 '${{ needs.init.outputs.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:${{ needs.init.outputs.version }}" \
# "ghcr.io/home-assistant/${arch}-homeassistant:${{ needs.init.outputs.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:${{ needs.init.outputs.version }}"
# done
- name: Copy architecture images to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
shell: bash
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 '${{ needs.init.outputs.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:${{ needs.init.outputs.version }}" \
"ghcr.io/home-assistant/${arch}-homeassistant:${{ needs.init.outputs.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:${{ needs.init.outputs.version }}"
done
# - name: Create and push multi-arch manifests
# shell: bash
# run: |
# # Build list of architecture images dynamically
# ARCHS=$(echo '${{ needs.init.outputs.architectures }}' | jq -r '.[]')
# ARCH_IMAGES=()
# for arch in $ARCHS; do
# ARCH_IMAGES+=("${{ matrix.registry }}/${arch}-homeassistant:${{ needs.init.outputs.version }}")
# done
- name: Create and push multi-arch manifests
shell: bash
run: |
# Build list of architecture images dynamically
ARCHS=$(echo '${{ needs.init.outputs.architectures }}' | jq -r '.[]')
ARCH_IMAGES=()
for arch in $ARCHS; do
ARCH_IMAGES+=("${{ matrix.registry }}/${arch}-homeassistant:${{ needs.init.outputs.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 <<< "${{ steps.meta.outputs.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 <<< "${{ steps.meta.outputs.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
# id-token: write
# if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
# steps:
# - name: Checkout the repository
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
build_python:
name: Build PyPi package
environment: ${{ needs.init.outputs.channel }}
needs: ["init", "build_base"]
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# - name: Set up Python ${{ env.DEFAULT_PYTHON }}
# uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
# with:
# python-version: ${{ env.DEFAULT_PYTHON }}
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
# - name: Download translations
# uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
# with:
# name: translations
- name: Download translations
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
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@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
# with:
# skip-existing: true
- name: Upload package to PyPI
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
skip-existing: true
# hassfest-image:
# name: Build and test hassfest image
# runs-on: ubuntu-latest
# permissions:
# contents: read
# packages: write
# attestations: write
# id-token: write
# 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
hassfest-image:
name: Build and test hassfest image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
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
# - name: Login to GitHub Container Registry
# uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
# with:
# registry: ghcr.io
# username: ${{ github.repository_owner }}
# password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
# - name: Build Docker image
# uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
# 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@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
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 ${{ env.HASSFEST_IMAGE_TAG }} --core-path=/github/workspace
- name: Run hassfest against core
run: docker run --rm -v ${{ github.workspace }}:/github/workspace ${{ env.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@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
# 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@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
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-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.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-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0
with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.1
rev: v0.15.0
hooks:
- id: ruff-check
args:

View File

@@ -322,12 +322,6 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
"call_deflections"
] = await self.async_update_call_deflections()
except FRITZ_EXCEPTIONS as ex:
_LOGGER.debug(
"Reload %s due to error '%s' to ensure proper re-login",
self.config_entry.title,
ex,
)
self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",

View File

@@ -2,8 +2,6 @@
from __future__ import annotations
from requests.exceptions import ConnectionError as RequestConnectionError, HTTPError
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, UnitOfTemperature
from homeassistant.core import Event, HomeAssistant
@@ -59,10 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzboxConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: FritzboxConfigEntry) -> bool:
"""Unloading the AVM FRITZ!SmartHome platforms."""
try:
await hass.async_add_executor_job(entry.runtime_data.fritz.logout)
except (RequestConnectionError, HTTPError) as ex:
LOGGER.debug("logout failed with '%s', anyway continue with unload", ex)
await hass.async_add_executor_job(entry.runtime_data.fritz.logout)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -121,11 +121,26 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
def _update_fritz_devices(self) -> FritzboxCoordinatorData:
"""Update all fritzbox device data."""
self.fritz.update_devices(ignore_removed=False)
if self.has_templates:
self.fritz.update_templates(ignore_removed=False)
if self.has_triggers:
self.fritz.update_triggers(ignore_removed=False)
try:
self.fritz.update_devices(ignore_removed=False)
if self.has_templates:
self.fritz.update_templates(ignore_removed=False)
if self.has_triggers:
self.fritz.update_triggers(ignore_removed=False)
except RequestConnectionError as ex:
raise UpdateFailed from ex
except HTTPError:
# If the device rebooted, login again
try:
self.fritz.login()
except LoginError as ex:
raise ConfigEntryAuthFailed from ex
self.fritz.update_devices(ignore_removed=False)
if self.has_templates:
self.fritz.update_templates(ignore_removed=False)
if self.has_triggers:
self.fritz.update_triggers(ignore_removed=False)
devices = self.fritz.get_devices()
device_data = {}
@@ -178,18 +193,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
async def _async_update_data(self) -> FritzboxCoordinatorData:
"""Fetch all device data."""
try:
new_data = await self.hass.async_add_executor_job(
self._update_fritz_devices
)
except (RequestConnectionError, HTTPError) as ex:
LOGGER.debug(
"Reload %s due to error '%s' to ensure proper re-login",
self.config_entry.title,
ex,
)
self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id)
raise UpdateFailed from ex
new_data = await self.hass.async_add_executor_job(self._update_fritz_devices)
for device in new_data.devices.values():
# create device registry entry for new main devices

View File

@@ -3,7 +3,13 @@
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from hdfury import OPERATION_MODES, TX0_INPUT_PORTS, TX1_INPUT_PORTS, HDFuryError
from hdfury import (
OPERATION_MODES,
TX0_INPUT_PORTS,
TX1_INPUT_PORTS,
HDFuryAPI,
HDFuryError,
)
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
@@ -21,7 +27,7 @@ PARALLEL_UPDATES = 1
class HDFurySelectEntityDescription(SelectEntityDescription):
"""Description for HDFury select entities."""
set_value_fn: Callable[[HDFuryCoordinator, str], Awaitable[None]]
set_value_fn: Callable[[HDFuryAPI, str], Awaitable[None]]
SELECT_PORTS: tuple[HDFurySelectEntityDescription, ...] = (

View File

@@ -27,15 +27,36 @@ from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import instance_id
from homeassistant.helpers.selector import TextSelector
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
TextSelector,
)
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_PRODUCT_NAME, CONF_PRODUCT_TYPE, CONF_SERIAL, DOMAIN, LOGGER
from .const import (
CONF_PRODUCT_NAME,
CONF_PRODUCT_TYPE,
CONF_SERIAL,
CONF_USAGE,
DOMAIN,
ENERGY_MONITORING_DEVICES,
LOGGER,
)
USAGE_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=["consumption", "generation"],
translation_key="usage",
mode=SelectSelectorMode.LIST,
)
)
class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for P1 meter."""
"""Handle a config flow for HomeWizard devices."""
VERSION = 1
@@ -43,6 +64,8 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
product_name: str | None = None
product_type: str | None = None
serial: str | None = None
token: str | None = None
usage: str | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -64,6 +87,12 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
f"{device_info.product_type}_{device_info.serial}"
)
self._abort_if_unique_id_configured(updates=user_input)
if device_info.product_type in ENERGY_MONITORING_DEVICES:
self.ip_address = user_input[CONF_IP_ADDRESS]
self.product_name = device_info.product_name
self.product_type = device_info.product_type
self.serial = device_info.serial
return await self.async_step_usage()
return self.async_create_entry(
title=f"{device_info.product_name}",
data=user_input,
@@ -82,6 +111,45 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_usage(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Step where we ask how the energy monitor is used."""
assert self.ip_address
assert self.product_name
assert self.product_type
assert self.serial
data: dict[str, Any] = {CONF_IP_ADDRESS: self.ip_address}
if self.token:
data[CONF_TOKEN] = self.token
if user_input is not None:
return self.async_create_entry(
title=f"{self.product_name}",
data=data | user_input,
)
return self.async_show_form(
step_id="usage",
data_schema=vol.Schema(
{
vol.Required(
CONF_USAGE,
default=user_input.get(CONF_USAGE)
if user_input is not None
else "consumption",
): USAGE_SELECTOR,
}
),
description_placeholders={
CONF_PRODUCT_NAME: self.product_name,
CONF_PRODUCT_TYPE: self.product_type,
CONF_SERIAL: self.serial,
CONF_IP_ADDRESS: self.ip_address,
},
)
async def async_step_authorize(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -101,8 +169,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
# Now we got a token, we can ask for some more info
async with HomeWizardEnergyV2(self.ip_address, token=token) as api:
device_info = await api.device()
device_info = await HomeWizardEnergyV2(self.ip_address, token=token).device()
data = {
CONF_IP_ADDRESS: self.ip_address,
@@ -113,6 +180,14 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
f"{device_info.product_type}_{device_info.serial}"
)
self._abort_if_unique_id_configured(updates=data)
self.product_name = device_info.product_name
self.product_type = device_info.product_type
self.serial = device_info.serial
if device_info.product_type in ENERGY_MONITORING_DEVICES:
self.token = token
return await self.async_step_usage()
return self.async_create_entry(
title=f"{device_info.product_name}",
data=data,
@@ -139,6 +214,8 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured(
updates={CONF_IP_ADDRESS: discovery_info.host}
)
if self.product_type in ENERGY_MONITORING_DEVICES:
return await self.async_step_usage()
return await self.async_step_discovery_confirm()

View File

@@ -5,6 +5,8 @@ from __future__ import annotations
from datetime import timedelta
import logging
from homewizard_energy.const import Model
from homeassistant.const import Platform
DOMAIN = "homewizard"
@@ -22,5 +24,14 @@ LOGGER = logging.getLogger(__package__)
CONF_PRODUCT_NAME = "product_name"
CONF_PRODUCT_TYPE = "product_type"
CONF_SERIAL = "serial"
CONF_USAGE = "usage"
UPDATE_INTERVAL = timedelta(seconds=5)
ENERGY_MONITORING_DEVICES = (
Model.ENERGY_SOCKET,
Model.ENERGY_METER_1_PHASE,
Model.ENERGY_METER_3_PHASE,
Model.ENERGY_METER_EASTRON_SDM230,
Model.ENERGY_METER_EASTRON_SDM630,
)

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Final
@@ -22,6 +23,7 @@ from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS,
EntityCategory,
Platform,
UnitOfApparentPower,
UnitOfElectricCurrent,
UnitOfElectricPotential,
@@ -32,14 +34,15 @@ from homeassistant.const import (
UnitOfVolume,
UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
from homeassistant.util.variance import ignore_variance
from .const import DOMAIN
from .const import CONF_USAGE, DOMAIN, ENERGY_MONITORING_DEVICES
from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator
from .entity import HomeWizardEntity
@@ -267,15 +270,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
enabled_fn=lambda data: data.measurement.energy_export_t4_kwh != 0,
value_fn=lambda data: data.measurement.energy_export_t4_kwh or None,
),
HomeWizardSensorEntityDescription(
key="active_power_w",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
has_fn=lambda data: data.measurement.power_w is not None,
value_fn=lambda data: data.measurement.power_w,
),
HomeWizardSensorEntityDescription(
key="active_power_l1_w",
translation_key="active_power_phase_w",
@@ -695,27 +689,46 @@ EXTERNAL_SENSORS = {
}
@callback
def async_cleanup_deleted_sensor(
hass: HomeAssistant, entry: HomeWizardConfigEntry, description_key: str
) -> None:
"""Cleanup sensor if it previously existed as deleted entity."""
with suppress(KeyError):
er.async_get(hass).deleted_entities.pop(
(Platform.SENSOR, DOMAIN, f"{entry.unique_id}_{description_key}")
)
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeWizardConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize sensors."""
# Initialize default sensors
"""Cleanup deleted entrity registry item."""
entities: list = [
HomeWizardSensorEntity(entry.runtime_data, description)
for description in SENSORS
if description.has_fn(entry.runtime_data.data)
]
active_power_sensor_description = HomeWizardSensorEntityDescription(
key="active_power_w",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
entity_registry_enabled_default=(
entry.runtime_data.data.device.product_type != Model.BATTERY
and entry.data.get(CONF_USAGE, "consumption") == "consumption"
),
has_fn=lambda x: True,
value_fn=lambda data: data.measurement.power_w,
)
# Add optional production power sensor for supported energy monitoring devices
# or plug-in battery
if entry.runtime_data.data.device.product_type in (
Model.ENERGY_SOCKET,
Model.ENERGY_METER_1_PHASE,
Model.ENERGY_METER_3_PHASE,
Model.ENERGY_METER_EASTRON_SDM230,
Model.ENERGY_METER_EASTRON_SDM630,
*ENERGY_MONITORING_DEVICES,
Model.BATTERY,
):
active_prodution_power_sensor_description = HomeWizardSensorEntityDescription(
@@ -735,17 +748,33 @@ async def async_setup_entry(
is not None
and total_export > 0
)
or entry.data.get(CONF_USAGE, "consumption") == "generation"
),
has_fn=lambda x: True,
value_fn=lambda data: (
power_w * -1 if (power_w := data.measurement.power_w) else power_w
),
)
entities.append(
HomeWizardSensorEntity(
entry.runtime_data, active_prodution_power_sensor_description
# We cleanup any deleted instance to assure the correct power sensor is enabled
async_cleanup_deleted_sensor(hass, entry, active_power_sensor_description.key)
async_cleanup_deleted_sensor(
hass, entry, active_prodution_power_sensor_description.key
)
entities.extend(
(
HomeWizardSensorEntity(
entry.runtime_data, active_power_sensor_description
),
HomeWizardSensorEntity(
entry.runtime_data, active_prodution_power_sensor_description
),
)
)
elif (data := entry.runtime_data.data) and data.measurement.power_w is not None:
async_cleanup_deleted_sensor(hass, entry, active_power_sensor_description.key)
entities.append(
HomeWizardSensorEntity(entry.runtime_data, active_power_sensor_description)
)
# Initialize external devices
measurement = entry.runtime_data.data.measurement

View File

@@ -41,6 +41,16 @@
},
"description": "Update configuration for {title}."
},
"usage": {
"data": {
"usage": "Usage"
},
"data_description": {
"usage": "This will enable either a power consumption or power production sensor the first time this device is set up."
},
"description": "What are you going to monitor with your {product_name} ({product_type} {serial} at {ip_address})?",
"title": "Usage"
},
"user": {
"data": {
"ip_address": "[%key:common::config_flow::data::ip%]"
@@ -199,5 +209,13 @@
},
"title": "Update the authentication method for {title}"
}
},
"selector": {
"usage": {
"options": {
"consumption": "Monitoring consumed energy",
"generation": "Monitoring generated energy"
}
}
}
}

View File

@@ -7,7 +7,6 @@ import datetime
import logging
import httpx
from mcp import McpError
from mcp.client.session import ClientSession
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamable_http_client
@@ -64,15 +63,10 @@ async def mcp_client(
# Method not Allowed likely means this is not a streamable HTTP server,
# but it may be an SSE server. This is part of the MCP Transport
# backwards compatibility specification.
# We also handle other generic McpErrors since proxies may not respond
# consistently with a 405.
if (
isinstance(main_error, httpx.HTTPStatusError)
and main_error.response.status_code == 405
) or isinstance(main_error, McpError):
_LOGGER.debug(
"Streamable HTTP client failed, attempting SSE client: %s", main_error
)
):
try:
async with (
sse_client(url=url, headers=headers) as streams,

View File

@@ -5280,7 +5280,7 @@ async def async_get_broker_settings( # noqa: C901
)
schema = vol.Schema({cv.string: cv.template})
schema(validated_user_input[CONF_WS_HEADERS])
except (*JSON_DECODE_EXCEPTIONS, vol.MultipleInvalid):
except (*JSON_DECODE_EXCEPTIONS, vol.MultipleInvalid): # fmt: off
errors["base"] = "bad_ws_headers"
return False
return True

View File

@@ -148,12 +148,6 @@ class OneDriveBackupAgent(BackupAgent):
**kwargs: Any,
) -> None:
"""Upload a backup."""
expires_at = self._entry.data["token"]["expires_at"]
_LOGGER.debug(
"Starting backup upload, token expiry: %s (in %s seconds)",
expires_at,
expires_at - time(),
)
backup_filename, metadata_filename = suggested_filenames(backup)
file = FileInfo(
backup_filename,

View File

@@ -6,7 +6,6 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import timedelta
import logging
from time import time
from onedrive_personal_sdk import OneDriveClient
from onedrive_personal_sdk.const import DriveState
@@ -59,12 +58,6 @@ class OneDriveUpdateCoordinator(DataUpdateCoordinator[Drive]):
async def _async_update_data(self) -> Drive:
"""Fetch data from API endpoint."""
expires_at = self.config_entry.data["token"]["expires_at"]
_LOGGER.debug(
"Token expiry: %s (in %s seconds)",
expires_at,
expires_at - time(),
)
try:
drive = await self._client.get_drive()

View File

@@ -71,9 +71,7 @@ class OpenAITaskEntity(
chat_log: conversation.ChatLog,
) -> ai_task.GenDataTaskResult:
"""Handle a generate data task."""
await self._async_handle_chat_log(
chat_log, task.name, task.structure, max_iterations=1000
)
await self._async_handle_chat_log(chat_log, task.name, task.structure)
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
raise HomeAssistantError(

View File

@@ -481,7 +481,6 @@ class OpenAIBaseLLMEntity(Entity):
structure_name: str | None = None,
structure: vol.Schema | None = None,
force_image: bool = False,
max_iterations: int = MAX_TOOL_ITERATIONS,
) -> None:
"""Generate an answer for the chat log."""
options = self.subentry.data
@@ -633,7 +632,7 @@ class OpenAIBaseLLMEntity(Entity):
client = self.entry.runtime_data
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(max_iterations):
for _iteration in range(MAX_TOOL_ITERATIONS):
try:
stream = await client.responses.create(**model_args)

View File

@@ -12,29 +12,25 @@ from .const import DATA_COMPONENT, DOMAIN
INTENT_LIST_ADD_ITEM = "HassListAddItem"
INTENT_LIST_COMPLETE_ITEM = "HassListCompleteItem"
INTENT_LIST_REMOVE_ITEM = "HassListRemoveItem"
async def async_setup_intents(hass: HomeAssistant) -> None:
"""Set up the todo intent handlers."""
intent.async_register(hass, ListAddItemIntentHandler())
intent.async_register(hass, ListCompleteItemIntentHandler())
intent.async_register(hass, ListRemoveItemIntentHandler())
"""Set up the todo intents."""
intent.async_register(hass, ListAddItemIntent())
intent.async_register(hass, ListCompleteItemIntent())
class ListBaseIntentHandler(intent.IntentHandler):
"""Base class for toto intent handlers."""
class ListAddItemIntent(intent.IntentHandler):
"""Handle ListAddItem intents."""
intent_type = INTENT_LIST_ADD_ITEM
description = "Add item to a todo list"
slot_schema = {
vol.Required("item"): intent.non_empty_string,
vol.Required("name"): intent.non_empty_string,
}
platforms = {DOMAIN}
async def _async_do_handle(self, target_list: TodoListEntity, item: str) -> None:
"""Execute action specific to this intent handler."""
raise NotImplementedError
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent."""
hass = intent_obj.hass
@@ -63,46 +59,62 @@ class ListBaseIntentHandler(intent.IntentHandler):
f"No to-do list: {list_name}", "list_not_found"
)
# Execute specific action
await self._async_do_handle(target_list, item)
# Add to list
await target_list.async_create_todo_item(
TodoItem(summary=item, status=TodoItemStatus.NEEDS_ACTION)
)
# Build intent response
response: intent.IntentResponse = intent_obj.create_response()
response.async_set_results(
[
intent.IntentResponseTarget(
type=intent.IntentResponseTargetType.ENTITY,
name=list_name,
id=target_list.entity_id,
id=match_result.states[0].entity_id,
)
]
)
return response
class ListAddItemIntentHandler(ListBaseIntentHandler):
"""Handle ListAddItem intents."""
intent_type = INTENT_LIST_ADD_ITEM
description = "Add item to a todo list"
async def _async_do_handle(self, target_list: TodoListEntity, item: str) -> None:
"""Execute action specific to this intent handler."""
# Add to list
await target_list.async_create_todo_item(
TodoItem(summary=item, status=TodoItemStatus.NEEDS_ACTION)
)
class ListCompleteItemIntentHandler(ListBaseIntentHandler):
class ListCompleteItemIntent(intent.IntentHandler):
"""Handle ListCompleteItem intents."""
intent_type = INTENT_LIST_COMPLETE_ITEM
description = "Complete item on a todo list"
slot_schema = {
vol.Required("item"): intent.non_empty_string,
vol.Required("name"): intent.non_empty_string,
}
platforms = {DOMAIN}
async def _async_do_handle(self, target_list: TodoListEntity, item: str) -> None:
"""Execute action specific to this intent handler."""
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent."""
hass = intent_obj.hass
slots = self.async_validate_slots(intent_obj.slots)
item = slots["item"]["value"]
list_name = slots["name"]["value"]
target_list: TodoListEntity | None = None
# Find matching list
match_constraints = intent.MatchTargetsConstraints(
name=list_name, domains=[DOMAIN], assistant=intent_obj.assistant
)
match_result = intent.async_match_targets(hass, match_constraints)
if not match_result.is_match:
raise intent.MatchFailedError(
result=match_result, constraints=match_constraints
)
target_list = hass.data[DATA_COMPONENT].get_entity(
match_result.states[0].entity_id
)
if target_list is None:
raise intent.IntentHandleError(
f"No to-do list: {list_name}", "list_not_found"
)
# Find item in list
matching_item = None
@@ -127,26 +139,14 @@ class ListCompleteItemIntentHandler(ListBaseIntentHandler):
)
)
class ListRemoveItemIntentHandler(ListBaseIntentHandler):
"""Handle LisRemoveItemIntent intents."""
intent_type = INTENT_LIST_REMOVE_ITEM
description = "Remove one or more items from a todo list"
async def _async_do_handle(self, target_list: TodoListEntity, item: str) -> None:
"""Execute action specific to this intent handler."""
# Find item in list
matching_item = None
for todo_item in target_list.todo_items or ():
if item in (todo_item.uid, todo_item.summary):
matching_item = todo_item
break
if not matching_item or not matching_item.uid:
raise intent.IntentHandleError(
f"Item '{item}' not found on list", "item_not_found"
)
# Remove items
await target_list.async_delete_todo_items(uids=[matching_item.uid])
response: intent.IntentResponse = intent_obj.create_response()
response.async_set_results(
[
intent.IntentResponseTarget(
type=intent.IntentResponseTargetType.ENTITY,
name=list_name,
id=match_result.states[0].entity_id,
)
]
)
return response

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any
from pyvlx import DimmableDevice, Intensity, Light, OnOffLight
from pyvlx import Intensity, Light, OnOffLight
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.core import HomeAssistant
@@ -23,52 +23,32 @@ async def async_setup_entry(
) -> None:
"""Set up light(s) for Velux platform."""
pyvlx = config_entry.runtime_data
entities: list[VeluxOnOffLight] = []
for node in pyvlx.nodes:
if isinstance(node, Light):
entities.append(VeluxDimmableLight(node, config_entry.entry_id))
elif isinstance(node, OnOffLight):
entities.append(VeluxOnOffLight(node, config_entry.entry_id))
async_add_entities(entities)
async_add_entities(
VeluxLight(node, config_entry.entry_id)
for node in pyvlx.nodes
if isinstance(node, (Light, OnOffLight))
)
class VeluxOnOffLight(VeluxEntity, LightEntity):
"""Representation of a Velux light without brightness control."""
_attr_supported_color_modes = {ColorMode.ONOFF}
_attr_color_mode = ColorMode.ONOFF
_attr_name = None
node: DimmableDevice
@property
def is_on(self) -> bool:
"""Return true if light is on."""
return not self.node.intensity.off and self.node.intensity.known
@wrap_pyvlx_call_exceptions
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
await self.node.turn_on(wait_for_completion=True)
@wrap_pyvlx_call_exceptions
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
await self.node.turn_off(wait_for_completion=True)
class VeluxDimmableLight(VeluxOnOffLight):
"""Representation of a Velux light with brightness control."""
class VeluxLight(VeluxEntity, LightEntity):
"""Representation of a Velux light."""
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
_attr_color_mode = ColorMode.BRIGHTNESS
_attr_name = None
node: Light
@property
def brightness(self) -> int:
def brightness(self):
"""Return the current brightness."""
return int(self.node.intensity.intensity_percent * 255 / 100)
@property
def is_on(self):
"""Return true if light is on."""
return not self.node.intensity.off and self.node.intensity.known
@wrap_pyvlx_call_exceptions
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
@@ -80,3 +60,8 @@ class VeluxDimmableLight(VeluxOnOffLight):
)
else:
await self.node.turn_on(wait_for_completion=True)
@wrap_pyvlx_call_exceptions
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
await self.node.turn_off(wait_for_completion=True)

View File

@@ -3,10 +3,8 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Sequence
from collections.abc import Awaitable, Callable
from contextlib import suppress
from functools import lru_cache
from ipaddress import ip_address
import socket
from ssl import SSLContext
import sys
@@ -14,11 +12,10 @@ from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Self
import aiohttp
from aiohttp import ClientMiddlewareType, hdrs, web
from aiohttp import web
from aiohttp.hdrs import CONTENT_TYPE, USER_AGENT
from aiohttp.web_exceptions import HTTPBadGateway, HTTPGatewayTimeout
from aiohttp_asyncmdnsresolver.api import AsyncDualMDNSResolver
from yarl import URL
from homeassistant import config_entries
from homeassistant.components import zeroconf
@@ -28,7 +25,6 @@ from homeassistant.loader import bind_hass
from homeassistant.util import ssl as ssl_util
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.json import json_loads
from homeassistant.util.network import is_loopback
from .frame import warn_use
from .json import json_dumps
@@ -53,92 +49,6 @@ SERVER_SOFTWARE = (
WARN_CLOSE_MSG = "closes the Home Assistant aiohttp session"
_LOCALHOST = "localhost"
_TRAILING_LOCAL_HOST = f".{_LOCALHOST}"
class SSRFRedirectError(aiohttp.ClientError):
"""SSRF redirect protection.
Raised when a redirect targets a blocked address (loopback or unspecified).
"""
async def _ssrf_redirect_middleware(
request: aiohttp.ClientRequest,
handler: aiohttp.ClientHandlerType,
) -> aiohttp.ClientResponse:
"""Block redirects from non-loopback origins to loopback targets."""
resp = await handler(request)
# Return early if not a redirect or already loopback to allow loopback origins
connector = request.session.connector
if not (300 <= resp.status < 400) or await _async_is_blocked_host(
request.url.host, connector
):
return resp
location = resp.headers.get(hdrs.LOCATION, "")
if not location:
return resp
redirect_url = URL(location)
if not redirect_url.is_absolute():
# Relative redirects stay on the same host - always safe
return resp
host = redirect_url.host
if await _async_is_blocked_host(host, connector):
resp.close()
raise SSRFRedirectError(
f"Redirect from {request.url.host} to a blocked address"
f" is not allowed: {host}"
)
return resp
@lru_cache
def _is_ssrf_address(address: str) -> bool:
"""Check if an IP address is a potential SSRF target.
Returns True for loopback and unspecified addresses.
"""
ip = ip_address(address)
return is_loopback(ip) or ip.is_unspecified
async def _async_is_blocked_host(
host: str | None, connector: aiohttp.BaseConnector | None
) -> bool:
"""Check if a host is blocked by hostname or by resolved IP.
First does a fast sync check on the hostname string, then resolves
the hostname via the connector and checks each resolved IP address.
"""
if not host:
return False
# Strip FQDN trailing dot (RFC 1035) since yarl preserves it,
# preventing an attacker from bypassing the check with "localhost."
stripped_host = host.strip().removesuffix(".")
if stripped_host == _LOCALHOST or stripped_host.endswith(_TRAILING_LOCAL_HOST):
return True
with suppress(ValueError):
return _is_ssrf_address(host)
if not isinstance(connector, HomeAssistantTCPConnector):
return False
try:
results = await connector.async_resolve_host(host)
except Exception: # noqa: BLE001
return False
return any(_is_ssrf_address(result["host"]) for result in results)
#
# The default connection limit of 100 meant that you could only have
# 100 concurrent connections.
@@ -281,16 +191,10 @@ def _async_create_clientsession(
**kwargs: Any,
) -> aiohttp.ClientSession:
"""Create a new ClientSession with kwargs, i.e. for cookies."""
middlewares: Sequence[ClientMiddlewareType] = (
_ssrf_redirect_middleware,
*kwargs.pop("middlewares", ()),
)
clientsession = aiohttp.ClientSession(
connector=_async_get_connector(hass, verify_ssl, family, ssl_cipher),
json_serialize=json_dumps,
response_class=HassClientResponse,
middlewares=middlewares,
**kwargs,
)
# Prevent packages accidentally overriding our default headers
@@ -439,10 +343,6 @@ class HomeAssistantTCPConnector(aiohttp.TCPConnector):
# abort transport after 60 seconds (cleanup broken connections)
_cleanup_closed_period = 60.0
async def async_resolve_host(self, host: str) -> list[aiohttp.abc.ResolveResult]:
"""Resolve a host to a list of addresses."""
return await self._resolve_host(host, 0)
@callback
def _async_get_connector(

View File

@@ -233,5 +233,5 @@ async def async_get_installed_packages() -> list[InstalledPackage]:
try:
return cast(list[InstalledPackage], json_loads_array(stdout.decode()))
except (*JSON_DECODE_EXCEPTIONS, ValueError):
except (*JSON_DECODE_EXCEPTIONS, ValueError): # fmt: off
return []

View File

@@ -675,7 +675,7 @@ exclude_lines = [
]
[tool.ruff]
required-version = ">=0.15.1"
required-version = ">=0.15.0"
[tool.ruff.lint]
select = [

View File

@@ -1,5 +1,5 @@
# Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit
codespell==2.4.1
ruff==0.15.1
ruff==0.15.0
yamllint==1.37.1

View File

@@ -26,7 +26,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.9.26,source=/uv,target=/bin/uv \
-r /usr/src/homeassistant/requirements.txt \
pipdeptree==2.26.1 \
tqdm==4.67.1 \
ruff==0.15.1
ruff==0.15.0
LABEL "name"="hassfest"
LABEL "maintainer"="Home Assistant <hello@home-assistant.io>"

View File

@@ -439,7 +439,7 @@ async def test_todo_add_item_fr(
with (
patch.object(hass.config, "language", "fr"),
patch(
"homeassistant.components.todo.intent.ListAddItemIntentHandler.async_handle",
"homeassistant.components.todo.intent.ListAddItemIntent.async_handle",
return_value=intent.IntentResponse(hass.config.language),
) as mock_handle,
):

View File

@@ -108,7 +108,7 @@ def config_entry_data(
{"config_entry": "incorrect entry"},
{"incl_vat": True},
ServiceValidationError,
"config entry with ID incorrect entry was not found",
"service_config_entry_not_found",
),
(
{"config_entry": True},

View File

@@ -155,21 +155,21 @@ async def test_automatic_offset(hass: HomeAssistant, fritz: Mock) -> None:
async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None:
"""Test update with error."""
device = FritzDeviceClimateMock()
fritz().update_devices.side_effect = ["", HTTPError("Boom"), ""]
fritz().update_devices.side_effect = HTTPError("Boom")
entry = await setup_config_entry(
hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz
)
assert entry.state is ConfigEntryState.LOADED
assert entry.state is ConfigEntryState.SETUP_RETRY
assert fritz().update_devices.call_count == 1
assert fritz().login.call_count == 1
assert fritz().update_devices.call_count == 2
assert fritz().login.call_count == 2
next_update = dt_util.utcnow() + timedelta(seconds=35)
next_update = dt_util.utcnow() + timedelta(seconds=200)
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done(wait_background_tasks=True)
assert fritz().update_devices.call_count == 3
assert fritz().login.call_count == 2
assert fritz().update_devices.call_count == 4
assert fritz().login.call_count == 4
@pytest.mark.parametrize(

View File

@@ -40,19 +40,14 @@ async def test_coordinator_update_after_reboot(
unique_id="any",
)
entry.add_to_hass(hass)
fritz().update_devices.side_effect = ["", HTTPError()]
fritz().update_devices.side_effect = [HTTPError(), ""]
assert await hass.config_entries.async_setup(entry.entry_id)
assert fritz().update_devices.call_count == 1
assert fritz().update_devices.call_count == 2
assert fritz().update_templates.call_count == 1
assert fritz().get_devices.call_count == 1
assert fritz().get_templates.call_count == 1
assert fritz().login.call_count == 1
async_fire_time_changed(hass, utcnow() + timedelta(seconds=35))
await hass.async_block_till_done(wait_background_tasks=True)
assert entry.state is ConfigEntryState.SETUP_RETRY
assert fritz().login.call_count == 2
async def test_coordinator_update_after_password_change(
@@ -65,10 +60,14 @@ async def test_coordinator_update_after_password_change(
unique_id="any",
)
entry.add_to_hass(hass)
fritz().login.side_effect = [LoginError("some_user")]
fritz().update_devices.side_effect = HTTPError()
fritz().login.side_effect = ["", LoginError("some_user")]
assert not await hass.config_entries.async_setup(entry.entry_id)
assert entry.state is ConfigEntryState.SETUP_ERROR
assert fritz().update_devices.call_count == 1
assert fritz().get_devices.call_count == 0
assert fritz().get_templates.call_count == 0
assert fritz().login.call_count == 2
async def test_coordinator_update_when_unreachable(
@@ -81,10 +80,9 @@ async def test_coordinator_update_when_unreachable(
unique_id="any",
)
entry.add_to_hass(hass)
fritz().update_devices.side_effect = [ConnectionError()]
fritz().update_devices.side_effect = [ConnectionError(), ""]
assert not await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done(wait_background_tasks=True)
assert entry.state is ConfigEntryState.SETUP_RETRY

View File

@@ -248,21 +248,20 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None:
device.get_colors.return_value = {
"Red": [("100", "70", "10"), ("100", "50", "10"), ("100", "30", "10")]
}
fritz().update_devices.side_effect = ["", HTTPError("Boom"), ""]
fritz().update_devices.side_effect = HTTPError("Boom")
entry = await setup_config_entry(
hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], device=device, fritz=fritz
hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz
)
assert entry.state is ConfigEntryState.LOADED
assert entry.state is ConfigEntryState.SETUP_RETRY
assert fritz().update_devices.call_count == 2
assert fritz().login.call_count == 2
assert fritz().update_devices.call_count == 1
assert fritz().login.call_count == 1
next_update = dt_util.utcnow() + timedelta(seconds=35)
next_update = dt_util.utcnow() + timedelta(seconds=200)
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done(wait_background_tasks=True)
assert fritz().update_devices.call_count == 3
assert fritz().login.call_count == 2
assert fritz().update_devices.call_count == 4
assert fritz().login.call_count == 4
async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None:

View File

@@ -80,21 +80,20 @@ async def test_update(hass: HomeAssistant, fritz: Mock) -> None:
async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None:
"""Test update with error."""
device = FritzDeviceSensorMock()
fritz().update_devices.side_effect = ["", HTTPError("Boom"), ""]
fritz().update_devices.side_effect = HTTPError("Boom")
entry = await setup_config_entry(
hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], device=device, fritz=fritz
hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz
)
assert entry.state is ConfigEntryState.LOADED
assert entry.state is ConfigEntryState.SETUP_RETRY
assert fritz().update_devices.call_count == 2
assert fritz().login.call_count == 2
assert fritz().update_devices.call_count == 1
assert fritz().login.call_count == 1
next_update = dt_util.utcnow() + timedelta(seconds=35)
next_update = dt_util.utcnow() + timedelta(seconds=200)
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done(wait_background_tasks=True)
assert fritz().update_devices.call_count == 3
assert fritz().login.call_count == 2
assert fritz().update_devices.call_count == 4
assert fritz().login.call_count == 4
async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None:

View File

@@ -136,21 +136,20 @@ async def test_update(hass: HomeAssistant, fritz: Mock) -> None:
async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None:
"""Test update with error."""
device = FritzDeviceSwitchMock()
fritz().update_devices.side_effect = ["", HTTPError("Boom"), ""]
fritz().update_devices.side_effect = HTTPError("Boom")
entry = await setup_config_entry(
hass, MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], device=device, fritz=fritz
)
assert entry.state is ConfigEntryState.LOADED
assert entry.state is ConfigEntryState.SETUP_RETRY
assert fritz().update_devices.call_count == 2
assert fritz().login.call_count == 2
assert fritz().update_devices.call_count == 1
assert fritz().login.call_count == 1
next_update = dt_util.utcnow() + timedelta(seconds=35)
next_update = dt_util.utcnow() + timedelta(seconds=200)
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done(wait_background_tasks=True)
assert fritz().update_devices.call_count == 3
assert fritz().login.call_count == 2
assert fritz().update_devices.call_count == 4
assert fritz().login.call_count == 4
async def test_assume_device_unavailable(hass: HomeAssistant, fritz: Mock) -> None:

View File

@@ -0,0 +1,16 @@
{
"wifi_ssid": "My Wi-Fi",
"wifi_strength": 92,
"total_power_import_t1_kwh": 0.003,
"total_power_export_t1_kwh": 0.0,
"active_power_w": 0.0,
"active_power_l1_w": 0.0,
"active_voltage_v": 228.472,
"active_current_a": 0.273,
"active_apparent_current_a": 0.0,
"active_reactive_current_a": 0.0,
"active_apparent_power_va": 9.0,
"active_reactive_power_var": -9.0,
"active_power_factor": 0.611,
"active_frequency_hz": 50
}

View File

@@ -0,0 +1,7 @@
{
"product_type": "HWE-KWH1",
"product_name": "kWh meter",
"serial": "5c2fafabcdef",
"firmware_version": "5.0103",
"api_version": "v2"
}

View File

@@ -0,0 +1,3 @@
{
"cloud_enabled": true
}

View File

@@ -0,0 +1,16 @@
{
"wifi_ssid": "My Wi-Fi",
"wifi_strength": 100,
"total_power_import_kwh": 0.003,
"total_power_import_t1_kwh": 0.003,
"total_power_export_kwh": 0.0,
"total_power_export_t1_kwh": 0.0,
"active_power_w": 0.0,
"active_power_l1_w": 0.0,
"active_voltage_v": 231.539,
"active_current_a": 0.0,
"active_reactive_power_var": 0.0,
"active_apparent_power_va": 0.0,
"active_power_factor": 0.0,
"active_frequency_hz": 50.005
}

View File

@@ -0,0 +1,7 @@
{
"product_type": "HWE-SKT",
"product_name": "Energy Socket",
"serial": "5c2fafabcdef",
"firmware_version": "4.07",
"api_version": "v1"
}

View File

@@ -0,0 +1,5 @@
{
"power_on": true,
"switch_lock": false,
"brightness": 255
}

View File

@@ -0,0 +1,3 @@
{
"cloud_enabled": true
}

View File

@@ -92,56 +92,7 @@
'version': 1,
})
# ---
# name: test_discovery_flow_works
FlowResultSnapshot({
'context': dict({
'confirm_only': True,
'dismiss_protected': True,
'source': 'zeroconf',
'title_placeholders': dict({
'name': 'Energy Socket (5c2fafabcdef)',
}),
'unique_id': 'HWE-SKT_5c2fafabcdef',
}),
'data': dict({
'ip_address': '127.0.0.1',
}),
'description': None,
'description_placeholders': None,
'flow_id': <ANY>,
'handler': 'homewizard',
'minor_version': 1,
'options': dict({
}),
'result': ConfigEntrySnapshot({
'data': dict({
'ip_address': '127.0.0.1',
}),
'disabled_by': None,
'discovery_keys': dict({
}),
'domain': 'homewizard',
'entry_id': <ANY>,
'minor_version': 1,
'options': dict({
}),
'pref_disable_new_entities': False,
'pref_disable_polling': False,
'source': 'zeroconf',
'subentries': list([
]),
'title': 'Energy Socket',
'unique_id': 'HWE-SKT_5c2fafabcdef',
'version': 1,
}),
'subentries': tuple(
),
'title': 'Energy Socket',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1,
})
# ---
# name: test_manual_flow_works
# name: test_manual_flow_works[HWE-P1]
FlowResultSnapshot({
'context': dict({
'source': 'user',
@@ -185,3 +136,238 @@
'version': 1,
})
# ---
# name: test_manual_flow_works_device_energy_monitoring[consumption-HWE-SKT-21]
FlowResultSnapshot({
'context': dict({
'source': 'user',
'unique_id': 'HWE-SKT_5c2fafabcdef',
}),
'data': dict({
'ip_address': '2.2.2.2',
'usage': 'consumption',
}),
'description': None,
'description_placeholders': None,
'flow_id': <ANY>,
'handler': 'homewizard',
'minor_version': 1,
'options': dict({
}),
'result': ConfigEntrySnapshot({
'data': dict({
'ip_address': '2.2.2.2',
'usage': 'consumption',
}),
'disabled_by': None,
'discovery_keys': dict({
}),
'domain': 'homewizard',
'entry_id': <ANY>,
'minor_version': 1,
'options': dict({
}),
'pref_disable_new_entities': False,
'pref_disable_polling': False,
'source': 'user',
'subentries': list([
]),
'title': 'Energy Socket',
'unique_id': 'HWE-SKT_5c2fafabcdef',
'version': 1,
}),
'subentries': tuple(
),
'title': 'Energy Socket',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1,
})
# ---
# name: test_manual_flow_works_device_energy_monitoring[generation-HWE-SKT-21]
FlowResultSnapshot({
'context': dict({
'source': 'user',
'unique_id': 'HWE-SKT_5c2fafabcdef',
}),
'data': dict({
'ip_address': '2.2.2.2',
'usage': 'generation',
}),
'description': None,
'description_placeholders': None,
'flow_id': <ANY>,
'handler': 'homewizard',
'minor_version': 1,
'options': dict({
}),
'result': ConfigEntrySnapshot({
'data': dict({
'ip_address': '2.2.2.2',
'usage': 'generation',
}),
'disabled_by': None,
'discovery_keys': dict({
}),
'domain': 'homewizard',
'entry_id': <ANY>,
'minor_version': 1,
'options': dict({
}),
'pref_disable_new_entities': False,
'pref_disable_polling': False,
'source': 'user',
'subentries': list([
]),
'title': 'Energy Socket',
'unique_id': 'HWE-SKT_5c2fafabcdef',
'version': 1,
}),
'subentries': tuple(
),
'title': 'Energy Socket',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1,
})
# ---
# name: test_power_monitoring_discovery_flow_works[consumption]
FlowResultSnapshot({
'context': dict({
'dismiss_protected': True,
'source': 'zeroconf',
'unique_id': 'HWE-SKT_5c2fafabcdef',
}),
'data': dict({
'ip_address': '127.0.0.1',
'usage': 'consumption',
}),
'description': None,
'description_placeholders': None,
'flow_id': <ANY>,
'handler': 'homewizard',
'minor_version': 1,
'options': dict({
}),
'result': ConfigEntrySnapshot({
'data': dict({
'ip_address': '127.0.0.1',
'usage': 'consumption',
}),
'disabled_by': None,
'discovery_keys': dict({
}),
'domain': 'homewizard',
'entry_id': <ANY>,
'minor_version': 1,
'options': dict({
}),
'pref_disable_new_entities': False,
'pref_disable_polling': False,
'source': 'zeroconf',
'subentries': list([
]),
'title': 'Energy Socket',
'unique_id': 'HWE-SKT_5c2fafabcdef',
'version': 1,
}),
'subentries': tuple(
),
'title': 'Energy Socket',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1,
})
# ---
# name: test_power_monitoring_discovery_flow_works[generation]
FlowResultSnapshot({
'context': dict({
'dismiss_protected': True,
'source': 'zeroconf',
'unique_id': 'HWE-SKT_5c2fafabcdef',
}),
'data': dict({
'ip_address': '127.0.0.1',
'usage': 'generation',
}),
'description': None,
'description_placeholders': None,
'flow_id': <ANY>,
'handler': 'homewizard',
'minor_version': 1,
'options': dict({
}),
'result': ConfigEntrySnapshot({
'data': dict({
'ip_address': '127.0.0.1',
'usage': 'generation',
}),
'disabled_by': None,
'discovery_keys': dict({
}),
'domain': 'homewizard',
'entry_id': <ANY>,
'minor_version': 1,
'options': dict({
}),
'pref_disable_new_entities': False,
'pref_disable_polling': False,
'source': 'zeroconf',
'subentries': list([
]),
'title': 'Energy Socket',
'unique_id': 'HWE-SKT_5c2fafabcdef',
'version': 1,
}),
'subentries': tuple(
),
'title': 'Energy Socket',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1,
})
# ---
# name: test_water_monitoring_discovery_flow_works
FlowResultSnapshot({
'context': dict({
'confirm_only': True,
'dismiss_protected': True,
'source': 'zeroconf',
'title_placeholders': dict({
'name': 'Watermeter',
}),
'unique_id': 'HWE-WTR_3c39efabcdef',
}),
'data': dict({
'ip_address': '127.0.0.1',
}),
'description': None,
'description_placeholders': None,
'flow_id': <ANY>,
'handler': 'homewizard',
'minor_version': 1,
'options': dict({
}),
'result': ConfigEntrySnapshot({
'data': dict({
'ip_address': '127.0.0.1',
}),
'disabled_by': None,
'discovery_keys': dict({
}),
'domain': 'homewizard',
'entry_id': <ANY>,
'minor_version': 1,
'options': dict({
}),
'pref_disable_new_entities': False,
'pref_disable_polling': False,
'source': 'zeroconf',
'subentries': list([
]),
'title': 'Watermeter',
'unique_id': 'HWE-WTR_3c39efabcdef',
'version': 1,
}),
'subentries': tuple(
),
'title': 'Watermeter',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1,
})
# ---

View File

@@ -24,6 +24,7 @@ from tests.common import MockConfigEntry
@pytest.mark.usefixtures("mock_setup_entry")
@pytest.mark.parametrize(("device_fixture"), ["HWE-P1"])
async def test_manual_flow_works(
hass: HomeAssistant,
mock_homewizardenergy: MagicMock,
@@ -51,12 +52,50 @@ async def test_manual_flow_works(
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_homewizardenergy", "mock_setup_entry")
async def test_discovery_flow_works(
@pytest.mark.usefixtures("mock_setup_entry")
@pytest.mark.parametrize(("device_fixture"), ["HWE-SKT-21"])
@pytest.mark.parametrize(("usage"), ["consumption", "generation"])
async def test_manual_flow_works_device_energy_monitoring(
hass: HomeAssistant,
mock_homewizardenergy: MagicMock,
mock_setup_entry: AsyncMock,
snapshot: SnapshotAssertion,
usage: str,
) -> None:
"""Test discovery setup flow works."""
"""Test config flow accepts user configuration for energy plug."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "usage"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"usage": usage}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result == snapshot
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_homewizardenergy.close.mock_calls) == 1
assert len(mock_homewizardenergy.device.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_homewizardenergy", "mock_setup_entry")
@pytest.mark.parametrize("usage", ["consumption", "generation"])
async def test_power_monitoring_discovery_flow_works(
hass: HomeAssistant, snapshot: SnapshotAssertion, usage: str
) -> None:
"""Test discovery energy monitoring setup flow works."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
@@ -77,6 +116,42 @@ async def test_discovery_flow_works(
),
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "usage"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"usage": usage}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result == snapshot
@pytest.mark.usefixtures("mock_homewizardenergy", "mock_setup_entry")
async def test_water_monitoring_discovery_flow_works(
hass: HomeAssistant, snapshot: SnapshotAssertion
) -> None:
"""Test discovery energy monitoring setup flow works."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
port=80,
hostname="watermeter-ddeeff.local.",
type="",
name="",
properties={
"api_enabled": "1",
"path": "/api/v1",
"product_name": "Watermeter",
"product_type": "HWE-WTR",
"serial": "3c39efabcdef",
},
),
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
@@ -620,7 +695,7 @@ async def test_reconfigure_cannot_connect(
@pytest.mark.usefixtures("mock_setup_entry")
@pytest.mark.parametrize(("device_fixture"), ["HWE-P1", "HWE-KWH1"])
@pytest.mark.parametrize(("device_fixture"), ["HWE-P1"])
async def test_manual_flow_works_with_v2_api_support(
hass: HomeAssistant,
mock_homewizardenergy_v2: MagicMock,
@@ -652,7 +727,70 @@ async def test_manual_flow_works_with_v2_api_support(
mock_homewizardenergy_v2.device.side_effect = None
mock_homewizardenergy_v2.get_token.side_effect = None
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
with patch(
"homeassistant.components.homewizard.config_flow.has_v2_api", return_value=True
):
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.CREATE_ENTRY
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_setup_entry")
@pytest.mark.parametrize(("device_fixture"), ["HWE-KWH1"])
async def test_manual_flow_energy_monitoring_works_with_v2_api_support(
hass: HomeAssistant,
mock_homewizardenergy_v2: MagicMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test config flow accepts user configuration for energy monitoring.
This should trigger authorization when v2 support is detected.
It should ask for usage if a energy monitoring device is configured.
"""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
# Simulate v2 support but not authorized
mock_homewizardenergy_v2.device.side_effect = UnauthorizedError
mock_homewizardenergy_v2.get_token.side_effect = DisabledError
with (
patch(
"homeassistant.components.homewizard.config_flow.has_v2_api",
return_value=True,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "authorize"
# Simulate user authorizing
mock_homewizardenergy_v2.device.side_effect = None
mock_homewizardenergy_v2.get_token.side_effect = None
with (
patch(
"homeassistant.components.homewizard.config_flow.has_v2_api",
return_value=True,
),
):
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "usage"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"usage": "generation"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
@@ -700,7 +838,16 @@ async def test_manual_flow_detects_failed_user_authorization(
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.CREATE_ENTRY
# Energy monitoring devices with an with configurable usage have an extra flow step
assert (
result["type"] is FlowResultType.CREATE_ENTRY and result["data"][CONF_TOKEN]
) or (result["type"] is FlowResultType.FORM and result["step_id"] == "usage")
if result["type"] is FlowResultType.FORM and result["step_id"] == "usage":
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"usage": "generation"}
)
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup_entry.mock_calls) == 1
@@ -830,10 +977,12 @@ async def test_discovery_with_v2_api_ask_authorization(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "authorize"
mock_homewizardenergy_v2.device.side_effect = None
mock_homewizardenergy_v2.get_token.side_effect = None
mock_homewizardenergy_v2.get_token.return_value = "cool_token"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_TOKEN] == "cool_token"
# Energy monitoring devices with an with configurable usage have an extra flow step
assert (
result["type"] is FlowResultType.CREATE_ENTRY and result["data"][CONF_TOKEN]
) or (result["type"] is FlowResultType.FORM and result["step_id"] == "usage")

View File

@@ -1,5 +1,6 @@
"""Tests for the homewizard component."""
from collections.abc import Iterable
from datetime import timedelta
from unittest.mock import MagicMock, patch
import weakref
@@ -11,8 +12,14 @@ import pytest
from homeassistant.components.homewizard import get_main_device
from homeassistant.components.homewizard.const import DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.const import CONF_IP_ADDRESS
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.helpers.entity_platform import EntityRegistry
from tests.common import MockConfigEntry, async_fire_time_changed
@@ -254,3 +261,203 @@ async def test_disablederror_reloads_integration(
flow = flows[0]
assert flow.get("step_id") == "reauth_enable_api"
assert flow.get("handler") == DOMAIN
@pytest.mark.usefixtures("mock_homewizardenergy")
@pytest.mark.parametrize(
("device_fixture", "mock_config_entry", "enabled", "disabled"),
[
(
"HWE-SKT-21-initial",
MockConfigEntry(
title="Device",
domain=DOMAIN,
data={
CONF_IP_ADDRESS: "127.0.0.1",
"usage": "consumption",
},
unique_id="HWE-SKT_5c2fafabcdef",
),
("sensor.device_power",),
("sensor.device_production_power",),
),
(
"HWE-SKT-21-initial",
MockConfigEntry(
title="Device",
domain=DOMAIN,
data={
CONF_IP_ADDRESS: "127.0.0.1",
"usage": "generation",
},
unique_id="HWE-SKT_5c2fafabcdef",
),
# we explicitly indicated that the device was monitoring
# generated energy, so we ignore power sensor to avoid confusion
("sensor.device_production_power",),
("sensor.device_power",),
),
(
"HWE-SKT-21",
MockConfigEntry(
title="Device",
domain=DOMAIN,
data={
CONF_IP_ADDRESS: "127.0.0.1",
"usage": "consumption",
},
unique_id="HWE-SKT_5c2fafabcdef",
),
# device has a non zero export, so both sensors are enabled
(
"sensor.device_power",
"sensor.device_production_power",
),
(),
),
(
"HWE-SKT-21",
MockConfigEntry(
title="Device",
domain=DOMAIN,
data={
CONF_IP_ADDRESS: "127.0.0.1",
"usage": "generation",
},
unique_id="HWE-SKT_5c2fafabcdef",
),
# we explicitly indicated that the device was monitoring
# generated energy, so we ignore power sensor to avoid confusion
("sensor.device_production_power",),
("sensor.device_power",),
),
],
ids=[
"consumption_intital",
"generation_initial",
"consumption_used",
"generation_used",
],
)
async def test_setup_device_energy_monitoring_v1(
hass: HomeAssistant,
entity_registry: EntityRegistry,
mock_config_entry: MockConfigEntry,
enabled: Iterable[str],
disabled: Iterable[str],
) -> None:
"""Test correct entities are enabled by default."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
for enabled_item in enabled:
assert (entry := entity_registry.async_get(enabled_item))
assert not entry.disabled
for disabled_item in disabled:
assert (entry := entity_registry.async_get(disabled_item))
assert entry.disabled
assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
@pytest.mark.usefixtures("mock_homewizardenergy")
@pytest.mark.parametrize(
("device_fixture", "mock_config_entry", "enabled", "disabled"),
[
(
"HWE-KWH1-initial",
MockConfigEntry(
title="Device",
domain=DOMAIN,
data={
CONF_IP_ADDRESS: "127.0.0.1",
"usage": "consumption",
},
unique_id="HWE-KWH1_5c2fafabcdef",
),
("sensor.device_power",),
("sensor.device_production_power",),
),
(
"HWE-KWH1-initial",
MockConfigEntry(
title="Device",
domain=DOMAIN,
data={
CONF_IP_ADDRESS: "127.0.0.1",
"usage": "generation",
},
unique_id="HWE-KWH1_5c2fafabcdef",
),
# we explicitly indicated that the device was monitoring
# generated energy, so we ignore power sensor to avoid confusion
("sensor.device_production_power",),
("sensor.device_power",),
),
(
"HWE-KWH1",
MockConfigEntry(
title="Device",
domain=DOMAIN,
data={
CONF_IP_ADDRESS: "127.0.0.1",
"usage": "consumption",
},
unique_id="HWE-KWH1_5c2fafabcdef",
),
# device has a non zero export, so both sensors are enabled
(
"sensor.device_power",
"sensor.device_production_power",
),
(),
),
(
"HWE-KWH1",
MockConfigEntry(
title="Device",
domain=DOMAIN,
data={
CONF_IP_ADDRESS: "127.0.0.1",
"usage": "generation",
},
unique_id="HWE-KWH1_5c2fafabcdef",
),
# we explicitly indicated that the device was monitoring
# generated energy, so we ignore power sensor to avoid confusion
("sensor.device_production_power",),
("sensor.device_power",),
),
],
ids=[
"consumption_intital",
"generation_initial",
"consumption_used",
"generation_used",
],
)
async def test_setup_device_energy_monitoring_v2(
hass: HomeAssistant,
entity_registry: EntityRegistry,
mock_config_entry: MockConfigEntry,
enabled: Iterable[str],
disabled: Iterable[str],
) -> None:
"""Test correct entities are enabled by default."""
mock_config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.homewizard.config_flow.has_v2_api", return_value=True
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done(wait_background_tasks=True)
for enabled_item in enabled:
assert (entry := entity_registry.async_get(enabled_item))
assert not entry.disabled
for disabled_item in disabled:
assert (entry := entity_registry.async_get(disabled_item))
assert entry.disabled
assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION

View File

@@ -4,8 +4,7 @@ import re
from unittest.mock import AsyncMock, Mock, patch
import httpx
from mcp import McpError
from mcp.types import CallToolResult, ErrorData, ListToolsResult, TextContent, Tool
from mcp.types import CallToolResult, ListToolsResult, TextContent, Tool
import pytest
import voluptuous as vol
@@ -137,44 +136,30 @@ async def test_mcp_server_sse_transport_failure(
"Connection error", [httpx.ConnectError("Connection failed")]
)
await hass.config_entries.async_setup(config_entry.entry_id)
assert config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.parametrize(
("side_effect"),
[
(
ExceptionGroup(
"Method not allowed",
[
httpx.HTTPStatusError(
"Method not allowed",
request=None,
response=httpx.Response(405),
)
],
),
),
(
ExceptionGroup(
"Some exception group",
[McpError(ErrorData(code=500, message="Session terminated"))],
)
),
],
)
async def test_mcp_client_fallback_to_sse_success(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_http_streamable_client: AsyncMock,
mock_sse_client: AsyncMock,
mock_mcp_client: Mock,
side_effect: Exception,
) -> None:
"""Test mcp_client falls back to SSE on some errors.
"""Test mcp_client falls back to SSE on method not allowed error.
This exercises the backwards compatibility part of the MCP Transport
specification.
"""
mock_http_streamable_client.side_effect = side_effect
http_405 = httpx.HTTPStatusError(
"Method not allowed",
request=None, # type: ignore[arg-type]
response=httpx.Response(405),
)
mock_http_streamable_client.side_effect = ExceptionGroup(
"Method not allowed", [http_405]
)
# Setup mocks for SSE fallback
mock_sse_client.return_value.__aenter__.return_value = ("read", "write")

View File

@@ -174,14 +174,13 @@ async def test_climate_fan(
async_fire_time_changed(hass)
await hass.async_block_till_done()
with pytest.raises(HomeAssistantError) as err:
with pytest.raises(HomeAssistantError, match="service_not_supported"):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_FAN_MODE,
{ATTR_ENTITY_ID: state.entity_id, ATTR_FAN_MODE: "low"},
blocking=True,
)
assert err.value.translation_key == "service_not_supported"
state = hass.states.get("climate.hallway")
assert "fan_mode" not in state.attributes
@@ -256,14 +255,13 @@ async def test_climate_swing(
async_fire_time_changed(hass)
await hass.async_block_till_done()
with pytest.raises(HomeAssistantError) as err:
with pytest.raises(HomeAssistantError, match="service_not_supported"):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_SWING_MODE,
{ATTR_ENTITY_ID: state.entity_id, ATTR_SWING_MODE: "fixedtop"},
blocking=True,
)
assert err.value.translation_key == "service_not_supported"
state = hass.states.get("climate.hallway")
assert "swing_mode" not in state.attributes
@@ -343,7 +341,7 @@ async def test_climate_horizontal_swing(
async_fire_time_changed(hass)
await hass.async_block_till_done()
with pytest.raises(HomeAssistantError) as err:
with pytest.raises(HomeAssistantError, match="service_not_supported"):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_SWING_HORIZONTAL_MODE,
@@ -353,7 +351,6 @@ async def test_climate_horizontal_swing(
},
blocking=True,
)
assert err.value.translation_key == "service_not_supported"
state = hass.states.get("climate.hallway")
assert "swing_horizontal_mode" not in state.attributes
@@ -468,14 +465,13 @@ async def test_climate_temperatures(
async_fire_time_changed(hass)
await hass.async_block_till_done()
with pytest.raises(HomeAssistantError) as err:
with pytest.raises(HomeAssistantError, match="service_not_supported"):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: state.entity_id, ATTR_TEMPERATURE: 20},
blocking=True,
)
assert err.value.translation_key == "service_not_supported"
state = hass.states.get("climate.hallway")
assert "temperature" not in state.attributes

View File

@@ -290,80 +290,3 @@ async def test_complete_item_intent_ha_errors(
{ATTR_ITEM: {"value": "wine"}, ATTR_NAME: {"value": "List 1"}},
assistant=conversation.DOMAIN,
)
async def test_remove_item_intent(
hass: HomeAssistant,
) -> None:
"""Test the remove item intent."""
entity1 = MockTodoListEntity(
[
TodoItem(summary="beer", uid="1", status=TodoItemStatus.NEEDS_ACTION),
TodoItem(summary="wine", uid="2", status=TodoItemStatus.NEEDS_ACTION),
TodoItem(summary="beer", uid="3", status=TodoItemStatus.COMPLETED),
]
)
entity1._attr_name = "List 1"
entity1.entity_id = "todo.list_1"
# Add entities to hass
config_entry = await create_mock_platform(hass, [entity1])
assert config_entry.state is ConfigEntryState.LOADED
assert len(entity1.items) == 3
# Remove item
async_mock_service(hass, DOMAIN, todo_intent.INTENT_LIST_REMOVE_ITEM)
response = await intent.async_handle(
hass,
DOMAIN,
todo_intent.INTENT_LIST_REMOVE_ITEM,
{ATTR_ITEM: {"value": "beer"}, ATTR_NAME: {"value": "list 1"}},
assistant=conversation.DOMAIN,
)
assert response.response_type == intent.IntentResponseType.ACTION_DONE
# only the first matching item has been removed
assert len(entity1.items) == 2
assert entity1.items[0].uid == "2"
assert entity1.items[1].uid == "3"
async def test_remove_item_intent_errors(
hass: HomeAssistant,
test_entity: TodoListEntity,
) -> None:
"""Test errors with the remove item intent."""
entity1 = MockTodoListEntity(
[
TodoItem(summary="beer", uid="1", status=TodoItemStatus.COMPLETED),
]
)
entity1._attr_name = "List 1"
entity1.entity_id = "todo.list_1"
# Add entities to hass
await create_mock_platform(hass, [entity1])
# Try to remove item in list that does not exist
with pytest.raises(intent.MatchFailedError):
await intent.async_handle(
hass,
"test",
todo_intent.INTENT_LIST_REMOVE_ITEM,
{
ATTR_ITEM: {"value": "wine"},
ATTR_NAME: {"value": "This list does not exist"},
},
assistant=conversation.DOMAIN,
)
# Try to remove item that does not exist
with pytest.raises(intent.IntentHandleError):
await intent.async_handle(
hass,
"test",
todo_intent.INTENT_LIST_REMOVE_ITEM,
{ATTR_ITEM: {"value": "bread"}, ATTR_NAME: {"value": "list 1"}},
assistant=conversation.DOMAIN,
)

View File

@@ -37,7 +37,7 @@ async def test_service_config_entry_not_loaded_state(
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
with pytest.raises(ServiceValidationError) as err:
with pytest.raises(ServiceValidationError, match="service_not_found"):
await hass.services.async_call(
DOMAIN,
SERVICE_ADD_TORRENT,
@@ -47,7 +47,6 @@ async def test_service_config_entry_not_loaded_state(
},
blocking=True,
)
assert err.value.translation_key == "service_not_found"
async def test_service_integration_not_found(

View File

@@ -65,7 +65,7 @@
'area_id': None,
'capabilities': dict({
'supported_color_modes': list([
<ColorMode.ONOFF: 'onoff'>,
<ColorMode.BRIGHTNESS: 'brightness'>,
]),
}),
'config_entry_id': <ANY>,
@@ -101,10 +101,11 @@
# name: test_light_setup[mock_onoff_light][light.test_on_off_light-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'brightness': None,
'color_mode': None,
'friendly_name': 'Test On Off Light',
'supported_color_modes': list([
<ColorMode.ONOFF: 'onoff'>,
<ColorMode.BRIGHTNESS: 'brightness'>,
]),
'supported_features': <LightEntityFeature: 0>,
}),

View File

@@ -1,6 +1,6 @@
"""Test Velux light entities."""
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import AsyncMock
import pytest
@@ -10,7 +10,7 @@ from homeassistant.components.light import (
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
)
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -107,14 +107,14 @@ async def test_entity_availability(
await update_callback_entity(hass, mock_light)
state = hass.states.get(entity_id)
assert state is not None
assert state.state != STATE_UNAVAILABLE
assert state.state != "unavailable"
# Simulate disconnection
mock_light.pyvlx.get_connected.return_value = False
await update_callback_entity(hass, mock_light)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_UNAVAILABLE
assert state.state == "unavailable"
assert caplog.text.count(f"Entity {entity_id} is unavailable") == 1
# Simulate disconnection, check we don't log again
@@ -122,7 +122,7 @@ async def test_entity_availability(
await update_callback_entity(hass, mock_light)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_UNAVAILABLE
assert state.state == "unavailable"
assert caplog.text.count(f"Entity {entity_id} is unavailable") == 1
# Simulate reconnection
@@ -130,7 +130,7 @@ async def test_entity_availability(
await update_callback_entity(hass, mock_light)
state = hass.states.get(entity_id)
assert state is not None
assert state.state != STATE_UNAVAILABLE
assert state.state != "unavailable"
assert caplog.text.count(f"Entity {entity_id} is back online") == 1
# Simulate reconnection, check we don't log again
@@ -138,7 +138,7 @@ async def test_entity_availability(
await update_callback_entity(hass, mock_light)
state = hass.states.get(entity_id)
assert state is not None
assert state.state != STATE_UNAVAILABLE
assert state.state != "unavailable"
assert caplog.text.count(f"Entity {entity_id} is back online") == 1
@@ -160,15 +160,15 @@ async def test_light_brightness_and_is_on(
state = hass.states.get(entity_id)
assert state is not None
# brightness = int(20 * 255 / 100) = int(51)
assert state.attributes.get(ATTR_BRIGHTNESS) == 51
assert state.state == STATE_ON
assert state.attributes.get("brightness") == 51
assert state.state == "on"
# Mark as off
mock_light.intensity.off = True
await update_callback_entity(hass, mock_light)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_OFF
assert state.state == "off"
async def test_light_turn_on_with_brightness_uses_set_intensity(
@@ -198,16 +198,12 @@ async def test_light_turn_on_with_brightness_uses_set_intensity(
assert kwargs.get("wait_for_completion") is True
@pytest.mark.parametrize(
"mock_pyvlx", ["mock_light", "mock_onoff_light"], indirect=True
)
async def test_light_turn_on_without_brightness_calls_turn_on(
hass: HomeAssistant, mock_pyvlx: MagicMock
hass: HomeAssistant, mock_light: AsyncMock
) -> None:
"""Turning on without brightness uses node.turn_on."""
"""Turning on without brightness uses device.turn_on."""
node = mock_pyvlx.nodes[0]
entity_id = f"light.{node.name.lower().replace(' ', '_')}"
entity_id = f"light.{mock_light.name.lower().replace(' ', '_')}"
await hass.services.async_call(
LIGHT_DOMAIN,
@@ -216,20 +212,16 @@ async def test_light_turn_on_without_brightness_calls_turn_on(
blocking=True,
)
node.turn_on.assert_awaited_once_with(wait_for_completion=True)
assert node.set_intensity.await_count == 0
mock_light.turn_on.assert_awaited_once_with(wait_for_completion=True)
assert mock_light.set_intensity.await_count == 0
@pytest.mark.parametrize(
"mock_pyvlx", ["mock_light", "mock_onoff_light"], indirect=True
)
async def test_light_turn_off_calls_turn_off(
hass: HomeAssistant, mock_pyvlx: MagicMock
hass: HomeAssistant, mock_light: AsyncMock
) -> None:
"""Turning off calls device.turn_off with wait_for_completion."""
node = mock_pyvlx.nodes[0]
entity_id = f"light.{node.name.lower().replace(' ', '_')}"
entity_id = f"light.{mock_light.name.lower().replace(' ', '_')}"
await hass.services.async_call(
LIGHT_DOMAIN,
@@ -238,5 +230,4 @@ async def test_light_turn_off_calls_turn_off(
blocking=True,
)
node.turn_off.assert_awaited_once_with(wait_for_completion=True)
assert node.set_intensity.await_count == 0
mock_light.turn_off.assert_awaited_once_with(wait_for_completion=True)

View File

@@ -1,895 +0,0 @@
# serializer version: 1
# name: test_device_types[backgroundlight_ct]
ReadOnlyDict({
'brightness': 204,
'color_mode': <ColorMode.COLOR_TEMP: 'color_temp'>,
'color_temp_kelvin': 5000,
'effect': None,
'effect_list': list([
'Strobe color',
'Police',
'Christmas',
'RGB',
'Random Loop',
'Fast Random Loop',
'LSD',
'Slowdown',
'Night Mode',
'Date Night',
'Movie',
'Sunrise',
'Sunset',
'Romance',
'Happy Birthday',
'Disco',
'Strobe epilepsy!',
'Alarm',
'Police2',
'WhatsApp',
'Facebook',
'Twitter',
'Home',
'Candle Flicker',
'Tea Time',
'Slow Temp',
'Stop',
]),
'flowing': False,
'friendly_name': 'Yeelight Color 0x15243f Ambilight',
'hs_color': tuple(
27.001,
19.243,
),
'max_color_temp_kelvin': 6500,
'min_color_temp_kelvin': 1700,
'music_mode': False,
'night_light': False,
'rgb_color': tuple(
255,
228,
206,
),
'supported_color_modes': list([
<ColorMode.COLOR_TEMP: 'color_temp'>,
<ColorMode.HS: 'hs'>,
<ColorMode.RGB: 'rgb'>,
]),
'supported_features': <LightEntityFeature: 44>,
'xy_color': tuple(
0.371,
0.349,
),
})
# ---
# name: test_device_types[backgroundlight_hs]
ReadOnlyDict({
'brightness': 204,
'color_mode': <ColorMode.HS: 'hs'>,
'color_temp_kelvin': None,
'effect': None,
'effect_list': list([
'Strobe color',
'Police',
'Christmas',
'RGB',
'Random Loop',
'Fast Random Loop',
'LSD',
'Slowdown',
'Night Mode',
'Date Night',
'Movie',
'Sunrise',
'Sunset',
'Romance',
'Happy Birthday',
'Disco',
'Strobe epilepsy!',
'Alarm',
'Police2',
'WhatsApp',
'Facebook',
'Twitter',
'Home',
'Candle Flicker',
'Tea Time',
'Slow Temp',
'Stop',
]),
'flowing': False,
'friendly_name': 'Yeelight Color 0x15243f Ambilight',
'hs_color': tuple(
200,
70,
),
'max_color_temp_kelvin': 6500,
'min_color_temp_kelvin': 1700,
'music_mode': False,
'night_light': False,
'rgb_color': tuple(
77,
195,
255,
),
'supported_color_modes': list([
<ColorMode.COLOR_TEMP: 'color_temp'>,
<ColorMode.HS: 'hs'>,
<ColorMode.RGB: 'rgb'>,
]),
'supported_features': <LightEntityFeature: 44>,
'xy_color': tuple(
0.168,
0.247,
),
})
# ---
# name: test_device_types[backgroundlight_rgb]
ReadOnlyDict({
'brightness': 204,
'color_mode': <ColorMode.RGB: 'rgb'>,
'color_temp_kelvin': None,
'effect': None,
'effect_list': list([
'Strobe color',
'Police',
'Christmas',
'RGB',
'Random Loop',
'Fast Random Loop',
'LSD',
'Slowdown',
'Night Mode',
'Date Night',
'Movie',
'Sunrise',
'Sunset',
'Romance',
'Happy Birthday',
'Disco',
'Strobe epilepsy!',
'Alarm',
'Police2',
'WhatsApp',
'Facebook',
'Twitter',
'Home',
'Candle Flicker',
'Tea Time',
'Slow Temp',
'Stop',
]),
'flowing': False,
'friendly_name': 'Yeelight Color 0x15243f Ambilight',
'hs_color': tuple(
120.0,
100.0,
),
'max_color_temp_kelvin': 6500,
'min_color_temp_kelvin': 1700,
'music_mode': False,
'night_light': False,
'rgb_color': tuple(
0,
255,
0,
),
'supported_color_modes': list([
<ColorMode.COLOR_TEMP: 'color_temp'>,
<ColorMode.HS: 'hs'>,
<ColorMode.RGB: 'rgb'>,
]),
'supported_features': <LightEntityFeature: 44>,
'xy_color': tuple(
0.172,
0.747,
),
})
# ---
# name: test_device_types[color_ct]
ReadOnlyDict({
'brightness': 128,
'color_mode': <ColorMode.COLOR_TEMP: 'color_temp'>,
'color_temp_kelvin': 4000,
'effect': None,
'effect_list': list([
'Strobe color',
'Police',
'Christmas',
'RGB',
'Random Loop',
'Fast Random Loop',
'LSD',
'Slowdown',
'Night Mode',
'Date Night',
'Movie',
'Sunrise',
'Sunset',
'Romance',
'Happy Birthday',
'Disco',
'Strobe epilepsy!',
'Alarm',
'Police2',
'WhatsApp',
'Facebook',
'Twitter',
'Home',
'Candle Flicker',
'Tea Time',
'Slow Temp',
'Stop',
]),
'flowing': False,
'friendly_name': 'Yeelight Color 0x15243f',
'hs_color': tuple(
26.812,
34.87,
),
'max_color_temp_kelvin': 6500,
'min_color_temp_kelvin': 1700,
'music_mode': False,
'night_light': False,
'rgb_color': tuple(
255,
206,
166,
),
'supported_color_modes': list([
<ColorMode.COLOR_TEMP: 'color_temp'>,
<ColorMode.HS: 'hs'>,
<ColorMode.RGB: 'rgb'>,
]),
'supported_features': <LightEntityFeature: 44>,
'xy_color': tuple(
0.42,
0.365,
),
})
# ---
# name: test_device_types[color_ct][color_ct_nightlight_entity]
ReadOnlyDict({
'color_mode': <ColorMode.ONOFF: 'onoff'>,
'flowing': False,
'friendly_name': 'Yeelight Color 0x15243f Nightlight',
'music_mode': False,
'night_light': True,
'supported_color_modes': list([
<ColorMode.ONOFF: 'onoff'>,
]),
'supported_features': <LightEntityFeature: 0>,
})
# ---
# name: test_device_types[color_ct][color_ct_nightlight_mode]
ReadOnlyDict({
'brightness': 59,
'color_mode': <ColorMode.COLOR_TEMP: 'color_temp'>,
'color_temp_kelvin': 1700,
'effect': None,
'effect_list': list([
'Strobe color',
'Police',
'Christmas',
'RGB',
'Random Loop',
'Fast Random Loop',
'LSD',
'Slowdown',
'Night Mode',
'Date Night',
'Movie',
'Sunrise',
'Sunset',
'Romance',
'Happy Birthday',
'Disco',
'Strobe epilepsy!',
'Alarm',
'Police2',
'WhatsApp',
'Facebook',
'Twitter',
'Home',
'Candle Flicker',
'Tea Time',
'Slow Temp',
'Stop',
]),
'flowing': False,
'friendly_name': 'Yeelight Color 0x15243f',
'hs_color': tuple(
28.401,
100.0,
),
'max_color_temp_kelvin': 6500,
'min_color_temp_kelvin': 1700,
'music_mode': False,
'night_light': True,
'rgb_color': tuple(
255,
121,
0,
),
'supported_color_modes': list([
<ColorMode.COLOR_TEMP: 'color_temp'>,
<ColorMode.HS: 'hs'>,
<ColorMode.RGB: 'rgb'>,
]),
'supported_features': <LightEntityFeature: 44>,
'xy_color': tuple(
0.62,
0.368,
),
})
# ---
# name: test_device_types[color_hsv]
ReadOnlyDict({
'brightness': 128,
'color_mode': <ColorMode.HS: 'hs'>,
'color_temp_kelvin': None,
'effect': None,
'effect_list': list([
'Strobe color',
'Police',
'Christmas',
'RGB',
'Random Loop',
'Fast Random Loop',
'LSD',
'Slowdown',
'Night Mode',
'Date Night',
'Movie',
'Sunrise',
'Sunset',
'Romance',
'Happy Birthday',
'Disco',
'Strobe epilepsy!',
'Alarm',
'Police2',
'WhatsApp',
'Facebook',
'Twitter',
'Home',
'Candle Flicker',
'Tea Time',
'Slow Temp',
'Stop',
]),
'flowing': False,
'friendly_name': 'Yeelight Color 0x15243f',
'hs_color': tuple(
100,
35,
),
'max_color_temp_kelvin': 6500,
'min_color_temp_kelvin': 1700,
'music_mode': False,
'night_light': False,
'rgb_color': tuple(
195,
255,
166,
),
'supported_color_modes': list([
<ColorMode.COLOR_TEMP: 'color_temp'>,
<ColorMode.HS: 'hs'>,
<ColorMode.RGB: 'rgb'>,
]),
'supported_features': <LightEntityFeature: 44>,
'xy_color': tuple(
0.31,
0.45,
),
})
# ---
# name: test_device_types[color_hsv][color_hsv_nightlight_entity]
ReadOnlyDict({
'color_mode': <ColorMode.ONOFF: 'onoff'>,
'flowing': False,
'friendly_name': 'Yeelight Color 0x15243f Nightlight',
'music_mode': False,
'night_light': True,
'supported_color_modes': list([
<ColorMode.ONOFF: 'onoff'>,
]),
'supported_features': <LightEntityFeature: 0>,
})
# ---
# name: test_device_types[color_hsv_no_hue]
ReadOnlyDict({
'brightness': 128,
'color_mode': <ColorMode.HS: 'hs'>,
'color_temp_kelvin': None,
'effect': None,
'effect_list': list([
'Strobe color',
'Police',
'Christmas',
'RGB',
'Random Loop',
'Fast Random Loop',
'LSD',
'Slowdown',
'Night Mode',
'Date Night',
'Movie',
'Sunrise',
'Sunset',
'Romance',
'Happy Birthday',
'Disco',
'Strobe epilepsy!',
'Alarm',
'Police2',
'WhatsApp',
'Facebook',
'Twitter',
'Home',
'Candle Flicker',
'Tea Time',
'Slow Temp',
'Stop',
]),
'flowing': False,
'friendly_name': 'Yeelight Color 0x15243f',
'hs_color': None,
'max_color_temp_kelvin': 6500,
'min_color_temp_kelvin': 1700,
'music_mode': False,
'night_light': False,
'rgb_color': None,
'supported_color_modes': list([
<ColorMode.COLOR_TEMP: 'color_temp'>,
<ColorMode.HS: 'hs'>,
<ColorMode.RGB: 'rgb'>,
]),
'supported_features': <LightEntityFeature: 44>,
'xy_color': None,
})
# ---
# name: test_device_types[color_hsv_no_hue][color_hsv_no_hue_nightlight_entity]
ReadOnlyDict({
'color_mode': <ColorMode.ONOFF: 'onoff'>,
'flowing': False,
'friendly_name': 'Yeelight Color 0x15243f Nightlight',
'music_mode': False,
'night_light': True,
'supported_color_modes': list([
<ColorMode.ONOFF: 'onoff'>,
]),
'supported_features': <LightEntityFeature: 0>,
})
# ---
# name: test_device_types[color_rgb]
ReadOnlyDict({
'brightness': 128,
'color_mode': <ColorMode.RGB: 'rgb'>,
'color_temp_kelvin': None,
'effect': None,
'effect_list': list([
'Strobe color',
'Police',
'Christmas',
'RGB',
'Random Loop',
'Fast Random Loop',
'LSD',
'Slowdown',
'Night Mode',
'Date Night',
'Movie',
'Sunrise',
'Sunset',
'Romance',
'Happy Birthday',
'Disco',
'Strobe epilepsy!',
'Alarm',
'Police2',
'WhatsApp',
'Facebook',
'Twitter',
'Home',
'Candle Flicker',
'Tea Time',
'Slow Temp',
'Stop',
]),
'flowing': False,
'friendly_name': 'Yeelight Color 0x15243f',
'hs_color': tuple(
0.0,
100.0,
),
'max_color_temp_kelvin': 6500,
'min_color_temp_kelvin': 1700,
'music_mode': False,
'night_light': False,
'rgb_color': tuple(
255,
0,
0,
),
'supported_color_modes': list([
<ColorMode.COLOR_TEMP: 'color_temp'>,
<ColorMode.HS: 'hs'>,
<ColorMode.RGB: 'rgb'>,
]),
'supported_features': <LightEntityFeature: 44>,
'xy_color': tuple(
0.701,
0.299,
),
})
# ---
# name: test_device_types[color_rgb][color_rgb_nightlight_entity]
ReadOnlyDict({
'color_mode': <ColorMode.ONOFF: 'onoff'>,
'flowing': False,
'friendly_name': 'Yeelight Color 0x15243f Nightlight',
'music_mode': False,
'night_light': True,
'supported_color_modes': list([
<ColorMode.ONOFF: 'onoff'>,
]),
'supported_features': <LightEntityFeature: 0>,
})
# ---
# name: test_device_types[color_rgb_no_color]
ReadOnlyDict({
'brightness': 128,
'color_mode': <ColorMode.RGB: 'rgb'>,
'color_temp_kelvin': None,
'effect': None,
'effect_list': list([
'Strobe color',
'Police',
'Christmas',
'RGB',
'Random Loop',
'Fast Random Loop',
'LSD',
'Slowdown',
'Night Mode',
'Date Night',
'Movie',
'Sunrise',
'Sunset',
'Romance',
'Happy Birthday',
'Disco',
'Strobe epilepsy!',
'Alarm',
'Police2',
'WhatsApp',
'Facebook',
'Twitter',
'Home',
'Candle Flicker',
'Tea Time',
'Slow Temp',
'Stop',
]),
'flowing': False,
'friendly_name': 'Yeelight Color 0x15243f',
'hs_color': None,
'max_color_temp_kelvin': 6500,
'min_color_temp_kelvin': 1700,
'music_mode': False,
'night_light': False,
'rgb_color': None,
'supported_color_modes': list([
<ColorMode.COLOR_TEMP: 'color_temp'>,
<ColorMode.HS: 'hs'>,
<ColorMode.RGB: 'rgb'>,
]),
'supported_features': <LightEntityFeature: 44>,
'xy_color': None,
})
# ---
# name: test_device_types[color_rgb_no_color][color_rgb_no_color_nightlight_entity]
ReadOnlyDict({
'color_mode': <ColorMode.ONOFF: 'onoff'>,
'flowing': False,
'friendly_name': 'Yeelight Color 0x15243f Nightlight',
'music_mode': False,
'night_light': True,
'supported_color_modes': list([
<ColorMode.ONOFF: 'onoff'>,
]),
'supported_features': <LightEntityFeature: 0>,
})
# ---
# name: test_device_types[color_unsupported]
ReadOnlyDict({
'brightness': None,
'color_mode': <ColorMode.UNKNOWN: 'unknown'>,
'color_temp_kelvin': None,
'effect': None,
'effect_list': list([
'Strobe color',
'Police',
'Christmas',
'RGB',
'Random Loop',
'Fast Random Loop',
'LSD',
'Slowdown',
'Night Mode',
'Date Night',
'Movie',
'Sunrise',
'Sunset',
'Romance',
'Happy Birthday',
'Disco',
'Strobe epilepsy!',
'Alarm',
'Police2',
'WhatsApp',
'Facebook',
'Twitter',
'Home',
'Candle Flicker',
'Tea Time',
'Slow Temp',
'Stop',
]),
'flowing': False,
'friendly_name': 'Yeelight Color 0x15243f',
'hs_color': None,
'max_color_temp_kelvin': 6500,
'min_color_temp_kelvin': 1700,
'music_mode': False,
'night_light': False,
'rgb_color': None,
'supported_color_modes': list([
<ColorMode.COLOR_TEMP: 'color_temp'>,
<ColorMode.HS: 'hs'>,
<ColorMode.RGB: 'rgb'>,
]),
'supported_features': <LightEntityFeature: 44>,
'xy_color': None,
})
# ---
# name: test_device_types[color_unsupported][color_unsupported_nightlight_entity]
ReadOnlyDict({
'color_mode': <ColorMode.ONOFF: 'onoff'>,
'flowing': False,
'friendly_name': 'Yeelight Color 0x15243f Nightlight',
'music_mode': False,
'night_light': True,
'supported_color_modes': list([
<ColorMode.ONOFF: 'onoff'>,
]),
'supported_features': <LightEntityFeature: 0>,
})
# ---
# name: test_device_types[default]
ReadOnlyDict({
'brightness': 128,
'color_mode': <ColorMode.BRIGHTNESS: 'brightness'>,
'effect': None,
'effect_list': list([
'Disco',
'Strobe epilepsy!',
'Alarm',
'Police2',
'WhatsApp',
'Facebook',
'Twitter',
'Home',
'Candle Flicker',
'Tea Time',
'Slow Temp',
'Stop',
]),
'flowing': False,
'friendly_name': 'Yeelight Color 0x15243f',
'music_mode': False,
'night_light': False,
'supported_color_modes': list([
<ColorMode.BRIGHTNESS: 'brightness'>,
]),
'supported_features': <LightEntityFeature: 44>,
})
# ---
# name: test_device_types[white]
ReadOnlyDict({
'brightness': 128,
'color_mode': <ColorMode.BRIGHTNESS: 'brightness'>,
'effect': None,
'effect_list': list([
'Disco',
'Strobe epilepsy!',
'Alarm',
'Police2',
'WhatsApp',
'Facebook',
'Twitter',
'Home',
'Candle Flicker',
'Tea Time',
'Slow Temp',
'Stop',
]),
'flowing': False,
'friendly_name': 'Yeelight Color 0x15243f',
'music_mode': False,
'night_light': False,
'supported_color_modes': list([
<ColorMode.BRIGHTNESS: 'brightness'>,
]),
'supported_features': <LightEntityFeature: 44>,
})
# ---
# name: test_device_types[whitetemp]
ReadOnlyDict({
'brightness': 128,
'color_mode': <ColorMode.COLOR_TEMP: 'color_temp'>,
'color_temp_kelvin': 4000,
'effect': None,
'effect_list': list([
'Slow Temp',
'Stop',
]),
'flowing': False,
'friendly_name': 'Yeelight Color 0x15243f',
'hs_color': tuple(
26.812,
34.87,
),
'max_color_temp_kelvin': 6500,
'min_color_temp_kelvin': 2700,
'music_mode': False,
'night_light': False,
'rgb_color': tuple(
255,
206,
166,
),
'supported_color_modes': list([
<ColorMode.COLOR_TEMP: 'color_temp'>,
]),
'supported_features': <LightEntityFeature: 44>,
'xy_color': tuple(
0.42,
0.365,
),
})
# ---
# name: test_device_types[whitetemp][whitetemp_nightlight_entity]
ReadOnlyDict({
'brightness': 59,
'color_mode': <ColorMode.BRIGHTNESS: 'brightness'>,
'flowing': False,
'friendly_name': 'Yeelight Color 0x15243f Nightlight',
'music_mode': False,
'night_light': True,
'supported_color_modes': list([
<ColorMode.BRIGHTNESS: 'brightness'>,
]),
'supported_features': <LightEntityFeature: 0>,
})
# ---
# name: test_device_types[whitetemp][whitetemp_nightlight_mode]
ReadOnlyDict({
'brightness': 59,
'color_mode': <ColorMode.COLOR_TEMP: 'color_temp'>,
'color_temp_kelvin': 2700,
'effect': None,
'effect_list': list([
'Slow Temp',
'Stop',
]),
'flowing': False,
'friendly_name': 'Yeelight Color 0x15243f',
'hs_color': tuple(
28.395,
65.723,
),
'max_color_temp_kelvin': 6500,
'min_color_temp_kelvin': 2700,
'music_mode': False,
'night_light': True,
'rgb_color': tuple(
255,
167,
87,
),
'supported_color_modes': list([
<ColorMode.COLOR_TEMP: 'color_temp'>,
]),
'supported_features': <LightEntityFeature: 44>,
'xy_color': tuple(
0.525,
0.388,
),
})
# ---
# name: test_device_types[whitetempmood]
ReadOnlyDict({
'brightness': 128,
'color_mode': <ColorMode.COLOR_TEMP: 'color_temp'>,
'color_temp_kelvin': 4000,
'effect': None,
'effect_list': list([
'Slow Temp',
'Stop',
]),
'flowing': False,
'friendly_name': 'Yeelight Color 0x15243f',
'hs_color': tuple(
26.812,
34.87,
),
'max_color_temp_kelvin': 6500,
'min_color_temp_kelvin': 2700,
'music_mode': False,
'night_light': False,
'rgb_color': tuple(
255,
206,
166,
),
'supported_color_modes': list([
<ColorMode.COLOR_TEMP: 'color_temp'>,
]),
'supported_features': <LightEntityFeature: 44>,
'xy_color': tuple(
0.42,
0.365,
),
})
# ---
# name: test_device_types[whitetempmood][whitetempmood_nightlight_entity]
ReadOnlyDict({
'brightness': 59,
'color_mode': <ColorMode.BRIGHTNESS: 'brightness'>,
'flowing': False,
'friendly_name': 'Yeelight Color 0x15243f Nightlight',
'music_mode': False,
'night_light': True,
'supported_color_modes': list([
<ColorMode.BRIGHTNESS: 'brightness'>,
]),
'supported_features': <LightEntityFeature: 0>,
})
# ---
# name: test_device_types[whitetempmood][whitetempmood_nightlight_mode]
ReadOnlyDict({
'brightness': 59,
'color_mode': <ColorMode.COLOR_TEMP: 'color_temp'>,
'color_temp_kelvin': 2700,
'effect': None,
'effect_list': list([
'Slow Temp',
'Stop',
]),
'flowing': False,
'friendly_name': 'Yeelight Color 0x15243f',
'hs_color': tuple(
28.395,
65.723,
),
'max_color_temp_kelvin': 6500,
'min_color_temp_kelvin': 2700,
'music_mode': False,
'night_light': True,
'rgb_color': tuple(
255,
167,
87,
),
'supported_color_modes': list([
<ColorMode.COLOR_TEMP: 'color_temp'>,
]),
'supported_features': <LightEntityFeature: 44>,
'xy_color': tuple(
0.525,
0.388,
),
})
# ---

View File

@@ -3,11 +3,9 @@
from datetime import timedelta
import logging
import socket
from typing import Any
from unittest.mock import ANY, AsyncMock, MagicMock, call, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from yeelight import (
BulbException,
BulbType,
@@ -40,6 +38,7 @@ from homeassistant.components.light import (
FLASH_SHORT,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
ColorMode,
LightEntityFeature,
)
from homeassistant.components.yeelight.const import (
@@ -90,6 +89,8 @@ from homeassistant.components.yeelight.light import (
SERVICE_SET_MUSIC_MODE,
SERVICE_START_FLOW,
YEELIGHT_COLOR_EFFECT_LIST,
YEELIGHT_MONO_EFFECT_LIST,
YEELIGHT_TEMP_ONLY_EFFECT_LIST,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
@@ -104,6 +105,12 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from homeassistant.util.color import (
color_hs_to_RGB,
color_hs_to_xy,
color_RGB_to_hs,
color_RGB_to_xy,
)
from . import (
CAPABILITIES,
@@ -111,6 +118,7 @@ from . import (
ENTITY_NIGHTLIGHT,
IP_ADDRESS,
MODULE,
NAME,
PROPERTIES,
UNIQUE_FRIENDLY_NAME,
_mocked_bulb,
@@ -770,185 +778,19 @@ async def test_state_already_set_avoid_ratelimit(hass: HomeAssistant) -> None:
mocked_bulb.last_properties["flowing"] = "0"
@pytest.mark.parametrize(
(
"bulb_type",
"model",
"name",
"entity_id",
"extra_properties",
"nightlight_entity",
"nightlight_mode",
),
[
# Default
pytest.param(
None,
"mono",
UNIQUE_FRIENDLY_NAME,
ENTITY_LIGHT,
{"power": "on", "color_mode": "3"}, # HSV
False,
False,
id="default",
),
# White
pytest.param(
BulbType.White,
"mono",
UNIQUE_FRIENDLY_NAME,
ENTITY_LIGHT,
{"power": "on", "color_mode": "3"}, # HSV
False,
False,
id="white",
),
# Color - color mode CT
pytest.param(
BulbType.Color,
"color",
UNIQUE_FRIENDLY_NAME,
ENTITY_LIGHT,
{"power": "on", "color_mode": "2"}, # CT
True,
True,
id="color_ct",
),
# Color - color mode HS
pytest.param(
BulbType.Color,
"color",
UNIQUE_FRIENDLY_NAME,
ENTITY_LIGHT,
{"power": "on", "color_mode": "3"}, # HSV
True,
False,
id="color_hsv",
),
# Color - color mode RGB
pytest.param(
BulbType.Color,
"color",
UNIQUE_FRIENDLY_NAME,
ENTITY_LIGHT,
{"power": "on", "color_mode": "1"}, # RGB
True,
False,
id="color_rgb",
),
# Color - color mode HS but no hue
pytest.param(
BulbType.Color,
"color",
UNIQUE_FRIENDLY_NAME,
ENTITY_LIGHT,
{"power": "on", "color_mode": "3", "hue": None}, # HSV
True,
False,
id="color_hsv_no_hue",
),
# Color - color mode RGB but no color
pytest.param(
BulbType.Color,
"color",
UNIQUE_FRIENDLY_NAME,
ENTITY_LIGHT,
{"power": "on", "color_mode": "1", "rgb": None}, # RGB
True,
False,
id="color_rgb_no_color",
),
# Color - unsupported color_mode
pytest.param(
BulbType.Color,
"color",
UNIQUE_FRIENDLY_NAME,
ENTITY_LIGHT,
{"power": "on", "color_mode": "4"}, # Unsupported
True,
False,
id="color_unsupported",
),
# WhiteTemp
pytest.param(
BulbType.WhiteTemp,
"ceiling1",
UNIQUE_FRIENDLY_NAME,
ENTITY_LIGHT,
{"power": "on"},
True,
True,
id="whitetemp",
),
# WhiteTempMood
pytest.param(
BulbType.WhiteTempMood,
"ceiling4",
UNIQUE_FRIENDLY_NAME,
ENTITY_LIGHT,
{},
True,
True,
id="whitetempmood",
),
# Background light - color mode CT
pytest.param(
BulbType.WhiteTempMood,
"ceiling4",
f"{UNIQUE_FRIENDLY_NAME} Ambilight",
f"{ENTITY_LIGHT}_ambilight",
{"bg_lmode": "2"}, # CT
False,
False,
id="backgroundlight_ct",
),
# Background light - color mode HS
pytest.param(
BulbType.WhiteTempMood,
"ceiling4",
f"{UNIQUE_FRIENDLY_NAME} Ambilight",
f"{ENTITY_LIGHT}_ambilight",
{"bg_lmode": "3"}, # HS
False,
False,
id="backgroundlight_hs",
),
# Background light - color mode RGB
pytest.param(
BulbType.WhiteTempMood,
"ceiling4",
f"{UNIQUE_FRIENDLY_NAME} Ambilight",
f"{ENTITY_LIGHT}_ambilight",
{"bg_lmode": "1"}, # RGB
False,
False,
id="backgroundlight_rgb",
),
],
)
async def test_device_types(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
caplog: pytest.LogCaptureFixture,
snapshot: SnapshotAssertion,
bulb_type: BulbType | None,
model: str,
name: str,
entity_id: str,
extra_properties: dict[str, Any],
nightlight_entity: bool,
nightlight_mode: bool,
request: pytest.FixtureRequest,
) -> None:
"""Test different device types."""
mocked_bulb = _mocked_bulb()
properties = {**PROPERTIES}
properties.pop("active_mode")
properties.pop("power")
properties.update(extra_properties)
properties["color_mode"] = "3" # HSV
mocked_bulb.last_properties = properties
async def _async_setup(config_entry: MockConfigEntry) -> None:
async def _async_setup(config_entry):
with _patch_discovery(), patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
@@ -957,14 +799,14 @@ async def test_device_types(
await hass.async_block_till_done()
async def _async_test(
bulb_type: BulbType | None,
model: str,
*,
nightlight_entity_properties: bool,
name: str,
entity_id: str,
nightlight_mode_properties: bool,
) -> None:
bulb_type,
model,
target_properties,
nightlight_entity_properties=None,
name=UNIQUE_FRIENDLY_NAME,
entity_id=ENTITY_LIGHT,
nightlight_mode_properties=None,
):
config_entry = MockConfigEntry(
domain=DOMAIN, data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False}
)
@@ -981,14 +823,18 @@ async def test_device_types(
state = hass.states.get(entity_id)
assert state.state == "on"
assert state.attributes == snapshot
target_properties["friendly_name"] = name
target_properties["flowing"] = False
target_properties["night_light"] = False
target_properties["music_mode"] = False
assert dict(state.attributes) == target_properties
await hass.config_entries.async_unload(config_entry.entry_id)
await hass.config_entries.async_remove(config_entry.entry_id)
entity_registry.async_clear_config_entry(config_entry.entry_id)
mocked_bulb.last_properties["nl_br"] = original_nightlight_brightness
# nightlight as a setting of the main entity
if nightlight_mode_properties:
if nightlight_mode_properties is not None:
mocked_bulb.last_properties["active_mode"] = True
config_entry = MockConfigEntry(
domain=DOMAIN, data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False}
@@ -997,9 +843,11 @@ async def test_device_types(
await _async_setup(config_entry)
state = hass.states.get(entity_id)
assert state.state == "on"
assert state.attributes == snapshot(
name=f"{request.node.callspec.id}_nightlight_mode"
)
nightlight_mode_properties["friendly_name"] = name
nightlight_mode_properties["flowing"] = False
nightlight_mode_properties["night_light"] = True
nightlight_mode_properties["music_mode"] = False
assert dict(state.attributes) == nightlight_mode_properties
await hass.config_entries.async_unload(config_entry.entry_id)
await hass.config_entries.async_remove(config_entry.entry_id)
@@ -1008,7 +856,7 @@ async def test_device_types(
mocked_bulb.last_properties.pop("active_mode")
# nightlight as a separate entity
if nightlight_entity_properties:
if nightlight_entity_properties is not None:
config_entry = MockConfigEntry(
domain=DOMAIN, data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: True}
)
@@ -1018,25 +866,408 @@ async def test_device_types(
assert hass.states.get(entity_id).state == "off"
state = hass.states.get(f"{entity_id}_nightlight")
assert state.state == "on"
assert state.attributes == snapshot(
name=f"{request.node.callspec.id}_nightlight_entity"
)
nightlight_entity_properties["friendly_name"] = f"{name} Nightlight"
nightlight_entity_properties["flowing"] = False
nightlight_entity_properties["night_light"] = True
nightlight_entity_properties["music_mode"] = False
assert dict(state.attributes) == nightlight_entity_properties
await hass.config_entries.async_unload(config_entry.entry_id)
await hass.config_entries.async_remove(config_entry.entry_id)
entity_registry.async_clear_config_entry(config_entry.entry_id)
await hass.async_block_till_done()
bright = round(255 * int(PROPERTIES["bright"]) / 100)
ct = int(PROPERTIES["ct"])
hue = int(PROPERTIES["hue"])
sat = int(PROPERTIES["sat"])
rgb = int(PROPERTIES["rgb"])
rgb_color = ((rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF)
hs_color = (hue, sat)
bg_bright = round(255 * int(PROPERTIES["bg_bright"]) / 100)
bg_ct = int(PROPERTIES["bg_ct"])
bg_hue = int(PROPERTIES["bg_hue"])
bg_sat = int(PROPERTIES["bg_sat"])
bg_rgb = int(PROPERTIES["bg_rgb"])
bg_hs_color = (bg_hue, bg_sat)
bg_rgb_color = ((bg_rgb >> 16) & 0xFF, (bg_rgb >> 8) & 0xFF, bg_rgb & 0xFF)
nl_br = round(255 * int(PROPERTIES["nl_br"]) / 100)
# Default
await _async_test(
bulb_type,
model,
name=name,
entity_id=entity_id,
nightlight_entity_properties=nightlight_entity,
nightlight_mode_properties=nightlight_mode,
None,
"mono",
{
"effect_list": YEELIGHT_MONO_EFFECT_LIST,
"effect": None,
"supported_features": SUPPORT_YEELIGHT,
"brightness": bright,
"color_mode": "brightness",
"supported_color_modes": ["brightness"],
},
)
assert ("Light reported unknown color mode: 4" in caplog.text) == (
request.node.callspec.id == "color_unsupported"
# White
await _async_test(
BulbType.White,
"mono",
{
"effect_list": YEELIGHT_MONO_EFFECT_LIST,
"supported_features": SUPPORT_YEELIGHT,
"effect": None,
"brightness": bright,
"color_mode": "brightness",
"supported_color_modes": ["brightness"],
},
)
# Color - color mode CT
mocked_bulb.last_properties["color_mode"] = "2" # CT
model_specs = _MODEL_SPECS["color"]
await _async_test(
BulbType.Color,
"color",
{
"effect_list": YEELIGHT_COLOR_EFFECT_LIST,
"effect": None,
"supported_features": SUPPORT_YEELIGHT,
"min_color_temp_kelvin": model_specs["color_temp"]["min"],
"max_color_temp_kelvin": model_specs["color_temp"]["max"],
"brightness": bright,
"color_temp_kelvin": ct,
"color_mode": "color_temp",
"supported_color_modes": ["color_temp", "hs", "rgb"],
"hs_color": (26.812, 34.87),
"rgb_color": (255, 206, 166),
"xy_color": (0.42, 0.365),
},
nightlight_entity_properties={
"supported_features": 0,
"color_mode": "onoff",
"supported_color_modes": ["onoff"],
},
nightlight_mode_properties={
"effect_list": YEELIGHT_COLOR_EFFECT_LIST,
"effect": None,
"supported_features": SUPPORT_YEELIGHT,
"hs_color": (28.401, 100.0),
"rgb_color": (255, 121, 0),
"xy_color": (0.62, 0.368),
"min_color_temp_kelvin": model_specs["color_temp"]["min"],
"max_color_temp_kelvin": model_specs["color_temp"]["max"],
"brightness": nl_br,
"color_mode": "color_temp",
"supported_color_modes": ["color_temp", "hs", "rgb"],
"color_temp_kelvin": model_specs["color_temp"]["min"],
},
)
# Color - color mode HS
mocked_bulb.last_properties["color_mode"] = "3" # HSV
model_specs = _MODEL_SPECS["color"]
await _async_test(
BulbType.Color,
"color",
{
"effect_list": YEELIGHT_COLOR_EFFECT_LIST,
"effect": None,
"supported_features": SUPPORT_YEELIGHT,
"min_color_temp_kelvin": model_specs["color_temp"]["min"],
"max_color_temp_kelvin": model_specs["color_temp"]["max"],
"brightness": bright,
"hs_color": hs_color,
"rgb_color": color_hs_to_RGB(*hs_color),
"xy_color": color_hs_to_xy(*hs_color),
"color_temp_kelvin": None,
"color_mode": "hs",
"supported_color_modes": ["color_temp", "hs", "rgb"],
},
nightlight_entity_properties={
"supported_features": 0,
"color_mode": "onoff",
"supported_color_modes": ["onoff"],
},
)
# Color - color mode RGB
mocked_bulb.last_properties["color_mode"] = "1" # RGB
model_specs = _MODEL_SPECS["color"]
await _async_test(
BulbType.Color,
"color",
{
"effect_list": YEELIGHT_COLOR_EFFECT_LIST,
"effect": None,
"supported_features": SUPPORT_YEELIGHT,
"min_color_temp_kelvin": model_specs["color_temp"]["min"],
"max_color_temp_kelvin": model_specs["color_temp"]["max"],
"brightness": bright,
"hs_color": color_RGB_to_hs(*rgb_color),
"rgb_color": rgb_color,
"xy_color": color_RGB_to_xy(*rgb_color),
"color_temp_kelvin": None,
"color_mode": "rgb",
"supported_color_modes": ["color_temp", "hs", "rgb"],
},
nightlight_entity_properties={
"supported_features": 0,
"color_mode": "onoff",
"supported_color_modes": ["onoff"],
},
)
# Color - color mode HS but no hue
mocked_bulb.last_properties["color_mode"] = "3" # HSV
mocked_bulb.last_properties["hue"] = None
model_specs = _MODEL_SPECS["color"]
await _async_test(
BulbType.Color,
"color",
{
"effect_list": YEELIGHT_COLOR_EFFECT_LIST,
"effect": None,
"supported_features": SUPPORT_YEELIGHT,
"min_color_temp_kelvin": model_specs["color_temp"]["min"],
"max_color_temp_kelvin": model_specs["color_temp"]["max"],
"brightness": bright,
"hs_color": None,
"rgb_color": None,
"xy_color": None,
"color_temp_kelvin": None,
"color_mode": "hs",
"supported_color_modes": ["color_temp", "hs", "rgb"],
},
nightlight_entity_properties={
"supported_features": 0,
"color_mode": "onoff",
"supported_color_modes": ["onoff"],
},
)
# Color - color mode RGB but no color
mocked_bulb.last_properties["color_mode"] = "1" # RGB
mocked_bulb.last_properties["rgb"] = None
model_specs = _MODEL_SPECS["color"]
await _async_test(
BulbType.Color,
"color",
{
"effect_list": YEELIGHT_COLOR_EFFECT_LIST,
"effect": None,
"supported_features": SUPPORT_YEELIGHT,
"min_color_temp_kelvin": model_specs["color_temp"]["min"],
"max_color_temp_kelvin": model_specs["color_temp"]["max"],
"brightness": bright,
"hs_color": None,
"rgb_color": None,
"xy_color": None,
"color_temp_kelvin": None,
"color_mode": "rgb",
"supported_color_modes": ["color_temp", "hs", "rgb"],
},
nightlight_entity_properties={
"supported_features": 0,
"color_mode": "onoff",
"supported_color_modes": ["onoff"],
},
)
# Color - unsupported color_mode
mocked_bulb.last_properties["color_mode"] = 4 # Unsupported
model_specs = _MODEL_SPECS["color"]
await _async_test(
BulbType.Color,
"color",
{
"effect_list": YEELIGHT_COLOR_EFFECT_LIST,
"effect": None,
"supported_features": SUPPORT_YEELIGHT,
"min_color_temp_kelvin": model_specs["color_temp"]["min"],
"max_color_temp_kelvin": model_specs["color_temp"]["max"],
"brightness": None,
"hs_color": None,
"rgb_color": None,
"xy_color": None,
"color_temp_kelvin": None,
"color_mode": "unknown",
"supported_color_modes": ["color_temp", "hs", "rgb"],
},
{
"supported_features": 0,
"color_mode": "onoff",
"supported_color_modes": ["onoff"],
},
)
assert "Light reported unknown color mode: 4" in caplog.text
# WhiteTemp
model_specs = _MODEL_SPECS["ceiling1"]
await _async_test(
BulbType.WhiteTemp,
"ceiling1",
{
"effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST,
"effect": None,
"supported_features": SUPPORT_YEELIGHT,
"min_color_temp_kelvin": model_specs["color_temp"]["min"],
"max_color_temp_kelvin": model_specs["color_temp"]["max"],
"brightness": bright,
"color_temp_kelvin": ct,
"color_mode": "color_temp",
"supported_color_modes": ["color_temp"],
"hs_color": (26.812, 34.87),
"rgb_color": (255, 206, 166),
"xy_color": (0.42, 0.365),
},
nightlight_entity_properties={
"supported_features": 0,
"brightness": nl_br,
"color_mode": "brightness",
"supported_color_modes": ["brightness"],
},
nightlight_mode_properties={
"effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST,
"effect": None,
"supported_features": SUPPORT_YEELIGHT,
"min_color_temp_kelvin": model_specs["color_temp"]["min"],
"max_color_temp_kelvin": model_specs["color_temp"]["max"],
"brightness": nl_br,
"color_temp_kelvin": model_specs["color_temp"]["min"],
"color_mode": "color_temp",
"supported_color_modes": ["color_temp"],
"hs_color": (28.395, 65.723),
"rgb_color": (255, 167, 87),
"xy_color": (0.525, 0.388),
},
)
# WhiteTempMood
properties.pop("power")
properties["main_power"] = "on"
model_specs = _MODEL_SPECS["ceiling4"]
await _async_test(
BulbType.WhiteTempMood,
"ceiling4",
{
"friendly_name": NAME,
"effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST,
"effect": None,
"flowing": False,
"night_light": True,
"supported_features": SUPPORT_YEELIGHT,
"min_color_temp_kelvin": model_specs["color_temp"]["min"],
"max_color_temp_kelvin": model_specs["color_temp"]["max"],
"brightness": bright,
"color_temp_kelvin": ct,
"color_mode": "color_temp",
"supported_color_modes": ["color_temp"],
"hs_color": (26.812, 34.87),
"rgb_color": (255, 206, 166),
"xy_color": (0.42, 0.365),
},
nightlight_entity_properties={
"supported_features": 0,
"brightness": nl_br,
"color_mode": "brightness",
"supported_color_modes": ["brightness"],
},
nightlight_mode_properties={
"friendly_name": NAME,
"effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST,
"effect": None,
"flowing": False,
"night_light": True,
"supported_features": SUPPORT_YEELIGHT,
"min_color_temp_kelvin": model_specs["color_temp"]["min"],
"max_color_temp_kelvin": model_specs["color_temp"]["max"],
"brightness": nl_br,
"color_temp_kelvin": model_specs["color_temp"]["min"],
"color_mode": "color_temp",
"supported_color_modes": ["color_temp"],
"hs_color": (28.395, 65.723),
"rgb_color": (255, 167, 87),
"xy_color": (0.525, 0.388),
},
)
# Background light - color mode CT
mocked_bulb.last_properties["bg_lmode"] = "2" # CT
await _async_test(
BulbType.WhiteTempMood,
"ceiling4",
{
"effect_list": YEELIGHT_COLOR_EFFECT_LIST,
"effect": None,
"supported_features": SUPPORT_YEELIGHT,
"min_color_temp_kelvin": 1700,
"max_color_temp_kelvin": 6500,
"brightness": bg_bright,
"color_temp_kelvin": bg_ct,
"color_mode": "color_temp",
"supported_color_modes": [
ColorMode.COLOR_TEMP,
ColorMode.HS,
ColorMode.RGB,
],
"hs_color": (27.001, 19.243),
"rgb_color": (255, 228, 206),
"xy_color": (0.371, 0.349),
},
name=f"{UNIQUE_FRIENDLY_NAME} Ambilight",
entity_id=f"{ENTITY_LIGHT}_ambilight",
)
# Background light - color mode HS
mocked_bulb.last_properties["bg_lmode"] = "3" # HS
await _async_test(
BulbType.WhiteTempMood,
"ceiling4",
{
"effect_list": YEELIGHT_COLOR_EFFECT_LIST,
"effect": None,
"supported_features": SUPPORT_YEELIGHT,
"min_color_temp_kelvin": 1700,
"max_color_temp_kelvin": 6500,
"brightness": bg_bright,
"hs_color": bg_hs_color,
"rgb_color": color_hs_to_RGB(*bg_hs_color),
"xy_color": color_hs_to_xy(*bg_hs_color),
"color_temp_kelvin": None,
"color_mode": "hs",
"supported_color_modes": [
ColorMode.COLOR_TEMP,
ColorMode.HS,
ColorMode.RGB,
],
},
name=f"{UNIQUE_FRIENDLY_NAME} Ambilight",
entity_id=f"{ENTITY_LIGHT}_ambilight",
)
# Background light - color mode RGB
mocked_bulb.last_properties["bg_lmode"] = "1" # RGB
await _async_test(
BulbType.WhiteTempMood,
"ceiling4",
{
"effect_list": YEELIGHT_COLOR_EFFECT_LIST,
"effect": None,
"supported_features": SUPPORT_YEELIGHT,
"min_color_temp_kelvin": 1700,
"max_color_temp_kelvin": 6500,
"brightness": bg_bright,
"hs_color": color_RGB_to_hs(*bg_rgb_color),
"rgb_color": bg_rgb_color,
"xy_color": color_RGB_to_xy(*bg_rgb_color),
"color_temp_kelvin": None,
"color_mode": "rgb",
"supported_color_modes": [
ColorMode.COLOR_TEMP,
ColorMode.HS,
ColorMode.RGB,
],
},
name=f"{UNIQUE_FRIENDLY_NAME} Ambilight",
entity_id=f"{ENTITY_LIGHT}_ambilight",
)

View File

@@ -626,9 +626,6 @@ async def hass(
loop.set_exception_handler(exc_handle)
frame.async_setup(hass)
# Ensure translations for "homeassistant" are always pre-loaded
await translation_helper.async_load_integrations(hass, {ha.DOMAIN})
yield hass
# Config entries are not normally unloaded on HA shutdown. They are unloaded here
@@ -1299,33 +1296,10 @@ def translations_once() -> Generator[_patch]:
def evict_faked_translations(translations_once) -> Generator[_patch]:
"""Clear translations for mocked integrations from the cache after each module."""
real_component_strings = translation_helper._async_get_component_strings
def _async_get_cached_translations(
_hass: HomeAssistant,
_language: str,
_category: str,
_integration: str | None = None,
) -> dict[str, str]:
# Override default implementation to ensure "homeassistant"
# is always considered when getting "global" cached translations
cache = translation_helper._async_get_translations_cache(_hass)
_components = (
{_integration}
if _integration
else _hass.config.top_level_components | {ha.DOMAIN}
)
return cache.get_cached(_language, _category, _components)
with (
patch(
"homeassistant.helpers.translation.async_get_cached_translations",
_async_get_cached_translations,
),
patch(
"homeassistant.helpers.translation._async_get_component_strings",
wraps=real_component_strings,
) as mock_component_strings,
):
with patch(
"homeassistant.helpers.translation._async_get_component_strings",
wraps=real_component_strings,
) as mock_component_strings:
yield
cache: _TranslationsCacheData = translations_once.kwargs["return_value"]
component_paths = components.__path__

View File

@@ -1,12 +1,10 @@
"""Test the aiohttp client helper."""
from collections.abc import AsyncGenerator
import socket
from unittest.mock import Mock, patch
import aiohttp
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from aiohttp.test_utils import TestClient
import pytest
from homeassistant.components.mjpeg import (
@@ -442,179 +440,3 @@ async def test_connector_no_verify_uses_http11_alpn(hass: HomeAssistant) -> None
mock_client_context_no_verify.assert_called_once_with(
SSLCipherList.PYTHON_DEFAULT, ssl_util.SSL_ALPN_HTTP11
)
@pytest.fixture
async def redirect_server() -> AsyncGenerator[TestServer]:
"""Start a test server that redirects based on query parameters."""
async def handle_redirect(request: web.Request) -> web.Response:
"""Redirect to the URL specified in the 'to' query parameter."""
location = request.query["to"]
return web.Response(status=307, headers={"Location": location})
async def handle_ok(request: web.Request) -> web.Response:
"""Return a 200 OK response."""
return web.Response(text="ok")
app = web.Application()
app.router.add_get("/redirect", handle_redirect)
app.router.add_get("/ok", handle_ok)
async def _mock_resolve_host(
self: aiohttp.TCPConnector,
host: str,
port: int,
traces: object = None,
) -> list[dict[str, object]]:
return [
{
"hostname": host,
"host": "127.0.0.1",
"port": port,
"family": socket.AF_INET,
"proto": 6,
"flags": 0,
}
]
server = TestServer(app)
await server.start_server()
# Route all TCP connections to the local test server
# This allows us to test redirect behavior of external URLs
# without actually making network requests
with patch.object(aiohttp.TCPConnector, "_resolve_host", _mock_resolve_host):
yield server
await server.close()
def _resolve_result(host: str, addr: str) -> list[dict[str, object]]:
"""Build a mock DNS resolve result for the SSRF check."""
return [
{
"hostname": host,
"host": addr,
"port": 0,
"family": socket.AF_INET,
"proto": 6,
"flags": 0,
}
]
@pytest.mark.usefixtures("socket_enabled")
async def test_redirect_loopback_to_loopback_allowed(
hass: HomeAssistant, redirect_server: TestServer
) -> None:
"""Test that redirects from loopback to loopback are allowed."""
session = client.async_get_clientsession(hass)
target = str(redirect_server.make_url("/ok"))
redirect_url = redirect_server.make_url(f"/redirect?to={target}")
# Both origin and target are on 127.0.0.1 — should be allowed
resp = await session.get(redirect_url)
assert resp.status == 200
@pytest.mark.usefixtures("socket_enabled")
async def test_redirect_relative_url_allowed(
hass: HomeAssistant, redirect_server: TestServer
) -> None:
"""Test that relative redirects are allowed (they stay on the same host)."""
session = client.async_create_clientsession(hass)
server_port = redirect_server.port
# Redirect from an external origin to a relative path
redirect_url = f"http://external.example.com:{server_port}/redirect?to=/ok"
async def mock_async_resolve_host(host: str) -> list[dict[str, object]]:
"""Return public IPs for all hosts."""
return _resolve_result(host, "93.184.216.34")
connector = session.connector
with patch.object(connector, "async_resolve_host", mock_async_resolve_host):
resp = await session.get(redirect_url)
assert resp.status == 200
@pytest.mark.usefixtures("socket_enabled")
@pytest.mark.parametrize(
"target",
[
"http://other.example.com:{port}/ok",
"http://safe.example.com:{port}/ok",
"http://notlocalhost:{port}/ok",
],
)
async def test_redirect_to_non_loopback_allowed(
hass: HomeAssistant, redirect_server: TestServer, target: str
) -> None:
"""Test that redirects to non-loopback addresses are allowed."""
session = client.async_create_clientsession(hass)
server_port = redirect_server.port
location = target.format(port=server_port)
redirect_url = f"http://external.example.com:{server_port}/redirect?to={location}"
async def mock_async_resolve_host(host: str) -> list[dict[str, object]]:
"""Return public IPs for all hosts."""
return _resolve_result(host, "93.184.216.34")
connector = session.connector
with patch.object(connector, "async_resolve_host", mock_async_resolve_host):
resp = await session.get(redirect_url)
assert resp.status == 200
@pytest.mark.usefixtures("socket_enabled")
@pytest.mark.parametrize(
("location", "target_resolved_addr"),
[
# Loopback IPs and hostnames — blocked before DNS resolution
("http://127.0.0.1/evil", None),
("http://[::1]/evil", None),
("http://localhost/evil", None),
("http://localhost./evil", None),
("http://example.localhost/evil", None),
("http://example.localhost./evil", None),
("http://app.localhost/evil", None),
("http://sub.domain.localhost/evil", None),
# Benign hostnames resolving to blocked IPs — blocked after DNS
("http://evil.example.com:{port}/steal", "127.0.0.1"),
("http://evil.example.com:{port}/steal", "127.0.0.2"),
("http://evil.example.com:{port}/steal", "::1"),
("http://evil.example.com:{port}/steal", "0.0.0.0"),
("http://evil.example.com:{port}/steal", "::"),
],
)
async def test_redirect_to_blocked_address(
hass: HomeAssistant,
redirect_server: TestServer,
location: str,
target_resolved_addr: str | None,
) -> None:
"""Test that redirects to blocked addresses are blocked.
Covers both cases: targets blocked by hostname/IP (before DNS) and
targets blocked after DNS resolution reveals a loopback/unspecified IP.
"""
session = client.async_create_clientsession(hass)
server_port = redirect_server.port
target = location.format(port=server_port)
redirect_url = f"http://external.example.com:{server_port}/redirect?to={target}"
async def mock_async_resolve_host(host: str) -> list[dict[str, object]]:
"""Return public IP for origin, optional blocked IP for target."""
if host == "external.example.com":
return _resolve_result(host, "93.184.216.34")
if target_resolved_addr is not None:
return _resolve_result(host, target_resolved_addr)
return []
connector = session.connector
with (
patch.object(connector, "async_resolve_host", mock_async_resolve_host),
pytest.raises(client.SSRFRedirectError),
):
await session.get(redirect_url)

View File

@@ -4019,6 +4019,7 @@ async def test_parallel_error(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test parallel action failure handling."""
await async_setup_component(hass, "homeassistant", {})
events = async_capture_events(hass, "test_event")
sequence = cv.SCRIPT_SCHEMA(
{
@@ -4072,6 +4073,7 @@ async def test_last_triggered(hass: HomeAssistant) -> None:
async def test_propagate_error_service_not_found(hass: HomeAssistant) -> None:
"""Test that a script aborts when a service is not found."""
await async_setup_component(hass, "homeassistant", {})
event = "test_event"
events = async_capture_events(hass, event)
sequence = cv.SCRIPT_SCHEMA([{"action": "test.script"}, {"event": event}])
@@ -6289,6 +6291,7 @@ async def test_continue_on_error_with_stop(hass: HomeAssistant) -> None:
async def test_continue_on_error_automation_issue(hass: HomeAssistant) -> None:
"""Test continue on error doesn't block action automation errors."""
await async_setup_component(hass, "homeassistant", {})
sequence = cv.SCRIPT_SCHEMA(
[
{
@@ -6325,6 +6328,7 @@ async def test_continue_on_error_automation_issue(hass: HomeAssistant) -> None:
async def test_continue_on_error_unknown_error(hass: HomeAssistant) -> None:
"""Test continue on error doesn't block unknown errors from e.g., libraries."""
await async_setup_component(hass, "homeassistant", {})
class MyLibraryError(Exception):
"""My custom library error."""
@@ -6420,6 +6424,7 @@ async def test_disabled_actions(
async def test_enabled_error_non_limited_template(hass: HomeAssistant) -> None:
"""Test that a script aborts when an action enabled uses non-limited template."""
await async_setup_component(hass, "homeassistant", {})
event = "test_event"
events = async_capture_events(hass, event)
sequence = cv.SCRIPT_SCHEMA(

View File

@@ -1511,6 +1511,7 @@ async def test_register_with_mixed_case(hass: HomeAssistant) -> None:
async def test_call_with_required_features(hass: HomeAssistant, mock_entities) -> None:
"""Test service calls invoked only if entity has required features."""
# Set up homeassistant component to fetch the translations
await async_setup_component(hass, "homeassistant", {})
test_service_mock = AsyncMock(return_value=None)
await service.entity_service_call(
hass,
@@ -1617,6 +1618,8 @@ async def test_call_with_device_class(
unsupported_entity: str,
) -> None:
"""Test service calls invoked only if entity has required features."""
# Set up homeassistant component to fetch the translations
await async_setup_component(hass, "homeassistant", {})
test_service_mock = AsyncMock(return_value=None)
await service.entity_service_call(
hass,

View File

@@ -78,7 +78,7 @@
}),
dict({
'has_exc_info': True,
'message': 'Unknown error calling custom_validator_bad_2 config validator - broken',
'message': 'config_validator_unknown_err',
}),
])
# ---
@@ -146,7 +146,7 @@
}),
dict({
'has_exc_info': True,
'message': 'Unknown error calling custom_validator_bad_2 config validator - broken',
'message': 'config_validator_unknown_err',
}),
])
# ---
@@ -262,7 +262,7 @@
}),
dict({
'has_exc_info': True,
'message': 'Unknown error calling custom_validator_bad_2 config validator - broken',
'message': 'config_validator_unknown_err',
}),
])
# ---
@@ -306,7 +306,7 @@
}),
dict({
'has_exc_info': True,
'message': 'Unknown error calling custom_validator_bad_2 config validator - broken',
'message': 'config_validator_unknown_err',
}),
dict({
'has_exc_info': False,
@@ -357,7 +357,7 @@
''',
"Invalid config for 'custom_validator_ok_2' at configuration.yaml, line 52: required key 'host' not provided, please check the docs at https://www.home-assistant.io/integrations/custom_validator_ok_2",
"Invalid config for 'custom_validator_bad_1' at configuration.yaml, line 55: broken, please check the docs at https://www.home-assistant.io/integrations/custom_validator_bad_1",
'Unknown error calling custom_validator_bad_2 config validator - broken',
'config_validator_unknown_err',
])
# ---
# name: test_individual_packages_schema_validation_errors[packages_dict]

View File

@@ -22,6 +22,7 @@ from homeassistant.exceptions import ConfigValidationError, HomeAssistantError
from homeassistant.helpers import check_config, config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import Integration, async_get_integration
from homeassistant.setup import async_setup_component
from homeassistant.util.yaml import SECRET_YAML
from homeassistant.util.yaml.objects import NodeDictClass
@@ -851,6 +852,9 @@ async def test_component_config_exceptions(
},
)
# Make sure the exception translation cache is loaded
await async_setup_component(hass, "homeassistant", {})
test_integration = Mock(
domain="test_domain",
async_get_component=AsyncMock(),
@@ -1285,6 +1289,9 @@ async def test_component_config_error_processing(
) -> None:
"""Test component config error processing."""
# Make sure the exception translation cache is loaded
await async_setup_component(hass, "homeassistant", {})
test_integration = Mock(
domain="test_domain",
documentation="https://example.com",

View File

@@ -58,6 +58,7 @@ from homeassistant.exceptions import (
ServiceValidationError,
)
from homeassistant.helpers.json import json_dumps
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from homeassistant.util.async_ import create_eager_task
from homeassistant.util.read_only_dict import ReadOnlyDict
@@ -1347,6 +1348,18 @@ async def test_eventbus_max_length_exceeded(hass: HomeAssistant) -> None:
"this_event_exceeds_the_max_character_length_even_with_the_new_limit"
)
# Without cached translations the translation key is returned
with pytest.raises(MaxLengthExceeded) as exc_info:
hass.bus.async_fire(long_evt_name)
assert str(exc_info.value) == "max_length_exceeded"
assert exc_info.value.property_name == "event_type"
assert exc_info.value.max_length == 64
assert exc_info.value.value == long_evt_name
# Fetch translations
await async_setup_component(hass, "homeassistant", {})
# With cached translations the formatted message is returned
with pytest.raises(MaxLengthExceeded) as exc_info:
hass.bus.async_fire(long_evt_name)
@@ -1355,7 +1368,6 @@ async def test_eventbus_max_length_exceeded(hass: HomeAssistant) -> None:
str(exc_info.value)
== f"Value {long_evt_name} for property event_type has a maximum length of 64 characters"
)
assert exc_info.value.translation_key == "max_length_exceeded"
assert exc_info.value.property_name == "event_type"
assert exc_info.value.max_length == 64
assert exc_info.value.value == long_evt_name
@@ -1720,6 +1732,7 @@ async def test_serviceregistry_remove_service(hass: HomeAssistant) -> None:
async def test_serviceregistry_service_that_not_exists(hass: HomeAssistant) -> None:
"""Test remove service that not exists."""
await async_setup_component(hass, "homeassistant", {})
calls_remove = async_capture_events(hass, EVENT_SERVICE_REMOVED)
assert not hass.services.has_service("test_xxx", "test_yyy")
hass.services.async_remove("test_xxx", "test_yyy")
@@ -1817,6 +1830,7 @@ async def test_services_call_return_response_requires_blocking(
hass: HomeAssistant,
) -> None:
"""Test that non-blocking service calls cannot ask for response data."""
await async_setup_component(hass, "homeassistant", {})
async_mock_service(hass, "test_domain", "test_service")
with pytest.raises(ServiceValidationError, match="blocking=False") as exc:
await hass.services.async_call(
@@ -1846,6 +1860,7 @@ async def test_serviceregistry_return_response_invalid(
hass: HomeAssistant, response_data: Any, expected_error: str
) -> None:
"""Test service call response data must be json serializable objects."""
await async_setup_component(hass, "homeassistant", {})
def service_handler(call: ServiceCall) -> ServiceResponse:
"""Service handler coroutine."""
@@ -1882,6 +1897,7 @@ async def test_serviceregistry_return_response_arguments(
expected_error: str,
) -> None:
"""Test service call response data invalid arguments."""
await async_setup_component(hass, "homeassistant", {})
hass.services.async_register(
"test_domain",