mirror of
https://github.com/home-assistant/core.git
synced 2026-02-14 02:49:32 +01:00
Compare commits
5 Commits
edenhaus-t
...
homewizard
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42308f8b68 | ||
|
|
21bf96e1ad | ||
|
|
365bd95963 | ||
|
|
d889217944 | ||
|
|
6b8915dcba |
578
.github/workflows/builder.yml
vendored
578
.github/workflows/builder.yml
vendored
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ...] = (
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -675,7 +675,7 @@ exclude_lines = [
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
required-version = ">=0.15.1"
|
||||
required-version = ">=0.15.0"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
|
||||
2
requirements_test_pre_commit.txt
generated
2
requirements_test_pre_commit.txt
generated
@@ -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
|
||||
|
||||
2
script/hassfest/docker/Dockerfile
generated
2
script/hassfest/docker/Dockerfile
generated
@@ -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>"
|
||||
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"product_type": "HWE-KWH1",
|
||||
"product_name": "kWh meter",
|
||||
"serial": "5c2fafabcdef",
|
||||
"firmware_version": "5.0103",
|
||||
"api_version": "v2"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"cloud_enabled": true
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"product_type": "HWE-SKT",
|
||||
"product_name": "Energy Socket",
|
||||
"serial": "5c2fafabcdef",
|
||||
"firmware_version": "4.07",
|
||||
"api_version": "v1"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"power_on": true,
|
||||
"switch_lock": false,
|
||||
"brightness": 255
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"cloud_enabled": true
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>,
|
||||
}),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
})
|
||||
# ---
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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__
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user