Compare commits

..

16 Commits

Author SHA1 Message Date
Daniel Hjelseth Høyer
d2cb3928e9 Homevolt select
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-27 10:46:46 +01:00
Maciej Bieniek
0fcfc3f070 Bump imgw_pib to 2.0.2 (#163940) 2026-02-24 19:15:41 +01:00
Przemko92
413506276c Add binary sensor for Compit (#161709) 2026-02-24 18:58:15 +01:00
Willem-Jan van Rootselaar
4a4e077d40 Add button platform for BSB-Lan integration (#160243) 2026-02-24 18:52:33 +01:00
Robin Lintermann
8f824b566e Add reauthentication flow to smarla (#163250) 2026-02-24 18:52:03 +01:00
Willem-Jan van Rootselaar
610aaa6eee Update BSB-LAN strings, error handling, and code cleanup (#163480) 2026-02-24 18:09:32 +01:00
Martin Arndt
ecb7ab238c Allow worxlandroid PIN to contain letters (#163266) 2026-02-24 18:07:15 +01:00
Simone Chemelli
9013b7835e Resolve pylance complaints for Fritz (#163313) 2026-02-24 18:06:19 +01:00
Erwin Douna
5363638c7e OAuth helper enhance response text logger (#163371)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-02-24 16:50:40 +01:00
Andreas Jakl
164b1cbb8c Add reconfiguration flow to NRGkick (#163828) 2026-02-24 16:46:23 +01:00
Mattias Michaux
b5a55ec032 Fix Sonos browse album art lookup for multi-segment A:ALBUM IDs (#163786) 2026-02-24 16:45:27 +01:00
Karl Beecken
0c6d635e83 Teltonika quality scale: mark unavailable rules done (#163705) 2026-02-24 16:43:48 +01:00
Christian Lackas
9259db0b85 Centralize ViCare error handling in base entity class (#162619) 2026-02-24 16:43:16 +01:00
Denis Shulyaka
6f1a021197 Add IQS to Anthropic (#163891) 2026-02-24 16:27:51 +01:00
Christian Lackas
8dbf7f7ad7 Add diagnostics support to homematicip_cloud (#163829) 2026-02-24 16:25:04 +01:00
Jamie Magee
3854c8e261 Econet friedrich support (#163904)
Co-authored-by: w1ll1am23 <6432770+w1ll1am23@users.noreply.github.com>
2026-02-24 16:20:35 +01:00
77 changed files with 2397 additions and 722 deletions

View File

@@ -57,10 +57,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: |
@@ -272,7 +272,7 @@ jobs:
name: Build ${{ matrix.machine }} machine core image
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"]
runs-on: ${{ matrix.runs-on }}
runs-on: ubuntu-latest
permissions:
contents: read # To check out the repository
packages: write # To push to GHCR
@@ -294,21 +294,6 @@ jobs:
- raspberrypi5-64
- yellow
- green
include:
# Default: aarch64 on native ARM runner
- arch: aarch64
runs-on: ubuntu-24.04-arm
# Overrides for amd64 machines
- machine: generic-x86-64
arch: amd64
runs-on: ubuntu-24.04
- machine: qemux86-64
arch: amd64
runs-on: ubuntu-24.04
# TODO: remove, intel-nuc is a legacy name for x86-64, renamed in 2021
- machine: intel-nuc
arch: amd64
runs-on: ubuntu-24.04
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -336,288 +321,286 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image
uses: home-assistant/builder@6cb4fd3d1338b6e22d0958a4bcb53e0965ea63b4 # 2026.02.1
uses: home-assistant/builder@2025.11.0 # zizmor: ignore[unpinned-uses]
with:
image: ${{ matrix.arch }}
args: |
$BUILD_ARGS \
--test \
--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 }}
# 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@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
# with:
# cosign-release: "v2.5.3"
#
# - name: Login to DockerHub
# if: matrix.registry == 'docker.io/homeassistant'
# uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
# with:
# username: ${{ secrets.DOCKERHUB_USERNAME }}
# password: ${{ secrets.DOCKERHUB_TOKEN }}
#
# - name: Login to GitHub Container Registry
# uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
# with:
# registry: ghcr.io
# username: ${{ github.repository_owner }}
# password: ${{ secrets.GITHUB_TOKEN }}
#
# - name: 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@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
# with:
# images: ${{ matrix.registry }}/home-assistant
# sep-tags: ","
# tags: |
# type=raw,value=${{ needs.init.outputs.version }},priority=9999
# type=raw,value=dev,enable=${{ contains(needs.init.outputs.version, 'd') }}
# type=raw,value=beta,enable=${{ !contains(needs.init.outputs.version, 'd') }}
# type=raw,value=rc,enable=${{ !contains(needs.init.outputs.version, 'd') }}
# type=raw,value=stable,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
# type=raw,value=latest,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
# type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
#
# - name: Set up Docker Buildx
# uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.7.1
#
# - name: 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 ${{ env.DEFAULT_PYTHON }}
# uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
# with:
# python-version: ${{ env.DEFAULT_PYTHON }}
#
# - name: Download translations
# uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
# with:
# name: translations
#
# - name: 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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
# with:
# registry: ghcr.io
# username: ${{ github.repository_owner }}
# password: ${{ secrets.GITHUB_TOKEN }}
#
# - name: Build Docker image
# uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
# with:
# context: . # So action will not pull the repository again
# file: ./script/hassfest/docker/Dockerfile
# load: true
# tags: ${{ env.HASSFEST_IMAGE_TAG }}
#
# - name: 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@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
# with:
# context: . # So action will not pull the repository again
# file: ./script/hassfest/docker/Dockerfile
# push: true
# tags: ${{ env.HASSFEST_IMAGE_TAG }},${{ env.HASSFEST_IMAGE_NAME }}:latest
#
# - name: Generate artifact attestation
# if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
# uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0
# with:
# subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
# subject-digest: ${{ steps.push.outputs.digest }}
# push-to-registry: true
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@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
with:
cosign-release: "v2.5.3"
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: 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@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ matrix.registry }}/home-assistant
sep-tags: ","
tags: |
type=raw,value=${{ needs.init.outputs.version }},priority=9999
type=raw,value=dev,enable=${{ contains(needs.init.outputs.version, 'd') }}
type=raw,value=beta,enable=${{ !contains(needs.init.outputs.version, 'd') }}
type=raw,value=rc,enable=${{ !contains(needs.init.outputs.version, 'd') }}
type=raw,value=stable,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
type=raw,value=latest,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.7.1
- name: 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 ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: translations
- name: 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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
load: true
tags: ${{ env.HASSFEST_IMAGE_TAG }}
- name: 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@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
push: true
tags: ${{ env.HASSFEST_IMAGE_TAG }},${{ env.HASSFEST_IMAGE_NAME }}:latest
- name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0
with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true

View File

@@ -8,5 +8,6 @@
"documentation": "https://www.home-assistant.io/integrations/anthropic",
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["anthropic==0.83.0"]
}

View File

@@ -1,4 +1,4 @@
"""The BSB-Lan integration."""
"""The BSB-LAN integration."""
import asyncio
import dataclasses
@@ -36,7 +36,7 @@ from .const import CONF_PASSKEY, DOMAIN
from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator
from .services import async_setup_services
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER]
PLATFORMS = [Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -56,13 +56,13 @@ class BSBLanData:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the BSB-Lan integration."""
"""Set up the BSB-LAN integration."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bool:
"""Set up BSB-Lan from a config entry."""
"""Set up BSB-LAN from a config entry."""
# create config using BSBLANConfig
config = BSBLANConfig(

View File

@@ -0,0 +1,59 @@
"""Button platform for BSB-Lan integration."""
from __future__ import annotations
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BSBLanConfigEntry, BSBLanData
from .coordinator import BSBLanFastCoordinator
from .entity import BSBLanEntity
from .helpers import async_sync_device_time
PARALLEL_UPDATES = 1
BUTTON_DESCRIPTIONS: tuple[ButtonEntityDescription, ...] = (
ButtonEntityDescription(
key="sync_time",
translation_key="sync_time",
entity_category=EntityCategory.CONFIG,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: BSBLanConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up BSB-Lan button entities from a config entry."""
data = entry.runtime_data
async_add_entities(
BSBLanButtonEntity(data.fast_coordinator, data, description)
for description in BUTTON_DESCRIPTIONS
)
class BSBLanButtonEntity(BSBLanEntity, ButtonEntity):
"""Defines a BSB-Lan button entity."""
entity_description: ButtonEntityDescription
def __init__(
self,
coordinator: BSBLanFastCoordinator,
data: BSBLanData,
description: ButtonEntityDescription,
) -> None:
"""Initialize BSB-Lan button entity."""
super().__init__(coordinator, data)
self.entity_description = description
self._attr_unique_id = f"{data.device.MAC}-{description.key}"
self._data = data
async def async_press(self) -> None:
"""Handle the button press."""
await async_sync_device_time(self._data.client, self._data.device.name)

View File

@@ -39,15 +39,15 @@ PRESET_MODES = [
PRESET_NONE,
]
# Mapping from Home Assistant HVACMode to BSB-Lan integer values
# BSB-Lan uses: 0=off, 1=auto, 2=eco/reduced, 3=heat/comfort
# Mapping from Home Assistant HVACMode to BSB-LAN integer values
# BSB-LAN uses: 0=off, 1=auto, 2=eco/reduced, 3=heat/comfort
HA_TO_BSBLAN_HVAC_MODE: Final[dict[HVACMode, int]] = {
HVACMode.OFF: 0,
HVACMode.AUTO: 1,
HVACMode.HEAT: 3,
}
# Mapping from BSB-Lan integer values to Home Assistant HVACMode
# Mapping from BSB-LAN integer values to Home Assistant HVACMode
BSBLAN_TO_HA_HVAC_MODE: Final[dict[int, HVACMode]] = {
0: HVACMode.OFF,
1: HVACMode.AUTO,
@@ -69,7 +69,6 @@ async def async_setup_entry(
class BSBLANClimate(BSBLanEntity, ClimateEntity):
"""Defines a BSBLAN climate device."""
_attr_has_entity_name = True
_attr_name = None
# Determine preset modes
_attr_supported_features = (
@@ -138,7 +137,7 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
# BSB-Lan mode 2 is eco/reduced mode
# BSB-LAN mode 2 is eco/reduced mode
if self._hvac_mode_value == 2:
return PRESET_ECO
return PRESET_NONE
@@ -163,7 +162,7 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
if ATTR_HVAC_MODE in kwargs:
data[ATTR_HVAC_MODE] = HA_TO_BSBLAN_HVAC_MODE[kwargs[ATTR_HVAC_MODE]]
if ATTR_PRESET_MODE in kwargs:
# eco preset uses BSB-Lan mode 2, none preset uses mode 1 (auto)
# eco preset uses BSB-LAN mode 2, none preset uses mode 1 (auto)
if kwargs[ATTR_PRESET_MODE] == PRESET_ECO:
data[ATTR_HVAC_MODE] = 2
elif kwargs[ATTR_PRESET_MODE] == PRESET_NONE:

View File

@@ -1,4 +1,4 @@
"""Config flow for BSB-Lan integration."""
"""Config flow for BSB-LAN integration."""
from __future__ import annotations

View File

@@ -1,4 +1,4 @@
"""Constants for the BSB-Lan integration."""
"""Constants for the BSB-LAN integration."""
from __future__ import annotations

View File

@@ -1,4 +1,4 @@
"""DataUpdateCoordinator for the BSB-Lan integration."""
"""DataUpdateCoordinator for the BSB-LAN integration."""
from __future__ import annotations
@@ -62,7 +62,7 @@ class BSBLanSlowData:
class BSBLanCoordinator[T](DataUpdateCoordinator[T]):
"""Base BSB-Lan coordinator."""
"""Base BSB-LAN coordinator."""
config_entry: BSBLanConfigEntry
@@ -74,7 +74,7 @@ class BSBLanCoordinator[T](DataUpdateCoordinator[T]):
name: str,
update_interval: timedelta,
) -> None:
"""Initialize the BSB-Lan coordinator."""
"""Initialize the BSB-LAN coordinator."""
super().__init__(
hass,
logger=LOGGER,
@@ -86,7 +86,7 @@ class BSBLanCoordinator[T](DataUpdateCoordinator[T]):
class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
"""The BSB-Lan fast update coordinator for frequently changing data."""
"""The BSB-LAN fast update coordinator for frequently changing data."""
def __init__(
self,
@@ -94,7 +94,7 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
config_entry: BSBLanConfigEntry,
client: BSBLAN,
) -> None:
"""Initialize the BSB-Lan fast coordinator."""
"""Initialize the BSB-LAN fast coordinator."""
super().__init__(
hass,
config_entry,
@@ -104,7 +104,7 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
)
async def _async_update_data(self) -> BSBLanFastData:
"""Fetch fast-changing data from the BSB-Lan device."""
"""Fetch fast-changing data from the BSB-LAN device."""
try:
# Client is already initialized in async_setup_entry
# Use include filtering to only fetch parameters we actually use
@@ -115,12 +115,15 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
except BSBLANAuthError as err:
raise ConfigEntryAuthFailed(
"Authentication failed for BSB-Lan device"
translation_domain=DOMAIN,
translation_key="coordinator_auth_error",
) from err
except BSBLANConnectionError as err:
host = self.config_entry.data[CONF_HOST]
raise UpdateFailed(
f"Error while establishing connection with BSB-Lan device at {host}"
translation_domain=DOMAIN,
translation_key="coordinator_connection_error",
translation_placeholders={"host": host},
) from err
return BSBLanFastData(
@@ -131,7 +134,7 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
"""The BSB-Lan slow update coordinator for infrequently changing data."""
"""The BSB-LAN slow update coordinator for infrequently changing data."""
def __init__(
self,
@@ -139,7 +142,7 @@ class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
config_entry: BSBLanConfigEntry,
client: BSBLAN,
) -> None:
"""Initialize the BSB-Lan slow coordinator."""
"""Initialize the BSB-LAN slow coordinator."""
super().__init__(
hass,
config_entry,
@@ -149,7 +152,7 @@ class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
)
async def _async_update_data(self) -> BSBLanSlowData:
"""Fetch slow-changing data from the BSB-Lan device."""
"""Fetch slow-changing data from the BSB-LAN device."""
try:
# Client is already initialized in async_setup_entry
# Use include filtering to only fetch parameters we actually use

View File

@@ -32,6 +32,15 @@ class BSBLanEntityBase[_T: BSBLanCoordinator](CoordinatorEntity[_T]):
model=(
data.info.device_identification.value
if data.info.device_identification
and data.info.device_identification.value
else None
),
model_id=(
f"{data.info.controller_family.value}_{data.info.controller_variant.value}"
if data.info.controller_family
and data.info.controller_variant
and data.info.controller_family.value
and data.info.controller_variant.value
else None
),
sw_version=data.device.version,

View File

@@ -0,0 +1,42 @@
"""Helper functions for BSB-Lan integration."""
from __future__ import annotations
from bsblan import BSBLAN, BSBLANError
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import dt as dt_util
from .const import DOMAIN
async def async_sync_device_time(client: BSBLAN, device_name: str) -> None:
"""Synchronize BSB-LAN device time with Home Assistant.
Only updates if device time differs from Home Assistant time.
Args:
client: The BSB-LAN client instance.
device_name: The name of the device (used in error messages).
Raises:
HomeAssistantError: If the time sync operation fails.
"""
try:
device_time = await client.time()
current_time = dt_util.now()
current_time_str = current_time.strftime("%d.%m.%Y %H:%M:%S")
# Only sync if device time differs from HA time
if device_time.time.value != current_time_str:
await client.set_time(current_time_str)
except BSBLANError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="sync_time_failed",
translation_placeholders={
"device_name": device_name,
"error": str(err),
},
) from err

View File

@@ -1,4 +1,11 @@
{
"entity": {
"button": {
"sync_time": {
"default": "mdi:timer-sync-outline"
}
}
},
"services": {
"set_hot_water_schedule": {
"service": "mdi:calendar-clock"

View File

@@ -1,6 +1,6 @@
{
"domain": "bsblan",
"name": "BSB-Lan",
"name": "BSB-LAN",
"codeowners": ["@liudger"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/bsblan",

View File

@@ -1,4 +1,4 @@
"""Support for BSB-Lan sensors."""
"""Support for BSB-LAN sensors."""
from __future__ import annotations
@@ -25,7 +25,7 @@ PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class BSBLanSensorEntityDescription(SensorEntityDescription):
"""Describes BSB-Lan sensor entity."""
"""Describes BSB-LAN sensor entity."""
value_fn: Callable[[BSBLanFastData], StateType]
exists_fn: Callable[[BSBLanFastData], bool] = lambda data: True
@@ -79,7 +79,7 @@ async def async_setup_entry(
entry: BSBLanConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up BSB-Lan sensor based on a config entry."""
"""Set up BSB-LAN sensor based on a config entry."""
data = entry.runtime_data
# Only create sensors for available data points
@@ -94,7 +94,7 @@ async def async_setup_entry(
class BSBLanSensor(BSBLanEntity, SensorEntity):
"""Defines a BSB-Lan sensor."""
"""Defines a BSB-LAN sensor."""
entity_description: BSBLanSensorEntityDescription
@@ -103,7 +103,7 @@ class BSBLanSensor(BSBLanEntity, SensorEntity):
data: BSBLanData,
description: BSBLanSensorEntityDescription,
) -> None:
"""Initialize BSB-Lan sensor."""
"""Initialize BSB-LAN sensor."""
super().__init__(data.fast_coordinator, data)
self.entity_description = description
self._attr_unique_id = f"{data.device.MAC}-{description.key}"

View File

@@ -1,4 +1,4 @@
"""Support for BSB-Lan services."""
"""Support for BSB-LAN services."""
from __future__ import annotations
@@ -13,9 +13,9 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .helpers import async_sync_device_time
if TYPE_CHECKING:
from . import BSBLanConfigEntry
@@ -192,7 +192,7 @@ async def set_hot_water_schedule(service_call: ServiceCall) -> None:
)
try:
# Call the BSB-Lan API to set the schedule
# Call the BSB-LAN API to set the schedule
await client.set_hot_water_schedule(dhw_schedule)
except BSBLANError as err:
raise HomeAssistantError(
@@ -245,25 +245,7 @@ async def async_sync_time(service_call: ServiceCall) -> None:
)
client = entry.runtime_data.client
try:
# Get current device time
device_time = await client.time()
current_time = dt_util.now()
current_time_str = current_time.strftime("%d.%m.%Y %H:%M:%S")
# Only sync if device time differs from HA time
if device_time.time.value != current_time_str:
await client.set_time(current_time_str)
except BSBLANError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="sync_time_failed",
translation_placeholders={
"device_name": device_entry.name or device_id,
"error": str(err),
},
) from err
await async_sync_device_time(client, device_entry.name or device_id)
SYNC_TIME_SCHEMA = vol.Schema(
@@ -275,7 +257,7 @@ SYNC_TIME_SCHEMA = vol.Schema(
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register the BSB-Lan services."""
"""Register the BSB-LAN services."""
hass.services.async_register(
DOMAIN,
SERVICE_SET_HOT_WATER_SCHEDULE,

View File

@@ -22,8 +22,8 @@
"password": "[%key:component::bsblan::config::step::user::data_description::password%]",
"username": "[%key:component::bsblan::config::step::user::data_description::username%]"
},
"description": "A BSB-Lan device was discovered at {host}. Please provide credentials if required.",
"title": "BSB-Lan device discovered"
"description": "A BSB-LAN device was discovered at {host}. Please provide credentials if required.",
"title": "BSB-LAN device discovered"
},
"reauth_confirm": {
"data": {
@@ -36,7 +36,7 @@
"password": "[%key:component::bsblan::config::step::user::data_description::password%]",
"username": "[%key:component::bsblan::config::step::user::data_description::username%]"
},
"description": "The BSB-Lan integration needs to re-authenticate with {name}",
"description": "The BSB-LAN integration needs to re-authenticate with {name}",
"title": "[%key:common::config_flow::title::reauth%]"
},
"user": {
@@ -48,18 +48,23 @@
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"host": "The hostname or IP address of your BSB-Lan device.",
"passkey": "The passkey for your BSB-Lan device.",
"password": "The password for your BSB-Lan device.",
"port": "The port number of your BSB-Lan device.",
"username": "The username for your BSB-Lan device."
"host": "The hostname or IP address of your BSB-LAN device.",
"passkey": "The passkey for your BSB-LAN device.",
"password": "The password for your BSB-LAN device.",
"port": "The port number of your BSB-LAN device.",
"username": "The username for your BSB-LAN device."
},
"description": "Set up your BSB-Lan device to integrate with Home Assistant.",
"title": "Connect to the BSB-Lan device"
"description": "Set up your BSB-LAN device to integrate with Home Assistant.",
"title": "Connect to the BSB-LAN device"
}
}
},
"entity": {
"button": {
"sync_time": {
"name": "Sync time"
}
},
"sensor": {
"current_temperature": {
"name": "Current temperature"
@@ -76,6 +81,12 @@
"config_entry_not_loaded": {
"message": "The device `{device_name}` is not currently loaded or available"
},
"coordinator_auth_error": {
"message": "Authentication failed for BSB-LAN device"
},
"coordinator_connection_error": {
"message": "Error while establishing connection with BSB-LAN device at {host}"
},
"end_time_before_start_time": {
"message": "End time ({end_time}) must be after start time ({start_time})"
},
@@ -86,14 +97,11 @@
"message": "No configuration entry found for device: {device_id}"
},
"set_data_error": {
"message": "An error occurred while sending the data to the BSB-Lan device"
"message": "An error occurred while sending the data to the BSB-LAN device"
},
"set_operation_mode_error": {
"message": "An error occurred while setting the operation mode"
},
"set_preset_mode_error": {
"message": "Can't set preset mode to {preset_mode} when HVAC mode is not set to auto"
},
"set_schedule_failed": {
"message": "Failed to set hot water schedule: {error}"
},
@@ -104,7 +112,7 @@
"message": "Authentication failed while retrieving static device data"
},
"setup_connection_error": {
"message": "Failed to retrieve static device data from BSB-Lan device at {host}"
"message": "Failed to retrieve static device data from BSB-LAN device at {host}"
},
"setup_general_error": {
"message": "An unknown error occurred while retrieving static device data"
@@ -153,7 +161,7 @@
"name": "Set hot water schedule"
},
"sync_time": {
"description": "Synchronize Home Assistant time to the BSB-Lan device. Only updates if device time differs from Home Assistant time.",
"description": "Synchronize Home Assistant time to the BSB-LAN device. Only updates if device time differs from Home Assistant time.",
"fields": {
"device_id": {
"description": "The BSB-LAN device to sync time for.",

View File

@@ -63,6 +63,7 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
"""Defines a BSBLAN water heater entity."""
_attr_name = None
_attr_operation_list = list(HA_TO_BSBLAN_OPERATION_MODE.keys())
_attr_supported_features = (
WaterHeaterEntityFeature.TARGET_TEMPERATURE
| WaterHeaterEntityFeature.OPERATION_MODE
@@ -73,7 +74,6 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
"""Initialize BSBLAN water heater."""
super().__init__(data.fast_coordinator, data.slow_coordinator, data)
self._attr_unique_id = format_mac(data.device.MAC)
self._attr_operation_list = list(HA_TO_BSBLAN_OPERATION_MODE.keys())
# Set temperature unit
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit

View File

@@ -10,6 +10,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.NUMBER,
Platform.SELECT,

View File

@@ -0,0 +1,189 @@
"""Binary sensor platform for Compit integration."""
from dataclasses import dataclass
from compit_inext_api.consts import CompitParameter
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER_NAME
from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
PARALLEL_UPDATES = 0
NO_SENSOR = "no_sensor"
ON_STATES = ["on", "yes", "charging", "alert", "exceeded"]
DESCRIPTIONS: dict[CompitParameter, BinarySensorEntityDescription] = {
CompitParameter.AIRING: BinarySensorEntityDescription(
key=CompitParameter.AIRING.value,
translation_key="airing",
device_class=BinarySensorDeviceClass.WINDOW,
entity_category=EntityCategory.DIAGNOSTIC,
),
CompitParameter.BATTERY_CHARGE_STATUS: BinarySensorEntityDescription(
key=CompitParameter.BATTERY_CHARGE_STATUS.value,
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
entity_category=EntityCategory.DIAGNOSTIC,
),
CompitParameter.CO2_ALERT: BinarySensorEntityDescription(
key=CompitParameter.CO2_ALERT.value,
translation_key="co2_alert",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
CompitParameter.CO2_LEVEL: BinarySensorEntityDescription(
key=CompitParameter.CO2_LEVEL.value,
translation_key="co2_level",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
CompitParameter.DUST_ALERT: BinarySensorEntityDescription(
key=CompitParameter.DUST_ALERT.value,
translation_key="dust_alert",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
CompitParameter.PUMP_STATUS: BinarySensorEntityDescription(
key=CompitParameter.PUMP_STATUS.value,
translation_key="pump_status",
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
),
CompitParameter.TEMPERATURE_ALERT: BinarySensorEntityDescription(
key=CompitParameter.TEMPERATURE_ALERT.value,
translation_key="temperature_alert",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
}
@dataclass(frozen=True, kw_only=True)
class CompitDeviceDescription:
"""Class to describe a Compit device."""
name: str
parameters: dict[CompitParameter, BinarySensorEntityDescription]
DEVICE_DEFINITIONS: dict[int, CompitDeviceDescription] = {
12: CompitDeviceDescription(
name="Nano Color",
parameters={
CompitParameter.CO2_LEVEL: DESCRIPTIONS[CompitParameter.CO2_LEVEL],
},
),
78: CompitDeviceDescription(
name="SPM - Nano Color 2",
parameters={
CompitParameter.DUST_ALERT: DESCRIPTIONS[CompitParameter.DUST_ALERT],
CompitParameter.TEMPERATURE_ALERT: DESCRIPTIONS[
CompitParameter.TEMPERATURE_ALERT
],
CompitParameter.CO2_ALERT: DESCRIPTIONS[CompitParameter.CO2_ALERT],
},
),
223: CompitDeviceDescription(
name="Nano Color 2",
parameters={
CompitParameter.AIRING: DESCRIPTIONS[CompitParameter.AIRING],
CompitParameter.CO2_LEVEL: DESCRIPTIONS[CompitParameter.CO2_LEVEL],
},
),
225: CompitDeviceDescription(
name="SPM - Nano Color",
parameters={
CompitParameter.CO2_LEVEL: DESCRIPTIONS[CompitParameter.CO2_LEVEL],
},
),
226: CompitDeviceDescription(
name="AF-1",
parameters={
CompitParameter.BATTERY_CHARGE_STATUS: DESCRIPTIONS[
CompitParameter.BATTERY_CHARGE_STATUS
],
CompitParameter.PUMP_STATUS: DESCRIPTIONS[CompitParameter.PUMP_STATUS],
},
),
}
async def async_setup_entry(
hass: HomeAssistant,
entry: CompitConfigEntry,
async_add_devices: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Compit binary sensor entities from a config entry."""
coordinator = entry.runtime_data
async_add_devices(
CompitBinarySensor(
coordinator,
device_id,
device_definition.name,
code,
entity_description,
)
for device_id, device in coordinator.connector.all_devices.items()
if (device_definition := DEVICE_DEFINITIONS.get(device.definition.code))
for code, entity_description in device_definition.parameters.items()
if coordinator.connector.get_current_value(device_id, code) != NO_SENSOR
)
class CompitBinarySensor(
CoordinatorEntity[CompitDataUpdateCoordinator], BinarySensorEntity
):
"""Representation of a Compit binary sensor entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: CompitDataUpdateCoordinator,
device_id: int,
device_name: str,
parameter_code: CompitParameter,
entity_description: BinarySensorEntityDescription,
) -> None:
"""Initialize the binary sensor entity."""
super().__init__(coordinator)
self.device_id = device_id
self.entity_description = entity_description
self._attr_unique_id = f"{device_id}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, str(device_id))},
name=device_name,
manufacturer=MANUFACTURER_NAME,
model=device_name,
)
self.parameter_code = parameter_code
@property
def available(self) -> bool:
"""Return if entity is available."""
return (
super().available
and self.coordinator.connector.get_device(self.device_id) is not None
)
@property
def is_on(self) -> bool | None:
"""Return the state of the binary sensor."""
value = self.coordinator.connector.get_current_value(
self.device_id, self.parameter_code
)
if value is None:
return None
return value in ON_STATES

View File

@@ -1,5 +1,25 @@
{
"entity": {
"binary_sensor": {
"airing": {
"default": "mdi:window-open-variant"
},
"co2_alert": {
"default": "mdi:alert"
},
"co2_level": {
"default": "mdi:molecule-co2"
},
"dust_alert": {
"default": "mdi:alert"
},
"pump_status": {
"default": "mdi:pump"
},
"temperature_alert": {
"default": "mdi:alert"
}
},
"number": {
"boiler_target_temperature": {
"default": "mdi:water-boiler"

View File

@@ -33,6 +33,26 @@
}
},
"entity": {
"binary_sensor": {
"airing": {
"name": "Airing"
},
"co2_alert": {
"name": "CO2 alert"
},
"co2_level": {
"name": "CO2 level"
},
"dust_alert": {
"name": "Dust alert"
},
"pump_status": {
"name": "Pump status"
},
"temperature_alert": {
"name": "Temperature alert"
}
},
"number": {
"boiler_target_temperature": {
"name": "Boiler target temperature"

View File

@@ -28,6 +28,7 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.WATER_HEATER,

View File

@@ -5,7 +5,7 @@ from typing import Any
from pyeconet.equipment import EquipmentType
from pyeconet.equipment.thermostat import (
Thermostat,
ThermostatFanMode,
ThermostatFanSpeed,
ThermostatOperationMode,
)
@@ -16,6 +16,7 @@ from homeassistant.components.climate import (
FAN_HIGH,
FAN_LOW,
FAN_MEDIUM,
FAN_TOP,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
@@ -41,13 +42,16 @@ HA_STATE_TO_ECONET = {
if key != ThermostatOperationMode.EMERGENCY_HEAT
}
ECONET_FAN_STATE_TO_HA = {
ThermostatFanMode.AUTO: FAN_AUTO,
ThermostatFanMode.LOW: FAN_LOW,
ThermostatFanMode.MEDIUM: FAN_MEDIUM,
ThermostatFanMode.HIGH: FAN_HIGH,
ECONET_FAN_SPEED_TO_HA = {
ThermostatFanSpeed.AUTO: FAN_AUTO,
ThermostatFanSpeed.LOW: FAN_LOW,
ThermostatFanSpeed.MEDIUM: FAN_MEDIUM,
ThermostatFanSpeed.HIGH: FAN_HIGH,
ThermostatFanSpeed.MAX: FAN_TOP,
}
HA_FAN_STATE_TO_ECONET_FAN_SPEED = {
value: key for key, value in ECONET_FAN_SPEED_TO_HA.items()
}
HA_FAN_STATE_TO_ECONET = {value: key for key, value in ECONET_FAN_STATE_TO_HA.items()}
SUPPORT_FLAGS_THERMOSTAT = (
ClimateEntityFeature.TARGET_TEMPERATURE
@@ -103,7 +107,7 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity):
return self._econet.set_point
@property
def current_humidity(self) -> int:
def current_humidity(self) -> int | None:
"""Return the current humidity."""
return self._econet.humidity
@@ -149,7 +153,7 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity):
@property
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool, mode.
"""Return hvac operation i.e. heat, cool, mode.
Needs to be one of HVAC_MODE_*.
"""
@@ -174,35 +178,35 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity):
@property
def fan_mode(self) -> str:
"""Return the current fan mode."""
econet_fan_mode = self._econet.fan_mode
econet_fan_speed = self._econet.fan_speed
# Remove this after we figure out how to handle med lo and med hi
if econet_fan_mode in [ThermostatFanMode.MEDHI, ThermostatFanMode.MEDLO]:
econet_fan_mode = ThermostatFanMode.MEDIUM
if econet_fan_speed in [ThermostatFanSpeed.MEDHI, ThermostatFanSpeed.MEDLO]:
econet_fan_speed = ThermostatFanSpeed.MEDIUM
_current_fan_mode = FAN_AUTO
if econet_fan_mode is not None:
_current_fan_mode = ECONET_FAN_STATE_TO_HA[econet_fan_mode]
return _current_fan_mode
_current_fan_speed = FAN_AUTO
if econet_fan_speed is not None:
_current_fan_speed = ECONET_FAN_SPEED_TO_HA[econet_fan_speed]
return _current_fan_speed
@property
def fan_modes(self) -> list[str]:
"""Return the fan modes."""
# Remove the MEDLO MEDHI once we figure out how to handle it
return [
ECONET_FAN_STATE_TO_HA[mode]
for mode in self._econet.fan_modes
# Remove the MEDLO MEDHI once we figure out how to handle it
ECONET_FAN_SPEED_TO_HA[mode]
for mode in self._econet.fan_speeds
if mode
not in [
ThermostatFanMode.UNKNOWN,
ThermostatFanMode.MEDLO,
ThermostatFanMode.MEDHI,
ThermostatFanSpeed.UNKNOWN,
ThermostatFanSpeed.MEDLO,
ThermostatFanSpeed.MEDHI,
]
]
def set_fan_mode(self, fan_mode: str) -> None:
"""Set the fan mode."""
self._econet.set_fan_mode(HA_FAN_STATE_TO_ECONET[fan_mode])
self._econet.set_fan_speed(HA_FAN_STATE_TO_ECONET_FAN_SPEED[fan_mode])
@property
def min_temp(self) -> float:

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["paho_mqtt", "pyeconet"],
"requirements": ["pyeconet==0.1.28"]
"requirements": ["pyeconet==0.2.1"]
}

View File

@@ -0,0 +1,53 @@
"""Support for Rheem EcoNet thermostats with variable fan speeds and fan modes."""
from __future__ import annotations
from pyeconet.equipment import EquipmentType
from pyeconet.equipment.thermostat import Thermostat, ThermostatFanMode
from homeassistant.components.select import SelectEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EconetConfigEntry
from .entity import EcoNetEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: EconetConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the econet thermostat select entity."""
equipment = entry.runtime_data
async_add_entities(
EconetFanModeSelect(thermostat)
for thermostat in equipment[EquipmentType.THERMOSTAT]
if thermostat.supports_fan_mode
)
class EconetFanModeSelect(EcoNetEntity[Thermostat], SelectEntity):
"""Select entity."""
def __init__(self, thermostat: Thermostat) -> None:
"""Initialize EcoNet platform."""
super().__init__(thermostat)
self._attr_name = f"{thermostat.device_name} fan mode"
self._attr_unique_id = (
f"{thermostat.device_id}_{thermostat.device_name}_fan_mode"
)
@property
def options(self) -> list[str]:
"""Return available select options."""
return [e.value for e in self._econet.fan_modes]
@property
def current_option(self) -> str:
"""Return current select option."""
return self._econet.fan_mode.value
def select_option(self, option: str) -> None:
"""Set the selected option."""
self._econet.set_fan_mode(ThermostatFanMode.by_string(option))

View File

@@ -23,19 +23,20 @@ async def async_setup_entry(
entry: EconetConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ecobee thermostat switch entity."""
"""Set up the econet thermostat switch entity."""
equipment = entry.runtime_data
async_add_entities(
EcoNetSwitchAuxHeatOnly(thermostat)
for thermostat in equipment[EquipmentType.THERMOSTAT]
if ThermostatOperationMode.EMERGENCY_HEAT in thermostat.modes
)
class EcoNetSwitchAuxHeatOnly(EcoNetEntity[Thermostat], SwitchEntity):
"""Representation of a aux_heat_only EcoNet switch."""
"""Representation of an aux_heat_only EcoNet switch."""
def __init__(self, thermostat: Thermostat) -> None:
"""Initialize EcoNet ventilator platform."""
"""Initialize EcoNet platform."""
super().__init__(thermostat)
self._attr_name = f"{thermostat.device_name} emergency heat"
self._attr_unique_id = (

View File

@@ -12,11 +12,7 @@ import re
from typing import Any, TypedDict, cast
from fritzconnection import FritzConnection
from fritzconnection.core.exceptions import (
FritzActionError,
FritzConnectionException,
FritzSecurityError,
)
from fritzconnection.core.exceptions import FritzActionError
from fritzconnection.lib.fritzcall import FritzCall
from fritzconnection.lib.fritzhosts import FritzHosts
from fritzconnection.lib.fritzstatus import FritzStatus
@@ -47,6 +43,7 @@ from .const import (
DEFAULT_SSL,
DEFAULT_USERNAME,
DOMAIN,
FRITZ_AUTH_EXCEPTIONS,
FRITZ_EXCEPTIONS,
SCAN_INTERVAL,
MeshRoles,
@@ -425,12 +422,18 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
hosts_info: list[HostInfo] = []
try:
try:
hosts_attributes = await self.hass.async_add_executor_job(
self.fritz_hosts.get_hosts_attributes
hosts_attributes = cast(
list[HostAttributes],
await self.hass.async_add_executor_job(
self.fritz_hosts.get_hosts_attributes
),
)
except FritzActionError:
hosts_info = await self.hass.async_add_executor_job(
self.fritz_hosts.get_hosts_info
hosts_info = cast(
list[HostInfo],
await self.hass.async_add_executor_job(
self.fritz_hosts.get_hosts_info
),
)
except Exception as ex:
if not self.hass.is_stopping:
@@ -586,7 +589,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
topology := await self.hass.async_add_executor_job(
self.fritz_hosts.get_mesh_topology
)
):
) or not isinstance(topology, dict):
raise Exception("Mesh supported but empty topology reported") # noqa: TRY002
except FritzActionError:
self.mesh_role = MeshRoles.SLAVE
@@ -742,7 +745,7 @@ class AvmWrapper(FritzBoxTools):
**kwargs,
)
)
except FritzSecurityError:
except FRITZ_AUTH_EXCEPTIONS:
_LOGGER.exception(
"Authorization Error: Please check the provided credentials and"
" verify that you can log into the web interface"
@@ -755,12 +758,6 @@ class AvmWrapper(FritzBoxTools):
action_name,
)
return {}
except FritzConnectionException:
_LOGGER.exception(
"Connection Error: Please check the device is properly configured"
" for remote login"
)
return {}
return result
async def async_get_upnp_configuration(self) -> dict[str, Any]:

View File

@@ -0,0 +1,27 @@
"""Diagnostics support for HomematicIP Cloud."""
from __future__ import annotations
import json
from typing import Any
from homematicip.base.helpers import handle_config
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from .hap import HomematicIPConfigEntry
TO_REDACT_CONFIG = {"city", "latitude", "longitude", "refreshToken"}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: HomematicIPConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
hap = config_entry.runtime_data
json_state = await hap.home.download_configuration_async()
anonymized = handle_config(json_state, anonymize=True)
config = json.loads(anonymized)
return async_redact_data(config, TO_REDACT_CONFIG)

View File

@@ -10,7 +10,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH]
PLATFORMS: list[Platform] = [
Platform.SENSOR,
Platform.SWITCH,
Platform.SELECT,
Platform.NUMBER,
]
async def async_setup_entry(hass: HomeAssistant, entry: HomevoltConfigEntry) -> bool:

View File

@@ -0,0 +1,132 @@
"""Support for Homevolt number entities."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
from .entity import HomevoltEntity, homevolt_exception_handler
@dataclass(frozen=True, kw_only=True)
class HomevoltNumberEntityDescription(NumberEntityDescription):
"""Custom entity description for Homevolt numbers."""
set_value_fn: Any = None
value_fn: Any = None
NUMBER_DESCRIPTIONS: tuple[HomevoltNumberEntityDescription, ...] = (
HomevoltNumberEntityDescription(
key="setpoint",
translation_key="setpoint",
native_min_value=0,
native_max_value=20000,
native_step=100,
native_unit_of_measurement=UnitOfPower.WATT,
entity_category=EntityCategory.CONFIG,
),
HomevoltNumberEntityDescription(
key="max_charge",
translation_key="max_charge",
native_min_value=0,
native_max_value=20000,
native_step=100,
native_unit_of_measurement=UnitOfPower.WATT,
entity_category=EntityCategory.CONFIG,
),
HomevoltNumberEntityDescription(
key="max_discharge",
translation_key="max_discharge",
native_min_value=0,
native_max_value=20000,
native_step=100,
native_unit_of_measurement=UnitOfPower.WATT,
entity_category=EntityCategory.CONFIG,
),
HomevoltNumberEntityDescription(
key="min_soc",
translation_key="min_soc",
native_min_value=0,
native_max_value=100,
native_step=1,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.CONFIG,
),
HomevoltNumberEntityDescription(
key="max_soc",
translation_key="max_soc",
native_min_value=0,
native_max_value=100,
native_step=1,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.CONFIG,
),
HomevoltNumberEntityDescription(
key="grid_import_limit",
translation_key="grid_import_limit",
native_min_value=0,
native_max_value=20000,
native_step=100,
native_unit_of_measurement=UnitOfPower.WATT,
entity_category=EntityCategory.CONFIG,
),
HomevoltNumberEntityDescription(
key="grid_export_limit",
translation_key="grid_export_limit",
native_min_value=0,
native_max_value=20000,
native_step=100,
native_unit_of_measurement=UnitOfPower.WATT,
entity_category=EntityCategory.CONFIG,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: HomevoltConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homevolt number entities."""
coordinator = entry.runtime_data
entities: list[HomevoltNumberEntity] = []
for description in NUMBER_DESCRIPTIONS:
entities.append(HomevoltNumberEntity(coordinator, description))
async_add_entities(entities)
class HomevoltNumberEntity(HomevoltEntity, NumberEntity):
"""Representation of a Homevolt number entity."""
entity_description: HomevoltNumberEntityDescription
def __init__(
self,
coordinator: HomevoltDataUpdateCoordinator,
description: HomevoltNumberEntityDescription,
) -> None:
"""Initialize the number entity."""
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.unique_id}_{description.key}"
device_id = coordinator.data.unique_id
super().__init__(coordinator, f"ems_{device_id}")
@property
def native_value(self) -> float | None:
"""Return the current value."""
value = self.coordinator.client.schedule.get(self.entity_description.key)
return float(value) if value is not None else None
@homevolt_exception_handler
async def async_set_native_value(self, value: float) -> None:
"""Set the value."""
key = self.entity_description.key
await self.coordinator.client.set_battery_parameters(**{key: int(value)})
await self.coordinator.async_request_refresh()

View File

@@ -0,0 +1,51 @@
"""Support for Homevolt select entities."""
from __future__ import annotations
from homevolt.const import SCHEDULE_TYPE
from homeassistant.components.select import SelectEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
from .entity import HomevoltEntity, homevolt_exception_handler
PARALLEL_UPDATES = 0 # Coordinator-based updates
async def async_setup_entry(
hass: HomeAssistant,
entry: HomevoltConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homevolt select entities."""
coordinator = entry.runtime_data
async_add_entities([HomevoltModeSelect(coordinator)])
class HomevoltModeSelect(HomevoltEntity, SelectEntity):
"""Select entity for battery operational mode."""
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "battery_mode"
_attr_options = list(SCHEDULE_TYPE.values())
def __init__(self, coordinator: HomevoltDataUpdateCoordinator) -> None:
"""Initialize the select entity."""
self._attr_unique_id = f"{coordinator.data.unique_id}_battery_mode"
device_id = coordinator.data.unique_id
super().__init__(coordinator, f"ems_{device_id}")
@property
def current_option(self) -> str | None:
"""Return the current selected mode."""
mode_int = self.coordinator.client.schedule_mode
return SCHEDULE_TYPE.get(mode_int)
@homevolt_exception_handler
async def async_select_option(self, option: str) -> None:
"""Change the selected mode."""
await self.coordinator.client.set_battery_mode(mode=option)
await self.coordinator.async_request_refresh()

View File

@@ -54,6 +54,46 @@
}
},
"entity": {
"number": {
"grid_export_limit": {
"name": "Grid export limit"
},
"grid_import_limit": {
"name": "Grid import limit"
},
"max_charge": {
"name": "Maximum charge power"
},
"max_discharge": {
"name": "Maximum discharge power"
},
"max_soc": {
"name": "Maximum state of charge"
},
"min_soc": {
"name": "Minimum state of charge"
},
"setpoint": {
"name": "Power setpoint"
}
},
"select": {
"battery_mode": {
"name": "Battery mode",
"state": {
"frequency_reserve": "Frequency reserve",
"full_solar_export": "Full solar export",
"grid_charge": "Grid charge",
"grid_charge_discharge": "Grid charge/discharge",
"grid_discharge": "Grid discharge",
"idle": "Idle",
"inverter_charge": "Inverter charge",
"inverter_discharge": "Inverter discharge",
"solar_charge": "Solar charge",
"solar_charge_discharge": "Solar charge/discharge"
}
}
},
"sensor": {
"available_charging_energy": {
"name": "Available charging energy"

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["imgw_pib==2.0.1"]
"requirements": ["imgw_pib==2.0.2"]
}

View File

@@ -24,6 +24,7 @@
"hydrological_alert": {
"name": "Hydrological alert",
"state": {
"exceeding_the_alarm_level": "Exceeding the alarm level",
"exceeding_the_warning_level": "Exceeding the warning level",
"hydrological_drought": "Hydrological drought",
"no_alert": "No alert",

View File

@@ -120,6 +120,31 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovered_name: str | None = None
self._pending_host: str | None = None
async def _async_validate_host(
self,
host: str,
errors: dict[str, str],
) -> tuple[dict[str, Any] | None, bool]:
"""Validate host connection and populate errors dict on failure.
Returns (info, needs_auth). When needs_auth is True, the caller
should store the host and redirect to the appropriate auth step.
"""
try:
return await validate_input(self.hass, host), False
except NRGkickApiClientApiDisabledError:
errors["base"] = "json_api_disabled"
except NRGkickApiClientAuthenticationError:
return None, True
except NRGkickApiClientInvalidResponseError:
errors["base"] = "invalid_response"
except NRGkickApiClientCommunicationError:
errors["base"] = "cannot_connect"
except NRGkickApiClientError:
_LOGGER.exception("Unexpected error")
errors["base"] = "unknown"
return None, False
async def _async_validate_credentials(
self,
host: str,
@@ -156,21 +181,11 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
except vol.Invalid:
errors["base"] = "cannot_connect"
else:
try:
info = await validate_input(self.hass, host)
except NRGkickApiClientApiDisabledError:
errors["base"] = "json_api_disabled"
except NRGkickApiClientAuthenticationError:
info, needs_auth = await self._async_validate_host(host, errors)
if needs_auth:
self._pending_host = host
return await self.async_step_user_auth()
except NRGkickApiClientInvalidResponseError:
errors["base"] = "invalid_response"
except NRGkickApiClientCommunicationError:
errors["base"] = "cannot_connect"
except NRGkickApiClientError:
_LOGGER.exception("Unexpected error")
errors["base"] = "unknown"
else:
if info:
await self.async_set_unique_id(
info["serial"], raise_on_progress=False
)
@@ -257,6 +272,81 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of the integration."""
errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
if user_input is not None:
try:
host = _normalize_host(user_input[CONF_HOST])
except vol.Invalid:
errors["base"] = "cannot_connect"
else:
info, needs_auth = await self._async_validate_host(host, errors)
if needs_auth:
self._pending_host = host
return await self.async_step_reconfigure_auth()
if info:
await self.async_set_unique_id(
info["serial"], raise_on_progress=False
)
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates={CONF_HOST: host},
)
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA,
reconfigure_entry.data,
),
errors=errors,
)
async def async_step_reconfigure_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration authentication step."""
errors: dict[str, str] = {}
if TYPE_CHECKING:
assert self._pending_host is not None
reconfigure_entry = self._get_reconfigure_entry()
if user_input is not None:
if info := await self._async_validate_credentials(
self._pending_host,
errors,
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
):
await self.async_set_unique_id(info["serial"], raise_on_progress=False)
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates={
CONF_HOST: self._pending_host,
CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
},
)
return self.async_show_form(
step_id="reconfigure_auth",
data_schema=self.add_suggested_values_to_schema(
STEP_AUTH_DATA_SCHEMA,
reconfigure_entry.data,
),
errors=errors,
description_placeholders={
"device_ip": self._pending_host,
},
)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
@@ -321,21 +411,13 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
assert self._discovered_name is not None
if user_input is not None:
try:
info = await validate_input(self.hass, self._discovered_host)
except NRGkickApiClientApiDisabledError:
errors["base"] = "json_api_disabled"
except NRGkickApiClientAuthenticationError:
info, needs_auth = await self._async_validate_host(
self._discovered_host, errors
)
if needs_auth:
self._pending_host = self._discovered_host
return await self.async_step_user_auth()
except NRGkickApiClientInvalidResponseError:
errors["base"] = "invalid_response"
except NRGkickApiClientCommunicationError:
errors["base"] = "cannot_connect"
except NRGkickApiClientError:
_LOGGER.exception("Unexpected error")
errors["base"] = "unknown"
else:
if info:
return self.async_create_entry(
title=info["title"], data={CONF_HOST: self._discovered_host}
)

View File

@@ -68,7 +68,7 @@ rules:
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
reconfiguration-flow: done
repair-issues: todo
stale-devices:
status: exempt

View File

@@ -6,6 +6,7 @@
"json_api_disabled": "JSON API is disabled on the device. Enable it in the NRGkick mobile app under Extended \u2192 Local API \u2192 API Variants.",
"no_serial_number": "Device does not provide a serial number",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "The device does not match the previous device"
},
"error": {
@@ -28,6 +29,26 @@
},
"description": "Reauthenticate with your NRGkick device.\n\nGet your username and password in the NRGkick mobile app:\n1. Open the NRGkick mobile app \u2192 Extended \u2192 Local API\n2. Under Authentication (JSON), check or set your username and password"
},
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "[%key:component::nrgkick::config::step::user::data_description::host%]"
},
"description": "Reconfigure your NRGkick device. This allows you to change the IP address or hostname of your NRGkick device."
},
"reconfigure_auth": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "[%key:component::nrgkick::config::step::user_auth::data_description::password%]",
"username": "[%key:component::nrgkick::config::step::user_auth::data_description::username%]"
},
"description": "[%key:component::nrgkick::config::step::user_auth::description%]"
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"

View File

@@ -9,7 +9,7 @@ from pysmarlaapi.connection.exceptions import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import HOST, PLATFORMS
@@ -23,16 +23,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: FederwiegeConfigEntry) -
# Check if token still has access
try:
await connection.refresh_token()
except (ConnectionException, AuthenticationException) as e:
raise ConfigEntryError("Invalid authentication") from e
except AuthenticationException as e:
raise ConfigEntryAuthFailed("Invalid authentication") from e
except ConnectionException as e:
raise ConfigEntryNotReady("Unable to connect to server") from e
federwiege = Federwiege(hass.loop, connection)
async def on_auth_failure():
entry.async_start_reauth(hass)
federwiege = Federwiege(hass.loop, connection, on_auth_failure)
federwiege.register()
entry.runtime_data = federwiege
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Starts a task to keep reconnecting, e.g. when device gets unreachable.
# When an authentication error occurs, it automatically stops and calls
# the on_auth_failure function.
federwiege.connect()
return True

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from pysmarlaapi import Connection
@@ -11,12 +12,12 @@ from pysmarlaapi.connection.exceptions import (
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN
from .const import DOMAIN, HOST
STEP_USER_DATA_SCHEMA = vol.Schema({CONF_ACCESS_TOKEN: str})
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str})
class SmarlaConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -24,45 +25,89 @@ class SmarlaConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
async def _handle_token(self, token: str) -> tuple[dict[str, str], str | None]:
"""Handle the token input."""
errors: dict[str, str] = {}
def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self.errors: dict[str, str] = {}
async def _handle_token(self, token: str) -> str | None:
"""Handle the token input."""
try:
conn = Connection(url=HOST, token_b64=token)
except ValueError:
errors["base"] = "malformed_token"
return errors, None
self.errors["base"] = "malformed_token"
return None
try:
await conn.refresh_token()
except ConnectionException, AuthenticationException:
errors["base"] = "invalid_auth"
return errors, None
except ConnectionException:
self.errors["base"] = "cannot_connect"
return None
except AuthenticationException:
self.errors["base"] = "invalid_auth"
return None
return errors, conn.token.serialNumber
return conn.token.serialNumber
async def _validate_input(
self, user_input: dict[str, Any]
) -> dict[str, Any] | None:
"""Validate the user input."""
token = user_input[CONF_ACCESS_TOKEN]
serial_number = await self._handle_token(token=token)
if serial_number is not None:
await self.async_set_unique_id(serial_number)
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch()
else:
self._abort_if_unique_id_configured()
return {"token": token, "serial_number": serial_number}
return None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
self.errors = {}
if user_input is not None:
raw_token = user_input[CONF_ACCESS_TOKEN]
errors, serial_number = await self._handle_token(token=raw_token)
if not errors and serial_number is not None:
await self.async_set_unique_id(serial_number)
self._abort_if_unique_id_configured()
validated_info = await self._validate_input(user_input)
if validated_info is not None:
return self.async_create_entry(
title=serial_number,
data={CONF_ACCESS_TOKEN: raw_token},
title=validated_info["serial_number"],
data={CONF_ACCESS_TOKEN: validated_info["token"]},
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
errors=self.errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauthentication upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauthentication dialog."""
self.errors = {}
if user_input is not None:
validated_info = await self._validate_input(user_input)
if validated_info is not None:
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates={CONF_ACCESS_TOKEN: validated_info["token"]},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_USER_DATA_SCHEMA,
errors=self.errors,
)

View File

@@ -28,7 +28,7 @@ rules:
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: todo
reauthentication-flow: done
test-coverage: done
# Gold

View File

@@ -1,13 +1,24 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unique_id_mismatch": "Please ensure you reconfigure against the same device."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"malformed_token": "Malformed access token"
},
"step": {
"reauth_confirm": {
"data": {
"access_token": "[%key:common::config_flow::data::access_token%]"
},
"data_description": {
"access_token": "[%key:component::smarla::config::step::user::data_description::access_token%]"
}
},
"user": {
"data": {
"access_token": "[%key:common::config_flow::data::access_token%]"

View File

@@ -585,10 +585,30 @@ def get_media(
item_id = "A:ALBUMARTIST/" + "/".join(item_id.split("/")[2:])
if item_id.startswith("A:ALBUM/") or search_type == "tracks":
search_term = urllib.parse.unquote(item_id.split("/")[-1])
# Some Sonos libraries return album ids in the shape:
# A:ALBUM/<album>/<artist>, where the artist part disambiguates results.
# Use the album segment for searching.
if item_id.startswith("A:ALBUM/"):
splits = item_id.split("/")
search_term = urllib.parse.unquote(splits[1]) if len(splits) > 1 else ""
album_title: str | None = search_term
else:
search_term = urllib.parse.unquote(item_id.split("/")[-1])
album_title = None
matches = media_library.get_music_library_information(
search_type, search_term=search_term, full_album_art_uri=True
)
if item_id.startswith("A:ALBUM/") and len(matches) > 1:
if result := next(
(item for item in matches if item_id == item.item_id), None
):
matches = [result]
elif album_title:
if result := next(
(item for item in matches if album_title == item.title), None
):
matches = [result]
elif search_type == SONOS_SHARE:
# In order to get the MusicServiceItem, we browse the parent folder
# and find one that matches on item_id.

View File

@@ -32,9 +32,9 @@ rules:
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: done

View File

@@ -12,14 +12,7 @@ from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
from PyViCare.PyViCareHeatingDevice import (
HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent,
)
from PyViCare.PyViCareUtils import (
PyViCareDeviceCommunicationError,
PyViCareInternalServerError,
PyViCareInvalidDataError,
PyViCareNotSupportedFeatureError,
PyViCareRateLimitError,
)
import requests
from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -233,18 +226,5 @@ class ViCareBinarySensor(ViCareEntity, BinarySensorEntity):
def update(self) -> None:
"""Update state of sensor."""
try:
with suppress(PyViCareNotSupportedFeatureError):
self._attr_is_on = self.entity_description.value_getter(self._api)
except requests.exceptions.ConnectionError:
_LOGGER.error("Unable to retrieve data from ViCare server")
except ValueError:
_LOGGER.error("Unable to decode data from ViCare server")
except PyViCareRateLimitError as limit_exception:
_LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception)
except PyViCareInvalidDataError as invalid_data_exception:
_LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception)
except PyViCareDeviceCommunicationError as comm_exception:
_LOGGER.warning("Device communication error: %s", comm_exception)
except PyViCareInternalServerError as server_exception:
_LOGGER.warning("Vicare server error: %s", server_exception)
with self.vicare_api_handler(), suppress(PyViCareNotSupportedFeatureError):
self._attr_is_on = self.entity_description.value_getter(self._api)

View File

@@ -8,14 +8,7 @@ import logging
from PyViCare.PyViCareDevice import Device as PyViCareDevice
from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
from PyViCare.PyViCareUtils import (
PyViCareDeviceCommunicationError,
PyViCareInternalServerError,
PyViCareInvalidDataError,
PyViCareNotSupportedFeatureError,
PyViCareRateLimitError,
)
import requests
from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory
@@ -104,18 +97,5 @@ class ViCareButton(ViCareEntity, ButtonEntity):
def press(self) -> None:
"""Handle the button press."""
try:
with suppress(PyViCareNotSupportedFeatureError):
self.entity_description.value_setter(self._api)
except requests.exceptions.ConnectionError:
_LOGGER.error("Unable to retrieve data from ViCare server")
except ValueError:
_LOGGER.error("Unable to decode data from ViCare server")
except PyViCareRateLimitError as limit_exception:
_LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception)
except PyViCareInvalidDataError as invalid_data_exception:
_LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception)
except PyViCareDeviceCommunicationError as comm_exception:
_LOGGER.warning("Device communication error: %s", comm_exception)
except PyViCareInternalServerError as server_exception:
_LOGGER.warning("Vicare server error: %s", server_exception)
with self.vicare_api_handler(), suppress(PyViCareNotSupportedFeatureError):
self.entity_description.value_setter(self._api)

View File

@@ -11,13 +11,8 @@ from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
from PyViCare.PyViCareHeatingDevice import HeatingCircuit as PyViCareHeatingCircuit
from PyViCare.PyViCareUtils import (
PyViCareCommandError,
PyViCareDeviceCommunicationError,
PyViCareInternalServerError,
PyViCareInvalidDataError,
PyViCareNotSupportedFeatureError,
PyViCareRateLimitError,
)
import requests
import voluptuous as vol
from homeassistant.components.climate import (
@@ -160,7 +155,7 @@ class ViCareClimate(ViCareEntity, ClimateEntity):
def update(self) -> None:
"""Let HA know there has been an update from the ViCare API."""
try:
with self.vicare_api_handler():
_room_temperature = None
with suppress(PyViCareNotSupportedFeatureError):
self._attributes["room_temperature"] = _room_temperature = (
@@ -216,19 +211,6 @@ class ViCareClimate(ViCareEntity, ClimateEntity):
self._current_action or compressor.getActive()
)
except requests.exceptions.ConnectionError:
_LOGGER.error("Unable to retrieve data from ViCare server")
except PyViCareRateLimitError as limit_exception:
_LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception)
except ValueError:
_LOGGER.error("Unable to decode data from ViCare server")
except PyViCareInvalidDataError as invalid_data_exception:
_LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception)
except PyViCareDeviceCommunicationError as comm_exception:
_LOGGER.warning("Device communication error: %s", comm_exception)
except PyViCareInternalServerError as server_exception:
_LOGGER.warning("Vicare server error: %s", server_exception)
@property
def hvac_mode(self) -> HVACMode | None:
"""Return current hvac mode."""

View File

@@ -1,22 +1,53 @@
"""Entities for the ViCare integration."""
from collections.abc import Generator
from contextlib import contextmanager
import logging
from PyViCare.PyViCareDevice import Device as PyViCareDevice
from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
from PyViCare.PyViCareHeatingDevice import (
HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent,
)
from PyViCare.PyViCareUtils import (
PyViCareDeviceCommunicationError,
PyViCareInternalServerError,
PyViCareInvalidDataError,
PyViCareRateLimitError,
)
from requests.exceptions import ConnectionError as RequestConnectionError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DOMAIN, VIESSMANN_DEVELOPER_PORTAL
_LOGGER = logging.getLogger(__name__)
class ViCareEntity(Entity):
"""Base class for ViCare entities."""
_attr_has_entity_name = True
@contextmanager
def vicare_api_handler(self) -> Generator[None]:
"""Handle common ViCare API errors."""
try:
yield
except RequestConnectionError:
_LOGGER.error("Unable to retrieve data from ViCare server")
except ValueError:
_LOGGER.error("Unable to decode data from ViCare server")
except PyViCareRateLimitError as err:
_LOGGER.error("ViCare API rate limit exceeded: %s", err)
except PyViCareInvalidDataError as err:
_LOGGER.error("Invalid data from ViCare server: %s", err)
except PyViCareDeviceCommunicationError as err:
_LOGGER.warning("Device communication error: %s", err)
except PyViCareInternalServerError as err:
_LOGGER.warning("ViCare server error: %s", err)
def __init__(
self,
unique_id_suffix: str,

View File

@@ -9,14 +9,7 @@ from typing import Any
from PyViCare.PyViCareDevice import Device as PyViCareDevice
from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
from PyViCare.PyViCareUtils import (
PyViCareDeviceCommunicationError,
PyViCareInternalServerError,
PyViCareInvalidDataError,
PyViCareNotSupportedFeatureError,
PyViCareRateLimitError,
)
from requests.exceptions import ConnectionError as RequestConnectionError
from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.core import HomeAssistant
@@ -173,7 +166,7 @@ class ViCareFan(ViCareEntity, FanEntity):
def update(self) -> None:
"""Update state of fan."""
level: str | None = None
try:
with self.vicare_api_handler():
with suppress(PyViCareNotSupportedFeatureError):
self._attr_preset_mode = VentilationMode.from_vicare_mode(
self._api.getActiveVentilationMode()
@@ -187,18 +180,6 @@ class ViCareFan(ViCareEntity, FanEntity):
)
else:
self._attr_percentage = 0
except RequestConnectionError:
_LOGGER.error("Unable to retrieve data from ViCare server")
except ValueError:
_LOGGER.error("Unable to decode data from ViCare server")
except PyViCareRateLimitError as limit_exception:
_LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception)
except PyViCareInvalidDataError as invalid_data_exception:
_LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception)
except PyViCareDeviceCommunicationError as comm_exception:
_LOGGER.warning("Device communication error: %s", comm_exception)
except PyViCareInternalServerError as server_exception:
_LOGGER.warning("Vicare server error: %s", server_exception)
@property
def is_on(self) -> bool | None:

View File

@@ -13,14 +13,7 @@ from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
from PyViCare.PyViCareHeatingDevice import (
HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent,
)
from PyViCare.PyViCareUtils import (
PyViCareDeviceCommunicationError,
PyViCareInternalServerError,
PyViCareInvalidDataError,
PyViCareNotSupportedFeatureError,
PyViCareRateLimitError,
)
from requests.exceptions import ConnectionError as RequestConnectionError
from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError
from homeassistant.components.number import (
NumberDeviceClass,
@@ -437,38 +430,23 @@ class ViCareNumber(ViCareEntity, NumberEntity):
def update(self) -> None:
"""Update state of number."""
try:
with suppress(PyViCareNotSupportedFeatureError):
self._attr_native_value = self.entity_description.value_getter(
self._api
)
with self.vicare_api_handler(), suppress(PyViCareNotSupportedFeatureError):
self._attr_native_value = self.entity_description.value_getter(self._api)
if min_value := _get_value(
self.entity_description.min_value_getter, self._api
):
self._attr_native_min_value = min_value
if min_value := _get_value(
self.entity_description.min_value_getter, self._api
):
self._attr_native_min_value = min_value
if max_value := _get_value(
self.entity_description.max_value_getter, self._api
):
self._attr_native_max_value = max_value
if max_value := _get_value(
self.entity_description.max_value_getter, self._api
):
self._attr_native_max_value = max_value
if stepping_value := _get_value(
self.entity_description.stepping_getter, self._api
):
self._attr_native_step = stepping_value
except RequestConnectionError:
_LOGGER.error("Unable to retrieve data from ViCare server")
except ValueError:
_LOGGER.error("Unable to decode data from ViCare server")
except PyViCareRateLimitError as limit_exception:
_LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception)
except PyViCareInvalidDataError as invalid_data_exception:
_LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception)
except PyViCareDeviceCommunicationError as comm_exception:
_LOGGER.warning("Device communication error: %s", comm_exception)
except PyViCareInternalServerError as server_exception:
_LOGGER.warning("Vicare server error: %s", server_exception)
if stepping_value := _get_value(
self.entity_description.stepping_getter, self._api
):
self._attr_native_step = stepping_value
def _get_value(

View File

@@ -12,14 +12,7 @@ from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
from PyViCare.PyViCareHeatingDevice import (
HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent,
)
from PyViCare.PyViCareUtils import (
PyViCareDeviceCommunicationError,
PyViCareInternalServerError,
PyViCareInvalidDataError,
PyViCareNotSupportedFeatureError,
PyViCareRateLimitError,
)
import requests
from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -1556,26 +1549,11 @@ class ViCareSensor(ViCareEntity, SensorEntity):
def update(self) -> None:
"""Update state of sensor."""
vicare_unit = None
try:
with suppress(PyViCareNotSupportedFeatureError):
self._attr_native_value = self.entity_description.value_getter(
self._api
)
with self.vicare_api_handler(), suppress(PyViCareNotSupportedFeatureError):
self._attr_native_value = self.entity_description.value_getter(self._api)
if self.entity_description.unit_getter:
vicare_unit = self.entity_description.unit_getter(self._api)
except requests.exceptions.ConnectionError:
_LOGGER.error("Unable to retrieve data from ViCare server")
except ValueError:
_LOGGER.error("Unable to decode data from ViCare server")
except PyViCareRateLimitError as limit_exception:
_LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception)
except PyViCareInvalidDataError as invalid_data_exception:
_LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception)
except PyViCareDeviceCommunicationError as comm_exception:
_LOGGER.warning("Device communication error: %s", comm_exception)
except PyViCareInternalServerError as server_exception:
_LOGGER.warning("Vicare server error: %s", server_exception)
if self.entity_description.unit_getter:
vicare_unit = self.entity_description.unit_getter(self._api)
if vicare_unit is not None:
if (

View File

@@ -9,14 +9,7 @@ from typing import Any
from PyViCare.PyViCareDevice import Device as PyViCareDevice
from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
from PyViCare.PyViCareHeatingDevice import HeatingCircuit as PyViCareHeatingCircuit
from PyViCare.PyViCareUtils import (
PyViCareDeviceCommunicationError,
PyViCareInternalServerError,
PyViCareInvalidDataError,
PyViCareNotSupportedFeatureError,
PyViCareRateLimitError,
)
import requests
from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError
from homeassistant.components.water_heater import (
WaterHeaterEntity,
@@ -120,7 +113,7 @@ class ViCareWater(ViCareEntity, WaterHeaterEntity):
def update(self) -> None:
"""Let HA know there has been an update from the ViCare API."""
try:
with self.vicare_api_handler():
with suppress(PyViCareNotSupportedFeatureError):
self._attr_current_temperature = (
self._api.getDomesticHotWaterStorageTemperature()
@@ -137,19 +130,6 @@ class ViCareWater(ViCareEntity, WaterHeaterEntity):
with suppress(PyViCareNotSupportedFeatureError):
self._dhw_active = self._api.getDomesticHotWaterActive()
except requests.exceptions.ConnectionError:
_LOGGER.error("Unable to retrieve data from ViCare server")
except PyViCareRateLimitError as limit_exception:
_LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception)
except ValueError:
_LOGGER.error("Unable to decode data from ViCare server")
except PyViCareInvalidDataError as invalid_data_exception:
_LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception)
except PyViCareDeviceCommunicationError as comm_exception:
_LOGGER.warning("Device communication error: %s", comm_exception)
except PyViCareInternalServerError as server_exception:
_LOGGER.warning("Vicare server error: %s", server_exception)
def set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperatures."""
if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None:

View File

@@ -28,7 +28,7 @@ DEFAULT_TIMEOUT = 5
PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_PIN): vol.All(vol.Coerce(str), vol.Match(r"\d{4}")),
vol.Required(CONF_PIN): cv.string,
vol.Optional(CONF_ALLOW_UNREACHABLE, default=True): cv.boolean,
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
}

View File

@@ -905,7 +905,7 @@
"iot_class": "local_polling"
},
"bsblan": {
"name": "BSB-Lan",
"name": "BSB-LAN",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"

View File

@@ -15,7 +15,7 @@ import base64
from collections.abc import Awaitable, Callable
import hashlib
from http import HTTPStatus
from json import JSONDecodeError
import json
import logging
import secrets
import time
@@ -248,19 +248,24 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation):
try:
resp = await session.post(self.token_url, data=data)
if resp.status >= 400:
error_body = ""
try:
error_response = await resp.json()
except ClientError, JSONDecodeError:
error_response = {}
error_code = error_response.get("error", "unknown")
error_description = error_response.get(
"error_description", "unknown error"
)
_LOGGER.error(
error_body = await resp.text()
error_data = json.loads(error_body)
error_code = error_data.get("error", "unknown error")
error_description = error_data.get("error_description")
detail = (
f"{error_code}: {error_description}"
if error_description
else error_code
)
except ClientError, ValueError, AttributeError:
detail = error_body[:200] if error_body else "unknown error"
_LOGGER.debug(
"Token request for %s failed (%s): %s",
self.domain,
error_code,
error_description,
resp.status,
detail,
)
resp.raise_for_status()
except ClientResponseError as err:

4
requirements_all.txt generated
View File

@@ -1301,7 +1301,7 @@ ihcsdk==2.8.5
imeon_inverter_api==0.4.0
# homeassistant.components.imgw_pib
imgw_pib==2.0.1
imgw_pib==2.0.2
# homeassistant.components.incomfort
incomfort-client==0.6.12
@@ -2046,7 +2046,7 @@ pyebox==1.1.4
pyecoforest==0.4.0
# homeassistant.components.econet
pyeconet==0.1.28
pyeconet==0.2.1
# homeassistant.components.ista_ecotrend
pyecotrend-ista==3.4.0

View File

@@ -1150,7 +1150,7 @@ igloohome-api==0.1.1
imeon_inverter_api==0.4.0
# homeassistant.components.imgw_pib
imgw_pib==2.0.1
imgw_pib==2.0.2
# homeassistant.components.incomfort
incomfort-client==0.6.12
@@ -1750,7 +1750,7 @@ pydroplet==2.3.4
pyecoforest==0.4.0
# homeassistant.components.econet
pyeconet==0.1.28
pyeconet==0.2.1
# homeassistant.components.ista_ecotrend
pyecotrend-ista==3.4.0

View File

@@ -1132,7 +1132,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"anel_pwrctrl",
"anova",
"anthemav",
"anthropic",
"aosmith",
"apache_kafka",
"apple_tv",

View File

@@ -0,0 +1,50 @@
# serializer version: 1
# name: test_button_entity_properties[button.bsb_lan_sync_time-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'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.CONFIG: 'config'>,
'entity_id': 'button.bsb_lan_sync_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Sync time',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Sync time',
'platform': 'bsblan',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'sync_time',
'unique_id': '00:80:41:19:69:90-sync_time',
'unit_of_measurement': None,
})
# ---
# name: test_button_entity_properties[button.bsb_lan_sync_time-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'BSB-LAN Sync time',
}),
'context': <ANY>,
'entity_id': 'button.bsb_lan_sync_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

@@ -0,0 +1,140 @@
"""Tests for the BSB-Lan button platform."""
from unittest.mock import MagicMock
from bsblan import BSBLANError, DeviceTime
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util
from . import setup_with_selected_platforms
from tests.common import MockConfigEntry, snapshot_platform
ENTITY_SYNC_TIME = "button.bsb_lan_sync_time"
async def test_button_entity_properties(
hass: HomeAssistant,
mock_bsblan: MagicMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
) -> None:
"""Test the button entity properties."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.BUTTON])
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_button_press_syncs_time(
hass: HomeAssistant,
mock_bsblan: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test pressing the sync time button syncs the device time."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.BUTTON])
# Mock device time that differs from HA time
mock_bsblan.time.return_value = DeviceTime.from_json(
'{"time": {"name": "Time", "value": "01.01.2020 00:00:00", "unit": "", "desc": "", "dataType": 0, "readonly": 0, "error": 0}}'
)
# Press the button
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: ENTITY_SYNC_TIME},
blocking=True,
)
# Verify time() was called to check current device time
assert mock_bsblan.time.called
# Verify set_time() was called with current HA time
current_time_str = dt_util.now().strftime("%d.%m.%Y %H:%M:%S")
mock_bsblan.set_time.assert_called_once_with(current_time_str)
async def test_button_press_no_update_when_same(
hass: HomeAssistant,
mock_bsblan: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test button press doesn't update when time matches."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.BUTTON])
# Mock device time that matches HA time
current_time_str = dt_util.now().strftime("%d.%m.%Y %H:%M:%S")
mock_bsblan.time.return_value = DeviceTime.from_json(
f'{{"time": {{"name": "Time", "value": "{current_time_str}", "unit": "", "desc": "", "dataType": 0, "readonly": 0, "error": 0}}}}'
)
# Press the button
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: ENTITY_SYNC_TIME},
blocking=True,
)
# Verify time() was called
assert mock_bsblan.time.called
# Verify set_time() was NOT called since times match
assert not mock_bsblan.set_time.called
async def test_button_press_error_handling(
hass: HomeAssistant,
mock_bsblan: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test button press handles errors gracefully."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.BUTTON])
# Mock time() to raise an error
mock_bsblan.time.side_effect = BSBLANError("Connection failed")
# Press the button - should raise HomeAssistantError
with pytest.raises(HomeAssistantError, match="Failed to sync time"):
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: ENTITY_SYNC_TIME},
blocking=True,
)
async def test_button_press_set_time_error(
hass: HomeAssistant,
mock_bsblan: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test button press handles set_time errors."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.BUTTON])
# Mock device time that differs
mock_bsblan.time.return_value = DeviceTime.from_json(
'{"time": {"name": "Time", "value": "01.01.2020 00:00:00", "unit": "", "desc": "", "dataType": 0, "readonly": 0, "error": 0}}'
)
# Mock set_time() to raise an error
mock_bsblan.set_time.side_effect = BSBLANError("Write failed")
# Press the button - should raise HomeAssistantError
with pytest.raises(HomeAssistantError, match="Failed to sync time"):
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: ENTITY_SYNC_TIME},
blocking=True,
)

View File

@@ -1,4 +1,4 @@
"""Tests for the BSB-Lan climate platform."""
"""Tests for the BSB-LAN climate platform."""
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock
@@ -69,7 +69,7 @@ async def test_climate_entity_properties(
state = hass.states.get(ENTITY_ID)
assert state.attributes["temperature"] == 23.5
# Test hvac_mode - BSB-Lan returns integer: 1=auto
# Test hvac_mode - BSB-LAN returns integer: 1=auto
mock_hvac_mode = MagicMock()
mock_hvac_mode.value = 1 # auto mode
mock_bsblan.state.return_value.hvac_mode = mock_hvac_mode
@@ -81,7 +81,7 @@ async def test_climate_entity_properties(
state = hass.states.get(ENTITY_ID)
assert state.state == HVACMode.AUTO
# Test preset_mode - BSB-Lan mode 2 is eco/reduced
# Test preset_mode - BSB-LAN mode 2 is eco/reduced
mock_hvac_mode.value = 2 # eco mode
freezer.tick(timedelta(minutes=1))
@@ -278,7 +278,7 @@ async def test_async_set_preset_mode_success(
"""Test setting preset mode via service call."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
# patch hvac_mode with integer value (BSB-Lan returns integers)
# patch hvac_mode with integer value (BSB-LAN returns integers)
mock_hvac_mode = MagicMock()
mock_hvac_mode.value = hvac_mode_int
mock_bsblan.state.return_value.hvac_mode = mock_hvac_mode

View File

@@ -1,4 +1,4 @@
"""Tests for the BSB-Lan sensor platform."""
"""Tests for the BSB-LAN sensor platform."""
from unittest.mock import AsyncMock

View File

@@ -1,4 +1,4 @@
"""Tests for the BSB-Lan water heater platform."""
"""Tests for the BSB-LAN water heater platform."""
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock

View File

@@ -53,6 +53,8 @@ def mock_connector():
MagicMock(
code="__trybpracy", value="de_icing"
), # parameter not relevant for this device, should be ignored
MagicMock(code="__t_ext", value=15.5),
MagicMock(code="__rr_temp_wyli_bufo", value=22.0),
MagicMock(code="__temp_zada_prac_cwu", value=55.0), # DHW Target Temperature
MagicMock(code="__rr_temp_zmier_cwu", value=50.0), # DHW Current Temperature
MagicMock(code="__tryb_cwu", value="on"), # DHW On/Off
@@ -63,6 +65,9 @@ def mock_connector():
mock_device_2.state.params = [
MagicMock(code="_jezyk", value="english"),
MagicMock(code="__aerokonfbypass", value="off"),
MagicMock(code="__rd_co2", value="normal"),
MagicMock(code="__rd_pm10", value="warning"),
MagicMock(code="__rr_wietrzenie", value="on"),
MagicMock(code="__tempzadkomf", value=21), # Target temperature comfort
MagicMock(code="__tempzadekozima", value=20), # Target temperature eco winter
MagicMock(

View File

@@ -0,0 +1,101 @@
# serializer version: 1
# name: test_binary_sensor_entities_snapshot[binary_sensor.nano_color_2_airing-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'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.nano_color_2_airing',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Airing',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.WINDOW: 'window'>,
'original_icon': None,
'original_name': 'Airing',
'platform': 'compit',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'airing',
'unique_id': '2_AIRING',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensor_entities_snapshot[binary_sensor.nano_color_2_airing-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'window',
'friendly_name': 'Nano Color 2 Airing',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.nano_color_2_airing',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_binary_sensor_entities_snapshot[binary_sensor.nano_color_2_co2_level-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'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.nano_color_2_co2_level',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'CO2 level',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.PROBLEM: 'problem'>,
'original_icon': None,
'original_name': 'CO2 level',
'platform': 'compit',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'co2_level',
'unique_id': '2_CO2_LEVEL',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensor_entities_snapshot[binary_sensor.nano_color_2_co2_level-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'problem',
'friendly_name': 'Nano Color 2 CO2 level',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.nano_color_2_co2_level',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---

View File

@@ -0,0 +1,75 @@
"""Tests for the Compit binary sensor platform."""
from typing import Any
from unittest.mock import MagicMock
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration, snapshot_compit_entities
from tests.common import MockConfigEntry
async def test_binary_sensor_entities_snapshot(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
mock_connector: MagicMock,
snapshot: SnapshotAssertion,
) -> None:
"""Snapshot test for binary sensor entities creation, unique IDs, and device info."""
await setup_integration(hass, mock_config_entry)
snapshot_compit_entities(hass, entity_registry, snapshot, Platform.BINARY_SENSOR)
@pytest.mark.parametrize(
("mock_return_value", "expected_state"),
[
(None, "unknown"),
("on", "on"),
("off", "off"),
("yes", "on"),
("no", "off"),
("charging", "on"),
("not_charging", "off"),
("alert", "on"),
("no_alert", "off"),
],
)
async def test_binary_sensor_return_value(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_connector: MagicMock,
mock_return_value: Any | None,
expected_state: str,
) -> None:
"""Test that binary sensor entity shows correct state for various values."""
mock_connector.get_current_value.side_effect = lambda device_id, parameter_code: (
mock_return_value
)
await setup_integration(hass, mock_config_entry)
state = hass.states.get("binary_sensor.nano_color_2_airing")
assert state.state == expected_state
async def test_binary_sensor_no_sensor(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
mock_connector: MagicMock,
) -> None:
"""Test that binary sensor entities with NO_SENSOR value are not created."""
mock_connector.get_current_value.side_effect = lambda device_id, parameter_code: (
"no_sensor"
)
await setup_integration(hass, mock_config_entry)
# Check that airing sensor is not created
airing_entity = entity_registry.async_get("binary_sensor.nano_color_2_airing")
assert airing_entity is None

View File

@@ -0,0 +1,29 @@
{
"accessPointId": "3014F7110000000000000001",
"home": {
"id": "a1b2c3d4-e5f6-1234-abcd-ef0123456789",
"location": {
"city": "Berlin, Germany",
"latitude": "52.520008",
"longitude": "13.404954"
},
"weather": {
"temperature": 18.3
}
},
"devices": {
"3014F7110000000000000002": {
"id": "3014F7110000000000000002",
"label": "Living Room Thermostat",
"type": "WALL_MOUNTED_THERMOSTAT_PRO",
"serializedGlobalTradeItemNumber": "ABCDEFGHIJKLMNOPQRSTUVWX"
}
},
"clients": {
"a1b2c3d4-e5f6-1234-abcd-ef0123456789": {
"id": "a1b2c3d4-e5f6-1234-abcd-ef0123456789",
"label": "Home Assistant",
"refreshToken": "secret-refresh-token"
}
}
}

View File

@@ -0,0 +1,32 @@
# serializer version: 1
# name: test_diagnostics
dict({
'accessPointId': '3014F7110000000000000000',
'clients': dict({
'00000000-0000-0000-0000-000000000000': dict({
'id': '00000000-0000-0000-0000-000000000000',
'label': 'Home Assistant',
'refreshToken': None,
}),
}),
'devices': dict({
'3014F7110000000000000001': dict({
'id': '3014F7110000000000000001',
'label': 'Living Room Thermostat',
'serializedGlobalTradeItemNumber': '3014F7110000000000000002',
'type': 'WALL_MOUNTED_THERMOSTAT_PRO',
}),
}),
'home': dict({
'id': '00000000-0000-0000-0000-000000000000',
'location': dict({
'city': '**REDACTED**',
'latitude': '**REDACTED**',
'longitude': '**REDACTED**',
}),
'weather': dict({
'temperature': 18.3,
}),
}),
})
# ---

View File

@@ -0,0 +1,28 @@
"""Tests for HomematicIP Cloud diagnostics."""
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.homematicip_cloud.const import DOMAIN
from homeassistant.core import HomeAssistant
from tests.common import async_load_json_object_fixture
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
async def test_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_hap_with_service,
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics for config entry."""
mock_hap_with_service.home.download_configuration_async.return_value = (
await async_load_json_object_fixture(hass, "diagnostics.json", DOMAIN)
)
entry = hass.config_entries.async_entries(DOMAIN)[0]
result = await get_diagnostics_for_config_entry(hass, hass_client, entry)
assert result == snapshot

View File

@@ -87,6 +87,8 @@ def mock_homevolt_client() -> Generator[MagicMock]:
client.local_mode_enabled = False
client.enable_local_mode = AsyncMock()
client.disable_local_mode = AsyncMock()
# SELECT platform ability to change schedule type
client.set_schedule_type = AsyncMock()
yield client

View File

@@ -0,0 +1,82 @@
"""Tests for the Homevolt SELECT platform."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from homeassistant.components.homevolt.const import DOMAIN
from homeassistant.components.select import DOMAIN as SELECT_DOMAIN
from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, Platform
from homeassistant.core import HomeAssistant
# entity_registry is not required for these tests
from tests.common import MockConfigEntry
@pytest.fixture
def platforms_select() -> list[Platform]:
"""Return platforms including SELECT for this test."""
# Sensor is required for the coordinator; add SELECT as well.
return [Platform.SENSOR, Platform.SELECT]
async def test_select_entity_created(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_homevolt_client,
platforms_select: list[Platform],
) -> None:
"""The select entity should be created with correct options and state."""
# Initialise integration with SELECT platform enabled.
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.homevolt.PLATFORMS", platforms_select):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entity_id = f"select.{DOMAIN}_schedule_type"
state = hass.states.get(entity_id)
assert state is not None
# The fixture schedule type is 1 → "grid_charge"
assert state.state == "grid_charge"
# Expect all defined schedule types to be present.
expected_options = {
"idle",
"grid_charge",
"grid_discharge",
"solar_charge",
"solar_discharge",
}
assert set(state.attributes["options"]) == expected_options
async def test_select_option_changes(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_homevolt_client,
platforms_select: list[Platform],
) -> None:
"""Selecting a new option calls the client and triggers a refresh."""
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.homevolt.PLATFORMS", platforms_select):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entity_id = f"select.{DOMAIN}_schedule_type"
# Change to a different schedule type.
await hass.services.async_call(
SELECT_DOMAIN,
"select_option",
{ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "solar_charge"},
blocking=True,
)
# Verify the client method was called with the correct enum value (3).
mock_homevolt_client.set_schedule_type.assert_awaited_once_with(3)
# The coordinator should have refreshed the data.
assert mock_homevolt_client.update_info.called

View File

@@ -9,6 +9,7 @@
'no_alert',
'hydrological_drought',
'rapid_water_level_rise',
'exceeding_the_alarm_level',
'exceeding_the_warning_level',
]),
}),
@@ -53,6 +54,7 @@
'no_alert',
'hydrological_drought',
'rapid_water_level_rise',
'exceeding_the_alarm_level',
'exceeding_the_warning_level',
]),
'probability': 80,

View File

@@ -775,3 +775,199 @@ async def test_reauth_flow_unique_id_mismatch(
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unique_id_mismatch"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_reconfigure_flow(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_nrgkick_api: AsyncMock,
) -> None:
"""Test reconfiguration flow."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: ""}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
assert result["errors"] == {"base": "cannot_connect"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "192.168.1.200"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert mock_config_entry.data[CONF_HOST] == "192.168.1.200"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_reconfigure_flow_with_credentials(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_nrgkick_api: AsyncMock,
) -> None:
"""Test reconfiguration flow when authentication is required."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
mock_nrgkick_api.test_connection.side_effect = NRGkickAuthenticationError
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "192.168.1.200"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure_auth"
mock_nrgkick_api.test_connection.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_USERNAME: "new_user", CONF_PASSWORD: "new_pass"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert mock_config_entry.data[CONF_HOST] == "192.168.1.200"
assert mock_config_entry.data[CONF_USERNAME] == "new_user"
assert mock_config_entry.data[CONF_PASSWORD] == "new_pass"
@pytest.mark.parametrize(
("exception", "error"),
[
(NRGkickAPIDisabledError, "json_api_disabled"),
(NRGkickApiClientInvalidResponseError, "invalid_response"),
(NRGkickConnectionError, "cannot_connect"),
(NRGkickApiClientError, "unknown"),
],
)
@pytest.mark.usefixtures("mock_setup_entry")
async def test_reconfigure_flow_errors(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_nrgkick_api: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test reconfiguration flow errors and recovery."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
mock_nrgkick_api.test_connection.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "192.168.1.200"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
assert result["errors"] == {"base": error}
mock_nrgkick_api.test_connection.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "192.168.1.200"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
@pytest.mark.parametrize(
("exception", "error"),
[
(NRGkickAPIDisabledError, "json_api_disabled"),
(NRGkickAuthenticationError, "invalid_auth"),
(NRGkickApiClientInvalidResponseError, "invalid_response"),
(NRGkickConnectionError, "cannot_connect"),
(NRGkickApiClientError, "unknown"),
],
)
@pytest.mark.usefixtures("mock_setup_entry")
async def test_reconfigure_flow_auth_errors(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_nrgkick_api: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test reconfiguration auth step errors and recovery."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
mock_nrgkick_api.test_connection.side_effect = NRGkickAuthenticationError
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "192.168.1.200"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure_auth"
mock_nrgkick_api.test_connection.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_USERNAME: "user", CONF_PASSWORD: "pass"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure_auth"
assert result["errors"] == {"base": error}
mock_nrgkick_api.test_connection.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_USERNAME: "user", CONF_PASSWORD: "pass"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_reconfigure_flow_unique_id_mismatch(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_nrgkick_api: AsyncMock,
) -> None:
"""Test reconfiguration aborts on unique ID mismatch."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
mock_nrgkick_api.get_info.return_value = {
"general": {"serial_number": "DIFFERENT123", "device_name": "Other"}
}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "192.168.1.200"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unique_id_mismatch"

View File

@@ -12,7 +12,7 @@ import pytest
from homeassistant.components.smarla.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from .const import MOCK_ACCESS_TOKEN_JSON, MOCK_SERIAL_NUMBER, MOCK_USER_INPUT
from .const import MOCK_ACCESS_TOKEN_JSON, MOCK_USER_INPUT
from tests.common import MockConfigEntry
@@ -22,7 +22,7 @@ def mock_config_entry() -> MockConfigEntry:
"""Create a mock config entry."""
return MockConfigEntry(
domain=DOMAIN,
unique_id=MOCK_SERIAL_NUMBER,
unique_id=MOCK_ACCESS_TOKEN_JSON["serialNumber"],
source=SOURCE_USER,
data=MOCK_USER_INPUT,
)
@@ -48,18 +48,24 @@ def mock_connection() -> Generator[MagicMock]:
),
):
connection = mock_connection.return_value
connection.token = AuthToken.from_json(MOCK_ACCESS_TOKEN_JSON)
def mocked_connection(url, token_b64: str):
connection.token = AuthToken.from_base64(token_b64)
return connection
mock_connection.side_effect = mocked_connection
yield connection
@pytest.fixture
def mock_federwiege(mock_connection: MagicMock) -> Generator[MagicMock]:
"""Mock the Federwiege instance."""
def mock_federwiege_cls(mock_connection: MagicMock) -> Generator[MagicMock]:
"""Mock the Federwiege class."""
with patch(
"homeassistant.components.smarla.Federwiege", autospec=True
) as mock_federwiege:
federwiege = mock_federwiege.return_value
federwiege.serial_number = MOCK_SERIAL_NUMBER
) as mock_federwiege_cls:
mock_federwiege = mock_federwiege_cls.return_value
mock_federwiege.serial_number = MOCK_ACCESS_TOKEN_JSON["serialNumber"]
mock_babywiege_service = MagicMock(spec=Service)
mock_babywiege_service.props = {
@@ -83,13 +89,21 @@ def mock_federwiege(mock_connection: MagicMock) -> Generator[MagicMock]:
mock_analyser_service.props["activity"].get.return_value = 0
mock_analyser_service.props["swing_count"].get.return_value = 0
federwiege.services = {
mock_federwiege.services = {
"babywiege": mock_babywiege_service,
"analyser": mock_analyser_service,
}
federwiege.get_property = MagicMock(
side_effect=lambda service, prop: federwiege.services[service].props[prop]
mock_federwiege.get_property = MagicMock(
side_effect=lambda service, prop: mock_federwiege.services[service].props[
prop
]
)
yield federwiege
yield mock_federwiege_cls
@pytest.fixture
def mock_federwiege(mock_federwiege_cls: MagicMock) -> Generator[MagicMock]:
"""Mock the Federwiege instance."""
return mock_federwiege_cls.return_value

View File

@@ -5,16 +5,27 @@ import json
from homeassistant.const import CONF_ACCESS_TOKEN
def _make_mock_user_input(token_json):
access_token = base64.b64encode(json.dumps(token_json).encode()).decode()
return {CONF_ACCESS_TOKEN: access_token}
MOCK_ACCESS_TOKEN_JSON = {
"refreshToken": "test",
"appIdentifier": "HA-test",
"serialNumber": "ABCD",
}
MOCK_USER_INPUT = _make_mock_user_input(MOCK_ACCESS_TOKEN_JSON)
MOCK_SERIAL_NUMBER = MOCK_ACCESS_TOKEN_JSON["serialNumber"]
MOCK_ACCESS_TOKEN_JSON_RECONFIGURE = {
**MOCK_ACCESS_TOKEN_JSON,
"refreshToken": "reconfiguretest",
}
MOCK_USER_INPUT_RECONFIGURE = _make_mock_user_input(MOCK_ACCESS_TOKEN_JSON_RECONFIGURE)
MOCK_ACCESS_TOKEN = base64.b64encode(
json.dumps(MOCK_ACCESS_TOKEN_JSON).encode()
).decode()
MOCK_USER_INPUT = {CONF_ACCESS_TOKEN: MOCK_ACCESS_TOKEN}
MOCK_ACCESS_TOKEN_JSON_MISMATCH = {
**MOCK_ACCESS_TOKEN_JSON_RECONFIGURE,
"serialNumber": "DCBA",
}
MOCK_USER_INPUT_MISMATCH = _make_mock_user_input(MOCK_ACCESS_TOKEN_JSON_MISMATCH)

View File

@@ -2,7 +2,10 @@
from unittest.mock import MagicMock, patch
from pysmarlaapi.connection.exceptions import AuthenticationException
from pysmarlaapi.connection.exceptions import (
AuthenticationException,
ConnectionException,
)
import pytest
from homeassistant.components.smarla.const import DOMAIN
@@ -10,7 +13,12 @@ from homeassistant.config_entries import SOURCE_USER
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .const import MOCK_SERIAL_NUMBER, MOCK_USER_INPUT
from .const import (
MOCK_ACCESS_TOKEN_JSON,
MOCK_USER_INPUT,
MOCK_USER_INPUT_MISMATCH,
MOCK_USER_INPUT_RECONFIGURE,
)
from tests.common import MockConfigEntry
@@ -32,9 +40,9 @@ async def test_config_flow(hass: HomeAssistant) -> None:
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == MOCK_SERIAL_NUMBER
assert result["title"] == MOCK_ACCESS_TOKEN_JSON["serialNumber"]
assert result["data"] == MOCK_USER_INPUT
assert result["result"].unique_id == MOCK_SERIAL_NUMBER
assert result["result"].unique_id == MOCK_ACCESS_TOKEN_JSON["serialNumber"]
@pytest.mark.usefixtures("mock_setup_entry", "mock_connection")
@@ -61,10 +69,22 @@ async def test_malformed_token(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.CREATE_ENTRY
@pytest.mark.parametrize(
("exception", "error_key"),
[
(AuthenticationException, "invalid_auth"),
(ConnectionException, "cannot_connect"),
],
)
@pytest.mark.usefixtures("mock_setup_entry")
async def test_invalid_auth(hass: HomeAssistant, mock_connection: MagicMock) -> None:
"""Test we show user form on invalid auth."""
mock_connection.refresh_token.side_effect = AuthenticationException
async def test_validation_exception(
hass: HomeAssistant,
mock_connection: MagicMock,
exception: type[Exception],
error_key: str,
) -> None:
"""Test we show user form on validation exception."""
mock_connection.refresh_token.side_effect = exception
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -76,7 +96,7 @@ async def test_invalid_auth(hass: HomeAssistant, mock_connection: MagicMock) ->
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "invalid_auth"}
assert result["errors"] == {"base": error_key}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -102,3 +122,51 @@ async def test_device_exists_abort(
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
@pytest.mark.usefixtures("mock_setup_entry", "mock_connection")
async def test_reauth_successful(
mock_config_entry: MockConfigEntry,
hass: HomeAssistant,
) -> None:
"""Test a successful reauthentication flow."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_USER_INPUT_RECONFIGURE,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data == MOCK_USER_INPUT_RECONFIGURE
@pytest.mark.usefixtures("mock_setup_entry", "mock_connection")
async def test_reauth_mismatch(
mock_config_entry: MockConfigEntry,
hass: HomeAssistant,
) -> None:
"""Test a reauthentication flow with mismatched serial number."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_USER_INPUT_MISMATCH,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unique_id_mismatch"
assert mock_config_entry.data == MOCK_USER_INPUT

View File

@@ -1,8 +1,12 @@
"""Test switch platform for Swing2Sleep Smarla integration."""
from collections.abc import Awaitable, Callable
from unittest.mock import MagicMock
from pysmarlaapi.connection.exceptions import AuthenticationException
from pysmarlaapi.connection.exceptions import (
AuthenticationException,
ConnectionException,
)
import pytest
from homeassistant.config_entries import ConfigEntryState
@@ -13,13 +17,55 @@ from . import setup_integration
from tests.common import MockConfigEntry
@pytest.mark.parametrize(
("exception", "expected_state"),
[
(AuthenticationException, ConfigEntryState.SETUP_ERROR),
(ConnectionException, ConfigEntryState.SETUP_RETRY),
],
)
@pytest.mark.usefixtures("mock_federwiege")
async def test_init_invalid_auth(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_connection: MagicMock
async def test_init_exception(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_connection: MagicMock,
exception: type[Exception],
expected_state: ConfigEntryState,
) -> None:
"""Test init invalid authentication behavior."""
mock_connection.refresh_token.side_effect = AuthenticationException
"""Test init config setup exception."""
mock_connection.refresh_token.side_effect = exception
assert not await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
assert mock_config_entry.state is expected_state
async def test_init_auth_failure_during_runtime(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_federwiege_cls: MagicMock,
) -> None:
"""Test behavior on invalid authentication during runtime."""
invalid_auth_callback: Callable[[], Awaitable[None]] | None = None
def mocked_federwiege(_1, _2, callback):
nonlocal invalid_auth_callback
invalid_auth_callback = callback
return mock_federwiege_cls.return_value
# Mock Federwiege class to gather authentication failure callback
mock_federwiege_cls.side_effect = mocked_federwiege
assert await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
# Check that config entry has no active reauth flows
assert not any(mock_config_entry.async_get_active_flows(hass, {"reauth"}))
# Simulate authentication failure during runtime
assert invalid_auth_callback is not None
await invalid_auth_callback()
await hass.async_block_till_done()
# Check that a reauth flow has been started
assert any(mock_config_entry.async_get_active_flows(hass, {"reauth"}))

View File

@@ -1,6 +1,7 @@
"""Tests for the Sonos Media Browser."""
from functools import partial
from unittest.mock import MagicMock
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -15,6 +16,7 @@ from homeassistant.components.media_player import (
from homeassistant.components.sonos.const import MEDIA_TYPE_DIRECTORY
from homeassistant.components.sonos.media_browser import (
build_item_response,
get_media,
get_thumbnail_url_full,
)
from homeassistant.const import ATTR_ENTITY_ID
@@ -109,6 +111,81 @@ async def test_build_item_response(
)
def test_get_media_multisegment_album_id_uses_album_segment() -> None:
"""Test `A:ALBUM/<album>/<artist>` uses album name as lookup search term."""
music_library = MagicMock()
music_library.get_music_library_information.return_value = []
result = get_media(
music_library,
"A:ALBUM/Abbey%20Road/The%20Beatles",
"album",
)
assert result is None
assert music_library.get_music_library_information.call_count == 1
assert music_library.get_music_library_information.call_args.args == ("albums",)
assert music_library.get_music_library_information.call_args.kwargs == {
"search_term": "Abbey Road",
"full_album_art_uri": True,
}
def test_get_media_multisegment_album_id_prefers_exact_item_id_match() -> None:
"""Test multi-match disambiguation prefers exact `item_id`."""
music_library = MagicMock()
exact_item = MockMusicServiceItem(
"Abbey Road (Remaster)",
"A:ALBUM/Abbey%20Road/The%20Beatles",
"A:ALBUM",
"object.container.album.musicAlbum",
)
music_library.get_music_library_information.return_value = [
MockMusicServiceItem(
"Abbey Road",
"A:ALBUM/Abbey%20Road/Someone%20Else",
"A:ALBUM",
"object.container.album.musicAlbum",
),
exact_item,
]
result = get_media(
music_library,
"A:ALBUM/Abbey%20Road/The%20Beatles",
"album",
)
assert result is exact_item
def test_get_media_multisegment_album_id_falls_back_to_exact_title_match() -> None:
"""Test multi-match disambiguation falls back to exact title match."""
music_library = MagicMock()
title_match_item = MockMusicServiceItem(
"Abbey Road",
"A:ALBUM/Abbey%20Road/The%20Beatles%20(Remaster)",
"A:ALBUM",
"object.container.album.musicAlbum",
)
music_library.get_music_library_information.return_value = [
MockMusicServiceItem(
"Abbey Road (Live)",
"A:ALBUM/Abbey%20Road/The%20Beatles%20(Live)",
"A:ALBUM",
"object.container.album.musicAlbum",
),
title_match_item,
]
result = get_media(
music_library,
"A:ALBUM/Abbey%20Road/The%20Beatles",
"album",
)
assert result is title_match_item
async def test_browse_media_root(
hass: HomeAssistant,
soco_factory: SoCoMockFactory,

View File

@@ -471,35 +471,32 @@ async def test_abort_discovered_multiple(
@pytest.mark.parametrize(
("status_code", "error_body", "error_reason", "error_log"),
("status_code", "error_body", "error_reason", "expected_detail"),
[
(HTTPStatus.UNAUTHORIZED, {}, "oauth_unauthorized", "unknown error"),
(HTTPStatus.NOT_FOUND, {}, "oauth_unauthorized", "unknown error"),
(HTTPStatus.INTERNAL_SERVER_ERROR, {}, "oauth_failed", "unknown error"),
(
HTTPStatus.UNAUTHORIZED,
{},
{"error_description": "The token has expired."},
"oauth_unauthorized",
"Token request for oauth2_test failed (unknown): unknown",
),
(
HTTPStatus.NOT_FOUND,
{},
"oauth_unauthorized",
"Token request for oauth2_test failed (unknown): unknown",
),
(
HTTPStatus.INTERNAL_SERVER_ERROR,
{},
"oauth_failed",
"Token request for oauth2_test failed (unknown): unknown",
"unknown error: The token has expired.",
),
(
HTTPStatus.BAD_REQUEST,
{
"error": "invalid_request",
"error_description": "Request was missing the 'redirect_uri' parameter.",
"error_uri": "See the full API docs at https://authorization-server.com/docs/access_token",
"error_uri": "Sensible URI: https://authorization-server.com/docs/access_token",
},
"oauth_unauthorized",
"Token request for oauth2_test failed (invalid_request): Request was missing the",
"invalid_request: Request was missing the 'redirect_uri' parameter.",
),
(
HTTPStatus.BAD_REQUEST,
"some error which is not formatted",
"oauth_unauthorized",
'"some error which is not formatted"',
),
],
)
@@ -513,7 +510,7 @@ async def test_abort_if_oauth_token_error(
status_code: HTTPStatus,
error_body: dict[str, Any],
error_reason: str,
error_log: str,
expected_detail: str,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Check error when obtaining an oauth token."""
@@ -560,11 +557,15 @@ async def test_abort_if_oauth_token_error(
json=error_body,
)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
with caplog.at_level(logging.DEBUG):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert (
f"Token request for {TEST_DOMAIN} failed ({status_code}): {expected_detail}"
in caplog.text
)
assert result["type"] == data_entry_flow.FlowResultType.ABORT
assert result["reason"] == error_reason
assert error_log in caplog.text
@pytest.mark.usefixtures("current_request_with_host")
@@ -622,7 +623,7 @@ async def test_abort_if_oauth_token_closing_error(
with caplog.at_level(logging.DEBUG):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert "Token request for oauth2_test failed (unknown): unknown" in caplog.text
assert "Token request for oauth2_test failed (401): unknown" in caplog.text
assert result["type"] == data_entry_flow.FlowResultType.ABORT
assert result["reason"] == "oauth_unauthorized"
@@ -1039,7 +1040,7 @@ async def test_oauth_session_refresh_failure_exceptions(
session = config_entry_oauth2_flow.OAuth2Session(hass, config_entry, local_impl)
with (
caplog.at_level(logging.WARNING),
caplog.at_level(logging.DEBUG),
pytest.raises(expected_exception) as err,
):
await session.async_request("post", "https://example.com")