mirror of
https://github.com/home-assistant/core.git
synced 2026-03-18 08:52:03 +01:00
Compare commits
37 Commits
gha-builde
...
PIRUnoccup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1824ef12bb | ||
|
|
c706e8a5b8 | ||
|
|
5bd9742eb3 | ||
|
|
26f3eb5f6d | ||
|
|
7a34d4f881 | ||
|
|
e0a37a5eeb | ||
|
|
ec3d1fd72c | ||
|
|
4edea21cb7 | ||
|
|
7f065c1942 | ||
|
|
46ce07a9a1 | ||
|
|
5807db2c60 | ||
|
|
85732543b2 | ||
|
|
054c61d73f | ||
|
|
be2c20c624 | ||
|
|
706127c9ea | ||
|
|
b163829970 | ||
|
|
7a93eb779c | ||
|
|
7d673cd9c4 | ||
|
|
44bc11580d | ||
|
|
c23795fe14 | ||
|
|
bf6f9a011b | ||
|
|
1cdbe596fe | ||
|
|
a9d52bfbe7 | ||
|
|
6eed1f9961 | ||
|
|
149607ab17 | ||
|
|
279b5be357 | ||
|
|
82b93e788b | ||
|
|
555813f84f | ||
|
|
ecf1b4e591 | ||
|
|
e17a9f12a1 | ||
|
|
e8f05f5291 | ||
|
|
a5a76e9268 | ||
|
|
edc3fb47b2 | ||
|
|
f1e514a70a | ||
|
|
5632baca5b | ||
|
|
78f9bad706 | ||
|
|
3fdaaecd0f |
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -16,7 +16,6 @@ Dockerfile.dev linguist-language=Dockerfile
|
||||
CODEOWNERS linguist-generated=true
|
||||
Dockerfile linguist-generated=true
|
||||
homeassistant/generated/*.py linguist-generated=true
|
||||
machine/* linguist-generated=true
|
||||
mypy.ini linguist-generated=true
|
||||
requirements.txt linguist-generated=true
|
||||
requirements_all.txt linguist-generated=true
|
||||
|
||||
771
.github/workflows/builder.yml
vendored
771
.github/workflows/builder.yml
vendored
@@ -35,7 +35,6 @@ jobs:
|
||||
channel: ${{ steps.version.outputs.channel }}
|
||||
publish: ${{ steps.version.outputs.publish }}
|
||||
architectures: ${{ env.ARCHITECTURES }}
|
||||
base_image_version: ${{ env.BASE_IMAGE_VERSION }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -57,10 +56,10 @@ jobs:
|
||||
with:
|
||||
type: ${{ env.BUILD_TYPE }}
|
||||
|
||||
# - name: Verify version
|
||||
# uses: home-assistant/actions/helpers/verify-version@master # zizmor: ignore[unpinned-uses]
|
||||
# with:
|
||||
# ignore-dev: true
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@master # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
ignore-dev: true
|
||||
|
||||
- name: Fail if translations files are checked in
|
||||
run: |
|
||||
@@ -73,10 +72,45 @@ jobs:
|
||||
- name: Download Translations
|
||||
run: python3 -m script.translations download
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
|
||||
- name: Archive translations
|
||||
shell: bash
|
||||
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
|
||||
|
||||
- name: Upload translations
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: translations
|
||||
path: translations.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
build_base:
|
||||
name: Build ${{ matrix.arch }} base core image
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: init
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: read # To check out the repository
|
||||
packages: write # To push to GHCR
|
||||
id-token: write # For cosign signing
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
||||
include:
|
||||
- arch: amd64
|
||||
os: ubuntu-latest
|
||||
- arch: aarch64
|
||||
os: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download nightly wheels of frontend
|
||||
if: steps.version.outputs.channel == 'dev'
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
@@ -87,7 +121,7 @@ jobs:
|
||||
name: wheels
|
||||
|
||||
- name: Download nightly wheels of intents
|
||||
if: steps.version.outputs.channel == 'dev'
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
@@ -97,12 +131,18 @@ jobs:
|
||||
workflow_conclusion: success
|
||||
name: package
|
||||
|
||||
- name: Set up Python
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Adjust nightly version
|
||||
if: steps.version.outputs.channel == 'dev'
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
shell: bash
|
||||
env:
|
||||
UV_PRERELEASE: allow
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
python3 -m pip install "$(grep '^uv' < requirements.txt)"
|
||||
uv pip install packaging tomli
|
||||
@@ -140,72 +180,92 @@ jobs:
|
||||
sed -i "s|home-assistant-intents==.*||" requirements_all.txt requirements.txt
|
||||
fi
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: translations
|
||||
|
||||
- name: Extract translations
|
||||
run: |
|
||||
tar xvf translations.tar.gz
|
||||
rm translations.tar.gz
|
||||
|
||||
- name: Write meta info file
|
||||
shell: bash
|
||||
run: |
|
||||
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
|
||||
|
||||
- name: Upload build context overlay
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
name: build-context
|
||||
if-no-files-found: ignore
|
||||
path: |
|
||||
homeassistant/components/*/translations/
|
||||
rootfs/OFFICIAL_IMAGE
|
||||
home_assistant_frontend-*.whl
|
||||
home_assistant_intents-*.whl
|
||||
homeassistant/const.py
|
||||
homeassistant/components/frontend/manifest.json
|
||||
homeassistant/components/conversation/manifest.json
|
||||
homeassistant/package_constraints.txt
|
||||
requirements_all.txt
|
||||
requirements.txt
|
||||
pyproject.toml
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build_base:
|
||||
name: Build ${{ matrix.arch }} base core image
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: init
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: read # To check out the repository
|
||||
packages: write # To push to GHCR
|
||||
id-token: write # For cosign signing
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
os: ubuntu-24.04
|
||||
- arch: aarch64
|
||||
os: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
cosign-release: "v2.5.3"
|
||||
|
||||
- name: Download build context overlay
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: build-context
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build variables
|
||||
id: vars
|
||||
shell: bash
|
||||
env:
|
||||
ARCH: ${{ matrix.arch }}
|
||||
run: |
|
||||
echo "base_image=ghcr.io/home-assistant/${ARCH}-homeassistant-base:${BASE_IMAGE_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "cache_image=ghcr.io/home-assistant/${ARCH}-homeassistant:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "created=$(date --rfc-3339=seconds --utc)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify base image signature
|
||||
env:
|
||||
BASE_IMAGE: ${{ steps.vars.outputs.base_image }}
|
||||
run: |
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
--certificate-identity-regexp "https://github.com/home-assistant/docker/.*" \
|
||||
"${BASE_IMAGE}"
|
||||
|
||||
- name: Verify cache image signature
|
||||
id: cache
|
||||
continue-on-error: true
|
||||
env:
|
||||
CACHE_IMAGE: ${{ steps.vars.outputs.cache_image }}
|
||||
run: |
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
--certificate-identity-regexp "https://github.com/home-assistant/core/.*" \
|
||||
"${CACHE_IMAGE}"
|
||||
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
|
||||
id: build
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
arch: ${{ matrix.arch }}
|
||||
build-args: |
|
||||
BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ needs.init.outputs.base_image_version }}
|
||||
cache-gha: false
|
||||
container-registry-password: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: .
|
||||
cosign-base-identity: "https://github.com/home-assistant/docker/.*"
|
||||
cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ needs.init.outputs.base_image_version }}
|
||||
image: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant
|
||||
image-tags: ${{ needs.init.outputs.version }}
|
||||
file: ./Dockerfile
|
||||
platforms: ${{ steps.vars.outputs.platform }}
|
||||
push: true
|
||||
version: ${{ needs.init.outputs.version }}
|
||||
cache-from: ${{ steps.cache.outcome == 'success' && steps.vars.outputs.cache_image || '' }}
|
||||
build-args: |
|
||||
BUILD_FROM=${{ steps.vars.outputs.base_image }}
|
||||
tags: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
|
||||
outputs: type=image,push=true,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true
|
||||
labels: |
|
||||
io.hass.arch=${{ matrix.arch }}
|
||||
io.hass.version=${{ needs.init.outputs.version }}
|
||||
org.opencontainers.image.created=${{ steps.vars.outputs.created }}
|
||||
org.opencontainers.image.version=${{ needs.init.outputs.version }}
|
||||
|
||||
- name: Sign image
|
||||
env:
|
||||
ARCH: ${{ matrix.arch }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
run: |
|
||||
cosign sign --yes "ghcr.io/home-assistant/${ARCH}-homeassistant:${VERSION}@${DIGEST}"
|
||||
|
||||
build_machine:
|
||||
name: Build ${{ matrix.machine }} machine core image
|
||||
@@ -254,305 +314,308 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Compute extra tags
|
||||
id: tags
|
||||
shell: bash
|
||||
- name: Set build additional args
|
||||
env:
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
# Create general tags
|
||||
if [[ "${VERSION}" =~ d ]]; then
|
||||
echo "extra_tags=dev" >> "$GITHUB_OUTPUT"
|
||||
echo "BUILD_ARGS=--additional-tag dev" >> $GITHUB_ENV
|
||||
elif [[ "${VERSION}" =~ b ]]; then
|
||||
echo "extra_tags=beta" >> "$GITHUB_OUTPUT"
|
||||
echo "BUILD_ARGS=--additional-tag beta" >> $GITHUB_ENV
|
||||
else
|
||||
echo "extra_tags=stable" >> "$GITHUB_OUTPUT"
|
||||
echo "BUILD_ARGS=--additional-tag stable" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Build machine image
|
||||
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
arch: ${{ matrix.arch }}
|
||||
build-args: |
|
||||
BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
|
||||
cache-gha: false
|
||||
container-registry-password: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: machine/
|
||||
cosign-base-identity: "https://github.com/home-assistant/core/.*"
|
||||
cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
|
||||
file: machine/${{ matrix.machine }}
|
||||
image: ghcr.io/home-assistant/${{ matrix.machine }}-homeassistant
|
||||
image-tags: |
|
||||
${{ needs.init.outputs.version }}
|
||||
${{ steps.tags.outputs.extra_tags }}
|
||||
push: true
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@6cb4fd3d1338b6e22d0958a4bcb53e0965ea63b4 # 2026.02.1
|
||||
with:
|
||||
image: ${{ matrix.arch }}
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
--target /data/machine \
|
||||
--cosign \
|
||||
--machine "${{ needs.init.outputs.version }}=${{ matrix.machine }}"
|
||||
|
||||
publish_ha:
|
||||
name: Publish version files
|
||||
environment: ${{ needs.init.outputs.channel }}
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: ["init", "build_machine"]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize git
|
||||
uses: home-assistant/actions/helpers/git-init@master # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
name: ${{ secrets.GIT_NAME }}
|
||||
email: ${{ secrets.GIT_EMAIL }}
|
||||
token: ${{ secrets.GIT_TOKEN }}
|
||||
|
||||
- name: Update version file
|
||||
uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
key: "homeassistant[]"
|
||||
key-description: "Home Assistant Core"
|
||||
version: ${{ needs.init.outputs.version }}
|
||||
# publish_ha:
|
||||
# name: Publish version files
|
||||
# environment: ${{ needs.init.outputs.channel }}
|
||||
# if: github.repository_owner == 'home-assistant'
|
||||
# needs: ["init", "build_machine"]
|
||||
# runs-on: ubuntu-latest
|
||||
# permissions:
|
||||
# contents: read
|
||||
# steps:
|
||||
# - name: Checkout the repository
|
||||
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
# with:
|
||||
# persist-credentials: false
|
||||
#
|
||||
# - name: Initialize git
|
||||
# uses: home-assistant/actions/helpers/git-init@master # zizmor: ignore[unpinned-uses]
|
||||
# with:
|
||||
# name: ${{ secrets.GIT_NAME }}
|
||||
# email: ${{ secrets.GIT_EMAIL }}
|
||||
# token: ${{ secrets.GIT_TOKEN }}
|
||||
#
|
||||
# - name: Update version file
|
||||
# uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
|
||||
# with:
|
||||
# key: "homeassistant[]"
|
||||
# key-description: "Home Assistant Core"
|
||||
# version: ${{ needs.init.outputs.version }}
|
||||
# channel: ${{ needs.init.outputs.channel }}
|
||||
# exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
|
||||
#
|
||||
# - name: Update version file (stable -> beta)
|
||||
# if: needs.init.outputs.channel == 'stable'
|
||||
# uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
|
||||
# with:
|
||||
# key: "homeassistant[]"
|
||||
# key-description: "Home Assistant Core"
|
||||
# version: ${{ needs.init.outputs.version }}
|
||||
# channel: beta
|
||||
# exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
|
||||
#
|
||||
# publish_container:
|
||||
# name: Publish meta container for ${{ matrix.registry }}
|
||||
# environment: ${{ needs.init.outputs.channel }}
|
||||
# if: github.repository_owner == 'home-assistant'
|
||||
# needs: ["init", "build_base"]
|
||||
# runs-on: ubuntu-latest
|
||||
# permissions:
|
||||
# contents: read # To check out the repository
|
||||
# packages: write # To push to GHCR
|
||||
# id-token: write # For cosign signing
|
||||
# strategy:
|
||||
# fail-fast: false
|
||||
# matrix:
|
||||
# registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
||||
# steps:
|
||||
# - name: Install Cosign
|
||||
# uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
|
||||
# with:
|
||||
# cosign-release: "v2.5.3"
|
||||
#
|
||||
# - name: Login to DockerHub
|
||||
# if: matrix.registry == 'docker.io/homeassistant'
|
||||
# uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
# with:
|
||||
# username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
# password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
#
|
||||
# - name: Login to GitHub Container Registry
|
||||
# uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
# with:
|
||||
# registry: ghcr.io
|
||||
# username: ${{ github.repository_owner }}
|
||||
# password: ${{ secrets.GITHUB_TOKEN }}
|
||||
#
|
||||
# - name: Verify architecture image signatures
|
||||
# shell: bash
|
||||
# env:
|
||||
# ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
# VERSION: ${{ needs.init.outputs.version }}
|
||||
# run: |
|
||||
# ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
# for arch in $ARCHS; do
|
||||
# echo "Verifying ${arch} image signature..."
|
||||
# cosign verify \
|
||||
# --certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
# --certificate-identity-regexp https://github.com/home-assistant/core/.* \
|
||||
# "ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"
|
||||
# done
|
||||
# echo "✓ All images verified successfully"
|
||||
#
|
||||
# # Generate all Docker tags based on version string
|
||||
# # Version format: YYYY.MM.PATCH, YYYY.MM.PATCHbN (beta), or YYYY.MM.PATCH.devYYYYMMDDHHMM (dev)
|
||||
# # Examples:
|
||||
# # 2025.12.1 (stable) -> tags: 2025.12.1, 2025.12, stable, latest, beta, rc
|
||||
# # 2025.12.0b3 (beta) -> tags: 2025.12.0b3, beta, rc
|
||||
# # 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
|
||||
# - name: Generate Docker metadata
|
||||
# id: meta
|
||||
# uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
# with:
|
||||
# images: ${{ matrix.registry }}/home-assistant
|
||||
# sep-tags: ","
|
||||
# tags: |
|
||||
# type=raw,value=${{ needs.init.outputs.version }},priority=9999
|
||||
# type=raw,value=dev,enable=${{ contains(needs.init.outputs.version, 'd') }}
|
||||
# type=raw,value=beta,enable=${{ !contains(needs.init.outputs.version, 'd') }}
|
||||
# type=raw,value=rc,enable=${{ !contains(needs.init.outputs.version, 'd') }}
|
||||
# type=raw,value=stable,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
# type=raw,value=latest,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
# type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
#
|
||||
# - name: Set up Docker Buildx
|
||||
# uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
|
||||
#
|
||||
# - name: Copy architecture images to DockerHub
|
||||
# if: matrix.registry == 'docker.io/homeassistant'
|
||||
# shell: bash
|
||||
# env:
|
||||
# ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
# VERSION: ${{ needs.init.outputs.version }}
|
||||
# run: |
|
||||
# # Use imagetools to copy image blobs directly between registries
|
||||
# # This preserves provenance/attestations and seems to be much faster than pull/push
|
||||
# ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
# for arch in $ARCHS; do
|
||||
# echo "Copying ${arch} image to DockerHub..."
|
||||
# for attempt in 1 2 3; do
|
||||
# if docker buildx imagetools create \
|
||||
# --tag "docker.io/homeassistant/${arch}-homeassistant:${VERSION}" \
|
||||
# "ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"; then
|
||||
# break
|
||||
# fi
|
||||
# echo "Attempt ${attempt} failed, retrying in 10 seconds..."
|
||||
# sleep 10
|
||||
# if [ "${attempt}" -eq 3 ]; then
|
||||
# echo "Failed after 3 attempts"
|
||||
# exit 1
|
||||
# fi
|
||||
# done
|
||||
# cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${VERSION}"
|
||||
# done
|
||||
#
|
||||
# - name: Create and push multi-arch manifests
|
||||
# shell: bash
|
||||
# env:
|
||||
# ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
# REGISTRY: ${{ matrix.registry }}
|
||||
# VERSION: ${{ needs.init.outputs.version }}
|
||||
# META_TAGS: ${{ steps.meta.outputs.tags }}
|
||||
# run: |
|
||||
# # Build list of architecture images dynamically
|
||||
# ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
# ARCH_IMAGES=()
|
||||
# for arch in $ARCHS; do
|
||||
# ARCH_IMAGES+=("${REGISTRY}/${arch}-homeassistant:${VERSION}")
|
||||
# done
|
||||
#
|
||||
# # Build list of all tags for single manifest creation
|
||||
# # Note: Using sep-tags=',' in metadata-action for easier parsing
|
||||
# TAG_ARGS=()
|
||||
# IFS=',' read -ra TAGS <<< "${META_TAGS}"
|
||||
# for tag in "${TAGS[@]}"; do
|
||||
# TAG_ARGS+=("--tag" "${tag}")
|
||||
# done
|
||||
#
|
||||
# # Create manifest with ALL tags in a single operation (much faster!)
|
||||
# echo "Creating multi-arch manifest with tags: ${TAGS[*]}"
|
||||
# docker buildx imagetools create "${TAG_ARGS[@]}" "${ARCH_IMAGES[@]}"
|
||||
#
|
||||
# # Sign each tag separately (signing requires individual tag names)
|
||||
# echo "Signing all tags..."
|
||||
# for tag in "${TAGS[@]}"; do
|
||||
# echo "Signing ${tag}"
|
||||
# cosign sign --yes "${tag}"
|
||||
# done
|
||||
#
|
||||
# echo "All manifests created and signed successfully"
|
||||
#
|
||||
# build_python:
|
||||
# name: Build PyPi package
|
||||
# environment: ${{ needs.init.outputs.channel }}
|
||||
# needs: ["init", "build_base"]
|
||||
# runs-on: ubuntu-latest
|
||||
# permissions:
|
||||
# contents: read # To check out the repository
|
||||
# id-token: write # For PyPI trusted publishing
|
||||
# if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
|
||||
# steps:
|
||||
# - name: Checkout the repository
|
||||
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
# with:
|
||||
# persist-credentials: false
|
||||
#
|
||||
# - name: Set up Python
|
||||
# uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
# with:
|
||||
# python-version-file: ".python-version"
|
||||
#
|
||||
# - name: Download build context overlay
|
||||
# uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
# with:
|
||||
# name: build-context
|
||||
#
|
||||
# - name: Build package
|
||||
# shell: bash
|
||||
# run: |
|
||||
# # Remove dist, build, and homeassistant.egg-info
|
||||
# # when build locally for testing!
|
||||
# pip install build
|
||||
# python -m build
|
||||
#
|
||||
# - name: Upload package to PyPI
|
||||
# uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
# with:
|
||||
# skip-existing: true
|
||||
#
|
||||
# hassfest-image:
|
||||
# name: Build and test hassfest image
|
||||
# runs-on: ubuntu-latest
|
||||
# permissions:
|
||||
# contents: read # To check out the repository
|
||||
# packages: write # To push to GHCR
|
||||
# attestations: write # For build provenance attestation
|
||||
# id-token: write # For build provenance attestation
|
||||
# needs: ["init"]
|
||||
# if: github.repository_owner == 'home-assistant'
|
||||
# env:
|
||||
# HASSFEST_IMAGE_NAME: ghcr.io/home-assistant/hassfest
|
||||
# HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
|
||||
# steps:
|
||||
# - name: Checkout repository
|
||||
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
# with:
|
||||
# persist-credentials: false
|
||||
#
|
||||
# - name: Login to GitHub Container Registry
|
||||
# uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
# with:
|
||||
# registry: ghcr.io
|
||||
# username: ${{ github.repository_owner }}
|
||||
# password: ${{ secrets.GITHUB_TOKEN }}
|
||||
#
|
||||
# - name: Build Docker image
|
||||
# uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
# with:
|
||||
# context: . # So action will not pull the repository again
|
||||
# file: ./script/hassfest/docker/Dockerfile
|
||||
# load: true
|
||||
# tags: ${{ env.HASSFEST_IMAGE_TAG }}
|
||||
#
|
||||
# - name: Run hassfest against core
|
||||
# run: docker run --rm -v "${GITHUB_WORKSPACE}":/github/workspace "${HASSFEST_IMAGE_TAG}" --core-path=/github/workspace
|
||||
#
|
||||
# - name: Push Docker image
|
||||
# if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
# id: push
|
||||
# uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
# with:
|
||||
# context: . # So action will not pull the repository again
|
||||
# file: ./script/hassfest/docker/Dockerfile
|
||||
# push: true
|
||||
# tags: ${{ env.HASSFEST_IMAGE_TAG }},${{ env.HASSFEST_IMAGE_NAME }}:latest
|
||||
#
|
||||
# - name: Generate artifact attestation
|
||||
# if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
# uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
# with:
|
||||
# subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
||||
# subject-digest: ${{ steps.push.outputs.digest }}
|
||||
# push-to-registry: true
|
||||
channel: ${{ needs.init.outputs.channel }}
|
||||
exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
|
||||
|
||||
- name: Update version file (stable -> beta)
|
||||
if: needs.init.outputs.channel == 'stable'
|
||||
uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
key: "homeassistant[]"
|
||||
key-description: "Home Assistant Core"
|
||||
version: ${{ needs.init.outputs.version }}
|
||||
channel: beta
|
||||
exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
|
||||
|
||||
publish_container:
|
||||
name: Publish meta container for ${{ matrix.registry }}
|
||||
environment: ${{ needs.init.outputs.channel }}
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: ["init", "build_base"]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To check out the repository
|
||||
packages: write # To push to GHCR
|
||||
id-token: write # For cosign signing
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
||||
steps:
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: "v2.5.3"
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Verify architecture image signatures
|
||||
shell: bash
|
||||
env:
|
||||
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
for arch in $ARCHS; do
|
||||
echo "Verifying ${arch} image signature..."
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
--certificate-identity-regexp https://github.com/home-assistant/core/.* \
|
||||
"ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"
|
||||
done
|
||||
echo "✓ All images verified successfully"
|
||||
|
||||
# Generate all Docker tags based on version string
|
||||
# Version format: YYYY.MM.PATCH, YYYY.MM.PATCHbN (beta), or YYYY.MM.PATCH.devYYYYMMDDHHMM (dev)
|
||||
# Examples:
|
||||
# 2025.12.1 (stable) -> tags: 2025.12.1, 2025.12, stable, latest, beta, rc
|
||||
# 2025.12.0b3 (beta) -> tags: 2025.12.0b3, beta, rc
|
||||
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ${{ matrix.registry }}/home-assistant
|
||||
sep-tags: ","
|
||||
tags: |
|
||||
type=raw,value=${{ needs.init.outputs.version }},priority=9999
|
||||
type=raw,value=dev,enable=${{ contains(needs.init.outputs.version, 'd') }}
|
||||
type=raw,value=beta,enable=${{ !contains(needs.init.outputs.version, 'd') }}
|
||||
type=raw,value=rc,enable=${{ !contains(needs.init.outputs.version, 'd') }}
|
||||
type=raw,value=stable,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
type=raw,value=latest,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
|
||||
|
||||
- name: Copy architecture images to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
shell: bash
|
||||
env:
|
||||
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
# Use imagetools to copy image blobs directly between registries
|
||||
# This preserves provenance/attestations and seems to be much faster than pull/push
|
||||
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
for arch in $ARCHS; do
|
||||
echo "Copying ${arch} image to DockerHub..."
|
||||
for attempt in 1 2 3; do
|
||||
if docker buildx imagetools create \
|
||||
--tag "docker.io/homeassistant/${arch}-homeassistant:${VERSION}" \
|
||||
"ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"; then
|
||||
break
|
||||
fi
|
||||
echo "Attempt ${attempt} failed, retrying in 10 seconds..."
|
||||
sleep 10
|
||||
if [ "${attempt}" -eq 3 ]; then
|
||||
echo "Failed after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${VERSION}"
|
||||
done
|
||||
|
||||
- name: Create and push multi-arch manifests
|
||||
shell: bash
|
||||
env:
|
||||
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
REGISTRY: ${{ matrix.registry }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
META_TAGS: ${{ steps.meta.outputs.tags }}
|
||||
run: |
|
||||
# Build list of architecture images dynamically
|
||||
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
ARCH_IMAGES=()
|
||||
for arch in $ARCHS; do
|
||||
ARCH_IMAGES+=("${REGISTRY}/${arch}-homeassistant:${VERSION}")
|
||||
done
|
||||
|
||||
# Build list of all tags for single manifest creation
|
||||
# Note: Using sep-tags=',' in metadata-action for easier parsing
|
||||
TAG_ARGS=()
|
||||
IFS=',' read -ra TAGS <<< "${META_TAGS}"
|
||||
for tag in "${TAGS[@]}"; do
|
||||
TAG_ARGS+=("--tag" "${tag}")
|
||||
done
|
||||
|
||||
# Create manifest with ALL tags in a single operation (much faster!)
|
||||
echo "Creating multi-arch manifest with tags: ${TAGS[*]}"
|
||||
docker buildx imagetools create "${TAG_ARGS[@]}" "${ARCH_IMAGES[@]}"
|
||||
|
||||
# Sign each tag separately (signing requires individual tag names)
|
||||
echo "Signing all tags..."
|
||||
for tag in "${TAGS[@]}"; do
|
||||
echo "Signing ${tag}"
|
||||
cosign sign --yes "${tag}"
|
||||
done
|
||||
|
||||
echo "All manifests created and signed successfully"
|
||||
|
||||
build_python:
|
||||
name: Build PyPi package
|
||||
environment: ${{ needs.init.outputs.channel }}
|
||||
needs: ["init", "build_base"]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To check out the repository
|
||||
id-token: write # For PyPI trusted publishing
|
||||
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: translations
|
||||
|
||||
- name: Extract translations
|
||||
run: |
|
||||
tar xvf translations.tar.gz
|
||||
rm translations.tar.gz
|
||||
|
||||
- name: Build package
|
||||
shell: bash
|
||||
run: |
|
||||
# Remove dist, build, and homeassistant.egg-info
|
||||
# when build locally for testing!
|
||||
pip install build
|
||||
python -m build
|
||||
|
||||
- name: Upload package to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
hassfest-image:
|
||||
name: Build and test hassfest image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To check out the repository
|
||||
packages: write # To push to GHCR
|
||||
attestations: write # For build provenance attestation
|
||||
id-token: write # For build provenance attestation
|
||||
needs: ["init"]
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
env:
|
||||
HASSFEST_IMAGE_NAME: ghcr.io/home-assistant/hassfest
|
||||
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
load: true
|
||||
tags: ${{ env.HASSFEST_IMAGE_TAG }}
|
||||
|
||||
- name: Run hassfest against core
|
||||
run: docker run --rm -v "${GITHUB_WORKSPACE}":/github/workspace "${HASSFEST_IMAGE_TAG}" --core-path=/github/workspace
|
||||
|
||||
- name: Push Docker image
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
id: push
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ env.HASSFEST_IMAGE_TAG }},${{ env.HASSFEST_IMAGE_NAME }}:latest
|
||||
|
||||
- name: Generate artifact attestation
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
with:
|
||||
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
8
.github/workflows/ci.yaml
vendored
8
.github/workflows/ci.yaml
vendored
@@ -852,6 +852,10 @@ jobs:
|
||||
needs:
|
||||
- info
|
||||
- base
|
||||
- gen-requirements-all
|
||||
- hassfest
|
||||
- prek
|
||||
- mypy
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
@@ -1396,7 +1400,7 @@ jobs:
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
flags: full-suite
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
pytest-partial:
|
||||
name: Run tests Python ${{ matrix.python-version }} (${{ matrix.group }})
|
||||
@@ -1566,7 +1570,7 @@ jobs:
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
upload-test-results:
|
||||
name: Upload test results to Codecov
|
||||
|
||||
4
.github/workflows/stale.yml
vendored
4
.github/workflows/stale.yml
vendored
@@ -58,8 +58,8 @@ jobs:
|
||||
# v1.7.0
|
||||
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a
|
||||
with:
|
||||
app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
|
||||
private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
|
||||
app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }}
|
||||
private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }}
|
||||
|
||||
# The 90 day stale policy for issues
|
||||
# Used for:
|
||||
|
||||
2
.github/workflows/translations.yml
vendored
2
.github/workflows/translations.yml
vendored
@@ -33,6 +33,6 @@ jobs:
|
||||
|
||||
- name: Upload Translations
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
run: |
|
||||
python3 -m script.translations upload
|
||||
|
||||
4
.github/workflows/wheels.yml
vendored
4
.github/workflows/wheels.yml
vendored
@@ -142,7 +142,7 @@ jobs:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }} # zizmor: ignore[secrets-outside-env]
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
env-file: true
|
||||
apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-ng-dev"
|
||||
skip-binary: aiohttp;multidict;propcache;yarl;SQLAlchemy
|
||||
@@ -200,7 +200,7 @@ jobs:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }} # zizmor: ignore[secrets-outside-env]
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
env-file: true
|
||||
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev"
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
|
||||
|
||||
@@ -18,7 +18,7 @@ repos:
|
||||
exclude_types: [csv, json, html]
|
||||
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
|
||||
- repo: https://github.com/zizmorcore/zizmor-pre-commit
|
||||
rev: v1.23.1
|
||||
rev: v1.22.0
|
||||
hooks:
|
||||
- id: zizmor
|
||||
args:
|
||||
|
||||
@@ -570,7 +570,6 @@ homeassistant.components.trafikverket_train.*
|
||||
homeassistant.components.trafikverket_weatherstation.*
|
||||
homeassistant.components.transmission.*
|
||||
homeassistant.components.trend.*
|
||||
homeassistant.components.trmnl.*
|
||||
homeassistant.components.tts.*
|
||||
homeassistant.components.twentemilieu.*
|
||||
homeassistant.components.unifi.*
|
||||
|
||||
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@@ -974,8 +974,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/logbook/ @home-assistant/core
|
||||
/homeassistant/components/logger/ @home-assistant/core
|
||||
/tests/components/logger/ @home-assistant/core
|
||||
/homeassistant/components/lojack/ @devinslick
|
||||
/tests/components/lojack/ @devinslick
|
||||
/homeassistant/components/london_underground/ @jpbede
|
||||
/tests/components/london_underground/ @jpbede
|
||||
/homeassistant/components/lookin/ @ANMalko @bdraco
|
||||
@@ -1772,8 +1770,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/trend/ @jpbede
|
||||
/homeassistant/components/triggercmd/ @rvmey
|
||||
/tests/components/triggercmd/ @rvmey
|
||||
/homeassistant/components/trmnl/ @joostlek
|
||||
/tests/components/trmnl/ @joostlek
|
||||
/homeassistant/components/tts/ @home-assistant/core
|
||||
/tests/components/tts/ @home-assistant/core
|
||||
/homeassistant/components/tuya/ @Tuya @zlinoliver
|
||||
|
||||
1
Dockerfile
generated
1
Dockerfile
generated
@@ -10,6 +10,7 @@ LABEL \
|
||||
org.opencontainers.image.description="Open-source home automation platform running on Python 3" \
|
||||
org.opencontainers.image.documentation="https://www.home-assistant.io/docs/" \
|
||||
org.opencontainers.image.licenses="Apache-2.0" \
|
||||
org.opencontainers.image.source="https://github.com/home-assistant/core" \
|
||||
org.opencontainers.image.title="Home Assistant" \
|
||||
org.opencontainers.image.url="https://www.home-assistant.io/"
|
||||
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
{
|
||||
"domain": "ubiquiti",
|
||||
"name": "Ubiquiti",
|
||||
"integrations": [
|
||||
"airos",
|
||||
"unifi",
|
||||
"unifi_access",
|
||||
"unifi_direct",
|
||||
"unifiled",
|
||||
"unifiprotect"
|
||||
]
|
||||
"integrations": ["airos", "unifi", "unifi_direct", "unifiled", "unifiprotect"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Defines a base Alexa Devices entity."""
|
||||
|
||||
from aioamazondevices.const.devices import SPEAKER_GROUP_DEVICE_TYPE
|
||||
from aioamazondevices.structures import AmazonDevice
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -24,15 +25,20 @@ class AmazonEntity(CoordinatorEntity[AmazonDevicesCoordinator]):
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._serial_num = serial_num
|
||||
model = self.device.model
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, serial_num)},
|
||||
name=self.device.account_name,
|
||||
model=self.device.model,
|
||||
model=model,
|
||||
model_id=self.device.device_type,
|
||||
manufacturer=self.device.manufacturer or "Amazon",
|
||||
hw_version=self.device.hardware_version,
|
||||
sw_version=self.device.software_version,
|
||||
serial_number=serial_num,
|
||||
sw_version=(
|
||||
self.device.software_version
|
||||
if model != SPEAKER_GROUP_DEVICE_TYPE
|
||||
else None
|
||||
),
|
||||
serial_number=serial_num if model != SPEAKER_GROUP_DEVICE_TYPE else None,
|
||||
)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{serial_num}-{description.key}"
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==13.0.1"]
|
||||
"requirements": ["aioamazondevices==13.0.0"]
|
||||
}
|
||||
|
||||
@@ -338,7 +338,6 @@ class Analytics:
|
||||
|
||||
hass = self._hass
|
||||
supervisor_info = None
|
||||
addons_info: dict[str, Any] | None = None
|
||||
operating_system_info: dict[str, Any] = {}
|
||||
|
||||
if self._data.uuid is None:
|
||||
@@ -348,7 +347,6 @@ class Analytics:
|
||||
if self.supervisor:
|
||||
supervisor_info = hassio.get_supervisor_info(hass)
|
||||
operating_system_info = hassio.get_os_info(hass) or {}
|
||||
addons_info = hassio.get_addons_info(hass) or {}
|
||||
|
||||
system_info = await async_get_system_info(hass)
|
||||
integrations = []
|
||||
@@ -421,10 +419,13 @@ class Analytics:
|
||||
|
||||
integrations.append(integration.domain)
|
||||
|
||||
if addons_info is not None:
|
||||
if supervisor_info is not None:
|
||||
supervisor_client = hassio.get_supervisor_client(hass)
|
||||
installed_addons = await asyncio.gather(
|
||||
*(supervisor_client.addons.addon_info(slug) for slug in addons_info)
|
||||
*(
|
||||
supervisor_client.addons.addon_info(addon[ATTR_SLUG])
|
||||
for addon in supervisor_info[ATTR_ADDONS]
|
||||
)
|
||||
)
|
||||
addons.extend(
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator, ArcamFmjRunti
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.MEDIA_PLAYER, Platform.SENSOR]
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ArcamFmjConfigEntry) -> bool:
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Arcam binary sensors for incoming stream info."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from arcam.fmj.state import State
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import ArcamFmjConfigEntry
|
||||
from .entity import ArcamFmjEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ArcamFmjBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describes an Arcam FMJ binary sensor entity."""
|
||||
|
||||
value_fn: Callable[[State], bool | None]
|
||||
|
||||
|
||||
BINARY_SENSORS: tuple[ArcamFmjBinarySensorEntityDescription, ...] = (
|
||||
ArcamFmjBinarySensorEntityDescription(
|
||||
key="incoming_video_interlaced",
|
||||
translation_key="incoming_video_interlaced",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda state: (
|
||||
vp.interlaced
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ArcamFmjConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Arcam FMJ binary sensors from a config entry."""
|
||||
coordinators = config_entry.runtime_data.coordinators
|
||||
|
||||
entities: list[ArcamFmjBinarySensorEntity] = []
|
||||
for coordinator in coordinators.values():
|
||||
entities.extend(
|
||||
ArcamFmjBinarySensorEntity(coordinator, description)
|
||||
for description in BINARY_SENSORS
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class ArcamFmjBinarySensorEntity(ArcamFmjEntity, BinarySensorEntity):
|
||||
"""Representation of an Arcam FMJ binary sensor."""
|
||||
|
||||
entity_description: ArcamFmjBinarySensorEntityDescription
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return the binary sensor value."""
|
||||
return self.entity_description.value_fn(self.coordinator.state)
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .coordinator import ArcamFmjCoordinator
|
||||
@@ -13,16 +12,9 @@ class ArcamFmjEntity(CoordinatorEntity[ArcamFmjCoordinator]):
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ArcamFmjCoordinator,
|
||||
description: EntityDescription | None = None,
|
||||
) -> None:
|
||||
def __init__(self, coordinator: ArcamFmjCoordinator) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = coordinator.device_info
|
||||
self._attr_entity_registry_enabled_default = coordinator.state.zn == 1
|
||||
self._attr_unique_id = coordinator.zone_unique_id
|
||||
if description is not None:
|
||||
self._attr_unique_id = f"{self._attr_unique_id}-{description.key}"
|
||||
self.entity_description = description
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"incoming_video_interlaced": {
|
||||
"default": "mdi:reorder-horizontal"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"incoming_audio_config": {
|
||||
"default": "mdi:surround-sound"
|
||||
},
|
||||
"incoming_audio_format": {
|
||||
"default": "mdi:dolby"
|
||||
},
|
||||
"incoming_audio_sample_rate": {
|
||||
"default": "mdi:waveform"
|
||||
},
|
||||
"incoming_video_aspect_ratio": {
|
||||
"default": "mdi:aspect-ratio"
|
||||
},
|
||||
"incoming_video_colorspace": {
|
||||
"default": "mdi:palette"
|
||||
},
|
||||
"incoming_video_horizontal_resolution": {
|
||||
"default": "mdi:arrow-expand-horizontal"
|
||||
},
|
||||
"incoming_video_refresh_rate": {
|
||||
"default": "mdi:animation"
|
||||
},
|
||||
"incoming_video_vertical_resolution": {
|
||||
"default": "mdi:arrow-expand-vertical"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
"""Arcam sensors for incoming stream info."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from arcam.fmj import IncomingVideoAspectRatio, IncomingVideoColorspace
|
||||
from arcam.fmj.state import IncomingAudioConfig, IncomingAudioFormat, State
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import EntityCategory, UnitOfFrequency
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import ArcamFmjConfigEntry
|
||||
from .entity import ArcamFmjEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ArcamFmjSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes an Arcam FMJ sensor entity."""
|
||||
|
||||
value_fn: Callable[[State], int | float | str | None]
|
||||
|
||||
|
||||
SENSORS: tuple[ArcamFmjSensorEntityDescription, ...] = (
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_video_horizontal_resolution",
|
||||
translation_key="incoming_video_horizontal_resolution",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement="px",
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda state: (
|
||||
vp.horizontal_resolution
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_video_vertical_resolution",
|
||||
translation_key="incoming_video_vertical_resolution",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement="px",
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda state: (
|
||||
vp.vertical_resolution
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_video_refresh_rate",
|
||||
translation_key="incoming_video_refresh_rate",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda state: (
|
||||
vp.refresh_rate
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_video_aspect_ratio",
|
||||
translation_key="incoming_video_aspect_ratio",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[member.name.lower() for member in IncomingVideoAspectRatio],
|
||||
value_fn=lambda state: (
|
||||
vp.aspect_ratio.name.lower()
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_video_colorspace",
|
||||
translation_key="incoming_video_colorspace",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[member.name.lower() for member in IncomingVideoColorspace],
|
||||
value_fn=lambda state: (
|
||||
vp.colorspace.name.lower()
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_audio_format",
|
||||
translation_key="incoming_audio_format",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[member.name.lower() for member in IncomingAudioFormat],
|
||||
value_fn=lambda state: (
|
||||
result.name.lower()
|
||||
if (result := state.get_incoming_audio_format()[0]) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_audio_config",
|
||||
translation_key="incoming_audio_config",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[member.name.lower() for member in IncomingAudioConfig],
|
||||
value_fn=lambda state: (
|
||||
result.name.lower()
|
||||
if (result := state.get_incoming_audio_format()[1]) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_audio_sample_rate",
|
||||
translation_key="incoming_audio_sample_rate",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda state: (
|
||||
None
|
||||
if (sample_rate := state.get_incoming_audio_sample_rate()) == 0
|
||||
else sample_rate
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ArcamFmjConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Arcam FMJ sensors from a config entry."""
|
||||
coordinators = config_entry.runtime_data.coordinators
|
||||
|
||||
entities: list[ArcamFmjSensorEntity] = []
|
||||
for coordinator in coordinators.values():
|
||||
entities.extend(
|
||||
ArcamFmjSensorEntity(coordinator, description) for description in SENSORS
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class ArcamFmjSensorEntity(ArcamFmjEntity, SensorEntity):
|
||||
"""Representation of an Arcam FMJ sensor."""
|
||||
|
||||
entity_description: ArcamFmjSensorEntityDescription
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | float | str | None:
|
||||
"""Return the sensor value."""
|
||||
return self.entity_description.value_fn(self.coordinator.state)
|
||||
@@ -23,121 +23,5 @@
|
||||
"trigger_type": {
|
||||
"turn_on": "{entity_name} was requested to turn on"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"incoming_video_interlaced": {
|
||||
"name": "Incoming video interlaced"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"incoming_audio_config": {
|
||||
"name": "Incoming audio configuration",
|
||||
"state": {
|
||||
"auro_10_1": "Auro 10.1",
|
||||
"auro_11_1": "Auro 11.1",
|
||||
"auro_13_1": "Auro 13.1",
|
||||
"auro_2_2_2": "Auro 2.2.2",
|
||||
"auro_5_0": "Auro 5.0",
|
||||
"auro_5_1": "Auro 5.1",
|
||||
"auro_8_0": "Auro 8.0",
|
||||
"auro_9_1": "Auro 9.1",
|
||||
"auro_quad": "Auro quad",
|
||||
"dual_mono": "Dual mono",
|
||||
"dual_mono_lfe": "Dual mono + LFE",
|
||||
"mono": "Mono",
|
||||
"mono_lfe": "Mono + LFE",
|
||||
"stereo_center": "Stereo center",
|
||||
"stereo_center_lfe": "Stereo center + LFE",
|
||||
"stereo_center_surr_lr": "Stereo center surround L/R",
|
||||
"stereo_center_surr_lr_back_lr": "Stereo center surround L/R back L/R",
|
||||
"stereo_center_surr_lr_back_lr_lfe": "Stereo center surround L/R back L/R + LFE",
|
||||
"stereo_center_surr_lr_back_matrix": "Stereo center surround L/R back matrix",
|
||||
"stereo_center_surr_lr_back_matrix_lfe": "Stereo center surround L/R back matrix + LFE",
|
||||
"stereo_center_surr_lr_back_mono": "Stereo center surround L/R back mono",
|
||||
"stereo_center_surr_lr_back_mono_lfe": "Stereo center surround L/R back mono + LFE",
|
||||
"stereo_center_surr_lr_lfe": "Stereo center surround L/R + LFE",
|
||||
"stereo_center_surr_mono": "Stereo center surround mono",
|
||||
"stereo_center_surr_mono_lfe": "Stereo center surround mono + LFE",
|
||||
"stereo_downmix": "Stereo downmix",
|
||||
"stereo_downmix_lfe": "Stereo downmix + LFE",
|
||||
"stereo_lfe": "Stereo + LFE",
|
||||
"stereo_only": "Stereo only",
|
||||
"stereo_only_lo_ro": "Stereo only Lo/Ro",
|
||||
"stereo_only_lo_ro_lfe": "Stereo only Lo/Ro + LFE",
|
||||
"stereo_surr_lr": "Stereo surround L/R",
|
||||
"stereo_surr_lr_back_lr": "Stereo surround L/R back L/R",
|
||||
"stereo_surr_lr_back_lr_lfe": "Stereo surround L/R back L/R + LFE",
|
||||
"stereo_surr_lr_back_matrix": "Stereo surround L/R back matrix",
|
||||
"stereo_surr_lr_back_matrix_lfe": "Stereo surround L/R back matrix + LFE",
|
||||
"stereo_surr_lr_back_mono": "Stereo surround L/R back mono",
|
||||
"stereo_surr_lr_back_mono_lfe": "Stereo surround L/R back mono + LFE",
|
||||
"stereo_surr_lr_lfe": "Stereo surround L/R + LFE",
|
||||
"stereo_surr_mono": "Stereo surround mono",
|
||||
"stereo_surr_mono_lfe": "Stereo surround mono + LFE",
|
||||
"undetected": "Undetected",
|
||||
"unknown": "Unknown"
|
||||
}
|
||||
},
|
||||
"incoming_audio_format": {
|
||||
"name": "Incoming audio format",
|
||||
"state": {
|
||||
"analogue_direct": "Analogue direct",
|
||||
"auro_3d": "Auro-3D",
|
||||
"dolby_atmos": "Dolby Atmos",
|
||||
"dolby_digital": "Dolby Digital",
|
||||
"dolby_digital_ex": "Dolby Digital EX",
|
||||
"dolby_digital_plus": "Dolby Digital Plus",
|
||||
"dolby_digital_surround": "Dolby Digital Surround",
|
||||
"dolby_digital_true_hd": "Dolby TrueHD",
|
||||
"dts": "DTS",
|
||||
"dts_96_24": "DTS 96/24",
|
||||
"dts_core": "DTS Core",
|
||||
"dts_es_discrete": "DTS-ES Discrete",
|
||||
"dts_es_discrete_96_24": "DTS-ES Discrete 96/24",
|
||||
"dts_es_matrix": "DTS-ES Matrix",
|
||||
"dts_es_matrix_96_24": "DTS-ES Matrix 96/24",
|
||||
"dts_hd_high_res_audio": "DTS-HD High Resolution Audio",
|
||||
"dts_hd_master_audio": "DTS-HD Master Audio",
|
||||
"dts_low_bit_rate": "DTS Low Bit Rate",
|
||||
"dts_x": "DTS:X",
|
||||
"imax_enhanced": "IMAX Enhanced",
|
||||
"pcm": "PCM",
|
||||
"pcm_zero": "PCM zero",
|
||||
"undetected": "Undetected",
|
||||
"unsupported": "Unsupported"
|
||||
}
|
||||
},
|
||||
"incoming_audio_sample_rate": {
|
||||
"name": "Incoming audio sample rate"
|
||||
},
|
||||
"incoming_video_aspect_ratio": {
|
||||
"name": "Incoming video aspect ratio",
|
||||
"state": {
|
||||
"aspect_16_9": "16:9",
|
||||
"aspect_4_3": "4:3",
|
||||
"undefined": "Undefined"
|
||||
}
|
||||
},
|
||||
"incoming_video_colorspace": {
|
||||
"name": "Incoming video colorspace",
|
||||
"state": {
|
||||
"dolby_vision": "Dolby Vision",
|
||||
"hdr10": "HDR10",
|
||||
"hdr10_plus": "HDR10+",
|
||||
"hlg": "HLG",
|
||||
"normal": "Normal"
|
||||
}
|
||||
},
|
||||
"incoming_video_horizontal_resolution": {
|
||||
"name": "Incoming video horizontal resolution"
|
||||
},
|
||||
"incoming_video_refresh_rate": {
|
||||
"name": "Incoming video refresh rate"
|
||||
},
|
||||
"incoming_video_vertical_resolution": {
|
||||
"name": "Incoming video vertical resolution"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,13 +78,19 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
|
||||
index: int = 0,
|
||||
) -> None:
|
||||
"""Initialize a pipeline selector."""
|
||||
if index >= 1:
|
||||
self.entity_description = replace(
|
||||
self.entity_description,
|
||||
key=f"pipeline_{index + 1}",
|
||||
translation_key="pipeline_n",
|
||||
translation_placeholders={"index": str(index + 1)},
|
||||
)
|
||||
if index < 1:
|
||||
# Keep compatibility
|
||||
key_suffix = ""
|
||||
placeholder = ""
|
||||
else:
|
||||
key_suffix = f"_{index + 1}"
|
||||
placeholder = f" {index + 1}"
|
||||
|
||||
self.entity_description = replace(
|
||||
self.entity_description,
|
||||
key=f"pipeline{key_suffix}",
|
||||
translation_placeholders={"index": placeholder},
|
||||
)
|
||||
|
||||
self._domain = domain
|
||||
self._unique_id_prefix = unique_id_prefix
|
||||
|
||||
@@ -7,17 +7,11 @@
|
||||
},
|
||||
"select": {
|
||||
"pipeline": {
|
||||
"name": "Assistant",
|
||||
"name": "Assistant{index}",
|
||||
"state": {
|
||||
"preferred": "Preferred"
|
||||
}
|
||||
},
|
||||
"pipeline_n": {
|
||||
"name": "Assistant {index}",
|
||||
"state": {
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"vad_sensitivity": {
|
||||
"name": "Finished speaking detection",
|
||||
"state": {
|
||||
|
||||
@@ -121,7 +121,6 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
|
||||
"alarm_control_panel",
|
||||
"assist_satellite",
|
||||
"climate",
|
||||
"cover",
|
||||
"device_tracker",
|
||||
"fan",
|
||||
"humidifier",
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.28.4",
|
||||
"dbus-fast==3.1.2",
|
||||
"habluetooth==5.10.2"
|
||||
"habluetooth==5.9.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
"""Diagnostics support for Chess.com."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import ChessConfigEntry
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: ChessConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
return {
|
||||
"player": asdict(coordinator.data.player),
|
||||
"stats": asdict(coordinator.data.stats),
|
||||
}
|
||||
@@ -41,7 +41,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Can't detect a game
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Provides conditions for climates."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.condition import Condition, make_entity_state_condition
|
||||
from homeassistant.helpers.condition import (
|
||||
Condition,
|
||||
make_entity_state_attribute_condition,
|
||||
make_entity_state_condition,
|
||||
)
|
||||
|
||||
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
|
||||
|
||||
@@ -19,14 +22,14 @@ CONDITIONS: dict[str, type[Condition]] = {
|
||||
HVACMode.HEAT_COOL,
|
||||
},
|
||||
),
|
||||
"is_cooling": make_entity_state_condition(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.COOLING
|
||||
"is_cooling": make_entity_state_attribute_condition(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
|
||||
),
|
||||
"is_drying": make_entity_state_condition(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
|
||||
"is_drying": make_entity_state_attribute_condition(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
|
||||
),
|
||||
"is_heating": make_entity_state_condition(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
|
||||
"is_heating": make_entity_state_attribute_condition(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from homeassistant.helpers.trigger import (
|
||||
TriggerConfig,
|
||||
make_entity_numerical_state_changed_trigger,
|
||||
make_entity_numerical_state_crossed_threshold_trigger,
|
||||
make_entity_target_state_attribute_trigger,
|
||||
make_entity_target_state_trigger,
|
||||
make_entity_transition_trigger,
|
||||
)
|
||||
@@ -46,11 +47,11 @@ class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"hvac_mode_changed": HVACModeChangedTrigger,
|
||||
"started_cooling": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.COOLING
|
||||
"started_cooling": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
|
||||
),
|
||||
"started_drying": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
|
||||
"started_drying": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
|
||||
),
|
||||
"target_humidity_changed": make_entity_numerical_state_changed_trigger(
|
||||
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}
|
||||
@@ -79,8 +80,8 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
HVACMode.HEAT_COOL,
|
||||
},
|
||||
),
|
||||
"started_heating": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
|
||||
"started_heating": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiocomelit"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiocomelit==2.0.1"]
|
||||
"requirements": ["aiocomelit==2.0.0"]
|
||||
}
|
||||
|
||||
@@ -153,8 +153,8 @@ def websocket_get_entities(
|
||||
{
|
||||
vol.Required("type"): "config/entity_registry/update",
|
||||
vol.Required("entity_id"): cv.entity_id,
|
||||
vol.Optional("aliases"): [vol.Any(str, None)],
|
||||
# If passed in, we update value. Passing None will remove old value.
|
||||
vol.Optional("aliases"): list,
|
||||
vol.Optional("area_id"): vol.Any(str, None),
|
||||
# Categories is a mapping of key/value (scope/category_id) pairs.
|
||||
# If passed in, we update/adjust only the provided scope(s).
|
||||
@@ -225,15 +225,10 @@ def websocket_update_entity(
|
||||
changes[key] = msg[key]
|
||||
|
||||
if "aliases" in msg:
|
||||
# Sanitize aliases by removing:
|
||||
# - Trailing and leading whitespace characters in the individual aliases
|
||||
# Create a set for the aliases without:
|
||||
# - Empty strings
|
||||
changes["aliases"] = aliases = []
|
||||
for alias in msg["aliases"]:
|
||||
if alias is None:
|
||||
aliases.append(er.COMPUTED_NAME)
|
||||
elif alias := alias.strip():
|
||||
aliases.append(alias)
|
||||
# - Trailing and leading whitespace characters in the individual aliases
|
||||
changes["aliases"] = {s_strip for s in msg["aliases"] if (s_strip := s.strip())}
|
||||
|
||||
if "labels" in msg:
|
||||
# Convert labels to a set
|
||||
|
||||
@@ -992,11 +992,18 @@ class DefaultAgent(ConversationEntity):
|
||||
continue
|
||||
context[attr] = state.attributes[attr]
|
||||
|
||||
entity_entry = entity_registry.async_get(state.entity_id)
|
||||
for name in intent.async_get_entity_aliases(
|
||||
self.hass, entity_entry, state=state
|
||||
):
|
||||
yield (name, name, context)
|
||||
if (
|
||||
entity := entity_registry.async_get(state.entity_id)
|
||||
) and entity.aliases:
|
||||
for alias in entity.aliases:
|
||||
alias = alias.strip()
|
||||
if not alias:
|
||||
continue
|
||||
|
||||
yield (alias, alias, context)
|
||||
|
||||
# Default name
|
||||
yield (state.name, state.name, context)
|
||||
|
||||
def _recognize_strict(
|
||||
self,
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
"""Provides conditions for covers."""
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant, State, split_entity_id
|
||||
from homeassistant.helpers.condition import Condition, EntityConditionBase
|
||||
|
||||
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
|
||||
from .models import CoverDomainSpec
|
||||
|
||||
|
||||
class CoverConditionBase(EntityConditionBase[CoverDomainSpec]):
|
||||
"""Base condition for cover state checks."""
|
||||
|
||||
def is_valid_state(self, entity_state: State) -> bool:
|
||||
"""Check if the state matches the expected cover state."""
|
||||
domain_spec = self._domain_specs[split_entity_id(entity_state.entity_id)[0]]
|
||||
if domain_spec.value_source is not None:
|
||||
return (
|
||||
entity_state.attributes.get(domain_spec.value_source)
|
||||
== domain_spec.target_value
|
||||
)
|
||||
return entity_state.state == domain_spec.target_value
|
||||
|
||||
|
||||
def make_cover_is_open_condition(
|
||||
*, device_classes: dict[str, str]
|
||||
) -> type[CoverConditionBase]:
|
||||
"""Create a condition for cover is open."""
|
||||
|
||||
class CoverIsOpenCondition(CoverConditionBase):
|
||||
"""Condition for cover is open."""
|
||||
|
||||
_domain_specs = {
|
||||
domain: CoverDomainSpec(
|
||||
device_class=dc,
|
||||
value_source=ATTR_IS_CLOSED if domain == DOMAIN else None,
|
||||
target_value=False if domain == DOMAIN else STATE_ON,
|
||||
)
|
||||
for domain, dc in device_classes.items()
|
||||
}
|
||||
|
||||
return CoverIsOpenCondition
|
||||
|
||||
|
||||
def make_cover_is_closed_condition(
|
||||
*, device_classes: dict[str, str]
|
||||
) -> type[CoverConditionBase]:
|
||||
"""Create a condition for cover is closed."""
|
||||
|
||||
class CoverIsClosedCondition(CoverConditionBase):
|
||||
"""Condition for cover is closed."""
|
||||
|
||||
_domain_specs = {
|
||||
domain: CoverDomainSpec(
|
||||
device_class=dc,
|
||||
value_source=ATTR_IS_CLOSED if domain == DOMAIN else None,
|
||||
target_value=True if domain == DOMAIN else STATE_OFF,
|
||||
)
|
||||
for domain, dc in device_classes.items()
|
||||
}
|
||||
|
||||
return CoverIsClosedCondition
|
||||
|
||||
|
||||
DEVICE_CLASSES_AWNING: dict[str, str] = {DOMAIN: CoverDeviceClass.AWNING}
|
||||
DEVICE_CLASSES_BLIND: dict[str, str] = {DOMAIN: CoverDeviceClass.BLIND}
|
||||
DEVICE_CLASSES_CURTAIN: dict[str, str] = {DOMAIN: CoverDeviceClass.CURTAIN}
|
||||
DEVICE_CLASSES_SHADE: dict[str, str] = {DOMAIN: CoverDeviceClass.SHADE}
|
||||
DEVICE_CLASSES_SHUTTER: dict[str, str] = {DOMAIN: CoverDeviceClass.SHUTTER}
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"awning_is_closed": make_cover_is_closed_condition(
|
||||
device_classes=DEVICE_CLASSES_AWNING
|
||||
),
|
||||
"awning_is_open": make_cover_is_open_condition(
|
||||
device_classes=DEVICE_CLASSES_AWNING
|
||||
),
|
||||
"blind_is_closed": make_cover_is_closed_condition(
|
||||
device_classes=DEVICE_CLASSES_BLIND
|
||||
),
|
||||
"blind_is_open": make_cover_is_open_condition(device_classes=DEVICE_CLASSES_BLIND),
|
||||
"curtain_is_closed": make_cover_is_closed_condition(
|
||||
device_classes=DEVICE_CLASSES_CURTAIN
|
||||
),
|
||||
"curtain_is_open": make_cover_is_open_condition(
|
||||
device_classes=DEVICE_CLASSES_CURTAIN
|
||||
),
|
||||
"shade_is_closed": make_cover_is_closed_condition(
|
||||
device_classes=DEVICE_CLASSES_SHADE
|
||||
),
|
||||
"shade_is_open": make_cover_is_open_condition(device_classes=DEVICE_CLASSES_SHADE),
|
||||
"shutter_is_closed": make_cover_is_closed_condition(
|
||||
device_classes=DEVICE_CLASSES_SHUTTER
|
||||
),
|
||||
"shutter_is_open": make_cover_is_open_condition(
|
||||
device_classes=DEVICE_CLASSES_SHUTTER
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the conditions for covers."""
|
||||
return CONDITIONS
|
||||
@@ -1,80 +0,0 @@
|
||||
.condition_common_fields: &condition_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
|
||||
awning_is_closed:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: awning
|
||||
|
||||
awning_is_open:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: awning
|
||||
|
||||
blind_is_closed:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: blind
|
||||
|
||||
blind_is_open:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: blind
|
||||
|
||||
curtain_is_closed:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: curtain
|
||||
|
||||
curtain_is_open:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: curtain
|
||||
|
||||
shade_is_closed:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: shade
|
||||
|
||||
shade_is_open:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: shade
|
||||
|
||||
shutter_is_closed:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: shutter
|
||||
|
||||
shutter_is_open:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: shutter
|
||||
@@ -1,36 +1,4 @@
|
||||
{
|
||||
"conditions": {
|
||||
"awning_is_closed": {
|
||||
"condition": "mdi:storefront-outline"
|
||||
},
|
||||
"awning_is_open": {
|
||||
"condition": "mdi:storefront-outline"
|
||||
},
|
||||
"blind_is_closed": {
|
||||
"condition": "mdi:blinds-horizontal-closed"
|
||||
},
|
||||
"blind_is_open": {
|
||||
"condition": "mdi:blinds-horizontal"
|
||||
},
|
||||
"curtain_is_closed": {
|
||||
"condition": "mdi:curtains-closed"
|
||||
},
|
||||
"curtain_is_open": {
|
||||
"condition": "mdi:curtains"
|
||||
},
|
||||
"shade_is_closed": {
|
||||
"condition": "mdi:roller-shade-closed"
|
||||
},
|
||||
"shade_is_open": {
|
||||
"condition": "mdi:roller-shade"
|
||||
},
|
||||
"shutter_is_closed": {
|
||||
"condition": "mdi:window-shutter"
|
||||
},
|
||||
"shutter_is_open": {
|
||||
"condition": "mdi:window-shutter-open"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:window-open",
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
"""Data models for the cover integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CoverDomainSpec(DomainSpec):
|
||||
"""DomainSpec with a target value for comparison."""
|
||||
|
||||
target_value: str | bool | None = None
|
||||
@@ -1,112 +1,8 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_description": "How the state should match on the targeted covers.",
|
||||
"condition_behavior_name": "Behavior",
|
||||
"trigger_behavior_description": "The behavior of the targeted covers to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"conditions": {
|
||||
"awning_is_closed": {
|
||||
"description": "Tests if one or more awnings are closed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Awning is closed"
|
||||
},
|
||||
"awning_is_open": {
|
||||
"description": "Tests if one or more awnings are open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Awning is open"
|
||||
},
|
||||
"blind_is_closed": {
|
||||
"description": "Tests if one or more blinds are closed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Blind is closed"
|
||||
},
|
||||
"blind_is_open": {
|
||||
"description": "Tests if one or more blinds are open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Blind is open"
|
||||
},
|
||||
"curtain_is_closed": {
|
||||
"description": "Tests if one or more curtains are closed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Curtain is closed"
|
||||
},
|
||||
"curtain_is_open": {
|
||||
"description": "Tests if one or more curtains are open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Curtain is open"
|
||||
},
|
||||
"shade_is_closed": {
|
||||
"description": "Tests if one or more shades are closed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Shade is closed"
|
||||
},
|
||||
"shade_is_open": {
|
||||
"description": "Tests if one or more shades are open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Shade is open"
|
||||
},
|
||||
"shutter_is_closed": {
|
||||
"description": "Tests if one or more shutters are closed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Shutter is closed"
|
||||
},
|
||||
"shutter_is_open": {
|
||||
"description": "Tests if one or more shutters are open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Shutter is open"
|
||||
}
|
||||
},
|
||||
"device_automation": {
|
||||
"action_type": {
|
||||
"close": "Close {entity_name}",
|
||||
@@ -191,12 +87,6 @@
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"condition_behavior": {
|
||||
"options": {
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
},
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
"""Provides triggers for covers."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant, State, split_entity_id
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
|
||||
|
||||
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
|
||||
from .models import CoverDomainSpec
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CoverDomainSpec(DomainSpec):
|
||||
"""DomainSpec with a target value for comparison."""
|
||||
|
||||
target_value: str | bool | None = None
|
||||
|
||||
|
||||
class CoverTriggerBase(EntityTriggerBase[CoverDomainSpec]):
|
||||
|
||||
@@ -38,9 +38,9 @@ from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.json import format_unserializable_data
|
||||
|
||||
from .const import DOMAIN, REDACTED, DiagnosticsSubType, DiagnosticsType
|
||||
from .util import async_redact_data, entity_entry_as_dict
|
||||
from .util import async_redact_data
|
||||
|
||||
__all__ = ["REDACTED", "async_redact_data", "entity_entry_as_dict"]
|
||||
__all__ = ["REDACTED", "async_redact_data"]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -5,10 +5,7 @@ from __future__ import annotations
|
||||
from collections.abc import Iterable, Mapping
|
||||
from typing import Any, cast, overload
|
||||
|
||||
import attr
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.entity_registry import RegistryEntry
|
||||
|
||||
from .const import REDACTED
|
||||
|
||||
@@ -45,16 +42,3 @@ def async_redact_data[_T](data: _T, to_redact: Iterable[Any]) -> _T:
|
||||
redacted[key] = [async_redact_data(item, to_redact) for item in value]
|
||||
|
||||
return cast(_T, redacted)
|
||||
|
||||
|
||||
def _entity_entry_filter(a: attr.Attribute, _: Any) -> bool:
|
||||
return a.name not in ("_cache", "compat_aliases", "compat_name")
|
||||
|
||||
|
||||
@callback
|
||||
def entity_entry_as_dict(entry: RegistryEntry) -> dict[str, Any]:
|
||||
"""Convert an entity registry entry to a dict for diagnostics.
|
||||
|
||||
This excludes internal fields that should not be exposed in diagnostics.
|
||||
"""
|
||||
return attr.asdict(entry, filter=_entity_entry_filter)
|
||||
|
||||
@@ -11,7 +11,7 @@ from attr import asdict
|
||||
from pyenphase.envoy import Envoy
|
||||
from pyenphase.exceptions import EnvoyError
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data, entity_entry_as_dict
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import (
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
@@ -111,7 +111,8 @@ async def async_get_config_entry_diagnostics(
|
||||
if state := hass.states.get(entity.entity_id):
|
||||
state_dict = dict(state.as_dict())
|
||||
state_dict.pop("context", None)
|
||||
entity_dict = entity_entry_as_dict(entity)
|
||||
entity_dict = asdict(entity)
|
||||
entity_dict.pop("_cache", None)
|
||||
entities.append({"entity": entity_dict, "state": state_dict})
|
||||
device_dict = asdict(device)
|
||||
device_dict.pop("_cache", None)
|
||||
|
||||
@@ -160,23 +160,6 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
_native_supported_color_modes: tuple[ESPHomeColorMode, ...]
|
||||
_supports_color_mode = False
|
||||
|
||||
def _color_temp_to_cold_warm(self, color_temp_mired: float) -> tuple[float, float]:
|
||||
"""Convert a color temperature in mireds to cold/warm white fractions.
|
||||
|
||||
Returns (cold_white, warm_white) normalized so the brighter channel
|
||||
is 1.0.
|
||||
"""
|
||||
static_info = self._static_info
|
||||
min_mireds = static_info.min_mireds
|
||||
max_mireds = static_info.max_mireds
|
||||
if max_mireds <= min_mireds:
|
||||
return 1.0, 1.0
|
||||
color_temp_clamped = min(max(color_temp_mired, min_mireds), max_mireds)
|
||||
ww_frac = (color_temp_clamped - min_mireds) / (max_mireds - min_mireds)
|
||||
cw_frac = 1 - ww_frac
|
||||
max_frac = max(cw_frac, ww_frac)
|
||||
return cw_frac / max_frac, ww_frac / max_frac
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def is_on(self) -> bool:
|
||||
@@ -258,19 +241,12 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
|
||||
if (color_temp_k := kwargs.get(ATTR_COLOR_TEMP_KELVIN)) is not None:
|
||||
# Do not use kelvin_to_mired here to prevent precision loss
|
||||
color_temp_mired = 1_000_000.0 / color_temp_k
|
||||
data["color_temperature"] = 1_000_000.0 / color_temp_k
|
||||
if color_temp_modes := _filter_color_modes(
|
||||
color_modes, LightColorCapability.COLOR_TEMPERATURE
|
||||
):
|
||||
data["color_temperature"] = color_temp_mired
|
||||
color_modes = color_temp_modes
|
||||
else:
|
||||
# Convert color temperature to explicit cold/warm white
|
||||
# values to avoid ESPHome applying brightness to both
|
||||
# master brightness and white channels (b² effect).
|
||||
data["cold_white"], data["warm_white"] = self._color_temp_to_cold_warm(
|
||||
color_temp_mired
|
||||
)
|
||||
color_modes = _filter_color_modes(
|
||||
color_modes, LightColorCapability.COLD_WARM_WHITE
|
||||
)
|
||||
@@ -369,13 +345,19 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
self._native_supported_color_modes, LightColorCapability.COLD_WARM_WHITE
|
||||
):
|
||||
# Try to reverse white + color temp to cwww
|
||||
static_info = self._static_info
|
||||
min_ct = static_info.min_mireds
|
||||
max_ct = static_info.max_mireds
|
||||
color_temp = min(max(state.color_temperature, min_ct), max_ct)
|
||||
white = state.white
|
||||
cw, ww = self._color_temp_to_cold_warm(state.color_temperature)
|
||||
|
||||
ww_frac = (color_temp - min_ct) / (max_ct - min_ct)
|
||||
cw_frac = 1 - ww_frac
|
||||
|
||||
return (
|
||||
*rgb,
|
||||
round(white * cw * 255),
|
||||
round(white * ww * 255),
|
||||
round(white * cw_frac / max(cw_frac, ww_frac) * 255),
|
||||
round(white * ww_frac / max(cw_frac, ww_frac) * 255),
|
||||
)
|
||||
return (
|
||||
*rgb,
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==44.5.2",
|
||||
"aioesphomeapi==44.3.1",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.7.1"
|
||||
],
|
||||
|
||||
@@ -123,13 +123,19 @@ class EsphomeAssistSatelliteWakeWordSelect(
|
||||
|
||||
def __init__(self, entry_data: RuntimeEntryData, index: int = 0) -> None:
|
||||
"""Initialize a wake word selector."""
|
||||
if index >= 1:
|
||||
self.entity_description = replace(
|
||||
self.entity_description,
|
||||
key=f"wake_word_{index + 1}",
|
||||
translation_key="wake_word_n",
|
||||
translation_placeholders={"index": str(index + 1)},
|
||||
)
|
||||
if index < 1:
|
||||
# Keep compatibility
|
||||
key_suffix = ""
|
||||
placeholder = ""
|
||||
else:
|
||||
key_suffix = f"_{index + 1}"
|
||||
placeholder = f" {index + 1}"
|
||||
|
||||
self.entity_description = replace(
|
||||
self.entity_description,
|
||||
key=f"wake_word{key_suffix}",
|
||||
translation_placeholders={"index": placeholder},
|
||||
)
|
||||
|
||||
EsphomeAssistEntity.__init__(self, entry_data)
|
||||
|
||||
|
||||
@@ -107,12 +107,6 @@
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"pipeline_n": {
|
||||
"name": "[%key:component::assist_pipeline::entity::select::pipeline_n::name%]",
|
||||
"state": {
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"vad_sensitivity": {
|
||||
"name": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::name%]",
|
||||
"state": {
|
||||
@@ -122,18 +116,11 @@
|
||||
}
|
||||
},
|
||||
"wake_word": {
|
||||
"name": "Wake word",
|
||||
"name": "Wake word{index}",
|
||||
"state": {
|
||||
"no_wake_word": "No wake word",
|
||||
"okay_nabu": "Okay Nabu"
|
||||
}
|
||||
},
|
||||
"wake_word_n": {
|
||||
"name": "Wake word {index}",
|
||||
"state": {
|
||||
"no_wake_word": "[%key:component::esphome::entity::select::wake_word::state::no_wake_word%]",
|
||||
"okay_nabu": "[%key:component::esphome::entity::select::wake_word::state::okay_nabu%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -103,8 +103,6 @@ class FreeboxFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Initialize flow from zeroconf."""
|
||||
zeroconf_properties = discovery_info.properties
|
||||
host = zeroconf_properties.get("api_domain")
|
||||
if not host:
|
||||
return self.async_abort(reason="missing_api_domain")
|
||||
port = zeroconf_properties.get("https_port") or discovery_info.port
|
||||
host = zeroconf_properties["api_domain"]
|
||||
port = zeroconf_properties["https_port"]
|
||||
return await self.async_step_user({CONF_HOST: host, CONF_PORT: port})
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"missing_api_domain": "The discovered Freebox service did not provide the required API domain. Try again later or configure the Freebox manually."
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientError
|
||||
@@ -57,42 +56,3 @@ class FreshrFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, _user_input: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication confirmation."""
|
||||
errors: dict[str, str] = {}
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is not None:
|
||||
client = FreshrClient(session=async_get_clientsession(self.hass))
|
||||
try:
|
||||
await client.login(
|
||||
reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD]
|
||||
)
|
||||
except LoginError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except ClientError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
|
||||
description_placeholders={CONF_USERNAME: reauth_entry.data[CONF_USERNAME]},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/freshr",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyfreshr==1.2.0"]
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"wrong_account": "Cannot change the account username."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -10,15 +12,6 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::freshr::config::step::user::data_description::password%]"
|
||||
},
|
||||
"description": "Re-enter the password for your Fresh-r account `{username}`."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["fritzconnection"],
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["fritzconnection[qr]==1.15.1", "xmltodict==1.0.2"],
|
||||
"ssdp": [
|
||||
{
|
||||
|
||||
@@ -29,7 +29,9 @@ rules:
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: we are close to the goal of 95%
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
|
||||
@@ -4,9 +4,9 @@ set_guest_wifi_password:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: fritz
|
||||
entity:
|
||||
integration: fritz
|
||||
domain: update
|
||||
device_class: connectivity
|
||||
password:
|
||||
required: false
|
||||
selector:
|
||||
@@ -23,9 +23,9 @@ dial:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: fritz
|
||||
entity:
|
||||
integration: fritz
|
||||
domain: update
|
||||
device_class: connectivity
|
||||
number:
|
||||
required: true
|
||||
selector:
|
||||
|
||||
@@ -133,20 +133,26 @@ async def _async_wifi_entities_list(
|
||||
]
|
||||
)
|
||||
_LOGGER.debug("WiFi networks count: %s", wifi_count)
|
||||
networks: dict[int, dict[str, Any]] = {}
|
||||
networks: dict = {}
|
||||
for i in range(1, wifi_count + 1):
|
||||
network_info = await avm_wrapper.async_get_wlan_configuration(i)
|
||||
# Devices with 4 WLAN services, use the 2nd for internal communications
|
||||
if not (wifi_count == 4 and i == 2):
|
||||
networks[i] = network_info
|
||||
networks[i] = {
|
||||
"ssid": network_info["NewSSID"],
|
||||
"bssid": network_info["NewBSSID"],
|
||||
"standard": network_info["NewStandard"],
|
||||
"enabled": network_info["NewEnable"],
|
||||
"status": network_info["NewStatus"],
|
||||
}
|
||||
for i, network in networks.copy().items():
|
||||
networks[i]["switch_name"] = network["NewSSID"]
|
||||
networks[i]["switch_name"] = network["ssid"]
|
||||
if (
|
||||
len(
|
||||
[
|
||||
j
|
||||
for j, n in networks.items()
|
||||
if slugify(n["NewSSID"]) == slugify(network["NewSSID"])
|
||||
if slugify(n["ssid"]) == slugify(network["ssid"])
|
||||
]
|
||||
)
|
||||
> 1
|
||||
@@ -428,11 +434,13 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch):
|
||||
for key, attr in attributes_dict.items():
|
||||
self._attributes[attr] = self.port_mapping[key]
|
||||
|
||||
async def _async_switch_on_off_executor(self, turn_on: bool) -> None:
|
||||
async def _async_switch_on_off_executor(self, turn_on: bool) -> bool:
|
||||
self.port_mapping["NewEnabled"] = "1" if turn_on else "0"
|
||||
await self._avm_wrapper.async_add_port_mapping(
|
||||
|
||||
resp = await self._avm_wrapper.async_add_port_mapping(
|
||||
self.connection_type, self.port_mapping
|
||||
)
|
||||
return bool(resp is not None)
|
||||
|
||||
|
||||
class FritzBoxDeflectionSwitch(FritzBoxBaseCoordinatorSwitch):
|
||||
@@ -517,11 +525,12 @@ class FritzBoxProfileSwitch(FritzDeviceBase, SwitchEntity):
|
||||
"""Turn off switch."""
|
||||
await self._async_handle_turn_on_off(turn_on=False)
|
||||
|
||||
async def _async_handle_turn_on_off(self, turn_on: bool) -> None:
|
||||
async def _async_handle_turn_on_off(self, turn_on: bool) -> bool:
|
||||
"""Handle switch state change request."""
|
||||
await self._avm_wrapper.async_set_allow_wan_access(self.ip_address, turn_on)
|
||||
self._avm_wrapper.devices[self._mac].wan_access = turn_on
|
||||
self.async_write_ha_state()
|
||||
return True
|
||||
|
||||
|
||||
class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
@@ -532,11 +541,10 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
avm_wrapper: AvmWrapper,
|
||||
device_friendly_name: str,
|
||||
network_num: int,
|
||||
network_data: dict[str, Any],
|
||||
network_data: dict,
|
||||
) -> None:
|
||||
"""Init Fritz Wifi switch."""
|
||||
self._avm_wrapper = avm_wrapper
|
||||
self._wifi_info = network_data
|
||||
|
||||
self._attributes = {}
|
||||
self._attr_entity_category = EntityCategory.CONFIG
|
||||
@@ -552,7 +560,7 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
type=SWITCH_TYPE_WIFINETWORK,
|
||||
callback_update=self._async_fetch_update,
|
||||
callback_switch=self._async_switch_on_off_executor,
|
||||
init_state=network_data["NewEnable"],
|
||||
init_state=network_data["enabled"],
|
||||
)
|
||||
super().__init__(self._avm_wrapper, device_friendly_name, switch_info)
|
||||
|
||||
@@ -579,9 +587,7 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
self._attributes["mac_address_control"] = wifi_info[
|
||||
"NewMACAddressControlEnabled"
|
||||
]
|
||||
self._wifi_info = wifi_info
|
||||
|
||||
async def _async_switch_on_off_executor(self, turn_on: bool) -> None:
|
||||
"""Handle wifi switch."""
|
||||
self._wifi_info["NewEnable"] = turn_on
|
||||
await self._avm_wrapper.async_set_wlan_configuration(self._network_num, turn_on)
|
||||
|
||||
@@ -7,7 +7,7 @@ import logging
|
||||
|
||||
from bleak.backends.device import BLEDevice
|
||||
from gardena_bluetooth.client import CachedConnection, Client
|
||||
from gardena_bluetooth.const import AquaContour, DeviceConfiguration, DeviceInformation
|
||||
from gardena_bluetooth.const import DeviceConfiguration, DeviceInformation
|
||||
from gardena_bluetooth.exceptions import (
|
||||
CharacteristicNoAccess,
|
||||
CharacteristicNotFound,
|
||||
@@ -35,7 +35,6 @@ PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.VALVE,
|
||||
@@ -91,10 +90,8 @@ async def async_setup_entry(
|
||||
|
||||
name = entry.title
|
||||
name = await client.read_char(DeviceConfiguration.custom_device_name, name)
|
||||
name = await client.read_char(AquaContour.custom_device_name, name)
|
||||
|
||||
await _update_timestamp(client, DeviceConfiguration.unix_timestamp)
|
||||
await _update_timestamp(client, AquaContour.unix_timestamp)
|
||||
|
||||
except (TimeoutError, CommunicationFailure, DeviceUnavailable) as exception:
|
||||
await client.disconnect()
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from gardena_bluetooth.const import AquaContour, Sensor, Valve
|
||||
from gardena_bluetooth.const import Sensor, Valve
|
||||
from gardena_bluetooth.parse import CharacteristicBool
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
@@ -47,13 +47,6 @@ DESCRIPTIONS = (
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
char=Sensor.connected_state,
|
||||
),
|
||||
GardenaBluetoothBinarySensorEntityDescription(
|
||||
key=AquaContour.frost_warning.unique_id,
|
||||
translation_key="frost_warning",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
char=AquaContour.frost_warning,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ def _is_supported(discovery_info: BluetoothServiceInfo):
|
||||
ProductType.WATER_COMPUTER,
|
||||
ProductType.AUTOMATS,
|
||||
ProductType.PRESSURE_TANKS,
|
||||
ProductType.AQUA_CONTOURS,
|
||||
):
|
||||
_LOGGER.debug("Unsupported device: %s", manufacturer_data)
|
||||
return False
|
||||
@@ -71,7 +70,6 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
async def async_read_data(self):
|
||||
"""Try to connect to device and extract information."""
|
||||
assert self.address
|
||||
client = Client(get_connection(self.hass, self.address))
|
||||
try:
|
||||
model = await client.read_char(DeviceInformation.model_number)
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from gardena_bluetooth.const import DeviceConfiguration, Sensor, Spray, Valve
|
||||
from gardena_bluetooth.const import DeviceConfiguration, Sensor, Valve
|
||||
from gardena_bluetooth.parse import (
|
||||
Characteristic,
|
||||
CharacteristicInt,
|
||||
@@ -18,7 +18,7 @@ from homeassistant.components.number import (
|
||||
NumberEntityDescription,
|
||||
NumberMode,
|
||||
)
|
||||
from homeassistant.const import DEGREE, PERCENTAGE, EntityCategory, UnitOfTime
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -34,7 +34,6 @@ class GardenaBluetoothNumberEntityDescription(NumberEntityDescription):
|
||||
default_factory=lambda: CharacteristicInt("")
|
||||
)
|
||||
connected_state: Characteristic | None = None
|
||||
scale: float = 1.0
|
||||
|
||||
@property
|
||||
def context(self) -> set[str]:
|
||||
@@ -105,27 +104,6 @@ DESCRIPTIONS = (
|
||||
char=Sensor.threshold,
|
||||
connected_state=Sensor.connected_state,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key="spray_sector",
|
||||
translation_key="spray_sector",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=0.0,
|
||||
native_max_value=359.0,
|
||||
native_step=1.0,
|
||||
char=Spray.sector,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key="spray_distance",
|
||||
translation_key="spray_distance",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
mode=NumberMode.SLIDER,
|
||||
native_min_value=0.0,
|
||||
native_max_value=100.0,
|
||||
native_step=0.1,
|
||||
char=Spray.distance,
|
||||
scale=10.0,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -156,7 +134,7 @@ class GardenaBluetoothNumber(GardenaBluetoothDescriptorEntity, NumberEntity):
|
||||
if data is None:
|
||||
self._attr_native_value = None
|
||||
else:
|
||||
self._attr_native_value = float(data) / self.entity_description.scale
|
||||
self._attr_native_value = float(data)
|
||||
|
||||
if char := self.entity_description.connected_state:
|
||||
self._attr_available = bool(self.coordinator.get_cached(char))
|
||||
@@ -167,9 +145,7 @@ class GardenaBluetoothNumber(GardenaBluetoothDescriptorEntity, NumberEntity):
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set new value."""
|
||||
await self.coordinator.write(
|
||||
self.entity_description.char, int(value * self.entity_description.scale)
|
||||
)
|
||||
await self.coordinator.write(self.entity_description.char, int(value))
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Support for select entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
|
||||
from gardena_bluetooth.const import (
|
||||
AquaContour,
|
||||
AquaContourPosition,
|
||||
AquaContourWatering,
|
||||
)
|
||||
from gardena_bluetooth.parse import CharacteristicInt
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import GardenaBluetoothConfigEntry
|
||||
from .entity import GardenaBluetoothDescriptorEntity
|
||||
|
||||
|
||||
def _enum_to_int(enum: type[IntEnum]) -> dict[str, int]:
|
||||
return {member.name.lower(): member.value for member in enum}
|
||||
|
||||
|
||||
def _reverse_dict(value: dict[str, int]) -> dict[int, str]:
|
||||
return {value: key for key, value in value.items()}
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class GardenaBluetoothSelectEntityDescription(SelectEntityDescription):
|
||||
"""Description of entity."""
|
||||
|
||||
key: str = field(init=False)
|
||||
char: CharacteristicInt
|
||||
option_to_number: dict[str, int]
|
||||
number_to_option: dict[int, str] = field(init=False)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize calculated fields."""
|
||||
object.__setattr__(self, "key", self.char.unique_id)
|
||||
object.__setattr__(self, "options", list(self.option_to_number.keys()))
|
||||
object.__setattr__(
|
||||
self, "number_to_option", _reverse_dict(self.option_to_number)
|
||||
)
|
||||
|
||||
@property
|
||||
def context(self) -> set[str]:
|
||||
"""Context needed for update coordinator."""
|
||||
return {self.char.uuid}
|
||||
|
||||
|
||||
DESCRIPTIONS = (
|
||||
GardenaBluetoothSelectEntityDescription(
|
||||
translation_key="watering_active",
|
||||
char=AquaContourWatering.watering_active,
|
||||
option_to_number=_enum_to_int(AquaContourWatering.watering_active.enum),
|
||||
),
|
||||
GardenaBluetoothSelectEntityDescription(
|
||||
translation_key="operation_mode",
|
||||
char=AquaContour.operation_mode,
|
||||
option_to_number=_enum_to_int(AquaContour.operation_mode.enum),
|
||||
),
|
||||
GardenaBluetoothSelectEntityDescription(
|
||||
translation_key="active_position",
|
||||
char=AquaContourPosition.active_position,
|
||||
option_to_number={
|
||||
"position_1": 1,
|
||||
"position_2": 2,
|
||||
"position_3": 3,
|
||||
"position_4": 4,
|
||||
"position_5": 5,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: GardenaBluetoothConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up select based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
entities = [
|
||||
GardenaBluetoothSelectEntity(coordinator, description, description.context)
|
||||
for description in DESCRIPTIONS
|
||||
if description.char.unique_id in coordinator.characteristics
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class GardenaBluetoothSelectEntity(GardenaBluetoothDescriptorEntity, SelectEntity):
|
||||
"""Representation of a select entity."""
|
||||
|
||||
entity_description: GardenaBluetoothSelectEntityDescription
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the selected entity option to represent the entity state."""
|
||||
char = self.entity_description.char
|
||||
value = self.coordinator.get_cached(char)
|
||||
if value is None:
|
||||
return None
|
||||
return self.entity_description.number_to_option.get(value)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected option."""
|
||||
char = self.entity_description.char
|
||||
value = self.entity_description.option_to_number[option]
|
||||
await self.coordinator.write(char, value)
|
||||
self.async_write_ha_state()
|
||||
@@ -2,19 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from gardena_bluetooth.const import (
|
||||
AquaContourBattery,
|
||||
Battery,
|
||||
EventHistory,
|
||||
FlowStatistics,
|
||||
Sensor,
|
||||
Spray,
|
||||
Valve,
|
||||
)
|
||||
from gardena_bluetooth.const import Battery, Sensor, Valve
|
||||
from gardena_bluetooth.parse import Characteristic
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -22,15 +13,8 @@ from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
StateType,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
DEGREE,
|
||||
PERCENTAGE,
|
||||
EntityCategory,
|
||||
UnitOfVolume,
|
||||
UnitOfVolumeFlowRate,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
@@ -38,28 +22,13 @@ from homeassistant.util import dt as dt_util
|
||||
from .coordinator import GardenaBluetoothConfigEntry, GardenaBluetoothCoordinator
|
||||
from .entity import GardenaBluetoothDescriptorEntity, GardenaBluetoothEntity
|
||||
|
||||
type SensorRawType = StateType | datetime
|
||||
|
||||
|
||||
def _get_timestamp(value: datetime | None):
|
||||
if value is None:
|
||||
return None
|
||||
return value.replace(tzinfo=dt_util.get_default_time_zone())
|
||||
|
||||
|
||||
def _get_distance_ratio(value: int | None):
|
||||
if value is None:
|
||||
return None
|
||||
return value / 1000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GardenaBluetoothSensorEntityDescription[T](SensorEntityDescription):
|
||||
class GardenaBluetoothSensorEntityDescription(SensorEntityDescription):
|
||||
"""Description of entity."""
|
||||
|
||||
char: Characteristic[T] = field(default_factory=lambda: Characteristic(""))
|
||||
char: Characteristic = field(default_factory=lambda: Characteristic(""))
|
||||
connected_state: Characteristic | None = None
|
||||
get: Callable[[T | None], SensorRawType] = lambda x: x # type: ignore[assignment, return-value]
|
||||
|
||||
@property
|
||||
def context(self) -> set[str]:
|
||||
@@ -87,14 +56,6 @@ DESCRIPTIONS = (
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
char=Battery.battery_level,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=AquaContourBattery.battery_level.unique_id,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
char=AquaContourBattery.battery_level,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Sensor.battery_level.unique_id,
|
||||
translation_key="sensor_battery_level",
|
||||
@@ -127,78 +88,6 @@ DESCRIPTIONS = (
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
char=Sensor.measurement_timestamp,
|
||||
connected_state=Sensor.connected_state,
|
||||
get=_get_timestamp,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=FlowStatistics.overall.unique_id,
|
||||
translation_key="flow_statistics_overall",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.VOLUME,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfVolume.LITERS,
|
||||
char=FlowStatistics.overall,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=FlowStatistics.current.unique_id,
|
||||
translation_key="flow_statistics_current",
|
||||
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE,
|
||||
char=FlowStatistics.current,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=FlowStatistics.resettable.unique_id,
|
||||
translation_key="flow_statistics_resettable",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.VOLUME,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfVolume.LITERS,
|
||||
char=FlowStatistics.resettable,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=FlowStatistics.last_reset.unique_id,
|
||||
translation_key="flow_statistics_reset_timestamp",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
char=FlowStatistics.last_reset,
|
||||
get=_get_timestamp,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Spray.current_distance.unique_id,
|
||||
translation_key="spray_current_distance",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
char=Spray.current_distance,
|
||||
get=_get_distance_ratio,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Spray.current_sector.unique_id,
|
||||
translation_key="spray_current_sector",
|
||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=DEGREE,
|
||||
char=Spray.current_sector,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key="aqua_contour_error",
|
||||
translation_key="aqua_contour_error",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
char=EventHistory.error,
|
||||
get=lambda x: (
|
||||
x.error_code.name.lower()
|
||||
if x and isinstance(x.error_code, EventHistory.error.enum)
|
||||
else None
|
||||
),
|
||||
options=[member.name.lower() for member in EventHistory.error.enum],
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key="aqua_contour_error_timestamp",
|
||||
translation_key="error_timestamp",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
char=EventHistory.error,
|
||||
get=lambda x: _get_timestamp(x.time_stamp) if x else None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -227,7 +116,8 @@ class GardenaBluetoothSensor(GardenaBluetoothDescriptorEntity, SensorEntity):
|
||||
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
value = self.coordinator.get_cached(self.entity_description.char)
|
||||
value = self.entity_description.get(value)
|
||||
if isinstance(value, datetime):
|
||||
value = value.replace(tzinfo=dt_util.get_default_time_zone())
|
||||
self._attr_native_value = value
|
||||
|
||||
if char := self.entity_description.connected_state:
|
||||
|
||||
@@ -22,9 +22,6 @@
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"frost_warning": {
|
||||
"name": "Frost"
|
||||
},
|
||||
"sensor_connected_state": {
|
||||
"name": "Sensor connection"
|
||||
},
|
||||
@@ -55,79 +52,12 @@
|
||||
},
|
||||
"sensor_threshold": {
|
||||
"name": "Sensor threshold"
|
||||
},
|
||||
"spray_distance": {
|
||||
"name": "Distance"
|
||||
},
|
||||
"spray_sector": {
|
||||
"name": "Sector"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"active_position": {
|
||||
"name": "Active position",
|
||||
"state": {
|
||||
"position_1": "Position 1",
|
||||
"position_2": "Position 2",
|
||||
"position_3": "Position 3",
|
||||
"position_4": "Position 4",
|
||||
"position_5": "Position 5"
|
||||
}
|
||||
},
|
||||
"operation_mode": {
|
||||
"name": "Operation mode",
|
||||
"state": {
|
||||
"active": "Active",
|
||||
"deep_sleep": "Deep sleep",
|
||||
"manual_mode": "Manual",
|
||||
"pre_winter": "Winter preparation"
|
||||
}
|
||||
},
|
||||
"watering_active": {
|
||||
"name": "Watering",
|
||||
"state": {
|
||||
"contour_1": "Contour 1",
|
||||
"contour_2": "Contour 2",
|
||||
"contour_3": "Contour 3",
|
||||
"contour_4": "Contour 4",
|
||||
"contour_5": "Contour 5",
|
||||
"preview": "Preview",
|
||||
"rest": "Idle",
|
||||
"setup_mode": "Setup"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"activation_reason": {
|
||||
"name": "Activation reason"
|
||||
},
|
||||
"aqua_contour_error": {
|
||||
"name": "Error",
|
||||
"state": {
|
||||
"charger_error": "Charger error",
|
||||
"flash_error": "Flash error",
|
||||
"no_error": "No error detected",
|
||||
"no_water": "Not enough water",
|
||||
"rotation_sensor_error": "Rotation sensor error",
|
||||
"sprinkler_motor_error": "Sprinkler motor error",
|
||||
"valve_motor_error": "Valve motor error"
|
||||
}
|
||||
},
|
||||
"error_timestamp": {
|
||||
"name": "Error timestamp"
|
||||
},
|
||||
"flow_statistics_current": {
|
||||
"name": "Current flow"
|
||||
},
|
||||
"flow_statistics_overall": {
|
||||
"name": "Overall flow"
|
||||
},
|
||||
"flow_statistics_reset_timestamp": {
|
||||
"name": "Flow reset timestamp"
|
||||
},
|
||||
"flow_statistics_resettable": {
|
||||
"name": "Flow since reset"
|
||||
},
|
||||
"remaining_open_timestamp": {
|
||||
"name": "Valve closing"
|
||||
},
|
||||
@@ -139,12 +69,6 @@
|
||||
},
|
||||
"sensor_type": {
|
||||
"name": "Sensor type"
|
||||
},
|
||||
"spray_current_distance": {
|
||||
"name": "Current distance"
|
||||
},
|
||||
"spray_current_sector": {
|
||||
"name": "Current sector"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["googleapiclient"],
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.2"]
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.0"]
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
intent,
|
||||
start,
|
||||
)
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
@@ -598,6 +597,7 @@ class GoogleEntity:
|
||||
state = self.state
|
||||
traits = self.traits()
|
||||
entity_config = self.config.entity_config.get(state.entity_id, {})
|
||||
name = (entity_config.get(CONF_NAME) or state.name).strip()
|
||||
|
||||
# Find entity/device/area registry entries
|
||||
entity_entry, device_entry, area_entry = _get_registry_entries(
|
||||
@@ -607,6 +607,7 @@ class GoogleEntity:
|
||||
# Build the device info
|
||||
device = {
|
||||
"id": state.entity_id,
|
||||
"name": {"name": name},
|
||||
"attributes": {},
|
||||
"traits": [trait.name for trait in traits],
|
||||
"willReportState": self.config.should_report_state,
|
||||
@@ -614,18 +615,13 @@ class GoogleEntity:
|
||||
state.domain, state.attributes.get(ATTR_DEVICE_CLASS)
|
||||
),
|
||||
}
|
||||
# Add name and aliases.
|
||||
# The entity's alias list is ordered: the first slot naturally serves
|
||||
# as the primary name (set to the auto-generated full entity name by
|
||||
# default), while the rest serve as alternative names (nicknames).
|
||||
aliases = intent.async_get_entity_aliases(
|
||||
self.hass, entity_entry, state=state, allow_empty=False
|
||||
)
|
||||
name, *aliases = aliases
|
||||
name = entity_config.get(CONF_NAME) or name
|
||||
device["name"] = {"name": name}
|
||||
if (config_aliases := entity_config.get(CONF_ALIASES, [])) or aliases:
|
||||
device["name"]["nicknames"] = [name, *config_aliases, *aliases]
|
||||
# Add aliases
|
||||
if (config_aliases := entity_config.get(CONF_ALIASES, [])) or (
|
||||
entity_entry and entity_entry.aliases
|
||||
):
|
||||
device["name"]["nicknames"] = [name, *config_aliases]
|
||||
if entity_entry:
|
||||
device["name"]["nicknames"].extend(entity_entry.aliases)
|
||||
|
||||
# Add local SDK info if enabled
|
||||
if self.config.is_local_sdk_active and self.should_expose_local():
|
||||
|
||||
@@ -239,9 +239,6 @@ def _login_classic_api(
|
||||
return login_response
|
||||
|
||||
|
||||
V1_DEVICE_TYPES: dict[int, str] = {5: "sph", 7: "min"}
|
||||
|
||||
|
||||
def get_device_list_v1(
|
||||
api, config: Mapping[str, str]
|
||||
) -> tuple[list[dict[str, str]], str]:
|
||||
@@ -263,17 +260,18 @@ def get_device_list_v1(
|
||||
f"API error during device list: {e.error_msg or str(e)} (Code: {e.error_code})"
|
||||
) from e
|
||||
devices = devices_dict.get("devices", [])
|
||||
# Only MIN device (type = 7) support implemented in current V1 API
|
||||
supported_devices = [
|
||||
{
|
||||
"deviceSn": device.get("device_sn", ""),
|
||||
"deviceType": V1_DEVICE_TYPES[device.get("type")],
|
||||
"deviceType": "min",
|
||||
}
|
||||
for device in devices
|
||||
if device.get("type") in V1_DEVICE_TYPES
|
||||
if device.get("type") == 7
|
||||
]
|
||||
|
||||
for device in devices:
|
||||
if device.get("type") not in V1_DEVICE_TYPES:
|
||||
if device.get("type") != 7:
|
||||
_LOGGER.warning(
|
||||
"Device %s with type %s not supported in Open API V1, skipping",
|
||||
device.get("device_sn", ""),
|
||||
@@ -350,7 +348,7 @@ async def async_setup_entry(
|
||||
hass, config_entry, device["deviceSn"], device["deviceType"], plant_id
|
||||
)
|
||||
for device in devices
|
||||
if device["deviceType"] in ["inverter", "tlx", "storage", "mix", "min", "sph"]
|
||||
if device["deviceType"] in ["inverter", "tlx", "storage", "mix", "min"]
|
||||
}
|
||||
|
||||
# Perform the first refresh for the total coordinator
|
||||
|
||||
@@ -167,36 +167,6 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
**storage_info_detail["storageDetailBean"],
|
||||
**storage_energy_overview,
|
||||
}
|
||||
elif self.device_type == "sph":
|
||||
try:
|
||||
sph_detail = self.api.sph_detail(self.device_id)
|
||||
sph_energy = self.api.sph_energy(self.device_id)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Authentication failed for Growatt API: {err.error_msg or str(err)}"
|
||||
) from err
|
||||
raise UpdateFailed(f"Error fetching SPH device data: {err}") from err
|
||||
|
||||
combined = {**sph_detail, **sph_energy}
|
||||
|
||||
# Parse last update timestamp from sph_energy "time" field
|
||||
time_str = sph_energy.get("time")
|
||||
if time_str:
|
||||
try:
|
||||
parsed = datetime.datetime.strptime(time_str, "%Y-%m-%d %H:%M:%S")
|
||||
combined["lastdataupdate"] = parsed.replace(
|
||||
tzinfo=dt_util.get_default_time_zone()
|
||||
)
|
||||
except ValueError, TypeError:
|
||||
_LOGGER.debug(
|
||||
"Could not parse SPH time field for %s: %r",
|
||||
self.device_id,
|
||||
time_str,
|
||||
)
|
||||
|
||||
self.data = combined
|
||||
_LOGGER.debug("sph_info for device %s: %r", self.device_id, self.data)
|
||||
elif self.device_type == "mix":
|
||||
mix_info = self.api.mix_info(self.device_id)
|
||||
mix_totals = self.api.mix_totals(self.device_id, self.plant_id)
|
||||
@@ -478,123 +448,3 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
return "00:00"
|
||||
else:
|
||||
return f"{hour:02d}:{minute:02d}"
|
||||
|
||||
async def update_ac_charge_times(
|
||||
self,
|
||||
charge_power: int,
|
||||
charge_stop_soc: int,
|
||||
mains_enabled: bool,
|
||||
periods: list[dict],
|
||||
) -> None:
|
||||
"""Update AC charge time periods for SPH device.
|
||||
|
||||
Args:
|
||||
charge_power: Charge power limit (0-100 %)
|
||||
charge_stop_soc: Stop charging at this SOC level (0-100 %)
|
||||
mains_enabled: Whether AC (mains) charging is enabled
|
||||
periods: List of up to 3 dicts with keys start_time, end_time, enabled
|
||||
"""
|
||||
if self.api_version != "v1":
|
||||
raise ServiceValidationError(
|
||||
"Updating AC charge times requires token authentication"
|
||||
)
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self.api.sph_write_ac_charge_times,
|
||||
self.device_id,
|
||||
charge_power,
|
||||
charge_stop_soc,
|
||||
mains_enabled,
|
||||
periods,
|
||||
)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
raise HomeAssistantError(
|
||||
f"API error updating AC charge times: {err}"
|
||||
) from err
|
||||
|
||||
if self.data:
|
||||
self.data["chargePowerCommand"] = charge_power
|
||||
self.data["wchargeSOCLowLimit"] = charge_stop_soc
|
||||
self.data["acChargeEnable"] = 1 if mains_enabled else 0
|
||||
for i, period in enumerate(periods, 1):
|
||||
self.data[f"forcedChargeTimeStart{i}"] = period["start_time"].strftime(
|
||||
"%H:%M"
|
||||
)
|
||||
self.data[f"forcedChargeTimeStop{i}"] = period["end_time"].strftime(
|
||||
"%H:%M"
|
||||
)
|
||||
self.data[f"forcedChargeStopSwitch{i}"] = (
|
||||
1 if period.get("enabled", False) else 0
|
||||
)
|
||||
self.async_set_updated_data(self.data)
|
||||
|
||||
async def update_ac_discharge_times(
|
||||
self,
|
||||
discharge_power: int,
|
||||
discharge_stop_soc: int,
|
||||
periods: list[dict],
|
||||
) -> None:
|
||||
"""Update AC discharge time periods for SPH device.
|
||||
|
||||
Args:
|
||||
discharge_power: Discharge power limit (0-100 %)
|
||||
discharge_stop_soc: Stop discharging at this SOC level (0-100 %)
|
||||
periods: List of up to 3 dicts with keys start_time, end_time, enabled
|
||||
"""
|
||||
if self.api_version != "v1":
|
||||
raise ServiceValidationError(
|
||||
"Updating AC discharge times requires token authentication"
|
||||
)
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self.api.sph_write_ac_discharge_times,
|
||||
self.device_id,
|
||||
discharge_power,
|
||||
discharge_stop_soc,
|
||||
periods,
|
||||
)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
raise HomeAssistantError(
|
||||
f"API error updating AC discharge times: {err}"
|
||||
) from err
|
||||
|
||||
if self.data:
|
||||
self.data["disChargePowerCommand"] = discharge_power
|
||||
self.data["wdisChargeSOCLowLimit"] = discharge_stop_soc
|
||||
for i, period in enumerate(periods, 1):
|
||||
self.data[f"forcedDischargeTimeStart{i}"] = period[
|
||||
"start_time"
|
||||
].strftime("%H:%M")
|
||||
self.data[f"forcedDischargeTimeStop{i}"] = period["end_time"].strftime(
|
||||
"%H:%M"
|
||||
)
|
||||
self.data[f"forcedDischargeStopSwitch{i}"] = (
|
||||
1 if period.get("enabled", False) else 0
|
||||
)
|
||||
self.async_set_updated_data(self.data)
|
||||
|
||||
async def read_ac_charge_times(self) -> dict:
|
||||
"""Read AC charge time settings from SPH device cache."""
|
||||
if self.api_version != "v1":
|
||||
raise ServiceValidationError(
|
||||
"Reading AC charge times requires token authentication"
|
||||
)
|
||||
|
||||
if not self.data:
|
||||
await self.async_refresh()
|
||||
|
||||
return self.api.sph_read_ac_charge_times(settings_data=self.data)
|
||||
|
||||
async def read_ac_discharge_times(self) -> dict:
|
||||
"""Read AC discharge time settings from SPH device cache."""
|
||||
if self.api_version != "v1":
|
||||
raise ServiceValidationError(
|
||||
"Reading AC discharge times requires token authentication"
|
||||
)
|
||||
|
||||
if not self.data:
|
||||
await self.async_refresh()
|
||||
|
||||
return self.api.sph_read_ac_discharge_times(settings_data=self.data)
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
{
|
||||
"services": {
|
||||
"read_ac_charge_times": {
|
||||
"service": "mdi:battery-clock-outline"
|
||||
},
|
||||
"read_ac_discharge_times": {
|
||||
"service": "mdi:battery-clock-outline"
|
||||
},
|
||||
"read_time_segments": {
|
||||
"service": "mdi:clock-outline"
|
||||
},
|
||||
"update_time_segment": {
|
||||
"service": "mdi:clock-edit"
|
||||
},
|
||||
"write_ac_charge_times": {
|
||||
"service": "mdi:battery-clock"
|
||||
},
|
||||
"write_ac_discharge_times": {
|
||||
"service": "mdi:battery-clock"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["growattServer"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["growattServer==1.9.0"]
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ from ..coordinator import GrowattConfigEntry, GrowattCoordinator
|
||||
from .inverter import INVERTER_SENSOR_TYPES
|
||||
from .mix import MIX_SENSOR_TYPES
|
||||
from .sensor_entity_description import GrowattSensorEntityDescription
|
||||
from .sph import SPH_SENSOR_TYPES
|
||||
from .storage import STORAGE_SENSOR_TYPES
|
||||
from .tlx import TLX_SENSOR_TYPES
|
||||
from .total import TOTAL_SENSOR_TYPES
|
||||
@@ -58,8 +57,6 @@ async def async_setup_entry(
|
||||
sensor_descriptions = list(STORAGE_SENSOR_TYPES)
|
||||
elif device_coordinator.device_type == "mix":
|
||||
sensor_descriptions = list(MIX_SENSOR_TYPES)
|
||||
elif device_coordinator.device_type == "sph":
|
||||
sensor_descriptions = list(SPH_SENSOR_TYPES)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Device type %s was found but is not supported right now",
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
"""Growatt Sensor definitions for the SPH type."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
UnitOfFrequency,
|
||||
UnitOfPower,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
|
||||
from .sensor_entity_description import GrowattSensorEntityDescription
|
||||
|
||||
SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
# Values from 'sph_detail' API call
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_statement_of_charge",
|
||||
translation_key="mix_statement_of_charge",
|
||||
api_key="bmsSOC",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_battery_voltage",
|
||||
translation_key="mix_battery_voltage",
|
||||
api_key="vbat",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_pv1_voltage",
|
||||
translation_key="mix_pv1_voltage",
|
||||
api_key="vpv1",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_pv2_voltage",
|
||||
translation_key="mix_pv2_voltage",
|
||||
api_key="vpv2",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_grid_voltage",
|
||||
translation_key="mix_grid_voltage",
|
||||
api_key="vac1",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_battery_charge",
|
||||
translation_key="mix_battery_charge",
|
||||
api_key="pcharge1",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_battery_discharge_w",
|
||||
translation_key="mix_battery_discharge_w",
|
||||
api_key="pdischarge1",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_export_to_grid",
|
||||
translation_key="mix_export_to_grid",
|
||||
api_key="pacToGridTotal",
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_import_from_grid",
|
||||
translation_key="mix_import_from_grid",
|
||||
api_key="pacToUserR",
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_grid_frequency",
|
||||
translation_key="sph_grid_frequency",
|
||||
api_key="fac",
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_temperature_1",
|
||||
translation_key="sph_temperature_1",
|
||||
api_key="temp1",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_temperature_2",
|
||||
translation_key="sph_temperature_2",
|
||||
api_key="temp2",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_temperature_3",
|
||||
translation_key="sph_temperature_3",
|
||||
api_key="temp3",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_temperature_4",
|
||||
translation_key="sph_temperature_4",
|
||||
api_key="temp4",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_temperature_5",
|
||||
translation_key="sph_temperature_5",
|
||||
api_key="temp5",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# Values from 'sph_energy' API call
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_wattage_pv_1",
|
||||
translation_key="mix_wattage_pv_1",
|
||||
api_key="ppv1",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_wattage_pv_2",
|
||||
translation_key="mix_wattage_pv_2",
|
||||
api_key="ppv2",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_wattage_pv_all",
|
||||
translation_key="mix_wattage_pv_all",
|
||||
api_key="ppv",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_battery_charge_today",
|
||||
translation_key="mix_battery_charge_today",
|
||||
api_key="echarge1Today",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_battery_charge_lifetime",
|
||||
translation_key="mix_battery_charge_lifetime",
|
||||
api_key="echarge1Total",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
never_resets=True,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_battery_discharge_today",
|
||||
translation_key="mix_battery_discharge_today",
|
||||
api_key="edischarge1Today",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_battery_discharge_lifetime",
|
||||
translation_key="mix_battery_discharge_lifetime",
|
||||
api_key="edischarge1Total",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
never_resets=True,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_solar_generation_today",
|
||||
translation_key="mix_solar_generation_today",
|
||||
api_key="epvtoday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_solar_generation_lifetime",
|
||||
translation_key="mix_solar_generation_lifetime",
|
||||
api_key="epvTotal",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
never_resets=True,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_system_production_today",
|
||||
translation_key="mix_system_production_today",
|
||||
api_key="esystemtoday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_self_consumption_today",
|
||||
translation_key="mix_self_consumption_today",
|
||||
api_key="eselfToday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_import_from_grid_today",
|
||||
translation_key="mix_import_from_grid_today",
|
||||
api_key="etoUserToday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_export_to_grid_today",
|
||||
translation_key="mix_export_to_grid_today",
|
||||
api_key="etoGridToday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_export_to_grid_lifetime",
|
||||
translation_key="mix_export_to_grid_lifetime",
|
||||
api_key="etogridTotal",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
never_resets=True,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_load_consumption_today",
|
||||
translation_key="mix_load_consumption_today",
|
||||
api_key="elocalLoadToday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_load_consumption_lifetime",
|
||||
translation_key="mix_load_consumption_lifetime",
|
||||
api_key="elocalLoadTotal",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
never_resets=True,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_load_consumption_battery_today",
|
||||
translation_key="mix_load_consumption_battery_today",
|
||||
api_key="echarge1",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_load_consumption_solar_today",
|
||||
translation_key="mix_load_consumption_solar_today",
|
||||
api_key="eChargeToday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
# Synthetic timestamp from 'time' field in sph_energy response
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_last_update",
|
||||
translation_key="mix_last_update",
|
||||
api_key="lastdataupdate",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
),
|
||||
)
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, time
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
@@ -21,77 +21,67 @@ if TYPE_CHECKING:
|
||||
from .coordinator import GrowattCoordinator
|
||||
|
||||
|
||||
def _get_coordinators(
|
||||
hass: HomeAssistant, device_type: str
|
||||
) -> dict[str, GrowattCoordinator]:
|
||||
"""Get all coordinators of a given device type with V1 API."""
|
||||
coordinators: dict[str, GrowattCoordinator] = {}
|
||||
|
||||
for entry in hass.config_entries.async_entries(DOMAIN):
|
||||
if entry.state != ConfigEntryState.LOADED:
|
||||
continue
|
||||
|
||||
for coord in entry.runtime_data.devices.values():
|
||||
if coord.device_type == device_type and coord.api_version == "v1":
|
||||
coordinators[coord.device_id] = coord
|
||||
|
||||
return coordinators
|
||||
|
||||
|
||||
def _get_coordinator(
|
||||
hass: HomeAssistant, device_id: str, device_type: str
|
||||
) -> GrowattCoordinator:
|
||||
"""Get coordinator by device registry ID and device type."""
|
||||
coordinators = _get_coordinators(hass, device_type)
|
||||
|
||||
if not coordinators:
|
||||
raise ServiceValidationError(
|
||||
f"No {device_type.upper()} devices with token authentication are configured. "
|
||||
f"Services require {device_type.upper()} devices with V1 API access."
|
||||
)
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entry = device_registry.async_get(device_id)
|
||||
|
||||
if not device_entry:
|
||||
raise ServiceValidationError(f"Device '{device_id}' not found")
|
||||
|
||||
serial_number = None
|
||||
for identifier in device_entry.identifiers:
|
||||
if identifier[0] == DOMAIN:
|
||||
serial_number = identifier[1]
|
||||
break
|
||||
|
||||
if not serial_number:
|
||||
raise ServiceValidationError(f"Device '{device_id}' is not a Growatt device")
|
||||
|
||||
if serial_number not in coordinators:
|
||||
raise ServiceValidationError(
|
||||
f"{device_type.upper()} device '{serial_number}' not found or not configured for services"
|
||||
)
|
||||
|
||||
return coordinators[serial_number]
|
||||
|
||||
|
||||
def _parse_time_str(time_str: str, field_name: str) -> time:
|
||||
"""Parse a time string (HH:MM or HH:MM:SS) to a datetime.time object."""
|
||||
parts = time_str.split(":")
|
||||
if len(parts) not in (2, 3):
|
||||
raise ServiceValidationError(
|
||||
f"{field_name} must be in HH:MM or HH:MM:SS format"
|
||||
)
|
||||
try:
|
||||
return datetime.strptime(f"{parts[0]}:{parts[1]}", "%H:%M").time()
|
||||
except (ValueError, IndexError) as err:
|
||||
raise ServiceValidationError(
|
||||
f"{field_name} must be in HH:MM or HH:MM:SS format"
|
||||
) from err
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register services for Growatt Server integration."""
|
||||
|
||||
def get_min_coordinators() -> dict[str, GrowattCoordinator]:
|
||||
"""Get all MIN coordinators with V1 API from loaded config entries."""
|
||||
min_coordinators: dict[str, GrowattCoordinator] = {}
|
||||
|
||||
for entry in hass.config_entries.async_entries(DOMAIN):
|
||||
if entry.state != ConfigEntryState.LOADED:
|
||||
continue
|
||||
|
||||
# Add MIN coordinators from this entry
|
||||
for coord in entry.runtime_data.devices.values():
|
||||
if coord.device_type == "min" and coord.api_version == "v1":
|
||||
min_coordinators[coord.device_id] = coord
|
||||
|
||||
return min_coordinators
|
||||
|
||||
def get_coordinator(device_id: str) -> GrowattCoordinator:
|
||||
"""Get coordinator by device_id.
|
||||
|
||||
Args:
|
||||
device_id: Device registry ID (not serial number)
|
||||
"""
|
||||
# Get current coordinators (they may have changed since service registration)
|
||||
min_coordinators = get_min_coordinators()
|
||||
|
||||
if not min_coordinators:
|
||||
raise ServiceValidationError(
|
||||
"No MIN devices with token authentication are configured. "
|
||||
"Services require MIN devices with V1 API access."
|
||||
)
|
||||
|
||||
# Device registry ID provided - map to serial number
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entry = device_registry.async_get(device_id)
|
||||
|
||||
if not device_entry:
|
||||
raise ServiceValidationError(f"Device '{device_id}' not found")
|
||||
|
||||
# Extract serial number from device identifiers
|
||||
serial_number = None
|
||||
for identifier in device_entry.identifiers:
|
||||
if identifier[0] == DOMAIN:
|
||||
serial_number = identifier[1]
|
||||
break
|
||||
|
||||
if not serial_number:
|
||||
raise ServiceValidationError(
|
||||
f"Device '{device_id}' is not a Growatt device"
|
||||
)
|
||||
|
||||
# Find coordinator by serial number
|
||||
if serial_number not in min_coordinators:
|
||||
raise ServiceValidationError(
|
||||
f"MIN device '{serial_number}' not found or not configured for services"
|
||||
)
|
||||
|
||||
return min_coordinators[serial_number]
|
||||
|
||||
async def handle_update_time_segment(call: ServiceCall) -> None:
|
||||
"""Handle update_time_segment service call."""
|
||||
segment_id: int = int(call.data["segment_id"])
|
||||
@@ -101,11 +91,13 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
enabled: bool = call.data["enabled"]
|
||||
device_id: str = call.data["device_id"]
|
||||
|
||||
# Validate segment_id range
|
||||
if not 1 <= segment_id <= 9:
|
||||
raise ServiceValidationError(
|
||||
f"segment_id must be between 1 and 9, got {segment_id}"
|
||||
)
|
||||
|
||||
# Validate and convert batt_mode string to integer
|
||||
valid_modes = {
|
||||
"load_first": BATT_MODE_LOAD_FIRST,
|
||||
"battery_first": BATT_MODE_BATTERY_FIRST,
|
||||
@@ -117,121 +109,50 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
)
|
||||
batt_mode: int = valid_modes[batt_mode_str]
|
||||
|
||||
start_time = _parse_time_str(start_time_str, "start_time")
|
||||
end_time = _parse_time_str(end_time_str, "end_time")
|
||||
# Convert time strings to datetime.time objects
|
||||
# UI time selector sends HH:MM:SS, but we only need HH:MM (strip seconds)
|
||||
try:
|
||||
# Take only HH:MM part (ignore seconds if present)
|
||||
start_parts = start_time_str.split(":")
|
||||
start_time_hhmm = f"{start_parts[0]}:{start_parts[1]}"
|
||||
start_time = datetime.strptime(start_time_hhmm, "%H:%M").time()
|
||||
except (ValueError, IndexError) as err:
|
||||
raise ServiceValidationError(
|
||||
"start_time must be in HH:MM or HH:MM:SS format"
|
||||
) from err
|
||||
|
||||
try:
|
||||
# Take only HH:MM part (ignore seconds if present)
|
||||
end_parts = end_time_str.split(":")
|
||||
end_time_hhmm = f"{end_parts[0]}:{end_parts[1]}"
|
||||
end_time = datetime.strptime(end_time_hhmm, "%H:%M").time()
|
||||
except (ValueError, IndexError) as err:
|
||||
raise ServiceValidationError(
|
||||
"end_time must be in HH:MM or HH:MM:SS format"
|
||||
) from err
|
||||
|
||||
# Get the appropriate MIN coordinator
|
||||
coordinator: GrowattCoordinator = get_coordinator(device_id)
|
||||
|
||||
coordinator: GrowattCoordinator = _get_coordinator(hass, device_id, "min")
|
||||
await coordinator.update_time_segment(
|
||||
segment_id, batt_mode, start_time, end_time, enabled
|
||||
segment_id,
|
||||
batt_mode,
|
||||
start_time,
|
||||
end_time,
|
||||
enabled,
|
||||
)
|
||||
|
||||
async def handle_read_time_segments(call: ServiceCall) -> dict[str, Any]:
|
||||
"""Handle read_time_segments service call."""
|
||||
coordinator: GrowattCoordinator = _get_coordinator(
|
||||
hass, call.data["device_id"], "min"
|
||||
)
|
||||
device_id: str = call.data["device_id"]
|
||||
|
||||
# Get the appropriate MIN coordinator
|
||||
coordinator: GrowattCoordinator = get_coordinator(device_id)
|
||||
|
||||
time_segments: list[dict[str, Any]] = await coordinator.read_time_segments()
|
||||
|
||||
return {"time_segments": time_segments}
|
||||
|
||||
async def handle_write_ac_charge_times(call: ServiceCall) -> None:
|
||||
"""Handle write_ac_charge_times service call for SPH devices."""
|
||||
coordinator: GrowattCoordinator = _get_coordinator(
|
||||
hass, call.data["device_id"], "sph"
|
||||
)
|
||||
# Read current settings first — the SPH API requires all 3 periods in
|
||||
# every write call. Any period not supplied by the caller is filled in
|
||||
# from the cache so existing settings are not overwritten with zeros.
|
||||
current = await coordinator.read_ac_charge_times()
|
||||
|
||||
charge_power: int = int(call.data.get("charge_power", current["charge_power"]))
|
||||
charge_stop_soc: int = int(
|
||||
call.data.get("charge_stop_soc", current["charge_stop_soc"])
|
||||
)
|
||||
mains_enabled: bool = call.data.get("mains_enabled", current["mains_enabled"])
|
||||
|
||||
if not 0 <= charge_power <= 100:
|
||||
raise ServiceValidationError(
|
||||
f"charge_power must be between 0 and 100, got {charge_power}"
|
||||
)
|
||||
if not 0 <= charge_stop_soc <= 100:
|
||||
raise ServiceValidationError(
|
||||
f"charge_stop_soc must be between 0 and 100, got {charge_stop_soc}"
|
||||
)
|
||||
|
||||
periods = []
|
||||
for i in range(1, 4):
|
||||
cached = current["periods"][i - 1]
|
||||
start = _parse_time_str(
|
||||
call.data.get(f"period_{i}_start", cached["start_time"]),
|
||||
f"period_{i}_start",
|
||||
)
|
||||
end = _parse_time_str(
|
||||
call.data.get(f"period_{i}_end", cached["end_time"]),
|
||||
f"period_{i}_end",
|
||||
)
|
||||
enabled: bool = call.data.get(f"period_{i}_enabled", cached["enabled"])
|
||||
periods.append({"start_time": start, "end_time": end, "enabled": enabled})
|
||||
|
||||
await coordinator.update_ac_charge_times(
|
||||
charge_power, charge_stop_soc, mains_enabled, periods
|
||||
)
|
||||
|
||||
async def handle_write_ac_discharge_times(call: ServiceCall) -> None:
|
||||
"""Handle write_ac_discharge_times service call for SPH devices."""
|
||||
coordinator: GrowattCoordinator = _get_coordinator(
|
||||
hass, call.data["device_id"], "sph"
|
||||
)
|
||||
# Read current settings first — same read-merge-write pattern as charge.
|
||||
current = await coordinator.read_ac_discharge_times()
|
||||
|
||||
discharge_power: int = int(
|
||||
call.data.get("discharge_power", current["discharge_power"])
|
||||
)
|
||||
discharge_stop_soc: int = int(
|
||||
call.data.get("discharge_stop_soc", current["discharge_stop_soc"])
|
||||
)
|
||||
|
||||
if not 0 <= discharge_power <= 100:
|
||||
raise ServiceValidationError(
|
||||
f"discharge_power must be between 0 and 100, got {discharge_power}"
|
||||
)
|
||||
if not 0 <= discharge_stop_soc <= 100:
|
||||
raise ServiceValidationError(
|
||||
f"discharge_stop_soc must be between 0 and 100, got {discharge_stop_soc}"
|
||||
)
|
||||
|
||||
periods = []
|
||||
for i in range(1, 4):
|
||||
cached = current["periods"][i - 1]
|
||||
start = _parse_time_str(
|
||||
call.data.get(f"period_{i}_start", cached["start_time"]),
|
||||
f"period_{i}_start",
|
||||
)
|
||||
end = _parse_time_str(
|
||||
call.data.get(f"period_{i}_end", cached["end_time"]),
|
||||
f"period_{i}_end",
|
||||
)
|
||||
enabled: bool = call.data.get(f"period_{i}_enabled", cached["enabled"])
|
||||
periods.append({"start_time": start, "end_time": end, "enabled": enabled})
|
||||
|
||||
await coordinator.update_ac_discharge_times(
|
||||
discharge_power, discharge_stop_soc, periods
|
||||
)
|
||||
|
||||
async def handle_read_ac_charge_times(call: ServiceCall) -> dict[str, Any]:
|
||||
"""Handle read_ac_charge_times service call for SPH devices."""
|
||||
coordinator: GrowattCoordinator = _get_coordinator(
|
||||
hass, call.data["device_id"], "sph"
|
||||
)
|
||||
return await coordinator.read_ac_charge_times()
|
||||
|
||||
async def handle_read_ac_discharge_times(call: ServiceCall) -> dict[str, Any]:
|
||||
"""Handle read_ac_discharge_times service call for SPH devices."""
|
||||
coordinator: GrowattCoordinator = _get_coordinator(
|
||||
hass, call.data["device_id"], "sph"
|
||||
)
|
||||
return await coordinator.read_ac_discharge_times()
|
||||
|
||||
# Register services without schema - services.yaml will provide UI definition
|
||||
# Schema validation happens in the handler functions
|
||||
hass.services.async_register(
|
||||
@@ -247,31 +168,3 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
handle_read_time_segments,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"write_ac_charge_times",
|
||||
handle_write_ac_charge_times,
|
||||
supports_response=SupportsResponse.NONE,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"write_ac_discharge_times",
|
||||
handle_write_ac_discharge_times,
|
||||
supports_response=SupportsResponse.NONE,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"read_ac_charge_times",
|
||||
handle_read_ac_charge_times,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"read_ac_discharge_times",
|
||||
handle_read_ac_discharge_times,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
@@ -48,162 +48,3 @@ read_time_segments:
|
||||
selector:
|
||||
device:
|
||||
integration: growatt_server
|
||||
|
||||
write_ac_charge_times:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: growatt_server
|
||||
charge_power:
|
||||
required: false
|
||||
example: 100
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
mode: slider
|
||||
charge_stop_soc:
|
||||
required: false
|
||||
example: 100
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
mode: slider
|
||||
mains_enabled:
|
||||
required: false
|
||||
example: true
|
||||
selector:
|
||||
boolean:
|
||||
period_1_start:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_1_end:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_1_enabled:
|
||||
required: false
|
||||
example: false
|
||||
selector:
|
||||
boolean:
|
||||
period_2_start:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_2_end:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_2_enabled:
|
||||
required: false
|
||||
example: false
|
||||
selector:
|
||||
boolean:
|
||||
period_3_start:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_3_end:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_3_enabled:
|
||||
required: false
|
||||
example: false
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
write_ac_discharge_times:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: growatt_server
|
||||
discharge_power:
|
||||
required: false
|
||||
example: 100
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
mode: slider
|
||||
discharge_stop_soc:
|
||||
required: false
|
||||
example: 20
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
mode: slider
|
||||
period_1_start:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_1_end:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_1_enabled:
|
||||
required: false
|
||||
example: false
|
||||
selector:
|
||||
boolean:
|
||||
period_2_start:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_2_end:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_2_enabled:
|
||||
required: false
|
||||
example: false
|
||||
selector:
|
||||
boolean:
|
||||
period_3_start:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_3_end:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_3_enabled:
|
||||
required: false
|
||||
example: false
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
read_ac_charge_times:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: growatt_server
|
||||
|
||||
read_ac_discharge_times:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: growatt_server
|
||||
|
||||
@@ -58,14 +58,14 @@
|
||||
"region": "[%key:component::growatt_server::config::step::password_auth::data_description::region%]",
|
||||
"token": "The API token for your Growatt account. You can generate one via the Growatt web portal or ShinePhone app."
|
||||
},
|
||||
"description": "Token authentication is only supported for MIN/SPH devices. For other device types, please use username/password authentication.",
|
||||
"description": "Token authentication is only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
|
||||
"title": "Enter your API token"
|
||||
},
|
||||
"user": {
|
||||
"description": "Note: Token authentication is currently only supported for MIN/SPH devices. For other device types, please use username/password authentication.",
|
||||
"description": "Note: Token authentication is currently only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
|
||||
"menu_options": {
|
||||
"password_auth": "Username/password",
|
||||
"token_auth": "API token (MIN/SPH only)"
|
||||
"token_auth": "API token (MIN/TLX only)"
|
||||
},
|
||||
"title": "Choose authentication method"
|
||||
}
|
||||
@@ -243,24 +243,6 @@
|
||||
"mix_wattage_pv_all": {
|
||||
"name": "All PV wattage"
|
||||
},
|
||||
"sph_grid_frequency": {
|
||||
"name": "AC frequency"
|
||||
},
|
||||
"sph_temperature_1": {
|
||||
"name": "Temperature 1"
|
||||
},
|
||||
"sph_temperature_2": {
|
||||
"name": "Temperature 2"
|
||||
},
|
||||
"sph_temperature_3": {
|
||||
"name": "Temperature 3"
|
||||
},
|
||||
"sph_temperature_4": {
|
||||
"name": "Temperature 4"
|
||||
},
|
||||
"sph_temperature_5": {
|
||||
"name": "Temperature 5"
|
||||
},
|
||||
"storage_ac_input_frequency_out": {
|
||||
"name": "AC input frequency"
|
||||
},
|
||||
@@ -594,26 +576,6 @@
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"read_ac_charge_times": {
|
||||
"description": "Read AC charge time periods from an SPH device.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"description": "The Growatt SPH device to read from.",
|
||||
"name": "Device"
|
||||
}
|
||||
},
|
||||
"name": "Read AC charge times"
|
||||
},
|
||||
"read_ac_discharge_times": {
|
||||
"description": "Read AC discharge time periods from an SPH device.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"description": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::description%]",
|
||||
"name": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::name%]"
|
||||
}
|
||||
},
|
||||
"name": "Read AC discharge times"
|
||||
},
|
||||
"read_time_segments": {
|
||||
"description": "Read all time segments from a supported inverter.",
|
||||
"fields": {
|
||||
@@ -653,118 +615,6 @@
|
||||
}
|
||||
},
|
||||
"name": "Update time segment"
|
||||
},
|
||||
"write_ac_charge_times": {
|
||||
"description": "Write AC charge time periods to an SPH device.",
|
||||
"fields": {
|
||||
"charge_power": {
|
||||
"description": "Charge power limit (%).",
|
||||
"name": "Charge power"
|
||||
},
|
||||
"charge_stop_soc": {
|
||||
"description": "Stop charging at this state of charge (%).",
|
||||
"name": "Charge stop SOC"
|
||||
},
|
||||
"device_id": {
|
||||
"description": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::description%]",
|
||||
"name": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::name%]"
|
||||
},
|
||||
"mains_enabled": {
|
||||
"description": "Enable AC (mains) charging.",
|
||||
"name": "Mains charging enabled"
|
||||
},
|
||||
"period_1_enabled": {
|
||||
"description": "Enable time period 1.",
|
||||
"name": "Period 1 enabled"
|
||||
},
|
||||
"period_1_end": {
|
||||
"description": "End time for period 1 (HH:MM or HH:MM:SS).",
|
||||
"name": "Period 1 end"
|
||||
},
|
||||
"period_1_start": {
|
||||
"description": "Start time for period 1 (HH:MM or HH:MM:SS).",
|
||||
"name": "Period 1 start"
|
||||
},
|
||||
"period_2_enabled": {
|
||||
"description": "Enable time period 2.",
|
||||
"name": "Period 2 enabled"
|
||||
},
|
||||
"period_2_end": {
|
||||
"description": "End time for period 2 (HH:MM or HH:MM:SS).",
|
||||
"name": "Period 2 end"
|
||||
},
|
||||
"period_2_start": {
|
||||
"description": "Start time for period 2 (HH:MM or HH:MM:SS).",
|
||||
"name": "Period 2 start"
|
||||
},
|
||||
"period_3_enabled": {
|
||||
"description": "Enable time period 3.",
|
||||
"name": "Period 3 enabled"
|
||||
},
|
||||
"period_3_end": {
|
||||
"description": "End time for period 3 (HH:MM or HH:MM:SS).",
|
||||
"name": "Period 3 end"
|
||||
},
|
||||
"period_3_start": {
|
||||
"description": "Start time for period 3 (HH:MM or HH:MM:SS).",
|
||||
"name": "Period 3 start"
|
||||
}
|
||||
},
|
||||
"name": "Write AC charge times"
|
||||
},
|
||||
"write_ac_discharge_times": {
|
||||
"description": "Write AC discharge time periods to an SPH device.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"description": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::description%]",
|
||||
"name": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::name%]"
|
||||
},
|
||||
"discharge_power": {
|
||||
"description": "Discharge power limit (%).",
|
||||
"name": "Discharge power"
|
||||
},
|
||||
"discharge_stop_soc": {
|
||||
"description": "Stop discharging at this state of charge (%).",
|
||||
"name": "Discharge stop SOC"
|
||||
},
|
||||
"period_1_enabled": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_enabled::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_enabled::name%]"
|
||||
},
|
||||
"period_1_end": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_end::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_end::name%]"
|
||||
},
|
||||
"period_1_start": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_start::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_start::name%]"
|
||||
},
|
||||
"period_2_enabled": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_enabled::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_enabled::name%]"
|
||||
},
|
||||
"period_2_end": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_end::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_end::name%]"
|
||||
},
|
||||
"period_2_start": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_start::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_start::name%]"
|
||||
},
|
||||
"period_3_enabled": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_enabled::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_enabled::name%]"
|
||||
},
|
||||
"period_3_end": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_end::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_end::name%]"
|
||||
},
|
||||
"period_3_start": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_start::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_start::name%]"
|
||||
}
|
||||
},
|
||||
"name": "Write AC discharge times"
|
||||
}
|
||||
},
|
||||
"title": "Growatt Server"
|
||||
|
||||
@@ -89,18 +89,18 @@
|
||||
"step": {
|
||||
"advanced": {
|
||||
"data": {
|
||||
"api_key": "API token",
|
||||
"api_key": "API Token",
|
||||
"api_user": "User ID",
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "API token of the Habitica account",
|
||||
"api_key": "API Token of the Habitica account",
|
||||
"api_user": "User ID of your Habitica account",
|
||||
"url": "URL of the Habitica installation to connect to. Defaults to `{default_url}`",
|
||||
"verify_ssl": "Enable SSL certificate verification for secure connections. Disable only if connecting to a Habitica instance using a self-signed certificate"
|
||||
},
|
||||
"description": "You can retrieve your 'User ID' and 'API token' from [**Settings -> Site Data**]({site_data}) on Habitica or the instance you want to connect to",
|
||||
"description": "You can retrieve your `User ID` and `API Token` from [**Settings -> Site Data**]({site_data}) on Habitica or the instance you want to connect to",
|
||||
"title": "[%key:component::habitica::config::step::user::menu_options::advanced%]"
|
||||
},
|
||||
"login": {
|
||||
@@ -126,7 +126,7 @@
|
||||
"api_key": "[%key:component::habitica::config::step::advanced::data_description::api_key%]"
|
||||
},
|
||||
"description": "Enter your new API token below. You can find it in Habitica under 'Settings -> Site Data'",
|
||||
"name": "Re-authorize via API token"
|
||||
"name": "Re-authorize via API Token"
|
||||
},
|
||||
"reauth_login": {
|
||||
"data": {
|
||||
|
||||
@@ -9,21 +9,10 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
from typing import Any, NamedTuple, cast
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
from aiohasupervisor import SupervisorError
|
||||
from aiohasupervisor.models import (
|
||||
GreenOptions,
|
||||
HomeAssistantInfo,
|
||||
HostInfo,
|
||||
InstalledAddon,
|
||||
NetworkInfo,
|
||||
OSInfo,
|
||||
RootInfo,
|
||||
StoreInfo,
|
||||
SupervisorInfo,
|
||||
YellowOptions,
|
||||
)
|
||||
from aiohasupervisor.models import GreenOptions, YellowOptions # noqa: F401
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.auth.const import GROUP_ID_ADMIN
|
||||
@@ -76,7 +65,7 @@ from . import ( # noqa: F401
|
||||
system_health,
|
||||
update,
|
||||
)
|
||||
from .addon_manager import AddonError, AddonInfo, AddonManager, AddonState
|
||||
from .addon_manager import AddonError, AddonInfo, AddonManager, AddonState # noqa: F401
|
||||
from .addon_panel import async_setup_addon_panel
|
||||
from .auth import async_setup_auth_view
|
||||
from .config import HassioConfig
|
||||
@@ -93,9 +82,7 @@ from .const import (
|
||||
ATTR_INPUT,
|
||||
ATTR_LOCATION,
|
||||
ATTR_PASSWORD,
|
||||
ATTR_REPOSITORIES,
|
||||
ATTR_SLUG,
|
||||
DATA_ADDONS_LIST,
|
||||
DATA_COMPONENT,
|
||||
DATA_CONFIG_STORE,
|
||||
DATA_CORE_INFO,
|
||||
@@ -113,21 +100,18 @@ from .const import (
|
||||
from .coordinator import (
|
||||
HassioDataUpdateCoordinator,
|
||||
get_addons_info,
|
||||
get_addons_list,
|
||||
get_addons_stats,
|
||||
get_core_info,
|
||||
get_core_stats,
|
||||
get_host_info,
|
||||
get_addons_stats, # noqa: F401
|
||||
get_core_info, # noqa: F401
|
||||
get_core_stats, # noqa: F401
|
||||
get_host_info, # noqa: F401
|
||||
get_info,
|
||||
get_issues_info,
|
||||
get_network_info,
|
||||
get_issues_info, # noqa: F401
|
||||
get_os_info,
|
||||
get_store,
|
||||
get_supervisor_info,
|
||||
get_supervisor_stats,
|
||||
get_supervisor_info, # noqa: F401
|
||||
get_supervisor_stats, # noqa: F401
|
||||
)
|
||||
from .discovery import async_setup_discovery_view
|
||||
from .handler import (
|
||||
from .handler import ( # noqa: F401
|
||||
HassIO,
|
||||
HassioAPIError,
|
||||
async_update_diagnostics,
|
||||
@@ -138,35 +122,6 @@ from .ingress import async_setup_ingress_view
|
||||
from .issues import SupervisorIssues
|
||||
from .websocket_api import async_load_websocket_api
|
||||
|
||||
# Expose the future safe name now so integrations can use it
|
||||
# All references to addons will eventually be refactored and deprecated
|
||||
get_apps_list = get_addons_list
|
||||
__all__ = [
|
||||
"AddonError",
|
||||
"AddonInfo",
|
||||
"AddonManager",
|
||||
"AddonState",
|
||||
"GreenOptions",
|
||||
"SupervisorError",
|
||||
"YellowOptions",
|
||||
"async_update_diagnostics",
|
||||
"get_addons_info",
|
||||
"get_addons_list",
|
||||
"get_addons_stats",
|
||||
"get_apps_list",
|
||||
"get_core_info",
|
||||
"get_core_stats",
|
||||
"get_host_info",
|
||||
"get_info",
|
||||
"get_issues_info",
|
||||
"get_network_info",
|
||||
"get_os_info",
|
||||
"get_store",
|
||||
"get_supervisor_client",
|
||||
"get_supervisor_info",
|
||||
"get_supervisor_stats",
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -549,55 +504,27 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
|
||||
try:
|
||||
(
|
||||
root_info,
|
||||
host_info,
|
||||
hass.data[DATA_INFO],
|
||||
hass.data[DATA_HOST_INFO],
|
||||
store_info,
|
||||
homeassistant_info,
|
||||
supervisor_info,
|
||||
os_info,
|
||||
network_info,
|
||||
addons_list,
|
||||
) = cast(
|
||||
tuple[
|
||||
RootInfo,
|
||||
HostInfo,
|
||||
StoreInfo,
|
||||
HomeAssistantInfo,
|
||||
SupervisorInfo,
|
||||
OSInfo,
|
||||
NetworkInfo,
|
||||
list[InstalledAddon],
|
||||
],
|
||||
await asyncio.gather(
|
||||
create_eager_task(supervisor_client.info()),
|
||||
create_eager_task(supervisor_client.host.info()),
|
||||
create_eager_task(supervisor_client.store.info()),
|
||||
create_eager_task(supervisor_client.homeassistant.info()),
|
||||
create_eager_task(supervisor_client.supervisor.info()),
|
||||
create_eager_task(supervisor_client.os.info()),
|
||||
create_eager_task(supervisor_client.network.info()),
|
||||
create_eager_task(supervisor_client.addons.list()),
|
||||
),
|
||||
hass.data[DATA_CORE_INFO],
|
||||
hass.data[DATA_SUPERVISOR_INFO],
|
||||
hass.data[DATA_OS_INFO],
|
||||
hass.data[DATA_NETWORK_INFO],
|
||||
) = await asyncio.gather(
|
||||
create_eager_task(hassio.get_info()),
|
||||
create_eager_task(hassio.get_host_info()),
|
||||
create_eager_task(supervisor_client.store.info()),
|
||||
create_eager_task(hassio.get_core_info()),
|
||||
create_eager_task(hassio.get_supervisor_info()),
|
||||
create_eager_task(hassio.get_os_info()),
|
||||
create_eager_task(hassio.get_network_info()),
|
||||
)
|
||||
|
||||
except SupervisorError as err:
|
||||
except HassioAPIError as err:
|
||||
_LOGGER.warning("Can't read Supervisor data: %s", err)
|
||||
else:
|
||||
hass.data[DATA_INFO] = root_info.to_dict()
|
||||
hass.data[DATA_HOST_INFO] = host_info.to_dict()
|
||||
hass.data[DATA_STORE] = store_info.to_dict()
|
||||
hass.data[DATA_CORE_INFO] = homeassistant_info.to_dict()
|
||||
hass.data[DATA_SUPERVISOR_INFO] = supervisor_info.to_dict()
|
||||
hass.data[DATA_OS_INFO] = os_info.to_dict()
|
||||
hass.data[DATA_NETWORK_INFO] = network_info.to_dict()
|
||||
hass.data[DATA_ADDONS_LIST] = [addon.to_dict() for addon in addons_list]
|
||||
|
||||
# Deprecated 2026.4.0: Folding repositories and addons.list results into supervisor_info for compatibility
|
||||
# Can drop this after removal period
|
||||
hass.data[DATA_SUPERVISOR_INFO]["repositories"] = hass.data[DATA_STORE][
|
||||
ATTR_REPOSITORIES
|
||||
]
|
||||
hass.data[DATA_SUPERVISOR_INFO]["addons"] = hass.data[DATA_ADDONS_LIST]
|
||||
|
||||
async_call_later(
|
||||
hass,
|
||||
|
||||
@@ -204,17 +204,8 @@ class SupervisorBackupAgent(BackupAgent):
|
||||
location={self.location},
|
||||
filename=PurePath(suggested_backup_filename(backup)),
|
||||
)
|
||||
|
||||
async def stream_with_progress() -> AsyncIterator[bytes]:
|
||||
"""Wrap stream to track upload progress."""
|
||||
bytes_uploaded = 0
|
||||
async for chunk in stream:
|
||||
bytes_uploaded += len(chunk)
|
||||
on_progress(bytes_uploaded=bytes_uploaded)
|
||||
yield chunk
|
||||
|
||||
await self._client.backups.upload_backup(
|
||||
stream_with_progress(),
|
||||
stream,
|
||||
upload_options,
|
||||
)
|
||||
|
||||
|
||||
@@ -93,7 +93,6 @@ DATA_SUPERVISOR_INFO = "hassio_supervisor_info"
|
||||
DATA_SUPERVISOR_STATS = "hassio_supervisor_stats"
|
||||
DATA_ADDONS_INFO = "hassio_addons_info"
|
||||
DATA_ADDONS_STATS = "hassio_addons_stats"
|
||||
DATA_ADDONS_LIST = "hassio_addons_list"
|
||||
HASSIO_UPDATE_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
ATTR_AUTO_UPDATE = "auto_update"
|
||||
@@ -107,7 +106,6 @@ ATTR_STATE = "state"
|
||||
ATTR_STARTED = "started"
|
||||
ATTR_URL = "url"
|
||||
ATTR_REPOSITORY = "repository"
|
||||
ATTR_REPOSITORIES = "repositories"
|
||||
|
||||
DATA_KEY_ADDONS = "addons"
|
||||
DATA_KEY_OS = "os"
|
||||
|
||||
@@ -4,20 +4,13 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import Awaitable
|
||||
from copy import deepcopy
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aiohasupervisor import SupervisorError, SupervisorNotFoundError
|
||||
from aiohasupervisor.models import (
|
||||
AddonState,
|
||||
CIFSMountResponse,
|
||||
InstalledAddon,
|
||||
NFSMountResponse,
|
||||
StoreInfo,
|
||||
)
|
||||
from aiohasupervisor.models.base import ResponseData
|
||||
from aiohasupervisor.models import StoreInfo
|
||||
from aiohasupervisor.models.mounts import CIFSMountResponse, NFSMountResponse
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_MANUFACTURER, ATTR_NAME
|
||||
@@ -30,16 +23,16 @@ from homeassistant.loader import bind_hass
|
||||
|
||||
from .const import (
|
||||
ATTR_AUTO_UPDATE,
|
||||
ATTR_REPOSITORIES,
|
||||
ATTR_REPOSITORY,
|
||||
ATTR_SLUG,
|
||||
ATTR_STARTED,
|
||||
ATTR_STATE,
|
||||
ATTR_URL,
|
||||
ATTR_VERSION,
|
||||
CONTAINER_INFO,
|
||||
CONTAINER_STATS,
|
||||
CORE_CONTAINER,
|
||||
DATA_ADDONS_INFO,
|
||||
DATA_ADDONS_LIST,
|
||||
DATA_ADDONS_STATS,
|
||||
DATA_COMPONENT,
|
||||
DATA_CORE_INFO,
|
||||
@@ -64,7 +57,7 @@ from .const import (
|
||||
SUPERVISOR_CONTAINER,
|
||||
SupervisorEntityModel,
|
||||
)
|
||||
from .handler import get_supervisor_client
|
||||
from .handler import HassioAPIError, get_supervisor_client
|
||||
from .jobs import SupervisorJobs
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -125,7 +118,7 @@ def get_network_info(hass: HomeAssistant) -> dict[str, Any] | None:
|
||||
|
||||
@callback
|
||||
@bind_hass
|
||||
def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any] | None] | None:
|
||||
def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any]] | None:
|
||||
"""Return Addons info.
|
||||
|
||||
Async friendly.
|
||||
@@ -133,18 +126,9 @@ def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any] | None] | N
|
||||
return hass.data.get(DATA_ADDONS_INFO)
|
||||
|
||||
|
||||
@callback
|
||||
def get_addons_list(hass: HomeAssistant) -> list[dict[str, Any]] | None:
|
||||
"""Return list of installed addons and subset of details for each.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
return hass.data.get(DATA_ADDONS_LIST)
|
||||
|
||||
|
||||
@callback
|
||||
@bind_hass
|
||||
def get_addons_stats(hass: HomeAssistant) -> dict[str, dict[str, Any] | None]:
|
||||
def get_addons_stats(hass: HomeAssistant) -> dict[str, Any]:
|
||||
"""Return Addons stats.
|
||||
|
||||
Async friendly.
|
||||
@@ -357,7 +341,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
|
||||
try:
|
||||
await self.force_data_refresh(is_first_update)
|
||||
except SupervisorError as err:
|
||||
except HassioAPIError as err:
|
||||
raise UpdateFailed(f"Error on Supervisor API: {err}") from err
|
||||
|
||||
new_data: dict[str, Any] = {}
|
||||
@@ -366,7 +350,6 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
addons_stats = get_addons_stats(self.hass)
|
||||
store_data = get_store(self.hass)
|
||||
mounts_info = await self.supervisor_client.mounts.info()
|
||||
addons_list = get_addons_list(self.hass) or []
|
||||
|
||||
if store_data:
|
||||
repositories = {
|
||||
@@ -377,17 +360,17 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
repositories = {}
|
||||
|
||||
new_data[DATA_KEY_ADDONS] = {
|
||||
(slug := addon[ATTR_SLUG]): {
|
||||
addon[ATTR_SLUG]: {
|
||||
**addon,
|
||||
**(addons_stats.get(slug) or {}),
|
||||
ATTR_AUTO_UPDATE: (addons_info.get(slug) or {}).get(
|
||||
**((addons_stats or {}).get(addon[ATTR_SLUG]) or {}),
|
||||
ATTR_AUTO_UPDATE: (addons_info.get(addon[ATTR_SLUG]) or {}).get(
|
||||
ATTR_AUTO_UPDATE, False
|
||||
),
|
||||
ATTR_REPOSITORY: repositories.get(
|
||||
repo_slug := addon.get(ATTR_REPOSITORY, ""), repo_slug
|
||||
addon.get(ATTR_REPOSITORY), addon.get(ATTR_REPOSITORY, "")
|
||||
),
|
||||
}
|
||||
for addon in addons_list
|
||||
for addon in supervisor_info.get("addons", [])
|
||||
}
|
||||
if self.is_hass_os:
|
||||
new_data[DATA_KEY_OS] = get_os_info(self.hass)
|
||||
@@ -479,48 +462,32 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
container_updates = self._container_updates
|
||||
|
||||
data = self.hass.data
|
||||
client = self.supervisor_client
|
||||
|
||||
updates: dict[str, Awaitable[ResponseData]] = {
|
||||
DATA_INFO: client.info(),
|
||||
DATA_CORE_INFO: client.homeassistant.info(),
|
||||
DATA_SUPERVISOR_INFO: client.supervisor.info(),
|
||||
DATA_OS_INFO: client.os.info(),
|
||||
DATA_STORE: client.store.info(),
|
||||
hassio = self.hassio
|
||||
updates = {
|
||||
DATA_INFO: hassio.get_info(),
|
||||
DATA_CORE_INFO: hassio.get_core_info(),
|
||||
DATA_SUPERVISOR_INFO: hassio.get_supervisor_info(),
|
||||
DATA_OS_INFO: hassio.get_os_info(),
|
||||
}
|
||||
if CONTAINER_STATS in container_updates[CORE_CONTAINER]:
|
||||
updates[DATA_CORE_STATS] = client.homeassistant.stats()
|
||||
updates[DATA_CORE_STATS] = hassio.get_core_stats()
|
||||
if CONTAINER_STATS in container_updates[SUPERVISOR_CONTAINER]:
|
||||
updates[DATA_SUPERVISOR_STATS] = client.supervisor.stats()
|
||||
updates[DATA_SUPERVISOR_STATS] = hassio.get_supervisor_stats()
|
||||
|
||||
# Pull off addons.list results for further processing before caching
|
||||
addons_list, *results = await asyncio.gather(
|
||||
client.addons.list(), *updates.values()
|
||||
)
|
||||
for key, result in zip(updates, cast(list[ResponseData], results), strict=True):
|
||||
data[key] = result.to_dict()
|
||||
|
||||
installed_addons = cast(list[InstalledAddon], addons_list)
|
||||
data[DATA_ADDONS_LIST] = [addon.to_dict() for addon in installed_addons]
|
||||
|
||||
# Deprecated 2026.4.0: Folding repositories and addons.list results into supervisor_info for compatibility
|
||||
# Can drop this after removal period
|
||||
data[DATA_SUPERVISOR_INFO].update(
|
||||
{
|
||||
"repositories": data[DATA_STORE][ATTR_REPOSITORIES],
|
||||
"addons": [addon.to_dict() for addon in installed_addons],
|
||||
}
|
||||
)
|
||||
|
||||
all_addons = {addon.slug for addon in installed_addons}
|
||||
started_addons = {
|
||||
addon.slug
|
||||
for addon in installed_addons
|
||||
if addon.state in {AddonState.STARTED, AddonState.STARTUP}
|
||||
}
|
||||
results = await asyncio.gather(*updates.values())
|
||||
for key, result in zip(updates, results, strict=False):
|
||||
data[key] = result
|
||||
|
||||
_addon_data = data[DATA_SUPERVISOR_INFO].get("addons", [])
|
||||
all_addons: list[str] = []
|
||||
started_addons: list[str] = []
|
||||
for addon in _addon_data:
|
||||
slug = addon[ATTR_SLUG]
|
||||
all_addons.append(slug)
|
||||
if addon[ATTR_STATE] == ATTR_STARTED:
|
||||
started_addons.append(slug)
|
||||
#
|
||||
# Update addon info if its the first update or
|
||||
# Update add-on info if its the first update or
|
||||
# there is at least one entity that needs the data.
|
||||
#
|
||||
# When entities are added they call async_enable_container_updates
|
||||
@@ -547,12 +514,6 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
),
|
||||
):
|
||||
container_data: dict[str, Any] = data.setdefault(data_key, {})
|
||||
|
||||
# Clean up cache
|
||||
for slug in container_data.keys() - wanted_addons:
|
||||
del container_data[slug]
|
||||
|
||||
# Update cache from API
|
||||
container_data.update(
|
||||
dict(
|
||||
await asyncio.gather(
|
||||
@@ -579,7 +540,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
return (slug, stats.to_dict())
|
||||
|
||||
async def _update_addon_info(self, slug: str) -> tuple[str, dict[str, Any] | None]:
|
||||
"""Return the info for an addon."""
|
||||
"""Return the info for an add-on."""
|
||||
try:
|
||||
info = await self.supervisor_client.addons.addon_info(slug)
|
||||
except SupervisorError as err:
|
||||
|
||||
@@ -6,7 +6,6 @@ from typing import Any
|
||||
|
||||
from attr import asdict
|
||||
|
||||
from homeassistant.components.diagnostics import entity_entry_as_dict
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
@@ -45,9 +44,7 @@ async def async_get_config_entry_diagnostics(
|
||||
state_dict = dict(state.as_dict())
|
||||
state_dict.pop("context", None)
|
||||
|
||||
entities.append(
|
||||
{"entry": entity_entry_as_dict(entity_entry), "state": state_dict}
|
||||
)
|
||||
entities.append({"entry": asdict(entity_entry), "state": state_dict})
|
||||
|
||||
devices.append({"device": asdict(device), "entities": entities})
|
||||
|
||||
|
||||
@@ -87,6 +87,70 @@ class HassIO:
|
||||
"""Return base url for Supervisor."""
|
||||
return self._base_url
|
||||
|
||||
@api_data
|
||||
def get_info(self) -> Coroutine:
|
||||
"""Return generic Supervisor information.
|
||||
|
||||
This method returns a coroutine.
|
||||
"""
|
||||
return self.send_command("/info", method="get")
|
||||
|
||||
@api_data
|
||||
def get_host_info(self) -> Coroutine:
|
||||
"""Return data for Host.
|
||||
|
||||
This method returns a coroutine.
|
||||
"""
|
||||
return self.send_command("/host/info", method="get")
|
||||
|
||||
@api_data
|
||||
def get_os_info(self) -> Coroutine:
|
||||
"""Return data for the OS.
|
||||
|
||||
This method returns a coroutine.
|
||||
"""
|
||||
return self.send_command("/os/info", method="get")
|
||||
|
||||
@api_data
|
||||
def get_core_info(self) -> Coroutine:
|
||||
"""Return data for Home Asssistant Core.
|
||||
|
||||
This method returns a coroutine.
|
||||
"""
|
||||
return self.send_command("/core/info", method="get")
|
||||
|
||||
@api_data
|
||||
def get_supervisor_info(self) -> Coroutine:
|
||||
"""Return data for the Supervisor.
|
||||
|
||||
This method returns a coroutine.
|
||||
"""
|
||||
return self.send_command("/supervisor/info", method="get")
|
||||
|
||||
@api_data
|
||||
def get_network_info(self) -> Coroutine:
|
||||
"""Return data for the Host Network.
|
||||
|
||||
This method returns a coroutine.
|
||||
"""
|
||||
return self.send_command("/network/info", method="get")
|
||||
|
||||
@api_data
|
||||
def get_core_stats(self) -> Coroutine:
|
||||
"""Return stats for the core.
|
||||
|
||||
This method returns a coroutine.
|
||||
"""
|
||||
return self.send_command("/core/stats", method="get")
|
||||
|
||||
@api_data
|
||||
def get_supervisor_stats(self) -> Coroutine:
|
||||
"""Return stats for the supervisor.
|
||||
|
||||
This method returns a coroutine.
|
||||
"""
|
||||
return self.send_command("/supervisor/stats", method="get")
|
||||
|
||||
@api_data
|
||||
def get_ingress_panels(self) -> Coroutine:
|
||||
"""Return data for Add-on ingress panels.
|
||||
|
||||
@@ -45,7 +45,6 @@ RESPONSE_HEADERS_FILTER = {
|
||||
}
|
||||
|
||||
MIN_COMPRESSED_SIZE = 128
|
||||
MAX_WEBSOCKET_MESSAGE_SIZE = 16 * 1024 * 1024 # 16 MiB
|
||||
MAX_SIMPLE_RESPONSE_SIZE = 4194000
|
||||
|
||||
DISABLED_TIMEOUT = ClientTimeout(total=None)
|
||||
@@ -127,10 +126,7 @@ class HassIOIngress(HomeAssistantView):
|
||||
req_protocols = ()
|
||||
|
||||
ws_server = web.WebSocketResponse(
|
||||
protocols=req_protocols,
|
||||
autoclose=False,
|
||||
autoping=False,
|
||||
max_msg_size=MAX_WEBSOCKET_MESSAGE_SIZE,
|
||||
protocols=req_protocols, autoclose=False, autoping=False
|
||||
)
|
||||
await ws_server.prepare(request)
|
||||
|
||||
@@ -153,7 +149,6 @@ class HassIOIngress(HomeAssistantView):
|
||||
protocols=req_protocols,
|
||||
autoclose=False,
|
||||
autoping=False,
|
||||
max_msg_size=MAX_WEBSOCKET_MESSAGE_SIZE,
|
||||
) as ws_client:
|
||||
# Proxy requests
|
||||
await asyncio.wait(
|
||||
|
||||
@@ -17,7 +17,6 @@ from aiohasupervisor.models import (
|
||||
UnsupportedReason,
|
||||
)
|
||||
|
||||
from homeassistant.const import ATTR_NAME
|
||||
from homeassistant.core import HassJob, HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
@@ -31,7 +30,6 @@ from .const import (
|
||||
ADDONS_COORDINATOR,
|
||||
ATTR_DATA,
|
||||
ATTR_HEALTHY,
|
||||
ATTR_SLUG,
|
||||
ATTR_STARTUP,
|
||||
ATTR_SUPPORTED,
|
||||
ATTR_UNHEALTHY_REASONS,
|
||||
@@ -61,7 +59,7 @@ from .const import (
|
||||
STARTUP_COMPLETE,
|
||||
UPDATE_KEY_SUPERVISOR,
|
||||
)
|
||||
from .coordinator import HassioDataUpdateCoordinator, get_addons_list, get_host_info
|
||||
from .coordinator import HassioDataUpdateCoordinator, get_addons_info, get_host_info
|
||||
from .handler import HassIO, get_supervisor_client
|
||||
|
||||
ISSUE_KEY_UNHEALTHY = "unhealthy"
|
||||
@@ -267,18 +265,23 @@ class SupervisorIssues:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON_URL] = (
|
||||
f"/hassio/addon/{issue.reference}"
|
||||
)
|
||||
addons_list = get_addons_list(self._hass) or []
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = issue.reference
|
||||
for addon in addons_list:
|
||||
if addon[ATTR_SLUG] == issue.reference:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = addon[ATTR_NAME]
|
||||
break
|
||||
addons = get_addons_info(self._hass)
|
||||
if addons and issue.reference in addons:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = addons[issue.reference][
|
||||
"name"
|
||||
]
|
||||
else:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = issue.reference
|
||||
|
||||
elif issue.key == ISSUE_KEY_SYSTEM_FREE_SPACE:
|
||||
host_info = get_host_info(self._hass)
|
||||
if host_info and "disk_free" in host_info:
|
||||
if (
|
||||
host_info
|
||||
and "data" in host_info
|
||||
and "disk_free" in host_info["data"]
|
||||
):
|
||||
placeholders[PLACEHOLDER_KEY_FREE_SPACE] = str(
|
||||
host_info["disk_free"]
|
||||
host_info["data"]["disk_free"]
|
||||
)
|
||||
else:
|
||||
placeholders[PLACEHOLDER_KEY_FREE_SPACE] = "<2"
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/hassio",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["aiohasupervisor==0.4.1"],
|
||||
"requirements": ["aiohasupervisor==0.3.3"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -11,13 +11,11 @@ from aiohasupervisor.models import ContextType
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.const import ATTR_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from . import get_addons_list, get_issues_info
|
||||
from . import get_addons_info, get_issues_info
|
||||
from .const import (
|
||||
ATTR_SLUG,
|
||||
EXTRA_PLACEHOLDERS,
|
||||
ISSUE_KEY_ADDON_BOOT_FAIL,
|
||||
ISSUE_KEY_ADDON_DEPRECATED,
|
||||
@@ -156,7 +154,7 @@ class DockerConfigIssueRepairFlow(SupervisorIssueRepairFlow):
|
||||
placeholders = {PLACEHOLDER_KEY_COMPONENTS: ""}
|
||||
supervisor_issues = get_issues_info(self.hass)
|
||||
if supervisor_issues and self.issue:
|
||||
addons_list = get_addons_list(self.hass) or []
|
||||
addons = get_addons_info(self.hass) or {}
|
||||
components: list[str] = []
|
||||
for issue in supervisor_issues.issues:
|
||||
if issue.key == self.issue.key or issue.type != self.issue.type:
|
||||
@@ -168,9 +166,9 @@ class DockerConfigIssueRepairFlow(SupervisorIssueRepairFlow):
|
||||
components.append(
|
||||
next(
|
||||
(
|
||||
addon[ATTR_NAME]
|
||||
for addon in addons_list
|
||||
if addon[ATTR_SLUG] == issue.reference
|
||||
info["name"]
|
||||
for slug, info in addons.items()
|
||||
if slug == issue.reference
|
||||
),
|
||||
issue.reference or "",
|
||||
)
|
||||
@@ -189,12 +187,13 @@ class AddonIssueRepairFlow(SupervisorIssueRepairFlow):
|
||||
"""Get description placeholders for steps."""
|
||||
placeholders: dict[str, str] = super().description_placeholders or {}
|
||||
if self.issue and self.issue.reference:
|
||||
addons_list = get_addons_list(self.hass) or []
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = self.issue.reference
|
||||
for addon in addons_list:
|
||||
if addon[ATTR_SLUG] == self.issue.reference:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = addon[ATTR_NAME]
|
||||
break
|
||||
addons = get_addons_info(self.hass)
|
||||
if addons and self.issue.reference in addons:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = addons[self.issue.reference][
|
||||
"name"
|
||||
]
|
||||
else:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = self.issue.reference
|
||||
|
||||
return placeholders or None
|
||||
|
||||
|
||||
@@ -225,6 +225,10 @@
|
||||
"description": "System is unsupported because Home Assistant cannot determine when an Internet connection is available. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - Connectivity check disabled"
|
||||
},
|
||||
"unsupported_content_trust": {
|
||||
"description": "System is unsupported because Home Assistant cannot verify content being run is trusted and not modified by attackers. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - Content-trust check disabled"
|
||||
},
|
||||
"unsupported_dbus": {
|
||||
"description": "System is unsupported because D-Bus is working incorrectly. Many things fail without this as Supervisor cannot communicate with the host. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - D-Bus issues"
|
||||
@@ -277,6 +281,10 @@
|
||||
"description": "System is unsupported because additional software outside the Home Assistant ecosystem has been detected. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - Unsupported software"
|
||||
},
|
||||
"unsupported_source_mods": {
|
||||
"description": "System is unsupported because Supervisor source code has been modified. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - Supervisor source modifications"
|
||||
},
|
||||
"unsupported_supervisor_version": {
|
||||
"description": "System is unsupported because an out-of-date version of Supervisor is in use and auto-update has been disabled. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - Supervisor version"
|
||||
|
||||
@@ -9,7 +9,6 @@ from homeassistant.components import system_health
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .coordinator import (
|
||||
get_addons_list,
|
||||
get_host_info,
|
||||
get_info,
|
||||
get_network_info,
|
||||
@@ -36,7 +35,6 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
host_info = get_host_info(hass) or {}
|
||||
supervisor_info = get_supervisor_info(hass)
|
||||
network_info = get_network_info(hass) or {}
|
||||
addons_list = get_addons_list(hass) or []
|
||||
|
||||
healthy: bool | dict[str, str]
|
||||
if supervisor_info is not None and supervisor_info.get("healthy"):
|
||||
@@ -86,8 +84,6 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
os_info = get_os_info(hass) or {}
|
||||
information["board"] = os_info.get("board")
|
||||
|
||||
# Not using aiohasupervisor for ping call below intentionally. Given system health
|
||||
# context, it seems preferable to do this check with minimal dependencies
|
||||
information["supervisor_api"] = system_health.async_check_can_reach_url(
|
||||
hass,
|
||||
SUPERVISOR_PING.format(ip_address=ip_address),
|
||||
@@ -99,7 +95,8 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
information["installed_addons"] = ", ".join(
|
||||
f"{addon['name']} ({addon['version']})" for addon in addons_list
|
||||
f"{addon['name']} ({addon['version']})"
|
||||
for addon in (supervisor_info or {}).get("addons", [])
|
||||
)
|
||||
|
||||
return information
|
||||
|
||||
@@ -39,7 +39,7 @@ from .const import (
|
||||
WS_TYPE_EVENT,
|
||||
WS_TYPE_SUBSCRIBE,
|
||||
)
|
||||
from .coordinator import get_addons_list
|
||||
from .coordinator import get_supervisor_info
|
||||
from .update_helper import update_addon, update_core
|
||||
|
||||
SCHEMA_WEBSOCKET_EVENT = vol.Schema(
|
||||
@@ -168,8 +168,8 @@ async def websocket_update_addon(
|
||||
"""Websocket handler to update an addon."""
|
||||
addon_name: str | None = None
|
||||
addon_version: str | None = None
|
||||
addons_list: list[dict[str, Any]] = get_addons_list(hass) or []
|
||||
for addon in addons_list:
|
||||
addons: list = (get_supervisor_info(hass) or {}).get("addons", [])
|
||||
for addon in addons:
|
||||
if addon[ATTR_SLUG] == msg["addon"]:
|
||||
addon_name = addon[ATTR_NAME]
|
||||
addon_version = addon[ATTR_VERSION]
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from apyhiveapi import Auth
|
||||
@@ -27,8 +26,6 @@ from homeassistant.core import callback
|
||||
from . import HiveConfigEntry
|
||||
from .const import CONF_CODE, CONF_DEVICE_NAME, CONFIG_ENTRY_VERSION, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a Hive config flow."""
|
||||
@@ -39,7 +36,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self.data: dict[str, Any] = {}
|
||||
self.tokens: dict[str, Any] = {}
|
||||
self.tokens: dict[str, str] = {}
|
||||
self.device_registration: bool = False
|
||||
self.device_name = "Home Assistant"
|
||||
|
||||
@@ -70,22 +67,11 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
except HiveApiError:
|
||||
errors["base"] = "no_internet_available"
|
||||
|
||||
if (
|
||||
auth_result := self.tokens.get("AuthenticationResult", {})
|
||||
) and auth_result.get("NewDeviceMetadata"):
|
||||
_LOGGER.debug("Login successful, New device detected")
|
||||
self.device_registration = True
|
||||
return await self.async_step_configuration()
|
||||
|
||||
if self.tokens.get("ChallengeName") == "SMS_MFA":
|
||||
_LOGGER.debug("Login successful, SMS 2FA required")
|
||||
# Complete SMS 2FA.
|
||||
return await self.async_step_2fa()
|
||||
|
||||
if not errors:
|
||||
_LOGGER.debug(
|
||||
"Login successful, no new device detected, no 2FA required"
|
||||
)
|
||||
# Complete the entry.
|
||||
try:
|
||||
return await self.async_setup_hive_entry()
|
||||
@@ -117,7 +103,6 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "no_internet_available"
|
||||
|
||||
if not errors:
|
||||
_LOGGER.debug("2FA successful")
|
||||
if self.source == SOURCE_REAUTH:
|
||||
return await self.async_setup_hive_entry()
|
||||
self.device_registration = True
|
||||
@@ -134,11 +119,10 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if user_input:
|
||||
if self.device_registration:
|
||||
_LOGGER.debug("Attempting to register device")
|
||||
self.device_name = user_input["device_name"]
|
||||
await self.hive_auth.device_registration(user_input["device_name"])
|
||||
self.data["device_data"] = await self.hive_auth.get_device_data()
|
||||
_LOGGER.debug("Device registration successful")
|
||||
|
||||
try:
|
||||
return await self.async_setup_hive_entry()
|
||||
except UnknownHiveError:
|
||||
@@ -158,7 +142,6 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
raise UnknownHiveError
|
||||
|
||||
# Setup the config entry
|
||||
_LOGGER.debug("Setting up Hive entry")
|
||||
self.data["tokens"] = self.tokens
|
||||
if self.source == SOURCE_REAUTH:
|
||||
return self.async_update_reload_and_abort(
|
||||
@@ -177,7 +160,6 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
CONF_USERNAME: entry_data[CONF_USERNAME],
|
||||
CONF_PASSWORD: entry_data[CONF_PASSWORD],
|
||||
}
|
||||
_LOGGER.debug("Reauthenticating user")
|
||||
return await self.async_step_user(data)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -38,7 +38,6 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.CLIMATE,
|
||||
Platform.FAN,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
"""Provides climate entities for Home Connect."""
|
||||
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
from aiohomeconnect.model.program import Execution
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
FAN_AUTO,
|
||||
ClimateEntity,
|
||||
ClimateEntityDescription,
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .common import setup_home_connect_entry
|
||||
from .const import BSH_POWER_ON, BSH_POWER_STANDBY, DOMAIN
|
||||
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
HVAC_MODES_PROGRAMS_MAP = {
|
||||
HVACMode.AUTO: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
|
||||
HVACMode.COOL: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_COOL,
|
||||
HVACMode.DRY: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_DRY,
|
||||
HVACMode.FAN_ONLY: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN,
|
||||
HVACMode.HEAT: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_HEAT,
|
||||
}
|
||||
|
||||
PROGRAMS_HVAC_MODES_MAP = {v: k for k, v in HVAC_MODES_PROGRAMS_MAP.items()}
|
||||
|
||||
PRESET_MODES_PROGRAMS_MAP = {
|
||||
"active_clean": ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN,
|
||||
}
|
||||
PROGRAMS_PRESET_MODES_MAP = {v: k for k, v in PRESET_MODES_PROGRAMS_MAP.items()}
|
||||
|
||||
FAN_MODES_OPTIONS = {
|
||||
FAN_AUTO: "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
|
||||
"manual": "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual",
|
||||
}
|
||||
|
||||
FAN_MODES_OPTIONS_INVERTED = {v: k for k, v in FAN_MODES_OPTIONS.items()}
|
||||
|
||||
|
||||
AIR_CONDITIONER_ENTITY_DESCRIPTION = ClimateEntityDescription(
|
||||
key="air_conditioner",
|
||||
translation_key="air_conditioner",
|
||||
name=None,
|
||||
)
|
||||
|
||||
|
||||
def _get_entities_for_appliance(
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
) -> list[HomeConnectEntity]:
|
||||
"""Get a list of entities."""
|
||||
return (
|
||||
[HomeConnectAirConditioningEntity(appliance_coordinator)]
|
||||
if (programs := appliance_coordinator.data.programs)
|
||||
and any(
|
||||
program.key in PROGRAMS_HVAC_MODES_MAP
|
||||
and (
|
||||
program.constraints is None
|
||||
or program.constraints.execution
|
||||
in (Execution.SELECT_AND_START, Execution.START_ONLY)
|
||||
)
|
||||
for program in programs
|
||||
)
|
||||
else []
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HomeConnectConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Home Connect climate entities."""
|
||||
setup_home_connect_entry(
|
||||
hass,
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
)
|
||||
|
||||
|
||||
class HomeConnectAirConditioningEntity(HomeConnectEntity, ClimateEntity):
|
||||
"""Representation of a Home Connect climate entity."""
|
||||
|
||||
# Note: The base class requires this to be set even though this
|
||||
# class doesn't support any temperature related functionality.
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HomeConnectApplianceCoordinator,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(
|
||||
coordinator,
|
||||
AIR_CONDITIONER_ENTITY_DESCRIPTION,
|
||||
context_override=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
)
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
"""Return the list of available hvac operation modes."""
|
||||
hvac_modes = [
|
||||
hvac_mode
|
||||
for program in self.appliance.programs
|
||||
if (hvac_mode := PROGRAMS_HVAC_MODES_MAP.get(program.key))
|
||||
and (
|
||||
program.constraints is None
|
||||
or program.constraints.execution
|
||||
in (Execution.SELECT_AND_START, Execution.START_ONLY)
|
||||
)
|
||||
]
|
||||
if SettingKey.BSH_COMMON_POWER_STATE in self.appliance.settings:
|
||||
hvac_modes.append(HVACMode.OFF)
|
||||
return hvac_modes
|
||||
|
||||
@property
|
||||
def preset_modes(self) -> list[str] | None:
|
||||
"""Return a list of available preset modes."""
|
||||
return (
|
||||
[
|
||||
PROGRAMS_PRESET_MODES_MAP[
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN
|
||||
]
|
||||
]
|
||||
if any(
|
||||
program.key
|
||||
is ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN
|
||||
for program in self.appliance.programs
|
||||
)
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def supported_features(self) -> ClimateEntityFeature:
|
||||
"""Return the list of supported features."""
|
||||
features = ClimateEntityFeature(0)
|
||||
if SettingKey.BSH_COMMON_POWER_STATE in self.appliance.settings:
|
||||
features |= ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
|
||||
if self.preset_modes:
|
||||
features |= ClimateEntityFeature.PRESET_MODE
|
||||
if self.appliance.options.get(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
):
|
||||
features |= ClimateEntityFeature.FAN_MODE
|
||||
return features
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update_fan_mode(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.async_write_ha_state()
|
||||
_LOGGER.debug(
|
||||
"Updated %s (fan mode), new state: %s", self.entity_id, self.fan_mode
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(
|
||||
self.async_write_ha_state,
|
||||
EventKey.BSH_COMMON_APPLIANCE_CONNECTED,
|
||||
)
|
||||
)
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(
|
||||
self._handle_coordinator_update_fan_mode,
|
||||
EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
)
|
||||
)
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(
|
||||
self._handle_coordinator_update,
|
||||
EventKey(SettingKey.BSH_COMMON_POWER_STATE),
|
||||
)
|
||||
)
|
||||
|
||||
def update_native_value(self) -> None:
|
||||
"""Set the HVAC Mode and preset mode values."""
|
||||
event = self.appliance.events.get(EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM)
|
||||
program_key = cast(ProgramKey, event.value) if event else None
|
||||
power_state = self.appliance.settings.get(SettingKey.BSH_COMMON_POWER_STATE)
|
||||
self._attr_hvac_mode = (
|
||||
HVACMode.OFF
|
||||
if power_state is not None and power_state.value != BSH_POWER_ON
|
||||
else PROGRAMS_HVAC_MODES_MAP.get(program_key)
|
||||
if program_key
|
||||
and program_key
|
||||
!= ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN
|
||||
else None
|
||||
)
|
||||
self._attr_preset_mode = (
|
||||
PROGRAMS_PRESET_MODES_MAP.get(program_key)
|
||||
if program_key
|
||||
== ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return the fan setting."""
|
||||
option_value = None
|
||||
if event := self.appliance.events.get(
|
||||
EventKey(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
)
|
||||
):
|
||||
option_value = event.value
|
||||
return (
|
||||
FAN_MODES_OPTIONS_INVERTED.get(cast(str, option_value))
|
||||
if option_value is not None
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def fan_modes(self) -> list[str] | None:
|
||||
"""Return the list of available fan modes."""
|
||||
if (
|
||||
(
|
||||
option_definition := self.appliance.options.get(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
)
|
||||
)
|
||||
and (option_constraints := option_definition.constraints)
|
||||
and option_constraints.allowed_values
|
||||
):
|
||||
return [
|
||||
fan_mode
|
||||
for fan_mode, api_value in FAN_MODES_OPTIONS.items()
|
||||
if api_value in option_constraints.allowed_values
|
||||
]
|
||||
if option_definition:
|
||||
# Then the constraints or the allowed values are not present
|
||||
# So we stick to the default values
|
||||
return list(FAN_MODES_OPTIONS.keys())
|
||||
return None
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Switch the device on."""
|
||||
try:
|
||||
await self.coordinator.client.set_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=SettingKey.BSH_COMMON_POWER_STATE,
|
||||
value=BSH_POWER_ON,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="power_on",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
"appliance_name": self.appliance.info.name,
|
||||
"value": BSH_POWER_ON,
|
||||
},
|
||||
) from err
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Switch the device off."""
|
||||
try:
|
||||
await self.coordinator.client.set_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=SettingKey.BSH_COMMON_POWER_STATE,
|
||||
value=BSH_POWER_STANDBY,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="power_off",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
"appliance_name": self.appliance.info.name,
|
||||
"value": BSH_POWER_STANDBY,
|
||||
},
|
||||
) from err
|
||||
|
||||
async def _set_program(self, program_key: ProgramKey) -> None:
|
||||
try:
|
||||
await self.coordinator.client.start_program(
|
||||
self.appliance.info.ha_id, program_key=program_key
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="start_program",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
"program": program_key.value,
|
||||
},
|
||||
) from err
|
||||
_LOGGER.debug("Updated %s, new state: %s", self.entity_id, self.state)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target hvac mode."""
|
||||
if hvac_mode is HVACMode.OFF:
|
||||
await self.async_turn_off()
|
||||
else:
|
||||
await self._set_program(HVAC_MODES_PROGRAMS_MAP[hvac_mode])
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new preset mode."""
|
||||
await self._set_program(PRESET_MODES_PROGRAMS_MAP[preset_mode])
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
await super().async_set_option_with_key(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
FAN_MODES_OPTIONS[fan_mode],
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s's speed mode option, new state: %s", self.entity_id, self.state
|
||||
)
|
||||
@@ -63,7 +63,6 @@ BSH_DOOR_STATE_OPEN = "BSH.Common.EnumType.DoorState.Open"
|
||||
|
||||
SERVICE_SET_PROGRAM_AND_OPTIONS = "set_program_and_options"
|
||||
SERVICE_SETTING = "change_setting"
|
||||
SERVICE_START_SELECTED_PROGRAM = "start_selected_program"
|
||||
|
||||
ATTR_AFFECTS_TO = "affects_to"
|
||||
ATTR_KEY = "key"
|
||||
|
||||
@@ -79,29 +79,6 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectApplianceCoordinator]):
|
||||
"""
|
||||
return self.appliance.info.connected and self._attr_available
|
||||
|
||||
async def async_set_option_with_key(
|
||||
self, option_key: OptionKey, value: Any
|
||||
) -> None:
|
||||
"""Set an option for the entity."""
|
||||
try:
|
||||
# We try to set the active program option first,
|
||||
# if it fails we try to set the selected program option
|
||||
with contextlib.suppress(ActiveProgramNotSetError):
|
||||
await self.coordinator.client.set_active_program_option(
|
||||
self.appliance.info.ha_id, option_key=option_key, value=value
|
||||
)
|
||||
return
|
||||
|
||||
await self.coordinator.client.set_selected_program_option(
|
||||
self.appliance.info.ha_id, option_key=option_key, value=value
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_option",
|
||||
translation_placeholders=get_dict_from_home_connect_error(err),
|
||||
) from err
|
||||
|
||||
|
||||
class HomeConnectOptionEntity(HomeConnectEntity):
|
||||
"""Class for entities that represents program options."""
|
||||
@@ -118,9 +95,40 @@ class HomeConnectOptionEntity(HomeConnectEntity):
|
||||
return event.value
|
||||
return None
|
||||
|
||||
async def async_set_option(self, value: Any) -> None:
|
||||
async def async_set_option(self, value: str | float | bool) -> None:
|
||||
"""Set an option for the entity."""
|
||||
await super().async_set_option_with_key(self.bsh_key, value)
|
||||
try:
|
||||
# We try to set the active program option first,
|
||||
# if it fails we try to set the selected program option
|
||||
with contextlib.suppress(ActiveProgramNotSetError):
|
||||
await self.coordinator.client.set_active_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=self.bsh_key,
|
||||
value=value,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s for the active program, new state: %s",
|
||||
self.entity_id,
|
||||
self.state,
|
||||
)
|
||||
return
|
||||
|
||||
await self.coordinator.client.set_selected_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=self.bsh_key,
|
||||
value=value,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s for the selected program, new state: %s",
|
||||
self.entity_id,
|
||||
self.state,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_option",
|
||||
translation_placeholders=get_dict_from_home_connect_error(err),
|
||||
) from err
|
||||
|
||||
@property
|
||||
def bsh_key(self) -> OptionKey:
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Provides fan entities for Home Connect."""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from aiohomeconnect.model import EventKey, OptionKey
|
||||
from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
FanEntity,
|
||||
@@ -11,11 +13,14 @@ from homeassistant.components.fan import (
|
||||
FanEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .common import setup_home_connect_entry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -171,7 +176,7 @@ class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity):
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed of the fan, as a percentage."""
|
||||
await super().async_set_option_with_key(
|
||||
await self._async_set_option(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
|
||||
percentage,
|
||||
)
|
||||
@@ -183,14 +188,41 @@ class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity):
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
await super().async_set_option_with_key(
|
||||
await self._async_set_option(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
FAN_SPEED_MODE_OPTIONS[preset_mode],
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s's speed mode option, new state: %s", self.entity_id, self.state
|
||||
"Updated %s's speed mode option, new state: %s",
|
||||
self.entity_id,
|
||||
self.state,
|
||||
)
|
||||
|
||||
async def _async_set_option(self, key: OptionKey, value: str | int) -> None:
|
||||
"""Set an option for the entity."""
|
||||
try:
|
||||
# We try to set the active program option first,
|
||||
# if it fails we try to set the selected program option
|
||||
with contextlib.suppress(ActiveProgramNotSetError):
|
||||
await self.coordinator.client.set_active_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=key,
|
||||
value=value,
|
||||
)
|
||||
return
|
||||
|
||||
await self.coordinator.client.set_selected_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=key,
|
||||
value=value,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_option",
|
||||
translation_placeholders=get_dict_from_home_connect_error(err),
|
||||
) from err
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
|
||||
@@ -245,10 +245,25 @@
|
||||
"change_setting": {
|
||||
"service": "mdi:cog"
|
||||
},
|
||||
"pause_program": {
|
||||
"service": "mdi:pause"
|
||||
},
|
||||
"resume_program": {
|
||||
"service": "mdi:play-pause"
|
||||
},
|
||||
"select_program": {
|
||||
"service": "mdi:form-select"
|
||||
},
|
||||
"set_option_active": {
|
||||
"service": "mdi:gesture-tap"
|
||||
},
|
||||
"set_option_selected": {
|
||||
"service": "mdi:gesture-tap"
|
||||
},
|
||||
"set_program_and_options": {
|
||||
"service": "mdi:form-select"
|
||||
},
|
||||
"start_selected_program": {
|
||||
"start_program": {
|
||||
"service": "mdi:play"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,6 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiohomeconnect==0.32.0"],
|
||||
"requirements": ["aiohomeconnect==0.30.0"],
|
||||
"zeroconf": ["_homeconnect._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ from aiohomeconnect.model import (
|
||||
ProgramKey,
|
||||
SettingKey,
|
||||
)
|
||||
from aiohomeconnect.model.error import HomeConnectError, NoProgramActiveError
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_DEVICE_ID
|
||||
@@ -32,7 +32,6 @@ from .const import (
|
||||
PROGRAM_ENUM_OPTIONS,
|
||||
SERVICE_SET_PROGRAM_AND_OPTIONS,
|
||||
SERVICE_SETTING,
|
||||
SERVICE_START_SELECTED_PROGRAM,
|
||||
TRANSLATION_KEYS_PROGRAMS_MAP,
|
||||
)
|
||||
from .coordinator import HomeConnectConfigEntry
|
||||
@@ -47,12 +46,10 @@ PROGRAM_OPTIONS = {
|
||||
value,
|
||||
)
|
||||
for key, value in {
|
||||
OptionKey.BSH_COMMON_DURATION: vol.All(int, vol.Range(min=0)),
|
||||
OptionKey.BSH_COMMON_START_IN_RELATIVE: vol.All(int, vol.Range(min=0)),
|
||||
OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: vol.All(int, vol.Range(min=0)),
|
||||
OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: vol.All(
|
||||
int, vol.Range(min=0)
|
||||
),
|
||||
OptionKey.BSH_COMMON_DURATION: int,
|
||||
OptionKey.BSH_COMMON_START_IN_RELATIVE: int,
|
||||
OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: int,
|
||||
OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int,
|
||||
OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool,
|
||||
OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool,
|
||||
OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY: bool,
|
||||
@@ -63,10 +60,7 @@ PROGRAM_OPTIONS = {
|
||||
OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS: bool,
|
||||
OptionKey.DISHCARE_DISHWASHER_ECO_DRY: bool,
|
||||
OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY: bool,
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE: vol.All(
|
||||
int, vol.Range(min=1, max=100)
|
||||
),
|
||||
OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: vol.All(int, vol.Range(min=0)),
|
||||
OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: int,
|
||||
OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool,
|
||||
OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool,
|
||||
OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool,
|
||||
@@ -125,23 +119,7 @@ SERVICE_PROGRAM_AND_OPTIONS_SCHEMA = vol.All(
|
||||
_require_program_or_at_least_one_option,
|
||||
)
|
||||
|
||||
SERVICE_START_SELECTED_PROGRAM_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_DEVICE_ID): str,
|
||||
}
|
||||
).extend(
|
||||
{
|
||||
vol.Optional(translation_key): schema
|
||||
for translation_key, (key, schema) in PROGRAM_OPTIONS.items()
|
||||
if key
|
||||
in (
|
||||
OptionKey.BSH_COMMON_START_IN_RELATIVE,
|
||||
OptionKey.BSH_COMMON_FINISH_IN_RELATIVE,
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str})
|
||||
|
||||
|
||||
async def _get_client_and_ha_id(
|
||||
@@ -279,50 +257,6 @@ async def async_service_set_program_and_options(call: ServiceCall) -> None:
|
||||
) from err
|
||||
|
||||
|
||||
async def async_service_start_selected_program(call: ServiceCall) -> None:
|
||||
"""Service to start a program that is already selected."""
|
||||
data = dict(call.data)
|
||||
client, ha_id = await _get_client_and_ha_id(call.hass, data.pop(ATTR_DEVICE_ID))
|
||||
try:
|
||||
try:
|
||||
program_obj = await client.get_active_program(ha_id)
|
||||
except NoProgramActiveError:
|
||||
program_obj = await client.get_selected_program(ha_id)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="fetch_program_error",
|
||||
translation_placeholders=get_dict_from_home_connect_error(err),
|
||||
) from err
|
||||
if not program_obj.key:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_program_to_start",
|
||||
)
|
||||
|
||||
program = program_obj.key
|
||||
options_dict = {option.key: option for option in program_obj.options or []}
|
||||
for option, value in data.items():
|
||||
option_key = PROGRAM_OPTIONS[option][0]
|
||||
options_dict[option_key] = Option(option_key, value)
|
||||
|
||||
try:
|
||||
await client.start_program(
|
||||
ha_id,
|
||||
program_key=program,
|
||||
options=list(options_dict.values()) if options_dict else None,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="start_program",
|
||||
translation_placeholders={
|
||||
"program": program,
|
||||
**get_dict_from_home_connect_error(err),
|
||||
},
|
||||
) from err
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register custom actions."""
|
||||
@@ -336,9 +270,3 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
async_service_set_program_and_options,
|
||||
schema=SERVICE_PROGRAM_AND_OPTIONS_SCHEMA,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_START_SELECTED_PROGRAM,
|
||||
async_service_start_selected_program,
|
||||
schema=SERVICE_START_SELECTED_PROGRAM_SCHEMA,
|
||||
)
|
||||
|
||||
@@ -127,7 +127,6 @@ set_program_and_options:
|
||||
- cooking_oven_program_heating_mode_top_bottom_heating
|
||||
- cooking_oven_program_heating_mode_top_bottom_heating_eco
|
||||
- cooking_oven_program_heating_mode_bottom_heating
|
||||
- cooking_oven_program_heating_mode_bread_baking
|
||||
- cooking_oven_program_heating_mode_pizza_setting
|
||||
- cooking_oven_program_heating_mode_slow_cook
|
||||
- cooking_oven_program_heating_mode_intensive_heat
|
||||
@@ -136,7 +135,6 @@ set_program_and_options:
|
||||
- cooking_oven_program_heating_mode_frozen_heatup_special
|
||||
- cooking_oven_program_heating_mode_desiccation
|
||||
- cooking_oven_program_heating_mode_defrost
|
||||
- cooking_oven_program_heating_mode_dough_proving
|
||||
- cooking_oven_program_heating_mode_proof
|
||||
- cooking_oven_program_heating_mode_hot_air_30_steam
|
||||
- cooking_oven_program_heating_mode_hot_air_60_steam
|
||||
@@ -680,29 +678,3 @@ change_setting:
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
|
||||
start_selected_program:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: home_connect
|
||||
b_s_h_common_option_finish_in_relative:
|
||||
example: 3600
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
step: 1
|
||||
mode: box
|
||||
unit_of_measurement: s
|
||||
b_s_h_common_option_start_in_relative:
|
||||
example: 3600
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
step: 1
|
||||
mode: box
|
||||
unit_of_measurement: s
|
||||
|
||||
@@ -119,23 +119,6 @@
|
||||
"name": "Stop program"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"air_conditioner": {
|
||||
"state_attributes": {
|
||||
"fan_mode": {
|
||||
"state": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"manual": "[%key:common::state::manual%]"
|
||||
}
|
||||
},
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"active_clean": "Active clean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"fan": {
|
||||
"air_conditioner": {
|
||||
"state_attributes": {
|
||||
@@ -261,10 +244,8 @@
|
||||
"cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
|
||||
"cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
|
||||
"cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
|
||||
"cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]",
|
||||
"cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]",
|
||||
"cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]",
|
||||
"cooking_oven_program_heating_mode_dough_proving": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_dough_proving%]",
|
||||
"cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]",
|
||||
"cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]",
|
||||
"cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]",
|
||||
@@ -617,10 +598,8 @@
|
||||
"cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
|
||||
"cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
|
||||
"cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
|
||||
"cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]",
|
||||
"cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]",
|
||||
"cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]",
|
||||
"cooking_oven_program_heating_mode_dough_proving": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_dough_proving%]",
|
||||
"cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]",
|
||||
"cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]",
|
||||
"cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]",
|
||||
@@ -1344,12 +1323,6 @@
|
||||
"fetch_api_error": {
|
||||
"message": "Error obtaining data from the API: {error}"
|
||||
},
|
||||
"fetch_program_error": {
|
||||
"message": "Error obtaining the selected or active program: {error}"
|
||||
},
|
||||
"no_program_to_start": {
|
||||
"message": "No program to start"
|
||||
},
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
},
|
||||
@@ -1622,10 +1595,8 @@
|
||||
"cooking_common_program_hood_delayed_shut_off": "Delayed shut off",
|
||||
"cooking_common_program_hood_venting": "Venting",
|
||||
"cooking_oven_program_heating_mode_bottom_heating": "Bottom heating",
|
||||
"cooking_oven_program_heating_mode_bread_baking": "Bread baking",
|
||||
"cooking_oven_program_heating_mode_defrost": "Defrost",
|
||||
"cooking_oven_program_heating_mode_desiccation": "Desiccation",
|
||||
"cooking_oven_program_heating_mode_dough_proving": "Dough proving",
|
||||
"cooking_oven_program_heating_mode_frozen_heatup_special": "Special heat-up for frozen products",
|
||||
"cooking_oven_program_heating_mode_hot_air": "Hot air",
|
||||
"cooking_oven_program_heating_mode_hot_air_100_steam": "Hot air + 100 RH",
|
||||
@@ -2084,24 +2055,6 @@
|
||||
"name": "Washer options"
|
||||
}
|
||||
}
|
||||
},
|
||||
"start_selected_program": {
|
||||
"description": "Starts the already selected program. You can update start-only options to start the program with them or modify them on a program that is already active with a delayed start.",
|
||||
"fields": {
|
||||
"b_s_h_common_option_finish_in_relative": {
|
||||
"description": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_finish_in_relative::description%]",
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_finish_in_relative::name%]"
|
||||
},
|
||||
"b_s_h_common_option_start_in_relative": {
|
||||
"description": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_start_in_relative::description%]",
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_start_in_relative::name%]"
|
||||
},
|
||||
"device_id": {
|
||||
"description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]",
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]"
|
||||
}
|
||||
},
|
||||
"name": "Start selected program"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
"loggers": ["pyhap"],
|
||||
"requirements": [
|
||||
"HAP-python==5.0.0",
|
||||
"fnv-hash-fast==2.0.0",
|
||||
"homekit-audio-proxy==1.2.1",
|
||||
"fnv-hash-fast==1.6.0",
|
||||
"PyQRCode==1.2.1",
|
||||
"base36==0.1.1"
|
||||
],
|
||||
|
||||
@@ -6,7 +6,6 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from haffmpeg.core import FFMPEG_STDERR, HAFFmpeg
|
||||
from homekit_audio_proxy import AudioProxy
|
||||
from pyhap.camera import (
|
||||
VIDEO_CODEC_PARAM_LEVEL_TYPES,
|
||||
VIDEO_CODEC_PARAM_PROFILE_ID_TYPES,
|
||||
@@ -90,10 +89,11 @@ AUDIO_OUTPUT = (
|
||||
"{a_application}"
|
||||
"-ac 1 -ar {a_sample_rate}k "
|
||||
"-b:a {a_max_bitrate}k -bufsize {a_bufsize}k "
|
||||
"{a_frame_duration}"
|
||||
"-payload_type 110 "
|
||||
"-ssrc {a_ssrc} -f rtp "
|
||||
"rtp://127.0.0.1:{a_proxy_port}?pkt_size={a_pkt_size}"
|
||||
"-srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params {a_srtp_key} "
|
||||
"srtp://{address}:{a_port}?rtcpport={a_port}&"
|
||||
"localrtpport={a_port}&pkt_size={a_pkt_size}"
|
||||
)
|
||||
|
||||
SLOW_RESOLUTIONS = [
|
||||
@@ -120,7 +120,6 @@ FFMPEG_WATCH_INTERVAL = timedelta(seconds=5)
|
||||
FFMPEG_LOGGER = "ffmpeg_logger"
|
||||
FFMPEG_WATCHER = "ffmpeg_watcher"
|
||||
FFMPEG_PID = "ffmpeg_pid"
|
||||
AUDIO_PROXY = "audio_proxy"
|
||||
SESSION_ID = "session_id"
|
||||
|
||||
CONFIG_DEFAULTS = {
|
||||
@@ -340,33 +339,8 @@ class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc]
|
||||
+ " "
|
||||
)
|
||||
audio_application = ""
|
||||
audio_frame_duration = ""
|
||||
if self.config[CONF_AUDIO_CODEC] == "libopus":
|
||||
audio_application = "-application lowdelay "
|
||||
audio_frame_duration = (
|
||||
f"-frame_duration {stream_config.get('a_packet_time', 20)} "
|
||||
)
|
||||
# Start audio proxy to convert Opus RTP timestamps from 48kHz
|
||||
# (FFmpeg's hardcoded Opus RTP clock rate per RFC 7587) to the
|
||||
# sample rate negotiated by HomeKit (typically 16kHz).
|
||||
# a_sample_rate is in kHz (e.g. 16 for 16000 Hz) from pyhap TLV.
|
||||
audio_proxy: AudioProxy | None = None
|
||||
if self.config[CONF_SUPPORT_AUDIO]:
|
||||
audio_proxy = AudioProxy(
|
||||
dest_addr=stream_config["address"],
|
||||
dest_port=stream_config["a_port"],
|
||||
srtp_key_b64=stream_config["a_srtp_key"],
|
||||
target_clock_rate=stream_config["a_sample_rate"] * 1000,
|
||||
)
|
||||
await audio_proxy.async_start()
|
||||
if not audio_proxy.local_port:
|
||||
_LOGGER.error(
|
||||
"[%s] Audio proxy failed to start",
|
||||
self.display_name,
|
||||
)
|
||||
await audio_proxy.async_stop()
|
||||
audio_proxy = None
|
||||
|
||||
output_vars = stream_config.copy()
|
||||
output_vars.update(
|
||||
{
|
||||
@@ -380,8 +354,6 @@ class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc]
|
||||
"a_pkt_size": self.config[CONF_AUDIO_PACKET_SIZE],
|
||||
"a_encoder": self.config[CONF_AUDIO_CODEC],
|
||||
"a_application": audio_application,
|
||||
"a_frame_duration": audio_frame_duration,
|
||||
"a_proxy_port": audio_proxy.local_port if audio_proxy else 0,
|
||||
}
|
||||
)
|
||||
output = VIDEO_OUTPUT.format(**output_vars)
|
||||
@@ -399,8 +371,6 @@ class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc]
|
||||
)
|
||||
if not opened:
|
||||
_LOGGER.error("Failed to open ffmpeg stream")
|
||||
if audio_proxy:
|
||||
await audio_proxy.async_stop()
|
||||
return False
|
||||
|
||||
_LOGGER.debug(
|
||||
@@ -411,7 +381,6 @@ class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc]
|
||||
|
||||
session_info["stream"] = stream
|
||||
session_info[FFMPEG_PID] = stream.process.pid
|
||||
session_info[AUDIO_PROXY] = audio_proxy
|
||||
|
||||
stderr_reader = await stream.get_reader(source=FFMPEG_STDERR)
|
||||
|
||||
@@ -472,9 +441,6 @@ class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc]
|
||||
async def stop_stream(self, session_info: dict[str, Any]) -> None:
|
||||
"""Stop the stream for the given ``session_id``."""
|
||||
session_id = session_info["id"]
|
||||
if proxy := session_info.pop(AUDIO_PROXY, None):
|
||||
await proxy.async_stop()
|
||||
|
||||
if not (stream := session_info.get("stream")):
|
||||
_LOGGER.debug("No stream for session ID %s", session_id)
|
||||
return
|
||||
|
||||
@@ -965,7 +965,7 @@ class HKDevice:
|
||||
# visible on the network.
|
||||
self.async_set_available_state(False)
|
||||
return
|
||||
except AccessoryDisconnectedError, EncryptionError, TimeoutError:
|
||||
except AccessoryDisconnectedError, EncryptionError:
|
||||
# Temporary connection failure. Device may still available but our
|
||||
# connection was dropped or we are reconnecting
|
||||
self._poll_failures += 1
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user