Compare commits

..

21 Commits

Author SHA1 Message Date
Stefan Agner
f73223b6d6 Fix Abort exception caught by wrong handler in backup encrypt/decrypt
The broad `except Exception` clause was catching `Abort` exceptions
before they could propagate to the outer `except Abort` handler.
This caused spurious "Unexpected error when decrypting backup" log
messages when a backup operation was intentionally aborted.

Add explicit `except Abort: raise` before the `except Exception`
clause in both encrypt_backup and decrypt_backup functions.

Also fix copy-paste error in encrypt_backup where the log message
incorrectly said "decrypting" instead of "encrypting".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:24:54 +01:00
Raman Gupta
754828188e Refactor Vizio integration to use DataUpdateCoordinator (#162188)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 17:20:01 -04:00
Erik Montnemery
6992a3c72b Adjust name and docstring of some trigger tests (#165846) 2026-03-17 22:11:32 +01:00
Joost Lekkerkerker
738d4f662a Bump pySmartThings to 3.7.2 (#165810) 2026-03-17 21:57:20 +01:00
Carlos Sánchez López
7f33ac72ab Add alarm control panel support for Tuya WG2 alarm panel (Duosmart C30) (#165837) 2026-03-17 21:44:57 +01:00
Carlos Sánchez López
0891d814fa Add sensor support for Tuya WG2 alarm panel (Duosmart C30) (#165834) 2026-03-17 21:42:20 +01:00
Carlos Sánchez López
ddab50edcc Add binary sensor support for Tuya WG2 alarm panel (Duosmart C30) (#165833) 2026-03-17 21:41:57 +01:00
Erik Montnemery
c8ce4eb32d Deduplicate tests testing conditions in mode all (#165841) 2026-03-17 21:06:26 +01:00
Jan Bouwhuis
22aca8b7af Add clean segment support to MQTT vacuum entities (#164983)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2026-03-17 20:27:42 +01:00
Erik Montnemery
770864082f Deduplicate tests testing conditions in mode any (#165801) 2026-03-17 19:23:47 +00:00
Abílio Costa
14545660e2 Make TODO subscriptions use TodoItem instead of JSON (#165802) 2026-03-17 19:09:13 +00:00
Jamie Magee
836353015b Detect new garage doors automatically in aladdin_connect (#165004) 2026-03-17 20:04:31 +01:00
Allen Porter
c57ffd4d78 Update python-roborock dependency to 4.25.0. (#165800)
Co-authored-by: Ludovic BOUÉ <lboue@users.noreply.github.com>
2026-03-17 19:58:18 +01:00
prana-dev-official
cbebfdf149 Add number platform for Prana integration (#165816) 2026-03-17 19:53:50 +01:00
Ludovic BOUÉ
d8ed9ca66f Fix timestamps in chess_com test diagnostics (#165829) 2026-03-17 19:30:08 +01:00
Cody
5caf8a5b83 Make Season integration timezone aware (#164876) 2026-03-17 18:09:25 +01:00
Aidan Timson
c05210683e Demo valve registry entry and device (#165803)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-17 17:37:21 +01:00
Joost Lekkerkerker
aa8dd4bb66 Add microfiber filter fixture to SmartThings (#165808) 2026-03-17 17:21:51 +01:00
Joost Lekkerkerker
ee7d6157d9 Fix Indevolt button snapshot (#165812) 2026-03-17 17:19:03 +01:00
Manu
adec1d128c Add exception handling to media source in Radio Browser integration (#164653) 2026-03-17 17:13:11 +01:00
prana-dev-official
0a2fc97696 Import improvement for Prana integration (#165805) 2026-03-17 16:28:53 +01:00
100 changed files with 3515 additions and 1636 deletions

1
.gitattributes vendored
View File

@@ -16,7 +16,6 @@ Dockerfile.dev linguist-language=Dockerfile
CODEOWNERS linguist-generated=true
Dockerfile linguist-generated=true
homeassistant/generated/*.py linguist-generated=true
machine/* linguist-generated=true
mypy.ini linguist-generated=true
requirements.txt linguist-generated=true
requirements_all.txt linguist-generated=true

View File

@@ -35,7 +35,6 @@ jobs:
channel: ${{ steps.version.outputs.channel }}
publish: ${{ steps.version.outputs.publish }}
architectures: ${{ env.ARCHITECTURES }}
base_image_version: ${{ env.BASE_IMAGE_VERSION }}
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -57,10 +56,10 @@ jobs:
with:
type: ${{ env.BUILD_TYPE }}
# - name: Verify version
# uses: home-assistant/actions/helpers/verify-version@master # zizmor: ignore[unpinned-uses]
# with:
# ignore-dev: true
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@master # zizmor: ignore[unpinned-uses]
with:
ignore-dev: true
- name: Fail if translations files are checked in
run: |
@@ -75,8 +74,43 @@ jobs:
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
- name: Archive translations
shell: bash
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
- name: Upload translations
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: translations
path: translations.tar.gz
if-no-files-found: error
build_base:
name: Build ${{ matrix.arch }} base core image
if: github.repository_owner == 'home-assistant'
needs: init
runs-on: ${{ matrix.os }}
permissions:
contents: read # To check out the repository
packages: write # To push to GHCR
id-token: write # For cosign signing
strategy:
fail-fast: false
matrix:
arch: ${{ fromJson(needs.init.outputs.architectures) }}
include:
- arch: amd64
os: ubuntu-latest
- arch: aarch64
os: ubuntu-24.04-arm
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Download nightly wheels of frontend
if: steps.version.outputs.channel == 'dev'
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
with:
github_token: ${{secrets.GITHUB_TOKEN}}
@@ -87,7 +121,7 @@ jobs:
name: wheels
- name: Download nightly wheels of intents
if: steps.version.outputs.channel == 'dev'
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
with:
github_token: ${{secrets.GITHUB_TOKEN}}
@@ -97,12 +131,18 @@ jobs:
workflow_conclusion: success
name: package
- name: Set up Python
if: needs.init.outputs.channel == 'dev'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version-file: ".python-version"
- name: Adjust nightly version
if: steps.version.outputs.channel == 'dev'
if: needs.init.outputs.channel == 'dev'
shell: bash
env:
UV_PRERELEASE: allow
VERSION: ${{ steps.version.outputs.version }}
VERSION: ${{ needs.init.outputs.version }}
run: |
python3 -m pip install "$(grep '^uv' < requirements.txt)"
uv pip install packaging tomli
@@ -140,72 +180,92 @@ jobs:
sed -i "s|home-assistant-intents==.*||" requirements_all.txt requirements.txt
fi
- name: Download translations
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: translations
- name: Extract translations
run: |
tar xvf translations.tar.gz
rm translations.tar.gz
- name: Write meta info file
shell: bash
run: |
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
- name: Upload build context overlay
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
name: build-context
if-no-files-found: ignore
path: |
homeassistant/components/*/translations/
rootfs/OFFICIAL_IMAGE
home_assistant_frontend-*.whl
home_assistant_intents-*.whl
homeassistant/const.py
homeassistant/components/frontend/manifest.json
homeassistant/components/conversation/manifest.json
homeassistant/package_constraints.txt
requirements_all.txt
requirements.txt
pyproject.toml
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
build_base:
name: Build ${{ matrix.arch }} base core image
if: github.repository_owner == 'home-assistant'
needs: init
runs-on: ${{ matrix.os }}
permissions:
contents: read # To check out the repository
packages: write # To push to GHCR
id-token: write # For cosign signing
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
os: ubuntu-24.04
- arch: aarch64
os: ubuntu-24.04-arm
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Cosign
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
with:
persist-credentials: false
cosign-release: "v2.5.3"
- name: Download build context overlay
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: build-context
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build variables
id: vars
shell: bash
env:
ARCH: ${{ matrix.arch }}
run: |
echo "base_image=ghcr.io/home-assistant/${ARCH}-homeassistant-base:${BASE_IMAGE_VERSION}" >> "$GITHUB_OUTPUT"
echo "cache_image=ghcr.io/home-assistant/${ARCH}-homeassistant:latest" >> "$GITHUB_OUTPUT"
echo "created=$(date --rfc-3339=seconds --utc)" >> "$GITHUB_OUTPUT"
- name: Verify base image signature
env:
BASE_IMAGE: ${{ steps.vars.outputs.base_image }}
run: |
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp "https://github.com/home-assistant/docker/.*" \
"${BASE_IMAGE}"
- name: Verify cache image signature
id: cache
continue-on-error: true
env:
CACHE_IMAGE: ${{ steps.vars.outputs.cache_image }}
run: |
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp "https://github.com/home-assistant/core/.*" \
"${CACHE_IMAGE}"
- name: Build base image
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
id: build
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
arch: ${{ matrix.arch }}
build-args: |
BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ needs.init.outputs.base_image_version }}
cache-gha: false
container-registry-password: ${{ secrets.GITHUB_TOKEN }}
context: .
cosign-base-identity: "https://github.com/home-assistant/docker/.*"
cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ needs.init.outputs.base_image_version }}
image: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant
image-tags: ${{ needs.init.outputs.version }}
file: ./Dockerfile
platforms: ${{ steps.vars.outputs.platform }}
push: true
version: ${{ needs.init.outputs.version }}
cache-from: ${{ steps.cache.outcome == 'success' && steps.vars.outputs.cache_image || '' }}
build-args: |
BUILD_FROM=${{ steps.vars.outputs.base_image }}
tags: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
outputs: type=image,push=true,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true
labels: |
io.hass.arch=${{ matrix.arch }}
io.hass.version=${{ needs.init.outputs.version }}
org.opencontainers.image.created=${{ steps.vars.outputs.created }}
org.opencontainers.image.version=${{ needs.init.outputs.version }}
- name: Sign image
env:
ARCH: ${{ matrix.arch }}
VERSION: ${{ needs.init.outputs.version }}
DIGEST: ${{ steps.build.outputs.digest }}
run: |
cosign sign --yes "ghcr.io/home-assistant/${ARCH}-homeassistant:${VERSION}@${DIGEST}"
build_machine:
name: Build ${{ matrix.machine }} machine core image
@@ -254,305 +314,308 @@ jobs:
with:
persist-credentials: false
- name: Compute extra tags
id: tags
shell: bash
- name: Set build additional args
env:
VERSION: ${{ needs.init.outputs.version }}
run: |
# Create general tags
if [[ "${VERSION}" =~ d ]]; then
echo "extra_tags=dev" >> "$GITHUB_OUTPUT"
echo "BUILD_ARGS=--additional-tag dev" >> $GITHUB_ENV
elif [[ "${VERSION}" =~ b ]]; then
echo "extra_tags=beta" >> "$GITHUB_OUTPUT"
echo "BUILD_ARGS=--additional-tag beta" >> $GITHUB_ENV
else
echo "extra_tags=stable" >> "$GITHUB_OUTPUT"
echo "BUILD_ARGS=--additional-tag stable" >> $GITHUB_ENV
fi
- name: Build machine image
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
arch: ${{ matrix.arch }}
build-args: |
BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
cache-gha: false
container-registry-password: ${{ secrets.GITHUB_TOKEN }}
context: machine/
cosign-base-identity: "https://github.com/home-assistant/core/.*"
cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
file: machine/${{ matrix.machine }}
image: ghcr.io/home-assistant/${{ matrix.machine }}-homeassistant
image-tags: |
${{ needs.init.outputs.version }}
${{ steps.tags.outputs.extra_tags }}
push: true
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image
uses: home-assistant/builder@6cb4fd3d1338b6e22d0958a4bcb53e0965ea63b4 # 2026.02.1
with:
image: ${{ matrix.arch }}
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
permissions:
contents: read
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Initialize git
uses: home-assistant/actions/helpers/git-init@master # zizmor: ignore[unpinned-uses]
with:
name: ${{ secrets.GIT_NAME }}
email: ${{ secrets.GIT_EMAIL }}
token: ${{ secrets.GIT_TOKEN }}
- name: Update version file
uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
with:
key: "homeassistant[]"
key-description: "Home Assistant Core"
version: ${{ needs.init.outputs.version }}
# publish_ha:
# name: Publish version files
# environment: ${{ needs.init.outputs.channel }}
# if: github.repository_owner == 'home-assistant'
# needs: ["init", "build_machine"]
# runs-on: ubuntu-latest
# permissions:
# contents: read
# steps:
# - name: Checkout the repository
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# with:
# persist-credentials: false
#
# - name: Initialize git
# uses: home-assistant/actions/helpers/git-init@master # zizmor: ignore[unpinned-uses]
# with:
# name: ${{ secrets.GIT_NAME }}
# email: ${{ secrets.GIT_EMAIL }}
# token: ${{ secrets.GIT_TOKEN }}
#
# - name: Update version file
# uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
# with:
# key: "homeassistant[]"
# key-description: "Home Assistant Core"
# version: ${{ needs.init.outputs.version }}
# channel: ${{ needs.init.outputs.channel }}
# exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
#
# - name: Update version file (stable -> beta)
# if: needs.init.outputs.channel == 'stable'
# uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
# with:
# key: "homeassistant[]"
# key-description: "Home Assistant Core"
# version: ${{ needs.init.outputs.version }}
# channel: beta
# exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
#
# publish_container:
# name: Publish meta container for ${{ matrix.registry }}
# environment: ${{ needs.init.outputs.channel }}
# if: github.repository_owner == 'home-assistant'
# needs: ["init", "build_base"]
# runs-on: ubuntu-latest
# permissions:
# contents: read # To check out the repository
# packages: write # To push to GHCR
# id-token: write # For cosign signing
# strategy:
# fail-fast: false
# matrix:
# registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
# steps:
# - name: Install Cosign
# uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
# with:
# cosign-release: "v2.5.3"
#
# - name: Login to DockerHub
# if: matrix.registry == 'docker.io/homeassistant'
# uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
# with:
# username: ${{ secrets.DOCKERHUB_USERNAME }}
# password: ${{ secrets.DOCKERHUB_TOKEN }}
#
# - name: Login to GitHub Container Registry
# uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
# with:
# registry: ghcr.io
# username: ${{ github.repository_owner }}
# password: ${{ secrets.GITHUB_TOKEN }}
#
# - name: Verify architecture image signatures
# shell: bash
# env:
# ARCHITECTURES: ${{ needs.init.outputs.architectures }}
# VERSION: ${{ needs.init.outputs.version }}
# run: |
# ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
# for arch in $ARCHS; do
# echo "Verifying ${arch} image signature..."
# cosign verify \
# --certificate-oidc-issuer https://token.actions.githubusercontent.com \
# --certificate-identity-regexp https://github.com/home-assistant/core/.* \
# "ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"
# done
# echo "✓ All images verified successfully"
#
# # Generate all Docker tags based on version string
# # Version format: YYYY.MM.PATCH, YYYY.MM.PATCHbN (beta), or YYYY.MM.PATCH.devYYYYMMDDHHMM (dev)
# # Examples:
# # 2025.12.1 (stable) -> tags: 2025.12.1, 2025.12, stable, latest, beta, rc
# # 2025.12.0b3 (beta) -> tags: 2025.12.0b3, beta, rc
# # 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
# - name: Generate Docker metadata
# id: meta
# uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
# with:
# images: ${{ matrix.registry }}/home-assistant
# sep-tags: ","
# tags: |
# type=raw,value=${{ needs.init.outputs.version }},priority=9999
# type=raw,value=dev,enable=${{ contains(needs.init.outputs.version, 'd') }}
# type=raw,value=beta,enable=${{ !contains(needs.init.outputs.version, 'd') }}
# type=raw,value=rc,enable=${{ !contains(needs.init.outputs.version, 'd') }}
# type=raw,value=stable,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
# type=raw,value=latest,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
# type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
#
# - name: Set up Docker Buildx
# uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
#
# - name: Copy architecture images to DockerHub
# if: matrix.registry == 'docker.io/homeassistant'
# shell: bash
# env:
# ARCHITECTURES: ${{ needs.init.outputs.architectures }}
# VERSION: ${{ needs.init.outputs.version }}
# run: |
# # Use imagetools to copy image blobs directly between registries
# # This preserves provenance/attestations and seems to be much faster than pull/push
# ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
# for arch in $ARCHS; do
# echo "Copying ${arch} image to DockerHub..."
# for attempt in 1 2 3; do
# if docker buildx imagetools create \
# --tag "docker.io/homeassistant/${arch}-homeassistant:${VERSION}" \
# "ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"; then
# break
# fi
# echo "Attempt ${attempt} failed, retrying in 10 seconds..."
# sleep 10
# if [ "${attempt}" -eq 3 ]; then
# echo "Failed after 3 attempts"
# exit 1
# fi
# done
# cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${VERSION}"
# done
#
# - name: Create and push multi-arch manifests
# shell: bash
# env:
# ARCHITECTURES: ${{ needs.init.outputs.architectures }}
# REGISTRY: ${{ matrix.registry }}
# VERSION: ${{ needs.init.outputs.version }}
# META_TAGS: ${{ steps.meta.outputs.tags }}
# run: |
# # Build list of architecture images dynamically
# ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
# ARCH_IMAGES=()
# for arch in $ARCHS; do
# ARCH_IMAGES+=("${REGISTRY}/${arch}-homeassistant:${VERSION}")
# done
#
# # Build list of all tags for single manifest creation
# # Note: Using sep-tags=',' in metadata-action for easier parsing
# TAG_ARGS=()
# IFS=',' read -ra TAGS <<< "${META_TAGS}"
# for tag in "${TAGS[@]}"; do
# TAG_ARGS+=("--tag" "${tag}")
# done
#
# # Create manifest with ALL tags in a single operation (much faster!)
# echo "Creating multi-arch manifest with tags: ${TAGS[*]}"
# docker buildx imagetools create "${TAG_ARGS[@]}" "${ARCH_IMAGES[@]}"
#
# # 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"
#
# build_python:
# name: Build PyPi package
# environment: ${{ needs.init.outputs.channel }}
# needs: ["init", "build_base"]
# runs-on: ubuntu-latest
# permissions:
# contents: read # To check out the repository
# id-token: write # For PyPI trusted publishing
# if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
# steps:
# - name: Checkout the repository
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# with:
# persist-credentials: false
#
# - name: Set up Python
# uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
# with:
# python-version-file: ".python-version"
#
# - name: Download build context overlay
# uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
# with:
# name: build-context
#
# - 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
#
# hassfest-image:
# name: Build and test hassfest image
# runs-on: ubuntu-latest
# permissions:
# contents: read # To check out the repository
# packages: write # To push to GHCR
# attestations: write # For build provenance attestation
# id-token: write # For build provenance attestation
# needs: ["init"]
# if: github.repository_owner == 'home-assistant'
# env:
# HASSFEST_IMAGE_NAME: ghcr.io/home-assistant/hassfest
# HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
# steps:
# - name: Checkout repository
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# with:
# persist-credentials: false
#
# - name: Login to GitHub Container Registry
# uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
# with:
# registry: ghcr.io
# username: ${{ github.repository_owner }}
# password: ${{ secrets.GITHUB_TOKEN }}
#
# - name: Build Docker image
# uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
# with:
# context: . # So action will not pull the repository again
# file: ./script/hassfest/docker/Dockerfile
# load: true
# tags: ${{ env.HASSFEST_IMAGE_TAG }}
#
# - name: Run hassfest against core
# run: docker run --rm -v "${GITHUB_WORKSPACE}":/github/workspace "${HASSFEST_IMAGE_TAG}" --core-path=/github/workspace
#
# - name: Push Docker image
# if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
# id: push
# uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
# with:
# context: . # So action will not pull the repository again
# file: ./script/hassfest/docker/Dockerfile
# push: true
# tags: ${{ env.HASSFEST_IMAGE_TAG }},${{ env.HASSFEST_IMAGE_NAME }}:latest
#
# - name: Generate artifact attestation
# if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
# uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
# with:
# subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
# subject-digest: ${{ steps.push.outputs.digest }}
# push-to-registry: true
channel: ${{ needs.init.outputs.channel }}
exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
- name: Update version file (stable -> beta)
if: needs.init.outputs.channel == 'stable'
uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
with:
key: "homeassistant[]"
key-description: "Home Assistant Core"
version: ${{ needs.init.outputs.version }}
channel: beta
exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
publish_container:
name: Publish meta container for ${{ matrix.registry }}
environment: ${{ needs.init.outputs.channel }}
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"]
runs-on: ubuntu-latest
permissions:
contents: read # To check out the repository
packages: write # To push to GHCR
id-token: write # For cosign signing
strategy:
fail-fast: false
matrix:
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
steps:
- name: Install Cosign
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
with:
cosign-release: "v2.5.3"
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Verify architecture image signatures
shell: bash
env:
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
VERSION: ${{ needs.init.outputs.version }}
run: |
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
for arch in $ARCHS; do
echo "Verifying ${arch} image signature..."
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp https://github.com/home-assistant/core/.* \
"ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"
done
echo "✓ All images verified successfully"
# Generate all Docker tags based on version string
# Version format: YYYY.MM.PATCH, YYYY.MM.PATCHbN (beta), or YYYY.MM.PATCH.devYYYYMMDDHHMM (dev)
# Examples:
# 2025.12.1 (stable) -> tags: 2025.12.1, 2025.12, stable, latest, beta, rc
# 2025.12.0b3 (beta) -> tags: 2025.12.0b3, beta, rc
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ matrix.registry }}/home-assistant
sep-tags: ","
tags: |
type=raw,value=${{ needs.init.outputs.version }},priority=9999
type=raw,value=dev,enable=${{ contains(needs.init.outputs.version, 'd') }}
type=raw,value=beta,enable=${{ !contains(needs.init.outputs.version, 'd') }}
type=raw,value=rc,enable=${{ !contains(needs.init.outputs.version, 'd') }}
type=raw,value=stable,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
type=raw,value=latest,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
- name: Copy architecture images to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
shell: bash
env:
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
VERSION: ${{ needs.init.outputs.version }}
run: |
# Use imagetools to copy image blobs directly between registries
# This preserves provenance/attestations and seems to be much faster than pull/push
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
for arch in $ARCHS; do
echo "Copying ${arch} image to DockerHub..."
for attempt in 1 2 3; do
if docker buildx imagetools create \
--tag "docker.io/homeassistant/${arch}-homeassistant:${VERSION}" \
"ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"; then
break
fi
echo "Attempt ${attempt} failed, retrying in 10 seconds..."
sleep 10
if [ "${attempt}" -eq 3 ]; then
echo "Failed after 3 attempts"
exit 1
fi
done
cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${VERSION}"
done
- name: Create and push multi-arch manifests
shell: bash
env:
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
REGISTRY: ${{ matrix.registry }}
VERSION: ${{ needs.init.outputs.version }}
META_TAGS: ${{ steps.meta.outputs.tags }}
run: |
# Build list of architecture images dynamically
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
ARCH_IMAGES=()
for arch in $ARCHS; do
ARCH_IMAGES+=("${REGISTRY}/${arch}-homeassistant:${VERSION}")
done
# Build list of all tags for single manifest creation
# Note: Using sep-tags=',' in metadata-action for easier parsing
TAG_ARGS=()
IFS=',' read -ra TAGS <<< "${META_TAGS}"
for tag in "${TAGS[@]}"; do
TAG_ARGS+=("--tag" "${tag}")
done
# Create manifest with ALL tags in a single operation (much faster!)
echo "Creating multi-arch manifest with tags: ${TAGS[*]}"
docker buildx imagetools create "${TAG_ARGS[@]}" "${ARCH_IMAGES[@]}"
# 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"
build_python:
name: Build PyPi package
environment: ${{ needs.init.outputs.channel }}
needs: ["init", "build_base"]
runs-on: ubuntu-latest
permissions:
contents: read # To check out the repository
id-token: write # For PyPI trusted publishing
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version-file: ".python-version"
- name: Download translations
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: translations
- 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: 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 # To check out the repository
packages: write # To push to GHCR
attestations: write # For build provenance attestation
id-token: write # For build provenance attestation
needs: ["init"]
if: github.repository_owner == 'home-assistant'
env:
HASSFEST_IMAGE_NAME: ghcr.io/home-assistant/hassfest
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
load: true
tags: ${{ env.HASSFEST_IMAGE_TAG }}
- name: Run hassfest against core
run: docker run --rm -v "${GITHUB_WORKSPACE}":/github/workspace "${HASSFEST_IMAGE_TAG}" --core-path=/github/workspace
- name: Push Docker image
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
id: push
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
push: true
tags: ${{ env.HASSFEST_IMAGE_TAG }},${{ env.HASSFEST_IMAGE_NAME }}:latest
- name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true

1
Dockerfile generated
View File

@@ -10,6 +10,7 @@ LABEL \
org.opencontainers.image.description="Open-source home automation platform running on Python 3" \
org.opencontainers.image.documentation="https://www.home-assistant.io/docs/" \
org.opencontainers.image.licenses="Apache-2.0" \
org.opencontainers.image.source="https://github.com/home-assistant/core" \
org.opencontainers.image.title="Home Assistant" \
org.opencontainers.image.url="https://www.home-assistant.io/"

View File

@@ -46,19 +46,10 @@ async def async_setup_entry(
api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session)
)
try:
doors = await client.get_doors()
except aiohttp.ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(err) from err
raise ConfigEntryNotReady from err
except aiohttp.ClientError as err:
raise ConfigEntryNotReady from err
coordinator = AladdinConnectCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = {
door.unique_id: AladdinConnectCoordinator(hass, entry, client, door)
for door in doors
}
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -100,7 +91,7 @@ def remove_stale_devices(
device_entries = dr.async_entries_for_config_entry(
device_registry, config_entry.entry_id
)
all_device_ids = set(config_entry.runtime_data)
all_device_ids = set(config_entry.runtime_data.data)
for device_entry in device_entries:
device_id: str | None = None

View File

@@ -11,22 +11,24 @@ from genie_partner_sdk.model import GarageDoor
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
_LOGGER = logging.getLogger(__name__)
type AladdinConnectConfigEntry = ConfigEntry[dict[str, AladdinConnectCoordinator]]
type AladdinConnectConfigEntry = ConfigEntry[AladdinConnectCoordinator]
SCAN_INTERVAL = timedelta(seconds=15)
class AladdinConnectCoordinator(DataUpdateCoordinator[GarageDoor]):
class AladdinConnectCoordinator(DataUpdateCoordinator[dict[str, GarageDoor]]):
"""Coordinator for Aladdin Connect integration."""
config_entry: AladdinConnectConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: AladdinConnectConfigEntry,
client: AladdinConnectClient,
garage_door: GarageDoor,
) -> None:
"""Initialize the coordinator."""
super().__init__(
@@ -37,18 +39,16 @@ class AladdinConnectCoordinator(DataUpdateCoordinator[GarageDoor]):
update_interval=SCAN_INTERVAL,
)
self.client = client
self.data = garage_door
async def _async_update_data(self) -> GarageDoor:
async def _async_update_data(self) -> dict[str, GarageDoor]:
"""Fetch data from the Aladdin Connect API."""
try:
await self.client.update_door(self.data.device_id, self.data.door_number)
doors = await self.client.get_doors()
except aiohttp.ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(err) from err
raise UpdateFailed(f"Error communicating with API: {err}") from err
except aiohttp.ClientError as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
self.data.status = self.client.get_door_status(
self.data.device_id, self.data.door_number
)
self.data.battery_level = self.client.get_battery_status(
self.data.device_id, self.data.door_number
)
return self.data
return {door.unique_id: door for door in doors}

View File

@@ -7,7 +7,7 @@ from typing import Any
import aiohttp
from homeassistant.components.cover import CoverDeviceClass, CoverEntity
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -24,11 +24,22 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the cover platform."""
coordinators = entry.runtime_data
coordinator = entry.runtime_data
known_devices: set[str] = set()
async_add_entities(
AladdinCoverEntity(coordinator) for coordinator in coordinators.values()
)
@callback
def _async_add_new_devices() -> None:
"""Detect and add entities for new doors."""
current_devices = set(coordinator.data)
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
AladdinCoverEntity(coordinator, door_id) for door_id in new_devices
)
_async_add_new_devices()
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
@@ -38,10 +49,10 @@ class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
_attr_supported_features = SUPPORTED_FEATURES
_attr_name = None
def __init__(self, coordinator: AladdinConnectCoordinator) -> None:
def __init__(self, coordinator: AladdinConnectCoordinator, door_id: str) -> None:
"""Initialize the Aladdin Connect cover."""
super().__init__(coordinator)
self._attr_unique_id = coordinator.data.unique_id
super().__init__(coordinator, door_id)
self._attr_unique_id = door_id
async def async_open_cover(self, **kwargs: Any) -> None:
"""Issue open command to cover."""
@@ -66,16 +77,16 @@ class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
@property
def is_closed(self) -> bool | None:
"""Update is closed attribute."""
if (status := self.coordinator.data.status) is None:
if (status := self.door.status) is None:
return None
return status == "closed"
@property
def is_closing(self) -> bool | None:
"""Update is closing attribute."""
return self.coordinator.data.status == "closing"
return self.door.status == "closing"
@property
def is_opening(self) -> bool | None:
"""Update is opening attribute."""
return self.coordinator.data.status == "opening"
return self.door.status == "opening"

View File

@@ -20,13 +20,13 @@ async def async_get_config_entry_diagnostics(
"config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT),
"doors": {
uid: {
"device_id": coordinator.data.device_id,
"door_number": coordinator.data.door_number,
"name": coordinator.data.name,
"status": coordinator.data.status,
"link_status": coordinator.data.link_status,
"battery_level": coordinator.data.battery_level,
"device_id": door.device_id,
"door_number": door.door_number,
"name": door.name,
"status": door.status,
"link_status": door.link_status,
"battery_level": door.battery_level,
}
for uid, coordinator in config_entry.runtime_data.items()
for uid, door in config_entry.runtime_data.data.items()
},
}

View File

@@ -1,6 +1,7 @@
"""Base class for Aladdin Connect entities."""
from genie_partner_sdk.client import AladdinConnectClient
from genie_partner_sdk.model import GarageDoor
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -14,17 +15,28 @@ class AladdinConnectEntity(CoordinatorEntity[AladdinConnectCoordinator]):
_attr_has_entity_name = True
def __init__(self, coordinator: AladdinConnectCoordinator) -> None:
def __init__(self, coordinator: AladdinConnectCoordinator, door_id: str) -> None:
"""Initialize Aladdin Connect entity."""
super().__init__(coordinator)
device = coordinator.data
self._door_id = door_id
door = self.door
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.unique_id)},
identifiers={(DOMAIN, door.unique_id)},
manufacturer="Aladdin Connect",
name=device.name,
name=door.name,
)
self._device_id = device.device_id
self._number = device.door_number
self._device_id = door.device_id
self._number = door.door_number
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self._door_id in self.coordinator.data
@property
def door(self) -> GarageDoor:
"""Return the garage door data."""
return self.coordinator.data[self._door_id]
@property
def client(self) -> AladdinConnectClient:

View File

@@ -57,7 +57,7 @@ rules:
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: todo
dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default: done

View File

@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator
@@ -49,13 +49,24 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Aladdin Connect sensor devices."""
coordinators = entry.runtime_data
coordinator = entry.runtime_data
known_devices: set[str] = set()
async_add_entities(
AladdinConnectSensor(coordinator, description)
for coordinator in coordinators.values()
for description in SENSOR_TYPES
)
@callback
def _async_add_new_devices() -> None:
"""Detect and add entities for new doors."""
current_devices = set(coordinator.data)
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
AladdinConnectSensor(coordinator, door_id, description)
for door_id in new_devices
for description in SENSOR_TYPES
)
_async_add_new_devices()
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
class AladdinConnectSensor(AladdinConnectEntity, SensorEntity):
@@ -66,14 +77,15 @@ class AladdinConnectSensor(AladdinConnectEntity, SensorEntity):
def __init__(
self,
coordinator: AladdinConnectCoordinator,
door_id: str,
entity_description: AladdinConnectSensorEntityDescription,
) -> None:
"""Initialize the Aladdin Connect sensor."""
super().__init__(coordinator)
super().__init__(coordinator, door_id)
self.entity_description = entity_description
self._attr_unique_id = f"{coordinator.data.unique_id}-{entity_description.key}"
self._attr_unique_id = f"{door_id}-{entity_description.key}"
@property
def native_value(self) -> float | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)
return self.entity_description.value_fn(self.door)

View File

@@ -246,6 +246,8 @@ def decrypt_backup(
except (DecryptError, SecureTarError, tarfile.TarError) as err:
LOGGER.warning("Error decrypting backup: %s", err)
error = err
except Abort:
raise
except Exception as err: # noqa: BLE001
LOGGER.exception("Unexpected error when decrypting backup: %s", err)
error = err
@@ -332,8 +334,10 @@ def encrypt_backup(
except (EncryptError, SecureTarError, tarfile.TarError) as err:
LOGGER.warning("Error encrypting backup: %s", err)
error = err
except Abort:
raise
except Exception as err: # noqa: BLE001
LOGGER.exception("Unexpected error when decrypting backup: %s", err)
LOGGER.exception("Unexpected error when encrypting backup: %s", err)
error = err
else:
# Pad the output stream to the requested minimum size

View File

@@ -9,9 +9,12 @@ from typing import Any
from homeassistant.components.valve import ValveEntity, ValveEntityFeature, ValveState
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_utc_time_change
from . import DOMAIN
OPEN_CLOSE_DELAY = 2 # Used to give a realistic open/close experience in frontend
@@ -23,10 +26,10 @@ async def async_setup_entry(
"""Set up the Demo config entry."""
async_add_entities(
[
DemoValve("Front Garden", ValveState.OPEN),
DemoValve("Orchard", ValveState.CLOSED),
DemoValve("Back Garden", ValveState.CLOSED, position=70),
DemoValve("Trees", ValveState.CLOSED, position=30),
DemoValve("valve_1", "Front Garden", ValveState.OPEN),
DemoValve("valve_2", "Orchard", ValveState.CLOSED),
DemoValve("valve_3", "Back Garden", ValveState.CLOSED, position=70),
DemoValve("valve_4", "Trees", ValveState.CLOSED, position=30),
]
)
@@ -34,17 +37,24 @@ async def async_setup_entry(
class DemoValve(ValveEntity):
"""Representation of a Demo valve."""
_attr_has_entity_name = True
_attr_name = None
_attr_should_poll = False
def __init__(
self,
unique_id: str,
name: str,
state: str,
moveable: bool = True,
position: int | None = None,
) -> None:
"""Initialize the valve."""
self._attr_name = name
self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
name=name,
)
if moveable:
self._attr_supported_features = (
ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE

View File

@@ -18,6 +18,8 @@ ABBREVIATIONS = {
"bri_stat_t": "brightness_state_topic",
"bri_tpl": "brightness_template",
"bri_val_tpl": "brightness_value_template",
"cln_segmnts_cmd_t": "clean_segments_command_topic",
"cln_segmnts_cmd_tpl": "clean_segments_command_template",
"clr_temp_cmd_tpl": "color_temp_command_template",
"clrm_stat_t": "color_mode_state_topic",
"clrm_val_tpl": "color_mode_value_template",
@@ -185,6 +187,7 @@ ABBREVIATIONS = {
"rgbww_cmd_t": "rgbww_command_topic",
"rgbww_stat_t": "rgbww_state_topic",
"rgbww_val_tpl": "rgbww_value_template",
"segmnts": "segments",
"send_cmd_t": "send_command_topic",
"send_if_off": "send_if_off",
"set_fan_spd_t": "set_fan_speed_topic",

View File

@@ -1484,6 +1484,7 @@ class MqttEntity(
self._config = config
self._setup_from_config(self._config)
self._setup_common_attributes_from_config(self._config)
self._process_entity_update()
# Prepare MQTT subscriptions
self.attributes_prepare_discovery_update(config)
@@ -1586,6 +1587,10 @@ class MqttEntity(
def _setup_from_config(self, config: ConfigType) -> None:
"""(Re)Setup the entity."""
@callback
def _process_entity_update(self) -> None:
"""Process an entity discovery update."""
@abstractmethod
@callback
def _prepare_subscribe_topics(self) -> None:

View File

@@ -10,12 +10,13 @@ import voluptuous as vol
from homeassistant.components import vacuum
from homeassistant.components.vacuum import (
ENTITY_ID_FORMAT,
Segment,
StateVacuumEntity,
VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME
from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_UNIQUE_ID
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -27,7 +28,7 @@ from . import subscription
from .config import MQTT_BASE_SCHEMA
from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC
from .entity import MqttEntity, async_setup_entity_entry_helper
from .models import ReceiveMessage
from .models import MqttCommandTemplate, ReceiveMessage
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
from .util import valid_publish_topic
@@ -52,6 +53,9 @@ POSSIBLE_STATES: dict[str, VacuumActivity] = {
STATE_CLEANING: VacuumActivity.CLEANING,
}
CONF_SEGMENTS = "segments"
CONF_CLEAN_SEGMENTS_COMMAND_TOPIC = "clean_segments_command_topic"
CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE = "clean_segments_command_template"
CONF_SUPPORTED_FEATURES = ATTR_SUPPORTED_FEATURES
CONF_PAYLOAD_TURN_ON = "payload_turn_on"
CONF_PAYLOAD_TURN_OFF = "payload_turn_off"
@@ -137,8 +141,39 @@ MQTT_VACUUM_ATTRIBUTES_BLOCKED = frozenset(
MQTT_VACUUM_DOCS_URL = "https://www.home-assistant.io/integrations/vacuum.mqtt/"
PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend(
def validate_clean_area_config(config: ConfigType) -> ConfigType:
"""Check for a valid configuration and check segments."""
if (config[CONF_SEGMENTS] and CONF_CLEAN_SEGMENTS_COMMAND_TOPIC not in config) or (
not config[CONF_SEGMENTS] and CONF_CLEAN_SEGMENTS_COMMAND_TOPIC in config
):
raise vol.Invalid(
f"Options `{CONF_SEGMENTS}` and "
f"`{CONF_CLEAN_SEGMENTS_COMMAND_TOPIC}` must be defined together"
)
segments: list[str]
if segments := config[CONF_SEGMENTS]:
if not config.get(CONF_UNIQUE_ID):
raise vol.Invalid(
f"Option `{CONF_SEGMENTS}` requires `{CONF_UNIQUE_ID}` to be configured"
)
unique_segments: set[str] = set()
for segment in segments:
segment_id, _, _ = segment.partition(".")
if not segment_id or segment_id in unique_segments:
raise vol.Invalid(
f"The `{CONF_SEGMENTS}` option contains an invalid or non-"
f"unique segment ID '{segment_id}'. Got {segments}"
)
unique_segments.add(segment_id)
return config
_BASE_SCHEMA = MQTT_BASE_SCHEMA.extend(
{
vol.Optional(CONF_SEGMENTS, default=[]): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(CONF_CLEAN_SEGMENTS_COMMAND_TOPIC): valid_publish_topic,
vol.Optional(CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE): cv.template,
vol.Optional(CONF_FAN_SPEED_LIST, default=[]): vol.All(
cv.ensure_list, [cv.string]
),
@@ -164,7 +199,10 @@ PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend(
}
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.ALLOW_EXTRA)
PLATFORM_SCHEMA_MODERN = vol.All(_BASE_SCHEMA, validate_clean_area_config)
DISCOVERY_SCHEMA = vol.All(
_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), validate_clean_area_config
)
async def async_setup_entry(
@@ -191,9 +229,11 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
_entity_id_format = ENTITY_ID_FORMAT
_attributes_extra_blocked = MQTT_VACUUM_ATTRIBUTES_BLOCKED
_segments: list[Segment]
_command_topic: str | None
_set_fan_speed_topic: str | None
_send_command_topic: str | None
_clean_segments_command_topic: str
_payloads: dict[str, str | None]
def __init__(
@@ -229,6 +269,23 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
self._attr_supported_features = _strings_to_services(
supported_feature_strings, STRING_TO_SERVICE
)
if config[CONF_SEGMENTS] and CONF_CLEAN_SEGMENTS_COMMAND_TOPIC in config:
self._attr_supported_features |= VacuumEntityFeature.CLEAN_AREA
segments: list[str] = config[CONF_SEGMENTS]
self._segments = [
Segment(id=segment_id, name=name or segment_id)
for segment_id, _, name in [
segment.partition(".") for segment in segments
]
]
self._clean_segments_command_topic = config[
CONF_CLEAN_SEGMENTS_COMMAND_TOPIC
]
self._clean_segments_command_template = MqttCommandTemplate(
config.get(CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE),
entity=self,
).async_render
self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST]
self._command_topic = config.get(CONF_COMMAND_TOPIC)
self._set_fan_speed_topic = config.get(CONF_SET_FAN_SPEED_TOPIC)
@@ -246,6 +303,20 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
)
}
@callback
def _process_entity_update(self) -> None:
"""Check vacuum segments with registry entry."""
if (
self._attr_supported_features & VacuumEntityFeature.CLEAN_AREA
and (last_seen := self.last_seen_segments) is not None
and {s.id: s for s in last_seen} != {s.id: s for s in self._segments}
):
self.async_create_segments_issue()
async def mqtt_async_added_to_hass(self) -> None:
"""Check vacuum segments with registry entry."""
self._process_entity_update()
def _update_state_attributes(self, payload: dict[str, Any]) -> None:
"""Update the entity state attributes."""
self._state_attrs.update(payload)
@@ -277,6 +348,19 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
"""(Re)Subscribe to topics."""
subscription.async_subscribe_topics_internal(self.hass, self._sub_state)
async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None:
"""Perform an area clean."""
await self.async_publish_with_config(
self._clean_segments_command_topic,
self._clean_segments_command_template(
json_dumps(segment_ids), {"value": segment_ids}
),
)
async def async_get_segments(self) -> list[Segment]:
"""Return the available segments."""
return self._segments
async def _async_publish_command(self, feature: VacuumEntityFeature) -> None:
"""Publish a command."""
if self._command_topic is None:

View File

@@ -14,7 +14,7 @@ from .coordinator import PranaConfigEntry, PranaCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.FAN, Platform.SENSOR, Platform.SWITCH]
PLATFORMS = [Platform.FAN, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
async def async_setup_entry(hass: HomeAssistant, entry: PranaConfigEntry) -> bool:

View File

@@ -21,8 +21,8 @@ from homeassistant.util.percentage import (
)
from homeassistant.util.scaling import int_states_in_range
from . import PranaConfigEntry
from .entity import PranaBaseEntity, PranaCoordinator
from .coordinator import PranaConfigEntry, PranaCoordinator
from .entity import PranaBaseEntity
PARALLEL_UPDATES = 1

View File

@@ -8,6 +8,20 @@
"default": "mdi:arrow-expand-left"
}
},
"number": {
"display_brightness": {
"default": "mdi:brightness-6",
"state": {
"0": "mdi:brightness-2",
"1": "mdi:brightness-4",
"2": "mdi:brightness-4",
"3": "mdi:brightness-5",
"4": "mdi:brightness-5",
"5": "mdi:brightness-7",
"6": "mdi:brightness-7"
}
}
},
"sensor": {
"inside_temperature": {
"default": "mdi:home-thermometer"

View File

@@ -0,0 +1,80 @@
"""Number platform for Prana integration."""
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
from typing import Any
from homeassistant.components.number import (
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import PranaConfigEntry, PranaCoordinator
from .entity import PranaBaseEntity
PARALLEL_UPDATES = 1
class PranaNumberType(StrEnum):
"""Enumerates Prana number types exposed by the device API."""
DISPLAY_BRIGHTNESS = "display_brightness"
@dataclass(frozen=True, kw_only=True)
class PranaNumberEntityDescription(NumberEntityDescription):
"""Description of a Prana number entity."""
key: PranaNumberType
value_fn: Callable[[PranaCoordinator], float | None]
set_value_fn: Callable[[Any, float], Any]
ENTITIES: tuple[PranaNumberEntityDescription, ...] = (
PranaNumberEntityDescription(
key=PranaNumberType.DISPLAY_BRIGHTNESS,
translation_key="display_brightness",
native_min_value=0,
native_max_value=6,
native_step=1,
mode=NumberMode.SLIDER,
entity_category=EntityCategory.CONFIG,
value_fn=lambda coord: coord.data.brightness,
set_value_fn=lambda api, val: api.set_brightness(
0 if val == 0 else 2 ** (int(val) - 1)
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: PranaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Prana number entities from a config entry."""
async_add_entities(
PranaNumber(entry.runtime_data, entity_description)
for entity_description in ENTITIES
)
class PranaNumber(PranaBaseEntity, NumberEntity):
"""Representation of a Prana number entity."""
entity_description: PranaNumberEntityDescription
@property
def native_value(self) -> float | None:
"""Return the entity value."""
return self.entity_description.value_fn(self.coordinator)
async def async_set_native_value(self, value: float) -> None:
"""Set new value."""
await self.entity_description.set_value_fn(self.coordinator.api_client, value)
await self.coordinator.async_refresh()

View File

@@ -21,8 +21,8 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import PranaConfigEntry
from .entity import PranaBaseEntity, PranaCoordinator
from .coordinator import PranaConfigEntry, PranaCoordinator
from .entity import PranaBaseEntity
PARALLEL_UPDATES = 1

View File

@@ -49,6 +49,11 @@
}
}
},
"number": {
"display_brightness": {
"name": "Display brightness"
}
},
"sensor": {
"inside_temperature": {
"name": "Inside temperature"

View File

@@ -9,7 +9,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import PranaConfigEntry, PranaCoordinator
from .coordinator import PranaConfigEntry, PranaCoordinator
from .entity import PranaBaseEntity
PARALLEL_UPDATES = 1

View File

@@ -4,10 +4,11 @@ from __future__ import annotations
import mimetypes
from aiodns.error import DNSError
import pycountry
from radios import FilterBy, Order, RadioBrowser, Station
from radios import FilterBy, Order, RadioBrowser, RadioBrowserError, Station
from homeassistant.components.media_player import MediaClass, MediaType
from homeassistant.components.media_player import BrowseError, MediaClass, MediaType
from homeassistant.components.media_source import (
BrowseMediaSource,
MediaSource,
@@ -15,6 +16,7 @@ from homeassistant.components.media_source import (
PlayMedia,
Unresolvable,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, callback
from homeassistant.util.location import vincenty
@@ -55,9 +57,20 @@ class RadioMediaSource(MediaSource):
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
"""Resolve selected Radio station to a streaming URL."""
radios = self.radios
station = await radios.station(uuid=item.identifier)
if self.entry.state != ConfigEntryState.LOADED:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="config_entry_not_ready",
)
radios = self.radios
try:
station = await radios.station(uuid=item.identifier)
except (DNSError, RadioBrowserError) as e:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="radio_browser_error",
) from e
if not station:
raise Unresolvable("Radio station is no longer available")
@@ -74,25 +87,37 @@ class RadioMediaSource(MediaSource):
item: MediaSourceItem,
) -> BrowseMediaSource:
"""Return media."""
if self.entry.state != ConfigEntryState.LOADED:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="config_entry_not_ready",
)
radios = self.radios
return BrowseMediaSource(
domain=DOMAIN,
identifier=None,
media_class=MediaClass.CHANNEL,
media_content_type=MediaType.MUSIC,
title=self.entry.title,
can_play=False,
can_expand=True,
children_media_class=MediaClass.DIRECTORY,
children=[
*await self._async_build_popular(radios, item),
*await self._async_build_by_tag(radios, item),
*await self._async_build_by_language(radios, item),
*await self._async_build_local(radios, item),
*await self._async_build_by_country(radios, item),
],
)
try:
return BrowseMediaSource(
domain=DOMAIN,
identifier=None,
media_class=MediaClass.CHANNEL,
media_content_type=MediaType.MUSIC,
title=self.entry.title,
can_play=False,
can_expand=True,
children_media_class=MediaClass.DIRECTORY,
children=[
*await self._async_build_popular(radios, item),
*await self._async_build_by_tag(radios, item),
*await self._async_build_by_language(radios, item),
*await self._async_build_local(radios, item),
*await self._async_build_by_country(radios, item),
],
)
except (DNSError, RadioBrowserError) as e:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="radio_browser_error",
) from e
@callback
@staticmethod

View File

@@ -5,5 +5,13 @@
"description": "Do you want to add Radio Browser to Home Assistant?"
}
}
},
"exceptions": {
"config_entry_not_ready": {
"message": "Radio Browser integration is not ready"
},
"radio_browser_error": {
"message": "Error occurred while communicating with Radio Browser"
}
}
}

View File

@@ -20,7 +20,7 @@
"loggers": ["roborock"],
"quality_scale": "silver",
"requirements": [
"python-roborock==4.20.0",
"python-roborock==4.25.0",
"vacuum-map-parser-roborock==0.1.4"
]
}

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import date, datetime
from datetime import datetime
import ephem
@@ -12,7 +12,7 @@ from homeassistant.const import CONF_TYPE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import utcnow
from homeassistant.util import dt as dt_util
from .const import DOMAIN, TYPE_ASTRONOMICAL
@@ -50,7 +50,7 @@ async def async_setup_entry(
def get_season(
current_date: date, hemisphere: str, season_tracking_type: str
current_datetime: datetime, hemisphere: str, season_tracking_type: str
) -> str | None:
"""Calculate the current season."""
@@ -58,22 +58,36 @@ def get_season(
return None
if season_tracking_type == TYPE_ASTRONOMICAL:
spring_start = ephem.next_equinox(str(current_date.year)).datetime()
summer_start = ephem.next_solstice(str(current_date.year)).datetime()
autumn_start = ephem.next_equinox(spring_start).datetime()
winter_start = ephem.next_solstice(summer_start).datetime()
spring_start = (
ephem.next_equinox(str(current_datetime.year))
.datetime()
.replace(tzinfo=dt_util.UTC)
)
summer_start = (
ephem.next_solstice(str(current_datetime.year))
.datetime()
.replace(tzinfo=dt_util.UTC)
)
autumn_start = (
ephem.next_equinox(spring_start).datetime().replace(tzinfo=dt_util.UTC)
)
winter_start = (
ephem.next_solstice(summer_start).datetime().replace(tzinfo=dt_util.UTC)
)
else:
spring_start = datetime(2017, 3, 1).replace(year=current_date.year)
spring_start = current_datetime.replace(
month=3, day=1, hour=0, minute=0, second=0, microsecond=0
)
summer_start = spring_start.replace(month=6)
autumn_start = spring_start.replace(month=9)
winter_start = spring_start.replace(month=12)
season = STATE_WINTER
if spring_start <= current_date < summer_start:
if spring_start <= current_datetime < summer_start:
season = STATE_SPRING
elif summer_start <= current_date < autumn_start:
elif summer_start <= current_datetime < autumn_start:
season = STATE_SUMMER
elif autumn_start <= current_date < winter_start:
elif autumn_start <= current_datetime < winter_start:
season = STATE_AUTUMN
# If user is located in the southern hemisphere swap the season
@@ -104,6 +118,4 @@ class SeasonSensorEntity(SensorEntity):
def update(self) -> None:
"""Update season."""
self._attr_native_value = get_season(
utcnow().replace(tzinfo=None), self.hemisphere, self.type
)
self._attr_native_value = get_season(dt_util.now(), self.hemisphere, self.type)

View File

@@ -38,5 +38,5 @@
"iot_class": "cloud_push",
"loggers": ["pysmartthings"],
"quality_scale": "bronze",
"requirements": ["pysmartthings==3.7.0"]
"requirements": ["pysmartthings==3.7.2"]
}

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Callable, Iterable
import copy
import dataclasses
import datetime
import logging
@@ -28,7 +29,6 @@ from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonValueType
from .const import (
ATTR_DESCRIPTION,
@@ -240,7 +240,7 @@ class TodoListEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""An entity that represents a To-do list."""
_attr_todo_items: list[TodoItem] | None = None
_update_listeners: list[Callable[[list[JsonValueType] | None], None]] | None = None
_update_listeners: list[Callable[[list[TodoItem]], None]] | None = None
@property
def state(self) -> int | None:
@@ -281,13 +281,9 @@ class TodoListEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@final
@callback
def async_subscribe_updates(
self,
listener: Callable[[list[JsonValueType] | None], None],
self, listener: Callable[[list[TodoItem]], None]
) -> CALLBACK_TYPE:
"""Subscribe to To-do list item updates.
Called by websocket API.
"""
"""Subscribe to To-do list item updates."""
if self._update_listeners is None:
self._update_listeners = []
self._update_listeners.append(listener)
@@ -306,9 +302,7 @@ class TodoListEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if not self._update_listeners:
return
todo_items: list[JsonValueType] = [
dataclasses.asdict(item) for item in self.todo_items or ()
]
todo_items = [copy.copy(item) for item in self.todo_items or []]
for listener in self._update_listeners:
listener(todo_items)
@@ -341,13 +335,13 @@ async def websocket_handle_subscribe_todo_items(
return
@callback
def todo_item_listener(todo_items: list[JsonValueType] | None) -> None:
def todo_item_listener(todo_items: list[TodoItem]) -> None:
"""Push updated To-do list items to websocket."""
connection.send_message(
websocket_api.event_message(
msg["id"],
{
"items": todo_items,
"items": [dataclasses.asdict(item) for item in todo_items],
},
)
)
@@ -357,7 +351,7 @@ async def websocket_handle_subscribe_todo_items(
)
connection.send_result(msg["id"])
# Push an initial forecast update
# Push an initial list update
entity.async_update_listeners()

View File

@@ -35,7 +35,13 @@ ALARM: dict[DeviceCategory, tuple[AlarmControlPanelEntityDescription, ...]] = {
key=DPCode.MASTER_MODE,
name="Alarm",
),
)
),
DeviceCategory.WG2: (
AlarmControlPanelEntityDescription(
key=DPCode.MASTER_MODE,
name="Alarm",
),
),
}
_TUYA_TO_HA_STATE_MAPPINGS = {

View File

@@ -317,6 +317,11 @@ BINARY_SENSORS: dict[DeviceCategory, tuple[TuyaBinarySensorEntityDescription, ..
entity_category=EntityCategory.DIAGNOSTIC,
on_value="alarm",
),
TuyaBinarySensorEntityDescription(
key=DPCode.CHARGE_STATE,
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
entity_category=EntityCategory.DIAGNOSTIC,
),
),
DeviceCategory.WK: (
TuyaBinarySensorEntityDescription(

View File

@@ -1233,6 +1233,7 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
),
*BATTERY_SENSORS,
),
DeviceCategory.WG2: (*BATTERY_SENSORS,),
DeviceCategory.WK: (*BATTERY_SENSORS,),
DeviceCategory.WKCZ: (
TuyaSensorEntityDescription(

View File

@@ -2,20 +2,34 @@
from __future__ import annotations
from typing import Any
from pyvizio import VizioAsync
from homeassistant.components.media_player import MediaPlayerDeviceClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_CLASS, Platform
from homeassistant.const import (
CONF_ACCESS_TOKEN,
CONF_DEVICE_CLASS,
CONF_HOST,
CONF_NAME,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
from .const import CONF_APPS, DOMAIN
from .coordinator import VizioAppsDataUpdateCoordinator
from .const import DEFAULT_TIMEOUT, DEVICE_ID, DOMAIN, VIZIO_DEVICE_CLASSES
from .coordinator import (
VizioAppsDataUpdateCoordinator,
VizioConfigEntry,
VizioDeviceCoordinator,
VizioRuntimeData,
)
from .services import async_setup_services
DATA_APPS: HassKey[VizioAppsDataUpdateCoordinator] = HassKey(f"{DOMAIN}_apps")
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.MEDIA_PLAYER]
@@ -26,38 +40,54 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: VizioConfigEntry) -> bool:
"""Load the saved entities."""
host = entry.data[CONF_HOST]
token = entry.data.get(CONF_ACCESS_TOKEN)
device_class = entry.data[CONF_DEVICE_CLASS]
hass.data.setdefault(DOMAIN, {})
if (
CONF_APPS not in hass.data[DOMAIN]
and entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV
):
store: Store[list[dict[str, Any]]] = Store(hass, 1, DOMAIN)
coordinator = VizioAppsDataUpdateCoordinator(hass, store)
await coordinator.async_setup()
hass.data[DOMAIN][CONF_APPS] = coordinator
await coordinator.async_refresh()
# Create device
device = VizioAsync(
DEVICE_ID,
host,
entry.data[CONF_NAME],
auth_token=token,
device_type=VIZIO_DEVICE_CLASSES[device_class],
session=async_get_clientsession(hass, False),
timeout=DEFAULT_TIMEOUT,
)
# Create device coordinator
device_coordinator = VizioDeviceCoordinator(hass, entry, device)
await device_coordinator.async_config_entry_first_refresh()
# Create apps coordinator for TVs (shared across entries)
if device_class == MediaPlayerDeviceClass.TV and DATA_APPS not in hass.data:
apps_coordinator = VizioAppsDataUpdateCoordinator(hass, Store(hass, 1, DOMAIN))
await apps_coordinator.async_setup()
hass.data[DATA_APPS] = apps_coordinator
await apps_coordinator.async_refresh()
entry.runtime_data = VizioRuntimeData(
device_coordinator=device_coordinator,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: VizioConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(
config_entry, PLATFORMS
)
if not any(
entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV
for entry in hass.config_entries.async_loaded_entries(DOMAIN)
):
if coordinator := hass.data[DOMAIN].pop(CONF_APPS, None):
await coordinator.async_shutdown()
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if not hass.data[DOMAIN]:
hass.data.pop(DOMAIN)
# Clean up apps coordinator if no TV entries remain
if unload_ok and not any(
e.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV
for e in hass.config_entries.async_loaded_entries(DOMAIN)
if e.entry_id != entry.entry_id
):
if apps_coordinator := hass.data.pop(DATA_APPS, None):
await apps_coordinator.async_shutdown()
return unload_ok

View File

@@ -8,13 +8,12 @@ import socket
from typing import Any
from pyvizio import VizioAsync, async_guess_device_type
from pyvizio.const import APP_HOME
from pyvizio.const import APP_HOME, APPS
import voluptuous as vol
from homeassistant.components.media_player import MediaPlayerDeviceClass
from homeassistant.config_entries import (
SOURCE_ZEROCONF,
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
@@ -34,6 +33,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.util.network import is_ip_address
from . import DATA_APPS
from .const import (
CONF_APPS,
CONF_APPS_TO_INCLUDE_OR_EXCLUDE,
@@ -45,6 +45,7 @@ from .const import (
DEVICE_ID,
DOMAIN,
)
from .coordinator import VizioConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -106,6 +107,14 @@ def _host_is_same(host1: str, host2: str) -> bool:
class VizioOptionsConfigFlow(OptionsFlow):
"""Handle Vizio options."""
def _get_app_list(self) -> list[dict[str, Any]]:
"""Return the current apps list, falling back to defaults."""
if (
apps_coordinator := self.hass.data.get(DATA_APPS)
) and apps_coordinator.data:
return apps_coordinator.data
return APPS
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -157,10 +166,7 @@ class VizioOptionsConfigFlow(OptionsFlow):
): cv.multi_select(
[
APP_HOME["name"],
*(
app["name"]
for app in self.hass.data[DOMAIN][CONF_APPS].data
),
*(app["name"] for app in self._get_app_list()),
]
),
}
@@ -176,7 +182,9 @@ class VizioConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> VizioOptionsConfigFlow:
def async_get_options_flow(
config_entry: VizioConfigEntry,
) -> VizioOptionsConfigFlow:
"""Get the options flow for this handler."""
return VizioOptionsConfigFlow()

View File

@@ -2,22 +2,150 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import Any
from typing import TYPE_CHECKING, Any
from pyvizio.const import APPS
from pyvizio import VizioAsync
from pyvizio.api.apps import AppConfig
from pyvizio.api.input import InputItem
from pyvizio.const import APPS, INPUT_APPS
from pyvizio.util import gen_apps_list_from_url
from homeassistant.components.media_player import MediaPlayerDeviceClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_CLASS, CONF_HOST, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.storage import Store
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
from .const import DOMAIN, VIZIO_AUDIO_SETTINGS, VIZIO_SOUND_MODE
type VizioConfigEntry = ConfigEntry[VizioRuntimeData]
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=30)
@dataclass(frozen=True)
class VizioRuntimeData:
"""Runtime data for Vizio integration."""
device_coordinator: VizioDeviceCoordinator
@dataclass(frozen=True)
class VizioDeviceData:
"""Raw data fetched from Vizio device."""
# Power state
is_on: bool
# Audio settings from get_all_settings("audio")
audio_settings: dict[str, Any] | None = None
# Sound mode options from get_setting_options("audio", "eq")
sound_mode_list: list[str] | None = None
# Current input from get_current_input()
current_input: str | None = None
# Available inputs from get_inputs_list()
input_list: list[InputItem] | None = None
# Current app config from get_current_app_config() (TVs only)
current_app_config: AppConfig | None = None
class VizioDeviceCoordinator(DataUpdateCoordinator[VizioDeviceData]):
"""Coordinator for Vizio device data."""
config_entry: VizioConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: VizioConfigEntry,
device: VizioAsync,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
self.device = device
async def _async_setup(self) -> None:
"""Fetch device info and update device registry."""
model = await self.device.get_model_name(log_api_exception=False)
version = await self.device.get_version(log_api_exception=False)
if TYPE_CHECKING:
assert self.config_entry.unique_id
device_registry = dr.async_get(self.hass)
device_registry.async_get_or_create(
config_entry_id=self.config_entry.entry_id,
identifiers={(DOMAIN, self.config_entry.unique_id)},
manufacturer="VIZIO",
name=self.config_entry.data[CONF_NAME],
model=model,
sw_version=version,
)
async def _async_update_data(self) -> VizioDeviceData:
"""Fetch all device data."""
is_on = await self.device.get_power_state(log_api_exception=False)
if is_on is None:
raise UpdateFailed(
f"Unable to connect to {self.config_entry.data[CONF_HOST]}"
)
if not is_on:
return VizioDeviceData(is_on=False)
# Device is on - fetch all data
audio_settings = await self.device.get_all_settings(
VIZIO_AUDIO_SETTINGS, log_api_exception=False
)
sound_mode_list = None
if audio_settings and VIZIO_SOUND_MODE in audio_settings:
sound_mode_list = await self.device.get_setting_options(
VIZIO_AUDIO_SETTINGS, VIZIO_SOUND_MODE, log_api_exception=False
)
current_input = await self.device.get_current_input(log_api_exception=False)
input_list = await self.device.get_inputs_list(log_api_exception=False)
current_app_config = None
# Only attempt to fetch app config if the device is a TV and supports apps
if (
self.config_entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV
and input_list
and any(input_item.name in INPUT_APPS for input_item in input_list)
):
current_app_config = await self.device.get_current_app_config(
log_api_exception=False
)
return VizioDeviceData(
is_on=True,
audio_settings=audio_settings,
sound_mode_list=sound_mode_list,
current_input=current_input,
input_list=input_list,
current_app_config=current_app_config,
)
class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]):
"""Define an object to hold Vizio app config data."""

View File

@@ -2,11 +2,7 @@
from __future__ import annotations
from datetime import timedelta
import logging
from pyvizio import AppConfig, VizioAsync
from pyvizio.api.apps import find_app_name
from pyvizio.api.apps import AppConfig, find_app_name
from pyvizio.const import APP_HOME, INPUT_APPS, NO_APP_RUNNING, UNKNOWN_APP
from homeassistant.components.media_player import (
@@ -15,58 +11,45 @@ from homeassistant.components.media_player import (
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_ACCESS_TOKEN,
CONF_DEVICE_CLASS,
CONF_EXCLUDE,
CONF_HOST,
CONF_INCLUDE,
CONF_NAME,
)
from homeassistant.const import CONF_DEVICE_CLASS, CONF_EXCLUDE, CONF_INCLUDE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import DATA_APPS
from .const import (
CONF_ADDITIONAL_CONFIGS,
CONF_APPS,
CONF_VOLUME_STEP,
DEFAULT_TIMEOUT,
DEFAULT_VOLUME_STEP,
DEVICE_ID,
DOMAIN,
SUPPORTED_COMMANDS,
VIZIO_AUDIO_SETTINGS,
VIZIO_DEVICE_CLASSES,
VIZIO_MUTE,
VIZIO_MUTE_ON,
VIZIO_SOUND_MODE,
VIZIO_VOLUME,
)
from .coordinator import VizioAppsDataUpdateCoordinator
from .coordinator import (
VizioAppsDataUpdateCoordinator,
VizioConfigEntry,
VizioDeviceCoordinator,
)
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=30)
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: VizioConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Vizio media player entry."""
host = config_entry.data[CONF_HOST]
token = config_entry.data.get(CONF_ACCESS_TOKEN)
name = config_entry.data[CONF_NAME]
device_class = config_entry.data[CONF_DEVICE_CLASS]
# If config entry options not set up, set them up,
@@ -105,59 +88,51 @@ async def async_setup_entry(
**params, # type: ignore[arg-type]
)
device = VizioAsync(
DEVICE_ID,
host,
name,
auth_token=token,
device_type=VIZIO_DEVICE_CLASSES[device_class],
session=async_get_clientsession(hass, False),
timeout=DEFAULT_TIMEOUT,
entity = VizioDevice(
config_entry,
device_class,
config_entry.runtime_data.device_coordinator,
hass.data.get(DATA_APPS) if device_class == MediaPlayerDeviceClass.TV else None,
)
apps_coordinator = hass.data[DOMAIN].get(CONF_APPS)
entity = VizioDevice(config_entry, device, name, device_class, apps_coordinator)
async_add_entities([entity], update_before_add=True)
async_add_entities([entity])
class VizioDevice(MediaPlayerEntity):
class VizioDevice(CoordinatorEntity[VizioDeviceCoordinator], MediaPlayerEntity):
"""Media Player implementation which performs REST requests to device."""
_attr_has_entity_name = True
_attr_name = None
_received_device_info = False
_current_input: str | None = None
_current_app_config: AppConfig | None = None
def __init__(
self,
config_entry: ConfigEntry,
device: VizioAsync,
name: str,
config_entry: VizioConfigEntry,
device_class: MediaPlayerDeviceClass,
coordinator: VizioDeviceCoordinator,
apps_coordinator: VizioAppsDataUpdateCoordinator | None,
) -> None:
"""Initialize Vizio device."""
super().__init__(coordinator)
self._config_entry = config_entry
self._apps_coordinator = apps_coordinator
self._volume_step = config_entry.options[CONF_VOLUME_STEP]
self._current_input: str | None = None
self._current_app_config: AppConfig | None = None
self._attr_sound_mode_list = []
self._available_inputs: list[str] = []
self._available_apps: list[str] = []
self._volume_step = config_entry.options[CONF_VOLUME_STEP]
self._all_apps = apps_coordinator.data if apps_coordinator else None
self._conf_apps = config_entry.options.get(CONF_APPS, {})
self._additional_app_configs = config_entry.data.get(CONF_APPS, {}).get(
CONF_ADDITIONAL_CONFIGS, []
)
self._device = device
self._max_volume = float(device.get_max_volume())
self._attr_assumed_state = True
self._device = coordinator.device
self._max_volume = float(coordinator.device.get_max_volume())
# Entity class attributes that will change with each update (we only include
# the ones that are initialized differently from the defaults)
self._attr_sound_mode_list = []
self._attr_supported_features = SUPPORTED_COMMANDS[device_class]
# Entity class attributes that will not change
@@ -165,11 +140,7 @@ class VizioDevice(MediaPlayerEntity):
assert unique_id
self._attr_unique_id = unique_id
self._attr_device_class = device_class
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
manufacturer="VIZIO",
name=name,
)
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, unique_id)})
def _apps_list(self, apps: list[str]) -> list[str]:
"""Return process apps list based on configured filters."""
@@ -181,112 +152,72 @@ class VizioDevice(MediaPlayerEntity):
return apps
async def async_update(self) -> None:
"""Retrieve latest state of the device."""
if (
is_on := await self._device.get_power_state(log_api_exception=False)
) is None:
if self._attr_available:
_LOGGER.warning(
"Lost connection to %s", self._config_entry.data[CONF_HOST]
)
self._attr_available = False
return
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
data = self.coordinator.data
if not self._attr_available:
_LOGGER.warning(
"Restored connection to %s", self._config_entry.data[CONF_HOST]
)
self._attr_available = True
if not self._received_device_info:
device_reg = dr.async_get(self.hass)
assert self._config_entry.unique_id
device = device_reg.async_get_device(
identifiers={(DOMAIN, self._config_entry.unique_id)}
)
if device:
device_reg.async_update_device(
device.id,
model=await self._device.get_model_name(log_api_exception=False),
sw_version=await self._device.get_version(log_api_exception=False),
)
self._received_device_info = True
if not is_on:
# Handle device off
if not data.is_on:
self._attr_state = MediaPlayerState.OFF
self._attr_volume_level = None
self._attr_is_volume_muted = None
self._current_input = None
self._attr_app_name = None
self._current_app_config = None
self._attr_sound_mode = None
self._attr_app_name = None
self._current_input = None
self._current_app_config = None
super()._handle_coordinator_update()
return
# Device is on - apply coordinator data
self._attr_state = MediaPlayerState.ON
if audio_settings := await self._device.get_all_settings(
VIZIO_AUDIO_SETTINGS, log_api_exception=False
):
# Audio settings
if data.audio_settings:
self._attr_volume_level = (
float(audio_settings[VIZIO_VOLUME]) / self._max_volume
float(data.audio_settings[VIZIO_VOLUME]) / self._max_volume
)
if VIZIO_MUTE in audio_settings:
if VIZIO_MUTE in data.audio_settings:
self._attr_is_volume_muted = (
audio_settings[VIZIO_MUTE].lower() == VIZIO_MUTE_ON
data.audio_settings[VIZIO_MUTE].lower() == VIZIO_MUTE_ON
)
else:
self._attr_is_volume_muted = None
if VIZIO_SOUND_MODE in audio_settings:
if VIZIO_SOUND_MODE in data.audio_settings:
self._attr_supported_features |= (
MediaPlayerEntityFeature.SELECT_SOUND_MODE
)
self._attr_sound_mode = audio_settings[VIZIO_SOUND_MODE]
self._attr_sound_mode = data.audio_settings[VIZIO_SOUND_MODE]
if not self._attr_sound_mode_list:
self._attr_sound_mode_list = await self._device.get_setting_options(
VIZIO_AUDIO_SETTINGS,
VIZIO_SOUND_MODE,
log_api_exception=False,
)
self._attr_sound_mode_list = data.sound_mode_list or []
else:
# Explicitly remove MediaPlayerEntityFeature.SELECT_SOUND_MODE from supported features
self._attr_supported_features &= (
~MediaPlayerEntityFeature.SELECT_SOUND_MODE
)
if input_ := await self._device.get_current_input(log_api_exception=False):
self._current_input = input_
# Input state
if data.current_input:
self._current_input = data.current_input
if data.input_list:
self._available_inputs = [i.name for i in data.input_list]
# If no inputs returned, end update
if not (inputs := await self._device.get_inputs_list(log_api_exception=False)):
return
self._available_inputs = [input_.name for input_ in inputs]
# Return before setting app variables if INPUT_APPS isn't in available inputs
if self._attr_device_class == MediaPlayerDeviceClass.SPEAKER or not any(
app for app in INPUT_APPS if app in self._available_inputs
# App state (TV only) - check if device supports apps
if (
self._attr_device_class == MediaPlayerDeviceClass.TV
and self._available_inputs
and any(app in self._available_inputs for app in INPUT_APPS)
):
return
all_apps = self._all_apps or ()
self._available_apps = self._apps_list([app["name"] for app in all_apps])
self._current_app_config = data.current_app_config
self._attr_app_name = find_app_name(
self._current_app_config,
[APP_HOME, *all_apps, *self._additional_app_configs],
)
if self._attr_app_name == NO_APP_RUNNING:
self._attr_app_name = None
# Create list of available known apps from known app list after
# filtering by CONF_INCLUDE/CONF_EXCLUDE
self._available_apps = self._apps_list(
[app["name"] for app in self._all_apps or ()]
)
self._current_app_config = await self._device.get_current_app_config(
log_api_exception=False
)
self._attr_app_name = find_app_name(
self._current_app_config,
[APP_HOME, *(self._all_apps or ()), *self._additional_app_configs],
)
if self._attr_app_name == NO_APP_RUNNING:
self._attr_app_name = None
super()._handle_coordinator_update()
def _get_additional_app_names(self) -> list[str]:
"""Return list of additional apps that were included in configuration.yaml."""
@@ -296,7 +227,7 @@ class VizioDevice(MediaPlayerEntity):
@staticmethod
async def _async_send_update_options_signal(
hass: HomeAssistant, config_entry: ConfigEntry
hass: HomeAssistant, config_entry: VizioConfigEntry
) -> None:
"""Send update event when Vizio config entry is updated."""
# Move this method to component level if another entity ever gets added for a
@@ -304,7 +235,7 @@ class VizioDevice(MediaPlayerEntity):
# See here: https://github.com/home-assistant/core/pull/30653#discussion_r366426121
async_dispatcher_send(hass, config_entry.entry_id, config_entry)
async def _async_update_options(self, config_entry: ConfigEntry) -> None:
async def _async_update_options(self, config_entry: VizioConfigEntry) -> None:
"""Update options if the update signal comes from this entity."""
self._volume_step = config_entry.options[CONF_VOLUME_STEP]
# Update so that CONF_ADDITIONAL_CONFIGS gets retained for imports
@@ -323,6 +254,11 @@ class VizioDevice(MediaPlayerEntity):
async def async_added_to_hass(self) -> None:
"""Register callbacks when entity is added."""
await super().async_added_to_hass()
# Process initial coordinator data
self._handle_coordinator_update()
# Register callback for when config entry is updated.
self.async_on_remove(
self._config_entry.add_update_listener(
@@ -337,21 +273,17 @@ class VizioDevice(MediaPlayerEntity):
)
)
if not self._apps_coordinator:
if not (apps_coordinator := self._apps_coordinator):
return
# Register callback for app list updates if device is a TV
@callback
def apps_list_update() -> None:
"""Update list of all apps."""
if not self._apps_coordinator:
return
self._all_apps = self._apps_coordinator.data
self._all_apps = apps_coordinator.data
self.async_write_ha_state()
self.async_on_remove(
self._apps_coordinator.async_add_listener(apps_list_update)
)
self.async_on_remove(apps_coordinator.async_add_listener(apps_list_update))
@property
def source(self) -> str | None:

10
machine/build.yaml Normal file
View File

@@ -0,0 +1,10 @@
image: ghcr.io/home-assistant/{machine}-homeassistant
build_from:
aarch64: "ghcr.io/home-assistant/aarch64-homeassistant:"
amd64: "ghcr.io/home-assistant/amd64-homeassistant:"
cosign:
base_identity: https://github.com/home-assistant/core/.*
identity: https://github.com/home-assistant/core/.*
labels:
io.hass.type: core
org.opencontainers.image.source: https://github.com/home-assistant/core

View File

@@ -1,10 +1,7 @@
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM=ghcr.io/home-assistant/amd64-homeassistant:latest
FROM ${BUILD_FROM}
ARG \
BUILD_FROM
FROM $BUILD_FROM
RUN apk --no-cache add \
libva-intel-driver
LABEL io.hass.machine="generic-x86-64"

View File

@@ -1,7 +1,4 @@
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM=ghcr.io/home-assistant/aarch64-homeassistant:latest
FROM ${BUILD_FROM}
ARG \
BUILD_FROM
LABEL io.hass.machine="green"
FROM $BUILD_FROM

View File

@@ -1,10 +1,10 @@
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM=ghcr.io/home-assistant/amd64-homeassistant:latest
FROM ${BUILD_FROM}
ARG \
BUILD_FROM
FROM $BUILD_FROM
# NOTE: intel-nuc will be replaced by generic-x86-64. Make sure to apply
# changes in generic-x86-64 as well.
RUN apk --no-cache add \
libva-intel-driver
LABEL io.hass.machine="intel-nuc"

View File

@@ -1,7 +1,4 @@
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM=ghcr.io/home-assistant/aarch64-homeassistant:latest
FROM ${BUILD_FROM}
ARG \
BUILD_FROM
LABEL io.hass.machine="khadas-vim3"
FROM $BUILD_FROM

View File

@@ -1,7 +1,4 @@
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM=ghcr.io/home-assistant/aarch64-homeassistant:latest
FROM ${BUILD_FROM}
ARG \
BUILD_FROM
LABEL io.hass.machine="odroid-c2"
FROM $BUILD_FROM

View File

@@ -1,7 +1,4 @@
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM=ghcr.io/home-assistant/aarch64-homeassistant:latest
FROM ${BUILD_FROM}
ARG \
BUILD_FROM
LABEL io.hass.machine="odroid-c4"
FROM $BUILD_FROM

View File

@@ -1,7 +1,4 @@
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM=ghcr.io/home-assistant/aarch64-homeassistant:latest
FROM ${BUILD_FROM}
ARG \
BUILD_FROM
LABEL io.hass.machine="odroid-m1"
FROM $BUILD_FROM

View File

@@ -1,7 +1,4 @@
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM=ghcr.io/home-assistant/aarch64-homeassistant:latest
FROM ${BUILD_FROM}
ARG \
BUILD_FROM
LABEL io.hass.machine="odroid-n2"
FROM $BUILD_FROM

View File

@@ -1,7 +1,4 @@
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM=ghcr.io/home-assistant/aarch64-homeassistant:latest
FROM ${BUILD_FROM}
ARG \
BUILD_FROM
LABEL io.hass.machine="qemuarm-64"
FROM $BUILD_FROM

View File

@@ -1,7 +1,4 @@
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM=ghcr.io/home-assistant/amd64-homeassistant:latest
FROM ${BUILD_FROM}
ARG \
BUILD_FROM
LABEL io.hass.machine="qemux86-64"
FROM $BUILD_FROM

View File

@@ -1,10 +1,7 @@
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM=ghcr.io/home-assistant/aarch64-homeassistant:latest
FROM ${BUILD_FROM}
ARG \
BUILD_FROM
FROM $BUILD_FROM
RUN apk --no-cache add \
raspberrypi-utils
LABEL io.hass.machine="raspberrypi3-64"
raspberrypi-utils

View File

@@ -1,10 +1,7 @@
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM=ghcr.io/home-assistant/aarch64-homeassistant:latest
FROM ${BUILD_FROM}
ARG \
BUILD_FROM
FROM $BUILD_FROM
RUN apk --no-cache add \
raspberrypi-utils
LABEL io.hass.machine="raspberrypi4-64"
raspberrypi-utils

View File

@@ -1,10 +1,7 @@
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM=ghcr.io/home-assistant/aarch64-homeassistant:latest
FROM ${BUILD_FROM}
ARG \
BUILD_FROM
FROM $BUILD_FROM
RUN apk --no-cache add \
raspberrypi-utils
LABEL io.hass.machine="raspberrypi5-64"
raspberrypi-utils

View File

@@ -1,10 +1,7 @@
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM=ghcr.io/home-assistant/aarch64-homeassistant:latest
FROM ${BUILD_FROM}
ARG \
BUILD_FROM
FROM $BUILD_FROM
RUN apk --no-cache add \
raspberrypi-utils
LABEL io.hass.machine="yellow"
raspberrypi-utils

4
requirements_all.txt generated
View File

@@ -2494,7 +2494,7 @@ pysmappee==0.2.29
pysmarlaapi==1.0.2
# homeassistant.components.smartthings
pysmartthings==3.7.0
pysmartthings==3.7.2
# homeassistant.components.smarty
pysmarty2==0.10.3
@@ -2651,7 +2651,7 @@ python-rabbitair==0.0.8
python-ripple-api==0.0.3
# homeassistant.components.roborock
python-roborock==4.20.0
python-roborock==4.25.0
# homeassistant.components.smarttub
python-smarttub==0.0.47

View File

@@ -2126,7 +2126,7 @@ pysmappee==0.2.29
pysmarlaapi==1.0.2
# homeassistant.components.smartthings
pysmartthings==3.7.0
pysmartthings==3.7.2
# homeassistant.components.smarty
pysmarty2==0.10.3
@@ -2247,7 +2247,7 @@ python-pooldose==0.8.6
python-rabbitair==0.0.8
# homeassistant.components.roborock
python-roborock==4.20.0
python-roborock==4.25.0
# homeassistant.components.smarttub
python-smarttub==0.0.47

View File

@@ -25,6 +25,7 @@ LABEL \
org.opencontainers.image.description="Open-source home automation platform running on Python 3" \
org.opencontainers.image.documentation="https://www.home-assistant.io/docs/" \
org.opencontainers.image.licenses="Apache-2.0" \
org.opencontainers.image.source="https://github.com/home-assistant/core" \
org.opencontainers.image.title="Home Assistant" \
org.opencontainers.image.url="https://www.home-assistant.io/"
@@ -76,59 +77,6 @@ RUN \
WORKDIR /config
"""
@dataclass(frozen=True)
class _MachineConfig:
"""Machine-specific Dockerfile configuration."""
arch: str
packages: tuple[str, ...] = ()
_MACHINES = {
"generic-x86-64": _MachineConfig(arch="amd64", packages=("libva-intel-driver",)),
"green": _MachineConfig(arch="aarch64"),
"intel-nuc": _MachineConfig(arch="amd64", packages=("libva-intel-driver",)),
"khadas-vim3": _MachineConfig(arch="aarch64"),
"odroid-c2": _MachineConfig(arch="aarch64"),
"odroid-c4": _MachineConfig(arch="aarch64"),
"odroid-m1": _MachineConfig(arch="aarch64"),
"odroid-n2": _MachineConfig(arch="aarch64"),
"qemuarm-64": _MachineConfig(arch="aarch64"),
"qemux86-64": _MachineConfig(arch="amd64"),
"raspberrypi3-64": _MachineConfig(arch="aarch64", packages=("raspberrypi-utils",)),
"raspberrypi4-64": _MachineConfig(arch="aarch64", packages=("raspberrypi-utils",)),
"raspberrypi5-64": _MachineConfig(arch="aarch64", packages=("raspberrypi-utils",)),
"yellow": _MachineConfig(arch="aarch64", packages=("raspberrypi-utils",)),
}
_MACHINE_DOCKERFILE_TEMPLATE = r"""# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM=ghcr.io/home-assistant/{arch}-homeassistant:latest
FROM ${{BUILD_FROM}}
{extra_packages}
LABEL io.hass.machine="{machine}"
"""
def _generate_machine_dockerfile(
machine_name: str, machine_config: _MachineConfig
) -> str:
"""Generate a machine Dockerfile from configuration."""
if machine_config.packages:
pkg_lines = " \\\n ".join(machine_config.packages)
extra_packages = f"\nRUN apk --no-cache add \\\n {pkg_lines}\n"
else:
extra_packages = ""
return _MACHINE_DOCKERFILE_TEMPLATE.format(
arch=machine_config.arch,
extra_packages=extra_packages,
machine=machine_name,
)
_HASSFEST_TEMPLATE = r"""# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
@@ -226,7 +174,7 @@ def _generate_files(config: Config) -> list[File]:
config.root / "requirements_test_pre_commit.txt", {"ruff"}
)
files = [
return [
File(
DOCKERFILE_TEMPLATE.format(
timeout=timeout,
@@ -244,16 +192,6 @@ def _generate_files(config: Config) -> list[File]:
),
]
for machine_name, machine_config in sorted(_MACHINES.items()):
files.append(
File(
_generate_machine_dockerfile(machine_name, machine_config),
config.root / "machine" / machine_name,
)
)
return files
def validate(integrations: dict[str, Integration], config: Config) -> None:
"""Validate dockerfile."""

View File

@@ -125,7 +125,7 @@ async def test_cover_unavailable(
assert state is not None
assert state.state != STATE_UNAVAILABLE
mock_aladdin_connect_api.update_door.side_effect = aiohttp.ClientError()
mock_aladdin_connect_api.get_doors.side_effect = aiohttp.ClientError()
freezer.tick(15)
async_fire_time_changed(hass)
await hass.async_block_till_done()

View File

@@ -5,16 +5,17 @@ from unittest.mock import AsyncMock, patch
from aiohttp import ClientConnectionError, RequestInfo
from aiohttp.client_exceptions import ClientResponseError
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.aladdin_connect import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import init_integration
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
async def test_setup_entry(
@@ -137,3 +138,49 @@ async def test_remove_stale_devices(
)
assert len(device_entries) == 1
assert device_entries[0].identifiers == {(DOMAIN, "test_device_id-1")}
async def test_dynamic_devices(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
mock_aladdin_connect_api: AsyncMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test new devices are automatically discovered on coordinator refresh."""
await init_integration(hass, mock_config_entry)
# Initially one door -> one cover entity + one sensor entity
device_entries = dr.async_entries_for_config_entry(
device_registry, mock_config_entry.entry_id
)
assert len(device_entries) == 1
assert hass.states.get("cover.test_door") is not None
# Simulate a new door appearing on the API
mock_door_2 = AsyncMock()
mock_door_2.device_id = "test_device_id_2"
mock_door_2.door_number = 1
mock_door_2.name = "Test Door 2"
mock_door_2.status = "open"
mock_door_2.link_status = "connected"
mock_door_2.battery_level = 80
mock_door_2.unique_id = f"{mock_door_2.device_id}-{mock_door_2.door_number}"
existing_door = mock_aladdin_connect_api.get_doors.return_value[0]
mock_aladdin_connect_api.get_doors.return_value = [existing_door, mock_door_2]
# Trigger coordinator refresh
freezer.tick(15)
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Now two devices should exist
device_entries = dr.async_entries_for_config_entry(
device_registry, mock_config_entry.entry_id
)
assert len(device_entries) == 2
# New cover entity should exist
assert hass.states.get("cover.test_door_2") is not None

View File

@@ -49,7 +49,7 @@ async def test_sensor_unavailable(
assert state is not None
assert state.state != STATE_UNAVAILABLE
mock_aladdin_connect_api.update_door.side_effect = aiohttp.ClientError()
mock_aladdin_connect_api.get_doors.side_effect = aiohttp.ClientError()
freezer.tick(15)
async_fire_time_changed(hass)
await hass.async_block_till_done()

View File

@@ -13,13 +13,13 @@ from homeassistant.core import HomeAssistant
from tests.components.common import (
ConditionStateDescription,
assert_condition_behavior_all,
assert_condition_behavior_any,
assert_condition_gated_by_labs_flag,
create_target_condition,
other_states,
parametrize_condition_states_all,
parametrize_condition_states_any,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
@@ -129,32 +129,17 @@ async def test_alarm_control_panel_state_condition_behavior_any(
states: list[ConditionStateDescription],
) -> None:
"""Test the alarm_control_panel state condition with the 'any' behavior."""
other_entity_ids = set(target_alarm_control_panels["included"]) - {entity_id}
# Set all alarm_control_panels, including the tested alarm_control_panel, to the initial state
for eid in target_alarm_control_panels["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_any(
hass,
target_entities=target_alarm_control_panels,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="any",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
# Check if changing other alarm_control_panels also passes the condition
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
@@ -236,29 +221,13 @@ async def test_alarm_control_panel_state_condition_behavior_all(
states: list[ConditionStateDescription],
) -> None:
"""Test the alarm_control_panel state condition with the 'all' behavior."""
other_entity_ids = set(target_alarm_control_panels["included"]) - {entity_id}
# Set all alarm_control_panels, including the tested alarm_control_panel, to the initial state
for eid in target_alarm_control_panels["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_all(
hass,
target_entities=target_alarm_control_panels,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="all",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true_first_entity"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]

View File

@@ -9,13 +9,13 @@ from homeassistant.core import HomeAssistant
from tests.components.common import (
ConditionStateDescription,
assert_condition_behavior_all,
assert_condition_behavior_any,
assert_condition_gated_by_labs_flag,
create_target_condition,
other_states,
parametrize_condition_states_all,
parametrize_condition_states_any,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
@@ -83,32 +83,17 @@ async def test_assist_satellite_state_condition_behavior_any(
states: list[ConditionStateDescription],
) -> None:
"""Test the assist satellite state condition with the 'any' behavior."""
other_entity_ids = set(target_assist_satellites["included"]) - {entity_id}
# Set all assist satellites, including the tested one, to the initial state
for eid in target_assist_satellites["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_any(
hass,
target_entities=target_assist_satellites,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="any",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
# Check if changing other assist satellites also passes the condition
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
@@ -151,29 +136,13 @@ async def test_assist_satellite_state_condition_behavior_all(
states: list[ConditionStateDescription],
) -> None:
"""Test the assist satellite state condition with the 'all' behavior."""
other_entity_ids = set(target_assist_satellites["included"]) - {entity_id}
# Set all assist satellites, including the tested one, to the initial state
for eid in target_assist_satellites["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_all(
hass,
target_entities=target_assist_satellites,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="all",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true_first_entity"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]

View File

@@ -127,7 +127,7 @@ async def test_button_triggers_gated_by_labs_flag(
),
],
)
async def test_button_state_trigger_behavior_any(
async def test_button_state_trigger(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_buttons: dict[str, list[str]],
@@ -137,7 +137,7 @@ async def test_button_state_trigger_behavior_any(
trigger: str,
states: list[TriggerStateDescription],
) -> None:
"""Test that the button state trigger fires when any button state changes to a specific state."""
"""Test that the button state trigger fires when targeted button state changes."""
other_entity_ids = set(target_buttons["included"]) - {entity_id}
# Set all buttons, including the tested button, to the initial state

View File

@@ -8,8 +8,8 @@
'fide': None,
'followers': 2,
'is_streamer': False,
'joined': '2026-02-20T11:48:14',
'last_online': '2026-03-06T13:32:59',
'joined': '2026-02-20T10:48:14',
'last_online': '2026-03-06T12:32:59',
'location': 'Utrecht',
'name': 'Joost',
'player_id': 532748851,

View File

@@ -13,13 +13,13 @@ from homeassistant.core import HomeAssistant
from tests.components.common import (
ConditionStateDescription,
assert_condition_behavior_all,
assert_condition_behavior_any,
assert_condition_gated_by_labs_flag,
create_target_condition,
other_states,
parametrize_condition_states_all,
parametrize_condition_states_any,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
@@ -85,32 +85,17 @@ async def test_climate_state_condition_behavior_any(
states: list[ConditionStateDescription],
) -> None:
"""Test the climate state condition with the 'any' behavior."""
other_entity_ids = set(target_climates["included"]) - {entity_id}
# Set all climates, including the tested climate, to the initial state
for eid in target_climates["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_any(
hass,
target_entities=target_climates,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="any",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
# Check if changing other climates also passes the condition
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
@@ -150,33 +135,17 @@ async def test_climate_state_condition_behavior_all(
states: list[ConditionStateDescription],
) -> None:
"""Test the climate state condition with the 'all' behavior."""
other_entity_ids = set(target_climates["included"]) - {entity_id}
# Set all climates, including the tested climate, to the initial state
for eid in target_climates["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_all(
hass,
target_entities=target_climates,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="all",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true_first_entity"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
@@ -214,32 +183,17 @@ async def test_climate_attribute_condition_behavior_any(
states: list[ConditionStateDescription],
) -> None:
"""Test the climate attribute condition with the 'any' behavior."""
other_entity_ids = set(target_climates["included"]) - {entity_id}
# Set all climates, including the tested climate, to the initial state
for eid in target_climates["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_any(
hass,
target_entities=target_climates,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="any",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
# Check if changing other climates also passes the condition
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
@@ -277,29 +231,13 @@ async def test_climate_attribute_condition_behavior_all(
states: list[ConditionStateDescription],
) -> None:
"""Test the climate attribute condition with the 'all' behavior."""
other_entity_ids = set(target_climates["included"]) - {entity_id}
# Set all climates, including the tested climate, to the initial state
for eid in target_climates["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_all(
hass,
target_entities=target_climates,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="all",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true_first_entity"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]

View File

@@ -864,6 +864,98 @@ async def assert_trigger_gated_by_labs_flag(
) in caplog.text
async def assert_condition_behavior_any(
hass: HomeAssistant,
*,
target_entities: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test condition with the 'any' behavior."""
other_entity_ids = set(target_entities["included"]) - {entity_id}
excluded_entity_ids = set(target_entities["excluded"]) - {entity_id}
for eid in target_entities["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
for eid in excluded_entity_ids:
set_or_remove_state(hass, eid, states[0]["excluded"])
await hass.async_block_till_done()
condition = await create_target_condition(
hass,
condition=condition,
target=condition_target_config,
behavior="any",
)
for state in states:
included_state = state["included"]
excluded_state = state["excluded"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
for excluded_entity_id in excluded_entity_ids:
set_or_remove_state(hass, excluded_entity_id, excluded_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
async def assert_condition_behavior_all(
hass: HomeAssistant,
*,
target_entities: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test condition with the 'all' behavior."""
other_entity_ids = set(target_entities["included"]) - {entity_id}
excluded_entity_ids = set(target_entities["excluded"]) - {entity_id}
for eid in target_entities["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
for eid in excluded_entity_ids:
set_or_remove_state(hass, eid, states[0]["excluded"])
await hass.async_block_till_done()
condition = await create_target_condition(
hass,
condition=condition,
target=condition_target_config,
behavior="all",
)
for state in states:
included_state = state["included"]
excluded_state = state["excluded"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true_first_entity"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
for excluded_entity_id in excluded_entity_ids:
set_or_remove_state(hass, excluded_entity_id, excluded_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
async def assert_trigger_behavior_any(
hass: HomeAssistant,
*,

View File

@@ -10,12 +10,13 @@ from homeassistant.core import HomeAssistant
from tests.components.common import (
ConditionStateDescription,
assert_condition_behavior_all,
assert_condition_behavior_any,
assert_condition_gated_by_labs_flag,
create_target_condition,
parametrize_condition_states_all,
parametrize_condition_states_any,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
@@ -100,38 +101,17 @@ async def test_cover_condition_behavior_any(
states: list[ConditionStateDescription],
) -> None:
"""Test cover condition with the 'any' behavior."""
other_entity_ids = set(target_covers["included"]) - {entity_id}
excluded_entity_ids = set(target_covers["excluded"]) - {entity_id}
for eid in target_covers["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
for eid in excluded_entity_ids:
set_or_remove_state(hass, eid, states[0]["excluded"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_any(
hass,
target_entities=target_covers,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="any",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
excluded_state = state["excluded"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
for excluded_entity_id in excluded_entity_ids:
set_or_remove_state(hass, excluded_entity_id, excluded_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
@@ -184,40 +164,17 @@ async def test_cover_condition_behavior_all(
states: list[ConditionStateDescription],
) -> None:
"""Test cover condition with the 'all' behavior."""
other_entity_ids = set(target_covers["included"]) - {entity_id}
excluded_entity_ids = set(target_covers["excluded"]) - {entity_id}
for eid in target_covers["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
for eid in excluded_entity_ids:
set_or_remove_state(hass, eid, states[0]["excluded"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_all(
hass,
target_entities=target_covers,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="all",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
excluded_state = state["excluded"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true_first_entity"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
for excluded_entity_id in excluded_entity_ids:
set_or_remove_state(hass, excluded_entity_id, excluded_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(

View File

@@ -1,5 +1,6 @@
"""The tests for the Demo valve platform."""
from collections.abc import Generator
from datetime import timedelta
from unittest.mock import patch
@@ -17,10 +18,9 @@ from homeassistant.components.valve import (
)
from homeassistant.const import ATTR_ENTITY_ID, EVENT_STATE_CHANGED, Platform
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from tests.common import async_capture_events, async_fire_time_changed
from tests.common import MockConfigEntry, async_capture_events, async_fire_time_changed
FRONT_GARDEN = "valve.front_garden"
ORCHARD = "valve.orchard"
@@ -28,7 +28,7 @@ BACK_GARDEN = "valve.back_garden"
@pytest.fixture
async def valve_only() -> None:
def valve_only() -> Generator[None]:
"""Enable only the valve platform."""
with patch(
"homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM",
@@ -38,11 +38,12 @@ async def valve_only() -> None:
@pytest.fixture(autouse=True)
async def setup_comp(hass: HomeAssistant, valve_only: None):
"""Set up demo component."""
assert await async_setup_component(
hass, VALVE_DOMAIN, {VALVE_DOMAIN: {"platform": DOMAIN}}
)
async def setup_comp(hass: HomeAssistant, valve_only: None) -> None:
"""Set up demo component from config entry."""
config_entry = MockConfigEntry(domain=DOMAIN)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
@@ -50,6 +51,7 @@ async def setup_comp(hass: HomeAssistant, valve_only: None):
async def test_closing(hass: HomeAssistant) -> None:
"""Test the closing of a valve."""
state = hass.states.get(FRONT_GARDEN)
assert state is not None
assert state.state == ValveState.OPEN
await hass.async_block_till_done()
@@ -63,9 +65,11 @@ async def test_closing(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
assert state_changes[0].data["entity_id"] == FRONT_GARDEN
assert state_changes[0].data["new_state"] is not None
assert state_changes[0].data["new_state"].state == ValveState.CLOSING
assert state_changes[1].data["entity_id"] == FRONT_GARDEN
assert state_changes[1].data["new_state"] is not None
assert state_changes[1].data["new_state"].state == ValveState.CLOSED
@@ -73,6 +77,7 @@ async def test_closing(hass: HomeAssistant) -> None:
async def test_opening(hass: HomeAssistant) -> None:
"""Test the opening of a valve."""
state = hass.states.get(ORCHARD)
assert state is not None
assert state.state == ValveState.CLOSED
await hass.async_block_till_done()
@@ -83,15 +88,18 @@ async def test_opening(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
assert state_changes[0].data["entity_id"] == ORCHARD
assert state_changes[0].data["new_state"] is not None
assert state_changes[0].data["new_state"].state == ValveState.OPENING
assert state_changes[1].data["entity_id"] == ORCHARD
assert state_changes[1].data["new_state"] is not None
assert state_changes[1].data["new_state"].state == ValveState.OPEN
async def test_set_valve_position(hass: HomeAssistant) -> None:
"""Test moving the valve to a specific position."""
state = hass.states.get(BACK_GARDEN)
assert state is not None
assert state.attributes[ATTR_CURRENT_POSITION] == 70
# close to 10%
@@ -102,6 +110,7 @@ async def test_set_valve_position(hass: HomeAssistant) -> None:
blocking=True,
)
state = hass.states.get(BACK_GARDEN)
assert state is not None
assert state.state == ValveState.CLOSING
for _ in range(6):
@@ -110,6 +119,7 @@ async def test_set_valve_position(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
state = hass.states.get(BACK_GARDEN)
assert state is not None
assert state.attributes[ATTR_CURRENT_POSITION] == 10
assert state.state == ValveState.OPEN
@@ -121,6 +131,7 @@ async def test_set_valve_position(hass: HomeAssistant) -> None:
blocking=True,
)
state = hass.states.get(BACK_GARDEN)
assert state is not None
assert state.state == ValveState.OPENING
for _ in range(7):
@@ -129,6 +140,7 @@ async def test_set_valve_position(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
state = hass.states.get(BACK_GARDEN)
assert state is not None
assert state.attributes[ATTR_CURRENT_POSITION] == 80
assert state.state == ValveState.OPEN

View File

@@ -9,12 +9,12 @@ from homeassistant.core import HomeAssistant
from tests.components.common import (
ConditionStateDescription,
assert_condition_behavior_all,
assert_condition_behavior_any,
assert_condition_gated_by_labs_flag,
create_target_condition,
parametrize_condition_states_all,
parametrize_condition_states_any,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
@@ -70,32 +70,17 @@ async def test_device_tracker_state_condition_behavior_any(
states: list[ConditionStateDescription],
) -> None:
"""Test the device tracker state condition with the 'any' behavior."""
other_entity_ids = set(target_device_trackers["included"]) - {entity_id}
# Set all device trackers, including the tested one, to the initial state
for eid in target_device_trackers["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_any(
hass,
target_entities=target_device_trackers,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="any",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
# Check if changing other device trackers also passes the condition
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
@@ -128,29 +113,13 @@ async def test_device_tracker_state_condition_behavior_all(
states: list[ConditionStateDescription],
) -> None:
"""Test the device tracker state condition with the 'all' behavior."""
other_entity_ids = set(target_device_trackers["included"]) - {entity_id}
# Set all device trackers, including the tested one, to the initial state
for eid in target_device_trackers["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_all(
hass,
target_entities=target_device_trackers,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="all",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true_first_entity"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]

View File

@@ -10,12 +10,12 @@ from homeassistant.core import HomeAssistant
from tests.components.common import (
ConditionStateDescription,
assert_condition_behavior_all,
assert_condition_behavior_any,
assert_condition_gated_by_labs_flag,
create_target_condition,
parametrize_condition_states_all,
parametrize_condition_states_any,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
@@ -73,32 +73,17 @@ async def test_humidifier_state_condition_behavior_any(
states: list[ConditionStateDescription],
) -> None:
"""Test the humidifier state condition with the 'any' behavior."""
other_entity_ids = set(target_humidifiers["included"]) - {entity_id}
# Set all humidifiers, including the tested humidifier, to the initial state
for eid in target_humidifiers["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_any(
hass,
target_entities=target_humidifiers,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="any",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
# Check if changing other humidifiers also passes the condition
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
@@ -131,33 +116,17 @@ async def test_humidifier_state_condition_behavior_all(
states: list[ConditionStateDescription],
) -> None:
"""Test the humidifier state condition with the 'all' behavior."""
other_entity_ids = set(target_humidifiers["included"]) - {entity_id}
# Set all humidifiers, including the tested humidifier, to the initial state
for eid in target_humidifiers["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_all(
hass,
target_entities=target_humidifiers,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="all",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true_first_entity"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
@@ -190,32 +159,17 @@ async def test_humidifier_attribute_condition_behavior_any(
states: list[ConditionStateDescription],
) -> None:
"""Test the humidifier attribute condition with the 'any' behavior."""
other_entity_ids = set(target_humidifiers["included"]) - {entity_id}
# Set all humidifiers, including the tested humidifier, to the initial state
for eid in target_humidifiers["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_any(
hass,
target_entities=target_humidifiers,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="any",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
# Check if changing other humidifiers also passes the condition
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
@@ -248,29 +202,13 @@ async def test_humidifier_attribute_condition_behavior_all(
states: list[ConditionStateDescription],
) -> None:
"""Test the humidifier attribute condition with the 'all' behavior."""
other_entity_ids = set(target_humidifiers["included"]) - {entity_id}
# Set all humidifiers, including the tested humidifier, to the initial state
for eid in target_humidifiers["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_all(
hass,
target_entities=target_humidifiers,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="all",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true_first_entity"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]

View File

@@ -1,8 +1,9 @@
# serializer version: 1
# name: test_button[1][button.bk1600_enable_standby_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
@@ -50,8 +51,9 @@
# ---
# name: test_button[2][button.cms_sf2000_enable_standby_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,

View File

@@ -9,13 +9,13 @@ from homeassistant.core import HomeAssistant
from tests.components.common import (
ConditionStateDescription,
assert_condition_behavior_all,
assert_condition_behavior_any,
assert_condition_gated_by_labs_flag,
create_target_condition,
other_states,
parametrize_condition_states_all,
parametrize_condition_states_any,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
@@ -89,32 +89,17 @@ async def test_lawn_mower_state_condition_behavior_any(
states: list[ConditionStateDescription],
) -> None:
"""Test the lawn mower state condition with the 'any' behavior."""
other_entity_ids = set(target_lawn_mowers["included"]) - {entity_id}
# Set all lawn mowers, including the tested lawn mower, to the initial state
for eid in target_lawn_mowers["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_any(
hass,
target_entities=target_lawn_mowers,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="any",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
# Check if changing other lawn mowers also passes the condition
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
@@ -162,29 +147,13 @@ async def test_lawn_mower_state_condition_behavior_all(
states: list[ConditionStateDescription],
) -> None:
"""Test the lawn mower state condition with the 'all' behavior."""
other_entity_ids = set(target_lawn_mowers["included"]) - {entity_id}
# Set all lawn mowers, including the tested lawn mower, to the initial state
for eid in target_lawn_mowers["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_all(
hass,
target_entities=target_lawn_mowers,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="all",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true_first_entity"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]

View File

@@ -9,13 +9,13 @@ from homeassistant.core import HomeAssistant
from tests.components.common import (
ConditionStateDescription,
assert_condition_behavior_all,
assert_condition_behavior_any,
assert_condition_gated_by_labs_flag,
create_target_condition,
other_states,
parametrize_condition_states_all,
parametrize_condition_states_any,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
@@ -83,32 +83,17 @@ async def test_lock_state_condition_behavior_any(
states: list[ConditionStateDescription],
) -> None:
"""Test the lock state condition with the 'any' behavior."""
other_entity_ids = set(target_locks["included"]) - {entity_id}
# Set all locks, including the tested lock, to the initial state
for eid in target_locks["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_any(
hass,
target_entities=target_locks,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="any",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
# Check if changing other locks also passes the condition
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
@@ -151,29 +136,13 @@ async def test_lock_state_condition_behavior_all(
states: list[ConditionStateDescription],
) -> None:
"""Test the lock state condition with the 'all' behavior."""
other_entity_ids = set(target_locks["included"]) - {entity_id}
# Set all locks, including the tested lock, to the initial state
for eid in target_locks["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_all(
hass,
target_entities=target_locks,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="all",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true_first_entity"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]

View File

@@ -9,13 +9,13 @@ from homeassistant.core import HomeAssistant
from tests.components.common import (
ConditionStateDescription,
assert_condition_behavior_all,
assert_condition_behavior_any,
assert_condition_gated_by_labs_flag,
create_target_condition,
other_states,
parametrize_condition_states_all,
parametrize_condition_states_any,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
@@ -101,32 +101,17 @@ async def test_media_player_state_condition_behavior_any(
states: list[ConditionStateDescription],
) -> None:
"""Test the media player state condition with the 'any' behavior."""
other_entity_ids = set(target_media_players["included"]) - {entity_id}
# Set all media players, including the tested media player, to the initial state
for eid in target_media_players["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_any(
hass,
target_entities=target_media_players,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="any",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
# Check if changing other media players also passes the condition
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
@@ -186,29 +171,13 @@ async def test_media_player_state_condition_behavior_all(
states: list[ConditionStateDescription],
) -> None:
"""Test the media player state condition with the 'all' behavior."""
other_entity_ids = set(target_media_players["included"]) - {entity_id}
# Set all media players, including the tested media player, to the initial state
for eid in target_media_players["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_all(
hass,
target_entities=target_media_players,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="all",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true_first_entity"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]

View File

@@ -3,7 +3,7 @@
from copy import deepcopy
import json
from typing import Any
from unittest.mock import patch
from unittest.mock import call, patch
import pytest
@@ -30,6 +30,7 @@ from homeassistant.components.vacuum import (
from homeassistant.const import CONF_NAME, ENTITY_MATCH_ALL, STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from .common import (
help_custom_config,
@@ -63,7 +64,11 @@ from .common import (
from tests.common import async_fire_mqtt_message
from tests.components.vacuum import common
from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient
from tests.typing import (
MqttMockHAClientGenerator,
MqttMockPahoClient,
WebSocketGenerator,
)
COMMAND_TOPIC = "vacuum/command"
SEND_COMMAND_TOPIC = "vacuum/send_command"
@@ -82,6 +87,27 @@ DEFAULT_CONFIG = {
}
}
CONFIG_CLEAN_SEGMENTS_1 = {
mqtt.DOMAIN: {
vacuum.DOMAIN: {
"name": "test",
"unique_id": "veryunique",
"segments": ["Livingroom", "Kitchen"],
"clean_segments_command_topic": "vacuum/clean_segment",
}
}
}
CONFIG_CLEAN_SEGMENTS_2 = {
mqtt.DOMAIN: {
vacuum.DOMAIN: {
"name": "test",
"unique_id": "veryunique",
"segments": ["1.Livingroom", "2.Kitchen"],
"clean_segments_command_topic": "vacuum/clean_segment",
}
}
}
DEFAULT_CONFIG_2 = {mqtt.DOMAIN: {vacuum.DOMAIN: {"name": "test"}}}
CONFIG_ALL_SERVICES = help_custom_config(
@@ -294,6 +320,347 @@ async def test_command_without_command_topic(
mqtt_mock.async_publish.reset_mock()
@pytest.mark.parametrize("hass_config", [CONFIG_CLEAN_SEGMENTS_1])
async def test_clean_segments_initial_setup_without_repair_issue(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
) -> None:
"""Test cleanable segments initial setup does not fire repair flow."""
await mqtt_mock_entry()
issue_registry = ir.async_get(hass)
assert len(issue_registry.issues) == 0
@pytest.mark.parametrize("hass_config", [CONFIG_CLEAN_SEGMENTS_1])
async def test_clean_segments_command_without_id(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
entity_registry: er.EntityRegistry,
mqtt_mock_entry: MqttMockHAClientGenerator,
) -> None:
"""Test cleanable segments without ID."""
config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0]
entity_registry.async_get_or_create(
vacuum.DOMAIN,
mqtt.DOMAIN,
"veryunique",
config_entry=config_entry,
suggested_object_id="test",
)
entity_registry.async_update_entity_options(
"vacuum.test",
vacuum.DOMAIN,
{
"area_mapping": {"Nabu Casa": ["Kitchen", "Livingroom"]},
"last_seen_segments": [
{"id": "Livingroom", "name": "Livingroom"},
{"id": "Kitchen", "name": "Kitchen"},
],
},
)
mqtt_mock = await mqtt_mock_entry()
await hass.async_block_till_done()
issue_registry = ir.async_get(hass)
# We do not expect a repair flow
assert len(issue_registry.issues) == 0
state = hass.states.get("vacuum.test")
assert state.state == STATE_UNKNOWN
await common.async_clean_area(hass, ["Nabu Casa"], entity_id="vacuum.test")
assert (
call("vacuum/clean_segment", '["Kitchen","Livingroom"]', 0, False)
in mqtt_mock.async_publish.mock_calls
)
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{"type": "vacuum/get_segments", "entity_id": "vacuum.test"}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"]["segments"] == [
{"id": "Livingroom", "name": "Livingroom", "group": None},
{"id": "Kitchen", "name": "Kitchen", "group": None},
]
@pytest.mark.parametrize("hass_config", [CONFIG_CLEAN_SEGMENTS_2])
async def test_clean_segments_command_with_id(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
entity_registry: er.EntityRegistry,
mqtt_mock_entry: MqttMockHAClientGenerator,
) -> None:
"""Test cleanable segments with ID."""
mqtt_mock = await mqtt_mock_entry()
# Set the area mapping
entity_registry.async_update_entity_options(
"vacuum.test",
vacuum.DOMAIN,
{
"area_mapping": {"Livingroom": ["1"], "Kitchen": ["2"]},
"last_seen_segments": [
{"id": "1", "name": "Livingroom"},
{"id": "2", "name": "Kitchen"},
],
},
)
await hass.async_block_till_done()
state = hass.states.get("vacuum.test")
assert state.state == STATE_UNKNOWN
await common.async_clean_area(hass, ["Kitchen"], entity_id="vacuum.test")
assert (
call("vacuum/clean_segment", '["2"]', 0, False)
in mqtt_mock.async_publish.mock_calls
)
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{"type": "vacuum/get_segments", "entity_id": "vacuum.test"}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"]["segments"] == [
{"id": "1", "name": "Livingroom", "group": None},
{"id": "2", "name": "Kitchen", "group": None},
]
async def test_clean_segments_command_update(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
entity_registry: er.EntityRegistry,
mqtt_mock_entry: MqttMockHAClientGenerator,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test cleanable segments update via discovery."""
# Prepare original entity config entry
config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0]
entity_registry.async_get_or_create(
vacuum.DOMAIN,
mqtt.DOMAIN,
"veryunique",
config_entry=config_entry,
suggested_object_id="test",
)
entity_registry.async_update_entity_options(
"vacuum.test",
vacuum.DOMAIN,
{
"area_mapping": {"Livingroom": ["1"], "Kitchen": ["2"]},
"last_seen_segments": [
{"id": "1", "name": "Livingroom"},
{"id": "2", "name": "Kitchen"},
],
},
)
await mqtt_mock_entry()
# Do initial discovery
config1 = CONFIG_CLEAN_SEGMENTS_2[mqtt.DOMAIN][vacuum.DOMAIN]
payload1 = json.dumps(config1)
config_topic = "homeassistant/vacuum/bla/config"
async_fire_mqtt_message(hass, config_topic, payload1)
await hass.async_block_till_done()
state = hass.states.get("vacuum.test")
assert state.state == STATE_UNKNOWN
issue_registry = ir.async_get(hass)
# We do not expect a repair flow
assert len(issue_registry.issues) == 0
# Update the segments
config2 = config1.copy()
config2["segments"] = ["1.Livingroom", "2.Kitchen", "3.Diningroom"]
payload2 = json.dumps(config2)
async_fire_mqtt_message(hass, config_topic, payload2)
await hass.async_block_till_done()
# A repair flow should start
assert len(issue_registry.issues) == 1
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{"type": "vacuum/get_segments", "entity_id": "vacuum.test"}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"]["segments"] == [
{"id": "1", "name": "Livingroom", "group": None},
{"id": "2", "name": "Kitchen", "group": None},
{"id": "3", "name": "Diningroom", "group": None},
]
# Test update with a non-unique segment list fails
config3 = config1.copy()
config3["segments"] = ["1.Livingroom", "2.Kitchen", "2.Diningroom"]
payload3 = json.dumps(config3)
async_fire_mqtt_message(hass, config_topic, payload3)
await hass.async_block_till_done()
assert (
"Error 'The `segments` option contains an invalid or non-unique segment ID '2'"
in caplog.text
)
@pytest.mark.parametrize(
"hass_config",
[
{
mqtt.DOMAIN: {
vacuum.DOMAIN: {
"name": "test",
"unique_id": "veryunique",
"segments": ["Livingroom", "Kitchen", "Kitchen"],
"clean_segments_command_topic": "vacuum/clean_segment",
}
}
},
{
mqtt.DOMAIN: {
vacuum.DOMAIN: {
"name": "test",
"unique_id": "veryunique",
"segments": ["Livingroom", "Kitchen", ""],
"clean_segments_command_topic": "vacuum/clean_segment",
}
}
},
{
mqtt.DOMAIN: {
vacuum.DOMAIN: {
"name": "test",
"unique_id": "veryunique",
"segments": ["1.Livingroom", "1.Kitchen"],
"clean_segments_command_topic": "vacuum/clean_segment",
}
}
},
{
mqtt.DOMAIN: {
vacuum.DOMAIN: {
"name": "test",
"unique_id": "veryunique",
"segments": ["1.Livingroom", "1.Kitchen", ".Diningroom"],
"clean_segments_command_topic": "vacuum/clean_segment",
}
}
},
],
)
async def test_non_unique_segments(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test with non-unique list of cleanable segments with valid segment IDs."""
await mqtt_mock_entry()
assert (
"The `segments` option contains an invalid or non-unique segment ID"
in caplog.text
)
@pytest.mark.usefixtures("hass")
@pytest.mark.parametrize(
("hass_config", "error_message"),
[
(
help_custom_config(
vacuum.DOMAIN,
DEFAULT_CONFIG,
({"clean_segments_command_topic": "test-topic"},),
),
"Options `segments` and "
"`clean_segments_command_topic` must be defined together",
),
(
help_custom_config(
vacuum.DOMAIN,
DEFAULT_CONFIG,
({"segments": ["Livingroom"]},),
),
"Options `segments` and "
"`clean_segments_command_topic` must be defined together",
),
(
help_custom_config(
vacuum.DOMAIN,
DEFAULT_CONFIG,
(
{
"segments": ["Livingroom"],
"clean_segments_command_topic": "test-topic",
},
),
),
"Option `segments` requires `unique_id` to be configured",
),
],
)
async def test_clean_segments_config_validation(
mqtt_mock_entry: MqttMockHAClientGenerator,
error_message: str,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test status clean segment config validation."""
await mqtt_mock_entry()
assert error_message in caplog.text
@pytest.mark.parametrize(
"hass_config",
[
help_custom_config(
vacuum.DOMAIN,
CONFIG_CLEAN_SEGMENTS_2,
({"clean_segments_command_template": "{{ ';'.join(value) }}"},),
)
],
)
async def test_clean_segments_command_with_id_and_command_template(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
entity_registry: er.EntityRegistry,
mqtt_mock_entry: MqttMockHAClientGenerator,
) -> None:
"""Test clean segments with command template."""
mqtt_mock = await mqtt_mock_entry()
entity_registry.async_update_entity_options(
"vacuum.test",
vacuum.DOMAIN,
{
"area_mapping": {"Livingroom": ["1"], "Kitchen": ["2"]},
"last_seen_segments": [
{"id": "1", "name": "Livingroom"},
{"id": "2", "name": "Kitchen"},
],
},
)
await hass.async_block_till_done()
state = hass.states.get("vacuum.test")
assert state.state == STATE_UNKNOWN
await common.async_clean_area(
hass, ["Livingroom", "Kitchen"], entity_id="vacuum.test"
)
assert (
call("vacuum/clean_segment", "1;2", 0, False)
in mqtt_mock.async_publish.mock_calls
)
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{"type": "vacuum/get_segments", "entity_id": "vacuum.test"}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"]["segments"] == [
{"id": "1", "name": "Livingroom", "group": None},
{"id": "2", "name": "Kitchen", "group": None},
]
@pytest.mark.parametrize("hass_config", [CONFIG_ALL_SERVICES])
async def test_status(
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator

View File

@@ -9,12 +9,12 @@ from homeassistant.core import HomeAssistant
from tests.components.common import (
ConditionStateDescription,
assert_condition_behavior_all,
assert_condition_behavior_any,
assert_condition_gated_by_labs_flag,
create_target_condition,
parametrize_condition_states_all,
parametrize_condition_states_any,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
@@ -70,32 +70,17 @@ async def test_person_state_condition_behavior_any(
states: list[ConditionStateDescription],
) -> None:
"""Test the person state condition with the 'any' behavior."""
other_entity_ids = set(target_persons["included"]) - {entity_id}
# Set all persons, including the tested person, to the initial state
for eid in target_persons["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_any(
hass,
target_entities=target_persons,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="any",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
# Check if changing other persons also passes the condition
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
@@ -128,29 +113,13 @@ async def test_person_state_condition_behavior_all(
states: list[ConditionStateDescription],
) -> None:
"""Test the person state condition with the 'all' behavior."""
other_entity_ids = set(target_persons["included"]) - {entity_id}
# Set all persons, including the tested person, to the initial state
for eid in target_persons["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_all(
hass,
target_entities=target_persons,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="all",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true_first_entity"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]

View File

@@ -0,0 +1,60 @@
# serializer version: 1
# name: test_numbers[number.prana_recuperator_display_brightness-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'max': 6,
'min': 0,
'mode': <NumberMode.SLIDER: 'slider'>,
'step': 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.prana_recuperator_display_brightness',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Display brightness',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Display brightness',
'platform': 'prana',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'display_brightness',
'unique_id': 'ECC9FFE0E574_display_brightness',
'unit_of_measurement': None,
})
# ---
# name: test_numbers[number.prana_recuperator_display_brightness-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'PRANA RECUPERATOR Display brightness',
'max': 6,
'min': 0,
'mode': <NumberMode.SLIDER: 'slider'>,
'step': 1,
}),
'context': <ANY>,
'entity_id': 'number.prana_recuperator_display_brightness',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '6',
})
# ---

View File

@@ -0,0 +1,76 @@
"""Integration-style tests for Prana numbers."""
from unittest.mock import MagicMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.number import (
ATTR_VALUE,
DOMAIN as NUMBER_DOMAIN,
SERVICE_SET_VALUE,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import async_init_integration
from tests.common import MockConfigEntry, snapshot_platform
async def test_numbers(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
mock_prana_api: MagicMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test the Prana numbers snapshot."""
with patch("homeassistant.components.prana.PLATFORMS", [Platform.NUMBER]):
await async_init_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("input_value", "expected_api_value"),
[
(0.0, 0), # 0 -> 0
(1.0, 1), # 2^(1-1) -> 1
(2.0, 2), # 2^(2-1) -> 2
(3.0, 4), # 2^(3-1) -> 4
(4.0, 8), # 2^(4-1) -> 8
(5.0, 16), # 2^(5-1) -> 16
(6.0, 32), # 2^(6-1) -> 32
],
)
async def test_number_actions(
hass: HomeAssistant,
mock_prana_api: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
input_value: float,
expected_api_value: int,
) -> None:
"""Test setting number values calls the API with correct math conversion."""
await async_init_integration(hass, mock_config_entry)
entries = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
assert entries
target = "number.prana_recuperator_display_brightness"
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: target,
ATTR_VALUE: input_value,
},
blocking=True,
)
mock_prana_api.set_brightness.assert_called_with(expected_api_value)

View File

@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from homeassistant.components.radio_browser.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@@ -39,10 +40,15 @@ async def init_integration(
) -> MockConfigEntry:
"""Set up the Radio Browser integration for testing."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
with patch(
"homeassistant.components.radio_browser.RadioBrowser",
autospec=True,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state == ConfigEntryState.LOADED
return mock_config_entry

View File

@@ -1,15 +1,20 @@
"""Tests for radio_browser media_source."""
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, patch
from aiodns.error import DNSError
import pytest
from radios import FilterBy, Order
from radios import FilterBy, Order, RadioBrowserError
from homeassistant.components import media_source
from homeassistant.components.media_player import BrowseError
from homeassistant.components.radio_browser.media_source import async_get_media_source
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
DOMAIN = "radio_browser"
@@ -71,3 +76,113 @@ async def test_browsing_local(
assert other_browse is not None
assert other_browse.title == "My Radios"
assert len(other_browse.children) == 0
@pytest.mark.parametrize(
"exception",
[DNSError, RadioBrowserError],
)
async def test_browsing_exceptions(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
exception: Exception,
) -> None:
"""Test browsing exceptions."""
with patch(
"homeassistant.components.radio_browser.RadioBrowser",
autospec=True,
) as mock_browser:
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state == ConfigEntryState.LOADED
mock_browser.return_value.stations.side_effect = exception
with pytest.raises(BrowseError) as exc_info:
await media_source.async_browse_media(
hass, f"{media_source.URI_SCHEME}{DOMAIN}/popular"
)
assert exc_info.value.translation_key == "radio_browser_error"
async def test_browsing_not_ready(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test browsing config entry not ready."""
with patch(
"homeassistant.components.radio_browser.RadioBrowser",
autospec=True,
) as mock_browser:
mock_browser.return_value.stats.side_effect = RadioBrowserError
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state == ConfigEntryState.SETUP_RETRY
with pytest.raises(BrowseError) as exc_info:
await media_source.async_browse_media(
hass, f"{media_source.URI_SCHEME}{DOMAIN}/popular"
)
assert exc_info.value.translation_key == "config_entry_not_ready"
@pytest.mark.parametrize(
"exception",
[DNSError, RadioBrowserError],
)
async def test_resolve_media_exceptions(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
exception: Exception,
) -> None:
"""Test resolving media exceptions."""
with patch(
"homeassistant.components.radio_browser.RadioBrowser",
autospec=True,
) as mock_browser:
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state == ConfigEntryState.LOADED
mock_browser.return_value.station.side_effect = exception
with pytest.raises(media_source.Unresolvable) as exc_info:
await media_source.async_resolve_media(
hass, f"{media_source.URI_SCHEME}{DOMAIN}/123456", None
)
assert exc_info.value.translation_key == "radio_browser_error"
async def test_resolve_media_not_ready(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test resolving media config entry not ready."""
with patch(
"homeassistant.components.radio_browser.RadioBrowser",
autospec=True,
) as mock_browser:
mock_browser.return_value.stats.side_effect = RadioBrowserError
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state == ConfigEntryState.SETUP_RETRY
with pytest.raises(media_source.Unresolvable) as exc_info:
await media_source.async_resolve_media(
hass, f"{media_source.URI_SCHEME}{DOMAIN}/123456", None
)
assert exc_info.value.translation_key == "config_entry_not_ready"

View File

@@ -127,7 +127,7 @@ async def test_scene_triggers_gated_by_labs_flag(
),
],
)
async def test_scene_state_trigger_behavior_any(
async def test_scene_state_trigger(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_scenes: dict[str, list[str]],
@@ -137,7 +137,7 @@ async def test_scene_state_trigger_behavior_any(
trigger: str,
states: list[TriggerStateDescription],
) -> None:
"""Test that the scene state trigger fires when any scene state changes to a specific state."""
"""Test that the scene state trigger fires when targeted scene state changes."""
other_entity_ids = set(target_scenes["included"]) - {entity_id}
# Set all scenes, including the tested scene, to the initial state

View File

@@ -1,6 +1,7 @@
"""The tests for the Season integration."""
from datetime import datetime
from zoneinfo import ZoneInfo
from freezegun import freeze_time
import pytest
@@ -20,6 +21,8 @@ from homeassistant.components.sensor import ATTR_OPTIONS, SensorDeviceClass
from homeassistant.const import ATTR_DEVICE_CLASS, CONF_TYPE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.entity_component import async_update_entity
from homeassistant.util.dt import UTC
from tests.common import MockConfigEntry
@@ -44,25 +47,25 @@ HEMISPHERE_EMPTY = {
}
NORTHERN_PARAMETERS = [
(TYPE_ASTRONOMICAL, datetime(2017, 9, 3, 0, 0), STATE_SUMMER),
(TYPE_METEOROLOGICAL, datetime(2017, 8, 13, 0, 0), STATE_SUMMER),
(TYPE_ASTRONOMICAL, datetime(2017, 9, 23, 0, 0), STATE_AUTUMN),
(TYPE_METEOROLOGICAL, datetime(2017, 9, 3, 0, 0), STATE_AUTUMN),
(TYPE_ASTRONOMICAL, datetime(2017, 12, 25, 0, 0), STATE_WINTER),
(TYPE_METEOROLOGICAL, datetime(2017, 12, 3, 0, 0), STATE_WINTER),
(TYPE_ASTRONOMICAL, datetime(2017, 4, 1, 0, 0), STATE_SPRING),
(TYPE_METEOROLOGICAL, datetime(2017, 3, 3, 0, 0), STATE_SPRING),
(TYPE_ASTRONOMICAL, datetime(2017, 9, 3, 0, 0, tzinfo=UTC), STATE_SUMMER),
(TYPE_METEOROLOGICAL, datetime(2017, 8, 13, 0, 0, tzinfo=UTC), STATE_SUMMER),
(TYPE_ASTRONOMICAL, datetime(2017, 9, 23, 0, 0, tzinfo=UTC), STATE_AUTUMN),
(TYPE_METEOROLOGICAL, datetime(2017, 9, 3, 0, 0, tzinfo=UTC), STATE_AUTUMN),
(TYPE_ASTRONOMICAL, datetime(2017, 12, 25, 0, 0, tzinfo=UTC), STATE_WINTER),
(TYPE_METEOROLOGICAL, datetime(2017, 12, 3, 0, 0, tzinfo=UTC), STATE_WINTER),
(TYPE_ASTRONOMICAL, datetime(2017, 4, 1, 0, 0, tzinfo=UTC), STATE_SPRING),
(TYPE_METEOROLOGICAL, datetime(2017, 3, 3, 0, 0, tzinfo=UTC), STATE_SPRING),
]
SOUTHERN_PARAMETERS = [
(TYPE_ASTRONOMICAL, datetime(2017, 12, 25, 0, 0), STATE_SUMMER),
(TYPE_METEOROLOGICAL, datetime(2017, 12, 3, 0, 0), STATE_SUMMER),
(TYPE_ASTRONOMICAL, datetime(2017, 4, 1, 0, 0), STATE_AUTUMN),
(TYPE_METEOROLOGICAL, datetime(2017, 3, 3, 0, 0), STATE_AUTUMN),
(TYPE_ASTRONOMICAL, datetime(2017, 9, 3, 0, 0), STATE_WINTER),
(TYPE_METEOROLOGICAL, datetime(2017, 8, 13, 0, 0), STATE_WINTER),
(TYPE_ASTRONOMICAL, datetime(2017, 9, 23, 0, 0), STATE_SPRING),
(TYPE_METEOROLOGICAL, datetime(2017, 9, 3, 0, 0), STATE_SPRING),
(TYPE_ASTRONOMICAL, datetime(2017, 12, 25, 0, 0, tzinfo=UTC), STATE_SUMMER),
(TYPE_METEOROLOGICAL, datetime(2017, 12, 3, 0, 0, tzinfo=UTC), STATE_SUMMER),
(TYPE_ASTRONOMICAL, datetime(2017, 4, 1, 0, 0, tzinfo=UTC), STATE_AUTUMN),
(TYPE_METEOROLOGICAL, datetime(2017, 3, 3, 0, 0, tzinfo=UTC), STATE_AUTUMN),
(TYPE_ASTRONOMICAL, datetime(2017, 9, 3, 0, 0, tzinfo=UTC), STATE_WINTER),
(TYPE_METEOROLOGICAL, datetime(2017, 8, 13, 0, 0, tzinfo=UTC), STATE_WINTER),
(TYPE_ASTRONOMICAL, datetime(2017, 9, 23, 0, 0, tzinfo=UTC), STATE_SPRING),
(TYPE_METEOROLOGICAL, datetime(2017, 9, 3, 0, 0, tzinfo=UTC), STATE_SPRING),
]
@@ -154,7 +157,7 @@ async def test_season_equator(
hass.config.latitude = HEMISPHERE_EQUATOR["homeassistant"]["latitude"]
mock_config_entry.add_to_hass(hass)
with freeze_time(datetime(2017, 9, 3, 0, 0)):
with freeze_time(datetime(2017, 9, 3, 0, 0, tzinfo=UTC)):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
@@ -165,3 +168,43 @@ async def test_season_equator(
entry = entity_registry.async_get("sensor.season")
assert entry
assert entry.unique_id == mock_config_entry.entry_id
async def test_season_local_midnight(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that season changes at local midnight, not UTC."""
await hass.config.async_set_time_zone("Australia/Sydney")
hass.config.latitude = HEMISPHERE_SOUTHERN["homeassistant"]["latitude"]
mock_config_entry.add_to_hass(hass)
hass.config_entries.async_update_entry(
mock_config_entry,
unique_id=TYPE_METEOROLOGICAL,
data={CONF_TYPE: TYPE_METEOROLOGICAL},
)
sydney_tz = ZoneInfo("Australia/Sydney")
# The day before autumn starts, at 23:59:59 local time (summer)
day_before = datetime(2017, 2, 28, 23, 59, 59, tzinfo=sydney_tz)
with freeze_time(day_before):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("sensor.season")
assert state
assert state.state == STATE_SUMMER
# Exactly midnight local time (autumn)
midnight = datetime(2017, 3, 1, 0, 0, 0, tzinfo=sydney_tz)
with freeze_time(midnight):
await async_update_entity(hass, "sensor.season")
await hass.async_block_till_done()
state = hass.states.get("sensor.season")
assert state
assert state.state == STATE_AUTUMN

View File

@@ -68,6 +68,7 @@ DEVICE_FIXTURES = [
"da_wm_wm_100002",
"da_wm_wm_000001",
"da_wm_wm_000001_1",
"da_wm_mf_01001",
"da_wm_sc_000001",
"da_wm_dw_01011",
"da_rvc_normal_000001",

View File

@@ -0,0 +1,331 @@
{
"components": {
"main": {
"refresh": {},
"execute": {
"data": {
"value": null
}
},
"samsungce.deviceIdentification": {
"micomAssayCode": {
"value": "20349241",
"timestamp": "2026-03-17T09:56:51.548Z"
},
"modelName": {
"value": null
},
"serialNumber": {
"value": null
},
"serialNumberExtra": {
"value": null
},
"releaseCountry": {
"value": null
},
"modelClassificationCode": {
"value": "3A000000001511000A90020200000000",
"timestamp": "2026-03-17T09:56:51.548Z"
},
"description": {
"value": "AMF-WW-TP1-22-COMMON_FT-MF/DC92-03492A_0001",
"timestamp": "2026-03-17T09:56:51.548Z"
},
"releaseYear": {
"value": 21,
"timestamp": "2025-06-16T00:39:32.549Z"
},
"binaryId": {
"value": "AMF-WW-TP1-22-COMMON",
"timestamp": "2026-03-17T09:56:51.547Z"
}
},
"switch": {
"switch": {
"value": "on",
"timestamp": "2026-03-17T09:56:51.608Z"
}
},
"sec.wifiConfiguration": {
"autoReconnection": {
"value": null
},
"minVersion": {
"value": null
},
"supportedWiFiFreq": {
"value": null
},
"supportedAuthType": {
"value": null
},
"protocolType": {
"value": null
}
},
"samsungce.microfiberFilterOperatingState": {
"operatingState": {
"value": "ready",
"timestamp": "2026-03-17T10:27:51.168Z"
},
"supportedJobStates": {
"value": [
"none",
"filtering",
"bypassing",
"waiting",
"stopping",
"sensing"
],
"timestamp": "2026-03-17T07:49:18.985Z"
},
"supportedOperatingStates": {
"value": ["ready", "running", "paused"],
"timestamp": "2026-03-17T07:49:18.985Z"
},
"microfiberFilterJobState": {
"value": "waiting",
"timestamp": "2026-03-17T10:27:51.168Z"
}
},
"samsungce.selfCheck": {
"result": {
"value": null
},
"supportedActions": {
"value": null
},
"progress": {
"value": null
},
"errors": {
"value": null
},
"status": {
"value": null
}
},
"samsungce.softwareVersion": {
"versions": {
"value": [
{
"id": "0",
"swType": "Software",
"versionNumber": "03334A230323(A603)",
"description": "AMF-WW-TP1-22-COMMON|20349241|3A000000001511000A90020200000000"
},
{
"id": "1",
"swType": "Firmware",
"versionNumber": "23051057,FFFFFFFF",
"description": "Firmware_1_DB_20349241230510571FFFFFFFFFFFFFFFFFFFFFFFFFFE(018020349241FFFFFFFF_30000000)(FileDown:0)(Type:0)"
}
],
"timestamp": "2026-03-17T09:56:51.548Z"
}
},
"samsungce.microfiberFilterSettings": {
"bypassMode": {
"value": "disabled",
"timestamp": "2026-03-17T09:56:51.095Z"
}
},
"ocf": {
"st": {
"value": null
},
"mndt": {
"value": null
},
"mnfv": {
"value": "AMF-WW-TP1-22-COMMON_30230323",
"timestamp": "2025-06-18T08:56:52.092Z"
},
"mnhw": {
"value": "Realtek",
"timestamp": "2025-06-18T08:56:52.092Z"
},
"di": {
"value": "42e80b4d-24c4-a810-11b3-f90375c56a39",
"timestamp": "2025-06-18T08:56:52.092Z"
},
"mnsl": {
"value": "http://www.samsung.com",
"timestamp": "2025-06-18T08:56:52.092Z"
},
"dmv": {
"value": "res.1.1.0,sh.1.1.0",
"timestamp": "2025-06-18T08:56:52.092Z"
},
"n": {
"value": "[microfiber] Samsung",
"timestamp": "2025-06-18T08:56:52.092Z"
},
"mnmo": {
"value": "AMF-WW-TP1-22-COMMON|20349241|3A000000001511000A90020200000000",
"timestamp": "2025-06-18T08:56:52.092Z"
},
"vid": {
"value": "DA-WM-MF-01001",
"timestamp": "2025-06-18T08:56:52.092Z"
},
"mnmn": {
"value": "Samsung Electronics",
"timestamp": "2025-06-18T08:56:52.092Z"
},
"mnml": {
"value": "http://www.samsung.com",
"timestamp": "2025-06-18T08:56:52.092Z"
},
"mnpv": {
"value": "DAWIT 2.0",
"timestamp": "2025-06-18T08:56:52.092Z"
},
"mnos": {
"value": "TizenRT 3.1",
"timestamp": "2025-06-18T08:56:52.092Z"
},
"pi": {
"value": "42e80b4d-24c4-a810-11b3-f90375c56a39",
"timestamp": "2025-06-18T08:56:52.092Z"
},
"icv": {
"value": "core.1.1.0",
"timestamp": "2025-06-18T08:56:52.092Z"
}
},
"custom.disabledCapabilities": {
"disabledCapabilities": {
"value": [],
"timestamp": "2026-03-17T09:19:46.018Z"
}
},
"samsungce.driverVersion": {
"versionNumber": {
"value": 25040101,
"timestamp": "2025-06-16T01:24:28.272Z"
}
},
"samsungce.softwareUpdate": {
"targetModule": {
"value": {},
"timestamp": "2026-03-17T09:56:51.716Z"
},
"otnDUID": {
"value": "MTCHUODPC4IYE",
"timestamp": "2026-03-17T09:56:51.548Z"
},
"lastUpdatedDate": {
"value": null
},
"availableModules": {
"value": [],
"timestamp": "2026-03-17T09:56:51.548Z"
},
"newVersionAvailable": {
"value": false,
"timestamp": "2026-03-17T09:56:51.548Z"
},
"operatingState": {
"value": null
},
"progress": {
"value": null
}
},
"sec.diagnosticsInformation": {
"logType": {
"value": ["errCode", "dump"],
"timestamp": "2026-03-17T09:56:51.548Z"
},
"endpoint": {
"value": "SSM",
"timestamp": "2026-03-17T09:56:51.548Z"
},
"minVersion": {
"value": "1.0",
"timestamp": "2026-03-17T09:56:51.548Z"
},
"signinPermission": {
"value": null
},
"setupId": {
"value": "WM0",
"timestamp": "2026-03-17T09:56:51.548Z"
},
"protocolType": {
"value": "wifi_https",
"timestamp": "2026-03-17T09:56:51.548Z"
},
"tsId": {
"value": null
},
"mnId": {
"value": "0AJT",
"timestamp": "2026-03-17T09:56:51.548Z"
},
"dumpType": {
"value": "file",
"timestamp": "2026-03-17T09:56:51.548Z"
}
},
"samsungce.bladeFilter": {
"bladeFilterStatus": {
"value": null
},
"bladeFilterLastResetDate": {
"value": null
},
"bladeFilterUsage": {
"value": null
},
"bladeFilterResetType": {
"value": null
},
"bladeFilterUsageStep": {
"value": null
},
"bladeFilterCapacity": {
"value": null
}
},
"samsungce.microfiberFilterStatus": {
"supportedStatus": {
"value": ["blockage", "normal"],
"timestamp": "2026-03-17T07:49:18.985Z"
},
"status": {
"value": "normal",
"timestamp": "2026-03-17T07:49:18.985Z"
}
},
"custom.waterFilter": {
"waterFilterUsageStep": {
"value": 1,
"timestamp": "2026-03-17T09:19:46.018Z"
},
"waterFilterResetType": {
"value": ["replaceable"],
"timestamp": "2026-03-17T09:19:46.018Z"
},
"waterFilterCapacity": {
"value": 12,
"unit": "Hour",
"timestamp": "2026-03-17T09:19:46.018Z"
},
"waterFilterLastResetDate": {
"value": null
},
"waterFilterUsage": {
"value": 78,
"timestamp": "2026-03-17T10:17:49.492Z"
},
"waterFilterStatus": {
"value": "normal",
"timestamp": "2026-03-17T09:19:46.018Z"
}
}
}
}
}

View File

@@ -0,0 +1,126 @@
{
"items": [
{
"deviceId": "42e80b4d-24c4-a810-11b3-f90375c56a39",
"name": "[microfiber] Samsung",
"label": "Filtro in microfibra",
"manufacturerName": "Samsung Electronics",
"presentationId": "DA-WM-MF-01001",
"deviceManufacturerCode": "Samsung Electronics",
"locationId": "dc3da2dd-adb0-41b9-9367-9dafc2637386",
"ownerId": "98f99f44-0f42-c20c-a48f-e53c911e27c7",
"roomId": "4696910e-f24d-4831-817b-b8b6b49ed885",
"deviceTypeName": "x.com.st.d.microfiberfilter",
"components": [
{
"id": "main",
"label": "main",
"capabilities": [
{
"id": "ocf",
"version": 1
},
{
"id": "execute",
"version": 1
},
{
"id": "refresh",
"version": 1
},
{
"id": "switch",
"version": 1
},
{
"id": "samsungce.bladeFilter",
"version": 1
},
{
"id": "samsungce.deviceIdentification",
"version": 1
},
{
"id": "samsungce.driverVersion",
"version": 1
},
{
"id": "samsungce.microfiberFilterOperatingState",
"version": 1
},
{
"id": "samsungce.microfiberFilterSettings",
"version": 1
},
{
"id": "samsungce.microfiberFilterStatus",
"version": 1
},
{
"id": "samsungce.selfCheck",
"version": 1
},
{
"id": "samsungce.softwareUpdate",
"version": 1
},
{
"id": "samsungce.softwareVersion",
"version": 1
},
{
"id": "sec.diagnosticsInformation",
"version": 1
},
{
"id": "sec.wifiConfiguration",
"version": 1
},
{
"id": "custom.disabledCapabilities",
"version": 1
},
{
"id": "custom.waterFilter",
"version": 1
}
],
"categories": [
{
"name": "MicroFiberFilter",
"categoryType": "manufacturer"
}
],
"optional": false
}
],
"createTime": "2025-05-06T11:48:20.516Z",
"profile": {
"id": "b40c8b41-e933-334b-8597-d721a881e2ee"
},
"ocf": {
"ocfDeviceType": "x.com.st.d.microfiberfilter",
"name": "[microfiber] Samsung",
"specVersion": "core.1.1.0",
"verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0",
"manufacturerName": "Samsung Electronics",
"modelNumber": "AMF-WW-TP1-22-COMMON|20349241|3A000000001511000A90020200000000",
"platformVersion": "DAWIT 2.0",
"platformOS": "TizenRT 3.1",
"hwVersion": "Realtek",
"firmwareVersion": "AMF-WW-TP1-22-COMMON_30230323",
"vendorId": "DA-WM-MF-01001",
"vendorResourceClientServerVersion": "Realtek Release 3.1.220727",
"lastSignupTime": "2025-05-06T11:48:20.456199900Z",
"transferCandidate": false,
"additionalAuthCodeRequired": false
},
"type": "OCF",
"restrictionTier": 0,
"allowed": null,
"executionContext": "CLOUD",
"relationships": []
}
],
"_links": {}
}

View File

@@ -2325,6 +2325,57 @@
'state': 'off',
})
# ---
# name: test_all_entities[da_wm_mf_01001][binary_sensor.filtro_in_microfibra_filter_status-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.filtro_in_microfibra_filter_status',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Filter status',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.PROBLEM: 'problem'>,
'original_icon': None,
'original_name': 'Filter status',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'filter_status',
'unique_id': '42e80b4d-24c4-a810-11b3-f90375c56a39_main_custom.waterFilter_waterFilterStatus_waterFilterStatus',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_wm_mf_01001][binary_sensor.filtro_in_microfibra_filter_status-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'problem',
'friendly_name': 'Filtro in microfibra Filter status',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.filtro_in_microfibra_filter_status',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[da_wm_sc_000001][binary_sensor.airdresser_child_lock-entry]
EntityRegistryEntrySnapshot({
'aliases': list([

View File

@@ -549,3 +549,53 @@
'state': 'unknown',
})
# ---
# name: test_all_entities[da_wm_mf_01001][button.filtro_in_microfibra_reset_water_filter-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'button.filtro_in_microfibra_reset_water_filter',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Reset water filter',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Reset water filter',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'reset_water_filter',
'unique_id': '42e80b4d-24c4-a810-11b3-f90375c56a39_main_custom.waterFilter_resetWaterFilter',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_wm_mf_01001][button.filtro_in_microfibra_reset_water_filter-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Filtro in microfibra Reset water filter',
}),
'context': <ANY>,
'entity_id': 'button.filtro_in_microfibra_reset_water_filter',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

@@ -1270,6 +1270,37 @@
'via_device_id': None,
})
# ---
# name: test_devices[da_wm_mf_01001]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': 'https://account.smartthings.com',
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': 'Realtek',
'id': <ANY>,
'identifiers': set({
tuple(
'smartthings',
'42e80b4d-24c4-a810-11b3-f90375c56a39',
),
}),
'labels': set({
}),
'manufacturer': 'Samsung Electronics',
'model': 'AMF-WW-TP1-22-COMMON',
'model_id': None,
'name': 'Filtro in microfibra',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': 'AMF-WW-TP1-22-COMMON_30230323',
'via_device_id': None,
})
# ---
# name: test_devices[da_wm_sc_000001]
DeviceRegistryEntrySnapshot({
'area_id': None,

View File

@@ -727,8 +727,9 @@
# ---
# name: test_all_entities[da_rvc_map_01011][select.robot_vacuum_sound_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'options': list([

View File

@@ -14360,6 +14360,60 @@
'state': '1336.2',
})
# ---
# name: test_all_entities[da_wm_mf_01001][sensor.filtro_in_microfibra_water_filter_usage-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.filtro_in_microfibra_water_filter_usage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Water filter usage',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Water filter usage',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'water_filter_usage',
'unique_id': '42e80b4d-24c4-a810-11b3-f90375c56a39_main_custom.waterFilter_waterFilterUsage_waterFilterUsage',
'unit_of_measurement': '%',
})
# ---
# name: test_all_entities[da_wm_mf_01001][sensor.filtro_in_microfibra_water_filter_usage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Filtro in microfibra Water filter usage',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.filtro_in_microfibra_water_filter_usage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '78',
})
# ---
# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_completion_time-entry]
EntityRegistryEntrySnapshot({
'aliases': list([

View File

@@ -1499,6 +1499,56 @@
'state': 'off',
})
# ---
# name: test_all_entities[da_wm_mf_01001][switch.filtro_in_microfibra-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.filtro_in_microfibra',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '42e80b4d-24c4-a810-11b3-f90375c56a39_main_switch_switch_switch',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_wm_mf_01001][switch.filtro_in_microfibra-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Filtro in microfibra',
}),
'context': <ANY>,
'entity_id': 'switch.filtro_in_microfibra',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_all_entities[da_wm_sc_000001][switch.airdresser_auto_cycle_link-entry]
EntityRegistryEntrySnapshot({
'aliases': list([

View File

@@ -88,7 +88,7 @@ async def test_text_triggers_gated_by_labs_flag(
),
],
)
async def test_text_state_trigger_behavior_any(
async def test_text_state_trigger(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_texts: dict[str, list[str]],
@@ -98,7 +98,7 @@ async def test_text_state_trigger_behavior_any(
trigger: str,
states: list[TriggerStateDescription],
) -> None:
"""Test that the text state trigger fires when any text state changes to a specific state."""
"""Test that the text state trigger fires when targeted text state changes."""
other_entity_ids = set(target_texts["included"]) - {entity_id}
# Set all texts, including the tested text, to the initial state

View File

@@ -1231,3 +1231,53 @@ async def test_list_todo_items_extended_fields(
]
}
}
async def test_async_subscribe_updates(
hass: HomeAssistant, test_entity: TodoListEntity
) -> None:
"""Test async_subscribe_updates delivers list updates to listeners."""
await create_mock_platform(hass, [test_entity])
received_updates: list[list[TodoItem]] = []
def listener(items: list[TodoItem]) -> None:
received_updates.append(items)
unsub = test_entity.async_subscribe_updates(listener)
# Trigger an update
test_entity.async_write_ha_state()
assert len(received_updates) == 1
items = received_updates[0]
assert len(items) == 2
assert isinstance(items[0], TodoItem)
assert items[0].summary == "Item #1"
assert items[0].uid == "1"
assert items[0].status == TodoItemStatus.NEEDS_ACTION
assert isinstance(items[1], TodoItem)
assert items[1].summary == "Item #2"
assert items[1].uid == "2"
assert items[1].status == TodoItemStatus.COMPLETED
# Verify items are copies (not the same objects)
assert items[0] is not test_entity.todo_items[0]
assert items[1] is not test_entity.todo_items[1]
# Add a new item and trigger update
test_entity._attr_todo_items = [
*test_entity._attr_todo_items,
TodoItem(summary="Item #3", uid="3", status=TodoItemStatus.NEEDS_ACTION),
]
test_entity.async_write_ha_state()
assert len(received_updates) == 2
items = received_updates[1]
assert len(items) == 3
assert items[2].summary == "Item #3"
# Unsubscribe and verify no more updates
unsub()
test_entity.async_write_ha_state()
assert len(received_updates) == 2

View File

@@ -1,4 +1,58 @@
# serializer version: 1
# name: test_platform_setup_and_discovery[alarm_control_panel.c30-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'alarm_control_panel',
'entity_category': None,
'entity_id': 'alarm_control_panel.c30',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <AlarmControlPanelEntityFeature: 11>,
'translation_key': None,
'unique_id': 'tuya.rirsc4vhpbv2whkp2gwmaster_mode',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[alarm_control_panel.c30-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'changed_by': None,
'code_arm_required': False,
'code_format': None,
'friendly_name': 'C30',
'supported_features': <AlarmControlPanelEntityFeature: 11>,
}),
'context': <ANY>,
'entity_id': 'alarm_control_panel.c30',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'disarmed',
})
# ---
# name: test_platform_setup_and_discovery[alarm_control_panel.multifunction_alarm-entry]
EntityRegistryEntrySnapshot({
'aliases': list([

View File

@@ -101,6 +101,57 @@
'state': 'off',
})
# ---
# name: test_platform_setup_and_discovery[binary_sensor.c30_charging-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.c30_charging',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Charging',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.BATTERY_CHARGING: 'battery_charging'>,
'original_icon': None,
'original_name': 'Charging',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'tuya.rirsc4vhpbv2whkp2gwcharge_state',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[binary_sensor.c30_charging-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'battery_charging',
'friendly_name': 'C30 Charging',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.c30_charging',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_platform_setup_and_discovery[binary_sensor.cat_feeder_feeding-entry]
EntityRegistryEntrySnapshot({
'aliases': list([

View File

@@ -7150,7 +7150,7 @@
'labels': set({
}),
'manufacturer': 'Tuya',
'model': 'C30 (unsupported)',
'model': 'C30',
'model_id': 'pkhw2vbphv4csrir',
'name': 'C30',
'name_by_user': None,

View File

@@ -3944,6 +3944,61 @@
'state': '0.0',
})
# ---
# name: test_platform_setup_and_discovery[sensor.c30_battery-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.c30_battery',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Battery',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.BATTERY: 'battery'>,
'original_icon': None,
'original_name': 'Battery',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'battery',
'unique_id': 'tuya.rirsc4vhpbv2whkp2gwbattery_percentage',
'unit_of_measurement': '%',
})
# ---
# name: test_platform_setup_and_discovery[sensor.c30_battery-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'battery',
'friendly_name': 'C30 Battery',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.c30_battery',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '85.0',
})
# ---
# name: test_platform_setup_and_discovery[sensor.c9_battery-entry]
EntityRegistryEntrySnapshot({
'aliases': list([

View File

@@ -43,8 +43,11 @@ async def test_platform_setup_and_discovery(
@patch("homeassistant.components.tuya.PLATFORMS", [Platform.ALARM_CONTROL_PANEL])
@pytest.mark.parametrize(
"mock_device_code",
["mal_gyitctrjj1kefxp2"],
("mock_device_code", "entity_id"),
[
("mal_gyitctrjj1kefxp2", "alarm_control_panel.multifunction_alarm"),
("wg2_pkhw2vbphv4csrir", "alarm_control_panel.c30"),
],
)
@pytest.mark.parametrize(
("service", "command"),
@@ -62,9 +65,9 @@ async def test_service(
mock_device: CustomerDevice,
service: str,
command: dict[str, Any],
entity_id: str,
) -> None:
"""Test service."""
entity_id = "alarm_control_panel.multifunction_alarm"
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
state = hass.states.get(entity_id)
@@ -82,8 +85,11 @@ async def test_service(
@patch("homeassistant.components.tuya.PLATFORMS", [Platform.ALARM_CONTROL_PANEL])
@pytest.mark.parametrize(
"mock_device_code",
["mal_gyitctrjj1kefxp2"],
("mock_device_code", "entity_id"),
[
("mal_gyitctrjj1kefxp2", "alarm_control_panel.multifunction_alarm"),
("wg2_pkhw2vbphv4csrir", "alarm_control_panel.c30"),
],
)
@pytest.mark.parametrize(
("status_updates", "expected_state"),
@@ -131,9 +137,9 @@ async def test_state(
mock_device: CustomerDevice,
status_updates: dict[str, Any],
expected_state: str,
entity_id: str,
) -> None:
"""Test state."""
entity_id = "alarm_control_panel.multifunction_alarm"
mock_device.status.update(status_updates)
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)

View File

@@ -10,6 +10,7 @@ from homeassistant.components.vacuum import (
ATTR_FAN_SPEED,
ATTR_PARAMS,
DOMAIN,
SERVICE_CLEAN_AREA,
SERVICE_CLEAN_SPOT,
SERVICE_LOCATE,
SERVICE_PAUSE,
@@ -82,6 +83,25 @@ async def async_locate(hass: HomeAssistant, entity_id: str = ENTITY_MATCH_ALL) -
await hass.services.async_call(DOMAIN, SERVICE_LOCATE, data, blocking=True)
def clean_area(
hass: HomeAssistant, cleaning_area_id: list[str], entity_id: str = ENTITY_MATCH_ALL
) -> None:
"""Tell all or specified vacuum to perform an area clean."""
hass.add_job(async_clean_area, hass, cleaning_area_id, entity_id)
async def async_clean_area(
hass: HomeAssistant,
cleaning_area_id: list[str],
entity_id: str = ENTITY_MATCH_ALL,
) -> None:
"""Tell all or specified vacuum to perform an area clean."""
data = {"cleaning_area_id": cleaning_area_id}
if entity_id:
data[ATTR_ENTITY_ID] = entity_id
await hass.services.async_call(DOMAIN, SERVICE_CLEAN_AREA, data, blocking=True)
@bind_hass
def clean_spot(hass: HomeAssistant, entity_id: str = ENTITY_MATCH_ALL) -> None:
"""Tell all or specified vacuum to perform a spot clean-up."""

View File

@@ -9,13 +9,13 @@ from homeassistant.core import HomeAssistant
from tests.components.common import (
ConditionStateDescription,
assert_condition_behavior_all,
assert_condition_behavior_any,
assert_condition_gated_by_labs_flag,
create_target_condition,
other_states,
parametrize_condition_states_all,
parametrize_condition_states_any,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
@@ -89,32 +89,17 @@ async def test_vacuum_state_condition_behavior_any(
states: list[ConditionStateDescription],
) -> None:
"""Test the vacuum state condition with the 'any' behavior."""
other_entity_ids = set(target_vacuums["included"]) - {entity_id}
# Set all vacuums, including the tested vacuum, to the initial state
for eid in target_vacuums["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_any(
hass,
target_entities=target_vacuums,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="any",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
# Check if changing other vacuums also passes the condition
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
@@ -162,29 +147,13 @@ async def test_vacuum_state_condition_behavior_all(
states: list[ConditionStateDescription],
) -> None:
"""Test the vacuum state condition with the 'all' behavior."""
other_entity_ids = set(target_vacuums["included"]) - {entity_id}
# Set all vacuums, including the tested vacuum, to the initial state
for eid in target_vacuums["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_all(
hass,
target_entities=target_vacuums,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="all",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true_first_entity"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]

View File

@@ -142,13 +142,36 @@ def vizio_bypass_setup_fixture() -> Generator[None]:
@pytest.fixture(name="vizio_bypass_update")
def vizio_bypass_update_fixture() -> Generator[None]:
"""Mock component update."""
"""Mock component update with minimal data."""
with (
patch(
"homeassistant.components.vizio.media_player.VizioAsync.can_connect_with_auth_check",
"homeassistant.components.vizio.VizioAsync.get_power_state",
return_value=True,
),
patch("homeassistant.components.vizio.media_player.VizioDevice.async_update"),
patch(
"homeassistant.components.vizio.VizioAsync.get_all_settings",
return_value=None,
),
patch(
"homeassistant.components.vizio.VizioAsync.get_current_input",
return_value=None,
),
patch(
"homeassistant.components.vizio.VizioAsync.get_inputs_list",
return_value=None,
),
patch(
"homeassistant.components.vizio.VizioAsync.get_current_app_config",
return_value=None,
),
patch(
"homeassistant.components.vizio.VizioAsync.get_model_name",
return_value=None,
),
patch(
"homeassistant.components.vizio.VizioAsync.get_version",
return_value=None,
),
):
yield
@@ -172,7 +195,15 @@ def vizio_cant_connect_fixture() -> Generator[None]:
AsyncMock(return_value=False),
),
patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_power_state",
"homeassistant.components.vizio.VizioAsync.get_power_state",
return_value=None,
),
patch(
"homeassistant.components.vizio.VizioAsync.get_model_name",
return_value=None,
),
patch(
"homeassistant.components.vizio.VizioAsync.get_version",
return_value=None,
),
):
@@ -184,11 +215,7 @@ def vizio_update_fixture() -> Generator[None]:
"""Mock valid updates to vizio device."""
with (
patch(
"homeassistant.components.vizio.media_player.VizioAsync.can_connect_with_auth_check",
return_value=True,
),
patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_all_settings",
"homeassistant.components.vizio.VizioAsync.get_all_settings",
return_value={
"volume": int(MAX_VOLUME[DEVICE_CLASS_SPEAKER] / 2),
"eq": CURRENT_EQ,
@@ -196,29 +223,33 @@ def vizio_update_fixture() -> Generator[None]:
},
),
patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_setting_options",
"homeassistant.components.vizio.VizioAsync.get_setting_options",
return_value=EQ_LIST,
),
patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_current_input",
"homeassistant.components.vizio.VizioAsync.get_current_input",
return_value=CURRENT_INPUT,
),
patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_inputs_list",
"homeassistant.components.vizio.VizioAsync.get_inputs_list",
return_value=get_mock_inputs(INPUT_LIST),
),
patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_power_state",
"homeassistant.components.vizio.VizioAsync.get_power_state",
return_value=True,
),
patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_model_name",
"homeassistant.components.vizio.VizioAsync.get_model_name",
return_value=MODEL,
),
patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_version",
"homeassistant.components.vizio.VizioAsync.get_version",
return_value=VERSION,
),
patch(
"homeassistant.components.vizio.VizioAsync.get_current_app_config",
return_value=None,
),
):
yield
@@ -228,15 +259,15 @@ def vizio_update_with_apps_fixture(vizio_update: None) -> Generator[None]:
"""Mock valid updates to vizio device that supports apps."""
with (
patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_inputs_list",
"homeassistant.components.vizio.VizioAsync.get_inputs_list",
return_value=get_mock_inputs(INPUT_LIST_WITH_APPS),
),
patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_current_input",
"homeassistant.components.vizio.VizioAsync.get_current_input",
return_value="CAST",
),
patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_current_app_config",
"homeassistant.components.vizio.VizioAsync.get_current_app_config",
return_value=AppConfig(**CURRENT_APP_CONFIG),
),
):
@@ -248,15 +279,15 @@ def vizio_update_with_apps_on_input_fixture(vizio_update: None) -> Generator[Non
"""Mock valid updates to vizio device that supports apps but is on a TV input."""
with (
patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_inputs_list",
"homeassistant.components.vizio.VizioAsync.get_inputs_list",
return_value=get_mock_inputs(INPUT_LIST_WITH_APPS),
),
patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_current_input",
"homeassistant.components.vizio.VizioAsync.get_current_input",
return_value=CURRENT_INPUT,
),
patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_current_app_config",
"homeassistant.components.vizio.VizioAsync.get_current_app_config",
return_value=AppConfig("unknown", 1, "app"),
),
):

View File

@@ -5,6 +5,7 @@ import dataclasses
import pytest
from homeassistant.components.media_player import MediaPlayerDeviceClass
from homeassistant.components.vizio import DATA_APPS
from homeassistant.components.vizio.const import (
CONF_APPS,
CONF_APPS_TO_INCLUDE_OR_EXCLUDE,
@@ -142,6 +143,36 @@ async def test_tv_options_flow_no_apps(hass: HomeAssistant) -> None:
assert CONF_APPS not in result["data"]
@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_update")
async def test_tv_options_flow_apps_fallback(hass: HomeAssistant) -> None:
"""Test options config flow falls back to default APPS when coordinator absent."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_VALID_TV_CONFIG
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
entry = result["result"]
# Remove apps coordinator to simulate it being unavailable
hass.data.pop(DATA_APPS)
result = await hass.config_entries.options.async_init(entry.entry_id, data=None)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
# Completing the flow should still work with the APPS fallback
options = {CONF_VOLUME_STEP: VOLUME_STEP}
options.update(MOCK_INCLUDE_APPS)
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input=options
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_APPS] == {CONF_INCLUDE: [CURRENT_APP]}
@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_update")
async def test_tv_options_flow_with_apps(hass: HomeAssistant) -> None:
"""Test options config flow for TV with providing apps option."""

View File

@@ -7,6 +7,7 @@ from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.media_player import MediaPlayerDeviceClass
from homeassistant.components.vizio import DATA_APPS
from homeassistant.components.vizio.const import DOMAIN
from homeassistant.const import (
CONF_ACCESS_TOKEN,
@@ -17,14 +18,17 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .const import (
APP_LIST,
HOST2,
MOCK_SPEAKER_CONFIG,
MOCK_USER_VALID_TV_CONFIG,
MODEL,
NAME2,
UNIQUE_ID,
VERSION,
)
from tests.common import MockConfigEntry, async_fire_time_changed
@@ -40,7 +44,7 @@ async def test_tv_load_and_unload(hass: HomeAssistant) -> None:
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_entity_ids(Platform.MEDIA_PLAYER)) == 1
assert DOMAIN in hass.data
assert DATA_APPS in hass.data
assert await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
@@ -48,7 +52,7 @@ async def test_tv_load_and_unload(hass: HomeAssistant) -> None:
assert len(entities) == 1
for entity in entities:
assert hass.states.get(entity).state == STATE_UNAVAILABLE
assert DOMAIN not in hass.data
assert DATA_APPS not in hass.data
@pytest.mark.usefixtures("vizio_connect", "vizio_update")
@@ -61,7 +65,6 @@ async def test_speaker_load_and_unload(hass: HomeAssistant) -> None:
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_entity_ids(Platform.MEDIA_PLAYER)) == 1
assert DOMAIN in hass.data
assert await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
@@ -69,7 +72,6 @@ async def test_speaker_load_and_unload(hass: HomeAssistant) -> None:
assert len(entities) == 1
for entity in entities:
assert hass.states.get(entity).state == STATE_UNAVAILABLE
assert DOMAIN not in hass.data
@pytest.mark.usefixtures(
@@ -88,6 +90,7 @@ async def test_coordinator_update_failure(
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_entity_ids(Platform.MEDIA_PLAYER)) == 1
assert DATA_APPS in hass.data
# Failing 25 days in a row should result in a single log message
# (first one after 10 days, next one would be at 30 days)
@@ -152,3 +155,41 @@ async def test_apps_coordinator_persists_until_last_tv_unloads(
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_fetch.call_count == 0
@pytest.mark.usefixtures("vizio_connect", "vizio_update")
async def test_device_registry_model_and_version(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test that coordinator populates device registry with model and version."""
config_entry = MockConfigEntry(
domain=DOMAIN, data=MOCK_USER_VALID_TV_CONFIG, unique_id=UNIQUE_ID
)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
device = device_registry.async_get_device(identifiers={(DOMAIN, UNIQUE_ID)})
assert device is not None
assert device.model == MODEL
assert device.sw_version == VERSION
assert device.manufacturer == "VIZIO"
@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_update")
async def test_device_registry_without_model_or_version(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test device registry when model and version are unavailable."""
config_entry = MockConfigEntry(
domain=DOMAIN, data=MOCK_USER_VALID_TV_CONFIG, unique_id=UNIQUE_ID
)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
device = device_registry.async_get_device(identifiers={(DOMAIN, UNIQUE_ID)})
assert device is not None
assert device.model is None
assert device.sw_version is None
assert device.manufacturer == "VIZIO"

View File

@@ -8,7 +8,7 @@ from datetime import timedelta
from typing import Any
from unittest.mock import call, patch
from freezegun import freeze_time
from freezegun.api import FrozenDateTimeFactory
import pytest
from pyvizio.api.apps import AppConfig
from pyvizio.const import (
@@ -40,6 +40,7 @@ from homeassistant.components.media_player import (
SERVICE_VOLUME_SET,
SERVICE_VOLUME_UP,
MediaPlayerDeviceClass,
MediaPlayerEntityFeature,
)
from homeassistant.components.vizio.const import (
CONF_ADDITIONAL_CONFIGS,
@@ -49,6 +50,7 @@ from homeassistant.components.vizio.const import (
DOMAIN,
)
from homeassistant.components.vizio.services import SERVICE_UPDATE_SETTING
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
@@ -88,15 +90,12 @@ async def _add_config_entry_to_hass(
await hass.async_block_till_done()
def _get_ha_power_state(vizio_power_state: bool | None) -> str:
def _get_ha_power_state(vizio_power_state: bool) -> str:
"""Return HA power state given Vizio power state."""
if vizio_power_state:
return STATE_ON
if vizio_power_state is False:
return STATE_OFF
return STATE_UNAVAILABLE
return STATE_OFF
def _assert_sources_and_volume(attr: dict[str, Any], vizio_device_class: str) -> None:
@@ -124,27 +123,27 @@ def _get_attr_and_assert_base_attr(
@asynccontextmanager
async def _cm_for_test_setup_without_apps(
all_settings: dict[str, Any], vizio_power_state: bool | None
all_settings: dict[str, Any], vizio_power_state: bool
) -> AsyncIterator[None]:
"""Context manager to setup test for Vizio devices without including app specific patches."""
with (
patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_all_settings",
"homeassistant.components.vizio.VizioAsync.get_all_settings",
return_value=all_settings,
),
patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_setting_options",
"homeassistant.components.vizio.VizioAsync.get_setting_options",
return_value=EQ_LIST,
),
patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_power_state",
"homeassistant.components.vizio.VizioAsync.get_power_state",
return_value=vizio_power_state,
),
):
yield
async def _test_setup_tv(hass: HomeAssistant, vizio_power_state: bool | None) -> None:
async def _test_setup_tv(hass: HomeAssistant, vizio_power_state: bool) -> None:
"""Test Vizio TV entity setup."""
ha_power_state = _get_ha_power_state(vizio_power_state)
@@ -155,7 +154,11 @@ async def _test_setup_tv(hass: HomeAssistant, vizio_power_state: bool | None) ->
)
async with _cm_for_test_setup_without_apps(
{"volume": int(MAX_VOLUME[VIZIO_DEVICE_CLASS_TV] / 2), "mute": "Off"},
{
"volume": int(MAX_VOLUME[VIZIO_DEVICE_CLASS_TV] / 2),
"mute": "Off",
"eq": CURRENT_EQ,
},
vizio_power_state,
):
await _add_config_entry_to_hass(hass, config_entry)
@@ -165,12 +168,10 @@ async def _test_setup_tv(hass: HomeAssistant, vizio_power_state: bool | None) ->
)
if ha_power_state == STATE_ON:
_assert_sources_and_volume(attr, VIZIO_DEVICE_CLASS_TV)
assert "sound_mode" not in attr
assert attr[ATTR_SOUND_MODE] == CURRENT_EQ
async def _test_setup_speaker(
hass: HomeAssistant, vizio_power_state: bool | None
) -> None:
async def _test_setup_speaker(hass: HomeAssistant, vizio_power_state: bool) -> None:
"""Test Vizio Speaker entity setup."""
ha_power_state = _get_ha_power_state(vizio_power_state)
@@ -190,18 +191,14 @@ async def _test_setup_speaker(
audio_settings,
vizio_power_state,
):
with patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_current_app_config",
) as service_call:
await _add_config_entry_to_hass(hass, config_entry)
await _add_config_entry_to_hass(hass, config_entry)
attr = _get_attr_and_assert_base_attr(
hass, MediaPlayerDeviceClass.SPEAKER, ha_power_state
)
if ha_power_state == STATE_ON:
_assert_sources_and_volume(attr, VIZIO_DEVICE_CLASS_SPEAKER)
assert not service_call.called
assert "sound_mode" in attr
attr = _get_attr_and_assert_base_attr(
hass, MediaPlayerDeviceClass.SPEAKER, ha_power_state
)
if ha_power_state == STATE_ON:
_assert_sources_and_volume(attr, VIZIO_DEVICE_CLASS_SPEAKER)
assert "sound_mode" in attr
@asynccontextmanager
@@ -218,7 +215,7 @@ async def _cm_for_test_setup_tv_with_apps(
True,
):
with patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_current_app_config",
"homeassistant.components.vizio.VizioAsync.get_current_app_config",
return_value=AppConfig(**app_config),
):
await _add_config_entry_to_hass(hass, config_entry)
@@ -262,7 +259,7 @@ async def _test_service(
service_data.update(additional_service_data)
with patch(
f"homeassistant.components.vizio.media_player.VizioAsync.{vizio_func_name}"
f"homeassistant.components.vizio.VizioAsync.{vizio_func_name}"
) as service_call:
await hass.services.async_call(
domain,
@@ -288,14 +285,6 @@ async def test_speaker_off(hass: HomeAssistant) -> None:
await _test_setup_speaker(hass, False)
@pytest.mark.usefixtures("vizio_connect", "vizio_update")
async def test_speaker_unavailable(
hass: HomeAssistant,
) -> None:
"""Test Vizio Speaker entity setup when unavailable."""
await _test_setup_speaker(hass, None)
@pytest.mark.usefixtures("vizio_connect", "vizio_update")
async def test_init_tv_on(hass: HomeAssistant) -> None:
"""Test Vizio TV entity setup when on."""
@@ -308,32 +297,28 @@ async def test_init_tv_off(hass: HomeAssistant) -> None:
await _test_setup_tv(hass, False)
@pytest.mark.usefixtures("vizio_connect", "vizio_update")
async def test_init_tv_unavailable(hass: HomeAssistant) -> None:
"""Test Vizio TV entity setup when unavailable."""
await _test_setup_tv(hass, None)
@pytest.mark.usefixtures("vizio_cant_connect")
async def test_setup_unavailable_speaker(hass: HomeAssistant) -> None:
"""Test speaker entity sets up as unavailable."""
"""Test speaker config entry retries setup when device is unavailable."""
config_entry = MockConfigEntry(
domain=DOMAIN, data=MOCK_SPEAKER_CONFIG, unique_id=UNIQUE_ID
)
await _add_config_entry_to_hass(hass, config_entry)
assert len(hass.states.async_entity_ids(MP_DOMAIN)) == 1
assert hass.states.get("media_player.vizio").state == STATE_UNAVAILABLE
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.usefixtures("vizio_cant_connect")
async def test_setup_unavailable_tv(hass: HomeAssistant) -> None:
"""Test TV entity sets up as unavailable."""
"""Test TV config entry retries setup when device is unavailable."""
config_entry = MockConfigEntry(
domain=DOMAIN, data=MOCK_USER_VALID_TV_CONFIG, unique_id=UNIQUE_ID
)
await _add_config_entry_to_hass(hass, config_entry)
assert len(hass.states.async_entity_ids(MP_DOMAIN)) == 1
assert hass.states.get("media_player.vizio").state == STATE_UNAVAILABLE
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.usefixtures("vizio_connect", "vizio_update")
@@ -377,7 +362,7 @@ async def test_services(hass: HomeAssistant) -> None:
"vol_up",
SERVICE_VOLUME_SET,
{ATTR_MEDIA_VOLUME_LEVEL: 1},
num=(100 - 15),
num=50, # From 50% to 100% = 50 steps (TV max volume 100, starting at 50)
)
await _test_service(
hass,
@@ -385,7 +370,7 @@ async def test_services(hass: HomeAssistant) -> None:
"vol_down",
SERVICE_VOLUME_SET,
{ATTR_MEDIA_VOLUME_LEVEL: 0},
num=(15 - 0),
num=100, # From 100% (after previous vol_up) to 0% = 100 steps
)
await _test_service(hass, MP_DOMAIN, "ch_up", SERVICE_MEDIA_NEXT_TRACK, None)
await _test_service(hass, MP_DOMAIN, "ch_down", SERVICE_MEDIA_PREVIOUS_TRACK, None)
@@ -444,66 +429,52 @@ async def test_options_update(hass: HomeAssistant) -> None:
)
async def _test_update_availability_switch(
@pytest.mark.usefixtures("vizio_connect", "vizio_update")
async def test_update_available_to_unavailable(
hass: HomeAssistant,
initial_power_state: bool | None,
final_power_state: bool | None,
caplog: pytest.LogCaptureFixture,
freezer: FrozenDateTimeFactory,
) -> None:
now = dt_util.utcnow()
future_interval = timedelta(minutes=1)
"""Test device becomes unavailable after being available."""
await _test_setup_speaker(hass, True)
# Setup device as if time is right now
with freeze_time(now):
await _test_setup_speaker(hass, initial_power_state)
# Clear captured logs so that only availability state changes are captured for
# future assertion
caplog.clear()
# Fast forward time to future twice to trigger update and assert vizio log message
for i in range(1, 3):
future = now + (future_interval * i)
with (
patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_power_state",
return_value=final_power_state,
),
freeze_time(future),
):
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
if final_power_state is None:
assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE
else:
assert hass.states.get(ENTITY_ID).state != STATE_UNAVAILABLE
# Ensure connection status messages from vizio.media_player appear exactly once
# (on availability state change)
vizio_log_list = [
log
for log in caplog.records
if log.name == "homeassistant.components.vizio.media_player"
]
assert len(vizio_log_list) == 1
# Simulate device becoming unreachable
with patch(
"homeassistant.components.vizio.VizioAsync.get_power_state",
return_value=None,
):
freezer.tick(timedelta(minutes=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE
@pytest.mark.usefixtures("vizio_connect", "vizio_update")
async def test_update_unavailable_to_available(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test device becomes available after being unavailable."""
await _test_update_availability_switch(hass, None, True, caplog)
await _test_setup_speaker(hass, True)
# First, make device unavailable
with patch(
"homeassistant.components.vizio.VizioAsync.get_power_state",
return_value=None,
):
freezer.tick(timedelta(minutes=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE
@pytest.mark.usefixtures("vizio_connect", "vizio_update")
async def test_update_available_to_unavailable(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test device becomes unavailable after being available."""
await _test_update_availability_switch(hass, True, None, caplog)
# Then, make device available again
with patch(
"homeassistant.components.vizio.VizioAsync.get_power_state",
return_value=True,
):
freezer.tick(timedelta(minutes=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(ENTITY_ID).state != STATE_UNAVAILABLE
@pytest.mark.usefixtures("vizio_connect", "vizio_update_with_apps")
@@ -619,11 +590,9 @@ async def test_setup_with_apps_additional_apps_config(
# Test that invalid app does nothing
with (
patch("homeassistant.components.vizio.VizioAsync.launch_app") as service_call1,
patch(
"homeassistant.components.vizio.media_player.VizioAsync.launch_app"
) as service_call1,
patch(
"homeassistant.components.vizio.media_player.VizioAsync.launch_app_config"
"homeassistant.components.vizio.VizioAsync.launch_app_config"
) as service_call2,
):
await hass.services.async_call(
@@ -679,7 +648,7 @@ async def test_setup_tv_without_mute(hass: HomeAssistant) -> None:
async with _cm_for_test_setup_without_apps(
{"volume": int(MAX_VOLUME[VIZIO_DEVICE_CLASS_TV] / 2)},
STATE_ON,
True,
):
await _add_config_entry_to_hass(hass, config_entry)
@@ -735,3 +704,122 @@ async def test_vizio_update_with_apps_on_input(hass: HomeAssistant) -> None:
attr = _get_attr_and_assert_base_attr(hass, MediaPlayerDeviceClass.TV, STATE_ON)
# app ID should not be in the attributes
assert "app_id" not in attr
@pytest.mark.usefixtures("vizio_connect", "vizio_update")
async def test_coordinator_update_on_to_off(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test device transitions from on to off during coordinator refresh."""
await _test_setup_speaker(hass, True)
attr = _get_attr_and_assert_base_attr(
hass, MediaPlayerDeviceClass.SPEAKER, STATE_ON
)
assert attr[ATTR_MEDIA_VOLUME_LEVEL] is not None
assert ATTR_SOUND_MODE in attr
# Device turns off
with patch(
"homeassistant.components.vizio.VizioAsync.get_power_state",
return_value=False,
):
freezer.tick(timedelta(minutes=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(ENTITY_ID).state == STATE_OFF
attr = hass.states.get(ENTITY_ID).attributes
assert attr.get(ATTR_MEDIA_VOLUME_LEVEL) is None
assert attr.get(ATTR_MEDIA_VOLUME_MUTED) is None
assert attr.get(ATTR_SOUND_MODE) is None
@pytest.mark.usefixtures("vizio_connect", "vizio_update")
async def test_coordinator_update_off_to_on(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test device transitions from off to on during coordinator refresh."""
await _test_setup_speaker(hass, False)
assert hass.states.get(ENTITY_ID).state == STATE_OFF
# Device turns on
freezer.tick(timedelta(minutes=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(ENTITY_ID).state == STATE_ON
attr = hass.states.get(ENTITY_ID).attributes
assert attr[ATTR_MEDIA_VOLUME_LEVEL] is not None
assert ATTR_SOUND_MODE in attr
@pytest.mark.usefixtures("vizio_connect", "vizio_update")
async def test_sound_mode_feature_toggling(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test sound mode feature is added when present and removed when absent."""
await _test_setup_speaker(hass, True)
attr = _get_attr_and_assert_base_attr(
hass, MediaPlayerDeviceClass.SPEAKER, STATE_ON
)
assert ATTR_SOUND_MODE in attr
state = hass.states.get(ENTITY_ID)
assert (
state.attributes["supported_features"]
& MediaPlayerEntityFeature.SELECT_SOUND_MODE
)
# Update with audio settings that have no sound mode
with (
patch(
"homeassistant.components.vizio.VizioAsync.get_all_settings",
return_value={"volume": 50, "mute": "Off"},
),
patch(
"homeassistant.components.vizio.VizioAsync.get_power_state",
return_value=True,
),
):
freezer.tick(timedelta(minutes=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state.state == STATE_ON
assert not (
state.attributes["supported_features"]
& MediaPlayerEntityFeature.SELECT_SOUND_MODE
)
@pytest.mark.usefixtures("vizio_connect", "vizio_update")
async def test_sound_mode_list_cached(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test sound mode list is cached after first retrieval."""
await _test_setup_speaker(hass, True)
attr = hass.states.get(ENTITY_ID).attributes
assert attr["sound_mode_list"] == EQ_LIST
# Update with different sound mode options — cached list should persist
with (
patch(
"homeassistant.components.vizio.VizioAsync.get_setting_options",
return_value=["Different1", "Different2"],
),
patch(
"homeassistant.components.vizio.VizioAsync.get_power_state",
return_value=True,
),
):
freezer.tick(timedelta(minutes=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
attr = hass.states.get(ENTITY_ID).attributes
# Sound mode list should still be the original cached list
assert attr["sound_mode_list"] == EQ_LIST