mirror of
https://github.com/home-assistant/core.git
synced 2026-02-28 04:51:41 +01:00
Compare commits
16 Commits
update-bui
...
homvolt_se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2cb3928e9 | ||
|
|
0fcfc3f070 | ||
|
|
413506276c | ||
|
|
4a4e077d40 | ||
|
|
8f824b566e | ||
|
|
610aaa6eee | ||
|
|
ecb7ab238c | ||
|
|
9013b7835e | ||
|
|
5363638c7e | ||
|
|
164b1cbb8c | ||
|
|
b5a55ec032 | ||
|
|
0c6d635e83 | ||
|
|
9259db0b85 | ||
|
|
6f1a021197 | ||
|
|
8dbf7f7ad7 | ||
|
|
3854c8e261 |
579
.github/workflows/builder.yml
vendored
579
.github/workflows/builder.yml
vendored
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
59
homeassistant/components/bsblan/button.py
Normal file
59
homeassistant/components/bsblan/button.py
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Config flow for BSB-Lan integration."""
|
||||
"""Config flow for BSB-LAN integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Constants for the BSB-Lan integration."""
|
||||
"""Constants for the BSB-LAN integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
42
homeassistant/components/bsblan/helpers.py
Normal file
42
homeassistant/components/bsblan/helpers.py
Normal 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
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"entity": {
|
||||
"button": {
|
||||
"sync_time": {
|
||||
"default": "mdi:timer-sync-outline"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"set_hot_water_schedule": {
|
||||
"service": "mdi:calendar-clock"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
189
homeassistant/components/compit/binary_sensor.py
Normal file
189
homeassistant/components/compit/binary_sensor.py
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -28,6 +28,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.WATER_HEATER,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
53
homeassistant/components/econet/select.py
Normal file
53
homeassistant/components/econet/select.py
Normal 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))
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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]:
|
||||
|
||||
27
homeassistant/components/homematicip_cloud/diagnostics.py
Normal file
27
homeassistant/components/homematicip_cloud/diagnostics.py
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
132
homeassistant/components/homevolt/number.py
Normal file
132
homeassistant/components/homevolt/number.py
Normal 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()
|
||||
51
homeassistant/components/homevolt/select.py
Normal file
51
homeassistant/components/homevolt/select.py
Normal 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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%]"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%]"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
4
requirements_all.txt
generated
@@ -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
|
||||
|
||||
4
requirements_test_all.txt
generated
4
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
@@ -1132,7 +1132,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
|
||||
"anel_pwrctrl",
|
||||
"anova",
|
||||
"anthemav",
|
||||
"anthropic",
|
||||
"aosmith",
|
||||
"apache_kafka",
|
||||
"apple_tv",
|
||||
|
||||
50
tests/components/bsblan/snapshots/test_button.ambr
Normal file
50
tests/components/bsblan/snapshots/test_button.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
||||
140
tests/components/bsblan/test_button.py
Normal file
140
tests/components/bsblan/test_button.py
Normal 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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for the BSB-Lan sensor platform."""
|
||||
"""Tests for the BSB-LAN sensor platform."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
101
tests/components/compit/snapshots/test_binary_sensor.ambr
Normal file
101
tests/components/compit/snapshots/test_binary_sensor.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
||||
75
tests/components/compit/test_binary_sensor.py
Normal file
75
tests/components/compit/test_binary_sensor.py
Normal 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
|
||||
29
tests/components/homematicip_cloud/fixtures/diagnostics.json
Normal file
29
tests/components/homematicip_cloud/fixtures/diagnostics.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
28
tests/components/homematicip_cloud/test_diagnostics.py
Normal file
28
tests/components/homematicip_cloud/test_diagnostics.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
82
tests/components/homevolt/test_select.py
Normal file
82
tests/components/homevolt/test_select.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user